mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 13:46:38 +08:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d97581fb1e | |||
| 83f126795d | |||
| 69467914fc | |||
| c624512da6 | |||
| 50717d1baf | |||
| fc569d1758 | |||
| ec4f1d4d23 | |||
| 9268acb84c | |||
| 41cd23a64d | |||
| f02fc9676a | |||
| 31b4886a14 | |||
| efcf61e32d | |||
| d615d85a26 | |||
| 8afd103751 | |||
| 7ef84cce52 | |||
| 96b8ddc077 | |||
| 03b81e5f74 | |||
| 5b52acdd6c | |||
| fb3dd5afe6 | |||
| 1160d5846a | |||
| 350b433cc1 | |||
| d4d9bad74d | |||
| d0536fcdd5 | |||
| e51f1e583d | |||
| b835144cd0 | |||
| 53c868e99b | |||
| 50678756d4 | |||
| 3eb670c674 | |||
| d10132fb02 | |||
| 61cf581621 | |||
| e2ac531abb | |||
| c8b1289043 | |||
| 13c5073bf8 | |||
| da1dd92404 | |||
| 4b11279662 | |||
| bbadcca294 | |||
| 44ce6497a1 | |||
| 4b83f91b31 | |||
| 9d2fac5d4c | |||
| b4b93ff4ed | |||
| 160e63558f | |||
| 9b3555c569 | |||
| b928928958 | |||
| b312460ddf | |||
| 50f7257d93 | |||
| 336185f01c | |||
| 44bba0f19a | |||
| f0eca028f9 | |||
| 58624db397 | |||
| 38946d1af5 | |||
| caf2ffcff4 | |||
| 0e86fe3547 | |||
| 6525bef15d | |||
| 3e910f1961 | |||
| 28c14eb054 | |||
| ae618905a3 | |||
| 5ad151469c | |||
| 6467b32d8e | |||
| cf72420815 | |||
| 34225cb88a | |||
| 389f02b6b0 | |||
| 2fcbb945fb | |||
| 23501259b2 | |||
| c561e65cd3 | |||
| 97095e8f12 | |||
| 23be2f9296 | |||
| 113ea25aa4 | |||
| c230d5a744 | |||
| c02b649b46 | |||
| fb54d6da61 | |||
| 9c7896df50 | |||
| 01ebec6dfb | |||
| 2816152536 | |||
| b029714c7a | |||
| e0f452eaae | |||
| d721a8fd74 | |||
| 1e349e5cde |
+21
-7
@@ -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,15 +23,16 @@ 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=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_USERNAME=openflare
|
||||
DB_PASSWORD=replace-with-strong-password
|
||||
DB_NAME=openflare
|
||||
DB_SSL_MODE=disable
|
||||
DB_TIMEZONE=Asia/Shanghai
|
||||
@@ -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
|
||||
CLICKHOUSE_PASSWORD=123456
|
||||
# 须与 compose clickhouse 服务密码一致(首次初始化后改密码需清 data/clickhouse_data)
|
||||
CLICKHOUSE_PASSWORD=replace-with-clickhouse-password
|
||||
CLICKHOUSE_NAME=openflare
|
||||
|
||||
|
||||
# ─── 日志 ──────────────────────────────────────────────────────────────────────
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=console
|
||||
@@ -67,6 +76,11 @@ OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
OTEL_SAMPLING_RATE=0.0
|
||||
# 全局 Tracer 命名空间,默认为 github.com/Rain-kl/OpenFlare
|
||||
# OTEL_TRACER_NAME=github.com/Rain-kl/OpenFlare
|
||||
# compose 可选端口覆盖
|
||||
# JAEGER_VERSION=2.19.0
|
||||
# JAEGER_UI_PORT=16686
|
||||
# JAEGER_OTLP_GRPC_PORT=4317
|
||||
# JAEGER_OTLP_HTTP_PORT=4318
|
||||
|
||||
# ─── Worker ────────────────────────────────────────────────────────────────────
|
||||
# WORKER_CONCURRENCY=20
|
||||
|
||||
+4
-1
@@ -77,4 +77,7 @@ profile.cov
|
||||
.grok
|
||||
/.gomodcache/
|
||||
*.mmdb
|
||||
!internal/apps/agent/geoipdata/GeoLite2-Country.mmdb
|
||||
!internal/apps/agent/geoipdata/GeoLite2-Country.mmdb
|
||||
|
||||
/.superpowers/
|
||||
/.worktrees/
|
||||
|
||||
@@ -352,5 +352,6 @@ frontend/lib/services/<service-name>/
|
||||
```
|
||||
|
||||
- 服务类继承 `BaseService`,定义 `basePath`,并暴露有类型的静态方法。
|
||||
- **防止回调 `this` 上下文丢失(核心规范)**:在传递服务类的静态方法作为组件事件回调(如 `onClick`)或 React Query 的 `mutationFn`/`queryFn` 时,**禁止直接传递静态方法引用**(如 `mutationFn: DnsAccountService.create`),必须使用箭头函数包裹以防止 `this` 上下文丢失导致运行时崩溃(如 `mutationFn: (payload) => DnsAccountService.create(payload)`)。
|
||||
- 在 `frontend/lib/services/index.ts` 中注册新服务。
|
||||
|
||||
|
||||
+19
-35
@@ -56,7 +56,7 @@ Quick links:
|
||||
```yaml
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare-server:latest
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -64,7 +64,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- openflare_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -77,13 +77,13 @@ services:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- ./data/postgres_data:/var/lib/postgresql/data
|
||||
- openflare_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -93,7 +93,7 @@ services:
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- ./data/valkey:/data
|
||||
- openflare_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
@@ -105,19 +105,25 @@ services:
|
||||
image: clickhouse/clickhouse-server:25.3-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CLICKHOUSE_DB: openflare
|
||||
CLICKHOUSE_USER: default
|
||||
CLICKHOUSE_PASSWORD: 123456
|
||||
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
volumes:
|
||||
- ./data/clickhouse_data:/var/lib/clickhouse
|
||||
- openflare_clickhouse_data:/var/lib/clickhouse
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--query", "SELECT 1"]
|
||||
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
|
||||
@@ -204,28 +210,6 @@ The version number format is fixed as `YYYYMMDD-NNN`. Historical versions are im
|
||||
|
||||

|
||||
|
||||
## Management Panel & API
|
||||
|
||||
The management panel includes:
|
||||
|
||||
* Reverse Proxy Rules
|
||||
* Configuration Versions
|
||||
* Node Management
|
||||
* Application Records
|
||||
* TLS Certificates
|
||||
* Domain Management
|
||||
* Pages Static Hosting
|
||||
* WAF Rule Groups
|
||||
* Intranet Tunnels
|
||||
* Uptime Kuma Monitoring Sync
|
||||
* SSO Login Configuration
|
||||
* User Management
|
||||
* Settings
|
||||
* Version Updates
|
||||
* PoW Rules
|
||||
|
||||
After logging in to the dashboard, access Swagger UI at: `/swagger/index.html`
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under [Apache License 2.0](./LICENSE).
|
||||
|
||||
@@ -21,7 +21,7 @@ OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `root` 用户初次登录系统后,务必修改默认密码 `123456`。
|
||||
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
|
||||
>
|
||||
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
|
||||
|
||||
@@ -48,16 +48,41 @@ OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、
|
||||
* **SSO 单点登录**:支持 GitHub OAuth 与标准 OIDC 协议,无缝接入企业身份提供商实现统一登录。
|
||||
* **统一观测**:聚合节点请求指标、实时访问日志明细、宿主机与 Nginx 资源快照、健康事件以及网络波动补传缓冲。
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 仪表盘总览
|
||||
|
||||

|
||||
|
||||
### 节点详情
|
||||
|
||||

|
||||
|
||||
### 配置新增
|
||||
|
||||

