Compare commits

...

43 Commits

Author SHA1 Message Date
ryan d97581fb1e chore(release): v3.2.0
### 🛠 修复
- 修复了嵌入式静态前端访问 Zone 详情页时回退到默认首页 HTML,导致界面显示错误并触发 React hydration 异常的问题。
- 修复了 Zone 概览中“已提供的数据总计”长期为 0 的问题,确保 Agent 上报的访问日志流量字节数可以正确入库并用于统计。
- 修复了 Zone 域名导入、删除和更新相关接口与前端交互中的异常,提升域名管理流程的稳定性。
- 修复了 Docker 部署 ClickHouse 时监听配置被覆盖的问题,避免宿主机无法访问 ClickHouse 服务。

### ⚡️ 优化与改进
- 新增 Zone 与正规化 Zone 域名管理能力,将网站、域名、证书与反代路由关系收敛到更稳定的资源模型。
- 管理端网站入口调整为 Zone 列表与 Zone 详情页,支持在概览、域名、路由、证书和设置之间统一管理网站资源。
- 配置快照、OpenResty 渲染、Tunnel 与 Uptime Kuma 监控改为从 Zone 域名绑定读取域名和证书,减少反代路由中的冗余字段。
- 新增 Cloudflare 风格的 Zone 流量概览图,支持按 24 小时、7 天和 30 天查看唯一访问者、请求数与已提供数据趋势。
- 支持在系统设置中管理控制台菜单展示范围,便于按使用场景精简侧边栏入口。

