Compare commits

...

27 Commits

Author SHA1 Message Date
Deqiying 9a51a68206 feat(domain): 支持通配符域名及关联功能适配 (#63)
* feat(domain): 支持通配符域名及关联功能适配

* fix(domain): preserve legacy Unicode imports and address review findings

* fix(domain): canonicalize legacy IDNs and exclude empty host labels

* fix(domain): normalize equivalent Unicode IDN spellings

---------

Co-authored-by: Ryan <63696351+Rain-kl@users.noreply.github.com>
Co-authored-by: ryan <ryan@arctel.net>
2026-11-09 10:31:03 +08:00
ryan 8d8b3e1ea8 feat(otel): support standard OTLP/HTTP trace export (#64) 2026-11-09 10:25:16 +08:00
ryan acd63ff410 fix(otel): resolve exporter protocol through startup configuration 2026-11-09 10:23:47 +08:00
Arthur Koba b281b65e78 feat(otel): support standard OTLP/HTTP trace export 2026-11-08 23:24:00 +03:00
Deqiying e0ba758afa feat(cloudflare): 支持分组级自动与手动 TTL 配置 (#62) 2026-11-08 22:07:26 +08:00
ryan 18dee29b73 chore(release): v3.5.9
### ✨ 新功能
- 新增完整俄语界面,支持俄语浏览器语言识别、原生语言名称、日期与数字格式;内置错误页及离线页跟随界面语言。

### 🛠 修复
- 修复 Agent 自动注册时节点不断重复创建的问题;响应丢失或重试后仍复用原节点,不会覆盖管理员修改。
- 修复 IP 组按规则自动抓取时会收录内网、回环及链路本地等地址的问题;历史抓取结果会在下次同步时清理。
- 修复通知收件人误判,以及推送模板变量与富文本标签丢失的问题。
- 选择非默认语言时,页面首次绘制前即应用所选语言,避免刷新或跳转时短暂显示英文。
- 界面脚本加载失败时提供同语言的重新加载入口,避免出现空白页。

### ⚡️ 优化与改进
- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,大规模节点场景下列表加载和轮询更轻量;详情页改为单节点查询。
- Agent、Relay 与 Flared 的自动更新检查改为每 6 小时一次,检查或下载更新失败时自动延长间隔重试(最长 3 小时),避免频繁请求触发 GitHub 限流;手动触发的立即更新不受限制。
- SQLite 数据库读写并发性能提升,数据库导出可完整包含最新已提交的数据。

### 💄 其他/体验
- 界面默认语言调整为英文,已有语言偏好保持不变;语言选择器固定显示各语言的原生名称。
- 统一并校对 Server、Agent、任务详情、通知与内置模板的英文文案,窄屏下 WAF 标题和通知模板说明的换行更合理。
2026-11-07 21:50:40 +08:00
ryan b60546a94d feat(updater): throttle auto-update checks with exponential backoff
Check GitHub releases at most every 6 hours and retry transient failures
(release query, checksum or binary download) with exponential backoff
from 1 minute up to 3 hours. Forced updates bypass the throttle.
2026-11-07 21:49:46 +08:00
dependabot[bot] 8867c822bb chore(deps): bump source-map-js from 1.2.1 to 1.2.2 in /docs (#61)
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:37:09 +08:00
ryan 5f7e6d8907 feat(i18n): default to English, localize runtime text and add Russian (#52) 2026-11-07 21:35:55 +08:00
ArthurKoba 3c7b347d7b perf(nodes): paginate node lists and fetch details directly (#54)
* perf(nodes): paginate node lists and fetch details directly

* fix(nodes): preserve Unicode search and page transition state

* fix(nodes): validate search using the UTF-8 byte limit
2026-11-07 21:29:52 +08:00
ArthurKoba 72d67c80f2 fix(agent): make discovery enrollment compatible and idempotent (#53)
* fix(agent): make discovery enrollment compatible and idempotent

* fix(agent): confirm enrollment persistence and document token aliases
2026-11-07 21:29:47 +08:00
dependabot[bot] 1ca03df0ef chore(deps): bump next from 16.3.6 to 16.4.1 in /frontend (#57)
Bumps [next](https://github.com/vercel/next.js) from 16.3.6 to 16.4.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.6...v16.4.1)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.4.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:28:38 +08:00
dependabot[bot] d7c7ccb29b chore(deps): bump follow-redirects from 1.16.0 to 1.16.2 in /frontend (#60)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.16.0 to 1.16.2.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.16.0...v1.16.2)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:26:52 +08:00
dependabot[bot] babead59e4 chore(deps): bump zod from 4.4.3 to 4.6.6 in /frontend (#59)
Bumps [zod](https://github.com/colinhacks/zod) from 4.4.3 to 4.6.6.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.4.3...v4.6.6)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.6.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:26:46 +08:00
dependabot[bot] 5db05bd18d chore(deps): bump undici from 8.11.2 to 8.12.3 in /frontend (#56)
Bumps [undici](https://github.com/nodejs/undici) from 8.11.2 to 8.12.3.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.11.2...v8.12.3)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.12.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:26:35 +08:00
dependabot[bot] bcdb2775d7 chore(deps): bump dompurify from 3.4.16 to 3.4.17 in /frontend (#55)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.16 to 3.4.17.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.16...3.4.17)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.17
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:26:29 +08:00
dependabot[bot] 47c47fea2c chore(deps): bump vite from 8.1.4 to 8.3.5 in /frontend (#50)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.1.4 to 8.3.5.
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.3.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:26:23 +08:00
dependabot[bot] 32cec2ac6e chore(deps): bump vite from 7.3.3 to 7.3.7 in /docs (#51)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.3 to 7.3.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.7/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.7/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:25:59 +08:00
dependabot[bot] 6a2503cf7a chore(deps): bump markdown-it from 14.3.2 to 14.3.3 in /docs (#49)
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 14.3.2 to 14.3.3.
- [Changelog](https://github.com/markdown-it/markdown-it/blob/14.3.3/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.3.2...14.3.3)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 14.3.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:25:54 +08:00
dependabot[bot] e2acbecb42 chore(deps): bump github.com/go-jose/go-jose/v4 from 4.1.4 to 4.1.6 (#58)
Bumps [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) from 4.1.4 to 4.1.6.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.1.4...v4.1.6)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-version: 4.1.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:25:48 +08:00
Arthur Koba b4e94076c9 feat(i18n): add Russian UI and harden localization 2026-10-25 22:35:32 +03:00
Arthur Koba 65a96670d5 fix(i18n): use English runtime text and localize system UI 2026-10-25 19:44:15 +03:00
Noru Wyrms ada0550571 fix(sqlite): enable WAL and export consistent snapshots 2026-10-25 02:27:05 +00:00
ryan 02a2b79c08 fix(waf): exclude internal IPs from automatic IP groups 2026-10-16 00:02:09 +08:00
ryan 0e1ce40cbb docs(readme): update project description and deployment instructions 2026-10-11 21:38:30 +08:00
ryan cf7613dade docs(readme): update project description and deployment instructions 2026-10-11 21:32:08 +08:00
ryan 53f2e58ced docs(readme): update project description and deployment instructions 2026-10-11 21:28:14 +08:00
474 changed files with 23235 additions and 4983 deletions
+4
View File
@@ -33,3 +33,7 @@ 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(`PR checklist 未全部勾选,请确保所有 checklist 项都已勾选。未勾选项如下:\n${unchecked.join('\n')}`);
core.setFailed(`Complete all PR checklist items. Unchecked items:\n${unchecked.join('\n')}`);
} else {
console.log('all checklist items are checked.');
}
+1
View File
@@ -87,3 +87,4 @@ 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 tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm check:i18n && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
build-backend:
@echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..."
+145 -69
View File
@@ -2,12 +2,10 @@
# 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">
@@ -20,65 +18,120 @@ OpenFlare is an open-source CDN orchestration and edge security platform. It sup
</a>
</p>
> [!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.
</div>
## Documentation
---
**https://openflare.fyrn.link**
## 📖 What is OpenFlare?
Common entry points:
**OpenFlare** is a modern, open-source **distributed reverse proxy and edge security gateway platform** designed for multi-server infrastructures and edge clusters.
* [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/)
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**:
## Core Capabilities
* ⚡ **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.
* **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.
---
## Interface Preview
## 🌐 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
### Dashboard Overview
![OpenFlare Dashboard Overview](./docs/assets/readme/dashboard-overview.png)
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### Edge WAF & Access Rules
![OpenFlare WAF Protection](./docs/assets/readme/waf.png)
### Access Logs
### Domain & Traffic Analytics
![OpenFlare Domain Overview](./docs/assets/readme/domain_overview.png)
![OpenFlare version release](./docs/assets/readme/domain_overview.png)
---
### WAF Protection
## ⚡ Quick Start
![OpenFlare version release](./docs/assets/readme/waf.png)
### 1. Launch OpenFlare Server
## 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`:
Deploy the control plane using `docker-compose`:
```bash
# Download environment variable template and create .env file
# Download environment template
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
```
@@ -130,46 +183,69 @@ services:
start_period: 5s
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
```
See the [deployment documentation](https://openflare.fyrn.link/deployment/deployment) for details.
* **Dashboard URL**: `http://localhost:3000`
* **Default Credentials**: `admin` / `12345678`
Access address: `http://localhost:3000`
> [!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.
Default account:
### 2. Connect Edge Agent Node
* 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:
Run the Agent container on any edge server (ensure ports 80/443 are open):
```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:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
-e OPENFLARE_SERVER_URL=http://<YOUR_SERVER_IP>: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)*
## Open Source License
---
## 📚 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
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>
+146 -67
View File
@@ -2,11 +2,10 @@
# OpenFlare
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
**新一代分布式反向代理与边缘安全网关**
*Next-Gen Distributed Reverse Proxy & Edge Gateway for OpenResty / Nginx*
OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、集中式配置同步、内网穿透(Tunnels)、动态 WAF 防护以及防 CC 挑战。
</div>
**[简体中文](./README.zh-CN.md) | [English](./README.md)**
<p align="center">
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
@@ -20,65 +19,120 @@ OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、
</a>
</p>
> [!WARNING]
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
>
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
</div>
## 文档
---
**https://openflare.fyrn.link**
## 📖 什么是 OpenFlare?
常用入口:
**OpenFlare** 是一套面向多服务器与分布式集群的**新一代开源反向代理与边缘安全网关平台**。
* [快速开始](https://openflare.fyrn.link/guide/quick-start)
* [部署说明](https://openflare.fyrn.link/deployment/deployment)
* [配置项参考](https://openflare.fyrn.link/reference/configuration)
* [系统设计](https://openflare.fyrn.link/design/)
它并非简单的单机管理面板,而是将传统的 OpenResty / Nginx 升级为具备**多节点集群化编排、动态热更新、边缘 WAF 防护以及内网穿透能力**的现代化边缘网络基础设施:
## 核心能力
* ⚡ **告别手写配置与 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 完美协作互补:
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### 1. 独立自建模式 (Standalone Edge Gateway)
适用于多台 VPS、跨地域云服务器或 Homelab:
* 自主搭建私有 CDN 与反向代理集群,数据和流量 100% 本地掌控。
* 拥有完整的边缘防 CC 人机验证、自建 SSL 证书管理与内网穿透能力,无需采购昂贵的商业安全套餐。
### 访问日志
### 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)
---
### WAF 防护
## 📊 横向对比:传统 Nginx vs Nginx Proxy Manager vs OpenFlare
![OpenFlare version release](./docs/assets/readme/waf.png)
| 维度 | 传统 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,完美充当源站护盾** |
## 快速开始
---
### 硬件配置推荐
## 🏗️ 架构拓扑
| 组件 | 最低硬件配额 | 推荐硬件配额 | 说明 |
| --- |-------------------------------| --- | --- |
| **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 磁盘 | 独立运行于内网,自身资源占用极小,保障网络吞吐即可 |
```mermaid
flowchart TD
subgraph Visitors ["公网访客与客户端"]
User["公网访客 / 移动端"]
CF["Cloudflare CDN (可选代理层)"]
end
### 1. 启动 Server
subgraph EdgePlane ["边缘数据面 (OpenResty Agent 节点)"]
Edge1["边缘节点 A (OpenResty)"]
Edge2["边缘节点 B (OpenResty)"]
end
使用 docker-compose
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` 快速启动控制面:
```bash
# 下载环境变量模板并创建 .env 文件
# 获取环境变量模板
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
```
@@ -130,46 +184,71 @@ services:
start_period: 5s
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
```
详细部署说明见 [部署文档](https://openflare.fyrn.link/deployment/deployment)。
* **访问控制台**:`http://localhost:3000`
* **默认账号密码**:`admin` / `12345678`
访问地址:`http://localhost:3000`
> [!WARNING]
> 初次登录后,请务必在后台第一时间修改默认密码 `12345678`。
> BETA 版本处于开发与测试阶段,生产环境请提前做好数据备份。
默认账号:
### 2. 部署边缘 Agent 节点
* 用户名:`admin`
* 密码:`12345678`
### 2. 安装 Agent
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
#### Docker 部署
Docker 部署可直接运行 Agent 镜像:
在任意作为边缘代理的服务器上执行(需开放 80/443 端口):
```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:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
-e OPENFLARE_SERVER_URL=http://<YOUR_SERVER_IP>:3000 \
-e OPENFLARE_AGENT_TOKEN=<YOUR_AGENT_TOKEN> \
ghcr.io/rain-kl/openflare-agent:latest
```
*(Token 可在管理后台的「节点管理 -> 详情 -> 节点信息」中直接一键生成与复制)*
## 开源协议
---
本项目采用 [Apache License 2.0](./LICENSE) 开源。
## 📚 官方文档
## Star History
完整配置与进阶部署详见官方文档:**[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
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
<picture>
+1
View File
@@ -95,6 +95,7 @@ worker:
# ─── OpenTelemetry Tracing ──────────────────────────────────────────────────────
otel:
export_protocol: "grpc" # grpc 或 http/protobuf;OTEL_EXPORTER_OTLP_TRACES_PROTOCOL 优先于通用 OTLP 协议变量
sampling_rate: 0.0 # Trace sampling rate (0.0 – 1.0)
tracer_name: "github.com/Rain-kl/OpenFlare" # Global tracer instrumentation name
+46 -8
View File
@@ -8,6 +8,52 @@ sidebar: false
格式基于 [Keep a Changelog](http://keepachangelog.com/),版本号遵循 [语义化版本](http://semver.org/)。
## 重大变更
> [!IMPORTANT]
>
>3.5.1 版本解耦了日志存储,ClickHouse 变为可选项,如果想切换数据库, 点击 「任务管理」 -> 「切换日志数据库」任务,按提示迁移数据并切换主库。
>
## [Unreleased]
### 🛠 修复
- 修复升级导入历史 Unicode 域名时被新域名校验拒绝、导致服务无法启动的问题,统一保存为 Punycode 并保留证书和站点绑定,确保路由与证书校验使用一致的域名身份。
- 修复通配符流量统计会计入含空标签的非法 Host 的问题,继续支持合法的多级子域名。
### ✨ 新功能
- 域名列表与快捷新增支持 `*.example.com`、`*.s3.example.com` 等通配符域名,可绑定站点规则并随配置版本下发至 CDN Agent;Cloudflare 分组可为其同步 A、AAAA 或 CNAME 记录。
- Zone 流量统计支持聚合通配符匹配的实际子域名,重叠域名不会重复计数;Uptime Kuma 优先使用具体域名,只有通配符时跳过监控创建或更新并记录原因。
- OpenTelemetry 链路追踪新增 OTLP/HTTP protobuf 导出,通过标准环境变量配置接收端地址和自定义请求头,协议由统一启动配置解析并支持 `otel.export_protocol`,保留原有 OTLP/gRPC 默认模式;未支持的协议在启动时明确报错。
- Cloudflare 指向分组支持选择自动 TTL 或手动填写 60~86400 秒,适用于节点绑定与自定义 A、AAAA、CNAME;灰云成员使用分组配置,橙云成员始终自动,旧分组保留原有 300 秒行为。
## [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
### ✨ 新功能
@@ -75,14 +121,6 @@ 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 控制面,用于管理反向代理、配置发布、节点同步、TLS 证书与基础观测。',
'OpenFlare 是新一代分布式反向代理与边缘安全网关,为多节点 OpenResty / Nginx 提供集群编排、Zero-Reload 动态配置同步、边缘 WAF、PoW 防 CC 盾与内网穿透。',
themeConfig: {
nav: nav(),
+16
View File
@@ -272,6 +272,22 @@ docker compose up -d
```
启动后可以通过访问 `http://localhost:16686` 打开 Jaeger 监控端查看系统 Span 链路。
### 自定义 OpenTelemetry 接收端
链路追踪默认使用 OTLP/gRPC。对于支持 OTLP/HTTP protobuf 的接收端,可在 Server 容器的环境变量中配置:
```yaml
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT: https://collector.example.com
OTEL_SAMPLING_RATE: "0.1"
```
标准 SDK 会在通用 `OTEL_EXPORTER_OTLP_ENDPOINT` 的路径后追加 `/v1/traces`;例如上述配置发送到 `https://collector.example.com/v1/traces`。如果接收端使用自定义路径,请设置完整的 `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`,SDK 将直接使用该 URL,不再追加路径。链路专用的 `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` 优先于通用协议配置;两者均未配置时使用 `otel.export_protocol`,缺省为 `grpc`。协议在启动配置层统一解析,再传入追踪组件。
需要鉴权或其他请求头时,使用可选的 `OTEL_EXPORTER_OTLP_HEADERS`,格式为 `key=value,key2=value2`,值按标准进行百分号编码,例如 `X-API-Key=example-key,X-Client-Name=example%20client`。链路专用的 `OTEL_EXPORTER_OTLP_TRACES_HEADERS` 优先于通用请求头配置。应用不预设鉴权方案;实际请求头与凭据由部署环境提供,不要将真实凭据写入仓库。
协议、地址和请求头由 OpenTelemetry SDK 按[标准 OTLP 配置](https://opentelemetry.io/docs/specs/otel/protocol/exporter/)处理,不依赖特定部署平台或观测后端。
---
## 首次登录
+17 -4
View File
@@ -32,15 +32,28 @@ 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` 的 `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` 进行鉴权认证。
若 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`,修复后的响应可使其正常完成注册,但其响应丢失后的重试仍不具备幂等保证。请保留每个实例独立的持久化状态与配置,不要把已初始化的状态目录复制给其他实例。
### 2. 双通道心跳与同步机制
* **HTTP 轮询通道(兜底与探测)**:Agent 默认按设定的 `heartbeat_interval` 间隔发送 POST 心跳包。上报指标的同时获取当前激活版本的摘要信息(Version & Checksum)。
+24 -6
View File
@@ -2,9 +2,9 @@
## 目标
通过 Cloudflare API 将 OpenFlare 中的 **ZoneDomain(明确 FQDN)** 快速指向边缘节点 IP,替代在 CF 控制台手工改 A 记录。用户以 **指向分组** 组织域名:每组配置主节点与备用节点、默认橙云策略;成员可单独覆盖橙云。系统以库表为期望状态,幂等同步远端 DNS。
通过 Cloudflare API 将 OpenFlare 中的 **ZoneDomain(具体或通配符 FQDN)** 快速指向边缘节点 IP 或自定义 DNS 目标。用户以 **指向分组** 组织域名:节点模式配置主节点与备用节点,自定义模式配置 A/AAAA/CNAME 的记录内容;成员可单独覆盖橙云。系统以库表为期望状态,幂等同步远端 DNS。
本模块是 **可选对接能力**,不把 Zone 本身变成权威 DNS 控制面。Zone 仍只负责根域边界、域名、证书与反代关联;DNS A 记录的创建/更新/删除由本模块驱动 Cloudflare。
本模块是 **可选对接能力**,不把 Zone 本身变成权威 DNS 控制面。Zone 仍只负责根域边界、域名、证书与反代关联;DNS A/AAAA/CNAME 记录的创建/更新/删除由本模块驱动 Cloudflare。
## 范围与分期
@@ -14,7 +14,7 @@
* 连接配置:从现有 DNS 账号导入 **或** 模块内独立录入(混合来源),加密存储
* 指向分组 CRUD:主节点、备用节点(预留)、分组默认橙云
* 成员管理:以 `zone_domain_id` 为粒度加入/移出;成员级橙云
* 同步:将每个成员写成 Cloudflare 上 **单条 A 记录** → 当前生效节点 IPv4
* 同步:节点模式写入 **单条 A 记录** → 当前生效节点 IPv4;自定义模式按分组设置写入 A、AAAA 或 CNAME 记录。
* 触发:手动同步、加入成员、改节点/橙云、节点 IP 变更入队
* 异步任务批量同步;成员同步状态与可读错误
@@ -26,11 +26,18 @@
### 明确不做(更远或永久)
* 多 Cloudflare 账号并行(全局一份连接配置)
* AAAA / 多 A 负载 / CNAME 到节点主机名
* 管理 MX/TXT/Page Rules 等非本模块 A 记录
* 节点模式的 IPv6 地址跟随 / 多 A 负载 / CNAME 到节点主机名(自定义 AAAA/CNAME 不受此限制)
* 管理 MX/TXT/Page Rules 等其他记录或规则
* 非 Cloudflare DNS 厂商
* 将 DNS 记录管理并入 Zone 核心模型
### 通配符域名
* 先在 Zone 域名列表登记 `*.example.com` 或 `*.s3.example.com`,再在分组成员选择器中添加;可用域名、成员归属与同步状态沿用具体域名的规则。
* 节点模式创建 A 记录;需要 AAAA 或 CNAME 时选择自定义模式,分别填写 IPv6 地址或具体目标主机名。
* Cloudflare Zone 查询仍使用注册根域(例如 `example.com`);DNS 记录的 `name` 保留完整通配符名称。创建、更新、查询与删除共用原有同步路径,不能把 `*.` 去掉后作为记录名。
* DNS-only / 橙云选项沿用分组默认值及成员覆盖值。DNS 同步成功不代表代理配置已生效,仍需绑定站点路由并发布激活配置;HTTPS 还需匹配的证书。
## 与现有能力的关系
| 现有能力 | 关系 |
@@ -104,6 +111,7 @@ erDiagram
| `backup_node_id` | 备用(可空;一期仅存储) |
| `active_node_id` | 当前生效节点;一期等于 primary;二期 failover 改写 |
| `default_proxied` | 分组默认橙云;**仅影响新加入成员** |
| `ttl` | 分组级 TTL:`1` 为自动,手动填写 `60~86400` 的整数秒;仅对 DNS-only 成员生效,无成员级覆盖 |
| `enabled` | 是否参与同步 |
约束:主备不得为同一节点;选作生效目标的节点须有合法 IPv4。
@@ -140,10 +148,20 @@ OpenFlare 库表为 Source of Truth。每个成员期望:
| name | ZoneDomain 的 FQDN |
| content | 分组 `active_node` 的 IPv4 |
| proxied | 成员 `proxied` |
| ttl | 橙云开启时由 CF 强制 Auto;关闭时使用统一默认(如 300) |
| ttl | 橙云成员始终传 `1`(自动);DNS-only 成员使用分组 `ttl`,升级前分组继续使用 300 秒 |
一期不写 AAAA。节点 IP 非合法 IPv4 → 该成员 `error`。
### 分组 TTL
节点绑定和自定义 A / AAAA / CNAME 共用分组 TTL。在分组创建或编辑弹窗选择“自动”或“手动”;手动模式填写 `60~86400` 的整数秒,不接受空值、小数或越界值。本模块采用所有 Cloudflare 套餐通用的范围,暂不开放仅 Enterprise 支持的 30~59 秒。
* 自动通过 Cloudflare API 的 `ttl: 1` 表示,不是 1 秒;Cloudflare 当前的 Auto 为 300 秒,因此切换到自动并不会缩短缓存时间。
* 成员实际 `proxied=true` 时始终使用自动;分组 TTL 只控制灰云成员,分组默认橙云不影响 TTL 是否可编辑。
* 修改 TTL 后,已有成员标记为待同步;启用的分组沿用现有整组任务自动同步,停用的分组暂不下发。成员移入另一分组后使用目标分组的 TTL。
* 新建分组省略 `ttl` 时默认自动;更新省略 `ttl` 时保留原值。分组查询始终返回可直接回填的 `ttl`(自动或手动秒数)。
* PostgreSQL / SQLite 为分组表新增 `ttl`,默认零值仅用于兼容旧数据,业务层将其解释为 300 秒,不向 Cloudflare 发送 `0`;不新增成员 TTL 字段,也不批量改写已有成员。回滚迁移会删除该字段及已保存的 TTL 配置。
### 触发
| 触发 | 行为 |
+6 -5
View File
@@ -2,18 +2,19 @@
你会学到:OpenFlare 是什么、当前稳定能力,以及开发时应遵守的核心产品边界与仓库结构目录分工。
OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组织内部运维场景。
OpenFlare 是一套新一代分布式反向代理与边缘安全网关平台,面向多服务器集群与单团队运维场景。
---
## 项目定位
OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具备以下定位:
OpenFlare 适合需要统一管理多台 OpenResty 代理节点、构建自建高可用边缘网络,或为源站搭建坚固防护网关的团队,具备以下定位:
* **控制与落地分离**:Server 控制面不直接 SSH 到代理节点,而是通过 Agent 主动拉取版本并应用。
* **不可变配置发布**:采用完整的配置版本进行预览、发布、激活和一键回滚。
* **一体化网关托管**:在同一个控制面内集成网站反代、TLS 证书自动续期申请、WAF 防护拦截、内网穿透(Tunnel)以及 Pages 静态网站托管。
* **Zero-Reload 动态配置同步**:基于 Lua 共享内存实现差分热更新,避免传统 Nginx 频繁 reload 导致的连接抖动。
* **一体化边缘安全网关**:在同一个控制面内集成反向代理、TLS 证书自动续期、WAF 防护拦截、Turnstile-like 客户端 PoW 防 CC 盾、内网穿透(Tunnel)以及 Pages 静态网站托管。
* **双重部署生态适应性**:既支持作为独立私有边缘 CDN 运行,亦可作为 Cloudflare 背后坚固的源站防护网关(Origin Shield),防止源站真实 IP 泄露并沉淀全量私有审计日志。
**非本产品定位**:多租户云平台、Kubernetes Ingress Controller、服务网格或通用日志平台。
**非本产品定位**:多租户公有云平台、Kubernetes Ingress Controller、服务网格或通用日志平台。
---
+45
View File
@@ -0,0 +1,45 @@
# 运行时语言与国际化
## 运维输出与界面文案
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 消息映射,以及服务端同名语言目录项,并验证格式占位符一致。新增系统消息仍以英文作为源文案,在相应语言资源中补齐译文。新增数据协议应使用稳定标识与参数,不再引入以中文显示文案充当持久化键的设计。
+13 -3
View File
@@ -10,7 +10,7 @@
* Zone 根域使用 Public Suffix List 解析,例如 `api.example.co.uk` 归属 `example.co.uk`。
* URL 使用 ID:列表为 `/websites`,详情为 `/websites/:zoneId`;不使用域名作为 URL 参数。
* Zone 域名必须是明确的 FQDN,禁止录入 `*.example.com`。TLS 证书可仍含通配符 SAN,并用于覆盖明确的 Zone 域名。
* Zone 根域不允许通配符;Zone 域名支持具体 FQDN 或最左侧完整标签为 `*` 的通配符 FQDN,例如 `*.example.com`、`*.s3.example.com`。后端去掉 `*.` 后校验注册根域归属,并拒绝非法 DNS 标签、空白与配置分隔符。
* 一个 Zone 域名至多关联一条反代路由;一条反代路由可关联多个 Zone 域名,因而可跨 Zone 共享同一套上游、缓存、限流、WAF 与 Pages 配置。
* Zone 模型本身不新增 DNS 记录、边缘函数、预览子域或租户隔离能力。对外 DNS A 记录的创建/更新由独立的 Cloudflare 指向模块负责,且不改变 Zone / ZoneDomain 表职责。
@@ -43,7 +43,7 @@ erDiagram
### `of_zone_domains`
保存 `zone_id`、明确 `domain`、可空的 `proxy_route_id`、可空的 `cert_id` 及时间戳。`domain` 全局唯一;所有关系字段建立索引但不建立物理外键。`proxy_route_id` 允许为空,以承接已准备证书但尚未配置反代的历史域名。
保存 `zone_id`、具体或通配符 `domain`、可空的 `proxy_route_id`、可空的 `cert_id` 及时间戳。`domain` 全局唯一;所有关系字段建立索引但不建立物理外键。`proxy_route_id` 允许为空,以承接已准备证书但尚未配置反代的历史域名。通配符作为一条普通域名记录存储,不展开为子域名列表,不需要额外列或迁移。
`of_proxy_routes` 逐步移除 `domain`、`domains`、`cert_id`、`cert_ids` 与 `domain_cert_ids` 等域名/证书冗余列。路由不得再指定任何 TLS 证书;路由名称 `site_name` 成为稳定的人类可读标识,编译器从关联的 Zone 域名读取 `server_name` 与其 `cert_id`。这使每个明确域名的证书只有一个来源。
@@ -70,13 +70,21 @@ WAF、Pages、上游与发布版本仍属于 `proxy_routes`。Zone 概览只聚
详情页包含:
* 概览:域名、路由和有效证书统计;域名—路由—证书摘要;路由级 WAF 与 Pages 摘要。
* 域名:明确 FQDN 的列表、证书选择和关联路由;不显示或接受通配符域名。
* 域名:具体或通配符 FQDN 的列表、证书选择和关联路由;快捷新增支持 `*` 展开为 `*.当前根域`,多级泛域名须输入完整名称。
* 路由:筛选到当前 Zone 的路由并链接到既有路由详情。
* 证书:当前 Zone 域名实际引用的证书。
* 设置:Zone 备注和受保护的删除操作。
新增路由时从 Zone 域名中选择;用户也可以先在 Zone 中登记域名,再绑定路由。全局反代路由入口保留,但改用同一套 Zone 域名选择器。
### 通配符运行语义
* 配置快照保留 `*.suffix` 原文;绑定路由并发布激活版本后,Agent 使用共享渲染器生成 `server_name *.suffix;`。仅登记域名而未绑定路由不会生成代理规则。
* 路由匹配沿用 OpenResty/Nginx 的规则:具体域名优先于通配符,多个通配符中更长的匹配后缀优先;`*.example.com` 不包含 `example.com` 本身,但可匹配多层子域名。
* TLS 覆盖范围与 HTTP 路由匹配不同:`*.s3.example.com` 站点必须绑定含相应通配符 SAN 的证书;它不能用仅覆盖某个具体子域名的证书替代。单层通配符证书不覆盖根自身或更深层子域名,发布时保留证书覆盖校验。
* Zone 流量统计按实际请求 Host 匹配具体域名或非空通配符后缀。ClickHouse、PostgreSQL 与 SQLite 使用相同的匹配约定,重叠的具体域名/通配符条件不会重复计数;域名数量仍表示登记记录数。
* 通配符不可直接作为访问或探测 URL。Uptime Kuma 同步优先选择路由绑定的具体域名;只有通配符时跳过创建/更新并记录原因,保留已有监控。
## 数据迁移
本次改造分两个发布阶段,以免 SQL 用错误的“末两段域名”规则处理多级公共后缀。操作细则见 [Zone 域名迁移与发布验收](../guide/zone-domain-migration.md)。
@@ -86,6 +94,8 @@ WAF、Pages、上游与发布版本仍属于 `proxy_routes`。Zone 概览只聚
3. **代码切换**:控制面 API、配置快照、渲染、前端均以 Zone 域名为唯一来源;路由写入仅使用 `zone_domain_ids`。
4. **第二阶段清理**:Goose SQL `202607130001_drop_legacy_route_domain_columns` 删除 `of_managed_domains` 与 `of_proxy_routes` 冗余列。Down 仅恢复开发库空结构,不回填历史数据。
历史数据升级导入时,Unicode 域名经 IDNA 转换并保存为 ASCII(Punycode)形式,以统一根域归属、全局唯一性、HTTP 路由匹配及 HTTPS 证书校验;原记录的证书和站点绑定保持不变。新增域名仍须符合 ASCII DNS 标签规则。
### 运行时模型边界
* 持久化:域名与证书只存在于 `of_zone_domains`;`of_proxy_routes` 仅保存路由策略(上游、缓存、限流、WAF 绑定键等)。
+273 -4
View File
@@ -4443,12 +4443,12 @@ const docTemplate = `{
"summary": "注册或发现 Agent 节点",
"parameters": [
{
"description": "节点上报数据",
"description": "节点上报数据;registration_key 为首次注册重试凭证",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/agent.NodePayload"
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest"
}
}
],
@@ -4464,7 +4464,7 @@ const docTemplate = `{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/agent.RegistrationResponse"
"$ref": "#/definitions/agent.RegistrationResponseDocs"
}
}
}
@@ -7953,6 +7953,169 @@ 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": [
@@ -12754,6 +12917,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
"description": "支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。",
"consumes": [
"application/json"
],
@@ -12881,6 +13045,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
"description": "支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。",
"consumes": [
"application/json"
],
@@ -14989,12 +15154,15 @@ const docTemplate = `{
}
}
},
"agent.RegistrationResponse": {
"agent.RegistrationResponseDocs": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"agent_token": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -15351,6 +15519,11 @@ const docTemplate = `{
},
"target_mode": {
"type": "string"
},
"ttl": {
"description": "TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。",
"type": "integer",
"example": 1
}
}
},
@@ -15393,6 +15566,11 @@ const docTemplate = `{
"target_mode": {
"type": "string"
},
"ttl": {
"description": "TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。",
"type": "integer",
"example": 1
},
"updated_at": {
"type": "string"
}
@@ -16293,6 +16471,77 @@ 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": {
@@ -17681,6 +17930,26 @@ 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": {
+1 -1
View File
@@ -2,7 +2,7 @@
你会学到:OpenFlare 文档如何组织、首次运行应该读哪些页面,以及部署、使用、排查分别从哪里开始。
OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站配置、配置版本发布、Agent 节点同步、TLS 证书和基础观测放到一个管理端中,适合单团队或单组织管理多台代理节点。
OpenFlare 是新一代分布式反向代理与边缘安全网关。它将传统 OpenResty / Nginx 升级为具备多节点集群编排、Zero-Reload 动态配置同步、边缘 WAF、客户端 PoW 防 CC 人机验证、内网穿透(Tunnels)与 Pages 静态托管的现代化边缘网络平台。可独立自建运行,亦可作为源站护盾与 Cloudflare 协同防御。
## 推荐阅读路径
+15 -3
View File
@@ -38,16 +38,28 @@
证书和源站就绪后,即可创建核心网站代理路由:
1. 进入左侧导航 **「网站管理」->「域名列表」**,点击 **「新增 Zone」**:
* **域名**:输入该站点绑定的域名。
1. 进入左侧导航 **「网站管理」->「域名列表」**,点击 **「新增 Zone」**,登记注册根域(如 `example.com`,不可填写 `*.example.com`)。进入该 Zone 的域名列表添加具体域名或通配符域名:
* **域名**:输入 `api`、`@`、`*` 或完整名称(如 `*.s3.example.com`)。`*` 展开为 `*.example.com`,不支持 `*.s3` 简写。
* **绑定证书**:选择第一步准备或申请好的证书。
2. 配置请求路由规则:进入 **「规则管理」** 页面,点击 **「新建规则」** 或编辑已有规则:
* **规则名称**:输入规则的唯一简易标识(如 `app-portal-route`)。
* **域名匹配**:填入对应的域名(支持通配符或精确域名,需与上面登记的域名一致)。
* **域名匹配**:从已登记域名中选择具体或通配符域名,也可通过「快捷新增域名」创建并绑定。
* 在下方 **「反向代理」** 选项卡下,选择 **回源方式** 为「直连上游」。
* **源站选择**:从下拉框中选择第二步创建的源站;或者选择手动输入并填入 `http://10.0.0.20:9000`。
3. 点击保存创建配置。
### 通配符域名示例
要让 `bucket.s3.example.com` 等域名共用站点规则:
1. 在 `example.com` Zone 下添加 `*.s3.example.com` 并绑定路由;不需要逐个登记 bucket 域名。
2. 使用 HTTPS 时绑定 SAN 包含 `*.s3.example.com` 的证书。`*.example.com` 证书不能替代它;`*.s3.example.com` 证书也不覆盖 `s3.example.com` 或 `a.bucket.s3.example.com`。
3. 在 Cloudflare 分组中选择该通配符域名:节点模式跟随节点 IPv4 写 A 记录;自定义模式可写 A、AAAA 或 CNAME。DNS 同步与站点配置发布是两件事,须分别确认成功。
4. 按下一节发布并激活版本,Agent 会收到并渲染 `server_name *.s3.example.com;`。通配符沿用 Nginx 后缀匹配,可匹配多层子域名但不含后缀根自身;具体域名路由优先,重叠通配符以更长后缀优先。TLS 仍受上一条的单层证书覆盖限制。
5. 使用具体主机名访问验证,例如 `https://bucket.s3.example.com`,不要直接打开带 `*` 的地址。Zone 统计会聚合命中的实际 Host;Uptime Kuma 则需要至少绑定一个具体域名,否则跳过该路由的监控创建/更新并记录原因。
不允许 `foo*.example.com`、`a.*.example.com` 或多个 `*`;带协议、路径、端口、域名内部空白及非法 DNS 标签的输入也会被拒绝。
---
## 第四步:发布并生效配置
+3 -1
View File
@@ -32,11 +32,13 @@
自动 IP 组先按单个客户端 IP 聚合指标,再对每个 IP 执行规则表达式:
1. Server 读取最近 `lookback` 时长内的请求日志。
2. 按 `remote_addr` 归一化后的 IP 分组。
2. 按 `remote_addr` 归一化后的 IP 分组,跳过 IPv4 私网、IPv6 唯一本地地址(ULA)、回环、链路本地、未指定、组播及广播地址;IPv4 映射 IPv6 地址按对应的 IPv4 地址判断。
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`。
## 可用关键字
+114 -20
View File
@@ -3,36 +3,130 @@ layout: home
hero:
name: OpenFlare
text: 开源 CDN 编排与边缘安全平台
tagline: 支持反向代理、集中式配置同步、Pages 静态托管、内网穿透(Tunnels)、动态 WAF 防护与人机防 CC 挑战。
text: 新一代分布式反向代理与边缘安全网关
tagline: 让 Nginx 拥有现代云端边缘能力。集中编排多节点 OpenResty、Zero-Reload 动态热生效、内置 WAF 与客户端 PoW 防 CC 盾。可独立自建运行,亦可作为源站护盾与 Cloudflare 协同防御。
actions:
- theme: brand
text: 快速开始
link: /guide/quick-start
- theme: alt
text: 设计边界
link: /design/
text: 系统架构
link: /design/architecture
- theme: alt
text: GitHub
link: https://github.com/Rain-kl/OpenFlare
features:
- icon: 🛰️
title: 集中式配置同步
details: 通过 WebSocket 与心跳实现全网节点配置秒级同步下发与热生效,状态即时回收。
- icon: 🌐
title: 分布式 CDN 编排
details: 将独立的 OpenResty 编排为高度协同的分布式 CDN 舰队,支持源站多负载均衡。
- icon: 📄
title: Pages 静态托管
details: 直接上传前端打包 zip 资产,由边缘节点拉取解压并提供高性能本地服务与 API 代理。
- icon: ⚡
title: 集群化集中编排 (Zero-Reload)
details: 单一控制台统一管理任意数量边缘节点,配置差分毫秒级下发,无需重载 Nginx,业务平滑无抖动。
- icon: 🛡️
title: 边缘 WAF 与防 CC 盾 (PoW)
details: 内置 WAF 规则组、国家级 GeoIP 访问控制,以及类似 Cloudflare Turnstile 的客户端密码学 PoW 挑战。
- icon: 🚇
title: 安全内网穿透 (Tunnels)
details: 对标 Cloudflare Tunnels,无须公网 IP 或暴露入向端口,安全穿透本地服务至公网。
- icon: 🛡️
title: 边缘 WAF 安全防护
details: IP 组成员差分同步写入 Lua 共享内存,实现免 Nginx 重载的 WAF 热更新与 GeoIP 过滤。
- icon: 🧩
title: 防 CC 与人机挑战 (PoW)
details: 内置高性能客户端 Proof of Work 密码学挑战,网关边缘秒级拦截阻断僵尸网络与爬虫。
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 单点登录。
---
<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>
+17 -17
View File
@@ -926,8 +926,8 @@ packages:
markdown-it-mathjax3@4.3.2:
resolution: {integrity: sha512-TX3GW5NjmupgFtMJGRauioMbbkGsOXAAt1DZ/rzzYmTHqzkO1rNAdiMD4NiruurToPApn2kYy76x02QN26qr2w==}
markdown-it@14.3.2:
resolution: {integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==}
markdown-it@14.3.3:
resolution: {integrity: sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==}
hasBin: true
markdown-title@1.0.2:
@@ -1225,8 +1225,8 @@ packages:
slick@1.12.2:
resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
space-separated-tokens@2.0.2:
@@ -1352,8 +1352,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@7.3.3:
resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==}
vite@7.3.7:
resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -1759,10 +1759,10 @@ snapshots:
'@ungap/structured-clone@1.3.1': {}
'@vitejs/plugin-vue@6.0.6(vite@7.3.3(jiti@1.21.7))(vue@3.5.34)':
'@vitejs/plugin-vue@6.0.6(vite@7.3.7(jiti@1.21.7))(vue@3.5.34)':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
vite: 7.3.3(jiti@1.21.7)
vite: 7.3.7(jiti@1.21.7)
vue: 3.5.34
'@vue/compiler-core@3.5.34':
@@ -1771,7 +1771,7 @@ snapshots:
'@vue/shared': 3.5.34
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
source-map-js: 1.2.2
'@vue/compiler-dom@3.5.34':
dependencies:
@@ -1788,7 +1788,7 @@ snapshots:
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.14
source-map-js: 1.2.1
source-map-js: 1.2.2
'@vue/compiler-ssr@3.5.34':
dependencies:
@@ -2222,7 +2222,7 @@ snapshots:
transitivePeerDependencies:
- encoding
markdown-it@14.3.2:
markdown-it@14.3.3:
dependencies:
argparse: 2.0.1
entities: 4.5.0
@@ -2532,7 +2532,7 @@ snapshots:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
source-map-js: 1.2.2
pretty-bytes@7.1.0: {}
@@ -2679,7 +2679,7 @@ snapshots:
slick@1.12.2: {}
source-map-js@1.2.1: {}
source-map-js@1.2.2: {}
space-separated-tokens@2.0.2: {}
@@ -2817,7 +2817,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@7.3.3(jiti@1.21.7):
vite@7.3.7(jiti@1.21.7):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
@@ -2832,7 +2832,7 @@ snapshots:
vitepress-plugin-llms@1.12.2:
dependencies:
gray-matter: 4.0.3
markdown-it: 14.3.2
markdown-it: 14.3.3
markdown-title: 1.0.2
mdast-util-from-markdown: 2.0.3
millify: 6.1.0
@@ -2858,7 +2858,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.3(jiti@1.21.7))(vue@3.5.34)
'@vitejs/plugin-vue': 6.0.6(vite@7.3.7(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 +2867,7 @@ snapshots:
mark.js: 8.11.1
minisearch: 7.2.0
shiki: 3.23.0
vite: 7.3.3(jiti@1.21.7)
vite: 7.3.7(jiti@1.21.7)
vue: 3.5.34
optionalDependencies:
markdown-it-mathjax3: 4.3.2
+4
View File
@@ -87,6 +87,8 @@ 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 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
| --- | --- | --- | --- |
@@ -126,6 +128,8 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
| `log.max_size` | - | 单个日志文件的最大空间(MB),超出自动切割轮转 | `100` |
| `log.max_age` | - | 历史切割日志文件最大保留天数 | `30` |
Server、Agent、调度器、任务框架及存储维护等运行时日志的固定描述使用英文,便于跨语言排障和检索。界面语言切换不会改变终端日志和 OTel 异常;接口错误、任务执行详情与内置元数据在展示时按语言翻译,历史记录不回写。用户配置名称及第三方错误保留原文,可能包含中文。现有日志格式、级别、参数及输出渠道保持不变,详见 [运行时语言与国际化](../design/log-language.md)。
### 6. 异步任务 Worker 队列配置 (`worker:`)
| 配置文件 YAML 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
| --- | --- | --- | --- |
@@ -0,0 +1,161 @@
# 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.
+273 -4
View File
@@ -4436,12 +4436,12 @@
"summary": "注册或发现 Agent 节点",
"parameters": [
{
"description": "节点上报数据",
"description": "节点上报数据;registration_key 为首次注册重试凭证",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/agent.NodePayload"
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest"
}
}
],
@@ -4457,7 +4457,7 @@
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/agent.RegistrationResponse"
"$ref": "#/definitions/agent.RegistrationResponseDocs"
}
}
}
@@ -7946,6 +7946,169 @@
}
}
},
"/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": [
@@ -12747,6 +12910,7 @@
"SessionCookie": []
}
],
"description": "支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。",
"consumes": [
"application/json"
],
@@ -12874,6 +13038,7 @@
"SessionCookie": []
}
],
"description": "支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。",
"consumes": [
"application/json"
],
@@ -14982,12 +15147,15 @@
}
}
},
"agent.RegistrationResponse": {
"agent.RegistrationResponseDocs": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"agent_token": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -15344,6 +15512,11 @@
},
"target_mode": {
"type": "string"
},
"ttl": {
"description": "TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。",
"type": "integer",
"example": 1
}
}
},
@@ -15386,6 +15559,11 @@
"target_mode": {
"type": "string"
},
"ttl": {
"description": "TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。",
"type": "integer",
"example": 1
},
"updated_at": {
"type": "string"
}
@@ -16286,6 +16464,77 @@
}
}
},
"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": {
@@ -17674,6 +17923,26 @@
}
}
},
"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": {
+175 -4
View File
@@ -99,10 +99,12 @@ definitions:
type: string
type: object
type: object
agent.RegistrationResponse:
agent.RegistrationResponseDocs:
properties:
access_token:
type: string
agent_token:
type: string
name:
type: string
node_id:
@@ -336,6 +338,10 @@ definitions:
type: string
target_mode:
type: string
ttl:
description: TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。
example: 1
type: integer
type: object
cloudflare.GroupItem:
properties:
@@ -363,6 +369,10 @@ definitions:
type: string
target_mode:
type: string
ttl:
description: TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。
example: 1
type: integer
updated_at:
type: string
type: object
@@ -955,6 +965,53 @@ 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:
@@ -1882,6 +1939,19 @@ 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:
@@ -7020,12 +7090,12 @@ paths:
- application/json
description: 使用节点 access token 重新注册,或使用全局 discovery token 发现新节点;请求头需携带 X-Agent-Token
parameters:
- description: 节点上报数据
- description: 节点上报数据;registration_key 为首次注册重试凭证
in: body
name: body
required: true
schema:
$ref: '#/definitions/agent.NodePayload'
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest'
produces:
- application/json
responses:
@@ -7036,7 +7106,7 @@ paths:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/agent.RegistrationResponse'
$ref: '#/definitions/agent.RegistrationResponseDocs'
type: object
"400":
description: 参数错误
@@ -9070,6 +9140,48 @@ 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 版本信息,需要管理员权限
@@ -9457,6 +9569,63 @@ 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 配置项,需要管理员权限
@@ -12026,6 +12195,7 @@ paths:
post:
consumes:
- application/json
description: 支持具体域名或最左侧完整标签为 * 的通配符域名,例如 *.s3.example.com;域名必须属于当前 Zone。
parameters:
- description: Zone ID
in: path
@@ -12104,6 +12274,7 @@ paths:
post:
consumes:
- application/json
description: 支持具体域名与通配符域名互相修改;通配符仅允许出现在最左侧完整标签,且域名必须属于当前 Zone。
parameters:
- description: Zone ID
in: path
+12 -6
View File
@@ -1,17 +1,23 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { apiSections, DOCS_LAST_UPDATED } from '@/components/common/docs/api';
import {
createApiSections,
DOCS_LAST_UPDATED,
} from '@/components/common/docs/api';
export default function ApiDocPage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title='API 接口文档'
title={rt('api_documentation_cb3d2229')}
lastUpdated={DOCS_LAST_UPDATED}
sections={apiSections}
sections={createApiSections(rt)}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
OpenFlare 提供简单、强大的 API
接口,支持多种编程语言和开发环境。通过标准化的 RESTful
接口,您可以轻松地与后端服务进行集成与二次开发。
{rt('openflare_provides_a_simple_powerful_api_for_ba400468')}
</p>
}
/>
+9 -5
View File
@@ -1,17 +1,21 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { howToUseSections } from '@/components/common/docs/how-to-use';
import { createHowToUseSections } 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='平台使用指南'
title={rt('platform_user_guide_d0ead991')}
lastUpdated={DOCS_LAST_UPDATED}
sections={howToUseSections}
sections={createHowToUseSections(rt)}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
欢迎使用 OpenFlare
管理平台。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
{rt('welcome_to_the_openflare_management_platform_this_e8ecbe4d')}
</p>
}
/>
@@ -1,18 +1,23 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { privacySections } from '@/components/common/docs/privacy';
import { createPrivacySections } 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='隐私政策 (Privacy)'
title={rt('privacy_policy_b6da1ca6')}
lastUpdated={TERMS_LAST_UPDATED}
sections={privacySections}
sections={createPrivacySections(rt)}
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,16 +1,25 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import {
TERMS_LAST_UPDATED,
termsSections,
createTermsSections,
} from '@/components/common/docs/terms';
export default function TermsOfServicePage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title='服务协议 (Terms)'
title={rt('terms_of_service_4e80a140')}
lastUpdated={TERMS_LAST_UPDATED}
sections={termsSections}
description={<span>为了保障您的合法权益,请您仔细阅读以下条款。</span>}
sections={createTermsSections(rt)}
description={
<span>
{rt('please_read_the_following_terms_carefully_to_c46a6a61')}
</span>
}
/>
);
}
@@ -1,3 +1,4 @@
import { getFormattingLocale } from '@/i18n/format';
export type AccessLogTab = 'overview' | 'ips' | 'list';
export type SearchDraft = {
@@ -66,7 +67,7 @@ export function parseSortValue(value: string) {
}
export function formatCompactNumber(value: number) {
return new Intl.NumberFormat('zh-CN', {
return new Intl.NumberFormat(getFormattingLocale(), {
notation: value >= 10000 ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(value);
@@ -368,6 +368,8 @@ export function IpAnalysisPanel({
*/
initialHours?: number;
}) {
const uiText = useTranslations('runtime');
const t = useTranslations('accessLogs');
const trendChartConfig = useTrendChartConfig();
const [ipGroupOpen, setIpGroupOpen] = useState(false);
@@ -576,32 +578,32 @@ export function IpAnalysisPanel({
{analysis ? (
<div className='grid gap-3 md:grid-cols-2'>
<MiniRankCard
title='Top Paths'
title={uiText('ui_topPaths')}
color='#a78bfa'
items={toRankItems(analysis.top_paths)}
/>
<MiniRankCard
title='Top Hosts'
title={uiText('ui_topHosts')}
color='#34d399'
items={toRankItems(analysis.top_hosts)}
/>
<MiniRankCard
title='Status Codes'
title={uiText('ui_statusCodes')}
color='#f59e0b'
items={toRankItems(analysis.status_codes)}
/>
<MiniRankCard
title='Top User-Agents'
title={uiText('ui_topUserAgents')}
color='#818cf8'
items={toRankItems(analysis.top_user_agents)}
/>
<MiniRankCard
title='Device Types'
title={uiText('ui_deviceTypes')}
color='#38bdf8'
items={toRankItems(analysis.device_types)}
/>
<MiniRankCard
title='Top Browsers'
title={uiText('ui_topBrowsers')}
color='#22c55e'
items={toRankItems(analysis.top_browsers)}
/>
@@ -318,6 +318,8 @@ 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),
@@ -331,7 +333,7 @@ function OverviewContent({
<>
<div className='grid gap-4 lg:grid-cols-3'>
<SparklineMetricCard
title='Total Requests'
title={uiText('ui_totalRequests')}
value={formatCompactNumber(data.summary.total_requests)}
hint={hint}
color='#f59e0b'
@@ -340,7 +342,7 @@ function OverviewContent({
values={requestValues}
/>
<SparklineMetricCard
title='Total Visits'
title={uiText('ui_totalVisits')}
value={formatCompactNumber(data.summary.total_visits)}
hint={t('overview.uniqueVisitors', { hint })}
color='#38bdf8'
@@ -349,7 +351,7 @@ function OverviewContent({
values={visitValues}
/>
<SparklineMetricCard
title='Bandwidth Served'
title={uiText('ui_bandwidthServed')}
value={formatBytes(data.summary.bandwidth_served)}
hint={t('overview.served', { hint })}
color='#34d399'
@@ -363,7 +365,7 @@ function OverviewContent({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
Requests over time
{uiText('ui_requestsOverTime')}
</CardTitle>
<CardDescription className='text-xs'>
{t('overview.requestsHint')}
@@ -389,13 +391,13 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-2'>
<PieDistributionCard
title='Requests by device type'
title={uiText('ui_requestsByDevice')}
description={t('overview.deviceDesc')}
items={data.device_types ?? []}
emptyMessage={t('overview.deviceEmpty')}
/>
<PieDistributionCard
title='Status code'
title={uiText('ui_statusCodes')}
description={t('overview.statusDesc')}
items={data.status_codes ?? []}
emptyMessage={t('overview.statusEmpty')}
@@ -404,17 +406,17 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top Paths'
title={uiText('ui_topPaths')}
description={t('overview.topPathsDesc')}
items={toRankItems(data.top_paths)}
/>
<RankCard
title='Top Hosts'
title={uiText('ui_topHosts')}
description={t('overview.topHostsDesc')}
items={toRankItems(data.top_hosts)}
/>
<RankCard
title='Top IPs'
title={uiText('ui_topIps')}
description={t('overview.topIpsDesc')}
items={toRankItems(data.top_ips)}
/>
@@ -422,17 +424,17 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top browsers'
title={uiText('ui_topBrowsers')}
description={t('overview.browsersDesc')}
items={toRankItems(data.top_browsers)}
/>
<RankCard
title='Top Operating System'
title={uiText('ui_topOperatingSystems')}
description={t('overview.osDesc')}
items={toRankItems(data.top_operating_systems)}
/>
<RankCard
title='Top User-Agent'
title={uiText('ui_topUserAgents')}
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 { useTranslations } from 'next-intl';
import { useFormatter, useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { HardDrive, RefreshCw, Trash2 } from 'lucide-react';
@@ -28,25 +28,7 @@ import {
} from '@/components/ui/dialog';
import type { CacheStatus } from '@/lib/services/admin';
import services from '@/lib/services';
/**
* 格式化字节大小
*/
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, ',');
};
import { formatBytes } from '@/lib/utils/metrics';
interface CacheManagerProps {
refreshTrigger: number;
@@ -54,6 +36,7 @@ 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);
@@ -208,7 +191,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{t('cacheKeysCount')}
</p>
<p className='text-xl font-bold tracking-tight text-foreground'>
{formatNumber(cacheStatus.keys_count)}{' '}
{format.number(cacheStatus.keys_count)}{' '}
<span className='text-xs text-muted-foreground font-normal'>
{t('files')}
</span>
@@ -142,7 +142,9 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
</div>
</div>
<div className='text-xs text-muted-foreground font-mono'>
{dbType === 'postgres' ? 'PostgreSQL Connected' : 'SQLite Connected'}
{t('connected', {
database: dbType === 'postgres' ? 'PostgreSQL' : 'SQLite',
})}
</div>
</div>
@@ -2,7 +2,7 @@
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useFormatter, useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Layers, RefreshCw } from 'lucide-react';
@@ -33,14 +33,6 @@ 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, ',');
};
/**
* 格式化表格单元格内容
*/
@@ -69,6 +61,7 @@ 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);
@@ -222,7 +215,7 @@ export function TableBrowser({
<div>
{t('totalRows')}:{' '}
<span className='font-mono text-foreground font-semibold'>
{formatNumber(tableData.total)}
{format.number(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 { useTranslations } from 'next-intl';
import { useFormatter, useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
Activity,
@@ -49,16 +49,9 @@ 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[]>([]);
@@ -275,7 +268,7 @@ export function DatabasePageClient() {
</CardHeader>
<CardContent>
<div className='text-sm font-bold'>
{formatNumber(overview.table_count)}
{format.number(overview.table_count)}
</div>
</CardContent>
</Card>
@@ -290,7 +283,7 @@ export function DatabasePageClient() {
</CardHeader>
<CardContent>
<div className='text-sm font-bold'>
{formatNumber(overview.connections)}
{format.number(overview.connections)}
</div>
</CardContent>
</Card>
+19 -17
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,6 +22,8 @@ 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');
@@ -59,7 +61,8 @@ export default function HeaderDemoPage() {
{t('rules.structureFocus.description')}
</li>
<li>
<strong>{t('rules.containerAlignment.title')}</strong>:使用{' '}
<strong>{t('rules.containerAlignment.title')}</strong>
{rt('use_1b3d699e')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-center gap-2
</code>{' '}
@@ -67,22 +70,23 @@ 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>:直接将
Lucide 图标组件嵌套于标题容器中,应用{' '}
<strong>{t('rules.iconPresentation.title')}</strong>
{rt('place_the_lucide_icon_directly_inside_the_104f794c')}{' '}
<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>:文本使用且仅使用{' '}
<strong>{t('rules.textStyle.title')}</strong>
{rt('use_only_5e12eecb')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'h1 className="text-2xl font-semibold tracking-tight"'}
</code>
@@ -93,10 +97,8 @@ export default function HeaderDemoPage() {
{t('rules.textStyle.description2')}
</li>
<li>
<strong>{t('rules.tabsModular.title')}</strong>:凡是带有多个
Tab 页切换的页面,**禁止**将所有 Tab
的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的
Content 必须单独拆分为独立组件文件(如{' '}
<strong>{t('rules.tabsModular.title')}</strong>
{rt('for_pages_with_multiple_tabs_keep_each_5f721959')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
tabs/events-tab.tsx
</code>{' '}
@@ -107,8 +109,8 @@ export default function HeaderDemoPage() {
{t('rules.tabsModular.description2')}
</li>
<li>
<strong>{t('rules.complexityDriven.title')}</strong>:不仅是
Tabs 切换,当一个页面代码行数超过 600
<strong>{t('rules.complexityDriven.title')}</strong>
{rt('beyond_tabs_when_a_page_exceeds_600_d152cfdc')}
{t('rules.complexityDriven.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
app/(main)/admin/database/components/
@@ -121,19 +123,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,6 +26,8 @@ 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: '',
@@ -166,78 +168,71 @@ 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>
Checkbox 应在左侧垂直偏上对齐,使用容器{' '}
{rt('align_checkboxes_toward_the_top_left_using_092dcef0')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-start gap-2.5
</code>{' '}
排列。Checkbox 组件本身添加{' '}
{rt('add_bf15da3a')}{' '}
<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>
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '}
{rt('use_the_default_primary_or_secondary_db7513e5')}{' '}
<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>{`// 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>{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>
</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,6 +10,8 @@ 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'>
@@ -129,17 +131,9 @@ 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'>
{`<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>`}
{rt.raw(
'card_classname_border_dashed_shadow_none_cardheader_35727acf',
)}
</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,6 +20,8 @@ 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 = [
{
@@ -193,7 +195,7 @@ export function TableTab() {
border border-dashed shadow-none rounded-lg overflow-hidden
bg-background
</code>
。
{rt('sentence_end')}
</li>
<li>
{t('spec.headerBg')}
@@ -204,7 +206,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')}
@@ -215,14 +217,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')}
@@ -233,27 +235,7 @@ 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'>
{`<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>`}
{rt.raw('div_classname_border_border_dashed_shadow_none_b430b520')}
</pre>
</CardContent>
</Card>
@@ -1,5 +1,6 @@
'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';
@@ -72,7 +73,7 @@ function getFileIcon(mimeType: string, className = 'size-8') {
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('zh-CN', {
return new Date(dateStr).toLocaleString(getFormattingLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -82,6 +83,8 @@ function formatDate(dateStr: string) {
}
export function FileList() {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.files');
const queryClient = useQueryClient();
const [keyword, setKeyword] = React.useState('');
@@ -564,7 +567,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'>
Hash (SHA-256)
{uiText('ui_fileHash')}
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.hash}
@@ -50,6 +50,12 @@ 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 {
@@ -158,11 +164,11 @@ export function FileStats() {
stats.categories.forEach((cat) => {
if (cat.count > maxCount) {
maxCount = cat.count;
maxCountName = cat.name;
maxCountName = t(categoryTranslationKey(cat.name));
}
if (cat.size > maxSize) {
maxSize = cat.size;
maxSizeName = cat.name;
maxSizeName = t(categoryTranslationKey(cat.name));
}
});
@@ -217,7 +223,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('stats.countUnit')}
{t.raw('stats.countUnit')}
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
@@ -254,7 +260,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('stats.countUnit')}
{t.raw('stats.countUnit')}
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
@@ -378,7 +384,7 @@ export function FileStats() {
const formattedValue =
trendMetric === 'size'
? formatFileSize(Number(value))
: `${value} ${t('stats.countUnit')}`;
: `${value} ${t.raw('stats.countUnit')}`;
return (
<>
<div className='flex items-center gap-1.5'>
@@ -488,7 +494,7 @@ export function FileStats() {
</span>
</div>
<span className='text-foreground font-mono font-medium tabular-nums ml-auto'>
{value} {t('stats.countUnit')}
{value} {t.raw('stats.countUnit')}
</span>
</>
);
@@ -501,13 +501,15 @@ 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='Account ID'
label={uiText('ui_accountId')}
value={value.account_id || ''}
onChange={(account_id) => onChange({ account_id })}
/>
@@ -516,7 +518,7 @@ function ObjectFields({
{driver !== 's3' && (
<div className='md:col-span-2'>
<TextField
label='Endpoint'
label={uiText('ui_endpointLabel')}
value={value.endpoint}
placeholder='https://...'
onChange={(endpoint) => onChange({ endpoint })}
@@ -524,22 +526,22 @@ function ObjectFields({
</div>
)}
<TextField
label='Region'
label={uiText('ui_regionLabel')}
value={value.region}
onChange={(region) => onChange({ region })}
/>
<TextField
label='Bucket'
label={uiText('ui_bucketLabel')}
value={value.bucket}
onChange={(bucket) => onChange({ bucket })}
/>
<TextField
label='Access Key ID'
label={uiText('ui_accessKeyId')}
value={value.access_key_id}
onChange={(access_key_id) => onChange({ access_key_id })}
/>
<TextField
label='Secret Access Key'
label={uiText('ui_secretAccessKey')}
type='password'
value={value.secret_access_key}
onChange={(secret_access_key) => onChange({ secret_access_key })}
@@ -564,7 +566,7 @@ function ObjectFields({
>
<div className='flex flex-col gap-0.5'>
<FieldLabel className='text-xs font-semibold cursor-pointer'>
Path Style
{uiText('ui_pathStyle')}
</FieldLabel>
<FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'>
{t('storage.pathStyleDesc')}
@@ -17,6 +17,7 @@ limitations under the License.
'use client';
import { useCallback, useEffect, useState } from 'react';
import { isLogStorageDisabledMessage } from './log-storage-error';
import {
BarChart3,
Globe,
@@ -117,7 +118,7 @@ export function AccessAnalytics() {
const errorInstance =
err instanceof Error ? err : new Error(t('fetchFailed'));
const errMsg = errorInstance.message || '';
if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) {
if (isLogStorageDisabledMessage(errMsg)) {
setClickhouseDisabled(true);
} else {
setError(errorInstance);
@@ -1,3 +1,5 @@
'use client';
/*
Copyright 2026 Arctel.net
@@ -14,9 +16,8 @@ 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';
@@ -94,10 +95,12 @@ function statusVariant(status: number) {
function isClickhouseDisabledError(error: Error) {
const errMsg = error.message || '';
return errMsg.includes('ClickHouse') || errMsg.includes('未启用');
return isLogStorageDisabledMessage(errMsg);
}
export function AccessLogs() {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.logs.accessLogs');
const [page, setPage] = useState(1);
@@ -519,7 +522,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'>
Request Headers
{uiText('ui_requestHeaders')}
</Label>
<Button
variant='ghost'
@@ -0,0 +1,14 @@
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,6 +81,8 @@ 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>(
@@ -706,11 +708,11 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between'>
<Label className='text-xs font-semibold'>
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
<span className='text-[10px] text-muted-foreground font-mono'>
{newEventType === 'task'
? t('taskTemplateVars')
: t('eventTemplateVars')}
@@ -721,7 +723,11 @@ export function EventsTab() {
onChange={(e) => setNewEventTemplate(e.target.value)}
rows={6}
className='text-xs font-mono'
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
placeholder={rt.raw(
newEventType === 'task'
? 'task_notification_template_example'
: 'title_administrator_login_alert_content_administrator_user_6f32cf7c',
)}
/>
</div>
@@ -886,11 +892,11 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between'>
<Label className='text-xs font-semibold'>
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
<span className='text-[10px] text-muted-foreground font-mono'>
{selectedEvent.task_type
? t('taskTemplateVars')
: t('eventTemplateVars')}
@@ -901,7 +907,11 @@ export function EventsTab() {
onChange={(e) => setEventTemplate(e.target.value)}
rows={6}
className='text-xs font-mono'
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
placeholder={rt.raw(
selectedEvent.task_type
? 'task_notification_template_example'
: 'title_administrator_login_alert_content_administrator_user_6f32cf7c',
)}
/>
</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,6 +62,8 @@ 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>(
@@ -606,7 +608,7 @@ export function SettingsTab() {
toast.success(t('loadedFeishuTemplate'));
}}
>
飞书 Webhook
{rt('lark_webhook_c2e0b312')}
</Button>
<Button
variant='outline'
@@ -623,7 +625,9 @@ export function SettingsTab() {
msgtype: 'markdown',
markdown: {
title: '$title',
text: '### $title\n$content\n\n[查看详情]($url)',
text: rt(
'title_content_view_details_url_7af4b7d5',
),
},
},
null,
@@ -633,7 +637,7 @@ export function SettingsTab() {
toast.success(t('loadedDingTalkTemplate'));
}}
>
钉钉群机器人
{rt('dingtalk_group_bot_5d8efec9')}
</Button>
<Button
variant='outline'
@@ -649,8 +653,9 @@ export function SettingsTab() {
{
msgtype: 'markdown',
markdown: {
content:
'### $title\n$content\n\n[查看详情]($url)',
content: rt(
'title_content_view_details_url_7af4b7d5',
),
},
},
null,
@@ -660,7 +665,7 @@ export function SettingsTab() {
toast.success(t('loadedWeChatTemplate'));
}}
>
企业微信群机器人
{rt('wecom_group_bot_138ea7a8')}
</Button>
<Button
variant='outline'
@@ -684,7 +689,7 @@ export function SettingsTab() {
toast.success(t('loadedBarkTemplate'));
}}
>
Bark App
{rt('ui_barkApp')}
</Button>
</div>
</div>
@@ -64,6 +64,8 @@ async function copyText(value: string) {
}
export function OpenFlareOpsSettings() {
const uiText = useTranslations('runtime');
const t = useTranslations('openflareOps');
const queryClient = useQueryClient();
const [fields, setFields] = useState<OpenFlareOpsFields>(
@@ -346,7 +348,7 @@ export function OpenFlareOpsSettings() {
value={geoIPMutation.data.name || '—'}
/>
<InfoCell
label='ISO Code'
label={uiText('ui_isoCode')}
value={geoIPMutation.data.iso_code || '—'}
/>
</div>
@@ -427,7 +429,7 @@ export function OpenFlareOpsSettings() {
</CardHeader>
<CardContent className='space-y-4'>
<FieldInput
label='Server URL'
label={uiText('ui_serverUrl')}
value={fields.server_address}
onChange={(value) => updateField('server_address', value)}
placeholder='https://yourdomain.com'
@@ -1,7 +1,9 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { normalizeLocale } from '@/i18n/config';
import { formatSystemMetric } from '@/lib/utils/system-metrics';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
@@ -34,19 +36,12 @@ 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);
@@ -164,7 +159,7 @@ export function SystemStatusManager() {
<span
className={`text-xs font-mono font-medium ${field ? getPulseClass(field) : ''}`}
>
{typeof value === 'number' ? formatNumber(value) : value}
{formatSystemMetric(value, locale)}
</span>
</div>
);
+20 -5
View File
@@ -1,7 +1,8 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { localizeConfigDescription } from '@/i18n/builtin-metadata';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
@@ -52,6 +53,7 @@ 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'>
@@ -138,8 +140,16 @@ function SystemConfigDetailPanel({
type='text'
value={
editData.description !== undefined
? editData.description
: config?.description || ''
? localizeConfigDescription(
config?.key ?? '',
editData.description,
locale,
)
: localizeConfigDescription(
config?.key ?? '',
config?.description || '',
locale,
)
}
placeholder={t('optionalDescription')}
onChange={(e) => {
@@ -205,6 +215,7 @@ function SystemConfigDetailPanel({
*/
export function SystemConfigs() {
const t = useTranslations('admin.system');
const locale = useLocale();
const {
systemConfigs: configs,
systemConfigsLoading: loading,
@@ -309,9 +320,13 @@ export function SystemConfigs() {
cell: (item) => (
<span
className='truncate max-w-[200px] inline-block text-muted-foreground'
title={item.description}
title={localizeConfigDescription(
item.key,
item.description,
locale,
)}
>
{item.description}
{localizeConfigDescription(item.key, item.description, locale)}
</span>
),
width: '200px',
@@ -47,7 +47,6 @@ 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,
@@ -177,7 +176,6 @@ function DatePickerWithTime({
mode='single'
selected={date}
onSelect={handleDateSelect}
locale={zhCN}
/>
</PopoverContent>
</Popover>
@@ -34,6 +34,8 @@ export function UserDetailSheet({
detailLoading,
onStatusToggle,
}: UserDetailSheetProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.users');
const displayValue = (value?: string) =>
value && value.trim() ? value : '-';
@@ -72,7 +74,7 @@ export function UserDetailSheet({
</Badge>
{selectedUser.is_admin && (
<Badge className='h-4.5 px-1.5 text-[9px] uppercase font-medium'>
Admin
{uiText('ui_administrator')}
</Badge>
)}
</div>
@@ -61,6 +61,8 @@ export function LogDetailSheet({
open,
onOpenChange,
}: LogDetailSheetProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('applyLogs');
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -75,7 +77,7 @@ export function LogDetailSheet({
<div className='flex flex-wrap gap-2'>
<ResultBadge result={log.result} />
<Badge variant='outline' className='text-[10px] rounded-full'>
Node: {log.node_id}
{uiText('ui_nodeLabel')} {log.node_id}
</Badge>
<Badge variant='outline' className='text-[10px] rounded-full'>
{t('versionBadge', { version: log.version })}
@@ -12,7 +12,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -64,6 +70,12 @@ export function GroupDialog({
const [backupNodeID, setBackupNodeID] = useState('none');
const [defaultProxied, setDefaultProxied] = useState(true);
const [enabled, setEnabled] = useState(true);
const [ttlMode, setTtlMode] = useState<'auto' | 'manual'>('auto');
const [manualTTL, setManualTTL] = useState('300');
const ttl = ttlMode === 'auto' ? 1 : Number(manualTTL);
const invalidTTL =
ttlMode === 'manual' &&
(!manualTTL.trim() || !Number.isInteger(ttl) || ttl < 60 || ttl > 86400);
useEffect(() => {
if (!open) return;
@@ -83,6 +95,9 @@ export function GroupDialog({
);
setDefaultProxied(group?.default_proxied ?? true);
setEnabled(group?.enabled ?? true);
const groupTTL = group ? (group.ttl ?? 300) : 1;
setTtlMode(groupTTL === 1 ? 'auto' : 'manual');
setManualTTL(String(groupTTL === 1 ? 300 : groupTTL));
}, [group, open]);
return (
@@ -209,6 +224,54 @@ export function GroupDialog({
</Field>
</>
)}
<Field>
<FieldLabel id='cf-ttl-mode-label'>{t('ttl')}</FieldLabel>
<ToggleGroup
type='single'
variant='outline'
value={ttlMode}
aria-labelledby='cf-ttl-mode-label'
aria-describedby='cf-ttl-hint'
className='grid w-full grid-cols-2'
onValueChange={(value) => {
if (value === 'auto' || value === 'manual') {
setTtlMode(value);
}
}}
>
<ToggleGroupItem value='auto' className='w-full'>
{t('ttlAuto')}
</ToggleGroupItem>
<ToggleGroupItem value='manual' className='w-full'>
{t('ttlManual')}
</ToggleGroupItem>
</ToggleGroup>
<FieldDescription id='cf-ttl-hint'>{t('ttlHint')}</FieldDescription>
</Field>
{ttlMode === 'manual' && (
<Field data-invalid={invalidTTL}>
<FieldLabel htmlFor='cf-ttl-seconds'>
{t('ttlSeconds')}
</FieldLabel>
<Input
id='cf-ttl-seconds'
type='number'
min={60}
max={86400}
step={1}
required
value={manualTTL}
aria-invalid={invalidTTL}
aria-describedby={
invalidTTL ? 'cf-ttl-hint cf-ttl-error' : 'cf-ttl-hint'
}
onChange={(event) => setManualTTL(event.target.value)}
/>
{invalidTTL && (
<FieldError id='cf-ttl-error'>{t('ttlError')}</FieldError>
)}
</Field>
)}
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-default-proxied'>
{t('defaultProxied')}
@@ -235,10 +298,12 @@ export function GroupDialog({
<Button
disabled={
pending ||
invalidTTL ||
!name.trim() ||
(targetMode === 'node' ? !primaryNodeID : !recordContent.trim())
}
onClick={() =>
onClick={() => {
if (invalidTTL) return;
onSubmit({
name: name.trim(),
target_mode: targetMode,
@@ -251,9 +316,10 @@ export function GroupDialog({
? Number(backupNodeID)
: null,
default_proxied: defaultProxied,
ttl,
enabled,
})
}
});
}}
>
{t('save')}
</Button>
@@ -130,6 +130,8 @@ function statusVariant(status: TaskExecutionStatus) {
}
export function SyncTasksPanel() {
const uiText = useTranslations('runtime');
const t = useTranslations('cloudflare.tasks');
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
@@ -469,7 +471,9 @@ export function SyncTasksPanel() {
) : null}
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>Payload</div>
<div className='text-xs text-muted-foreground'>
{uiText('ui_payload')}
</div>
<pre className='max-h-36 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.payload || '{}'}
</pre>
@@ -67,6 +67,8 @@ function getGroupIdFromPathname(pathname: string | null): number {
}
export function CloudflareGroupDetailPageClient() {
const uiText = useTranslations('runtime');
const t = useTranslations('cloudflare');
const tCommon = useTranslations('common');
const [mounted, setMounted] = useState(false);
@@ -369,6 +371,11 @@ export function CloudflareGroupDetailPageClient() {
: 'nodeTargetMode',
)}
</Badge>
<Badge variant='outline'>
{group.ttl === 1
? t('ttlAutoSummary')
: t('ttlSecondsSummary', { seconds: group.ttl ?? 300 })}
</Badge>
</CardContent>
</Card>
@@ -449,7 +456,7 @@ export function CloudflareGroupDetailPageClient() {
setSelectedMemberIDs(new Set());
}
}}
aria-label='Select all'
aria-label={uiText('ui_selectAll')}
/>
</TableHead>
<TableHead>{t('columns.domain')}</TableHead>
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart';
@@ -16,7 +17,7 @@ function buildNodeRankItems(
const leftValue = selector(left);
const rightValue = selector(right);
if (leftValue === rightValue) {
return left.name.localeCompare(right.name, 'zh-CN');
return left.name.localeCompare(right.name, getFormattingLocale());
}
return rightValue - leftValue;
})
@@ -1,5 +1,6 @@
'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';
@@ -506,7 +507,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('zh-CN') })}</div>`,
`<div>${t('sourceRequests', { count: data.value.toLocaleString(getFormattingLocale()) })}</div>`,
].join('');
}
@@ -522,8 +523,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('zh-CN'),
errors: data.errorCount.toLocaleString('zh-CN'),
requests: data.requestCount.toLocaleString(getFormattingLocale()),
errors: data.errorCount.toLocaleString(getFormattingLocale()),
})}</div>`,
`<div>${t('eventsStatus', {
events: data.activeEventCount,
@@ -1,3 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import type { LucideIcon } from 'lucide-react';
import {
@@ -13,6 +16,8 @@ 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'>
@@ -22,8 +27,12 @@ export function PlaceholderPage({ title, icon: Icon }: PlaceholderPageProps) {
<Card>
<CardHeader>
<CardTitle className='text-base'>开发中</CardTitle>
<CardDescription>该功能正在迁移开发中,敬请期待。</CardDescription>
<CardTitle className='text-base'>
{rt('under_development_78c33fbf')}
</CardTitle>
<CardDescription>
{rt('this_feature_is_being_migrated_and_developed_61ebaf78')}
</CardDescription>
</CardHeader>
</Card>
</div>
@@ -64,6 +64,7 @@ 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();
@@ -320,7 +321,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel='Edge'
typeLabel={rt('node_type_edge')}
typeTone='info'
statusBadges={[
{
@@ -0,0 +1,148 @@
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,5 +1,6 @@
'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';
@@ -652,7 +653,9 @@ export function NodeObservability({
hint={
topSourceCountry
? t('obs.requestTimes', {
count: topSourceCountry.value.toLocaleString('zh-CN'),
count: topSourceCountry.value.toLocaleString(
getFormattingLocale(),
),
})
: t('obs.noSourceDist')
}
@@ -64,13 +64,14 @@ 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: 'Edge',
relay: 'Relay',
tunnel: 'Tunnel',
edge: rt('node_type_edge'),
relay: rt('node_type_relay'),
tunnel: rt('node_type_tunnel'),
};
return (
@@ -1,3 +1,5 @@
import { getFormattingLocale } from '@/i18n/format';
import { runtimeText } from '@/i18n/runtime';
import type {
ApplyResult,
NodeItem,
@@ -111,9 +113,9 @@ export function getRelayStatusLabel(
}
export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
if (nodeType === 'tunnel_relay') return 'Relay';
if (nodeType === 'tunnel_client') return 'Tunnel';
return 'Edge';
if (nodeType === 'tunnel_relay') return runtimeText('node_type_relay');
if (nodeType === 'tunnel_client') return runtimeText('node_type_tunnel');
return runtimeText('node_type_edge');
}
export function getErrorMessage(error: unknown, fallback: string) {
@@ -255,7 +257,7 @@ export function formatMetricCount(value?: number | null) {
if (value === undefined || value === null || !Number.isFinite(value)) {
return '—';
}
return value.toLocaleString('zh-CN');
return value.toLocaleString(getFormattingLocale());
}
export function formatBytesPerSecond(value?: number | null, windowSeconds = 1) {
@@ -37,15 +37,20 @@ 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'>
<div
className='border border-dashed rounded-lg overflow-hidden'
aria-busy={actionsDisabled}
>
<Table>
<TableHeader>
<TableRow className='border-dashed hover:bg-transparent'>
@@ -144,16 +149,22 @@ export function NodesTable({
variant='outline'
size='sm'
className='h-7 text-xs'
asChild
asChild={!actionsDisabled}
disabled={actionsDisabled}
>
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
{actionsDisabled ? (
t('detail')
) : (
<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' />
@@ -163,7 +174,7 @@ export function NodesTable({
variant='outline'
size='sm'
className='h-7 text-xs text-destructive hover:text-destructive'
disabled={deletingId === node.id}
disabled={actionsDisabled || deletingId === node.id}
onClick={() => onDelete(node)}
>
<Trash2 className='size-3 mr-1' />
@@ -79,6 +79,8 @@ 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();
@@ -116,7 +118,9 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
'relay_frps_web_ui_port',
{
value: String(portNum),
description: portCfg?.description || 'FRPS 内置 Web 管理界面端口',
description:
portCfg?.description ||
rt('port_for_the_built_in_frps_web_cfab540f'),
},
);
},
@@ -485,7 +489,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel='Relay'
typeLabel={rt('node_type_relay')}
typeTone='warning'
statusBadges={[
{
@@ -65,6 +65,7 @@ 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();
@@ -313,7 +314,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel='Tunnel'
typeLabel={rt('node_type_tunnel')}
typeTone='info'
statusBadges={[
{
@@ -1,7 +1,6 @@
'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';
@@ -25,20 +24,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,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
queryKey: [...nodesQueryKey, 'detail', nodeId],
queryFn: () => NodeService.getNode(Number(nodeId)),
enabled: validNodeId,
refetchInterval: (query) =>
query.state.status === 'error' ? false : 15000,
refetchIntervalInBackground: false,
});
const node = useMemo(() => {
if (!nodeId) return null;
return (
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
);
}, [nodeId, nodesQuery.data]);
const node = nodesQuery.data;
if (!nodeId) {
if (!validNodeId) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder icon={Server} description={t('missingId')} />
+136 -26
View File
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
import { useTranslations } from 'next-intl';
@@ -29,46 +29,112 @@ import {
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type { NodeItem, NodeMutationPayload } from '@/lib/services/openflare';
import type {
NodeItem,
NodeListQuery,
NodeMutationPayload,
NodeStatus,
NodeType,
} 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 nodeFilter = useMemo(
() => getNodeFilter(new URLSearchParams(searchParams.toString())),
[searchParams],
);
const resetNodeActions = useCallback(() => {
setEditingNode(null);
setEditorOpen(false);
setDeleteTarget(null);
}, []);
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,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
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,
});
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const filteredNodes = useMemo(
() => filterNodesByType(nodes, nodeFilter),
[nodeFilter, nodes],
const nodes = useMemo(
() => nodesQuery.data?.results ?? [],
[nodesQuery.data],
);
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) => {
@@ -106,6 +172,7 @@ export function NodesPageClient() {
};
const handleEdit = (node: NodeItem) => {
if (nodesQuery.isPlaceholderData) return;
setEditingNode(node);
setEditorOpen(true);
};
@@ -115,7 +182,7 @@ export function NodesPageClient() {
};
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Server className='size-5 text-primary' />
@@ -166,8 +233,34 @@ export function NodesPageClient() {
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<CardContent className='flex flex-col gap-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
@@ -182,21 +275,26 @@ export function NodesPageClient() {
className='justify-center'
/>
</div>
) : filteredNodes.length === 0 ? (
) : nodes.length === 0 ? (
<EmptyStateWithBorder
icon={Server}
description={
nodes.length === 0 ? t('emptyAll') : t('emptyFilter')
debouncedSearch || status || nodeFilter !== 'all'
? t('emptyFilter')
: t('emptyAll')
}
/>
) : (
<NodesTable
nodes={filteredNodes}
nodes={nodes}
deletingId={
deleteMutation.isPending ? (deleteTarget?.id ?? null) : null
}
onEdit={handleEdit}
onDelete={setDeleteTarget}
onDelete={(node) => {
if (!nodesQuery.isPlaceholderData) setDeleteTarget(node);
}}
actionsDisabled={nodesQuery.isPlaceholderData}
/>
)}
</CardContent>
@@ -205,13 +303,17 @@ export function NodesPageClient() {
<NodeEditorDialog
open={editorOpen}
node={editingNode}
submitting={saveMutation.isPending}
submitting={
saveMutation.isPending ||
(Boolean(editingNode) && nodesQuery.isPlaceholderData)
}
onClose={() => {
setEditorOpen(false);
setEditingNode(null);
}}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
if (!editingNode || !nodesQuery.isPlaceholderData)
await saveMutation.mutateAsync(payload);
}}
/>
@@ -227,14 +329,22 @@ export function NodesPageClient() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
<AlertDialogCancel
disabled={
deleteMutation.isPending || nodesQuery.isPlaceholderData
}
>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
disabled={
deleteMutation.isPending || nodesQuery.isPlaceholderData
}
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
deleteTarget &&
!nodesQuery.isPlaceholderData &&
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' ? 'Remote URL' : 'GitHub';
return type === 'remote_url' ? t('history.source.remote_url') : 'GitHub';
}
function deploymentSnapshot(
@@ -230,6 +230,8 @@ export function DeploymentHistory({
rootDir = '',
entryFile = 'index.html',
}: DeploymentHistoryProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('pages');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
@@ -401,7 +403,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'>Production</CardTitle>
<CardTitle className='text-base'>{uiText('ui_production')}</CardTitle>
<CardDescription>{t('history.productionHint')}</CardDescription>
<CardAction>
<Button
@@ -440,7 +442,9 @@ export function DeploymentHistory({
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>All deployments</CardTitle>
<CardTitle className='text-base'>
{uiText('ui_allDeployments')}
</CardTitle>
<CardDescription>{t('history.historyHint')}</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
@@ -94,6 +94,8 @@ export function PagesSourceDialog({
initialMode,
onActionDispatched,
}: PagesSourceDialogProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('pages.source');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
@@ -361,10 +363,10 @@ export function PagesSourceDialog({
{t('manual')}
</ToggleGroupItem>
<ToggleGroupItem value='remote_url' className='w-full'>
Remote URL
{uiText('ui_remoteUrl')}
</ToggleGroupItem>
<ToggleGroupItem value='github_release' className='w-full'>
GitHub Release
{uiText('ui_githubRelease')}
</ToggleGroupItem>
</ToggleGroup>
</Field>
@@ -379,7 +381,9 @@ export function PagesSourceDialog({
) : mode === 'remote_url' ? (
<>
<Field data-invalid={Boolean(urlError)}>
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
<FieldLabel htmlFor='pages-remote-url'>
{uiText('ui_remoteUrl')}
</FieldLabel>
<Input
id='pages-remote-url'
type='url'
@@ -1,3 +1,5 @@
'use client';
import { useTranslations } from 'next-intl';
import {
@@ -44,6 +46,8 @@ export function PagesSourceGitHubFields({
onChange,
onErrorsChange,
}: PagesSourceGitHubFieldsProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('pages.source');
return (
<>
@@ -107,7 +111,9 @@ export function PagesSourceGitHubFields({
{value.releaseSelector === 'tag' ? (
<Field data-invalid={Boolean(errors.releaseTag)}>
<FieldLabel htmlFor='pages-github-tag'>Release tag</FieldLabel>
<FieldLabel htmlFor='pages-github-tag'>
{uiText('ui_releaseTag')}
</FieldLabel>
<Input
id='pages-github-tag'
placeholder='v1.2.3'
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
import type {
ProxyRouteConfigSection,
ProxyRouteCustomHeader,
@@ -154,7 +155,8 @@ const limitRatePattern = /^\d+(?:[kKmM])?$/;
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ?? '请求失败,请稍后重试。');
: (fallback ??
runtimeText('request_failed_please_try_again_later_8977b79a'));
}
export function linesFromTextarea(value: string) {
@@ -65,6 +65,8 @@ export function ProxySection({
onRouteUpdate,
onSavingChange,
}: ProxySectionProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('proxyRoutes');
const reverseProxySchema = z
.object({
@@ -479,7 +481,7 @@ export function ProxySection({
name='origin_host'
render={({ field }) => (
<FormItem>
<FormLabel>Origin Host Header</FormLabel>
<FormLabel>{uiText('ui_originHostHeader')}</FormLabel>
<FormControl>
<Input placeholder='origin.example.internal' {...field} />
</FormControl>
@@ -116,6 +116,8 @@ 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[]>([]);
@@ -220,7 +222,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'>
Global Rule Group
{uiText('ui_globalRuleGroup')}
</p>
<p className='mt-1 text-sm font-semibold'>
{wafQuery.data.global_rule_group.name}
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { Gauge } from 'lucide-react';
import { useTranslations } from 'next-intl';
@@ -31,9 +32,11 @@ function toAvgRpsItems(items: DistributionItem[] | undefined, hours: number) {
function formatRps(value: number) {
if (!Number.isFinite(value)) return '—';
if (value >= 100) {
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 1,
});
}
return value.toLocaleString('zh-CN', {
return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 3,
minimumFractionDigits: 0,
});
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useCallback, useMemo, useRef } from 'react';
import type { EChartsOption } from 'echarts';
import type { EChartsType } from 'echarts/core';
@@ -48,7 +49,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('zh-CN', {
return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
});
+19 -8
View File
@@ -1,7 +1,10 @@
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 ?? '操作失败');
return error instanceof Error
? error.message
: (fallback ?? runtimeText('operation_failed_0c3b4cf7'));
}
export function listToText(items: string[] | undefined) {
@@ -17,7 +20,9 @@ export function parseTextareaList(text: string) {
export function parseAutomaticConfig(
text: string,
invalidMessage = '自动配置必须是 JSON 对象。',
invalidMessage = runtimeText(
'automatic_configuration_must_be_a_json_object_90ba17a4',
),
): Record<string, unknown> {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
@@ -28,12 +33,16 @@ export function parseAutomaticConfig(
export const automaticPresetRules = [
{
name: '单 IP 404 高频扫描',
get name() {
return runtimeText('high_frequency_404_scan_from_one_ip_f7d27ffb');
},
labelKey: 'presets.scan404' as const,
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
},
{
name: '单 IP 直连访问异常',
get name() {
return runtimeText('abnormal_direct_access_from_one_ip_732dbeee');
},
labelKey: 'presets.directAccess' as const,
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
},
@@ -75,7 +84,7 @@ export function formatIPGroupBanRemaining(
): string {
const reference = now ?? new Date();
if (ttlSeconds <= 0) {
return t ? t('ban.permanent') : '永久';
return t ? t('ban.permanent') : runtimeText('permanent_3e71ccc8');
}
const capturedDate = new Date(capturedAt);
if (Number.isNaN(capturedDate.getTime())) {
@@ -83,7 +92,7 @@ export function formatIPGroupBanRemaining(
}
const expireDate = new Date(capturedDate.getTime() + ttlSeconds * 1000);
if (expireDate.getTime() <= reference.getTime()) {
return t ? t('ban.expired') : '已过期';
return t ? t('ban.expired') : runtimeText('expired_2fe0e333');
}
const diffMins = Math.round(
(expireDate.getTime() - reference.getTime()) / (60 * 1000),
@@ -91,10 +100,12 @@ export function formatIPGroupBanRemaining(
if (diffMins < 60) {
return t
? t('ban.minutesLater', { count: diffMins })
: `${diffMins} 分钟后`;
: runtimeText('in_value0_minutes_fd2a5b53', { value0: diffMins });
}
const hours = Math.round(diffMins / 60);
return t ? t('ban.hoursLater', { count: hours }) : `${hours} 小时后`;
return t
? t('ban.hoursLater', { count: hours })
: runtimeText('in_value0_hours_5ca37144', { value0: hours });
}
export function getIPGroupViewEntries(
@@ -1,7 +1,9 @@
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 };
@@ -109,9 +111,9 @@ export function findGraphErrorTarget(
}
const messages = collectMessages(payload);
for (const message of messages) {
const nodeId = findMessageId(message, '节点', nodeIds);
const nodeId = findMessageId(message, 'node', nodeIds);
if (nodeId) return { kind: 'node', id: nodeId };
const edgeId = findMessageId(message, '边', edgeIds);
const edgeId = findMessageId(message, 'edge', edgeIds);
if (edgeId) return { kind: 'edge', id: edgeId };
}
return undefined;
@@ -132,16 +134,22 @@ function collectMessages(value: unknown): string[] {
function findMessageId(
message: string,
prefix: '节点' | '边',
prefix: 'node' | 'edge',
ids: string[],
): string | undefined {
const token = prefix === '边' ? '边(?:\\s+ID)?' : '节点';
const tokenKey = prefix === 'edge' ? 'edge_099db313' : 'node_2410d860';
return [...ids]
.sort((a, b) => b.length - a.length)
.find((id) =>
new RegExp(`${token}\\s+${escapeRegExp(id)}(?=$|[\\s,。,::的])`).test(
message,
),
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);
}),
);
}
@@ -1,8 +1,22 @@
import { describe, expect, it } from 'vitest';
import { countryOptions, regionOptions } from './geo-options';
import { createGeoOptions } from './geo-options';
const { countryOptions, regionOptions } = createGeoOptions('zh-CN');
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,46 +8,50 @@ export interface GeoOption {
const countryCodePattern = /^[A-Z]{2}$/;
const regionCodePattern = /^[A-Z0-9]{1,3}$/;
const countryDisplayNames = new Intl.DisplayNames(['zh-CN'], {
type: 'region',
});
export function createGeoOptions(locale: string) {
const countryDisplayNames = new Intl.DisplayNames([locale], {
type: 'region',
});
function localizedCountryName(code: string, fallback: string) {
const localized = countryDisplayNames.of(code);
return !localized || localized === code ? fallback : localized;
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 };
}
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,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
import { UA_BROWSER_LABELS, UA_OS_LABELS } from './ua-options';
@@ -39,25 +40,33 @@ 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: '规则图 schema_version 必须为 1' });
issues.push({
code: 'schema',
message: runtimeText('rule_graph_schema_version_must_be_1_ea9a1161'),
});
if (
graph.nodes.length > 128 ||
graph.edges.length > 256 ||
new TextEncoder().encode(JSON.stringify(graph)).length > 256 * 1024
)
issues.push({ code: 'size_limit', message: '规则图超过大小限制' });
issues.push({
code: 'size_limit',
message: runtimeText('rule_graph_exceeds_the_size_limit_337651b9'),
});
const nodeIds = new Set<string>();
for (const node of graph.nodes) {
if (!node.id.trim())
issues.push({
code: 'empty_id',
message: '节点 ID 不能为空',
message: runtimeText('node_id_must_not_be_empty_51a5de58'),
nodeId: node.id,
});
if (nodeIds.has(node.id))
issues.push({
code: 'duplicate_node_id',
message: `节点 ID ${node.id} 重复`,
message: runtimeText('duplicate_node_id_value0_621230af', {
value0: node.id,
}),
nodeId: node.id,
});
nodeIds.add(node.id);
@@ -74,20 +83,22 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!edge.id.trim())
issues.push({
code: 'empty_id',
message: '连线 ID 不能为空',
message: runtimeText('edge_id_must_not_be_empty_bc8f69cb'),
edgeId: edge.id,
});
if (edgeIds.has(edge.id))
issues.push({
code: 'duplicate_edge_id',
message: `连线 ID ${edge.id} 重复`,
message: runtimeText('duplicate_edge_id_value0_0a9d1c7c', {
value0: edge.id,
}),
edgeId: edge.id,
});
edgeIds.add(edge.id);
if (nodeMap.get(edge.target)?.type === 'start')
issues.push({
code: 'start_incoming',
message: '开始节点不能有入边',
message: runtimeText('the_start_node_must_not_have_incoming_50a07b17'),
edgeId: edge.id,
nodeId: edge.target,
});
@@ -95,12 +106,12 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (graph.nodes.filter((node) => node.type === 'start').length !== 1)
issues.push({
code: 'start_count',
message: '规则图必须恰好有一个开始节点',
message: runtimeText('the_rule_graph_must_contain_exactly_one_7dfbc027'),
});
if (graph.nodes.filter((node) => node.type === 'allow').length !== 1)
issues.push({
code: 'allow_count',
message: '规则图必须恰好有一个通过节点',
message: runtimeText('the_rule_graph_must_contain_exactly_one_0036eb82'),
});
for (const edge of graph.edges) {
@@ -112,7 +123,10 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
) {
issues.push({
code: 'invalid_edge',
message: `连线 ${edge.id} 的端点或出口无效`,
message: runtimeText(
'edge_value0_has_an_invalid_endpoint_or_9d84eb7e',
{ value0: edge.id },
),
edgeId: edge.id,
});
}
@@ -125,13 +139,19 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (outgoing.length === 0)
issues.push({
code: 'missing_handle',
message: `节点 ${node.id} 的 ${handle} 出口未连接`,
message: runtimeText(
'node_value0_output_value1_is_not_connected_c3038e81',
{ value0: node.id, value1: handle },
),
nodeId: node.id,
});
if (outgoing.length > 1)
issues.push({
code: 'duplicate_handle',
message: `节点 ${node.id} 的 ${handle} 出口只能连接一次`,
message: runtimeText(
'node_value0_output_value1_can_only_be_903e54ce',
{ value0: node.id, value1: handle },
),
nodeId: node.id,
});
}
@@ -151,7 +171,10 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!reachable.has(node.id))
issues.push({
code: 'unreachable',
message: `节点 ${node.id} 无法从开始节点到达`,
message: runtimeText(
'node_value0_is_unreachable_from_the_start_086531dc',
{ value0: node.id },
),
nodeId: node.id,
});
const terminals = graph.nodes
@@ -162,18 +185,27 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!canTerminate.has(node.id))
issues.push({
code: 'non_terminating',
message: `节点 ${node.id} 无法抵达终止节点`,
message: runtimeText(
'node_value0_cannot_reach_a_terminal_node_8de2ff6e',
{ value0: node.id },
),
nodeId: node.id,
});
if (hasCycle(graph))
issues.push({ code: 'cycle', message: '规则图不能包含循环' });
issues.push({
code: 'cycle',
message: runtimeText('the_rule_graph_must_not_contain_cycles_fd6fe797'),
});
return issues;
}
function validateNodeConfig(node: WAFRuleNode): string | undefined {
if (node.type === 'ip_match') {
if (node.config.ips.some((value) => !isIP(value)))
return `节点 ${node.id} 包含无效 IP`;
return runtimeText(
'node_value0_contains_an_invalid_ip_address_1873ab94',
{ value0: node.id },
);
if (
node.config.cidrs.some((value) => {
const parts = value.split('/');
@@ -186,9 +218,13 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
);
})
)
return `节点 ${node.id} 包含无效 CIDR`;
return runtimeText('node_value0_contains_an_invalid_cidr_00c880d9', {
value0: node.id,
});
if (node.config.ip_group_ids.some((id) => !Number.isInteger(id) || id <= 0))
return `节点 ${node.id} 包含无效 IP 组`;
return runtimeText('node_value0_contains_an_invalid_ip_group_589219a5', {
value0: node.id,
});
}
if (
node.type === 'geo_match' &&
@@ -197,7 +233,10 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
(code) => !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(code),
))
)
return `节点 ${node.id} 包含无效地域代码`;
return runtimeText(
'node_value0_contains_an_invalid_geography_code_1a9f469b',
{ value0: node.id },
);
if (
node.type === 'pow' &&
(!['fast', 'slow'].includes(node.config.algorithm) ||
@@ -205,37 +244,61 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
!isIntegerInRange(node.config.session_ttl, 60) ||
!isIntegerInRange(node.config.challenge_ttl, 30))
)
return `节点 ${node.id} 的 PoW 配置超出范围`;
return runtimeText('node_value0_has_pow_settings_outside_the_9fd2a4cb', {
value0: node.id,
});
if (
node.type === 'block' &&
(!isIntegerInRange(node.config.status_code, 400, 599) ||
new TextEncoder().encode(node.config.response_body).length > 16 * 1024)
)
return `节点 ${node.id} 的阻止响应配置无效`;
return runtimeText(
'node_value0_has_invalid_block_response_settings_ae4c35ca',
{ value0: node.id },
);
if (node.type === 'ua_check') {
if (!['and', 'or'].includes(node.config.match_mode))
return `节点 ${node.id} 的匹配模式必须为 and 或 or`;
return runtimeText('node_value0_match_mode_must_be_and_242c1ae7', {
value0: node.id,
});
if (node.config.browsers.some((label) => !UA_BROWSER_LABELS.has(label)))
return `节点 ${node.id} 包含无效浏览器标签`;
return runtimeText(
'node_value0_contains_an_invalid_browser_tag_4d0e6740',
{ value0: node.id },
);
if (node.config.operating_systems.some((label) => !UA_OS_LABELS.has(label)))
return `节点 ${node.id} 包含无效操作系统标签`;
return runtimeText(
'node_value0_contains_an_invalid_operating_system_45bea1fb',
{ value0: node.id },
);
if (node.config.custom_ua_patterns.length > 32)
return `节点 ${node.id} 的自定义 UA 正则不能超过 32 条`;
return runtimeText('node_value0_allows_at_most_32_custom_497938c6', {
value0: node.id,
});
for (const pattern of node.config.custom_ua_patterns) {
if (!pattern.trim()) return `节点 ${node.id} 的自定义 UA 正则不能为空`;
if (!pattern.trim())
return runtimeText('node_value0_custom_ua_pattern_must_not_a72718be', {
value0: node.id,
});
if (new TextEncoder().encode(pattern).length > 256)
return `节点 ${node.id} 的自定义 UA 正则过长`;
return runtimeText('node_value0_custom_ua_pattern_is_too_c2c83c0e', {
value0: node.id,
});
try {
void new RegExp(pattern);
} catch {
return `节点 ${node.id} 的自定义 UA 正则无效`;
return runtimeText('node_value0_has_an_invalid_custom_ua_c47ffd0d', {
value0: node.id,
});
}
}
if (
node.config.block_custom_ua &&
node.config.custom_ua_patterns.length === 0
)
return `节点 ${node.id} 开启屏蔽自定义 UA 时至少需要一条正则`;
return runtimeText('node_value0_requires_at_least_one_pattern_6114bb93', {
value0: node.id,
});
}
return undefined;
}
@@ -1,29 +1,38 @@
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: ' 办公室 ',
}),
displayNodeTitle(
{
type: 'ip_match',
label: ' 办公室 ',
},
getRuntimeTranslator('en'),
),
).toBe('办公室');
});
it('falls back to type default when label empty', () => {
expect(
displayNodeTitle({
type: 'block',
label: ' ',
}),
).toBe(NODE_TYPE_LABELS.block);
displayNodeTitle(
{
type: 'block',
label: ' ',
},
getRuntimeTranslator('en'),
),
).toBe('Block');
expect(
displayNodeTitle({ type: 'block' }, getRuntimeTranslator('zh-CN')),
).toBe('阻止');
});
});
@@ -7,22 +7,25 @@ export type AddableNodeType = Extract<
'ip_match' | 'geo_match' | 'ua_check' | 'security_check' | 'pow' | 'block'
>;
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 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 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 || NODE_TYPE_LABELS[node.type];
return custom || translate(NODE_TYPE_LABEL_KEYS[node.type]);
}
export function createRuleNode(
@@ -1,3 +1,4 @@
import { useTranslations } from 'next-intl';
import {
Ban,
Fingerprint,
@@ -10,7 +11,7 @@ import {
import { Button } from '@/components/ui/button';
import {
NODE_TYPE_LABELS,
NODE_TYPE_LABEL_KEYS,
WAF_NODE_DRAG_MIME,
type AddableNodeType,
} from './node-factory';
@@ -25,6 +26,7 @@ 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 }) => (
@@ -42,7 +44,7 @@ export function NodeLibrary() {
}}
>
<Icon data-icon='inline-start' />
{NODE_TYPE_LABELS[type]}
{rt(NODE_TYPE_LABEL_KEYS[type])}
</Button>
))}
</div>
@@ -1,6 +1,6 @@
import { CircleHelp, Settings2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useMemo, 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 { countryOptions, regionOptions, type GeoOption } from './geo-options';
import { NODE_TYPE_LABELS } from './node-factory';
import { createGeoOptions, type GeoOption } from './geo-options';
import { NODE_TYPE_LABEL_KEYS } from './node-factory';
import { UA_BROWSER_OPTIONS, UA_OS_OPTIONS } from './ua-options';
export function NodeProperties({
@@ -78,6 +78,14 @@ 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>
@@ -518,13 +526,14 @@ 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={NODE_TYPE_LABELS[node.type]}
placeholder={rt(NODE_TYPE_LABEL_KEYS[node.type])}
onChange={(event) => onChange({ ...node, label: event.target.value })}
/>
</Field>
@@ -1,4 +1,5 @@
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { useTranslations } from 'next-intl';
import {
Ban,
Fingerprint,
@@ -42,10 +43,11 @@ 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);
const title = displayNodeTitle(rule, rt);
return (
<div
className={cn(
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
export const UA_BROWSER_OPTIONS = [
{ value: 'Chrome', label: 'Chrome' },
{ value: 'Safari', label: 'Safari' },
@@ -5,12 +6,32 @@ export const UA_BROWSER_OPTIONS = [
{ value: 'Edge', label: 'Edge' },
{ value: 'Opera', label: 'Opera' },
{ value: 'Chromium', label: 'Chromium' },
{ value: 'WeChat', label: '微信' },
{
value: 'WeChat',
get label() {
return runtimeText('wechat_81b430b6');
},
},
{ value: 'Postman', label: 'Postman' },
{ value: 'CLI', label: 'CLI' },
{ value: 'Bot', label: 'Bot' },
{ value: 'Unknown', label: 'Unknown' },
{ value: 'Other', label: 'Other' },
{
value: 'Bot',
get label() {
return runtimeText('ui_botLabel');
},
},
{
value: 'Unknown',
get label() {
return runtimeText('ui_unknownLabel');
},
},
{
value: 'Other',
get label() {
return runtimeText('ui_otherLabel');
},
},
] as const;
export const UA_OS_OPTIONS = [
@@ -20,9 +41,24 @@ export const UA_OS_OPTIONS = [
{ value: 'macOS', label: 'macOS' },
{ value: 'Chrome OS', label: 'Chrome OS' },
{ value: 'Linux', label: 'Linux' },
{ value: 'Bot', label: 'Bot' },
{ value: 'Unknown', label: 'Unknown' },
{ value: 'Other', label: 'Other' },
{
value: 'Bot',
get label() {
return runtimeText('ui_botLabel');
},
},
{
value: 'Unknown',
get label() {
return runtimeText('ui_unknownLabel');
},
},
{
value: 'Other',
get label() {
return runtimeText('ui_otherLabel');
},
},
] 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 items-center justify-between gap-4'>
<div className='flex min-w-0 items-center gap-2'>
<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'>
<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 shrink-0 items-center gap-2'>
<div className='flex flex-wrap items-center gap-2 xl:shrink-0'>
<div className='flex items-center gap-2'>
<Switch
id='rule-enabled'
@@ -1,5 +1,6 @@
'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';
@@ -31,13 +32,13 @@ function formatAxisLabel(iso: string, range: ZoneStatsRange) {
return '';
}
if (range === '24h') {
return date.toLocaleTimeString('en-US', {
return date.toLocaleTimeString(getFormattingLocale(), {
hour: 'numeric',
minute: undefined,
hour12: true,
});
}
return date.toLocaleDateString('en-GB', {
return date.toLocaleDateString(getFormattingLocale(), {
day: 'numeric',
month: 'short',
});
@@ -54,7 +55,10 @@ function formatWindowLabel(startedAt?: string, endedAt?: string) {
}
const fmt = (value: Date) =>
value
.toLocaleDateString('en-GB', { day: 'numeric', month: 'long' })
.toLocaleDateString(getFormattingLocale(), {
day: 'numeric',
month: 'long',
})
.toUpperCase();
return `${fmt(start)} — ${fmt(end)}`;
}
@@ -65,6 +65,8 @@ 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();
@@ -112,7 +114,7 @@ export function QuickCreateZoneDomainDialog({
const schema = z.object({
zone_id: z.string().min(1, t('selectZone')),
domain_input: z.string().trim().min(1, t('enterDomain')),
domain_input: z.string().min(1, t('enterDomain')),
cert_id: z.string(),
});
const form = useForm<Values>({
@@ -214,7 +216,7 @@ export function QuickCreateZoneDomainDialog({
>
{!fixedZoneId ? (
<div className='space-y-1.5'>
<Label>Zone</Label>
<Label>{uiText('ui_zone')}</Label>
<Select
value={form.watch('zone_id') || undefined}
onValueChange={(value) => form.setValue('zone_id', value)}
@@ -3,6 +3,7 @@
*
* - `@` → apex (zone root itself), e.g. `example.com`
* - single label `name` → `name.example.com`
* - `*` → `*.example.com`; other wildcards require a full FQDN
* - full FQDN must equal the root or end with `.root`
*/
export type ZoneDomainInputError =
@@ -13,6 +14,15 @@ export type ZoneDomainInputError =
| 'invalidSubdomain'
| 'mustBelongToZone';
const dnsLabelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
function isDnsName(value: string): boolean {
return (
value.length <= 253 &&
value.split('.').every((label) => dnsLabelPattern.test(label))
);
}
export function resolveZoneDomainInput(
rawInput: string,
zoneRoot: string,
@@ -26,15 +36,7 @@ export function resolveZoneDomainInput(
if (!input) {
return { domain: '', error: 'enterDomain' };
}
if (input.includes('*')) {
return { domain: '', error: 'noWildcard' };
}
if (
input.includes('://') ||
input.includes('/') ||
input.includes('?') ||
input.includes('#')
) {
if (/\s/.test(input) || !isDnsName(root)) {
return { domain: '', error: 'invalidFormat' };
}
@@ -42,17 +44,28 @@ export function resolveZoneDomainInput(
return { domain: root };
}
// Short label: no dots → prefix under zone root
if (!input.includes('.')) {
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(input)) {
return { domain: '', error: 'invalidSubdomain' };
}
return { domain: `${input}.${root}` };
// 星号只能作为最左侧完整标签;*.s3 不推断为 Zone 内的相对名称。
const wildcard = input === '*' || input.startsWith('*.');
if (input.includes('*') && (!wildcard || input.slice(1).includes('*'))) {
return { domain: '', error: 'noWildcard' };
}
// Full FQDN
if (input === root || input.endsWith(`.${root}`)) {
return { domain: input };
let domain = input;
if (input === '*') {
domain = `*.${root}`;
} else if (!input.includes('.')) {
if (!dnsLabelPattern.test(input)) {
return { domain: '', error: 'invalidSubdomain' };
}
domain = `${input}.${root}`;
}
const hostname = wildcard ? domain.slice(2) : domain;
if (domain.length > 253 || !isDnsName(hostname)) {
return { domain: '', error: 'invalidFormat' };
}
if (hostname === root || hostname.endsWith(`.${root}`)) {
return { domain };
}
return { domain: '', error: 'mustBelongToZone' };
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
import type {
TlsCertificateFileImportPayload,
TlsCertificateItem,
@@ -17,7 +18,8 @@ export type TranslateFn = (
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ?? '请求失败,请稍后重试。');
: (fallback ??
runtimeText('request_failed_please_try_again_later_8977b79a'));
}
export function getMatchTypeMeta(domain: string): {
@@ -25,8 +27,8 @@ export function getMatchTypeMeta(domain: string): {
tone: StatusTone;
} {
return domain.startsWith('*.')
? { label: '通配符', tone: 'warning' }
: { label: '精确匹配', tone: 'info' };
? { label: runtimeText('wildcard_c6b0739e'), tone: 'warning' }
: { label: runtimeText('exact_match_391fe725'), tone: 'info' };
}
export function getCertificateStatus(
@@ -57,7 +59,10 @@ export function getCertificateStatus(
export function buildCertificateLabel(certificate: TlsCertificateItem) {
return certificate.not_after
? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})`
? runtimeText('value0_expires_value1_7d8600f8', {
value0: certificate.name,
value1: formatDateTime(certificate.not_after),
})
: certificate.name;
}
+12
View File
@@ -4,6 +4,18 @@
@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);
+24 -1
View File
@@ -12,10 +12,12 @@ import { AppIntlProvider } from '@/components/providers/intl-provider';
import { SiteTitleUpdater } from '@/components/providers/title-updater';
import { RobotsMeta } from '@/components/layout/robots-meta';
import type { AppLocale } from '@/i18n/config';
import { createLocaleBootstrapScript } from '@/i18n/bootstrap';
import { Button } from '@/components/ui/button';
import './globals.css';
const inter = Inter({
subsets: ['latin'],
subsets: ['latin', 'cyrillic'],
variable: '--font-inter',
display: 'swap',
});
@@ -35,6 +37,7 @@ export default async function RootLayout({
}>) {
const locale = (await getLocale()) as AppLocale;
const messages = await getMessages();
const loading = await getTranslations('languageLoading');
return (
<html
@@ -42,10 +45,30 @@ export default async function RootLayout({
className={`hide-scrollbar font-sans ${inter.variable}`}
suppressHydrationWarning
>
<head>
<script
dangerouslySetInnerHTML={{
__html: createLocaleBootstrapScript(locale),
}}
/>
</head>
<body
className='hide-scrollbar font-sans antialiased'
suppressHydrationWarning
>
<div
id='locale-load-fallback'
role='alert'
hidden
className='visible flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center'
>
<p data-locale-error-message suppressHydrationWarning>
{loading('failed')}
</p>
<Button type='button' suppressHydrationWarning>
{loading('reload')}
</Button>
</div>
<ThemeProvider
attribute='class'
defaultTheme='system'
+8 -5
View File
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { motion } from 'motion/react';
@@ -11,6 +12,8 @@ import { Button } from '@/components/ui/button';
* 当用户访问不存在的页面时显示
*/
export default function NotFound() {
const rt = useTranslations('runtime');
const router = useRouter();
return (
@@ -31,7 +34,7 @@ export default function NotFound() {
</h1>
<div className='absolute inset-0 flex items-center justify-center'>
<span className='text-2xl font-medium tracking-[0.2em] text-foreground/80 uppercase'>
Page Not Found
{rt('ui_pageNotFound')}
</span>
</div>
</motion.div>
@@ -43,9 +46,9 @@ export default function NotFound() {
className='space-y-8'
>
<p className='text-muted-foreground max-w-[400px] mx-auto text-sm leading-relaxed'>
抱歉,您访问的页面似已迷失在数字星云中。
{rt('sorry_the_page_you_requested_seems_to_ba66cd83')}
<br />
请检查路径或返回安全地带。
{rt('check_the_path_or_return_to_safety_b1f31888')}
</p>
<div className='flex justify-center gap-4'>
@@ -56,7 +59,7 @@ export default function NotFound() {
className='rounded-full w-24 text-xs border-foreground/10 hover:bg-foreground/10 transition-all duration-300'
>
<ArrowLeft className='size-3 opacity-70' />
上一页
{rt('previous_c9b9ae7a')}
</Button>
<Link href='/'>
@@ -66,7 +69,7 @@ export default function NotFound() {
className='rounded-full w-24 text-xs hover:bg-primary/80 transition-all duration-300'
>
<Home className='size-3' />
首页
{rt('home_203c08e0')}
</Button>
</Link>
</div>
+459 -404
View File
@@ -1,3 +1,4 @@
import type { RuntimeTranslator } from '@/i18n/runtime';
import { type PolicySection } from './types';
import { CodeBlock } from '@/components/ui/code-block';
import {
@@ -16,57 +17,65 @@ export const DOCS_LAST_UPDATED = '2026-06-07';
* API 文档
* ------------------------------------------------------------------
*/
export const apiSections: PolicySection[] = [
{
value: 'api-specs',
title: '1. 接口规范与鉴权说明',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
平台统一接口调用格式规范以及开发者访问令牌鉴权方式说明
</p>
</div>
export function createApiSections(rt: RuntimeTranslator): PolicySection[] {
return [
{
value: 'api-specs',
title: rt('1_api_conventions_and_authentication_e6d4df61'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
{rt(
'standard_api_request_conventions_and_developer_access_39eb956e',
)}
</p>
</div>
<h3
id='1-1-response-format'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
1.1 统一响应格式
</h3>
<p>系统所有 API 接口均遵循标准 JSON 响应结构:</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>字段</DocsTableHead>
<DocsTableHead className='w-[100px]'>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
error_msg
</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
错误信息。请求成功时为空字符串
`&quot;&quot;`,失败时包含错误详情描述。
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>data</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>
接口返回的具体数据内容。请求失败或无数据返回时为 `null`。
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='1-1-response-format'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
{rt('1_1_standard_response_format_5cddae2f')}
</h3>
<p>{rt('all_api_endpoints_use_a_standard_json_87c92fb0')}</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{rt('field_49ecd0e3')}
</DocsTableHead>
<DocsTableHead className='w-[100px]'>
{rt('type_ba40014f')}
</DocsTableHead>
<DocsTableHead>{rt('description_4262c45d')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
error_msg
</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt('error_message_empty_on_success_contains_error_d89d457f')}
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
data
</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>
{rt('the_response_payload_null_when_the_request_97b8b29c')}
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className='mt-2'>成功响应示例:</p>
<CodeBlock
code={`{
<p className='mt-2'>{rt('successful_response_example_8917e853')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"id": 1,
@@ -74,263 +83,290 @@ export const apiSections: PolicySection[] = [
"nickname": "Ryan"
}
}`}
language='json'
/>
language='json'
/>
<p className='mt-2'>失败响应示例:</p>
<CodeBlock
code={`{
"error_msg": "用户密码错误",
"data": null
}`}
language='json'
/>
<p className='mt-2'>{rt('failed_response_example_eae899e1')}</p>
<CodeBlock
code={rt.raw('error_msg_incorrect_password_data_null_b2eccc86')}
language='json'
/>
<h3
id='1-2-authentication'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
1.2 鉴权方式
</h3>
<p>
除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>Session 凭证:</strong>浏览器环境下支持利用常规 Session
Cookie 会话保持登录。
</li>
<li>
<strong>AccessToken 令牌:</strong>
供后台调用或第三方应用集成使用。客户端生成 API
访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。
</li>
</ul>
<div className='bg-muted border rounded-xl p-4 mt-2 space-y-2'>
<p className='font-bold text-xs'>
支持携带令牌的请求头格式(二选一):
<h3
id='1-2-authentication'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
{rt('1_2_authentication_methods_3c5db38a')}
</h3>
<p>
{rt(
'protected_endpoints_require_credentials_except_public_endpoints_1fa0a24c',
)}
</p>
<ul className='list-disc pl-5 text-xs text-muted-foreground space-y-1'>
<ul className='list-disc pl-5 space-y-2'>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
Authorization: Bearer at_xxx
</code>
<strong>{rt('session_credentials_de72024d')}</strong>{' '}
{rt('browser_clients_can_stay_signed_in_using_f316279b')}
</li>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
X-Access-Token: at_xxx
</code>
<strong>{rt('access_tokens_8de23550')}</strong>{' '}
{rt('for_backend_calls_and_third_party_integrations_dfd8bf0f')}
</li>
</ul>
<div className='bg-muted border rounded-xl p-4 mt-2 space-y-2'>
<p className='font-bold text-xs'>
{rt('use_either_of_the_following_request_headers_1e03b04b')}
</p>
<ul className='list-disc pl-5 text-xs text-muted-foreground space-y-1'>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
Authorization: Bearer at_xxx
</code>
</li>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
X-Access-Token: at_xxx
</code>
</li>
</ul>
</div>
</div>
</div>
),
children: [
{ value: '1-1-response-format', title: '1.1 统一响应格式' },
{ value: '1-2-authentication', title: '1.2 鉴权方式' },
],
},
{
value: 'auth-apis',
title: '2. 用户与认证接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='2-1-register'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.1 用户注册
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/register
</code>
</p>
<p>
<strong>说明:</strong>注册本地账户(在后台注册开关开启状态下)。
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名,必须唯一且无空格。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码,长度必须大于等于 8 位。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
nickname
</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>昵称。未传时默认与用户名一致。</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
),
children: [
{
value: '1-1-response-format',
title: rt('1_1_standard_response_format_5cddae2f'),
},
{
value: '1-2-authentication',
title: rt('1_2_authentication_methods_3c5db38a'),
},
],
},
{
value: 'auth-apis',
title: rt('2_user_and_authentication_endpoints_4a89d429'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='2-1-register'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('2_1_user_registration_4f332f85')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/register
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('register_a_local_account_when_registration_is_938891dc')}
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{rt('parameter_9634fb08')}
</DocsTableHead>
<DocsTableHead className='w-[80px]'>
{rt('required_11da9dc4')}
</DocsTableHead>
<DocsTableHead>{rt('type_ba40014f')}</DocsTableHead>
<DocsTableHead>{rt('description_4262c45d')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt('username_must_be_unique_and_contain_no_e81c1e5c')}
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt('password_at_least_8_characters_37cdc807')}
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
nickname
</DocsTableCell>
<DocsTableCell>{rt('no_0c70665b')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt(
'nickname_defaults_to_the_username_when_omitted_676aae7e',
)}
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-2-login'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.2 密码登录
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/login
</code>
</p>
<p>
<strong>说明:</strong>通过常规用户名密码进行登录校验,成功后建立
Session Cookie 会话。
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-2-login'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('2_2_password_login_271483ba')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/login
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt(
'authenticate_with_a_username_and_password_establishing_a38ff252',
)}
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{rt('parameter_9634fb08')}
</DocsTableHead>
<DocsTableHead className='w-[80px]'>
{rt('required_11da9dc4')}
</DocsTableHead>
<DocsTableHead>{rt('type_ba40014f')}</DocsTableHead>
<DocsTableHead>{rt('description_4262c45d')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{rt('username_1a3f0617')}</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{rt('password_a621ab60')}</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-3-logout'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.3 退出登录
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/logout
</code>
</p>
<p>
<strong>说明:</strong>销毁当前会话 Cookie 并退出登录状态。
</p>
<h3
id='2-3-logout'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('2_3_sign_out_3252611d')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/logout
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('delete_the_current_session_cookie_and_sign_c213ffd9')}
</p>
<h3
id='2-4-profile'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.4 获取个人资料
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/self
</code>
</p>
<p>
<strong>说明:</strong>获取当前登录账户的基本数据模型(包含
ID、角色、昵称等)。
</p>
</div>
),
children: [
{ value: '2-1-register', title: '2.1 用户注册' },
{ value: '2-2-login', title: '2.2 密码登录' },
{ value: '2-3-logout', title: '2.3 退出登录' },
{ value: '2-4-profile', title: '2.4 获取个人资料' },
],
},
{
value: 'token-apis',
title: '3. 个人访问令牌 (AccessToken) 接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4'>
<p className='text-muted-foreground m-0'>
AccessToken 管理相关接口均要求通过 Session
登录后调用,支持普通用户权限。
<h3
id='2-4-profile'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('2_4_get_profile_7b96110a')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/self
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('get_the_signed_in_account_s_basic_b0eb8cfa')}
</p>
</div>
),
children: [
{ value: '2-1-register', title: rt('2_1_user_registration_4f332f85') },
{ value: '2-2-login', title: rt('2_2_password_login_271483ba') },
{ value: '2-3-logout', title: rt('2_3_sign_out_3252611d') },
{ value: '2-4-profile', title: rt('2_4_get_profile_7b96110a') },
],
},
{
value: 'token-apis',
title: rt('3_personal_access_token_endpoints_989f5d7c'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4'>
<p className='text-muted-foreground m-0'>
{rt('access_token_management_requires_a_session_login_42d91543')}
</p>
</div>
<h3
id='3-1-list-token'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.1 获取令牌列表
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>说明:</strong>
查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。
</p>
<h3
id='3-1-list-token'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('3_1_list_tokens_9840c227')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('list_the_current_user_s_tokens_with_c34d2210')}
</p>
<h3
id='3-2-create-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.2 新建访问令牌
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>参数:</strong>JSON Body{' '}
<code className='bg-muted px-1.5 rounded text-xs font-mono'>{`{"name": "token名称", "is_admin": false}`}</code>
</p>
<p>
<strong>说明:</strong>生成一个全新访问令牌。返回体中包含一次性明文
Token,切勿遗失。
</p>
<p className='mt-1 text-xs text-muted-foreground'>
<code className='bg-muted px-1 rounded'>is_admin</code>(可选,默认{' '}
<code className='bg-muted px-1 rounded'>false</code>
):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问{' '}
<code className='bg-muted px-1 rounded'>/admin/**</code> 端点。
</p>
<p className='mt-2'>成功返回样例:</p>
<CodeBlock
code={`{
<h3
id='3-2-create-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('3_2_create_an_access_token_e53bd784')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>{rt('parameters_ba89685c')}</strong> {rt('ui_jsonBody')}{' '}
<code className='bg-muted px-1.5 rounded text-xs font-mono'>
{rt.raw('name_token_name_is_admin_false_4f3e4868')}
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('generate_an_access_token_the_response_includes_70552390')}
</p>
<p className='mt-1 text-xs text-muted-foreground'>
<code className='bg-muted px-1 rounded'>is_admin</code>
{rt('optional_default_210b9d8f')}{' '}
<code className='bg-muted px-1 rounded'>false</code>
{rt('grant_administrator_privileges_to_the_token_only_2292cad8')}{' '}
<code className='bg-muted px-1 rounded'>/admin/**</code>{' '}
{rt('endpoints_815c88e5')}
</p>
<p className='mt-2'>{rt('successful_response_example_7783a39c')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"token": "at_628d022b7a95e26bcd8b29c9...",
@@ -344,76 +380,84 @@ export const apiSections: PolicySection[] = [
}
}
}`}
language='json'
/>
language='json'
/>
<h3
id='3-3-delete-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.3 撤销/删除令牌
</h3>
<p>
<strong>接口:</strong>DELETE{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id
</code>
</p>
<p>
<strong>说明:</strong>通过 ID
物理删除对应访问令牌,该令牌将立即失效。
</p>
<h3
id='3-3-delete-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('3_3_revoke_delete_a_token_ae510f58')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> DELETE{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('permanently_delete_a_token_by_id_immediately_145287f6')}
</p>
<h3
id='3-4-rotate-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.4 轮换令牌密钥
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id/rotate
</code>
</p>
<p>
<strong>说明:</strong>
轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文
Token,令牌名称与 ID 保持一致。
</p>
</div>
),
children: [
{ value: '3-1-list-token', title: '3.1 获取令牌列表' },
{ value: '3-2-create-token', title: '3.2 新建访问令牌' },
{ value: '3-3-delete-token', title: '3.3 撤销/删除令牌' },
{ value: '3-4-rotate-token', title: '3.4 轮换令牌密钥' },
],
},
{
value: 'config-apis',
title: '4. 公共配置与管理接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='4-1-public-config'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
4.1 公共系统配置
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/config/public
</code>
</p>
<p>
<strong>说明:</strong>
无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。
</p>
<p className='mt-2'>返回数据结构样例:</p>
<CodeBlock
code={`{
<h3
id='3-4-rotate-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('3_4_rotate_a_token_secret_79209983')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id/rotate
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('replace_a_token_s_secret_and_return_d2caca89')}
</p>
</div>
),
children: [
{ value: '3-1-list-token', title: rt('3_1_list_tokens_9840c227') },
{
value: '3-2-create-token',
title: rt('3_2_create_an_access_token_e53bd784'),
},
{
value: '3-3-delete-token',
title: rt('3_3_revoke_delete_a_token_ae510f58'),
},
{
value: '3-4-rotate-token',
title: rt('3_4_rotate_a_token_secret_79209983'),
},
],
},
{
value: 'config-apis',
title: rt('4_public_configuration_and_administration_17ebdbd5'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='4-1-public-config'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('4_1_public_system_configuration_2be912e4')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/config/public
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('get_publicly_visible_configuration_keys_and_values_bc7bff66')}
</p>
<p className='mt-2'>{rt('response_example_d3087445')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"site_name": "OpenFlare",
@@ -424,50 +468,61 @@ export const apiSections: PolicySection[] = [
"oidc_login_enabled": "true"
}
}`}
language='json'
/>
language='json'
/>
<h3
id='4-2-admin-configs'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
4.2 系统配置项 CRUD (管理员)
</h3>
<p>
<strong>说明:</strong>用于在后台对 `system_configs`
配置进行动态变更,要求管理员权限会话调用。
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>获取配置列表:</strong>GET{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs?type=system
</code>
</li>
<li>
<strong>新建配置项:</strong>POST{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs
</code>
</li>
<li>
<strong>修改指定配置值:</strong>PUT{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
<li>
<strong>删除配置项:</strong>DELETE{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
</ul>
</div>
),
children: [
{ value: '4-1-public-config', title: '4.1 公共系统配置' },
{ value: '4-2-admin-configs', title: '4.2 系统配置项 CRUD (管理员)' },
],
},
];
<h3
id='4-2-admin-configs'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('4_2_system_configuration_crud_administrator_2ce66c8b')}
</h3>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt(
'manage_system_configs_dynamically_through_an_administrator_acdcdb99',
)}
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>{rt('list_configuration_3d58680b')}</strong> GET{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs?type=system
</code>
</li>
<li>
<strong>{rt('create_a_configuration_entry_1d995264')}</strong>{' '}
POST{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs
</code>
</li>
<li>
<strong>{rt('update_a_configuration_value_2a0b0b76')}</strong> PUT{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
<li>
<strong>{rt('delete_a_configuration_entry_1b77a6ea')}</strong>{' '}
DELETE{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
</ul>
</div>
),
children: [
{
value: '4-1-public-config',
title: rt('4_1_public_system_configuration_2be912e4'),
},
{
value: '4-2-admin-configs',
title: rt('4_2_system_configuration_crud_administrator_2ce66c8b'),
},
],
},
];
}
+264 -239
View File
@@ -1,3 +1,4 @@
import type { RuntimeTranslator } from '@/i18n/runtime';
import { type PolicySection } from './types';
import { CodeBlock } from '@/components/ui/code-block';
@@ -6,249 +7,273 @@ import { CodeBlock } from '@/components/ui/code-block';
* 使用指南 (How To Use)
* ------------------------------------------------------------------
*/
export const howToUseSections: PolicySection[] = [
{
value: 'quick-start',
title: '1. 快速开始',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明
</p>
export function createHowToUseSections(rt: RuntimeTranslator): PolicySection[] {
return [
{
value: 'quick-start',
title: rt('1_quick_start_ef8fbb58'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
{rt('a_guide_to_the_general_purpose_full_9cd8c987')}
</p>
</div>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>{rt('architecture_e992b65c')}</strong>{' '}
{rt('go_gin_gorm_redis_asynq_backend_react_d5fe02d3')}
</li>
<li>
<strong>{rt('authentication_d36b345b')}</strong>{' '}
{rt(
'local_username_password_registration_and_login_plus_583ab638',
)}
</li>
<li>
<strong>{rt('access_tokens_b7b856d3')}</strong>{' '}
{rt('personal_developer_access_tokens_api_keys_for_4b407149')}
</li>
<li>
<strong>{rt('observability_ea0cc1bf')}</strong>{' '}
{rt(
'zap_structured_logging_and_opentelemetry_distributed_tracing_9fd6da9f',
)}
</li>
</ul>
</div>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>架构底座:</strong>Go (Gin + GORM + Redis + Asynq) 后端 +
React (Next.js 16 + Tailwind CSS 4 + Shadcn UI) 前端
</li>
<li>
<strong>认证体系:</strong>支持本地常规账号密码注册登录 +
第三方自定义 OIDC (OAuth2) 认证源绑定
</li>
<li>
<strong>访问令牌:</strong>提供开发者个人 AccessToken (API
Key),用于通过 Http Header 鉴权直接调用系统 API
</li>
<li>
<strong>可观测性:</strong>集成 Zap 结构化日志与 OpenTelemetry
全链路 Tracing 追踪
</li>
</ul>
</div>
),
},
{
value: 'auth-security',
title: '2. 身份认证与安全设置',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>平台采用混合式身份认证,满足不同的部署和业务场景需求:</p>
<h3
id='2-1-login'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.1 常规账号密码认证
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>自主注册与密码登录:</strong>
支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
bcrypt
</code>{' '}
高强度加盐哈希存储。
</li>
<li>
<strong>系统开关控制:</strong>
管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。
</li>
</ul>
<h3
id='2-2-oidc'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.2 第三方 OIDC 认证源
</h3>
<p>用户可以在个人资料页面关联绑定外部授权账户:</p>
<ol className='list-decimal pl-4 md:pl-5 space-y-1'>
<li>
进入 <strong>设置 / 个人资料</strong> 页面。
</li>
<li>
在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC
认证源直接触发 OAuth2 绑定流。
</li>
<li>
绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。
</li>
</ol>
</div>
),
children: [
{ value: '2-1-login', title: '2.1 常规账号密码认证' },
{ value: '2-2-oidc', title: '2.2 第三方 OIDC 认证源' },
],
},
{
value: 'access-token',
title: '3. 个人访问令牌 (AccessToken) 接口对接',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
为便于开发者或第三方工具直接调用系统
API,平台提供个人访问令牌管理功能。
</p>
<h3
id='3-1-generation'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.1 令牌生成与存储规范
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>一次性明文展示:</strong>创建令牌时生成的随机明文 Token 值
(形如{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
at_xxx
</code>
)
仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。
</li>
<li>
<strong>安全哈希存储:</strong>数据库仅存储 Token 的 SHA-256
哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。
</li>
</ul>
<h3
id='3-2-usage'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.2 携带 Header 进行 API 调用
</h3>
<p>
您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种
Http 请求头携带方式之一:
</p>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
方式一:Authorization Bearer 头
),
},
{
value: 'auth-security',
title: rt('2_authentication_and_security_settings_76967059'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt(
'the_platform_combines_authentication_methods_to_support_bc9f1772',
)}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
<h3
id='2-1-login'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('2_1_username_and_password_authentication_97184f8f')}
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('registration_and_password_login_c5bd6cb4')}</strong>{' '}
{rt('users_can_register_and_create_sessions_with_21883e04')}{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
bcrypt
</code>{' '}
{rt('salted_hashing_dc2a9b63')}
</li>
<li>
<strong>{rt('system_switches_53741557')}</strong>{' '}
{rt(
'administrators_can_disable_password_registration_or_login_a2648e62',
)}
</li>
</ul>
<h3
id='2-2-oidc'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('2_2_third_party_oidc_providers_f14083c8')}
</h3>
<p>{rt('users_can_link_external_accounts_from_their_c08d3f5a')}</p>
<ol className='list-decimal pl-4 md:pl-5 space-y-1'>
<li>
{rt('open_4540d74d')}
<strong>{rt('settings_profile_32835b0d')}</strong>{' '}
{rt('message_a173cce8')}
</li>
<li>
{rt(
'under_linked_accounts_view_existing_connections_or_5151eb9b',
)}
</li>
<li>{rt('after_linking_use_the_oidc_login_button_6095b15d')}</li>
</ol>
</div>
),
children: [
{
value: '2-1-login',
title: rt('2_1_username_and_password_authentication_97184f8f'),
},
{
value: '2-2-oidc',
title: rt('2_2_third_party_oidc_providers_f14083c8'),
},
],
},
{
value: 'access-token',
title: rt('3_integrating_personal_access_tokens_af2647b5'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt('personal_access_tokens_allow_developers_and_third_9a242a0e')}
</p>
<h3
id='3-1-generation'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('3_1_token_generation_and_storage_2b78c681')}
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('one_time_plaintext_display_fd13912a')}</strong>{' '}
{rt('a_newly_generated_plaintext_token_such_as_57eb11a1')}{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
at_xxx
</code>
{rt('is_displayed_only_once_copy_and_save_8d378c8a')}
</li>
<li>
<strong>{rt('secure_hash_storage_1ce392ff')}</strong>{' '}
{rt('only_a_sha_256_hash_of_the_dbf434b1')}
</li>
</ul>
<h3
id='3-2-usage'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('3_2_calling_apis_with_headers_fccd7b0d')}
</h3>
<p>{rt('use_your_saved_plaintext_token_with_either_3af53a05')}</p>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
{rt('method_1_authorization_bearer_header_2a9c2a6c')}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:3000
Authorization: Bearer at_628d022b7a95e26b...`}
language='http'
/>
</div>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
方式二:X-Access-Token 自定义头
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
language='http'
/>
</div>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
{rt('method_2_custom_x_access_token_header_4548169f')}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:3000
X-Access-Token: at_628d022b7a95e26b...`}
language='http'
/>
language='http'
/>
</div>
</div>
</div>
),
children: [
{ value: '3-1-generation', title: '3.1 令牌生成与存储规范' },
{ value: '3-2-usage', title: '3.2 携带 Header 进行 API 调用' },
],
},
{
value: 'config-system',
title: '4. 动态系统配置项',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>缓存读取加速:</strong>配置加载基于 GORM 读取数据库,并辅以
Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。
</li>
<li>
<strong>核心系统配置项说明:</strong>
<ul className='list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground'>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
site_name
</code>
:平台展示名称
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
password_login_enabled
</code>
:密码登录开关
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
registration_enabled
</code>
:用户自主注册开关
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
max_api_keys_per_user
</code>
:普通用户创建令牌的最大数限制 (默认5)
</li>
</ul>
</li>
</ul>
</div>
),
},
{
value: 'worker-scheduler',
title: '5. 异步任务与定时调度',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
项目借助 Cobra 实现多命令入口分发,通过独立部署 Worker
服务解耦复杂计算或高延迟IO:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>定时任务派发 (CMD scheduler):</strong>负责按 Cron
表达式配置,定时将待执行任务推送到 Redis 队列中。
</li>
<li>
<strong>多优先级 Worker (CMD worker):</strong>基于 Asynq
驱动,按优先级拉取任务并并发调度执行(如定时清理临时上传目录、同步外部系统日志等)。
</li>
</ul>
</div>
),
},
{
value: 'tracing-metrics',
title: '6. 链路追踪与结构化日志',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>OpenTelemetry Tracing:</strong>自动传递 Tracing
上下文,所有经由 Gin 中间件、外部请求或 GORM
数据库的事务操作都将带有全局唯一的
Span,用于排查链路耗时或调用异常。
</li>
<li>
<strong>Zap 结构化日志:</strong>
将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki
等日志收集分析工具无缝对接。
</li>
</ul>
</div>
),
},
];
),
children: [
{
value: '3-1-generation',
title: rt('3_1_token_generation_and_storage_2b78c681'),
},
{
value: '3-2-usage',
title: rt('3_2_calling_apis_with_headers_fccd7b0d'),
},
],
},
{
value: 'config-system',
title: rt('4_dynamic_system_configuration_b3bf8c49'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{rt('the_built_in_key_value_configuration_module_713f6d05')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('cached_reads_35828322')}</strong>{' '}
{rt('configuration_is_read_from_the_database_through_3e9f6a34')}
</li>
<li>
<strong>{rt('core_system_configuration_keys_6dc02be8')}</strong>
<ul className='list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground'>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
site_name
</code>
{rt('platform_display_name_116219b1')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
password_login_enabled
</code>
{rt('password_login_switch_153eaa45')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
registration_enabled
</code>
{rt('user_registration_switch_827b5b21')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
max_api_keys_per_user
</code>
{rt('maximum_tokens_per_regular_user_default_5_5dd6d9e7')}
</li>
</ul>
</li>
</ul>
</div>
),
},
{
value: 'worker-scheduler',
title: rt('5_background_tasks_and_scheduling_7142ce96'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt('cobra_provides_separate_commands_so_worker_services_1bbae2df')}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>
{rt('scheduled_dispatch_scheduler_command_e79062ad')}
</strong>{' '}
{rt('enqueues_tasks_in_redis_according_to_their_694e5985')}
</li>
<li>
<strong>
{rt('workers_with_queue_priorities_worker_command_faa970b1')}
</strong>{' '}
{rt('asynq_consumes_tasks_by_priority_and_executes_69d82251')}
</li>
</ul>
</div>
),
},
{
value: 'tracing-metrics',
title: rt('6_distributed_tracing_and_structured_logs_e8971cad'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt(
'the_platform_integrates_monitoring_components_for_observability_2b1da50f',
)}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('ui_openTelemetryTracing')}</strong>{' '}
{rt(
'tracing_context_is_propagated_automatically_gin_requests_0f549cce',
)}
</li>
<li>
<strong>{rt('zap_structured_logs_1c9725e8')}</strong>{' '}
{rt('standardizes_backend_console_and_log_output_as_16970757')}
</li>
</ul>
</div>
),
},
];
}
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import Link from 'next/link';
import { ArrowLeft, Calendar, Check, Copy, FileText } from 'lucide-react';
@@ -21,6 +22,8 @@ export function LegalPageLayout({
sections,
description,
}: LegalPageLayoutProps) {
const rt = useTranslations('runtime');
const [activeSection, setActiveSection] = React.useState<string>(
sections[0]?.value || '',
);
@@ -192,7 +195,7 @@ export function LegalPageLayout({
const lines: string[] = [];
lines.push(`# ${title}`);
lines.push('');
lines.push(`最后更新:${lastUpdated}`);
lines.push(rt('last_updated_value0_87a896df', { value0: lastUpdated }));
if (description) {
const descriptionElement = contentRoot.querySelector(
'[data-docs-description]',
@@ -303,7 +306,7 @@ export function LegalPageLayout({
className='gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2'
>
<ArrowLeft className='w-4 h-4' />
返回首页
{rt('back_to_home_d66f4359')}
</Button>
</Link>
@@ -318,11 +321,12 @@ export function LegalPageLayout({
<div className='flex flex-wrap items-center gap-4 text-xs text-muted-foreground mb-8'>
<span className='flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50'>
<FileText className='w-3.5 h-3.5' />
{sections.length} 个内容
{sections.length} {rt('sections_fcb2696e')}
</span>
<span className='flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50'>
<Calendar className='w-3.5 h-3.5' />
最后更新:{lastUpdated}
{rt('last_updated_a83cd480')}
{lastUpdated}
</span>
<Button
type='button'
@@ -336,7 +340,9 @@ export function LegalPageLayout({
) : (
<Copy className='w-3.5 h-3.5' />
)}
{hasCopied ? '已复制' : '复制 Markdown'}
{hasCopied
? rt('copied_8f6f8d97')
: rt('copy_markdown_f9c71ee4')}
</Button>
</div>
{description && (
@@ -357,7 +363,7 @@ export function LegalPageLayout({
<div className='sticky top-24'>
<div className='space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4 scrollbar-thin'>
<div className='text-sm font-semibold text-foreground mb-4 pl-3'>
目录
{rt('table_of_contents_52daa71e')}
</div>
<nav className='space-y-1'>
{sections.map((section) => (
@@ -445,7 +451,7 @@ export function LegalPageLayout({
<div className='pt-16 border-t border-border mt-20'>
<div className='bg-muted/30 rounded-2xl p-8 text-center'>
<p className='text-muted-foreground text-sm mb-4'>
对这些内容有疑问?
{rt('questions_about_this_content_feb7d44a')}
</p>
<div className='flex justify-center gap-4'>
<Link
@@ -453,7 +459,7 @@ export function LegalPageLayout({
target='_blank'
>
<Button variant='outline' className='h-9 text-xs'>
联系支持
{rt('contact_support_4cac90c8')}
</Button>
</Link>
</div>

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