Compare commits

..

1 Commits

Author SHA1 Message Date
ryan 9f79fb9969 chore(release): v3.5.4
### ✨ 新功能
- 控制台接入中英双语(next-intl,无 URL 语言前缀):默认中文,可在顶栏或「外观设置」切换,选择写入 cookie 后刷新生效。

### 🛠 修复
- 修复在网站列表中删除已加入 Cloudflare 指向分组的域名后,访问 Cloudflare 指向分组详情报错「Cloudflare 资源不存在」的问题。
- 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。
- 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。
- 修复静态导出(build:embed)部署下切换语言无效的问题:此前页面在构建时固定为默认中文,运行时不再读取 `NEXT_LOCALE`;现在客户端会按 cookie/浏览器语言重新解析并切换界面语言与 `html lang`。
- 修复 frpc 子进程在被杀后孤儿进程继续持有管道导致退出阻塞的问题。

### 💄 其他/体验
- 前端使用 `next/font` 自托管 Inter 字体,并忽略浏览器扩展改写 `body` 属性引起的 hydration 警告。
2026-08-29 18:34:07 +08:00
495 changed files with 5575 additions and 27288 deletions
-4
View File
@@ -33,7 +33,3 @@ jobs:
- name: Run ESLint
working-directory: frontend
run: npx eslint . --max-warnings 0
- name: Check translation catalogs and runtime language
working-directory: frontend
run: node scripts/merge-i18n-fragments.mjs && pnpm check:i18n
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
// if any unchecked, fail the workflow
if (unchecked.length > 0) {
core.setFailed(`Complete all PR checklist items. Unchecked items:\n${unchecked.join('\n')}`);
core.setFailed(`PR checklist 未全部勾选,请确保所有 checklist 项都已勾选。未勾选项如下:\n${unchecked.join('\n')}`);
} else {
console.log('all checklist items are checked.');
}
-1
View File
@@ -87,4 +87,3 @@ profile.cov
# i18n 生成物(由 scripts/merge-i18n-fragments.mjs 从 fragments 生成)
frontend/messages/zh-CN.json
frontend/messages/en.json
frontend/messages/ru.json
+1 -1
View File
@@ -45,7 +45,7 @@ code-check:
exit 1; \
fi
golangci-lint run
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm check:i18n && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
build-backend:
@echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..."
+69 -145
View File
@@ -2,10 +2,12 @@
# OpenFlare
**Next-Gen Distributed Reverse Proxy & Edge Security Gateway for OpenResty / Nginx**
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
OpenFlare is an open-source CDN orchestration and edge security platform. It supports reverse proxy, centralized configuration synchronization, in-network tunneling (Tunnels), dynamic WAF protection, and CC defense challenges.
</div>
<p align="center">
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
@@ -18,120 +20,65 @@
</a>
</p>
</div>
> [!WARNING]
> After the first login with the `admin` user, you must change the default password `12345678`.
>
> The BETA version is a temporary product in the development and testing stage and may have unknown issues. It should not be used in production environments.
---
## Documentation
## 📖 What is OpenFlare?
**https://openflare.fyrn.link**
**OpenFlare** is a modern, open-source **distributed reverse proxy and edge security gateway platform** designed for multi-server infrastructures and edge clusters.
Common entry points:
Rather than a simple single-host admin panel, OpenFlare empowers traditional OpenResty / Nginx nodes with **centralized cluster orchestration, dynamic zero-reload sync, line-rate edge WAF defense, and secure in-network tunneling**:
* [Quick Start](https://openflare.fyrn.link/guide/quick-start)
* [Deployment Guide](https://openflare.fyrn.link/deployment/deployment)
* [Configuration Reference](https://openflare.fyrn.link/reference/configuration)
* [System Design](https://openflare.fyrn.link/design/)
* ⚡ **Zero-Reload Dynamic Orchestration**: Centralized control plane manages any number of edge nodes. Synchronize rule diffs in milliseconds with zero Nginx reload disruptions.
* 🛡️ **Built-in Edge Security & Anti-CC (Turnstile Alternative)**: Comprehensive WAF rule engine, MaxMind GeoIP regional ACLs, and **client-side cryptographic Proof of Work (PoW) challenges** to eliminate botnets and CC attacks at line rate.
* 🚇 **Zero-Trust Tunnels (Cloudflare Tunnel Alternative)**: Securely publish private homelab and internal web services to edge proxies via lightweight `openflared` clients without public IPs or open inbound ports.
* 📦 **Pages Edge Hosting**: Host static sites and SPAs pulled directly from GitHub Releases or uploaded bundles, distributed across edge nodes with instant rollbacks and fallback routing.
* 🔒 **Automated Certificates & SSO**: Automatic multi-domain Let's Encrypt certificates via ACME and enterprise single sign-on with GitHub OAuth and standard OIDC.
## Core Capabilities
---
* **Reverse Proxy Configuration Management**: Uses website rules as the aggregation boundary, supports multi-domain binding and multi-upstream load balancing, and centrally manages reverse proxy configurations for all OpenResty nodes.
* **Secure In-Network Tunneling (Tunnels)**: Open-source version of Cloudflare Tunnels. No public IP or exposed inbound ports are required. Securely reverse-proxy internal web services to the public internet through Relay relay nodes and OpenFlared clients.
* **Edge WAF Security Protection**: Provides global and custom rule groups, supports manual/auto/subscription-type IP groups, MaxMind GeoIP national-level geographic access control, IP group member Checksum differential synchronization (no Nginx reload required), and custom blocking responses.
* **CC Defense and Human-Computer Challenge (PoW)**: Built-in high-performance client-side cryptography Proof of Work challenge (similar to Turnstile). Secures high-speed interception and blocking of zombie networks and crawlers at the gateway edge.
* **Pages Static Hosting**: Supports uploading or synchronizing pre-built artifacts from restricted Remote URLs or public GitHub Release assets. GitHub latest can be checked periodically and optionally auto-published. All sources are unified to generate immutable deployments, pulled by the edge Agent and served locally by OpenResty, supporting rollbacks, SPA Fallback, and API reverse proxy.
* **TLS Certificate Automation**: Supports dynamic certificate uploads, automatic multi-domain certificate matching and binding, and automatic issuance and renewal of certificates from Let's Encrypt via the ACME protocol.
* **Uptime Kuma Monitoring Synchronization**: Integrated with Uptime Kuma to automatically perform differential synchronization of monitoring site lists, real-time awareness of node availability and service status.
* **SSO Single Sign-On**: Supports GitHub OAuth and standard OIDC protocol for seamless integration with enterprise identity providers to achieve unified login.
* **Unified Observability**: Aggregates node request metrics, real-time access log details, host and Nginx resource snapshots, health events, and network fluctuation replenishment buffers.
## 🌐 Flexible Deployment: Standalone or Behind Cloudflare
OpenFlare is designed to operate seamlessly either as an independent edge gateway or in harmony with Cloudflare and public CDNs:
### 1. Standalone Edge Gateway (Private CDN)
Ideal for developers managing multi-region VPS clusters or homelabs:
* Deploy your own high-availability CDN and edge proxy cluster with 100% data and privacy ownership.
* Protect origin sites with built-in Turnstile-like PoW human challenges, dynamic WAF, and automated SSL certs without expensive enterprise CDN security plans.
### 2. Behind Cloudflare (Hardened Origin Shield)
Functions as an intelligent **origin shield and routing gateway** behind Cloudflare:
* **Leaked Real IP Protection**: If attackers bypass Cloudflare and directly hit your origin's public IP address, OpenFlare's edge WAF and PoW challenges will immediately intercept and block the attack.
* **Full Private Log & Metric Retention**: Overcomes Cloudflare Free tier limitations by retaining comprehensive, searchable request logs and operational metrics locally.
* **Unified In-Network Routing**: Ingest incoming Cloudflare traffic and dynamically route it across internal backends or private homelab services through secure tunnels.
---
## 📊 Comparison: Traditional Nginx vs NPM vs OpenFlare
| Feature | Traditional Nginx (`nginx.conf`) | Nginx Proxy Manager (NPM) | **OpenFlare** |
| :--- | :--- | :--- | :--- |
| **Cluster Architecture** | Manual per-host editing via SSH | Single node only; no cluster sync | **Native Distributed Control Plane + Edge Agents** |
| **Configuration Sync** | Requires `nginx -s reload` (connection churn) | Reloads on every change | **Shared memory dynamic sync (Zero-Reload)** |
| **Anti-CC / Bot Protection** | Basic `limit_req` rate limiting only | Not supported | **Built-in Turnstile-like PoW Client Challenge** |
| **In-Network Tunnels** | Requires separate third-party tools (frp) | Not supported | **Built-in OpenFlared Tunnels (Zero inbound ports)** |
| **WAF & Threat Defense** | Requires manual ModSecurity compilation | Basic exploit rules only | **Dynamic WAF, GeoIP, IP list checksum sync** |
| **Static Pages Hosting** | Manual directory copying and setup | Static file proxying only | **Automated GitHub Release deployment & rollbacks** |
| **Works with Cloudflare** | Manual `set_real_ip_from` list maintenance | Complex proxy header tuning | **Native real IP restoration, ideal origin shield** |
---
## 🏗️ Architecture
```mermaid
flowchart TD
subgraph Visitors ["Public Visitors & Clients"]
User["Public Users / Browsers"]
CF["Cloudflare CDN (Optional Edge Layer)"]
end
subgraph EdgePlane ["Edge Data Plane (OpenResty Agent Nodes)"]
Edge1["Edge Node A (OpenResty)"]
Edge2["Edge Node B (OpenResty)"]
end
subgraph ControlPlane ["Control Plane"]
Server["OpenFlare Server (Web Dashboard)"]
DB[("PostgreSQL + Redis")]
end
subgraph Backend ["Upstream & Internal Services"]
Origin["Public Origin Web Server"]
subgraph PrivateNet ["Private Network / Homelab"]
NAS["Internal Apps / NAS"]
Client["OpenFlared Tunnel Client"]
end
end
User -->|Direct HTTPS Request| Edge1
User -->|Proxied via Cloudflare| CF
CF -->|Secure Upstream Traffic| Edge2
Server <-->|Dynamic Sync / Metrics| Edge1
Server <-->|Dynamic Sync / Metrics| Edge2
Server --- DB
Edge1 -->|Reverse Proxy| Origin
Edge2 -->|Reverse Proxy| Origin
Client <-->|Encrypted Tunnel| Edge1
NAS --- Client
```
---
## 🖥️ UI Preview
## Interface Preview
### Dashboard Overview
![OpenFlare Dashboard Overview](./docs/assets/readme/dashboard-overview.png)
### Edge WAF & Access Rules
![OpenFlare WAF Protection](./docs/assets/readme/waf.png)
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### Domain & Traffic Analytics
![OpenFlare Domain Overview](./docs/assets/readme/domain_overview.png)
### Access Logs
---
![OpenFlare version release](./docs/assets/readme/domain_overview.png)
## ⚡ Quick Start
### WAF Protection
### 1. Launch OpenFlare Server
![OpenFlare version release](./docs/assets/readme/waf.png)
Deploy the control plane using `docker-compose`:
## Quick Start
### Hardware Configuration Recommendations
| Component | Minimum Hardware Requirements | Recommended Hardware Requirements | Notes |
|------------------------|-----------------------------------|-----------------------------------|-------|
| **Server Control Plane** | 1 CPU core / 2 GB RAM / 20 GB disk | 2 CPU cores / 4 GB RAM / 50 GB+ disk | Disk usage should be expanded reasonably based on access log retention duration and concurrent traffic |
| **Agent Data Plane** | 1 CPU core / 512 MB RAM / 2 GB disk | 2 CPU cores / 2 GB RAM / 10 GB+ disk | Expanded based on OpenResty concurrent proxy connections and WAF interception processing |
| **Relay Relay Node** | 1 CPU core / 1 GB RAM / 5 GB disk | 2 CPU cores / 2 GB RAM / 20 GB disk | frps transmission relay throughput is mainly limited by bandwidth and CPU throughput |
| **OpenFlared Client** | 1 CPU core / 256 MB RAM / 1 GB disk | 1 CPU core / 512 MB RAM / 5 GB disk | Runs independently on the internal network with extremely low resource consumption; only network throughput needs to be guaranteed |
### 1. Start the Server
Use `docker-compose`:
```bash
# Download environment template
# Download environment variable template and create .env file
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
```
@@ -183,69 +130,46 @@ services:
start_period: 5s
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
```
* **Dashboard URL**: `http://localhost:3000`
* **Default Credentials**: `admin` / `12345678`
See the [deployment documentation](https://openflare.fyrn.link/deployment/deployment) for details.
> [!WARNING]
> Change the default password `12345678` immediately upon first login.
> The BETA version is in active development and testing; avoid using it in mission-critical production environments without regular backups.
Access address: `http://localhost:3000`
### 2. Connect Edge Agent Node
Default account:
Run the Agent container on any edge server (ensure ports 80/443 are open):
* Username: `admin`
* Password: `12345678`
### 2. Install Agent
Before installing the Agent, first install OpenResty on the node or use the built-in OpenResty Agent Docker image.
You can copy the installation command from the control panel's **Nodes Management -> Details -> Node Information -> Node ID and Deployment**, or use the script below:
#### Docker Deployment
Docker deployment can directly run the Agent image:
```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 openflare-agent-pages:/data/var/lib/openflare/pages \
-e OPENFLARE_SERVER_URL=http://<YOUR_SERVER_IP>:3000 \
-e OPENFLARE_AGENT_TOKEN=<YOUR_AGENT_TOKEN> \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
```
*(Copy the node token directly from Node Management in the dashboard)*
---
## 📚 Documentation
Official Documentation: **[https://openflare.fyrn.link](https://openflare.fyrn.link)**
* [Quick Start](https://openflare.fyrn.link/guide/quick-start)
* [Deployment Guide](https://openflare.fyrn.link/deployment/deployment)
* [Configuration Reference](https://openflare.fyrn.link/reference/configuration)
* [System Design](https://openflare.fyrn.link/design/)
<details>
<summary><b>🔍 Minimum & Recommended Hardware Specifications</b></summary>
| Component | Minimum Requirements | Recommended Requirements | Note |
| :--- | :--- | :--- | :--- |
| **Server Control Plane** | 1 CPU / 2 GB RAM / 20 GB Disk | 2 CPU / 4 GB RAM / 50 GB+ Disk | Scale disk based on access log retention |
| **Agent Data Plane** | 1 CPU / 512 MB RAM / 2 GB Disk | 2 CPU / 2 GB RAM / 10 GB+ Disk | Scale based on concurrent connections & WAF load |
| **Relay Node** | 1 CPU / 1 GB RAM / 5 GB Disk | 2 CPU / 2 GB RAM / 20 GB Disk | Mainly bound by bandwidth and CPU network throughput |
| **OpenFlared Client** | 1 CPU / 256 MB RAM / 1 GB Disk | 1 CPU / 512 MB RAM / 5 GB Disk | Extremely lightweight client |
</details>
---
## ⚖️ Trademark Disclaimer
OpenFlare is an independent, community-driven open-source project. It is not affiliated with, endorsed by, or sponsored by Cloudflare, Inc. Cloudflare and related trademarks are the property of their respective owners.
---
## 📄 License
## Open Source License
This project is licensed under the [Apache License 2.0](./LICENSE).
## ⭐ Star History
## Star History
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
<picture>
+67 -146
View File
@@ -2,10 +2,11 @@
# OpenFlare
**新一代分布式反向代理与边缘安全网关**
*Next-Gen Distributed Reverse Proxy & Edge Gateway for OpenResty / Nginx*
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
**[简体中文](./README.zh-CN.md) | [English](./README.md)**
OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、集中式配置同步、内网穿透(Tunnels)、动态 WAF 防护以及防 CC 挑战。
</div>
<p align="center">
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
@@ -19,120 +20,65 @@
</a>
</p>
</div>
> [!WARNING]
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
>
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
---
## 文档
## 📖 什么是 OpenFlare?
**https://openflare.fyrn.link**
**OpenFlare** 是一套面向多服务器与分布式集群的**新一代开源反向代理与边缘安全网关平台**。
常用入口:
它并非简单的单机管理面板,而是将传统的 OpenResty / Nginx 升级为具备**多节点集群化编排、动态热更新、边缘 WAF 防护以及内网穿透能力**的现代化边缘网络基础设施:
* [快速开始](https://openflare.fyrn.link/guide/quick-start)
* [部署说明](https://openflare.fyrn.link/deployment/deployment)
* [配置项参考](https://openflare.fyrn.link/reference/configuration)
* [系统设计](https://openflare.fyrn.link/design/)
* ⚡ **告别手写配置与 Reload 抖动**:单一控制面板管理任意数量的边缘节点,配置差分毫秒级热同步(Zero-Reload),业务流量平滑无感。
* 🛡️ **开箱即用的边缘安全防御**:内置 WAF 规则引擎、国家级 GeoIP 访问控制,以及**类似 Cloudflare Turnstile 的客户端密码学 PoW 防 CC 人机验证(5 秒盾)**,在边缘秒级阻断僵尸网络。
* 🚇 **原生安全内网穿透(Tunnels)**:无需公网 IP 和复杂路由器端口映射,通过轻量级 `openflared` 客户端打通加密隧道,安全将内网 Web 服务接入边缘网关并享受全套安全防护。
* 📦 **静态 Pages 边缘托管**:支持从 GitHub Release 产物或直传静态包全自动部署至各边缘节点本地分发,支持多版本回滚与 SPA 路由。
* 🔒 **自动化证书与统一认证**:ACME 协议全自动申请与续期 Let's Encrypt 多域名证书,支持 GitHub OAuth 和标准 OIDC 企业单点登录。
## 核心能力
---
* **反代配置管理**:以网站规则为聚合边界,支持多域名绑定与多上游负载均衡,统一管理所有 OpenResty 节点的反代配置。
* **安全内网穿透(Tunnels)**:开源版的 Cloudflare Tunnels。无须公网 IP 或暴露入向端口,通过 Relay 中继节点与 OpenFlared 客户端安全反向穿透内网 Web 服务至公网。
* **边缘 WAF 安全防护**:提供全局与自定义规则组,支持手动/自动/订阅型 IP 组、MaxMind GeoIP 国家级地域准入、IP 组成员 Checksum 差分同步(无需 Nginx 重载)以及自定义拦截响应。
* **防 CC 与人机挑战(PoW)**:内置高性能客户端密码学 Proof of Work 挑战(类似 Turnstile),在网关边缘秒级拦截并阻断僵尸网络与爬虫。
* **Pages 静态托管**:支持上传或从受限 Remote URL、公开 GitHub Release asset 同步预构建产物;GitHub latest 可定时检查并可选自动发布。所有来源统一生成不可变部署,由边缘 Agent 拉取并通过 OpenResty 本地提供服务,支持回滚、SPA Fallback 与 API 反向代理。
* **TLS 证书自动化**:支持证书动态上传、多域名证书自动匹配绑定,以及通过 ACME 协议向 Let's Encrypt 自动申请与续期证书。
* **Uptime Kuma 监控同步**:与 Uptime Kuma 集成,自动差分同步监控站点列表,实时感知节点存活与服务可用状态。
* **SSO 单点登录**:支持 GitHub OAuth 与标准 OIDC 协议,无缝接入企业身份提供商实现统一登录。
* **统一观测**:聚合节点请求指标、实时访问日志明细、宿主机与 Nginx 资源快照、健康事件以及网络波动补传缓冲。
## 🌐 灵活的部署模式:独立自建 vs 协同 Cloudflare
## 界面预览
OpenFlare 既可以作为独立的边缘网关使用,也可以与 Cloudflare 等公网 CDN 完美协作互补:
### 仪表盘总览
### 1. 独立自建模式 (Standalone Edge Gateway)
适用于多台 VPS、跨地域云服务器或 Homelab:
* 自主搭建私有 CDN 与反向代理集群,数据和流量 100% 本地掌控。
* 拥有完整的边缘防 CC 人机验证、自建 SSL 证书管理与内网穿透能力,无需采购昂贵的商业安全套餐。
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### 2. Cloudflare 源站防护盾模式 (Behind Cloudflare / Origin Shield)
作为 Cloudflare 背后坚固的**源站集群网关**:
* **防范源站真实 IP 泄露**:即使攻击者通过历史解析或漏洞绕过 Cloudflare 直连你的源站真实 IP,OpenFlare 边缘的 WAF 与 PoW 人机挑战仍会在第一时间将其阻断。
* **全量私有访问日志沉淀**:弥补商业 CDN 免费版日志保留极短、明细不足的痛点,全量审计请求日志与监控指标。
* **统一内网与跨服路由**:由 OpenFlare 统一承接来自 Cloudflare 的流量,智能分发至源站多服务或内网穿透应用。
### 访问日志
---
![OpenFlare version release](./docs/assets/readme/domain_overview.png)
## 📊 横向对比:传统 Nginx vs Nginx Proxy Manager vs OpenFlare
### WAF 防护
| 维度 | 传统 Nginx (`nginx.conf`) | Nginx Proxy Manager (NPM) | **OpenFlare** |
| :--- | :--- | :--- | :--- |
| **集群架构** | 每台机器独立维护,频繁 SSH | 仅支持单机,无法多节点集群协同 | **原生分布式控制面 + 边缘 Agent 集群** |
| **配置生效方式** | 每次需 `nginx -s reload`,连接可能抖动 | 频繁 Reload | **共享内存/差分热同步,Zero-Reload** |
| **防 CC / 爬虫人机验证** | 仅支持简单的 `limit_req` 频率限制 | 不支持 | **内置类似 Turnstile 的 PoW 客户端挑战** |
| **内网穿透 (Tunnels)** | 需繁琐集成第三方程式 (如 frp) | 不支持 | **内置 OpenFlared 穿透隧道,开箱即用** |
| **WAF 与安全防护** | 需复杂编译配置 ModSecurity | 仅基础 Exploits 规则 | **动态 WAF、国家级 GeoIP、IP 组差分准入** |
| **静态 Pages 托管** | 手动上传并配置静态目录 | 仅静态文件代理 | **自动化拉取 GitHub Release,版本回滚** |
| **配合 Cloudflare** | 手动配置 `set_real_ip_from` 极其繁琐 | 需繁琐设置代理头部 | **原生还原真实 IP,完美充当源站护盾** |
![OpenFlare version release](./docs/assets/readme/waf.png)
---
## 快速开始
## 🏗️ 架构拓扑
### 硬件配置推荐
```mermaid
flowchart TD
subgraph Visitors ["公网访客与客户端"]
User["公网访客 / 移动端"]
CF["Cloudflare CDN (可选代理层)"]
end
| 组件 | 最低硬件配额 | 推荐硬件配额 | 说明 |
| --- |-------------------------------| --- | --- |
| **Server 控制面** | 1 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 磁盘用量需根据访问日志留存时长与并发流量合理扩容 |
| **Agent 数据面** | 1 核 CPU / 512 MB 内存 / 2 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 10 GB+ 磁盘 | 根据 OpenResty 的并发代理连接量与 WAF 拦截处理扩容 |
| **Relay 中继节点**| 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | frps 传输中继吞吐量主要受带宽与 CPU 吞吐能力限制 |
| **OpenFlared 客户端**| 1 核 CPU / 256 MB 内存 / 1 GB 磁盘 | 1 核 CPU / 512 MB 内存 / 5 GB 磁盘 | 独立运行于内网,自身资源占用极小,保障网络吞吐即可 |
subgraph EdgePlane ["边缘数据面 (OpenResty Agent 节点)"]
Edge1["边缘节点 A (OpenResty)"]
Edge2["边缘节点 B (OpenResty)"]
end
### 1. 启动 Server
subgraph ControlPlane ["控制面 (Control Plane)"]
Server["OpenFlare Server (Web 控制台)"]
DB[("PostgreSQL + Redis")]
end
subgraph Backend ["源站与内网服务"]
Origin["公网源站 Web 服务"]
subgraph PrivateNet ["私有网络 / Homelab"]
NAS["私有应用 / NAS"]
Client["OpenFlared 穿透客户端"]
end
end
User -->|直连 HTTPS 访问| Edge1
User -->|经 Cloudflare 代理| CF
CF -->|安全回源| Edge2
Server <-->|差分热同步 / 指标上报| Edge1
Server <-->|差分热同步 / 指标上报| Edge2
Server --- DB
Edge1 -->|反向代理| Origin
Edge2 -->|反向代理| Origin
Client <-->|加密穿透隧道| Edge1
NAS --- Client
```
---
## 🖥️ 界面预览
### 仪表盘与实时观测
![OpenFlare 仪表盘总览](./docs/assets/readme/dashboard-overview.png)
### 边缘 WAF 与访问控制
![OpenFlare WAF 防护](./docs/assets/readme/waf.png)
### 域名与流量分析
![OpenFlare 域名访问概览](./docs/assets/readme/domain_overview.png)
---
## ⚡ 快速开始
### 1. 启动 Server 控制端
使用 `docker-compose` 快速启动控制面:
使用 docker-compose
```bash
# 获取环境变量模板
# 下载环境变量模板并创建 .env 文件
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
```
@@ -184,71 +130,46 @@ services:
start_period: 5s
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
```
* **访问控制台**:`http://localhost:3000`
* **默认账号密码**:`admin` / `12345678`
详细部署说明见 [部署文档](https://openflare.fyrn.link/deployment/deployment)。
> [!WARNING]
> 初次登录后,请务必在后台第一时间修改默认密码 `12345678`。
> BETA 版本处于开发与测试阶段,生产环境请提前做好数据备份。
访问地址:`http://localhost:3000`
### 2. 部署边缘 Agent 节点
默认账号:
在任意作为边缘代理的服务器上执行(需开放 80/443 端口):
* 用户名:`admin`
* 密码:`12345678`
### 2. 安装 Agent
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
#### Docker 部署
Docker 部署可直接运行 Agent 镜像:
```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 openflare-agent-pages:/data/var/lib/openflare/pages \
-e OPENFLARE_SERVER_URL=http://<YOUR_SERVER_IP>:3000 \
-e OPENFLARE_AGENT_TOKEN=<YOUR_AGENT_TOKEN> \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
ghcr.io/rain-kl/openflare-agent:latest
```
*(Token 可在管理后台的「节点管理 -> 详情 -> 节点信息」中直接一键生成与复制)*
---
## 开源协议
## 📚 官方文档
本项目采用 [Apache License 2.0](./LICENSE) 开源。
完整配置与进阶部署详见官方文档:**[https://openflare.fyrn.link](https://openflare.fyrn.link)**
* [快速入门指南](https://openflare.fyrn.link/guide/quick-start)
* [生产部署与架构说明](https://openflare.fyrn.link/deployment/deployment)
* [配置项详细参考](https://openflare.fyrn.link/reference/configuration)
* [系统底层设计](https://openflare.fyrn.link/design/)
<details>
<summary><b>🔍 查看各组件最低与推荐硬件配置要求</b></summary>
| 组件 | 最低硬件配置 | 推荐硬件配置 | 说明 |
| :--- | :--- | :--- | :--- |
| **Server 控制面** | 1 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 根据访问日志存储时长和吞吐量灵活规划 |
| **Agent 数据面** | 1 核 CPU / 512 MB 内存 / 2 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 10 GB+ 磁盘 | 取决于并发代理量与 WAF 规则量 |
| **Relay 中继节点** | 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 主要受网络带宽与并发吞吐限制 |
| **OpenFlared 客户端** | 1 核 CPU / 256 MB 内存 / 1 GB 磁盘 | 1 核 CPU / 512 MB 内存 / 5 GB 磁盘 | 极低内存占用,保证网络连接畅通即可 |
</details>
---
## ⚖️ 商标免责声明 (Trademark Disclaimer)
OpenFlare is an independent, community-driven open-source project. It is not affiliated with, endorsed by, or sponsored by Cloudflare, Inc. Cloudflare and related trademarks are the property of their respective owners.
OpenFlare 是一个由社区驱动的独立开源项目,与 Cloudflare, Inc. 无任何附属、认可或赞助关系。Cloudflare 及其相关商标均为其各自持有者的财产。
---
## 📄 开源协议
本项目基于 [Apache License 2.0](./LICENSE) 协议开源。
## ⭐ Star History
## Star History
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
<picture>
+8 -8
View File
@@ -26,14 +26,11 @@ RUN --mount=type=cache,target=/go/pkg/mod \
RUN apk add --no-cache bash curl \
&& bash scripts/fetch-agent-geoip-mmdb.sh
FROM openresty/openresty:alpine-slim
FROM openresty/openresty:alpine
RUN apk add --no-cache ca-certificates tzdata libmaxminddb su-exec libcap \
RUN apk add --no-cache ca-certificates tzdata perl libmaxminddb su-exec libcap \
&& ln -sf /usr/lib/libmaxminddb.so.0 /usr/lib/libmaxminddb.so \
&& apk add --no-cache --virtual .build-deps perl curl \
&& opm get anjia0532/lua-resty-maxminddb \
&& apk del .build-deps \
&& rm -rf /root/.opm \
&& addgroup -S openflare \
&& adduser -S -G openflare -H -h /data -s /sbin/nologin openflare \
&& mkdir -p /etc/openflare /data/etc/openflare \
@@ -45,10 +42,13 @@ ENV OPENFLARE_OPENRESTY_PATH=openresty \
COPY --from=builder /build/bin/openflare-agent /usr/local/bin/openflare-agent
# Default agent paths: data_dir/etc/openflare/GeoLite2-*.mmdb
COPY --chown=openflare:openflare --chmod=644 --from=builder /build/dist/geoip/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-Country.mmdb
COPY --chown=openflare:openflare --chmod=644 --from=builder /build/dist/geoip/GeoLite2-City.mmdb /data/etc/openflare/GeoLite2-City.mmdb
COPY --from=builder /build/dist/geoip/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-Country.mmdb
COPY --from=builder /build/dist/geoip/GeoLite2-City.mmdb /data/etc/openflare/GeoLite2-City.mmdb
RUN chown openflare:openflare /data/etc/openflare/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-City.mmdb \
&& chmod 644 /data/etc/openflare/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-City.mmdb
COPY --chmod=755 scripts/agent-entrypoint.sh /usr/local/bin/openflare-agent-entrypoint.sh
COPY scripts/agent-entrypoint.sh /usr/local/bin/openflare-agent-entrypoint.sh
RUN chmod +x /usr/local/bin/openflare-agent-entrypoint.sh
EXPOSE 80 443 18081
ENTRYPOINT ["/usr/local/bin/openflare-agent-entrypoint.sh"]
+8 -84
View File
@@ -8,92 +8,8 @@ sidebar: false
格式基于 [Keep a Changelog](http://keepachangelog.com/),版本号遵循 [语义化版本](http://semver.org/)。
## 重大变更
> [!IMPORTANT]
>
>3.5.1 版本解耦了日志存储,ClickHouse 变为可选项,如果想切换数据库, 点击 「任务管理」 -> 「切换日志数据库」任务,按提示迁移数据并切换主库。
>
## [Unreleased]
## [v3.5.9] - 2026-11-07
### ✨ 新功能
- 新增完整俄语界面,支持俄语浏览器语言识别、原生语言名称、日期与数字格式;内置错误页及离线页跟随界面语言。
### 🛠 修复
- 修复 Agent 自动注册时节点不断重复创建的问题;响应丢失或重试后仍复用原节点,不会覆盖管理员修改。
- 修复 IP 组按规则自动抓取时会收录内网、回环及链路本地等地址的问题;历史抓取结果会在下次同步时清理。
- 修复通知收件人误判,以及推送模板变量与富文本标签丢失的问题。
- 选择非默认语言时,页面首次绘制前即应用所选语言,避免刷新或跳转时短暂显示英文。
- 界面脚本加载失败时提供同语言的重新加载入口,避免出现空白页。
### ⚡️ 优化与改进
- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,大规模节点场景下列表加载和轮询更轻量;详情页改为单节点查询。
- Agent、Relay 与 Flared 的自动更新检查改为每 6 小时一次,检查或下载更新失败时自动延长间隔重试(最长 3 小时),避免频繁请求触发 GitHub 限流;手动触发的立即更新不受限制。
- SQLite 数据库读写并发性能提升,数据库导出可完整包含最新已提交的数据。
### 💄 其他/体验
- 界面默认语言调整为英文,已有语言偏好保持不变;语言选择器固定显示各语言的原生名称。
- 统一并校对 Server、Agent、任务详情、通知与内置模板的英文文案,窄屏下 WAF 标题和通知模板说明的换行更合理。
## [v3.5.8] - 2026-10-08
### ✨ 新功能
- 在「系统管理 → 系统配置 → 业务配置」新增可信代理范围设置,默认信任 Cloudflare 官方网段,支持追加、覆盖或关闭;识别出的访客真实 IP 会用于访问日志、WAF 判断和上游请求,并兼容已有自定义 OpenResty 主模板。
### 🛠 修复
- 修复 Casdoor 等第三方认证源使用字符串用户 ID 时登录回调失败的问题;自动注册不会再与已有本地用户或其他认证源账号发生 ID 冲突,已绑定账号不受影响。
- 修复 Agent 在 OpenResty 运行状态异常时可能重复启动并导致端口占用的问题;现可恢复已有运行实例,避免节点配置应用失败。
## [v3.5.7] - 2026-10-07
### ✨ 新功能
- Cloudflare 指向分组新增自定义 DNS 目标,可直接填写域名或 IP;原有节点绑定方式仍为默认选项。
- Cloudflare 节点分组会在主节点离线或应用失败时自动切换至备用节点,并在主节点恢复后切回;分组详情支持批量开启橙云。
- 代理路由详情支持单独关闭 HTTP/2,默认仍启用;关闭后该路由生成的 HTTPS 配置不再启用 HTTP/2。
### 🛠 修复
- 节点配置应用失败后,Agent 会按指数退避自动强制重试,最长间隔为 5 分钟;成功后停止重试,重启后也会恢复未完成的重试。
### ⚡️ 优化与改进
- 优化常见代理访问的响应压缩与上传处理效率,同时保留流式上传的兼容选项。
### 💄 其他/体验
- Cloudflare 分组成员操作改为图标按钮,减少操作栏占用空间。
- 性能设置的代理选项新增请求体缓冲开关,默认开启;流式上传场景可关闭。
## [v3.5.6] - 2026-10-01
### ✨ 新功能
- Cloudflare 指向分组:支持将已添加的域名在不同分组之间移动,自动排队更新远程 DNS 指向。
- Cloudflare 指向分组:详情页支持批量勾选域名进行批量移动与批量移出操作。
- WAF IP 组:在查看 IP 组弹窗中新增即时搜索功能,支持快速过滤和定位 IP 地址。
### 🛠 修复
- 修复 WebDAV 文件存储配置「基础路径」后,上传的文件无法正常访问或删除的问题(路径被重复拼接);升级后历史文件记录同样可正常读写。
- 修复本地存储读取历史记录中路径以斜杠开头的文件时报错的问题,现可正常读取。
## [v3.5.5] - 2026-09-19
### 🛠 修复
- 修复 Cloudflare 指向分组引用的节点已被删除时,分组列表/详情接口整体返回「Cloudflare 资源不存在」的问题;现会跳过缺失节点并继续返回其余分组。
- 修复静态导出部署下访问 Cloudflare 指向分组详情(`/cloudflare/groups/{id}`,id 不为 1)会跳回首页并触发 React hydration 报错的问题。
### ⚡️ 优化与改进
- 优化 openflare-agent Docker 镜像体积:精简运行时依赖并消除离线 IP 库冗余层,镜像总体积从 500MB+ 缩减至约 150MB。
## [v3.5.4] - 2026-08-29
### ✨ 新功能
@@ -109,6 +25,14 @@ sidebar: false
### 💄 其他/体验
- 前端使用 `next/font` 自托管 Inter 字体,并忽略浏览器扩展改写 `body` 属性引起的 hydration 警告。
## 重大变更
> [!IMPORTANT]
>
>3.5.1 版本解耦了日志存储,ClickHouse 变为可选项,如果想切换数据库, 点击 「任务管理」 -> 「切换日志数据库」任务,按提示迁移数据并切换主库。
>
## [v3.5.3] - 2026-08-13
### 新增
+1 -1
View File
@@ -2,7 +2,7 @@ import {type DefaultTheme, defineAdditionalConfig} from 'vitepress'
export default defineAdditionalConfig({
description:
'OpenFlare 是新一代分布式反向代理与边缘安全网关,为多节点 OpenResty / Nginx 提供集群编排、Zero-Reload 动态配置同步、边缘 WAF、PoW 防 CC 盾与内网穿透。',
'OpenFlare 是轻量、自托管的 OpenResty 控制面,用于管理反向代理、配置发布、节点同步、TLS 证书与基础观测。',
themeConfig: {
nav: nav(),
+1 -3
View File
@@ -156,6 +156,4 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/unin
| --- |---------------------------------------------------------------------------------------------------------|
| `agent_token 和 discovery_token 不能同时为空` | 检查 `agent.json` 至少配置了一个 Token |
| 节点一直离线 | 在 Agent 节点执行 `curl -I http://your-server:3000`,确认 Server 地址可达 |
| 发布后重复失败 | Agent 会按指数退避自动强制同步,初始间隔 10 秒,最长间隔 5 分钟,直到目标版本应用成功;回滚至旧配置也会继续重试。可在节点详情页点击「强制同步」立即触发 |
| PID 文件为空或失效,但端口仍被占用 | Agent 会核对 OpenResty master 的进程身份,恢复 PID 文件并重载现有进程;按配置路径和进程父子关系识别本实例,其他 nginx 实例不会阻塞启动。若出现多个匹配的 master,会报告错误并拒绝重复启动 |
| OpenResty 重启等待超时 | 重启会先发送优雅退出信号,最多等待 10 秒让本实例旧 master 和 worker 退出(持续跟踪已识别的 worker,即使其被重新托管);超时会保留错误,不会启动第二个实例。检查仍未结束的连接及进程后再重试 |
| 发布后重复失败 | Agent 会阻断同一 `version + checksum` 的重复应用;在节点详情页点击「强制同步」,或重新发布新版本 |
+4 -17
View File
@@ -32,28 +32,15 @@ Agent 主要由以下核心子模块组成,共同配合完成其完整的生
---
## 节点列表与详情查询
节点管理页使用管理员接口 `GET /api/v1/d/nodes/page`,返回 `data: {total, results, page, page_size}`。默认每页 20 条,最大 100 条;按数据库 ID 降序排列,可通过 `search`(名称、节点标识或 IP 的字面子串,最多 256 字节)、`status` 和 `node_type` 进行服务端筛选。`pending` 表示尚未上报心跳,最近 60 秒内上报的节点为 `online`,其余为 `offline`;筛选和返回状态使用同一时间基准。未设置类型的旧节点按 `edge_node` 处理。搜索保留原始 Unicode 字符的字面匹配,同时兼容 ASCII 大小写折叠;SQLite 不保证完整的 Unicode 大小写不敏感匹配。前端使用相同的 256 字节 UTF-8 上限校验去除首尾空白后的搜索词,超限时保留上次有效查询并显示提示,缩短输入后恢复搜索。
配置下发记录仅为当前页节点批量加载;详情页通过 `GET /api/v1/d/nodes/{id}` 获取单个节点及相同的下发元数据。列表与详情每 15 秒刷新,在后台、离线或请求出错时暂停定时刷新,可通过窗口聚焦、网络恢复或手动刷新重试。筛选或每页数量变化后回到第一页,删除末页最后一条记录后自动回到有效页。同一筛选条件下切页时保留已加载的表格与总数,页码仍显示已加载页,等待响应期间禁用节点编辑、删除和详情入口;改变筛选条件时不会显示上一筛选的节点,请求失败时可手动重试。
为兼容现有集成和节点选择器,原 `GET /api/v1/d/nodes` 仍返回完整数组,未改变含义;选择器尚未迁移为分页搜索。仪表盘仍为全局统计和地图加载全部节点,并使用现有缓存;本次没有截断全局总数或地图数据。分页计数和当前页是独立查询,节点并发增删时可能短暂不一致,下一次刷新会更新。普通列表和类型筛选利用主键及类型索引,状态筛选可使用心跳时间索引;字面子串搜索仍可能扫描匹配候选,未宣称 B-tree 索引能加速任意子串。
---
## 与 Server 的交互链路
Agent 在生命周期中主要通过 **基于 Token 的自动注册** 和 **心跳/WebSocket 双通道** 与控制面通信。
### 1. 自动注册流程
若 Agent 启动时本地 `agent.json` 的 `agent_token` 为空,但配置了 `discovery_token`,将触发自动注册流程:
1. Agent 在本地状态文件原子保存随机 256 位 `registration_key`,再向控制面 `/api/v1/agent/nodes/register` 发送注册请求,携带该重试凭证、本地硬件摘要、IP 及主机名。支持目录刷盘的平台会在文件刷盘及重命名后刷新所在目录;目录刷盘失败会返回错误,已有凭证也必须在重试时确认目录刷盘成功后才能发送。Windows 保留文件刷盘与替换,但目录持久化仅为尽力而为;上述行为针对所在目录已存在的文件更新,不保证所有文件系统或突然断电场景下的完整持久性。该凭证仅用于注册,不进入心跳、WebSocket 或观测数据,必须与节点 Token 一样保密。
2. Server 校验当前 `discovery_token` 有效后,从重试凭证使用不同的哈希域分别生成 `NodeID` 与专属 Token。相同凭证的重复或并发请求只保留一个节点,不按 IP、主机名或公开 `node_id` 合并,也不会覆盖管理员修改的名称、升级标志等数据。响应以 `agent_token` 为准,同时保留旧的 `access_token` 字段;新版 Agent 能识别两种字段,值冲突时拒绝响应。
3. Agent 原子保存服务端分配的节点 ID 和配置 Token,全部写入成功后才切换运行时鉴权并清空配置中的 `discovery_token`,后续通信均使用节点专属 Token。响应丢失或配置写入失败时,重启仍沿用原重试凭证;状态文件中的重试凭证与节点 ID 不会被旧的后台任务快照覆盖。
4. 注册失败按 3、6、12、24、48、60 秒退避,之后每次至少间隔 60 秒重试(实际请求在下一个心跳定时器周期触发),不再随每次心跳立即注册。成功后重置退避。
若节点 Token 已被清空或更换,旧重试凭证不能取回新 Token 或恢复旧 Token,必须使用管理员分配的节点凭证。物理删除节点不构成永久封禁:持有当前有效 discovery Token 的客户端仍能重新注册。旧版 Agent 不携带 `registration_key`,修复后的响应可使其正常完成注册,但其响应丢失后的重试仍不具备幂等保证。请保留每个实例独立的持久化状态与配置,不要把已初始化的状态目录复制给其他实例。
若 Agent 启动时本地 `agent.json` 的 `access_token` 为空,但配置了 `discovery_token`,将触发自动注册流程:
1. Agent 向控制面 `/api/v1/agent/nodes/register` 发送注册请求,携带本地硬件摘要、IP 及主机名。
2. Server 校验 `discovery_token` 有效后,在数据库生成唯一的 `NodeID` 与专属 `AccessToken`(即 `agent_token`)并返回。
3. Agent 将获取的专用 Token 写入本地配置文件,擦除一次性 `discovery_token`,后续所有的通信均基于专属 `AccessToken` 进行鉴权认证。
### 2. 双通道心跳与同步机制
* **HTTP 轮询通道(兜底与探测)**:Agent 默认按设定的 `heartbeat_interval` 间隔发送 POST 心跳包。上报指标的同时获取当前激活版本的摘要信息(Version & Checksum)。
+5 -6
View File
@@ -2,19 +2,18 @@
你会学到:OpenFlare 是什么、当前稳定能力,以及开发时应遵守的核心产品边界与仓库结构目录分工。
OpenFlare 是一套新一代分布式反向代理与边缘安全网关平台,面向多服务器集群与单团队运维场景。
OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组织内部运维场景。
---
## 项目定位
OpenFlare 适合需要统一管理多台 OpenResty 代理节点、构建自建高可用边缘网络,或为源站搭建坚固防护网关的团队,具备以下定位:
OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具备以下定位:
* **控制与落地分离**:Server 控制面不直接 SSH 到代理节点,而是通过 Agent 主动拉取版本并应用。
* **Zero-Reload 动态配置同步**:基于 Lua 共享内存实现差分热更新,避免传统 Nginx 频繁 reload 导致的连接抖动。
* **一体化边缘安全网关**:在同一个控制面内集成反向代理、TLS 证书自动续期、WAF 防护拦截、Turnstile-like 客户端 PoW 防 CC 盾、内网穿透(Tunnel)以及 Pages 静态网站托管。
* **双重部署生态适应性**:既支持作为独立私有边缘 CDN 运行,亦可作为 Cloudflare 背后坚固的源站防护网关(Origin Shield),防止源站真实 IP 泄露并沉淀全量私有审计日志。
* **不可变配置发布**:采用完整的配置版本进行预览、发布、激活和一键回滚。
* **一体化网关托管**:在同一个控制面内集成网站反代、TLS 证书自动续期申请、WAF 防护拦截、内网穿透(Tunnel)以及 Pages 静态网站托管。
**非本产品定位**:多租户公有云平台、Kubernetes Ingress Controller、服务网格或通用日志平台。
**非本产品定位**:多租户云平台、Kubernetes Ingress Controller、服务网格或通用日志平台。
---
-45
View File
@@ -1,45 +0,0 @@
# 运行时语言与国际化
## 运维输出与界面文案
Server、Agent、后台任务、安装/卸载脚本以及浏览器 PoW 调试日志的固定文案使用英文。语言切换不会改变控制台日志或 OTel 异常信息,便于跨语言检索和排障。任务参数、文件名、用户配置和第三方错误保留原文,可能包含中文。
前端使用 `next-intl`,支持 `en`、`zh-CN` 和 `ru`,默认语言仍为 `en`。语言偏好按 `NEXT_LOCALE` Cookie、浏览器语言、默认语言的顺序解析,俄语区域变体(如 `ru-RU`)归一为 `ru`。文案维护在 `frontend/messages/fragments`,通过 `node scripts/merge-i18n-fragments.mjs` 生成主消息包,禁止手动修改生成的 `en.json`、`zh-CN.json` 和 `ru.json`。技术示例与 HTML 使用原始消息读取,避免把代码中的花括号当作 ICU 参数。
语言选择器始终从各语言自己的资源读取名称:`English`、`中文`、`Русский`,不随当前界面语言翻译语言名称。日期、数字和日历跟随界面语言;语言切换不改变既有时区。俄语数量文案使用 ICU 的 `one`、`few`、`many` 和 `other` 分支。
静态导出仍预渲染英文。`<head>` 中的独立启动脚本在正文绘制前读取语言偏好;若与预渲染语言不同,则暂时隐藏正文,Provider 提交对应语言资源后再显示,避免刷新时闪现英文。脚本不依赖应用 bundle,损坏的 Cookie 会回退到浏览器语言。脚本加载出错或等待超过 10 秒时,显示同语言的独立恢复提示和重新加载按钮;应用恢复后隐藏提示并清理等待监听,避免无限空白页。格式化使用的语言在 Provider 更新上下文前同步,已有数据的首次本地化渲染也使用正确的日期和数字格式。
不在 React 渲染上下文中的浏览器代码(如网络异常和纯表单工具)通过 `frontend/i18n/runtime.ts` 调用同一套 `next-intl` 文案。消息在使用时解析语言,不在模块导入时固定语言。API 客户端发送当前界面语言的 `Accept-Language`。
## 服务端展示边界
`pkg/i18n/locales` 保留内置服务端文案的英文、中文和俄语版本,英文同时是运行时源码中的基准文案。展示接口依次采用 `NEXT_LOCALE` Cookie、`Accept-Language`,没有支持的语言时回退英文。
- 错误中间件先记录原始英文异常及 OTel 状态,再翻译 HTTP 响应中的 `error_msg`。登录流程使用的 `smtp_invalid:` 和 `need_email_code:` 控制前缀保持不变。
- 任务类型、参数说明、执行记录和内置通知元数据使用请求内的副本进行翻译,避免修改共享注册表或持久化记录。
- 默认定时任务名称可翻译;管理员自定义名称、通知接收目标和已保存的消息模板保留原文。
- 通知接收目标只识别既有技术标识 `system`、`0` 和历史别名 `系统`;显示译文 `System`、`Система` 不会扩大保留目标集合,也不会把同名普通用户改投给管理员。
- 新任务执行详情保存英文系统文案。历史中文文本通过已知的完整文案或格式模板在读取时展示为英文,不执行数据库回写。
格式模板只识别已登记的系统消息,并逐字保留捕获到的参数;时间戳也保留。未知消息原样返回。此兼容机制只用于系统拥有的展示字段,不能用来翻译任意用户数据,也不能通过比较翻译后的文本决定业务状态。任务重试和存储配置校验使用错误类型进行分类。
## 持久化兼容性
上传统计原有的六个中文分类名同时是数据库计数器键和 API 分类标识,不能当作标签直接改名。保留这些既有键,前端图表、图例和最大分类说明单独使用语言包标签;无需清空或重建现有统计。
历史 SQL 迁移、测试样例、中文项目文档以及用户已经保存的数据不属于待替换的运行时固定文案。离线页面和通知模板一旦由管理员选择、编辑并保存,就作为用户数据处理,不随界面语言切换而回写。
系统配置描述和内置邮件模板的名称、说明可能来自历史 seed。管理界面仅在配置键或内置模板键已登记、且文本与已知语言默认值完全一致时,使用 `builtin-metadata` 语言分片展示;未知键、自定义描述与非内置模板保持原文。编辑表单保留原始状态,单纯保存其他字段不会把展示翻译写回数据库;邮件主题和正文始终按已保存内容显示。内置源站错误页和离线页在选择模板或空值预览时使用当前语言,已保存的自定义 HTML 保持不变。
系统状态 API 的诊断时长和容量字符串保留英文及技术单位,界面在展示边界转换为当前语言;原始字符串继续用于既有内存计算。数据库计数采用当前 `next-intl` 上下文格式化。日历使用 DayPicker 扩展语言包,同时翻译日期与导航、下拉框、选中日期等无障碍名称。
## 校验与扩展
在仓库根目录运行 `node scripts/audit-non-english.mjs` 可生成完整的非 ASCII 文本清单,默认输出到被 Git 忽略的 `.codex-artifacts/non-english-audit/`。`occurrences.csv` 包含文件、行号、模块、分类和原文;JSON 另含字符位置、Unicode 码点、字符集及跳过的文件;`review-candidates.csv` 单列待审查源码。命令读取所有已跟踪和未被忽略的新文件,不按业务目录名称排除内容,也识别 Unicode 转义。
清单包含标点、表情、注释、文档、语言资源与历史迁移,不表示每个命中都需要替换。依赖、构建输出和私有环境文件不进入清单;二进制文件单独列出,不执行截图 OCR 或解包。生成的 Swagger 和中文文档站配置保留中文,既有上传分类键保留兼容性。字符集也不等同于语言,例如 Han 字符可能属于中文或日文。
`pnpm check:i18n` 校验三个语言包的键、标签和 `$` 变量一致性,并检查 Go、TypeScript/TSX 和安装脚本中的汉字、日文假名、韩文及西里尔字母运行时文本;注释、语言资源、测试样例、六个上传持久化键及通知历史目标别名有明确边界。该检查同时接入 `make code-check` 和现有前端 CI。俄语资源测试还验证命名参数、真实 rich-text 渲染、ICU 格式、复数边界、可复制的限速语法及 HTML 模板占位符,服务端测试验证格式动词、模板变量和枚举值。技术标识(如 `10r/s`、`30m`、`true`、`and`)不随语言翻译。
新增语言时,补齐前端分片、语言配置与 Provider 消息映射,以及服务端同名语言目录项,并验证格式占位符一致。新增系统消息仍以英文作为源文案,在相应语言资源中补齐译文。新增数据协议应使用稳定标识与参数,不再引入以中文显示文案充当持久化键的设计。
+5 -571
View File
@@ -4443,12 +4443,12 @@ const docTemplate = `{
"summary": "注册或发现 Agent 节点",
"parameters": [
{
"description": "节点上报数据;registration_key 为首次注册重试凭证",
"description": "节点上报数据",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest"
"$ref": "#/definitions/agent.NodePayload"
}
}
],
@@ -4464,7 +4464,7 @@ const docTemplate = `{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/agent.RegistrationResponseDocs"
"$ref": "#/definitions/agent.RegistrationResponse"
}
}
}
@@ -6362,247 +6362,6 @@ const docTemplate = `{
}
}
},
"/api/v1/d/cloudflare/groups/{id}/members/batch-move": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-cloudflare"
],
"summary": "批量移动 Cloudflare 指向成员",
"parameters": [
{
"type": "integer",
"description": "原分组 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "批量移动参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/cloudflare.MemberBatchMoveInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/cloudflare/groups/{id}/members/batch-proxy": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-cloudflare"
],
"summary": "批量开启 Cloudflare 橙云",
"parameters": [
{
"type": "integer",
"description": "分组 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "批量开启橙云参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/cloudflare.MemberBatchProxyInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/cloudflare/groups/{id}/members/batch-remove": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-cloudflare"
],
"summary": "批量移出 Cloudflare 指向成员",
"parameters": [
{
"type": "integer",
"description": "分组 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "批量移出参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/cloudflare.MemberBatchRemoveInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/move": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-cloudflare"
],
"summary": "移动 Cloudflare 指向成员到其他分组",
"parameters": [
{
"type": "integer",
"description": "原分组 ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "成员 ID",
"name": "memberId",
"in": "path",
"required": true
},
{
"description": "目标分组参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/cloudflare.MemberMoveInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/cloudflare.MemberItem"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": {
"post": {
"security": [
@@ -7953,169 +7712,6 @@ const docTemplate = `{
}
}
},
"/api/v1/d/nodes/page": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回分页节点和筛选后的总数;旧版 /nodes 列表接口保持兼容,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取分页节点列表",
"parameters": [
{
"type": "integer",
"description": "页码(默认 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页数量(默认 20,最大 100)",
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "名称、节点标识或 IP 的字面子串(最多 256 字节)",
"name": "search",
"in": "query"
},
{
"type": "string",
"description": "pending、online 或 offline",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "edge_node、tunnel_relay 或 tunnel_client",
"name": "node_type",
"in": "query"
}
],
"responses": {
"200": {
"description": "节点分页",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.ListResult"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回指定节点及最新配置下发记录,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取节点详情",
"parameters": [
{
"type": "integer",
"description": "节点 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "节点详情",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.View"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}/agent-release": {
"get": {
"security": [
@@ -14664,7 +14260,7 @@ const docTemplate = `{
}
},
"400": {
"description": "用户名或密码错误",
"description": "用户名或密码错误、帐号已禁用等",
"schema": {
"$ref": "#/definitions/response.Any"
}
@@ -15152,15 +14748,12 @@ const docTemplate = `{
}
}
},
"agent.RegistrationResponseDocs": {
"agent.RegistrationResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"agent_token": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -15508,15 +15101,6 @@ const docTemplate = `{
},
"primary_node_id": {
"type": "integer"
},
"record_content": {
"type": "string"
},
"record_type": {
"type": "string"
},
"target_mode": {
"type": "string"
}
}
},
@@ -15550,56 +15134,11 @@ const docTemplate = `{
"primary_node": {
"$ref": "#/definitions/cloudflare.NodeOption"
},
"record_content": {
"type": "string"
},
"record_type": {
"type": "string"
},
"target_mode": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"cloudflare.MemberBatchMoveInput": {
"type": "object",
"properties": {
"member_ids": {
"type": "array",
"items": {
"type": "integer"
}
},
"target_group_id": {
"type": "integer"
}
}
},
"cloudflare.MemberBatchProxyInput": {
"type": "object",
"properties": {
"member_ids": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"cloudflare.MemberBatchRemoveInput": {
"type": "object",
"properties": {
"member_ids": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"cloudflare.MemberCreateInput": {
"type": "object",
"properties": {
@@ -15646,14 +15185,6 @@ const docTemplate = `{
}
}
},
"cloudflare.MemberMoveInput": {
"type": "object",
"properties": {
"target_group_id": {
"type": "integer"
}
}
},
"cloudflare.MemberUpdateInput": {
"type": "object",
"properties": {
@@ -16459,77 +15990,6 @@ const docTemplate = `{
}
}
},
"github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest": {
"type": "object",
"properties": {
"access_logs": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog"
}
},
"buffered": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord"
}
},
"current_version": {
"type": "string"
},
"edge_health": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth"
},
"ext_version": {
"type": "string"
},
"health_events": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent"
}
},
"host_metrics": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot"
},
"ip": {
"type": "string"
},
"last_error": {
"type": "string"
},
"name": {
"type": "string"
},
"node_id": {
"type": "string"
},
"openresty_message": {
"type": "string"
},
"openresty_status": {
"type": "string"
},
"profile": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile"
},
"registration_key": {
"type": "string"
},
"schema_version": {
"type": "integer"
},
"version": {
"type": "string"
},
"waf_ip_group_checksums": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
"github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup": {
"type": "object",
"properties": {
@@ -17918,26 +17378,6 @@ const docTemplate = `{
}
}
},
"node.ListResult": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"results": {
"type": "array",
"items": {
"$ref": "#/definitions/node.View"
}
},
"total": {
"type": "integer"
}
}
},
"node.ObservabilityView": {
"type": "object",
"properties": {
@@ -19769,9 +19209,6 @@ const docTemplate = `{
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
}
},
"enable_http2": {
"type": "boolean"
},
"enable_https": {
"type": "boolean"
},
@@ -19888,9 +19325,6 @@ const docTemplate = `{
"custom_headers": {
"type": "string"
},
"enable_http2": {
"type": "boolean"
},
"enable_https": {
"type": "boolean"
},
+1 -1
View File
@@ -2,7 +2,7 @@
你会学到:OpenFlare 文档如何组织、首次运行应该读哪些页面,以及部署、使用、排查分别从哪里开始。
OpenFlare 是新一代分布式反向代理与边缘安全网关。它将传统 OpenResty / Nginx 升级为具备多节点集群编排、Zero-Reload 动态配置同步、边缘 WAF、客户端 PoW 防 CC 人机验证、内网穿透(Tunnels)与 Pages 静态托管的现代化边缘网络平台。可独立自建运行,亦可作为源站护盾与 Cloudflare 协同防御。
OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站配置、配置版本发布、Agent 节点同步、TLS 证书和基础观测放到一个管理端中,适合单团队或单组织管理多台代理节点。
## 推荐阅读路径
-8
View File
@@ -60,8 +60,6 @@ https://openflare.example.com/login
如果希望只允许已有用户使用 SSO,可以关闭用户注册。未绑定的第三方账号会进入绑定已有账号流程。
第三方身份通过「认证源 + `sub`」关联本地用户;自动注册时独立生成本地用户 ID,不使用第三方的 `id` 或数字 `sub` 作为本地用户主键。升级不会修改已有用户 ID 或账号绑定。
## 修改认证源
修改认证源时,Client Secret 输入框留空表示保留已有密钥;填写新值则会覆盖保存。
@@ -70,12 +68,6 @@ https://openflare.example.com/login
## 常见问题
### Casdoor 授权后提示「服务器内部错误,请稍后重试」
旧版本将 ID Token 中非标准的 `id` 字段按整数解析,而 Casdoor 返回的 `id` 是字符串(通常为 UUID),可能导致授权成功后回调失败。请升级到包含此修复的版本,并从登录页重新发起授权,不要刷新或重复使用旧的回调链接。
如果升级后仍失败,请在浏览器开发者工具的 Network 中检查 `POST /api/v1/oauth/callback` 的状态码和响应 `error_msg`,以区分令牌兑换、ID Token 校验或数据库错误。排查时不要分享授权码、Token、Client Secret 或完整用户信息。
### 返回 `invalid_scope`
说明第三方平台不允许当前配置的 Scope。OIDC 默认 Scope 是 `openid profile email`。请到认证源编辑页调整 Scope,或在第三方平台放行对应 Scope。
+1 -3
View File
@@ -32,13 +32,11 @@
自动 IP 组先按单个客户端 IP 聚合指标,再对每个 IP 执行规则表达式:
1. Server 读取最近 `lookback` 时长内的请求日志。
2. 按 `remote_addr` 归一化后的 IP 分组,跳过 IPv4 私网、IPv6 唯一本地地址(ULA)、回环、链路本地、未指定、组播及广播地址;IPv4 映射 IPv6 地址按对应的 IPv4 地址判断。
2. 按 `remote_addr` 归一化后的 IP 分组。
3. 为每个 IP 计算请求数、404 数、直连 IP Host 次数等指标。
4. 逐个 IP 执行 `rules[].expr`。
5. 只要某个 IP 命中任意规则,就写入该自动 IP 组的 `IP / IP 段` 列表。
**测试规则**与实际自动抓取使用相同的地址过滤逻辑。自动 IP 组下次同步时,也会清理历史抓取结果中的上述地址,不受存活时间(TTL)限制。手动 IP 组和订阅 IP 组不受此过滤影响。
Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断:如果 Host 是 IPv4 或 IPv6 字面量,例如 `203.0.113.10`、`[2001:db8::10]`、`203.0.113.10:443`,就计入 `ip_host_count`。
## 可用关键字
+20 -114
View File
@@ -3,130 +3,36 @@ layout: home
hero:
name: OpenFlare
text: 新一代分布式反向代理与边缘安全网关
tagline: 让 Nginx 拥有现代云端边缘能力。集中编排多节点 OpenResty、Zero-Reload 动态热生效、内置 WAF 与客户端 PoW 防 CC 盾。可独立自建运行,亦可作为源站护盾与 Cloudflare 协同防御。
text: 开源 CDN 编排与边缘安全平台
tagline: 支持反向代理、集中式配置同步、Pages 静态托管、内网穿透(Tunnels)、动态 WAF 防护与人机防 CC 挑战。
actions:
- theme: brand
text: 快速开始
link: /guide/quick-start
- theme: alt
text: 系统架构
link: /design/architecture
text: 设计边界
link: /design/
- theme: alt
text: GitHub
link: https://github.com/Rain-kl/OpenFlare
features:
- icon: ⚡
title: 集群化集中编排 (Zero-Reload)
details: 单一控制台统一管理任意数量边缘节点,配置差分毫秒级下发,无需重载 Nginx,业务平滑无抖动。
- icon: 🛡️
title: 边缘 WAF 与防 CC 盾 (PoW)
details: 内置 WAF 规则组、国家级 GeoIP 访问控制,以及类似 Cloudflare Turnstile 的客户端密码学 PoW 挑战。
- icon: 🛰️
title: 集中式配置同步
details: 通过 WebSocket 与心跳实现全网节点配置秒级同步下发与热生效,状态即时回收。
- icon: 🌐
title: 分布式 CDN 编排
details: 将独立的 OpenResty 编排为高度协同的分布式 CDN 舰队,支持源站多负载均衡。
- icon: 📄
title: Pages 静态托管
details: 直接上传前端打包 zip 资产,由边缘节点拉取解压并提供高性能本地服务与 API 代理。
- icon: 🚇
title: 安全内网穿透 (Tunnels)
details: 无须公网 IP 或暴露入向端口,通过轻量级 openflared 客户端建立加密隧道,安全穿透内网服务。
- icon: 📦
title: Pages 静态托管
details: 支持直传压缩包或自动从 GitHub Release 拉取预构建产物,边缘多版本回滚与 SPA 路由支持。
- icon: 🌐
title: 独立自建或协同 Cloudflare
details: 既可作为独立自建边缘网关,亦可作为 Cloudflare 背后坚固的源站护盾(防真实 IP 泄露,沉淀全量私有日志)。
- icon: 🔒
title: 自动化证书与企业 SSO
details: 基于 ACME 协议向 Let's Encrypt 自动申请与续期多域名证书,支持 GitHub OAuth 与标准 OIDC 单点登录。
details: 对标 Cloudflare Tunnels,无须公网 IP 或暴露入向端口,安全穿透本地服务至公网。
- icon: 🛡️
title: 边缘 WAF 安全防护
details: IP 组成员差分同步写入 Lua 共享内存,实现免 Nginx 重载的 WAF 热更新与 GeoIP 过滤。
- icon: 🧩
title: 防 CC 与人机挑战 (PoW)
details: 内置高性能客户端 Proof of Work 密码学挑战,网关边缘秒级拦截阻断僵尸网络与爬虫。
---
<div class="vp-doc" style="max-width: 960px; margin: 40px auto 0; padding: 0 24px;">
## 📖 核心理念:超越传统 Nginx 单机运维
在传统的多服务器架构中,管理 Nginx 往往面临如下痛点:
1. **多节点难以协同**:每台机器需要手动编写 `nginx.conf`,证书分散,容易产生配置漂移;
2. **Reload 容易产生连接抖动**:每次更新域名或上游必须执行 `nginx -s reload`;
3. **缺乏开箱即用的高阶防御**:单机 Nginx 仅有基础的 `limit_req` 频率限制,面对分布式 CC 攻击和爬虫往往无能为力。
**OpenFlare 将 OpenResty 升级为具备云端调度能力的高可用边缘网络**。控制面统一下发配置,数据面通过 Lua 共享内存实现差分热更新,并内置类似 Turnstile 的客户端 PoW 人机验证,在网络边缘秒级阻断恶意流量。
---
## 🌐 双重部署模式
::: tip 模式一:独立自建模式 (Standalone Edge Gateway)
适用于拥有多台独立 VPS、跨地域云服务器或 Homelab 的开发者。完全自主掌控流量与数据,自建属于自己的高可用分发与安全防御网络。
:::
::: info 模式二:Cloudflare 源站护盾模式 (Behind Cloudflare / Origin Shield)
作为 Cloudflare 背后的源站集群网关:
- **防范源站真实 IP 泄露**:攻击者即使绕过 Cloudflare 直连源站真实 IP,OpenFlare 边缘的 WAF 与 PoW 人机验证依然能够就地拦截;
- **全量私有访问日志留存**:弥补商业 CDN 免费版日志保留极短、明细不足的痛点;
- **多后端统一动态路由**:承接 Cloudflare 流量并动态分发至内网服务或不同源站。
:::
---
## 📊 横向对比
| 维度 | 传统 Nginx (`nginx.conf`) | Nginx Proxy Manager (NPM) | **OpenFlare** |
| :--- | :--- | :--- | :--- |
| **集群架构** | 每台机器独立维护,频繁 SSH | 仅支持单机,无法多节点集群协同 | **原生分布式控制面 + 边缘 Agent 集群** |
| **配置生效方式** | 每次需 `nginx -s reload`,连接可能抖动 | 频繁 Reload | **共享内存/差分热同步,Zero-Reload** |
| **防 CC / 爬虫人机验证** | 仅支持简单的 `limit_req` 频率限制 | 不支持 | **内置类似 Turnstile 的 PoW 客户端挑战** |
| **内网穿透 (Tunnels)** | 需繁琐集成第三方程式 (如 frp) | 不支持 | **内置 OpenFlared 穿透隧道,开箱即用** |
| **WAF 与安全防护** | 需复杂编译配置 ModSecurity | 仅基础 Exploits 规则 | **动态 WAF、国家级 GeoIP、IP 组差分准入** |
| **静态 Pages 托管** | 手动上传并配置静态目录 | 仅静态文件代理 | **自动化拉取 GitHub Release,版本回滚** |
| **配合 Cloudflare** | 手动配置 `set_real_ip_from` 极其繁琐 | 需繁琐设置代理头部 | **原生还原真实 IP,完美充当源站护盾** |
---
## 🏗️ 架构拓扑
```mermaid
flowchart TD
subgraph Visitors ["公网访客与客户端"]
User["公网访客 / 移动端"]
CF["Cloudflare CDN (可选代理层)"]
end
subgraph EdgePlane ["边缘数据面 (OpenResty Agent 节点)"]
Edge1["边缘节点 A (OpenResty)"]
Edge2["边缘节点 B (OpenResty)"]
end
subgraph ControlPlane ["控制面 (Control Plane)"]
Server["OpenFlare Server (Web 控制台)"]
DB[("PostgreSQL + Redis")]
end
subgraph Backend ["源站与内网服务"]
Origin["公网源站 Web 服务"]
subgraph PrivateNet ["私有网络 / Homelab"]
NAS["私有应用 / NAS"]
Client["OpenFlared 穿透客户端"]
end
end
User -->|直连 HTTPS 访问| Edge1
User -->|经 Cloudflare 代理| CF
CF -->|安全回源| Edge2
Server <-->|差分热同步 / 指标上报| Edge1
Server <-->|差分热同步 / 指标上报| Edge2
Server --- DB
Edge1 -->|反向代理| Origin
Edge2 -->|反向代理| Origin
Client <-->|加密穿透隧道| Edge1
NAS --- Client
```
---
## ⚖️ 商标免责声明 (Trademark Disclaimer)
OpenFlare is an independent, community-driven open-source project. It is not affiliated with, endorsed by, or sponsored by Cloudflare, Inc. Cloudflare and related trademarks are the property of their respective owners.
OpenFlare 是一个由社区驱动的独立开源项目,与 Cloudflare, Inc. 无任何附属、认可或赞助关系。Cloudflare 及其相关商标均为其各自持有者的财产。
</div>
+21 -35
View File
@@ -283,79 +283,66 @@ packages:
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.3':
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.3':
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.3':
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.3':
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.3':
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.3':
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.3':
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.3':
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.3':
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.3':
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
@@ -556,7 +543,6 @@ packages:
'@xmldom/xmldom@0.9.10':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
deprecated: this version has critical issues, please update to the latest version
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
@@ -911,8 +897,8 @@ packages:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
linkify-it@5.0.2:
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -926,8 +912,8 @@ packages:
markdown-it-mathjax3@4.3.2:
resolution: {integrity: sha512-TX3GW5NjmupgFtMJGRauioMbbkGsOXAAt1DZ/rzzYmTHqzkO1rNAdiMD4NiruurToPApn2kYy76x02QN26qr2w==}
markdown-it@14.3.3:
resolution: {integrity: sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==}
markdown-it@14.1.1:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
markdown-title@1.0.2:
@@ -1225,8 +1211,8 @@ packages:
slick@1.12.2:
resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==}
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
space-separated-tokens@2.0.2:
@@ -1352,8 +1338,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@7.3.7:
resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==}
vite@7.3.3:
resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -1759,10 +1745,10 @@ snapshots:
'@ungap/structured-clone@1.3.1': {}
'@vitejs/plugin-vue@6.0.6(vite@7.3.7(jiti@1.21.7))(vue@3.5.34)':
'@vitejs/plugin-vue@6.0.6(vite@7.3.3(jiti@1.21.7))(vue@3.5.34)':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
vite: 7.3.7(jiti@1.21.7)
vite: 7.3.3(jiti@1.21.7)
vue: 3.5.34
'@vue/compiler-core@3.5.34':
@@ -1771,7 +1757,7 @@ snapshots:
'@vue/shared': 3.5.34
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.34':
dependencies:
@@ -1788,7 +1774,7 @@ snapshots:
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.14
source-map-js: 1.2.2
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.34':
dependencies:
@@ -2203,7 +2189,7 @@ snapshots:
kind-of@6.0.3: {}
linkify-it@5.0.2:
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
@@ -2222,11 +2208,11 @@ snapshots:
transitivePeerDependencies:
- encoding
markdown-it@14.3.3:
markdown-it@14.1.1:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.2
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
@@ -2532,7 +2518,7 @@ snapshots:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.2
source-map-js: 1.2.1
pretty-bytes@7.1.0: {}
@@ -2679,7 +2665,7 @@ snapshots:
slick@1.12.2: {}
source-map-js@1.2.2: {}
source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {}
@@ -2817,7 +2803,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@7.3.7(jiti@1.21.7):
vite@7.3.3(jiti@1.21.7):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
@@ -2832,7 +2818,7 @@ snapshots:
vitepress-plugin-llms@1.12.2:
dependencies:
gray-matter: 4.0.3
markdown-it: 14.3.3
markdown-it: 14.1.1
markdown-title: 1.0.2
mdast-util-from-markdown: 2.0.3
millify: 6.1.0
@@ -2858,7 +2844,7 @@ snapshots:
'@shikijs/transformers': 3.23.0
'@shikijs/types': 3.23.0
'@types/markdown-it': 14.1.2
'@vitejs/plugin-vue': 6.0.6(vite@7.3.7(jiti@1.21.7))(vue@3.5.34)
'@vitejs/plugin-vue': 6.0.6(vite@7.3.3(jiti@1.21.7))(vue@3.5.34)
'@vue/devtools-api': 8.1.2
'@vue/shared': 3.5.34
'@vueuse/core': 14.3.0(vue@3.5.34)
@@ -2867,7 +2853,7 @@ snapshots:
mark.js: 8.11.1
minisearch: 7.2.0
shiki: 3.23.0
vite: 7.3.7(jiti@1.21.7)
vite: 7.3.3(jiti@1.21.7)
vue: 3.5.34
optionalDependencies:
markdown-it-mathjax3: 4.3.2
+1 -8
View File
@@ -87,8 +87,6 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
| `database.max_idle_conn` | `DB_MAX_IDLE_CONN` | 数据库连接池最大空闲连接数 | `16` |
| `database.max_open_conn` | `DB_MAX_OPEN_CONN` | 数据库连接池最大打开连接数 | `128` |
SQLite 文件数据库在启动时自动启用 WAL 模式;内存数据库保留 `memory` 模式。请确保数据库所在目录可写,以创建 `-wal` 和 `-shm` 文件。在线备份请使用管理端数据库导出(生成一致性快照),不要仅复制运行中的 `.db` 文件。
### 3. Redis 配置 (`redis:`)
| 配置文件 YAML 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
| --- | --- | --- | --- |
@@ -128,8 +126,6 @@ SQLite 文件数据库在启动时自动启用 WAL 模式;内存数据库保
| `log.max_size` | - | 单个日志文件的最大空间(MB),超出自动切割轮转 | `100` |
| `log.max_age` | - | 历史切割日志文件最大保留天数 | `30` |
Server、Agent、调度器、任务框架及存储维护等运行时日志的固定描述使用英文,便于跨语言排障和检索。界面语言切换不会改变终端日志和 OTel 异常;接口错误、任务执行详情与内置元数据在展示时按语言翻译,历史记录不回写。用户配置名称及第三方错误保留原文,可能包含中文。现有日志格式、级别、参数及输出渠道保持不变,详见 [运行时语言与国际化](../design/log-language.md)。
### 6. 异步任务 Worker 队列配置 (`worker:`)
| 配置文件 YAML 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
| --- | --- | --- | --- |
@@ -241,7 +237,7 @@ Server、Agent、调度器、任务框架及存储维护等运行时日志的固
| `openresty_proxy_read_timeout` | `int` | 源站收到请求后返回数据,Agent 最大的等待数据返回间隔时长(秒) | `60` |
| `openresty_websocket_enabled` | `bool` | 是否在 HTTP 段中自动载入渲染支持 WebSocket 协议的全局变量及头信息 | `true` |
| `openresty_http3_enabled` | `bool` | 是否在生成 nginx 监听描述中渲染支持 HTTP/3 QUIC 双栈监听能力 | `true` |
| `openresty_proxy_request_buffering_enabled`| `bool` | 是否将客户端 Request Body 先在网关做完全部读取缓存再向源站递交;普通代理建议开启,流式上传或实时转发场景可关闭 | `true` |
| `openresty_proxy_request_buffering_enabled`| `bool` | 是否将客户端 Request Body 先在网关做完全部读取缓存再向源站递交 | `false` |
| `openresty_proxy_buffering_enabled` | `bool` | 是否允许网关暂存源站的大量 Response 数据待全部解析后再转发给用户 | `true` |
| `openresty_proxy_buffers` | `string` | nginx 反代响应缓冲区的分配数量与单缓存大大小(如 `16 16k`) | `16 16k` |
| `openresty_proxy_buffer_size` | `string` | 存放源站返回 Response Header 头部信息的专属缓冲区限制 | `8k` |
@@ -263,9 +259,6 @@ Server、Agent、调度器、任务框架及存储维护等运行时日志的固
| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 |
| `openresty_default_limit_req_per_ip` | `string` | 站点未配置时的默认单 IP 请求频率限制(如 `10r/s`、`100r/m`);空表示默认关闭 | 空 |
| `openresty_main_config_template` | `string` | 允许用户完全重写整个 OpenResty nginx.conf 的底层结构大骨架模板 | 空 (内置缺省骨架) |
| `openresty_trusted_proxy_cidrs` | `string` (JSON 数组) | 可读取 `CF-Connecting-IP` 的可信代理 IPv4/IPv6 CIDR 列表;默认包含 Cloudflare 官方 22 个 IPv4/IPv6 网段;显式 `[]` 会关闭自动生成的可信代理配置 | Cloudflare 官方网段 |
在管理端「系统管理 → 系统配置 → 业务配置」中编辑 `openresty_trusted_proxy_cidrs`。默认值为 Cloudflare 官方完整 22 个 IPv4/IPv6 网段,可从 [Cloudflare IPv4 网段](https://www.cloudflare.com/ips-v4) 和 [IPv6 网段](https://www.cloudflare.com/ips-v6) 核对并按官方更新维护。若要信任额外内网代理,应将其 CIDR 追加到现有数组;例如 `10.2.0.227` 只有在确认它是可信代理、且它发送的 `CF-Connecting-IP` 真实可靠后,才追加 `10.2.0.227/32`。该地址不属于默认信任范围。显式设置为 `[]` 会关闭由此配置生成的转发头信任;已有显式空值不会在升级时自动改为默认。自定义主模板中手工写入的 Real-IP 指令仍按模板生效。源站请求必须带有真实的 `CF-Connecting-IP`。保存后需重新生成并发布节点配置版本。Nginx Real-IP 会按 `CF-Connecting-IP` 更新 `$remote_addr`,访问日志、WAF 和上游 `X-Real-IP` 使用这个单一客户端地址;`X-Forwarded-For` 保留原代理链并追加恢复后的客户端地址。旧版自定义主模板即使没有新占位符,也会在 `http {}` 中自动插入 Real-IP 指令;若模板已有 `real_ip_header`,该模板控制使用的头,设置仅追加可信来源 CIDR。数据库中缺少该配置项时使用 Cloudflare 默认网段;非法 JSON 或读取配置失败时按空列表关闭信任。
### 7. 源站错误页 (Origin Error Page)
@@ -1,516 +0,0 @@
# Cloudflare 分组域名移动/批量操作与 IP 组查看搜索实现计划
> **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:** 为 `/cloudflare/groups/[id]` 页面增加域名单条移动、批量移动与批量移出操作及后端支持,并为 `/ip-groups` 的查看 IP 组弹窗增加 IP 即时搜索过滤功能。
**Architecture:** 后端在 `internal/apps/openflare/cloudflare` 扩展单条移动及批量移动/删除接口并排队更新 DNS 同步;前端在服务层封装调用,在详情页增加多选框、批量工具栏与移动弹窗;IP 组查看弹窗增加输入框实时过滤 entries。
**Tech Stack:** Go 1.25+, Gin, GORM, Next.js App Router, TypeScript, React Query, Tailwind CSS, shadcn/ui, Vitest.
## Global Constraints
- 禁止删除 `frontend/node_modules`。
- 遵循 `AGENTS.md`:API 错误使用 `response.Abort*`,禁止使用 200 返回错误体。
- 业务路由仅在 `internal/router/v1/openflare/register_cloudflare.go` 挂载。
- i18n 变更修改 `frontend/messages/fragments`,通过 `node scripts/merge-i18n-fragments.mjs` 合并,禁止直接修改 `zh-CN.json` / `en.json`。
- 完成后必须执行 `make swagger` 与 `make code-check`。
---
### Task 1: 后端 DTO、业务逻辑及单测 (Backend Logic & Unit Tests)
**Files:**
- Modify: `internal/apps/openflare/cloudflare/types.go`
- Modify: `internal/apps/openflare/cloudflare/errs.go`
- Modify: `internal/apps/openflare/cloudflare/logics.go`
- Modify: `internal/apps/openflare/cloudflare/logics_test.go`
**Interfaces:**
- Produces:
- `MemberMoveInput { TargetGroupID uint `json:"target_group_id"` }`
- `MemberBatchMoveInput { MemberIDs []uint `json:"member_ids"`, TargetGroupID uint `json:"target_group_id"` }`
- `MemberBatchRemoveInput { MemberIDs []uint `json:"member_ids"` }`
- `MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error)`
- `BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error`
- `BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error`
- [ ] **Step 1: 在 types.go 和 errs.go 中新增结构体与错误常量**
在 `internal/apps/openflare/cloudflare/types.go` 增加:
```go
// MemberMoveInput contains the target group ID for moving a member.
type MemberMoveInput struct {
TargetGroupID uint `json:"target_group_id"`
}
// MemberBatchMoveInput contains the member IDs and target group ID for batch moving.
type MemberBatchMoveInput struct {
MemberIDs []uint `json:"member_ids"`
TargetGroupID uint `json:"target_group_id"`
}
// MemberBatchRemoveInput contains the member IDs for batch deletion.
type MemberBatchRemoveInput struct {
MemberIDs []uint `json:"member_ids"`
}
```
在 `internal/apps/openflare/cloudflare/errs.go` 增加:
```go
errTargetGroupSame = "目标分组不能为当前分组"
errTargetGroupInvalid = "目标分组不存在"
errNoMembersSelected = "未选择任何成员"
```
- [ ] **Step 2: 编写失败的单元测试**
在 `internal/apps/openflare/cloudflare/logics_test.go` 中新增测试函数 `TestMoveMemberAndBatchOperations(t *testing.T)`,测试:
1. 目标分组等于源分组时报错 `errTargetGroupSame`;
2. 目标分组不存在时报错;
3. 成功移动成员到目标分组,更新 `GroupID` 并将 `SyncStatus` 置为 `pending`;
4. 批量移动多个成员到目标分组;
5. 批量删除成员。
- [ ] **Step 3: 运行测试验证失败**
运行:`go test -v ./internal/apps/openflare/cloudflare -run TestMoveMemberAndBatchOperations`
预期:编译错误或未实现失败。
- [ ] **Step 4: 实现 MoveMember, BatchMoveMembers, BatchRemoveMembers**
在 `internal/apps/openflare/cloudflare/logics.go` 实现:
```go
// MoveMember transfers a member from sourceGroupID to targetGroupID.
func MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error) {
if targetGroupID == 0 || targetGroupID == sourceGroupID {
return nil, errors.New(errTargetGroupSame)
}
targetGroup, err := repository.GetCFPointingGroup(ctx, targetGroupID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(errTargetGroupInvalid)
}
return nil, err
}
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
if err != nil {
return nil, err
}
member.GroupID = targetGroupID
member.SyncStatus = model.CFMemberSyncPending
member.LastError = ""
if err = repository.SaveCFPointingMember(ctx, member); err != nil {
return nil, err
}
if targetGroup.Enabled {
if _, err = DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); err != nil {
logger.WarnF(ctx, "[Cloudflare] dispatch move sync failed: member_id=%d error=%v", member.ID, err)
}
}
domain, err := repository.GetZoneDomainByID(ctx, member.ZoneDomainID)
if err != nil {
return nil, err
}
return memberItem(member, domain), nil
}
// BatchMoveMembers transfers multiple members from sourceGroupID to targetGroupID.
func BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error {
if len(input.MemberIDs) == 0 {
return errors.New(errNoMembersSelected)
}
if input.TargetGroupID == 0 || input.TargetGroupID == sourceGroupID {
return errors.New(errTargetGroupSame)
}
targetGroup, err := repository.GetCFPointingGroup(ctx, input.TargetGroupID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(errTargetGroupInvalid)
}
return nil, err
}
for _, memberID := range input.MemberIDs {
member, getErr := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
if getErr != nil {
continue
}
member.GroupID = input.TargetGroupID
member.SyncStatus = model.CFMemberSyncPending
member.LastError = ""
if saveErr := repository.SaveCFPointingMember(ctx, member); saveErr != nil {
logger.ErrorF(ctx, "[Cloudflare] batch move save member failed: member_id=%d error=%v", memberID, saveErr)
continue
}
if targetGroup.Enabled {
if _, syncErr := DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); syncErr != nil {
logger.WarnF(ctx, "[Cloudflare] dispatch batch move sync failed: member_id=%d error=%v", member.ID, syncErr)
}
}
}
return nil
}
// BatchRemoveMembers deletes multiple members and their remote A records.
func BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error {
if len(input.MemberIDs) == 0 {
return errors.New(errNoMembersSelected)
}
for _, memberID := range input.MemberIDs {
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
if err != nil {
continue
}
if delErr := DeleteManagedRecord(ctx, member.ID); delErr != nil {
logger.WarnF(ctx, "[Cloudflare] delete remote record failed during batch remove: member_id=%d error=%v", member.ID, delErr)
}
if err = repository.DeleteCFPointingMember(ctx, member); err != nil {
logger.ErrorF(ctx, "[Cloudflare] delete member failed during batch remove: member_id=%d error=%v", member.ID, err)
}
}
return nil
}
```
- [ ] **Step 5: 运行测试验证通过**
运行:`go test -v ./internal/apps/openflare/cloudflare -run TestMoveMemberAndBatchOperations`
预期:PASS。
- [ ] **Step 6: Commit**
```bash
git add internal/apps/openflare/cloudflare/
git commit -m "feat(cloudflare): add move and batch operation logics with tests"
```
---
### Task 2: 后端 Handler、路由注册与 Swagger (Backend Handlers & Routing)
**Files:**
- Modify: `internal/apps/openflare/cloudflare/routers.go`
- Modify: `internal/apps/openflare/cloudflare/routers_test.go`
- Modify: `internal/router/v1/openflare/register_cloudflare.go`
**Interfaces:**
- Produces:
- `MoveMemberHandler(c *gin.Context)`
- `BatchMoveMembersHandler(c *gin.Context)`
- `BatchRemoveMembersHandler(c *gin.Context)`
- 路由:`POST /groups/:id/members/:memberId/move`
- 路由:`POST /groups/:id/members/batch-move`
- 路由:`POST /groups/:id/members/batch-remove`
- [ ] **Step 1: 在 routers.go 中实现 Handler 并补充 Swagger 注解**
```go
// MoveMemberHandler moves a member to a target group.
// @Summary 移动 Cloudflare 指向成员到其他分组
// @Tags openflare-cloudflare
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "原分组 ID"
// @Param memberId path int true "成员 ID"
// @Param body body cloudflare.MemberMoveInput true "目标分组参数"
// @Success 200 {object} response.Any{data=cloudflare.MemberItem}
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Router /api/v1/d/cloudflare/groups/{id}/members/{memberId}/move [post]
func MoveMemberHandler(c *gin.Context) {
groupID, memberID, ok := memberParams(c)
if !ok {
return
}
var input MemberMoveInput
if !apiutil.BindJSON(c, &input) {
return
}
item, err := MoveMember(c.Request.Context(), groupID, memberID, input.TargetGroupID)
if abortLogic(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(item))
}
// BatchMoveMembersHandler moves multiple members to a target group.
// @Summary 批量移动 Cloudflare 指向成员
// @Tags openflare-cloudflare
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "原分组 ID"
// @Param body body cloudflare.MemberBatchMoveInput true "批量移动参数"
// @Success 200 {object} response.Any
// @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-move [post]
func BatchMoveMembersHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input MemberBatchMoveInput
if !apiutil.BindJSON(c, &input) {
return
}
if abortLogic(c, BatchMoveMembers(c.Request.Context(), id, input)) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// BatchRemoveMembersHandler removes multiple members.
// @Summary 批量移出 Cloudflare 指向成员
// @Tags openflare-cloudflare
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "分组 ID"
// @Param body body cloudflare.MemberBatchRemoveInput true "批量移出参数"
// @Success 200 {object} response.Any
// @Failure 400 {object} response.Any
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-remove [post]
func BatchRemoveMembersHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input MemberBatchRemoveInput
if !apiutil.BindJSON(c, &input) {
return
}
if abortLogic(c, BatchRemoveMembers(c.Request.Context(), id, input)) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}
```
- [ ] **Step 2: 在 register_cloudflare.go 中挂载路由**
在 `internal/router/v1/openflare/register_cloudflare.go` 添加:
```go
route.POST("/groups/:id/members/:memberId/move", cf.MoveMemberHandler)
route.POST("/groups/:id/members/batch-move", cf.BatchMoveMembersHandler)
route.POST("/groups/:id/members/batch-remove", cf.BatchRemoveMembersHandler)
```
- [ ] **Step 3: 在 routers_test.go 中补充接口级单测**
为新 Handler 添加单元测试,验证请求解析与响应状态码。
运行:`go test -v ./internal/apps/openflare/cloudflare -run TestRouters`
预期:PASS。
- [ ] **Step 4: Commit**
```bash
git add internal/apps/openflare/cloudflare/routers.go internal/apps/openflare/cloudflare/routers_test.go internal/router/v1/openflare/register_cloudflare.go
git commit -m "feat(cloudflare): register move and batch member API routes"
```
---
### Task 3: WAF IP 组「查看 IP 组」弹窗搜索功能 (IP Group View Dialog Search)
**Files:**
- Modify: `frontend/messages/fragments/security.zh-CN.json`
- Modify: `frontend/messages/fragments/security.en.json`
- Modify: `frontend/app/(main)/waf/components/ip-group-view-dialog.tsx`
**Interfaces:**
- Produces:
- 搜索框 Input,支持清空按钮
- `filteredEntries` 动态过滤
- 数量摘要显示,如 `共 10 条 IP(匹配 3 条)`
- 过滤无匹配时的独立空状态提示
- [ ] **Step 1: 在 security.zh-CN.json 与 security.en.json 中补充文案**
在 `frontend/messages/fragments/security.zh-CN.json` 中的 `ipGroups.viewDialog` 增加:
```json
"searchPlaceholder": "搜索 IP 地址...",
"summaryFiltered": "{type} · 共 {count} 条 IP(匹配 {matched} 条)",
"noSearchResult": "未找到匹配的 IP 地址。"
```
并在 `security.en.json` 对应增加英文文案。
执行 `node scripts/merge-i18n-fragments.mjs` 同步生成全量文件。
- [ ] **Step 2: 修改 ip-group-view-dialog.tsx**
增加 `searchKeyword` state:
- 渲染包含 `Search` 图标和清空按钮的 Input;
- `filteredEntries`:使用 `entries.filter(e => e.ip.toLowerCase().includes(keyword.trim().toLowerCase()))`;
- 弹窗关闭时自动清空 `searchKeyword`;
- 当 `entries.length > 0` 且 `filteredEntries.length === 0` 时展示 `EmptyStateWithBorder`(使用 `noSearchResult`);
- DialogDescription 中根据是否有过滤展示 `summaryFiltered` 或 `summary`。
- [ ] **Step 3: 运行 TypeScript 检查**
运行:`cd frontend && pnpm tsc --noEmit`
预期:无类型错误。
- [ ] **Step 4: Commit**
```bash
git add frontend/messages/ frontend/app/\(main\)/waf/components/ip-group-view-dialog.tsx
git commit -m "feat(waf): add search and filter in ip group view dialog"
```
---
### Task 4: 前端 Cloudflare 分组单条移动与批量操作 (Frontend Move & Batch Operations)
**Files:**
- Modify: `frontend/messages/fragments/cloudflare.zh-CN.json`
- Modify: `frontend/messages/fragments/cloudflare.en.json`
- Modify: `frontend/lib/services/openflare/cloudflare.service.ts`
- Create: `frontend/app/(main)/cloudflare/components/member-move-dialog.tsx`
- Modify: `frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx`
- Modify: `frontend/tests/cloudflare/cloudflare-group-detail.test.tsx`
**Interfaces:**
- Produces:
- `CloudflareService.moveMember(groupId, memberId, targetGroupId)`
- `CloudflareService.batchMoveMembers(groupId, memberIds, targetGroupId)`
- `CloudflareService.batchRemoveMembers(groupId, memberIds)`
- `<MemberMoveDialog />` 组件
- 多选框 Checkbox、全选/半选/清空、批量操作工具条
- [ ] **Step 1: 在 cloudflare.zh-CN.json 与 cloudflare.en.json 增加国际化文案**
增加:
- `move`: "移动"
- `batchMove`: "批量移动"
- `batchRemove`: "批量删除"
- `selectedCount`: "已选择 {count} 项"
- `clearSelection`: "取消选择"
- `moveDialog`:
- `titleSingle`: "移动域名「{domain}」"
- `titleBatch`: "批量移动域名(共 {count} 项)"
- `targetGroupLabel`: "目标指向分组"
- `targetGroupPlaceholder`: "请选择目标分组"
- `noOtherGroups`: "没有其他可用的指向分组,请先创建分组"
- `submit`: "确认移动"
- `moving`: "移动中..."
- `batchRemoveDialog`:
- `title`: "确认批量删除域名"
- `desc`: "确认从当前分组移出已选中的 {count} 个域名吗?这将同时清理 Cloudflare 上的解析记录。"
- `confirm`: "确认删除"
- `removing`: "删除中..."
- `memberMoved`: "域名移动成功"
- `batchMoved`: "成功移动 {count} 个域名"
- `batchRemoved`: "成功删除 {count} 个域名"
执行 `node scripts/merge-i18n-fragments.mjs` 同步。
- [ ] **Step 2: 在 cloudflare.service.ts 扩展 API 方法**
```typescript
static async moveMember(
groupId: number,
memberId: number,
targetGroupId: number,
): Promise<CloudflareMember> {
return this.post<CloudflareMember>(
`/cloudflare/groups/${groupId}/members/${memberId}/move`,
{ target_group_id: targetGroupId },
);
}
static async batchMoveMembers(
groupId: number,
memberIds: number[],
targetGroupId: number,
): Promise<void> {
return this.post<void>(
`/cloudflare/groups/${groupId}/members/batch-move`,
{ member_ids: memberIds, target_group_id: targetGroupId },
);
}
static async batchRemoveMembers(
groupId: number,
memberIds: number[],
): Promise<void> {
return this.post<void>(
`/cloudflare/groups/${groupId}/members/batch-remove`,
{ member_ids: memberIds },
);
}
```
- [ ] **Step 3: 创建 MemberMoveDialog 组件**
在 `frontend/app/(main)/cloudflare/components/member-move-dialog.tsx`:
- Props: `open`, `onOpenChange`, `members: CloudflareMember[]`, `groups: CloudflareGroup[]`, `currentGroupId: number`, `pending: boolean`, `onSubmit: (targetGroupId: number) => void`
- 过滤 `availableGroups = groups.filter(g => g.id !== currentGroupId)`
- 下拉选择 Select 组件展示分组名称与主节点 IP
- 提交按钮
- [ ] **Step 4: 修改 CloudflareGroupDetailPageClient 实现多选与批量/单条操作**
在 `page-client.tsx`:
- 使用 React Query 获取全部分组列表 `CloudflareService.listGroups()`;
- 状态管理:
- `selectedMemberIDs: Set<number>`
- `moveDialogOpen: boolean`
- `movingMembers: CloudflareMember[]`
- `batchRemoveOpen: boolean`
- 多选逻辑:
- 表头 Checkbox 绑定当前页成员选中状态(`allSelected`, `indeterminate`);
- 每行首列增加 Checkbox;
- 批量工具栏:
- 在卡片内部当 `selectedMemberIDs.size > 0` 时展示浮动/嵌入的操作栏:包含数量、`批量移动`、`批量删除`、`取消选择`;
- 行操作区:
- 在原有 `同步`、`删除` 旁新增 `移动` 按钮(FolderInput 或 ArrowRightLeft 图标),点击打开 `MemberMoveDialog`(传入单条成员);
- Mutations:
- `moveMutation`:支持单条和批量移动,完成后清空 `selectedMemberIDs` 并刷新 query;
- `batchRemoveMutation`:批量移出,完成后清空并刷新。
- [ ] **Step 5: 更新前端测试**
在 `frontend/tests/cloudflare/cloudflare-group-detail.test.tsx` 增加测试用例覆盖移动与批量删除触发逻辑。
- [ ] **Step 6: Commit**
```bash
git add frontend/
git commit -m "feat(cloudflare): add domain move and batch operations in group detail page"
```
---
### Task 5: 质量门禁、Swagger 与更新日志 (Quality Gates & Verification)
**Files:**
- Modify: `docs/changelog/index.md`
- Generated: `internal/router/root/swagger/` (via make swagger)
- [ ] **Step 1: 生成 Swagger API 文档**
运行:`make swagger`
检查生成的 swagger 规范包含新端点。
- [ ] **Step 2: 执行全量代码检查**
运行:`make code-check`
确保 `golangci-lint`、`tsc` 与 `eslint` 均零警告零错误通过。
- [ ] **Step 3: 更新 docs/changelog/index.md**
在 `[Unreleased]` 下添加新增功能说明:
```markdown
### 新增
- Cloudflare 指向分组:支持将已添加的域名在不同分组之间移动,自动排队更新远程 DNS 指向。
- Cloudflare 指向分组:详情页支持批量勾选域名进行批量移动与批量移出操作。
- WAF IP 组:在查看 IP 组弹窗中新增即时搜索功能,支持快速过滤和定位 IP 地址。
```
- [ ] **Step 4: 提交变更**
```bash
git add docs/changelog/index.md internal/router/root/swagger/
git commit -m "docs(changelog): document cloudflare member move and ip group search"
```
@@ -1,161 +0,0 @@
# AutoUpdate 6-Hour Interval and Exponential Backoff 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:** Throttle agent/edge auto-update checks to once every 6 hours, and apply exponential backoff on failure (doubling up to a maximum of 3 hours) to prevent exhausting GitHub API rate limits.
**Architecture:** Add check interval and exponential backoff state tracking to `edgeupdater.Service` protected by a mutex, with clock injection (`NowFunc`) for deterministic testing. Expose `CanCheck(options UpdateOptions) bool` and integrate it into both `CheckAndUpdate` and `edgeheartbeat.TryAutoUpdate` so unthrottled checks and noisy logs are eliminated during periodic heartbeat loops.
**Tech Stack:** Go (1.25), standard library (`sync`, `time`, `net/http`, `log/slog`).
## Global Constraints
- Normal update check interval: 6 hours (`6 * time.Hour`).
- Initial backoff on failure: 1 minute (`1 * time.Minute`), doubling on each consecutive failure.
- Maximum retry backoff: 3 hours (`3 * time.Hour`).
- Success resets failure count and backoff, and sets next check to `now + 6h`.
- Force check (`UpdateOptions.Force = true`) must bypass backoff and interval immediately.
- TDD required: write failing tests first, watch them fail, implement minimal code, verify pass.
---
### Task 1: Add Interval and Exponential Backoff to `edgeupdater.Service`
**Files:**
- Modify: `internal/apps/edge/updater/service.go`
- Test: `internal/apps/edge/updater/service_test.go`
**Interfaces:**
- Consumes: `UpdateOptions{Channel, TagName, Force}`
- Produces: `(s *Service) CanCheck(options UpdateOptions) bool`, `(s *Service) CheckAndUpdate(...) error` with throttling and exponential backoff
- [x] **Step 1: Write the failing tests in `service_test.go`**
Add tests covering:
- `TestCheckAndUpdateIntervalAndBackoff`:
1. Initial state allows check.
2. Successful check sets next check time 6 hours later. Calls before 6 hours return false on `CanCheck` and are skipped in `CheckAndUpdate`.
3. After 6 hours, check is allowed again.
4. Failed check (e.g. mock server returns 403) schedules next check 1 minute later.
5. Second failed check schedules next check 2 minutes later.
6. Subsequent failures double up to 3 hours (capped at 3 hours).
7. Force check (`Force: true`) always succeeds in running regardless of cooldown/backoff.
8. Success after failure resets backoff to initial and schedules next check in 6 hours.
- [x] **Step 2: Run test to verify it fails**
Run: `go test -v ./internal/apps/edge/updater -run TestCheckAndUpdateIntervalAndBackoff`
Expected: FAIL (fields or methods missing or behavior not implemented).
- [x] **Step 3: Write minimal implementation in `service.go`**
1. Define constants:
```go
const (
DefaultCheckInterval = 6 * time.Hour
DefaultInitialBackoff = 1 * time.Minute
DefaultMaxBackoff = 3 * time.Hour
)
```
2. Extend `Config`:
```go
type Config struct {
LocalVersion string
AssetPrefix string
LogLabel string
CheckInterval time.Duration
InitialBackoff time.Duration
MaxBackoff time.Duration
NowFunc func() time.Time
}
```
3. Extend `Service` struct with:
```go
type Service struct {
httpClient *http.Client
lastCheckKey string
localVersion string
assetPrefix string
logLabel string
checkInterval time.Duration
initialBackoff time.Duration
maxBackoff time.Duration
nowFunc func() time.Time
mu sync.Mutex
nextCheckTime time.Time
currentBackoff time.Duration
consecutiveFailures int
}
```
4. Implement `CanCheck(options UpdateOptions) bool`:
```go
func (s *Service) CanCheck(options UpdateOptions) bool {
if options.Force {
return true
}
s.mu.Lock()
defer s.mu.Unlock()
if s.nextCheckTime.IsZero() {
return true
}
now := s.now()
return !now.Before(s.nextCheckTime)
}
```
5. In `CheckAndUpdate`:
- Check `CanCheck(options)`. If false, return `nil`.
- On `getRelease` error: calculate next backoff (initial backoff on 1st failure, double previous backoff capped at maxBackoff on subsequent), increment `consecutiveFailures`, set `nextCheckTime = now.Add(currentBackoff)`, return error.
- On `getRelease` success: reset `consecutiveFailures = 0`, reset `currentBackoff = initialBackoff`, set `nextCheckTime = now.Add(checkInterval)`.
- [x] **Step 4: Run test to verify it passes**
Run: `go test -v ./internal/apps/edge/updater/...`
Expected: PASS.
---
### Task 2: Integrate `CanCheck` in `edgeheartbeat.TryAutoUpdate`
**Files:**
- Modify: `internal/apps/edge/heartbeat/autoupdate.go`
- Test: `internal/apps/edge/heartbeat/autoupdate_test.go`
**Interfaces:**
- Consumes: `(s *Service) CanCheck(options UpdateOptions) bool`
- Produces: `TryAutoUpdate(ctx context.Context, updater *edgeupdater.Service, settings *AutoUpdateSettings, logLabel string)`
- [x] **Step 1: Write the failing test in `autoupdate_test.go`**
Test that `TryAutoUpdate` checks `updater.CanCheck(opts)` and skips both log output and `CheckAndUpdate` call when throttled.
- [x] **Step 2: Run test to verify it fails**
Run: `go test -v ./internal/apps/edge/heartbeat/...`
Expected: FAIL.
- [x] **Step 3: Update `autoupdate.go`**
Update `TryAutoUpdate` to evaluate `updater.CanCheck(opts)` before logging `checking for updates` and executing `CheckAndUpdate`.
- [x] **Step 4: Run test to verify it passes**
Run: `go test -v ./internal/apps/edge/heartbeat/...`
Expected: PASS.
---
### Task 3: Full Verification of Edge and Agent Packages
**Files:**
- Test: All tests in `internal/apps/edge/...` and `internal/apps/agent/...`
- [x] **Step 1: Run comprehensive tests**
Run:
```bash
go test -v ./internal/apps/edge/... ./internal/apps/agent/...
```
Expected: PASS with zero failures or race conditions.
@@ -1,138 +0,0 @@
# Cloudflare 分组域名移动/批量操作与 IP 组查看搜索设计文档
- **日期**:2026-09-24
- **状态**:已批准 (Approved)
---
## 1. 目标与背景 (Goals & Context)
1. **Cloudflare 分组域名管理增强**:
- 在 `/cloudflare/groups/[id]` 页面,为单个成员域名增加「移动」操作,支持迁移至其他可用分组;
- 增加表格行复选框多选机制与批量操作栏,支持「批量删除」与「批量移动」;
- 确保域名移动后,如果目标分组处于启用状态,自动重新排队向 Cloudflare 同步目标节点的新 IP。
2. **WAF IP 组查看弹窗增强**:
- 在 `/ip-groups` 的「查看 IP 组」弹窗(`IPGroupViewDialog`)内,新增即时 IP 搜索与过滤功能,方便用户在包含大量 IP 条目时快速查找与定位。
---
## 2. 后端接口设计 (Backend API)
### 2.1 数据模型与 DTO
文件:`internal/apps/openflare/cloudflare/types.go`
```go
type MemberMoveInput struct {
TargetGroupID uint `json:"target_group_id"`
}
type MemberBatchMoveInput struct {
MemberIDs []uint `json:"member_ids"`
TargetGroupID uint `json:"target_group_id"`
}
type MemberBatchRemoveInput struct {
MemberIDs []uint `json:"member_ids"`
}
```
### 2.2 路由端点
文件:`internal/apps/openflare/cloudflare/routers.go`
注册:`internal/router/v1/openflare/register_cloudflare.go`
1. **单条移动**:
- 路径:`POST /api/v1/d/cloudflare/groups/:id/members/:memberId/move`
- 请求体:`MemberMoveInput`
- 响应:`200 OK` + `cloudflare.MemberItem`
2. **批量移动**:
- 路径:`POST /api/v1/d/cloudflare/groups/:id/members/batch-move`
- 请求体:`MemberBatchMoveInput`
- 响应:`200 OK`
3. **批量移出/删除**:
- 路径:`POST /api/v1/d/cloudflare/groups/:id/members/batch-remove`
- 请求体:`MemberBatchRemoveInput`
- 响应:`200 OK`
### 2.3 业务逻辑层
文件:`internal/apps/openflare/cloudflare/logics.go`
- `MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error)`
- 检查 `targetGroupID != sourceGroupID`;
- 检查目标分组是否存在且非空;
- 查询原成员,更新 `GroupID = targetGroupID`,将 `SyncStatus` 置为 `pending`,清除 `LastError`,保存到数据库;
- 若目标分组 `Enabled == true`,调用 `DispatchMemberSync(ctx, member.ID, "cloudflare_member_move")` 排队执行远程 A 记录同步;
- 返回更新后的 `MemberItem`。
- `BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error`
- 校验目标分组有效性;
- 针对 `input.MemberIDs` 过滤去重,对每个成员执行分组归属更新并重置同步状态;
- 若目标分组启用,分发同步任务。
- `BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error`
- 针对每个 `memberID`,校验属于 `sourceGroupID`,调用 `DeleteManagedRecord(ctx, member.ID)` 清除 Cloudflare 上的 A 记录,并调用 `repository.DeleteCFPointingMember(ctx, member)`。
---
## 3. 前端交互设计 (Frontend UI & Interaction)
### 3.1 Cloudflare 服务客户端扩展
文件:`frontend/lib/services/openflare/cloudflare.service.ts`
- `moveMember(groupId: number, memberId: number, targetGroupId: number): Promise<CloudflareMember>`
- `batchMoveMembers(groupId: number, memberIds: number[], targetGroupId: number): Promise<void>`
- `batchRemoveMembers(groupId: number, memberIds: number[]): Promise<void>`
### 3.2 域名成员列表交互 (`frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx`)
1. **多选管理**:
- `selectedMemberIDs: Set<number>` 状态;
- 表头 Checkbox 支持全部选中/取消选中(含半选状态);
- 每行首列为 Checkbox,行内可单选。
2. **批量操作栏 (Batch Action Bar)**:
- 当 `selectedMemberIDs.size > 0` 时,在成员卡片头部或列表上方显示批量操作工具条:
- 已选条数提示(Badge);
- 「批量移动」按钮:唤起移动弹窗,包含选中的所有成员;
- 「批量删除」按钮:唤起确认弹窗,明确提示删除的数量与域名,确认后执行批量移出;
- 「取消选择」按钮:清空勾选。
3. **行级「移动」操作**:
- 行右侧操作按钮区新增「移动」操作按钮;
- 点击唤起 `MemberMoveDialog` 弹窗。
4. **移动域名弹窗 (`MemberMoveDialog`)**:
- 提取独立组件或子组件,接收待移动的成员列表和全部 Cloudflare 分组列表;
- 过滤排除当前分组,用户下拉选择目标分组;
- 点击确认后执行 `moveMutation` 或 `batchMoveMutation`。
### 3.3 WAF IP 组查看弹窗搜索 (`frontend/app/(main)/waf/components/ip-group-view-dialog.tsx`)
1. **搜索框**:
- 在 DialogHeader 和 IP 列表表格之间,增加搜索过滤条;
- 带 `Search` 图标,清除按钮,响应输入并去除首尾空格;
2. **过滤行为**:
- `searchKeyword` 过滤 `entries` 列表,匹配 `entry.ip`;
- 更新汇总文案:如 `共 50 条 IP (匹配 3 条)`;
- 无匹配时展示特定空状态「未找到匹配的 IP 地址」。
---
## 4. 国际化与文案 (i18n)
- `frontend/messages/fragments/cloudflare.zh-CN.json` & `cloudflare.en.json`:
- 增加移动相关文案:`move`, `batchMove`, `batchRemove`, `moveTitle`, `targetGroup`, `selectTargetGroup`, `batchRemoveConfirm`, 等。
- `frontend/messages/fragments/security.zh-CN.json` & `security.en.json`:
- 增加 `viewDialog` 搜索框文案:`searchPlaceholder`, `matchedCount`, `noMatchFilter`。
---
## 5. 验证标准 (Verification Criteria)
1. **后端单测**:
- 针对 `MoveMember`、`BatchMoveMembers`、`BatchRemoveMembers` 编写完整单元测试,验证成功与异常分支(如移到同一分组报错、不存在的分组报错等);
- 执行 `go test ./internal/apps/openflare/cloudflare/...` 全部通过。
2. **前端测试与校验**:
- 更新或新增前端测试用例覆盖移动与批量删除逻辑;
- 执行 `make code-check` 保证 TypeScript、ESLint 与后端架构规则通过。
3. **API 文档与更新日志**:
- 执行 `make swagger` 同步 OpenAPI 文档;
- 更新 `docs/changelog/index.md`。
+5 -571
View File
@@ -4436,12 +4436,12 @@
"summary": "注册或发现 Agent 节点",
"parameters": [
{
"description": "节点上报数据;registration_key 为首次注册重试凭证",
"description": "节点上报数据",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest"
"$ref": "#/definitions/agent.NodePayload"
}
}
],
@@ -4457,7 +4457,7 @@
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/agent.RegistrationResponseDocs"
"$ref": "#/definitions/agent.RegistrationResponse"
}
}
}
@@ -6355,247 +6355,6 @@
}
}
},
"/api/v1/d/cloudflare/groups/{id}/members/batch-move": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-cloudflare"
],
"summary": "批量移动 Cloudflare 指向成员",
"parameters": [
{
"type": "integer",
"description": "原分组 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "批量移动参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/cloudflare.MemberBatchMoveInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/cloudflare/groups/{id}/members/batch-proxy": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-cloudflare"
],
"summary": "批量开启 Cloudflare 橙云",
"parameters": [
{
"type": "integer",
"description": "分组 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "批量开启橙云参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/cloudflare.MemberBatchProxyInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/cloudflare/groups/{id}/members/batch-remove": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-cloudflare"
],
"summary": "批量移出 Cloudflare 指向成员",
"parameters": [
{
"type": "integer",
"description": "分组 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "批量移出参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/cloudflare.MemberBatchRemoveInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/move": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-cloudflare"
],
"summary": "移动 Cloudflare 指向成员到其他分组",
"parameters": [
{
"type": "integer",
"description": "原分组 ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "成员 ID",
"name": "memberId",
"in": "path",
"required": true
},
{
"description": "目标分组参数",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/cloudflare.MemberMoveInput"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/cloudflare.MemberItem"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": {
"post": {
"security": [
@@ -7946,169 +7705,6 @@
}
}
},
"/api/v1/d/nodes/page": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回分页节点和筛选后的总数;旧版 /nodes 列表接口保持兼容,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取分页节点列表",
"parameters": [
{
"type": "integer",
"description": "页码(默认 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页数量(默认 20,最大 100)",
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "名称、节点标识或 IP 的字面子串(最多 256 字节)",
"name": "search",
"in": "query"
},
{
"type": "string",
"description": "pending、online 或 offline",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "edge_node、tunnel_relay 或 tunnel_client",
"name": "node_type",
"in": "query"
}
],
"responses": {
"200": {
"description": "节点分页",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.ListResult"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回指定节点及最新配置下发记录,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取节点详情",
"parameters": [
{
"type": "integer",
"description": "节点 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "节点详情",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.View"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}/agent-release": {
"get": {
"security": [
@@ -14657,7 +14253,7 @@
}
},
"400": {
"description": "用户名或密码错误",
"description": "用户名或密码错误、帐号已禁用等",
"schema": {
"$ref": "#/definitions/response.Any"
}
@@ -15145,15 +14741,12 @@
}
}
},
"agent.RegistrationResponseDocs": {
"agent.RegistrationResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"agent_token": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -15501,15 +15094,6 @@
},
"primary_node_id": {
"type": "integer"
},
"record_content": {
"type": "string"
},
"record_type": {
"type": "string"
},
"target_mode": {
"type": "string"
}
}
},
@@ -15543,56 +15127,11 @@
"primary_node": {
"$ref": "#/definitions/cloudflare.NodeOption"
},
"record_content": {
"type": "string"
},
"record_type": {
"type": "string"
},
"target_mode": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"cloudflare.MemberBatchMoveInput": {
"type": "object",
"properties": {
"member_ids": {
"type": "array",
"items": {
"type": "integer"
}
},
"target_group_id": {
"type": "integer"
}
}
},
"cloudflare.MemberBatchProxyInput": {
"type": "object",
"properties": {
"member_ids": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"cloudflare.MemberBatchRemoveInput": {
"type": "object",
"properties": {
"member_ids": {
"type": "array",
"items": {
"type": "integer"
}
}
}
},
"cloudflare.MemberCreateInput": {
"type": "object",
"properties": {
@@ -15639,14 +15178,6 @@
}
}
},
"cloudflare.MemberMoveInput": {
"type": "object",
"properties": {
"target_group_id": {
"type": "integer"
}
}
},
"cloudflare.MemberUpdateInput": {
"type": "object",
"properties": {
@@ -16452,77 +15983,6 @@
}
}
},
"github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest": {
"type": "object",
"properties": {
"access_logs": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog"
}
},
"buffered": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord"
}
},
"current_version": {
"type": "string"
},
"edge_health": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth"
},
"ext_version": {
"type": "string"
},
"health_events": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent"
}
},
"host_metrics": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot"
},
"ip": {
"type": "string"
},
"last_error": {
"type": "string"
},
"name": {
"type": "string"
},
"node_id": {
"type": "string"
},
"openresty_message": {
"type": "string"
},
"openresty_status": {
"type": "string"
},
"profile": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile"
},
"registration_key": {
"type": "string"
},
"schema_version": {
"type": "integer"
},
"version": {
"type": "string"
},
"waf_ip_group_checksums": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
"github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup": {
"type": "object",
"properties": {
@@ -17911,26 +17371,6 @@
}
}
},
"node.ListResult": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"results": {
"type": "array",
"items": {
"$ref": "#/definitions/node.View"
}
},
"total": {
"type": "integer"
}
}
},
"node.ObservabilityView": {
"type": "object",
"properties": {
@@ -19762,9 +19202,6 @@
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
}
},
"enable_http2": {
"type": "boolean"
},
"enable_https": {
"type": "boolean"
},
@@ -19881,9 +19318,6 @@
"custom_headers": {
"type": "string"
},
"enable_http2": {
"type": "boolean"
},
"enable_https": {
"type": "boolean"
},
+5 -360
View File
@@ -99,12 +99,10 @@ definitions:
type: string
type: object
type: object
agent.RegistrationResponseDocs:
agent.RegistrationResponse:
properties:
access_token:
type: string
agent_token:
type: string
name:
type: string
node_id:
@@ -332,12 +330,6 @@ definitions:
type: string
primary_node_id:
type: integer
record_content:
type: string
record_type:
type: string
target_mode:
type: string
type: object
cloudflare.GroupItem:
properties:
@@ -359,38 +351,9 @@ definitions:
type: string
primary_node:
$ref: '#/definitions/cloudflare.NodeOption'
record_content:
type: string
record_type:
type: string
target_mode:
type: string
updated_at:
type: string
type: object
cloudflare.MemberBatchMoveInput:
properties:
member_ids:
items:
type: integer
type: array
target_group_id:
type: integer
type: object
cloudflare.MemberBatchProxyInput:
properties:
member_ids:
items:
type: integer
type: array
type: object
cloudflare.MemberBatchRemoveInput:
properties:
member_ids:
items:
type: integer
type: array
type: object
cloudflare.MemberCreateInput:
properties:
proxied:
@@ -421,11 +384,6 @@ definitions:
zone_id:
type: integer
type: object
cloudflare.MemberMoveInput:
properties:
target_group_id:
type: integer
type: object
cloudflare.MemberUpdateInput:
properties:
proxied:
@@ -957,53 +915,6 @@ definitions:
total_size:
type: integer
type: object
github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest:
properties:
access_logs:
items:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog'
type: array
buffered:
items:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord'
type: array
current_version:
type: string
edge_health:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth'
ext_version:
type: string
health_events:
items:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent'
type: array
host_metrics:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot'
ip:
type: string
last_error:
type: string
name:
type: string
node_id:
type: string
openresty_message:
type: string
openresty_status:
type: string
profile:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile'
registration_key:
type: string
schema_version:
type: integer
version:
type: string
waf_ip_group_checksums:
additionalProperties:
type: string
type: object
type: object
github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup:
properties:
checksum:
@@ -1931,19 +1842,6 @@ definitions:
relay_web_server_enabled:
type: boolean
type: object
node.ListResult:
properties:
page:
type: integer
page_size:
type: integer
results:
items:
$ref: '#/definitions/node.View'
type: array
total:
type: integer
type: object
node.ObservabilityView:
properties:
analytics:
@@ -3144,8 +3042,6 @@ definitions:
items:
$ref: '#/definitions/proxy_route.CustomHeaderInput'
type: array
enable_http2:
type: boolean
enable_https:
type: boolean
enabled:
@@ -3223,8 +3119,6 @@ definitions:
type: array
custom_headers:
type: string
enable_http2:
type: boolean
enable_https:
type: boolean
enabled:
@@ -7082,12 +6976,12 @@ paths:
- application/json
description: 使用节点 access token 重新注册,或使用全局 discovery token 发现新节点;请求头需携带 X-Agent-Token
parameters:
- description: 节点上报数据;registration_key 为首次注册重试凭证
- description: 节点上报数据
in: body
name: body
required: true
schema:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest'
$ref: '#/definitions/agent.NodePayload'
produces:
- application/json
responses:
@@ -7098,7 +6992,7 @@ paths:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/agent.RegistrationResponseDocs'
$ref: '#/definitions/agent.RegistrationResponse'
type: object
"400":
description: 参数错误
@@ -8241,52 +8135,6 @@ paths:
summary: 添加 Cloudflare 指向成员
tags:
- openflare-cloudflare
/api/v1/d/cloudflare/groups/{id}/members/{memberId}/move:
post:
consumes:
- application/json
parameters:
- description: 原分组 ID
in: path
name: id
required: true
type: integer
- description: 成员 ID
in: path
name: memberId
required: true
type: integer
- description: 目标分组参数
in: body
name: body
required: true
schema:
$ref: '#/definitions/cloudflare.MemberMoveInput'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/cloudflare.MemberItem'
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.Any'
"404":
description: Not Found
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 移动 Cloudflare 指向成员到其他分组
tags:
- openflare-cloudflare
/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove:
post:
parameters:
@@ -8380,110 +8228,6 @@ paths:
summary: 更新 Cloudflare 指向成员
tags:
- openflare-cloudflare
/api/v1/d/cloudflare/groups/{id}/members/batch-move:
post:
consumes:
- application/json
parameters:
- description: 原分组 ID
in: path
name: id
required: true
type: integer
- description: 批量移动参数
in: body
name: body
required: true
schema:
$ref: '#/definitions/cloudflare.MemberBatchMoveInput'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Any'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.Any'
"404":
description: Not Found
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 批量移动 Cloudflare 指向成员
tags:
- openflare-cloudflare
/api/v1/d/cloudflare/groups/{id}/members/batch-proxy:
post:
consumes:
- application/json
parameters:
- description: 分组 ID
in: path
name: id
required: true
type: integer
- description: 批量开启橙云参数
in: body
name: body
required: true
schema:
$ref: '#/definitions/cloudflare.MemberBatchProxyInput'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Any'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.Any'
"404":
description: Not Found
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 批量开启 Cloudflare 橙云
tags:
- openflare-cloudflare
/api/v1/d/cloudflare/groups/{id}/members/batch-remove:
post:
consumes:
- application/json
parameters:
- description: 分组 ID
in: path
name: id
required: true
type: integer
- description: 批量移出参数
in: body
name: body
required: true
schema:
$ref: '#/definitions/cloudflare.MemberBatchRemoveInput'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Any'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 批量移出 Cloudflare 指向成员
tags:
- openflare-cloudflare
/api/v1/d/cloudflare/groups/{id}/sync:
post:
parameters:
@@ -9132,48 +8876,6 @@ paths:
summary: 创建节点
tags:
- openflare-node
/api/v1/d/nodes/{id}:
get:
description: 返回指定节点及最新配置下发记录,需要管理员权限
parameters:
- description: 节点 ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 节点详情
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/node.View'
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 无权限或不存在
schema:
$ref: '#/definitions/response.Any'
"500":
description: 查询失败
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 获取节点详情
tags:
- openflare-node
/api/v1/d/nodes/{id}/agent-release:
get:
description: 返回指定节点可用的最新 Agent 版本信息,需要管理员权限
@@ -9561,63 +9263,6 @@ paths:
summary: 轮换引导令牌
tags:
- openflare-node
/api/v1/d/nodes/page:
get:
description: 返回分页节点和筛选后的总数;旧版 /nodes 列表接口保持兼容,需要管理员权限
parameters:
- description: 页码(默认 1)
in: query
name: page
type: integer
- description: 每页数量(默认 20,最大 100)
in: query
name: page_size
type: integer
- description: 名称、节点标识或 IP 的字面子串(最多 256 字节)
in: query
name: search
type: string
- description: pending、online 或 offline
in: query
name: status
type: string
- description: edge_node、tunnel_relay 或 tunnel_client
in: query
name: node_type
type: string
produces:
- application/json
responses:
"200":
description: 节点分页
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/node.ListResult'
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 无权限
schema:
$ref: '#/definitions/response.Any'
"500":
description: 查询失败
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 获取分页节点列表
tags:
- openflare-node
/api/v1/d/option:
get:
description: 返回全部非敏感 OpenFlare 配置项,需要管理员权限
@@ -13224,7 +12869,7 @@ paths:
$ref: '#/definitions/oauth.BasicUserInfo'
type: object
"400":
description: 用户名或密码错误
description: 用户名或密码错误、帐号已禁用等
schema:
$ref: '#/definitions/response.Any'
"500":
+6 -12
View File
@@ -1,23 +1,17 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import {
createApiSections,
DOCS_LAST_UPDATED,
} from '@/components/common/docs/api';
import { apiSections, DOCS_LAST_UPDATED } from '@/components/common/docs/api';
export default function ApiDocPage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title={rt('api_documentation_cb3d2229')}
title='API 接口文档'
lastUpdated={DOCS_LAST_UPDATED}
sections={createApiSections(rt)}
sections={apiSections}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
{rt('openflare_provides_a_simple_powerful_api_for_ba400468')}
OpenFlare 提供简单、强大的 API
接口,支持多种编程语言和开发环境。通过标准化的 RESTful
接口,您可以轻松地与后端服务进行集成与二次开发。
</p>
}
/>
+5 -9
View File
@@ -1,21 +1,17 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { createHowToUseSections } from '@/components/common/docs/how-to-use';
import { howToUseSections } from '@/components/common/docs/how-to-use';
import { DOCS_LAST_UPDATED } from '@/components/common/docs/api';
export default function HowToUsePage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title={rt('platform_user_guide_d0ead991')}
title='平台使用指南'
lastUpdated={DOCS_LAST_UPDATED}
sections={createHowToUseSections(rt)}
sections={howToUseSections}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
{rt('welcome_to_the_openflare_management_platform_this_e8ecbe4d')}
欢迎使用 OpenFlare
管理平台。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
</p>
}
/>
@@ -1,23 +1,18 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { createPrivacySections } from '@/components/common/docs/privacy';
import { privacySections } from '@/components/common/docs/privacy';
import { TERMS_LAST_UPDATED } from '@/components/common/docs/terms';
export default function PrivacyPolicyPage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title={rt('privacy_policy_b6da1ca6')}
title='隐私政策 (Privacy)'
lastUpdated={TERMS_LAST_UPDATED}
sections={createPrivacySections(rt)}
sections={privacySections}
description={
<span>
{rt('we_value_your_privacy_this_policy_explains_2c9e3c0b')}
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
<br className='hidden md:block' />
{rt('and_the_rights_you_have_over_it_4560abf2')}
以及您享有的相关权利。
</span>
}
/>
@@ -1,25 +1,16 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import {
TERMS_LAST_UPDATED,
createTermsSections,
termsSections,
} from '@/components/common/docs/terms';
export default function TermsOfServicePage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title={rt('terms_of_service_4e80a140')}
title='服务协议 (Terms)'
lastUpdated={TERMS_LAST_UPDATED}
sections={createTermsSections(rt)}
description={
<span>
{rt('please_read_the_following_terms_carefully_to_c46a6a61')}
</span>
}
sections={termsSections}
description={<span>为了保障您的合法权益,请您仔细阅读以下条款。</span>}
/>
);
}
@@ -1,4 +1,3 @@
import { getFormattingLocale } from '@/i18n/format';
export type AccessLogTab = 'overview' | 'ips' | 'list';
export type SearchDraft = {
@@ -67,7 +66,7 @@ export function parseSortValue(value: string) {
}
export function formatCompactNumber(value: number) {
return new Intl.NumberFormat(getFormattingLocale(), {
return new Intl.NumberFormat('zh-CN', {
notation: value >= 10000 ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(value);
@@ -368,8 +368,6 @@ export function IpAnalysisPanel({
*/
initialHours?: number;
}) {
const uiText = useTranslations('runtime');
const t = useTranslations('accessLogs');
const trendChartConfig = useTrendChartConfig();
const [ipGroupOpen, setIpGroupOpen] = useState(false);
@@ -578,32 +576,32 @@ export function IpAnalysisPanel({
{analysis ? (
<div className='grid gap-3 md:grid-cols-2'>
<MiniRankCard
title={uiText('ui_topPaths')}
title='Top Paths'
color='#a78bfa'
items={toRankItems(analysis.top_paths)}
/>
<MiniRankCard
title={uiText('ui_topHosts')}
title='Top Hosts'
color='#34d399'
items={toRankItems(analysis.top_hosts)}
/>
<MiniRankCard
title={uiText('ui_statusCodes')}
title='Status Codes'
color='#f59e0b'
items={toRankItems(analysis.status_codes)}
/>
<MiniRankCard
title={uiText('ui_topUserAgents')}
title='Top User-Agents'
color='#818cf8'
items={toRankItems(analysis.top_user_agents)}
/>
<MiniRankCard
title={uiText('ui_deviceTypes')}
title='Device Types'
color='#38bdf8'
items={toRankItems(analysis.device_types)}
/>
<MiniRankCard
title={uiText('ui_topBrowsers')}
title='Top Browsers'
color='#22c55e'
items={toRankItems(analysis.top_browsers)}
/>
@@ -318,8 +318,6 @@ function OverviewContent({
data: AccessLogOverview;
hours: number;
}) {
const uiText = useTranslations('runtime');
const t = useTranslations('accessLogs');
const requestLabels = data.trends.requests.map((point) =>
formatOverviewTrendLabel(point.bucket_started_at, hours),
@@ -333,7 +331,7 @@ function OverviewContent({
<>
<div className='grid gap-4 lg:grid-cols-3'>
<SparklineMetricCard
title={uiText('ui_totalRequests')}
title='Total Requests'
value={formatCompactNumber(data.summary.total_requests)}
hint={hint}
color='#f59e0b'
@@ -342,7 +340,7 @@ function OverviewContent({
values={requestValues}
/>
<SparklineMetricCard
title={uiText('ui_totalVisits')}
title='Total Visits'
value={formatCompactNumber(data.summary.total_visits)}
hint={t('overview.uniqueVisitors', { hint })}
color='#38bdf8'
@@ -351,7 +349,7 @@ function OverviewContent({
values={visitValues}
/>
<SparklineMetricCard
title={uiText('ui_bandwidthServed')}
title='Bandwidth Served'
value={formatBytes(data.summary.bandwidth_served)}
hint={t('overview.served', { hint })}
color='#34d399'
@@ -365,7 +363,7 @@ function OverviewContent({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
{uiText('ui_requestsOverTime')}
Requests over time
</CardTitle>
<CardDescription className='text-xs'>
{t('overview.requestsHint')}
@@ -391,13 +389,13 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-2'>
<PieDistributionCard
title={uiText('ui_requestsByDevice')}
title='Requests by device type'
description={t('overview.deviceDesc')}
items={data.device_types ?? []}
emptyMessage={t('overview.deviceEmpty')}
/>
<PieDistributionCard
title={uiText('ui_statusCodes')}
title='Status code'
description={t('overview.statusDesc')}
items={data.status_codes ?? []}
emptyMessage={t('overview.statusEmpty')}
@@ -406,17 +404,17 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title={uiText('ui_topPaths')}
title='Top Paths'
description={t('overview.topPathsDesc')}
items={toRankItems(data.top_paths)}
/>
<RankCard
title={uiText('ui_topHosts')}
title='Top Hosts'
description={t('overview.topHostsDesc')}
items={toRankItems(data.top_hosts)}
/>
<RankCard
title={uiText('ui_topIps')}
title='Top IPs'
description={t('overview.topIpsDesc')}
items={toRankItems(data.top_ips)}
/>
@@ -424,17 +422,17 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title={uiText('ui_topBrowsers')}
title='Top browsers'
description={t('overview.browsersDesc')}
items={toRankItems(data.top_browsers)}
/>
<RankCard
title={uiText('ui_topOperatingSystems')}
title='Top Operating System'
description={t('overview.osDesc')}
items={toRankItems(data.top_operating_systems)}
/>
<RankCard
title={uiText('ui_topUserAgents')}
title='Top User-Agent'
description={t('overview.uaDesc')}
items={toRankItems(data.top_user_agents)}
/>
@@ -2,7 +2,7 @@
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useFormatter, useTranslations } from 'next-intl';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { HardDrive, RefreshCw, Trash2 } from 'lucide-react';
@@ -28,7 +28,25 @@ import {
} from '@/components/ui/dialog';
import type { CacheStatus } from '@/lib/services/admin';
import services from '@/lib/services';
import { formatBytes } from '@/lib/utils/metrics';
/**
* 格式化字节大小
*/
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
};
interface CacheManagerProps {
refreshTrigger: number;
@@ -36,7 +54,6 @@ interface CacheManagerProps {
export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const t = useTranslations('admin.database.cache');
const format = useFormatter();
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null);
const [loadingCache, setLoadingCache] = useState<boolean>(true);
const [savingConfig, setSavingConfig] = useState<boolean>(false);
@@ -191,7 +208,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{t('cacheKeysCount')}
</p>
<p className='text-xl font-bold tracking-tight text-foreground'>
{format.number(cacheStatus.keys_count)}{' '}
{formatNumber(cacheStatus.keys_count)}{' '}
<span className='text-xs text-muted-foreground font-normal'>
{t('files')}
</span>
@@ -142,9 +142,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
</div>
</div>
<div className='text-xs text-muted-foreground font-mono'>
{t('connected', {
database: dbType === 'postgres' ? 'PostgreSQL' : 'SQLite',
})}
{dbType === 'postgres' ? 'PostgreSQL Connected' : 'SQLite Connected'}
</div>
</div>
@@ -2,7 +2,7 @@
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useFormatter, useTranslations } from 'next-intl';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Layers, RefreshCw } from 'lucide-react';
@@ -33,6 +33,14 @@ import { Button } from '@/components/ui/button';
import type { TableDataResponse } from '@/lib/services/db-manage';
import { DbManageService } from '@/lib/services/db-manage';
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
};
/**
* 格式化表格单元格内容
*/
@@ -61,7 +69,6 @@ export function TableBrowser({
refreshTrigger,
}: TableBrowserProps) {
const t = useTranslations('admin.database.tableBrowser');
const format = useFormatter();
const [selectedTable, setSelectedTable] = useState<string>('');
const [tableData, setTableData] = useState<TableDataResponse | null>(null);
const [page, setPage] = useState<number>(1);
@@ -215,7 +222,7 @@ export function TableBrowser({
<div>
{t('totalRows')}:{' '}
<span className='font-mono text-foreground font-semibold'>
{format.number(tableData.total)}
{formatNumber(tableData.total)}
</span>{' '}
{t('records')}
</div>
@@ -3,7 +3,7 @@
import dynamic from 'next/dynamic';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useFormatter, useTranslations } from 'next-intl';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
Activity,
@@ -49,9 +49,16 @@ const SQLConsole = dynamic(
{ ssr: false },
);
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
export function DatabasePageClient() {
const t = useTranslations('admin.database');
const format = useFormatter();
// 核心状态
const [overview, setOverview] = useState<DBOverview | null>(null);
const [tables, setTables] = useState<string[]>([]);
@@ -268,7 +275,7 @@ export function DatabasePageClient() {
</CardHeader>
<CardContent>
<div className='text-sm font-bold'>
{format.number(overview.table_count)}
{formatNumber(overview.table_count)}
</div>
</CardContent>
</Card>
@@ -283,7 +290,7 @@ export function DatabasePageClient() {
</CardHeader>
<CardContent>
<div className='text-sm font-bold'>
{format.number(overview.connections)}
{formatNumber(overview.connections)}
</div>
</CardContent>
</Card>
+17 -19
View File
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
@@ -22,8 +22,6 @@ import { TableTab } from './tabs/table-tab';
import { ControlsTab } from './tabs/controls-tab';
export default function HeaderDemoPage() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo');
const [activeTab, setActiveTab] = React.useState('dashboard');
@@ -61,8 +59,7 @@ export default function HeaderDemoPage() {
{t('rules.structureFocus.description')}
</li>
<li>
<strong>{t('rules.containerAlignment.title')}</strong>
{rt('use_1b3d699e')}{' '}
<strong>{t('rules.containerAlignment.title')}</strong>:使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-center gap-2
</code>{' '}
@@ -70,23 +67,22 @@ export default function HeaderDemoPage() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6 px-1
</code>{' '}
{rt('or_07d4e3c0')}{' '}
(或{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6
</code>
) {t('rules.containerAlignment.description2')}
</li>
<li>
<strong>{t('rules.iconPresentation.title')}</strong>
{rt('place_the_lucide_icon_directly_inside_the_104f794c')}{' '}
<strong>{t('rules.iconPresentation.title')}</strong>:直接将
Lucide 图标组件嵌套于标题容器中,应用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
size-5 text-primary
</code>{' '}
{t('rules.iconPresentation.description')}
</li>
<li>
<strong>{t('rules.textStyle.title')}</strong>
{rt('use_only_5e12eecb')}{' '}
<strong>{t('rules.textStyle.title')}</strong>:文本使用且仅使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'h1 className="text-2xl font-semibold tracking-tight"'}
</code>
@@ -97,8 +93,10 @@ export default function HeaderDemoPage() {
{t('rules.textStyle.description2')}
</li>
<li>
<strong>{t('rules.tabsModular.title')}</strong>
{rt('for_pages_with_multiple_tabs_keep_each_5f721959')}{' '}
<strong>{t('rules.tabsModular.title')}</strong>:凡是带有多个
Tab 页切换的页面,**禁止**将所有 Tab
的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的
Content 必须单独拆分为独立组件文件(如{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
tabs/events-tab.tsx
</code>{' '}
@@ -109,8 +107,8 @@ export default function HeaderDemoPage() {
{t('rules.tabsModular.description2')}
</li>
<li>
<strong>{t('rules.complexityDriven.title')}</strong>
{rt('beyond_tabs_when_a_page_exceeds_600_d152cfdc')}
<strong>{t('rules.complexityDriven.title')}</strong>:不仅是
Tabs 切换,当一个页面代码行数超过 600
{t('rules.complexityDriven.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
app/(main)/admin/database/components/
@@ -123,19 +121,19 @@ export default function HeaderDemoPage() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
/admin/database
</code>
{rt('message_18ab4763')}{' '}
) 的{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
table-browser.tsx
</code>
{rt('list_separator')}
、
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
cache-manager.tsx
</code>{' '}
{rt('and_de1d5dd0')}{' '}
与{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
sql-console.tsx
</code>
{rt('sentence_end')}
。
</li>
</ul>
</div>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
@@ -26,8 +26,6 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
export function ControlsTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo.controls');
const [formData, setFormData] = React.useState({
name: '',
@@ -168,71 +166,78 @@ export function ControlsTab() {
{t('spec.unifiedHeight')}
</p>
<p>
{rt('inputs_fabee312')}
输入框 (
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
Input
</code>
{rt('and_selectors_e4875cdf')}
) 与下拉选择器 (
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
SelectTrigger
</code>
{rt('should_share_the_compact_height_f1fe2218')}{' '}
) 的高度应统一限制为小号高度{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-8
</code>
{rt('with_font_size_e7c29991')}{' '}
,字体大小使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs
</code>
{rt('and_use_ca398a12')}{' '}
,并使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
shadow-none
</code>{' '}
{rt('to_remove_overlapping_native_shadows_dd65b475')}
去除原生的重叠阴影。
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
{t('spec.checkboxAlignment')}
</p>
<p>
{rt('align_checkboxes_toward_the_top_left_using_092dcef0')}{' '}
Checkbox 应在左侧垂直偏上对齐,使用容器{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-start gap-2.5
</code>{' '}
{rt('add_bf15da3a')}{' '}
排列。Checkbox 组件本身添加{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
mt-0.5
</code>
{rt('to_the_checkbox_so_multiline_descriptions_stay_1bb85c4b')}
,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
{t('spec.buttonStrategy')}
</p>
<p>
{rt('use_the_default_primary_or_secondary_db7513e5')}{' '}
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'variant="secondary"'}
</code>{' '}
{rt('button_for_forms_and_create_save_actions_a657dba8')}{' '}
按钮,小号高度统一采用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-8 text-xs
</code>
{rt('outline_buttons_must_include_the_66ace005')}{' '}
。线框按钮必须带有{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed shadow-none
</code>{' '}
{rt('class_43eff5ff')}
类。
</p>
</div>
<div className='bg-background/80 p-3 rounded border border-border/40 font-mono text-[10px] space-y-2 overflow-x-auto'>
<div>{rt('1_align_label_and_input_32d4558d')}</div>
<div>{rt.raw('div_classname_space_y_1_5_label_32230ad7')}</div>
<div className='pt-2'>
{rt('2_align_checkbox_to_the_top_bdf68ad8')}
</div>
<div>{rt.raw('div_classname_flex_items_start_gap_2_ccf0d526')}</div>
<div>{`// 1. Label 与 Input 对齐`}</div>
<div>{`<div className="space-y-1.5">
<Label htmlFor="id" className="text-xs font-medium text-foreground">标题</Label>
<Input id="id" className="h-8 text-xs shadow-none" />
</div>`}</div>
<div className='pt-2'>{`// 2. Checkbox 顶对齐`}</div>
<div>{`<div className="flex items-start gap-2.5">
<Checkbox id="c" className="mt-0.5" />
<div className="grid gap-1">
<label htmlFor="c" className="text-xs font-medium leading-none">标题</label>
<p className="text-[10px] text-muted-foreground">多行说明内容</p>
</div>
</div>`}</div>
</div>
</CardContent>
</Card>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Cpu, Database, HardDrive } from 'lucide-react';
@@ -10,8 +10,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
export function DashboardTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo.dashboard');
return (
<div className='space-y-6'>
@@ -131,9 +129,17 @@ export function DashboardTab() {
</li>
</ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
{rt.raw(
'card_classname_border_dashed_shadow_none_cardheader_35727acf',
)}
{`<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">指标标题</span>
<Cpu className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">42.8 %</div>
<Progress value={42.8} className="h-1.5" />
<p className="text-[10px] text-muted-foreground">状态说明小字</p>
</CardContent>
</Card>`}
</pre>
</CardContent>
</Card>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react';
@@ -20,8 +20,6 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function TableTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo.table');
const mockTableData = [
{
@@ -195,7 +193,7 @@ export function TableTab() {
border border-dashed shadow-none rounded-lg overflow-hidden
bg-background
</code>
{rt('sentence_end')}
。
</li>
<li>
{t('spec.headerBg')}
@@ -206,7 +204,7 @@ export function TableTab() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs font-semibold
</code>
{rt('sentence_end')}
。
</li>
<li>
{t('spec.cellDivider')}
@@ -217,14 +215,14 @@ export function TableTab() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
hover:bg-muted/10 transition-colors
</code>
{rt('sentence_end')}
。
</li>
<li>
{t('spec.monoFont')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-mono text-xs
</code>
{rt('sentence_end')}
。
</li>
<li>
{t('spec.badgeStyle')}
@@ -235,7 +233,27 @@ export function TableTab() {
</li>
</ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
{rt.raw('div_classname_border_border_dashed_shadow_none_b430b520')}
{`<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs font-semibold">编号</TableHead>
<TableHead className="text-xs font-semibold">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="border-dashed hover:bg-muted/10 transition-colors">
<TableCell className="font-mono text-xs">ID-1001</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2">
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
成功
</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>`}
</pre>
</CardContent>
</Card>
@@ -1,6 +1,5 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -73,7 +72,7 @@ function getFileIcon(mimeType: string, className = 'size-8') {
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString(getFormattingLocale(), {
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -83,8 +82,6 @@ function formatDate(dateStr: string) {
}
export function FileList() {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.files');
const queryClient = useQueryClient();
const [keyword, setKeyword] = React.useState('');
@@ -567,7 +564,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
{uiText('ui_fileHash')}
Hash (SHA-256)
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.hash}
@@ -50,12 +50,6 @@ const categoryMap: Record<string, string> = {
其他: 'others',
};
// The API retains persisted category keys; labels always follow the UI locale.
function categoryTranslationKey(name: string) {
const key = categoryMap[name] || 'others';
return `stats.cat${key.charAt(0).toUpperCase()}${key.slice(1)}`;
}
function useStatsChartConfig() {
const t = useTranslations('admin.files');
return {
@@ -164,11 +158,11 @@ export function FileStats() {
stats.categories.forEach((cat) => {
if (cat.count > maxCount) {
maxCount = cat.count;
maxCountName = t(categoryTranslationKey(cat.name));
maxCountName = cat.name;
}
if (cat.size > maxSize) {
maxSize = cat.size;
maxSizeName = t(categoryTranslationKey(cat.name));
maxSizeName = cat.name;
}
});
@@ -223,7 +217,7 @@ export function FileStats() {
<div className='text-2xl font-bold font-mono text-sky-500'>
{stats.total_count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'>
{t.raw('stats.countUnit')}
{t('stats.countUnit')}
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
@@ -260,7 +254,7 @@ export function FileStats() {
<div className='text-2xl font-bold font-mono text-indigo-500'>
+{trendSummary.count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'>
{t.raw('stats.countUnit')}
{t('stats.countUnit')}
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
@@ -384,7 +378,7 @@ export function FileStats() {
const formattedValue =
trendMetric === 'size'
? formatFileSize(Number(value))
: `${value} ${t.raw('stats.countUnit')}`;
: `${value} ${t('stats.countUnit')}`;
return (
<>
<div className='flex items-center gap-1.5'>
@@ -494,7 +488,7 @@ export function FileStats() {
</span>
</div>
<span className='text-foreground font-mono font-medium tabular-nums ml-auto'>
{value} {t.raw('stats.countUnit')}
{value} {t('stats.countUnit')}
</span>
</>
);
@@ -501,15 +501,13 @@ function ObjectFields({
value: ObjectStorageConfig;
onChange: (patch: Partial<ObjectStorageConfig>) => void;
}) {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.files');
return (
<>
{driver === 'r2' && (
<div className='md:col-span-2'>
<TextField
label={uiText('ui_accountId')}
label='Account ID'
value={value.account_id || ''}
onChange={(account_id) => onChange({ account_id })}
/>
@@ -518,7 +516,7 @@ function ObjectFields({
{driver !== 's3' && (
<div className='md:col-span-2'>
<TextField
label={uiText('ui_endpointLabel')}
label='Endpoint'
value={value.endpoint}
placeholder='https://...'
onChange={(endpoint) => onChange({ endpoint })}
@@ -526,22 +524,22 @@ function ObjectFields({
</div>
)}
<TextField
label={uiText('ui_regionLabel')}
label='Region'
value={value.region}
onChange={(region) => onChange({ region })}
/>
<TextField
label={uiText('ui_bucketLabel')}
label='Bucket'
value={value.bucket}
onChange={(bucket) => onChange({ bucket })}
/>
<TextField
label={uiText('ui_accessKeyId')}
label='Access Key ID'
value={value.access_key_id}
onChange={(access_key_id) => onChange({ access_key_id })}
/>
<TextField
label={uiText('ui_secretAccessKey')}
label='Secret Access Key'
type='password'
value={value.secret_access_key}
onChange={(secret_access_key) => onChange({ secret_access_key })}
@@ -566,7 +564,7 @@ function ObjectFields({
>
<div className='flex flex-col gap-0.5'>
<FieldLabel className='text-xs font-semibold cursor-pointer'>
{uiText('ui_pathStyle')}
Path Style
</FieldLabel>
<FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'>
{t('storage.pathStyleDesc')}
@@ -17,7 +17,6 @@ limitations under the License.
'use client';
import { useCallback, useEffect, useState } from 'react';
import { isLogStorageDisabledMessage } from './log-storage-error';
import {
BarChart3,
Globe,
@@ -118,7 +117,7 @@ export function AccessAnalytics() {
const errorInstance =
err instanceof Error ? err : new Error(t('fetchFailed'));
const errMsg = errorInstance.message || '';
if (isLogStorageDisabledMessage(errMsg)) {
if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) {
setClickhouseDisabled(true);
} else {
setError(errorInstance);
@@ -1,5 +1,3 @@
'use client';
/*
Copyright 2026 Arctel.net
@@ -16,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use client';
import { useMemo, useState } from 'react';
import { isLogStorageDisabledMessage } from './log-storage-error';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format } from 'date-fns';
@@ -95,12 +94,10 @@ function statusVariant(status: number) {
function isClickhouseDisabledError(error: Error) {
const errMsg = error.message || '';
return isLogStorageDisabledMessage(errMsg);
return errMsg.includes('ClickHouse') || errMsg.includes('未启用');
}
export function AccessLogs() {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.logs.accessLogs');
const [page, setPage] = useState(1);
@@ -522,7 +519,7 @@ export function AccessLogs() {
<div className='grid gap-2 flex-1'>
<div className='flex items-center justify-between'>
<Label className='text-xs font-bold text-muted-foreground'>
{uiText('ui_requestHeaders')}
Request Headers
</Label>
<Button
variant='ghost'
@@ -1,14 +0,0 @@
import { locales } from '@/i18n/config';
import { getRuntimeTranslator } from '@/i18n/runtime';
export function isLogStorageDisabledMessage(message: string): boolean {
const normalized = message.toLowerCase();
return (
message.includes('ClickHouse') ||
locales.some((locale) =>
normalized.includes(
getRuntimeTranslator(locale)('disabled_f95ea7f4').toLowerCase(),
),
)
);
}
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -81,8 +81,6 @@ import type {
import { PushService } from '@/lib/services/push';
export function EventsTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.push.events');
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushEvent | null>(
@@ -708,11 +706,11 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'>
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono'>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{newEventType === 'task'
? t('taskTemplateVars')
: t('eventTemplateVars')}
@@ -723,11 +721,7 @@ export function EventsTab() {
onChange={(e) => setNewEventTemplate(e.target.value)}
rows={6}
className='text-xs font-mono'
placeholder={rt.raw(
newEventType === 'task'
? 'task_notification_template_example'
: 'title_administrator_login_alert_content_administrator_user_6f32cf7c',
)}
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
/>
</div>
@@ -892,11 +886,11 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'>
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono'>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{selectedEvent.task_type
? t('taskTemplateVars')
: t('eventTemplateVars')}
@@ -907,11 +901,7 @@ export function EventsTab() {
onChange={(e) => setEventTemplate(e.target.value)}
rows={6}
className='text-xs font-mono'
placeholder={rt.raw(
selectedEvent.task_type
? 'task_notification_template_example'
: 'title_administrator_login_alert_content_administrator_user_6f32cf7c',
)}
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
/>
</div>
</div>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -62,8 +62,6 @@ import type {
import { PushService } from '@/lib/services/push';
export function SettingsTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.push.settings');
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushChannel | null>(
@@ -608,7 +606,7 @@ export function SettingsTab() {
toast.success(t('loadedFeishuTemplate'));
}}
>
{rt('lark_webhook_c2e0b312')}
飞书 Webhook
</Button>
<Button
variant='outline'
@@ -625,9 +623,7 @@ export function SettingsTab() {
msgtype: 'markdown',
markdown: {
title: '$title',
text: rt(
'title_content_view_details_url_7af4b7d5',
),
text: '### $title\n$content\n\n[查看详情]($url)',
},
},
null,
@@ -637,7 +633,7 @@ export function SettingsTab() {
toast.success(t('loadedDingTalkTemplate'));
}}
>
{rt('dingtalk_group_bot_5d8efec9')}
钉钉群机器人
</Button>
<Button
variant='outline'
@@ -653,9 +649,8 @@ export function SettingsTab() {
{
msgtype: 'markdown',
markdown: {
content: rt(
'title_content_view_details_url_7af4b7d5',
),
content:
'### $title\n$content\n\n[查看详情]($url)',
},
},
null,
@@ -665,7 +660,7 @@ export function SettingsTab() {
toast.success(t('loadedWeChatTemplate'));
}}
>
{rt('wecom_group_bot_138ea7a8')}
企业微信群机器人
</Button>
<Button
variant='outline'
@@ -689,7 +684,7 @@ export function SettingsTab() {
toast.success(t('loadedBarkTemplate'));
}}
>
{rt('ui_barkApp')}
Bark App
</Button>
</div>
</div>
@@ -64,8 +64,6 @@ async function copyText(value: string) {
}
export function OpenFlareOpsSettings() {
const uiText = useTranslations('runtime');
const t = useTranslations('openflareOps');
const queryClient = useQueryClient();
const [fields, setFields] = useState<OpenFlareOpsFields>(
@@ -348,7 +346,7 @@ export function OpenFlareOpsSettings() {
value={geoIPMutation.data.name || '—'}
/>
<InfoCell
label={uiText('ui_isoCode')}
label='ISO Code'
value={geoIPMutation.data.iso_code || '—'}
/>
</div>
@@ -429,7 +427,7 @@ export function OpenFlareOpsSettings() {
</CardHeader>
<CardContent className='space-y-4'>
<FieldInput
label={uiText('ui_serverUrl')}
label='Server URL'
value={fields.server_address}
onChange={(value) => updateField('server_address', value)}
placeholder='https://yourdomain.com'
@@ -1,9 +1,7 @@
'use client';
import * as React from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { normalizeLocale } from '@/i18n/config';
import { formatSystemMetric } from '@/lib/utils/system-metrics';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
@@ -36,12 +34,19 @@ import { Skeleton } from '@/components/ui/skeleton';
import type { SystemStatus } from '@/lib/services/admin';
import services from '@/lib/services';
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
};
/**
* 运行时系统状态展示与管理组件
*/
export function SystemStatusManager() {
const t = useTranslations('openflareOps.status');
const locale = normalizeLocale(useLocale());
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [wavelet, setWavelet] = useState(false);
@@ -159,7 +164,7 @@ export function SystemStatusManager() {
<span
className={`text-xs font-mono font-medium ${field ? getPulseClass(field) : ''}`}
>
{formatSystemMetric(value, locale)}
{typeof value === 'number' ? formatNumber(value) : value}
</span>
</div>
);
+5 -20
View File
@@ -1,8 +1,7 @@
'use client';
import * as React from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { localizeConfigDescription } from '@/i18n/builtin-metadata';
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
@@ -53,7 +52,6 @@ function SystemConfigDetailPanel({
saving: boolean;
}) {
const t = useTranslations('admin.system');
const locale = useLocale();
return (
<ManageDetailPanel isEmpty={!config} onSave={onSave} saving={saving}>
<div className='grid grid-cols-1 gap-0'>
@@ -140,16 +138,8 @@ function SystemConfigDetailPanel({
type='text'
value={
editData.description !== undefined
? localizeConfigDescription(
config?.key ?? '',
editData.description,
locale,
)
: localizeConfigDescription(
config?.key ?? '',
config?.description || '',
locale,
)
? editData.description
: config?.description || ''
}
placeholder={t('optionalDescription')}
onChange={(e) => {
@@ -215,7 +205,6 @@ function SystemConfigDetailPanel({
*/
export function SystemConfigs() {
const t = useTranslations('admin.system');
const locale = useLocale();
const {
systemConfigs: configs,
systemConfigsLoading: loading,
@@ -320,13 +309,9 @@ export function SystemConfigs() {
cell: (item) => (
<span
className='truncate max-w-[200px] inline-block text-muted-foreground'
title={localizeConfigDescription(
item.key,
item.description,
locale,
)}
title={item.description}
>
{localizeConfigDescription(item.key, item.description, locale)}
{item.description}
</span>
),
width: '200px',
@@ -47,6 +47,7 @@ import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
@@ -176,6 +177,7 @@ function DatePickerWithTime({
mode='single'
selected={date}
onSelect={handleDateSelect}
locale={zhCN}
/>
</PopoverContent>
</Popover>
@@ -34,8 +34,6 @@ export function UserDetailSheet({
detailLoading,
onStatusToggle,
}: UserDetailSheetProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.users');
const displayValue = (value?: string) =>
value && value.trim() ? value : '-';
@@ -74,7 +72,7 @@ export function UserDetailSheet({
</Badge>
{selectedUser.is_admin && (
<Badge className='h-4.5 px-1.5 text-[9px] uppercase font-medium'>
{uiText('ui_administrator')}
Admin
</Badge>
)}
</div>
@@ -61,8 +61,6 @@ export function LogDetailSheet({
open,
onOpenChange,
}: LogDetailSheetProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('applyLogs');
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -77,7 +75,7 @@ export function LogDetailSheet({
<div className='flex flex-wrap gap-2'>
<ResultBadge result={log.result} />
<Badge variant='outline' className='text-[10px] rounded-full'>
{uiText('ui_nodeLabel')} {log.node_id}
Node: {log.node_id}
</Badge>
<Badge variant='outline' className='text-[10px] rounded-full'>
{t('versionBadge', { version: log.version })}
@@ -23,7 +23,6 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type {
CloudflareGroup,
CloudflareGroupPayload,
@@ -51,15 +50,7 @@ export function GroupDialog({
() => nodes.filter((node) => node.node_type === 'edge_node'),
[nodes],
);
const recordContentPlaceholders = {
CNAME: t('recordContentPlaceholder.CNAME'),
A: t('recordContentPlaceholder.A'),
AAAA: t('recordContentPlaceholder.AAAA'),
};
const [name, setName] = useState('');
const [targetMode, setTargetMode] = useState<'node' | 'custom'>('node');
const [recordType, setRecordType] = useState<'CNAME' | 'A' | 'AAAA'>('A');
const [recordContent, setRecordContent] = useState('');
const [primaryNodeID, setPrimaryNodeID] = useState('');
const [backupNodeID, setBackupNodeID] = useState('none');
const [defaultProxied, setDefaultProxied] = useState(true);
@@ -68,19 +59,8 @@ export function GroupDialog({
useEffect(() => {
if (!open) return;
setName(group?.name ?? '');
setTargetMode(group?.target_mode === 'custom' ? 'custom' : 'node');
setRecordType(group?.record_type ?? 'A');
setRecordContent(group?.record_content ?? '');
setPrimaryNodeID(
group?.target_mode !== 'custom' && group
? String(group.primary_node.id)
: '',
);
setBackupNodeID(
group?.target_mode !== 'custom' && group?.backup_node
? String(group.backup_node.id)
: 'none',
);
setPrimaryNodeID(group ? String(group.primary_node.id) : '');
setBackupNodeID(group?.backup_node ? String(group.backup_node.id) : 'none');
setDefaultProxied(group?.default_proxied ?? true);
setEnabled(group?.enabled ?? true);
}, [group, open]);
@@ -102,113 +82,46 @@ export function GroupDialog({
/>
</Field>
<Field>
<FieldLabel id='cf-target-mode-label'>{t('targetMode')}</FieldLabel>
<ToggleGroup
type='single'
variant='outline'
value={targetMode}
aria-labelledby='cf-target-mode-label'
className='grid w-full grid-cols-2'
onValueChange={(value) => {
if (value === 'node' || value === 'custom') {
setTargetMode(value);
}
}}
>
<ToggleGroupItem value='node' className='w-full'>
{t('bindNode')}
</ToggleGroupItem>
<ToggleGroupItem value='custom' className='w-full'>
{t('customTarget')}
</ToggleGroupItem>
</ToggleGroup>
<FieldLabel htmlFor='cf-primary-node'>{t('primary')}</FieldLabel>
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
<SelectTrigger id='cf-primary-node' className='w-full'>
<SelectValue placeholder={t('primaryPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{edgeNodes.map((node) => (
<SelectItem
key={node.id}
value={String(node.id)}
disabled={!node.ip}
>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
<SelectTrigger id='cf-backup-node' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='none'>{t('none')}</SelectItem>
{edgeNodes
.filter((node) => String(node.id) !== primaryNodeID)
.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
{targetMode === 'node' ? (
<>
<Field>
<FieldLabel htmlFor='cf-primary-node'>
{t('primary')}
</FieldLabel>
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
<SelectTrigger id='cf-primary-node' className='w-full'>
<SelectValue placeholder={t('primaryPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{edgeNodes.map((node) => (
<SelectItem
key={node.id}
value={String(node.id)}
disabled={!node.ip}
>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
<SelectTrigger id='cf-backup-node' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='none'>{t('none')}</SelectItem>
{edgeNodes
.filter((node) => String(node.id) !== primaryNodeID)
.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</>
) : (
<>
<Field>
<FieldLabel htmlFor='cf-record-type'>
{t('recordType')}
</FieldLabel>
<Select
value={recordType}
onValueChange={(value: 'CNAME' | 'A' | 'AAAA') =>
setRecordType(value)
}
>
<SelectTrigger id='cf-record-type' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='CNAME'>CNAME</SelectItem>
<SelectItem value='A'>A</SelectItem>
<SelectItem value='AAAA'>AAAA</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-record-content'>
{t('recordContent')}
</FieldLabel>
<Input
id='cf-record-content'
value={recordContent}
placeholder={recordContentPlaceholders[recordType]}
onChange={(event) => setRecordContent(event.target.value)}
/>
<p className='text-sm text-muted-foreground'>
{t('recordContentHint')}
</p>
</Field>
</>
)}
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-default-proxied'>
{t('defaultProxied')}
@@ -233,23 +146,13 @@ export function GroupDialog({
{tCommon('cancel')}
</Button>
<Button
disabled={
pending ||
!name.trim() ||
(targetMode === 'node' ? !primaryNodeID : !recordContent.trim())
}
disabled={pending || !name.trim() || !primaryNodeID}
onClick={() =>
onSubmit({
name: name.trim(),
target_mode: targetMode,
record_type: recordType,
record_content: recordContent.trim(),
primary_node_id:
targetMode === 'node' ? Number(primaryNodeID) : 0,
primary_node_id: Number(primaryNodeID),
backup_node_id:
targetMode === 'node' && backupNodeID !== 'none'
? Number(backupNodeID)
: null,
backupNodeID === 'none' ? null : Number(backupNodeID),
default_proxied: defaultProxied,
enabled,
})
@@ -1,145 +0,0 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type {
CloudflareGroup,
CloudflareMember,
} from '@/lib/services/openflare';
export interface MemberMoveDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
members: CloudflareMember[];
groups: CloudflareGroup[];
currentGroupId: number;
pending: boolean;
onSubmit: (targetGroupId: number) => void;
}
export function MemberMoveDialog({
open,
onOpenChange,
members,
groups,
currentGroupId,
pending,
onSubmit,
}: MemberMoveDialogProps) {
const t = useTranslations('cloudflare');
const tCommon = useTranslations('common');
const [targetGroupId, setTargetGroupId] = useState('');
const availableGroups = useMemo(
() => groups.filter((g) => g.id !== currentGroupId),
[groups, currentGroupId],
);
useEffect(() => {
if (!open) return;
setTargetGroupId('');
}, [open]);
const title =
members.length === 1
? t('moveDialog.titleSingle', { domain: members[0]?.domain ?? '' })
: t('moveDialog.titleBatch', { count: members.length });
const description =
members.length > 0
? members.map((m) => m.domain).join(', ')
: t('moveDialog.targetGroupLabel');
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!pending) {
onOpenChange(next);
}
}}
>
<DialogContent
onPointerDownOutside={(e) => {
if (pending) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (pending) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className='line-clamp-2'>
{description}
</DialogDescription>
</DialogHeader>
<FieldGroup>
{availableGroups.length === 0 ? (
<p className='text-sm text-muted-foreground'>
{t('moveDialog.noOtherGroups')}
</p>
) : (
<Field>
<FieldLabel htmlFor='cf-move-target-group'>
{t('moveDialog.targetGroupLabel')}
</FieldLabel>
<Select value={targetGroupId} onValueChange={setTargetGroupId}>
<SelectTrigger id='cf-move-target-group' className='w-full'>
<SelectValue
placeholder={t('moveDialog.targetGroupPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{availableGroups.map((group) => (
<SelectItem key={group.id} value={String(group.id)}>
{group.name}
{group.primary_node?.ip
? ` · ${group.primary_node.ip}`
: ''}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
)}
</FieldGroup>
<DialogFooter>
<Button
variant='outline'
disabled={pending}
onClick={() => onOpenChange(false)}
>
{tCommon('cancel')}
</Button>
<Button
disabled={pending || !targetGroupId || availableGroups.length === 0}
onClick={() => onSubmit(Number(targetGroupId))}
>
{pending ? t('moveDialog.moving') : t('moveDialog.submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -130,8 +130,6 @@ function statusVariant(status: TaskExecutionStatus) {
}
export function SyncTasksPanel() {
const uiText = useTranslations('runtime');
const t = useTranslations('cloudflare.tasks');
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
@@ -471,9 +469,7 @@ export function SyncTasksPanel() {
) : null}
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
{uiText('ui_payload')}
</div>
<div className='text-xs text-muted-foreground'>Payload</div>
<pre className='max-h-36 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.payload || '{}'}
</pre>
@@ -3,9 +3,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
ArrowRightLeft,
Cloud,
CloudCog,
Loader2,
Plus,
RefreshCw,
@@ -14,20 +12,10 @@ import {
} from 'lucide-react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { usePathname } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -37,7 +25,6 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import {
Table,
@@ -54,40 +41,18 @@ import {
cloudflareQueryKey,
NodeService,
type CloudflareGroupPayload,
type CloudflareMember,
} from '@/lib/services/openflare';
import { getErrorMessage } from '../../../websites/components/website-utils';
import { GroupDialog } from '../../components/group-dialog';
import { MemberAddDialog } from '../../components/member-add-dialog';
import { MemberMoveDialog } from '../../components/member-move-dialog';
function getGroupIdFromPathname(pathname: string | null): number {
const match = pathname?.match(/^\/cloudflare\/groups\/([^/]+)$/);
return Number(match?.[1]);
}
export function CloudflareGroupDetailPageClient() {
const uiText = useTranslations('runtime');
const t = useTranslations('cloudflare');
const tCommon = useTranslations('common');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const pathname = usePathname();
const groupID = useMemo(() => getGroupIdFromPathname(pathname), [pathname]);
const params = useParams<{ id: string }>();
const groupID = Number(params.id);
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [selectedMemberIDs, setSelectedMemberIDs] = useState<Set<number>>(
() => new Set(),
);
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
const [movingMembers, setMovingMembers] = useState<CloudflareMember[]>([]);
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false);
const detailQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'groups', groupID],
queryFn: () => CloudflareService.getGroup(groupID),
@@ -102,10 +67,6 @@ export function CloudflareGroupDetailPageClient() {
queryKey: ['openflare', 'nodes'],
queryFn: () => NodeService.listNodes(),
});
const groupsQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'groups'],
queryFn: () => CloudflareService.listGroups(),
});
const invalidate = async () =>
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
@@ -179,92 +140,14 @@ export function CloudflareGroupDetailPageClient() {
const removeMutation = useMutation({
mutationFn: (memberID: number) =>
CloudflareService.removeMember(groupID, memberID),
onSuccess: async (_, memberID) => {
onSuccess: async () => {
toast.success(t('memberDeleted'));
setSelectedMemberIDs((prev) => {
const next = new Set(prev);
next.delete(memberID);
return next;
});
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const moveMutation = useMutation({
mutationFn: async ({
targetGroupId,
membersToMove,
}: {
targetGroupId: number;
membersToMove: CloudflareMember[];
}) => {
if (membersToMove.length === 1) {
return CloudflareService.moveMember(
groupID,
membersToMove[0].id,
targetGroupId,
);
}
return CloudflareService.batchMoveMembers(
groupID,
membersToMove.map((m) => m.id),
targetGroupId,
);
},
onSuccess: async (_, variables) => {
if (variables.membersToMove.length === 1) {
toast.success(t('memberMoved'));
} else {
toast.success(
t('batchMoved', { count: variables.membersToMove.length }),
);
}
setMoveDialogOpen(false);
setSelectedMemberIDs((prev) => {
const next = new Set(prev);
for (const m of variables.membersToMove) {
next.delete(m.id);
}
return next;
});
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const batchRemoveMutation = useMutation({
mutationFn: (memberIds: number[]) =>
CloudflareService.batchRemoveMembers(groupID, memberIds),
onSuccess: async (_, memberIds) => {
toast.success(t('batchRemoved', { count: memberIds.length }));
setBatchRemoveOpen(false);
setSelectedMemberIDs(new Set());
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const batchEnableProxyMutation = useMutation({
mutationFn: (memberIds: number[]) =>
CloudflareService.batchEnableProxy(groupID, memberIds),
onSuccess: async (_, memberIds) => {
toast.success(t('batchProxyEnabled', { count: memberIds.length }));
setSelectedMemberIDs(new Set());
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const handleSingleMove = (member: CloudflareMember) => {
setMovingMembers([member]);
setMoveDialogOpen(true);
};
const handleBatchMove = (selectedList: CloudflareMember[]) => {
if (selectedList.length === 0) return;
setMovingMembers(selectedList);
setMoveDialogOpen(true);
};
if (!mounted || detailQuery.isLoading)
if (detailQuery.isLoading)
return (
<div className='w-full py-6 px-1'>
<LoadingStateWithBorder icon={Cloud} description={t('loadingDetail')} />
@@ -281,12 +164,6 @@ export function CloudflareGroupDetailPageClient() {
);
const { group, members } = detailQuery.data;
const selectedMembers = members.filter((m) => selectedMemberIDs.has(m.id));
const allSelected =
members.length > 0 && selectedMembers.length === members.length;
const isIndeterminate =
selectedMembers.length > 0 && selectedMembers.length < members.length;
return (
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<div className='flex flex-col gap-4'>
@@ -337,92 +214,31 @@ export function CloudflareGroupDetailPageClient() {
<CardHeader>
<CardTitle className='text-base'>{t('currentPoint')}</CardTitle>
<CardDescription>
{group.target_mode === 'custom'
? t('customTargetSummary', {
type: group.record_type,
content: group.record_content,
})
: t('activeNode', {
name: group.active_node.name,
ip: group.active_node.ip,
})}
{t('activeNode', {
name: group.active_node.name,
ip: group.active_node.ip,
})}
</CardDescription>
</CardHeader>
<CardContent className='flex flex-wrap gap-2'>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? t('syncEnabled') : t('syncDisabled')}
</Badge>
{group.target_mode === 'node' ? (
<>
<Badge variant='outline'>
{t('primaryNode', { name: group.primary_node.name })}
</Badge>
<Badge variant='outline'>
{t('backupNode', {
name: group.backup_node?.name ?? t('backupUnset'),
})}
</Badge>
</>
) : null}
<Badge variant='outline'>
{t(
group.target_mode === 'custom'
? 'customTargetMode'
: 'nodeTargetMode',
)}
{t('primaryNode', { name: group.primary_node.name })}
</Badge>
<Badge variant='outline'>
{t('backupNode', {
name: group.backup_node?.name ?? t('backupUnset'),
})}
</Badge>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div>
<CardTitle className='text-base'>{t('members')}</CardTitle>
<CardDescription>{t('membersDesc')}</CardDescription>
</div>
{selectedMembers.length > 0 && (
<div className='flex flex-wrap items-center gap-2'>
<Badge variant='secondary' className='text-xs'>
{t('selectedCount', { count: selectedMembers.length })}
</Badge>
<Button
variant='outline'
size='sm'
onClick={() =>
batchEnableProxyMutation.mutate(
selectedMembers.map((member) => member.id),
)
}
disabled={batchEnableProxyMutation.isPending}
>
<CloudCog data-icon='inline-start' />
{t('batchEnableProxy')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handleBatchMove(selectedMembers)}
>
<ArrowRightLeft data-icon='inline-start' />
{t('batchMove')}
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => setBatchRemoveOpen(true)}
>
<Trash2 data-icon='inline-start' />
{t('batchRemove')}
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => setSelectedMemberIDs(new Set())}
>
{t('clearSelection')}
</Button>
</div>
)}
<CardHeader>
<CardTitle className='text-base'>{t('members')}</CardTitle>
<CardDescription>{t('membersDesc')}</CardDescription>
</CardHeader>
<CardContent>
{members.length === 0 ? (
@@ -433,27 +249,6 @@ export function CloudflareGroupDetailPageClient() {
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-12'>
<Checkbox
checked={
allSelected
? true
: isIndeterminate
? 'indeterminate'
: false
}
onCheckedChange={(checked) => {
if (checked) {
setSelectedMemberIDs(
new Set(members.map((m) => m.id)),
);
} else {
setSelectedMemberIDs(new Set());
}
}}
aria-label={uiText('ui_selectAll')}
/>
</TableHead>
<TableHead>{t('columns.domain')}</TableHead>
<TableHead>{t('columns.desiredIp')}</TableHead>
<TableHead>{t('columns.status')}</TableHead>
@@ -465,29 +260,7 @@ export function CloudflareGroupDetailPageClient() {
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow
key={member.id}
data-state={
selectedMemberIDs.has(member.id) ? 'selected' : undefined
}
>
<TableCell className='w-12'>
<Checkbox
checked={selectedMemberIDs.has(member.id)}
onCheckedChange={(checked) => {
setSelectedMemberIDs((prev) => {
const next = new Set(prev);
if (checked) {
next.add(member.id);
} else {
next.delete(member.id);
}
return next;
});
}}
aria-label={`Select ${member.domain}`}
/>
</TableCell>
<TableRow key={member.id}>
<TableCell>
<div className='font-medium'>{member.domain}</div>
{member.last_error ? (
@@ -527,30 +300,19 @@ export function CloudflareGroupDetailPageClient() {
<div className='flex justify-end gap-2'>
<Button
variant='outline'
size='icon'
title={t('move')}
aria-label={t('move')}
onClick={() => handleSingleMove(member)}
>
<ArrowRightLeft />
</Button>
<Button
variant='outline'
size='icon'
title={t('sync')}
aria-label={t('sync')}
size='sm'
onClick={() => syncMutation.mutate(member.id)}
>
<RefreshCw />
<RefreshCw data-icon='inline-start' />
{t('sync')}
</Button>
<Button
variant='destructive'
size='icon'
title={t('remove')}
aria-label={t('remove')}
size='sm'
onClick={() => removeMutation.mutate(member.id)}
>
<Trash2 />
<Trash2 data-icon='inline-start' />
{t('remove')}
</Button>
</div>
</TableCell>
@@ -580,43 +342,6 @@ export function CloudflareGroupDetailPageClient() {
addMutation.mutate({ domainIDs, proxied })
}
/>
<MemberMoveDialog
open={moveDialogOpen}
onOpenChange={setMoveDialogOpen}
members={movingMembers}
groups={groupsQuery.data ?? []}
currentGroupId={groupID}
pending={moveMutation.isPending}
onSubmit={(targetGroupId) =>
moveMutation.mutate({
targetGroupId,
membersToMove: movingMembers,
})
}
/>
<AlertDialog open={batchRemoveOpen} onOpenChange={setBatchRemoveOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('batchRemoveDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('batchRemoveDialog.desc', { count: selectedMembers.length })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
<AlertDialogAction
disabled={batchRemoveMutation.isPending}
onClick={() =>
batchRemoveMutation.mutate(selectedMembers.map((m) => m.id))
}
>
{batchRemoveMutation.isPending
? t('batchRemoveDialog.removing')
: t('batchRemoveDialog.confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -6,7 +6,7 @@ import { LoadingStateWithBorder } from '@/components/layout/loading';
import { CloudflareGroupDetailPageClient } from './page-client';
/** Placeholder for static export; real ids resolve client-side from the pathname. */
/** Placeholder for static export; real ids resolve client-side via useParams. */
export async function generateStaticParams() {
return [{ id: '1' }];
}
+1 -6
View File
@@ -182,12 +182,7 @@ export default function CloudflarePage() {
<div>
<CardTitle className='text-base'>{group.name}</CardTitle>
<CardDescription>
{group.target_mode === 'custom'
? t('customTargetSummary', {
type: group.record_type,
content: group.record_content,
})
: `${group.active_node.name} · ${group.active_node.ip}`}
{group.active_node.name} · {group.active_node.ip}
</CardDescription>
</div>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
@@ -1,6 +1,5 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart';
@@ -17,7 +16,7 @@ function buildNodeRankItems(
const leftValue = selector(left);
const rightValue = selector(right);
if (leftValue === rightValue) {
return left.name.localeCompare(right.name, getFormattingLocale());
return left.name.localeCompare(right.name, 'zh-CN');
}
return rightValue - leftValue;
})
@@ -1,6 +1,5 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import type { EChartsCoreOption } from 'echarts/core';
import * as echarts from 'echarts/core';
@@ -507,7 +506,7 @@ export function WorldStageMap({
) {
return [
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
`<div>${t('sourceRequests', { count: data.value.toLocaleString(getFormattingLocale()) })}</div>`,
`<div>${t('sourceRequests', { count: data.value.toLocaleString('zh-CN') })}</div>`,
].join('');
}
@@ -523,8 +522,8 @@ export function WorldStageMap({
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`,
`<div>${t('requestsErrors', {
requests: data.requestCount.toLocaleString(getFormattingLocale()),
errors: data.errorCount.toLocaleString(getFormattingLocale()),
requests: data.requestCount.toLocaleString('zh-CN'),
errors: data.errorCount.toLocaleString('zh-CN'),
})}</div>`,
`<div>${t('eventsStatus', {
events: data.activeEventCount,
@@ -1,6 +1,3 @@
'use client';
import { useTranslations } from 'next-intl';
import type { LucideIcon } from 'lucide-react';
import {
@@ -16,8 +13,6 @@ interface PlaceholderPageProps {
}
export function PlaceholderPage({ title, icon: Icon }: PlaceholderPageProps) {
const rt = useTranslations('runtime');
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center gap-2'>
@@ -27,12 +22,8 @@ export function PlaceholderPage({ title, icon: Icon }: PlaceholderPageProps) {
<Card>
<CardHeader>
<CardTitle className='text-base'>
{rt('under_development_78c33fbf')}
</CardTitle>
<CardDescription>
{rt('this_feature_is_being_migrated_and_developed_61ebaf78')}
</CardDescription>
<CardTitle className='text-base'>开发中</CardTitle>
<CardDescription>该功能正在迁移开发中,敬请期待。</CardDescription>
</CardHeader>
</Card>
</div>
@@ -64,7 +64,6 @@ const nodesQueryKey = ['openflare', 'nodes'];
export function EdgeNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const rt = useTranslations('runtime');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
@@ -321,7 +320,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel={rt('node_type_edge')}
typeLabel='Edge'
typeTone='info'
statusBadges={[
{
@@ -1,148 +0,0 @@
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { NodeStatus } from '@/lib/services/openflare';
export function NodeListControls({
search,
searchTooLong,
onSearchChange,
status,
onStatusChange,
page,
pageSize,
onPageSizeChange,
total,
totalPages,
loading,
hasData,
onPageChange,
}: {
search: string;
searchTooLong: boolean;
onSearchChange: (value: string) => void;
status: NodeStatus | '';
onStatusChange: (value: NodeStatus | '') => void;
page: number;
pageSize: number;
onPageSizeChange: (value: number) => void;
total: number;
totalPages: number;
loading: boolean;
hasData: boolean;
onPageChange: (value: number) => void;
}) {
const t = useTranslations('nodes');
return (
<div className='flex flex-col gap-4'>
<FieldGroup className='flex flex-wrap gap-3 sm:flex-row'>
<Field className='flex-1' data-invalid={searchTooLong || undefined}>
<FieldLabel htmlFor='node-search'>
{t('pagination.search')}
</FieldLabel>
<Input
id='node-search'
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={t('pagination.searchPlaceholder')}
maxLength={256}
aria-invalid={searchTooLong || undefined}
aria-describedby={searchTooLong ? 'node-search-error' : undefined}
/>
{searchTooLong && (
<FieldError id='node-search-error'>
{t('pagination.searchTooLong')}
</FieldError>
)}
</Field>
<Field className='sm:w-44'>
<FieldLabel htmlFor='node-status'>{t('table.status')}</FieldLabel>
<Select
value={status || 'all'}
onValueChange={(value) =>
onStatusChange(value === 'all' ? '' : (value as NodeStatus))
}
>
<SelectTrigger id='node-status'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='all'>
{t('pagination.allStatuses')}
</SelectItem>
{(['pending', 'online', 'offline'] as const).map((value) => (
<SelectItem key={value} value={value}>
{t(`status.${value}`)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field className='sm:w-32'>
<FieldLabel htmlFor='node-page-size'>
{t('pagination.pageSize')}
</FieldLabel>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger id='node-page-size'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[20, 50, 100].map((value) => (
<SelectItem key={value} value={String(value)}>
{value}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
<div className='flex flex-wrap items-center justify-between gap-3'>
{hasData && (
<p className='text-sm text-muted-foreground' aria-live='polite'>
{t('pagination.summary', { page, totalPages, total })}
</p>
)}
<nav aria-label={t('pagination.navigation')} className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={!hasData || loading || page <= 1}
onClick={() => onPageChange(page - 1)}
>
{t('pagination.previous')}
</Button>
<Button
variant='outline'
size='sm'
disabled={!hasData || loading || page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
{t('pagination.next')}
</Button>
</nav>
</div>
</div>
);
}
@@ -1,6 +1,5 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Trash2 } from 'lucide-react';
@@ -653,9 +652,7 @@ export function NodeObservability({
hint={
topSourceCountry
? t('obs.requestTimes', {
count: topSourceCountry.value.toLocaleString(
getFormattingLocale(),
),
count: topSourceCountry.value.toLocaleString('zh-CN'),
})
: t('obs.noSourceDist')
}
@@ -64,14 +64,13 @@ export function getFilterDescription(
export function NodeTypeFilter() {
const t = useTranslations('nodes');
const rt = useTranslations('runtime');
const searchParams = useSearchParams();
const activeFilter = getNodeFilter(searchParams);
const filterLabels: Record<NodeFilter, string> = {
all: t('filter.all'),
edge: rt('node_type_edge'),
relay: rt('node_type_relay'),
tunnel: rt('node_type_tunnel'),
edge: 'Edge',
relay: 'Relay',
tunnel: 'Tunnel',
};
return (
@@ -1,5 +1,3 @@
import { getFormattingLocale } from '@/i18n/format';
import { runtimeText } from '@/i18n/runtime';
import type {
ApplyResult,
NodeItem,
@@ -113,9 +111,9 @@ export function getRelayStatusLabel(
}
export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
if (nodeType === 'tunnel_relay') return runtimeText('node_type_relay');
if (nodeType === 'tunnel_client') return runtimeText('node_type_tunnel');
return runtimeText('node_type_edge');
if (nodeType === 'tunnel_relay') return 'Relay';
if (nodeType === 'tunnel_client') return 'Tunnel';
return 'Edge';
}
export function getErrorMessage(error: unknown, fallback: string) {
@@ -257,7 +255,7 @@ export function formatMetricCount(value?: number | null) {
if (value === undefined || value === null || !Number.isFinite(value)) {
return '—';
}
return value.toLocaleString(getFormattingLocale());
return value.toLocaleString('zh-CN');
}
export function formatBytesPerSecond(value?: number | null, windowSeconds = 1) {
@@ -37,20 +37,15 @@ export function NodesTable({
deletingId,
onEdit,
onDelete,
actionsDisabled = false,
}: {
nodes: NodeItem[];
deletingId: number | null;
actionsDisabled?: boolean;
onEdit: (node: NodeItem) => void;
onDelete: (node: NodeItem) => void;
}) {
const t = useTranslations('nodes');
return (
<div
className='border border-dashed rounded-lg overflow-hidden'
aria-busy={actionsDisabled}
>
<div className='border border-dashed rounded-lg overflow-hidden'>
<Table>
<TableHeader>
<TableRow className='border-dashed hover:bg-transparent'>
@@ -149,22 +144,16 @@ export function NodesTable({
variant='outline'
size='sm'
className='h-7 text-xs'
asChild={!actionsDisabled}
disabled={actionsDisabled}
asChild
>
{actionsDisabled ? (
t('detail')
) : (
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
)}
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
</Button>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
disabled={actionsDisabled}
onClick={() => onEdit(node)}
>
<Pencil className='size-3 mr-1' />
@@ -174,7 +163,7 @@ export function NodesTable({
variant='outline'
size='sm'
className='h-7 text-xs text-destructive hover:text-destructive'
disabled={actionsDisabled || deletingId === node.id}
disabled={deletingId === node.id}
onClick={() => onDelete(node)}
>
<Trash2 className='size-3 mr-1' />
@@ -79,8 +79,6 @@ function systemConfigMap(configs: SystemConfig[]) {
}
export function RelayNodeDetail({ node }: { node: NodeItem }) {
const rt = useTranslations('runtime');
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
@@ -118,9 +116,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
'relay_frps_web_ui_port',
{
value: String(portNum),
description:
portCfg?.description ||
rt('port_for_the_built_in_frps_web_cfab540f'),
description: portCfg?.description || 'FRPS 内置 Web 管理界面端口',
},
);
},
@@ -489,7 +485,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel={rt('node_type_relay')}
typeLabel='Relay'
typeTone='warning'
statusBadges={[
{
@@ -65,7 +65,6 @@ const nodesQueryKey = ['openflare', 'nodes'];
export function TunnelNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const rt = useTranslations('runtime');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
@@ -314,7 +313,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel={rt('node_type_tunnel')}
typeLabel='Tunnel'
typeTone='info'
statusBadges={[
{
@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Server } from 'lucide-react';
@@ -24,20 +25,20 @@ export function NodeDetailPageClient() {
const searchParams = useSearchParams();
const nodeId = searchParams.get('id')?.trim() ?? '';
const validNodeId =
Number.isSafeInteger(Number(nodeId)) && Number(nodeId) > 0;
const nodesQuery = useQuery({
queryKey: [...nodesQueryKey, 'detail', nodeId],
queryFn: () => NodeService.getNode(Number(nodeId)),
enabled: validNodeId,
refetchInterval: (query) =>
query.state.status === 'error' ? false : 15000,
refetchIntervalInBackground: false,
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
});
const node = nodesQuery.data;
const node = useMemo(() => {
if (!nodeId) return null;
return (
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
);
}, [nodeId, nodesQuery.data]);
if (!validNodeId) {
if (!nodeId) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder icon={Server} description={t('missingId')} />
+26 -136
View File
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
import { useTranslations } from 'next-intl';
@@ -29,112 +29,46 @@ import {
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type {
NodeItem,
NodeListQuery,
NodeMutationPayload,
NodeStatus,
NodeType,
} from '@/lib/services/openflare';
import type { NodeItem, NodeMutationPayload } from '@/lib/services/openflare';
import { NodeService } from '@/lib/services/openflare';
import { NodeEditorDialog } from './components/node-editor-dialog';
import {
filterNodesByType,
getFilterDescription,
getNodeFilter,
NodeTypeFilter,
type NodeFilter,
} from './components/node-type-filter';
import { NodesTable } from './components/nodes-table';
import { NodeListControls } from './components/node-list-controls';
import { getErrorMessage } from './components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function NodesPageClient() {
const searchParams = useSearchParams();
const nodeFilter = getNodeFilter(
new URLSearchParams(searchParams.toString()),
);
return <NodesPage key={nodeFilter} nodeFilter={nodeFilter} />;
}
function NodesPage({ nodeFilter }: { nodeFilter: NodeFilter }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<NodeItem | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [status, setStatus] = useState<NodeStatus | ''>('');
const searchTooLong = new TextEncoder().encode(search.trim()).length > 256;
const resetNodeActions = useCallback(() => {
setEditingNode(null);
setEditorOpen(false);
setDeleteTarget(null);
}, []);
const nodeFilter = useMemo(
() => getNodeFilter(new URLSearchParams(searchParams.toString())),
[searchParams],
);
useEffect(() => {
if (searchTooLong || search.trim() === debouncedSearch) return;
const timeout = setTimeout(() => {
setDebouncedSearch(search.trim());
setPage(1);
resetNodeActions();
}, 300);
return () => clearTimeout(timeout);
}, [search, searchTooLong, debouncedSearch, resetNodeActions]);
const nodeTypes: Record<NodeFilter, NodeType | ''> = {
all: '',
edge: 'edge_node',
relay: 'tunnel_relay',
tunnel: 'tunnel_client',
};
const query = {
page,
page_size: pageSize,
search: debouncedSearch,
status,
node_type: nodeTypes[nodeFilter],
};
const nodesQuery = useQuery({
queryKey: [...nodesQueryKey, 'page', query],
queryFn: () => NodeService.listNodePage(query),
placeholderData: (previousData, previousQuery) => {
const previousParams = previousQuery?.queryKey[3] as
| NodeListQuery
| undefined;
return previousParams?.search === query.search &&
previousParams?.status === query.status &&
previousParams?.node_type === query.node_type &&
previousParams?.page_size === query.page_size
? previousData
: undefined;
},
refetchInterval: (query) =>
query.state.status === 'error' ? false : 15000,
refetchIntervalInBackground: false,
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
});
const nodes = useMemo(
() => nodesQuery.data?.results ?? [],
[nodesQuery.data],
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const filteredNodes = useMemo(
() => filterNodesByType(nodes, nodeFilter),
[nodeFilter, nodes],
);
const total = nodesQuery.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (
!nodesQuery.isPlaceholderData &&
nodesQuery.data?.page === page &&
page > totalPages
) {
setPage(totalPages);
resetNodeActions();
}
const saveMutation = useMutation({
mutationFn: async (payload: NodeMutationPayload) => {
@@ -172,7 +106,6 @@ function NodesPage({ nodeFilter }: { nodeFilter: NodeFilter }) {
};
const handleEdit = (node: NodeItem) => {
if (nodesQuery.isPlaceholderData) return;
setEditingNode(node);
setEditorOpen(true);
};
@@ -182,7 +115,7 @@ function NodesPage({ nodeFilter }: { nodeFilter: NodeFilter }) {
};
return (
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Server className='size-5 text-primary' />
@@ -233,34 +166,8 @@ function NodesPage({ nodeFilter }: { nodeFilter: NodeFilter }) {
</Button>
</div>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
<CardContent className='space-y-4'>
<NodeTypeFilter />
<NodeListControls
search={search}
searchTooLong={searchTooLong}
onSearchChange={setSearch}
status={status}
onStatusChange={(value) => {
setStatus(value);
setPage(1);
resetNodeActions();
}}
page={nodesQuery.data?.page ?? page}
pageSize={pageSize}
onPageSizeChange={(value) => {
setPageSize(value);
setPage(1);
resetNodeActions();
}}
total={total}
totalPages={totalPages}
loading={nodesQuery.isFetching || nodesQuery.isPlaceholderData}
hasData={Boolean(nodesQuery.data)}
onPageChange={(value) => {
setPage(value);
resetNodeActions();
}}
/>
{nodesQuery.isLoading ? (
<LoadingStateWithBorder
@@ -275,26 +182,21 @@ function NodesPage({ nodeFilter }: { nodeFilter: NodeFilter }) {
className='justify-center'
/>
</div>
) : nodes.length === 0 ? (
) : filteredNodes.length === 0 ? (
<EmptyStateWithBorder
icon={Server}
description={
debouncedSearch || status || nodeFilter !== 'all'
? t('emptyFilter')
: t('emptyAll')
nodes.length === 0 ? t('emptyAll') : t('emptyFilter')
}
/>
) : (
<NodesTable
nodes={nodes}
nodes={filteredNodes}
deletingId={
deleteMutation.isPending ? (deleteTarget?.id ?? null) : null
}
onEdit={handleEdit}
onDelete={(node) => {
if (!nodesQuery.isPlaceholderData) setDeleteTarget(node);
}}
actionsDisabled={nodesQuery.isPlaceholderData}
onDelete={setDeleteTarget}
/>
)}
</CardContent>
@@ -303,17 +205,13 @@ function NodesPage({ nodeFilter }: { nodeFilter: NodeFilter }) {
<NodeEditorDialog
open={editorOpen}
node={editingNode}
submitting={
saveMutation.isPending ||
(Boolean(editingNode) && nodesQuery.isPlaceholderData)
}
submitting={saveMutation.isPending}
onClose={() => {
setEditorOpen(false);
setEditingNode(null);
}}
onSubmit={async (payload) => {
if (!editingNode || !nodesQuery.isPlaceholderData)
await saveMutation.mutateAsync(payload);
await saveMutation.mutateAsync(payload);
}}
/>
@@ -329,22 +227,14 @@ function NodesPage({ nodeFilter }: { nodeFilter: NodeFilter }) {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={
deleteMutation.isPending || nodesQuery.isPlaceholderData
}
>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={
deleteMutation.isPending || nodesQuery.isPlaceholderData
}
disabled={deleteMutation.isPending}
onClick={() =>
deleteTarget &&
!nodesQuery.isPlaceholderData &&
deleteMutation.mutate(deleteTarget.id)
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
@@ -92,7 +92,7 @@ function sourceLabel(
) {
const key = SOURCE_LABEL_KEYS[type];
if (key) return t(key);
return type === 'remote_url' ? t('history.source.remote_url') : 'GitHub';
return type === 'remote_url' ? 'Remote URL' : 'GitHub';
}
function deploymentSnapshot(
@@ -230,8 +230,6 @@ export function DeploymentHistory({
rootDir = '',
entryFile = 'index.html',
}: DeploymentHistoryProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('pages');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
@@ -403,7 +401,7 @@ export function DeploymentHistory({
<div className='flex flex-col gap-6'>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>{uiText('ui_production')}</CardTitle>
<CardTitle className='text-base'>Production</CardTitle>
<CardDescription>{t('history.productionHint')}</CardDescription>
<CardAction>
<Button
@@ -442,9 +440,7 @@ export function DeploymentHistory({
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>
{uiText('ui_allDeployments')}
</CardTitle>
<CardTitle className='text-base'>All deployments</CardTitle>
<CardDescription>{t('history.historyHint')}</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
@@ -94,8 +94,6 @@ export function PagesSourceDialog({
initialMode,
onActionDispatched,
}: PagesSourceDialogProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('pages.source');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
@@ -363,10 +361,10 @@ export function PagesSourceDialog({
{t('manual')}
</ToggleGroupItem>
<ToggleGroupItem value='remote_url' className='w-full'>
{uiText('ui_remoteUrl')}
Remote URL
</ToggleGroupItem>
<ToggleGroupItem value='github_release' className='w-full'>
{uiText('ui_githubRelease')}
GitHub Release
</ToggleGroupItem>
</ToggleGroup>
</Field>
@@ -381,9 +379,7 @@ export function PagesSourceDialog({
) : mode === 'remote_url' ? (
<>
<Field data-invalid={Boolean(urlError)}>
<FieldLabel htmlFor='pages-remote-url'>
{uiText('ui_remoteUrl')}
</FieldLabel>
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
<Input
id='pages-remote-url'
type='url'
@@ -1,5 +1,3 @@
'use client';
import { useTranslations } from 'next-intl';
import {
@@ -46,8 +44,6 @@ export function PagesSourceGitHubFields({
onChange,
onErrorsChange,
}: PagesSourceGitHubFieldsProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('pages.source');
return (
<>
@@ -111,9 +107,7 @@ export function PagesSourceGitHubFields({
{value.releaseSelector === 'tag' ? (
<Field data-invalid={Boolean(errors.releaseTag)}>
<FieldLabel htmlFor='pages-github-tag'>
{uiText('ui_releaseTag')}
</FieldLabel>
<FieldLabel htmlFor='pages-github-tag'>Release tag</FieldLabel>
<Input
id='pages-github-tag'
placeholder='v1.2.3'
@@ -56,7 +56,7 @@ export const defaultPerformanceFields: PerformanceFields = {
openresty_proxy_read_timeout: '60',
openresty_websocket_enabled: true,
openresty_http3_enabled: true,
openresty_proxy_request_buffering_enabled: true,
openresty_proxy_request_buffering_enabled: false,
openresty_proxy_buffering_enabled: true,
openresty_proxy_buffers: '16 16k',
openresty_proxy_buffer_size: '8k',
@@ -133,7 +133,7 @@ export function mapOptionsToFields(
),
openresty_proxy_request_buffering_enabled: toBoolean(
optionMap.openresty_proxy_request_buffering_enabled,
true,
false,
),
openresty_proxy_buffering_enabled: toBoolean(
optionMap.openresty_proxy_buffering_enabled,
+1 -8
View File
@@ -345,13 +345,6 @@ export default function PerformancePage() {
updateField('openresty_proxy_buffering_enabled', v)
}
/>
<ToggleRow
label={t('proxyRequestBuffering')}
checked={fields.openresty_proxy_request_buffering_enabled}
onChange={(v) =>
updateField('openresty_proxy_request_buffering_enabled', v)
}
/>
<FieldInput
label='proxy_buffers'
value={fields.openresty_proxy_buffers}
@@ -506,7 +499,7 @@ function ToggleRow({
return (
<div className='flex items-center justify-between rounded-lg border border-dashed px-3 py-2'>
<Label className='text-xs'>{label}</Label>
<Switch checked={checked} onCheckedChange={onChange} aria-label={label} />
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
}
@@ -1,4 +1,3 @@
import { runtimeText } from '@/i18n/runtime';
import type {
ProxyRouteConfigSection,
ProxyRouteCustomHeader,
@@ -155,8 +154,7 @@ const limitRatePattern = /^\d+(?:[kKmM])?$/;
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ??
runtimeText('request_failed_please_try_again_later_8977b79a'));
: (fallback ?? '请求失败,请稍后重试。');
}
export function linesFromTextarea(value: string) {
@@ -382,7 +380,6 @@ export function buildPayloadFromRoute(
upstreams: (route.upstream_list ?? []).slice(1),
enabled: route.enabled,
enable_https: route.enable_https,
enable_http2: route.enable_http2,
redirect_http: route.redirect_http,
limit_conn_per_server: route.limit_conn_per_server,
limit_conn_per_ip: route.limit_conn_per_ip,
@@ -24,7 +24,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type { ProxyRouteItem } from '@/lib/services/openflare';
import { NodeService, PagesService } from '@/lib/services/openflare';
@@ -51,7 +50,6 @@ type ReverseProxyValues = {
tunnel_target_protocol?: 'http' | 'https';
pages_project_id?: string;
custom_headers_text: string;
enable_http2: boolean;
};
interface ProxySectionProps {
@@ -65,8 +63,6 @@ export function ProxySection({
onRouteUpdate,
onSavingChange,
}: ProxySectionProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('proxyRoutes');
const reverseProxySchema = z
.object({
@@ -78,7 +74,6 @@ export function ProxySection({
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
pages_project_id: z.string().optional(),
custom_headers_text: z.string(),
enable_http2: z.boolean(),
})
.superRefine((value, context) => {
if (value.upstream_type === 'direct') {
@@ -179,7 +174,6 @@ export function ProxySection({
? String(route.pages_project_id)
: '',
custom_headers_text: customHeadersToText(route.custom_header_list),
enable_http2: route.enable_http2 ?? true,
},
});
@@ -196,7 +190,6 @@ export function ProxySection({
? String(route.pages_project_id)
: '',
custom_headers_text: customHeadersToText(route.custom_header_list),
enable_http2: route.enable_http2 ?? true,
});
}, [form, route]);
@@ -274,7 +267,6 @@ export function ProxySection({
values.upstream_type === 'pages' && values.pages_project_id
? Number(values.pages_project_id)
: null,
enable_http2: values.enable_http2,
},
t('proxySaved'),
);
@@ -314,25 +306,6 @@ export function ProxySection({
)}
/>
<FormField
control={form.control}
name='enable_http2'
render={({ field }) => (
<FormItem className='flex items-center justify-between rounded-lg border p-3'>
<div className='space-y-1'>
<FormLabel>{t('enableHttp2')}</FormLabel>
<FormDescription>{t('enableHttp2Desc')}</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{upstreamType === 'direct' ? (
<FormField
control={form.control}
@@ -481,7 +454,7 @@ export function ProxySection({
name='origin_host'
render={({ field }) => (
<FormItem>
<FormLabel>{uiText('ui_originHostHeader')}</FormLabel>
<FormLabel>Origin Host Header</FormLabel>
<FormControl>
<Input placeholder='origin.example.internal' {...field} />
</FormControl>
@@ -116,8 +116,6 @@ function SortableRule({ id, name, index, total, onMove }: SortableRuleProps) {
}
export function WafSection({ route, onSavingChange }: WafSectionProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('proxyRoutes');
const queryClient = useQueryClient();
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
@@ -222,7 +220,7 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
<div className='flex items-center justify-between gap-3'>
<div>
<p className='text-[10px] font-semibold uppercase tracking-wider text-muted-foreground'>
{uiText('ui_globalRuleGroup')}
Global Rule Group
</p>
<p className='mt-1 text-sm font-semibold'>
{wafQuery.data.global_rule_group.name}
@@ -1,6 +1,5 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { Gauge } from 'lucide-react';
import { useTranslations } from 'next-intl';
@@ -32,11 +31,9 @@ function toAvgRpsItems(items: DistributionItem[] | undefined, hours: number) {
function formatRps(value: number) {
if (!Number.isFinite(value)) return '—';
if (value >= 100) {
return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 1,
});
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
}
return value.toLocaleString(getFormattingLocale(), {
return value.toLocaleString('zh-CN', {
maximumFractionDigits: 3,
minimumFractionDigits: 0,
});
@@ -1,6 +1,5 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useCallback, useMemo, useRef } from 'react';
import type { EChartsOption } from 'echarts';
import type { EChartsType } from 'echarts/core';
@@ -49,7 +48,7 @@ function formatAxisTime(value: string, hours: number) {
function formatRps(value: number) {
if (!Number.isFinite(value)) return '—';
if (value >= 100) return formatCompactNumber(value);
return value.toLocaleString(getFormattingLocale(), {
return value.toLocaleString('zh-CN', {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
});
+8 -19
View File
@@ -1,10 +1,7 @@
import { runtimeText } from '@/i18n/runtime';
import type { WAFIPGroup, WAFIPGroupPayload } from '@/lib/services/openflare';
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ?? runtimeText('operation_failed_0c3b4cf7'));
return error instanceof Error ? error.message : (fallback ?? '操作失败');
}
export function listToText(items: string[] | undefined) {
@@ -20,9 +17,7 @@ export function parseTextareaList(text: string) {
export function parseAutomaticConfig(
text: string,
invalidMessage = runtimeText(
'automatic_configuration_must_be_a_json_object_90ba17a4',
),
invalidMessage = '自动配置必须是 JSON 对象。',
): Record<string, unknown> {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
@@ -33,16 +28,12 @@ export function parseAutomaticConfig(
export const automaticPresetRules = [
{
get name() {
return runtimeText('high_frequency_404_scan_from_one_ip_f7d27ffb');
},
name: '单 IP 404 高频扫描',
labelKey: 'presets.scan404' as const,
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
},
{
get name() {
return runtimeText('abnormal_direct_access_from_one_ip_732dbeee');
},
name: '单 IP 直连访问异常',
labelKey: 'presets.directAccess' as const,
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
},
@@ -84,7 +75,7 @@ export function formatIPGroupBanRemaining(
): string {
const reference = now ?? new Date();
if (ttlSeconds <= 0) {
return t ? t('ban.permanent') : runtimeText('permanent_3e71ccc8');
return t ? t('ban.permanent') : '永久';
}
const capturedDate = new Date(capturedAt);
if (Number.isNaN(capturedDate.getTime())) {
@@ -92,7 +83,7 @@ export function formatIPGroupBanRemaining(
}
const expireDate = new Date(capturedDate.getTime() + ttlSeconds * 1000);
if (expireDate.getTime() <= reference.getTime()) {
return t ? t('ban.expired') : runtimeText('expired_2fe0e333');
return t ? t('ban.expired') : '已过期';
}
const diffMins = Math.round(
(expireDate.getTime() - reference.getTime()) / (60 * 1000),
@@ -100,12 +91,10 @@ export function formatIPGroupBanRemaining(
if (diffMins < 60) {
return t
? t('ban.minutesLater', { count: diffMins })
: runtimeText('in_value0_minutes_fd2a5b53', { value0: diffMins });
: `${diffMins} 分钟后`;
}
const hours = Math.round(diffMins / 60);
return t
? t('ban.hoursLater', { count: hours })
: runtimeText('in_value0_hours_5ca37144', { value0: hours });
return t ? t('ban.hoursLater', { count: hours }) : `${hours} 小时后`;
}
export function getIPGroupViewEntries(
@@ -1,8 +1,8 @@
'use client';
import { Loader2, Search, Trash2, X } from 'lucide-react';
import { Loader2, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import {
AlertDialog,
@@ -24,7 +24,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
@@ -60,14 +59,6 @@ export function IPGroupViewDialog({
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(
null,
);
const [searchKeyword, setSearchKeyword] = useState('');
useEffect(() => {
if (!open) {
setSearchKeyword('');
setDeleteTarget(null);
}
}, [open]);
const entries = useMemo(
() =>
@@ -77,13 +68,6 @@ export function IPGroupViewDialog({
[group, t],
);
const trimmedKeyword = searchKeyword.trim().toLowerCase();
const filteredEntries = useMemo(() => {
if (!trimmedKeyword) return entries;
return entries.filter((e) => e.ip.toLowerCase().includes(trimmedKeyword));
}, [entries, trimmedKeyword]);
const showAutomaticMeta = group?.type === 'automatic';
return (
@@ -94,7 +78,6 @@ export function IPGroupViewDialog({
onOpenChange(nextOpen);
if (!nextOpen) {
setDeleteTarget(null);
setSearchKeyword('');
}
}}
>
@@ -107,16 +90,10 @@ export function IPGroupViewDialog({
</DialogTitle>
<DialogDescription>
{group
? trimmedKeyword
? t('viewDialog.summaryFiltered', {
type: t(`types.${group.type}`),
count: entries.length,
matched: filteredEntries.length,
})
: t('viewDialog.summary', {
type: t(`types.${group.type}`),
count: entries.length,
})
? t('viewDialog.summary', {
type: t(`types.${group.type}`),
count: entries.length,
})
: t('viewDialog.fallbackDesc')}
</DialogDescription>
</DialogHeader>
@@ -145,95 +122,64 @@ export function IPGroupViewDialog({
{t('viewDialog.subscriptionHint')}
</p>
) : null}
<div className='relative'>
<Search className='pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
<Input
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder={t('viewDialog.searchPlaceholder')}
className='pl-8 pr-8'
/>
{searchKeyword ? (
<Button
type='button'
variant='ghost'
size='icon'
className='absolute right-1 top-1/2 size-7 -translate-y-1/2 text-muted-foreground hover:text-foreground'
onClick={() => setSearchKeyword('')}
>
<X className='size-3.5' />
<span className='sr-only'>
{t('viewDialog.clearSearch')}
</span>
</Button>
) : null}
</div>
{filteredEntries.length === 0 ? (
<EmptyStateWithBorder
description={t('viewDialog.noSearchResult')}
/>
) : (
<div className='rounded-lg border border-dashed'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('viewDialog.ipAddress')}</TableHead>
<div className='rounded-lg border border-dashed'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('viewDialog.ipAddress')}</TableHead>
{showAutomaticMeta ? (
<>
<TableHead>{t('viewDialog.capturedAt')}</TableHead>
<TableHead>{t('viewDialog.banRemaining')}</TableHead>
</>
) : null}
<TableHead className='w-[80px] text-right'>
{t('viewDialog.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.ip}>
<TableCell className='font-mono text-sm'>
{entry.ip}
</TableCell>
{showAutomaticMeta ? (
<>
<TableHead>{t('viewDialog.capturedAt')}</TableHead>
<TableHead>
{t('viewDialog.banRemaining')}
</TableHead>
<TableCell className='text-sm text-muted-foreground'>
{entry.capturedAt
? formatDateTime(entry.capturedAt)
: '—'}
</TableCell>
<TableCell className='text-sm'>
{entry.banRemaining ?? '—'}
</TableCell>
</>
) : null}
<TableHead className='w-[80px] text-right'>
{t('viewDialog.actions')}
</TableHead>
<TableCell className='text-right'>
<Button
type='button'
variant='ghost'
size='icon'
className='size-8 text-destructive hover:text-destructive'
disabled={removingIp === entry.ip}
onClick={() => setDeleteTarget(entry)}
>
{removingIp === entry.ip ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Trash2 className='size-4' />
)}
<span className='sr-only'>
{t('viewDialog.deleteIp', { ip: entry.ip })}
</span>
</Button>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredEntries.map((entry) => (
<TableRow key={entry.ip}>
<TableCell className='font-mono text-sm'>
{entry.ip}
</TableCell>
{showAutomaticMeta ? (
<>
<TableCell className='text-sm text-muted-foreground'>
{entry.capturedAt
? formatDateTime(entry.capturedAt)
: '—'}
</TableCell>
<TableCell className='text-sm'>
{entry.banRemaining ?? '—'}
</TableCell>
</>
) : null}
<TableCell className='text-right'>
<Button
type='button'
variant='ghost'
size='icon'
className='size-8 text-destructive hover:text-destructive'
disabled={removingIp === entry.ip}
onClick={() => setDeleteTarget(entry)}
>
{removingIp === entry.ip ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Trash2 className='size-4' />
)}
<span className='sr-only'>
{t('viewDialog.deleteIp', { ip: entry.ip })}
</span>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
))}
</TableBody>
</Table>
</div>
</div>
)}
@@ -1,9 +1,7 @@
import { locales } from '@/i18n/config';
import type { EdgeChange, Node, NodeChange } from '@xyflow/react';
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
import { wouldCreateCycle } from './graph-validation';
import { getRuntimeTranslator } from '@/i18n/runtime';
export type GraphErrorTarget = { kind: 'node' | 'edge'; id: string };
@@ -111,9 +109,9 @@ export function findGraphErrorTarget(
}
const messages = collectMessages(payload);
for (const message of messages) {
const nodeId = findMessageId(message, 'node', nodeIds);
const nodeId = findMessageId(message, '节点', nodeIds);
if (nodeId) return { kind: 'node', id: nodeId };
const edgeId = findMessageId(message, 'edge', edgeIds);
const edgeId = findMessageId(message, '边', edgeIds);
if (edgeId) return { kind: 'edge', id: edgeId };
}
return undefined;
@@ -134,22 +132,16 @@ function collectMessages(value: unknown): string[] {
function findMessageId(
message: string,
prefix: 'node' | 'edge',
prefix: '节点' | '边',
ids: string[],
): string | undefined {
const tokenKey = prefix === 'edge' ? 'edge_099db313' : 'node_2410d860';
const token = prefix === '边' ? '边(?:\\s+ID)?' : '节点';
return [...ids]
.sort((a, b) => b.length - a.length)
.find((id) =>
locales.some((locale) => {
const t = getRuntimeTranslator(locale);
const token = `${escapeRegExp(t(tokenKey))}(?:\\s+ID)?`;
const pattern = t
.raw('value0_s_value1_s_40960c98')
.replace('{value0}', token)
.replace('{value1}', escapeRegExp(id));
return new RegExp(pattern, 'i').test(message);
}),
new RegExp(`${token}\\s+${escapeRegExp(id)}(?=$|[\\s,。,::的])`).test(
message,
),
);
}
@@ -1,22 +1,8 @@
import { describe, expect, it } from 'vitest';
import { createGeoOptions } from './geo-options';
const { countryOptions, regionOptions } = createGeoOptions('zh-CN');
import { countryOptions, regionOptions } from './geo-options';
describe('geo options', () => {
it('uses English country names when requested without changing country codes', () => {
const english = createGeoOptions('en');
expect(english.countryOptions).toContainEqual(
expect.objectContaining({ value: 'CN', label: 'China' }),
);
expect(english.regionOptions).toContainEqual(
expect.objectContaining({
value: 'CN-BJ',
searchText: expect.stringContaining('China'),
}),
);
});
it('contains the complete country list with localized names', () => {
expect(countryOptions).toHaveLength(249);
expect(countryOptions).toContainEqual(
@@ -8,50 +8,46 @@ export interface GeoOption {
const countryCodePattern = /^[A-Z]{2}$/;
const regionCodePattern = /^[A-Z0-9]{1,3}$/;
export function createGeoOptions(locale: string) {
const countryDisplayNames = new Intl.DisplayNames([locale], {
type: 'region',
});
const countryDisplayNames = new Intl.DisplayNames(['zh-CN'], {
type: 'region',
});
function localizedCountryName(code: string, fallback: string) {
const localized = countryDisplayNames.of(code);
return !localized || localized === code ? fallback : localized;
}
const countryOptions: GeoOption[] = allCountries
.filter(([, code]) => countryCodePattern.test(code))
.map(([englishName, code]) => {
const label = localizedCountryName(code, englishName);
return {
value: code,
label,
searchText: `${label} ${englishName} ${code}`,
};
})
.sort((left, right) => left.label.localeCompare(right.label, locale));
const regionOptions: GeoOption[] = allCountries.flatMap(
([englishCountryName, countryCode, regions]) => {
const countryName = localizedCountryName(countryCode, englishCountryName);
return regions.flatMap(([regionName, shortCode]) => {
const normalizedShortCode = shortCode?.toUpperCase();
if (
!normalizedShortCode ||
!regionCodePattern.test(normalizedShortCode)
) {
return [];
}
const value = `${countryCode}-${normalizedShortCode}`;
return [
{
value,
label: `${regionName} · ${countryName}`,
searchText: `${regionName} ${countryName} ${englishCountryName} ${value}`,
},
];
});
},
);
return { countryOptions, regionOptions };
function localizedCountryName(code: string, fallback: string) {
const localized = countryDisplayNames.of(code);
return !localized || localized === code ? fallback : localized;
}
export const countryOptions: GeoOption[] = allCountries
.filter(([, code]) => countryCodePattern.test(code))
.map(([englishName, code]) => {
const label = localizedCountryName(code, englishName);
return {
value: code,
label,
searchText: `${label} ${englishName} ${code}`,
};
})
.sort((left, right) => left.label.localeCompare(right.label, 'zh-CN'));
export const regionOptions: GeoOption[] = allCountries.flatMap(
([englishCountryName, countryCode, regions]) => {
const countryName = localizedCountryName(countryCode, englishCountryName);
return regions.flatMap(([regionName, shortCode]) => {
const normalizedShortCode = shortCode?.toUpperCase();
if (
!normalizedShortCode ||
!regionCodePattern.test(normalizedShortCode)
) {
return [];
}
const value = `${countryCode}-${normalizedShortCode}`;
return [
{
value,
label: `${regionName} · ${countryName}`,
searchText: `${regionName} ${countryName} ${englishCountryName} ${value}`,
},
];
});
},
);
@@ -1,4 +1,3 @@
import { runtimeText } from '@/i18n/runtime';
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
import { UA_BROWSER_LABELS, UA_OS_LABELS } from './ua-options';
@@ -40,33 +39,25 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
const issues: GraphIssue[] = [];
const nodeMap = new Map(graph.nodes.map((node) => [node.id, node]));
if (graph.schema_version !== 1)
issues.push({
code: 'schema',
message: runtimeText('rule_graph_schema_version_must_be_1_ea9a1161'),
});
issues.push({ code: 'schema', message: '规则图 schema_version 必须为 1' });
if (
graph.nodes.length > 128 ||
graph.edges.length > 256 ||
new TextEncoder().encode(JSON.stringify(graph)).length > 256 * 1024
)
issues.push({
code: 'size_limit',
message: runtimeText('rule_graph_exceeds_the_size_limit_337651b9'),
});
issues.push({ code: 'size_limit', message: '规则图超过大小限制' });
const nodeIds = new Set<string>();
for (const node of graph.nodes) {
if (!node.id.trim())
issues.push({
code: 'empty_id',
message: runtimeText('node_id_must_not_be_empty_51a5de58'),
message: '节点 ID 不能为空',
nodeId: node.id,
});
if (nodeIds.has(node.id))
issues.push({
code: 'duplicate_node_id',
message: runtimeText('duplicate_node_id_value0_621230af', {
value0: node.id,
}),
message: `节点 ID ${node.id} 重复`,
nodeId: node.id,
});
nodeIds.add(node.id);
@@ -83,22 +74,20 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!edge.id.trim())
issues.push({
code: 'empty_id',
message: runtimeText('edge_id_must_not_be_empty_bc8f69cb'),
message: '连线 ID 不能为空',
edgeId: edge.id,
});
if (edgeIds.has(edge.id))
issues.push({
code: 'duplicate_edge_id',
message: runtimeText('duplicate_edge_id_value0_0a9d1c7c', {
value0: edge.id,
}),
message: `连线 ID ${edge.id} 重复`,
edgeId: edge.id,
});
edgeIds.add(edge.id);
if (nodeMap.get(edge.target)?.type === 'start')
issues.push({
code: 'start_incoming',
message: runtimeText('the_start_node_must_not_have_incoming_50a07b17'),
message: '开始节点不能有入边',
edgeId: edge.id,
nodeId: edge.target,
});
@@ -106,12 +95,12 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (graph.nodes.filter((node) => node.type === 'start').length !== 1)
issues.push({
code: 'start_count',
message: runtimeText('the_rule_graph_must_contain_exactly_one_7dfbc027'),
message: '规则图必须恰好有一个开始节点',
});
if (graph.nodes.filter((node) => node.type === 'allow').length !== 1)
issues.push({
code: 'allow_count',
message: runtimeText('the_rule_graph_must_contain_exactly_one_0036eb82'),
message: '规则图必须恰好有一个通过节点',
});
for (const edge of graph.edges) {
@@ -123,10 +112,7 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
) {
issues.push({
code: 'invalid_edge',
message: runtimeText(
'edge_value0_has_an_invalid_endpoint_or_9d84eb7e',
{ value0: edge.id },
),
message: `连线 ${edge.id} 的端点或出口无效`,
edgeId: edge.id,
});
}
@@ -139,19 +125,13 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (outgoing.length === 0)
issues.push({
code: 'missing_handle',
message: runtimeText(
'node_value0_output_value1_is_not_connected_c3038e81',
{ value0: node.id, value1: handle },
),
message: `节点 ${node.id} 的 ${handle} 出口未连接`,
nodeId: node.id,
});
if (outgoing.length > 1)
issues.push({
code: 'duplicate_handle',
message: runtimeText(
'node_value0_output_value1_can_only_be_903e54ce',
{ value0: node.id, value1: handle },
),
message: `节点 ${node.id} 的 ${handle} 出口只能连接一次`,
nodeId: node.id,
});
}
@@ -171,10 +151,7 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!reachable.has(node.id))
issues.push({
code: 'unreachable',
message: runtimeText(
'node_value0_is_unreachable_from_the_start_086531dc',
{ value0: node.id },
),
message: `节点 ${node.id} 无法从开始节点到达`,
nodeId: node.id,
});
const terminals = graph.nodes
@@ -185,27 +162,18 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!canTerminate.has(node.id))
issues.push({
code: 'non_terminating',
message: runtimeText(
'node_value0_cannot_reach_a_terminal_node_8de2ff6e',
{ value0: node.id },
),
message: `节点 ${node.id} 无法抵达终止节点`,
nodeId: node.id,
});
if (hasCycle(graph))
issues.push({
code: 'cycle',
message: runtimeText('the_rule_graph_must_not_contain_cycles_fd6fe797'),
});
issues.push({ code: 'cycle', message: '规则图不能包含循环' });
return issues;
}
function validateNodeConfig(node: WAFRuleNode): string | undefined {
if (node.type === 'ip_match') {
if (node.config.ips.some((value) => !isIP(value)))
return runtimeText(
'node_value0_contains_an_invalid_ip_address_1873ab94',
{ value0: node.id },
);
return `节点 ${node.id} 包含无效 IP`;
if (
node.config.cidrs.some((value) => {
const parts = value.split('/');
@@ -218,13 +186,9 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
);
})
)
return runtimeText('node_value0_contains_an_invalid_cidr_00c880d9', {
value0: node.id,
});
return `节点 ${node.id} 包含无效 CIDR`;
if (node.config.ip_group_ids.some((id) => !Number.isInteger(id) || id <= 0))
return runtimeText('node_value0_contains_an_invalid_ip_group_589219a5', {
value0: node.id,
});
return `节点 ${node.id} 包含无效 IP 组`;
}
if (
node.type === 'geo_match' &&
@@ -233,10 +197,7 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
(code) => !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(code),
))
)
return runtimeText(
'node_value0_contains_an_invalid_geography_code_1a9f469b',
{ value0: node.id },
);
return `节点 ${node.id} 包含无效地域代码`;
if (
node.type === 'pow' &&
(!['fast', 'slow'].includes(node.config.algorithm) ||
@@ -244,61 +205,37 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
!isIntegerInRange(node.config.session_ttl, 60) ||
!isIntegerInRange(node.config.challenge_ttl, 30))
)
return runtimeText('node_value0_has_pow_settings_outside_the_9fd2a4cb', {
value0: node.id,
});
return `节点 ${node.id} 的 PoW 配置超出范围`;
if (
node.type === 'block' &&
(!isIntegerInRange(node.config.status_code, 400, 599) ||
new TextEncoder().encode(node.config.response_body).length > 16 * 1024)
)
return runtimeText(
'node_value0_has_invalid_block_response_settings_ae4c35ca',
{ value0: node.id },
);
return `节点 ${node.id} 的阻止响应配置无效`;
if (node.type === 'ua_check') {
if (!['and', 'or'].includes(node.config.match_mode))
return runtimeText('node_value0_match_mode_must_be_and_242c1ae7', {
value0: node.id,
});
return `节点 ${node.id} 的匹配模式必须为 and 或 or`;
if (node.config.browsers.some((label) => !UA_BROWSER_LABELS.has(label)))
return runtimeText(
'node_value0_contains_an_invalid_browser_tag_4d0e6740',
{ value0: node.id },
);
return `节点 ${node.id} 包含无效浏览器标签`;
if (node.config.operating_systems.some((label) => !UA_OS_LABELS.has(label)))
return runtimeText(
'node_value0_contains_an_invalid_operating_system_45bea1fb',
{ value0: node.id },
);
return `节点 ${node.id} 包含无效操作系统标签`;
if (node.config.custom_ua_patterns.length > 32)
return runtimeText('node_value0_allows_at_most_32_custom_497938c6', {
value0: node.id,
});
return `节点 ${node.id} 的自定义 UA 正则不能超过 32 条`;
for (const pattern of node.config.custom_ua_patterns) {
if (!pattern.trim())
return runtimeText('node_value0_custom_ua_pattern_must_not_a72718be', {
value0: node.id,
});
if (!pattern.trim()) return `节点 ${node.id} 的自定义 UA 正则不能为空`;
if (new TextEncoder().encode(pattern).length > 256)
return runtimeText('node_value0_custom_ua_pattern_is_too_c2c83c0e', {
value0: node.id,
});
return `节点 ${node.id} 的自定义 UA 正则过长`;
try {
void new RegExp(pattern);
} catch {
return runtimeText('node_value0_has_an_invalid_custom_ua_c47ffd0d', {
value0: node.id,
});
return `节点 ${node.id} 的自定义 UA 正则无效`;
}
}
if (
node.config.block_custom_ua &&
node.config.custom_ua_patterns.length === 0
)
return runtimeText('node_value0_requires_at_least_one_pattern_6114bb93', {
value0: node.id,
});
return `节点 ${node.id} 开启屏蔽自定义 UA 时至少需要一条正则`;
}
return undefined;
}
@@ -1,38 +1,29 @@
import { describe, expect, it } from 'vitest';
import { getRuntimeTranslator } from '@/i18n/runtime';
import {
createRuleNode,
displayNodeTitle,
NODE_TYPE_LABELS,
parseAddableNodeType,
} from './node-factory';
describe('displayNodeTitle', () => {
it('uses trimmed label when present', () => {
expect(
displayNodeTitle(
{
type: 'ip_match',
label: ' 办公室 ',
},
getRuntimeTranslator('en'),
),
displayNodeTitle({
type: 'ip_match',
label: ' 办公室 ',
}),
).toBe('办公室');
});
it('falls back to type default when label empty', () => {
expect(
displayNodeTitle(
{
type: 'block',
label: ' ',
},
getRuntimeTranslator('en'),
),
).toBe('Block');
expect(
displayNodeTitle({ type: 'block' }, getRuntimeTranslator('zh-CN')),
).toBe('阻止');
displayNodeTitle({
type: 'block',
label: ' ',
}),
).toBe(NODE_TYPE_LABELS.block);
});
});
@@ -7,25 +7,22 @@ export type AddableNodeType = Extract<
'ip_match' | 'geo_match' | 'ua_check' | 'security_check' | 'pow' | 'block'
>;
export const NODE_TYPE_LABEL_KEYS = {
start: 'start_d2bb025a',
ip_match: 'ip_match_43f4b6d4',
geo_match: 'geography_match_9acb08c4',
ua_check: 'ua_check_b56dac23',
security_check: 'security_checks_e09822e6',
pow: 'pow_challenge_3c043088',
allow: 'allow_1e9f2561',
block: 'block_2a366d09',
} as const satisfies Record<WAFRuleNode['type'], string>;
export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
start: '开始',
ip_match: 'IP 匹配',
geo_match: '地域匹配',
ua_check: 'UA 检查',
security_check: '安全防护',
pow: 'PoW 挑战',
allow: '通过',
block: '阻止',
};
export function displayNodeTitle(
node: Pick<WAFRuleNode, 'type' | 'label'>,
translate: (
key: (typeof NODE_TYPE_LABEL_KEYS)[WAFRuleNode['type']],
) => string,
): string {
const custom = node.label?.trim();
return custom || translate(NODE_TYPE_LABEL_KEYS[node.type]);
return custom || NODE_TYPE_LABELS[node.type];
}
export function createRuleNode(
@@ -1,4 +1,3 @@
import { useTranslations } from 'next-intl';
import {
Ban,
Fingerprint,
@@ -11,7 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import {
NODE_TYPE_LABEL_KEYS,
NODE_TYPE_LABELS,
WAF_NODE_DRAG_MIME,
type AddableNodeType,
} from './node-factory';
@@ -26,7 +25,6 @@ const items = [
] satisfies { type: AddableNodeType; icon: typeof Fingerprint }[];
export function NodeLibrary() {
const rt = useTranslations('runtime');
return (
<div className='flex items-center gap-2'>
{items.map(({ type, icon: Icon }) => (
@@ -44,7 +42,7 @@ export function NodeLibrary() {
}}
>
<Icon data-icon='inline-start' />
{rt(NODE_TYPE_LABEL_KEYS[type])}
{NODE_TYPE_LABELS[type]}
</Button>
))}
</div>
@@ -1,6 +1,6 @@
import { CircleHelp, Settings2 } from 'lucide-react';
import { useLocale, useTranslations } from 'next-intl';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -35,8 +35,8 @@ import {
} from '@/components/ui/tooltip';
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
import { createGeoOptions, type GeoOption } from './geo-options';
import { NODE_TYPE_LABEL_KEYS } from './node-factory';
import { countryOptions, regionOptions, type GeoOption } from './geo-options';
import { NODE_TYPE_LABELS } from './node-factory';
import { UA_BROWSER_OPTIONS, UA_OS_OPTIONS } from './ua-options';
export function NodeProperties({
@@ -78,14 +78,6 @@ function PropertyFields({
onChange: (node: WAFRuleNode) => void;
}) {
const t = useTranslations('waf.editor');
const locale = useLocale();
const { countryOptions, regionOptions } = useMemo(
() =>
node.type === 'geo_match'
? createGeoOptions(locale)
: { countryOptions: [], regionOptions: [] },
[locale, node.type],
);
if (node.type === 'start' || node.type === 'allow')
return (
<p className='text-sm text-muted-foreground'>{t('systemNodeNoConfig')}</p>
@@ -526,14 +518,13 @@ function DisplayNameField({
onChange: (node: WAFRuleNode) => void;
}) {
const t = useTranslations('waf.editor');
const rt = useTranslations('runtime');
return (
<Field>
<FieldLabel htmlFor={`${node.id}-label`}>{t('displayName')}</FieldLabel>
<Input
id={`${node.id}-label`}
value={node.label ?? ''}
placeholder={rt(NODE_TYPE_LABEL_KEYS[node.type])}
placeholder={NODE_TYPE_LABELS[node.type]}
onChange={(event) => onChange({ ...node, label: event.target.value })}
/>
</Field>
@@ -1,5 +1,4 @@
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { useTranslations } from 'next-intl';
import {
Ban,
Fingerprint,
@@ -43,11 +42,10 @@ const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {
};
export function RuleNode({ data, selected }: NodeProps) {
const rt = useTranslations('runtime');
const value = data as RuleFlowNodeData;
const { rule, issues } = value;
const { icon: Icon } = meta[rule.type];
const title = displayNodeTitle(rule, rt);
const title = displayNodeTitle(rule);
return (
<div
className={cn(
@@ -1,4 +1,3 @@
import { runtimeText } from '@/i18n/runtime';
export const UA_BROWSER_OPTIONS = [
{ value: 'Chrome', label: 'Chrome' },
{ value: 'Safari', label: 'Safari' },
@@ -6,32 +5,12 @@ export const UA_BROWSER_OPTIONS = [
{ value: 'Edge', label: 'Edge' },
{ value: 'Opera', label: 'Opera' },
{ value: 'Chromium', label: 'Chromium' },
{
value: 'WeChat',
get label() {
return runtimeText('wechat_81b430b6');
},
},
{ value: 'WeChat', label: '微信' },
{ value: 'Postman', label: 'Postman' },
{ value: 'CLI', label: 'CLI' },
{
value: 'Bot',
get label() {
return runtimeText('ui_botLabel');
},
},
{
value: 'Unknown',
get label() {
return runtimeText('ui_unknownLabel');
},
},
{
value: 'Other',
get label() {
return runtimeText('ui_otherLabel');
},
},
{ value: 'Bot', label: 'Bot' },
{ value: 'Unknown', label: 'Unknown' },
{ value: 'Other', label: 'Other' },
] as const;
export const UA_OS_OPTIONS = [
@@ -41,24 +20,9 @@ export const UA_OS_OPTIONS = [
{ value: 'macOS', label: 'macOS' },
{ value: 'Chrome OS', label: 'Chrome OS' },
{ value: 'Linux', label: 'Linux' },
{
value: 'Bot',
get label() {
return runtimeText('ui_botLabel');
},
},
{
value: 'Unknown',
get label() {
return runtimeText('ui_unknownLabel');
},
},
{
value: 'Other',
get label() {
return runtimeText('ui_otherLabel');
},
},
{ value: 'Bot', label: 'Bot' },
{ value: 'Unknown', label: 'Unknown' },
{ value: 'Other', label: 'Other' },
] as const;
export const UA_BROWSER_LABELS = new Set<string>(
@@ -219,8 +219,8 @@ function EditorContent() {
<ArrowLeft className='size-3.5' />
{t('back')}
</Button>
<div className='flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between'>
<div className='flex min-w-0 flex-wrap items-center gap-2'>
<div className='flex items-center justify-between gap-4'>
<div className='flex min-w-0 items-center gap-2'>
<GitBranch className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{ruleQuery.data.name}
@@ -235,7 +235,7 @@ function EditorContent() {
</Badge>
{dirty && <Badge variant='secondary'>{t('unsaved')}</Badge>}
</div>
<div className='flex flex-wrap items-center gap-2 xl:shrink-0'>
<div className='flex shrink-0 items-center gap-2'>
<div className='flex items-center gap-2'>
<Switch
id='rule-enabled'
@@ -1,6 +1,5 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Activity, HardDrive, Users } from 'lucide-react';
@@ -32,13 +31,13 @@ function formatAxisLabel(iso: string, range: ZoneStatsRange) {
return '';
}
if (range === '24h') {
return date.toLocaleTimeString(getFormattingLocale(), {
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: undefined,
hour12: true,
});
}
return date.toLocaleDateString(getFormattingLocale(), {
return date.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
});
@@ -55,10 +54,7 @@ function formatWindowLabel(startedAt?: string, endedAt?: string) {
}
const fmt = (value: Date) =>
value
.toLocaleDateString(getFormattingLocale(), {
day: 'numeric',
month: 'long',
})
.toLocaleDateString('en-GB', { day: 'numeric', month: 'long' })
.toUpperCase();
return `${fmt(start)} — ${fmt(end)}`;
}
@@ -65,8 +65,6 @@ export function QuickCreateZoneDomainDialog({
zones?: ZoneItem[];
onCreated(domain: ZoneDomainItem): void | Promise<void>;
}) {
const uiText = useTranslations('runtime');
const t = useTranslations('websites');
const tc = useTranslations('common');
const queryClient = useQueryClient();
@@ -216,7 +214,7 @@ export function QuickCreateZoneDomainDialog({
>
{!fixedZoneId ? (
<div className='space-y-1.5'>
<Label>{uiText('ui_zone')}</Label>
<Label>Zone</Label>
<Select
value={form.watch('zone_id') || undefined}
onValueChange={(value) => form.setValue('zone_id', value)}
@@ -1,4 +1,3 @@
import { runtimeText } from '@/i18n/runtime';
import type {
TlsCertificateFileImportPayload,
TlsCertificateItem,
@@ -18,8 +17,7 @@ export type TranslateFn = (
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ??
runtimeText('request_failed_please_try_again_later_8977b79a'));
: (fallback ?? '请求失败,请稍后重试。');
}
export function getMatchTypeMeta(domain: string): {
@@ -27,8 +25,8 @@ export function getMatchTypeMeta(domain: string): {
tone: StatusTone;
} {
return domain.startsWith('*.')
? { label: runtimeText('wildcard_c6b0739e'), tone: 'warning' }
: { label: runtimeText('exact_match_391fe725'), tone: 'info' };
? { label: '通配符', tone: 'warning' }
: { label: '精确匹配', tone: 'info' };
}
export function getCertificateStatus(
@@ -59,10 +57,7 @@ export function getCertificateStatus(
export function buildCertificateLabel(certificate: TlsCertificateItem) {
return certificate.not_after
? runtimeText('value0_expires_value1_7d8600f8', {
value0: certificate.name,
value1: formatDateTime(certificate.not_after),
})
? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})`
: certificate.name;
}
-12
View File
@@ -4,18 +4,6 @@
@custom-variant dark (&:is(.dark *));
html[data-locale-pending] {
overflow: hidden;
}
html[data-locale-pending] body {
visibility: hidden;
}
#locale-load-fallback[hidden] {
display: none;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);

Some files were not shown because too many files have changed in this diff Show More