### 💄 其他/体验
- 调整数据库自动清理设置文案,明确自动清理遵循 ClickHouse 表 TTL,并说明访问日志与观测数据的保留下限。
- 优化 Zone 列表、Zone 详情页和系统设置页面布局,使域名、路由和快捷创建流程更清晰。
- 补充 Zone 域名迁移与发布验证文档,便于升级前后核对配置快照与回滚策略。
2026-07-12 19:24:37 +08:00
ryan 83f126795d fix: 调整数据库自动清理设置文案 2026-07-12 19:07:56 +08:00
ryan 69467914fc fix(server): 修复 Agent 上报访问日志的 bytes_sent 在 Server 入库链路丢失,导致 Zone 概览“已提供的数据总计”长期为 0 的问题。 2026-07-12 19:02:52 +08:00
ryan c624512da6 fix(frontend): serve zone detail static export fallback 2026-07-12 18:51:01 +08:00
ryan 50717d1baf fix(frontend): support static export dynamic routes compliance via client useParams 2026-07-12 18:07:43 +08:00
ryan fc569d1758 fix(frontend): dynamically import zone dashboard with ssr: false to cure hydration mismatch
- Dynamically import `ZonePageClient` with `ssr: false` in `websites/[zoneId]/page.tsx`.
- Remove `generateStaticParams` to prevent dynamic paths from building with inconsistent SSG/ISR outputs.
- Remove redundant `mounted` check from `page-client.tsx` since dashboard is client-only.
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Support full console menu display management in settings other-tab.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:52:40 +08:00
ryan ec4f1d4d23 feat(settings): support full console menu display management in settings other-tab
- Rebuild MENU_GROUPS in `other-tab.tsx` to include all 13 business console items with safety read-only constraints on Dashboard.
- Support reactive filtering in `OpenFlareSidebarMenu` using `menu_display_config` to hide items and empty collapsible groups.
- Fix React Hydration Error #418 when directly loading dynamic websites on SSR by wrapping client component with mounted state hook.
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:46:49 +08:00
ryan 9268acb84c feat(api): support traffic bytes tracking, refactor analytics models and fix website hydration
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Fix React Hydration Error #418 when directly loading dynamic websites on SSR by wrapping client component with mounted state hook.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:42:19 +08:00
ryan 41cd23a64d feat(api): support traffic bytes tracking in edge access logs and refactor analytics models
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:27:57 +08:00
ryan f02fc9676a fix: resolve all build-test and code-check failures
- Fix Go backend mnd (magic number) and revive lint issues in zone stats.
- Remove unused React/Lucide imports and variables in zone overview.
- Add generateStaticParams and Suspense wrapper for websites/[zoneId] page to support Next.js static HTML export.
- Remove next/font/google dependency to allow fully offline frontend compilation.
- Fix hardcoded time dependency in ssl_renew_test.go.
- Fix async_tasks_test.go to respect minimum 90-day retention clamping in database auto-cleanup.
- Add Zone and ZoneDomain models to test database AutoMigrate schemas.
- Update integration tests to use the new zone_domain_ids route binding scheme.
2026-07-12 17:05:52 +08:00
ryan 31b4886a14 feat(zone): add Cloudflare-style traffic overview charts
Expose Zone stats API with multi-host access-log aggregates and time
series, and render unique visitors, requests and data served on the
Zone overview with 24h/7d/30d range controls.
2026-07-12 16:38:35 +08:00
ryan efcf61e32d feat(web): polish zone list and settings layout
Show Zone list as a table, align detail back navigation with proxy
route detail, and move edit/delete actions into the settings tab.
2026-07-12 16:22:16 +08:00
ryan d615d85a26 refactor: remove unused remarks and add quick domain create
Drop remark fields from Zone, Zone domains, proxy routes, WAF rule
groups and IP groups across models, APIs, UI and DB columns (keep
certificate/origin remarks). Add quick-create domain input for short
labels, @ apex and full FQDNs when binding domains.
2026-07-12 16:16:56 +08:00
ryan 8afd103751 fix(zone): register domain delete/update APIs and drop edit UI
补全 Zone 与 Zone 域名的 update/delete 路由与业务逻辑,修复删除域名
404;前端域名列表移除编辑入口,仅保留添加与删除。
2026-07-12 15:56:45 +08:00
ryan 7ef84cce52 feat(web): improve zone domain list and detail navigation
合并 Zone 域名与路由展示,上游地址多行显示并支持路由详情跳转;
域名列表对齐用户管理页样式;Zone 页支持 ?tab= 定位;反代详情返回
使用浏览器历史上一级。
2026-07-12 15:50:43 +08:00
ryan 96b8ddc077 fix(migrator): only import zone domains during upgrade window
Zone 历史导入仅在 goose 版本位于 [202607120002, 202607130001) 时执行,
phase-2 删列完成后日常启动不再进入导入逻辑。
2026-07-12 15:38:28 +08:00
ryan 03b81e5f74 refactor(migrator): use goose SQL only and auto-import zones on upgrade
移除 Go goose 迁移(bridge/legacy data/zone import),改为 goose SQL 占位
与结构迁移脚本;启动时 Migrate 在删旧列前自动导入历史域名,去掉
migrate-zones 手动命令及文档中的人工导入步骤。
2026-07-12 15:36:00 +08:00
ryan 5b52acdd6c refactor(zone): remove legacy route domain storage
第二阶段清理:删除 of_managed_domains 与 of_proxy_routes 冗余域名/证书列,
移除 ManagedDomain 模型与 API、路由侧 legacy 字段维护,以及前端 WebsiteService。
ImportLegacy 在旧列/旧表缺失时跳过对应源,保持幂等。
2026-07-12 15:31:01 +08:00
ryan fb3dd5afe6 docs(zone): add migration and release verification guide
补充 Zone 域名迁移操作指南(备份、migrate-zones、预览等价性、发布与回滚),
更新设计文档阶段说明、指南导航与 Unreleased 变更日志。
2026-07-12 15:31:01 +08:00
ryan 1160d5846a refactor(web): select route domains from zones
反代路由创建/域名配置改为绑定 zone_domain_ids,移除手写域名列表与
旧证书字段;列表与 WAF/UptimeKuma 消费端同步读取 zone_domains。
2026-07-12 15:23:48 +08:00
ryan 350b433cc1 feat(web): add zone-based website management
以稳定 Zone ID 替换旧托管域名详情页:列表展示根域与域名计数,详情提供
概览/域名/路由/证书/设置 Tabs,并补齐 ZoneService 与 vitest 行为测试。
Zone 列表 API 返回 domain_count;同步 Swagger 与重构计划进度。
2026-07-12 15:23:43 +08:00
ryan d4d9bad74d refactor(config): render routes from zone domains 2026-07-12 15:03:24 +08:00
ryan d0536fcdd5 refactor(proxy): bind routes through zone domains 2026-07-12 14:52:19 +08:00
ryan e51f1e583d chore: remove execution report artifact 2026-07-12 14:41:22 +08:00
ryan b835144cd0 merge: zone domain foundation 2026-07-12 14:41:04 +08:00
ryan 53c868e99b feat(zone): add zone management api and legacy importer 2026-07-12 14:35:53 +08:00
ryan 50678756d4 feat(zone): add normalized zone domain schema 2026-07-12 14:23:25 +08:00
ryan 3eb670c674 chore: ignore local worktrees 2026-07-12 14:17:07 +08:00
ryan d10132fb02 docs(plan): add zone domain refactor plan 2026-07-12 14:14:25 +08:00
ryan 61cf581621 docs(zone): clarify certificate ownership 2026-07-12 14:08:43 +08:00
ryan e2ac531abb docs(zone): define zone domain management model 2026-07-12 14:05:04 +08:00
ryan c8b1289043 fix(docker): preserve clickhouse listener config 2026-07-12 12:28:34 +08:00
ryan 13c5073bf8 chore(release): v3.1.2
### 🛠 修复
- 修复了节点与仪表盘 24 小时容量、网络、磁盘 IO 趋势在限流查询下几乎为空的问题,改为小时级聚合与计数器增量统计,历史时段可正常展示。
- 修复了静置场景下 ClickHouse CPU 偏高的问题,可观测与访问日志写入改为批量凑批并限制小 part 产生。
- 修复了数据清理接口将「物化表 TTL」误报为已删除行数的问题,短于表 TTL 的保留天数会被明确拒绝。
- 修复了可观测去重在入队/刷盘失败后仍占用键、导致故障窗口数据更易丢失的问题,并在 flush 失败时短重试与释放键。
- 修复了 Dashboard「每节点最新指标」被全局 LIMIT 截断导致安静节点缺失的问题,改为按节点取最新快照。
- 修复了 Docker 部署 ClickHouse 25.x 因后台池与 mutation 空闲阈值不兼容而无法启动的问题。
- 修复了小时预聚合仅有迁移后少量数据时 24 小时趋势再次残缺的问题:读路径按小时 merge(窗口完整走 rollup,缺口用 raw 补齐),并增加历史 backfill 迁移。

