mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 21:56:36 +08:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d97581fb1e | |||
| 83f126795d | |||
| 69467914fc | |||
| c624512da6 | |||
| 50717d1baf | |||
| fc569d1758 | |||
| ec4f1d4d23 | |||
| 9268acb84c | |||
| 41cd23a64d | |||
| f02fc9676a | |||
| 31b4886a14 | |||
| efcf61e32d | |||
| d615d85a26 | |||
| 8afd103751 | |||
| 7ef84cce52 | |||
| 96b8ddc077 | |||
| 03b81e5f74 | |||
| 5b52acdd6c | |||
| fb3dd5afe6 | |||
| 1160d5846a | |||
| 350b433cc1 | |||
| d4d9bad74d | |||
| d0536fcdd5 | |||
| e51f1e583d | |||
| b835144cd0 | |||
| 53c868e99b | |||
| 50678756d4 | |||
| 3eb670c674 | |||
| d10132fb02 | |||
| 61cf581621 | |||
| e2ac531abb | |||
| c8b1289043 | |||
| 13c5073bf8 | |||
| da1dd92404 | |||
| 4b11279662 | |||
| bbadcca294 | |||
| 44ce6497a1 | |||
| 4b83f91b31 | |||
| 9d2fac5d4c | |||
| b4b93ff4ed | |||
| 160e63558f | |||
| 9b3555c569 | |||
| b928928958 |
+18
-4
@@ -1,7 +1,8 @@
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# openflare — 环境变量配置模板
|
||||
# 复制此文件为 .env 并填入实际值: cp .env.example .env
|
||||
# 环境变量优先级高于 config.yaml / config.docker.yaml
|
||||
# 环境变量优先级高于 config.yaml
|
||||
# docker compose 会读取本文件(env_file: .env)并替换 compose 中的 ${VAR}
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ─── 时区 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -22,11 +23,12 @@ APP_SESSION_HTTP_ONLY=true
|
||||
# HTTPS 部署时设为 true,HTTP 环境必须为 false
|
||||
APP_SESSION_SECURE=true
|
||||
|
||||
# ─── 数据库 ────────────────────────────────────────────────────────────────────
|
||||
# ─── 数据库(PostgreSQL)──────────────────────────────────────────────────────
|
||||
# 设置 DB_HOST 后自动启用 PostgreSQL,也可通过 DB_ENABLED 显式控制
|
||||
# DB_ENABLED=false 时使用 SQLite 作为后备数据库
|
||||
DB_ENABLED=true
|
||||
# SQLITE_PATH=./data/openflare.db
|
||||
# compose 内应用连服务名;本机直连 Docker 映射端口时用 127.0.0.1
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=openflare
|
||||
@@ -38,7 +40,7 @@ DB_TIMEZONE=Asia/Shanghai
|
||||
# DB_MAX_IDLE_CONN=16
|
||||
# DB_MAX_OPEN_CONN=128
|
||||
|
||||
# ─── Redis ─────────────────────────────────────────────────────────────────────
|
||||
# ─── Redis / Valkey ────────────────────────────────────────────────────────────
|
||||
# 设置 REDIS_ADDR 后自动启用,也可通过 REDIS_ENABLED 显式控制
|
||||
REDIS_ENABLED=true
|
||||
REDIS_ADDR=redis:6379
|
||||
@@ -47,13 +49,20 @@ REDIS_ADDR=redis:6379
|
||||
# REDIS_DB=0
|
||||
REDIS_KEY_PREFIX=openflare:
|
||||
# REDIS_POOL_SIZE=100
|
||||
# compose 宿主机映射端口(仅 docker-compose 使用)
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# ─── ClickHouse(必需)────────────────────────────────────────────────────
|
||||
# ─── ClickHouse(必需)────────────────────────────────────────────────────────
|
||||
# CLICKHOUSE_HOST 设置后会自动启用;测试环境可显式 CLICKHOUSE_ENABLED=true 做 live 联调
|
||||
CLICKHOUSE_ENABLED=true
|
||||
# compose 内:clickhouse:9000;本机连映射端口:127.0.0.1:9000
|
||||
CLICKHOUSE_HOST=clickhouse:9000
|
||||
CLICKHOUSE_USERNAME=default
|
||||
# 须与 compose clickhouse 服务密码一致(首次初始化后改密码需清 data/clickhouse_data)
|
||||
CLICKHOUSE_PASSWORD=replace-with-clickhouse-password
|
||||
CLICKHOUSE_NAME=openflare
|
||||
|
||||
|
||||
# ─── 日志 ──────────────────────────────────────────────────────────────────────
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=console
|
||||
@@ -67,6 +76,11 @@ OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
OTEL_SAMPLING_RATE=0.0
|
||||
# 全局 Tracer 命名空间,默认为 github.com/Rain-kl/OpenFlare
|
||||
# OTEL_TRACER_NAME=github.com/Rain-kl/OpenFlare
|
||||
# compose 可选端口覆盖
|
||||
# JAEGER_VERSION=2.19.0
|
||||
# JAEGER_UI_PORT=16686
|
||||
# JAEGER_OTLP_GRPC_PORT=4317
|
||||
# JAEGER_OTLP_HTTP_PORT=4318
|
||||
|
||||
# ─── Worker ────────────────────────────────────────────────────────────────────
|
||||
# WORKER_CONCURRENCY=20
|
||||
|
||||
+4
-1
@@ -77,4 +77,7 @@ profile.cov
|
||||
.grok
|
||||
/.gomodcache/
|
||||
*.mmdb
|
||||
!internal/apps/agent/geoipdata/GeoLite2-Country.mmdb
|
||||
!internal/apps/agent/geoipdata/GeoLite2-Country.mmdb
|
||||
|
||||
/.superpowers/
|
||||
/.worktrees/
|
||||
|
||||
@@ -72,6 +72,11 @@ OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、
|
||||
# 下载环境变量模板并创建 .env 文件
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
|
||||
# ClickHouse 服务端:curl performance.xml 到 ./config/clickhouse,并以单文件方式挂载到 config.d
|
||||
mkdir -p ./config/clickhouse
|
||||
curl -fsSL -o ./config/clickhouse/performance.xml \
|
||||
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
|
||||
```
|
||||
|
||||
```yaml
|
||||
@@ -131,15 +136,20 @@ services:
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- openflare_clickhouse_data:/var/lib/clickhouse
|
||||
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
|
||||
volumes:
|
||||
openflare_uploads:
|
||||
openflare_postgres_data:
|
||||
|
||||
+8
-5
@@ -99,15 +99,18 @@ otel:
|
||||
|
||||
|
||||
# ─── ClickHouse (required) ──────────────────────────────────────────────────────
|
||||
# Analytics / observability OLAP store. Telemetry writes are best-effort (async batch).
|
||||
clickhouse:
|
||||
enabled: true
|
||||
hosts:
|
||||
- "127.0.0.1:9000"
|
||||
- "127.0.0.1:9000" # compose 内应用可用 clickhouse:9000(经 CLICKHOUSE_HOST)
|
||||
username: "default"
|
||||
password: "123456"
|
||||
password: "replace-with-clickhouse-password" # 与 .env / compose CLICKHOUSE_PASSWORD 一致
|
||||
database: "openflare"
|
||||
max_idle_conn: 20
|
||||
max_open_conn: 50
|
||||
max_idle_conn: 8 # keep warm sockets low to save client + server RAM
|
||||
max_open_conn: 16 # cap concurrent native sessions on modest CH boxes
|
||||
conn_max_lifetime: 3600
|
||||
dial_timeout: 5
|
||||
block_buffer_size: 100
|
||||
block_buffer_size: 32 # rows buffered per block; 32 is enough for our batch sizes
|
||||
# Runtime client also enables async_insert (wait_for_async_insert=1, busy_timeout≈2s)
|
||||
# in internal/db/clickhouse.go — not configured via YAML.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
Tuned for small control-plane hosts (e.g. 3c6g).
|
||||
|
||||
background_pool_size * background_merges_mutations_concurrency_ratio must stay
|
||||
greater than merge_tree number_of_free_entries_in_pool_to_execute_mutation
|
||||
(ClickHouse 25.x refuses to start otherwise). Keep the merge free-entry
|
||||
thresholds low so a small pool remains valid.
|
||||
-->
|
||||
<clickhouse>
|
||||
<max_concurrent_queries>20</max_concurrent_queries>
|
||||
<background_pool_size>4</background_pool_size>
|
||||
<background_merges_mutations_concurrency_ratio>2</background_merges_mutations_concurrency_ratio>
|
||||
<background_schedule_pool_size>4</background_schedule_pool_size>
|
||||
<background_common_pool_size>2</background_common_pool_size>
|
||||
<background_fetches_pool_size>2</background_fetches_pool_size>
|
||||
<background_move_pool_size>1</background_move_pool_size>
|
||||
<mark_cache_size>268435456</mark_cache_size>
|
||||
<uncompressed_cache_size>0</uncompressed_cache_size>
|
||||
<merge_tree>
|
||||
<number_of_free_entries_in_pool_to_execute_mutation>2</number_of_free_entries_in_pool_to_execute_mutation>
|
||||
<number_of_free_entries_in_pool_to_lower_max_size_of_merge>2</number_of_free_entries_in_pool_to_lower_max_size_of_merge>
|
||||
<number_of_free_entries_in_pool_to_execute_optimize_entire_partition>2</number_of_free_entries_in_pool_to_execute_optimize_entire_partition>
|
||||
</merge_tree>
|
||||
</clickhouse>
|
||||
+3
-3
@@ -84,11 +84,11 @@ services:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
ports:
|
||||
- "${CLICKHOUSE_HTTP_PORT:-8123}:8123"
|
||||
- "${CLICKHOUSE_NATIVE_PORT:-9000}:9000"
|
||||
- "8123:8123"
|
||||
- "9000:9000"
|
||||
volumes:
|
||||
- ./data/clickhouse_data:/var/lib/clickhouse
|
||||
- ./docker/clickhouse/config.d:/etc/clickhouse-server/config.d
|
||||
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
|
||||
interval: 10s
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<clickhouse>
|
||||
<max_concurrent_queries>50</max_concurrent_queries>
|
||||
<background_pool_size>8</background_pool_size>
|
||||
<background_merges_mutations_concurrency_ratio>2</background_merges_mutations_concurrency_ratio>
|
||||
</clickhouse>
|
||||
@@ -11,6 +11,9 @@ sidebar: false
|
||||
## 重大变更
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> 3.1.2 版本更新了 CLickHouse 部署配置。
|
||||
>
|
||||
> 3.0.0 版本为 Wavelet 平台迁移与架构重构版本,涉及数据库表结构、环境变量以及前后端底层架构的重大变更。请务必在升级前备份数据库,并且更新到 V2.3.4。
|
||||
> 目前已知的兼容性问题:
|
||||
> - Pages 无法迁移, 升级前请先手动下载并备份 Pages 静态站点的 ZIP 包,升级后重新创建。
|
||||
@@ -18,6 +21,50 @@ sidebar: false
|
||||
|
||||
## [unreleased]
|
||||
|
||||
## [v3.2.0] - 2026-07-12
|
||||
|
||||
### 新增
|
||||
|
||||
- Zone 概览页新增 Cloudflare 风格流量图:支持 24 小时 / 7 天 / 30 天,展示唯一访问者、请求总数与已提供数据趋势。
|
||||
- 新增第一阶段 Zone 与正规化 Zone 域名数据库表及路由绑定模型,为后续以稳定 ID 管理网站与域名关联提供基础。
|
||||
- 新增 Zone 管理 API 与显式历史域名导入命令,使用公共后缀列表验证注册根域和域名归属。
|
||||
|
||||
### 修改
|
||||
|
||||
- 调整数据库自动清理设置文案,明确自动清理按 ClickHouse 表 TTL 执行,并提示访问日志 90 天、其它观测数据 30 天的保留下限。
|
||||
- 重构并清理了分析仓统计层 `node_access_log_stats.go` 和 `openflare_access_log.go` 之间的重复模型,使用底层 type aliases 简化了类型转换和拷贝逻辑。
|
||||
- 配置快照、OpenResty 渲染、Tunnel 与 Uptime Kuma 监控改为从 Zone 域名绑定读取域名 and 证书,移除对反代路由旧域名/证书字段的运行时回退。
|
||||
- 管理端网站入口改为 Zone 列表与 `/websites/:zoneId` 详情(概览 / 域名 / 路由 / 证书 / 设置),反代路由通过 Zone 域名选择器绑定。
|
||||
- 反代路由 API 以 `zone_domain_ids` / `zone_domains` 为唯一域名与证书关联来源,不再接受或返回路由内嵌域名/证书字段。
|
||||
|
||||
### 移除
|
||||
|
||||
- 移除托管域名(managed-domains)管理 API 与前端 `WebsiteService`;请改用 Zone / Zone 域名 API。
|
||||
- 移除 Zone、Zone 域名、反代路由、WAF 规则组与 IP 组的备注字段(前后端与数据库列同步删除);证书与源站备注保留。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 Agent 上报访问日志的 `bytes_sent` 在 Server 入库链路丢失,导致 Zone 概览“已提供的数据总计”长期为 0 的问题。
|
||||
- 修复嵌入式静态前端访问 `/websites/:zoneId` 时回退到首页 HTML,导致 Zone 详情页显示总览并触发 React hydration error 的问题。
|
||||
- Docker ClickHouse 性能配置改为单文件挂载,避免覆盖镜像内置的 Docker 网络监听配置,导致宿主机无法通过 8123/9000 访问服务。
|
||||
|
||||
## [v3.1.2] - 2026-07-10
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复节点/仪表盘 24 小时容量、网络、磁盘 IO 趋势在 ClickHouse 限流查询下几乎为空的问题:改为基于小时级聚合与计数器 delta 统计,避免仅依赖最近有限条原始快照导致历史时段全空。
|
||||
- 降低静置时 ClickHouse CPU:可观测/访问日志 batchwriter 启用 `MinBatchSize` 与 `MaxFlushWait`,减少心跳小 part 写入;Docker `performance.xml` 收紧小规格后台 merge 池。
|
||||
- ClickHouse 清理语义:按保留天数仅 `MATERIALIZE` 表 DDL TTL,`deleted_count` 不再伪报删除;短于表 TTL 的保留请求被拒绝。
|
||||
- 可观测 dedup 仅在入队成功后保留,flush 失败释放键并短重试;审计 writer 增加 `MaxFlushWait`;`/admin/status/clickhouse` 暴露 batch writer 队列深度/丢弃/flush 错误。
|
||||
- model 层通过 hooks 写入 CH,去除对 `chwriter` 的直接依赖。
|
||||
- Dashboard 每节点最新指标改为 `LIMIT 1 BY node_id`;新增 metric/openresty 小时预聚合表;读路径按小时 merge(rollup 窗口完整时仅走预聚合,不足时用 raw 补洞),并提供历史 backfill 迁移。
|
||||
- 小规格默认连接池下调;`async_insert_busy_timeout` 调至 2s;`of_node_traffic_hourly` 增加 30 天 TTL,UV 改为峰值窗口估计并修正前端文案。
|
||||
- Docker ClickHouse:`performance.xml` 下调 merge free-entry 阈值以兼容小 `background_pool`(避免 25.x 启动 Code 36)。
|
||||
|
||||
### 文档
|
||||
|
||||
- 同步 `.env.example` 与 `config.example.yaml`;ClickHouse 服务端配置改为 curl `performance.xml` 到 `./config/clickhouse` 后整目录挂载至 `config.d`(不要放入 listen 配置)。
|
||||
|
||||
## [v3.1.1] - 2026-07-06
|
||||
|
||||
### 修改
|
||||
|
||||
+2
-1
@@ -73,6 +73,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
{ text: '概览', link: '' },
|
||||
{ text: '快速开始', link: 'quick-start' },
|
||||
{ text: 'TLS 证书与自动续期', link: 'certificates' },
|
||||
{ text: 'Zone 域名迁移', link: 'zone-domain-migration' },
|
||||
{ text: '新建反代配置', link: 'proxy-config' },
|
||||
{ text: 'Pages 静态托管使用', link: 'pages-usage' },
|
||||
{ text: '内网穿透与隧道使用', link: 'tunnel-usage' },
|
||||
@@ -125,6 +126,7 @@ function sidebarDesign(): DefaultTheme.SidebarItem[] {
|
||||
items: [
|
||||
{ text: '产品边界', link: '' },
|
||||
{ text: '系统架构', link: 'architecture' },
|
||||
{ text: 'Zone 与域名资源设计', link: 'zone-design' },
|
||||
{ text: 'Agent 与发布模型', link: 'agent-design' },
|
||||
{ text: '内网穿透隧道设计', link: 'tunnel-design' },
|
||||
{ text: 'WAF 设计', link: 'waf-design' },
|
||||
@@ -135,4 +137,3 @@ function sidebarDesign(): DefaultTheme.SidebarItem[] {
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,29 @@ OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 AP
|
||||
> **关于外部依赖**:
|
||||
> OpenFlare 系统内建了对后台异步任务(Asynq 框架)及海量节点日志分析与度量指标(观测面板)的支持。因此,**无论采用何种部署模式,系统都必须依赖 Redis(或 Valkey)与 ClickHouse 的运行**。各个部署方案的主要差异在于主关系型数据库的选择(SQLite vs PostgreSQL)以及是否启用链路追踪服务(Jaeger)。
|
||||
|
||||
> [!TIP]
|
||||
> **ClickHouse 服务端性能配置(推荐挂载)**
|
||||
> 控制面常见为小规格主机(如 3c6g)。仓库提供的 `performance.xml` 会收紧后台 merge/mutation 线程池,避免默认配置在小机器上静置 CPU 偏高或 ClickHouse 25.x 启动校验失败。
|
||||
> 将本地 `./config/clickhouse/performance.xml` 以单文件方式挂载到容器 `/etc/clickhouse-server/config.d/performance.xml`,以保留官方镜像内置的 Docker 网络监听配置。
|
||||
|
||||
部署前将配置拉到本地:
|
||||
|
||||
```bash
|
||||
mkdir -p ./config/clickhouse
|
||||
curl -fsSL -o ./config/clickhouse/performance.xml \
|
||||
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
|
||||
```
|
||||
|
||||
在 ClickHouse 服务的 `volumes` 中增加(与数据卷并列):
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data/clickhouse_data:/var/lib/clickhouse # 或 named volume
|
||||
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
|
||||
```
|
||||
|
||||
修改 `performance.xml` 后需 `docker compose restart clickhouse` 才生效。
|
||||
|
||||
---
|
||||
|
||||
## 方式一:Docker 部署 (推荐)
|
||||
@@ -71,10 +94,15 @@ services:
|
||||
CLICKHOUSE_PASSWORD: 123456
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: Asia/Shanghai
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- ./data/clickhouse_data:/var/lib/clickhouse
|
||||
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
|
||||
test: ["CMD", "clickhouse-client", "--user", "default", "--password", "123456", "--query", "SELECT 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -84,6 +112,9 @@ services:
|
||||
运行启动命令:
|
||||
|
||||
```bash
|
||||
mkdir -p ./config/clickhouse
|
||||
curl -fsSL -o ./config/clickhouse/performance.xml \
|
||||
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -154,8 +185,13 @@ services:
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- openflare_clickhouse_data:/var/lib/clickhouse
|
||||
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
|
||||
interval: 10s
|
||||
@@ -173,6 +209,9 @@ volumes:
|
||||
创建对应的 `.env` 文件来配置系统环境变量(可复制并修改根目录下的 `.env.example`):
|
||||
|
||||
```bash
|
||||
mkdir -p ./config/clickhouse
|
||||
curl -fsSL -o ./config/clickhouse/performance.xml \
|
||||
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件,填入对应的数据库、Redis、ClickHouse 连接地址、密码与 APP_SESSION_SECRET
|
||||
@@ -264,25 +303,33 @@ services:
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- openflare_clickhouse_data:/var/lib/clickhouse
|
||||
|
||||
volumes:
|
||||
openflare_uploads:
|
||||
openflare_postgres_data:
|
||||
openflare_redis_data:
|
||||
openflare_clickhouse_data:
|
||||
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
openflare_uploads:
|
||||
openflare_postgres_data:
|
||||
openflare_redis_data:
|
||||
openflare_clickhouse_data:
|
||||
```
|
||||
|
||||
启动并验证:
|
||||
|
||||
```bash
|
||||
mkdir -p ./config/clickhouse
|
||||
curl -fsSL -o ./config/clickhouse/performance.xml \
|
||||
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件并确保设置好 APP_SESSION_SECRET 密码
|
||||
@@ -304,7 +351,7 @@ docker compose up -d
|
||||
| Go | `1.25+` |
|
||||
| Node.js | `18+` |
|
||||
| pnpm | 推荐通过 `corepack enable` 使用项目声明的 pnpm |
|
||||
| 外部服务 | 必须在本地或远端运行 Redis (Valkey) 和 ClickHouse 实例 |
|
||||
| 外部服务 | 必须在本地或远端运行 Redis (Valkey) 和 ClickHouse 实例;ClickHouse 建议挂载仓库提供的 `performance.xml`(见上文「ClickHouse 服务端性能配置」) |
|
||||
|
||||
### 1. 构建管理端前端
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ OpenResty (Agent, TLS/WAF)
|
||||
|
||||
当前系统核心实体包括:
|
||||
|
||||
* **反代与配置**:`proxy_routes` (网站配置), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书), `managed_domains` (托管域名).
|
||||
* **反代与配置**:`zones` (根域管理边界), `zone_domains` (明确域名与证书/路由关联), `proxy_routes` (路由策略), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书). 详见 [Zone 与域名资源设计](./zone-design.md)。
|
||||
* **Pages 静态托管**:`pages_projects` (Pages项目), `pages_deployments` (不可变部署), `pages_deployment_files` (部署文件清单).
|
||||
* **节点与穿透**:`nodes` (节点), `tunnels` (隧道客户端), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
|
||||
* **WAF 与安全**:`waf_rule_groups` (WAF规则组), `waf_ip_groups` (WAF IP组), `waf_rule_group_bindings` (网站WAF绑定).
|
||||
@@ -154,7 +154,7 @@ OpenResty (Agent, TLS/WAF)
|
||||
| 完整配置版本,而不是在线 patch | 让预览、激活、历史和回滚有稳定边界,保证节点状态一致 |
|
||||
| Agent 主动拉取 | Server 不需要 SSH 权限,降低安全风险;支持 HTTP 与 WebSocket 双协议灵活切换 |
|
||||
| 全局单激活版本 | 降低控制面复杂度,保证所有节点默认一致;提供一键秒级回滚的稳定机制 |
|
||||
| 网站配置聚合多域名 | 支持单个业务站点共享站点级策略,同时支持按域名灵活绑定不同的 TLS 证书 |
|
||||
| Zone 域名与路由策略分离 | Zone 提供根域入口与域名边界;路由仍可复用同一套站点级策略并按域名绑定证书 |
|
||||
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道引起稳定性风险;其 Vhost 机制天然适配反代路由 |
|
||||
| 运行时配置与控制库解耦 | 如 WAF 运行时只读取本地 JSON 规则包,配置变更通过差分广播或快速重载热生效 |
|
||||
|
||||
@@ -167,6 +167,7 @@ OpenResty (Agent, TLS/WAF)
|
||||
1. **[产品边界](./index.md)**:了解 OpenFlare 核心定位与不允许逾越的设计边界。
|
||||
3. **[Agent 与发布模型](./agent-design.md)**:理解版本快照同步及失败回滚的安全兜底逻辑。
|
||||
4. **细分领域设计**:
|
||||
* Zone 与域名相关开发:阅读 [Zone 与域名资源设计](./zone-design.md)。
|
||||
* 穿透相关开发:阅读 [内网穿透隧道设计](./tunnel-design.md)。
|
||||
* WAF 相关开发:阅读 [WAF 设计](./waf-design.md)。
|
||||
* Pages 托管开发:阅读 [Pages 静态托管设计](./pages-design.md)。
|
||||
|
||||
@@ -22,11 +22,12 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
|
||||
| 能力 | 说明 | 详细设计/使用指南 |
|
||||
| --- | --- | --- |
|
||||
| **反代配置管理** | 以网站规则(Proxy Route)为聚合边界,支持多域名与多上游负载均衡 | [新建反代配置](../guide/proxy-config.md) |
|
||||
| **Zone 与域名管理** | 以可注册根域为管理入口,聚合明确域名、域名证书与反代路由 | [Zone 与域名资源设计](./zone-design.md) |
|
||||
| **配置版本控制** | 支持全局单一激活版本的预览、发布、不可变快照历史与秒级一键回滚 | [Agent 与发布模型](./agent-design.md) |
|
||||
| **WAF 安全防护** | 全局与自定义规则组,支持手动/自动/订阅型 IP 组,GeoIP 准入与 PoW CC 防护 | [WAF 设计](./waf-design.md) / [WAF 使用指南](../guide/waf-usage.md) |
|
||||
| **内网穿透** | 通过中继节点(Relay)与内网客户端(OpenFlared),反向穿透暴露内网 Web 服务 | [内网穿透设计](./tunnel-design.md) / [穿透使用指南](../guide/tunnel-usage.md) |
|
||||
| **Pages 静态托管** | 直接上传前端 zip 包,由边缘节点拉取并由 OpenResty 本地服务,支持 API 反代与 SPA Fallback | [Pages 静态托管设计](./pages-design.md) |
|
||||
| **TLS 证书自动续期** | 绑定 managed_domains 并通过 ACME 协议向 Let's Encrypt 申请/续期证书 | [新建反代配置](../guide/proxy-config.md) |
|
||||
| **TLS 证书自动续期** | 将证书显式绑定到 Zone 域名,并通过 ACME 协议向 Let's Encrypt 申请/续期证书 | [Zone 与域名资源设计](./zone-design.md) |
|
||||
| **多节点监控与观测** | 收集节点资源快照、健康事件,聚合请求指标与访问日志明细 | [系统架构](./architecture.md) |
|
||||
|
||||
---
|
||||
@@ -190,4 +191,3 @@ OpenFlare 已收敛为**单 monorepo**(Go 模块 `github.com/Rain-kl/Wavelet`
|
||||
* 发布、同步、回滚与 Agent 模型变化:更新 [Agent 与发布模型](./agent-design.md)。
|
||||
* 部署方式变化:更新 [部署说明](../deployment/deployment.md) 与 README。
|
||||
* 配置项变化:更新 [配置项参考](../reference/configuration.md)。
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Zone 与域名资源设计
|
||||
|
||||
## 目标
|
||||
|
||||
将“网站”重构为以可注册根域为入口的 Zone 管理体验。`example.com` 之类的 Zone 是稳定的管理边界;用户通过稳定 ID 路径进入该 Zone,查看并维护其中明确声明的域名、域名所绑定的反代路由和证书,以及路由级 WAF、Pages 等能力。
|
||||
|
||||
本设计替代 `managed_domains` 的概念、表与 API。它不引入权威 DNS 解析记录管理。
|
||||
|
||||
## 范围与约束
|
||||
|
||||
* Zone 根域使用 Public Suffix List 解析,例如 `api.example.co.uk` 归属 `example.co.uk`。
|
||||
* URL 使用 ID:列表为 `/websites`,详情为 `/websites/:zoneId`;不使用域名作为 URL 参数。
|
||||
* Zone 域名必须是明确的 FQDN,禁止录入 `*.example.com`。TLS 证书可仍含通配符 SAN,并用于覆盖明确的 Zone 域名。
|
||||
* 一个 Zone 域名至多关联一条反代路由;一条反代路由可关联多个 Zone 域名,因而可跨 Zone 共享同一套上游、缓存、限流、WAF 与 Pages 配置。
|
||||
* 不新增 DNS 记录、边缘函数、预览子域或租户隔离能力。
|
||||
|
||||
## 核心模型
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
ZONES ||--o{ ZONE_DOMAINS : contains
|
||||
PROXY_ROUTES ||--o{ ZONE_DOMAINS : serves
|
||||
TLS_CERTIFICATES ||--o{ ZONE_DOMAINS : secures
|
||||
PROXY_ROUTES ||--o{ WAF_RULE_GROUP_BINDINGS : applies
|
||||
PAGES_PROJECTS ||--o{ PROXY_ROUTES : backs
|
||||
|
||||
ZONES {
|
||||
uint id PK
|
||||
string domain UK
|
||||
}
|
||||
ZONE_DOMAINS {
|
||||
uint id PK
|
||||
uint zone_id
|
||||
uint proxy_route_id
|
||||
string domain UK
|
||||
uint cert_id
|
||||
}
|
||||
```
|
||||
|
||||
### `of_zones`
|
||||
|
||||
保存根域、创建时间与更新时间。根域全局唯一且创建后不可原地修改;需要变更时新建 Zone 并迁移域名。删除 Zone 前必须先清空其 Zone 域名。
|
||||
|
||||
### `of_zone_domains`
|
||||
|
||||
保存 `zone_id`、明确 `domain`、可空的 `proxy_route_id`、可空的 `cert_id` 及时间戳。`domain` 全局唯一;所有关系字段建立索引但不建立物理外键。`proxy_route_id` 允许为空,以承接已准备证书但尚未配置反代的历史域名。
|
||||
|
||||
`of_proxy_routes` 逐步移除 `domain`、`domains`、`cert_id`、`cert_ids` 与 `domain_cert_ids` 等域名/证书冗余列。路由不得再指定任何 TLS 证书;路由名称 `site_name` 成为稳定的人类可读标识,编译器从关联的 Zone 域名读取 `server_name` 与其 `cert_id`。这使每个明确域名的证书只有一个来源。
|
||||
|
||||
## 业务与 API
|
||||
|
||||
管理端新增 Zone 资源:
|
||||
|
||||
* `GET/POST /api/v1/d/zones`
|
||||
* `GET/POST /api/v1/d/zones/:id/update`
|
||||
* `POST /api/v1/d/zones/:id/delete`
|
||||
* `GET/POST /api/v1/d/zones/:id/domains`
|
||||
* `POST /api/v1/d/zones/:id/domains/:domainID/update`
|
||||
* `POST /api/v1/d/zones/:id/domains/:domainID/delete`
|
||||
* `GET /api/v1/d/zones/:id/overview`
|
||||
|
||||
反代路由的创建、更新请求改用 `zone_domain_ids`,不再提交 `domains`、`cert_id`、`cert_ids` 或 `domain_cert_ids`。服务端在事务中验证域名归属、全局唯一性和证书 SAN 覆盖;失败通过 `response.Abort*` 统一返回。删除已绑定路由的 Zone 域名必须先解除或删除该路由;删除仍有域名的 Zone 必须拒绝。
|
||||
|
||||
WAF、Pages、上游与发布版本仍属于 `proxy_routes`。Zone 概览只聚合展示其域名关联的路由状态,不复制或重新定义这些配置。
|
||||
|
||||
## 前端体验
|
||||
|
||||
`/websites` 只展示 Zone 根域,显示已配置域名数、路由数与状态,并提供搜索、创建和操作菜单。点击进入 `/websites/:zoneId`。
|
||||
|
||||
详情页包含:
|
||||
|
||||
* 概览:域名、路由和有效证书统计;域名—路由—证书摘要;路由级 WAF 与 Pages 摘要。
|
||||
* 域名:明确 FQDN 的列表、证书选择和关联路由;不显示或接受通配符域名。
|
||||
* 路由:筛选到当前 Zone 的路由并链接到既有路由详情。
|
||||
* 证书:当前 Zone 域名实际引用的证书。
|
||||
* 设置:Zone 备注和受保护的删除操作。
|
||||
|
||||
新增路由时从 Zone 域名中选择;用户也可以先在 Zone 中登记域名,再绑定路由。全局反代路由入口保留,但改用同一套 Zone 域名选择器。
|
||||
|
||||
## 数据迁移
|
||||
|
||||
本次改造分两个发布阶段,以免 SQL 用错误的“末两段域名”规则处理多级公共后缀。操作细则见 [Zone 域名迁移与发布验收](../guide/zone-domain-migration.md)。
|
||||
|
||||
1. **第一阶段 DDL**:PostgreSQL 与 SQLite 同版本 Goose 创建 `of_zones` / `of_zone_domains`;暂时保留 `of_managed_domains` 与路由冗余列。
|
||||
2. **数据导入(自动)**:Server 启动时 `migrator.Migrate()` 先应用 goose SQL 至 `202607120002`,再自动导入旧路由域名 / `managed_domains`(`publicsuffix` 解析注册根域,写入 `cert_id` 与 `proxy_route_id`),最后继续后续 SQL。冲突时启动失败;修复后重启可幂等重试。无需手动命令。
|
||||
3. **代码切换**:控制面 API、配置快照、渲染、前端均以 Zone 域名为唯一来源;路由写入仅使用 `zone_domain_ids`。
|
||||
4. **第二阶段清理**:Goose SQL `202607130001_drop_legacy_route_domain_columns` 删除 `of_managed_domains` 与 `of_proxy_routes` 冗余列。Down 仅恢复开发库空结构,不回填历史数据。
|
||||
|
||||
### 运行时模型边界
|
||||
|
||||
* 持久化:域名与证书只存在于 `of_zone_domains`;`of_proxy_routes` 仅保存路由策略(上游、缓存、限流、WAF 绑定键等)。
|
||||
* 渲染:配置快照在内存中组装临时 `Domains` / `DomainCertIDs` 供 OpenResty 渲染,不写回数据库。
|
||||
* 结构迁移仅使用 `internal/db/migrator/goose/{postgres,sqlite}/*.sql`;启动时自动导入历史域名,第二阶段后旧列不存在则为空操作。
|
||||
|
||||
## 验证
|
||||
|
||||
* 单元测试:Public Suffix List 分组、FQDN / 通配符拒绝、跨 Zone 路由、证书 SAN 覆盖、删除保护及迁移幂等性;清理后断言旧列/旧表不存在。
|
||||
* 集成测试:Zone、Zone 域名与路由 API 的成功与失败响应;现有路由迁移后生成相同 OpenResty 域名与证书配置。
|
||||
* 前端测试:Zone 列表、ID 路由、详情加载 / 错误 / 空状态、域名选择器与 API 负载。
|
||||
* 手动验证:迁移前后比较激活配置快照中的 `server_name` 和证书路径,发布后使用根域及各子域请求验证路由。
|
||||
+788
-503
File diff suppressed because it is too large
Load Diff
+9
-8
@@ -11,14 +11,15 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
|
||||
1. [快速开始](./quick-start.md):用 Docker Compose 启动 Server,登录管理端,并接入第一个 Agent。
|
||||
2. [发布第一份配置](./first-site.md):快速新建一条最基础的 HTTP 反代站点规则,并验证节点生效状态。
|
||||
3. [新建反代配置](./proxy-config.md):一步一步了解如何从证书导入与申请开始,配置 HTTPS 加密与上游源站管理。
|
||||
4. [Pages 静态托管使用](./pages-usage.md):了解静态项目 ZIP 上传限制、SPA Fallback、以及内置 API 反向代理配置。
|
||||
5. [内网穿透与隧道使用](./tunnel-usage.md):部署 Relay 与 Client,实现安全、无公网 IP 反向穿透。
|
||||
6. [WAF 安全防护使用](./waf-usage.md):配置 WAF 规则组,掌握 IP 黑白名单、自动/订阅 IP 组、地域限制与 PoW CC 防护。
|
||||
7. [WAF 自动 IP 组语法](./waf-ip-group-expr.md):编写自动 IP 组 Expr 规则,了解关键字含义和预设规则。
|
||||
8. [Uptime Kuma 监控同步](./uptime-kuma.md):配置并使用 Uptime Kuma 自动差分同步和监控范围控制。
|
||||
9. [SSO 登录配置](./sso.md):配置 GitHub 或 OIDC 实现第三方单点登录 (SSO) 接入。
|
||||
10. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。
|
||||
11. [引用与致谢](./credits.md):查看系统依赖的优秀开源项目与社区致谢清单。
|
||||
4. [Zone 域名迁移](./zone-domain-migration.md):从旧托管域名/路由内嵌域名升级到 Zone 模型(goose 自动导入),含备份、验收与回滚说明。
|
||||
5. [Pages 静态托管使用](./pages-usage.md):了解静态项目 ZIP 上传限制、SPA Fallback、以及内置 API 反向代理配置。
|
||||
6. [内网穿透与隧道使用](./tunnel-usage.md):部署 Relay 与 Client,实现安全、无公网 IP 反向穿透。
|
||||
7. [WAF 安全防护使用](./waf-usage.md):配置 WAF 规则组,掌握 IP 黑白名单、自动/订阅 IP 组、地域限制与 PoW CC 防护。
|
||||
8. [WAF 自动 IP 组语法](./waf-ip-group-expr.md):编写自动 IP 组 Expr 规则,了解关键字含义和预设规则。
|
||||
9. [Uptime Kuma 监控同步](./uptime-kuma.md):配置并使用 Uptime Kuma 自动差分同步和监控范围控制。
|
||||
10. [SSO 登录配置](./sso.md):配置 GitHub 或 OIDC 实现第三方单点登录 (SSO) 接入。
|
||||
11. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。
|
||||
12. [引用与致谢](./credits.md):查看系统依赖的优秀开源项目与社区致谢清单。
|
||||
|
||||
## 按角色查找
|
||||
|
||||
|
||||
@@ -30,6 +30,14 @@ Agent 统一通过 OpenResty 二进制控制运行时。本地部署需要节点
|
||||
|
||||
为了保证异步任务队列(Asynq 框架)及可观测流量看板功能完整运行,快速开始推荐采用 **PostgreSQL + Redis + ClickHouse** 经典单机版编排。
|
||||
|
||||
先拉取 ClickHouse 服务端性能配置到 `./config/clickhouse`,并以单文件方式挂载:
|
||||
|
||||
```bash
|
||||
mkdir -p ./config/clickhouse
|
||||
curl -fsSL -o ./config/clickhouse/performance.xml \
|
||||
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
|
||||
```
|
||||
|
||||
在空目录中创建 `docker-compose.yaml`:
|
||||
|
||||
```yaml
|
||||
@@ -54,9 +62,9 @@ services:
|
||||
DB_PASSWORD: "${DB_PASSWORD:-replace-with-strong-password}"
|
||||
DB_NAME: "${DB_NAME:-openflare}"
|
||||
REDIS_ENABLED: "true"
|
||||
REDIS_ADDRS: "redis:6379"
|
||||
REDIS_ADDR: "redis:6379"
|
||||
CLICKHOUSE_ENABLED: "true"
|
||||
CLICKHOUSE_HOSTS: "clickhouse:9000"
|
||||
CLICKHOUSE_HOST: "clickhouse:9000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -101,10 +109,15 @@ services:
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: Asia/Shanghai
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- openflare_clickhouse_data:/var/lib/clickhouse
|
||||
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
|
||||
test: ["CMD", "clickhouse-client", "--user", "default", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Zone 域名迁移与发布验收
|
||||
|
||||
从旧版 `managed_domains` / 反代路由内嵌域名列迁移到 Zone + Zone 域名模型时,数据导入与表结构升级均由 **Server 启动时的 goose 自动迁移**完成,无需单独执行导入命令。
|
||||
|
||||
## 升级时发生了什么
|
||||
|
||||
启动(或滚动升级)包含 Zone 改造的 Server 版本时,**无需手动命令**,`migrator.Migrate()` 自动:
|
||||
|
||||
1. 应用 goose SQL:创建 `of_zones` / `of_zone_domains`(若尚未存在)。
|
||||
2. **自动导入**旧路由域名列(及无路由域名时的 `of_managed_domains`)为 Zone / Zone 域名,并绑定 `proxy_route_id` / `cert_id`(公共后缀列表解析注册根域)。
|
||||
3. 继续 goose SQL:删除 `of_managed_domains` 与 `of_proxy_routes` 冗余域名/证书列。
|
||||
|
||||
导入幂等:已存在的域名会跳过或补绑路由。
|
||||
|
||||
**若历史数据无法解析(冲突域名、无效根域、证书不存在等),启动失败。** 修复数据或恢复备份后再次启动即可重试。
|
||||
|
||||
## 建议操作
|
||||
|
||||
### 1. 升级前备份
|
||||
|
||||
```bash
|
||||
# PostgreSQL 示例
|
||||
pg_dump "$DATABASE_URL" > openflare-pre-zone-$(date +%Y%m%d).sql
|
||||
|
||||
# 或复制备份卷 / 快照;SQLite 则复制 data 目录中的库文件
|
||||
```
|
||||
|
||||
可选:在管理端记下当前**激活配置版本号**与 checksum,便于配置回滚对比。
|
||||
|
||||
### 2. 升级并启动 Server
|
||||
|
||||
部署新版本并启动即可。观察启动日志中的 goose 成功信息;若出现「迁移 Zone 失败(N 个冲突)」则按日志中的冲突项修复源数据后重启。
|
||||
|
||||
### 3. 升级后检查
|
||||
|
||||
1. 管理端 **网站** `/websites`:Zone 根域与域名计数是否合理。
|
||||
2. Zone 详情:域名、证书、关联路由 ID。
|
||||
3. **反代路由**:域名绑定来自 Zone 域名,而非旧手写字段。
|
||||
|
||||
### 4. 配置预览与发布
|
||||
|
||||
1. 在管理端查看配置差异 / 预览。
|
||||
2. **逐路由**核对:`server_name` 集合、证书路径、WAF Route ID、Pages 引用。
|
||||
3. **允许**旧快照 JSON 中路由上的冗余 `domain` / `domains` / `cert_ids` 消失。
|
||||
4. **不允许**数据面语义变化。
|
||||
5. 预览通过后发布;需要时在配置版本中激活升级前版本做配置回滚。数据库回退请使用升级前备份(Down 迁移不回填业务域名数据)。
|
||||
|
||||
## 相关文档
|
||||
|
||||
* [Zone 与域名资源设计](../design/zone-design.md)
|
||||
* [新建反代配置](./proxy-config.md)
|
||||
* [发布第一份配置](./first-site.md)
|
||||
@@ -0,0 +1,22 @@
|
||||
# ClickHouse P0–P3 修复计划
|
||||
|
||||
> 状态: 已完成(已合并主工作区,`make code-check` 通过)
|
||||
> 策略: 4 个互不干扰 worktree 并行,最后由主代理合并
|
||||
|
||||
## 任务拆分
|
||||
|
||||
| ID | Worktree 主题 | 范围 | 禁止改动 |
|
||||
|----|---------------|------|----------|
|
||||
| WT1 | P0 清理语义 C1 | cleanup maintenance / delete / tasks | chwriter、dashboard、DDL 新 MV |
|
||||
| WT2 | 写路径 C2+H1+H2+H3 | chwriter、batchwriter、risk_control、model store 分层、status 指标 | goose 迁移、dashboard 读逻辑 |
|
||||
| WT3 | 读路径 H4+H5 | 最新快照查询、metric/openresty 小时 MV + 读路径 | chwriter、cleanup |
|
||||
| WT4 | P3 打磨 | 连接池/async_insert、traffic hourly TTL、UV 语义 | model store 分层、cleanup |
|
||||
|
||||
## 合并顺序
|
||||
|
||||
1. WT1 → 2. WT2 → 3. WT3 → 4. WT4
|
||||
(迁移文件时间戳已错开,changelog 由主代理统一写)
|
||||
|
||||
## 验收
|
||||
|
||||
各 worktree: 相关 `go test` + 可运行部分;合并后 `make code-check`。
|
||||
@@ -0,0 +1,491 @@
|
||||
# Zone 与域名资源重构 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 以稳定 ID 的 Zone 管理入口和正规化 Zone 域名替代 `managed_domains` 及反代路由中的域名/证书冗余字段,同时保持配置发布后的 OpenResty 行为不变。
|
||||
|
||||
**Architecture:** `of_zones` 管理可注册根域;`of_zone_domains` 是明确 FQDN、证书和反代路由之间的唯一关联来源。反代路由保留路由策略,配置快照在控制面联查 Zone 域名与证书后生成现有 OpenResty 配置格式。第一发布阶段保留旧列供可重复执行的历史数据导入读取;生产快照对比通过后才执行第二阶段清理。
|
||||
|
||||
**Tech Stack:** Go 1.25、Gin、GORM、goose(PostgreSQL/SQLite)、`golang.org/x/net/publicsuffix`、Next.js App Router、TypeScript、TanStack Query、shadcn/ui。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
* Zone URL 必须为 `/websites/:zoneId`,不得使用域名作为路由参数。
|
||||
* Zone 根域由 `publicsuffix.EffectiveTLDPlusOne` 解析;Zone 域名只接受明确 FQDN,拒绝 `*.`。
|
||||
* TLS 证书可含通配符 SAN;证书只能由 `of_zone_domains.cert_id` 指定,`of_proxy_routes` 不再保存证书字段。
|
||||
* `of_zone_domains.domain` 全局唯一;同一 Zone 域名至多绑定一条反代路由,路由可关联多个 Zone 的域名。
|
||||
* 不建立物理数据库外键;所有关联列必须建立显式索引。
|
||||
* 所有 HTTP 路由仅通过 `internal/router/v1/openflare/` 的管理端注册器委派;Handler 使用 `response.Abort*` 报错并补全 Swagger。
|
||||
* 不新增 DNS 记录管理、边缘函数、预览子域或多租户能力。
|
||||
* 每次 API 变更运行 `make swagger`;每个实现任务结束运行对应测试;完成前必须运行 `make code-check`。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `internal/db/migrator/goose/{postgres,sqlite}/202607120001_create_zone_domain_tables.sql` | 第一阶段 Zone/ZoneDomain DDL 与索引。 |
|
||||
| `internal/model/openflare_zone.go` | Zone、ZoneDomain 模型及数据访问。 |
|
||||
| `internal/apps/openflare/zone/{logics.go,routers.go,errs.go,legacy_import.go}` | Zone CRUD、概览、输入验证和历史导入。 |
|
||||
| `internal/cmd/migrate_zones.go` | 显式、可重复运行的历史 Zone 数据导入命令。 |
|
||||
| `internal/router/v1/openflare/register_zone.go` | `/api/v1/d/zones` 路由注册。 |
|
||||
| `internal/apps/openflare/proxy_route/*` | 以 `zone_domain_ids` 取代域名与证书输入。 |
|
||||
| `internal/apps/openflare/config_version/*`、`pkg/render/openresty/*` | 快照与 OpenResty 渲染改为使用 Zone 域名。 |
|
||||
| `frontend/lib/services/openflare/{zone.service.ts,types.ts,index.ts}` | Zone API 类型和服务。 |
|
||||
| `frontend/vitest.config.ts`、`frontend/tests/zone/*.test.tsx` | Zone 页面与域名选择器的最小前端测试运行环境。 |
|
||||
| `frontend/app/(main)/websites/*` | Zone 列表、`[zoneId]` 动态详情页和局部组件。 |
|
||||
| `frontend/app/(main)/proxy-routes/*` | Zone 域名选择器替换旧域名/证书编辑器。 |
|
||||
| `internal/db/migrator/goose/{postgres,sqlite}/202607130001_drop_legacy_route_domain_columns.sql` | 第二阶段删除旧表、列与索引。 |
|
||||
|
||||
### Task 1: 第一阶段 Schema、模型与迁移测试
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/db/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql`
|
||||
- Create: `internal/db/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql`
|
||||
- Create: `internal/model/openflare_zone.go`
|
||||
- Create: `internal/model/openflare_zone_test.go`
|
||||
- Modify: `internal/model/openflare_proxy_route.go`
|
||||
- Test: `internal/db/migrator/migrator_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `model.Zone`, `model.ZoneDomain`, `ListZoneDomainsByRouteID(ctx, routeID)`, `ReplaceZoneDomainRouteBindings(ctx, routeID, domainIDs)`.
|
||||
- Consumes: existing `model.ProxyRoute` and `model.TLSCertificate` IDs; no physical FK.
|
||||
|
||||
- [x] **Step 1: 写失败的模型与迁移测试**
|
||||
|
||||
```go
|
||||
func TestReplaceZoneDomainRouteBindingsRejectsForeignDomain(t *testing.T) {
|
||||
// Create zones and domains, then assert a domain cannot be bound twice.
|
||||
}
|
||||
```
|
||||
|
||||
Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count=1`
|
||||
|
||||
Expected: FAIL,因为 Zone 模型和 goose 文件尚不存在。
|
||||
|
||||
- [x] **Step 2: 新建双方言 DDL**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS of_zones (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zones_domain ON of_zones (domain);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS of_zone_domains (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
zone_id BIGINT NOT NULL,
|
||||
proxy_route_id BIGINT,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
cert_id BIGINT,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zone_domains_domain ON of_zone_domains (domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_zone_id ON of_zone_domains (zone_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_id);
|
||||
```
|
||||
|
||||
SQLite 使用 `INTEGER PRIMARY KEY AUTOINCREMENT`、`DATETIME`,字段/索引语义完全对齐。此任务不得删除旧列或旧表。
|
||||
|
||||
- [x] **Step 3: 实现模型和受事务保护的绑定替换**
|
||||
|
||||
```go
|
||||
type Zone struct { ID uint; Domain string; Remark string; CreatedAt time.Time; UpdatedAt time.Time }
|
||||
type ZoneDomain struct { ID uint; ZoneID uint; ProxyRouteID *uint; Domain string; CertID *uint; Remark string; CreatedAt time.Time; UpdatedAt time.Time }
|
||||
|
||||
func ReplaceZoneDomainRouteBindings(ctx context.Context, routeID uint, domainIDs []uint) error
|
||||
```
|
||||
|
||||
实现先锁定/读取请求域名,拒绝已绑定到其他路由的记录,再把当前路由已绑定但不在 `domainIDs` 的记录置空,最后将请求记录写为 `routeID`;所有动作放在同一 `db.DB(ctx).Transaction` 内。
|
||||
|
||||
- [x] **Step 4: 运行模型与迁移测试**
|
||||
|
||||
Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count=1`
|
||||
|
||||
Expected: PASS,空 SQLite 库可应用迁移,唯一域名和绑定排他性受保护。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/db/migrator/goose internal/model
|
||||
git commit -m "feat(zone): add normalized zone domain schema"
|
||||
```
|
||||
|
||||
### Task 2: Zone 领域逻辑、历史导入命令与管理 API
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/apps/openflare/zone/{logics.go,routers.go,errs.go,legacy_import.go,logics_test.go}`
|
||||
- Create: `internal/cmd/migrate_zones.go`
|
||||
- Create: `internal/router/v1/openflare/register_zone.go`
|
||||
- Modify: `internal/cmd/root.go`
|
||||
- Modify: `internal/router/v1/openflare/register_tls.go`
|
||||
- Test: `internal/apps/openflare/integration/security_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `zone.Create`, `zone.Update`, `zone.GetOverview`, `zone.ImportLegacy(ctx) (ImportReport, error)` and Zone REST handlers.
|
||||
- Consumes: Task 1 models; legacy `managed_domains` and proxy-route columns only inside `ImportLegacy`.
|
||||
|
||||
- [x] **Step 1: 写失败的逻辑与 API 测试**
|
||||
|
||||
```go
|
||||
func TestCreateZoneDomainRejectsWildcard(t *testing.T) { _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "*.example.com"}); require.EqualError(t, err, errDomainWildcardUnsupported) }
|
||||
func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) {
|
||||
root, err := zoneRoot("api.example.co.uk")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "example.co.uk", root)
|
||||
}
|
||||
```
|
||||
|
||||
集成测试请求 `POST /api/v1/d/zones/`、`POST /api/v1/d/zones/:id/domains`,并断言错误响应使用 400 信封。
|
||||
|
||||
- [x] **Step 2: 实现精确域名和 Zone 归属验证**
|
||||
|
||||
```go
|
||||
func zoneRoot(domain string) (string, error) { return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain))) }
|
||||
func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.ZoneDomain, error)
|
||||
```
|
||||
|
||||
拒绝空值、协议、路径和 `*`;要求 `zoneRoot(input.Domain) == zone.Domain`;若 `cert_id` 非空,验证 TLS 证书存在。Zone 根域创建也必须经 `EffectiveTLDPlusOne` 验证且输入等于结果。
|
||||
|
||||
- [x] **Step 3: 实现显式导入命令**
|
||||
|
||||
```go
|
||||
var migrateZonesCmd = &cobra.Command{Use: "migrate-zones", RunE: func(_ *cobra.Command, _ []string) error {
|
||||
report, err := zone.ImportLegacy(context.Background())
|
||||
return report.LogAndReturn(err)
|
||||
}}
|
||||
```
|
||||
|
||||
导入以事务执行:用 `routeidentity.DecodeDomains(route.Domains, route.Domain)` 读取旧路由;按 `domain_cert_ids` 的同一索引写入 `zone_domains.cert_id`;只在无路由域名时导入旧 `managed_domains`。发现无效根域、通配符记录或全局域名冲突时回滚并输出全部冲突项。重复执行不得生成重复 Zone/ZoneDomain。
|
||||
|
||||
- [x] **Step 4: 注册 API 并删除旧 managed-domain 路由**
|
||||
|
||||
```go
|
||||
zoneGroup := apiGroup.Group("/zones")
|
||||
zoneGroup.Use(apiutil.AdminMiddlewares()...)
|
||||
zoneGroup.GET("/", zone.ListHandler)
|
||||
zoneGroup.POST("/", zone.CreateHandler)
|
||||
zoneGroup.GET("/:id/overview", zone.GetOverviewHandler)
|
||||
```
|
||||
|
||||
把 `managed-domains` 路由块从 `register_tls.go` 移除;每个 Handler 使用 `apiutil.BindJSON` 和 `response.AbortBadRequest/AbortNotFound/AbortConflict`。
|
||||
|
||||
- [x] **Step 5: 验证并 Commit**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/zone ./internal/apps/openflare/integration -count=1 && make swagger`
|
||||
|
||||
Expected: PASS,Swagger 不再含 `/managed-domains` 且包含 `/zones`。
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/zone internal/cmd internal/router/v1/openflare docs
|
||||
git commit -m "feat(zone): add zone management api and legacy importer"
|
||||
```
|
||||
|
||||
### Task 3: 反代路由改用 ZoneDomain 关联
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/proxy_route/{logics.go,helpers.go,build_helpers.go,routers.go,errs.go,logics_test.go}`
|
||||
- Modify: `internal/model/openflare_proxy_route.go`
|
||||
- Modify: `internal/apps/openflare/tls/logics.go`
|
||||
- Modify: `internal/apps/openflare/origin/logics.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `zone_domain_ids []uint` and Task 1 binding API.
|
||||
- Produces: `proxy_route.Input{ZoneDomainIDs []uint}`, `proxy_route.View{ZoneDomains []ZoneDomainView}`.
|
||||
|
||||
- [x] **Step 1: 写失败的路由逻辑测试**
|
||||
|
||||
```go
|
||||
input := Input{SiteName: "api", ZoneDomainIDs: []uint{domainA.ID, domainB.ID}, EnableHTTPS: true}
|
||||
view, err := CreateProxyRoute(ctx, input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []uint{domainA.ID, domainB.ID}, view.ZoneDomainIDs)
|
||||
```
|
||||
|
||||
同时覆盖:空 `zone_domain_ids`、重复 ID、其他路由已占用域名、HTTPS 域名无证书、证书 SAN 不覆盖。
|
||||
|
||||
- [x] **Step 2: 删除路由输入/视图中的旧域名与证书字段**
|
||||
|
||||
```go
|
||||
type ZoneDomainBindingInput struct {
|
||||
ZoneDomainIDs []uint `json:"zone_domain_ids"`
|
||||
}
|
||||
type ZoneDomainView struct { ID uint `json:"id"`; ZoneID uint `json:"zone_id"`; Domain string `json:"domain"`; CertID *uint `json:"cert_id"` }
|
||||
```
|
||||
|
||||
移除 `Input.Domain`、`Input.Domains`、`Input.CertID`、`Input.CertIDs`、`Input.DomainCertIDs` 及对应 View 字段;删除旧证书派生辅助函数与 `WebsiteService.match` 所需后端逻辑。
|
||||
|
||||
- [x] **Step 3: 用关联记录验证并构建路由**
|
||||
|
||||
在 `buildProxyRoute` 中读取所有 `ZoneDomainIDs`,对每个 HTTPS 域名调用现有 `validateCertificateCoverage`,再调用 `ReplaceZoneDomainRouteBindings`。更新/删除路由也必须在事务内同步解除关联。来源、证书删除检查和 Origin 路由摘要改从 `zone_domains` 查询域名/证书。
|
||||
|
||||
- [x] **Step 4: 运行路由和 TLS 回归测试**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/proxy_route ./internal/apps/openflare/tls ./internal/apps/openflare/origin -count=1`
|
||||
|
||||
Expected: PASS;任一证书已被 Zone 域名引用时,删除证书被拒绝。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/proxy_route internal/apps/openflare/tls internal/apps/openflare/origin internal/model
|
||||
git commit -m "refactor(proxy): bind routes through zone domains"
|
||||
```
|
||||
|
||||
### Task 4: 配置快照、渲染与关联消费者
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/config_version/{snapshot.go,helpers.go,logics.go,logics_test.go,certificate_snapshot_test.go,pages_snapshot.go}`
|
||||
- Modify: `pkg/render/openresty/{types.go,render.go,render_route.go,render_test.go}`
|
||||
- Modify: `internal/apps/openflare/{flared/logics.go,uptimekuma/sync.go}`
|
||||
- Modify: `internal/apps/openflare/routeidentity/identity.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: snapshot/render `Route{SiteName, Domains, DomainCertIDs}` built transiently from ZoneDomain rows; neither DB model nor API stores those fields.
|
||||
|
||||
- [x] **Step 1: 写快照等价性失败测试**
|
||||
|
||||
```go
|
||||
func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) {
|
||||
// Two explicit ZoneDomains with different certs must render two TLS server blocks.
|
||||
}
|
||||
```
|
||||
|
||||
加入 Pages、Tunnel、WAF 绑定测试,断言 Route ID 与 `site_name` 未改变。
|
||||
|
||||
- [x] **Step 2: 在快照边界联查并生成临时渲染字段**
|
||||
|
||||
```go
|
||||
domains, err := model.ListZoneDomainsByRouteID(ctx, route.ID)
|
||||
snapshotRoute.Domains = maps.Values(domainNames)
|
||||
snapshotRoute.DomainCertIDs = certIDsInDomainOrder(domains)
|
||||
```
|
||||
|
||||
`pkg/render/openresty.Route` 可继续保留 `Domains` 与 `DomainCertIDs`,因为它是不可变配置快照的渲染输入;移除其中持久化主域/证书回退逻辑,所有错误消息改用 `SiteName`。
|
||||
|
||||
- [x] **Step 3: 移除旧字段回退路径**
|
||||
|
||||
删除 `routeidentity.DecodeDomains` 对持久化 `route.Domain` 的依赖;Flared、Uptime Kuma、配置 diff、WAF 文档和 Pages 错误信息都从 snapshot/ZoneDomain 查询的明确域名获取显示文本。
|
||||
|
||||
- [x] **Step 4: 运行数据面测试**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/config_version ./pkg/render/openresty ./internal/apps/openflare/flared ./internal/apps/openflare/uptimekuma -count=1`
|
||||
|
||||
Expected: PASS;迁移后的路由产生的 `server_name`、证书支持文件和 WAF RouteID 绑定与迁移前一致。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/config_version internal/apps/openflare/flared internal/apps/openflare/uptimekuma internal/apps/openflare/routeidentity pkg/render/openresty
|
||||
git commit -m "refactor(config): render routes from zone domains"
|
||||
```
|
||||
|
||||
### Task 5: Zone 前端服务与 ID 动态页面
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/lib/services/openflare/zone.service.ts`
|
||||
- Create: `frontend/vitest.config.ts`
|
||||
- Create: `frontend/tests/zone/{websites-page.test.tsx,zone-page.test.tsx}`
|
||||
- Modify: `frontend/lib/services/openflare/{types.ts,index.ts}`
|
||||
- Modify: `frontend/lib/services/index.ts`
|
||||
- Modify: `frontend/app/(main)/websites/page.tsx`
|
||||
- Create: `frontend/app/(main)/websites/[zoneId]/page.tsx`
|
||||
- Create: `frontend/app/(main)/websites/[zoneId]/components/{zone-overview.tsx,zone-domains-table.tsx,zone-route-summary.tsx,zone-editor-dialog.tsx,zone-domain-dialog.tsx}`
|
||||
- Delete: `frontend/app/(main)/websites/detail/page.tsx`
|
||||
- Delete: `frontend/app/(main)/websites/detail/page-client.tsx`
|
||||
- Delete: legacy Website/managed-domain-only components after imports are removed.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ZoneService.list/getOverview/create/update/delete`, `ZoneDomainService.create/update/delete` and `ZoneOverview` TypeScript types.
|
||||
|
||||
- [x] **Step 1: 写服务与页面行为测试**
|
||||
|
||||
先安装仅用于本次页面测试的开发依赖:
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom
|
||||
```
|
||||
|
||||
```ts
|
||||
expect(ZoneService.getOverview).toHaveBeenCalledWith(42)
|
||||
expect(screen.getByRole('heading', {name: 'example.com'})).toBeVisible()
|
||||
```
|
||||
|
||||
覆盖 `/websites/42` 的加载、404、空域名、搜索列表和从列表点击 ID 链接。
|
||||
|
||||
- [x] **Step 2: 实现类型化服务与查询键**
|
||||
|
||||
```ts
|
||||
export interface ZoneDomainItem { id: number; zone_id: number; proxy_route_id: number | null; domain: string; cert_id: number | null; remark: string }
|
||||
export class ZoneService extends OpenFlareBaseService { protected static override basePath = '/api/v1/d/zones' }
|
||||
export const zoneQueryKey = ['openflare', 'zones'] as const
|
||||
```
|
||||
|
||||
所有 React Query 回调使用箭头函数,避免静态 service `this` 丢失。
|
||||
|
||||
- [x] **Step 3: 用 Next 动态段实现 Zone 详情**
|
||||
|
||||
```tsx
|
||||
export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>) {
|
||||
const {zoneId} = await params
|
||||
return <ZonePageClient zoneId={Number(zoneId)} />
|
||||
}
|
||||
```
|
||||
|
||||
遵循本地 Next 文档:动态 `params` 是 Promise;无效或非正整数 ID 显示既有 `EmptyStateWithBorder`,不把域名写入 URL。主页面只维护页面骨架和 Tabs,具体 Tab 放入同目录组件。
|
||||
|
||||
- [x] **Step 4: 实现列表和详情交互**
|
||||
|
||||
列表仅渲染 Zone 根域及计数;详情使用概览、域名、路由、证书、设置 Tabs。域名弹窗拒绝 `*.`,但证书选择器不限制其 SAN。删除 Zone/域名使用确认对话框和服务端错误文案。
|
||||
|
||||
- [x] **Step 5: 验证并 Commit**
|
||||
|
||||
Run: `cd frontend && pnpm exec vitest run && pnpm lint`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
```bash
|
||||
git add frontend/lib/services frontend/app/'(main)'/websites
|
||||
git commit -m "feat(web): add zone-based website management"
|
||||
```
|
||||
|
||||
### Task 6: 反代路由前端切换到 Zone 域名选择器
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/app/(main)/proxy-routes/components/zone-domain-selector.tsx`
|
||||
- Create: `frontend/tests/zone/zone-domain-selector.test.tsx`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/{components/proxy-route-create-sheet.tsx,components/helpers.ts,page-client.tsx}`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/detail/{helpers.ts,page-client.tsx,components/domain-section.tsx}`
|
||||
- Delete: `frontend/app/(main)/proxy-routes/detail/components/domain-list-input.tsx`
|
||||
- Modify: `frontend/lib/services/openflare/types.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ZoneDomainItem[]` and route `zone_domain_ids: number[]`.
|
||||
- Produces: selector values with explicit domain/Zone/证书信息;不发送任何旧域名或证书字段。
|
||||
|
||||
- [x] **Step 1: 写失败的选择器测试**
|
||||
|
||||
```tsx
|
||||
render(<ZoneDomainSelector value={[7]} onChange={onChange} domains={[apiDomain]} />)
|
||||
expect(screen.getByText('api.example.com')).toBeVisible()
|
||||
expect(onChange).toHaveBeenCalledWith([7])
|
||||
```
|
||||
|
||||
覆盖搜索、跨 Zone 多选、已被其他路由占用的禁用项和 HTTPS 缺少证书的表单错误。
|
||||
|
||||
- [x] **Step 2: 移除旧前端负载与自动匹配**
|
||||
|
||||
从 `ProxyRouteItem`/`ProxyRouteMutationPayload` 删除 `domain`、`domains`、`primary_domain`、`cert_id`、`cert_ids`、`domain_cert_ids`;删除 `WebsiteService.match` 及 `DomainListInput` 自动填证书交互。
|
||||
|
||||
- [x] **Step 3: 实现 Zone 域名选择和保存负载**
|
||||
|
||||
```ts
|
||||
mutationFn: (payload) => ProxyRouteService.update(route.id, {
|
||||
...payload,
|
||||
zone_domain_ids: selectedDomainIDs,
|
||||
})
|
||||
```
|
||||
|
||||
展示每个选择项的 FQDN、所属 Zone 与证书;路由详情的“域名”区只编辑关联关系,证书链接跳转 Zone 详情而非路由内编辑。
|
||||
|
||||
- [x] **Step 4: 运行前端类型和交互测试**
|
||||
|
||||
Run: `cd frontend && pnpm exec tsc --noEmit`
|
||||
|
||||
Expected: PASS;不存在旧持久化域名/证书字段的 TypeScript 引用。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/'(main)'/proxy-routes frontend/lib/services/openflare/types.ts
|
||||
git commit -m "refactor(web): select route domains from zones"
|
||||
```
|
||||
|
||||
### Task 7: 第一发布阶段验证、文档与发布前数据检查
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/design/zone-design.md`
|
||||
- Modify: `docs/changelog/index.md`
|
||||
- Modify: generated `docs/{docs.go,swagger.json,swagger.yaml}`
|
||||
- Create: `docs/guide/zone-domain-migration.md`
|
||||
|
||||
- [x] **Step 1: 为导入命令写可操作迁移指南**
|
||||
|
||||
文档写明备份、执行 `wavelet migrate-zones`、读取导入报告、发布预览、比较 `server_name`/证书支持文件、发布激活和回滚步骤;不允许在报告有冲突时继续。
|
||||
|
||||
- [x] **Step 2: 生成 Swagger 和更新未发布变更**
|
||||
|
||||
Run: `make swagger`
|
||||
|
||||
在 `[Unreleased]` 记录 Zone 管理、反代路由域名正规化和移除 managed-domain API。
|
||||
|
||||
- [x] **Step 3: 运行全量质量门禁**
|
||||
|
||||
Run: `go test ./... && make code-check`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
- [x] **Step 4: 做快照等价性验收**
|
||||
|
||||
在升级前导出活动版本,在导入后生成预览;逐个比较所有路由的明确 `server_name` 集合、证书路径、WAF RouteID 绑定与 Pages 部署引用。只允许旧快照的域名/证书冗余 JSON 消失,不允许数据面语义变化。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs
|
||||
git commit -m "docs(zone): add migration and release verification guide"
|
||||
```
|
||||
|
||||
### Task 8: 第二发布阶段——删除旧表和冗余列
|
||||
|
||||
**Precondition:** 已在生产环境完成 Task 7 的导入、预览对比和至少一次发布/回滚验证;`migrate-zones` 报告无冲突。
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/db/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql`
|
||||
- Create: `internal/db/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql`
|
||||
- Delete: `internal/model/openflare_managed_domain.go`
|
||||
- Delete: `internal/apps/openflare/tls/managed_domain.go`
|
||||
- Delete: `internal/apps/openflare/tls/helpers.go` 中仅用于旧路由证书数组的函数
|
||||
- Modify: legacy迁移相关测试、模型测试与 `docs/design/zone-design.md`
|
||||
|
||||
- [x] **Step 1: 写空库与升级库清理失败测试**
|
||||
|
||||
```go
|
||||
func TestLegacyRouteColumnsAreAbsentAfterCleanup(t *testing.T) {
|
||||
require.False(t, db.DB(ctx).Migrator().HasColumn(&model.ProxyRoute{}, "domain"))
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 编写双方言清理 DDL**
|
||||
|
||||
PostgreSQL 删除旧唯一索引和 `domain`、`domains`、`cert_id`、`cert_ids`、`domain_cert_ids`,再删除 `of_managed_domains`;SQLite 使用重建 `of_proxy_routes` 表的迁移方式保留所有非旧字段与索引。Down 仅在开发数据库恢复旧结构,不回填历史数据。
|
||||
|
||||
- [x] **Step 3: 删除旧读取代码与测试 fixture**
|
||||
|
||||
删除所有 `route.Domain`、`route.Domains`、`route.CertID`、`route.CertIDs`、`route.DomainCertIDs` 的持久化引用;让编译器、Uptime Kuma、Flared、来源摘要及 API 只使用 ZoneDomain 查询结果。
|
||||
|
||||
- [x] **Step 4: 验证升级和完整回归**
|
||||
|
||||
Run: `go test ./internal/db/migrator ./internal/model ./internal/apps/openflare/... ./pkg/render/openresty -count=1 && make code-check`
|
||||
|
||||
Expected: PASS;全仓搜索不再发现旧 `ManagedDomain` 业务代码、`ProxyRoute` 持久化字段或管理端 API;渲染快照中的临时 `DomainCertIDs` 类型允许保留。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/db/migrator internal/model internal/apps frontend docs
|
||||
git commit -m "refactor(zone): remove legacy route domain storage"
|
||||
```
|
||||
|
||||
## Plan Self-Review
|
||||
|
||||
* Spec coverage: Tasks 1–4 交付正规化数据模型、API、迁移与数据面;Tasks 5–6 交付 ID 路由和 Zone 交互;Tasks 7–8 覆盖质量门禁与旧表清理。
|
||||
* Placeholder scan: 无待定标记或未定义的实现步骤;所有删除动作在明确的生产验证前置条件后执行。
|
||||
* Type consistency: 路由写入统一使用 `zone_domain_ids`,持久化关系统一为 `ZoneDomain.ProxyRouteID`,渲染边界仅使用临时 `Domains`/`DomainCertIDs`。
|
||||
+3
-1
@@ -11,7 +11,9 @@
|
||||
|
||||
## 正在进行的计划
|
||||
|
||||
当前暂无正在进行的开发计划或 AI 接手计划。所有历史迁移与重构项目(如后端/前端向 Wavelet 平台的迁移、边缘运行时重构等)均已完成开发并上线,对应的临时计划文档已归档清理。
|
||||
当前进行中的开发计划:
|
||||
|
||||
* [Zone 与域名资源重构](./20260712-zone-domain-refactor.md):以 Zone 和正规化 Zone 域名替代托管域名及反代路由中的域名/证书冗余字段。
|
||||
|
||||
## 使用建议
|
||||
|
||||
|
||||
@@ -99,13 +99,21 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
|
||||
| `redis.pool_size` | `REDIS_POOL_SIZE` | Redis 连接池大小 | `100` |
|
||||
|
||||
### 4. ClickHouse 配置 (`clickhouse:`)
|
||||
|
||||
> **说明**:下列为 OpenFlare **客户端**连接参数。ClickHouse **服务端**小规格调优:将 `performance.xml` curl 到 `./config/clickhouse/`,再以单文件方式挂载至容器的 `config.d/performance.xml`,详见 [启动 Server](../deployment/server.md)。
|
||||
|
||||
| 配置文件 YAML 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
|
||||
| --- | --- | --- | --- |
|
||||
| `clickhouse.enabled` | `CLICKHOUSE_ENABLED` | 是否启用 ClickHouse。**系统节点指标与访问日志在此进行海量写入** | `true` |
|
||||
| `clickhouse.hosts` | `CLICKHOUSE_HOST` | ClickHouse 集群连接地址数组(环境变量仅设置单地址) | `["127.0.0.1:9000"]` |
|
||||
| `clickhouse.username` | `CLICKHOUSE_USERNAME` | ClickHouse 账号用户名 | `default` |
|
||||
| `clickhouse.password` | `CLICKHOUSE_PASSWORD` | ClickHouse 密码 | `123456` |
|
||||
| `clickhouse.password` | `CLICKHOUSE_PASSWORD` | ClickHouse 密码 | `replace-with-clickhouse-password` |
|
||||
| `clickhouse.database` | `CLICKHOUSE_NAME` | ClickHouse 存储的数据库名称 | `openflare` |
|
||||
| `clickhouse.max_idle_conn` | - | 客户端空闲连接数(小规格默认偏低) | `8` |
|
||||
| `clickhouse.max_open_conn` | - | 客户端最大打开连接数 | `16` |
|
||||
| `clickhouse.conn_max_lifetime` | - | 连接最大存活时间(秒) | `3600` |
|
||||
| `clickhouse.dial_timeout` | - | 建连超时(秒) | `5` |
|
||||
| `clickhouse.block_buffer_size` | - | 原生协议 block 缓冲行数 | `32` |
|
||||
|
||||
### 5. 系统日志配置 (`log:`)
|
||||
| 配置文件 YAML 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Zone 域名重构规格
|
||||
|
||||
已确认的设计:
|
||||
|
||||
* `/websites` 展示可注册根域 Zone;详情 URL 使用 `/websites/:zoneId`。
|
||||
* `managed_domains` 将被彻底替换为 `of_zones` 与 `of_zone_domains`。
|
||||
* Zone 域名是 `of_proxy_routes` 域名与证书的规范化来源;一个域名至多连接一条路由,一条路由可含多个 Zone 的域名。
|
||||
* `of_proxy_routes` 移除 `cert_id`、`cert_ids` 与 `domain_cert_ids`,不再指定证书;配置编译只从关联 Zone 域名的 `cert_id` 查询证书。
|
||||
* Zone 域名只允许明确 FQDN;允许把含 `*.example.com` SAN 的 TLS 证书绑定到明确域名,但不允许通配符域名记录。
|
||||
* 路由仍拥有上游、缓存、限流、WAF 与 Pages;Zone 只提供聚合管理和展示。
|
||||
* 迁移先建新表、用 Public Suffix List 回填和验证,再在后续独立发布中移除旧表及冗余列。
|
||||
|
||||
完整设计、API、迁移与验证策略见 [Zone 与域名资源设计](../../design/zone-design.md)。
|
||||
+788
-503
File diff suppressed because it is too large
Load Diff
+487
-314
@@ -177,6 +177,12 @@ definitions:
|
||||
type: integer
|
||||
async_insert_queue:
|
||||
type: integer
|
||||
batch_writers:
|
||||
description: BatchWriters reports in-process queue depth/drops/flush errors
|
||||
for CH writers.
|
||||
items:
|
||||
$ref: '#/definitions/batchwriter.Stats'
|
||||
type: array
|
||||
database:
|
||||
type: string
|
||||
pending_mutations:
|
||||
@@ -241,6 +247,21 @@ definitions:
|
||||
is_active:
|
||||
type: boolean
|
||||
type: object
|
||||
batchwriter.Stats:
|
||||
properties:
|
||||
cap:
|
||||
type: integer
|
||||
depth:
|
||||
type: integer
|
||||
drops:
|
||||
type: integer
|
||||
flush_errors:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
running:
|
||||
type: boolean
|
||||
type: object
|
||||
cache.updateCacheConfigRequest:
|
||||
properties:
|
||||
lru_enabled:
|
||||
@@ -635,6 +656,8 @@ definitions:
|
||||
type: object
|
||||
github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog:
|
||||
properties:
|
||||
bytes_sent:
|
||||
type: integer
|
||||
host:
|
||||
type: string
|
||||
logged_at_unix:
|
||||
@@ -1083,23 +1106,6 @@ definitions:
|
||||
id:
|
||||
type: integer
|
||||
type: object
|
||||
model.ManagedDomain:
|
||||
properties:
|
||||
cert_id:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
domain:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
id:
|
||||
type: integer
|
||||
remark:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
model.OpenFlareApplyLog:
|
||||
properties:
|
||||
checksum:
|
||||
@@ -1619,6 +1625,34 @@ definitions:
|
||||
- UploadStatusPending
|
||||
- UploadStatusUsed
|
||||
- UploadStatusDeleted
|
||||
model.Zone:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
domain:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
model.ZoneDomain:
|
||||
properties:
|
||||
cert_id:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
domain:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
proxy_route_id:
|
||||
type: integer
|
||||
updated_at:
|
||||
type: string
|
||||
zone_id:
|
||||
type: integer
|
||||
type: object
|
||||
node.AgentReleaseInfo:
|
||||
properties:
|
||||
body:
|
||||
@@ -2257,12 +2291,18 @@ definitions:
|
||||
type: object
|
||||
option.databaseCleanupResult:
|
||||
properties:
|
||||
cleanup_mode:
|
||||
type: string
|
||||
delete_all:
|
||||
type: boolean
|
||||
deleted_count:
|
||||
type: integer
|
||||
eligible_count:
|
||||
type: integer
|
||||
retention_days:
|
||||
type: integer
|
||||
table_ttl_days:
|
||||
type: integer
|
||||
target:
|
||||
type: string
|
||||
target_label:
|
||||
@@ -2668,26 +2708,10 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
cert_id:
|
||||
type: integer
|
||||
cert_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
custom_headers:
|
||||
items:
|
||||
$ref: '#/definitions/proxy_route.CustomHeaderInput'
|
||||
type: array
|
||||
domain:
|
||||
type: string
|
||||
domain_cert_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
domains:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
enable_https:
|
||||
type: boolean
|
||||
enabled:
|
||||
@@ -2716,8 +2740,6 @@ definitions:
|
||||
type: integer
|
||||
redirect_http:
|
||||
type: boolean
|
||||
remark:
|
||||
type: string
|
||||
site_name:
|
||||
type: string
|
||||
tunnel_id:
|
||||
@@ -2734,6 +2756,10 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
zone_domain_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
type: object
|
||||
proxy_route.View:
|
||||
properties:
|
||||
@@ -2753,12 +2779,6 @@ definitions:
|
||||
type: array
|
||||
cache_rules:
|
||||
type: string
|
||||
cert_id:
|
||||
type: integer
|
||||
cert_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
created_at:
|
||||
type: string
|
||||
custom_header_list:
|
||||
@@ -2767,18 +2787,6 @@ definitions:
|
||||
type: array
|
||||
custom_headers:
|
||||
type: string
|
||||
domain:
|
||||
type: string
|
||||
domain_cert_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
domain_count:
|
||||
type: integer
|
||||
domains:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
enable_https:
|
||||
type: boolean
|
||||
enabled:
|
||||
@@ -2799,12 +2807,8 @@ definitions:
|
||||
type: string
|
||||
pages_project_id:
|
||||
type: integer
|
||||
primary_domain:
|
||||
type: string
|
||||
redirect_http:
|
||||
type: boolean
|
||||
remark:
|
||||
type: string
|
||||
site_name:
|
||||
type: string
|
||||
tunnel_id:
|
||||
@@ -2825,6 +2829,25 @@ definitions:
|
||||
type: string
|
||||
upstreams:
|
||||
type: string
|
||||
zone_domain_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
zone_domains:
|
||||
items:
|
||||
$ref: '#/definitions/proxy_route.ZoneDomainView'
|
||||
type: array
|
||||
type: object
|
||||
proxy_route.ZoneDomainView:
|
||||
properties:
|
||||
cert_id:
|
||||
type: integer
|
||||
domain:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
zone_id:
|
||||
type: integer
|
||||
type: object
|
||||
push.Config:
|
||||
properties:
|
||||
@@ -3425,43 +3448,6 @@ definitions:
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
tls.ManagedDomainInput:
|
||||
properties:
|
||||
cert_id:
|
||||
type: integer
|
||||
domain:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
remark:
|
||||
type: string
|
||||
type: object
|
||||
tls.ManagedDomainMatchCandidate:
|
||||
properties:
|
||||
certificate_id:
|
||||
type: integer
|
||||
certificate_name:
|
||||
type: string
|
||||
domain:
|
||||
type: string
|
||||
managed_domain_id:
|
||||
type: integer
|
||||
match_type:
|
||||
type: string
|
||||
type: object
|
||||
tls.ManagedDomainMatchResult:
|
||||
properties:
|
||||
candidate:
|
||||
$ref: '#/definitions/tls.ManagedDomainMatchCandidate'
|
||||
candidates:
|
||||
items:
|
||||
$ref: '#/definitions/tls.ManagedDomainMatchCandidate'
|
||||
type: array
|
||||
domain:
|
||||
type: string
|
||||
matched:
|
||||
type: boolean
|
||||
type: object
|
||||
updater.Status:
|
||||
properties:
|
||||
asset_name:
|
||||
@@ -3705,8 +3691,6 @@ definitions:
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
remark:
|
||||
type: string
|
||||
subscription_format:
|
||||
type: string
|
||||
subscription_mapping_rule:
|
||||
@@ -3765,8 +3749,6 @@ definitions:
|
||||
type: string
|
||||
referenced_by_rule_count:
|
||||
type: integer
|
||||
remark:
|
||||
type: string
|
||||
subscription_format:
|
||||
type: string
|
||||
subscription_mapping_rule:
|
||||
@@ -3866,8 +3848,6 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
remark:
|
||||
type: string
|
||||
type: object
|
||||
waf.RuleGroupView:
|
||||
properties:
|
||||
@@ -3927,8 +3907,6 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
remark:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
@@ -3951,6 +3929,88 @@ definitions:
|
||||
$ref: '#/definitions/waf.RuleGroupView'
|
||||
type: array
|
||||
type: object
|
||||
zone.DomainInput:
|
||||
properties:
|
||||
cert_id:
|
||||
type: integer
|
||||
domain:
|
||||
type: string
|
||||
type: object
|
||||
zone.Input:
|
||||
properties:
|
||||
domain:
|
||||
type: string
|
||||
type: object
|
||||
zone.ListItem:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
domain:
|
||||
type: string
|
||||
domain_count:
|
||||
type: integer
|
||||
id:
|
||||
type: integer
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
zone.Overview:
|
||||
properties:
|
||||
domains:
|
||||
items:
|
||||
$ref: '#/definitions/model.ZoneDomain'
|
||||
type: array
|
||||
zone:
|
||||
$ref: '#/definitions/model.Zone'
|
||||
type: object
|
||||
zone.Stats:
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
bucket_minutes:
|
||||
type: integer
|
||||
bytes_sent:
|
||||
type: integer
|
||||
domain_count:
|
||||
type: integer
|
||||
range:
|
||||
$ref: '#/definitions/zone.StatsRange'
|
||||
range_hours:
|
||||
type: integer
|
||||
request_count:
|
||||
type: integer
|
||||
series:
|
||||
items:
|
||||
$ref: '#/definitions/zone.StatsPoint'
|
||||
type: array
|
||||
unique_visitors:
|
||||
type: integer
|
||||
window_ended_at:
|
||||
type: string
|
||||
window_started_at:
|
||||
type: string
|
||||
type: object
|
||||
zone.StatsPoint:
|
||||
properties:
|
||||
bucket_started_at:
|
||||
type: string
|
||||
bytes_sent:
|
||||
type: integer
|
||||
request_count:
|
||||
type: integer
|
||||
unique_visitors:
|
||||
type: integer
|
||||
type: object
|
||||
zone.StatsRange:
|
||||
enum:
|
||||
- 24h
|
||||
- 7d
|
||||
- 30d
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- StatsRange24h
|
||||
- StatsRange7d
|
||||
- StatsRange30d
|
||||
info:
|
||||
contact:
|
||||
name: OpenFlare
|
||||
@@ -5062,7 +5122,7 @@ paths:
|
||||
- admin
|
||||
/api/v1/admin/status/clickhouse:
|
||||
get:
|
||||
description: 返回 ClickHouse parts、mutation、async_insert 队列等运维指标,需要管理员权限
|
||||
description: 返回 ClickHouse parts、mutation、async_insert 队列及进程内 batch writer 指标,需要管理员权限
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -7750,217 +7810,6 @@ paths:
|
||||
summary: 更新 DNS 账号
|
||||
tags:
|
||||
- openflare-tls
|
||||
/api/v1/d/managed-domains:
|
||||
get:
|
||||
description: 返回全部托管域名及关联证书,需要管理员权限
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 托管域名列表
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/model.ManagedDomain'
|
||||
type: array
|
||||
type: object
|
||||
"400":
|
||||
description: 参数错误
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: 无权限或不存在
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 列出托管域名
|
||||
tags:
|
||||
- openflare-tls
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 创建新的托管域名记录,需要管理员权限
|
||||
parameters:
|
||||
- description: 托管域名参数
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/tls.ManagedDomainInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 创建成功的托管域名
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/model.ManagedDomain'
|
||||
type: object
|
||||
"400":
|
||||
description: 参数错误
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: 无权限或不存在
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 创建托管域名
|
||||
tags:
|
||||
- openflare-tls
|
||||
/api/v1/d/managed-domains/{id}/delete:
|
||||
post:
|
||||
description: 按 ID 删除托管域名,需要管理员权限
|
||||
parameters:
|
||||
- description: 托管域名 ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 删除成功
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"400":
|
||||
description: 参数错误
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: 记录不存在
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 删除托管域名
|
||||
tags:
|
||||
- openflare-tls
|
||||
/api/v1/d/managed-domains/{id}/update:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 按 ID 更新托管域名,需要管理员权限
|
||||
parameters:
|
||||
- description: 托管域名 ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 托管域名参数
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/tls.ManagedDomainInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 更新后的托管域名
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/model.ManagedDomain'
|
||||
type: object
|
||||
"400":
|
||||
description: 参数错误
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: 记录不存在
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 更新托管域名
|
||||
tags:
|
||||
- openflare-tls
|
||||
/api/v1/d/managed-domains/match:
|
||||
get:
|
||||
description: 按域名查询可用的证书匹配候选,需要管理员权限
|
||||
parameters:
|
||||
- description: 域名
|
||||
in: query
|
||||
name: domain
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 证书匹配结果
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/tls.ManagedDomainMatchResult'
|
||||
type: object
|
||||
"400":
|
||||
description: 参数错误
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: 无权限或不存在
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 匹配托管域名证书
|
||||
tags:
|
||||
- openflare-tls
|
||||
/api/v1/d/nodes:
|
||||
get:
|
||||
description: 返回所有节点及最新配置下发记录,需要管理员权限
|
||||
@@ -10669,6 +10518,330 @@ paths:
|
||||
summary: 替换站点 WAF 规则组
|
||||
tags:
|
||||
- openflare-waf
|
||||
/api/v1/d/zones:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/zone.ListItem'
|
||||
type: array
|
||||
type: object
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 获取 Zone 列表
|
||||
tags:
|
||||
- openflare-zone
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Zone 参数
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/zone.Input'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/model.Zone'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"409":
|
||||
description: Conflict
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 创建 Zone
|
||||
tags:
|
||||
- openflare-zone
|
||||
/api/v1/d/zones/{id}/delete:
|
||||
post:
|
||||
parameters:
|
||||
- description: Zone ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 删除 Zone
|
||||
tags:
|
||||
- openflare-zone
|
||||
/api/v1/d/zones/{id}/domains:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Zone ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 域名参数
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/zone.DomainInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/model.ZoneDomain'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"409":
|
||||
description: Conflict
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 创建 Zone 域名
|
||||
tags:
|
||||
- openflare-zone
|
||||
/api/v1/d/zones/{id}/domains/{domainId}/delete:
|
||||
post:
|
||||
parameters:
|
||||
- description: Zone ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 域名 ID
|
||||
in: path
|
||||
name: domainId
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 删除 Zone 域名
|
||||
tags:
|
||||
- openflare-zone
|
||||
/api/v1/d/zones/{id}/domains/{domainId}/update:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Zone ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 域名 ID
|
||||
in: path
|
||||
name: domainId
|
||||
required: true
|
||||
type: integer
|
||||
- description: 域名参数
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/zone.DomainInput'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/model.ZoneDomain'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"409":
|
||||
description: Conflict
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 更新 Zone 域名
|
||||
tags:
|
||||
- openflare-zone
|
||||
/api/v1/d/zones/{id}/overview:
|
||||
get:
|
||||
parameters:
|
||||
- description: Zone ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/zone.Overview'
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 获取 Zone 概览
|
||||
tags:
|
||||
- openflare-zone
|
||||
/api/v1/d/zones/{id}/stats:
|
||||
get:
|
||||
description: 按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。
|
||||
parameters:
|
||||
- description: Zone ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: 时间范围:24h(默认)、7d、30d
|
||||
in: query
|
||||
name: range
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/zone.Stats'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 获取 Zone 流量统计
|
||||
tags:
|
||||
- openflare-zone
|
||||
/api/v1/d/zones/{id}/update:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Zone ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Zone 参数
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/zone.Input'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/response.Any'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/model.Zone'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
"409":
|
||||
description: Conflict
|
||||
schema:
|
||||
$ref: '#/definitions/response.Any'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 更新 Zone
|
||||
tags:
|
||||
- openflare-zone
|
||||
/api/v1/oauth/{source}/authorize:
|
||||
get:
|
||||
description: 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。
|
||||
|
||||
@@ -53,17 +53,17 @@ const cleanupTargets: Array<{
|
||||
{
|
||||
target: "node_access_logs",
|
||||
label: "访问日志",
|
||||
description: "清理 node_access_logs,影响访问明细与 IP 汇总。",
|
||||
description: "清理 node_access_logs,影响访问明细与 IP 汇总;表 TTL 为 90 天。",
|
||||
},
|
||||
{
|
||||
target: "node_metric_snapshots",
|
||||
label: "性能快照",
|
||||
description: "清理 node_metric_snapshots,影响节点资源趋势。",
|
||||
description: "清理 node_metric_snapshots,影响节点资源趋势;表 TTL 为 30 天。",
|
||||
},
|
||||
{
|
||||
target: "node_request_reports",
|
||||
label: "请求聚合",
|
||||
description: "清理 node_request_reports,影响请求量与错误量统计。",
|
||||
description: "清理 node_request_reports,影响请求量与错误量统计;表 TTL 为 30 天。",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -540,7 +540,9 @@ export function OpenFlareOpsSettings() {
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">数据库自动清理</CardTitle>
|
||||
<CardDescription>每天凌晨 3 点清理超出保留期的观测数据。</CardDescription>
|
||||
<CardDescription>
|
||||
每天凌晨 3 点物化 ClickHouse 表 TTL;访问日志至少保留 90 天,其它观测数据至少保留 30 天。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -556,19 +558,26 @@ export function OpenFlareOpsSettings() {
|
||||
checked={fields.database_auto_cleanup_enabled}
|
||||
onChange={(value) => updateField("database_auto_cleanup_enabled", value)}
|
||||
/>
|
||||
<FieldInput
|
||||
label="保留天数"
|
||||
value={fields.database_auto_cleanup_retention_days}
|
||||
type="number"
|
||||
onChange={(value) => updateField("database_auto_cleanup_retention_days", value)}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<FieldInput
|
||||
label="期望保留天数"
|
||||
value={fields.database_auto_cleanup_retention_days}
|
||||
type="number"
|
||||
onChange={(value) => updateField("database_auto_cleanup_retention_days", value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
小于表 TTL 时自动按下限执行:访问日志 90 天,其它观测数据 30 天。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">手动数据清理</CardTitle>
|
||||
<CardDescription>保留天数留空时将删除该类数据的全部历史记录。</CardDescription>
|
||||
<CardDescription>
|
||||
按表 TTL 清理;输入天数不能小于对应表 TTL,留空时将删除该类数据的全部历史记录。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{cleanupTargets.map((item) => (
|
||||
@@ -617,7 +626,7 @@ export function OpenFlareOpsSettings() {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清理{cleanupTarget?.label}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
输入保留天数后仅删除超出范围的数据;留空则删除全部历史记录,操作不可恢复。
|
||||
输入保留天数后会按该表 TTL 物化过期数据;小于表 TTL 的天数会被拒绝。留空则删除全部历史记录,操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<FieldInput
|
||||
|
||||
@@ -48,11 +48,13 @@ export function UptimeKumaSiteSelectModal({
|
||||
const routes = routesQuery.data ?? []
|
||||
const keyword = searchTerm.trim().toLowerCase()
|
||||
if (!keyword) return routes
|
||||
return routes.filter(
|
||||
(route) =>
|
||||
return routes.filter((route) => {
|
||||
const domains = (route.zone_domains ?? []).map((item) => item.domain).join(' ')
|
||||
return (
|
||||
route.site_name.toLowerCase().includes(keyword) ||
|
||||
route.primary_domain.toLowerCase().includes(keyword),
|
||||
)
|
||||
domains.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
}, [routesQuery.data, searchTerm])
|
||||
|
||||
const toggleSite = (siteName: string) => {
|
||||
@@ -147,7 +149,9 @@ export function UptimeKumaSiteSelectModal({
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium">{route.site_name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{route.primary_domain}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{(route.zone_domains ?? []).map((item) => item.domain).join(', ') || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function DashboardStatCards({
|
||||
{formatCompactNumber(traffic.request_count)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
独立访客 {formatCompactNumber(traffic.unique_visitors)} · 错误{' '}
|
||||
窗口UV(估) {formatCompactNumber(traffic.unique_visitors)} · 错误{' '}
|
||||
{formatCompactNumber(traffic.error_count)} · 估算 QPS{' '}
|
||||
{traffic.estimated_qps.toFixed(2)}
|
||||
</p>
|
||||
|
||||
@@ -661,7 +661,7 @@ export function NodeObservability({
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{trafficSummary
|
||||
? `近 60 秒 · UV ${formatMetricCount(trafficSummary.unique_visitor_count)}`
|
||||
? `近 60 秒 · 窗口UV ${formatMetricCount(trafficSummary.unique_visitor_count)}`
|
||||
: '暂无窗口流量摘要'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,15 @@ import type {
|
||||
ProxyRouteCustomHeader,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
ZoneDomainItem,
|
||||
} from '@/lib/services/openflare';
|
||||
import {ZoneService} from '@/lib/services/openflare';
|
||||
|
||||
export const proxyRouteConfigSections = [
|
||||
{
|
||||
key: 'domains' as const,
|
||||
label: '域名设置',
|
||||
description: '维护站点标识、域名列表和证书绑定。',
|
||||
description: '维护站点标识,并从 Zone 中选择绑定域名。',
|
||||
},
|
||||
{
|
||||
key: 'limits' as const,
|
||||
@@ -77,21 +79,50 @@ export function parseOriginUrl(originUrl: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export function getUpstreamSummary(route: ProxyRouteItem): string {
|
||||
/** Domain FQDNs bound to a proxy route (from zone_domains). */
|
||||
export function getRouteDomainNames(route: ProxyRouteItem): string[] {
|
||||
return (route.zone_domains ?? []).map((item) => item.domain).filter(Boolean);
|
||||
}
|
||||
|
||||
export function getRoutePrimaryDomain(route: ProxyRouteItem): string {
|
||||
return getRouteDomainNames(route)[0] ?? '';
|
||||
}
|
||||
|
||||
export function getRouteDomainsLabel(route: ProxyRouteItem): string {
|
||||
const names = getRouteDomainNames(route);
|
||||
return names.length > 0 ? names.join(', ') : '未绑定域名';
|
||||
}
|
||||
|
||||
/** Upstream address labels for display (supports multi-upstream). */
|
||||
export function getUpstreamLabels(route: ProxyRouteItem): string[] {
|
||||
if (route.upstream_type === 'pages') {
|
||||
return route.pages_project_id
|
||||
? `Pages 项目 #${route.pages_project_id}`
|
||||
: 'Pages 项目未绑定';
|
||||
return [
|
||||
route.pages_project_id
|
||||
? `Pages 项目 #${route.pages_project_id}`
|
||||
: 'Pages 项目未绑定',
|
||||
];
|
||||
}
|
||||
if (route.upstream_type === 'tunnel') {
|
||||
const protocol = route.tunnel_target_protocol || 'http';
|
||||
const target = route.tunnel_target_addr || '未配置目标';
|
||||
return `Tunnel → ${protocol}://${target}`;
|
||||
return [`Tunnel → ${protocol}://${target}`];
|
||||
}
|
||||
if (route.upstream_list.length <= 1) {
|
||||
return route.origin_url;
|
||||
const list = (route.upstream_list ?? []).filter(Boolean);
|
||||
if (list.length > 0) {
|
||||
return list;
|
||||
}
|
||||
return `${route.upstream_list.length} 个上游,主上游 ${route.origin_url}`;
|
||||
return route.origin_url ? [route.origin_url] : [];
|
||||
}
|
||||
|
||||
export function getUpstreamSummary(route: ProxyRouteItem): string {
|
||||
const labels = getUpstreamLabels(route);
|
||||
if (labels.length === 0) {
|
||||
return '未配置上游';
|
||||
}
|
||||
if (labels.length === 1) {
|
||||
return labels[0];
|
||||
}
|
||||
return labels.join(' · ');
|
||||
}
|
||||
|
||||
const originHostPattern =
|
||||
@@ -284,12 +315,11 @@ export function buildPayloadFromRoute(
|
||||
const primaryOrigin =
|
||||
route.upstream_type === 'pages'
|
||||
? parseOriginUrl('http://127.0.0.1')
|
||||
: parseOriginUrl(route.origin_url);
|
||||
: parseOriginUrl(route.origin_url || 'http://127.0.0.1');
|
||||
|
||||
return {
|
||||
site_name: route.site_name,
|
||||
domain: route.primary_domain,
|
||||
domains: route.domains,
|
||||
zone_domain_ids: route.zone_domain_ids ?? [],
|
||||
origin_id: null,
|
||||
origin_url: route.origin_url,
|
||||
origin_scheme: primaryOrigin.scheme,
|
||||
@@ -297,21 +327,17 @@ export function buildPayloadFromRoute(
|
||||
origin_port: primaryOrigin.port,
|
||||
origin_uri: primaryOrigin.uri,
|
||||
origin_host: route.origin_host || '',
|
||||
upstreams: route.upstream_list.slice(1),
|
||||
upstreams: (route.upstream_list ?? []).slice(1),
|
||||
enabled: route.enabled,
|
||||
enable_https: route.enable_https,
|
||||
cert_id: route.cert_id,
|
||||
cert_ids: route.cert_ids,
|
||||
domain_cert_ids: route.domain_cert_ids,
|
||||
redirect_http: route.redirect_http,
|
||||
limit_conn_per_server: route.limit_conn_per_server,
|
||||
limit_conn_per_ip: route.limit_conn_per_ip,
|
||||
limit_rate: route.limit_rate,
|
||||
cache_enabled: route.cache_enabled,
|
||||
cache_policy: route.cache_policy || 'url',
|
||||
cache_rules: route.cache_rule_list,
|
||||
custom_headers: route.custom_header_list,
|
||||
remark: route.remark || '',
|
||||
cache_rules: route.cache_rule_list ?? [],
|
||||
custom_headers: route.custom_header_list ?? [],
|
||||
basic_auth_enabled: route.basic_auth_enabled,
|
||||
basic_auth_username: route.basic_auth_username,
|
||||
basic_auth_password: route.basic_auth_password,
|
||||
@@ -323,3 +349,10 @@ export function buildPayloadFromRoute(
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Load all Zone domains across registered Zones for route binding selectors. */
|
||||
export async function listAllZoneDomains(): Promise<ZoneDomainItem[]> {
|
||||
const zones = await ZoneService.list();
|
||||
const overviews = await Promise.all(zones.map((zone) => ZoneService.getOverview(zone.id)));
|
||||
return overviews.flatMap((overview) => overview.domains ?? []);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
@@ -10,30 +11,20 @@ import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, Form
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from '@/components/ui/sheet';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import type {ProxyRouteItem} from '@/lib/services/openflare';
|
||||
import {ProxyRouteService} from '@/lib/services/openflare';
|
||||
import {ProxyRouteService, ZoneService, zoneQueryKey} from '@/lib/services/openflare';
|
||||
|
||||
import {parseOriginUrl, validateDomain} from './helpers';
|
||||
import {listAllZoneDomains, parseOriginUrl} from './helpers';
|
||||
import {ZoneDomainSelector} from './zone-domain-selector';
|
||||
|
||||
const createProxyRouteSchema = z
|
||||
.object({
|
||||
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
||||
domain: z.string().trim().min(1, '请输入域名'),
|
||||
zone_domain_ids: z.array(z.number().int().positive()).min(1, '请至少选择一个域名'),
|
||||
origin_url: z.string().trim().min(1, '请输入上游地址'),
|
||||
enabled: z.boolean(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const domainError = validateDomain(value.domain);
|
||||
if (domainError) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['domain'],
|
||||
message: domainError,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value.origin_url);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
@@ -56,10 +47,9 @@ type CreateProxyRouteFormValues = z.infer<typeof createProxyRouteSchema>;
|
||||
|
||||
const defaultValues: CreateProxyRouteFormValues = {
|
||||
site_name: '',
|
||||
domain: '',
|
||||
zone_domain_ids: [],
|
||||
origin_url: '',
|
||||
enabled: true,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
interface ProxyRouteCreateSheetProps {
|
||||
@@ -78,6 +68,18 @@ export function ProxyRouteCreateSheet({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const zonesQuery = useQuery({
|
||||
queryKey: zoneQueryKey,
|
||||
queryFn: () => ZoneService.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: [...zoneQueryKey, 'all-domains'],
|
||||
queryFn: () => listAllZoneDomains(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset(defaultValues);
|
||||
@@ -85,14 +87,17 @@ export function ProxyRouteCreateSheet({
|
||||
}, [form, open]);
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
const domain = values.domain.trim().toLowerCase();
|
||||
const origin = parseOriginUrl(values.origin_url.trim());
|
||||
const selectedDomains = (domainsQuery.data ?? []).filter((domain) =>
|
||||
values.zone_domain_ids.includes(domain.id),
|
||||
);
|
||||
const primaryDomain = selectedDomains[0]?.domain ?? '';
|
||||
const hasCert = selectedDomains.some((domain) => domain.cert_id != null);
|
||||
|
||||
try {
|
||||
const route = await ProxyRouteService.create({
|
||||
site_name: values.site_name.trim() || domain,
|
||||
domain,
|
||||
domains: [domain],
|
||||
site_name: values.site_name.trim() || primaryDomain,
|
||||
zone_domain_ids: values.zone_domain_ids,
|
||||
origin_id: null,
|
||||
origin_url: values.origin_url.trim(),
|
||||
origin_scheme: origin.scheme,
|
||||
@@ -102,10 +107,7 @@ export function ProxyRouteCreateSheet({
|
||||
origin_host: '',
|
||||
upstreams: [],
|
||||
enabled: values.enabled,
|
||||
enable_https: false,
|
||||
cert_id: null,
|
||||
cert_ids: [],
|
||||
domain_cert_ids: [0],
|
||||
enable_https: hasCert,
|
||||
redirect_http: false,
|
||||
limit_conn_per_server: 0,
|
||||
limit_conn_per_ip: 0,
|
||||
@@ -115,7 +117,6 @@ export function ProxyRouteCreateSheet({
|
||||
cache_rules: [],
|
||||
custom_headers: [],
|
||||
basic_auth_enabled: false,
|
||||
remark: values.remark.trim(),
|
||||
upstream_type: 'direct',
|
||||
});
|
||||
|
||||
@@ -135,7 +136,7 @@ export function ProxyRouteCreateSheet({
|
||||
<SheetHeader>
|
||||
<SheetTitle>新建规则</SheetTitle>
|
||||
<SheetDescription>
|
||||
创建网站后可进入详情页继续配置 HTTPS、缓存和限流等高级选项。
|
||||
从已注册的 Zone 域名中选择绑定关系,创建后可继续配置缓存和限流等高级选项。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -150,7 +151,7 @@ export function ProxyRouteCreateSheet({
|
||||
<FormControl>
|
||||
<Input placeholder="marketing-site" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>可选,留空时会自动使用域名。</FormDescription>
|
||||
<FormDescription>可选,留空时会自动使用首个域名。</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -158,13 +159,25 @@ export function ProxyRouteCreateSheet({
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
name="zone_domain_ids"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>主域名</FormLabel>
|
||||
<FormLabel>绑定域名</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="www.example.com" {...field} />
|
||||
<ZoneDomainSelector
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
domains={domainsQuery.data ?? []}
|
||||
zones={zonesQuery.data ?? []}
|
||||
disabled={domainsQuery.isLoading}
|
||||
onDomainCreated={async () => {
|
||||
await domainsQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
勾选已登记域名,或使用「快捷新增域名」创建后自动勾选。
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -177,9 +190,8 @@ export function ProxyRouteCreateSheet({
|
||||
<FormItem>
|
||||
<FormLabel>上游地址</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://origin.internal:443" {...field} />
|
||||
<Input placeholder="http://127.0.0.1:8080" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>主回源完整 URL,创建后可在详情页添加多上游。</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -191,8 +203,8 @@ export function ProxyRouteCreateSheet({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>创建后立即启用</FormLabel>
|
||||
<FormDescription>关闭后站点会以草稿保存。</FormDescription>
|
||||
<FormLabel>启用站点</FormLabel>
|
||||
<FormDescription>关闭后会保留配置,但不会参与发布。</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
@@ -201,27 +213,16 @@ export function ProxyRouteCreateSheet({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remark"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={3} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.formState.errors.root?.message ? (
|
||||
{form.formState.errors.root ? (
|
||||
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
|
||||
) : null}
|
||||
|
||||
<SheetFooter className="px-0">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? '创建中...' : '创建'}
|
||||
{form.formState.isSubmitting ? '创建中…' : '创建'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import Link from 'next/link';
|
||||
import {ExternalLink, Plus} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {cn} from '@/lib/utils';
|
||||
import type {ZoneDomainItem, ZoneItem} from '@/lib/services/openflare';
|
||||
|
||||
import {QuickCreateZoneDomainDialog} from '../../websites/components/quick-create-zone-domain-dialog';
|
||||
|
||||
export interface ZoneDomainSelectorProps {
|
||||
value: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
domains: ZoneDomainItem[];
|
||||
zones?: ZoneItem[];
|
||||
/** Current route ID: domains bound to this route remain selectable. */
|
||||
currentRouteId?: number | null;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** Called after a domain is created so parent can refresh the catalog. */
|
||||
onDomainCreated?: (domain: ZoneDomainItem) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ZoneDomainSelector({
|
||||
value,
|
||||
onChange,
|
||||
domains,
|
||||
zones = [],
|
||||
currentRouteId = null,
|
||||
disabled = false,
|
||||
className,
|
||||
onDomainCreated,
|
||||
}: ZoneDomainSelectorProps) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const zoneMap = useMemo(
|
||||
() => new Map(zones.map((zone) => [zone.id, zone.domain])),
|
||||
[zones],
|
||||
);
|
||||
|
||||
/** Hide domains already bound to another route; keep unbound + current route. */
|
||||
const availableDomains = useMemo(
|
||||
() =>
|
||||
domains.filter(
|
||||
(domain) =>
|
||||
domain.proxy_route_id == null || domain.proxy_route_id === currentRouteId,
|
||||
),
|
||||
[currentRouteId, domains],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
const list = [...availableDomains].sort((a, b) =>
|
||||
a.domain.localeCompare(b.domain),
|
||||
);
|
||||
if (!normalized) {
|
||||
return list;
|
||||
}
|
||||
return list.filter((domain) => {
|
||||
const zoneRoot = zoneMap.get(domain.zone_id) ?? '';
|
||||
return (
|
||||
domain.domain.toLowerCase().includes(normalized) ||
|
||||
zoneRoot.toLowerCase().includes(normalized) ||
|
||||
String(domain.id).includes(normalized)
|
||||
);
|
||||
});
|
||||
}, [availableDomains, keyword, zoneMap]);
|
||||
|
||||
const toggle = (domain: ZoneDomainItem) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
domain.proxy_route_id != null &&
|
||||
domain.proxy_route_id !== currentRouteId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.has(domain.id)) {
|
||||
onChange(value.filter((id) => id !== domain.id));
|
||||
return;
|
||||
}
|
||||
onChange([...value, domain.id].sort((a, b) => a - b));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索域名或 Zone…"
|
||||
disabled={disabled}
|
||||
className="sm:flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
快捷新增域名
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{domains.length === 0 ? (
|
||||
<>
|
||||
暂无可用 Zone 域名。请先在{' '}
|
||||
<Link href="/websites" className="text-primary underline-offset-4 hover:underline">
|
||||
网站管理
|
||||
</Link>{' '}
|
||||
中添加 FQDN,或使用「快捷新增域名」。
|
||||
</>
|
||||
) : availableDomains.length === 0 ? (
|
||||
'没有可绑定的域名(其余域名已绑定其他路由)。可用「快捷新增域名」创建。'
|
||||
) : (
|
||||
'没有匹配的域名'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2">
|
||||
{filtered.map((domain) => {
|
||||
const checked = selected.has(domain.id);
|
||||
const zoneRoot = zoneMap.get(domain.zone_id);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={domain.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 transition-colors hover:bg-muted/60',
|
||||
checked && 'bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => toggle(domain)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{domain.domain}</span>
|
||||
{domain.cert_id ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
证书 #{domain.cert_id}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
无证书
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{zoneRoot ? (
|
||||
<Link
|
||||
href={`/websites/${domain.zone_id}`}
|
||||
className="inline-flex items-center gap-0.5 hover:text-foreground"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
Zone {zoneRoot}
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span>Zone #{domain.zone_id}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<QuickCreateZoneDomainDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
zones={zones}
|
||||
onCreated={async (domain) => {
|
||||
// Auto-select the new domain for this route binding.
|
||||
if (!value.includes(domain.id)) {
|
||||
onChange([...value, domain.id].sort((a, b) => a - b));
|
||||
}
|
||||
await onDomainCreated?.(domain);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useId, useMemo} from 'react';
|
||||
import {Link2, Minus, Plus} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
import type {TlsCertificateItem} from '@/lib/services/openflare';
|
||||
import {WebsiteService} from '@/lib/services/openflare';
|
||||
import {validateDomain} from '../../components/helpers';
|
||||
import type {DomainListRow} from '../helpers';
|
||||
|
||||
function ensureRows(rows: DomainListRow[]) {
|
||||
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
|
||||
}
|
||||
|
||||
function buildSuggestionSources(domains: string[]) {
|
||||
const values = new Set<string>();
|
||||
|
||||
for (const domain of domains) {
|
||||
const normalized = domain.trim().toLowerCase().replace(/^\*\./, '');
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
values.add(normalized);
|
||||
|
||||
const segments = normalized.split('.');
|
||||
for (let index = 1; index < segments.length - 1; index += 1) {
|
||||
values.add(segments.slice(index).join('.'));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(values);
|
||||
}
|
||||
|
||||
function buildDomainSuggestions(input: string, sources: string[], rows: DomainListRow[]) {
|
||||
const normalizedInput = input.trim().toLowerCase();
|
||||
if (!normalizedInput) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingDomains = new Set(
|
||||
rows
|
||||
.map((row) => row.domain.trim().toLowerCase())
|
||||
.filter((row) => row && row !== normalizedInput),
|
||||
);
|
||||
const suggestions: string[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
if (source.startsWith(normalizedInput) && source !== normalizedInput) {
|
||||
suggestions.push(source);
|
||||
}
|
||||
|
||||
const separatorIndex = normalizedInput.lastIndexOf('.');
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prefix = normalizedInput.slice(0, separatorIndex);
|
||||
const suffixInput = normalizedInput.slice(separatorIndex + 1);
|
||||
|
||||
if (!suffixInput || source.startsWith(suffixInput)) {
|
||||
suggestions.push(`${prefix}.${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions.filter((suggestion, index) => {
|
||||
return (
|
||||
suggestion !== normalizedInput &&
|
||||
!existingDomains.has(suggestion) &&
|
||||
suggestions.indexOf(suggestion) === index
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
interface DomainListInputProps {
|
||||
rows: DomainListRow[];
|
||||
onChange: (rows: DomainListRow[]) => void;
|
||||
onBlur?: () => void;
|
||||
suggestionSources?: string[];
|
||||
certificates?: TlsCertificateItem[];
|
||||
domainPlaceholder?: string;
|
||||
}
|
||||
|
||||
export function DomainListInput({
|
||||
rows,
|
||||
onChange,
|
||||
onBlur,
|
||||
suggestionSources = [],
|
||||
certificates = [],
|
||||
domainPlaceholder = 'app.example.com',
|
||||
}: DomainListInputProps) {
|
||||
const listId = useId();
|
||||
const safeRows = ensureRows(rows);
|
||||
const normalizedSources = useMemo(
|
||||
() => buildSuggestionSources(suggestionSources),
|
||||
[suggestionSources],
|
||||
);
|
||||
|
||||
const updateRows = (nextRows: DomainListRow[]) => {
|
||||
onChange(ensureRows(nextRows));
|
||||
};
|
||||
|
||||
const applyManagedDomainCertificate = async (index: number, domain: string) => {
|
||||
const normalized = domain.trim().toLowerCase();
|
||||
if (!normalized || validateDomain(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await WebsiteService.match(normalized);
|
||||
if (!result.matched || !result.candidate?.certificate_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRows = safeRows.slice();
|
||||
if (!nextRows[index].certificateId) {
|
||||
nextRows[index] = {
|
||||
...nextRows[index],
|
||||
certificateId: String(result.candidate.certificate_id),
|
||||
};
|
||||
updateRows(nextRows);
|
||||
}
|
||||
} catch {
|
||||
// Ignore match failures; user can still pick a certificate manually.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{safeRows.map((row, index) => {
|
||||
const suggestions = buildDomainSuggestions(row.domain, normalizedSources, safeRows).slice(
|
||||
0,
|
||||
4,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={`${index}-${safeRows.length}`} className="space-y-2">
|
||||
<div className="grid gap-3 md:grid-cols-[40px_minmax(0,1fr)_220px] md:items-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9 shrink-0"
|
||||
disabled={safeRows.length === 1}
|
||||
aria-label={`删除域名输入框 ${index + 1}`}
|
||||
onClick={() => {
|
||||
if (safeRows.length === 1) {
|
||||
updateRows([{ domain: '', certificateId: '' }]);
|
||||
return;
|
||||
}
|
||||
updateRows(safeRows.filter((_, rowIndex) => rowIndex !== index));
|
||||
}}
|
||||
>
|
||||
<Minus className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Input
|
||||
value={row.domain}
|
||||
list={`${listId}-${index}`}
|
||||
aria-label={`域名 ${index + 1}`}
|
||||
placeholder={index === 0 ? domainPlaceholder : 'www.example.com'}
|
||||
onBlur={() => {
|
||||
onBlur?.();
|
||||
void applyManagedDomainCertificate(index, row.domain);
|
||||
}}
|
||||
onChange={(event) => {
|
||||
const nextRows = safeRows.slice();
|
||||
nextRows[index] = {
|
||||
...nextRows[index],
|
||||
domain: event.target.value,
|
||||
};
|
||||
updateRows(nextRows);
|
||||
}}
|
||||
/>
|
||||
<datalist id={`${listId}-${index}`}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<option key={suggestion} value={suggestion} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{suggestions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="rounded-full border px-2.5 py-0.5 text-[11px] text-muted-foreground transition hover:border-primary hover:text-foreground"
|
||||
onClick={() => {
|
||||
const nextRows = safeRows.slice();
|
||||
nextRows[index] = {
|
||||
...nextRows[index],
|
||||
domain: suggestion,
|
||||
};
|
||||
updateRows(nextRows);
|
||||
void applyManagedDomainCertificate(index, suggestion);
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={row.certificateId || 'none'}
|
||||
onValueChange={(value) => {
|
||||
const nextRows = safeRows.slice();
|
||||
nextRows[index] = {
|
||||
...nextRows[index],
|
||||
certificateId: value === 'none' ? '' : value,
|
||||
};
|
||||
updateRows(nextRows);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue
|
||||
placeholder={certificates.length === 0 ? '暂无可选证书' : '选择证书'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{certificates.length === 0 ? '暂无可选证书' : '不绑定证书'}
|
||||
</SelectItem>
|
||||
{certificates.map((certificate) => (
|
||||
<SelectItem key={certificate.id} value={String(certificate.id)}>
|
||||
{certificate.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={() => {
|
||||
updateRows([...safeRows, { domain: '', certificateId: '' }]);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
添加域名
|
||||
</Button>
|
||||
<Badge variant="outline" className="gap-1 text-[10px] font-normal">
|
||||
<Link2 className="size-3" />
|
||||
输入域名后将自动匹配托管域名证书
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,17 +10,12 @@ import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, Form
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import type {ProxyRouteItem} from '@/lib/services/openflare';
|
||||
import {TlsCertificateService, WebsiteService} from '@/lib/services/openflare';
|
||||
import {ZoneService, zoneQueryKey} from '@/lib/services/openflare';
|
||||
|
||||
import {validateDomains} from '../../components/helpers';
|
||||
import {
|
||||
buildDomainCertificateIDs,
|
||||
buildDomainRows,
|
||||
normalizeSelectedCertificateIDs,
|
||||
proxyRouteFormIds,
|
||||
} from '../helpers';
|
||||
import {listAllZoneDomains} from '../../components/helpers';
|
||||
import {ZoneDomainSelector} from '../../components/zone-domain-selector';
|
||||
import {proxyRouteFormIds} from '../helpers';
|
||||
import {useRouteSectionSave} from '../hooks/use-route-section-save';
|
||||
import {DomainListInput} from './domain-list-input';
|
||||
import {SectionShell} from './section-shell';
|
||||
|
||||
const domainSettingsSchema = z
|
||||
@@ -30,42 +25,9 @@ const domainSettingsSchema = z
|
||||
.trim()
|
||||
.min(1, '请输入站点标识')
|
||||
.max(255, '站点标识不能超过 255 个字符'),
|
||||
domain_rows: z
|
||||
.array(
|
||||
z.object({
|
||||
domain: z.string(),
|
||||
certificateId: z.string(),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
zone_domain_ids: z.array(z.number().int().positive()).min(1, '请至少选择一个域名'),
|
||||
enabled: z.boolean(),
|
||||
redirect_http: z.boolean(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const domains = value.domain_rows
|
||||
.map((item) => item.domain.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const error = validateDomains(domains);
|
||||
if (error) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['domain_rows'],
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
|
||||
const selectedCertificateCount = new Set(
|
||||
value.domain_rows
|
||||
.map((item) => Number(item.certificateId))
|
||||
.filter((item) => Number.isFinite(item) && item > 0),
|
||||
).size;
|
||||
if (value.redirect_http && selectedCertificateCount === 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['redirect_http'],
|
||||
message: '启用 HTTP 跳转前,请先为域名选择证书',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type DomainSettingsValues = z.infer<typeof domainSettingsSchema>;
|
||||
@@ -79,21 +41,21 @@ interface DomainSectionProps {
|
||||
export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSectionProps) {
|
||||
const { saving, save } = useRouteSectionSave(route, onRouteUpdate, onSavingChange);
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: ['openflare', 'tls-certificates'],
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
const zonesQuery = useQuery({
|
||||
queryKey: zoneQueryKey,
|
||||
queryFn: () => ZoneService.list(),
|
||||
});
|
||||
|
||||
const managedDomainsQuery = useQuery({
|
||||
queryKey: ['openflare', 'managed-domains'],
|
||||
queryFn: () => WebsiteService.list(),
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: [...zoneQueryKey, 'all-domains'],
|
||||
queryFn: () => listAllZoneDomains(),
|
||||
});
|
||||
|
||||
const form = useForm<DomainSettingsValues>({
|
||||
resolver: zodResolver(domainSettingsSchema),
|
||||
defaultValues: {
|
||||
site_name: route.site_name,
|
||||
domain_rows: buildDomainRows(route),
|
||||
zone_domain_ids: route.zone_domain_ids ?? [],
|
||||
enabled: route.enabled,
|
||||
redirect_http: route.redirect_http,
|
||||
},
|
||||
@@ -102,26 +64,46 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
site_name: route.site_name,
|
||||
domain_rows: buildDomainRows(route),
|
||||
zone_domain_ids: route.zone_domain_ids ?? [],
|
||||
enabled: route.enabled,
|
||||
redirect_http: route.redirect_http,
|
||||
});
|
||||
}, [form, route]);
|
||||
|
||||
const domainSuggestionSources = useMemo(
|
||||
() => [
|
||||
...(route.domains ?? []),
|
||||
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
|
||||
],
|
||||
[managedDomainsQuery.data, route.domains],
|
||||
);
|
||||
const selectedIDs = form.watch('zone_domain_ids');
|
||||
const selectedDomains = useMemo(() => {
|
||||
const fromApi = domainsQuery.data ?? [];
|
||||
const byId = new Map(fromApi.map((domain) => [domain.id, domain]));
|
||||
// Prefer live catalog; fall back to route-bound domains for display before catalog loads.
|
||||
return selectedIDs
|
||||
.map((id) => {
|
||||
const catalog = byId.get(id);
|
||||
if (catalog) {
|
||||
return catalog;
|
||||
}
|
||||
const bound = (route.zone_domains ?? []).find((item) => item.id === id);
|
||||
if (!bound) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: bound.id,
|
||||
zone_id: bound.zone_id,
|
||||
proxy_route_id: route.id,
|
||||
domain: bound.domain,
|
||||
cert_id: bound.cert_id,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item != null);
|
||||
}, [domainsQuery.data, route.id, route.zone_domains, selectedIDs]);
|
||||
|
||||
const selectedCertificateIDs = normalizeSelectedCertificateIDs(form.watch('domain_rows'));
|
||||
const hasCertificate = selectedDomains.some((domain) => domain.cert_id != null);
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="域名设置"
|
||||
description="在一个列表里同时维护域名、证书和 HTTPS 跳转。保存时会自动汇总站点证书集合。"
|
||||
description="绑定已在 Zone 中注册的 FQDN。证书请在 Zone 域名管理中维护。"
|
||||
formId={proxyRouteFormIds.domains}
|
||||
saving={saving}
|
||||
>
|
||||
@@ -130,23 +112,20 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
|
||||
id={proxyRouteFormIds.domains}
|
||||
className="space-y-5"
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
const domains = values.domain_rows
|
||||
.map((item) => item.domain.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
|
||||
const certIDs = normalizeSelectedCertificateIDs(values.domain_rows);
|
||||
if (values.redirect_http && !hasCertificate) {
|
||||
form.setError('redirect_http', {
|
||||
message: '启用 HTTP 跳转前,请先为所选域名绑定证书(在 Zone 中配置)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await save(
|
||||
{
|
||||
site_name: values.site_name.trim(),
|
||||
domain: domains[0],
|
||||
domains,
|
||||
zone_domain_ids: values.zone_domain_ids,
|
||||
enabled: values.enabled,
|
||||
enable_https: certIDs.length > 0,
|
||||
cert_id: certIDs[0] ?? null,
|
||||
cert_ids: certIDs,
|
||||
domain_cert_ids: domainCertIDs,
|
||||
redirect_http: certIDs.length > 0 ? values.redirect_http : false,
|
||||
enable_https: hasCertificate,
|
||||
redirect_http: hasCertificate ? values.redirect_http : false,
|
||||
},
|
||||
'域名设置已保存',
|
||||
);
|
||||
@@ -185,21 +164,26 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain_rows"
|
||||
name="zone_domain_ids"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>域名列表</FormLabel>
|
||||
<FormLabel>绑定域名</FormLabel>
|
||||
<FormControl>
|
||||
<DomainListInput
|
||||
rows={field.value}
|
||||
<ZoneDomainSelector
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
suggestionSources={domainSuggestionSources}
|
||||
certificates={certificatesQuery.data ?? []}
|
||||
domains={domainsQuery.data ?? []}
|
||||
zones={zonesQuery.data ?? []}
|
||||
currentRouteId={route.id}
|
||||
disabled={domainsQuery.isLoading}
|
||||
onDomainCreated={async () => {
|
||||
await domainsQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
每行配置一个域名。可为不同域名选择不同证书,托管域名将自动匹配证书。
|
||||
从已登记域名中勾选绑定;可用「快捷新增域名」在 Zone 下创建 FQDN
|
||||
并自动勾选。
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -214,15 +198,15 @@ export function DomainSection({ route, onRouteUpdate, onSavingChange }: DomainSe
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>HTTP 自动跳转到 HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
{selectedCertificateIDs.length > 0
|
||||
{hasCertificate
|
||||
? '开启后会额外生成 80 端口重定向规则。'
|
||||
: '至少为一个域名选择证书后才能启用。'}
|
||||
: '所选域名至少绑定一张证书后才能启用。'}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
disabled={selectedCertificateIDs.length === 0}
|
||||
disabled={!hasCertificate}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -35,7 +35,6 @@ const reverseProxySchema = z
|
||||
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||
pages_project_id: z.string().optional(),
|
||||
custom_headers_text: z.string(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.upstream_type === 'direct') {
|
||||
@@ -137,7 +136,6 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
|
||||
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
||||
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
|
||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||
remark: route.remark || '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,7 +150,6 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
|
||||
(route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
||||
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
|
||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||
remark: route.remark || '',
|
||||
});
|
||||
}, [form, route]);
|
||||
|
||||
@@ -210,7 +207,6 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
|
||||
origin_host: values.origin_host.trim(),
|
||||
upstreams,
|
||||
custom_headers: headers,
|
||||
remark: values.remark.trim(),
|
||||
upstream_type: values.upstream_type,
|
||||
tunnel_node_id:
|
||||
values.upstream_type === 'tunnel' && values.tunnel_id
|
||||
@@ -417,20 +413,6 @@ export function ProxySection({ route, onRouteUpdate, onSavingChange }: ProxySect
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remark"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="例如:多活回源,优先使用上海入口" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SectionShell>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useCallback, useState} from 'react';
|
||||
import {ArrowLeft, Loader2, Route, Upload} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
@@ -41,6 +41,7 @@ interface RouteHeaderProps {
|
||||
}
|
||||
|
||||
export function RouteHeader({ route }: RouteHeaderProps) {
|
||||
const router = useRouter();
|
||||
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
|
||||
const [diff, setDiff] = useState<ConfigDiffResult | null>(null);
|
||||
const [diffLoading, setDiffLoading] = useState(false);
|
||||
@@ -90,11 +91,14 @@ export function RouteHeader({ route }: RouteHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 px-0 text-xs" asChild>
|
||||
<Link href="/proxy-routes">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
返回规则列表
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-0 text-xs"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
返回
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {ProxyRouteConfigSection, ProxyRouteItem} from '@/lib/services/openflare';
|
||||
|
||||
export type DomainListRow = {
|
||||
domain: string;
|
||||
certificateId: string;
|
||||
};
|
||||
import type {ProxyRouteConfigSection} from '@/lib/services/openflare';
|
||||
|
||||
export const proxyRouteFormIds: Record<ProxyRouteConfigSection, string> = {
|
||||
domains: 'proxy-route-domains-form',
|
||||
@@ -14,74 +9,6 @@ export const proxyRouteFormIds: Record<ProxyRouteConfigSection, string> = {
|
||||
auth: 'proxy-route-auth-form',
|
||||
};
|
||||
|
||||
function ensureRows(rows: DomainListRow[]) {
|
||||
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
|
||||
}
|
||||
|
||||
export function buildDomainRowsFromRoute(
|
||||
domains: string[],
|
||||
domainCertIDs: number[],
|
||||
certIDs: number[],
|
||||
): DomainListRow[] {
|
||||
if (domains.length === 0) {
|
||||
return ensureRows([]);
|
||||
}
|
||||
|
||||
if (domainCertIDs.length === domains.length) {
|
||||
return domains.map((domain, index) => ({
|
||||
domain,
|
||||
certificateId: domainCertIDs[index] ? String(domainCertIDs[index]) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
if (certIDs.length === 0) {
|
||||
return domains.map((domain) => ({ domain, certificateId: '' }));
|
||||
}
|
||||
|
||||
if (certIDs.length === 1) {
|
||||
return domains.map((domain) => ({
|
||||
domain,
|
||||
certificateId: String(certIDs[0]),
|
||||
}));
|
||||
}
|
||||
|
||||
return domains.map((domain, index) => ({
|
||||
domain,
|
||||
certificateId: certIDs[index] ? String(certIDs[index]) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeSelectedCertificateIDs(rows: DomainListRow[]) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
rows
|
||||
.filter((item) => item.domain.trim() !== '')
|
||||
.map((item) => Number(item.certificateId))
|
||||
.filter((item) => Number.isFinite(item) && item > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDomainCertificateIDs(rows: DomainListRow[]) {
|
||||
return rows
|
||||
.filter((item) => item.domain.trim() !== '')
|
||||
.map((item) => {
|
||||
const certificateID = Number(item.certificateId);
|
||||
return Number.isFinite(certificateID) && certificateID > 0 ? certificateID : 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDomainRows(route: ProxyRouteItem) {
|
||||
const selectedCertIDs =
|
||||
route.cert_ids.length > 0
|
||||
? route.cert_ids
|
||||
: route.cert_id
|
||||
? [route.cert_id]
|
||||
: [];
|
||||
|
||||
return buildDomainRowsFromRoute(route.domains, route.domain_cert_ids, selectedCertIDs);
|
||||
}
|
||||
|
||||
export function submitProxyRouteSectionForm(section: ProxyRouteConfigSection) {
|
||||
const form = document.getElementById(proxyRouteFormIds[section]);
|
||||
if (form instanceof HTMLFormElement) {
|
||||
@@ -89,4 +16,4 @@ export function submitProxyRouteSectionForm(section: ProxyRouteConfigSection) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,12 @@ import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/
|
||||
import type {ProxyRouteItem} from '@/lib/services/openflare';
|
||||
import {ProxyRouteService} from '@/lib/services/openflare';
|
||||
|
||||
import {getUpstreamSummary} from './components/helpers';
|
||||
import {
|
||||
getRouteDomainNames,
|
||||
getRouteDomainsLabel,
|
||||
getRoutePrimaryDomain,
|
||||
getUpstreamSummary,
|
||||
} from './components/helpers';
|
||||
import {ProxyRouteCreateSheet} from './components/proxy-route-create-sheet';
|
||||
|
||||
export function ProxyRoutesPageClient() {
|
||||
@@ -64,10 +69,9 @@ export function ProxyRoutesPageClient() {
|
||||
return routes.filter((route) => {
|
||||
const haystack = [
|
||||
route.site_name,
|
||||
route.primary_domain,
|
||||
...route.domains,
|
||||
getRoutePrimaryDomain(route),
|
||||
...getRouteDomainNames(route),
|
||||
route.origin_url,
|
||||
route.remark,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
@@ -170,8 +174,11 @@ export function ProxyRoutesPageClient() {
|
||||
{filteredRoutes.map((route) => (
|
||||
<TableRow key={route.id}>
|
||||
<TableCell className="font-medium">{route.site_name}</TableCell>
|
||||
<TableCell className="max-w-[220px] truncate" title={route.domains.join(', ')}>
|
||||
{route.primary_domain || route.domain}
|
||||
<TableCell
|
||||
className="max-w-[220px] truncate"
|
||||
title={getRouteDomainsLabel(route)}
|
||||
>
|
||||
{getRoutePrimaryDomain(route) || getRouteDomainsLabel(route)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={route.enabled ? 'default' : 'secondary'}>
|
||||
|
||||
@@ -81,7 +81,6 @@ export const emptyRuleGroupDraft: WAFRuleGroupPayload = {
|
||||
region_blacklist: [],
|
||||
pow_enabled: false,
|
||||
pow_config: defaultPowConfig,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
export const defaultRuleModalState: RuleModalState = {
|
||||
@@ -147,7 +146,6 @@ export function buildRuleGroupDraft(group: WAFRuleGroup | null): WAFRuleGroupPay
|
||||
region_blacklist: group.region_blacklist ?? [],
|
||||
pow_enabled: group.pow_enabled ?? false,
|
||||
pow_config: group.pow_config ?? defaultPowConfig,
|
||||
remark: group.remark ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -293,7 +291,6 @@ export function buildIPGroupPayloadFromGroup(
|
||||
subscription_format: group.subscription_format ?? 'text',
|
||||
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
|
||||
sync_interval_minutes: group.sync_interval_minutes || 1440,
|
||||
remark: group.remark ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ const ipGroupSchema = z
|
||||
subscription_format: z.enum(['text', 'json']),
|
||||
subscription_mapping_rule: z.string(),
|
||||
sync_interval_minutes: z.number().int().min(5),
|
||||
remark: z.string().max(500),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.type === 'subscription' && !value.subscription_url.trim()) {
|
||||
@@ -75,7 +74,6 @@ const defaultValues: IPGroupFormValues = {
|
||||
subscription_format: 'text',
|
||||
subscription_mapping_rule: '',
|
||||
sync_interval_minutes: 1440,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
function buildFormValues(group: WAFIPGroup | null): IPGroupFormValues {
|
||||
@@ -90,7 +88,6 @@ function buildFormValues(group: WAFIPGroup | null): IPGroupFormValues {
|
||||
subscription_format: group.subscription_format ?? 'text',
|
||||
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
|
||||
sync_interval_minutes: group.sync_interval_minutes || 1440,
|
||||
remark: group.remark ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +106,6 @@ function buildPayload(values: IPGroupFormValues): WAFIPGroupPayload {
|
||||
subscription_format: values.subscription_format,
|
||||
subscription_mapping_rule: values.subscription_mapping_rule.trim(),
|
||||
sync_interval_minutes: values.sync_interval_minutes,
|
||||
remark: values.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,19 +243,6 @@ export function IPGroupDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remark"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{type === 'subscription' ? (
|
||||
|
||||
@@ -43,7 +43,6 @@ const ruleGroupSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
block_status_code: z.number().int().min(400).max(599),
|
||||
block_response_body: z.string(),
|
||||
remark: z.string().max(500, '备注不能超过 500 个字符'),
|
||||
pow_enabled: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -82,7 +81,6 @@ export function RuleGroupSheet({
|
||||
enabled: true,
|
||||
block_status_code: 418,
|
||||
block_response_body: '',
|
||||
remark: '',
|
||||
pow_enabled: false,
|
||||
},
|
||||
});
|
||||
@@ -96,7 +94,6 @@ export function RuleGroupSheet({
|
||||
enabled: draft.enabled,
|
||||
block_status_code: draft.block_status_code,
|
||||
block_response_body: draft.block_response_body,
|
||||
remark: draft.remark,
|
||||
pow_enabled: draft.pow_enabled,
|
||||
});
|
||||
setRuleEntryOpen(false);
|
||||
@@ -148,7 +145,6 @@ export function RuleGroupSheet({
|
||||
enabled: values.enabled,
|
||||
block_status_code: values.block_status_code,
|
||||
block_response_body: values.block_response_body,
|
||||
remark: values.remark,
|
||||
pow_enabled: values.pow_enabled,
|
||||
};
|
||||
try {
|
||||
@@ -220,19 +216,6 @@ export function RuleGroupSheet({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remark"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>备注</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ export function SiteBindingSheet({
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) return routes;
|
||||
return routes.filter((route) =>
|
||||
[route.site_name, route.primary_domain, ...route.domains]
|
||||
[
|
||||
route.site_name,
|
||||
...(route.zone_domains ?? []).map((item) => item.domain),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(normalized),
|
||||
@@ -109,7 +112,8 @@ export function SiteBindingSheet({
|
||||
{route.site_name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{route.domains.join(', ')}
|
||||
{(route.zone_domains ?? []).map((item) => item.domain).join(', ') ||
|
||||
'未绑定域名'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useMemo} from 'react';
|
||||
import {ExternalLink, FileKey} from 'lucide-react';
|
||||
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
|
||||
import type {TlsCertificateItem, ZoneDomainItem} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
|
||||
import {getCertificateStatus} from '../../components/website-utils';
|
||||
|
||||
export function ZoneCertificatesPanel({
|
||||
domains,
|
||||
certificates,
|
||||
}: {
|
||||
domains: ZoneDomainItem[];
|
||||
certificates: TlsCertificateItem[];
|
||||
}) {
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((certificate) => [certificate.id, certificate])),
|
||||
[certificates],
|
||||
);
|
||||
|
||||
const boundCertificates = useMemo(() => {
|
||||
const rows = new Map<
|
||||
number,
|
||||
{certificate: TlsCertificateItem; domains: ZoneDomainItem[]}
|
||||
>();
|
||||
|
||||
for (const domain of domains) {
|
||||
if (domain.cert_id == null) {
|
||||
continue;
|
||||
}
|
||||
const certificate = certificateMap.get(domain.cert_id);
|
||||
if (!certificate) {
|
||||
continue;
|
||||
}
|
||||
const existing = rows.get(certificate.id);
|
||||
if (existing) {
|
||||
existing.domains.push(domain);
|
||||
} else {
|
||||
rows.set(certificate.id, {certificate, domains: [domain]});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(rows.values()).sort((left, right) =>
|
||||
left.certificate.name.localeCompare(right.certificate.name),
|
||||
);
|
||||
}, [certificateMap, domains]);
|
||||
|
||||
const unboundCount = domains.filter((domain) => domain.cert_id == null).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base">本 Zone 使用的证书</CardTitle>
|
||||
<CardDescription>
|
||||
证书在全局证书库管理;此处仅展示已绑定到本 Zone 域名的证书。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href="/certificates">
|
||||
证书库
|
||||
<ExternalLink className="ml-1 size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{unboundCount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
还有 {unboundCount} 个域名未绑定证书,可在「域名」Tab 中选择证书。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{boundCertificates.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={FileKey}
|
||||
description="暂无绑定证书。请先在域名行选择证书,或前往证书库导入/申请。"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>证书</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>覆盖域名</TableHead>
|
||||
<TableHead>到期时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{boundCertificates.map(({certificate, domains: boundDomains}) => {
|
||||
const status = getCertificateStatus(certificate);
|
||||
return (
|
||||
<TableRow key={certificate.id}>
|
||||
<TableCell className="font-medium">
|
||||
{certificate.name}
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{certificate.primary_domain || `证书 #${certificate.id}`}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{status.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-muted-foreground">
|
||||
{boundDomains.map((domain) => domain.domain).join(' · ')}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDateTime(certificate.not_after)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import {QuickCreateZoneDomainDialog} from '../../components/quick-create-zone-domain-dialog';
|
||||
|
||||
/** Zone 详情页添加域名(固定 Zone,支持简写 / @ / 完整 FQDN)。 */
|
||||
export function ZoneDomainDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
zoneId,
|
||||
zoneRoot,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
zoneId: number;
|
||||
zoneRoot: string;
|
||||
onSaved(): Promise<unknown> | void;
|
||||
}) {
|
||||
return (
|
||||
<QuickCreateZoneDomainDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
fixedZoneId={zoneId}
|
||||
fixedZoneRoot={zoneRoot}
|
||||
onCreated={async () => {
|
||||
await onSaved();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {Eye, Plus, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip';
|
||||
import {
|
||||
ZoneDomainService,
|
||||
type ProxyRouteItem,
|
||||
type TlsCertificateItem,
|
||||
type ZoneDomainItem,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {getUpstreamLabels} from '../../../proxy-routes/components/helpers';
|
||||
import {ZoneDomainDialog} from './zone-domain-dialog';
|
||||
|
||||
export function ZoneDomainsTable({
|
||||
zoneId,
|
||||
zoneRoot,
|
||||
domains,
|
||||
certificates,
|
||||
routes,
|
||||
routesLoading = false,
|
||||
onChanged,
|
||||
}: {
|
||||
zoneId: number;
|
||||
zoneRoot: string;
|
||||
domains: ZoneDomainItem[];
|
||||
certificates: TlsCertificateItem[];
|
||||
routes: ProxyRouteItem[];
|
||||
routesLoading?: boolean;
|
||||
onChanged(): Promise<unknown> | void;
|
||||
}) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState<ZoneDomainItem | null>(null);
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => ZoneDomainService.deleteById(zoneId, id),
|
||||
onSuccess: async () => {
|
||||
toast.success('域名已删除');
|
||||
setDeleting(null);
|
||||
await onChanged();
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : '删除失败'),
|
||||
});
|
||||
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((certificate) => [certificate.id, certificate])),
|
||||
[certificates],
|
||||
);
|
||||
const routeMap = useMemo(
|
||||
() => new Map(routes.map((route) => [route.id, route])),
|
||||
[routes],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
添加域名
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{domains.length === 0 ? (
|
||||
<EmptyStateWithBorder description="暂无已添加域名" />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-dashed shadow-none">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Table className="w-full min-w-full caption-bottom text-sm">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="h-8 whitespace-nowrap py-2 min-w-[160px]">
|
||||
FQDN
|
||||
</TableHead>
|
||||
<TableHead className="h-8 whitespace-nowrap py-2 min-w-[120px]">
|
||||
证书
|
||||
</TableHead>
|
||||
<TableHead className="h-8 whitespace-nowrap py-2 min-w-[200px]">
|
||||
上游
|
||||
</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 h-8 w-[90px] bg-background py-2 text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domains.map((domain) => {
|
||||
const route =
|
||||
domain.proxy_route_id != null
|
||||
? routeMap.get(domain.proxy_route_id)
|
||||
: undefined;
|
||||
const upstreams = route ? getUpstreamLabels(route) : [];
|
||||
const certLabel = domain.cert_id
|
||||
? (certificateMap.get(domain.cert_id)?.name ?? `证书 #${domain.cert_id}`)
|
||||
: '未绑定';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={domain.id}
|
||||
className="group border-dashed hover:bg-muted/30"
|
||||
>
|
||||
<TableCell className="py-1">
|
||||
<span
|
||||
className="max-w-[220px] truncate text-[11px] font-medium leading-tight"
|
||||
title={domain.domain}
|
||||
>
|
||||
{domain.domain}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground">
|
||||
{certLabel}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px] py-1">
|
||||
{!domain.proxy_route_id ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
未关联路由
|
||||
</span>
|
||||
) : routesLoading && !route ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
加载中…
|
||||
</span>
|
||||
) : upstreams.length === 0 ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
未配置上游
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0">
|
||||
{upstreams.map((upstream) => (
|
||||
<span
|
||||
key={upstream}
|
||||
className="truncate font-mono text-[10px] leading-tight text-muted-foreground"
|
||||
title={upstream}
|
||||
>
|
||||
{upstream}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-background py-1 text-center"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
{domain.proxy_route_id ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/proxy-routes/detail?id=${domain.proxy_route_id}`}
|
||||
>
|
||||
<Eye className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
查看路由详情
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleting(domain)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
删除域名
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ZoneDomainDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
zoneId={zoneId}
|
||||
zoneRoot={zoneRoot}
|
||||
onSaved={onChanged}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除域名</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除 {deleting?.domain} 吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={remove.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
disabled={remove.isPending || !deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (deleting) {
|
||||
remove.mutate(deleting.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? '删除中…' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import {useEffect} from 'react'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import {Loader2} from 'lucide-react'
|
||||
import {toast} from 'sonner'
|
||||
import {z} from 'zod'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Label} from '@/components/ui/label'
|
||||
import {ZoneService, zoneQueryKey, type ZoneItem} from '@/lib/services/openflare'
|
||||
|
||||
const schema = z.object({
|
||||
domain: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入 Zone 根域')
|
||||
.refine((value) => !/[*/?#@]|:\/\//.test(value), '请输入不含协议或通配符的根域'),
|
||||
})
|
||||
type Values = z.infer<typeof schema>
|
||||
|
||||
export function ZoneEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
zone,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange(open: boolean): void
|
||||
zone?: ZoneItem | null
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {domain: ''},
|
||||
})
|
||||
useEffect(() => {
|
||||
if (open) form.reset({domain: zone?.domain ?? ''})
|
||||
}, [form, open, zone])
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: Values) =>
|
||||
zone
|
||||
? ZoneService.update(zone.id, {domain: values.domain.toLowerCase()})
|
||||
: ZoneService.create({domain: values.domain.toLowerCase()}),
|
||||
onSuccess: async () => {
|
||||
toast.success(zone ? 'Zone 已更新' : 'Zone 已创建')
|
||||
await queryClient.invalidateQueries({queryKey: zoneQueryKey})
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : '保存失败'),
|
||||
})
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{zone ? '编辑 Zone' : '新增 Zone'}</DialogTitle>
|
||||
<DialogDescription>Zone 仅接受可注册根域,例如 example.com。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
id="zone-editor"
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
mutation.mutate({domain: values.domain.toLowerCase()}),
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="zone-domain">根域</Label>
|
||||
<Input id="zone-domain" placeholder="example.com" {...form.register('domain')} />
|
||||
{form.formState.errors.domain && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.domain.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button form="zone-editor" type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending && <Loader2 className="mr-1 size-4 animate-spin" />}
|
||||
{zone ? '保存修改' : '新增 Zone'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {Activity, HardDrive, Users} from 'lucide-react';
|
||||
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from 'recharts';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {
|
||||
ZoneService,
|
||||
zoneQueryKey,
|
||||
type ZoneOverview,
|
||||
type ZoneStatsRange,
|
||||
} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import {formatBytes, formatCompactNumber} from '@/lib/utils/metrics';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
const rangeOptions: Array<{value: ZoneStatsRange; label: string}> = [
|
||||
{value: '24h', label: '24 小时'},
|
||||
{value: '7d', label: '7 天'},
|
||||
{value: '30d', label: '30 天'},
|
||||
];
|
||||
|
||||
const visitorsChartConfig = {
|
||||
value: {label: '唯一访问者', color: 'hsl(217 91% 60%)'},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const requestsChartConfig = {
|
||||
value: {label: '请求总数', color: 'hsl(217 91% 60%)'},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const bytesChartConfig = {
|
||||
value: {label: '已提供数据', color: 'hsl(217 91% 60%)'},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function formatAxisLabel(iso: string, range: ZoneStatsRange) {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
if (range === '24h') {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: undefined,
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
function formatWindowLabel(startedAt?: string, endedAt?: string) {
|
||||
if (!startedAt || !endedAt) {
|
||||
return '—';
|
||||
}
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
const fmt = (value: Date) =>
|
||||
value
|
||||
.toLocaleDateString('en-GB', {day: 'numeric', month: 'long'})
|
||||
.toUpperCase();
|
||||
return `${fmt(start)} — ${fmt(end)}`;
|
||||
}
|
||||
|
||||
export function ZoneOverviewPanel({
|
||||
overview,
|
||||
zoneId,
|
||||
}: {
|
||||
overview: ZoneOverview;
|
||||
zoneId: number;
|
||||
}) {
|
||||
const [range, setRange] = useState<ZoneStatsRange>('24h');
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: [...zoneQueryKey, zoneId, 'stats', range],
|
||||
queryFn: () => ZoneService.getStats(zoneId, range),
|
||||
enabled: zoneId > 0,
|
||||
});
|
||||
|
||||
const stats = statsQuery.data;
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
(stats?.series ?? []).map((point) => ({
|
||||
label: formatAxisLabel(point.bucket_started_at, range),
|
||||
at: point.bucket_started_at,
|
||||
visitors: point.unique_visitors,
|
||||
requests: point.request_count,
|
||||
bytes: point.bytes_sent,
|
||||
})),
|
||||
[range, stats?.series],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="inline-flex rounded-lg border bg-muted/40 p-0.5">
|
||||
{rangeOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-7 rounded-md px-3 text-xs font-medium shadow-none',
|
||||
range === option.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={() => setRange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||
{formatWindowLabel(stats?.window_started_at, stats?.window_ended_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{statsQuery.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({length: 3}).map((_, index) => (
|
||||
<Skeleton key={index} className="h-36 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : statsQuery.isError ? (
|
||||
<div className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
加载流量统计失败
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void statsQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{!stats?.available ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
分析存储暂不可用,图表可能为空。请确认 ClickHouse 已启用。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<MetricTrendCard
|
||||
icon={Users}
|
||||
label="唯一访问者"
|
||||
value={formatCompactNumber(stats?.unique_visitors ?? 0)}
|
||||
data={chartData}
|
||||
dataKey="visitors"
|
||||
config={visitorsChartConfig}
|
||||
gradientId="zone-visitors"
|
||||
/>
|
||||
<MetricTrendCard
|
||||
icon={Activity}
|
||||
label="请求总数"
|
||||
value={formatCompactNumber(stats?.request_count ?? 0)}
|
||||
data={chartData}
|
||||
dataKey="requests"
|
||||
config={requestsChartConfig}
|
||||
gradientId="zone-requests"
|
||||
/>
|
||||
<MetricTrendCard
|
||||
icon={HardDrive}
|
||||
label="已提供的数据总计"
|
||||
value={formatBytes(stats?.bytes_sent ?? 0, {zeroText: '0 B'})}
|
||||
data={chartData}
|
||||
dataKey="bytes"
|
||||
config={bytesChartConfig}
|
||||
gradientId="zone-bytes"
|
||||
valueFormatter={(value) => formatBytes(value, {zeroText: '0 B'})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-dashed shadow-none md:col-span-3">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Zone 信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">根域</p>
|
||||
<p className="mt-1 font-medium">{overview.zone.domain}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">创建时间</p>
|
||||
<p className="mt-1">{formatDateTime(overview.zone.created_at)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricTrendCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
data,
|
||||
dataKey,
|
||||
config,
|
||||
gradientId,
|
||||
valueFormatter,
|
||||
}: {
|
||||
icon: typeof Users;
|
||||
label: string;
|
||||
value: string;
|
||||
data: Array<Record<string, string | number>>;
|
||||
dataKey: string;
|
||||
config: ChartConfig;
|
||||
gradientId: string;
|
||||
valueFormatter?: (value: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="overflow-hidden border shadow-none">
|
||||
<CardContent className="p-0">
|
||||
<div className="grid gap-0 md:grid-cols-[minmax(140px,200px)_1fr]">
|
||||
<div className="flex flex-col justify-between gap-3 border-b p-4 md:border-b-0 md:border-r">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Icon className="size-3.5 text-primary" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<p className="text-3xl font-semibold tracking-tight">{value}</p>
|
||||
</div>
|
||||
<div className="h-36 min-h-[9rem] w-full px-2 py-3 md:h-40">
|
||||
<ChartContainer config={config} className="h-full w-full aspect-auto">
|
||||
<AreaChart data={data} margin={{top: 8, right: 12, left: 0, bottom: 0}}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-value)" stopOpacity={0.28} />
|
||||
<stop offset="95%" stopColor="var(--color-value)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-border/60"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={28}
|
||||
tickMargin={8}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={36}
|
||||
tickMargin={4}
|
||||
className="text-[10px]"
|
||||
allowDecimals={false}
|
||||
tickFormatter={(tick) =>
|
||||
valueFormatter
|
||||
? valueFormatter(Number(tick))
|
||||
: formatCompactNumber(Number(tick))
|
||||
}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(raw) => {
|
||||
const numeric = Number(raw);
|
||||
return valueFormatter
|
||||
? valueFormatter(numeric)
|
||||
: formatCompactNumber(numeric);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
name="value"
|
||||
stroke="var(--color-value)"
|
||||
strokeWidth={2}
|
||||
fill={`url(#${gradientId})`}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
'use client';
|
||||
|
||||
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {ArrowLeft, Globe, Pencil, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import {
|
||||
ProxyRouteService,
|
||||
TlsCertificateService,
|
||||
ZoneService,
|
||||
zoneQueryKey,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {getErrorMessage} from '../components/website-utils';
|
||||
import {ZoneCertificatesPanel} from './components/zone-certificates';
|
||||
import {ZoneDomainsTable} from './components/zone-domains-table';
|
||||
import {ZoneEditorDialog} from './components/zone-editor-dialog';
|
||||
import {ZoneOverviewPanel} from './components/zone-overview';
|
||||
|
||||
const zoneTabs = ['overview', 'domains', 'certificates', 'settings'] as const;
|
||||
export type ZonePageTab = (typeof zoneTabs)[number];
|
||||
|
||||
function getZonePageTab(value: string | null | undefined): ZonePageTab {
|
||||
return zoneTabs.includes(value as ZonePageTab) ? (value as ZonePageTab) : 'overview';
|
||||
}
|
||||
|
||||
function getZoneIdFromPathname(pathname: string | null): number {
|
||||
const match = pathname?.match(/^\/websites\/([^/]+)$/);
|
||||
return Number(match?.[1]);
|
||||
}
|
||||
|
||||
export function ZonePageClient() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const zoneId = useMemo(() => getZoneIdFromPathname(pathname), [pathname]);
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const activeTab = useMemo(
|
||||
() => getZonePageTab(searchParams.get('tab')),
|
||||
[searchParams],
|
||||
);
|
||||
const [editZone, setEditZone] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const setActiveTab = useCallback(
|
||||
(tab: string) => {
|
||||
const next = getZonePageTab(tab);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (next === 'overview') {
|
||||
params.delete('tab');
|
||||
} else {
|
||||
params.set('tab', next);
|
||||
}
|
||||
const query = params.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {scroll: false});
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: [...zoneQueryKey, zoneId],
|
||||
queryFn: () => ZoneService.getOverview(zoneId),
|
||||
enabled: Number.isInteger(zoneId) && zoneId > 0,
|
||||
});
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: ['openflare', 'tls-certificates'],
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
enabled: overviewQuery.isSuccess,
|
||||
});
|
||||
|
||||
const routesQuery = useQuery({
|
||||
queryKey: ['openflare', 'proxy-routes'],
|
||||
queryFn: () => ProxyRouteService.list(),
|
||||
enabled: overviewQuery.isSuccess,
|
||||
});
|
||||
|
||||
const deleteZone = useMutation({
|
||||
mutationFn: () => ZoneService.deleteById(zoneId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Zone 已删除');
|
||||
await queryClient.invalidateQueries({queryKey: zoneQueryKey});
|
||||
window.location.assign('/websites');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={Globe} description="加载 Zone 详情中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(zoneId) || zoneId <= 0) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="无效的网站 ID,请从网站列表进入详情页。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (overviewQuery.isLoading) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={Globe} description="加载 Zone 详情中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (overviewQuery.isError || !overviewQuery.data) {
|
||||
return (
|
||||
<div className="space-y-4 py-6 px-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-0 text-xs"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
返回
|
||||
</Button>
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="网站不存在,可能已被删除或 ID 无效。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewQuery.data;
|
||||
const certificates = certificatesQuery.data ?? [];
|
||||
const routes = routesQuery.data ?? [];
|
||||
const boundCertCount = new Set(
|
||||
overview.domains.map((domain) => domain.cert_id).filter((id): id is number => id != null),
|
||||
).size;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 py-6 px-1">
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-0 text-xs"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
返回
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{overview.zone.domain}</h1>
|
||||
<p className="text-sm text-muted-foreground">Zone 网站管理</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList variant="line" className="mb-6 inline-flex w-fit gap-8">
|
||||
<TabsTrigger value="overview" className="px-0 pb-2 text-xs font-semibold">
|
||||
概览
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="domains" className="px-0 pb-2 text-xs font-semibold">
|
||||
域名 ({overview.domains.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="certificates" className="px-0 pb-2 text-xs font-semibold">
|
||||
证书 ({boundCertCount})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="px-0 pb-2 text-xs font-semibold">
|
||||
设置
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<ZoneOverviewPanel overview={overview} zoneId={zoneId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="domains">
|
||||
<ZoneDomainsTable
|
||||
zoneId={zoneId}
|
||||
zoneRoot={overview.zone.domain}
|
||||
domains={overview.domains}
|
||||
certificates={certificates}
|
||||
routes={routes}
|
||||
routesLoading={routesQuery.isLoading}
|
||||
onChanged={() => overviewQuery.refetch()}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="certificates">
|
||||
<ZoneCertificatesPanel domains={overview.domains} certificates={certificates} />
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-semibold">基本信息</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
维护 Zone 的可注册根域。根域变更后,请确认其下域名仍归属该根域。
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">当前根域</p>
|
||||
<p className="mt-1 font-mono text-[13px] font-medium">{overview.zone.domain}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">域名数量</p>
|
||||
<p className="mt-1 font-mono text-[13px] font-medium">{overview.domains.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 h-7 text-xs"
|
||||
onClick={() => setEditZone(true)}
|
||||
>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
编辑根域
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-destructive/30 p-4">
|
||||
<p className="text-sm font-semibold text-destructive">危险操作</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
删除 Zone 前须清空其下全部域名;删除后不可恢复。
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="mt-4 h-7 text-xs"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3.5" />
|
||||
删除 Zone
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ZoneEditorDialog
|
||||
open={editZone}
|
||||
onOpenChange={setEditZone}
|
||||
zone={overview.zone}
|
||||
/>
|
||||
|
||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除 Zone</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除 {overview.zone.domain} 吗?其下域名须先解绑路由后才能删除,此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteZone.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
disabled={deleteZone.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
deleteZone.mutate();
|
||||
}}
|
||||
>
|
||||
{deleteZone.isPending ? '删除中…' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {Suspense} from 'react'
|
||||
import {Globe} from 'lucide-react'
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading'
|
||||
import {ZonePageClient} from './page-client'
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{zoneId: '1'}];
|
||||
}
|
||||
|
||||
export default async function ZonePage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={Globe} description="加载 Zone 详情中..." />
|
||||
</div>
|
||||
}>
|
||||
<ZonePageClient />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -62,8 +62,7 @@ export function CertificateEditorDialog({
|
||||
onSuccess: async (certificate) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['openflare', 'managed-domains']}),
|
||||
]);
|
||||
]);
|
||||
onSaved?.(certificate);
|
||||
handleClose();
|
||||
},
|
||||
|
||||
@@ -58,8 +58,7 @@ export function CertificateImportDialog({
|
||||
const invalidateQueries = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['openflare', 'managed-domains']}),
|
||||
]);
|
||||
]);
|
||||
};
|
||||
|
||||
const resetFileForm = () => {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import {
|
||||
TlsCertificateService,
|
||||
ZoneDomainService,
|
||||
ZoneService,
|
||||
zoneQueryKey,
|
||||
type ZoneDomainItem,
|
||||
type ZoneItem,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {previewZoneDomainInput, resolveZoneDomainInput} from './resolve-zone-domain-input';
|
||||
|
||||
const schema = z.object({
|
||||
zone_id: z.string().min(1, '请选择 Zone'),
|
||||
domain_input: z.string().trim().min(1, '请输入域名'),
|
||||
cert_id: z.string(),
|
||||
});
|
||||
|
||||
type Values = z.infer<typeof schema>;
|
||||
|
||||
export function QuickCreateZoneDomainDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fixedZoneId,
|
||||
fixedZoneRoot,
|
||||
zones: zonesProp,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
/** When set, Zone 选择器隐藏 */
|
||||
fixedZoneId?: number;
|
||||
fixedZoneRoot?: string;
|
||||
zones?: ZoneItem[];
|
||||
onCreated(domain: ZoneDomainItem): void | Promise<void>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const zonesQuery = useQuery({
|
||||
queryKey: zoneQueryKey,
|
||||
queryFn: () => ZoneService.list(),
|
||||
enabled: open && !fixedZoneId && !zonesProp,
|
||||
});
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: ['openflare', 'tls-certificates'],
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const zones = useMemo(
|
||||
() => zonesProp ?? zonesQuery.data ?? [],
|
||||
[zonesProp, zonesQuery.data],
|
||||
);
|
||||
const fixedZone = useMemo(() => {
|
||||
if (!fixedZoneId) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
zones.find((zone) => zone.id === fixedZoneId) ??
|
||||
(fixedZoneRoot
|
||||
? ({id: fixedZoneId, domain: fixedZoneRoot, created_at: '', updated_at: ''} as ZoneItem)
|
||||
: undefined)
|
||||
);
|
||||
}, [fixedZoneId, fixedZoneRoot, zones]);
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
zone_id: fixedZoneId ? String(fixedZoneId) : '',
|
||||
domain_input: '',
|
||||
cert_id: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
form.reset({
|
||||
zone_id: fixedZoneId ? String(fixedZoneId) : '',
|
||||
domain_input: '',
|
||||
cert_id: '',
|
||||
});
|
||||
}, [fixedZoneId, form, open]);
|
||||
|
||||
const watchedZoneId = form.watch('zone_id');
|
||||
const watchedInput = form.watch('domain_input');
|
||||
const selectedZone = useMemo(() => {
|
||||
if (fixedZone) {
|
||||
return fixedZone;
|
||||
}
|
||||
const id = Number(watchedZoneId);
|
||||
return zones.find((zone) => zone.id === id);
|
||||
}, [fixedZone, watchedZoneId, zones]);
|
||||
|
||||
const preview = selectedZone
|
||||
? previewZoneDomainInput(watchedInput, selectedZone.domain)
|
||||
: '';
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: Values) => {
|
||||
const zoneId = Number(values.zone_id);
|
||||
const zone =
|
||||
fixedZone ?? zones.find((item) => item.id === zoneId);
|
||||
if (!zone) {
|
||||
throw new Error('请选择 Zone');
|
||||
}
|
||||
const resolved = resolveZoneDomainInput(values.domain_input, zone.domain);
|
||||
if (resolved.error || !resolved.domain) {
|
||||
throw new Error(resolved.error || '域名格式不合法');
|
||||
}
|
||||
return ZoneDomainService.create(zone.id, {
|
||||
domain: resolved.domain,
|
||||
cert_id: values.cert_id ? Number(values.cert_id) : null,
|
||||
});
|
||||
},
|
||||
onSuccess: async (domain) => {
|
||||
toast.success('域名已添加', {description: domain.domain});
|
||||
await Promise.all([
|
||||
onCreated(domain),
|
||||
queryClient.invalidateQueries({queryKey: zoneQueryKey}),
|
||||
queryClient.invalidateQueries({queryKey: [...zoneQueryKey, 'all-domains']}),
|
||||
]);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : '添加失败'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>快捷新增域名</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择 Zone 后输入:子域名称(如 api)、@(根域)或完整 FQDN。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
id="quick-create-zone-domain"
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
if (!selectedZone) {
|
||||
form.setError('zone_id', {message: '请选择 Zone'});
|
||||
return;
|
||||
}
|
||||
const resolved = resolveZoneDomainInput(
|
||||
values.domain_input,
|
||||
selectedZone.domain,
|
||||
);
|
||||
if (resolved.error) {
|
||||
form.setError('domain_input', {message: resolved.error});
|
||||
return;
|
||||
}
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
{!fixedZoneId ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Zone</Label>
|
||||
<Select
|
||||
value={form.watch('zone_id') || undefined}
|
||||
onValueChange={(value) => form.setValue('zone_id', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择注册根域" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{zones.map((zone) => (
|
||||
<SelectItem key={zone.id} value={String(zone.id)}>
|
||||
{zone.domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.zone_id ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.zone_id.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
Zone:
|
||||
<span className="ml-1 font-medium">
|
||||
{fixedZoneRoot || fixedZone?.domain || `#${fixedZoneId}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="domain-input">域名</Label>
|
||||
<Input
|
||||
id="domain-input"
|
||||
placeholder={
|
||||
selectedZone
|
||||
? `api 或 @ 或 api.${selectedZone.domain}`
|
||||
: 'api / @ / 完整域名'
|
||||
}
|
||||
{...form.register('domain_input')}
|
||||
/>
|
||||
{preview ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
将创建:
|
||||
<code className="ml-1 rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
||||
{preview}
|
||||
</code>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
示例:输入 <code className="font-mono">api</code> →{' '}
|
||||
<code className="font-mono">api.zone.com</code>;
|
||||
<code className="font-mono">@</code> → 根域本身
|
||||
</p>
|
||||
)}
|
||||
{form.formState.errors.domain_input ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.domain_input.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>证书(可选)</Label>
|
||||
<Select
|
||||
value={form.watch('cert_id') || '__none'}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('cert_id', value === '__none' ? '' : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不绑定证书" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不绑定证书</SelectItem>
|
||||
{(certificatesQuery.data ?? []).map((certificate) => (
|
||||
<SelectItem key={certificate.id} value={String(certificate.id)}>
|
||||
{certificate.name} · {certificate.primary_domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="quick-create-zone-domain"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-1 size-4 animate-spin" />
|
||||
) : null}
|
||||
添加域名
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Resolve user input into a full FQDN under a Zone root.
|
||||
*
|
||||
* - `@` → apex (zone root itself), e.g. `example.com`
|
||||
* - single label `name` → `name.example.com`
|
||||
* - full FQDN must equal the root or end with `.root`
|
||||
*/
|
||||
export function resolveZoneDomainInput(
|
||||
rawInput: string,
|
||||
zoneRoot: string,
|
||||
): {domain: string; error?: string} {
|
||||
const input = rawInput.trim().toLowerCase();
|
||||
const root = zoneRoot.trim().toLowerCase();
|
||||
|
||||
if (!root) {
|
||||
return {domain: '', error: '请先选择 Zone'};
|
||||
}
|
||||
if (!input) {
|
||||
return {domain: '', error: '请输入域名'};
|
||||
}
|
||||
if (input.includes('*')) {
|
||||
return {domain: '', error: 'Zone 域名不支持通配符'};
|
||||
}
|
||||
if (input.includes('://') || input.includes('/') || input.includes('?') || input.includes('#')) {
|
||||
return {domain: '', error: '域名格式不合法'};
|
||||
}
|
||||
|
||||
if (input === '@') {
|
||||
return {domain: root};
|
||||
}
|
||||
|
||||
// Short label: no dots → prefix under zone root
|
||||
if (!input.includes('.')) {
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(input)) {
|
||||
return {domain: '', error: '子域名称格式不合法'};
|
||||
}
|
||||
return {domain: `${input}.${root}`};
|
||||
}
|
||||
|
||||
// Full FQDN
|
||||
if (input === root || input.endsWith(`.${root}`)) {
|
||||
return {domain: input};
|
||||
}
|
||||
|
||||
return {domain: '', error: `域名必须属于 Zone ${root}`};
|
||||
}
|
||||
|
||||
/** Live preview string for the input helper text. */
|
||||
export function previewZoneDomainInput(rawInput: string, zoneRoot: string): string {
|
||||
const resolved = resolveZoneDomainInput(rawInput, zoneRoot);
|
||||
if (resolved.error || !resolved.domain) {
|
||||
return '';
|
||||
}
|
||||
return resolved.domain;
|
||||
}
|
||||
@@ -1,33 +1,5 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
export const managedDomainSchema = z.object({
|
||||
domain: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入域名')
|
||||
.max(255, '域名不能超过 255 个字符')
|
||||
.refine(
|
||||
(value) => !value.includes('://') && !value.includes('/'),
|
||||
'域名格式不合法',
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
/^(?:\*\.)?(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/.test(
|
||||
value,
|
||||
),
|
||||
'域名格式不合法',
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.includes('*') ||
|
||||
(value.startsWith('*.') && value.indexOf('*', 1) === -1),
|
||||
'通配符域名仅支持 *.example.com 格式',
|
||||
),
|
||||
cert_id: z.string(),
|
||||
enabled: z.boolean(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
});
|
||||
|
||||
export const manualImportSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
@@ -39,7 +11,6 @@ export const manualImportSchema = z.object({
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
});
|
||||
|
||||
export type ManagedDomainFormValues = z.infer<typeof managedDomainSchema>;
|
||||
export type ManualImportFormValues = z.infer<typeof manualImportSchema>;
|
||||
|
||||
export type FileImportFormValues = {
|
||||
@@ -47,13 +18,6 @@ export type FileImportFormValues = {
|
||||
remark: string;
|
||||
};
|
||||
|
||||
export const defaultManagedDomainValues: ManagedDomainFormValues = {
|
||||
domain: '',
|
||||
cert_id: '',
|
||||
enabled: true,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
export const defaultManualImportValues: ManualImportFormValues = {
|
||||
name: '',
|
||||
cert_pem: '',
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {useEffect} from 'react';
|
||||
import {useForm, useWatch} from 'react-hook-form';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import type {ManagedDomainItem, TlsCertificateItem} from '@/lib/services/openflare';
|
||||
import {WebsiteService} from '@/lib/services/openflare';
|
||||
|
||||
import {defaultManagedDomainValues, type ManagedDomainFormValues, managedDomainSchema,} from './schemas';
|
||||
import {WebsiteStatusBadge} from './status-badge';
|
||||
import {
|
||||
buildCertificateLabel,
|
||||
getErrorMessage,
|
||||
getMatchTypeMeta,
|
||||
toManagedDomainFormValues,
|
||||
toManagedDomainPayload,
|
||||
} from './website-utils';
|
||||
|
||||
const domainsQueryKey = ['openflare', 'managed-domains'];
|
||||
|
||||
interface WebsiteEditorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved?: (domain: ManagedDomainItem, mode: 'create' | 'update') => void;
|
||||
onRequestImportCertificate: () => void;
|
||||
certificates: TlsCertificateItem[];
|
||||
certificatesLoading?: boolean;
|
||||
initialDomain?: ManagedDomainItem | null;
|
||||
preferredCertificateId?: number | null;
|
||||
}
|
||||
|
||||
export function WebsiteEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
onRequestImportCertificate,
|
||||
certificates,
|
||||
certificatesLoading = false,
|
||||
initialDomain = null,
|
||||
preferredCertificateId = null,
|
||||
}: WebsiteEditorDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<ManagedDomainFormValues>({
|
||||
resolver: zodResolver(managedDomainSchema),
|
||||
defaultValues: defaultManagedDomainValues,
|
||||
});
|
||||
|
||||
const watchedDomain = useWatch({control: form.control, name: 'domain'});
|
||||
const watchedCertId = useWatch({control: form.control, name: 'cert_id'});
|
||||
const watchedEnabled = useWatch({control: form.control, name: 'enabled'});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: ManagedDomainFormValues) => {
|
||||
const payload = toManagedDomainPayload(values);
|
||||
return initialDomain
|
||||
? WebsiteService.update(initialDomain.id, payload)
|
||||
: WebsiteService.create(payload);
|
||||
},
|
||||
onSuccess: async (domain) => {
|
||||
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
|
||||
onSaved?.(domain, initialDomain ? 'update' : 'create');
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
|
||||
const currentCertificate = watchedCertId
|
||||
? (certificates.find((item) => item.id === Number(watchedCertId)) ?? null)
|
||||
: null;
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
saveMutation.mutate(values);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
saveMutation.reset();
|
||||
form.reset(defaultManagedDomainValues);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset(
|
||||
initialDomain
|
||||
? toManagedDomainFormValues(initialDomain)
|
||||
: defaultManagedDomainValues,
|
||||
);
|
||||
}, [form, initialDomain, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !preferredCertificateId) return;
|
||||
form.setValue('cert_id', String(preferredCertificateId), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}, [form, open, preferredCertificateId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{initialDomain ? '编辑网站' : '新增网站'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
录入域名并选择绑定证书。证书可在弹窗内直接新增,导入成功后会自动回填。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{saveMutation.isError ? (
|
||||
<p className="text-sm text-destructive">{getErrorMessage(saveMutation.error)}</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="domain">域名</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
placeholder="example.com 或 *.example.com"
|
||||
{...form.register('domain')}
|
||||
/>
|
||||
{form.formState.errors.domain ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.domain.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>绑定证书</Label>
|
||||
<Select
|
||||
value={watchedCertId || 'none'}
|
||||
disabled={certificatesLoading}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('cert_id', value === 'none' ? '' : value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不绑定证书" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不绑定证书</SelectItem>
|
||||
{certificates.map((certificate) => (
|
||||
<SelectItem key={certificate.id} value={String(certificate.id)}>
|
||||
{buildCertificateLabel(certificate)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={onRequestImportCertificate}
|
||||
>
|
||||
添加证书
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border border-dashed p-3 md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">当前域名</p>
|
||||
<p className="mt-1 text-sm">{watchedDomain?.trim() || '未填写域名'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">匹配类型</p>
|
||||
<div className="mt-1">
|
||||
{watchedDomain?.trim() ? (
|
||||
<WebsiteStatusBadge {...getMatchTypeMeta(watchedDomain.trim())} />
|
||||
) : (
|
||||
<WebsiteStatusBadge label="等待输入" tone="warning" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">当前证书</p>
|
||||
<div className="mt-1">
|
||||
<WebsiteStatusBadge
|
||||
label={currentCertificate ? currentCertificate.name : '未绑定证书'}
|
||||
tone={currentCertificate ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">启用网站</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
停用后该网站不会参与自动匹配,但记录会保留。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={watchedEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue('enabled', checked, {shouldDirty: true, shouldValidate: true})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="remark">备注</Label>
|
||||
<Input
|
||||
id="remark"
|
||||
placeholder="例如:主站 / 泛域名 / 生产"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : initialDomain ? (
|
||||
'保存网站'
|
||||
) : (
|
||||
'创建网站'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import type {
|
||||
ManagedDomainItem,
|
||||
ManagedDomainMutationPayload,
|
||||
ProxyRouteItem,
|
||||
TlsCertificateFileImportPayload,
|
||||
TlsCertificateItem,
|
||||
TlsCertificateMutationPayload,
|
||||
} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
|
||||
import type {FileImportFormValues, ManagedDomainFormValues, ManualImportFormValues,} from './schemas';
|
||||
import type {FileImportFormValues, ManualImportFormValues} from './schemas';
|
||||
|
||||
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
@@ -51,28 +48,6 @@ export function buildCertificateLabel(certificate: TlsCertificateItem) {
|
||||
: certificate.name;
|
||||
}
|
||||
|
||||
export function toManagedDomainPayload(
|
||||
values: ManagedDomainFormValues,
|
||||
): ManagedDomainMutationPayload {
|
||||
return {
|
||||
domain: values.domain.trim().toLowerCase(),
|
||||
cert_id: values.cert_id ? Number(values.cert_id) : null,
|
||||
enabled: values.enabled,
|
||||
remark: values.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toManagedDomainFormValues(
|
||||
domain: ManagedDomainItem,
|
||||
): ManagedDomainFormValues {
|
||||
return {
|
||||
domain: domain.domain,
|
||||
cert_id: domain.cert_id ? String(domain.cert_id) : '',
|
||||
enabled: domain.enabled,
|
||||
remark: domain.remark || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function toManualPayload(
|
||||
values: ManualImportFormValues,
|
||||
): TlsCertificateMutationPayload {
|
||||
@@ -100,20 +75,3 @@ export function toFilePayload(
|
||||
keyFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function isRouteRelatedToManagedDomain(
|
||||
managedDomain: string,
|
||||
route: ProxyRouteItem,
|
||||
) {
|
||||
const domains = route.domains.length > 0 ? route.domains : [route.primary_domain];
|
||||
return domains.some((routeDomain) => {
|
||||
if (managedDomain === routeDomain) {
|
||||
return true;
|
||||
}
|
||||
if (!managedDomain.startsWith('*.')) {
|
||||
return false;
|
||||
}
|
||||
const suffix = managedDomain.slice(2);
|
||||
return routeDomain.endsWith(`.${suffix}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {ArrowLeft, Globe, Plus, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
|
||||
import type {TlsCertificateItem} from '@/lib/services/openflare';
|
||||
import {ProxyRouteService, TlsCertificateService, WebsiteService,} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import {getUpstreamSummary} from '@/app/(main)/proxy-routes/components/helpers';
|
||||
|
||||
import {CertificateApplyDialog} from '../components/certificate-apply-dialog';
|
||||
import {CertificateDetailDialog} from '../components/certificate-detail-dialog';
|
||||
import {CertificateEditorDialog} from '../components/certificate-editor-dialog';
|
||||
import {CertificateImportDialog} from '../components/certificate-import-dialog';
|
||||
import {WebsiteStatusBadge} from '../components/status-badge';
|
||||
import {WebsiteEditorDialog} from '../components/website-editor-dialog';
|
||||
import {
|
||||
buildCertificateLabel,
|
||||
getCertificateStatus,
|
||||
getErrorMessage,
|
||||
isRouteRelatedToManagedDomain,
|
||||
} from '../components/website-utils';
|
||||
|
||||
const domainsQueryKey = ['openflare', 'managed-domains'];
|
||||
const certificatesQueryKey = ['openflare', 'tls-certificates'];
|
||||
|
||||
export function WebsiteDetailPageClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const websiteId = searchParams.get('id')?.trim() ?? '';
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [certEditorOpen, setCertEditorOpen] = useState(false);
|
||||
const [deleteWebsiteOpen, setDeleteWebsiteOpen] = useState(false);
|
||||
const [deleteCertOpen, setDeleteCertOpen] = useState(false);
|
||||
const [preferredCertificateId, setPreferredCertificateId] = useState<number | null>(null);
|
||||
const [convertCertificate, setConvertCertificate] = useState<TlsCertificateItem | null>(null);
|
||||
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: domainsQueryKey,
|
||||
queryFn: () => WebsiteService.list(),
|
||||
});
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: certificatesQueryKey,
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
});
|
||||
|
||||
const routesQuery = useQuery({
|
||||
queryKey: ['openflare', 'proxy-routes'],
|
||||
queryFn: () => ProxyRouteService.list(),
|
||||
});
|
||||
|
||||
const website = useMemo(
|
||||
() =>
|
||||
(domainsQuery.data ?? []).find((item) => String(item.id) === websiteId) ?? null,
|
||||
[domainsQuery.data, websiteId],
|
||||
);
|
||||
|
||||
const certificates = useMemo(
|
||||
() => certificatesQuery.data ?? [],
|
||||
[certificatesQuery.data],
|
||||
);
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((item) => [item.id, item])),
|
||||
[certificates],
|
||||
);
|
||||
|
||||
const certificate = website?.cert_id
|
||||
? (certificateMap.get(website.cert_id) ?? null)
|
||||
: null;
|
||||
|
||||
const relatedRoutes = useMemo(() => {
|
||||
if (!website) return [];
|
||||
return (routesQuery.data ?? []).filter((route) =>
|
||||
isRouteRelatedToManagedDomain(website.domain, route),
|
||||
);
|
||||
}, [routesQuery.data, website]);
|
||||
|
||||
const deleteDomainMutation = useMutation({
|
||||
mutationFn: (id: number) => WebsiteService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
|
||||
router.push('/websites');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
const deleteCertificateMutation = useMutation({
|
||||
mutationFn: (id: number) => TlsCertificateService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('证书已删除');
|
||||
setDeleteCertOpen(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
|
||||
queryClient.invalidateQueries({queryKey: domainsQueryKey}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
if (!websiteId) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="缺少网站 ID,请从网站列表进入详情页。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
domainsQuery.isLoading ||
|
||||
certificatesQuery.isLoading ||
|
||||
routesQuery.isLoading
|
||||
) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={Globe} description="加载网站详情中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (domainsQuery.isError) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<ErrorInline
|
||||
message={getErrorMessage(domainsQuery.error)}
|
||||
onRetry={() => void domainsQuery.refetch()}
|
||||
className="justify-center"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!website) {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-4">
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
|
||||
<Link href="/websites">
|
||||
<ArrowLeft className="size-4 mr-1" />
|
||||
返回网站列表
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="网站不存在,可能已被删除或 ID 无效。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const certificateStatus = certificate ? getCertificateStatus(certificate) : null;
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{website.domain}</h1>
|
||||
<p className="text-sm text-muted-foreground">网站详情</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href="/websites">
|
||||
<ArrowLeft className="size-3.5 mr-1" />
|
||||
返回
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑网站
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => setImportOpen(true)}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
添加证书
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setDeleteWebsiteOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
删除网站
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">网站信息</CardTitle>
|
||||
<CardDescription>当前网站的基础配置与托管信息。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">域名</p>
|
||||
<p className="mt-1 text-sm">{website.domain}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
创建时间
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{formatDateTime(website.created_at)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 md:col-span-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">备注</p>
|
||||
<p className="mt-1 text-sm">{website.remark || '暂无备注'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">证书信息</CardTitle>
|
||||
<CardDescription>
|
||||
当前网站绑定的默认证书信息。若还没有证书,可直接添加后回填。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{certificate ? (
|
||||
<div className="space-y-3">
|
||||
{certificateStatus ? (
|
||||
<WebsiteStatusBadge
|
||||
label={certificateStatus.label}
|
||||
tone={certificateStatus.tone}
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>证书名称:{buildCertificateLabel(certificate)}</p>
|
||||
<p>生效时间:{formatDateTime(certificate.not_before)}</p>
|
||||
<p>到期时间:{formatDateTime(certificate.not_after)}</p>
|
||||
<p>备注:{certificate.remark || '暂无备注'}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive"
|
||||
onClick={() => setDeleteCertOpen(true)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateWithBorder description="未绑定证书,点击右上角「添加证书」后可在编辑网站时直接选择。" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">关联规则</CardTitle>
|
||||
<CardDescription>命中当前网站域名的代理规则。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{relatedRoutes.length === 0 ? (
|
||||
<EmptyStateWithBorder description="暂无关联规则。" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>规则域名</TableHead>
|
||||
<TableHead>源站</TableHead>
|
||||
<TableHead>HTTPS</TableHead>
|
||||
<TableHead>证书</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{relatedRoutes.map((route) => {
|
||||
const routeCertificate = route.cert_id
|
||||
? (certificateMap.get(route.cert_id) ?? null)
|
||||
: null;
|
||||
const matchedDomain =
|
||||
route.domains.find((d) => d === website.domain) ?? route.primary_domain;
|
||||
|
||||
return (
|
||||
<TableRow key={route.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">{matchedDomain}</p>
|
||||
<WebsiteStatusBadge
|
||||
label={
|
||||
matchedDomain === website.domain
|
||||
? '直接关联'
|
||||
: '被网站覆盖'
|
||||
}
|
||||
tone={matchedDomain === website.domain ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-56 break-all">
|
||||
{getUpstreamSummary(route)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WebsiteStatusBadge
|
||||
label={route.enable_https ? '启用 HTTPS' : 'HTTP'}
|
||||
tone={route.enable_https ? 'success' : 'warning'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{routeCertificate ? routeCertificate.name : '未绑定证书'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WebsiteStatusBadge
|
||||
label={route.enabled ? '启用' : '停用'}
|
||||
tone={route.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{route.remark || '暂无备注'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WebsiteEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
initialDomain={website}
|
||||
certificates={certificates}
|
||||
certificatesLoading={certificatesQuery.isLoading}
|
||||
preferredCertificateId={preferredCertificateId}
|
||||
onRequestImportCertificate={() => setImportOpen(true)}
|
||||
onSaved={() => toast.success('网站已更新')}
|
||||
/>
|
||||
|
||||
<CertificateImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={(imported) => {
|
||||
setPreferredCertificateId(imported.id);
|
||||
toast.success(`证书 ${imported.name} 已导入,可在编辑网站时直接应用`);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CertificateDetailDialog
|
||||
certificateId={certificate?.id ?? null}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
onEdit={() => {
|
||||
setDetailOpen(false);
|
||||
setCertEditorOpen(true);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setDetailOpen(false);
|
||||
setDeleteCertOpen(true);
|
||||
}}
|
||||
deleting={deleteCertificateMutation.isPending}
|
||||
/>
|
||||
|
||||
<CertificateEditorDialog
|
||||
certificateId={certificate?.id ?? null}
|
||||
open={certEditorOpen}
|
||||
onOpenChange={setCertEditorOpen}
|
||||
onSaved={(updated) => toast.success(`证书 ${updated.name} 已更新`)}
|
||||
onConvert={(manualCertificate) => {
|
||||
setCertEditorOpen(false);
|
||||
setConvertCertificate(manualCertificate);
|
||||
}}
|
||||
/>
|
||||
|
||||
{convertCertificate ? (
|
||||
<CertificateApplyDialog
|
||||
open
|
||||
onOpenChange={(open) => !open && setConvertCertificate(null)}
|
||||
mode="convert-upload"
|
||||
certificate={convertCertificate}
|
||||
onApplied={(converted) => {
|
||||
setConvertCertificate(null);
|
||||
toast.success(`证书 ${converted.name} 转换申请已提交`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AlertDialog open={deleteWebsiteOpen} onOpenChange={setDeleteWebsiteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除网站 {website.domain} 吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteDomainMutation.mutate(website.id)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={deleteCertOpen} onOpenChange={setDeleteCertOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除证书</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除证书 {certificate?.name} 吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => certificate && deleteCertificateMutation.mutate(certificate.id)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import {Suspense} from 'react';
|
||||
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
|
||||
import {WebsiteDetailPageClient} from './page-client';
|
||||
|
||||
function WebsiteDetailPageFallback() {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-full max-w-xl" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebsiteDetailPage() {
|
||||
return (
|
||||
<Suspense fallback={<WebsiteDetailPageFallback />}>
|
||||
<WebsiteDetailPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,211 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Globe, Plus, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {Eye, Globe, Plus, Search} from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import type {ManagedDomainItem} from '@/lib/services/openflare';
|
||||
import {TlsCertificateService, WebsiteService} from '@/lib/services/openflare';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip';
|
||||
import {ZoneService, zoneQueryKey} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
|
||||
import {CertificateImportDialog} from './components/certificate-import-dialog';
|
||||
import {WebsiteStatusBadge} from './components/status-badge';
|
||||
import {WebsiteEditorDialog} from './components/website-editor-dialog';
|
||||
import {buildCertificateLabel, getErrorMessage, getMatchTypeMeta,} from './components/website-utils';
|
||||
|
||||
const domainsQueryKey = ['openflare', 'managed-domains'];
|
||||
const certificatesQueryKey = ['openflare', 'tls-certificates'];
|
||||
import {ZoneEditorDialog} from './[zoneId]/components/zone-editor-dialog';
|
||||
import {getErrorMessage} from './components/website-utils';
|
||||
|
||||
export default function WebsitesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState('');
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [preferredCertificateId, setPreferredCertificateId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ManagedDomainItem | null>(null);
|
||||
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: domainsQueryKey,
|
||||
queryFn: () => WebsiteService.list(),
|
||||
const zonesQuery = useQuery({
|
||||
queryKey: zoneQueryKey,
|
||||
queryFn: () => ZoneService.list(),
|
||||
});
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: certificatesQueryKey,
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => WebsiteService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('网站已删除');
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
const domains = useMemo(() => domainsQuery.data ?? [], [domainsQuery.data]);
|
||||
const certificates = useMemo(
|
||||
() => certificatesQuery.data ?? [],
|
||||
[certificatesQuery.data],
|
||||
);
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((item) => [item.id, item])),
|
||||
[certificates],
|
||||
);
|
||||
const zones = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
const list = zonesQuery.data ?? [];
|
||||
if (!keyword) {
|
||||
return list;
|
||||
}
|
||||
return list.filter((zone) => zone.domain.toLowerCase().includes(keyword));
|
||||
}, [search, zonesQuery.data]);
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-4 py-6 px-1">
|
||||
<div className="flex items-center justify-between gap-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">网站</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新增网站
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
新增 Zone
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold">网站列表</CardTitle>
|
||||
<CardDescription>查看网站绑定的证书、启用状态和更新时间。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{domainsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Globe} description="加载网站列表中..." />
|
||||
) : domainsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<ErrorInline
|
||||
message={getErrorMessage(domainsQuery.error)}
|
||||
onRetry={() => void domainsQuery.refetch()}
|
||||
className="justify-center"
|
||||
/>
|
||||
</div>
|
||||
) : domains.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="暂无网站,点击右上角「新增网站」开始录入。"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{domains.map((domain) => {
|
||||
const certificate = domain.cert_id
|
||||
? (certificateMap.get(domain.cert_id) ?? null)
|
||||
: null;
|
||||
const matchType = getMatchTypeMeta(domain.domain);
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="搜索 Zone 根域"
|
||||
placeholder="搜索 Zone 根域"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={domain.id}
|
||||
className="rounded-lg border bg-card p-4 space-y-3"
|
||||
{zonesQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Globe} description="加载 Zone 列表中..." />
|
||||
) : zonesQuery.isError ? (
|
||||
<div className="rounded-lg border border-dashed p-8">
|
||||
<ErrorInline
|
||||
className="justify-center"
|
||||
message={getErrorMessage(zonesQuery.error)}
|
||||
onRetry={() => void zonesQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : zones.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description={
|
||||
search
|
||||
? '未找到匹配的 Zone。'
|
||||
: '暂无 Zone,点击右上角「新增 Zone」开始录入。'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-dashed shadow-none">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Table className="w-full min-w-full caption-bottom text-sm">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="h-8 w-[90px] whitespace-nowrap py-2">
|
||||
ID
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[180px] whitespace-nowrap py-2">
|
||||
根域
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[100px] whitespace-nowrap py-2">
|
||||
域名数
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[140px] whitespace-nowrap py-2">
|
||||
创建时间
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[140px] whitespace-nowrap py-2">
|
||||
更新时间
|
||||
</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 h-8 w-[90px] bg-background py-2 text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{zones.map((zone) => (
|
||||
<TableRow
|
||||
key={zone.id}
|
||||
className="group cursor-pointer border-dashed hover:bg-muted/30"
|
||||
onClick={() => router.push(`/websites/${zone.id}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-sm font-semibold truncate">{domain.domain}</h2>
|
||||
<WebsiteStatusBadge label={matchType.label} tone={matchType.tone} />
|
||||
<WebsiteStatusBadge
|
||||
label={domain.enabled ? '启用' : '停用'}
|
||||
tone={domain.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{domain.remark || '暂无备注'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
绑定证书:
|
||||
{certificate
|
||||
? buildCertificateLabel(certificate)
|
||||
: '未绑定证书'}
|
||||
</p>
|
||||
<TableCell className="py-1 font-mono text-[11px] text-muted-foreground">
|
||||
{zone.id}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[11px] font-medium">
|
||||
{zone.domain}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[11px] text-muted-foreground">
|
||||
{zone.domain_count ?? 0}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(zone.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(zone.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-background py-1 text-center"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/websites/${zone.id}`}>
|
||||
<Eye className="size-3" />
|
||||
<span className="sr-only">管理</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
管理 Zone
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href={`/websites/detail?id=${domain.id}`}>
|
||||
详情
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive"
|
||||
onClick={() => setDeleteTarget(domain)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WebsiteEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
certificates={certificates}
|
||||
certificatesLoading={certificatesQuery.isLoading}
|
||||
preferredCertificateId={preferredCertificateId}
|
||||
onRequestImportCertificate={() => setImportOpen(true)}
|
||||
onSaved={(_, mode) => {
|
||||
setPreferredCertificateId(null);
|
||||
toast.success(mode === 'create' ? '网站已创建' : '网站已更新');
|
||||
}}
|
||||
/>
|
||||
|
||||
<CertificateImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={(certificate) => {
|
||||
setPreferredCertificateId(certificate.id);
|
||||
toast.success(`证书 ${certificate.name} 已导入,可直接用于当前网站`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除网站 {deleteTarget?.domain} 吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ZoneEditorDialog open={editorOpen} onOpenChange={setEditorOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-inter), 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-sans: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'PingFang SC', 'Microsoft YaHei', monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {Metadata} from "next";
|
||||
import {Inter} from "next/font/google";
|
||||
import {Toaster} from "@/components/ui/sonner";
|
||||
import {ThemeProvider} from "@/components/layout/theme-provider";
|
||||
import {CustomThemeProvider} from "@/lib/theme";
|
||||
@@ -11,12 +10,6 @@ import {SiteTitleUpdater} from "@/components/providers/title-updater";
|
||||
import {RobotsMeta} from "@/components/layout/robots-meta";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenFlare",
|
||||
description: "OpenFlare 边缘节点与反向代理管理平台",
|
||||
@@ -30,7 +23,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={`hide-scrollbar font-sans ${inter.variable}`}
|
||||
className="hide-scrollbar font-sans"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
|
||||
@@ -14,7 +14,14 @@ import {
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
UserRound
|
||||
UserRound,
|
||||
LayoutDashboard,
|
||||
Route,
|
||||
Server,
|
||||
Globe,
|
||||
GitBranch,
|
||||
ScrollText,
|
||||
Gauge
|
||||
} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
@@ -38,9 +45,21 @@ interface MenuGroup {
|
||||
|
||||
const MENU_GROUPS: MenuGroup[] = [
|
||||
{
|
||||
name: "基础菜单",
|
||||
name: "业务菜单",
|
||||
items: [
|
||||
{ path: "/", label: "总览", description: "OpenFlare 控制台总览", icon: Home },
|
||||
{ path: "/", label: "数据看板", description: "查看边缘节点请求、访客与流量等多维度图表看板", icon: LayoutDashboard, readOnly: true },
|
||||
{ path: "/nodes", label: "节点管理", description: "注册与管理反向代理边缘节点服务状态", icon: Server },
|
||||
{ path: "/proxy-routes", label: "规则管理", description: "配置和发布反代路由规则、负载均衡与源站绑定", icon: Route },
|
||||
{ path: "/websites", label: "域名列表", description: "配置站点根域名并查看关联网站详情", icon: Globe },
|
||||
{ path: "/certificates", label: "TLS证书", description: "管理和下发网站 SSL/TLS 证书", icon: ShieldCheck },
|
||||
{ path: "/dns-accounts", label: "DNS账号", description: "配置 Cloudflare/Alidns 等 DNS 服务商 API 凭证", icon: Settings },
|
||||
{ path: "/origins", label: "源站地址", description: "集中维护和管理后端业务源站服务器组", icon: Home },
|
||||
{ path: "/waf", label: "WAF 管理", description: "配置 Web 应用防火墙自定义规则与频率限制", icon: ShieldCheck },
|
||||
{ path: "/ip-groups", label: "IP 组", description: "管理黑白名单 IP 集合,用于 WAF 规则过滤", icon: Layers },
|
||||
{ path: "/pages", label: "Pages 静态站", description: "托管和发布静态网页项目,支持自动关联域名", icon: FileText },
|
||||
{ path: "/config-versions", label: "版本发布", description: "查看规则快照版本并进行配置热更新分发与回滚", icon: GitBranch },
|
||||
{ path: "/access-logs", label: "访问日志", description: "多维度实时浏览边缘节点请求访问明细", icon: ScrollText },
|
||||
{ path: "/performance", label: "性能调优", description: "配置缓存、图片压缩等页面性能加速策略", icon: Gauge },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import {usePathname} from 'next/navigation';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {ChevronRight} from 'lucide-react';
|
||||
|
||||
import {Collapsible, CollapsibleContent, CollapsibleTrigger} from '@/components/ui/collapsible';
|
||||
@@ -20,6 +20,24 @@ import {
|
||||
openflareSidebarNav,
|
||||
isNavGroupActive,
|
||||
} from '@/lib/navigation/openflare-nav';
|
||||
import {usePublicConfig} from '@/hooks/use-public-config';
|
||||
|
||||
function parseMenuDisplayConfig(raw: string | undefined): Record<string, boolean> {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
|
||||
return Object.entries(parsed).reduce<Record<string, boolean>>((result, [key, value]) => {
|
||||
if (typeof value === 'boolean') {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function SidebarNavGroupMenuItem({
|
||||
group,
|
||||
@@ -81,21 +99,30 @@ export function OpenFlareSidebarMenu({
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const { config } = usePublicConfig();
|
||||
const displayConfig = useMemo(() => parseMenuDisplayConfig(config?.menu_display_config), [config]);
|
||||
|
||||
return (
|
||||
<SidebarMenu className="gap-1">
|
||||
{openflareSidebarNav.map((entry) => {
|
||||
if (entry.kind === 'group') {
|
||||
const filteredItems = entry.items.filter((item) => displayConfig[item.url] !== false);
|
||||
if (filteredItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SidebarNavGroupMenuItem
|
||||
key={entry.title}
|
||||
group={entry}
|
||||
group={{ ...entry, items: filteredItems }}
|
||||
pathname={pathname}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayConfig[entry.url] === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={entry.title}>
|
||||
<SidebarMenuButton
|
||||
|
||||
@@ -41,7 +41,8 @@ import {
|
||||
TlsCertificateService,
|
||||
UptimeKumaService,
|
||||
WafService,
|
||||
WebsiteService,
|
||||
ZoneDomainService,
|
||||
ZoneService,
|
||||
} from './openflare';
|
||||
|
||||
const services = {
|
||||
@@ -66,7 +67,8 @@ const services = {
|
||||
openflareApplyLog: ApplyLogService,
|
||||
openflareDashboard: DashboardService,
|
||||
openflareWaf: WafService,
|
||||
openflareWebsite: WebsiteService,
|
||||
openflareZone: ZoneService,
|
||||
openflareZoneDomain: ZoneDomainService,
|
||||
openflareTls: TlsCertificateService,
|
||||
openflareDns: DnsAccountService,
|
||||
openflarePages: PagesService,
|
||||
@@ -187,7 +189,6 @@ export {
|
||||
ApplyLogService,
|
||||
DashboardService,
|
||||
WafService,
|
||||
WebsiteService,
|
||||
TlsCertificateService,
|
||||
DnsAccountService,
|
||||
PagesService,
|
||||
@@ -201,6 +202,7 @@ export {
|
||||
export type {
|
||||
NodeItem,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteZoneDomain,
|
||||
ProxyRouteConfigSection,
|
||||
ConfigVersionSummary,
|
||||
ConfigVersionDetail,
|
||||
@@ -211,7 +213,6 @@ export type {
|
||||
WAFIPGroup,
|
||||
WAFRuleGroup,
|
||||
WAFSiteRuleGroups,
|
||||
ManagedDomainItem,
|
||||
TlsCertificateItem,
|
||||
DnsAccountItem,
|
||||
PagesProject,
|
||||
|
||||
@@ -6,7 +6,7 @@ export { ConfigVersionService } from './config-version.service';
|
||||
export { ApplyLogService } from './apply-log.service';
|
||||
export { DashboardService } from './dashboard.service';
|
||||
export { WafService } from './waf.service';
|
||||
export { WebsiteService } from './website.service';
|
||||
export { ZoneDomainService, ZoneService, zoneQueryKey } from './zone.service';
|
||||
export { TlsCertificateService } from './tls-certificate.service';
|
||||
export { DnsAccountService } from './dns-account.service';
|
||||
export { PagesService } from './pages.service';
|
||||
@@ -49,6 +49,7 @@ export type {
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
ProxyRoutePoWConfig,
|
||||
ProxyRouteZoneDomain,
|
||||
ReleaseChannel,
|
||||
SupportFile,
|
||||
DashboardOverview,
|
||||
@@ -102,9 +103,14 @@ export type {
|
||||
AcmeAccountItem,
|
||||
DnsAccountItem,
|
||||
DnsAccountMutationPayload,
|
||||
ManagedDomainItem,
|
||||
ManagedDomainMatchResult,
|
||||
ManagedDomainMutationPayload,
|
||||
ZoneDomainItem,
|
||||
ZoneDomainMutationPayload,
|
||||
ZoneItem,
|
||||
ZoneMutationPayload,
|
||||
ZoneOverview,
|
||||
ZoneStats,
|
||||
ZoneStatsPoint,
|
||||
ZoneStatsRange,
|
||||
TlsCertificateApplyPayload,
|
||||
TlsCertificateContentItem,
|
||||
TlsCertificateDetailItem,
|
||||
@@ -127,7 +133,7 @@ import {PagesService} from './pages.service';
|
||||
import {ProxyRouteService} from './proxy-route.service';
|
||||
import {TlsCertificateService} from './tls-certificate.service';
|
||||
import {WafService} from './waf.service';
|
||||
import {WebsiteService} from './website.service';
|
||||
import {ZoneDomainService, ZoneService} from './zone.service';
|
||||
|
||||
export const openflareServices = {
|
||||
node: NodeService,
|
||||
@@ -136,7 +142,8 @@ export const openflareServices = {
|
||||
applyLog: ApplyLogService,
|
||||
dashboard: DashboardService,
|
||||
waf: WafService,
|
||||
website: WebsiteService,
|
||||
zone: ZoneService,
|
||||
zoneDomain: ZoneDomainService,
|
||||
tlsCertificate: TlsCertificateService,
|
||||
dnsAccount: DnsAccountService,
|
||||
pages: PagesService,
|
||||
|
||||
@@ -228,13 +228,19 @@ export interface ProxyRoutePoWConfig {
|
||||
blacklist: ProxyRoutePoWListConfig;
|
||||
}
|
||||
|
||||
/** Route-bound Zone domain as returned by proxy-route APIs. */
|
||||
export interface ProxyRouteZoneDomain {
|
||||
id: number;
|
||||
zone_id: number;
|
||||
domain: string;
|
||||
cert_id: number | null;
|
||||
}
|
||||
|
||||
export interface ProxyRouteItem {
|
||||
id: number;
|
||||
site_name: string;
|
||||
domain: string;
|
||||
domains: string[];
|
||||
primary_domain: string;
|
||||
domain_count: number;
|
||||
zone_domain_ids: number[];
|
||||
zone_domains: ProxyRouteZoneDomain[];
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_host: string;
|
||||
@@ -242,9 +248,6 @@ export interface ProxyRouteItem {
|
||||
upstream_list: string[];
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
cert_id: number | null;
|
||||
cert_ids: number[];
|
||||
domain_cert_ids: number[];
|
||||
redirect_http: boolean;
|
||||
limit_conn_per_server: number;
|
||||
limit_conn_per_ip: number;
|
||||
@@ -258,7 +261,6 @@ export interface ProxyRouteItem {
|
||||
basic_auth_enabled: boolean;
|
||||
basic_auth_username: string;
|
||||
basic_auth_password: string;
|
||||
remark: string;
|
||||
upstream_type: 'direct' | 'tunnel' | 'pages';
|
||||
tunnel_node_id?: number | null;
|
||||
tunnel_id?: number | null;
|
||||
@@ -271,8 +273,7 @@ export interface ProxyRouteItem {
|
||||
|
||||
export interface ProxyRouteMutationPayload {
|
||||
site_name?: string;
|
||||
domain: string;
|
||||
domains?: string[];
|
||||
zone_domain_ids: number[];
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_scheme: 'http' | 'https';
|
||||
@@ -283,9 +284,6 @@ export interface ProxyRouteMutationPayload {
|
||||
upstreams: string[];
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
cert_id: number | null;
|
||||
cert_ids?: number[];
|
||||
domain_cert_ids?: number[];
|
||||
redirect_http: boolean;
|
||||
limit_conn_per_server?: number;
|
||||
limit_conn_per_ip?: number;
|
||||
@@ -297,7 +295,6 @@ export interface ProxyRouteMutationPayload {
|
||||
basic_auth_enabled: boolean;
|
||||
basic_auth_username?: string;
|
||||
basic_auth_password?: string;
|
||||
remark: string;
|
||||
upstream_type?: 'direct' | 'tunnel' | 'pages';
|
||||
tunnel_node_id?: number | null;
|
||||
tunnel_id?: number | null;
|
||||
@@ -709,7 +706,6 @@ export interface WAFRuleGroup {
|
||||
region_blacklist: string[];
|
||||
pow_enabled: boolean;
|
||||
pow_config: ProxyRoutePoWConfig;
|
||||
remark: string;
|
||||
applied_site_ids: number[];
|
||||
applied_site_count: number;
|
||||
created_at: string;
|
||||
@@ -731,7 +727,6 @@ export interface WAFRuleGroupPayload {
|
||||
region_blacklist: string[];
|
||||
pow_enabled: boolean;
|
||||
pow_config: ProxyRoutePoWConfig;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface WAFSiteRuleGroups {
|
||||
@@ -766,7 +761,6 @@ export interface WAFIPGroup {
|
||||
next_sync_at?: string;
|
||||
last_sync_status: string;
|
||||
last_sync_message: string;
|
||||
remark: string;
|
||||
referenced_by_rule_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -782,7 +776,6 @@ export interface WAFIPGroupPayload {
|
||||
subscription_format: WAFIPGroupSubscriptionFormat;
|
||||
subscription_mapping_rule: string;
|
||||
sync_interval_minutes: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface WAFIPGroupSyncResult {
|
||||
@@ -962,38 +955,66 @@ export interface DashboardOverviewCompact {
|
||||
|
||||
// ==================== Websites / TLS / DNS ====================
|
||||
|
||||
export interface ManagedDomainItem {
|
||||
export interface ZoneItem {
|
||||
id: number;
|
||||
domain: string;
|
||||
cert_id: number | null;
|
||||
enabled: boolean;
|
||||
remark: string;
|
||||
/** Present on list API; may be omitted on nested zone objects. */
|
||||
domain_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMutationPayload {
|
||||
export interface ZoneMutationPayload {
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface ZoneDomainItem {
|
||||
id: number;
|
||||
zone_id: number;
|
||||
proxy_route_id: number | null;
|
||||
domain: string;
|
||||
cert_id: number | null;
|
||||
enabled: boolean;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchCandidate {
|
||||
managed_domain_id: number;
|
||||
export interface ZoneDomainMutationPayload {
|
||||
domain: string;
|
||||
match_type: 'exact' | 'wildcard' | string;
|
||||
certificate_id: number;
|
||||
certificate_name: string;
|
||||
cert_id: number | null;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchResult {
|
||||
domain: string;
|
||||
matched: boolean;
|
||||
candidate?: ManagedDomainMatchCandidate;
|
||||
candidates: ManagedDomainMatchCandidate[];
|
||||
export interface ZoneOverview {
|
||||
zone: ZoneItem;
|
||||
domains: ZoneDomainItem[];
|
||||
}
|
||||
|
||||
export type ZoneStatsRange = '24h' | '7d' | '30d';
|
||||
|
||||
export interface ZoneStatsPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
unique_visitors: number;
|
||||
bytes_sent: number;
|
||||
}
|
||||
|
||||
export interface ZoneStats {
|
||||
range: ZoneStatsRange;
|
||||
range_hours: number;
|
||||
window_started_at: string;
|
||||
window_ended_at: string;
|
||||
bucket_minutes: number;
|
||||
unique_visitors: number;
|
||||
request_count: number;
|
||||
bytes_sent: number;
|
||||
domain_count: number;
|
||||
available: boolean;
|
||||
series: ZoneStatsPoint[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export interface TlsCertificateItem {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -1078,4 +1099,4 @@ export interface DnsAccountMutationPayload {
|
||||
name: string;
|
||||
type: string;
|
||||
authorization: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import {OpenFlareBaseService} from './base.service';
|
||||
import type {
|
||||
ManagedDomainItem,
|
||||
ManagedDomainMatchResult,
|
||||
ManagedDomainMutationPayload,
|
||||
} from './types';
|
||||
|
||||
export class WebsiteService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath: string = '/api/v1/d/managed-domains';
|
||||
|
||||
static async list(): Promise<ManagedDomainItem[]> {
|
||||
return this.get<ManagedDomainItem[]>('/');
|
||||
}
|
||||
|
||||
static async create(
|
||||
payload: ManagedDomainMutationPayload,
|
||||
): Promise<ManagedDomainItem> {
|
||||
return this.post<ManagedDomainItem>('/', payload);
|
||||
}
|
||||
|
||||
static async update(
|
||||
id: number,
|
||||
payload: ManagedDomainMutationPayload,
|
||||
): Promise<ManagedDomainItem> {
|
||||
return this.post<ManagedDomainItem>(`/${id}/update`, payload);
|
||||
}
|
||||
|
||||
static async deleteById(id: number): Promise<void> {
|
||||
return this.post<void>(`/${id}/delete`);
|
||||
}
|
||||
|
||||
static async match(domain: string): Promise<ManagedDomainMatchResult> {
|
||||
return this.get<ManagedDomainMatchResult>('/match', {domain});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {OpenFlareBaseService} from './base.service'
|
||||
import type {
|
||||
ZoneDomainItem,
|
||||
ZoneDomainMutationPayload,
|
||||
ZoneItem,
|
||||
ZoneMutationPayload,
|
||||
ZoneOverview,
|
||||
ZoneStats,
|
||||
ZoneStatsRange,
|
||||
} from './types'
|
||||
|
||||
export const zoneQueryKey = ['openflare', 'zones'] as const
|
||||
|
||||
export class ZoneService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath = '/api/v1/d/zones'
|
||||
|
||||
static list(): Promise<ZoneItem[]> { return this.get<ZoneItem[]>('/') }
|
||||
static getOverview(id: number): Promise<ZoneOverview> { return this.get<ZoneOverview>(`/${id}/overview`) }
|
||||
static getStats(id: number, range: ZoneStatsRange = '24h'): Promise<ZoneStats> {
|
||||
return this.get<ZoneStats>(`/${id}/stats`, {range})
|
||||
}
|
||||
static create(payload: ZoneMutationPayload): Promise<ZoneItem> { return this.post<ZoneItem>('/', payload) }
|
||||
static update(id: number, payload: ZoneMutationPayload): Promise<ZoneItem> { return this.post<ZoneItem>(`/${id}/update`, payload) }
|
||||
static deleteById(id: number): Promise<void> { return this.post<void>(`/${id}/delete`) }
|
||||
}
|
||||
|
||||
export class ZoneDomainService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath = '/api/v1/d/zones'
|
||||
|
||||
static create(zoneId: number, payload: ZoneDomainMutationPayload): Promise<ZoneDomainItem> { return this.post<ZoneDomainItem>(`/${zoneId}/domains`, payload) }
|
||||
static update(zoneId: number, id: number, payload: ZoneDomainMutationPayload): Promise<ZoneDomainItem> { return this.post<ZoneDomainItem>(`/${zoneId}/domains/${id}/update`, payload) }
|
||||
static deleteById(zoneId: number, id: number): Promise<void> { return this.post<void>(`/${zoneId}/domains/${id}/delete`) }
|
||||
}
|
||||
@@ -82,13 +82,18 @@
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1198
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -0,0 +1,28 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {resolveZoneDomainInput} from '@/app/(main)/websites/components/resolve-zone-domain-input';
|
||||
|
||||
describe('resolveZoneDomainInput', () => {
|
||||
it('maps short label and apex @ under zone root', () => {
|
||||
expect(resolveZoneDomainInput('api', 'example.com')).toEqual({
|
||||
domain: 'api.example.com',
|
||||
});
|
||||
expect(resolveZoneDomainInput('@', 'example.com')).toEqual({
|
||||
domain: 'example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts full FQDN under the zone', () => {
|
||||
expect(resolveZoneDomainInput('www.api.example.com', 'example.com')).toEqual({
|
||||
domain: 'www.api.example.com',
|
||||
});
|
||||
expect(resolveZoneDomainInput('example.com', 'example.com')).toEqual({
|
||||
domain: 'example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects foreign domains and wildcards', () => {
|
||||
expect(resolveZoneDomainInput('evil.com', 'example.com').error).toBeTruthy();
|
||||
expect(resolveZoneDomainInput('*.example.com', 'example.com').error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||
import {fireEvent, render, screen} from '@testing-library/react';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import WebsitesPage from '@/app/(main)/websites/page';
|
||||
import {ZoneService} from '@/lib/services/openflare';
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({children, href}: {children: React.ReactNode; href: string}) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({push: vi.fn()}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/services/openflare')>();
|
||||
return {...actual, ZoneService: {list: vi.fn()}};
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WebsitesPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('WebsitesPage', () => {
|
||||
it('filters zones, shows domain counts, and links to stable ID routes', async () => {
|
||||
vi.mocked(ZoneService.list).mockResolvedValue([
|
||||
{
|
||||
id: 42,
|
||||
domain: 'example.com',
|
||||
domain_count: 3,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: 43,
|
||||
domain: 'another.com',
|
||||
domain_count: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
]);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('example.com')).toBeVisible();
|
||||
expect(screen.getByText('3')).toBeVisible();
|
||||
expect(screen.getByText('0')).toBeVisible();
|
||||
expect(screen.getByRole('columnheader', {name: '根域'})).toBeVisible();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索 Zone 根域'), {
|
||||
target: {value: 'example'},
|
||||
});
|
||||
expect(screen.getByRole('link', {name: '管理'})).toHaveAttribute(
|
||||
'href',
|
||||
'/websites/42',
|
||||
);
|
||||
expect(screen.queryByText('another.com')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||
import {render, screen} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import {ZoneDomainSelector} from '@/app/(main)/proxy-routes/components/zone-domain-selector';
|
||||
import type {ZoneDomainItem, ZoneItem} from '@/lib/services/openflare';
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({children, href}: {children: React.ReactNode; href: string}) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const zones: ZoneItem[] = [
|
||||
{id: 1, domain: 'example.com', created_at: '', updated_at: ''},
|
||||
];
|
||||
|
||||
const domains: ZoneDomainItem[] = [
|
||||
{
|
||||
id: 7,
|
||||
zone_id: 1,
|
||||
proxy_route_id: null,
|
||||
domain: 'api.example.com',
|
||||
cert_id: 9,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
zone_id: 1,
|
||||
proxy_route_id: 99,
|
||||
domain: 'bound.example.com',
|
||||
cert_id: null,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
];
|
||||
|
||||
function renderSelector(ui: React.ReactElement) {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe('ZoneDomainSelector', () => {
|
||||
it('renders domains and toggles selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
renderSelector(
|
||||
<ZoneDomainSelector
|
||||
value={[7]}
|
||||
onChange={onChange}
|
||||
domains={domains}
|
||||
zones={zones}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('api.example.com')).toBeVisible();
|
||||
// Bound to another route — hidden by default
|
||||
expect(screen.queryByText('bound.example.com')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', {name: /快捷新增域名/})).toBeVisible();
|
||||
|
||||
await user.click(screen.getByText('api.example.com'));
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('hides domains bound to another route', () => {
|
||||
renderSelector(
|
||||
<ZoneDomainSelector
|
||||
value={[]}
|
||||
onChange={vi.fn()}
|
||||
domains={domains}
|
||||
zones={zones}
|
||||
currentRouteId={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('api.example.com')).toBeVisible();
|
||||
expect(screen.queryByText('bound.example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still shows domains already bound to the current route', () => {
|
||||
renderSelector(
|
||||
<ZoneDomainSelector
|
||||
value={[8]}
|
||||
onChange={vi.fn()}
|
||||
domains={domains}
|
||||
zones={zones}
|
||||
currentRouteId={99}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('bound.example.com')).toBeVisible();
|
||||
expect(screen.getByText('api.example.com')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||
import {render, screen, waitFor} from '@testing-library/react';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import {ZonePageClient} from '@/app/(main)/websites/[zoneId]/page-client';
|
||||
import {ProxyRouteService, TlsCertificateService, ZoneService} from '@/lib/services/openflare';
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
|
||||
let mockZoneId = '42';
|
||||
let mockParamZoneId = '42';
|
||||
|
||||
const replaceMock = vi.fn();
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({children, href}: {children: React.ReactNode; href: string}) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({replace: replaceMock, back: vi.fn()}),
|
||||
usePathname: () => `/websites/${mockZoneId}`,
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useParams: () => ({zoneId: mockParamZoneId}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/services/openflare')>();
|
||||
return {
|
||||
...actual,
|
||||
ZoneService: {
|
||||
getOverview: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
deleteById: vi.fn(),
|
||||
list: vi.fn(),
|
||||
},
|
||||
TlsCertificateService: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
ProxyRouteService: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function renderPage(zoneId: number, paramZoneId = zoneId) {
|
||||
mockZoneId = String(zoneId);
|
||||
mockParamZoneId = String(paramZoneId);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {retry: false, gcTime: 0},
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ZonePageClient />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ZonePageClient', () => {
|
||||
beforeEach(() => {
|
||||
mockParamZoneId = mockZoneId;
|
||||
vi.mocked(ZoneService.getOverview).mockReset();
|
||||
vi.mocked(ZoneService.getStats).mockReset();
|
||||
vi.mocked(ZoneService.getStats).mockResolvedValue({
|
||||
range: '24h',
|
||||
range_hours: 24,
|
||||
window_started_at: new Date().toISOString(),
|
||||
window_ended_at: new Date().toISOString(),
|
||||
bucket_minutes: 60,
|
||||
unique_visitors: 0,
|
||||
request_count: 0,
|
||||
bytes_sent: 0,
|
||||
domain_count: 0,
|
||||
available: true,
|
||||
series: [],
|
||||
});
|
||||
vi.mocked(ZoneService.deleteById).mockReset();
|
||||
vi.mocked(TlsCertificateService.list).mockReset();
|
||||
vi.mocked(TlsCertificateService.list).mockResolvedValue([]);
|
||||
vi.mocked(ProxyRouteService.list).mockReset();
|
||||
vi.mocked(ProxyRouteService.list).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('loads the overview by stable ID and exposes all tabs including empty domains', async () => {
|
||||
vi.mocked(ZoneService.getOverview).mockImplementation(async () => ({
|
||||
zone: {id: 42, domain: 'example.com', created_at: '', updated_at: ''},
|
||||
domains: [],
|
||||
}));
|
||||
|
||||
renderPage(42);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ZoneService.getOverview).toHaveBeenCalledWith(42);
|
||||
});
|
||||
expect(await screen.findByRole('heading', {name: 'example.com'})).toBeVisible();
|
||||
expect(await screen.findByText('唯一访问者')).toBeVisible();
|
||||
expect(screen.getByText('请求总数')).toBeVisible();
|
||||
expect(screen.getByText('已提供的数据总计')).toBeVisible();
|
||||
expect(screen.getByRole('tab', {name: '域名 (0)'})).toBeVisible();
|
||||
expect(screen.getByRole('tab', {name: '证书 (0)'})).toBeVisible();
|
||||
expect(screen.queryByRole('tab', {name: '路由'})).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', {name: '设置'})).toBeVisible();
|
||||
});
|
||||
|
||||
it('uses the browser pathname ID when serving a static-export fallback shell', async () => {
|
||||
vi.mocked(ZoneService.getOverview).mockImplementation(async () => ({
|
||||
zone: {id: 42, domain: 'example.com', created_at: '', updated_at: ''},
|
||||
domains: [],
|
||||
}));
|
||||
|
||||
renderPage(42, 1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ZoneService.getOverview).toHaveBeenCalledWith(42);
|
||||
});
|
||||
expect(ZoneService.getOverview).not.toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('renders a not-found state for a missing Zone', async () => {
|
||||
vi.mocked(ZoneService.getOverview).mockRejectedValue(new Error('Zone 不存在'));
|
||||
|
||||
renderPage(42);
|
||||
|
||||
expect(
|
||||
await screen.findByText('网站不存在,可能已被删除或 ID 无效。'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders invalid ID empty state without calling the API', async () => {
|
||||
renderPage(0);
|
||||
expect(
|
||||
await screen.findByText('无效的网站 ID,请从网站列表进入详情页。'),
|
||||
).toBeVisible();
|
||||
expect(ZoneService.getOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from 'node:path'
|
||||
import {defineConfig} from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
},
|
||||
})
|
||||
@@ -6,16 +6,19 @@ package status
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/chwriter"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/risk_control"
|
||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||
"github.com/Rain-kl/Wavelet/internal/config"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/db/batchwriter"
|
||||
analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetClickHouseStatus returns ClickHouse operational metrics for administrators.
|
||||
// @Summary 获取 ClickHouse 运行指标
|
||||
// @Description 返回 ClickHouse parts、mutation、async_insert 队列等运维指标,需要管理员权限
|
||||
// @Description 返回 ClickHouse parts、mutation、async_insert 队列及进程内 batch writer 指标,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
@@ -36,5 +39,15 @@ func GetClickHouseStatus(c *gin.Context) {
|
||||
response.AbortInternal(c, "获取 ClickHouse 运行指标失败")
|
||||
return
|
||||
}
|
||||
stats.BatchWriters = collectBatchWriterStats()
|
||||
c.JSON(http.StatusOK, response.OK(stats))
|
||||
}
|
||||
}
|
||||
|
||||
func collectBatchWriterStats() []batchwriter.Stats {
|
||||
out := chwriter.WriterStats()
|
||||
if out == nil {
|
||||
out = make([]batchwriter.Stats, 0, 1)
|
||||
}
|
||||
out = append(out, risk_control.LogWriterStats())
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ func (aggregate *trafficAggregate) consume(line []byte) {
|
||||
Host: strings.TrimSpace(record.Host),
|
||||
Path: normalizeAccessLogPath(record.Path),
|
||||
StatusCode: record.Status,
|
||||
BytesSent: record.BytesSent,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
healthEventStatusActive = "active"
|
||||
healthEventStatusResolved = "resolved"
|
||||
healthSeverityInfo = "info"
|
||||
healthSeverityWarning = "warning"
|
||||
healthSeverityCritical = "critical"
|
||||
accessLogPathMaxLength = 100
|
||||
healthEventMessageMaxLength = 4096
|
||||
healthEventStatusActive = "active"
|
||||
healthEventStatusResolved = "resolved"
|
||||
healthSeverityInfo = "info"
|
||||
healthSeverityWarning = "warning"
|
||||
healthSeverityCritical = "critical"
|
||||
accessLogPathMaxLength = 100
|
||||
healthEventMessageMaxLength = 4096
|
||||
)
|
||||
|
||||
// PersistHeartbeatObservability stores profile, snapshots, traffic, access logs, and health events.
|
||||
@@ -221,6 +221,7 @@ func buildNodeAccessLogRecords(nodeID string, direct []NodeAccessLog, buffered [
|
||||
Host: strings.TrimSpace(item.Host),
|
||||
Path: truncateForDatabase(strings.TrimSpace(item.Path), accessLogPathMaxLength),
|
||||
StatusCode: item.StatusCode,
|
||||
BytesSent: item.BytesSent,
|
||||
}
|
||||
if resolver != nil {
|
||||
record.Region = resolver.Resolve(record.RemoteAddr)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildNodeAccessLogRecordsPreservesBytesSent(t *testing.T) {
|
||||
reportedAt := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
records, err := buildNodeAccessLogRecords("node-a", []NodeAccessLog{
|
||||
{
|
||||
LoggedAtUnix: reportedAt.Unix(),
|
||||
RemoteAddr: "203.0.113.10",
|
||||
Host: "api.example.com",
|
||||
Path: "/v1/ping",
|
||||
StatusCode: 200,
|
||||
BytesSent: 4096,
|
||||
},
|
||||
}, nil, reportedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("buildNodeAccessLogRecords() error = %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected one access log record, got %d", len(records))
|
||||
}
|
||||
if records[0].BytesSent != 4096 {
|
||||
t.Fatalf("BytesSent = %d, want 4096", records[0].BytesSent)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,12 @@ func BindJSON(c *gin.Context, dst any) bool {
|
||||
|
||||
// IDParam parses :id from the URL path.
|
||||
func IDParam(c *gin.Context) (uint, bool) {
|
||||
raw := c.Param("id")
|
||||
return NamedIDParam(c, "id")
|
||||
}
|
||||
|
||||
// NamedIDParam parses a named path parameter as a positive uint ID.
|
||||
func NamedIDParam(c *gin.Context, name string) (uint, bool) {
|
||||
raw := c.Param(name)
|
||||
if raw == "" {
|
||||
response.AbortBadRequest(c, errInvalidID)
|
||||
return 0, false
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestDatabaseAutoCleanupHandlerDeletesRowsWhenEnabled(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, model.InsertOpenFlareAccessLogsBatch(ctx, []*model.OpenFlareAccessLog{{
|
||||
NodeID: "node-a",
|
||||
LoggedAt: now.Add(-48 * time.Hour),
|
||||
LoggedAt: now.Add(-95 * 24 * time.Hour),
|
||||
RemoteAddr: "203.0.113.10",
|
||||
Host: "example.com",
|
||||
Path: "/access",
|
||||
|
||||
@@ -25,7 +25,7 @@ func newDedupSet() *dedupSet {
|
||||
|
||||
// markIfNew records key when it has not been seen within dedupTTL.
|
||||
func (s *dedupSet) markIfNew(key string) bool {
|
||||
if key == "" {
|
||||
if s == nil || key == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -33,19 +33,33 @@ func (s *dedupSet) markIfNew(key string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Periodically clean up all expired keys (e.g., every 30 seconds)
|
||||
if now.Sub(s.lastCleanup) >= 30*time.Second {
|
||||
for existing, expiresAt := range s.keys {
|
||||
if now.After(expiresAt) {
|
||||
delete(s.keys, existing)
|
||||
}
|
||||
}
|
||||
s.lastCleanup = now
|
||||
}
|
||||
s.cleanupExpiredLocked(now)
|
||||
|
||||
if expiresAt, exists := s.keys[key]; exists && now.Before(expiresAt) {
|
||||
return false
|
||||
}
|
||||
s.keys[key] = now.Add(dedupTTL)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// unmark removes a key so a later enqueue or flush retry may accept it again.
|
||||
func (s *dedupSet) unmark(key string) {
|
||||
if s == nil || key == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.keys, key)
|
||||
}
|
||||
|
||||
func (s *dedupSet) cleanupExpiredLocked(now time.Time) {
|
||||
if now.Sub(s.lastCleanup) < 30*time.Second {
|
||||
return
|
||||
}
|
||||
for existing, expiresAt := range s.keys {
|
||||
if now.After(expiresAt) {
|
||||
delete(s.keys, existing)
|
||||
}
|
||||
}
|
||||
s.lastCleanup = now
|
||||
}
|
||||
|
||||
@@ -3,7 +3,16 @@
|
||||
|
||||
package chwriter
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db/batchwriter"
|
||||
analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics"
|
||||
)
|
||||
|
||||
func TestDedupSetMarkIfNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -21,4 +30,157 @@ func TestDedupSetMarkIfNew(t *testing.T) {
|
||||
if set.markIfNew("") {
|
||||
t.Fatal("markIfNew() = true, want false on empty key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupSetUnmarkAllowsRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
set := newDedupSet()
|
||||
if !set.markIfNew("k") {
|
||||
t.Fatal("markIfNew() = false, want true")
|
||||
}
|
||||
set.unmark("k")
|
||||
if !set.markIfNew("k") {
|
||||
t.Fatal("markIfNew() after unmark = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueWithDedupDoesNotMarkWhenEnqueueFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := batchwriter.DefaultConfig()
|
||||
cfg.QueueSize = 1
|
||||
cfg.MaxBatchSize = 10
|
||||
cfg.FlushInterval = time.Hour
|
||||
|
||||
// Block the worker so the queue stays full after one enqueue.
|
||||
block := make(chan struct{})
|
||||
writer, err := batchwriter.New[int](cfg, func(context.Context, []int) error {
|
||||
<-block
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
writer.Start(context.Background())
|
||||
t.Cleanup(func() {
|
||||
close(block)
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = writer.Stop(stopCtx)
|
||||
})
|
||||
|
||||
// Fill the channel buffer (and the worker's current receive slot may empty one).
|
||||
// Keep enqueueing until full so subsequent queueWithDedup fails.
|
||||
for i := 0; i < cfg.QueueSize+2; i++ {
|
||||
_ = writer.TryEnqueue(i)
|
||||
if writer.IsFull() {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !writer.IsFull() {
|
||||
t.Fatal("writer not full after filling; cannot test enqueue failure path")
|
||||
}
|
||||
|
||||
dedup := newDedupSet()
|
||||
queueWithDedup(writer, dedup, "dedup-key", 99)
|
||||
// Key must not remain marked after failed enqueue.
|
||||
if !dedup.markIfNew("dedup-key") {
|
||||
t.Fatal("dedup key still marked after failed enqueue; want unmark")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueWithDedupMarksOnlyOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := batchwriter.DefaultConfig()
|
||||
cfg.MaxBatchSize = 100
|
||||
cfg.FlushInterval = time.Hour
|
||||
|
||||
writer, err := batchwriter.New[int](cfg, func(context.Context, []int) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
writer.Start(context.Background())
|
||||
t.Cleanup(func() {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = writer.Stop(stopCtx)
|
||||
})
|
||||
|
||||
dedup := newDedupSet()
|
||||
queueWithDedup(writer, dedup, "ok-key", 1)
|
||||
if dedup.markIfNew("ok-key") {
|
||||
t.Fatal("markIfNew() = true after successful enqueue, want false (key marked)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushErrorHandlerUnmarksKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dedup := newDedupSet()
|
||||
flushErr := errors.New("ch down")
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
errCount int
|
||||
)
|
||||
|
||||
cfg := batchwriter.Config{
|
||||
Name: "test_obs",
|
||||
QueueSize: 10,
|
||||
MaxBatchSize: 1,
|
||||
FlushInterval: time.Hour,
|
||||
}
|
||||
keyFn := func(s analyticsmodel.NodeMetricSnapshot) string {
|
||||
return metricSnapshotKey(s)
|
||||
}
|
||||
writer, err := batchwriter.New(
|
||||
cfg,
|
||||
func(context.Context, []analyticsmodel.NodeMetricSnapshot) error { return flushErr },
|
||||
batchwriter.WithFlushErrorHandler[analyticsmodel.NodeMetricSnapshot](func(_ context.Context, items []analyticsmodel.NodeMetricSnapshot, err error) {
|
||||
mu.Lock()
|
||||
errCount++
|
||||
mu.Unlock()
|
||||
for _, item := range items {
|
||||
dedup.unmark(keyFn(item))
|
||||
}
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
writer.Start(context.Background())
|
||||
t.Cleanup(func() {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = writer.Stop(stopCtx)
|
||||
})
|
||||
|
||||
item := analyticsmodel.NodeMetricSnapshot{
|
||||
NodeID: "n1",
|
||||
CapturedAt: time.Unix(1, 0).UTC(),
|
||||
}
|
||||
key := keyFn(item)
|
||||
if !dedup.markIfNew(key) {
|
||||
t.Fatal("markIfNew failed")
|
||||
}
|
||||
if !writer.TryEnqueue(item) {
|
||||
t.Fatal("TryEnqueue failed")
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
mu.Lock()
|
||||
ready := errCount >= 1
|
||||
mu.Unlock()
|
||||
if ready || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
if !dedup.markIfNew(key) {
|
||||
t.Fatal("key still marked after flush error unmark; want available for retry")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//go:build live_ch
|
||||
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package chwriter_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/chwriter"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
)
|
||||
|
||||
// Run with Docker ClickHouse + config.yaml:
|
||||
//
|
||||
// go test -tags live_ch ./internal/apps/openflare/chwriter -run TestLiveAppWritePath -count=1 -timeout 2m
|
||||
func TestLiveAppWritePath(t *testing.T) {
|
||||
if !db.ChConnReady() {
|
||||
t.Skip("ClickHouse connection not ready")
|
||||
}
|
||||
ctx := context.Background()
|
||||
chwriter.Init(ctx)
|
||||
|
||||
now := time.Now().UTC()
|
||||
nodeID := "e2e-app-write-" + now.Format("150405")
|
||||
if err := model.InsertOpenFlareMetricSnapshot(ctx, &model.OpenFlareMetricSnapshot{
|
||||
NodeID: nodeID,
|
||||
CapturedAt: now,
|
||||
CPUUsagePercent: 33.3,
|
||||
MemoryUsedBytes: 111,
|
||||
MemoryTotalBytes: 1000,
|
||||
StorageUsedBytes: 222,
|
||||
StorageTotalBytes: 2000,
|
||||
DiskReadBytes: 10,
|
||||
DiskWriteBytes: 20,
|
||||
NetworkRxBytes: 30,
|
||||
NetworkTxBytes: 40,
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertOpenFlareMetricSnapshot: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(45 * time.Second)
|
||||
var found bool
|
||||
for time.Now().Before(deadline) {
|
||||
rows, err := model.ListOpenFlareMetricSnapshotsSince(ctx, nodeID, now.Add(-time.Minute), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListOpenFlareMetricSnapshotsSince: %v", err)
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
found = true
|
||||
t.Logf("found snapshot id=%d cpu=%.1f after flush", rows[0].ID, rows[0].CPUUsagePercent)
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("metric snapshot not visible in ClickHouse after flush wait")
|
||||
}
|
||||
|
||||
latest, err := model.ListOpenFlareLatestMetricSnapshotsSince(ctx, "", now.Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("ListOpenFlareLatestMetricSnapshotsSince: %v", err)
|
||||
}
|
||||
var latestOK bool
|
||||
for _, row := range latest {
|
||||
if row != nil && row.NodeID == nodeID {
|
||||
latestOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !latestOK {
|
||||
t.Fatalf("latest-per-node query missing node %s (rows=%d)", nodeID, len(latest))
|
||||
}
|
||||
|
||||
stats := chwriter.WriterStats()
|
||||
if len(stats) == 0 {
|
||||
t.Fatal("WriterStats empty after Init")
|
||||
}
|
||||
for _, s := range stats {
|
||||
t.Logf("writer %s running=%v depth=%d drops=%d flush_err=%d", s.Name, s.Running, s.Depth, s.Drops, s.FlushErrors)
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,30 @@ import (
|
||||
"github.com/Rain-kl/Wavelet/internal/config"
|
||||
"github.com/Rain-kl/Wavelet/internal/db/batchwriter"
|
||||
"github.com/Rain-kl/Wavelet/internal/lifecycle"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics"
|
||||
analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// Observability traffic is sparse (heartbeat ~10s/node). Prefer larger batches to
|
||||
// cut ClickHouse parts/merges; MaxFlushWait bounds visibility lag for single-node labs.
|
||||
observabilityQueueSize = 5_000
|
||||
observabilityMaxBatchSize = 500
|
||||
observabilityFlushEvery = 5 * time.Second
|
||||
observabilityMinBatchSize = 20
|
||||
observabilityFlushEvery = 10 * time.Second
|
||||
observabilityMaxFlushWait = 30 * time.Second
|
||||
|
||||
nodeAccessLogQueueSize = 10_000
|
||||
nodeAccessLogMaxBatchSize = 1_000
|
||||
nodeAccessLogFlushEvery = time.Second
|
||||
nodeAccessLogMinBatchSize = 50
|
||||
nodeAccessLogFlushEvery = 2 * time.Second
|
||||
nodeAccessLogMaxFlushWait = 5 * time.Second
|
||||
|
||||
// flushAttempts is total tries (1 initial + short retries) before giving up a batch.
|
||||
flushAttempts = 2
|
||||
flushRetryBackoff = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -59,11 +70,36 @@ func Init(ctx context.Context) {
|
||||
frpsDedup = newDedupSet()
|
||||
frpcDedup = newDedupSet()
|
||||
|
||||
metricSnapshotWriter = mustNewObservabilityWriter("metric_snapshots", analyticsrepo.BatchInsertNodeMetricSnapshots)
|
||||
requestReportWriter = mustNewObservabilityWriter("request_reports", analyticsrepo.BatchInsertNodeRequestReports)
|
||||
openrestyWriter = mustNewObservabilityWriter("openresty_obs", analyticsrepo.BatchInsertNodeObsOpenresty)
|
||||
frpsWriter = mustNewObservabilityWriter("frps_obs", analyticsrepo.BatchInsertNodeObsFrps)
|
||||
frpcWriter = mustNewObservabilityWriter("frpc_obs", analyticsrepo.BatchInsertNodeObsFrpc)
|
||||
metricSnapshotWriter = mustNewObservabilityWriter(
|
||||
"metric_snapshots",
|
||||
withFlushRetries(analyticsrepo.BatchInsertNodeMetricSnapshots),
|
||||
metricSnapshotDedup,
|
||||
metricSnapshotKey,
|
||||
)
|
||||
requestReportWriter = mustNewObservabilityWriter(
|
||||
"request_reports",
|
||||
withFlushRetries(analyticsrepo.BatchInsertNodeRequestReports),
|
||||
requestReportDedup,
|
||||
requestReportKey,
|
||||
)
|
||||
openrestyWriter = mustNewObservabilityWriter(
|
||||
"openresty_obs",
|
||||
withFlushRetries(analyticsrepo.BatchInsertNodeObsOpenresty),
|
||||
openrestyDedup,
|
||||
openrestyKey,
|
||||
)
|
||||
frpsWriter = mustNewObservabilityWriter(
|
||||
"frps_obs",
|
||||
withFlushRetries(analyticsrepo.BatchInsertNodeObsFrps),
|
||||
frpsDedup,
|
||||
frpsKey,
|
||||
)
|
||||
frpcWriter = mustNewObservabilityWriter(
|
||||
"frpc_obs",
|
||||
withFlushRetries(analyticsrepo.BatchInsertNodeObsFrpc),
|
||||
frpcDedup,
|
||||
frpcKey,
|
||||
)
|
||||
nodeAccessLogWriter = mustNewNodeAccessLogWriter()
|
||||
|
||||
metricSnapshotWriter.Start(ctx)
|
||||
@@ -73,6 +109,7 @@ func Init(ctx context.Context) {
|
||||
frpcWriter.Start(ctx)
|
||||
nodeAccessLogWriter.Start(ctx)
|
||||
|
||||
wireModelInsertHooks()
|
||||
lifecycle.OnShutdown("openflare_chwriter", Stop)
|
||||
})
|
||||
}
|
||||
@@ -102,69 +139,49 @@ func Stop(ctx context.Context) error {
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// WriterStats returns queue depth and failure counters for all OpenFlare writers.
|
||||
func WriterStats() []batchwriter.Stats {
|
||||
writers := []statsProvider{
|
||||
metricSnapshotWriter,
|
||||
requestReportWriter,
|
||||
openrestyWriter,
|
||||
frpsWriter,
|
||||
frpcWriter,
|
||||
nodeAccessLogWriter,
|
||||
}
|
||||
out := make([]batchwriter.Stats, 0, len(writers))
|
||||
for _, w := range writers {
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, w.Stats())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// QueueMetricSnapshot enqueues a metric snapshot for asynchronous flush.
|
||||
func QueueMetricSnapshot(snapshot analyticsmodel.NodeMetricSnapshot) {
|
||||
if metricSnapshotWriter == nil {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d", snapshot.NodeID, snapshot.CapturedAt.UTC().UnixNano())
|
||||
if !metricSnapshotDedup.markIfNew(key) {
|
||||
return
|
||||
}
|
||||
metricSnapshotWriter.TryEnqueue(snapshot)
|
||||
queueWithDedup(metricSnapshotWriter, metricSnapshotDedup, metricSnapshotKey(snapshot), snapshot)
|
||||
}
|
||||
|
||||
// QueueRequestReport enqueues a request report for asynchronous flush.
|
||||
func QueueRequestReport(report analyticsmodel.NodeRequestReport) {
|
||||
if requestReportWriter == nil {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf(
|
||||
"%s|%d|%d",
|
||||
report.NodeID,
|
||||
report.WindowStartedAt.UTC().UnixNano(),
|
||||
report.WindowEndedAt.UTC().UnixNano(),
|
||||
)
|
||||
if !requestReportDedup.markIfNew(key) {
|
||||
return
|
||||
}
|
||||
requestReportWriter.TryEnqueue(report)
|
||||
queueWithDedup(requestReportWriter, requestReportDedup, requestReportKey(report), report)
|
||||
}
|
||||
|
||||
// QueueOpenrestyObservation enqueues an OpenResty observation for asynchronous flush.
|
||||
func QueueOpenrestyObservation(observation analyticsmodel.NodeObsOpenresty) {
|
||||
if openrestyWriter == nil {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d", observation.NodeID, observation.CapturedAt.UTC().UnixNano())
|
||||
if !openrestyDedup.markIfNew(key) {
|
||||
return
|
||||
}
|
||||
openrestyWriter.TryEnqueue(observation)
|
||||
queueWithDedup(openrestyWriter, openrestyDedup, openrestyKey(observation), observation)
|
||||
}
|
||||
|
||||
// QueueFrpsObservation enqueues an FRPS observation for asynchronous flush.
|
||||
func QueueFrpsObservation(observation analyticsmodel.NodeObsFrps) {
|
||||
if frpsWriter == nil {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d", observation.NodeID, observation.CapturedAt.UTC().UnixNano())
|
||||
if !frpsDedup.markIfNew(key) {
|
||||
return
|
||||
}
|
||||
frpsWriter.TryEnqueue(observation)
|
||||
queueWithDedup(frpsWriter, frpsDedup, frpsKey(observation), observation)
|
||||
}
|
||||
|
||||
// QueueFrpcObservation enqueues an FRPC observation for asynchronous flush.
|
||||
func QueueFrpcObservation(observation analyticsmodel.NodeObsFrpc) {
|
||||
if frpcWriter == nil {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d", observation.NodeID, observation.CapturedAt.UTC().UnixNano())
|
||||
if !frpcDedup.markIfNew(key) {
|
||||
return
|
||||
}
|
||||
frpcWriter.TryEnqueue(observation)
|
||||
queueWithDedup(frpcWriter, frpcDedup, frpcKey(observation), observation)
|
||||
}
|
||||
|
||||
// QueueNodeAccessLogs enqueues node access logs for asynchronous flush.
|
||||
@@ -177,19 +194,46 @@ func QueueNodeAccessLogs(logs []analyticsmodel.NodeAccessLog) {
|
||||
}
|
||||
}
|
||||
|
||||
func mustNewObservabilityWriter[T any](name string, flush batchwriter.FlushFunc[T]) *batchwriter.Writer[T] {
|
||||
func queueWithDedup[T any](writer *batchwriter.Writer[T], dedup *dedupSet, key string, item T) {
|
||||
if writer == nil {
|
||||
return
|
||||
}
|
||||
// Mark first so concurrent duplicates still collapse; release on enqueue failure
|
||||
// so a full queue does not permanently suppress the item.
|
||||
if !dedup.markIfNew(key) {
|
||||
return
|
||||
}
|
||||
if !writer.TryEnqueue(item) {
|
||||
dedup.unmark(key)
|
||||
}
|
||||
}
|
||||
|
||||
func mustNewObservabilityWriter[T any](
|
||||
name string,
|
||||
flush batchwriter.FlushFunc[T],
|
||||
dedup *dedupSet,
|
||||
keyFn func(T) string,
|
||||
) *batchwriter.Writer[T] {
|
||||
cfg := batchwriter.Config{
|
||||
Name: name,
|
||||
QueueSize: observabilityQueueSize,
|
||||
MaxBatchSize: observabilityMaxBatchSize,
|
||||
MinBatchSize: observabilityMinBatchSize,
|
||||
FlushInterval: observabilityFlushEvery,
|
||||
MaxFlushWait: observabilityMaxFlushWait,
|
||||
}
|
||||
writer, err := batchwriter.New(
|
||||
cfg,
|
||||
flush,
|
||||
withObservabilityDropHandler[T](name),
|
||||
batchwriter.WithFlushErrorHandler[T](func(ctx context.Context, batchSize int, err error) {
|
||||
logger.ErrorF(ctx, "[OpenFlare] flush %s failed (batch=%d): %v", name, batchSize, err)
|
||||
batchwriter.WithFlushErrorHandler[T](func(ctx context.Context, items []T, err error) {
|
||||
logger.ErrorF(ctx, "[OpenFlare] flush %s failed (batch=%d): %v", name, len(items), err)
|
||||
if dedup == nil || keyFn == nil {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
dedup.unmark(keyFn(item))
|
||||
}
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -203,14 +247,18 @@ func mustNewNodeAccessLogWriter() *batchwriter.Writer[analyticsmodel.NodeAccessL
|
||||
Name: "node_access_logs",
|
||||
QueueSize: nodeAccessLogQueueSize,
|
||||
MaxBatchSize: nodeAccessLogMaxBatchSize,
|
||||
MinBatchSize: nodeAccessLogMinBatchSize,
|
||||
FlushInterval: nodeAccessLogFlushEvery,
|
||||
MaxFlushWait: nodeAccessLogMaxFlushWait,
|
||||
}
|
||||
writer, err := batchwriter.New[analyticsmodel.NodeAccessLog](cfg, analyticsrepo.BatchInsertNodeAccessLogs,
|
||||
writer, err := batchwriter.New[analyticsmodel.NodeAccessLog](
|
||||
cfg,
|
||||
withFlushRetries(analyticsrepo.BatchInsertNodeAccessLogs),
|
||||
batchwriter.WithDropHandler[analyticsmodel.NodeAccessLog](func(item analyticsmodel.NodeAccessLog) {
|
||||
logger.WarnF(context.Background(), "[OpenFlare] node access log queue full, dropping log for node %s path %s", item.NodeID, item.Path)
|
||||
}),
|
||||
batchwriter.WithFlushErrorHandler[analyticsmodel.NodeAccessLog](func(ctx context.Context, batchSize int, err error) {
|
||||
logger.ErrorF(ctx, "[OpenFlare] flush node access logs failed (batch=%d): %v", batchSize, err)
|
||||
batchwriter.WithFlushErrorHandler[analyticsmodel.NodeAccessLog](func(ctx context.Context, items []analyticsmodel.NodeAccessLog, err error) {
|
||||
logger.ErrorF(ctx, "[OpenFlare] flush node access logs failed (batch=%d): %v", len(items), err)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -225,10 +273,74 @@ func withObservabilityDropHandler[T any](name string) batchwriter.Option[T] {
|
||||
})
|
||||
}
|
||||
|
||||
// withFlushRetries wraps a flush function with a short retry to ride out brief CH blips.
|
||||
func withFlushRetries[T any](flush batchwriter.FlushFunc[T]) batchwriter.FlushFunc[T] {
|
||||
return func(ctx context.Context, items []T) error {
|
||||
var err error
|
||||
for attempt := 1; attempt <= flushAttempts; attempt++ {
|
||||
err = flush(ctx, items)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if attempt == flushAttempts {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(flushRetryBackoff * time.Duration(attempt)):
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func wireModelInsertHooks() {
|
||||
model.SetObservabilityInsertHooks(model.ObservabilityInsertHooks{
|
||||
QueueMetricSnapshot: QueueMetricSnapshot,
|
||||
QueueRequestReport: QueueRequestReport,
|
||||
QueueOpenrestyObservation: QueueOpenrestyObservation,
|
||||
QueueFrpsObservation: QueueFrpsObservation,
|
||||
QueueFrpcObservation: QueueFrpcObservation,
|
||||
})
|
||||
model.SetAccessLogInsertHooks(model.AccessLogInsertHooks{
|
||||
QueueNodeAccessLogs: QueueNodeAccessLogs,
|
||||
})
|
||||
}
|
||||
|
||||
func metricSnapshotKey(snapshot analyticsmodel.NodeMetricSnapshot) string {
|
||||
return fmt.Sprintf("%s|%d", snapshot.NodeID, snapshot.CapturedAt.UTC().UnixNano())
|
||||
}
|
||||
|
||||
func requestReportKey(report analyticsmodel.NodeRequestReport) string {
|
||||
return fmt.Sprintf(
|
||||
"%s|%d|%d",
|
||||
report.NodeID,
|
||||
report.WindowStartedAt.UTC().UnixNano(),
|
||||
report.WindowEndedAt.UTC().UnixNano(),
|
||||
)
|
||||
}
|
||||
|
||||
func openrestyKey(observation analyticsmodel.NodeObsOpenresty) string {
|
||||
return fmt.Sprintf("%s|%d", observation.NodeID, observation.CapturedAt.UTC().UnixNano())
|
||||
}
|
||||
|
||||
func frpsKey(observation analyticsmodel.NodeObsFrps) string {
|
||||
return fmt.Sprintf("%s|%d", observation.NodeID, observation.CapturedAt.UTC().UnixNano())
|
||||
}
|
||||
|
||||
func frpcKey(observation analyticsmodel.NodeObsFrpc) string {
|
||||
return fmt.Sprintf("%s|%d", observation.NodeID, observation.CapturedAt.UTC().UnixNano())
|
||||
}
|
||||
|
||||
type batchStopper interface {
|
||||
Stop(ctx context.Context) error
|
||||
}
|
||||
|
||||
type statsProvider interface {
|
||||
Stats() batchwriter.Stats
|
||||
}
|
||||
|
||||
func running() bool {
|
||||
return metricSnapshotWriter != nil && metricSnapshotWriter.Running()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestBuildCertificateSupportFilesDecryptsSealedPrivateKey(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
files, err := buildCertificateSupportFiles(ctx, []snapshotRoute{
|
||||
{CertIDs: []uint{certificate.ID}},
|
||||
{DomainCertIDs: []uint{certificate.ID}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 2)
|
||||
@@ -58,13 +58,51 @@ func TestBuildCertificateSupportFilesDecryptsSealedPrivateKey(t *testing.T) {
|
||||
assert.Equal(t, normalizePEM(strings.TrimSpace(keyPEM)), keyContent)
|
||||
}
|
||||
|
||||
func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) {
|
||||
cleanup := setupConfigVersionTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
require.NoError(t, db.DB(ctx).AutoMigrate(&model.TLSCertificate{}))
|
||||
|
||||
oldSecret := config.Config.App.SessionSecret
|
||||
config.Config.App.SessionSecret = "test-session-secret-for-zone-domain-snapshots"
|
||||
t.Cleanup(func() { config.Config.App.SessionSecret = oldSecret })
|
||||
|
||||
firstCertPEM, firstKeyPEM := generateTestCertKeyPairForSnapshotForDomain(t, "one.example.com")
|
||||
first, err := oftls.CreateCertificate(ctx, oftls.CertificateInput{Name: "first", CertPEM: firstCertPEM, KeyPEM: firstKeyPEM})
|
||||
require.NoError(t, err)
|
||||
secondCertPEM, secondKeyPEM := generateTestCertKeyPairForSnapshotForDomain(t, "two.example.com")
|
||||
second, err := oftls.CreateCertificate(ctx, oftls.CertificateInput{Name: "second", CertPEM: secondCertPEM, KeyPEM: secondKeyPEM})
|
||||
require.NoError(t, err)
|
||||
|
||||
route := &model.ProxyRoute{SiteName: "tls-site", OriginURL: "http://origin:8080", Upstreams: `["http://origin:8080"]`, Enabled: true, EnableHTTPS: true}
|
||||
require.NoError(t, model.CreateProxyRouteRecord(ctx, route))
|
||||
zone := &model.Zone{Domain: "example.com"}
|
||||
require.NoError(t, db.DB(ctx).Create(zone).Error)
|
||||
require.NoError(t, db.DB(ctx).Create(&model.ZoneDomain{ZoneID: zone.ID, ProxyRouteID: &route.ID, Domain: "one.example.com", CertID: &first.ID}).Error)
|
||||
require.NoError(t, db.DB(ctx).Create(&model.ZoneDomain{ZoneID: zone.ID, ProxyRouteID: &route.ID, Domain: "two.example.com", CertID: &second.ID}).Error)
|
||||
|
||||
bundle, err := buildCurrentConfigBundle(ctx, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, bundle.SnapshotRoutes, 1)
|
||||
assert.Equal(t, []string{"one.example.com", "two.example.com"}, bundle.SnapshotRoutes[0].Domains)
|
||||
assert.Equal(t, []uint{first.ID, second.ID}, bundle.SnapshotRoutes[0].DomainCertIDs)
|
||||
assert.Contains(t, bundle.RouteConfig, "server_name one.example.com;")
|
||||
assert.Contains(t, bundle.RouteConfig, "server_name two.example.com;")
|
||||
}
|
||||
|
||||
func generateTestCertKeyPairForSnapshot(t *testing.T) (certPEM string, keyPEM string) {
|
||||
return generateTestCertKeyPairForSnapshotForDomain(t, "test.example.com")
|
||||
}
|
||||
|
||||
func generateTestCertKeyPairForSnapshotForDomain(t *testing.T, domain string) (certPEM string, keyPEM string) {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||
Subject: pkix.Name{CommonName: domain},
|
||||
DNSNames: []string{domain},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
|
||||
@@ -19,6 +19,26 @@ type customHeaderInput struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func normalizeSnapshotDomains(domains []string) ([]string, error) {
|
||||
normalized := make([]string, 0, len(domains))
|
||||
seen := make(map[string]struct{}, len(domains))
|
||||
for _, raw := range domains {
|
||||
domain := strings.ToLower(strings.TrimSpace(raw))
|
||||
if domain == "" || strings.Contains(domain, "://") || strings.Contains(domain, "/") {
|
||||
return nil, fmt.Errorf("domains payload is invalid")
|
||||
}
|
||||
if _, ok := seen[domain]; ok {
|
||||
continue
|
||||
}
|
||||
seen[domain] = struct{}{}
|
||||
normalized = append(normalized, domain)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil, fmt.Errorf("domain is required")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
@@ -94,92 +114,6 @@ func decodeStoredCacheRules(raw string) ([]string, error) {
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func decodeStoredCertIDs(raw string, fallbackCertID *uint) ([]uint, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
if fallbackCertID == nil || *fallbackCertID == 0 {
|
||||
return []uint{}, nil
|
||||
}
|
||||
return []uint{*fallbackCertID}, nil
|
||||
}
|
||||
var certIDs []uint
|
||||
if err := json.Unmarshal([]byte(text), &certIDs); err != nil {
|
||||
return nil, fmt.Errorf("cert_ids payload is invalid")
|
||||
}
|
||||
normalized := make([]uint, 0, len(certIDs))
|
||||
seen := make(map[uint]struct{}, len(certIDs))
|
||||
for _, certID := range certIDs {
|
||||
if certID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[certID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[certID] = struct{}{}
|
||||
normalized = append(normalized, certID)
|
||||
}
|
||||
if len(normalized) == 0 && fallbackCertID != nil && *fallbackCertID != 0 {
|
||||
return []uint{*fallbackCertID}, nil
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func resolveDomainCertIDs(domains []string, certIDs []uint, rawDomainCertIDs string) ([]uint, error) {
|
||||
text := strings.TrimSpace(rawDomainCertIDs)
|
||||
if text != "" {
|
||||
var domainCertIDs []uint
|
||||
if err := json.Unmarshal([]byte(text), &domainCertIDs); err != nil {
|
||||
return nil, fmt.Errorf("domain_cert_ids payload is invalid")
|
||||
}
|
||||
if len(domains) > 0 && len(domainCertIDs) != len(domains) {
|
||||
return nil, fmt.Errorf("domain_cert_ids length is invalid")
|
||||
}
|
||||
return domainCertIDs, nil
|
||||
}
|
||||
if len(certIDs) == 0 {
|
||||
return []uint{}, nil
|
||||
}
|
||||
if len(certIDs) == 1 {
|
||||
result := make([]uint, len(domains))
|
||||
for index := range result {
|
||||
result[index] = certIDs[0]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
if len(certIDs) == len(domains) {
|
||||
result := make([]uint, len(certIDs))
|
||||
copy(result, certIDs)
|
||||
return result, nil
|
||||
}
|
||||
return []uint{}, nil
|
||||
}
|
||||
|
||||
func mustDecodeCertIDs(route *model.ProxyRoute) []uint {
|
||||
if route == nil {
|
||||
return []uint{}
|
||||
}
|
||||
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
|
||||
if err != nil {
|
||||
return []uint{}
|
||||
}
|
||||
return certIDs
|
||||
}
|
||||
|
||||
func mustDecodeDomainCertIDs(route *model.ProxyRoute, domains []string) []uint {
|
||||
if route == nil {
|
||||
return []uint{}
|
||||
}
|
||||
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
|
||||
if err != nil {
|
||||
return []uint{}
|
||||
}
|
||||
domainCertIDs, err := resolveDomainCertIDs(domains, certIDs, route.DomainCertIDs)
|
||||
if err != nil {
|
||||
return []uint{}
|
||||
}
|
||||
return domainCertIDs
|
||||
}
|
||||
|
||||
func normalizeUpstreamType(raw string) string {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch value {
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
pkgprotocol "github.com/Rain-kl/Wavelet/pkg/protocol"
|
||||
@@ -333,23 +332,10 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
|
||||
return []snapshotRoute{}
|
||||
}
|
||||
for index := range routes {
|
||||
normalizedDomains, err := routeidentity.DecodeDomains("", routes[index].Domain)
|
||||
if len(routes[index].Domains) > 0 {
|
||||
normalizedDomains, err = routeidentity.NormalizeDomains(routes[index].Domains)
|
||||
}
|
||||
normalizedDomains, err := normalizeSnapshotDomains(routes[index].Domains)
|
||||
if err == nil && len(normalizedDomains) > 0 {
|
||||
routes[index].Domains = normalizedDomains
|
||||
routes[index].Domain = normalizedDomains[0]
|
||||
routes[index].SiteName = routeidentity.ResolveSiteName(nil, routes[index].SiteName, normalizedDomains[0])
|
||||
}
|
||||
normalizedCertIDs, primaryCertID, certErr := normalizeSnapshotCertificateIDs(routes[index].CertID, routes[index].CertIDs)
|
||||
if certErr == nil {
|
||||
routes[index].CertID = primaryCertID
|
||||
routes[index].CertIDs = normalizedCertIDs
|
||||
}
|
||||
normalizedDomainCertIDs, domainCertErr := resolveDomainCertIDs(routes[index].Domains, routes[index].CertIDs, "")
|
||||
if domainCertErr == nil && len(routes[index].DomainCertIDs) == 0 {
|
||||
routes[index].DomainCertIDs = normalizedDomainCertIDs
|
||||
routes[index].SiteName = strings.TrimSpace(routes[index].SiteName)
|
||||
}
|
||||
normalizedUpstreams, upstreamErr := normalizeUpstreams(routes[index].OriginURL, routes[index].Upstreams)
|
||||
if upstreamErr == nil {
|
||||
@@ -378,7 +364,6 @@ func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRo
|
||||
for _, route := range normalizeSnapshotRoutes(routes) {
|
||||
for _, domain := range route.Domains {
|
||||
item := route
|
||||
item.Domain = domain
|
||||
domainMap[domain] = item
|
||||
}
|
||||
}
|
||||
@@ -398,12 +383,11 @@ func snapshotRouteScalarsEqual(left, right snapshotRoute) bool {
|
||||
snapshotRouteOriginEqual(left, right) &&
|
||||
snapshotRoutePolicyEqual(left, right) &&
|
||||
snapshotRouteTunnelEqual(left, right) &&
|
||||
uintSliceEqual(left.CertIDs, right.CertIDs) &&
|
||||
uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs)
|
||||
}
|
||||
|
||||
func snapshotRouteIdentityEqual(left, right snapshotRoute) bool {
|
||||
return left.SiteName == right.SiteName && left.Domain == right.Domain
|
||||
return left.SiteName == right.SiteName
|
||||
}
|
||||
|
||||
func snapshotRouteOriginEqual(left, right snapshotRoute) bool {
|
||||
@@ -463,31 +447,6 @@ func snapshotWAFConfigEqual(left snapshotWAFDocument, right snapshotWAFDocument)
|
||||
return string(leftJSON) == string(rightJSON)
|
||||
}
|
||||
|
||||
func normalizeSnapshotCertificateIDs(primaryCertID *uint, certIDs []uint) ([]uint, *uint, error) {
|
||||
candidates := make([]uint, 0, len(certIDs)+1)
|
||||
if primaryCertID != nil && *primaryCertID != 0 {
|
||||
candidates = append(candidates, *primaryCertID)
|
||||
}
|
||||
candidates = append(candidates, certIDs...)
|
||||
normalized := make([]uint, 0, len(candidates))
|
||||
seen := make(map[uint]struct{}, len(candidates))
|
||||
for _, certID := range candidates {
|
||||
if certID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[certID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[certID] = struct{}{}
|
||||
normalized = append(normalized, certID)
|
||||
}
|
||||
var normalizedPrimary *uint
|
||||
if len(normalized) > 0 {
|
||||
normalizedPrimary = &normalized[0]
|
||||
}
|
||||
return normalized, normalizedPrimary, nil
|
||||
}
|
||||
|
||||
func buildInitialOpenRestyOptionDiffs(current openRestyConfigSnapshot) []ConfigOptionDiffItem {
|
||||
details := diffOpenRestyOptionDetails(openRestyConfigSnapshot{}, current)
|
||||
for index := range details {
|
||||
|
||||
@@ -6,6 +6,7 @@ package config_version
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +28,8 @@ func setupConfigVersionTestDB(t *testing.T) func() {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, sqliteDB.AutoMigrate(
|
||||
&model.ProxyRoute{},
|
||||
&model.Zone{},
|
||||
&model.ZoneDomain{},
|
||||
&model.ConfigVersion{},
|
||||
&model.OpenFlareWAFRuleGroup{},
|
||||
&model.OpenFlareWAFRuleGroupBinding{},
|
||||
@@ -40,6 +43,19 @@ func setupConfigVersionTestDB(t *testing.T) func() {
|
||||
}
|
||||
}
|
||||
|
||||
func createSnapshotZoneDomains(t *testing.T, ctx context.Context, route *model.ProxyRoute, domains ...string) {
|
||||
t.Helper()
|
||||
zone := &model.Zone{Domain: fmt.Sprintf("zone-%d.example", route.ID)}
|
||||
require.NoError(t, db.DB(ctx).Create(zone).Error)
|
||||
for _, domain := range domains {
|
||||
require.NoError(t, db.DB(ctx).Create(&model.ZoneDomain{
|
||||
ZoneID: zone.ID,
|
||||
ProxyRouteID: &route.ID,
|
||||
Domain: domain,
|
||||
}).Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListConfigVersionsOrdersByCreatedAtDesc(t *testing.T) {
|
||||
cleanup := setupConfigVersionTestDB(t)
|
||||
defer cleanup()
|
||||
@@ -80,13 +96,12 @@ func TestPublishConfigVersionCreatesVersion(t *testing.T) {
|
||||
|
||||
route := &model.ProxyRoute{
|
||||
SiteName: "publish-site",
|
||||
Domain: "publish.example.com",
|
||||
Domains: `["publish.example.com"]`,
|
||||
OriginURL: "http://origin.publish.example.com:8080",
|
||||
Upstreams: `["http://origin.publish.example.com:8080"]`,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, model.CreateProxyRouteRecord(ctx, route))
|
||||
createSnapshotZoneDomains(t, ctx, route, "publish.example.com")
|
||||
|
||||
version, err := PublishConfigVersion(ctx, "tester", false)
|
||||
require.NoError(t, err)
|
||||
@@ -103,7 +118,7 @@ func TestPublishConfigVersionCreatesVersion(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal([]byte(version.SnapshotJSON), &snapshot))
|
||||
require.Len(t, snapshot.Routes, 1)
|
||||
assert.Equal(t, "publish-site", snapshot.Routes[0].SiteName)
|
||||
assert.Equal(t, "publish.example.com", snapshot.Routes[0].Domain)
|
||||
assert.Equal(t, []string{"publish.example.com"}, snapshot.Routes[0].Domains)
|
||||
|
||||
active, err := GetActiveConfigVersion(ctx)
|
||||
require.NoError(t, err)
|
||||
@@ -124,13 +139,13 @@ func TestBuildSnapshotWAFDocumentUsesNormalizedSiteNames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
route := &model.ProxyRoute{
|
||||
Domain: "Example.COM",
|
||||
Domains: `["example.com","www.example.com"]`,
|
||||
SiteName: "example.com",
|
||||
OriginURL: "http://origin.example.com:8080",
|
||||
Upstreams: `["http://origin.example.com:8080"]`,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, model.CreateProxyRouteRecord(ctx, route))
|
||||
createSnapshotZoneDomains(t, ctx, route, "example.com", "www.example.com")
|
||||
|
||||
require.NoError(t, waf.EnsureDefaultRuleGroup(ctx))
|
||||
globalGroup, err := model.GetGlobalOpenFlareWAFRuleGroup(ctx)
|
||||
@@ -184,13 +199,13 @@ func TestBuildCurrentConfigBundleEnablesGlobalPoWWithoutExplicitBinding(t *testi
|
||||
ctx := context.Background()
|
||||
|
||||
route := &model.ProxyRoute{
|
||||
Domain: "pow-global.example.com",
|
||||
Domains: `["pow-global.example.com"]`,
|
||||
SiteName: "pow-global.example.com",
|
||||
OriginURL: "http://origin.example.com:8080",
|
||||
Upstreams: `["http://origin.example.com:8080"]`,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, model.CreateProxyRouteRecord(ctx, route))
|
||||
createSnapshotZoneDomains(t, ctx, route, "pow-global.example.com")
|
||||
|
||||
require.NoError(t, waf.EnsureDefaultRuleGroup(ctx))
|
||||
globalGroup, err := model.GetGlobalOpenFlareWAFRuleGroup(ctx)
|
||||
|
||||
@@ -25,36 +25,36 @@ func buildPagesRouteSnapshot(
|
||||
return "", nil, nil, nil, errors.New("pages 路由配置无效")
|
||||
}
|
||||
if !model.HasPagesProjectsTable(ctx) {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 模块不可用", route.Domain)
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 模块不可用", route.SiteName)
|
||||
}
|
||||
if route.PagesProjectID == nil || *route.PagesProjectID == 0 {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: 未绑定 Pages 项目", route.Domain)
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: 未绑定 Pages 项目", route.SiteName)
|
||||
}
|
||||
project, err := model.GetPagesProjectByID(ctx, *route.PagesProjectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", route.Domain)
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", route.SiteName)
|
||||
}
|
||||
return "", nil, nil, nil, err
|
||||
}
|
||||
if !project.Enabled {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", route.Domain)
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", route.SiteName)
|
||||
}
|
||||
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", route.Domain)
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", route.SiteName)
|
||||
}
|
||||
activeDeployment, err := model.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", route.Domain)
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", route.SiteName)
|
||||
}
|
||||
return "", nil, nil, nil, err
|
||||
}
|
||||
if activeDeployment.ProjectID != project.ID {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", route.Domain)
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", route.SiteName)
|
||||
}
|
||||
if strings.TrimSpace(activeDeployment.Checksum) == "" {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", route.Domain)
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", route.SiteName)
|
||||
}
|
||||
|
||||
pagesProjectID = route.PagesProjectID
|
||||
|
||||
@@ -45,8 +45,6 @@ func TestBuildSnapshotRoutesPages(t *testing.T) {
|
||||
|
||||
route := &model.ProxyRoute{
|
||||
SiteName: "speedtest",
|
||||
Domain: "speedtest.arctel.net",
|
||||
Domains: `["speedtest.arctel.net"]`,
|
||||
OriginURL: "openflare-pages://project/1",
|
||||
Upstreams: `["openflare-pages://project/1"]`,
|
||||
Enabled: true,
|
||||
@@ -54,6 +52,7 @@ func TestBuildSnapshotRoutesPages(t *testing.T) {
|
||||
PagesProjectID: &project.ID,
|
||||
}
|
||||
require.NoError(t, model.CreateProxyRouteRecord(ctx, route))
|
||||
createSnapshotZoneDomains(t, ctx, route, "speedtest.arctel.net")
|
||||
|
||||
bundle, err := buildCurrentConfigBundle(ctx, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity"
|
||||
oftls "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/waf"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
@@ -40,15 +39,12 @@ const (
|
||||
type snapshotRoute struct {
|
||||
ID uint `json:"id,omitempty"`
|
||||
SiteName string `json:"site_name,omitempty"`
|
||||
Domain string `json:"domain"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
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"`
|
||||
CertIDs []uint `json:"cert_ids,omitempty"`
|
||||
DomainCertIDs []uint `json:"domain_cert_ids,omitempty"`
|
||||
RedirectHTTP bool `json:"redirect_http"`
|
||||
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
|
||||
@@ -61,7 +57,6 @@ type snapshotRoute struct {
|
||||
BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"`
|
||||
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
|
||||
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
UpstreamType string `json:"upstream_type,omitempty"`
|
||||
TunnelNodeID *uint `json:"tunnel_node_id,omitempty"`
|
||||
TunnelTargetAddr string `json:"tunnel_target_addr,omitempty"`
|
||||
@@ -231,19 +226,32 @@ func buildCurrentConfigBundle(ctx context.Context, requireRoutes bool) (*configB
|
||||
func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]snapshotRoute, error) {
|
||||
items := make([]snapshotRoute, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
domains, err := routeidentity.DecodeDomains(route.Domains, route.Domain)
|
||||
zoneDomains, err := model.ListZoneDomainsByRouteID(ctx, route.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("route %s domains are invalid", route.Domain)
|
||||
return nil, err
|
||||
}
|
||||
if len(zoneDomains) == 0 {
|
||||
return nil, fmt.Errorf("route %s has no zone domains", route.SiteName)
|
||||
}
|
||||
domains := make([]string, 0, len(zoneDomains))
|
||||
domainCertIDs := make([]uint, 0, len(zoneDomains))
|
||||
for _, zoneDomain := range zoneDomains {
|
||||
domains = append(domains, zoneDomain.Domain)
|
||||
if zoneDomain.CertID == nil {
|
||||
domainCertIDs = append(domainCertIDs, 0)
|
||||
continue
|
||||
}
|
||||
domainCertIDs = append(domainCertIDs, *zoneDomain.CertID)
|
||||
}
|
||||
customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
|
||||
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.SiteName)
|
||||
}
|
||||
upstreamType := normalizeUpstreamType(route.UpstreamType)
|
||||
originURL := route.OriginURL
|
||||
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain)
|
||||
return nil, fmt.Errorf("路由 %s 上游配置无效", route.SiteName)
|
||||
}
|
||||
var tunnelNodeID *uint
|
||||
var tunnelTargetAddr string
|
||||
@@ -265,21 +273,18 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
|
||||
}
|
||||
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
|
||||
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.SiteName)
|
||||
}
|
||||
items = append(items, snapshotRoute{
|
||||
ID: route.ID,
|
||||
SiteName: routeidentity.ResolveSiteName(route, route.SiteName, domains[0]),
|
||||
Domain: domains[0],
|
||||
SiteName: route.SiteName,
|
||||
Domains: domains,
|
||||
OriginURL: originURL,
|
||||
OriginHost: route.OriginHost,
|
||||
Upstreams: upstreams,
|
||||
Enabled: route.Enabled,
|
||||
EnableHTTPS: route.EnableHTTPS,
|
||||
CertID: route.CertID,
|
||||
CertIDs: mustDecodeCertIDs(route),
|
||||
DomainCertIDs: mustDecodeDomainCertIDs(route, domains),
|
||||
DomainCertIDs: domainCertIDs,
|
||||
RedirectHTTP: route.RedirectHTTP,
|
||||
LimitConnPerServer: route.LimitConnPerServer,
|
||||
LimitConnPerIP: route.LimitConnPerIP,
|
||||
@@ -291,7 +296,6 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
|
||||
BasicAuthEnabled: route.BasicAuthEnabled,
|
||||
BasicAuthUsername: route.BasicAuthUsername,
|
||||
BasicAuthPassword: route.BasicAuthPassword,
|
||||
Remark: route.Remark,
|
||||
UpstreamType: upstreamType,
|
||||
TunnelNodeID: tunnelNodeID,
|
||||
TunnelTargetAddr: tunnelTargetAddr,
|
||||
@@ -344,11 +348,14 @@ func buildSnapshotWAFDocument(ctx context.Context, routes []*model.ProxyRoute) (
|
||||
if route == nil {
|
||||
continue
|
||||
}
|
||||
domains, domainErr := routeidentity.DecodeDomains(route.Domains, route.Domain)
|
||||
domains, domainErr := model.ListZoneDomainsByRouteID(ctx, route.ID)
|
||||
if domainErr != nil {
|
||||
return snapshotWAFDocument{}, fmt.Errorf("route %s domains are invalid", route.Domain)
|
||||
return snapshotWAFDocument{}, domainErr
|
||||
}
|
||||
enabledRouteSiteNames[route.ID] = routeidentity.ResolveSiteName(route, route.SiteName, domains[0])
|
||||
if len(domains) == 0 {
|
||||
return snapshotWAFDocument{}, fmt.Errorf("route %s has no zone domains", route.SiteName)
|
||||
}
|
||||
enabledRouteSiteNames[route.ID] = route.SiteName
|
||||
}
|
||||
rawBindings, err := model.ListOpenFlareWAFRuleGroupBindings(ctx)
|
||||
if err != nil {
|
||||
@@ -552,14 +559,6 @@ func normalizeProxyCachePathForSnapshot(cacheEnabled bool, cachePath string) str
|
||||
func buildCertificateSupportFiles(ctx context.Context, routes []snapshotRoute) ([]SupportFile, error) {
|
||||
certIDSet := make(map[uint]struct{})
|
||||
for _, route := range routes {
|
||||
if route.CertID != nil && *route.CertID != 0 {
|
||||
certIDSet[*route.CertID] = struct{}{}
|
||||
}
|
||||
for _, certID := range route.CertIDs {
|
||||
if certID != 0 {
|
||||
certIDSet[certID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, certID := range route.DomainCertIDs {
|
||||
if certID != 0 {
|
||||
certIDSet[certID] = struct{}{}
|
||||
|
||||
@@ -117,6 +117,16 @@ func buildOverviewView(ctx context.Context) (*OverviewView, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Latest-per-node health: dedicated LIMIT 1 BY queries (not a global raw LIMIT).
|
||||
latestSnapshotRows, err := model.ListOpenFlareLatestMetricSnapshotsSince(ctx, "", since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
latestTrafficRows, err := model.ListOpenFlareLatestRequestReportsSince(ctx, "", since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Bounded raw windows remain for distributions and trend fallbacks; trends prefer hourly rollups.
|
||||
snapshots, err := model.ListOpenFlareMetricSnapshotsSince(ctx, "", since, dashboardOverviewSnapshotLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -137,27 +147,17 @@ func buildOverviewView(ctx context.Context) (*OverviewView, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trafficTrend := observability.BuildTrafficTrendPoints(now, reports)
|
||||
if trafficHourly, hourlyErr := model.ListOpenFlareTrafficHourlySince(ctx, "", since); hourlyErr == nil && len(trafficHourly) > 0 {
|
||||
trafficTrend = observability.BuildTrafficTrendPointsFromHourly(now, trafficHourly)
|
||||
}
|
||||
|
||||
view := &OverviewView{
|
||||
GeneratedAt: now,
|
||||
Nodes: make([]NodeHealth, 0, len(nodes)),
|
||||
Distributions: observability.BuildTrafficDistributions(reports, accessLogRegions, dashboardDistributionLimit),
|
||||
Trends: observability.NodeTrends{
|
||||
Traffic24h: trafficTrend,
|
||||
Capacity24h: observability.BuildCapacityTrendPoints(now, snapshots),
|
||||
Network24h: observability.BuildNetworkTrendPoints(now, snapshots, openrestySnapshots),
|
||||
DiskIO24h: observability.BuildDiskIOTrendPoints(now, snapshots),
|
||||
},
|
||||
Trends: observability.BuildNodeTrends(ctx, now, "", snapshots, openrestySnapshots, reports),
|
||||
}
|
||||
|
||||
var cpuNodeCount int
|
||||
var memoryNodeCount int
|
||||
latestSnapshots := observability.LatestMetricSnapshotsByNode(snapshots)
|
||||
latestTrafficReports := observability.LatestTrafficReportsByNode(reports)
|
||||
latestSnapshots := observability.LatestMetricSnapshotsByNode(latestSnapshotRows)
|
||||
latestTrafficReports := observability.LatestTrafficReportsByNode(latestTrafficRows)
|
||||
activeEventsByNode := observability.ActiveHealthEventsByNode(activeEvents)
|
||||
|
||||
for _, node := range nodes {
|
||||
|
||||
@@ -58,6 +58,32 @@ func TestGetOverviewStructure(t *testing.T) {
|
||||
OpenrestyStatus: "unknown",
|
||||
}).Error)
|
||||
|
||||
// Seed older + newer snapshots per node; health must use latest-per-node, not a global raw limit.
|
||||
require.NoError(t, model.InsertOpenFlareMetricSnapshot(ctx, &model.OpenFlareMetricSnapshot{
|
||||
NodeID: "node-dashboard-1",
|
||||
CapturedAt: now.Add(-2 * time.Hour),
|
||||
CPUUsagePercent: 10,
|
||||
MemoryUsedBytes: 1,
|
||||
MemoryTotalBytes: 10,
|
||||
}))
|
||||
require.NoError(t, model.InsertOpenFlareMetricSnapshot(ctx, &model.OpenFlareMetricSnapshot{
|
||||
NodeID: "node-dashboard-1",
|
||||
CapturedAt: now.Add(-time.Minute),
|
||||
CPUUsagePercent: 55,
|
||||
MemoryUsedBytes: 5,
|
||||
MemoryTotalBytes: 10,
|
||||
StorageUsedBytes: 2,
|
||||
StorageTotalBytes: 10,
|
||||
}))
|
||||
require.NoError(t, model.InsertOpenFlareRequestReport(ctx, &model.OpenFlareRequestReport{
|
||||
NodeID: "node-dashboard-1",
|
||||
WindowStartedAt: now.Add(-2 * time.Minute),
|
||||
WindowEndedAt: now.Add(-time.Minute),
|
||||
RequestCount: 12,
|
||||
ErrorCount: 1,
|
||||
UniqueVisitorCount: 4,
|
||||
}))
|
||||
|
||||
overview, err := GetOverview(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, overview)
|
||||
@@ -69,14 +95,14 @@ func TestGetOverviewStructure(t *testing.T) {
|
||||
assert.Equal(t, 0, overview.Summary.OfflineNodes)
|
||||
assert.Equal(t, 0, overview.Summary.UnhealthyNodes)
|
||||
|
||||
assert.Equal(t, int64(0), overview.Traffic.RequestCount)
|
||||
assert.Equal(t, int64(0), overview.Traffic.UniqueVisitors)
|
||||
assert.Equal(t, int64(0), overview.Traffic.ErrorCount)
|
||||
assert.Equal(t, float64(0), overview.Traffic.EstimatedQPS)
|
||||
assert.Equal(t, 0, overview.Traffic.ReportedNodes)
|
||||
assert.Equal(t, int64(12), overview.Traffic.RequestCount)
|
||||
assert.Equal(t, int64(4), overview.Traffic.UniqueVisitors)
|
||||
assert.Equal(t, int64(1), overview.Traffic.ErrorCount)
|
||||
assert.InDelta(t, 0.2, overview.Traffic.EstimatedQPS, 0.0001)
|
||||
assert.Equal(t, 1, overview.Traffic.ReportedNodes)
|
||||
|
||||
assert.Equal(t, float64(0), overview.Capacity.AverageCPUUsagePercent)
|
||||
assert.Equal(t, float64(0), overview.Capacity.AverageMemoryUsagePercent)
|
||||
assert.Equal(t, 55.0, overview.Capacity.AverageCPUUsagePercent)
|
||||
assert.Equal(t, 50.0, overview.Capacity.AverageMemoryUsagePercent)
|
||||
assert.Equal(t, 0, overview.Capacity.HighCPUNodes)
|
||||
assert.Equal(t, 0, overview.Capacity.HighMemoryNodes)
|
||||
assert.Equal(t, 0, overview.Capacity.HighStorageNodes)
|
||||
@@ -120,10 +146,20 @@ func TestGetOverviewStructure(t *testing.T) {
|
||||
assert.Equal(t, "Edge 1", onlineNode[2])
|
||||
assert.Equal(t, "online", onlineNode[6])
|
||||
assert.Equal(t, "healthy", onlineNode[7])
|
||||
// Latest-per-node health fields (indexes match compressDashboardNodes).
|
||||
assert.Equal(t, 55.0, onlineNode[11]) // cpu_usage_percent from latest snapshot
|
||||
assert.Equal(t, 50.0, onlineNode[12]) // memory_usage_percent
|
||||
assert.Equal(t, int64(12), onlineNode[14])
|
||||
assert.Equal(t, int64(1), onlineNode[15])
|
||||
assert.Equal(t, int64(4), onlineNode[16])
|
||||
|
||||
pendingNode := nodeByID["node-dashboard-2"]
|
||||
require.NotNil(t, pendingNode)
|
||||
assert.Equal(t, "Edge 2", pendingNode[2])
|
||||
assert.Equal(t, "pending", pendingNode[6])
|
||||
assert.Equal(t, "unknown", pendingNode[7])
|
||||
|
||||
assert.Equal(t, 55.0, overview.Capacity.AverageCPUUsagePercent)
|
||||
assert.Equal(t, 1, overview.Traffic.ReportedNodes)
|
||||
assert.Equal(t, int64(4), overview.Traffic.UniqueVisitors)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"gorm.io/gorm"
|
||||
@@ -134,17 +133,17 @@ func GetTunnelConfig(ctx context.Context, node *model.OpenFlareNode) (*TunnelCon
|
||||
if !route.Enabled {
|
||||
continue
|
||||
}
|
||||
domains, decodeErr := routeidentity.DecodeDomains(route.Domains, route.Domain)
|
||||
if decodeErr != nil {
|
||||
zoneDomains, domainErr := model.ListZoneDomainsByRouteID(ctx, route.ID)
|
||||
if domainErr != nil || len(zoneDomains) == 0 {
|
||||
continue
|
||||
}
|
||||
localAddr, localPort := parseTunnelTargetAddr(route.TunnelTargetAddr)
|
||||
proxies = append(proxies, ProxyEntry{
|
||||
Name: fmt.Sprintf("%s-%s", node.NodeID, sanitizeProxyName(domains[0])),
|
||||
Name: fmt.Sprintf("%s-%s", node.NodeID, sanitizeProxyName(zoneDomains[0].Domain)),
|
||||
Type: "http",
|
||||
LocalAddr: localAddr,
|
||||
LocalPort: localPort,
|
||||
CustomDomains: domains,
|
||||
CustomDomains: zoneDomainNames(zoneDomains),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,6 +155,14 @@ func GetTunnelConfig(ctx context.Context, node *model.OpenFlareNode) (*TunnelCon
|
||||
}, nil
|
||||
}
|
||||
|
||||
func zoneDomainNames(domains []model.ZoneDomain) []string {
|
||||
names := make([]string, 0, len(domains))
|
||||
for _, domain := range domains {
|
||||
names = append(names, domain.Domain)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ReportApplyLog records an apply result from OpenFlared.
|
||||
func ReportApplyLog(ctx context.Context, payload ApplyLogPayload) (*model.OpenFlareApplyLog, error) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -49,6 +50,8 @@ func setupCoreChainTest(t *testing.T) (*gin.Engine, adminSeed, func()) {
|
||||
&model.OpenFlareNode{},
|
||||
&model.SystemConfig{},
|
||||
&model.OpenFlareApplyLog{},
|
||||
&model.Zone{},
|
||||
&model.ZoneDomain{},
|
||||
))
|
||||
|
||||
db.SetDB(sqliteDB)
|
||||
@@ -138,13 +141,22 @@ func TestCoreChainMigrationFlow(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("create proxy route linked to origin", func(t *testing.T) {
|
||||
// Create Zone and ZoneDomain directly in the DB
|
||||
zone := model.Zone{Domain: "example.com"}
|
||||
require.NoError(t, db.DB(context.Background()).Create(&zone).Error)
|
||||
zoneDomain := model.ZoneDomain{
|
||||
ZoneID: zone.ID,
|
||||
Domain: "core-chain.example.com",
|
||||
}
|
||||
require.NoError(t, db.DB(context.Background()).Create(&zoneDomain).Error)
|
||||
|
||||
rec := performJSONRequest(t, engine, http.MethodPost, apiPath("/proxy-routes/"), map[string]any{
|
||||
"site_name": "core-chain-site",
|
||||
"domain": "core-chain.example.com",
|
||||
"origin_id": originID,
|
||||
"origin_scheme": "http",
|
||||
"origin_port": "8080",
|
||||
"enabled": true,
|
||||
"site_name": "core-chain-site",
|
||||
"zone_domain_ids": []uint{zoneDomain.ID},
|
||||
"origin_id": originID,
|
||||
"origin_scheme": "http",
|
||||
"origin_port": "8080",
|
||||
"enabled": true,
|
||||
}, map[string]string{
|
||||
"X-Access-Token": seed.Token,
|
||||
})
|
||||
@@ -155,7 +167,10 @@ func TestCoreChainMigrationFlow(t *testing.T) {
|
||||
proxyRouteID = uint(data["id"].(float64))
|
||||
assert.NotZero(t, proxyRouteID)
|
||||
assert.Equal(t, "core-chain-site", data["site_name"])
|
||||
assert.Equal(t, "core-chain.example.com", data["domain"])
|
||||
assert.NotEmpty(t, data["zone_domains"])
|
||||
zoneDomains := data["zone_domains"].([]any)
|
||||
assert.Len(t, zoneDomains, 1)
|
||||
assert.Equal(t, "core-chain.example.com", zoneDomains[0].(map[string]any)["domain"])
|
||||
assert.Equal(t, float64(originID), data["origin_id"])
|
||||
assert.Equal(t, "http://origin.core-chain.internal:8080", data["origin_url"])
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
@@ -42,7 +43,8 @@ func setupSecurityTest(t *testing.T) (*gin.Engine, adminSeed, func()) {
|
||||
&model.OpenFlareWAFRuleGroupBinding{},
|
||||
&model.OpenFlareWAFIPGroup{},
|
||||
&model.TLSCertificate{},
|
||||
&model.ManagedDomain{},
|
||||
&model.Zone{},
|
||||
&model.ZoneDomain{},
|
||||
&model.DNSAccount{},
|
||||
&model.AcmeAccount{},
|
||||
&model.SystemConfig{},
|
||||
@@ -113,7 +115,6 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
|
||||
"ip_whitelist": []string{"192.0.2.1"},
|
||||
"ip_blacklist": []string{"203.0.113.10"},
|
||||
"country_blacklist": []string{"CN"},
|
||||
"remark": "integration rule group",
|
||||
}, adminAuthHeaders(seed.Token))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
@@ -178,7 +179,6 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
|
||||
"name": "edge-security-updated",
|
||||
"enabled": true,
|
||||
"block_status_code": 451,
|
||||
"remark": "updated by integration test",
|
||||
},
|
||||
adminAuthHeaders(seed.Token),
|
||||
)
|
||||
@@ -196,7 +196,6 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
|
||||
"type": "manual",
|
||||
"enabled": true,
|
||||
"ip_list": []string{"203.0.113.0/24", "198.51.100.10"},
|
||||
"remark": "manual deny list",
|
||||
}, adminAuthHeaders(seed.Token))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
@@ -209,11 +208,20 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("create proxy route for WAF binding", func(t *testing.T) {
|
||||
// Create Zone and ZoneDomain directly in the DB
|
||||
routeZone := model.Zone{Domain: "example-route.com"}
|
||||
require.NoError(t, db.DB(context.Background()).Create(&routeZone).Error)
|
||||
routeZoneDomain := model.ZoneDomain{
|
||||
ZoneID: routeZone.ID,
|
||||
Domain: "route.example-route.com",
|
||||
}
|
||||
require.NoError(t, db.DB(context.Background()).Create(&routeZoneDomain).Error)
|
||||
|
||||
rec := performJSONRequest(t, engine, http.MethodPost, apiPath("/proxy-routes/"), map[string]any{
|
||||
"site_name": "security-site",
|
||||
"domain": "security.example.com",
|
||||
"origin_url": "http://origin.security.internal:8080",
|
||||
"enabled": true,
|
||||
"site_name": "security-site",
|
||||
"zone_domain_ids": []uint{routeZoneDomain.ID},
|
||||
"origin_url": "http://origin.security.internal:8080",
|
||||
"enabled": true,
|
||||
}, adminAuthHeaders(seed.Token))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
@@ -221,7 +229,10 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
|
||||
data := unmarshalAPIMap(t, resp.Data)
|
||||
proxyRouteID = uint(data["id"].(float64))
|
||||
assert.NotZero(t, proxyRouteID)
|
||||
assert.Equal(t, "security.example.com", data["domain"])
|
||||
assert.NotEmpty(t, data["zone_domains"])
|
||||
zoneDomains := data["zone_domains"].([]any)
|
||||
assert.Len(t, zoneDomains, 1)
|
||||
assert.Equal(t, "route.example-route.com", zoneDomains[0].(map[string]any)["domain"])
|
||||
})
|
||||
|
||||
t.Run("bind WAF rule group to proxy route", func(t *testing.T) {
|
||||
@@ -289,12 +300,23 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
|
||||
assert.Equal(t, "upload", data["provider"])
|
||||
})
|
||||
|
||||
t.Run("create managed domain", func(t *testing.T) {
|
||||
rec := performJSONRequest(t, engine, http.MethodPost, apiPath("/managed-domains/"), map[string]any{
|
||||
"domain": "security.example.com",
|
||||
"cert_id": certID,
|
||||
"enabled": true,
|
||||
"remark": "primary security domain",
|
||||
t.Run("create Zone domain", func(t *testing.T) {
|
||||
zoneRec := performJSONRequest(t, engine, http.MethodPost, apiPath("/zones/"), map[string]any{
|
||||
"domain": "example.com",
|
||||
}, adminAuthHeaders(seed.Token))
|
||||
require.Equal(t, http.StatusOK, zoneRec.Code)
|
||||
zoneData := unmarshalAPIMap(t, requireAPIOK(t, zoneRec).Data)
|
||||
zoneID := uint(zoneData["id"].(float64))
|
||||
|
||||
rec := performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
|
||||
"domain": "*.example.com",
|
||||
}, adminAuthHeaders(seed.Token))
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
errResp := decodeAPIResponse(t, rec)
|
||||
assert.NotEmpty(t, errResp.ErrorMsg)
|
||||
|
||||
rec = performJSONRequest(t, engine, http.MethodPost, fmt.Sprintf("%s/zones/%d/domains", apiPath(""), zoneID), map[string]any{
|
||||
"domain": "security.example.com", "cert_id": certID,
|
||||
}, adminAuthHeaders(seed.Token))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
@@ -304,7 +326,6 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
|
||||
assert.NotZero(t, domainID)
|
||||
assert.Equal(t, "security.example.com", data["domain"])
|
||||
assert.Equal(t, float64(certID), data["cert_id"])
|
||||
assert.Equal(t, true, data["enabled"])
|
||||
})
|
||||
|
||||
t.Run("create DNS account", func(t *testing.T) {
|
||||
|
||||
@@ -197,7 +197,7 @@ func ListAccessLogs(ctx context.Context, input AccessLogQuery) (*AccessLogList,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalRecords, totalIPs, err := model.CountOpenFlareAccessLogs(ctx, modelQuery)
|
||||
totalRecords, totalIPs, _, err := model.CountOpenFlareAccessLogs(ctx, modelQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func ListFoldedAccessLogs(ctx context.Context, input AccessLogQuery) (*FoldedAcc
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalRecords, totalIPs, err := model.CountOpenFlareAccessLogs(ctx, modelQuery)
|
||||
totalRecords, totalIPs, _, err := model.CountOpenFlareAccessLogs(ctx, modelQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
)
|
||||
|
||||
const observabilityTrendBuckets = 24
|
||||
const unknownTrendNodeKey = "__unknown__"
|
||||
|
||||
const (
|
||||
healthEventStatusActive = "active"
|
||||
@@ -133,6 +135,12 @@ type diskCounterState struct {
|
||||
seen bool
|
||||
}
|
||||
|
||||
type networkCounterState struct {
|
||||
rx int64
|
||||
tx int64
|
||||
seen bool
|
||||
}
|
||||
|
||||
func buildTrafficWindowSummary(report *model.OpenFlareRequestReport) *TrafficWindowSummary {
|
||||
if report == nil {
|
||||
return nil
|
||||
@@ -257,6 +265,44 @@ func buildHealthSummary(
|
||||
return summary
|
||||
}
|
||||
|
||||
// BuildNodeTrends builds 24h trend series, preferring ClickHouse hourly aggregates
|
||||
// over limited raw snapshot windows so capacity/network/disk charts stay complete.
|
||||
func BuildNodeTrends(
|
||||
ctx context.Context,
|
||||
now time.Time,
|
||||
nodeID string,
|
||||
snapshots []*model.OpenFlareMetricSnapshot,
|
||||
openrestyObs []*model.OpenFlareNodeObservationOpenresty,
|
||||
reports []*model.OpenFlareRequestReport,
|
||||
) NodeTrends {
|
||||
trendSince := now.Add(-24 * time.Hour)
|
||||
trafficTrend := BuildTrafficTrendPoints(now, reports)
|
||||
if trafficHourly, err := model.ListOpenFlareTrafficHourlySince(ctx, nodeID, trendSince); err == nil && len(trafficHourly) > 0 {
|
||||
trafficTrend = BuildTrafficTrendPointsFromHourly(now, trafficHourly)
|
||||
}
|
||||
|
||||
capacityTrend := BuildCapacityTrendPoints(now, snapshots)
|
||||
networkTrend := BuildNetworkTrendPoints(now, snapshots, openrestyObs)
|
||||
diskIOTrend := BuildDiskIOTrendPoints(now, snapshots)
|
||||
|
||||
metricHourly, metricErr := model.ListOpenFlareMetricHourlySince(ctx, nodeID, trendSince)
|
||||
if metricErr == nil && len(metricHourly) > 0 {
|
||||
capacityTrend = BuildCapacityTrendPointsFromHourly(now, metricHourly)
|
||||
diskIOTrend = BuildDiskIOTrendPointsFromHourly(now, metricHourly)
|
||||
}
|
||||
openrestyHourly, openrestyErr := model.ListOpenFlareOpenrestyHourlySince(ctx, nodeID, trendSince)
|
||||
if metricErr == nil && openrestyErr == nil && (len(metricHourly) > 0 || len(openrestyHourly) > 0) {
|
||||
networkTrend = BuildNetworkTrendPointsFromHourly(now, metricHourly, openrestyHourly)
|
||||
}
|
||||
|
||||
return NodeTrends{
|
||||
Traffic24h: trafficTrend,
|
||||
Capacity24h: capacityTrend,
|
||||
Network24h: networkTrend,
|
||||
DiskIO24h: diskIOTrend,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildTrafficTrendPointsFromHourly builds 24h traffic trend buckets from hourly rollups.
|
||||
func BuildTrafficTrendPointsFromHourly(now time.Time, hourly []*model.OpenFlareTrafficHourly) []TrafficTrendPoint {
|
||||
start := trendWindowStart(now)
|
||||
@@ -336,7 +382,30 @@ func BuildCapacityTrendPoints(now time.Time, snapshots []*model.OpenFlareMetricS
|
||||
return points
|
||||
}
|
||||
|
||||
// BuildCapacityTrendPointsFromHourly builds 24h capacity trend buckets from hourly aggregates.
|
||||
func BuildCapacityTrendPointsFromHourly(now time.Time, hourly []*model.OpenFlareMetricHourly) []CapacityTrendPoint {
|
||||
start := trendWindowStart(now)
|
||||
points := make([]CapacityTrendPoint, observabilityTrendBuckets)
|
||||
for index := range points {
|
||||
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
|
||||
}
|
||||
for _, row := range hourly {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
index, ok := trendBucketIndex(row.Hour, start)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
points[index].AverageCPUUsagePercent = row.AverageCPUUsagePercent
|
||||
points[index].AverageMemoryUsagePercent = row.AverageMemoryUsagePercent
|
||||
points[index].ReportedNodes = row.ReportedNodes
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
// BuildNetworkTrendPoints builds 24h network trend buckets.
|
||||
// Host and OpenResty counters are cumulative; values are consecutive deltas.
|
||||
func BuildNetworkTrendPoints(
|
||||
now time.Time,
|
||||
snapshots []*model.OpenFlareMetricSnapshot,
|
||||
@@ -349,24 +418,70 @@ func BuildNetworkTrendPoints(
|
||||
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
|
||||
accumulators[index].nodes = make(map[string]struct{})
|
||||
}
|
||||
sort.Slice(snapshots, func(i int, j int) bool {
|
||||
if snapshots[i].CapturedAt.Equal(snapshots[j].CapturedAt) {
|
||||
return snapshots[i].NodeID < snapshots[j].NodeID
|
||||
}
|
||||
return snapshots[i].CapturedAt.Before(snapshots[j].CapturedAt)
|
||||
})
|
||||
previousHostByNode := make(map[string]networkCounterState, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
if snapshot == nil {
|
||||
continue
|
||||
}
|
||||
nodeKey := snapshot.NodeID
|
||||
if nodeKey == "" {
|
||||
nodeKey = unknownTrendNodeKey
|
||||
}
|
||||
previous := previousHostByNode[nodeKey]
|
||||
previousHostByNode[nodeKey] = networkCounterState{
|
||||
rx: snapshot.NetworkRxBytes,
|
||||
tx: snapshot.NetworkTxBytes,
|
||||
seen: true,
|
||||
}
|
||||
if !previous.seen {
|
||||
continue
|
||||
}
|
||||
index, ok := trendBucketIndex(snapshot.CapturedAt, start)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
points[index].NetworkRxBytes += snapshot.NetworkRxBytes
|
||||
points[index].NetworkTxBytes += snapshot.NetworkTxBytes
|
||||
points[index].NetworkRxBytes += nonNegativeDelta(snapshot.NetworkRxBytes, previous.rx)
|
||||
points[index].NetworkTxBytes += nonNegativeDelta(snapshot.NetworkTxBytes, previous.tx)
|
||||
if snapshot.NodeID != "" {
|
||||
accumulators[index].nodes[snapshot.NodeID] = struct{}{}
|
||||
}
|
||||
}
|
||||
sort.Slice(openrestyObs, func(i int, j int) bool {
|
||||
if openrestyObs[i].CapturedAt.Equal(openrestyObs[j].CapturedAt) {
|
||||
return openrestyObs[i].NodeID < openrestyObs[j].NodeID
|
||||
}
|
||||
return openrestyObs[i].CapturedAt.Before(openrestyObs[j].CapturedAt)
|
||||
})
|
||||
previousOpenrestyByNode := make(map[string]networkCounterState, len(openrestyObs))
|
||||
for _, obs := range openrestyObs {
|
||||
if obs == nil {
|
||||
continue
|
||||
}
|
||||
nodeKey := obs.NodeID
|
||||
if nodeKey == "" {
|
||||
nodeKey = unknownTrendNodeKey
|
||||
}
|
||||
previous := previousOpenrestyByNode[nodeKey]
|
||||
previousOpenrestyByNode[nodeKey] = networkCounterState{
|
||||
rx: obs.OpenrestyRxBytes,
|
||||
tx: obs.OpenrestyTxBytes,
|
||||
seen: true,
|
||||
}
|
||||
if !previous.seen {
|
||||
continue
|
||||
}
|
||||
index, ok := trendBucketIndex(obs.CapturedAt, start)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
points[index].OpenrestyRxBytes += obs.OpenrestyRxBytes
|
||||
points[index].OpenrestyTxBytes += obs.OpenrestyTxBytes
|
||||
points[index].OpenrestyRxBytes += nonNegativeDelta(obs.OpenrestyRxBytes, previous.rx)
|
||||
points[index].OpenrestyTxBytes += nonNegativeDelta(obs.OpenrestyTxBytes, previous.tx)
|
||||
if obs.NodeID != "" {
|
||||
accumulators[index].nodes[obs.NodeID] = struct{}{}
|
||||
}
|
||||
@@ -377,6 +492,48 @@ func BuildNetworkTrendPoints(
|
||||
return points
|
||||
}
|
||||
|
||||
// BuildNetworkTrendPointsFromHourly builds 24h network trend buckets from hourly aggregates.
|
||||
func BuildNetworkTrendPointsFromHourly(
|
||||
now time.Time,
|
||||
metricHourly []*model.OpenFlareMetricHourly,
|
||||
openrestyHourly []*model.OpenFlareOpenrestyHourly,
|
||||
) []NetworkTrendPoint {
|
||||
start := trendWindowStart(now)
|
||||
points := make([]NetworkTrendPoint, observabilityTrendBuckets)
|
||||
for index := range points {
|
||||
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
|
||||
}
|
||||
for _, row := range metricHourly {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
index, ok := trendBucketIndex(row.Hour, start)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
points[index].NetworkRxBytes += row.NetworkRxBytes
|
||||
points[index].NetworkTxBytes += row.NetworkTxBytes
|
||||
if row.ReportedNodes > points[index].ReportedNodes {
|
||||
points[index].ReportedNodes = row.ReportedNodes
|
||||
}
|
||||
}
|
||||
for _, row := range openrestyHourly {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
index, ok := trendBucketIndex(row.Hour, start)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
points[index].OpenrestyRxBytes += row.OpenrestyRxBytes
|
||||
points[index].OpenrestyTxBytes += row.OpenrestyTxBytes
|
||||
if row.ReportedNodes > points[index].ReportedNodes {
|
||||
points[index].ReportedNodes = row.ReportedNodes
|
||||
}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
// BuildDiskIOTrendPoints builds 24h disk IO trend buckets.
|
||||
func BuildDiskIOTrendPoints(now time.Time, snapshots []*model.OpenFlareMetricSnapshot) []DiskIOTrendPoint {
|
||||
start := trendWindowStart(now)
|
||||
@@ -396,7 +553,7 @@ func BuildDiskIOTrendPoints(now time.Time, snapshots []*model.OpenFlareMetricSna
|
||||
for _, snapshot := range snapshots {
|
||||
nodeKey := snapshot.NodeID
|
||||
if nodeKey == "" {
|
||||
nodeKey = "__unknown__"
|
||||
nodeKey = unknownTrendNodeKey
|
||||
}
|
||||
previous := previousByNode[nodeKey]
|
||||
previousByNode[nodeKey] = diskCounterState{
|
||||
@@ -411,16 +568,8 @@ func BuildDiskIOTrendPoints(now time.Time, snapshots []*model.OpenFlareMetricSna
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
readDelta := snapshot.DiskReadBytes - previous.read
|
||||
writeDelta := snapshot.DiskWriteBytes - previous.write
|
||||
if readDelta < 0 {
|
||||
readDelta = 0
|
||||
}
|
||||
if writeDelta < 0 {
|
||||
writeDelta = 0
|
||||
}
|
||||
points[index].DiskReadBytes += readDelta
|
||||
points[index].DiskWriteBytes += writeDelta
|
||||
points[index].DiskReadBytes += nonNegativeDelta(snapshot.DiskReadBytes, previous.read)
|
||||
points[index].DiskWriteBytes += nonNegativeDelta(snapshot.DiskWriteBytes, previous.write)
|
||||
if snapshot.NodeID != "" {
|
||||
accumulators[index].nodes[snapshot.NodeID] = struct{}{}
|
||||
}
|
||||
@@ -431,6 +580,36 @@ func BuildDiskIOTrendPoints(now time.Time, snapshots []*model.OpenFlareMetricSna
|
||||
return points
|
||||
}
|
||||
|
||||
// BuildDiskIOTrendPointsFromHourly builds 24h disk IO trend buckets from hourly aggregates.
|
||||
func BuildDiskIOTrendPointsFromHourly(now time.Time, hourly []*model.OpenFlareMetricHourly) []DiskIOTrendPoint {
|
||||
start := trendWindowStart(now)
|
||||
points := make([]DiskIOTrendPoint, observabilityTrendBuckets)
|
||||
for index := range points {
|
||||
points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour)
|
||||
}
|
||||
for _, row := range hourly {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
index, ok := trendBucketIndex(row.Hour, start)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
points[index].DiskReadBytes += row.DiskReadBytes
|
||||
points[index].DiskWriteBytes += row.DiskWriteBytes
|
||||
points[index].ReportedNodes = row.ReportedNodes
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func nonNegativeDelta(current int64, previous int64) int64 {
|
||||
delta := current - previous
|
||||
if delta < 0 {
|
||||
return 0
|
||||
}
|
||||
return delta
|
||||
}
|
||||
|
||||
func latestMetricSnapshot(snapshots []*model.OpenFlareMetricSnapshot) *model.OpenFlareMetricSnapshot {
|
||||
var latest *model.OpenFlareMetricSnapshot
|
||||
for _, snapshot := range snapshots {
|
||||
|
||||
@@ -132,3 +132,84 @@ func TestBuildTrafficWindowSummaryNilWithoutReport(t *testing.T) {
|
||||
t.Fatalf("buildTrafficWindowSummary(nil) = %#v, want nil", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCapacityTrendPointsFromHourlyFillsBuckets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 7, 10, 9, 30, 0, 0, time.UTC)
|
||||
hourly := []*model.OpenFlareMetricHourly{
|
||||
{
|
||||
Hour: now.Add(-3 * time.Hour).Truncate(time.Hour),
|
||||
AverageCPUUsagePercent: 42.5,
|
||||
AverageMemoryUsagePercent: 61.2,
|
||||
ReportedNodes: 1,
|
||||
},
|
||||
{
|
||||
Hour: now.Truncate(time.Hour),
|
||||
AverageCPUUsagePercent: 12.0,
|
||||
AverageMemoryUsagePercent: 50.0,
|
||||
ReportedNodes: 2,
|
||||
},
|
||||
}
|
||||
|
||||
points := BuildCapacityTrendPointsFromHourly(now, hourly)
|
||||
if len(points) != observabilityTrendBuckets {
|
||||
t.Fatalf("len = %d, want %d", len(points), observabilityTrendBuckets)
|
||||
}
|
||||
if points[len(points)-4].AverageCPUUsagePercent != 42.5 {
|
||||
t.Fatalf("hour-3 cpu = %v, want 42.5", points[len(points)-4].AverageCPUUsagePercent)
|
||||
}
|
||||
if points[len(points)-1].ReportedNodes != 2 {
|
||||
t.Fatalf("current hour reported_nodes = %d, want 2", points[len(points)-1].ReportedNodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNetworkTrendPointsUsesCounterDeltas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 7, 10, 9, 30, 0, 0, time.UTC)
|
||||
base := now.Truncate(time.Hour)
|
||||
snapshots := []*model.OpenFlareMetricSnapshot{
|
||||
{NodeID: "n1", CapturedAt: base.Add(10 * time.Minute), NetworkRxBytes: 1000, NetworkTxBytes: 2000},
|
||||
{NodeID: "n1", CapturedAt: base.Add(20 * time.Minute), NetworkRxBytes: 1500, NetworkTxBytes: 2600},
|
||||
}
|
||||
openrestyObs := []*model.OpenFlareNodeObservationOpenresty{
|
||||
{NodeID: "n1", CapturedAt: base.Add(10 * time.Minute), OpenrestyRxBytes: 100, OpenrestyTxBytes: 200},
|
||||
{NodeID: "n1", CapturedAt: base.Add(20 * time.Minute), OpenrestyRxBytes: 180, OpenrestyTxBytes: 250},
|
||||
}
|
||||
|
||||
points := BuildNetworkTrendPoints(now, snapshots, openrestyObs)
|
||||
current := points[len(points)-1]
|
||||
if current.NetworkRxBytes != 500 {
|
||||
t.Fatalf("network_rx_bytes = %d, want 500", current.NetworkRxBytes)
|
||||
}
|
||||
if current.NetworkTxBytes != 600 {
|
||||
t.Fatalf("network_tx_bytes = %d, want 600", current.NetworkTxBytes)
|
||||
}
|
||||
if current.OpenrestyRxBytes != 80 {
|
||||
t.Fatalf("openresty_rx_bytes = %d, want 80", current.OpenrestyRxBytes)
|
||||
}
|
||||
if current.OpenrestyTxBytes != 50 {
|
||||
t.Fatalf("openresty_tx_bytes = %d, want 50", current.OpenrestyTxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDiskIOTrendPointsFromHourlyFillsBuckets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 7, 10, 9, 30, 0, 0, time.UTC)
|
||||
hourly := []*model.OpenFlareMetricHourly{
|
||||
{
|
||||
Hour: now.Add(-1 * time.Hour).Truncate(time.Hour),
|
||||
DiskReadBytes: 1024,
|
||||
DiskWriteBytes: 2048,
|
||||
ReportedNodes: 1,
|
||||
},
|
||||
}
|
||||
|
||||
points := BuildDiskIOTrendPointsFromHourly(now, hourly)
|
||||
prev := points[len(points)-2]
|
||||
if prev.DiskReadBytes != 1024 || prev.DiskWriteBytes != 2048 {
|
||||
t.Fatalf("previous hour disk io = %#v, want read=1024 write=2048", prev)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user