|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启动 Server
|
||||
|
||||
使用 docker-compose
|
||||
|
||||
```bash
|
||||
# 下载环境变量模板并创建 .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
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare-server:latest
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -65,7 +90,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- openflare_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -78,13 +103,13 @@ services:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- ./data/postgres_data:/var/lib/postgresql/data
|
||||
- openflare_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -94,7 +119,7 @@ services:
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- ./data/valkey:/data
|
||||
- openflare_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
@@ -106,19 +131,30 @@ services:
|
||||
image: clickhouse/clickhouse-server:25.3-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CLICKHOUSE_DB: openflare
|
||||
CLICKHOUSE_USER: default
|
||||
CLICKHOUSE_PASSWORD: 123456
|
||||
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- ./data/clickhouse_data:/var/lib/clickhouse
|
||||
- openflare_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", "${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:
|
||||
```
|
||||
|
||||
详细部署说明见 [部署文档](https://open-flare.pages.dev/deployment/deployment)。
|
||||
@@ -127,8 +163,8 @@ services:
|
||||
|
||||
默认账号:
|
||||
|
||||
* 用户名:`root`
|
||||
* 密码:`123456`
|
||||
* 用户名:`admin`
|
||||
* 密码:`12345678`
|
||||
|
||||
### 2. 安装 Agent
|
||||
|
||||
@@ -150,42 +186,6 @@ docker run -d --name openflare-agent --restart unless-stopped \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 仪表盘总览
|
||||
|
||||

|
||||
|
||||
### 节点详情
|
||||
|
||||

|
||||
|
||||
### 配置新增
|
||||
|
||||

|
||||
|
||||
## 管理端与接口
|
||||
|
||||
管理端当前覆盖:
|
||||
|
||||
* 反代规则
|
||||
* 配置版本
|
||||
* 节点管理
|
||||
* 应用记录
|
||||
* TLS 证书
|
||||
* 域名管理
|
||||
* Pages 静态托管
|
||||
* WAF 规则组
|
||||
* 内网穿透(Tunnels)
|
||||
* Uptime Kuma 监控同步
|
||||
* SSO 登录配置
|
||||
* 用户管理
|
||||
* 设置
|
||||
* 版本更新
|
||||
* PoW 规则
|
||||
|
||||
登录管理端后,可访问 Swagger UI:`/swagger/index.html`
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
|
||||
+8
-5
@@ -99,15 +99,18 @@ otel:
|
||||
|
||||
|
||||
# ─── ClickHouse (required) ──────────────────────────────────────────────────────
|
||||
# Analytics / observability OLAP store. Telemetry writes are best-effort (async batch).
|
||||
clickhouse:
|
||||
enabled: true
|
||||
hosts:
|
||||
- "127.0.0.1:9000"
|
||||
- "127.0.0.1:9000" # compose 内应用可用 clickhouse:9000(经 CLICKHOUSE_HOST)
|
||||
username: "default"
|
||||
password: "123456"
|
||||
password: "replace-with-clickhouse-password" # 与 .env / compose CLICKHOUSE_PASSWORD 一致
|
||||
database: "openflare"
|
||||
max_idle_conn: 10
|
||||
max_open_conn: 100
|
||||
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: 10
|
||||
block_buffer_size: 32 # rows buffered per block; 32 is enough for our batch sizes
|
||||
# Runtime client also enables async_insert (wait_for_async_insert=1, busy_timeout≈2s)
|
||||
# in internal/db/clickhouse.go — not configured via YAML.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
Tuned for small control-plane hosts (e.g. 3c6g).
|
||||
|
||||
background_pool_size * background_merges_mutations_concurrency_ratio must stay
|
||||
greater than merge_tree number_of_free_entries_in_pool_to_execute_mutation
|
||||
(ClickHouse 25.x refuses to start otherwise). Keep the merge free-entry
|
||||
thresholds low so a small pool remains valid.
|
||||
-->
|
||||
<clickhouse>
|
||||
<max_concurrent_queries>20</max_concurrent_queries>
|
||||
<background_pool_size>4</background_pool_size>
|
||||
<background_merges_mutations_concurrency_ratio>2</background_merges_mutations_concurrency_ratio>
|
||||
<background_schedule_pool_size>4</background_schedule_pool_size>
|
||||
<background_common_pool_size>2</background_common_pool_size>
|
||||
<background_fetches_pool_size>2</background_fetches_pool_size>
|
||||
<background_move_pool_size>1</background_move_pool_size>
|
||||
<mark_cache_size>268435456</mark_cache_size>
|
||||
<uncompressed_cache_size>0</uncompressed_cache_size>
|
||||
<merge_tree>
|
||||
<number_of_free_entries_in_pool_to_execute_mutation>2</number_of_free_entries_in_pool_to_execute_mutation>
|
||||
<number_of_free_entries_in_pool_to_lower_max_size_of_merge>2</number_of_free_entries_in_pool_to_lower_max_size_of_merge>
|
||||
<number_of_free_entries_in_pool_to_execute_optimize_entire_partition>2</number_of_free_entries_in_pool_to_execute_optimize_entire_partition>
|
||||
</merge_tree>
|
||||
</clickhouse>
|
||||
+16
-11
@@ -5,7 +5,7 @@ services:
|
||||
dockerfile: docker/Dockerfile
|
||||
args:
|
||||
VERSION: v0.9.9
|
||||
# image: ghcr.io/rain-kl/openflare-server:latest
|
||||
# image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -34,13 +34,13 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- ./data/postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -74,18 +74,23 @@ services:
|
||||
image: clickhouse/clickhouse-server:25.3-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CLICKHOUSE_DB: openflare
|
||||
CLICKHOUSE_USER: default
|
||||
CLICKHOUSE_PASSWORD: 123456
|
||||
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ulimits:
|
||||
nofile:
|
||||
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
|
||||
- ./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", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 64 KiB |
@@ -11,6 +11,9 @@ sidebar: false
|
||||
## 重大变更
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> 3.1.2 版本更新了 CLickHouse 部署配置。
|
||||
>
|
||||
> 3.0.0 版本为 Wavelet 平台迁移与架构重构版本,涉及数据库表结构、环境变量以及前后端底层架构的重大变更。请务必在升级前备份数据库,并且更新到 V2.3.4。
|
||||
> 目前已知的兼容性问题:
|
||||
> - Pages 无法迁移, 升级前请先手动下载并备份 Pages 静态站点的 ZIP 包,升级后重新创建。
|
||||
@@ -18,6 +21,99 @@ 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
|
||||
|
||||
### 修改
|
||||
|
||||
- 将 `cap_login_enabled` 默认值由 `true` 变更为 `false`,默认关闭登录界面 PoW 人机验证。
|
||||
|
||||
## [v3.1.0] - 2026-07-04
|
||||
|
||||
### 修改
|
||||
|
||||
- 修复 ClickHouse TTL 迁移:`DateTime64` 时间列通过 `toDateTime()` 转换后再设置 TTL,避免 goose 启动报错;移除 `MODIFY ORDER BY`(ClickHouse 不允许将排序键缩短至短于隐式主键前缀)。
|
||||
- ClickHouse 遗留治理 Phase 2:保留期清理改为 TTL `MATERIALIZE TTL`(全量清理使用 `TRUNCATE`),消除定时 `ALTER DELETE` mutation;移除 GORM 双连接池并统一 `ChConn` 读路径;查询侧去除 `trim(remote_addr)`;`wait_for_async_insert` 调整为 1;新增 `/admin/status/clickhouse` 运维指标与 `of_node_traffic_hourly` 预聚合 MV。
|
||||
- ClickHouse 写入路径优化:移除 Agent 心跳路径中的同步 `ALTER DELETE` 保留清理;`batchwriter` 新增 `MinBatchSize` 抑制过小批次定时 flush;可观测 writer 批次提升至 500、flush 间隔 5s,并为 OpenResty/FRPS/FRPC 补全去重。
|
||||
- ClickHouse 客户端启用 `async_insert` 异步写入缓冲,并调高 `block_buffer_size` 与连接池默认值,降低小 part 与连接争用。
|
||||
- Dashboard 与节点可观测 API 消除无 `LIMIT` 全表扫描、增加短 TTL 内存缓存,前端轮询间隔分别调整为 60s/30s。
|
||||
- 访问日志与 WAF IP 组同步改为 ClickHouse 侧聚合与 SQL 分页,默认查询窗口限制为近 7 天,浏览器分布查询增加 Top 100 限制。
|
||||
- ClickHouse 分析表新增 TTL 自动过期策略:`w_user_access_logs` 180 天、`of_node_access_logs` 90 天,其余节点观测与聚合表 30 天。
|
||||
- 节点访问日志写入 ClickHouse 时对 `remote_addr` 执行 `TrimSpace` 规范化,避免首尾空白影响 IP 汇总统计。
|
||||
- 数据库自动清理任务新增 OpenResty、FRPS、FRPC 观测表清理目标。
|
||||
- Docker 部署为 ClickHouse 服务增加 `nofile` ulimits 与 `docker/clickhouse/config.d/performance.xml` 性能配置挂载,限制 `max_concurrent_queries`、`background_pool_size` 与 `background_merges_mutations_concurrency_ratio`,降低高负载下的合并与查询争用。
|
||||
- 审计访问日志写入 ClickHouse 时仅保留安全相关请求头(Authorization、Cookie、X-Forwarded-For、X-Real-IP、User-Agent、Content-Type),敏感头字段以 SHA-256 摘要脱敏,并将序列化后的 headers 载荷上限收紧至 2KB,减小 `w_user_access_logs` 行宽与 merge CPU 开销。
|
||||
- 隐藏侧边栏“文档库”分组中的“规范示例”与“接口文档”,并将“使用文档”及其他相关页面的文档链接统一跳转至外部文档 https://open-flare.pages.dev/
|
||||
- 修复全局搜索数据源覆盖不全的问题,补全了所有核心业务控制台页面(节点、规则、域名、证书、DNS、源站、WAF、IP组、Pages、版本发布、访问日志、应用记录和性能调优)及缺失的管理员专有页面(存储、数据、推送、日志)的搜索检索支持。
|
||||
- 修复系统自更新(Updater)检测上游 GitHub Action Release 时,因资产包名称前缀(`openflare-server`)与仓库名不完全一致导致匹配失败并报错“未找到兼容的 Release”的问题。
|
||||
- 修复系统设置页面(`/admin/settings`)基于 URL `tab` 参数的定位逻辑,补全缺失的 `openflare-ops` (OpenFlare) Tab,且在不带参数时默认选中 OpenFlare 选项卡。
|
||||
- 移除系统设置中 OpenFlare 标签页下的“版本信息”卡片及对应的升级管理弹窗逻辑。
|
||||
|
||||
## [v3.0.2] - 2026-06-30
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 PostgreSQL 自增主键序列在历史数据迁移(INSERT 指定显式 ID)后与实际数据不同步的问题,通过新增全局序列同步脚本一键重置所有相关表的自增计数器。
|
||||
|
||||
## [v3.0.1] - 2026-06-30
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增管理后台用户个人信息编辑与重置密码功能。
|
||||
- 新增用户列表邮箱列展示以及基于邮箱的搜索过滤。
|
||||
- 后端新增 `reset-passwd` 命令行工具,支持通过命令行直接重置用户密码。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复添加 DNS 账号时因直接传递 class static 方法作为 React Query 的 mutationFn 导致 JavaScript 丢失 `this` 上下文报错 `this.post is not a function` 的问题。
|
||||
- 修复侧边栏一级菜单项当前页面字体颜色被硬编码为 `#6366F1` 的问题,改用 CSS 主题变量 `text-sidebar-primary`,以保证在多主题系统下的色彩一致性。
|
||||
- 修复默认(Default)主题因遗漏声明 `destructive-foreground` 变量,导致删除按钮(如确认删除证书弹窗)在某些状态下渲染为黑底黑字而无法阅读的问题。
|
||||
- 修复 Cobra 命令行初始化注册逻辑,确保所有应用运行模式(All, API, Worker, Scheduler)都正确注册为 Cobra 子命令。
|
||||
- 优化系统设置页面的色彩定义,移除硬编码的 Indigo 靛蓝色以适配多主题切换。
|
||||
|
||||
## [v3.0.0] - 2026-06-27
|
||||
|
||||
### 升级与迁移注意事项
|
||||
|
||||
+2
-1
@@ -73,6 +73,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
{ text: '概览', link: '' },
|
||||
{ text: '快速开始', link: 'quick-start' },
|
||||
{ text: 'TLS 证书与自动续期', link: 'certificates' },
|
||||
{ text: 'Zone 域名迁移', link: 'zone-domain-migration' },
|
||||
{ text: '新建反代配置', link: 'proxy-config' },
|
||||
{ text: 'Pages 静态托管使用', link: 'pages-usage' },
|
||||
{ text: '内网穿透与隧道使用', link: 'tunnel-usage' },
|
||||
@@ -125,6 +126,7 @@ function sidebarDesign(): DefaultTheme.SidebarItem[] {
|
||||
items: [
|
||||
{ text: '产品边界', link: '' },
|
||||
{ text: '系统架构', link: 'architecture' },
|
||||
{ text: 'Zone 与域名资源设计', link: 'zone-design' },
|
||||
{ text: 'Agent 与发布模型', link: 'agent-design' },
|
||||
{ text: '内网穿透隧道设计', link: 'tunnel-design' },
|
||||
{ text: 'WAF 设计', link: 'waf-design' },
|
||||
@@ -135,4 +137,3 @@ function sidebarDesign(): DefaultTheme.SidebarItem[] {
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ Agent:
|
||||
仓库根目录已提供完整 `docker-compose.yaml`(含 PostgreSQL、Redis、ClickHouse、Jaeger)。
|
||||
|
||||
```bash
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
# 编辑 .env,至少修改 APP_SESSION_SECRET 与数据库密码
|
||||
docker compose up -d
|
||||
@@ -88,7 +89,7 @@ docker compose ps
|
||||
docker compose logs -f openflare
|
||||
```
|
||||
|
||||
首次访问 `http://localhost:3000`,默认账号为 `root` / `123456`。登录后请立即修改默认密码。
|
||||
首次访问 `http://localhost:3000`,默认账号为 `admin` / `12345678`。登录后请立即修改默认密码。
|
||||
|
||||
## 源码启动 Server
|
||||
|
||||
@@ -117,22 +118,6 @@ go run main.go all
|
||||
|
||||
Docker 部署是 Agent 推荐的部署方式。Docker 部署时直接运行 Agent 镜像,该镜像基于 OpenResty 镜像制作,内置 Agent 控制器与 OpenResty 二进制。未显式配置 `node_ip` 时,Agent 会优先通过第三方 API 获取真实出口 IP,避免把 Docker 网桥地址登记为节点 IP。
|
||||
|
||||
> [!NOTE]
|
||||
> Agent 镜像已完成非 Root 安全加固,统一以普通用户 `openflare` 权限运行,通过内核 capabilities 授权(`cap_net_bind_service`)监听 80/443 特权端口,并自动重定向临时文件和 PID 路径至容器内 `/data` 目录以防止写入冲突。
|
||||
|
||||
挂载配置文件:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443/tcp -p 443:443/udp \
|
||||
-v ./agent.json:/etc/openflare/agent.json:ro \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
使用环境变量:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
@@ -219,26 +204,3 @@ export LOG_LEVEL='info'
|
||||
默认情况下,Agent 在 HTTP 心跳成功后会尝试升级为 WebSocket。升级成功时,Server 发布或激活配置会立即通知 Agent;如果 WebSocket 无法建立或意外断开,Agent 会自动退回 HTTP 心跳同步。
|
||||
|
||||
WAF 地域规则依赖 Agent 本地 `GeoLite2-Country.mmdb`。Agent 启动时会在 `data_dir/etc/openflare/GeoLite2-Country.mmdb` 初始化内置数据库,并按配置周期尝试更新;更新失败只记录警告,不影响配置同步与 OpenResty reload。
|
||||
|
||||
## 升级与卸载
|
||||
|
||||
Server:
|
||||
|
||||
* Root 用户可在管理端顶栏检查并升级正式版。
|
||||
* 如需尝试 preview 版本,可手动检查对应发布。
|
||||
* 也可通过上传 Server 二进制的方式执行确认升级。
|
||||
|
||||
Agent:
|
||||
|
||||
* Agent 默认只跟随正式版自动更新。
|
||||
* Agent 自更新从 GitHub Release 拉取目标二进制,优先使用 Release API 的 `digest` 字段做 SHA-256 校验;仅当 digest 为空(历史 Release)时才回退读取同名 `.sha256` 侧车文件,校验通过后才替换本地可执行文件。
|
||||
* 安装脚本可重复执行,用于重装或升级 Agent。
|
||||
* preview 升级需要手动触发。
|
||||
|
||||
卸载 Agent:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
卸载脚本会停止 Agent、删除 systemd 服务和安装目录,不会删除本机 OpenResty。
|
||||
|
||||
+91
-30
@@ -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 部署 (推荐)
|
||||
@@ -27,7 +50,7 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare-server:latest
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
container_name: openflare-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -100,7 +131,7 @@ docker compose up -d
|
||||
```yaml
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare-server:latest
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -108,7 +139,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- openflare_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -121,13 +152,13 @@ services:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- ./data/postgres_data:/var/lib/postgresql/data
|
||||
- openflare_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -137,7 +168,7 @@ services:
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- ./data/valkey:/data
|
||||
- openflare_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
@@ -149,24 +180,39 @@ services:
|
||||
image: clickhouse/clickhouse-server:25.3-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CLICKHOUSE_DB: openflare
|
||||
CLICKHOUSE_USER: default
|
||||
CLICKHOUSE_PASSWORD: replace-with-clickhouse-password
|
||||
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- ./data/clickhouse_data:/var/lib/clickhouse
|
||||
- openflare_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", "${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:
|
||||
```
|
||||
|
||||
创建对应的 `.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
|
||||
|
||||
@@ -188,7 +234,7 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare-server:latest
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
@@ -199,7 +245,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- openflare_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -214,13 +260,13 @@ services:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- ./data/postgres_data:/var/lib/postgresql/data
|
||||
- openflare_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -230,7 +276,7 @@ services:
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- ./data/valkey:/data
|
||||
- openflare_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
@@ -252,24 +298,39 @@ services:
|
||||
image: clickhouse/clickhouse-server:25.3-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CLICKHOUSE_DB: openflare
|
||||
CLICKHOUSE_USER: default
|
||||
CLICKHOUSE_PASSWORD: replace-with-clickhouse-password
|
||||
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- ./data/clickhouse_data:/var/lib/clickhouse
|
||||
- openflare_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", "${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 密码
|
||||
|
||||
@@ -290,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. 构建管理端前端
|
||||
|
||||
@@ -345,7 +406,7 @@ Server 默认监听 `3000` 端口,启动成功后可以使用浏览器访问
|
||||
|
||||
| 用户名 | 密码 |
|
||||
| --- | --- |
|
||||
| `root` | `123456` |
|
||||
| `admin` | `12345678` |
|
||||
|
||||
> [!WARNING]
|
||||
> 为了你的系统安全,首次登录后请立即前往个人设置页面修改默认密码。
|
||||
|
||||
@@ -139,7 +139,7 @@ OpenResty (Agent, TLS/WAF)
|
||||
|
||||
当前系统核心实体包括:
|
||||
|
||||
* **反代与配置**:`proxy_routes` (网站配置), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书), `managed_domains` (托管域名).
|
||||
* **反代与配置**:`zones` (根域管理边界), `zone_domains` (明确域名与证书/路由关联), `proxy_routes` (路由策略), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书). 详见 [Zone 与域名资源设计](./zone-design.md)。
|
||||
* **Pages 静态托管**:`pages_projects` (Pages项目), `pages_deployments` (不可变部署), `pages_deployment_files` (部署文件清单).
|
||||
* **节点与穿透**:`nodes` (节点), `tunnels` (隧道客户端), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
|
||||
* **WAF 与安全**:`waf_rule_groups` (WAF规则组), `waf_ip_groups` (WAF IP组), `waf_rule_group_bindings` (网站WAF绑定).
|
||||
@@ -154,7 +154,7 @@ OpenResty (Agent, TLS/WAF)
|
||||
| 完整配置版本,而不是在线 patch | 让预览、激活、历史和回滚有稳定边界,保证节点状态一致 |
|
||||
| Agent 主动拉取 | Server 不需要 SSH 权限,降低安全风险;支持 HTTP 与 WebSocket 双协议灵活切换 |
|
||||
| 全局单激活版本 | 降低控制面复杂度,保证所有节点默认一致;提供一键秒级回滚的稳定机制 |
|
||||
| 网站配置聚合多域名 | 支持单个业务站点共享站点级策略,同时支持按域名灵活绑定不同的 TLS 证书 |
|
||||
| Zone 域名与路由策略分离 | Zone 提供根域入口与域名边界;路由仍可复用同一套站点级策略并按域名绑定证书 |
|
||||
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道引起稳定性风险;其 Vhost 机制天然适配反代路由 |
|
||||
| 运行时配置与控制库解耦 | 如 WAF 运行时只读取本地 JSON 规则包,配置变更通过差分广播或快速重载热生效 |
|
||||
|
||||
@@ -167,6 +167,7 @@ OpenResty (Agent, TLS/WAF)
|
||||
1. **[产品边界](./index.md)**:了解 OpenFlare 核心定位与不允许逾越的设计边界。
|
||||
3. **[Agent 与发布模型](./agent-design.md)**:理解版本快照同步及失败回滚的安全兜底逻辑。
|
||||
4. **细分领域设计**:
|
||||
* Zone 与域名相关开发:阅读 [Zone 与域名资源设计](./zone-design.md)。
|
||||
* 穿透相关开发:阅读 [内网穿透隧道设计](./tunnel-design.md)。
|
||||
* WAF 相关开发:阅读 [WAF 设计](./waf-design.md)。
|
||||
* Pages 托管开发:阅读 [Pages 静态托管设计](./pages-design.md)。
|
||||
|
||||
@@ -22,11 +22,12 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
|
||||
| 能力 | 说明 | 详细设计/使用指南 |
|
||||
| --- | --- | --- |
|
||||
| **反代配置管理** | 以网站规则(Proxy Route)为聚合边界,支持多域名与多上游负载均衡 | [新建反代配置](../guide/proxy-config.md) |
|
||||
| **Zone 与域名管理** | 以可注册根域为管理入口,聚合明确域名、域名证书与反代路由 | [Zone 与域名资源设计](./zone-design.md) |
|
||||
| **配置版本控制** | 支持全局单一激活版本的预览、发布、不可变快照历史与秒级一键回滚 | [Agent 与发布模型](./agent-design.md) |
|
||||
| **WAF 安全防护** | 全局与自定义规则组,支持手动/自动/订阅型 IP 组,GeoIP 准入与 PoW CC 防护 | [WAF 设计](./waf-design.md) / [WAF 使用指南](../guide/waf-usage.md) |
|
||||
| **内网穿透** | 通过中继节点(Relay)与内网客户端(OpenFlared),反向穿透暴露内网 Web 服务 | [内网穿透设计](./tunnel-design.md) / [穿透使用指南](../guide/tunnel-usage.md) |
|
||||
| **Pages 静态托管** | 直接上传前端 zip 包,由边缘节点拉取并由 OpenResty 本地服务,支持 API 反代与 SPA Fallback | [Pages 静态托管设计](./pages-design.md) |
|
||||
| **TLS 证书自动续期** | 绑定 managed_domains 并通过 ACME 协议向 Let's Encrypt 申请/续期证书 | [新建反代配置](../guide/proxy-config.md) |
|
||||
| **TLS 证书自动续期** | 将证书显式绑定到 Zone 域名,并通过 ACME 协议向 Let's Encrypt 申请/续期证书 | [Zone 与域名资源设计](./zone-design.md) |
|
||||
| **多节点监控与观测** | 收集节点资源快照、健康事件,聚合请求指标与访问日志明细 | [系统架构](./architecture.md) |
|
||||
|
||||
---
|
||||
@@ -190,4 +191,3 @@ OpenFlare 已收敛为**单 monorepo**(Go 模块 `github.com/Rain-kl/Wavelet`
|
||||
* 发布、同步、回滚与 Agent 模型变化:更新 [Agent 与发布模型](./agent-design.md)。
|
||||
* 部署方式变化:更新 [部署说明](../deployment/deployment.md) 与 README。
|
||||
* 配置项变化:更新 [配置项参考](../reference/configuration.md)。
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Zone 与域名资源设计
|
||||
|
||||
## 目标
|
||||
|
||||
将“网站”重构为以可注册根域为入口的 Zone 管理体验。`example.com` 之类的 Zone 是稳定的管理边界;用户通过稳定 ID 路径进入该 Zone,查看并维护其中明确声明的域名、域名所绑定的反代路由和证书,以及路由级 WAF、Pages 等能力。
|
||||
|
||||
本设计替代 `managed_domains` 的概念、表与 API。它不引入权威 DNS 解析记录管理。
|
||||
|
||||
## 范围与约束
|
||||
|
||||
* Zone 根域使用 Public Suffix List 解析,例如 `api.example.co.uk` 归属 `example.co.uk`。
|
||||
* URL 使用 ID:列表为 `/websites`,详情为 `/websites/:zoneId`;不使用域名作为 URL 参数。
|
||||
* Zone 域名必须是明确的 FQDN,禁止录入 `*.example.com`。TLS 证书可仍含通配符 SAN,并用于覆盖明确的 Zone 域名。
|
||||
* 一个 Zone 域名至多关联一条反代路由;一条反代路由可关联多个 Zone 域名,因而可跨 Zone 共享同一套上游、缓存、限流、WAF 与 Pages 配置。
|
||||
* 不新增 DNS 记录、边缘函数、预览子域或租户隔离能力。
|
||||
|
||||
## 核心模型
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
ZONES ||--o{ ZONE_DOMAINS : contains
|
||||
PROXY_ROUTES ||--o{ ZONE_DOMAINS : serves
|
||||
TLS_CERTIFICATES ||--o{ ZONE_DOMAINS : secures
|
||||
PROXY_ROUTES ||--o{ WAF_RULE_GROUP_BINDINGS : applies
|
||||
PAGES_PROJECTS ||--o{ PROXY_ROUTES : backs
|
||||
|
||||
ZONES {
|
||||
uint id PK
|
||||
string domain UK
|
||||
}
|
||||
ZONE_DOMAINS {
|
||||
uint id PK
|
||||
uint zone_id
|
||||
uint proxy_route_id
|
||||
string domain UK
|
||||
uint cert_id
|
||||
}
|
||||
```
|
||||
|
||||
### `of_zones`
|
||||
|
||||
保存根域、创建时间与更新时间。根域全局唯一且创建后不可原地修改;需要变更时新建 Zone 并迁移域名。删除 Zone 前必须先清空其 Zone 域名。
|
||||
|
||||
### `of_zone_domains`
|
||||
|
||||
保存 `zone_id`、明确 `domain`、可空的 `proxy_route_id`、可空的 `cert_id` 及时间戳。`domain` 全局唯一;所有关系字段建立索引但不建立物理外键。`proxy_route_id` 允许为空,以承接已准备证书但尚未配置反代的历史域名。
|
||||
|
||||
`of_proxy_routes` 逐步移除 `domain`、`domains`、`cert_id`、`cert_ids` 与 `domain_cert_ids` 等域名/证书冗余列。路由不得再指定任何 TLS 证书;路由名称 `site_name` 成为稳定的人类可读标识,编译器从关联的 Zone 域名读取 `server_name` 与其 `cert_id`。这使每个明确域名的证书只有一个来源。
|
||||
|
||||
## 业务与 API
|
||||
|
||||
管理端新增 Zone 资源:
|
||||
|
||||
* `GET/POST /api/v1/d/zones`
|
||||
* `GET/POST /api/v1/d/zones/:id/update`
|
||||
* `POST /api/v1/d/zones/:id/delete`
|
||||
* `GET/POST /api/v1/d/zones/:id/domains`
|
||||
* `POST /api/v1/d/zones/:id/domains/:domainID/update`
|
||||
* `POST /api/v1/d/zones/:id/domains/:domainID/delete`
|
||||
* `GET /api/v1/d/zones/:id/overview`
|
||||
|
||||
反代路由的创建、更新请求改用 `zone_domain_ids`,不再提交 `domains`、`cert_id`、`cert_ids` 或 `domain_cert_ids`。服务端在事务中验证域名归属、全局唯一性和证书 SAN 覆盖;失败通过 `response.Abort*` 统一返回。删除已绑定路由的 Zone 域名必须先解除或删除该路由;删除仍有域名的 Zone 必须拒绝。
|
||||
|
||||
WAF、Pages、上游与发布版本仍属于 `proxy_routes`。Zone 概览只聚合展示其域名关联的路由状态,不复制或重新定义这些配置。
|
||||
|
||||
## 前端体验
|
||||
|
||||
`/websites` 只展示 Zone 根域,显示已配置域名数、路由数与状态,并提供搜索、创建和操作菜单。点击进入 `/websites/:zoneId`。
|
||||
|
||||
详情页包含:
|
||||
|
||||
* 概览:域名、路由和有效证书统计;域名—路由—证书摘要;路由级 WAF 与 Pages 摘要。
|
||||
* 域名:明确 FQDN 的列表、证书选择和关联路由;不显示或接受通配符域名。
|
||||
* 路由:筛选到当前 Zone 的路由并链接到既有路由详情。
|
||||
* 证书:当前 Zone 域名实际引用的证书。
|
||||
* 设置:Zone 备注和受保护的删除操作。
|
||||
|
||||
新增路由时从 Zone 域名中选择;用户也可以先在 Zone 中登记域名,再绑定路由。全局反代路由入口保留,但改用同一套 Zone 域名选择器。
|
||||
|
||||
## 数据迁移
|
||||
|
||||
本次改造分两个发布阶段,以免 SQL 用错误的“末两段域名”规则处理多级公共后缀。操作细则见 [Zone 域名迁移与发布验收](../guide/zone-domain-migration.md)。
|
||||
|
||||
1. **第一阶段 DDL**:PostgreSQL 与 SQLite 同版本 Goose 创建 `of_zones` / `of_zone_domains`;暂时保留 `of_managed_domains` 与路由冗余列。
|
||||
2. **数据导入(自动)**:Server 启动时 `migrator.Migrate()` 先应用 goose SQL 至 `202607120002`,再自动导入旧路由域名 / `managed_domains`(`publicsuffix` 解析注册根域,写入 `cert_id` 与 `proxy_route_id`),最后继续后续 SQL。冲突时启动失败;修复后重启可幂等重试。无需手动命令。
|
||||
3. **代码切换**:控制面 API、配置快照、渲染、前端均以 Zone 域名为唯一来源;路由写入仅使用 `zone_domain_ids`。
|
||||
4. **第二阶段清理**:Goose SQL `202607130001_drop_legacy_route_domain_columns` 删除 `of_managed_domains` 与 `of_proxy_routes` 冗余列。Down 仅恢复开发库空结构,不回填历史数据。
|
||||
|
||||
### 运行时模型边界
|
||||
|
||||
* 持久化:域名与证书只存在于 `of_zone_domains`;`of_proxy_routes` 仅保存路由策略(上游、缓存、限流、WAF 绑定键等)。
|
||||
* 渲染:配置快照在内存中组装临时 `Domains` / `DomainCertIDs` 供 OpenResty 渲染,不写回数据库。
|
||||
* 结构迁移仅使用 `internal/db/migrator/goose/{postgres,sqlite}/*.sql`;启动时自动导入历史域名,第二阶段后旧列不存在则为空操作。
|
||||
|
||||
## 验证
|
||||
|
||||
* 单元测试:Public Suffix List 分组、FQDN / 通配符拒绝、跨 Zone 路由、证书 SAN 覆盖、删除保护及迁移幂等性;清理后断言旧列/旧表不存在。
|
||||
* 集成测试:Zone、Zone 域名与路由 API 的成功与失败响应;现有路由迁移后生成相同 OpenResty 域名与证书配置。
|
||||
* 前端测试:Zone 列表、ID 路由、详情加载 / 错误 / 空状态、域名选择器与 API 负载。
|
||||
* 手动验证:迁移前后比较激活配置快照中的 `server_name` 和证书路径,发布后使用根域及各子域请求验证路由。
|
||||
+1006
-507
File diff suppressed because it is too large
Load Diff
+9
-8
@@ -11,14 +11,15 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
|
||||
1. [快速开始](./quick-start.md):用 Docker Compose 启动 Server,登录管理端,并接入第一个 Agent。
|
||||
2. [发布第一份配置](./first-site.md):快速新建一条最基础的 HTTP 反代站点规则,并验证节点生效状态。
|
||||
3. [新建反代配置](./proxy-config.md):一步一步了解如何从证书导入与申请开始,配置 HTTPS 加密与上游源站管理。
|
||||
4. [Pages 静态托管使用](./pages-usage.md):了解静态项目 ZIP 上传限制、SPA Fallback、以及内置 API 反向代理配置。
|
||||
5. [内网穿透与隧道使用](./tunnel-usage.md):部署 Relay 与 Client,实现安全、无公网 IP 反向穿透。
|
||||
6. [WAF 安全防护使用](./waf-usage.md):配置 WAF 规则组,掌握 IP 黑白名单、自动/订阅 IP 组、地域限制与 PoW CC 防护。
|
||||
7. [WAF 自动 IP 组语法](./waf-ip-group-expr.md):编写自动 IP 组 Expr 规则,了解关键字含义和预设规则。
|
||||
8. [Uptime Kuma 监控同步](./uptime-kuma.md):配置并使用 Uptime Kuma 自动差分同步和监控范围控制。
|
||||
9. [SSO 登录配置](./sso.md):配置 GitHub 或 OIDC 实现第三方单点登录 (SSO) 接入。
|
||||
10. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。
|
||||
11. [引用与致谢](./credits.md):查看系统依赖的优秀开源项目与社区致谢清单。
|
||||
4. [Zone 域名迁移](./zone-domain-migration.md):从旧托管域名/路由内嵌域名升级到 Zone 模型(goose 自动导入),含备份、验收与回滚说明。
|
||||
5. [Pages 静态托管使用](./pages-usage.md):了解静态项目 ZIP 上传限制、SPA Fallback、以及内置 API 反向代理配置。
|
||||
6. [内网穿透与隧道使用](./tunnel-usage.md):部署 Relay 与 Client,实现安全、无公网 IP 反向穿透。
|
||||
7. [WAF 安全防护使用](./waf-usage.md):配置 WAF 规则组,掌握 IP 黑白名单、自动/订阅 IP 组、地域限制与 PoW CC 防护。
|
||||
8. [WAF 自动 IP 组语法](./waf-ip-group-expr.md):编写自动 IP 组 Expr 规则,了解关键字含义和预设规则。
|
||||
9. [Uptime Kuma 监控同步](./uptime-kuma.md):配置并使用 Uptime Kuma 自动差分同步和监控范围控制。
|
||||
10. [SSO 登录配置](./sso.md):配置 GitHub 或 OIDC 实现第三方单点登录 (SSO) 接入。
|
||||
11. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。
|
||||
12. [引用与致谢](./credits.md):查看系统依赖的优秀开源项目与社区致谢清单。
|
||||
|
||||
## 按角色查找
|
||||
|
||||
|
||||
+36
-17
@@ -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
|
||||
@@ -37,26 +45,26 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare-server:latest
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
container_name: openflare-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- openflare_uploads:/app/uploads
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
APP_SESSION_SECRET: 'replace-with-a-long-random-string' # 生产环境请替换为长随机字符串
|
||||
DB_ENABLED: "true"
|
||||
DB_HOST: "postgres"
|
||||
DB_PORT: "5432"
|
||||
DB_USERNAME: "openflare"
|
||||
DB_PASSWORD: "replace-with-strong-password"
|
||||
DB_NAME: "openflare"
|
||||
DB_USERNAME: "${DB_USERNAME:-openflare}"
|
||||
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
|
||||
@@ -69,13 +77,13 @@ services:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: openflare
|
||||
POSTGRES_USER: openflare
|
||||
POSTGRES_PASSWORD: replace-with-strong-password
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- ./data/postgres_data:/var/lib/postgresql/data
|
||||
- openflare_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -85,7 +93,7 @@ services:
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- ./data/valkey:/data
|
||||
- openflare_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
@@ -98,17 +106,28 @@ services:
|
||||
environment:
|
||||
CLICKHOUSE_DB: openflare
|
||||
CLICKHOUSE_USER: default
|
||||
CLICKHOUSE_PASSWORD: 123456
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: Asia/Shanghai
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- ./data/clickhouse_data:/var/lib/clickhouse
|
||||
- openflare_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", "${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:
|
||||
```
|
||||
|
||||
启动服务:
|
||||
@@ -134,7 +153,7 @@ http://localhost:3000
|
||||
|
||||
| 用户名 | 密码 |
|
||||
| --- | --- |
|
||||
| `root` | `123456` |
|
||||
| `admin` | `12345678` |
|
||||
|
||||
> [!WARNING]
|
||||
> 为了你的系统安全,首次登录后请立即修改默认密码。
|
||||
|
||||
@@ -79,7 +79,7 @@ NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev
|
||||
|
||||
## 默认账号无法登录
|
||||
|
||||
默认账号是 `root` / `123456`。首次登录后如果已经修改密码,应使用修改后的密码。
|
||||
默认账号是 `admin` / `12345678`。首次登录后如果已经修改密码,应使用修改后的密码。
|
||||
|
||||
排查步骤:
|
||||
|
||||
@@ -90,7 +90,7 @@ NEXT_DEV_BACKEND_URL=http://127.0.0.1:3000 pnpm dev
|
||||
|
||||
### 应急重置管理员密码
|
||||
|
||||
如果忘记了 `root` 账户的密码,可以通过直接更新数据库中的密码哈希值将其重置为 `123456`(登录后请务必立即修改):
|
||||
如果忘记了 `admin` 账户的密码,可以通过直接更新数据库中的密码哈希值将其重置为 `12345678`(登录后请务必立即修改):
|
||||
|
||||
#### 1. 若使用 SQLite 数据库
|
||||
停止 Server 运行,使用 sqlite3 客户端打开数据库文件:
|
||||
@@ -99,16 +99,16 @@ sqlite3 /path/to/openflare.db
|
||||
```
|
||||
执行以下 SQL 语句:
|
||||
```sql
|
||||
UPDATE users SET password_hash = '$2a$10$wN9aE3zTz83rO7R1uKlhuehJtA3c604pX4Z12B/9.5c0X337t1L4m' WHERE username = 'root';
|
||||
UPDATE users SET password = '$2a$10$eXpE9i/6S3gPT94/G0mu0.B8ser66ARETFz5NWYSYcrQ4JmtSrMXu' WHERE username = 'admin';
|
||||
```
|
||||
输入 `.exit` 退出并重新启动 Server。
|
||||
|
||||
#### 2. 若使用 PostgreSQL 数据库
|
||||
通过您的数据库连接工具(如 psql、pgAdmin 或 DBeaver)连接到 PostgreSQL 实例,选择对应的 `openflare` 数据库,执行以下 SQL 语句:
|
||||
```sql
|
||||
UPDATE users SET password_hash = '$2a$10$wN9aE3zTz83rO7R1uKlhuehJtA3c604pX4Z12B/9.5c0X337t1L4m' WHERE username = 'root';
|
||||
UPDATE users SET password = '$2a$10$eXpE9i/6S3gPT94/G0mu0.B8ser66ARETFz5NWYSYcrQ4JmtSrMXu' WHERE username = 'admin';
|
||||
```
|
||||
执行成功后即可使用默认密码 `123456` 重新登录管理后台。
|
||||
执行成功后即可使用默认密码 `12345678` 重新登录管理后台。
|
||||
|
||||
## Agent 无法注册或一直离线
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Zone 域名迁移与发布验收
|
||||
|
||||
从旧版 `managed_domains` / 反代路由内嵌域名列迁移到 Zone + Zone 域名模型时,数据导入与表结构升级均由 **Server 启动时的 goose 自动迁移**完成,无需单独执行导入命令。
|
||||
|
||||
## 升级时发生了什么
|
||||
|
||||
启动(或滚动升级)包含 Zone 改造的 Server 版本时,**无需手动命令**,`migrator.Migrate()` 自动:
|
||||
|
||||
1. 应用 goose SQL:创建 `of_zones` / `of_zone_domains`(若尚未存在)。
|
||||
2. **自动导入**旧路由域名列(及无路由域名时的 `of_managed_domains`)为 Zone / Zone 域名,并绑定 `proxy_route_id` / `cert_id`(公共后缀列表解析注册根域)。
|
||||
3. 继续 goose SQL:删除 `of_managed_domains` 与 `of_proxy_routes` 冗余域名/证书列。
|
||||
|
||||
导入幂等:已存在的域名会跳过或补绑路由。
|
||||
|
||||
**若历史数据无法解析(冲突域名、无效根域、证书不存在等),启动失败。** 修复数据或恢复备份后再次启动即可重试。
|
||||
|
||||
## 建议操作
|
||||
|
||||
### 1. 升级前备份
|
||||
|
||||
```bash
|
||||
# PostgreSQL 示例
|
||||
pg_dump "$DATABASE_URL" > openflare-pre-zone-$(date +%Y%m%d).sql
|
||||
|
||||
# 或复制备份卷 / 快照;SQLite 则复制 data 目录中的库文件
|
||||
```
|
||||
|
||||
可选:在管理端记下当前**激活配置版本号**与 checksum,便于配置回滚对比。
|
||||
|
||||
### 2. 升级并启动 Server
|
||||
|
||||
部署新版本并启动即可。观察启动日志中的 goose 成功信息;若出现「迁移 Zone 失败(N 个冲突)」则按日志中的冲突项修复源数据后重启。
|
||||
|
||||
### 3. 升级后检查
|
||||
|
||||
1. 管理端 **网站** `/websites`:Zone 根域与域名计数是否合理。
|
||||
2. Zone 详情:域名、证书、关联路由 ID。
|
||||
3. **反代路由**:域名绑定来自 Zone 域名,而非旧手写字段。
|
||||
|
||||
### 4. 配置预览与发布
|
||||
|
||||
1. 在管理端查看配置差异 / 预览。
|
||||
2. **逐路由**核对:`server_name` 集合、证书路径、WAF Route ID、Pages 引用。
|
||||
3. **允许**旧快照 JSON 中路由上的冗余 `domain` / `domains` / `cert_ids` 消失。
|
||||
4. **不允许**数据面语义变化。
|
||||
5. 预览通过后发布;需要时在配置版本中激活升级前版本做配置回滚。数据库回退请使用升级前备份(Down 迁移不回填业务域名数据)。
|
||||
|
||||
## 相关文档
|
||||
|
||||
* [Zone 与域名资源设计](../design/zone-design.md)
|
||||
* [新建反代配置](./proxy-config.md)
|
||||
* [发布第一份配置](./first-site.md)
|
||||
@@ -0,0 +1,22 @@
|
||||
# ClickHouse P0–P3 修复计划
|
||||
|
||||
> 状态: 已完成(已合并主工作区,`make code-check` 通过)
|
||||
> 策略: 4 个互不干扰 worktree 并行,最后由主代理合并
|
||||
|
||||
## 任务拆分
|
||||
|
||||
| ID | Worktree 主题 | 范围 | 禁止改动 |
|
||||
|----|---------------|------|----------|
|
||||
| WT1 | P0 清理语义 C1 | cleanup maintenance / delete / tasks | chwriter、dashboard、DDL 新 MV |
|
||||
| WT2 | 写路径 C2+H1+H2+H3 | chwriter、batchwriter、risk_control、model store 分层、status 指标 | goose 迁移、dashboard 读逻辑 |
|
||||
| WT3 | 读路径 H4+H5 | 最新快照查询、metric/openresty 小时 MV + 读路径 | chwriter、cleanup |
|
||||
| WT4 | P3 打磨 | 连接池/async_insert、traffic hourly TTL、UV 语义 | model store 分层、cleanup |
|
||||
|
||||
## 合并顺序
|
||||
|
||||
1. WT1 → 2. WT2 → 3. WT3 → 4. WT4
|
||||
(迁移文件时间戳已错开,changelog 由主代理统一写)
|
||||
|
||||
## 验收
|
||||
|
||||
各 worktree: 相关 `go test` + 可运行部分;合并后 `make code-check`。
|
||||
@@ -0,0 +1,491 @@
|
||||
# Zone 与域名资源重构 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 以稳定 ID 的 Zone 管理入口和正规化 Zone 域名替代 `managed_domains` 及反代路由中的域名/证书冗余字段,同时保持配置发布后的 OpenResty 行为不变。
|
||||
|
||||
**Architecture:** `of_zones` 管理可注册根域;`of_zone_domains` 是明确 FQDN、证书和反代路由之间的唯一关联来源。反代路由保留路由策略,配置快照在控制面联查 Zone 域名与证书后生成现有 OpenResty 配置格式。第一发布阶段保留旧列供可重复执行的历史数据导入读取;生产快照对比通过后才执行第二阶段清理。
|
||||
|
||||
**Tech Stack:** Go 1.25、Gin、GORM、goose(PostgreSQL/SQLite)、`golang.org/x/net/publicsuffix`、Next.js App Router、TypeScript、TanStack Query、shadcn/ui。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
* Zone URL 必须为 `/websites/:zoneId`,不得使用域名作为路由参数。
|
||||
* Zone 根域由 `publicsuffix.EffectiveTLDPlusOne` 解析;Zone 域名只接受明确 FQDN,拒绝 `*.`。
|
||||
* TLS 证书可含通配符 SAN;证书只能由 `of_zone_domains.cert_id` 指定,`of_proxy_routes` 不再保存证书字段。
|
||||
* `of_zone_domains.domain` 全局唯一;同一 Zone 域名至多绑定一条反代路由,路由可关联多个 Zone 的域名。
|
||||
* 不建立物理数据库外键;所有关联列必须建立显式索引。
|
||||
* 所有 HTTP 路由仅通过 `internal/router/v1/openflare/` 的管理端注册器委派;Handler 使用 `response.Abort*` 报错并补全 Swagger。
|
||||
* 不新增 DNS 记录管理、边缘函数、预览子域或多租户能力。
|
||||
* 每次 API 变更运行 `make swagger`;每个实现任务结束运行对应测试;完成前必须运行 `make code-check`。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `internal/db/migrator/goose/{postgres,sqlite}/202607120001_create_zone_domain_tables.sql` | 第一阶段 Zone/ZoneDomain DDL 与索引。 |
|
||||
| `internal/model/openflare_zone.go` | Zone、ZoneDomain 模型及数据访问。 |
|
||||
| `internal/apps/openflare/zone/{logics.go,routers.go,errs.go,legacy_import.go}` | Zone CRUD、概览、输入验证和历史导入。 |
|
||||
| `internal/cmd/migrate_zones.go` | 显式、可重复运行的历史 Zone 数据导入命令。 |
|
||||
| `internal/router/v1/openflare/register_zone.go` | `/api/v1/d/zones` 路由注册。 |
|
||||
| `internal/apps/openflare/proxy_route/*` | 以 `zone_domain_ids` 取代域名与证书输入。 |
|
||||
| `internal/apps/openflare/config_version/*`、`pkg/render/openresty/*` | 快照与 OpenResty 渲染改为使用 Zone 域名。 |
|
||||
| `frontend/lib/services/openflare/{zone.service.ts,types.ts,index.ts}` | Zone API 类型和服务。 |
|
||||
| `frontend/vitest.config.ts`、`frontend/tests/zone/*.test.tsx` | Zone 页面与域名选择器的最小前端测试运行环境。 |
|
||||
| `frontend/app/(main)/websites/*` | Zone 列表、`[zoneId]` 动态详情页和局部组件。 |
|
||||
| `frontend/app/(main)/proxy-routes/*` | Zone 域名选择器替换旧域名/证书编辑器。 |
|
||||
| `internal/db/migrator/goose/{postgres,sqlite}/202607130001_drop_legacy_route_domain_columns.sql` | 第二阶段删除旧表、列与索引。 |
|
||||
|
||||
### Task 1: 第一阶段 Schema、模型与迁移测试
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/db/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql`
|
||||
- Create: `internal/db/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql`
|
||||
- Create: `internal/model/openflare_zone.go`
|
||||
- Create: `internal/model/openflare_zone_test.go`
|
||||
- Modify: `internal/model/openflare_proxy_route.go`
|
||||
- Test: `internal/db/migrator/migrator_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `model.Zone`, `model.ZoneDomain`, `ListZoneDomainsByRouteID(ctx, routeID)`, `ReplaceZoneDomainRouteBindings(ctx, routeID, domainIDs)`.
|
||||
- Consumes: existing `model.ProxyRoute` and `model.TLSCertificate` IDs; no physical FK.
|
||||
|
||||
- [x] **Step 1: 写失败的模型与迁移测试**
|
||||
|
||||
```go
|
||||
func TestReplaceZoneDomainRouteBindingsRejectsForeignDomain(t *testing.T) {
|
||||
// Create zones and domains, then assert a domain cannot be bound twice.
|
||||
}
|
||||
```
|
||||
|
||||
Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count=1`
|
||||
|
||||
Expected: FAIL,因为 Zone 模型和 goose 文件尚不存在。
|
||||
|
||||
- [x] **Step 2: 新建双方言 DDL**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS of_zones (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zones_domain ON of_zones (domain);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS of_zone_domains (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
zone_id BIGINT NOT NULL,
|
||||
proxy_route_id BIGINT,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
cert_id BIGINT,
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zone_domains_domain ON of_zone_domains (domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_zone_id ON of_zone_domains (zone_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_id);
|
||||
```
|
||||
|
||||
SQLite 使用 `INTEGER PRIMARY KEY AUTOINCREMENT`、`DATETIME`,字段/索引语义完全对齐。此任务不得删除旧列或旧表。
|
||||
|
||||
- [x] **Step 3: 实现模型和受事务保护的绑定替换**
|
||||
|
||||
```go
|
||||
type Zone struct { ID uint; Domain string; Remark string; CreatedAt time.Time; UpdatedAt time.Time }
|
||||
type ZoneDomain struct { ID uint; ZoneID uint; ProxyRouteID *uint; Domain string; CertID *uint; Remark string; CreatedAt time.Time; UpdatedAt time.Time }
|
||||
|
||||
func ReplaceZoneDomainRouteBindings(ctx context.Context, routeID uint, domainIDs []uint) error
|
||||
```
|
||||
|
||||
实现先锁定/读取请求域名,拒绝已绑定到其他路由的记录,再把当前路由已绑定但不在 `domainIDs` 的记录置空,最后将请求记录写为 `routeID`;所有动作放在同一 `db.DB(ctx).Transaction` 内。
|
||||
|
||||
- [x] **Step 4: 运行模型与迁移测试**
|
||||
|
||||
Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count=1`
|
||||
|
||||
Expected: PASS,空 SQLite 库可应用迁移,唯一域名和绑定排他性受保护。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/db/migrator/goose internal/model
|
||||
git commit -m "feat(zone): add normalized zone domain schema"
|
||||
```
|
||||
|
||||
### Task 2: Zone 领域逻辑、历史导入命令与管理 API
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/apps/openflare/zone/{logics.go,routers.go,errs.go,legacy_import.go,logics_test.go}`
|
||||
- Create: `internal/cmd/migrate_zones.go`
|
||||
- Create: `internal/router/v1/openflare/register_zone.go`
|
||||
- Modify: `internal/cmd/root.go`
|
||||
- Modify: `internal/router/v1/openflare/register_tls.go`
|
||||
- Test: `internal/apps/openflare/integration/security_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `zone.Create`, `zone.Update`, `zone.GetOverview`, `zone.ImportLegacy(ctx) (ImportReport, error)` and Zone REST handlers.
|
||||
- Consumes: Task 1 models; legacy `managed_domains` and proxy-route columns only inside `ImportLegacy`.
|
||||
|
||||
- [x] **Step 1: 写失败的逻辑与 API 测试**
|
||||
|
||||
```go
|
||||
func TestCreateZoneDomainRejectsWildcard(t *testing.T) { _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "*.example.com"}); require.EqualError(t, err, errDomainWildcardUnsupported) }
|
||||
func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) {
|
||||
root, err := zoneRoot("api.example.co.uk")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "example.co.uk", root)
|
||||
}
|
||||
```
|
||||
|
||||
集成测试请求 `POST /api/v1/d/zones/`、`POST /api/v1/d/zones/:id/domains`,并断言错误响应使用 400 信封。
|
||||
|
||||
- [x] **Step 2: 实现精确域名和 Zone 归属验证**
|
||||
|
||||
```go
|
||||
func zoneRoot(domain string) (string, error) { return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain))) }
|
||||
func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.ZoneDomain, error)
|
||||
```
|
||||
|
||||
拒绝空值、协议、路径和 `*`;要求 `zoneRoot(input.Domain) == zone.Domain`;若 `cert_id` 非空,验证 TLS 证书存在。Zone 根域创建也必须经 `EffectiveTLDPlusOne` 验证且输入等于结果。
|
||||
|
||||
- [x] **Step 3: 实现显式导入命令**
|
||||
|
||||
```go
|
||||
var migrateZonesCmd = &cobra.Command{Use: "migrate-zones", RunE: func(_ *cobra.Command, _ []string) error {
|
||||
report, err := zone.ImportLegacy(context.Background())
|
||||
return report.LogAndReturn(err)
|
||||
}}
|
||||
```
|
||||
|
||||
导入以事务执行:用 `routeidentity.DecodeDomains(route.Domains, route.Domain)` 读取旧路由;按 `domain_cert_ids` 的同一索引写入 `zone_domains.cert_id`;只在无路由域名时导入旧 `managed_domains`。发现无效根域、通配符记录或全局域名冲突时回滚并输出全部冲突项。重复执行不得生成重复 Zone/ZoneDomain。
|
||||
|
||||
- [x] **Step 4: 注册 API 并删除旧 managed-domain 路由**
|
||||
|
||||
```go
|
||||
zoneGroup := apiGroup.Group("/zones")
|
||||
zoneGroup.Use(apiutil.AdminMiddlewares()...)
|
||||
zoneGroup.GET("/", zone.ListHandler)
|
||||
zoneGroup.POST("/", zone.CreateHandler)
|
||||
zoneGroup.GET("/:id/overview", zone.GetOverviewHandler)
|
||||
```
|
||||
|
||||
把 `managed-domains` 路由块从 `register_tls.go` 移除;每个 Handler 使用 `apiutil.BindJSON` 和 `response.AbortBadRequest/AbortNotFound/AbortConflict`。
|
||||
|
||||
- [x] **Step 5: 验证并 Commit**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/zone ./internal/apps/openflare/integration -count=1 && make swagger`
|
||||
|
||||
Expected: PASS,Swagger 不再含 `/managed-domains` 且包含 `/zones`。
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/zone internal/cmd internal/router/v1/openflare docs
|
||||
git commit -m "feat(zone): add zone management api and legacy importer"
|
||||
```
|
||||
|
||||
### Task 3: 反代路由改用 ZoneDomain 关联
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/proxy_route/{logics.go,helpers.go,build_helpers.go,routers.go,errs.go,logics_test.go}`
|
||||
- Modify: `internal/model/openflare_proxy_route.go`
|
||||
- Modify: `internal/apps/openflare/tls/logics.go`
|
||||
- Modify: `internal/apps/openflare/origin/logics.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `zone_domain_ids []uint` and Task 1 binding API.
|
||||
- Produces: `proxy_route.Input{ZoneDomainIDs []uint}`, `proxy_route.View{ZoneDomains []ZoneDomainView}`.
|
||||
|
||||
- [x] **Step 1: 写失败的路由逻辑测试**
|
||||
|
||||
```go
|
||||
input := Input{SiteName: "api", ZoneDomainIDs: []uint{domainA.ID, domainB.ID}, EnableHTTPS: true}
|
||||
view, err := CreateProxyRoute(ctx, input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []uint{domainA.ID, domainB.ID}, view.ZoneDomainIDs)
|
||||
```
|
||||
|
||||
同时覆盖:空 `zone_domain_ids`、重复 ID、其他路由已占用域名、HTTPS 域名无证书、证书 SAN 不覆盖。
|
||||
|
||||
- [x] **Step 2: 删除路由输入/视图中的旧域名与证书字段**
|
||||
|
||||
```go
|
||||
type ZoneDomainBindingInput struct {
|
||||
ZoneDomainIDs []uint `json:"zone_domain_ids"`
|
||||
}
|
||||
type ZoneDomainView struct { ID uint `json:"id"`; ZoneID uint `json:"zone_id"`; Domain string `json:"domain"`; CertID *uint `json:"cert_id"` }
|
||||
```
|
||||
|
||||
移除 `Input.Domain`、`Input.Domains`、`Input.CertID`、`Input.CertIDs`、`Input.DomainCertIDs` 及对应 View 字段;删除旧证书派生辅助函数与 `WebsiteService.match` 所需后端逻辑。
|
||||
|
||||
- [x] **Step 3: 用关联记录验证并构建路由**
|
||||
|
||||
在 `buildProxyRoute` 中读取所有 `ZoneDomainIDs`,对每个 HTTPS 域名调用现有 `validateCertificateCoverage`,再调用 `ReplaceZoneDomainRouteBindings`。更新/删除路由也必须在事务内同步解除关联。来源、证书删除检查和 Origin 路由摘要改从 `zone_domains` 查询域名/证书。
|
||||
|
||||
- [x] **Step 4: 运行路由和 TLS 回归测试**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/proxy_route ./internal/apps/openflare/tls ./internal/apps/openflare/origin -count=1`
|
||||
|
||||
Expected: PASS;任一证书已被 Zone 域名引用时,删除证书被拒绝。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/proxy_route internal/apps/openflare/tls internal/apps/openflare/origin internal/model
|
||||
git commit -m "refactor(proxy): bind routes through zone domains"
|
||||
```
|
||||
|
||||
### Task 4: 配置快照、渲染与关联消费者
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/openflare/config_version/{snapshot.go,helpers.go,logics.go,logics_test.go,certificate_snapshot_test.go,pages_snapshot.go}`
|
||||
- Modify: `pkg/render/openresty/{types.go,render.go,render_route.go,render_test.go}`
|
||||
- Modify: `internal/apps/openflare/{flared/logics.go,uptimekuma/sync.go}`
|
||||
- Modify: `internal/apps/openflare/routeidentity/identity.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: snapshot/render `Route{SiteName, Domains, DomainCertIDs}` built transiently from ZoneDomain rows; neither DB model nor API stores those fields.
|
||||
|
||||
- [x] **Step 1: 写快照等价性失败测试**
|
||||
|
||||
```go
|
||||
func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) {
|
||||
// Two explicit ZoneDomains with different certs must render two TLS server blocks.
|
||||
}
|
||||
```
|
||||
|
||||
加入 Pages、Tunnel、WAF 绑定测试,断言 Route ID 与 `site_name` 未改变。
|
||||
|
||||
- [x] **Step 2: 在快照边界联查并生成临时渲染字段**
|
||||
|
||||
```go
|
||||
domains, err := model.ListZoneDomainsByRouteID(ctx, route.ID)
|
||||
snapshotRoute.Domains = maps.Values(domainNames)
|
||||
snapshotRoute.DomainCertIDs = certIDsInDomainOrder(domains)
|
||||
```
|
||||
|
||||
`pkg/render/openresty.Route` 可继续保留 `Domains` 与 `DomainCertIDs`,因为它是不可变配置快照的渲染输入;移除其中持久化主域/证书回退逻辑,所有错误消息改用 `SiteName`。
|
||||
|
||||
- [x] **Step 3: 移除旧字段回退路径**
|
||||
|
||||
删除 `routeidentity.DecodeDomains` 对持久化 `route.Domain` 的依赖;Flared、Uptime Kuma、配置 diff、WAF 文档和 Pages 错误信息都从 snapshot/ZoneDomain 查询的明确域名获取显示文本。
|
||||
|
||||
- [x] **Step 4: 运行数据面测试**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/config_version ./pkg/render/openresty ./internal/apps/openflare/flared ./internal/apps/openflare/uptimekuma -count=1`
|
||||
|
||||
Expected: PASS;迁移后的路由产生的 `server_name`、证书支持文件和 WAF RouteID 绑定与迁移前一致。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/config_version internal/apps/openflare/flared internal/apps/openflare/uptimekuma internal/apps/openflare/routeidentity pkg/render/openresty
|
||||
git commit -m "refactor(config): render routes from zone domains"
|
||||
```
|
||||
|
||||
### Task 5: Zone 前端服务与 ID 动态页面
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/lib/services/openflare/zone.service.ts`
|
||||
- Create: `frontend/vitest.config.ts`
|
||||
- Create: `frontend/tests/zone/{websites-page.test.tsx,zone-page.test.tsx}`
|
||||
- Modify: `frontend/lib/services/openflare/{types.ts,index.ts}`
|
||||
- Modify: `frontend/lib/services/index.ts`
|
||||
- Modify: `frontend/app/(main)/websites/page.tsx`
|
||||
- Create: `frontend/app/(main)/websites/[zoneId]/page.tsx`
|
||||
- Create: `frontend/app/(main)/websites/[zoneId]/components/{zone-overview.tsx,zone-domains-table.tsx,zone-route-summary.tsx,zone-editor-dialog.tsx,zone-domain-dialog.tsx}`
|
||||
- Delete: `frontend/app/(main)/websites/detail/page.tsx`
|
||||
- Delete: `frontend/app/(main)/websites/detail/page-client.tsx`
|
||||
- Delete: legacy Website/managed-domain-only components after imports are removed.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ZoneService.list/getOverview/create/update/delete`, `ZoneDomainService.create/update/delete` and `ZoneOverview` TypeScript types.
|
||||
|
||||
- [x] **Step 1: 写服务与页面行为测试**
|
||||
|
||||
先安装仅用于本次页面测试的开发依赖:
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom
|
||||
```
|
||||
|
||||
```ts
|
||||
expect(ZoneService.getOverview).toHaveBeenCalledWith(42)
|
||||
expect(screen.getByRole('heading', {name: 'example.com'})).toBeVisible()
|
||||
```
|
||||
|
||||
覆盖 `/websites/42` 的加载、404、空域名、搜索列表和从列表点击 ID 链接。
|
||||
|
||||
- [x] **Step 2: 实现类型化服务与查询键**
|
||||
|
||||
```ts
|
||||
export interface ZoneDomainItem { id: number; zone_id: number; proxy_route_id: number | null; domain: string; cert_id: number | null; remark: string }
|
||||
export class ZoneService extends OpenFlareBaseService { protected static override basePath = '/api/v1/d/zones' }
|
||||
export const zoneQueryKey = ['openflare', 'zones'] as const
|
||||
```
|
||||
|
||||
所有 React Query 回调使用箭头函数,避免静态 service `this` 丢失。
|
||||
|
||||
- [x] **Step 3: 用 Next 动态段实现 Zone 详情**
|
||||
|
||||
```tsx
|
||||
export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>) {
|
||||
const {zoneId} = await params
|
||||
return <ZonePageClient zoneId={Number(zoneId)} />
|
||||
}
|
||||
```
|
||||
|
||||
遵循本地 Next 文档:动态 `params` 是 Promise;无效或非正整数 ID 显示既有 `EmptyStateWithBorder`,不把域名写入 URL。主页面只维护页面骨架和 Tabs,具体 Tab 放入同目录组件。
|
||||
|
||||
- [x] **Step 4: 实现列表和详情交互**
|
||||
|
||||
列表仅渲染 Zone 根域及计数;详情使用概览、域名、路由、证书、设置 Tabs。域名弹窗拒绝 `*.`,但证书选择器不限制其 SAN。删除 Zone/域名使用确认对话框和服务端错误文案。
|
||||
|
||||
- [x] **Step 5: 验证并 Commit**
|
||||
|
||||
Run: `cd frontend && pnpm exec vitest run && pnpm lint`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
```bash
|
||||
git add frontend/lib/services frontend/app/'(main)'/websites
|
||||
git commit -m "feat(web): add zone-based website management"
|
||||
```
|
||||
|
||||
### Task 6: 反代路由前端切换到 Zone 域名选择器
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/app/(main)/proxy-routes/components/zone-domain-selector.tsx`
|
||||
- Create: `frontend/tests/zone/zone-domain-selector.test.tsx`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/{components/proxy-route-create-sheet.tsx,components/helpers.ts,page-client.tsx}`
|
||||
- Modify: `frontend/app/(main)/proxy-routes/detail/{helpers.ts,page-client.tsx,components/domain-section.tsx}`
|
||||
- Delete: `frontend/app/(main)/proxy-routes/detail/components/domain-list-input.tsx`
|
||||
- Modify: `frontend/lib/services/openflare/types.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ZoneDomainItem[]` and route `zone_domain_ids: number[]`.
|
||||
- Produces: selector values with explicit domain/Zone/证书信息;不发送任何旧域名或证书字段。
|
||||
|
||||
- [x] **Step 1: 写失败的选择器测试**
|
||||
|
||||
```tsx
|
||||
render(<ZoneDomainSelector value={[7]} onChange={onChange} domains={[apiDomain]} />)
|
||||
expect(screen.getByText('api.example.com')).toBeVisible()
|
||||
expect(onChange).toHaveBeenCalledWith([7])
|
||||
```
|
||||
|
||||
覆盖搜索、跨 Zone 多选、已被其他路由占用的禁用项和 HTTPS 缺少证书的表单错误。
|
||||
|
||||
- [x] **Step 2: 移除旧前端负载与自动匹配**
|
||||
|
||||
从 `ProxyRouteItem`/`ProxyRouteMutationPayload` 删除 `domain`、`domains`、`primary_domain`、`cert_id`、`cert_ids`、`domain_cert_ids`;删除 `WebsiteService.match` 及 `DomainListInput` 自动填证书交互。
|
||||
|
||||
- [x] **Step 3: 实现 Zone 域名选择和保存负载**
|
||||
|
||||
```ts
|
||||
mutationFn: (payload) => ProxyRouteService.update(route.id, {
|
||||
...payload,
|
||||
zone_domain_ids: selectedDomainIDs,
|
||||
})
|
||||
```
|
||||
|
||||
展示每个选择项的 FQDN、所属 Zone 与证书;路由详情的“域名”区只编辑关联关系,证书链接跳转 Zone 详情而非路由内编辑。
|
||||
|
||||
- [x] **Step 4: 运行前端类型和交互测试**
|
||||
|
||||
Run: `cd frontend && pnpm exec tsc --noEmit`
|
||||
|
||||
Expected: PASS;不存在旧持久化域名/证书字段的 TypeScript 引用。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/'(main)'/proxy-routes frontend/lib/services/openflare/types.ts
|
||||
git commit -m "refactor(web): select route domains from zones"
|
||||
```
|
||||
|
||||
### Task 7: 第一发布阶段验证、文档与发布前数据检查
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/design/zone-design.md`
|
||||
- Modify: `docs/changelog/index.md`
|
||||
- Modify: generated `docs/{docs.go,swagger.json,swagger.yaml}`
|
||||
- Create: `docs/guide/zone-domain-migration.md`
|
||||
|
||||
- [x] **Step 1: 为导入命令写可操作迁移指南**
|
||||
|
||||
文档写明备份、执行 `wavelet migrate-zones`、读取导入报告、发布预览、比较 `server_name`/证书支持文件、发布激活和回滚步骤;不允许在报告有冲突时继续。
|
||||
|
||||
- [x] **Step 2: 生成 Swagger 和更新未发布变更**
|
||||
|
||||
Run: `make swagger`
|
||||
|
||||
在 `[Unreleased]` 记录 Zone 管理、反代路由域名正规化和移除 managed-domain API。
|
||||
|
||||
- [x] **Step 3: 运行全量质量门禁**
|
||||
|
||||
Run: `go test ./... && make code-check`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
- [x] **Step 4: 做快照等价性验收**
|
||||
|
||||
在升级前导出活动版本,在导入后生成预览;逐个比较所有路由的明确 `server_name` 集合、证书路径、WAF RouteID 绑定与 Pages 部署引用。只允许旧快照的域名/证书冗余 JSON 消失,不允许数据面语义变化。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs
|
||||
git commit -m "docs(zone): add migration and release verification guide"
|
||||
```
|
||||
|
||||
### Task 8: 第二发布阶段——删除旧表和冗余列
|
||||
|
||||
**Precondition:** 已在生产环境完成 Task 7 的导入、预览对比和至少一次发布/回滚验证;`migrate-zones` 报告无冲突。
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/db/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql`
|
||||
- Create: `internal/db/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql`
|
||||
- Delete: `internal/model/openflare_managed_domain.go`
|
||||
- Delete: `internal/apps/openflare/tls/managed_domain.go`
|
||||
- Delete: `internal/apps/openflare/tls/helpers.go` 中仅用于旧路由证书数组的函数
|
||||
- Modify: legacy迁移相关测试、模型测试与 `docs/design/zone-design.md`
|
||||
|
||||
- [x] **Step 1: 写空库与升级库清理失败测试**
|
||||
|
||||
```go
|
||||
func TestLegacyRouteColumnsAreAbsentAfterCleanup(t *testing.T) {
|
||||
require.False(t, db.DB(ctx).Migrator().HasColumn(&model.ProxyRoute{}, "domain"))
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 编写双方言清理 DDL**
|
||||
|
||||
PostgreSQL 删除旧唯一索引和 `domain`、`domains`、`cert_id`、`cert_ids`、`domain_cert_ids`,再删除 `of_managed_domains`;SQLite 使用重建 `of_proxy_routes` 表的迁移方式保留所有非旧字段与索引。Down 仅在开发数据库恢复旧结构,不回填历史数据。
|
||||
|
||||
- [x] **Step 3: 删除旧读取代码与测试 fixture**
|
||||
|
||||
删除所有 `route.Domain`、`route.Domains`、`route.CertID`、`route.CertIDs`、`route.DomainCertIDs` 的持久化引用;让编译器、Uptime Kuma、Flared、来源摘要及 API 只使用 ZoneDomain 查询结果。
|
||||
|
||||
- [x] **Step 4: 验证升级和完整回归**
|
||||
|
||||
Run: `go test ./internal/db/migrator ./internal/model ./internal/apps/openflare/... ./pkg/render/openresty -count=1 && make code-check`
|
||||
|
||||
Expected: PASS;全仓搜索不再发现旧 `ManagedDomain` 业务代码、`ProxyRoute` 持久化字段或管理端 API;渲染快照中的临时 `DomainCertIDs` 类型允许保留。
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/db/migrator internal/model internal/apps frontend docs
|
||||
git commit -m "refactor(zone): remove legacy route domain storage"
|
||||
```
|
||||
|
||||
## Plan Self-Review
|
||||
|
||||
* Spec coverage: Tasks 1–4 交付正规化数据模型、API、迁移与数据面;Tasks 5–6 交付 ID 路由和 Zone 交互;Tasks 7–8 覆盖质量门禁与旧表清理。
|
||||
* Placeholder scan: 无待定标记或未定义的实现步骤;所有删除动作在明确的生产验证前置条件后执行。
|
||||
* Type consistency: 路由写入统一使用 `zone_domain_ids`,持久化关系统一为 `ZoneDomain.ProxyRouteID`,渲染边界仅使用临时 `Domains`/`DomainCertIDs`。
|
||||
@@ -0,0 +1,47 @@
|
||||
# ClickHouse CPU 性能优化计划
|
||||
|
||||
> PLAN_ID: `63ba981b`
|
||||
> 状态: 已完成(含 Phase 2 遗留治理)
|
||||
> 目标: 完成 P0–P2 优化,降低 ClickHouse CPU 占用
|
||||
|
||||
## 背景
|
||||
|
||||
ClickHouse CPU 偏高由写入侧(小 part 频繁 flush、心跳同步 DELETE mutation)与查询侧(无 LIMIT 全表扫、高频轮询、WAF 全量拉日志)叠加导致。
|
||||
|
||||
## PR Plan
|
||||
|
||||
### PR 1: 写入路径 P0 优化
|
||||
|
||||
- **Description:** 移除心跳路径同步 `ALTER DELETE`;为 `batchwriter` 增加 `MinBatchSize`;调大可观测 writer 批次与 flush 间隔;为 openresty/frps/frpc 补全去重。
|
||||
- **Files/components affected:** `internal/apps/openflare/agent/observability.go`, `internal/db/batchwriter/`, `internal/apps/openflare/chwriter/`, `internal/db/batchwriter/*_test.go`
|
||||
- **Dependencies:** None
|
||||
|
||||
### PR 2: ClickHouse 客户端与配置 P1
|
||||
|
||||
- **Description:** 启用 `async_insert` 等写入优化 settings;提高 `block_buffer_size` 默认值;更新 `config.example.yaml` 与配置模型注释。
|
||||
- **Files/components affected:** `internal/db/clickhouse.go`, `internal/config/model.go`, `internal/config/config.go`, `config.example.yaml`
|
||||
- **Dependencies:** None
|
||||
|
||||
### PR 3: Dashboard 与可观测查询 P0
|
||||
|
||||
- **Description:** 消除 `limit=0` 无界查询;复用已有限制数据构建趋势;增加服务端短 TTL 缓存;降低前端轮询频率。
|
||||
- **Files/components affected:** `internal/apps/openflare/dashboard/logics.go`, `internal/apps/openflare/observability/node_logics.go`, `frontend/app/(main)/page.tsx`, `frontend/app/(main)/nodes/components/node-observability.tsx`
|
||||
- **Dependencies:** None
|
||||
|
||||
### PR 4: 访问日志与 WAF 查询 P0/P1
|
||||
|
||||
- **Description:** WAF IP 同步改为 ClickHouse 侧聚合;IP 汇总与折叠日志 SQL 分页;消除 count 重复全量扫描;列表 API 强制默认时间窗口。
|
||||
- **Files/components affected:** `internal/apps/openflare/waf/ip_group_sync.go`, `internal/repository/analytics/node_access_log_stats.go`, `internal/model/openflare_access_log.go`, `internal/apps/openflare/observability/access_log_logics.go`, `internal/repository/analytics/access_log_stats.go`
|
||||
- **Dependencies:** None
|
||||
|
||||
### PR 5: ClickHouse DDL 与数据规范化 P1
|
||||
|
||||
- **Description:** 为 7 张分析表添加 TTL;收窄 `of_node_access_logs` ORDER BY;插入时规范化 `remote_addr`(去 trim 查询);将可观测 obs 三表纳入自动清理。
|
||||
- **Files/components affected:** `internal/db/migrator/goose/clickhouse/`, `internal/repository/analytics/node_access_log_writer.go`, `internal/apps/openflare/tasks/database_cleanup.go`, `internal/model/analytics/`
|
||||
- **Dependencies:** PR 1
|
||||
|
||||
### PR 6: 基础设施与审计减负 P2
|
||||
|
||||
- **Description:** Docker ClickHouse 服务端基础调优;审计日志 headers 截断/精简;更新 changelog。
|
||||
- **Files/components affected:** `docker-compose.yaml`, `docker/clickhouse/` (if needed), `internal/apps/risk_control/middleware.go`, `docs/changelog/index.md`
|
||||
- **Dependencies:** None
|
||||
+3
-1
@@ -11,7 +11,9 @@
|
||||
|
||||
## 正在进行的计划
|
||||
|
||||
当前暂无正在进行的开发计划或 AI 接手计划。所有历史迁移与重构项目(如后端/前端向 Wavelet 平台的迁移、边缘运行时重构等)均已完成开发并上线,对应的临时计划文档已归档清理。
|
||||
当前进行中的开发计划:
|
||||
|
||||
* [Zone 与域名资源重构](./20260712-zone-domain-refactor.md):以 Zone 和正规化 Zone 域名替代托管域名及反代路由中的域名/证书冗余字段。
|
||||
|
||||
## 使用建议
|
||||
|
||||
|
||||
@@ -99,13 +99,21 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
|
||||
| `redis.pool_size` | `REDIS_POOL_SIZE` | Redis 连接池大小 | `100` |
|
||||
|
||||
### 4. ClickHouse 配置 (`clickhouse:`)
|
||||
|
||||
> **说明**:下列为 OpenFlare **客户端**连接参数。ClickHouse **服务端**小规格调优:将 `performance.xml` curl 到 `./config/clickhouse/`,再以单文件方式挂载至容器的 `config.d/performance.xml`,详见 [启动 Server](../deployment/server.md)。
|
||||
|
||||
| 配置文件 YAML 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
|
||||
| --- | --- | --- | --- |
|
||||
| `clickhouse.enabled` | `CLICKHOUSE_ENABLED` | 是否启用 ClickHouse。**系统节点指标与访问日志在此进行海量写入** | `true` |
|
||||
| `clickhouse.hosts` | `CLICKHOUSE_HOST` | ClickHouse 集群连接地址数组(环境变量仅设置单地址) | `["127.0.0.1:9000"]` |
|
||||
| `clickhouse.username` | `CLICKHOUSE_USERNAME` | ClickHouse 账号用户名 | `default` |
|
||||
| `clickhouse.password` | `CLICKHOUSE_PASSWORD` | ClickHouse 密码 | `123456` |
|
||||
| `clickhouse.password` | `CLICKHOUSE_PASSWORD` | ClickHouse 密码 | `replace-with-clickhouse-password` |
|
||||
| `clickhouse.database` | `CLICKHOUSE_NAME` | ClickHouse 存储的数据库名称 | `openflare` |
|
||||
| `clickhouse.max_idle_conn` | - | 客户端空闲连接数(小规格默认偏低) | `8` |
|
||||
| `clickhouse.max_open_conn` | - | 客户端最大打开连接数 | `16` |
|
||||
| `clickhouse.conn_max_lifetime` | - | 连接最大存活时间(秒) | `3600` |
|
||||
| `clickhouse.dial_timeout` | - | 建连超时(秒) | `5` |
|
||||
| `clickhouse.block_buffer_size` | - | 原生协议 block 缓冲行数 | `32` |
|
||||
|
||||
### 5. 系统日志配置 (`log:`)
|
||||
| 配置文件 YAML 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
|
||||
@@ -161,7 +169,7 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
|
||||
### 2. 人机安全校验 (PoW Captcha)
|
||||
| 配置键 (Key) | 数据类型 | 作用说明 | 默认值 |
|
||||
| --- | --- | --- | --- |
|
||||
| `cap_login_enabled` | `bool` | 是否在登录界面强制要求进行本地 PoW 算力防爆破人机验证 | `true` |
|
||||
| `cap_login_enabled` | `bool` | 是否在登录界面强制要求进行本地 PoW 算力防爆破人机验证 | `false` |
|
||||
| `cap_auto_solve` | `bool` | 打开页面后是否由浏览器自动开始后台背景计算算力(无需用户手动点击)| `true` |
|
||||
| `cap_challenge_count` | `int` | 人机验证所需的计算难题数。数量越大,计算要求时间越长(推荐 1~5) | `1` |
|
||||
| `cap_challenge_difficulty`| `int`| 每次计算所需的 PoW 哈希前缀匹配难度。推荐数值在 3-5 之间 | `4` |
|
||||
|
||||
@@ -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)。
|
||||
+1006
-507
File diff suppressed because it is too large
Load Diff
+626
-315
File diff suppressed because it is too large
Load Diff
@@ -327,7 +327,7 @@ export function EventsTab() {
|
||||
<Switch
|
||||
checked={event.enabled}
|
||||
onCheckedChange={() => toggleEventMutation.mutate(event.id)}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
className="scale-75"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -324,7 +324,7 @@ export function SettingsTab() {
|
||||
}
|
||||
})
|
||||
}}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
className="scale-75"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Input} from "@/components/ui/input"
|
||||
@@ -28,9 +27,6 @@ import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import type {DatabaseCleanupTarget} from "@/lib/services/openflare"
|
||||
import {NodeService, OptionService, StatusService, UptimeKumaService,} from "@/lib/services/openflare"
|
||||
import {AdminStatusService} from "@/lib/services/admin"
|
||||
import {VersionUpgradeDialog} from "@/app/(main)/components/version-upgrade-dialog"
|
||||
import {adminUpdateStatusQueryKey, openflarePublicStatusQueryKey,} from "@/lib/hooks/use-openflare-server-upgrade"
|
||||
|
||||
import {
|
||||
agentOptionEntries,
|
||||
@@ -47,6 +43,7 @@ import {
|
||||
import {UptimeKumaSiteSelectModal} from "./uptimekuma-site-modal"
|
||||
|
||||
const optionsQueryKey = ["openflare", "options"] as const
|
||||
const openflarePublicStatusQueryKey = ["openflare", "public-status"] as const
|
||||
|
||||
const cleanupTargets: Array<{
|
||||
target: DatabaseCleanupTarget
|
||||
@@ -56,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 天。",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -85,7 +82,6 @@ export function OpenFlareOpsSettings() {
|
||||
label: string
|
||||
} | null>(null)
|
||||
const [cleanupRetentionDays, setCleanupRetentionDays] = useState("")
|
||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false)
|
||||
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: optionsQueryKey,
|
||||
@@ -102,10 +98,6 @@ export function OpenFlareOpsSettings() {
|
||||
queryFn: () => NodeService.getBootstrapToken(),
|
||||
})
|
||||
|
||||
const releaseQuery = useQuery({
|
||||
queryKey: adminUpdateStatusQueryKey,
|
||||
queryFn: () => AdminStatusService.getUpdateStatus(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!optionsQuery.data) return
|
||||
@@ -548,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"
|
||||
@@ -564,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) => (
|
||||
@@ -605,50 +606,6 @@ export function OpenFlareOpsSettings() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">版本信息</CardTitle>
|
||||
<CardDescription>
|
||||
检查上游 GitHub Release 并升级当前服务。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={() => setVersionDialogOpen(true)}>
|
||||
管理升级
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCell label="当前版本" value={statusQuery.data?.version ?? releaseQuery.data?.current_version ?? "—"} />
|
||||
<InfoCell
|
||||
label="最新 Release"
|
||||
value={releaseQuery.data?.latest_version ?? "—"}
|
||||
/>
|
||||
<div className="rounded-lg border border-dashed px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">更新状态</p>
|
||||
<div className="mt-2">
|
||||
{releaseQuery.data?.update_available ? (
|
||||
<Badge variant="secondary">有新版本</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">已是最新</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InfoCell
|
||||
label="启动时间"
|
||||
value={
|
||||
statusQuery.data?.start_time
|
||||
? new Date(statusQuery.data.start_time * 1000).toLocaleString()
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<VersionUpgradeDialog
|
||||
open={versionDialogOpen}
|
||||
onOpenChange={setVersionDialogOpen}
|
||||
canUpgrade
|
||||
/>
|
||||
|
||||
<UptimeKumaSiteSelectModal
|
||||
open={uptimeKumaModalOpen}
|
||||
@@ -669,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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -7,7 +7,7 @@ import dynamic from "next/dynamic"
|
||||
import {useEffect, useMemo} from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {Loader2, Settings} from "lucide-react"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {useRouter, useSearchParams} from "next/navigation"
|
||||
import {motion} from "motion/react"
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
@@ -64,6 +64,17 @@ function systemConfigMap(configs: SystemConfig[]) {
|
||||
export function AdminSettingsPageClient() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const activeTab = useMemo(() => {
|
||||
const rawTab = searchParams.get("tab")
|
||||
const validTabs = ["openflare-ops", "security", "operation", "system", "other", "status", "info"]
|
||||
return rawTab && validTabs.includes(rawTab) ? rawTab : "openflare-ops"
|
||||
}, [searchParams])
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
router.push(`/admin/settings?tab=${value}`)
|
||||
}
|
||||
|
||||
const systemConfigsQuery = useQuery({
|
||||
queryKey: ["admin", "system-configs"],
|
||||
@@ -85,7 +96,7 @@ export function AdminSettingsPageClient() {
|
||||
if (loading || !user || !user.is_admin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="size-6 animate-spin text-indigo-500" />
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -103,7 +114,7 @@ export function AdminSettingsPageClient() {
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统设置</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultValue="security" className="w-full">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
|
||||
<TabsTrigger value="openflare-ops" className="px-0 pb-2 text-xs font-semibold">
|
||||
OpenFlare
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import {Suspense} from "react"
|
||||
import {Loader2} from "lucide-react"
|
||||
import {AdminSettingsPageClient} from "./page-client"
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return <AdminSettingsPageClient />
|
||||
}
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AdminSettingsPageClient />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {useAdminUsers} from "@/contexts/admin-users-context"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import type {AdminUser} from "@/lib/services/admin"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Loader2} from "lucide-react"
|
||||
|
||||
interface EditUserForm {
|
||||
nickname: string
|
||||
email: string
|
||||
is_admin: boolean
|
||||
password?: string
|
||||
}
|
||||
|
||||
export function EditUserModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
user: AdminUser | null
|
||||
}) {
|
||||
const { updateUser, deleteUser, getUserDetail } = useAdminUsers()
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
const isSelf = currentUser && user && currentUser.id.toString() === user.id.toString()
|
||||
|
||||
const [form, setForm] = useState<EditUserForm>({
|
||||
nickname: "",
|
||||
email: "",
|
||||
is_admin: false,
|
||||
password: "",
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (isOpen && user) {
|
||||
setForm({
|
||||
nickname: user.nickname || "",
|
||||
email: user.email || "",
|
||||
is_admin: user.is_admin || false,
|
||||
password: "",
|
||||
})
|
||||
setErrors({})
|
||||
|
||||
getUserDetail(user.id)
|
||||
.then((detail) => {
|
||||
if (active && detail) {
|
||||
setForm({
|
||||
nickname: detail.nickname || "",
|
||||
email: detail.email || "",
|
||||
is_admin: detail.is_admin || false,
|
||||
password: "",
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore fetching error
|
||||
})
|
||||
} else {
|
||||
setSaving(false)
|
||||
}
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [isOpen, user, getUserDetail])
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!form.email.trim()) {
|
||||
newErrors.email = "邮箱不能为空"
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
newErrors.email = "邮箱格式不正确"
|
||||
}
|
||||
|
||||
if (form.password && form.password.length < 8) {
|
||||
newErrors.password = "密码长度不能少于 8 位"
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!user || !validate()) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateUser(user.id, {
|
||||
nickname: form.nickname.trim() || undefined,
|
||||
email: form.email.trim(),
|
||||
is_admin: form.is_admin,
|
||||
password: form.password?.trim() || undefined,
|
||||
})
|
||||
onClose()
|
||||
} catch {
|
||||
// Errors are handled by context toast notifications
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!user) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await deleteUser(user)
|
||||
onClose()
|
||||
} catch {
|
||||
// Errors are handled by context toast notifications
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑用户</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改用户档案、授予/撤销管理权限或重置其密码。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username" className="text-xs text-muted-foreground">用户名 (不可修改)</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={user.username}
|
||||
disabled
|
||||
className="bg-muted/50 cursor-not-allowed select-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nickname">昵称 (选填)</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
value={form.nickname}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="请输入邮箱地址"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-destructive">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">重置密码 (选填)</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="留空表示保持当前密码,输入则重置 (至少 8 位)"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
|
||||
<div>
|
||||
<div className="font-medium text-sm">管理员权限</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isSelf ? "不能撤销当前登录用户的管理员权限。" : "开启后此账号将拥有后台管理权限。"}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.is_admin}
|
||||
disabled={!!isSelf || saving}
|
||||
onCheckedChange={async (checked) => {
|
||||
setForm((prev) => ({ ...prev, is_admin: checked }))
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateUser(user.id, {
|
||||
nickname: form.nickname.trim() || undefined,
|
||||
email: form.email.trim(),
|
||||
is_admin: checked,
|
||||
})
|
||||
} catch {
|
||||
setForm((prev) => ({ ...prev, is_admin: !checked }))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2 border-t mt-2">
|
||||
<div>
|
||||
{!user.is_admin && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
删除用户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" type="button" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} variant="secondary">
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除用户 {user.nickname || user.username} 吗?该操作会移除用户账号,删除后无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={saving}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, Trash2, UserCheck,} from "lucide-react"
|
||||
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, UserCheck,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
|
||||
import type {AdminUser} from "@/lib/services/admin"
|
||||
import {cn, formatDateTime} from "@/lib/utils"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
|
||||
interface UserDetailSheetProps {
|
||||
selectedUser: AdminUser | null
|
||||
@@ -16,7 +16,6 @@ interface UserDetailSheetProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
detailLoading: boolean
|
||||
onStatusToggle: (user: AdminUser) => Promise<void>
|
||||
onDeleteTarget: (user: AdminUser) => void
|
||||
}
|
||||
|
||||
export function UserDetailSheet({
|
||||
@@ -25,7 +24,6 @@ export function UserDetailSheet({
|
||||
onOpenChange,
|
||||
detailLoading,
|
||||
onStatusToggle,
|
||||
onDeleteTarget,
|
||||
}: UserDetailSheetProps) {
|
||||
|
||||
const displayValue = (value?: string) => value && value.trim() ? value : "-"
|
||||
@@ -56,7 +54,7 @@ export function UserDetailSheet({
|
||||
UID: {selectedUser.id}
|
||||
</Badge>
|
||||
{selectedUser.is_admin && (
|
||||
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
|
||||
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
@@ -156,15 +154,10 @@ export function UserDetailSheet({
|
||||
</div>
|
||||
|
||||
{!selectedUser.is_admin && (
|
||||
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0 flex flex-col gap-2">
|
||||
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
|
||||
<Button
|
||||
variant={selectedUser.is_active ? "destructive" : "default"}
|
||||
className={cn(
|
||||
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
|
||||
selectedUser.is_active
|
||||
? "bg-red-500 hover:bg-red-600 text-white"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
)}
|
||||
className="w-full h-9 text-xs font-medium transition-all active:scale-[0.98]"
|
||||
onClick={() => onStatusToggle(selectedUser)}
|
||||
>
|
||||
{selectedUser.is_active ? (
|
||||
@@ -179,14 +172,6 @@ export function UserDetailSheet({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-9 text-xs font-medium"
|
||||
onClick={() => onDeleteTarget(selectedUser)}
|
||||
>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
删除用户
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,17 +18,19 @@ export function UserFilterBar() {
|
||||
pageSize,
|
||||
searchUserId,
|
||||
searchUsername,
|
||||
searchEmail,
|
||||
statusFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setSearchUserId,
|
||||
setSearchUsername,
|
||||
setSearchEmail,
|
||||
setStatusFilter,
|
||||
fetchUsers
|
||||
} = useAdminUsers()
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const hasSearchFilter = Boolean(searchUserId || searchUsername)
|
||||
const hasSearchFilter = Boolean(searchUserId || searchUsername || searchEmail)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
||||
@@ -49,8 +51,8 @@ export function UserFilterBar() {
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
variant="default"
|
||||
className="text-[10px] h-3 px-1 rounded-full"
|
||||
>
|
||||
!
|
||||
</Badge>
|
||||
@@ -72,6 +74,12 @@ export function UserFilterBar() {
|
||||
value={searchUsername}
|
||||
onChange={(e) => setSearchUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
|
||||
placeholder="输入邮箱..."
|
||||
value={searchEmail}
|
||||
onChange={(e) => setSearchEmail(e.target.value)}
|
||||
/>
|
||||
{hasSearchFilter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -80,6 +88,7 @@ export function UserFilterBar() {
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
setSearchEmail("")
|
||||
}}
|
||||
>
|
||||
清除
|
||||
@@ -105,8 +114,8 @@ export function UserFilterBar() {
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
variant="default"
|
||||
className="text-[10px] h-3 px-1 rounded-full"
|
||||
>
|
||||
1
|
||||
</Badge>
|
||||
@@ -160,6 +169,7 @@ export function UserFilterBar() {
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
setSearchEmail("")
|
||||
setStatusFilter('all')
|
||||
}}
|
||||
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
|
||||
@@ -6,18 +6,8 @@ import {Switch} from "@/components/ui/switch"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {Eye, Layers, Loader2, Plus, Trash2, UserRound, UserX,} from "lucide-react"
|
||||
import {Eye, Layers, Pencil, Plus, UserRound, UserX,} from "lucide-react"
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
import type {AdminUser} from "@/lib/services/admin"
|
||||
import {formatDateTime} from "@/lib/utils"
|
||||
@@ -28,6 +18,7 @@ import {useAdminUsers} from "@/contexts/admin-users-context"
|
||||
import {CreateUserModal} from "./components/create-user-modal"
|
||||
import {UserFilterBar} from "./components/user-filter-bar"
|
||||
import {UserDetailSheet} from "./components/user-detail-sheet"
|
||||
import {EditUserModal} from "./components/edit-user-modal"
|
||||
|
||||
export default function UsersPage() {
|
||||
const {
|
||||
@@ -36,16 +27,14 @@ export default function UsersPage() {
|
||||
error,
|
||||
fetchUsers,
|
||||
getUserDetail,
|
||||
updateUserStatus,
|
||||
deleteUser
|
||||
updateUserStatus
|
||||
} = useAdminUsers()
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<AdminUser | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
@@ -74,21 +63,7 @@ export default function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deleteTarget) return
|
||||
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
await deleteUser(deleteTarget)
|
||||
if (selectedUser?.id === deleteTarget.id) {
|
||||
setDetailOpen(false)
|
||||
setSelectedUser(null)
|
||||
}
|
||||
setDeleteTarget(null)
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-4">
|
||||
@@ -125,7 +100,8 @@ export default function UsersPage() {
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
|
||||
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[160px] py-2 h-8 pl-4">邮箱</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次登陆</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
|
||||
@@ -163,6 +139,10 @@ export default function UsersPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
|
||||
{user.email || "-"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
|
||||
{formatDateTime(user.last_login_at)}
|
||||
</TableCell>
|
||||
@@ -181,7 +161,7 @@ export default function UsersPage() {
|
||||
checked={user.is_active}
|
||||
onCheckedChange={() => handleStatusToggle(user)}
|
||||
disabled={user.is_admin}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -201,23 +181,18 @@ export default function UsersPage() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{!user.is_admin && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(user)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
删除用户
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => setEditTarget(user)}>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
编辑用户
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -235,27 +210,16 @@ export default function UsersPage() {
|
||||
onOpenChange={setDetailOpen}
|
||||
detailLoading={detailLoading}
|
||||
onStatusToggle={handleStatusToggle}
|
||||
onDeleteTarget={setDeleteTarget}
|
||||
/>
|
||||
|
||||
{/* 删除确认警告弹窗 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleteLoading && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除用户 {deleteTarget?.nickname || deleteTarget?.username} 吗?该操作会移除用户账号,删除后无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteLoading}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteUser} disabled={deleteLoading}>
|
||||
{deleteLoading && <Loader2 className="size-3 animate-spin" />}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{/* 编辑用户弹窗 */}
|
||||
<EditUserModal
|
||||
user={editTarget}
|
||||
isOpen={!!editTarget}
|
||||
onClose={() => setEditTarget(null)}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
{/* 新建用户模态弹窗 */}
|
||||
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {ExternalLink, Loader2} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||
import {useOpenFlareServerUpgrade} from '@/lib/hooks/use-openflare-server-upgrade';
|
||||
import type {AppUpdateStatus} from '@/lib/services/admin/types';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import {formatRelativeTime} from '@/app/(main)/nodes/components/node-utils';
|
||||
|
||||
function getUpgradeBadge(update: AppUpdateStatus | null | undefined) {
|
||||
if (!update) {
|
||||
return { label: '未检查', variant: 'outline' as const };
|
||||
}
|
||||
if (update.update_available) {
|
||||
return { label: '可升级', variant: 'secondary' as const };
|
||||
}
|
||||
return { label: '最新', variant: 'default' as const };
|
||||
}
|
||||
|
||||
export function VersionUpgradeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
canUpgrade = true,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
canUpgrade?: boolean;
|
||||
}) {
|
||||
const {
|
||||
currentVersion,
|
||||
update,
|
||||
releaseErrorMessage,
|
||||
isInitialLoading,
|
||||
isChecking,
|
||||
isUpgrading,
|
||||
handleOpen,
|
||||
handleCheckRelease,
|
||||
handleUpgrade,
|
||||
} = useOpenFlareServerUpgrade({ open, canUpgrade });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
handleOpen();
|
||||
}
|
||||
}, [open, handleOpen]);
|
||||
|
||||
const upgradeBadge = getUpgradeBadge(update);
|
||||
const isBusy = isChecking || isUpgrading;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>服务端版本</DialogTitle>
|
||||
<DialogDescription>
|
||||
检查上游 GitHub Release 并升级当前服务。升级开始后服务会短暂重启。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="border-dashed shadow-none py-4 gap-3">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<CardTitle className="text-sm">当前版本</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium">{currentVersion}</p>
|
||||
<Badge variant={upgradeBadge.variant}>{upgradeBadge.label}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-dashed shadow-none py-4 gap-3">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<CardTitle className="text-sm">最新版本</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 space-y-3">
|
||||
<p className="text-sm font-medium">{update?.latest_version || '未检查'}</p>
|
||||
{canUpgrade ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isBusy}
|
||||
onClick={handleCheckRelease}
|
||||
>
|
||||
{isChecking ? '检查中...' : '检查更新'}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isInitialLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
加载版本信息...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isInitialLoading && releaseErrorMessage ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{releaseErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isInitialLoading && !releaseErrorMessage && !update ? (
|
||||
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
尚未检查更新,点击「检查更新」后展示 GitHub Release 信息。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{update ? (
|
||||
<Card className="border-dashed shadow-none py-4 gap-3">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<CardTitle className="text-sm">GitHub Release · {update.latest_version}</CardTitle>
|
||||
<CardDescription>
|
||||
{update.published_at
|
||||
? `发布时间:${formatRelativeTime(update.published_at)} · ${formatDateTime(update.published_at)}`
|
||||
: '未提供发布时间'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={update.update_available ? 'secondary' : 'default'}>
|
||||
{update.update_available ? '发现新版本' : '已经是最新版本'}
|
||||
</Badge>
|
||||
{update.prerelease ? (
|
||||
<Badge variant="secondary">Preview 发布</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">正式发布</Badge>
|
||||
)}
|
||||
{!update.can_upgrade ? (
|
||||
<Badge variant="destructive">当前平台不支持自动升级</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{update.release_notes || '暂无更新说明'}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{update.release_url ? (
|
||||
<a
|
||||
href={update.release_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center text-sm text-primary hover:underline"
|
||||
>
|
||||
查看发布详情
|
||||
<ExternalLink className="size-3 ml-1" />
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{canUpgrade ? (
|
||||
<div className="flex justify-end">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
!update.update_available ||
|
||||
isUpgrading ||
|
||||
!update.can_upgrade ||
|
||||
isBusy
|
||||
}
|
||||
>
|
||||
{isUpgrading ? '升级中...' : '立即升级'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>升级到 {update.latest_version}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
服务将下载并校验 {update.asset_name},随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleUpgrade}>确认升级</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!update.can_upgrade ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{update.current_version === 'dev'
|
||||
? '开发构建没有可比较的 Release 版本,不能执行自动升级。'
|
||||
: update.update_available
|
||||
? '当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。'
|
||||
: '当前版本无需升级。'}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -289,7 +289,7 @@ export function NodeObservability({
|
||||
const observabilityQuery = useQuery({
|
||||
queryKey: ['openflare', 'node-observability', nodeId],
|
||||
queryFn: () => NodeService.getObservability(nodeId, { hours: 24, limit: 48 }),
|
||||
refetchInterval: 10000,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
@@ -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>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function OpenFlareDashboardPage() {
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: dashboardQueryKey,
|
||||
queryFn: () => DashboardService.getOverview(),
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const overview = overviewQuery.data;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
import {DnsAccountService} from '@/lib/services/openflare';
|
||||
import type {DnsAccountMutationPayload} from '@/lib/services/openflare';
|
||||
|
||||
import {getErrorMessage} from './website-utils';
|
||||
|
||||
@@ -52,7 +53,7 @@ export function DnsAccountCreateDialog({
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: DnsAccountService.create,
|
||||
mutationFn: (payload: DnsAccountMutationPayload) => DnsAccountService.create(payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: dnsAccountsQueryKey});
|
||||
form.reset();
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import {
|
||||
TlsCertificateService,
|
||||
ZoneDomainService,
|
||||
ZoneService,
|
||||
zoneQueryKey,
|
||||
type ZoneDomainItem,
|
||||
type ZoneItem,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {previewZoneDomainInput, resolveZoneDomainInput} from './resolve-zone-domain-input';
|
||||
|
||||
const schema = z.object({
|
||||
zone_id: z.string().min(1, '请选择 Zone'),
|
||||
domain_input: z.string().trim().min(1, '请输入域名'),
|
||||
cert_id: z.string(),
|
||||
});
|
||||
|
||||
type Values = z.infer<typeof schema>;
|
||||
|
||||
export function QuickCreateZoneDomainDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fixedZoneId,
|
||||
fixedZoneRoot,
|
||||
zones: zonesProp,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
/** When set, Zone 选择器隐藏 */
|
||||
fixedZoneId?: number;
|
||||
fixedZoneRoot?: string;
|
||||
zones?: ZoneItem[];
|
||||
onCreated(domain: ZoneDomainItem): void | Promise<void>;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const zonesQuery = useQuery({
|
||||
queryKey: zoneQueryKey,
|
||||
queryFn: () => ZoneService.list(),
|
||||
enabled: open && !fixedZoneId && !zonesProp,
|
||||
});
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: ['openflare', 'tls-certificates'],
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const zones = useMemo(
|
||||
() => zonesProp ?? zonesQuery.data ?? [],
|
||||
[zonesProp, zonesQuery.data],
|
||||
);
|
||||
const fixedZone = useMemo(() => {
|
||||
if (!fixedZoneId) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
zones.find((zone) => zone.id === fixedZoneId) ??
|
||||
(fixedZoneRoot
|
||||
? ({id: fixedZoneId, domain: fixedZoneRoot, created_at: '', updated_at: ''} as ZoneItem)
|
||||
: undefined)
|
||||
);
|
||||
}, [fixedZoneId, fixedZoneRoot, zones]);
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
zone_id: fixedZoneId ? String(fixedZoneId) : '',
|
||||
domain_input: '',
|
||||
cert_id: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
form.reset({
|
||||
zone_id: fixedZoneId ? String(fixedZoneId) : '',
|
||||
domain_input: '',
|
||||
cert_id: '',
|
||||
});
|
||||
}, [fixedZoneId, form, open]);
|
||||
|
||||
const watchedZoneId = form.watch('zone_id');
|
||||
const watchedInput = form.watch('domain_input');
|
||||
const selectedZone = useMemo(() => {
|
||||
if (fixedZone) {
|
||||
return fixedZone;
|
||||
}
|
||||
const id = Number(watchedZoneId);
|
||||
return zones.find((zone) => zone.id === id);
|
||||
}, [fixedZone, watchedZoneId, zones]);
|
||||
|
||||
const preview = selectedZone
|
||||
? previewZoneDomainInput(watchedInput, selectedZone.domain)
|
||||
: '';
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: Values) => {
|
||||
const zoneId = Number(values.zone_id);
|
||||
const zone =
|
||||
fixedZone ?? zones.find((item) => item.id === zoneId);
|
||||
if (!zone) {
|
||||
throw new Error('请选择 Zone');
|
||||
}
|
||||
const resolved = resolveZoneDomainInput(values.domain_input, zone.domain);
|
||||
if (resolved.error || !resolved.domain) {
|
||||
throw new Error(resolved.error || '域名格式不合法');
|
||||
}
|
||||
return ZoneDomainService.create(zone.id, {
|
||||
domain: resolved.domain,
|
||||
cert_id: values.cert_id ? Number(values.cert_id) : null,
|
||||
});
|
||||
},
|
||||
onSuccess: async (domain) => {
|
||||
toast.success('域名已添加', {description: domain.domain});
|
||||
await Promise.all([
|
||||
onCreated(domain),
|
||||
queryClient.invalidateQueries({queryKey: zoneQueryKey}),
|
||||
queryClient.invalidateQueries({queryKey: [...zoneQueryKey, 'all-domains']}),
|
||||
]);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : '添加失败'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>快捷新增域名</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择 Zone 后输入:子域名称(如 api)、@(根域)或完整 FQDN。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
id="quick-create-zone-domain"
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
if (!selectedZone) {
|
||||
form.setError('zone_id', {message: '请选择 Zone'});
|
||||
return;
|
||||
}
|
||||
const resolved = resolveZoneDomainInput(
|
||||
values.domain_input,
|
||||
selectedZone.domain,
|
||||
);
|
||||
if (resolved.error) {
|
||||
form.setError('domain_input', {message: resolved.error});
|
||||
return;
|
||||
}
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
{!fixedZoneId ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Zone</Label>
|
||||
<Select
|
||||
value={form.watch('zone_id') || undefined}
|
||||
onValueChange={(value) => form.setValue('zone_id', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择注册根域" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{zones.map((zone) => (
|
||||
<SelectItem key={zone.id} value={String(zone.id)}>
|
||||
{zone.domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.zone_id ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.zone_id.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
Zone:
|
||||
<span className="ml-1 font-medium">
|
||||
{fixedZoneRoot || fixedZone?.domain || `#${fixedZoneId}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="domain-input">域名</Label>
|
||||
<Input
|
||||
id="domain-input"
|
||||
placeholder={
|
||||
selectedZone
|
||||
? `api 或 @ 或 api.${selectedZone.domain}`
|
||||
: 'api / @ / 完整域名'
|
||||
}
|
||||
{...form.register('domain_input')}
|
||||
/>
|
||||
{preview ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
将创建:
|
||||
<code className="ml-1 rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
||||
{preview}
|
||||
</code>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
示例:输入 <code className="font-mono">api</code> →{' '}
|
||||
<code className="font-mono">api.zone.com</code>;
|
||||
<code className="font-mono">@</code> → 根域本身
|
||||
</p>
|
||||
)}
|
||||
{form.formState.errors.domain_input ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.domain_input.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>证书(可选)</Label>
|
||||
<Select
|
||||
value={form.watch('cert_id') || '__none'}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('cert_id', value === '__none' ? '' : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不绑定证书" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不绑定证书</SelectItem>
|
||||
{(certificatesQuery.data ?? []).map((certificate) => (
|
||||
<SelectItem key={certificate.id} value={String(certificate.id)}>
|
||||
{certificate.name} · {certificate.primary_domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="quick-create-zone-domain"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-1 size-4 animate-spin" />
|
||||
) : null}
|
||||
添加域名
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Resolve user input into a full FQDN under a Zone root.
|
||||
*
|
||||
* - `@` → apex (zone root itself), e.g. `example.com`
|
||||
* - single label `name` → `name.example.com`
|
||||
* - full FQDN must equal the root or end with `.root`
|
||||
*/
|
||||
export function resolveZoneDomainInput(
|
||||
rawInput: string,
|
||||
zoneRoot: string,
|
||||
): {domain: string; error?: string} {
|
||||
const input = rawInput.trim().toLowerCase();
|
||||
const root = zoneRoot.trim().toLowerCase();
|
||||
|
||||
if (!root) {
|
||||
return {domain: '', error: '请先选择 Zone'};
|
||||
}
|
||||
if (!input) {
|
||||
return {domain: '', error: '请输入域名'};
|
||||
}
|
||||
if (input.includes('*')) {
|
||||
return {domain: '', error: 'Zone 域名不支持通配符'};
|
||||
}
|
||||
if (input.includes('://') || input.includes('/') || input.includes('?') || input.includes('#')) {
|
||||
return {domain: '', error: '域名格式不合法'};
|
||||
}
|
||||
|
||||
if (input === '@') {
|
||||
return {domain: root};
|
||||
}
|
||||
|
||||
// Short label: no dots → prefix under zone root
|
||||
if (!input.includes('.')) {
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(input)) {
|
||||
return {domain: '', error: '子域名称格式不合法'};
|
||||
}
|
||||
return {domain: `${input}.${root}`};
|
||||
}
|
||||
|
||||
// Full FQDN
|
||||
if (input === root || input.endsWith(`.${root}`)) {
|
||||
return {domain: input};
|
||||
}
|
||||
|
||||
return {domain: '', error: `域名必须属于 Zone ${root}`};
|
||||
}
|
||||
|
||||
/** Live preview string for the input helper text. */
|
||||
export function previewZoneDomainInput(rawInput: string, zoneRoot: string): string {
|
||||
const resolved = resolveZoneDomainInput(rawInput, zoneRoot);
|
||||
if (resolved.error || !resolved.domain) {
|
||||
return '';
|
||||
}
|
||||
return resolved.domain;
|
||||
}
|
||||
@@ -1,33 +1,5 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
export const managedDomainSchema = z.object({
|
||||
domain: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入域名')
|
||||
.max(255, '域名不能超过 255 个字符')
|
||||
.refine(
|
||||
(value) => !value.includes('://') && !value.includes('/'),
|
||||
'域名格式不合法',
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
/^(?:\*\.)?(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/.test(
|
||||
value,
|
||||
),
|
||||
'域名格式不合法',
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.includes('*') ||
|
||||
(value.startsWith('*.') && value.indexOf('*', 1) === -1),
|
||||
'通配符域名仅支持 *.example.com 格式',
|
||||
),
|
||||
cert_id: z.string(),
|
||||
enabled: z.boolean(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
});
|
||||
|
||||
export const manualImportSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
@@ -39,7 +11,6 @@ export const manualImportSchema = z.object({
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
});
|
||||
|
||||
export type ManagedDomainFormValues = z.infer<typeof managedDomainSchema>;
|
||||
export type ManualImportFormValues = z.infer<typeof manualImportSchema>;
|
||||
|
||||
export type FileImportFormValues = {
|
||||
@@ -47,13 +18,6 @@ export type FileImportFormValues = {
|
||||
remark: string;
|
||||
};
|
||||
|
||||
export const defaultManagedDomainValues: ManagedDomainFormValues = {
|
||||
domain: '',
|
||||
cert_id: '',
|
||||
enabled: true,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
export const defaultManualImportValues: ManualImportFormValues = {
|
||||
name: '',
|
||||
cert_pem: '',
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {useEffect} from 'react';
|
||||
import {useForm, useWatch} from 'react-hook-form';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import type {ManagedDomainItem, TlsCertificateItem} from '@/lib/services/openflare';
|
||||
import {WebsiteService} from '@/lib/services/openflare';
|
||||
|
||||
import {defaultManagedDomainValues, type ManagedDomainFormValues, managedDomainSchema,} from './schemas';
|
||||
import {WebsiteStatusBadge} from './status-badge';
|
||||
import {
|
||||
buildCertificateLabel,
|
||||
getErrorMessage,
|
||||
getMatchTypeMeta,
|
||||
toManagedDomainFormValues,
|
||||
toManagedDomainPayload,
|
||||
} from './website-utils';
|
||||
|
||||
const domainsQueryKey = ['openflare', 'managed-domains'];
|
||||
|
||||
interface WebsiteEditorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved?: (domain: ManagedDomainItem, mode: 'create' | 'update') => void;
|
||||
onRequestImportCertificate: () => void;
|
||||
certificates: TlsCertificateItem[];
|
||||
certificatesLoading?: boolean;
|
||||
initialDomain?: ManagedDomainItem | null;
|
||||
preferredCertificateId?: number | null;
|
||||
}
|
||||
|
||||
export function WebsiteEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
onRequestImportCertificate,
|
||||
certificates,
|
||||
certificatesLoading = false,
|
||||
initialDomain = null,
|
||||
preferredCertificateId = null,
|
||||
}: WebsiteEditorDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<ManagedDomainFormValues>({
|
||||
resolver: zodResolver(managedDomainSchema),
|
||||
defaultValues: defaultManagedDomainValues,
|
||||
});
|
||||
|
||||
const watchedDomain = useWatch({control: form.control, name: 'domain'});
|
||||
const watchedCertId = useWatch({control: form.control, name: 'cert_id'});
|
||||
const watchedEnabled = useWatch({control: form.control, name: 'enabled'});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: ManagedDomainFormValues) => {
|
||||
const payload = toManagedDomainPayload(values);
|
||||
return initialDomain
|
||||
? WebsiteService.update(initialDomain.id, payload)
|
||||
: WebsiteService.create(payload);
|
||||
},
|
||||
onSuccess: async (domain) => {
|
||||
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
|
||||
onSaved?.(domain, initialDomain ? 'update' : 'create');
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
|
||||
const currentCertificate = watchedCertId
|
||||
? (certificates.find((item) => item.id === Number(watchedCertId)) ?? null)
|
||||
: null;
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
saveMutation.mutate(values);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
saveMutation.reset();
|
||||
form.reset(defaultManagedDomainValues);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset(
|
||||
initialDomain
|
||||
? toManagedDomainFormValues(initialDomain)
|
||||
: defaultManagedDomainValues,
|
||||
);
|
||||
}, [form, initialDomain, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !preferredCertificateId) return;
|
||||
form.setValue('cert_id', String(preferredCertificateId), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}, [form, open, preferredCertificateId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{initialDomain ? '编辑网站' : '新增网站'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
录入域名并选择绑定证书。证书可在弹窗内直接新增,导入成功后会自动回填。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{saveMutation.isError ? (
|
||||
<p className="text-sm text-destructive">{getErrorMessage(saveMutation.error)}</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="domain">域名</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
placeholder="example.com 或 *.example.com"
|
||||
{...form.register('domain')}
|
||||
/>
|
||||
{form.formState.errors.domain ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.domain.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>绑定证书</Label>
|
||||
<Select
|
||||
value={watchedCertId || 'none'}
|
||||
disabled={certificatesLoading}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('cert_id', value === 'none' ? '' : value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不绑定证书" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不绑定证书</SelectItem>
|
||||
{certificates.map((certificate) => (
|
||||
<SelectItem key={certificate.id} value={String(certificate.id)}>
|
||||
{buildCertificateLabel(certificate)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={onRequestImportCertificate}
|
||||
>
|
||||
添加证书
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border border-dashed p-3 md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">当前域名</p>
|
||||
<p className="mt-1 text-sm">{watchedDomain?.trim() || '未填写域名'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">匹配类型</p>
|
||||
<div className="mt-1">
|
||||
{watchedDomain?.trim() ? (
|
||||
<WebsiteStatusBadge {...getMatchTypeMeta(watchedDomain.trim())} />
|
||||
) : (
|
||||
<WebsiteStatusBadge label="等待输入" tone="warning" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">当前证书</p>
|
||||
<div className="mt-1">
|
||||
<WebsiteStatusBadge
|
||||
label={currentCertificate ? currentCertificate.name : '未绑定证书'}
|
||||
tone={currentCertificate ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">启用网站</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
停用后该网站不会参与自动匹配,但记录会保留。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={watchedEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue('enabled', checked, {shouldDirty: true, shouldValidate: true})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="remark">备注</Label>
|
||||
<Input
|
||||
id="remark"
|
||||
placeholder="例如:主站 / 泛域名 / 生产"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : initialDomain ? (
|
||||
'保存网站'
|
||||
) : (
|
||||
'创建网站'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import type {
|
||||
ManagedDomainItem,
|
||||
ManagedDomainMutationPayload,
|
||||
ProxyRouteItem,
|
||||
TlsCertificateFileImportPayload,
|
||||
TlsCertificateItem,
|
||||
TlsCertificateMutationPayload,
|
||||
} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
|
||||
import type {FileImportFormValues, ManagedDomainFormValues, ManualImportFormValues,} from './schemas';
|
||||
import type {FileImportFormValues, ManualImportFormValues} from './schemas';
|
||||
|
||||
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
@@ -51,28 +48,6 @@ export function buildCertificateLabel(certificate: TlsCertificateItem) {
|
||||
: certificate.name;
|
||||
}
|
||||
|
||||
export function toManagedDomainPayload(
|
||||
values: ManagedDomainFormValues,
|
||||
): ManagedDomainMutationPayload {
|
||||
return {
|
||||
domain: values.domain.trim().toLowerCase(),
|
||||
cert_id: values.cert_id ? Number(values.cert_id) : null,
|
||||
enabled: values.enabled,
|
||||
remark: values.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toManagedDomainFormValues(
|
||||
domain: ManagedDomainItem,
|
||||
): ManagedDomainFormValues {
|
||||
return {
|
||||
domain: domain.domain,
|
||||
cert_id: domain.cert_id ? String(domain.cert_id) : '',
|
||||
enabled: domain.enabled,
|
||||
remark: domain.remark || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function toManualPayload(
|
||||
values: ManualImportFormValues,
|
||||
): TlsCertificateMutationPayload {
|
||||
@@ -100,20 +75,3 @@ export function toFilePayload(
|
||||
keyFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function isRouteRelatedToManagedDomain(
|
||||
managedDomain: string,
|
||||
route: ProxyRouteItem,
|
||||
) {
|
||||
const domains = route.domains.length > 0 ? route.domains : [route.primary_domain];
|
||||
return domains.some((routeDomain) => {
|
||||
if (managedDomain === routeDomain) {
|
||||
return true;
|
||||
}
|
||||
if (!managedDomain.startsWith('*.')) {
|
||||
return false;
|
||||
}
|
||||
const suffix = managedDomain.slice(2);
|
||||
return routeDomain.endsWith(`.${suffix}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {ArrowLeft, Globe, Plus, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
|
||||
import type {TlsCertificateItem} from '@/lib/services/openflare';
|
||||
import {ProxyRouteService, TlsCertificateService, WebsiteService,} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import {getUpstreamSummary} from '@/app/(main)/proxy-routes/components/helpers';
|
||||
|
||||
import {CertificateApplyDialog} from '../components/certificate-apply-dialog';
|
||||
import {CertificateDetailDialog} from '../components/certificate-detail-dialog';
|
||||
import {CertificateEditorDialog} from '../components/certificate-editor-dialog';
|
||||
import {CertificateImportDialog} from '../components/certificate-import-dialog';
|
||||
import {WebsiteStatusBadge} from '../components/status-badge';
|
||||
import {WebsiteEditorDialog} from '../components/website-editor-dialog';
|
||||
import {
|
||||
buildCertificateLabel,
|
||||
getCertificateStatus,
|
||||
getErrorMessage,
|
||||
isRouteRelatedToManagedDomain,
|
||||
} from '../components/website-utils';
|
||||
|
||||
const domainsQueryKey = ['openflare', 'managed-domains'];
|
||||
const certificatesQueryKey = ['openflare', 'tls-certificates'];
|
||||
|
||||
export function WebsiteDetailPageClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const websiteId = searchParams.get('id')?.trim() ?? '';
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [certEditorOpen, setCertEditorOpen] = useState(false);
|
||||
const [deleteWebsiteOpen, setDeleteWebsiteOpen] = useState(false);
|
||||
const [deleteCertOpen, setDeleteCertOpen] = useState(false);
|
||||
const [preferredCertificateId, setPreferredCertificateId] = useState<number | null>(null);
|
||||
const [convertCertificate, setConvertCertificate] = useState<TlsCertificateItem | null>(null);
|
||||
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: domainsQueryKey,
|
||||
queryFn: () => WebsiteService.list(),
|
||||
});
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: certificatesQueryKey,
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
});
|
||||
|
||||
const routesQuery = useQuery({
|
||||
queryKey: ['openflare', 'proxy-routes'],
|
||||
queryFn: () => ProxyRouteService.list(),
|
||||
});
|
||||
|
||||
const website = useMemo(
|
||||
() =>
|
||||
(domainsQuery.data ?? []).find((item) => String(item.id) === websiteId) ?? null,
|
||||
[domainsQuery.data, websiteId],
|
||||
);
|
||||
|
||||
const certificates = useMemo(
|
||||
() => certificatesQuery.data ?? [],
|
||||
[certificatesQuery.data],
|
||||
);
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((item) => [item.id, item])),
|
||||
[certificates],
|
||||
);
|
||||
|
||||
const certificate = website?.cert_id
|
||||
? (certificateMap.get(website.cert_id) ?? null)
|
||||
: null;
|
||||
|
||||
const relatedRoutes = useMemo(() => {
|
||||
if (!website) return [];
|
||||
return (routesQuery.data ?? []).filter((route) =>
|
||||
isRouteRelatedToManagedDomain(website.domain, route),
|
||||
);
|
||||
}, [routesQuery.data, website]);
|
||||
|
||||
const deleteDomainMutation = useMutation({
|
||||
mutationFn: (id: number) => WebsiteService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
|
||||
router.push('/websites');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
const deleteCertificateMutation = useMutation({
|
||||
mutationFn: (id: number) => TlsCertificateService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('证书已删除');
|
||||
setDeleteCertOpen(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
|
||||
queryClient.invalidateQueries({queryKey: domainsQueryKey}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
if (!websiteId) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="缺少网站 ID,请从网站列表进入详情页。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
domainsQuery.isLoading ||
|
||||
certificatesQuery.isLoading ||
|
||||
routesQuery.isLoading
|
||||
) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={Globe} description="加载网站详情中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (domainsQuery.isError) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<ErrorInline
|
||||
message={getErrorMessage(domainsQuery.error)}
|
||||
onRetry={() => void domainsQuery.refetch()}
|
||||
className="justify-center"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!website) {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-4">
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
|
||||
<Link href="/websites">
|
||||
<ArrowLeft className="size-4 mr-1" />
|
||||
返回网站列表
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="网站不存在,可能已被删除或 ID 无效。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const certificateStatus = certificate ? getCertificateStatus(certificate) : null;
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{website.domain}</h1>
|
||||
<p className="text-sm text-muted-foreground">网站详情</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href="/websites">
|
||||
<ArrowLeft className="size-3.5 mr-1" />
|
||||
返回
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑网站
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => setImportOpen(true)}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
添加证书
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setDeleteWebsiteOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
删除网站
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">网站信息</CardTitle>
|
||||
<CardDescription>当前网站的基础配置与托管信息。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">域名</p>
|
||||
<p className="mt-1 text-sm">{website.domain}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
创建时间
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{formatDateTime(website.created_at)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 md:col-span-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">备注</p>
|
||||
<p className="mt-1 text-sm">{website.remark || '暂无备注'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">证书信息</CardTitle>
|
||||
<CardDescription>
|
||||
当前网站绑定的默认证书信息。若还没有证书,可直接添加后回填。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{certificate ? (
|
||||
<div className="space-y-3">
|
||||
{certificateStatus ? (
|
||||
<WebsiteStatusBadge
|
||||
label={certificateStatus.label}
|
||||
tone={certificateStatus.tone}
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>证书名称:{buildCertificateLabel(certificate)}</p>
|
||||
<p>生效时间:{formatDateTime(certificate.not_before)}</p>
|
||||
<p>到期时间:{formatDateTime(certificate.not_after)}</p>
|
||||
<p>备注:{certificate.remark || '暂无备注'}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive"
|
||||
onClick={() => setDeleteCertOpen(true)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateWithBorder description="未绑定证书,点击右上角「添加证书」后可在编辑网站时直接选择。" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">关联规则</CardTitle>
|
||||
<CardDescription>命中当前网站域名的代理规则。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{relatedRoutes.length === 0 ? (
|
||||
<EmptyStateWithBorder description="暂无关联规则。" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>规则域名</TableHead>
|
||||
<TableHead>源站</TableHead>
|
||||
<TableHead>HTTPS</TableHead>
|
||||
<TableHead>证书</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{relatedRoutes.map((route) => {
|
||||
const routeCertificate = route.cert_id
|
||||
? (certificateMap.get(route.cert_id) ?? null)
|
||||
: null;
|
||||
const matchedDomain =
|
||||
route.domains.find((d) => d === website.domain) ?? route.primary_domain;
|
||||
|
||||
return (
|
||||
<TableRow key={route.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">{matchedDomain}</p>
|
||||
<WebsiteStatusBadge
|
||||
label={
|
||||
matchedDomain === website.domain
|
||||
? '直接关联'
|
||||
: '被网站覆盖'
|
||||
}
|
||||
tone={matchedDomain === website.domain ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-56 break-all">
|
||||
{getUpstreamSummary(route)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WebsiteStatusBadge
|
||||
label={route.enable_https ? '启用 HTTPS' : 'HTTP'}
|
||||
tone={route.enable_https ? 'success' : 'warning'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{routeCertificate ? routeCertificate.name : '未绑定证书'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WebsiteStatusBadge
|
||||
label={route.enabled ? '启用' : '停用'}
|
||||
tone={route.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{route.remark || '暂无备注'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WebsiteEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
initialDomain={website}
|
||||
certificates={certificates}
|
||||
certificatesLoading={certificatesQuery.isLoading}
|
||||
preferredCertificateId={preferredCertificateId}
|
||||
onRequestImportCertificate={() => setImportOpen(true)}
|
||||
onSaved={() => toast.success('网站已更新')}
|
||||
/>
|
||||
|
||||
<CertificateImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={(imported) => {
|
||||
setPreferredCertificateId(imported.id);
|
||||
toast.success(`证书 ${imported.name} 已导入,可在编辑网站时直接应用`);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CertificateDetailDialog
|
||||
certificateId={certificate?.id ?? null}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
onEdit={() => {
|
||||
setDetailOpen(false);
|
||||
setCertEditorOpen(true);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setDetailOpen(false);
|
||||
setDeleteCertOpen(true);
|
||||
}}
|
||||
deleting={deleteCertificateMutation.isPending}
|
||||
/>
|
||||
|
||||
<CertificateEditorDialog
|
||||
certificateId={certificate?.id ?? null}
|
||||
open={certEditorOpen}
|
||||
onOpenChange={setCertEditorOpen}
|
||||
onSaved={(updated) => toast.success(`证书 ${updated.name} 已更新`)}
|
||||
onConvert={(manualCertificate) => {
|
||||
setCertEditorOpen(false);
|
||||
setConvertCertificate(manualCertificate);
|
||||
}}
|
||||
/>
|
||||
|
||||
{convertCertificate ? (
|
||||
<CertificateApplyDialog
|
||||
open
|
||||
onOpenChange={(open) => !open && setConvertCertificate(null)}
|
||||
mode="convert-upload"
|
||||
certificate={convertCertificate}
|
||||
onApplied={(converted) => {
|
||||
setConvertCertificate(null);
|
||||
toast.success(`证书 ${converted.name} 转换申请已提交`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AlertDialog open={deleteWebsiteOpen} onOpenChange={setDeleteWebsiteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除网站 {website.domain} 吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteDomainMutation.mutate(website.id)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={deleteCertOpen} onOpenChange={setDeleteCertOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除证书</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除证书 {certificate?.name} 吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => certificate && deleteCertificateMutation.mutate(certificate.id)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import {Suspense} from 'react';
|
||||
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
|
||||
import {WebsiteDetailPageClient} from './page-client';
|
||||
|
||||
function WebsiteDetailPageFallback() {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-full max-w-xl" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebsiteDetailPage() {
|
||||
return (
|
||||
<Suspense fallback={<WebsiteDetailPageFallback />}>
|
||||
<WebsiteDetailPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,211 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Globe, Plus, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {Eye, Globe, Plus, Search} from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import type {ManagedDomainItem} from '@/lib/services/openflare';
|
||||
import {TlsCertificateService, WebsiteService} from '@/lib/services/openflare';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip';
|
||||
import {ZoneService, zoneQueryKey} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
|
||||
import {CertificateImportDialog} from './components/certificate-import-dialog';
|
||||
import {WebsiteStatusBadge} from './components/status-badge';
|
||||
import {WebsiteEditorDialog} from './components/website-editor-dialog';
|
||||
import {buildCertificateLabel, getErrorMessage, getMatchTypeMeta,} from './components/website-utils';
|
||||
|
||||
const domainsQueryKey = ['openflare', 'managed-domains'];
|
||||
const certificatesQueryKey = ['openflare', 'tls-certificates'];
|
||||
import {ZoneEditorDialog} from './[zoneId]/components/zone-editor-dialog';
|
||||
import {getErrorMessage} from './components/website-utils';
|
||||
|
||||
export default function WebsitesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState('');
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [preferredCertificateId, setPreferredCertificateId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ManagedDomainItem | null>(null);
|
||||
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: domainsQueryKey,
|
||||
queryFn: () => WebsiteService.list(),
|
||||
const zonesQuery = useQuery({
|
||||
queryKey: zoneQueryKey,
|
||||
queryFn: () => ZoneService.list(),
|
||||
});
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: certificatesQueryKey,
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => WebsiteService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('网站已删除');
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
const domains = useMemo(() => domainsQuery.data ?? [], [domainsQuery.data]);
|
||||
const certificates = useMemo(
|
||||
() => certificatesQuery.data ?? [],
|
||||
[certificatesQuery.data],
|
||||
);
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((item) => [item.id, item])),
|
||||
[certificates],
|
||||
);
|
||||
const zones = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
const list = zonesQuery.data ?? [];
|
||||
if (!keyword) {
|
||||
return list;
|
||||
}
|
||||
return list.filter((zone) => zone.domain.toLowerCase().includes(keyword));
|
||||
}, [search, zonesQuery.data]);
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-4 py-6 px-1">
|
||||
<div className="flex items-center justify-between gap-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">网站</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新增网站
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
新增 Zone
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold">网站列表</CardTitle>
|
||||
<CardDescription>查看网站绑定的证书、启用状态和更新时间。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{domainsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Globe} description="加载网站列表中..." />
|
||||
) : domainsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<ErrorInline
|
||||
message={getErrorMessage(domainsQuery.error)}
|
||||
onRetry={() => void domainsQuery.refetch()}
|
||||
className="justify-center"
|
||||
/>
|
||||
</div>
|
||||
) : domains.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="暂无网站,点击右上角「新增网站」开始录入。"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{domains.map((domain) => {
|
||||
const certificate = domain.cert_id
|
||||
? (certificateMap.get(domain.cert_id) ?? null)
|
||||
: null;
|
||||
const matchType = getMatchTypeMeta(domain.domain);
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="搜索 Zone 根域"
|
||||
placeholder="搜索 Zone 根域"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={domain.id}
|
||||
className="rounded-lg border bg-card p-4 space-y-3"
|
||||
{zonesQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Globe} description="加载 Zone 列表中..." />
|
||||
) : zonesQuery.isError ? (
|
||||
<div className="rounded-lg border border-dashed p-8">
|
||||
<ErrorInline
|
||||
className="justify-center"
|
||||
message={getErrorMessage(zonesQuery.error)}
|
||||
onRetry={() => void zonesQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : zones.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description={
|
||||
search
|
||||
? '未找到匹配的 Zone。'
|
||||
: '暂无 Zone,点击右上角「新增 Zone」开始录入。'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-dashed shadow-none">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Table className="w-full min-w-full caption-bottom text-sm">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="h-8 w-[90px] whitespace-nowrap py-2">
|
||||
ID
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[180px] whitespace-nowrap py-2">
|
||||
根域
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[100px] whitespace-nowrap py-2">
|
||||
域名数
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[140px] whitespace-nowrap py-2">
|
||||
创建时间
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[140px] whitespace-nowrap py-2">
|
||||
更新时间
|
||||
</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 h-8 w-[90px] bg-background py-2 text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{zones.map((zone) => (
|
||||
<TableRow
|
||||
key={zone.id}
|
||||
className="group cursor-pointer border-dashed hover:bg-muted/30"
|
||||
onClick={() => router.push(`/websites/${zone.id}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-sm font-semibold truncate">{domain.domain}</h2>
|
||||
<WebsiteStatusBadge label={matchType.label} tone={matchType.tone} />
|
||||
<WebsiteStatusBadge
|
||||
label={domain.enabled ? '启用' : '停用'}
|
||||
tone={domain.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{domain.remark || '暂无备注'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
绑定证书:
|
||||
{certificate
|
||||
? buildCertificateLabel(certificate)
|
||||
: '未绑定证书'}
|
||||
</p>
|
||||
<TableCell className="py-1 font-mono text-[11px] text-muted-foreground">
|
||||
{zone.id}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[11px] font-medium">
|
||||
{zone.domain}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[11px] text-muted-foreground">
|
||||
{zone.domain_count ?? 0}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(zone.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(zone.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-background py-1 text-center"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/websites/${zone.id}`}>
|
||||
<Eye className="size-3" />
|
||||
<span className="sr-only">管理</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
管理 Zone
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href={`/websites/detail?id=${domain.id}`}>
|
||||
详情
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive"
|
||||
onClick={() => setDeleteTarget(domain)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WebsiteEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
certificates={certificates}
|
||||
certificatesLoading={certificatesQuery.isLoading}
|
||||
preferredCertificateId={preferredCertificateId}
|
||||
onRequestImportCertificate={() => setImportOpen(true)}
|
||||
onSaved={(_, mode) => {
|
||||
setPreferredCertificateId(null);
|
||||
toast.success(mode === 'create' ? '网站已创建' : '网站已更新');
|
||||
}}
|
||||
/>
|
||||
|
||||
<CertificateImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={(certificate) => {
|
||||
setPreferredCertificateId(certificate.id);
|
||||
toast.success(`证书 ${certificate.name} 已导入,可直接用于当前网站`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除网站 {deleteTarget?.domain} 吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ZoneEditorDialog open={editorOpen} onOpenChange={setEditorOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, var(--font-noto-sans-sc), sans-serif;
|
||||
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', var(--font-noto-sans-sc), monospace;
|
||||
--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);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -25,6 +25,7 @@
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
@@ -61,6 +62,7 @@
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(98.5% 0% 0);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
@@ -95,6 +97,7 @@
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(98.5% 0% 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
@@ -193,4 +196,4 @@
|
||||
80% { transform: rotate(-1deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: bool
|
||||
queryFn: () => AuthService.getAuthSources(),
|
||||
})
|
||||
|
||||
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, true)
|
||||
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, false)
|
||||
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true)
|
||||
|
||||
const loginMutation = useMutation({
|
||||
|
||||
@@ -68,7 +68,7 @@ export function RegisterForm() {
|
||||
|
||||
const emailRegisterEnabled = configBool(publicConfigQuery.data?.email_register_verification_enabled, false)
|
||||
|
||||
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, true)
|
||||
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, false)
|
||||
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true)
|
||||
|
||||
const [capScope, setCapScope] = useState<'send_email_code' | 'register'>('send_email_code')
|
||||
|
||||
@@ -245,7 +245,7 @@ export const apiSections: PolicySection[] = [
|
||||
"registration_enabled": "false",
|
||||
"password_login_enabled": "true",
|
||||
"password_register_enabled": "false",
|
||||
"cap_login_enabled": "true",
|
||||
"cap_login_enabled": "false",
|
||||
"oidc_login_enabled": "true"
|
||||
}
|
||||
}`}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {AnimatePresence, motion} from "motion/react"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
import {Card, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {ArrowRight, ExternalLink, FileText, HelpCircle, Layers, Shield, ShieldCheck, Terminal, User} from "lucide-react"
|
||||
import {ArrowRight, ExternalLink, HelpCircle, Layers, Shield, ShieldCheck, Terminal, User} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export function HomeMain() {
|
||||
@@ -22,21 +22,11 @@ export function HomeMain() {
|
||||
bgColor: "bg-blue-500/10",
|
||||
borderColor: "hover:border-blue-500/30",
|
||||
},
|
||||
{
|
||||
title: "开发接口文档",
|
||||
description: "查看开放平台的 RESTful 接口规格说明",
|
||||
icon: FileText,
|
||||
url: "/docs/api",
|
||||
color: "text-emerald-500",
|
||||
bgColor: "bg-emerald-500/10",
|
||||
borderColor: "hover:border-emerald-500/30",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "使用文档",
|
||||
description: "学习如何集成 API 及日常操作帮助指南",
|
||||
icon: HelpCircle,
|
||||
url: "/docs/how-to-use",
|
||||
url: "https://open-flare.pages.dev/",
|
||||
color: "text-purple-500",
|
||||
bgColor: "bg-purple-500/10",
|
||||
borderColor: "hover:border-purple-500/30",
|
||||
|
||||
@@ -194,7 +194,7 @@ export function AccessTokenMain() {
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
{accessTokensQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-indigo-500" />
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (accessTokensQuery.data ?? []).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
@@ -231,7 +231,7 @@ export function AccessTokenMain() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleCopyText(token.masked_token, token.id)}
|
||||
>
|
||||
{copiedId === token.id ? (
|
||||
@@ -245,7 +245,7 @@ export function AccessTokenMain() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/5 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleRotateToken(token.id, token.name)}
|
||||
disabled={rotateTokenMutation.isPending}
|
||||
>
|
||||
@@ -305,7 +305,7 @@ export function AccessTokenMain() {
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="rounded-xl border border-dashed focus:border-indigo-500 focus:ring-0 focus-visible:ring-0"
|
||||
className="rounded-xl border border-dashed focus:border-primary focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
{user?.is_admin && (
|
||||
@@ -375,15 +375,15 @@ export function AccessTokenMain() {
|
||||
{newCreatedToken && (
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 明文 Token 文本框 */}
|
||||
<div className="flex items-center gap-2 rounded-xl bg-indigo-500/5 border border-dashed border-indigo-500/30 p-3">
|
||||
<span className="font-mono text-xs select-all break-all flex-1 text-indigo-600 font-semibold leading-relaxed">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-primary/5 border border-dashed border-primary/30 p-3">
|
||||
<span className="font-mono text-xs select-all break-all flex-1 text-primary font-semibold leading-relaxed">
|
||||
{newCreatedToken.token}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-8 rounded-lg shrink-0 border-dashed text-indigo-500 hover:bg-indigo-500/10 hover:border-indigo-500/30 transition-colors"
|
||||
className="size-8 rounded-lg shrink-0 border-dashed text-primary hover:bg-primary/10 hover:border-primary/30 transition-colors"
|
||||
onClick={() => handleCopyText(newCreatedToken.token, 9999)}
|
||||
>
|
||||
{copiedId === 9999 ? (
|
||||
|
||||
@@ -88,7 +88,7 @@ export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps)
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<KeyRound className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -125,9 +125,9 @@ export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps)
|
||||
</div>
|
||||
|
||||
{/* 当前白名单列表 */}
|
||||
<div className="rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm space-y-3">
|
||||
<div className="rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-4 text-indigo-500" />
|
||||
<ShieldAlert className="size-4 text-primary" />
|
||||
<span className="font-medium text-sm text-foreground">当前免鉴权列表</span>
|
||||
</div>
|
||||
|
||||
@@ -137,14 +137,14 @@ export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps)
|
||||
<Badge
|
||||
key={type}
|
||||
variant="secondary"
|
||||
className="px-2.5 py-1 text-xs gap-1.5 flex items-center bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 dark:bg-indigo-500/20 border border-indigo-500/20"
|
||||
className="px-2.5 py-1 text-xs gap-1.5 flex items-center bg-primary/10 text-primary dark:bg-primary/20 border border-primary/20"
|
||||
>
|
||||
{availableTypes.find(t => t.value === type)?.label || type}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveType(type)}
|
||||
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending}
|
||||
className="rounded-full outline-hidden hover:bg-indigo-500/20 p-0.5 text-indigo-600 dark:text-indigo-400 cursor-pointer disabled:cursor-not-allowed"
|
||||
className="rounded-full outline-hidden hover:bg-primary/20 p-0.5 text-primary cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
|
||||
@@ -4,8 +4,6 @@ import {ComponentType, useMemo} from "react"
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query"
|
||||
import {
|
||||
Bell,
|
||||
Code,
|
||||
CreditCard,
|
||||
Database,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
@@ -16,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"
|
||||
@@ -40,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 },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -61,9 +78,7 @@ const MENU_GROUPS: MenuGroup[] = [
|
||||
{
|
||||
name: "文档菜单",
|
||||
items: [
|
||||
{ path: "/admin/demo", label: "规范示例", description: "内置 UI 组件与设计规范的展示、调试与参考", icon: Code },
|
||||
{ path: "/docs/api", label: "接口文档", description: "系统 Swagger 交互式 API 接口文档", icon: CreditCard },
|
||||
{ path: "/docs/how-to-use", label: "使用文档", description: "面向开发与运营的部署使用指南", icon: FileText },
|
||||
{ path: "https://open-flare.pages.dev/", label: "使用文档", description: "面向开发与运营的部署使用指南", icon: FileText },
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -112,7 +127,7 @@ export function OtherTab({ configs }: OtherTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<LayoutList className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -141,11 +156,11 @@ export function OtherTab({ configs }: OtherTabProps) {
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1.5 flex-1 min-w-0 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4 text-indigo-500 shrink-0" />}
|
||||
{Icon && <Icon className="size-4 text-primary shrink-0" />}
|
||||
<span className="font-medium text-sm text-foreground truncate">{item.label}</span>
|
||||
{isReadOnly && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0">
|
||||
@@ -171,8 +186,8 @@ export function OtherTab({ configs }: OtherTabProps) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="p-3.5 rounded-lg border border-dashed border-indigo-500/20 bg-indigo-500/5 flex items-start gap-2.5">
|
||||
<Info className="size-4 text-indigo-500 shrink-0 mt-0.5" />
|
||||
<div className="p-3.5 rounded-lg border border-dashed border-primary/20 bg-primary/5 flex items-start gap-2.5">
|
||||
<Info className="size-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="font-semibold text-foreground">安全提示:</span>
|
||||
为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。
|
||||
|
||||
@@ -275,7 +275,7 @@ export function ProfileMain() {
|
||||
>
|
||||
<Avatar className="size-20 md:size-24 border-2 border-primary/10 shadow-md">
|
||||
<AvatarImage src={user.avatar_url} alt={user.nickname || user.username} />
|
||||
<AvatarFallback className="text-2xl bg-indigo-600 text-white font-bold">
|
||||
<AvatarFallback className="text-2xl bg-primary text-primary-foreground font-bold">
|
||||
{(user.nickname || user.username).slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -342,7 +342,7 @@ export function ProfileMain() {
|
||||
href={user.website.startsWith("http") ? user.website : `http://${user.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:underline"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{user.website}
|
||||
</a>
|
||||
@@ -473,7 +473,7 @@ export function ProfileMain() {
|
||||
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="border-b pb-4 flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Link2 className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -487,7 +487,7 @@ export function ProfileMain() {
|
||||
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">已绑定账号</h3>
|
||||
{externalAccountBindingsQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-indigo-500" />
|
||||
<Loader2 className="size-4 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (externalAccountBindingsQuery.data ?? []).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
@@ -534,7 +534,7 @@ export function ProfileMain() {
|
||||
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">绑定新账号</h3>
|
||||
{publicAuthSourcesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-indigo-500" />
|
||||
<Loader2 className="size-4 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (publicAuthSourcesQuery.data ?? []).length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
@@ -543,16 +543,16 @@ export function ProfileMain() {
|
||||
key={source.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex items-center justify-between w-full border border-dashed rounded-xl px-3 py-2 text-left font-normal text-xs hover:bg-indigo-500/5 hover:text-indigo-500 hover:border-indigo-500/30 transition-all duration-300 group h-8"
|
||||
className="flex items-center justify-between w-full border border-dashed rounded-xl px-3 py-2 text-left font-normal text-xs hover:bg-primary/5 hover:text-primary hover:border-primary/30 transition-all duration-300 group h-8"
|
||||
onClick={() => {
|
||||
void bindSourceMutation.mutateAsync(source.name)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 className="size-3 text-muted-foreground group-hover:text-indigo-500" />
|
||||
<Link2 className="size-3 text-muted-foreground group-hover:text-primary" />
|
||||
<span>绑定 {source.display_name || source.name}</span>
|
||||
</div>
|
||||
<ArrowRight className="size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-indigo-500" />
|
||||
<ArrowRight className="size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-primary" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -583,7 +583,7 @@ export function ProfileMain() {
|
||||
>
|
||||
<Avatar className="size-20 border-2 border-primary/5 shadow-md">
|
||||
<AvatarImage src={avatarUrl} alt={nickname} />
|
||||
<AvatarFallback className="text-xl bg-indigo-600 text-white font-bold">
|
||||
<AvatarFallback className="text-xl bg-primary text-primary-foreground font-bold">
|
||||
{(nickname || "U").slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -245,7 +245,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Settings className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -263,11 +263,11 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4 text-indigo-500" />}
|
||||
{Icon && <Icon className="size-4 text-primary" />}
|
||||
<span className="font-medium text-sm text-foreground">{item.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed pr-2">{item.description}</p>
|
||||
@@ -283,11 +283,11 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
|
||||
{/* 登录状态保持时间 (选择后立即更改) */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm md:col-span-2"
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm md:col-span-2"
|
||||
>
|
||||
<div className="space-y-1 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-4 text-indigo-500" />
|
||||
<Clock className="size-4 text-primary" />
|
||||
<span className="font-medium text-sm text-foreground">登录状态保持时间</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed pr-2">
|
||||
@@ -340,7 +340,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Globe className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -390,7 +390,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`text-xs px-2.5 py-1 rounded-lg border font-medium hidden sm:inline-block ${
|
||||
source.client_secret_configured
|
||||
? "bg-indigo-500/5 text-indigo-500 border-indigo-500/10"
|
||||
? "bg-primary/5 text-primary border-primary/10"
|
||||
: "bg-rose-500/5 text-rose-500 border-rose-500/10"
|
||||
}`}>
|
||||
{source.client_secret_configured ? "Secret 已配置" : "Secret 未配置"}
|
||||
@@ -407,7 +407,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
||||
className="size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedSource(source)
|
||||
setAuthSourceModalOpen(true)
|
||||
@@ -458,7 +458,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Shield className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -193,7 +193,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 text-primary">
|
||||
<Server className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -310,7 +310,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function TemplatesManager() {
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -154,14 +154,14 @@ export function TemplatesManager() {
|
||||
{(templatesQuery.data ?? []).map((tmpl) => (
|
||||
<div
|
||||
key={tmpl.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-foreground">{tmpl.name}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
|
||||
tmpl.is_system
|
||||
? "bg-indigo-500/10 text-indigo-500 border-indigo-500/20"
|
||||
? "bg-primary/10 text-primary border-primary/20"
|
||||
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
}`}>
|
||||
{tmpl.is_system ? "系统内置" : "自定义"}
|
||||
@@ -171,7 +171,7 @@ export function TemplatesManager() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
标识符: <span className="font-mono text-indigo-500 bg-indigo-500/5 px-1.5 py-0.5 rounded">{tmpl.key}</span>
|
||||
标识符: <span className="font-mono text-primary bg-primary/5 px-1.5 py-0.5 rounded">{tmpl.key}</span>
|
||||
{tmpl.subject && ` · 主题: ${tmpl.subject}`}
|
||||
</div>
|
||||
{tmpl.description && (
|
||||
@@ -185,7 +185,7 @@ export function TemplatesManager() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
||||
className="size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
onClick={() => handleOpenEdit(tmpl)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
|
||||
@@ -126,10 +126,10 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link href="/docs/api">
|
||||
<Link href="https://open-flare.pages.dev/" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="secondary" className="rounded-full text-xs hover:bg-muted-foreground/10">
|
||||
<Book className="w-3 h-3" />
|
||||
API 文档
|
||||
使用文档
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -43,8 +43,7 @@ export const FooterSection = React.memo(function FooterSection({ className }: Fo
|
||||
<div className="lg:col-span-1">
|
||||
<h3 className="font-semibold text-foreground mb-6">开发</h3>
|
||||
<ul className="space-y-4 text-sm text-muted-foreground">
|
||||
<li><FooterLink href="/docs/how-to-use">快速开始</FooterLink></li>
|
||||
<li><FooterLink href="/docs/api">API 文档</FooterLink></li>
|
||||
<li><FooterLink href="https://open-flare.pages.dev/">使用文档</FooterLink></li>
|
||||
<li><FooterLink href="https://github.com/Rain-kl/OpenFlare">源代码</FooterLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/docs/how-to-use" className="w-full sm:w-auto">
|
||||
<Link href="https://open-flare.pages.dev/" target="_blank" rel="noopener noreferrer" className="w-full sm:w-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,8 +47,6 @@ import {
|
||||
ArrowUpRight,
|
||||
Bell,
|
||||
ChevronDown,
|
||||
Code,
|
||||
CreditCard,
|
||||
Database,
|
||||
FileQuestionMark,
|
||||
FileText,
|
||||
@@ -79,9 +77,7 @@ const data = {
|
||||
{ title: "系统设置", url: "/admin/settings", icon: Settings },
|
||||
],
|
||||
document: [
|
||||
{ title: "规范示例", url: "/admin/demo", icon: Code },
|
||||
{ title: "接口文档", url: "/docs/api", icon: CreditCard, external: true },
|
||||
{ title: "使用文档", url: "/docs/how-to-use", icon: FileText, external: true },
|
||||
{ title: "使用文档", url: "https://open-flare.pages.dev/", icon: FileText, external: true },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -281,7 +277,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-2" />
|
||||
<DropdownMenuItem onClick={() => {
|
||||
router.push("/docs/how-to-use")
|
||||
window.open("https://open-flare.pages.dev/", "_blank", "noopener,noreferrer")
|
||||
handleCloseSidebar()
|
||||
}}>
|
||||
<FileQuestionMark className="mr-2 size-4" />
|
||||
|
||||
@@ -473,7 +473,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-bold data-[active=true]:text-[#6366F1] data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-bold data-[active=true]:text-sidebar-primary data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import {createContext, useCallback, useContext, useEffect, useRef, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import type {AdminUser, CreateUserRequest, ListUsersRequest} from "@/lib/services/admin"
|
||||
import type {AdminUser, CreateUserRequest, ListUsersRequest, UpdateUserRequest} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
|
||||
/** 用户列表查询参数 */
|
||||
@@ -12,6 +12,7 @@ export interface UserQueryParams {
|
||||
page_size: number
|
||||
user_id?: string
|
||||
username?: string
|
||||
email?: string
|
||||
status?: 'all' | 'active' | 'inactive'
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ interface AdminUsersContextState {
|
||||
pageSize: number
|
||||
searchUserId: string
|
||||
searchUsername: string
|
||||
searchEmail: string
|
||||
statusFilter: 'all' | 'active' | 'inactive'
|
||||
|
||||
// Actions
|
||||
@@ -34,6 +36,7 @@ interface AdminUsersContextState {
|
||||
setPageSize: (size: number) => void
|
||||
setSearchUserId: (userId: string) => void
|
||||
setSearchUsername: (username: string) => void
|
||||
setSearchEmail: (email: string) => void
|
||||
setStatusFilter: (status: 'all' | 'active' | 'inactive') => void
|
||||
|
||||
fetchUsers: (force?: boolean) => Promise<void>
|
||||
@@ -41,6 +44,7 @@ interface AdminUsersContextState {
|
||||
getUserDetail: (id: string) => Promise<AdminUser>
|
||||
updateUserStatus: (user: AdminUser) => Promise<void>
|
||||
createUser: (req: CreateUserRequest) => Promise<AdminUser>
|
||||
updateUser: (id: string, req: UpdateUserRequest) => Promise<void>
|
||||
deleteUser: (user: AdminUser) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -60,9 +64,11 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [searchUserId, setSearchUserId] = useState("")
|
||||
const [searchUsername, setSearchUsername] = useState("")
|
||||
const [searchEmail, setSearchEmail] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
|
||||
const [debouncedSearchUserId, setDebouncedSearchUserId] = useState("")
|
||||
const [debouncedSearchUsername, setDebouncedSearchUsername] = useState("")
|
||||
const [debouncedSearchEmail, setDebouncedSearchEmail] = useState("")
|
||||
|
||||
// Cache
|
||||
const cacheRef = useRef<Record<string, { data: AdminUser[], total: number, timestamp: number }>>({})
|
||||
@@ -73,12 +79,17 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchUserId(searchUserId)
|
||||
setDebouncedSearchUsername(searchUsername)
|
||||
if (searchUserId !== debouncedSearchUserId || searchUsername !== debouncedSearchUsername) {
|
||||
setDebouncedSearchEmail(searchEmail)
|
||||
if (
|
||||
searchUserId !== debouncedSearchUserId ||
|
||||
searchUsername !== debouncedSearchUsername ||
|
||||
searchEmail !== debouncedSearchEmail
|
||||
) {
|
||||
setPage(1) // Reset to page 1 on search change
|
||||
}
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchUserId, searchUsername, debouncedSearchUserId, debouncedSearchUsername])
|
||||
}, [searchUserId, searchUsername, searchEmail, debouncedSearchUserId, debouncedSearchUsername, debouncedSearchEmail])
|
||||
|
||||
const generateCacheKey = (params: UserQueryParams) => {
|
||||
return JSON.stringify(params)
|
||||
@@ -90,6 +101,7 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
page_size: pageSize,
|
||||
user_id: debouncedSearchUserId || undefined,
|
||||
username: debouncedSearchUsername || undefined,
|
||||
email: debouncedSearchEmail || undefined,
|
||||
status: statusFilter
|
||||
}
|
||||
|
||||
@@ -127,7 +139,8 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
page,
|
||||
page_size: pageSize,
|
||||
user_id: debouncedSearchUserId || undefined,
|
||||
username: debouncedSearchUsername || undefined
|
||||
username: debouncedSearchUsername || undefined,
|
||||
email: debouncedSearchEmail || undefined
|
||||
}
|
||||
|
||||
const response = await services.adminUser.listUsers(requestParams)
|
||||
@@ -160,7 +173,7 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [page, pageSize, debouncedSearchUserId, debouncedSearchUsername, statusFilter])
|
||||
}, [page, pageSize, debouncedSearchUserId, debouncedSearchUsername, debouncedSearchEmail, statusFilter])
|
||||
|
||||
// Removed auto-fetch useEffect. Consumer (UsersManager) should trigger fetch.
|
||||
|
||||
@@ -224,6 +237,20 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
}
|
||||
|
||||
const updateUser = async (id: string, req: UpdateUserRequest) => {
|
||||
try {
|
||||
await services.adminUser.updateUser(id, req)
|
||||
setUsers(prev => prev.map(u =>
|
||||
u.id === id ? { ...u, ...req } : u
|
||||
))
|
||||
cacheRef.current = {}
|
||||
toast.success('更新用户信息成功')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '更新用户信息失败')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const value = {
|
||||
users,
|
||||
total,
|
||||
@@ -233,17 +260,20 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
||||
pageSize,
|
||||
searchUserId,
|
||||
searchUsername,
|
||||
searchEmail,
|
||||
statusFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setSearchUserId,
|
||||
setSearchUsername,
|
||||
setSearchEmail,
|
||||
setStatusFilter,
|
||||
fetchUsers,
|
||||
refresh,
|
||||
getUserDetail,
|
||||
updateUserStatus,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser
|
||||
}
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQuery} from '@tanstack/react-query';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {AdminStatusService} from '@/lib/services/admin';
|
||||
import type {AppUpdateStatus} from '@/lib/services/admin/types';
|
||||
import {StatusService} from '@/lib/services/openflare';
|
||||
|
||||
export const openflarePublicStatusQueryKey = ['openflare', 'public-status'] as const;
|
||||
|
||||
export const adminUpdateStatusQueryKey = ['admin', 'update'] as const;
|
||||
|
||||
export function useOpenFlareServerUpgrade({
|
||||
open,
|
||||
canUpgrade,
|
||||
}: {
|
||||
open: boolean;
|
||||
canUpgrade: boolean;
|
||||
}) {
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
const upgradeReloadStartedRef = useRef(false);
|
||||
const upgradeReloadTimerRef = useRef<number | null>(null);
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: openflarePublicStatusQueryKey,
|
||||
queryFn: () => StatusService.getPublicStatus(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const updateQuery = useQuery({
|
||||
queryKey: adminUpdateStatusQueryKey,
|
||||
queryFn: () => AdminStatusService.getUpdateStatus(),
|
||||
enabled: open && canUpgrade,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const scheduleUpgradePageReload = useCallback(() => {
|
||||
if (upgradeReloadStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
upgradeReloadStartedRef.current = true;
|
||||
setFeedback('服务升级已进入重启阶段,页面将在服务恢复后自动刷新。');
|
||||
|
||||
const reloadWhenServerReady = async () => {
|
||||
try {
|
||||
await StatusService.getPublicStatus();
|
||||
window.location.reload();
|
||||
} catch {
|
||||
upgradeReloadTimerRef.current = window.setTimeout(reloadWhenServerReady, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
upgradeReloadTimerRef.current = window.setTimeout(reloadWhenServerReady, 1200);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (upgradeReloadTimerRef.current !== null) {
|
||||
window.clearTimeout(upgradeReloadTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
mutationFn: () => AdminStatusService.applyUpdate(),
|
||||
onSuccess: () => {
|
||||
scheduleUpgradePageReload();
|
||||
setFeedback('升级包已校验完成,服务正在重启。');
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback(error instanceof Error ? error.message : '升级失败,请稍后重试。');
|
||||
},
|
||||
});
|
||||
|
||||
const resetTransientState = useCallback(() => {
|
||||
setFeedback(null);
|
||||
upgradeReloadStartedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
resetTransientState();
|
||||
if (canUpgrade) {
|
||||
void updateQuery.refetch();
|
||||
}
|
||||
}, [canUpgrade, resetTransientState, updateQuery]);
|
||||
|
||||
const handleCheckRelease = useCallback(() => {
|
||||
setFeedback(null);
|
||||
if (!canUpgrade) {
|
||||
return;
|
||||
}
|
||||
void updateQuery.refetch();
|
||||
}, [canUpgrade, updateQuery]);
|
||||
|
||||
const handleUpgrade = useCallback(() => {
|
||||
setFeedback(null);
|
||||
upgradeMutation.mutate();
|
||||
}, [upgradeMutation]);
|
||||
|
||||
const update = updateQuery.data;
|
||||
const releaseErrorMessage =
|
||||
feedback ||
|
||||
(updateQuery.isError
|
||||
? updateQuery.error instanceof Error
|
||||
? updateQuery.error.message
|
||||
: '版本检查失败,请稍后重试。'
|
||||
: undefined);
|
||||
|
||||
const currentVersion = statusQuery.data?.version || update?.current_version || 'unknown';
|
||||
|
||||
return {
|
||||
currentVersion,
|
||||
update,
|
||||
releaseErrorMessage,
|
||||
isInitialLoading: updateQuery.isLoading && !updateQuery.data && canUpgrade,
|
||||
isChecking: updateQuery.isFetching,
|
||||
isUpgrading: upgradeMutation.isPending,
|
||||
handleOpen,
|
||||
handleCheckRelease,
|
||||
handleUpgrade,
|
||||
};
|
||||
}
|
||||
|
||||
export type {AppUpdateStatus};
|
||||
@@ -27,6 +27,7 @@ export type {
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
UpdateUserStatusRequest,
|
||||
UpdateUserRequest,
|
||||
SystemStatus,
|
||||
AppUpdateStatus,
|
||||
Schedule,
|
||||
@@ -40,4 +41,4 @@ export type {
|
||||
StorageDriver,
|
||||
StorageConfig,
|
||||
ObjectStorageConfig,
|
||||
} from './types';
|
||||
} from './types';
|
||||
|
||||
@@ -310,6 +310,8 @@ export interface ListUsersRequest {
|
||||
user_id?: string;
|
||||
/** 用户名前缀过滤(可选) */
|
||||
username?: string;
|
||||
/** 邮箱前缀过滤(可选) */
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,6 +350,20 @@ export interface CreateUserRequest {
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户请求参数
|
||||
*/
|
||||
export interface UpdateUserRequest {
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
/** 邮箱 */
|
||||
email: string;
|
||||
/** 是否管理员 */
|
||||
is_admin?: boolean;
|
||||
/** 密码(可选重置) */
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证源信息
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CreateUserRequest,
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
UpdateUserRequest,
|
||||
UpdateUserStatusRequest,
|
||||
} from './types';
|
||||
|
||||
@@ -29,4 +30,8 @@ export class AdminUserService extends BaseService {
|
||||
static async deleteUser(id: string): Promise<void> {
|
||||
return this.delete<void>(`/users/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateUser(id: string, request: UpdateUserRequest): Promise<void> {
|
||||
return this.put<void>(`/users/${id}`, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -146,6 +148,7 @@ export type {
|
||||
ListUsersResponse,
|
||||
UpdateUserStatusRequest,
|
||||
CreateUserRequest,
|
||||
UpdateUserRequest,
|
||||
SystemStatus,
|
||||
AppUpdateStatus,
|
||||
Schedule,
|
||||
@@ -186,7 +189,6 @@ export {
|
||||
ApplyLogService,
|
||||
DashboardService,
|
||||
WafService,
|
||||
WebsiteService,
|
||||
TlsCertificateService,
|
||||
DnsAccountService,
|
||||
PagesService,
|
||||
@@ -200,6 +202,7 @@ export {
|
||||
export type {
|
||||
NodeItem,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteZoneDomain,
|
||||
ProxyRouteConfigSection,
|
||||
ConfigVersionSummary,
|
||||
ConfigVersionDetail,
|
||||
@@ -210,7 +213,6 @@ export type {
|
||||
WAFIPGroup,
|
||||
WAFRuleGroup,
|
||||
WAFSiteRuleGroups,
|
||||
ManagedDomainItem,
|
||||
TlsCertificateItem,
|
||||
DnsAccountItem,
|
||||
PagesProject,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user