### ⚡️ 优化与改进
- 为容量与 OpenResty 指标增加小时预聚合表,窗口完整时优先走 rollup 降低查询压力。
- 审计日志写入增加最长等待刷盘,管理端可观测状态接口暴露 batch writer 队列深度、丢弃与 flush 错误指标。
- 小规格场景下调 ClickHouse 客户端连接池默认值,并调整 async_insert 合并超时与流量小时表 TTL。
- 流量独立访客在小时汇总中改为窗口峰值估计,并修正界面文案,避免被误解为全局真实 UV。

### 💄 其他/体验
- 同步环境变量与配置模板中的 ClickHouse 说明;部署文档改为将 performance.xml 下载到 ./config/clickhouse 后挂载,且不挂载 listen 配置。
2026-07-10 11:42:49 +08:00
ryan da1dd92404 fix(observability): merge rollup+raw hourly trends and backfill history
Prefer capacity/openresty rollups only when they cover the 24h window;
otherwise merge per hour so raw fills pre-MV gaps and rollup wins on
overlap. Add a one-time ANTI JOIN backfill migration for the last 30 days.
2026-07-10 11:27:51 +08:00
ryan 4b11279662 fix(observability): fall back to raw hourly when rollup is incomplete
Materialized capacity/openresty hourly tables only hold data after the MV
exists. Preferring any non-empty rollup hid full raw history and left 24h
charts with only recent hours. Use rollup only when its earliest bucket
covers the query window start.
2026-07-10 11:27:51 +08:00
ryan bbadcca294 ### 🛠 修复
- 修复了节点与仪表盘 24 小时容量、网络、磁盘 IO 趋势在限流查询下几乎为空的问题,改为小时级聚合与计数器增量统计,历史时段可正常展示。
- 修复了静置场景下 ClickHouse CPU 偏高的问题,可观测与访问日志写入改为批量凑批并限制小 part 产生。
- 修复了数据清理接口将「物化表 TTL」误报为已删除行数的问题,短于表 TTL 的保留天数会被明确拒绝。
- 修复了可观测去重在入队/刷盘失败后仍占用键、导致故障窗口数据更易丢失的问题,并在 flush 失败时短重试与释放键。
- 修复了 Dashboard「每节点最新指标」被全局 LIMIT 截断导致安静节点缺失的问题,改为按节点取最新快照。
- 修复了 Docker 部署 ClickHouse 25.x 因后台池与 mutation 空闲阈值不兼容而无法启动的问题。

### ⚡️ 优化与改进
- 为容量与 OpenResty 指标增加小时预聚合表,读路径优先 rollup,降低 24 小时趋势查询压力。
- 审计日志写入增加最长等待刷盘,管理端可观测状态接口暴露 batch writer 队列深度、丢弃与 flush 错误指标。
- 小规格场景下调 ClickHouse 客户端连接池默认值,并调整 async_insert 合并超时与流量小时表 TTL。
- 流量独立访客在小时汇总中改为窗口峰值估计,并修正界面文案,避免被误解为全局真实 UV。

### 💄 其他/体验
- 同步环境变量与配置模板中的 ClickHouse 说明;部署文档改为将 performance.xml 下载到 ./config/clickhouse 后挂载,且不挂载 listen 配置。
2026-07-10 11:27:51 +08:00
ryan 44ce6497a1 docs(docker): use ./config/clickhouse for CH performance mount
Move performance.xml to config/clickhouse and mount the directory to
/etc/clickhouse-server/config.d; update docs and compose paths.
2026-07-10 11:10:32 +08:00
ryan 4b83f91b31 docs(docker): use ./config/clickhouse for CH performance mount
Move performance.xml to config/clickhouse and mount the directory to
/etc/clickhouse-server/config.d; update docs and compose paths.
2026-07-10 10:59:21 +08:00
ryan 9d2fac5d4c docs(config): sync .env.example and config.example.yaml for CH defaults
Align ClickHouse pool/password placeholders and docker-compose env docs with
runtime defaults and published ports used in local Docker testing.
2026-07-10 10:48:30 +08:00
ryan b4b93ff4ed fix(docker): make ClickHouse startable on 25.x and reachable from host
Lower merge-tree free-entry thresholds for small background pools, bind
listen_host to 0.0.0.0 for published ports, and allow CLICKHOUSE_ENABLED=true
in tests for live_ch smoke coverage.
2026-07-10 10:44:49 +08:00
ryan 160e63558f fix(clickhouse): harden R/W path P0–P3 (cleanup, durability, rollups)
Honest TTL cleanup semantics; enqueue-safe dedup with flush retry and writer
metrics; model insert hooks; latest-per-node and hourly metric/openresty
rollups; small-host pool/async defaults, traffic hourly TTL, and UV labeling.
2026-07-10 10:34:04 +08:00
ryan 9b3555c569 fix(clickhouse): cut idle CPU from tiny parts and oversized merge pools
Observability writers flushed every few seconds with MinBatchSize unset,
creating constant small parts and merge load. Enable MinBatchSize with
MaxFlushWait, batch access logs more aggressively, and shrink ClickHouse
background pools for 3c hosts.
2026-07-10 10:08:32 +08:00
ryan b928928958 fix(observability): restore 24h capacity/network/disk trends via CH hourly agg
Node and dashboard 24h capacity, network, and disk IO charts only used the
latest limited raw snapshots (120/500 rows), so historical hour buckets stayed
empty. Prefer ClickHouse hourly aggregates with counter deltas, and fall back
to raw snapshots when aggregation is unavailable.
2026-07-10 09:48:45 +08:00
203 changed files with 12410 additions and 6162 deletions
+18 -4
View File
@@ -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
View File
@@ -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/
+11 -1
View File
@@ -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
View File
@@ -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.
+25
View File
@@ -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
View File
@@ -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>
+47
View File
@@ -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
View File
@@ -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[] {
}
]
}
+55 -8
View File
@@ -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. 构建管理端前端
+3 -2
View File
@@ -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)。
+2 -2
View File
@@ -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)。
+100
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -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):查看系统依赖的优秀开源项目与社区致谢清单。
## 按角色查找
+16 -3
View File
@@ -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
+52
View File
@@ -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`。
+491
View File
@@ -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
View File
@@ -11,7 +11,9 @@
## 正在进行的计划
当前暂无正在进行的开发计划或 AI 接手计划。所有历史迁移与重构项目(如后端/前端向 Wavelet 平台的迁移、边缘运行时重构等)均已完成开发并上线,对应的临时计划文档已归档清理。
当前进行中的开发计划:
* [Zone 与域名资源重构](./20260712-zone-domain-refactor.md):以 Zone 和正规化 Zone 域名替代托管域名及反代路由中的域名/证书冗余字段。
## 使用建议
+9 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+487 -314
View File
@@ -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>
);
}
+142 -181
View File
@@ -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>
);
}
+1 -1
View File
@@ -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 -8
View File
@@ -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
+5 -4
View File
@@ -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,
+13 -6
View File
@@ -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,
+57 -36
View File
@@ -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`) }
}
+6 -1
View File
@@ -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"
}
}
+1198
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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();
});
});
+145
View File
@@ -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();
});
});
+15
View File
@@ -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'],
},
})
+15 -2
View File
@@ -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)
}
}
+6 -1
View File
@@ -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
+1 -1
View File
@@ -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 -11
View File
@@ -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
}
+164 -2
View File
@@ -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)
}
}
+171 -59
View File
@@ -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{}{}
+13 -13
View File
@@ -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)
}
+12 -5
View File
@@ -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