Compare commits

...

57 Commits

Author SHA1 Message Date
ryan 36cff502f7 chore(release): v3.4.2
### 🛠 修复
- 修复 Pages 部署包路径校验、归档展开限额、历史版本裁剪、代理路由绑定与 Agent
  下载过程中的安全和一致性问题;大包改为流式处理,异常中断遗留的部署包会安全补偿清理。

### ⚡️ 优化与改进
- Pages 项目新增持久部署源,支持 Remote URL 或公开 GitHub Release;GitHub latest
  可按设定间隔自动检查并发布更新,默认间隔为每天一次。
- Remote URL 默认允许公网与内网地址,新增「允许不安全连接」开关。
- Pages 详情页重构为「部署 / 设置」Tab,部署源卡片样式更紧凑统一。
- 安全性新增「限流」设置,可为边缘站点配置默认并发与带宽;填 -1 可关闭。
- 限流页新增分析视图,展示请求压力与独立访客趋势,支持域名过滤与时间预设。

### 💄 其他/体验
- 精简部署源数据模型,去除脱敏与无用字段。
- Pages 部署源任务不再隐藏,可在任务管理中查看。
- make prettier 支持自动清理前后端无用 import。
- 限流趋势桶调整至 3 分钟粒度,范围扩展至 24h/3d。
- Agent 部署命令增加 Pages 命名卷持久化。
2026-07-19 20:54:57 +08:00
ryan fa588797bf chore: eslint fix 先于 prettier 执行 2026-07-19 20:52:01 +08:00
ryan a963b8bf54 chore(prettier): format 并清理无用 import
make prettier 统一格式化,goimports 与 eslint unused-imports 自动移除未使用导入。
2026-07-19 20:51:12 +08:00
ryan d9663f91d6 chore: make prettier 自动清理前后端无用 import
前端接入 eslint-plugin-unused-imports,pnpm format 同步执行 eslint --fix;
后端将 gofmt 替换为 goimports,并修复 danger-zone 未使用导入。
2026-07-19 20:47:28 +08:00
ryan 4481677ef3 refactor(pages): 公开部署源任务并默认每日扫描
移除 Pages 部署源任务的 InternalOnly 限制,任务管理可查看与调度;
将 scanner cron 与 GitHub latest 默认检查间隔调整为每天一次,
并优化部署历史列表展示。
2026-07-19 20:41:09 +08:00
ryan 20249d917c feat(rate-limits): use 3-minute trend buckets
Rate-limit analysis requests overview with bucket_minutes=3; RPS uses
count/180. Overview still defaults to 60-minute buckets.
2026-07-19 20:30:20 +08:00
ryan abe8fb8268 refactor(pages): 精简部署源模型并重构详情页交互
将 Remote 网络策略收敛为 allow_insecure,去掉脱敏与无用字段;
Pages 详情拆为部署/设置 Tab,统一卡片样式与来源信息展示。
2026-07-19 20:23:31 +08:00
ryan f0b51a99b3 fix(db): 重编号 Pages 部署源迁移避免版本冲突
main 已占用 202607190001(OpenResty 默认限流),
将 Pages source runtime / scanner seed 顺延为
202607190002、202607190003,并同步迁移测试期望配置数。
2026-07-19 19:41:30 +08:00
ryan c92f986978 merge: 合并 PR #22 Pages 部署源 V2 到 feat/pages-source-sync-v2
基于最新 main 合并 deqiying/feat/pages-source-sync-v2,
解决 docs/changelog/index.md 与限流相关条目的冲突。
2026-07-19 19:36:48 +08:00
deqiying ccea08fe47 docs(pages): 收口部署源 V2 实现
同步 Pages、总体架构、Agent 与使用指南,记录阶段提交、验证结果和生产验收边界。
2026-07-19 19:25:28 +08:00
ryan 72962beb0f feat(rate-limits): use 1m buckets and 24h/3d ranges
Allow overview bucket_minutes=1; rate-limit analysis uses 1-minute
buckets and replaces 7d preset with 3 days.
2026-07-19 19:20:59 +08:00
ryan b56290d79d feat(rate-limits): use 5m trend buckets and 24h/7d ranges
Overview API accepts bucket_minutes (5|60); rate-limit analysis uses
5-minute buckets and drops 15d/30d presets. Widen rank value column.
2026-07-19 19:18:15 +08:00
deqiying 67b051c2bc feat(frontend): 支持 Pages 自动更新交互
在 GitHub latest 来源中提供自动更新开关、检查间隔和运行状态。\n页面按来源到期时间低频刷新,并在自动发布或人工回滚后同步项目与部署历史。
2026-07-19 19:09:57 +08:00
deqiying 999428cf9a feat(pages): 增加来源扫描与自动更新
为 GitHub latest 来源增加五分钟 scanner、按来源间隔检查、精确 revision 自动发布与租约恢复。\n记录退避和投递统计,并为 PostgreSQL 与 SQLite 幂等创建内部排程。
2026-07-19 19:09:21 +08:00
ryan 86d2d6b0ad feat(rate-limits): add RPS analysis tab with dual-axis chart
Split rate-limits into analysis/config tabs; reuse access-log overview
filters; chart hourly RPS vs visits with dataZoom; rank top hosts/IPs
by window-average RPS.
2026-07-19 19:09:01 +08:00
deqiying 848884d8cd fix(pages): 增加部署包孤儿补偿
按项目、来源、运行时与上传记录锁序补偿异常中断遗留的部署包。\n同时隐藏并保护系统内部排程,避免通用任务管理入口修改 scanner。
2026-07-19 19:08:43 +08:00
ryan f783a1e6fa docs: add rate-limit analytics design
Tabs for analysis/config, dual-axis RPS chart with overview filters
and average RPS rankings from access-log overview.
2026-07-19 19:06:06 +08:00
deqiying c39a3edcc3 feat(pages): 支持 GitHub Release 部署源
增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。
2026-07-19 18:31:42 +08:00
ryan 4c17f5277a feat(agent): persist Pages dir in Docker deploy volume
Mount openflare-agent-pages to /data/var/lib/openflare/pages so
container rebuilds keep local Pages packages.
2026-07-19 18:28:35 +08:00
ryan 0e097a66c4 docs: document default edge rate limits 2026-07-19 18:19:20 +08:00
ryan 39cba821d5 feat(frontend): add security rate-limits page and inherit UI 2026-07-19 18:17:16 +08:00
ryan c5f8105db8 feat(proxy-route): allow -1 to disable rate limits 2026-07-19 18:14:16 +08:00
ryan 2bc2d82ad0 feat(config): add openresty default rate limit system options 2026-07-19 18:12:51 +08:00
ryan a3125c8276 feat(openresty): merge global default limits at route render 2026-07-19 18:10:51 +08:00
ryan 4d7b63f217 docs: add default edge rate limit implementation plan
Task breakdown for global OpenResty limit defaults, route inherit
semantics, security rate-limits page, and render-time merge.
2026-07-19 18:05:43 +08:00
ryan fada04c373 docs: add edge default rate limit design
Specify global OpenResty limit defaults with per-route inherit (-1 off)
and render-time merge in RenderRouteConfig.
2026-07-19 18:02:13 +08:00
deqiying 38b0516937 feat(pages): 支持 Remote 部署源同步
新增部署源配置与运行态模型、安全下载、租约续期、原子激活和失败补偿。

接入内部任务与脱敏前端交互,并阻止数据库 Trace 和日志展开敏感查询参数。
2026-07-19 17:36:51 +08:00
deqiying 4e8ec23264 fix(pages): 收紧部署包与 Agent 同步边界
完成 V2 Phase 0 安全与一致性前置:统一真实归档限额、流式拉取、候选裁剪、保留上传删除语义及 Pages 路由引用锁。
2026-07-19 16:42:45 +08:00
deqiying f386674464 docs(pages): 完善部署源 V2 实现方案 2026-07-19 16:14:09 +08:00
ryan e0398397a9 chore(release): v3.4.1
### 🛠 修复
- 收紧 WAF 安全防护特征,降低对常见正常请求的误伤(含避免 SQL 特征 /* */ 误匹配 Accept: */*)。
- 优化 WAF 规则编辑器返回按钮、列表操作与属性栏布局体验。
- 节点详情「运行诊断」摘要不再展示具体错误日志,避免长日志撑破布局。

### ⚡️ 优化与改进
- WAF 规则编排新增「UA 检查」与「安全防护」节点,支持浏览器/操作系统白名单、爬虫与自定义正则屏蔽,以及路径穿越、注入类等基础特征检测。
- 优化边缘 WAF 安全防护、UA 检查与 IP 匹配热路径,降低开启基础防护时的 CPU 占用。
- Agent 内嵌 resty.ipmatcher,部署时不再依赖无效 opm 包。
- 新建反代规则时默认开启边缘缓存(标准静态资源策略)。
- 节点详情页调整为「概览」与「状态与部署」,边缘节点支持自动填充部署命令。

### 💄 其他/体验
- WAF 规则编辑器支持节点自定义命名、拖放添加、右键删除与一键格式化布局。
2026-07-19 15:43:05 +08:00
ryan fafee0055a feat(nodes): 优化 2026-07-19 15:42:02 +08:00
ryan 6619f5b650 fix(nodes): 运行诊断不再展示具体错误日志
摘要区仅保留异常数量与事件类型,避免长日志撑破卡片布局。
2026-07-19 15:25:02 +08:00
ryan a65d0f291b feat(nodes): 调整节点详情 Tab
将数据看板并入概览,运行状态与配置合并为状态与部署;。
2026-07-19 15:16:26 +08:00
ryan c00ead9aa0 feat(nodes): 调整节点详情 Tab 并新增边缘部署命令
将数据看板并入概览,运行状态与配置合并为状态与部署;
边缘节点支持自动填充 Server URL 与 Agent Token 的 Docker 部署卡片。
2026-07-19 15:01:38 +08:00
ryan 7366832e12 fix(agent): 内嵌 resty.ipmatcher,移除无效 opm 依赖
OPM 无 api7/lua-resty-ipmatcher 账号导致镜像构建失败;改为 vendor
api7 v0.6.1 并随 ManagedWAFLuaFiles 部署到 lua 目录。
2026-07-19 14:57:51 +08:00
ryan 1a7e5e6c41 perf(waf): IP 匹配改为索引查询(ipmatcher / 预编译)
加载时编译 IP 组与节点 IP/CIDR 索引,优先 resty.ipmatcher 基数树,
否则 exact 哈希 + 预解析 CIDR,避免大名单线性扫描打满边缘 CPU。
2026-07-19 14:48:07 +08:00
ryan 46ce7de513 perf(waf): 收窄安全防护扫描面并优化 UA 热路径
注入类检测仅扫 Query/Cookie/Referer/有限 Body,避免全 Header 匹配拖垮边缘 CPU;
按开关采集输入、GET 跳过 read_body,UA 仅 lower 一次并用 set 匹配白名单。
2026-07-19 14:16:34 +08:00
ryan 39473cb370 chore: prettier 2026-07-19 13:00:01 +08:00
ryan 64e40a7c18 fix(waf): 移除编辑器未使用的图标导入以通过 code-check 2026-07-19 12:58:24 +08:00
ryan 53ddb45614 fix(waf): 规则编辑器返回按钮对齐 websites 详情样式 2026-07-19 12:55:49 +08:00
ryan ad6621fce9 fix(waf): 收紧安全防护特征,降低常见正常请求误伤
- SSRF 仅匹配 URL 形态,避免 Chrome/x.0.0.0 误中
- 命令注入去掉裸 &&/|| 与裸 shell 名
- SQL sleep/benchmark 要求数字参数
- XSS javascript:/eval 要求更像代码的上下文
- 路径穿越去掉过宽的 c:\windows;CRLF 去掉单独 %0a/%0d
2026-07-19 12:54:01 +08:00
ryan 60d6e3e846 fix(waf): 调整编辑器返回与格式化布局按钮位置
返回置于标题上方;格式化布局移至保存按钮左侧。
2026-07-19 12:52:12 +08:00
ryan fd9348b7bd feat(waf): 规则编辑器一键格式化节点布局
按从开始节点出发的层次从左到右整理坐标,并 fitView 到画布。
2026-07-19 12:49:42 +08:00
ryan 32113eb790 fix(waf): 列表操作改为直接图标按钮
规则组与 IP 组表格去掉「…」菜单,操作以图标平铺展示。
2026-07-19 12:47:02 +08:00
ryan 1ba05ec0bd fix(waf): 避免 SQL 特征 /* */ 误匹配 Accept: */*
开启 SQL 注入防护时不再把正常 Accept 头当成攻击。
2026-07-19 12:46:23 +08:00
ryan b75f985815 feat(waf): 新增安全防护节点 security_check
基础特征检测九项可开关;默认开启路径穿越与文件包含;命中任意规则走 false。
2026-07-19 12:33:13 +08:00
ryan db89f68547 docs(waf): 规格 — 安全防护节点 security_check
九项基础特征检测可开关;默认仅路径穿越与文件包含;命中任意规则 false。
2026-07-19 12:20:51 +08:00
ryan 74106474ca fix(waf): UA 检查说明改为问号悬浮提示
将屏蔽/匹配相关 FieldDescription 收敛为 CircleHelp Tooltip。
2026-07-19 11:52:29 +08:00
ryan 1d97ea69d0 fix(waf): UA 检查属性栏将屏蔽区块移到匹配上方 2026-07-19 11:49:55 +08:00
ryan 53d9572508 refactor(waf): 移除规则画布右上角删除按钮
删除改为右键菜单与键盘快捷键。
2026-07-19 11:48:54 +08:00
ryan 8f3ff59567 feat(waf): 规则画布右键删除节点与连线
覆盖画布默认右键菜单;节点/连线右键弹出删除项,系统节点禁用。
2026-07-19 11:47:04 +08:00
ryan d47ceb9971 feat(waf): UA 非正常不含爬虫,并支持自定义正则屏蔽
block_abnormal_ua 仅 Other/Unknown;新增 block_custom_ua 与 custom_ua_patterns。
2026-07-19 11:43:45 +08:00
ryan 7476c86976 fix(waf): UA 检查开启后才显示匹配与屏蔽并补充说明
未开启 require_ua 时隐藏匹配/屏蔽区块;爬虫与非正常 UA 开关增加分类提示。
2026-07-19 11:38:18 +08:00
ryan 28eef0bbcd feat(waf): 新增 UA 检查节点 ua_check
支持要求携带 UA、浏览器/OS 白名单 and-or 匹配,以及优先屏蔽爬虫与非正常 UA。
2026-07-19 11:35:31 +08:00
ryan 047ed6554d docs(waf): 规格 — UA 检查节点 ua_check
定义 require/白名单 and-or/屏蔽优先级及与访问日志一致的 UA 分类标签。
2026-07-19 11:27:55 +08:00
ryan b5e27fabde feat(waf): 规则编辑器节点自定义命名与拖放添加
对齐后端 label 字段;属性栏可编辑显示名称;节点库改为拖到画布落点创建。
2026-07-19 11:01:24 +08:00
ryan 4166cc9861 docs(waf): 规格 — 规则编辑器节点命名与拖放添加
确认仅前端消费已有 label,节点库改为拖到画布落点,不做备注。
2026-07-19 10:57:28 +08:00
221 changed files with 32205 additions and 2973 deletions
+7 -3
View File
@@ -14,9 +14,13 @@ license-check:
scripts/update_go_license.sh --check
prettier:
@echo "==> Formatting backend Go source..."
gofmt -w $$(find . -type f -name '*.go' -not -path './.git/*' -not -path './frontend/*')
@echo "==> Formatting frontend source..."
@echo "==> Formatting backend Go source and removing unused imports..."
@command -v goimports >/dev/null 2>&1 || { \
echo "goimports not found, installing..."; \
go install golang.org/x/tools/cmd/goimports@latest; \
}
goimports -w $$(find . -type f -name '*.go' -not -path './.git/*' -not -path './frontend/*')
@echo "==> Formatting frontend source and removing unused imports..."
cd frontend && pnpm format
build-embedded:
+2 -1
View File
@@ -42,7 +42,7 @@ OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、
* **安全内网穿透(Tunnels)**:开源版的 Cloudflare Tunnels。无须公网 IP 或暴露入向端口,通过 Relay 中继节点与 OpenFlared 客户端安全反向穿透内网 Web 服务至公网。
* **边缘 WAF 安全防护**:提供全局与自定义规则组,支持手动/自动/订阅型 IP 组、MaxMind GeoIP 国家级地域准入、IP 组成员 Checksum 差分同步(无需 Nginx 重载)以及自定义拦截响应。
* **防 CC 与人机挑战(PoW)**:内置高性能客户端密码学 Proof of Work 挑战(类似 Turnstile),在网关边缘秒级拦截并阻断僵尸网络与爬虫。
* **Pages 静态托管**:直接上传预构建 ZIP 包,由边缘 Agent 拉取并通过 OpenResty 本地提供服务,支持 SPA Fallback 与内置 API 反向代理配置。
* **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 协议,无缝接入企业身份提供商实现统一登录。
@@ -181,6 +181,7 @@ 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 \
ghcr.io/rain-kl/openflare-agent:latest
+46 -4
View File
@@ -11,16 +11,60 @@ sidebar: false
## 重大变更
> [!IMPORTANT]
>
>
> 3.1.2 版本更新了 CLickHouse 部署配置。
>
>
> 3.0.0 版本为 Wavelet 平台迁移与架构重构版本,涉及数据库表结构、环境变量以及前后端底层架构的重大变更。请务必在升级前备份数据库,并且更新到 V2.3.4。
> 目前已知的兼容性问题:
>
> - Pages 无法迁移, 升级前请先手动下载并备份 Pages 静态站点的 ZIP 包,升级后重新创建。
> - 性能调优参数重置, 升级后请重新配置
## [unreleased]
## [v3.4.2] - 2026-07-19
### 新增
- 安全性新增「限流」设置:可为边缘站点配置默认并发与带宽;站点未设置时继承,填 `-1` 可显式关闭。
- Pages 项目新增持久部署源,可配置 Remote URL 或公开 GitHub Release,并支持手动检查、同步发布、来源状态查看与同一 Release 资源替换确认;GitHub latest 来源可按设定间隔自动检查并发布更新,部署历史会保留安全的来源快照。
- Pages 部署源默认扫描间隔调整为每天一次,部署源任务可在任务管理中查看与调度。
### 改进
- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。
- Agent Docker 部署命令默认挂载命名卷 `openflare-agent-pages` 持久化 Pages 目录,重建容器时无需重新拉取静态站点包。
- 限流页新增「分析」视图:默认展示近 24 小时请求压力(RPS)与独立访客双轴趋势(3 分钟桶),支持域名过滤与 24 小时/3 天预设,并按窗口平均 RPS 排行域名与 IP;原全局默认配置迁入「配置」页签。
- Pages 详情页重构为「部署 / 设置」Tab,部署源卡片样式更紧凑统一,Remote URL 改为明文编辑。
### 修复
- 修复 Pages 部署包路径校验、归档展开限额、历史版本裁剪、代理路由绑定与 Agent 下载过程中的安全和一致性问题;大包改为流式处理,部署入口、旧版目录切换、保留版本及上传记录在并发场景下更加可靠,异常中断遗留的部署包也会被安全补偿清理。
## [v3.4.1] - 2026-07-19
### 新增
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。
- WAF 规则编排新增「安全防护」节点:可开关路径穿越、文件包含、SQL 注入、XSS、命令注入、SSRF、恶意上传、XXE 与 CRLF 等基础特征检测;默认仅开启路径穿越与文件包含。
### 改进
- 新建反代规则时默认开启边缘缓存,策略为仅缓存标准静态资源。
- 节点详情页 Tab 调整为「概览」与「状态与部署」:原数据看板并入概览;运行状态与配置信息并入状态与部署;边缘节点新增可自动填充 Server URL 与 Agent Token 的 Docker 部署命令卡片。
- 节点详情「运行诊断」摘要不再展示具体错误日志,避免长日志撑破布局。
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
- WAF 规则画布支持右键删除节点或连线,并屏蔽浏览器默认右键菜单。
- WAF 规则编辑器支持一键格式化布局,按流程层次自动整理节点位置。
- 优化边缘 WAF「安全防护」与「UA 检查」热路径:SQL/命令/XSS 等仅扫描 Query、Cookie、Referer 与有限 Body,避免对全部请求头做特征匹配;路径检测不再重复扫描完整 `request_uri`;无请求体时跳过 Body 读取;UA 分类仅小写一次并加速白名单匹配,显著降低开启基础防护时的 CPU 占用。
- 优化边缘 WAF「IP 匹配」:IP 组与节点 IP/CIDR 在加载时编译为索引(优先随 Agent 下发的 `resty.ipmatcher` 基数树,否则 exact 哈希 + 预解析 CIDR),查询与名单规模解耦,避免大名单线性扫描打满 CPU。
- Agent 内嵌 `resty.ipmatcher`,部署时不再依赖无效 opm 包。
### 修复
- 收紧 WAF 安全防护特征,降低对常见正常请求的误伤(含避免 SQL 特征 `/* */` 误匹配 `Accept: */*`)。
- 优化 WAF 规则编辑器返回按钮、列表操作与属性栏布局体验。
## [v3.4.0] - 2026-07-19
### 新增
@@ -77,7 +121,6 @@ sidebar: false
- 移除了 WAF 旧版固定名单与人机验证配置;升级后请在发布前使用新的可视化规则重新编排防护策略。
## [v3.2.0] - 2026-07-12
### 新增
@@ -164,7 +207,6 @@ sidebar: false
- 网站管理拆分为域名、路由、静态托管、WAF 和缓存等独立能力,更适合维护复杂站点配置。
- Tunnel 节点统一纳入节点管理,配置发布和运行状态查看更加一致。
## [v2.3.4] - 2026-06-17
### 变更
+5
View File
@@ -126,11 +126,16 @@ 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 \
ghcr.io/rain-kl/openflare-agent:latest
```
> [!NOTE]
> **Pages 持久化**
> 默认将 Pages 部署目录挂载到 Docker 命名卷 `openflare-agent-pages`(容器内路径 `/data/var/lib/openflare/pages`)。重建或升级 Agent 容器时无需重新拉取静态站点包。
> [!NOTE]
> **非 Root 安全加固运行**
> Agent 容器内部已完成安全加固,在启动后会统一以低权限非 root 用户 `openflare` 运行。
+3
View File
@@ -123,11 +123,14 @@ 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 \
ghcr.io/rain-kl/openflare-agent:latest
```
命名卷 `openflare-agent-pages` 持久化 Pages 部署目录,重建容器时无需重新拉取静态站点包。
## Agent 接入(脚本安装)
除了 Docker 部署外,也支持通过安装脚本将 Agent 部署在本地宿主机上。安装脚本会自动在本地 Linux 系统中注册低权限的 `openflare` 服务账号,并将 systemd 服务配置为以该用户身份运行,利用 Linux Capabilities 安全地监听 80/443 特权端口。
+2 -1
View File
@@ -98,7 +98,7 @@ Agent 对数据面 OpenResty 的管控实现了端到端的闭环,包含配置
* `certs/`:证书存放目录(文件命名为 `{cert_id}.crt` 和 `{cert_id}.key`)。
* `waf/` 与 `pow/`:WAF 及防 CC 挑战所需的专用 Lua 运行时脚本。
* `waf_config.json` 与 `waf_ip_groups.json`:WAF 过滤引擎所需的结构化规则配置文件。
* `pages_dir`:Pages 静态站点部署目录,默认位于 `data_dir/var/lib/openflare/pages`。当激活配置引用 Pages **项目**时,Agent 按 `project_id` 请求控制面「最新激活包」(hash + package,下载后再校验 hash 防竞态),解压到 `projects/{project_id}/releases/{hash}`,切换 `current` 后**立即删除同项目其它历史 release**(仅保留最新)。项目内切换激活无需重发主配置;多项目对账时单项目失败不阻塞其它项目。
* `pages_dir`:Pages 静态站点部署目录,默认位于 `data_dir/var/lib/openflare/pages`。当激活配置引用 Pages **项目**时,Agent 按 `project_id` 请求控制面「最新激活包」(hash + package),以流式方式写入临时文件并执行实际响应上限与 SHA-256 校验,再安全解压到 `projects/{project_id}/releases/{hash}`。解压后会复核文件数与总字节,绝对防御上限为 2 GiB 包、1,000 个文件、单文件及总量 8 GiB;随后原子切换 `current` 并**立即删除同项目其它历史 release**(仅保留最新)。项目内切换激活无需重发主配置;多项目对账时单项目失败不阻塞其它项目。
### 2. 精细化的重载动作
1. **备份当前配置**:在写入新文件之前,Agent 会将现有的配置文件复制到 `.backup` 临时目录下,保留完整的现场快照。
@@ -174,3 +174,4 @@ graph TD
2. **严格的 Token 过滤与前缀验证**:Agent 侧向 Server 请求资源时,接口端点固定以 `/api/v1/agent/` 为前缀,并强制携带 `X-Agent-Token` 进行签名或令牌核验。
3. **节点自治原则**:Agent 须具备完备的离线工作能力。在与 Server 失去连接期间,本地 OpenResty 必须依靠本地已落地的配置保持反向代理服务的绝对正常运行。
4. **观测只上报事实**:访问日志以明细形式上送;主机指标上报计数器/瞬时读数。禁止在 Agent 内计算业务 UV、Top 域名、24h 已提供数据等结论性指标(由 Server 聚合)。详见 [边缘可观测与业务流量统计](./observability-design.md)。
5. **Pages 只消费控制面产物**:Remote URL、GitHub Release、自动 scanner,以及未来仓库 checkout/build executor 均属于 Server 职责。Agent 不接收外部 URL、访问令牌、仓库凭据或任意 clone/install/build 命令,只拉取已经激活且带完整性元数据的部署包。
+4 -2
View File
@@ -80,7 +80,7 @@ OpenResty (Agent, TLS/WAF)
* 提供管理端 REST API(`/api/v1/d/*`),通过 **Session Cookie** 鉴权,可选 `X-Access-Token` 访问令牌。
* 边缘节点协议走 `/api/v1/agent|relay|tunnel/*`,分别使用 `X-Agent-Token` / `X-Tunnel-Token` 鉴权。
* 包含配置编译器(Compiler),将数据库中的规则、证书与全局参数统一编译为不可变的配置快照及 OpenResty 物理配置文件文本。
* 存储 Pages 部署 ZIP 包于本地 Artifacts 目录,并向 Agent 提供受控的下载接口。
* 统一接收 Pages 本地上传、Remote URL 与公开 GitHub Release 预构建产物,完成来源检查、受限下载、归档校验和不可变 deployment;manual 上传生成待显式激活的 candidate,持久来源 sync 才 create-or-load 并原子激活。Server 向 Agent 提供受控的 latest 下载接口;内部 scanner 负责 GitHub latest 的限量检查、租约恢复、可选自动发布与孤儿上传记录补偿,通用任务管理入口不能修改该排程。未来仓库源码构建由独立 Server build executor 扩展,Agent 不执行第三方拉取或构建命令。
* 后台集成 Uptime Kuma 监控同步服务,自动为可用站点维护 HTTP 探测任务。
* 启动入口为根目录 `main.go` + `internal/cmd/`(`api` / `worker` / `scheduler` / `all`);OpenFlare 业务在 `internal/apps/openflare/`,边缘协议处理在 `internal/apps/openflare/{agent,relay,flared}/`。
* *详细设计请参阅:[Agent 与发布模型设计](./agent-design.md) 以及 [Uptime Kuma 监控同步设计](./kuma-design.md)*
@@ -126,6 +126,7 @@ OpenResty (Agent, TLS/WAF)
### 2. 静态托管与 API 代理流
* 静态资源解压落地于 Agent 节点的 `projects/{project_id}/current` 下(按项目 latest 拉取,仅保留最新包),OpenResty 通过 `root`/`index`/`try_files` 在边缘直接提供静态资源服务。
* 当启用 API 代理时,OpenResty 自动根据站点配置的 `api_proxy_path`(如 `/api`)将 API 请求重写并转发(`proxy_pass`)给后端动态接口。
* 管理员操作和内部 scanner 都只生成受约束的 artifact candidate,并复用统一 inspect、`upload.Ingest` 与 deployment pipeline。manual 上传创建新的未激活 candidate;持久来源 sync/scanner 才 create-or-load 并原子激活。未来 repository build executor 也只能向同一 artifact pipeline 输出产物;Agent 始终只是 active deployment 消费者。
* *部署包校验、解压逃逸防御及 Nginx 规则渲染详见:[Pages 静态托管设计文档](./pages-design.md)*
### 3. WAF 安全过滤流
@@ -160,7 +161,7 @@ OpenResty 健康与连接数 --> 边缘健康(瞬时,不作 24h 业务总量
当前系统核心实体包括:
* **反代与配置**:`zones` (根域管理边界), `zone_domains` (明确域名与证书/路由关联), `proxy_routes` (路由策略), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书). 详见 [Zone 与域名资源设计](./zone-design.md)。
* **Pages 静态托管**:`pages_projects` (Pages项目), `pages_deployments` (不可变部署), `pages_deployment_files` (部署文件清单).
* **Pages 静态托管**:`of_pages_projects` (Pages项目), `of_pages_project_sources` / `of_pages_project_source_runtime` (可变来源配置与运行态), `of_pages_deployments` (不可变部署), `of_pages_deployment_files` (部署文件清单).
* **节点与穿透**:`nodes` (节点), `tunnels` (隧道客户端), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
* **WAF 与安全**:`waf_rule_groups` (WAF规则组), `waf_ip_groups` (WAF IP组), `waf_rule_group_bindings` (网站WAF绑定).
* **系统与账号**:`acme_accounts` (ACME账户), `dns_accounts` (DNS账户), `geoip_update_configs` (GeoIP更新配置).
@@ -179,6 +180,7 @@ OpenResty 健康与连接数 --> 边缘健康(瞬时,不作 24h 业务总量
| 运行时配置与控制库解耦 | WAF 规则发布时编译并随 OpenResty reload 加载;动态 IP 组通过 checksum 驱动的内存快照独立刷新 |
| 业务流量以访问日志为唯一真相 | Agent 禁止业务预聚合;看板与 Zone 共用 Server 侧聚合,避免 openresty_tx 与 bytes_sent 双轨 |
| 业务交付 / 边缘健康 / 主机资源分层 | 已提供数据≠宿主机网卡出站≠OpenResty 连接数,UI 与 API 分名分区 |
| Pages artifact 与仓库构建分离 | 现有来源只导入预构建产物;未来 checkout/build 由 Server 隔离 executor 完成并复用 artifact pipeline,Agent 不执行第三方构建 |
---
+4 -3
View File
@@ -27,7 +27,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
| **配置版本控制** | 支持全局单一激活版本的预览、发布、不可变快照历史与秒级一键回滚 | [Agent 与发布模型](./agent-design.md) |
| **WAF 安全防护** | 支持可视化 DAG 编排规则、手动/自动/订阅型 IP 组、GeoIP 匹配与 PoW CC 防护 | [WAF 设计](./waf-design.md) / [WAF 可编排规则设计](./waf-orchestration-design.md) / [WAF 使用指南](../guide/waf-usage.md) |
| **内网穿透** | 通过中继节点(Relay)与内网客户端(OpenFlared),反向穿透暴露内网 Web 服务 | [内网穿透设计](./tunnel-design.md) / [穿透使用指南](../guide/tunnel-usage.md) |
| **Pages 静态托管** | 直接上传前端压缩包(zip / tar.gz / tar.xz / 7z 等),由边缘节点拉取并由 OpenResty 本地服务,支持 API 反代与 SPA Fallback | [Pages 静态托管设计](./pages-design.md) |
| **Pages 静态托管** | 支持上传或从 Remote URL、公开 GitHub Release 同步预构建产物;GitHub latest 可定时检查并可选自动发布。不可变部署由边缘节点拉取并由 OpenResty 本地服务,支持回滚、API 反代与 SPA Fallback | [Pages 静态托管设计](./pages-design.md) / [Pages 使用指南](../guide/pages-usage.md) |
| **TLS 证书自动续期** | 将证书显式绑定到 Zone 域名,并通过 ACME 协议向 Let's Encrypt 申请/续期证书 | [Zone 与域名资源设计](./zone-design.md) |
| **多节点监控与观测** | 访问日志为业务流量唯一真相;Agent 只上报明细与主机读数,Server 统一聚合;与 Zone/看板对账 | [观测数据传输模型](./observability-transport-model.md) / [边缘可观测与业务流量统计](./observability-design.md) / [上报协议与表结构](./observability-data-model.md) / [系统架构](./architecture.md) |
@@ -53,8 +53,9 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
* **Tunnel 与 Node 体系隔离**:Tunnel 客户端在内网发起出向建连,与控制面托管的边缘 Node(公网节点)是独立的实体,使用专属的 `tunnel_token` 进行鉴权。
### 4. Pages 静态托管边界
* **Direct Upload 托管模式**:仅支持直接上传预构建的 ZIP 静态资源包。不支持外部 Git 仓库自动构建、边缘 Serverless 函数、动态 SSR 服务或生成的二级预览域名。
* **包体硬上限限制**:为了保障边缘节点安全,ZIP 压缩包体最大 25 MiB,解压文件树不超过 1,000 个且总体积不超过 100 MiB。禁止上传含有任何软链接或目录跨越(Zip-Slip)的安全高危压缩包。
* **预构建产物来源**:项目可保持手动上传,或配置一个 Remote URL / 公开 GitHub Release asset 来源。Remote 与固定 tag 只支持手动操作;只有 GitHub latest 进入定时检查并可选择自动更新。来源可切换,但不可变 deployment 与当前生产版本不会随 source 编辑或删除而丢失。
* **归档与资源上限**:支持 `zip`、`tar.gz` / `tgz`、`tar.xz` / `txz`、`tar.bz2` / `tbz2`、`tar`、`7z`。压缩包上限由 `pages_max_package_size_mb` 控制(默认 100 MiB,范围 1~2048);展开后的单文件和总量上限为包上限的 4 倍且最低 100 MiB,最多 1,000 个常规文件。Server 与 Agent 都校验实际字节,并拒绝路径逃逸、软/硬链接与特殊文件。
* **构建与运行时边界**:当前不从外部 Git 仓库拉取源码或执行构建,也不提供边缘 Serverless、动态 SSR 或二级预览域名。未来仓库集成必须使用独立 `git_repository` Provider 与 Server 侧隔离 build executor,只向统一 artifact 管线输出受限产物;Agent 不接收仓库凭据、外部 URL 或 clone/install/build 命令。
### 5. 系统与版本边界
* **全局单一激活版本**:所有节点拉取并消费同一份全局激活配置。不进行按节点分组的差异化配置发布。
+74 -51
View File
@@ -12,20 +12,31 @@
2. **多节点分发困难**:当控制面管理多台边缘节点时,将静态文件同步分发到所有节点,并确保文件一致性,需要维护复杂的同步脚本(如 rsync 等)。
3. **回滚缺乏一致性**:一旦新前端包发布失败或存在严重缺陷,不仅要恢复静态文件,还要恢复对应的反代规则,很难做到原子回滚。
为了解决这些问题,OpenFlare 引入了受 Cloudflare Pages 启发的 **Pages 静态托管** 功能。该功能将“前端部署包上传”与“网站代理规则配置”合二为一,依托 OpenFlare 的 pull-based(拉取式)协同架构,实现静态文件分发与反代配置发布的强一致性、不可变性与一键秒级回滚。
为了解决这些问题,OpenFlare 引入了受 Cloudflare Pages 启发的 **Pages 静态托管** 功能。该功能将“预构建产物导入”与“网站代理规则配置”纳入同一控制面,依托 OpenFlare 的 pull-based(拉取式)协同架构,以不可变 deployment、单节点原子切换和周期对账实现多 Agent 最终收敛,并支持快速回滚。
---
## 核心功能
Pages 静态托管子系统包含以下核心能力:
* **Direct Upload 部署模式**:支持直接上传预构建的静态资源压缩包(`zip`、`tar.gz`、`tar.xz`、`tar.bz2`、`tar`、`7z`),或填写 URL 由控制面代为下载导入(允许内网地址与自签证书),省去复杂的 Git 集成和构建环境依赖。
* **不可变部署快照**:每次上传产生一个带唯一 ID 和 SHA-256 Checksum 的不可变部署记录。支持按系统配置保留最近 N 个历史部署,并可随时激活和回滚。
* **预构建产物部署**:支持直接上传静态资源压缩包,也可为项目保存一个 Remote URL 或公开 GitHub Release asset 来源。外部来源只由 Server 访问,成功同步后统一创建或复用不可变 deployment 并原子激活。
* **不可变部署快照**:本地上传每次创建新的候选 deployment;持久来源同步按 source identity/revision 创建或复用 deployment 并激活。所有部署都有唯一 ID 和整包 SHA-256,支持按系统配置保留最近 N 个历史版本并随时回滚。
* **检查与自动更新**:GitHub latest 可按项目间隔定时检查;默认只提示可用更新,管理员显式开启后才按检查到的精确 revision 自动同步并发布。
* **SPA Fallback 支持**:支持对单页应用(SPA)进行 Fallback 路由配置,请求找不到静态文件时自动重定向到入口文件。
* **内置 API 反代服务**:支持在 Pages 规则内一键启用 API 代理,消除跨域问题,将请求转发给指定的后端服务。
* **安全包校验与解压缩**:内置路径逃逸防御、防软链接劫持、文件大小/数量上限与可配置上传包体积控制,保障节点物理安全。
* **可配置限额**:管理员可在运维设置中调整「部署包大小上限」与「历史部署保留数」。
### 部署源与未来构建边界
项目当前支持 manual、Remote URL、GitHub Release 三种来源视图。无 source 记录即 manual;切换或删除 source 不删除历史 deployment,也不改变当前 active deployment。Remote URL 只允许手动“同步并发布”;GitHub Release 支持 latest/tag 手动检查与同步,只有 latest 可选择定时检查和自动更新。
source 是可变配置,deployment 是不可变事实。source 配置与运行态游标、状态、租约分别存储;deployment 只保存创建时的安全 provenance 快照。所有产物都复用“下载或接收产物 → 真实字节与入口校验 → `upload.Ingest` → deployment”的 artifact pipeline:manual 上传停在 candidate,等待管理员显式激活;持久来源 sync 才在同一业务事务中 create-or-load 并原子激活。Agent 只消费 active deployment,不感知来源类型。
后续从 Git 仓库拉取源码并自动构建时,将新增独立 `git_repository` provider 与隔离的 build executor。它输出受限的预构建产物后继续复用上述导入管线;不得把 clone、依赖安装或任意构建命令下发给 Agent,也不得把 branch/build/env 字段塞入现有 `github_release` source。当前 V2 不增加这些未来字段或空任务,只稳定 provider 输出、source discriminated view 与 deployment provenance 三个扩展边界。
管理端信息架构参考 Cloudflare Pages 当前把 [Git integration](https://developers.cloudflare.com/pages/configuration/git-integration/) 与 [Direct Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/) 分离、并统一展示生产状态与历史部署的方式:OpenFlare 项目详情按“当前生产部署 → 部署源 → 部署历史”组织。OpenFlare 仍允许切换来源并保留历史部署,不采用 Cloudflare 项目创建后来源不可切换的限制。
---
## Pages 静态托管架构
@@ -40,35 +51,41 @@ graph TD
OpenResty -->|2. 转发 API 代理| BackEnd[4. 后端 API 服务]
%% 控制流与心跳
Server[OpenFlare Server 控制面] <-->|Agent API / Heartbeat| Agent[openflare-agent 进程]
Server -.->|5. 存储部署包| LocalStore[(Server 本地存储)]
Admin[管理员 / CI] -->|上传或配置来源| Server[OpenFlare Server 控制面]
Providers[Remote / GitHub Provider] -->|受限 artifact candidate| Server
Scanner[内部 scanner / action task] -->|检查与自动同步| Server
Server <-->|Agent API / Heartbeat| Agent[openflare-agent 进程]
Server -.->|统一 upload.Ingest| UploadStore[(平台 upload backend)]
Agent -->|1. 发现新版本| Server
Agent -->|2. 下载部署包| Server
Agent -->|3. 校验并解压缩| StaticFiles
Agent -->|4. 应用并 Reload| OpenResty
Agent -->|3. 校验、解压并原子切换| StaticFiles
style Browser fill:#f9f,stroke:#333,stroke-width:2px
style StaticFiles fill:#9f9,stroke:#333,stroke-width:2px
style Server fill:#f96,stroke:#333,stroke-width:2px
```
* **控制面(Control Plane)**:Server 接收前端上传的部署包,并将包存储于本地磁盘,元数据写入数据库。配置发布时,编译出带有 `pages_deployment` 详情的不可变全局版本快照。
* **数据面(Data Plane)**:Agent 在心跳同步中发现版本更新并引用了 Pages 部署,通过专属 API 下载对应的部署包并执行校验解压缩。OpenResty 拦截域名请求,在本地提供静态文件服务。
* **控制面(Control Plane)**:Server 接收本地上传,或通过受限 Provider 获取 Remote/GitHub 预构建产物;action task 与内部 scanner 负责检查、同步和自动更新。所有产物经统一 inspect 与 `upload.Ingest` 写入平台存储后端;manual 上传创建新的 candidate,持久来源 sync 则 create-or-load deployment 并原子激活。配置发布时只编译稳定的项目锚点与静态服务元数据。
* **数据面(Data Plane)**:Agent 在心跳/WS 对账中发现配置引用的 Pages 项目,通过专属 API 拉取该项目当前激活包并执行校验解压缩。OpenResty 在本地提供静态文件服务;Agent 不感知产物来自上传、Remote、GitHub 或未来 build executor。
---
## 数据模型与元数据设计
### 1. 核心数据库实体
* **Pages 项目 (`pages_projects`)**:
* **Pages 项目 (`of_pages_projects`)**:
* 记录项目的业务名称、Slug 标识(URL 友好型)、启用状态、静态服务根目录(RootDir,可为空)、入口文件名(EntryFile,默认 `index.html`)、SPA Fallback 设置,以及 API 反向代理配置(APIProxyPath, APIProxyPass, APIProxyRewrite)。
* **Pages 部署 (`pages_deployments`)**:
* 记录单次上传生成的不可变快照。包含:部署号 (DeploymentNumber, 递增序列)、SHA-256 Checksum 校验和、部署状态 (uploaded/active)、部署包的本地存储路径、解压后的文件数与总字节数。
* **部署文件清单 (`pages_deployment_files`)**:
* 存储每次部署的完整静态文件树路径与文件大小(来自压缩包声明的未压缩大小),供控制台展示与统计。
* 不再为包内每个文件计算内容哈希;完整性由**整包** SHA-256(`pages_deployments.checksum`)保证,Agent 拉取时只校验整包 hash。
* 控制面 inspect 通过文件句柄 / 随机访问读取归档索引(zip/7z 中央目录;tar 流式读 header 并丢弃 body),避免将整包 `ReadFile` 进内存,也避免逐文件解压算 hash。
* **部署源配置 (`of_pages_project_sources`)**:
* 每个项目最多一条可变来源配置,使用 `source_type` 区分 Remote URL 与 GitHub Release。`config_version` 用于 fence 旧任务;Remote 完整 URL 只保存在配置表中,不会进入响应、日志、任务 payload 或 deployment provenance。V2 不承诺数据库列加密。
* **部署源运行态 (`of_pages_project_source_runtime`)**:
* 与 source 1:1 保存 ETag、seen/applied revision、最近检查/同步、下次检查、错误和 lease。状态固定为 `idle | checking | update_available | syncing | failed | attention`,排队/完成状态由 `TaskExecution` 承担。
* **Pages 部署 (`of_pages_deployments`)**:
* 记录不可变部署事实:项目内递增部署号、整包 SHA-256、`upload_id`、文件数/总字节、创建者,以及可空的 source identity/revision、来源安全快照与 trigger。`artifact_path` 仅为旧数据兼容字段,不再是新部署的存储真相。
* **部署文件清单 (`of_pages_deployment_files`)**:
* 存储每次部署的完整常规文件路径与实际字节数,供控制台展示与统计。
* 不再为包内每个文件计算内容哈希;完整性由**整包** SHA-256(`of_pages_deployments.checksum`)保证,Agent 拉取时校验整包 hash。
* 控制面 inspect 通过文件句柄读取归档,流式消费每个常规文件体并核对声明大小与实际字节,避免将整包 `ReadFile` 进内存,也避免逐文件落盘计算 hash。
### 2. 路由关联与快照
`proxy_routes` 路由规则通过 `upstream_type = "pages"` 及 `pages_project_id` 关联 Pages 项目。当路由类型为 `pages` 且该项目存在已激活的部署时,才允许将该路由加入发布流程。
@@ -87,7 +104,7 @@ graph TD
"api_proxy_path": "/api",
"api_proxy_pass": "http://api.internal:8000",
"api_proxy_rewrite": "/api/(.*) /$1",
"local_root": "__OPENFLARE_PAGES_DIR__/deployments/12/current"
"local_root": "__OPENFLARE_PAGES_DIR__/projects/1/current"
}
```
@@ -98,7 +115,7 @@ graph TD
* Agent 按项目请求「最新激活包」(类似 `github/release/latest`):
* `GET /api/v1/agent/pages/projects/:project_id/latest/hash`
* `GET /api/v1/agent/pages/projects/:project_id/latest/package`
* 控制面根据该项目**当前激活部署**返回哈希与压缩包;Agent 不关心具体 deployment_id。
* 控制面根据该项目**当前激活部署**返回 deployment ID、哈希、包大小与展开清单元数据。Agent 用 deployment ID 与其它 latest 元数据识别下载期间的指针竞态,但主配置和本地目录的稳定锚点仍是 project ID。
* 因此:在项目内切换激活部署后,**不必发布主配置**;Agent 在周期性对账时轮询 latest hash,发现变化即下载并切换 `current`。
* 快照中的 `pages_deployment` 字段仍可记录发布时元数据(入口文件、SPA/API 代理等),但不作为 Agent 拉包的版本锁定。
@@ -107,40 +124,49 @@ graph TD
## Server 端 (控制面) 职责与生命周期
### 1. 部署包安全校验与分析
为了避免不可信的用户上传恶意压缩包攻击服务器,控制面在 `UploadDeployment` 时执行严格校验:
为了避免不可信产物攻击服务器,控制面对本地上传和所有外部来源执行同一套严格校验:
* **格式支持**:`zip`、`tar.gz` / `tgz`、`tar.xz` / `txz`、`tar.bz2` / `tbz2`、`tar`、`7z`。
* **大小限制**:压缩包体积由系统配置 `pages_max_package_size_mb` 控制(默认 100 MiB,范围 1~2048);展开后总体积上限为「包大小 × 4」且不低于 100 MiB(按归档**声明**的未压缩大小累计,默认不流式重读每个文件内容)。
* **大小限制**:压缩包体积由系统配置 `pages_max_package_size_mb` 控制(默认 100 MiB,范围 1~2048);展开后的单文件与总体积上限为「包大小 × 4」且不低于 100 MiB。inspect 始终流式读取常规文件体,核对声明大小与实际字节并按实际值执行上限。
* **数量限制**:压缩包中包含的静态文件总数不得超过 1,000 个。
* **软链接阻断**:遍历归档文件,一旦检测到任何软链接,立即抛出错误并拒绝上传,防御软链接劫持攻击。
* **路径逃逸防御**:对每个压缩文件路径进行 `Clean` 并检查是否包含 `..` 或以 `/` 开头,防御目录跨越漏洞,防止写入系统敏感路径。
* **入口文件校验**:项目指定的入口文件(例如 `index.html`,可在 `project.RootDir` 下)必须在部署包中存在,否则拒绝上传。
* **公共根目录去噪**:许多打包工具会包含一个多余的主文件夹作为公共根前缀。控制面自动探测公共根前缀并将其安全剥离。
* **整包完整性**:上传/导入时对压缩包字节计算一次 SHA-256,写入部署记录;Agent 拉包后按整包 hash 对账。包内单文件不做内容哈希。
* **可选体积实测**:`InspectOptions.VerifySizes` 可对流式统计实际字节并与声明大小比对(仍不算 hash);默认关闭以降低上传 CPU/IO。
* **历史保留**:系统配置 `pages_max_history_count`(默认 20,0 表示不限制)在每次上传成功后执行裁剪。语义为:**每个项目最多保留 N 条部署**;当前激活部署始终保留;其余名额按部署 ID 从新到旧填充;超出的非激活部署连同文件清单与存储对象一并删除。上传已成功时裁剪失败只记日志、不回滚上传;并发上传下可能短暂超过 N,后续上传的裁剪会收敛回 N。主配置版本回滚不依赖旧 Pages 包(见上节双轨关系)。
* **实际体积复核**:`InspectOptions.VerifySizes` 只保留兼容意义;当前 inspect 无论该值为何都会读取常规文件体、核对声明值并累计实际大小,但仍不为单文件计算内容 hash。
* **历史保留**:系统配置 `pages_max_history_count`(默认 20,0 表示不限制)在部署成功后执行裁剪。通常语义为:**每个项目最多保留 N 条部署**;当前激活部署始终保留,其余名额按部署 ID 从新到旧填充。`history_count=1` 时,manual 上传会临时保留 active 与最新 candidate 两条,下一次上传替换旧 candidate;candidate 激活后恢复严格上限。超出的非激活 deployment 与文件清单会删除,对应 upload record 通过平台原语幂等软删除;Pages 不直接物理删除可能被 dedup 共享的 blob。部署已成功时裁剪失败只记日志、不回滚激活;并发操作下可能短暂超过 N,后续裁剪会收敛回 N。主配置版本回滚不依赖旧 Pages 包(见上节双轨关系)。
### 2. 部署包存储规划
控制面通过统一上传框架(`upload.Ingest`)存储原始部署包,并在数据库中记录 `upload_id` 与文件清单。**大体积静态包不写入 config_versions 记录和任何配置推送通道**,以保障控制面数据同步的轻量与高效。
控制面通过统一上传框架(`upload.Ingest`)把本地、Remote 和 GitHub 产物存入配置的本地/S3 后端,并在数据库中记录 `upload_id` 与文件清单。**大体积静态包不写入 config_versions 记录和任何配置推送通道**,以保障控制面数据同步的轻量与高效。
### 3. 来源检查、自动更新与上传补偿
* `openflare:pages_source_action` 执行管理员 check/sync 或 scanner 派发的精确 revision sync;payload 不携带 URL、Token、ETag 或 lease token。手动 sync 只接受真实用户 actor,自动 sync 只接受系统 actor 与 `scheduled_auto_update` trigger。
* `openflare:pages_source_scan` 是固定 `*/5 * * * *` 的 internal-only TaskHandler,只接受 `{}`,不会出现在通用任务类型与排程管理界面。每轮按“恢复过期 lease → 补偿 orphan upload → 扫描到期来源”执行。
* scanner 按 `next_check_at, source_id` 稳定排序,每批最多串行检查 20 个 GitHub latest source;ETag/304 仍推进检查时间,403/429 记录状态码和实际退避截止时间,单来源失败不阻塞后续来源。
* 发现更新总会先保存 seen cursor。只有 `auto_update_enabled=true` 且状态为普通 `update_available` 时,才携带本次检查得到的精确 revision 派发同步;`attention`、Remote 和固定 tag 不会自动发布。人工激活其它 deployment 会 fence 在途任务并关闭 auto。
* orphan 补偿每轮最多检查 100 条至少隔离 2 小时的 upload record,并要求 system owner、Pages 保留 type、V2 marker、无 deployment 引用。候选在 `project → source → runtime → upload` 锁序内复查,只通过上传框架软删除 record 和更新统计,不直接物理删除可能被 dedup 共享的 blob。
---
## Agent 端 (数据落地) 职责与自愈
Agent 运行在各边缘代理节点上,在应用配置版本前,必须先将 Pages 静态资源“原子”地拉取到节点本地。
Agent 运行在各边缘代理节点上:首次应用引用 Pages 项目的配置时,以及后续周期性 latest 对账时,都会把当前激活的静态资源“原子”地拉取到节点本地。
### 1. 按项目拉取 latest
1. Agent 从激活主配置中解析 `UpstreamType == "pages"` 的路由,收集稳定锚点 **`pages_project_id`**。
2. 对每个项目调用 `GET /api/v1/agent/pages/projects/:project_id/latest/hash` 获取控制面当前激活包哈希(类似 latest 指针)。
3. 若本地 `projects/{project_id}/releases/{hash}` 尚未就绪,再下载 `.../latest/package`。下载后 **再次请求 hash** 与包内容 SHA-256 对齐,避免激活切换造成的竞态;不一致则有限次重试。
3. 若本地 `projects/{project_id}/releases/{hash}` 尚未就绪,再把 `.../latest/package` 流式下载到临时文件,执行真实响应上限与 SHA-256;下载后 **再次请求 hash**,避免激活切换造成的竞态,不一致则有限次重试。
4. 请求头携带节点 `X-Agent-Token`。
### 2. 安全解压缩、原子切换与只保留最新
1. 下载字节计算 SHA-256,须与「下载后再次查询」的 latest hash 一致。
2. 解压至 `projects/{project_id}/releases/{hash}.tmp`(支持 zip / tar.* / 7z)。Agent 信任控制面业务校验,仅做路径逃逸/软链防护。
3. 写入 `.openflare-pages.json` 后 rename 为 `releases/{hash}`。
4. **原子切换** `projects/{project_id}/current` 指向新 release(优先 symlink,失败则拷贝)。
5. **仅当新包已就绪且 current 切换成功后**,删除该项目下其它 `releases/*`(含 `.tmp`),**不保留历史部署包**。边缘节点每个项目永远只保留一份最新内容。
6. 多项目对账时 **隔离失败**:单个项目失败记日志并继续其它项目,最后汇总返回错误。
1. 包体绝对上限为 2 GiB;下载内容的 SHA-256 须与「下载后再次查询」的 latest hash 一致,整个包不会进入 `[]byte`。
2. 解压至 `projects/{project_id}/releases/.{hash}-<random>.tmp` 随机 staging 目录(支持 zip / tar.* / 7z),拒绝路径逃逸、链接和特殊文件。Agent 同时服从 Server metadata 上限与本地绝对上限:最多 1,000 个文件,单文件及总量最多 8 GiB。
3. 解压完成后遍历实际文件树,精确复核文件数与总字节是否等于 Server metadata;不一致时拒绝切换。
4. 写入 `.openflare-pages.json` 后 rename 为 `releases/{hash}`。
5. **原子切换** `projects/{project_id}/current` 指向新 release(优先 symlink,失败则拷贝)。
6. **仅当新包已就绪且 current 切换成功后**,删除该项目下其它 `releases/*`(含 `.tmp`),**不保留历史部署包**。边缘节点每个项目永远只保留一份最新内容。
7. 多项目对账时 **隔离失败**:单个项目失败记日志并继续其它项目,最后汇总返回错误。
---
@@ -211,26 +237,23 @@ server {
## 交互逻辑与同步流程
一次完整的 Pages 上传与全局生效的生命周期如下:
一次完整的预构建产物导入与生效生命周期如下。首次绑定项目需要发布主配置;后续 active deployment 变化通过项目 latest 独立收敛:
```text
[ 前端管理员 ] [ Server (控制面) ] [ Agent (数据落地) ] [ OpenResty ]
| | | |
|--- 1. 上传 ZIP 包 ----->| | |
| |--- 2. 安全校验与解压分析 ----| |
| |--- 3. 归档包与持久化清单 ---| |
| | | |
|--- 4. 绑定路由并发布 -->| | |
| |--- 5. 生成新配置版本并广播 ->| |
| | | |
| | |--- 6. 下载 ZIP 部署包 -->|
| | |<-- 7. 返回文件数据 -------|
| | | |
| | |--- 8. 强一致性 Checksum -|
| | |--- 9. 安全解压缩 -------|
| | |--- 10. 原子切换 current -|
| | |--- 11. 测试与重载配置 ---->|
| | |<-- 12. 重载成功 ---------|
| |<-- 13. 上报 Apply Success | |
| | | |
[管理员 / scanner] [Server 控制面] [Agent] [OpenResty]
| | | |
|-- manual 上传 ------>|-- inspect / Ingest ---->| |
| |-- 创建 candidate | |
|-- 显式激活 candidate ->|-- 切换 active | |
| | | |
|-- source sync ------>|-- inspect / Ingest | |
| |-- create/load + 原子激活 | |
| | | |
|-- 首次绑定项目并发布 ->|-- 广播项目锚点 -------->|-- 写入/重载路由 ---------->|
| | | |
|-- 后续激活/同步/回滚 ->|-- active latest 改变 ---| |
| |<-- latest 元数据对账 ----| |
| |--- 流式返回 package ---->| |
| | |-- 校验、解压、复核 --------|
| | |-- 原子切换 current -------->|
```
+5 -3
View File
@@ -15,9 +15,11 @@
| 阻止 | 可创建多个 | 一个或多个 | 无 | HTTP 状态码、HTML 响应体 |
| IP 匹配 | 可创建多个 | 一个或多个 | `true`、`false` | IP、CIDR、IP 组 ID |
| 地域匹配 | 可创建多个 | 一个或多个 | `true`、`false` | 国家代码、地区代码 |
| UA 检查 | 可创建多个 | 一个或多个 | `true`、`false` | 要求携带 UA、浏览器/OS 白名单与 and/or、屏蔽爬虫/非正常 UA(不含爬虫)/自定义正则 |
| 安全防护 | 可创建多个 | 一个或多个 | `true`、`false` | 基础特征检测(路径穿越/文件包含默认开;SQL/XSS/命令注入/SSRF/上传/XXE/CRLF 可开关);命中任一已启用规则为 false |
| PoW | 可创建多个 | 一个或多个 | `next` | 算法、难度、会话 TTL、挑战 TTL |
IP 匹配和地域匹配不区分黑名单或白名单。`true` 只表示请求匹配节点配置,`false` 只表示未匹配;放行或阻止的业务含义完全由连线决定。PoW 验证完成后沿 `next` 继续,未完成时由挑战页面接管当前请求,不产生 `false` 分支。
IP 匹配、地域匹配、UA 检查与安全防护不区分黑名单或白名单。`true` 只表示请求通过该节点判定,`false` 只表示未通过;放行或阻止的业务含义完全由连线决定。UA 检查的求值顺序为:要求携带 UA → 屏蔽爬虫/非正常 UA → 白名单匹配。安全防护在请求 Path/Query/Header/Cookie/Body(有限)上做特征匹配。PoW 验证完成后沿 `next` 继续,未完成时由挑战页面接管当前请求,不产生 `false` 分支。
不在第一阶段实现循环、脚本节点、任意表达式节点、子图调用和跨规则跳转。
@@ -40,7 +42,7 @@ IP 匹配和地域匹配不区分黑名单或白名单。`true` 只表示请求
* 图是有向无环图,禁止自环和任意循环。
* 恰好存在一个开始节点和一个通过节点;阻止节点可以存在多个。
* 开始节点无入边且恰好有一个 `next` 出口;通过和阻止节点无出口。
* IP 匹配与地域匹配的 `true`、`false` 出口必须各连接一次;PoW 的 `next` 必须连接一次。
* IP 匹配、地域匹配、UA 检查与安全防护的 `true`、`false` 出口必须各连接一次;PoW 的 `next` 必须连接一次。
* 除终止节点外不得存在悬空出口;每个非开始节点至少有一条入边。
* 所有节点都必须从开始节点可达,且从每个可执行节点出发都能抵达通过或阻止。
* 边的源端口必须属于源节点类型;同一源端口不得连接多个目标。
@@ -86,7 +88,7 @@ React Flow 编辑页采用全宽画布和固定右侧属性栏:
* 顶部提供返回、规则名称、启用状态、校验状态和保存操作。
* 画布使用紧凑高度和较小的首次适配缩放,支持缩放、平移、框选、删除、自动布局和 MiniMap/Controls 等必要导航能力;节点拖动由 React Flow 本地受控状态实时处理,拖动结束后才把坐标写回编辑图。
* “添加处理单元”提供 IP 匹配、地域匹配、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。
* “添加处理单元”提供 IP 匹配、地域匹配、UA 检查、安全防护、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。
* 选中普通节点或连线后可使用画布删除按钮或 Delete/Backspace 删除;删除节点时同步移除关联连线。
* 右侧属性栏默认隐藏,选中节点后才显示并用于编辑配置;点击连线或画布空白处时收起。
* 地域匹配属性使用完整国家与 ISO 3166-2 一级行政区数据;国家选项同时显示本地化名称与代码,行政区支持按国家名、行政区名或代码搜索,避免一次渲染数千个选项。
+595 -4
View File
@@ -2595,7 +2595,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
"description": "返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限",
"description": "返回管理员可管理的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态;系统内部排程不会暴露,需要管理员权限",
"produces": [
"application/json"
],
@@ -2860,6 +2860,12 @@ const docTemplate = `{
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "定时任务不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "删除定时任务失败",
"schema": {
@@ -3738,6 +3744,12 @@ const docTemplate = `{
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "系统保留类型或存储只读",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
@@ -5556,6 +5568,12 @@ const docTemplate = `{
"description": "统计时间范围(小时)",
"name": "hours",
"in": "query"
},
{
"type": "integer",
"description": "趋势桶分钟数(1、5 或 60,默认 60)",
"name": "bucket_minutes",
"in": "query"
}
],
"responses": {
@@ -8578,7 +8596,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
"description": "从用户提供的 HTTP(S) 链接下载部署包并创建部署记录;服务端使用浏览器伪装请求头拉取,允许内网地址与不安全 TLS 证书,需要管理员权限",
"description": "已弃用的一次性 URL 导入;使用 trusted_internal 策略兼容内网与自签名证书,不创建持久部署源",
"consumes": [
"application/json"
],
@@ -8589,6 +8607,7 @@ const docTemplate = `{
"openflare-pages"
],
"summary": "从 URL 导入 Pages 部署包",
"deprecated": true,
"parameters": [
{
"type": "integer",
@@ -8795,6 +8814,391 @@ const docTemplate = `{
}
}
},
"/api/v1/d/pages/{id}/source": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回项目部署源配置与运行状态,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-pages"
],
"summary": "获取 Pages 部署源",
"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/pages.SourceView"
}
}
}
]
}
},
"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/pages/{id}/source/check": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新",
"produces": [
"application/json"
],
"tags": [
"openflare-pages"
],
"summary": "检查 Pages 部署源",
"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/pages.SourceActionReceipt"
}
}
}
]
}
},
"400": {
"description": "当前来源不支持检查",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "部署源不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "来源任务正在执行",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/pages/{id}/source/delete": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "幂等删除持久部署源;已有部署历史与当前生产部署保持不变",
"produces": [
"application/json"
],
"tags": [
"openflare-pages"
],
"summary": "删除 Pages 部署源",
"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/pages.SourceView"
}
}
}
]
}
},
"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/pages/{id}/source/sync": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "异步下载、校验并原子激活来源部署包;空请求体与空 JSON 对象均有效",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-pages"
],
"summary": "同步并发布 Pages 部署源",
"parameters": [
{
"type": "integer",
"description": "项目 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "同步参数",
"name": "request",
"in": "body",
"schema": {
"$ref": "#/definitions/pages.SourceSyncInput"
}
}
],
"responses": {
"200": {
"description": "任务回执",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/pages.SourceActionReceipt"
}
}
}
]
}
},
"400": {
"description": "参数或来源类型无效",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "部署源不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "来源任务正在执行",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/pages/{id}/source/update": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-pages"
],
"summary": "更新 Pages 部署源",
"parameters": [
{
"type": "integer",
"description": "项目 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "部署源配置",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/pages.SourceUpdateInput"
}
}
],
"responses": {
"200": {
"description": "更新结果",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/pages.SourceUpdateResult"
}
}
}
]
}
},
"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/pages/{id}/update": {
"post": {
"security": [
@@ -12535,6 +12939,12 @@ const docTemplate = `{
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "系统保留类型或存储只读",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
@@ -12729,6 +13139,12 @@ const docTemplate = `{
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "系统保留类型或存储只读",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
@@ -14574,11 +14990,20 @@ const docTemplate = `{
"deployment_id": {
"type": "integer"
},
"file_count": {
"type": "integer"
},
"hash": {
"type": "string"
},
"package_size": {
"type": "integer"
},
"project_id": {
"type": "integer"
},
"total_size": {
"type": "integer"
}
}
},
@@ -16455,6 +16880,9 @@ const docTemplate = `{
"observability.AccessLogOverview": {
"type": "object",
"properties": {
"bucket_minutes": {
"type": "integer"
},
"device_types": {
"type": "array",
"items": {
@@ -16568,9 +16996,15 @@ const docTemplate = `{
"observability.AccessLogView": {
"type": "object",
"properties": {
"bytes_sent": {
"type": "integer"
},
"cache_status": {
"type": "string"
},
"created_at": {
"type": "string"
},
"host": {
"type": "string"
},
@@ -16595,6 +17029,12 @@ const docTemplate = `{
"remote_addr": {
"type": "string"
},
"request_length": {
"type": "integer"
},
"request_time_ms": {
"type": "integer"
},
"status_code": {
"type": "integer"
},
@@ -17290,12 +17730,21 @@ const docTemplate = `{
"project_id": {
"type": "integer"
},
"source_label": {
"type": "string"
},
"source_type": {
"type": "string"
},
"status": {
"type": "string"
},
"total_size": {
"type": "integer"
},
"trigger_type": {
"type": "string"
},
"upload_id": {
"type": "string",
"example": "0"
@@ -17343,6 +17792,144 @@ const docTemplate = `{
}
}
},
"pages.SourceActionReceipt": {
"type": "object",
"properties": {
"action": {
"type": "string"
},
"execution_id": {
"type": "string"
},
"task_id": {
"type": "string"
}
}
},
"pages.SourceRevisionView": {
"type": "object",
"properties": {
"asset_name": {
"type": "string"
},
"label": {
"type": "string"
},
"revision": {
"type": "string"
}
}
},
"pages.SourceSyncInput": {
"type": "object",
"properties": {
"confirmed_revision": {
"type": "string"
}
}
},
"pages.SourceUpdateInput": {
"type": "object",
"properties": {
"allow_insecure": {
"type": "boolean"
},
"asset_name": {
"type": "string"
},
"auto_update_enabled": {
"type": "boolean"
},
"check_interval_minutes": {
"type": "integer"
},
"release_selector": {
"type": "string"
},
"release_tag": {
"type": "string"
},
"remote_url": {
"type": "string"
},
"repository_url": {
"type": "string"
},
"source_type": {
"type": "string"
}
}
},
"pages.SourceUpdateResult": {
"type": "object",
"properties": {
"check_task": {
"$ref": "#/definitions/pages.SourceActionReceipt"
},
"source": {
"$ref": "#/definitions/pages.SourceView"
},
"warning": {
"type": "string"
}
}
},
"pages.SourceView": {
"type": "object",
"properties": {
"allow_insecure": {
"type": "boolean"
},
"asset_name": {
"type": "string"
},
"auto_update_enabled": {
"type": "boolean"
},
"check_interval_minutes": {
"type": "integer"
},
"github_repository": {
"type": "string"
},
"last_applied": {
"$ref": "#/definitions/pages.SourceRevisionView"
},
"last_checked_at": {
"type": "string"
},
"last_error": {
"type": "string"
},
"last_seen": {
"$ref": "#/definitions/pages.SourceRevisionView"
},
"last_synced_at": {
"type": "string"
},
"next_check_at": {
"type": "string"
},
"release_selector": {
"type": "string"
},
"release_tag": {
"type": "string"
},
"remote_url": {
"type": "string"
},
"source_type": {
"type": "string"
},
"sync_status": {
"type": "string"
},
"update_available": {
"type": "boolean"
}
}
},
"pages.UploadFromURLInput": {
"type": "object",
"properties": {
@@ -19310,7 +19897,9 @@ const docTemplate = `{
"block",
"ip_match",
"geo_match",
"pow"
"pow",
"ua_check",
"security_check"
],
"x-enum-varnames": [
"RuleNodeStart",
@@ -19318,7 +19907,9 @@ const docTemplate = `{
"RuleNodeBlock",
"RuleNodeIPMatch",
"RuleNodeGeoMatch",
"RuleNodePoW"
"RuleNodePoW",
"RuleNodeUACheck",
"RuleNodeSecurityCheck"
]
},
"waf.RulePosition": {
+89 -59
View File
@@ -1,86 +1,116 @@
# Pages 静态托管使用
你会学到:如何在 OpenFlare 中使用 Pages 静态托管功能部署前端项目(如 React、Vue 等 SPA 或 VitePress、Hugo 等静态站点),配置单页应用 (SPA) Fallback 路由以及接口反向代理 (API Proxy),并理解不可变部署与 Agent 侧原子切换的底层逻辑。
你会学到:如何通过本地上传、Remote URL 或公开 GitHub Release asset 部署预构建静态站点,配置 SPA Fallback 与 API 反向代理,并安全地检查更新、自动发布和回滚。
---
## 核心机制与工作流
## 核心机制与页面结构
OpenFlare Pages 提供受 Cloudflare Pages 启发的 **Direct Upload (直接上传)** 静态网站托管服务。它与常规代理站点的不同之处在于,数据面的边缘节点 (Agent) 会将静态文件拉取并解压到节点本地,直接通过本地的 OpenResty 提供高性能的静态文件服务,无需维护额外的 Nginx 宿主机静态目录同步。
OpenFlare Pages 受 Cloudflare Pages 的 Direct Upload 与部署历史交互启发,但当前处理的是**预构建产物**,不是仓库源码构建。项目详情按“当前生产部署 → 部署源 → 部署历史”组织:来源配置可以变化,已经创建的 deployment 保持不可变。
```text
[ 管理员 / CI ] ────── 1. 上传 ZIP 压缩包 ──────► [ OpenFlare Server ]
│
[ 访客浏览器 ] ◄────── 4. 访问页面 / 静态资源 ────────── [ Agent 节点 / OpenResty ]
▲
│
2. 检查 Checksum 并拉取 ZIP
3. 解压并原子切换 current 链接
本地上传 ─> 统一校验 / upload.Ingest ─> 新 candidate ─> 管理员显式激活 ─┐
Remote URL ── Server 受限下载 ────────┐ │
GitHub Release asset ─ Server 解析 ───┴─> create/load deployment ─────┤
└─> source sync 原子激活 ────────┘
|
v
Agent 按项目 latest 拉取
|
v
OpenResty 本地静态服务
```
1. **直接上传部署包**:在控制面上传预构建好的网站 `.zip` 压缩包,Server 会生成一条带有唯一 SHA-256 校验和 (Checksum) 的不可变部署记录。
2. **发布与推送**:在路由配置中将源站类型 (Upstream Type) 设为 `Pages 静态托管` 并绑定项目。发布配置版本后,Server 会广播给所有 Agent 节点。
3. **安全拉取与部署**:Agent 节点识别到新配置引用了新的 Pages 部署,增量下载 ZIP 包,校验 Checksum 保证一致性,并在本地解压、完成原子目录切换,重载 OpenResty 使服务生效。
外部 URL、GitHub 元数据和自动检查都只由 Server 处理。Agent 只从控制面拉取当前激活的部署包,不接收外部来源凭据,也不执行 `git clone`、依赖安装或构建命令。
---
## 第一步:创建项目
## 第一步:上传部署包与创建 Pages 项目
1. 登录管理端,进入 **「Pages」**,点击 **「创建项目」**。
2. 填写项目名称与唯一 Slug。
3. 配置内容入口:
* **入口文件名**:默认 `index.html`。
* **静态资源根路径(RootDir)**:产物位于 `dist/` 等子目录时填写该相对路径;产物就在归档根目录时留空。
4. 按需设置 SPA Fallback 与 API 代理。RootDir 和入口文件是项目级配置,会统一应用于所有来源。
1. 登录管理端控制面板,进入左侧导航 **「Pages」** 菜单,点击 **「创建项目」**。
2. 填写项目基本信息:
* **项目名称**:业务名称(如 `我的前端应用`)。
* **项目标识 (Slug)**:URL 友好的唯一英文标识(如 `my-react-app`),将作为存储目录的文件夹名。
3. 设定站点目录结构与入口:
* **入口文件名**:默认为 `index.html`。
* **静态资源根路径 (RootDir)**:如果你的打包产物在压缩包的子目录下(例如打包出来的 zip 里包含一个 `dist/` 目录),则需要在这里填入子路径(如 `dist`)。若打包产物直接在 zip 根目录,留空即可。
4. **上传 ZIP 压缩包**:
* 上传你的项目静态资源打包生成的 `.zip` 文件。
## 第二步:选择部署源
> [!IMPORTANT]
> **部署包安全限制规范**
> 为了保障控制面和边缘节点的系统安全与性能,上传的部署包必须满足以下硬性指标,否则会被系统拒绝:
> * **大小限制**:ZIP 压缩包体积不得超过 **25 MiB**,解压后的总文件大小不得超过 **100 MiB**。
> * **数量限制**:解压后的文件总数不得超过 **1,000 个**。
> * **软链接拦截**:ZIP 包内禁止包含任何软链接 (Symbolic Link),防御软链接劫持攻击。
> * **Zip-Slip 防御**:压缩包中所有文件路径会被强制规范化,禁止使用 `..` 或以 `/` 开头,防止解压路径穿越攻击。
> * **入口文件检查**:你指定的入口文件(在静态资源根路径下,如 `dist/index.html`)**必须在压缩包中存在**。
### 1. 手动上传
---
不配置持久来源时,项目保持手动模式。点击 **「上传部署包」** 选择预构建归档;上传成功会创建一条候选 deployment,再从部署历史中显式激活。重复上传不会修改已有 deployment。
## 第二步:配置高级路由规则
支持 `zip`、`tar.gz` / `tgz`、`tar.xz` / `txz`、`tar.bz2` / `tbz2`、`tar` 与 `7z`。
在项目详情的配置页面中,你可以根据前端项目类型开启以下高级特性:
### 2. Remote URL
### 1. 单页应用 (SPA) Fallback 路由
对于使用 React Router、Vue Router 等进行前端路由的单页应用 (SPA),当用户直接刷新类似 `/profile/settings` 的子路径时,边缘节点本地并不存在该物理文件,会导致 404 错误。
* **配置方式**:在项目设置中开启 **「SPA Fallback」**,并将路径设为入口文件(如 `/index.html`)。
* **生效逻辑**:开启后,如果访客请求的静态资源在物理上不存在,OpenResty 会自动降级重定向渲染入口文件,将路由交由前端 JavaScript 接管,避免 404 报错。
在部署源卡片中选择 **Remote URL**,填写 HTTP(S) 地址并选择网络策略:
### 2. 内置 API 反向代理
为了避免前端请求后端 API 时遭遇跨域 (CORS) 限制,Pages 托管支持在同一个域名下直通后端 API。
* **配置方式**:
* **API 代理路径 (APIProxyPath)**:匹配的 URL 前缀(如 `/api`)。
* **后端服务地址 (APIProxyPass)**:后端 API 的源站地址(如 `http://10.0.0.5:8080`)。
* **重写规则 (APIProxyRewrite)**:可选。如果需要剥离前缀或重写路径,可使用正则匹配。例如:
* 剥离前缀:将请求 `/api/users` 重写为 `/users` 发送给后端,配置为 `^/api/(.*)$ /$1`。
* **生效逻辑**:所有以 `/api` 开头的请求会被直接转发至后端服务,而其他请求则继续由静态托管服务处理。
* **public**:默认策略,拒绝 loopback、私网、链路本地地址、DNS rebinding、自签 TLS,以及重定向到非公网目标。
* **trusted_internal**:仅用于明确受信的内网或自签服务;保存前需要再次确认风险。
---
保存后地址只以脱敏形式展示。编辑其它配置时无需重新填写;只有选择更换地址时才提交新 URL。Remote 来源只提供 **「同步并发布」**:每次由 Server 下载、校验并原子激活,不支持“检查更新”、定时检查或自动更新。
## 第三步:绑定代理路由并发布
### 3. GitHub Release
Pages 项目配置并上传好部署包后,需要绑定到对外公开的域名上才能被访客访问。
GitHub 来源仅支持公开 `github.com` 仓库。填写:
1. 导航至左侧菜单 **「规则管理」**,创建或编辑一条代理规则。
2. 切换到 **「反向代理」** 选项卡:
* **源站类型**:选择 **「Pages」**。
* **选择 Pages 项目**:选择你刚才创建的项目,并关联要激活的部署版本(默认会自动关联最新上传成功的部署)。
3. 点击右上角 **「配置预览」** -> 确认无误后点击 **「发布并激活」**。
* `https://github.com/{owner}/{repo}` 格式的仓库地址;
* **最新 Release** 或 **固定 Tag**;
* 精确、区分大小写的 Release Asset 文件名,默认 `dist.zip`。
## 运维与回滚
两种选择都可手动 **「检查更新」** 和 **「同步并发布」**。区别如下:
* **不可变部署与回滚**:每次在 Pages 项目下上传 `.zip` 文件,系统都会产生一个全新且唯一的部署版本。如果在历史部署列表中将上一版本设为激活并重新发布,可实现边缘节点的秒级回滚。
* **原子切换与自愈**:边缘节点(Agent)在拉取静态资源包时,会执行校验与流式解压,并通过原子切换物理目录来保障服务的无缝过渡。同时,Agent 会定时清理不再引用的历史部署包。
* **latest**:可设置 5~1440 分钟检查间隔,默认 60 分钟;自动更新默认关闭。开启后,scanner 发现新 revision 才会异步同步并发布。
* **tag**:只支持管理员手动检查和同步,不参与定时 scanner。
“检查更新”只解析 Release/asset 并更新版本游标,不下载部署包;“同步并发布”才会下载、校验、创建或复用 deployment 并激活。如果同一个 Release 下的 asset 被替换,来源会进入 **「需要确认」**,必须确认页面显示的精确 revision 后才能发布,避免静默覆盖。
GitHub Release 在这里是预构建产物源,不等同于连接代码仓库自动构建。未来仓库集成会使用独立的 `git_repository` 来源和 Server build executor,再把构建产物送入同一部署管线。
### 4. 切换或删除来源
可以在手动、Remote 和 GitHub Release 之间切换。修改或删除来源不会删除当前生产部署和历史 deployment;切回手动模式后可继续上传并显式激活。
## 部署包安全限制
部署包必须满足以下约束:
* 压缩包大小由系统配置 `pages_max_package_size_mb` 控制,默认 100 MiB,可配置 1~2048 MiB。
* 展开后的单文件和总量上限为“包大小上限 × 4”,且最低为 100 MiB;最多 1,000 个常规文件。
* 控制面会流式读取常规文件体,核对声明大小与实际字节,并校验项目入口文件。
* 归档中的绝对路径、`..` 路径逃逸、软链接、硬链接和特殊文件都会被拒绝。
Agent 下载时还会执行 SHA-256、真实响应字节上限、解压后文件数与总大小复核;失败不会切换现有 `current`。
## 第三步:配置高级路由规则
### 1. SPA Fallback
使用 React Router、Vue Router 等前端路由时,开启 **「SPA Fallback」** 并设置入口路径(通常为 `/index.html`)。访客直接访问不存在的物理路径时,OpenResty 会回退到入口文件交由前端路由处理。
### 2. API 反向代理
Pages 可在同一域名下把指定前缀转发到后端 API:
* **APIProxyPath**:匹配前缀,例如 `/api`。
* **APIProxyPass**:后端地址,例如 `http://10.0.0.5:8080`。
* **APIProxyRewrite**:可选的路径重写规则。
匹配 API 前缀的请求走反向代理,其余请求继续由静态站点处理。
## 第四步:绑定路由并首次发布
1. 创建或编辑一条代理规则。
2. 将源站类型设为 **Pages**,并选择 Pages **项目**。
3. 预览配置后发布并激活。
路由绑定的是稳定的项目 ID,不是某个 deployment。首次发布让 Agent 获得项目锚点;此后本地上传、来源同步、自动更新或人工回滚只会改变项目的 active deployment,Agent 会通过 latest hash 对账收敛,无需重新发布主配置。
## 运维、状态与回滚
* 来源卡片展示最近检查/同步、已发现与已应用 revision、下次检查和安全错误。检查或同步任务运行时,页面会轮询任务状态;latest 空闲时只在接近检查时间时低频刷新。
* 自动更新失败不会替换旧 active deployment;单个来源失败也不会阻塞 scanner 处理其它项目。
* 在部署历史中激活其它 deployment 即完成人工回滚。系统会 fence 在途来源任务,并关闭该来源的自动更新,避免下一轮 latest 又覆盖人工选择;重复激活当前版本是 no-op。
* Agent 下载到临时文件并校验 SHA-256,安全解压后原子切换 `current`。任一步失败都保留旧内容,多项目对账时单项目失败不影响其它项目。
> [!TIP]
> 关于不可变部署、目录结构设计、增量拉取和安全防逃逸校验等底层架构与自愈细节,请参阅 [Pages 静态托管设计](../design/pages-design.md)。
> 关于来源状态机、自动 scanner、上传补偿、不可变部署和 Agent 原子切换,请参阅 [Pages 静态托管设计](../design/pages-design.md)。
+1
View File
@@ -189,6 +189,7 @@ 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 \
ghcr.io/rain-kl/openflare-agent:latest
@@ -0,0 +1,709 @@
# 边缘限流全局默认 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:** 为边缘限流增加三项全局默认;站点 `0`/空继承默认、`-1` 显式关闭、`>0` 覆盖;在 `RenderRouteConfig` 唯一合并。
**Architecture:** 全局默认存 `system_configs`,进入 `openresty_config` 快照;站点字段语义变更后仍原样入库与快照;`pkg/render/openresty.RenderRouteConfig` 用 `doc.OpenRestyConfig` 与 route 字段合并后输出 location 指令。UI:安全性下新页「限流」+ 站点限流文案更新。
**Tech Stack:** Go、goose SQL、Option API、`pkg/render/openresty`、Next.js、OptionService
**Spec:** [docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md](../specs/2026-07-19-http-default-rate-limit-design.md)
## Global Constraints
- 合并**只**在 `RenderRouteConfig`;快照保留站点原始值(含 `0`/`-1`)
- 不引入 `limit_req`;不在 `http {}` 写默认 `limit_conn`/`limit_rate`
- 全局默认初始 `0`/空 → 存量行为不变
- 完成后 `make code-check`;改前端后 `make prettier`;中文 changelog;不写英文文档
- 所有 HTTP 路由仍只在 `internal/router/router.go` 委派(本功能复用 Option API,无需新业务路由)
## File map
| 文件 | 职责 |
|------|------|
| `internal/model/system_configs.go` | 三个 ConfigKey 常量 |
| `internal/db/migrator/goose/{postgres,sqlite}/202607190001_add_openresty_default_rate_limits.sql` | seed 默认值 |
| `internal/apps/openflare/option/openresty_validators.go` + `validate.go` | 全局默认校验 |
| `internal/apps/openflare/config_version/snapshot.go` | 快照字段 + 读取 |
| `internal/apps/openflare/config_version/logics.go` | option diff keys |
| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 |
| `pkg/render/openresty/render.go` | `mergeRouteLimit*` + 调用点 |
| `pkg/render/openresty/render_test.go` | 合并渲染单测 |
| `internal/apps/openflare/proxy_route/helpers.go` | 站点 normalize 允许 -1 |
| `frontend/lib/navigation/openflare-nav.ts` | 安全性子菜单 |
| `frontend/app/(main)/rate-limits/page.tsx` | 全局限流设置页 |
| `frontend/app/(main)/proxy-routes/.../limits-section.tsx` + helpers | 站点语义 UI |
| `frontend/lib/utils/search-data.ts` | 搜索入口 |
| `docs/reference/configuration.md` | 配置键说明 |
| `docs/changelog/index.md` | Unreleased |
| `docs/plan/index.md` | 进行中计划索引 |
---
### Task 1: Render 合并(TDD 核心)
**Files:**
- Modify: `pkg/render/openresty/types.go` (`ConfigSnapshot`)
- Modify: `pkg/render/openresty/render.go`
- Test: `pkg/render/openresty/render_test.go`
**Interfaces:**
- Produces: `ConfigSnapshot` 字段 `DefaultLimitConnPerServer int`, `DefaultLimitConnPerIP int`, `DefaultLimitRate string`(json: `default_limit_conn_per_server` 等)
- Produces: `mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig`
- Produces: `mergeLimitConn(route, def int) int`, `mergeLimitRate(route, def string) string`
- [ ] **Step 1: 写失败单测**
在 `render_test.go` 末尾追加:
```go
func TestMergeRouteLimitConfig(t *testing.T) {
t.Parallel()
cases := []struct {
name string
route Route
cfg ConfigSnapshot
want routeLimitConfig
}{
{
name: "both zero off",
route: Route{},
cfg: ConfigSnapshot{},
want: routeLimitConfig{},
},
{
name: "inherit all defaults",
route: Route{},
cfg: ConfigSnapshot{
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
},
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k"},
},
{
name: "explicit off ignores default",
route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1"},
cfg: ConfigSnapshot{
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
},
want: routeLimitConfig{},
},
{
name: "route overrides default",
route: Route{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
cfg: ConfigSnapshot{
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
},
want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
},
{
name: "partial inherit",
route: Route{LimitConnPerServer: 0, LimitConnPerIP: -1, LimitRate: ""},
cfg: ConfigSnapshot{
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "256k",
},
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := mergeRouteLimitConfig(tc.route, tc.cfg)
if got != tc.want {
t.Fatalf("mergeRouteLimitConfig() = %#v, want %#v", got, tc.want)
}
})
}
}
func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
doc := Document{
Routes: []Route{{
SiteName: "example.com",
Domains: []string{"example.com"},
Enabled: true,
OriginURL: "http://127.0.0.1:8080",
Upstreams: []string{"http://127.0.0.1:8080"},
}},
OpenRestyConfig: ConfigSnapshot{
DefaultLimitConnPerServer: 120,
DefaultLimitConnPerIP: 12,
DefaultLimitRate: "512k",
},
}
rendered, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatalf("RenderRouteConfig() error = %v", err)
}
for _, want := range []string{
"limit_conn openflare_conn_per_server 120;",
"limit_conn openflare_conn_per_ip 12;",
"limit_rate 512k;",
} {
if !strings.Contains(rendered, want) {
t.Fatalf("expected %q in route config, got:\n%s", want, rendered)
}
}
}
func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) {
doc := Document{
Routes: []Route{{
SiteName: "example.com",
Domains: []string{"example.com"},
Enabled: true,
OriginURL: "http://127.0.0.1:8080",
Upstreams: []string{"http://127.0.0.1:8080"},
LimitConnPerServer: -1,
LimitConnPerIP: -1,
LimitRate: "-1",
}},
OpenRestyConfig: ConfigSnapshot{
DefaultLimitConnPerServer: 120,
DefaultLimitConnPerIP: 12,
DefaultLimitRate: "512k",
},
}
rendered, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatalf("RenderRouteConfig() error = %v", err)
}
if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") {
t.Fatalf("expected no limit directives, got:\n%s", rendered)
}
}
```
- [ ] **Step 2: 跑测确认失败**
```bash
go test ./pkg/render/openresty/ -run 'TestMergeRouteLimitConfig|TestRenderRouteConfigAppliesDefaultLimits|TestRenderRouteConfigExplicitOffSkipsDefaultLimits' -count=1
```
Expected: FAIL(`mergeRouteLimitConfig` undefined 或行为不符)
- [ ] **Step 3: 实现 types + merge + 调用**
`ConfigSnapshot` 增加:
```go
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
```
`render.go` 中 `RenderRouteConfig` 将:
```go
limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate}
```
改为:
```go
limitConfig := mergeRouteLimitConfig(route, doc.OpenRestyConfig)
```
并新增:
```go
func mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig {
return routeLimitConfig{
LimitConnPerServer: mergeLimitConn(route.LimitConnPerServer, cfg.DefaultLimitConnPerServer),
LimitConnPerIP: mergeLimitConn(route.LimitConnPerIP, cfg.DefaultLimitConnPerIP),
LimitRate: mergeLimitRate(route.LimitRate, cfg.DefaultLimitRate),
}
}
func mergeLimitConn(route, def int) int {
if route == -1 {
return 0
}
if route > 0 {
return route
}
if def > 0 {
return def
}
return 0
}
func mergeLimitRate(route, def string) string {
r := strings.ToLower(strings.TrimSpace(route))
if r == "-1" {
return ""
}
if r != "" && r != "0" {
return r
}
d := strings.ToLower(strings.TrimSpace(def))
if d != "" && d != "0" {
return d
}
return ""
}
```
- [ ] **Step 4: 跑测通过**
```bash
go test ./pkg/render/openresty/ -count=1
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add pkg/render/openresty/types.go pkg/render/openresty/render.go pkg/render/openresty/render_test.go
git commit -m "feat(openresty): merge global default limits at route render"
```
---
### Task 2: 配置键、迁移、校验、快照
**Files:**
- Modify: `internal/model/system_configs.go`
- Create: `internal/db/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql`
- Create: `internal/db/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql`
- Modify: `internal/apps/openflare/option/validate.go`
- Modify: `internal/apps/openflare/option/openresty_validators.go`
- Modify: `internal/apps/openflare/config_version/snapshot.go`
- Modify: `internal/apps/openflare/config_version/logics.go`
**Interfaces:**
- Consumes: Task 1 的 `ConfigSnapshot` JSON 字段名
- Produces: `ConfigKeyOpenRestyDefaultLimitConnPerServer` 等三常量;snapshot 填充;diff 可见
- [ ] **Step 1: 常量**
在 `system_configs.go` OpenResty 段末尾(`MainConfigTemplate` 前或后)加入:
```go
ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
```
- [ ] **Step 2: goose 迁移(PG + SQLite 同内容)**
```sql
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('openresty_default_limit_conn_per_server', '0', 'business', 0, '默认站点并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('openresty_default_limit_conn_per_ip', '0', 'business', 0, '默认单 IP 并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('openresty_default_limit_rate', '', 'business', 0, '默认单请求带宽限速(空关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN (
'openresty_default_limit_conn_per_server',
'openresty_default_limit_conn_per_ip',
'openresty_default_limit_rate'
);
```
SQLite:若项目其它 seed 不用 `ON CONFLICT`,对照 `202607170001_add_pages_system_configs.sql` 的 sqlite twin 写法保持一致(通常可同用 `ON CONFLICT (key) DO NOTHING`)。
- [ ] **Step 3: 校验器**
`validate.go` 增加:
```go
func validateNonNegativeIntegerOption(key, value string) error {
intValue, err := strconv.Atoi(value)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
}
return nil
}
```
`openresty_validators.go` 注册:
```go
model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption,
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
```
```go
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
func validateOpenRestyDefaultLimitRate(key, trimmed string) error {
if trimmed == "" || trimmed == "0" {
return nil
}
if !openRestyDefaultLimitRatePattern.MatchString(strings.ToLower(trimmed)) {
return fmt.Errorf("%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", key)
}
return nil
}
```
- [ ] **Step 4: 快照读取(注意 0 合法)**
`openRestyConfigSnapshot` 与 `buildOpenRestyConfigSnapshot` 增加三字段。
**禁止**对这三项使用现有 `getIntConfig`(其 `val <= 0` 会把合法 `0` 与错误混在一起;虽 default=0 时偶然正确,但语义不清)。改为:
```go
getNonNegIntConfig := func(key string, defaultVal int) int {
val, err := repository.GetIntByKey(ctx, key)
if err != nil || val < 0 {
return defaultVal
}
return val
}
```
```go
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
```
若 `DefaultLimitRate == "0"`,规范化为 `""`。
确认 snapshot → render JSON 字段名与 `openrestyrender.ConfigSnapshot` 一致(`snapshotDocument` 序列化后由 `RenderJSON` 反序列化到 render types)。`openRestyConfigSnapshot` 的 json tag 必须与 `ConfigSnapshot` 对齐:
```go
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
```
- [ ] **Step 5: option diff**
在 `diffOpenRestyOptionDetails` 末尾:
```go
appendIfChanged("OpenRestyDefaultLimitConnPerServer", fmt.Sprintf("%d", left.DefaultLimitConnPerServer), fmt.Sprintf("%d", right.DefaultLimitConnPerServer))
appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP))
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
```
`openRestyOptionKeys()` 同步追加这三 key 字符串。
- [ ] **Step 6: 编译/相关测试**
```bash
go test ./internal/apps/openflare/config_version/ ./internal/apps/openflare/option/ ./pkg/render/openresty/ -count=1
```
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add internal/model/system_configs.go \
internal/db/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql \
internal/db/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql \
internal/apps/openflare/option/validate.go \
internal/apps/openflare/option/openresty_validators.go \
internal/apps/openflare/config_version/snapshot.go \
internal/apps/openflare/config_version/logics.go
git commit -m "feat(config): add openresty default rate limit system options"
```
---
### Task 3: 站点 normalize 允许 -1
**Files:**
- Modify: `internal/apps/openflare/proxy_route/helpers.go`
- Modify: `internal/apps/openflare/proxy_route/errs.go`(如需更新文案)
- Test: 若无现成 helpers 测试文件则新建 `helpers_limit_test.go`
**Interfaces:**
- Produces: `normalizeProxyRouteLimitConnValue` 允许 `>= -1`;`normalizeProxyRouteLimitRate` 允许 `"-1"`
- [ ] **Step 1: 失败单测**
```go
func TestNormalizeProxyRouteLimitConnValue(t *testing.T) {
t.Parallel()
got, err := normalizeProxyRouteLimitConnValue(-1, "limit_conn_per_server")
if err != nil || got != -1 {
t.Fatalf("want -1, got %d err %v", got, err)
}
if _, err := normalizeProxyRouteLimitConnValue(-2, "limit_conn_per_server"); err == nil {
t.Fatal("expected error for -2")
}
}
func TestNormalizeProxyRouteLimitRate(t *testing.T) {
t.Parallel()
got, err := normalizeProxyRouteLimitRate("-1")
if err != nil || got != "-1" {
t.Fatalf("want -1, got %q err %v", got, err)
}
got, err = normalizeProxyRouteLimitRate("0")
if err != nil || got != "" {
t.Fatalf("want empty inherit, got %q err %v", got, err)
}
}
```
- [ ] **Step 2: 实现**
```go
func normalizeProxyRouteLimitConnValue(value int, field string) (int, error) {
if value < -1 {
return 0, fmt.Errorf("%s must be greater than or equal to -1", field)
}
return value, nil
}
func normalizeProxyRouteLimitRate(raw string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
if normalized == "" || normalized == "0" {
return "", nil
}
if normalized == "-1" {
return "-1", nil
}
if !proxyRouteLimitRatePattern.MatchString(normalized) {
return "", errors.New(errProxyRouteLimitRate)
}
if strings.TrimRight(normalized, "km") == "" {
return "", nil
}
return normalized, nil
}
```
可选:`errProxyRouteLimitRate` 文案追加「或 -1 表示关闭」。
- [ ] **Step 3: 测试**
```bash
go test ./internal/apps/openflare/proxy_route/ -count=1
```
- [ ] **Step 4: Commit**
```bash
git add internal/apps/openflare/proxy_route/
git commit -m "feat(proxy-route): allow -1 to disable rate limits"
```
---
### Task 4: 前端 — 安全性「限流」页 + 站点文案
**Files:**
- Modify: `frontend/lib/navigation/openflare-nav.ts`
- Create: `frontend/app/(main)/rate-limits/page.tsx`
- Modify: `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx`
- Modify: `frontend/app/(main)/proxy-routes/components/helpers.ts`
- Modify: `frontend/lib/utils/search-data.ts`
**Interfaces:**
- Consumes: Option keys 字面量 `openresty_default_limit_conn_per_server` 等
- Produces: `/rate-limits` 管理页;站点表单接受 `-1`
- [ ] **Step 1: 导航**
`openflareSecurityNavGroup.items`:
```ts
{ title: 'WAF', url: '/waf' },
{ title: 'IP 组', url: '/ip-groups' },
{ title: '限流', url: '/rate-limits' },
```
- [ ] **Step 2: 搜索**
`search-data.ts` 在 IP 组后增加:
```ts
{
id: 'console-rate-limits',
title: '限流',
description: '配置边缘站点默认并发与带宽限流策略',
url: '/rate-limits',
category: 'page',
keywords: ['限流', 'rate limit', 'limit_conn', 'limit_rate', '并发', '带宽'],
},
```
- [ ] **Step 3: 限流设置页**
新建 `frontend/app/(main)/rate-limits/page.tsx`,模式对齐 `performance/page.tsx`:
- `useAuth` 管理员校验
- `OptionService.list` / `updateBatch`
- 三字段表单 + 单卡片保存
- 标题:`Shield` 或 `Gauge` 图标 + `h1`「限流」
- 描述:空/0 表示默认关闭;修改后需在版本发布中生效
- keys:
- `openresty_default_limit_conn_per_server`
- `openresty_default_limit_conn_per_ip`
- `openresty_default_limit_rate`
- conn:非负整数;rate:空或 `^\d+[kKmM]?$`
- 保存成功 toast + invalidate options / config-preview / config-versions
- 链到 `/config-versions`
页面骨架要点(完整实现时展开为完整组件,勿留半成品):
```tsx
// 字段 state、OptionService.list map、updateBatch([{key,value},...])
// 文案:「0 或空表示默认关闭;站点未单独配置时继承此处设置。」
```
- [ ] **Step 4: 站点 limits-section**
1. schema:conn 允许空、`0`、`-1`、正整数:
```ts
if (!rawValue) continue;
if (!/^-1$|^\d+$/.test(rawValue)) {
context.addIssue({ ..., message: '请输入 -1、0 或正整数' });
}
```
2. `validateLimitRate` / `normalizeLimitRate`:
```ts
export function validateLimitRate(value: string) {
const normalized = value.trim();
if (!normalized || normalized === '0' || normalized === '-1') {
return null;
}
if (!limitRatePattern.test(normalized)) {
return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭';
}
return null;
}
export function normalizeLimitRate(value: string) {
const normalized = value.trim().toLowerCase();
if (normalized === '0') return '';
return normalized; // 保留 -1
}
```
3. 表单展示:`-1` 需显示为 `'-1'`(注意 `route.limit_conn_per_server ? String : ''` 对 `-1` 已为 truthy;对 `0` 仍为空)
4. 提交:空 → `0`;`-1` → `-1`;正数 → 数字
5. 文案:
```
description='站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。'
FormDescription 同步说明
```
6. 侧栏「流量限制」section description 可改为:`设置连接数和限速(可继承全局默认)。`
- [ ] **Step 5: prettier + 类型检查(按项目习惯)**
```bash
make prettier
# 若有前端 typecheck:
# cd frontend && pnpm exec tsc --noEmit
```
- [ ] **Step 6: Commit**
```bash
git add frontend/lib/navigation/openflare-nav.ts \
frontend/app/\(main\)/rate-limits/ \
frontend/app/\(main\)/proxy-routes/detail/components/limits-section.tsx \
frontend/app/\(main\)/proxy-routes/components/helpers.ts \
frontend/lib/utils/search-data.ts
git commit -m "feat(frontend): add security rate-limits page and inherit UI"
```
---
### Task 5: 文档、索引、门禁
**Files:**
- Modify: `docs/reference/configuration.md`(OpenResty 配置表)
- Modify: `docs/changelog/index.md` `[unreleased]`
- Modify: `docs/plan/index.md`
- [ ] **Step 1: configuration.md**
在 `openresty_cache_use_stale` 与 `openresty_main_config_template` 之间插入:
```md
| `openresty_default_limit_conn_per_server` | `int` | 站点未配置时的默认并发连接上限;`0` 表示默认关闭 | `0` |
| `openresty_default_limit_conn_per_ip` | `int` | 站点未配置时的默认单 IP 并发上限;`0` 表示默认关闭 | `0` |
| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 |
```
- [ ] **Step 2: changelog**
`[unreleased]` 下:
```md
### 新增
- 安全性新增「限流」设置:可为边缘站点配置默认并发与带宽;站点未设置时继承,填 `-1` 可显式关闭。
### 改进
- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。
```
- [ ] **Step 3: plan index**
`docs/plan/index.md` 进行中列表增加:
```md
* [边缘限流全局默认](../superpowers/plans/2026-07-19-http-default-rate-limit.md):http/全局默认限流,站点 0 继承、-1 关闭。
```
- [ ] **Step 4: 全量门禁**
```bash
make code-check
make prettier
```
Expected: 通过;修复任何报错后再提交。
- [ ] **Step 5: Commit**
```bash
git add docs/reference/configuration.md docs/changelog/index.md docs/plan/index.md
git commit -m "docs: document default edge rate limits"
```
---
## Spec coverage checklist
| Spec 要求 | Task |
|-----------|------|
| 三项全局默认 | 2, 4 |
| 0/空继承、-1 关、>0 覆盖 | 1, 3, 4 |
| 仅 `RenderRouteConfig` 合并 | 1 |
| 快照保留原始站点值 | 2(不写回 route) |
| 安全性子页「限流」 | 4 |
| 初始 0/空兼容 | 2 seed |
| option diff / 发布 | 2 |
| 测试合并/normalize | 1, 3 |
| 中文文档/changelog | 5 |
| 非目标 limit_req / http 级指令 | 未做 |
## 手动验收
1. 迁移后三键存在且为 `0`/空
2. 安全性 → 限流 设置 `120` / `12` / `512k` 并保存
3. 版本发布预览:未配置站点的 location 出现对应 `limit_conn`/`limit_rate`
4. 站点将该项改为 `-1` 保存并发布:该维度指令消失
5. 站点改为 `50`:输出 50 而非全局值
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
# WAF IP 匹配:Radix / lua-resty-ipmatcher
## 1. 目标与背景 (Goal & Context)
* **需求背景**:`ip_match` 对 IP 组 `ip_list` 做线性扫描,且每行强制 `ipv6_equal` + `ip_in_cidr`,大名单(订阅/自动规则可达万~十万级)时压测 RPS 约 65、OpenResty CPU 打满。
* **开发范围 (Scope)**:
* **必做**:边缘热路径改为预处理索引 + O(W) 查询;IP 组快照加载时编译;节点内联 `ips`/`cidrs` 同样编译;Agent 镜像安装 `lua-resty-ipmatcher`;规格与 changelog。
* **Out of Scope**:控制面协议变更、改 IP 组存储格式、Geo 匹配优化。
## 2. 设计与决策 (Design & Decisions)
* **选型**:OpenResty 使用 `resty.ipmatcher`(底层 Radix,支持 IP 与 CIDR 统一;可用 `match_bin(binary_remote_addr)`)。
* **编译时机**:
* IP 组:`waf.ip_groups` 采纳新快照时为每组 `ip_list` 建 matcher,挂到 `group._matcher`。
* 节点 `ips`/`cidrs`:首次匹配时合并列表建 matcher,用 weak 缓存或按 config 引用缓存。
* **回退**:`require("resty.ipmatcher")` 失败时用纯 Lua「exact set + 预解析 CIDR」回退(测试 / 未装 opm 的本地 OpenResty),避免回归到每行 IPv6 全解析。
* **不引入**:手写纯 Lua 十万节点 table 树作为生产主路径(内存与 GC 差)。
## 3. 具体修改文件清单 (Proposed Changes)
### 边缘 Agent 与 OpenResty
* #### [MODIFY] `docker/Dockerfile.agent`
* **不**通过 OPM 安装 ipmatcher(`api7` 账号在 OPM 不存在)。
* #### [NEW] `internal/apps/agent/nginx/resty/ipmatcher.lua`(vendor api7 v0.6.1)
* 随 `ManagedWAFLuaFiles` 部署到 `<luaDir>/resty/ipmatcher.lua`,由 `lua_package_path` 加载。
* #### [MODIFY] `internal/apps/agent/nginx/waf_runtime.lua`
* 编译/查询 helper;重写 `matches_ip_values`。
* #### [MODIFY] `internal/apps/agent/nginx/waf_ip_groups.lua`
* 无需在刷新模块内编译;快照采纳后由 `waf.runtime` 惰性编译 `group._matcher`。
* #### [MODIFY] `internal/apps/agent/nginx/waf_runtime_spec.lua` / `waf_ip_groups_spec.lua`
* 覆盖 exact/CIDR/IPv6/组 miss;大名单语义 smoke。
* #### [MODIFY] `docs/changelog/index.md`、相关设计/plan 备注
## 4. 验证计划 (Verification Plan)
* `go test ./internal/apps/agent/nginx/ -count=1`
* 重建 Agent 镜像后压测:三组大名单 miss 路径 CPU/RPS 对比。
+5
View File
@@ -19,6 +19,11 @@
* [访问日志 cache_status 明细可见](./20260718-access-log-cache-status.md):上报 `$upstream_cache_status`,明细展示命中/回源/未缓存三态。
* [边缘缓存默认 static 策略](./20260718-edge-cache-static-default.md):开启缓存默认仅静态扩展名;存量 url→all。
* [访问日志 IP 明细 Tab](./20260719-access-log-ip-tab.md):第三 Tab 按 IP 聚合列表(时间窗/流量/2xx 比例);IP 情报迁入独立详情;日志详情仅请求字段。
* [边缘限流全局默认](./20260719-http-default-rate-limit.md):全局默认并发/带宽;站点 0 继承、-1 关闭;RenderRouteConfig 合并。
## 已完成的计划
* [Pages 项目部署源与 GitHub Releases 自动更新 V2](./20260719-pages-source-sync-v2.md):已完成 Remote URL / GitHub Release 来源、不可变部署、自动检查更新与安全回滚,并预留独立仓库构建 Provider 边界;生产环境验收边界见计划内验证记录。
## 使用建议
+3
View File
@@ -254,6 +254,9 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
| `openresty_cache_lock_enabled` | `bool` | 遭遇高并发请求击穿同一失效资源时是否对向源站发起建连排队加锁 | `true` |
| `openresty_cache_lock_timeout` | `string` | 抢夺代理缓存锁排队建连时排队等待的最长等待耗时限制 | `5s` |
| `openresty_cache_use_stale` | `string` | 当源站遇到特定报错(如500/502/504等)时是否直接向用户投递过期缓存 | `error timeout updating http_500 http_502 http_503 http_504` |
| `openresty_default_limit_conn_per_server` | `int` | 站点未配置时的默认并发连接上限;`0` 表示默认关闭 | `0` |
| `openresty_default_limit_conn_per_ip` | `int` | 站点未配置时的默认单 IP 并发上限;`0` 表示默认关闭 | `0` |
| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 |
| `openresty_main_config_template` | `string` | 允许用户完全重写整个 OpenResty nginx.conf 的底层结构大骨架模板 | 空 (内置缺省骨架) |
---
@@ -0,0 +1,709 @@
# 边缘限流全局默认 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:** 为边缘限流增加三项全局默认;站点 `0`/空继承默认、`-1` 显式关闭、`>0` 覆盖;在 `RenderRouteConfig` 唯一合并。
**Architecture:** 全局默认存 `system_configs`,进入 `openresty_config` 快照;站点字段语义变更后仍原样入库与快照;`pkg/render/openresty.RenderRouteConfig` 用 `doc.OpenRestyConfig` 与 route 字段合并后输出 location 指令。UI:安全性下新页「限流」+ 站点限流文案更新。
**Tech Stack:** Go、goose SQL、Option API、`pkg/render/openresty`、Next.js、OptionService
**Spec:** [docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md](../specs/2026-07-19-http-default-rate-limit-design.md)
## Global Constraints
- 合并**只**在 `RenderRouteConfig`;快照保留站点原始值(含 `0`/`-1`)
- 不引入 `limit_req`;不在 `http {}` 写默认 `limit_conn`/`limit_rate`
- 全局默认初始 `0`/空 → 存量行为不变
- 完成后 `make code-check`;改前端后 `make prettier`;中文 changelog;不写英文文档
- 所有 HTTP 路由仍只在 `internal/router/router.go` 委派(本功能复用 Option API,无需新业务路由)
## File map
| 文件 | 职责 |
|------|------|
| `internal/model/system_configs.go` | 三个 ConfigKey 常量 |
| `internal/db/migrator/goose/{postgres,sqlite}/202607190001_add_openresty_default_rate_limits.sql` | seed 默认值 |
| `internal/apps/openflare/option/openresty_validators.go` + `validate.go` | 全局默认校验 |
| `internal/apps/openflare/config_version/snapshot.go` | 快照字段 + 读取 |
| `internal/apps/openflare/config_version/logics.go` | option diff keys |
| `pkg/render/openresty/types.go` | `ConfigSnapshot` 三字段 |
| `pkg/render/openresty/render.go` | `mergeRouteLimit*` + 调用点 |
| `pkg/render/openresty/render_test.go` | 合并渲染单测 |
| `internal/apps/openflare/proxy_route/helpers.go` | 站点 normalize 允许 -1 |
| `frontend/lib/navigation/openflare-nav.ts` | 安全性子菜单 |
| `frontend/app/(main)/rate-limits/page.tsx` | 全局限流设置页 |
| `frontend/app/(main)/proxy-routes/.../limits-section.tsx` + helpers | 站点语义 UI |
| `frontend/lib/utils/search-data.ts` | 搜索入口 |
| `docs/reference/configuration.md` | 配置键说明 |
| `docs/changelog/index.md` | Unreleased |
| `docs/plan/index.md` | 进行中计划索引 |
---
### Task 1: Render 合并(TDD 核心)
**Files:**
- Modify: `pkg/render/openresty/types.go` (`ConfigSnapshot`)
- Modify: `pkg/render/openresty/render.go`
- Test: `pkg/render/openresty/render_test.go`
**Interfaces:**
- Produces: `ConfigSnapshot` 字段 `DefaultLimitConnPerServer int`, `DefaultLimitConnPerIP int`, `DefaultLimitRate string`(json: `default_limit_conn_per_server` 等)
- Produces: `mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig`
- Produces: `mergeLimitConn(route, def int) int`, `mergeLimitRate(route, def string) string`
- [ ] **Step 1: 写失败单测**
在 `render_test.go` 末尾追加:
```go
func TestMergeRouteLimitConfig(t *testing.T) {
t.Parallel()
cases := []struct {
name string
route Route
cfg ConfigSnapshot
want routeLimitConfig
}{
{
name: "both zero off",
route: Route{},
cfg: ConfigSnapshot{},
want: routeLimitConfig{},
},
{
name: "inherit all defaults",
route: Route{},
cfg: ConfigSnapshot{
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
},
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k"},
},
{
name: "explicit off ignores default",
route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1"},
cfg: ConfigSnapshot{
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
},
want: routeLimitConfig{},
},
{
name: "route overrides default",
route: Route{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
cfg: ConfigSnapshot{
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
},
want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
},
{
name: "partial inherit",
route: Route{LimitConnPerServer: 0, LimitConnPerIP: -1, LimitRate: ""},
cfg: ConfigSnapshot{
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "256k",
},
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := mergeRouteLimitConfig(tc.route, tc.cfg)
if got != tc.want {
t.Fatalf("mergeRouteLimitConfig() = %#v, want %#v", got, tc.want)
}
})
}
}
func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
doc := Document{
Routes: []Route{{
SiteName: "example.com",
Domains: []string{"example.com"},
Enabled: true,
OriginURL: "http://127.0.0.1:8080",
Upstreams: []string{"http://127.0.0.1:8080"},
}},
OpenRestyConfig: ConfigSnapshot{
DefaultLimitConnPerServer: 120,
DefaultLimitConnPerIP: 12,
DefaultLimitRate: "512k",
},
}
rendered, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatalf("RenderRouteConfig() error = %v", err)
}
for _, want := range []string{
"limit_conn openflare_conn_per_server 120;",
"limit_conn openflare_conn_per_ip 12;",
"limit_rate 512k;",
} {
if !strings.Contains(rendered, want) {
t.Fatalf("expected %q in route config, got:\n%s", want, rendered)
}
}
}
func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) {
doc := Document{
Routes: []Route{{
SiteName: "example.com",
Domains: []string{"example.com"},
Enabled: true,
OriginURL: "http://127.0.0.1:8080",
Upstreams: []string{"http://127.0.0.1:8080"},
LimitConnPerServer: -1,
LimitConnPerIP: -1,
LimitRate: "-1",
}},
OpenRestyConfig: ConfigSnapshot{
DefaultLimitConnPerServer: 120,
DefaultLimitConnPerIP: 12,
DefaultLimitRate: "512k",
},
}
rendered, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatalf("RenderRouteConfig() error = %v", err)
}
if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") {
t.Fatalf("expected no limit directives, got:\n%s", rendered)
}
}
```
- [ ] **Step 2: 跑测确认失败**
```bash
go test ./pkg/render/openresty/ -run 'TestMergeRouteLimitConfig|TestRenderRouteConfigAppliesDefaultLimits|TestRenderRouteConfigExplicitOffSkipsDefaultLimits' -count=1
```
Expected: FAIL(`mergeRouteLimitConfig` undefined 或行为不符)
- [ ] **Step 3: 实现 types + merge + 调用**
`ConfigSnapshot` 增加:
```go
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
```
`render.go` 中 `RenderRouteConfig` 将:
```go
limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate}
```
改为:
```go
limitConfig := mergeRouteLimitConfig(route, doc.OpenRestyConfig)
```
并新增:
```go
func mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig {
return routeLimitConfig{
LimitConnPerServer: mergeLimitConn(route.LimitConnPerServer, cfg.DefaultLimitConnPerServer),
LimitConnPerIP: mergeLimitConn(route.LimitConnPerIP, cfg.DefaultLimitConnPerIP),
LimitRate: mergeLimitRate(route.LimitRate, cfg.DefaultLimitRate),
}
}
func mergeLimitConn(route, def int) int {
if route == -1 {
return 0
}
if route > 0 {
return route
}
if def > 0 {
return def
}
return 0
}
func mergeLimitRate(route, def string) string {
r := strings.ToLower(strings.TrimSpace(route))
if r == "-1" {
return ""
}
if r != "" && r != "0" {
return r
}
d := strings.ToLower(strings.TrimSpace(def))
if d != "" && d != "0" {
return d
}
return ""
}
```
- [ ] **Step 4: 跑测通过**
```bash
go test ./pkg/render/openresty/ -count=1
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add pkg/render/openresty/types.go pkg/render/openresty/render.go pkg/render/openresty/render_test.go
git commit -m "feat(openresty): merge global default limits at route render"
```
---
### Task 2: 配置键、迁移、校验、快照
**Files:**
- Modify: `internal/model/system_configs.go`
- Create: `internal/db/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql`
- Create: `internal/db/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql`
- Modify: `internal/apps/openflare/option/validate.go`
- Modify: `internal/apps/openflare/option/openresty_validators.go`
- Modify: `internal/apps/openflare/config_version/snapshot.go`
- Modify: `internal/apps/openflare/config_version/logics.go`
**Interfaces:**
- Consumes: Task 1 的 `ConfigSnapshot` JSON 字段名
- Produces: `ConfigKeyOpenRestyDefaultLimitConnPerServer` 等三常量;snapshot 填充;diff 可见
- [ ] **Step 1: 常量**
在 `system_configs.go` OpenResty 段末尾(`MainConfigTemplate` 前或后)加入:
```go
ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
```
- [ ] **Step 2: goose 迁移(PG + SQLite 同内容)**
```sql
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('openresty_default_limit_conn_per_server', '0', 'business', 0, '默认站点并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('openresty_default_limit_conn_per_ip', '0', 'business', 0, '默认单 IP 并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('openresty_default_limit_rate', '', 'business', 0, '默认单请求带宽限速(空关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN (
'openresty_default_limit_conn_per_server',
'openresty_default_limit_conn_per_ip',
'openresty_default_limit_rate'
);
```
SQLite:若项目其它 seed 不用 `ON CONFLICT`,对照 `202607170001_add_pages_system_configs.sql` 的 sqlite twin 写法保持一致(通常可同用 `ON CONFLICT (key) DO NOTHING`)。
- [ ] **Step 3: 校验器**
`validate.go` 增加:
```go
func validateNonNegativeIntegerOption(key, value string) error {
intValue, err := strconv.Atoi(value)
if err != nil || intValue < 0 {
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
}
return nil
}
```
`openresty_validators.go` 注册:
```go
model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption,
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
```
```go
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
func validateOpenRestyDefaultLimitRate(key, trimmed string) error {
if trimmed == "" || trimmed == "0" {
return nil
}
if !openRestyDefaultLimitRatePattern.MatchString(strings.ToLower(trimmed)) {
return fmt.Errorf("%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", key)
}
return nil
}
```
- [ ] **Step 4: 快照读取(注意 0 合法)**
`openRestyConfigSnapshot` 与 `buildOpenRestyConfigSnapshot` 增加三字段。
**禁止**对这三项使用现有 `getIntConfig`(其 `val <= 0` 会把合法 `0` 与错误混在一起;虽 default=0 时偶然正确,但语义不清)。改为:
```go
getNonNegIntConfig := func(key string, defaultVal int) int {
val, err := repository.GetIntByKey(ctx, key)
if err != nil || val < 0 {
return defaultVal
}
return val
}
```
```go
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
```
若 `DefaultLimitRate == "0"`,规范化为 `""`。
确认 snapshot → render JSON 字段名与 `openrestyrender.ConfigSnapshot` 一致(`snapshotDocument` 序列化后由 `RenderJSON` 反序列化到 render types)。`openRestyConfigSnapshot` 的 json tag 必须与 `ConfigSnapshot` 对齐:
```go
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
```
- [ ] **Step 5: option diff**
在 `diffOpenRestyOptionDetails` 末尾:
```go
appendIfChanged("OpenRestyDefaultLimitConnPerServer", fmt.Sprintf("%d", left.DefaultLimitConnPerServer), fmt.Sprintf("%d", right.DefaultLimitConnPerServer))
appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP))
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
```
`openRestyOptionKeys()` 同步追加这三 key 字符串。
- [ ] **Step 6: 编译/相关测试**
```bash
go test ./internal/apps/openflare/config_version/ ./internal/apps/openflare/option/ ./pkg/render/openresty/ -count=1
```
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add internal/model/system_configs.go \
internal/db/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql \
internal/db/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql \
internal/apps/openflare/option/validate.go \
internal/apps/openflare/option/openresty_validators.go \
internal/apps/openflare/config_version/snapshot.go \
internal/apps/openflare/config_version/logics.go
git commit -m "feat(config): add openresty default rate limit system options"
```
---
### Task 3: 站点 normalize 允许 -1
**Files:**
- Modify: `internal/apps/openflare/proxy_route/helpers.go`
- Modify: `internal/apps/openflare/proxy_route/errs.go`(如需更新文案)
- Test: 若无现成 helpers 测试文件则新建 `helpers_limit_test.go`
**Interfaces:**
- Produces: `normalizeProxyRouteLimitConnValue` 允许 `>= -1`;`normalizeProxyRouteLimitRate` 允许 `"-1"`
- [ ] **Step 1: 失败单测**
```go
func TestNormalizeProxyRouteLimitConnValue(t *testing.T) {
t.Parallel()
got, err := normalizeProxyRouteLimitConnValue(-1, "limit_conn_per_server")
if err != nil || got != -1 {
t.Fatalf("want -1, got %d err %v", got, err)
}
if _, err := normalizeProxyRouteLimitConnValue(-2, "limit_conn_per_server"); err == nil {
t.Fatal("expected error for -2")
}
}
func TestNormalizeProxyRouteLimitRate(t *testing.T) {
t.Parallel()
got, err := normalizeProxyRouteLimitRate("-1")
if err != nil || got != "-1" {
t.Fatalf("want -1, got %q err %v", got, err)
}
got, err = normalizeProxyRouteLimitRate("0")
if err != nil || got != "" {
t.Fatalf("want empty inherit, got %q err %v", got, err)
}
}
```
- [ ] **Step 2: 实现**
```go
func normalizeProxyRouteLimitConnValue(value int, field string) (int, error) {
if value < -1 {
return 0, fmt.Errorf("%s must be greater than or equal to -1", field)
}
return value, nil
}
func normalizeProxyRouteLimitRate(raw string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
if normalized == "" || normalized == "0" {
return "", nil
}
if normalized == "-1" {
return "-1", nil
}
if !proxyRouteLimitRatePattern.MatchString(normalized) {
return "", errors.New(errProxyRouteLimitRate)
}
if strings.TrimRight(normalized, "km") == "" {
return "", nil
}
return normalized, nil
}
```
可选:`errProxyRouteLimitRate` 文案追加「或 -1 表示关闭」。
- [ ] **Step 3: 测试**
```bash
go test ./internal/apps/openflare/proxy_route/ -count=1
```
- [ ] **Step 4: Commit**
```bash
git add internal/apps/openflare/proxy_route/
git commit -m "feat(proxy-route): allow -1 to disable rate limits"
```
---
### Task 4: 前端 — 安全性「限流」页 + 站点文案
**Files:**
- Modify: `frontend/lib/navigation/openflare-nav.ts`
- Create: `frontend/app/(main)/rate-limits/page.tsx`
- Modify: `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx`
- Modify: `frontend/app/(main)/proxy-routes/components/helpers.ts`
- Modify: `frontend/lib/utils/search-data.ts`
**Interfaces:**
- Consumes: Option keys 字面量 `openresty_default_limit_conn_per_server` 等
- Produces: `/rate-limits` 管理页;站点表单接受 `-1`
- [ ] **Step 1: 导航**
`openflareSecurityNavGroup.items`:
```ts
{ title: 'WAF', url: '/waf' },
{ title: 'IP 组', url: '/ip-groups' },
{ title: '限流', url: '/rate-limits' },
```
- [ ] **Step 2: 搜索**
`search-data.ts` 在 IP 组后增加:
```ts
{
id: 'console-rate-limits',
title: '限流',
description: '配置边缘站点默认并发与带宽限流策略',
url: '/rate-limits',
category: 'page',
keywords: ['限流', 'rate limit', 'limit_conn', 'limit_rate', '并发', '带宽'],
},
```
- [ ] **Step 3: 限流设置页**
新建 `frontend/app/(main)/rate-limits/page.tsx`,模式对齐 `performance/page.tsx`:
- `useAuth` 管理员校验
- `OptionService.list` / `updateBatch`
- 三字段表单 + 单卡片保存
- 标题:`Shield` 或 `Gauge` 图标 + `h1`「限流」
- 描述:空/0 表示默认关闭;修改后需在版本发布中生效
- keys:
- `openresty_default_limit_conn_per_server`
- `openresty_default_limit_conn_per_ip`
- `openresty_default_limit_rate`
- conn:非负整数;rate:空或 `^\d+[kKmM]?$`
- 保存成功 toast + invalidate options / config-preview / config-versions
- 链到 `/config-versions`
页面骨架要点(完整实现时展开为完整组件,勿留半成品):
```tsx
// 字段 state、OptionService.list map、updateBatch([{key,value},...])
// 文案:「0 或空表示默认关闭;站点未单独配置时继承此处设置。」
```
- [ ] **Step 4: 站点 limits-section**
1. schema:conn 允许空、`0`、`-1`、正整数:
```ts
if (!rawValue) continue;
if (!/^-1$|^\d+$/.test(rawValue)) {
context.addIssue({ ..., message: '请输入 -1、0 或正整数' });
}
```
2. `validateLimitRate` / `normalizeLimitRate`:
```ts
export function validateLimitRate(value: string) {
const normalized = value.trim();
if (!normalized || normalized === '0' || normalized === '-1') {
return null;
}
if (!limitRatePattern.test(normalized)) {
return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭';
}
return null;
}
export function normalizeLimitRate(value: string) {
const normalized = value.trim().toLowerCase();
if (normalized === '0') return '';
return normalized; // 保留 -1
}
```
3. 表单展示:`-1` 需显示为 `'-1'`(注意 `route.limit_conn_per_server ? String : ''` 对 `-1` 已为 truthy;对 `0` 仍为空)
4. 提交:空 → `0`;`-1` → `-1`;正数 → 数字
5. 文案:
```
description='站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。'
FormDescription 同步说明
```
6. 侧栏「流量限制」section description 可改为:`设置连接数和限速(可继承全局默认)。`
- [ ] **Step 5: prettier + 类型检查(按项目习惯)**
```bash
make prettier
# 若有前端 typecheck:
# cd frontend && pnpm exec tsc --noEmit
```
- [ ] **Step 6: Commit**
```bash
git add frontend/lib/navigation/openflare-nav.ts \
frontend/app/\(main\)/rate-limits/ \
frontend/app/\(main\)/proxy-routes/detail/components/limits-section.tsx \
frontend/app/\(main\)/proxy-routes/components/helpers.ts \
frontend/lib/utils/search-data.ts
git commit -m "feat(frontend): add security rate-limits page and inherit UI"
```
---
### Task 5: 文档、索引、门禁
**Files:**
- Modify: `docs/reference/configuration.md`(OpenResty 配置表)
- Modify: `docs/changelog/index.md` `[unreleased]`
- Modify: `docs/plan/index.md`
- [ ] **Step 1: configuration.md**
在 `openresty_cache_use_stale` 与 `openresty_main_config_template` 之间插入:
```md
| `openresty_default_limit_conn_per_server` | `int` | 站点未配置时的默认并发连接上限;`0` 表示默认关闭 | `0` |
| `openresty_default_limit_conn_per_ip` | `int` | 站点未配置时的默认单 IP 并发上限;`0` 表示默认关闭 | `0` |
| `openresty_default_limit_rate` | `string` | 站点未配置时的默认单请求带宽(如 `512k`);空表示默认关闭 | 空 |
```
- [ ] **Step 2: changelog**
`[unreleased]` 下:
```md
### 新增
- 安全性新增「限流」设置:可为边缘站点配置默认并发与带宽;站点未设置时继承,填 `-1` 可显式关闭。
### 改进
- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。
```
- [ ] **Step 3: plan index**
`docs/plan/index.md` 进行中列表增加:
```md
* [边缘限流全局默认](../superpowers/plans/2026-07-19-http-default-rate-limit.md):http/全局默认限流,站点 0 继承、-1 关闭。
```
- [ ] **Step 4: 全量门禁**
```bash
make code-check
make prettier
```
Expected: 通过;修复任何报错后再提交。
- [ ] **Step 5: Commit**
```bash
git add docs/reference/configuration.md docs/changelog/index.md docs/plan/index.md
git commit -m "docs: document default edge rate limits"
```
---
## Spec coverage checklist
| Spec 要求 | Task |
|-----------|------|
| 三项全局默认 | 2, 4 |
| 0/空继承、-1 关、>0 覆盖 | 1, 3, 4 |
| 仅 `RenderRouteConfig` 合并 | 1 |
| 快照保留原始站点值 | 2(不写回 route) |
| 安全性子页「限流」 | 4 |
| 初始 0/空兼容 | 2 seed |
| option diff / 发布 | 2 |
| 测试合并/normalize | 1, 3 |
| 中文文档/changelog | 5 |
| 非目标 limit_req / http 级指令 | 未做 |
## 手动验收
1. 迁移后三键存在且为 `0`/空
2. 安全性 → 限流 设置 `120` / `12` / `512k` 并保存
3. 版本发布预览:未配置站点的 location 出现对应 `limit_conn`/`limit_rate`
4. 站点将该项改为 `-1` 保存并发布:该维度指令消失
5. 站点改为 `50`:输出 50 而非全局值
@@ -0,0 +1,388 @@
# WAF Editor Node Label + Drag-Add 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:** Let users rename WAF rule nodes via optional `label`, and add nodes by dragging from the library onto the canvas drop position (no click-to-fixed-offset).
**Architecture:** Frontend-only. Align TS `WAFRuleNode` with backend `label`. Pure helpers for display name and default node factory. Node library is drag source; React Flow pane handles drop with `screenToFlowPosition`. Properties panel edits `label` for non-system nodes.
**Tech Stack:** Next.js App Router, React, TypeScript, `@xyflow/react`, Vitest + Testing Library, shadcn/ui.
**Spec:** `docs/superpowers/specs/2026-07-19-waf-editor-node-label-drag-design.md`
## Global Constraints
- No backend / schema_version / note field changes.
- System nodes `start` / `allow`: no rename UI.
- New nodes: no default `label` (type name shown).
- Drag-only add; remove click-add.
- After code: relevant vitest pass; run `make prettier` / `make code-check` if touching repo gates.
## File Map
| File | Role |
|------|------|
| `frontend/lib/services/openflare/types.ts` | Add `label?: string` to all `WAFRuleNode` variants |
| `frontend/app/(main)/waf/rules/editor/components/node-factory.ts` | `NODE_TYPE_LABELS`, `displayNodeTitle`, `createRuleNode`, drag MIME constant |
| `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts` | Unit tests for title + factory |
| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | Use `displayNodeTitle` |
| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」Input |
| `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx` | Label edit + system node |
| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | Draggable items, no onClick |
| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | Drop handler + position-aware create |
---
### Task 1: Types + pure helpers
**Files:**
- Modify: `frontend/lib/services/openflare/types.ts`
- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.ts`
- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts`
**Interfaces:**
- Produces: `WAF_NODE_DRAG_MIME`, `AddableNodeType`, `NODE_TYPE_LABELS`, `displayNodeTitle(node)`, `createRuleNode(type, position)`
- [ ] **Step 1: Add `label?: string` to every `WAFRuleNode` union member** in `types.ts`.
- [ ] **Step 2: Write failing tests** in `node-factory.test.ts`:
```ts
import { describe, expect, it } from 'vitest';
import {
createRuleNode,
displayNodeTitle,
NODE_TYPE_LABELS,
} from './node-factory';
describe('displayNodeTitle', () => {
it('uses trimmed label when present', () => {
expect(
displayNodeTitle({
id: 'x',
type: 'ip_match',
label: ' 办公室 ',
position: { x: 0, y: 0 },
config: { ips: [], cidrs: [], ip_group_ids: [] },
}),
).toBe('办公室');
});
it('falls back to type default when label empty', () => {
expect(
displayNodeTitle({
id: 'x',
type: 'block',
label: ' ',
position: { x: 0, y: 0 },
config: { status_code: 403, response_body: '' },
}),
).toBe(NODE_TYPE_LABELS.block);
});
});
describe('createRuleNode', () => {
it('creates typed node at position without label', () => {
const node = createRuleNode('pow', { x: 12, y: 34 });
expect(node.type).toBe('pow');
expect(node.position).toEqual({ x: 12, y: 34 });
expect(node.label).toBeUndefined();
expect(node.id.startsWith('pow-')).toBe(true);
if (node.type === 'pow') {
expect(node.config).toEqual({
algorithm: 'fast',
difficulty: 4,
session_ttl: 3600,
challenge_ttl: 300,
});
}
});
});
```
- [ ] **Step 3: Implement `node-factory.ts`**
```ts
import type { WAFRuleNode } from '@/lib/services/openflare';
export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node';
export type AddableNodeType = Extract<
WAFRuleNode['type'],
'ip_match' | 'geo_match' | 'pow' | 'block'
>;
export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
start: '开始',
ip_match: 'IP 匹配',
geo_match: '地域匹配',
pow: 'PoW 挑战',
allow: '通过',
block: '阻止',
};
export function displayNodeTitle(
node: Pick<WAFRuleNode, 'type' | 'label'>,
): string {
const custom = node.label?.trim();
return custom || NODE_TYPE_LABELS[node.type];
}
export function createRuleNode(
type: AddableNodeType,
position: { x: number; y: number },
): WAFRuleNode {
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
if (type === 'ip_match')
return {
id,
type,
position,
config: { ips: [], cidrs: [], ip_group_ids: [] },
};
if (type === 'geo_match')
return { id, type, position, config: { countries: [], regions: [] } };
if (type === 'pow')
return {
id,
type,
position,
config: {
algorithm: 'fast',
difficulty: 4,
session_ttl: 3600,
challenge_ttl: 300,
},
};
return {
id,
type: 'block',
position,
config: { status_code: 403, response_body: '' },
};
}
export function parseAddableNodeType(value: string): AddableNodeType | null {
if (
value === 'ip_match' ||
value === 'geo_match' ||
value === 'pow' ||
value === 'block'
)
return value;
return null;
}
```
- [ ] **Step 4: Run tests**
```bash
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-factory.test.ts'
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add frontend/lib/services/openflare/types.ts \
frontend/app/(main)/waf/rules/editor/components/node-factory.ts \
frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts
git commit -m "feat(waf): add node label type and factory helpers"
```
---
### Task 2: Canvas title + properties label field
**Files:**
- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx`
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx`
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx`
- [ ] **Step 1: Tests for properties**
Add to `node-properties.test.tsx`:
```ts
it('edits display name for configurable nodes', () => {
const node: WAFRuleNode = {
id: 'match',
type: 'ip_match',
position: { x: 0, y: 0 },
config: { ips: [], cidrs: [], ip_group_ids: [] },
};
const onChange = vi.fn();
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
fireEvent.change(screen.getByLabelText('显示名称'), {
target: { value: '内网放行' },
});
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ label: '内网放行' }),
);
});
it('hides display name for system nodes', () => {
const node: WAFRuleNode = {
id: 'start',
type: 'start',
position: { x: 0, y: 0 },
config: {},
};
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument();
expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument();
});
```
- [ ] **Step 2: Implement properties field** — at start of each configurable `FieldGroup` (or wrap once before type switch for non-system):
Prefer extract:
```tsx
function DisplayNameField({
node,
onChange,
}: {
node: WAFRuleNode;
onChange: (node: WAFRuleNode) => void;
}) {
return (
<Field>
<FieldLabel htmlFor={`${node.id}-label`}>显示名称</FieldLabel>
<Input
id={`${node.id}-label`}
value={node.label ?? ''}
placeholder={/* type default from NODE_TYPE_LABELS */}
onChange={(e) => onChange({ ...node, label: e.target.value })}
/>
</Field>
);
}
```
Insert `<DisplayNameField ... />` as first child inside each non-system `FieldGroup`.
- [ ] **Step 3: `rule-node.tsx`** — use `displayNodeTitle(rule)` for main title; keep icon from meta; keep id subtitle.
- [ ] **Step 4: Run tests**
```bash
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-properties.test.tsx' 'app/(main)/waf/rules/editor/components/node-factory.test.ts'
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add frontend/app/(main)/waf/rules/editor/components/rule-node.tsx \
frontend/app/(main)/waf/rules/editor/components/node-properties.tsx \
frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx
git commit -m "feat(waf): show and edit rule node display names"
```
---
### Task 3: Drag library + canvas drop
**Files:**
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-library.tsx`
- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx`
- Create (optional pure tests): extend `node-factory.test.ts` for `parseAddableNodeType`
- [ ] **Step 1: Node library** — remove `onAdd` prop; make each button `draggable` with:
```tsx
onDragStart={(e) => {
e.dataTransfer.setData(WAF_NODE_DRAG_MIME, type);
e.dataTransfer.setData('text/plain', type);
e.dataTransfer.effectAllowed = 'copy';
}}
```
Use `type='button'` + cursor `cursor-grab active:cursor-grabbing`. No `onClick` that adds nodes.
- [ ] **Step 2: Canvas** — replace `addNode(type)` fixed position with:
```ts
const addNodeAt = useCallback(
(type: AddableNodeType, position: { x: number; y: number }) => {
const node = createRuleNode(type, position);
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
onSelectEdge(undefined);
onSelect(node.id);
},
[graph, onGraphChange, onSelect, onSelectEdge],
);
const onDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}, []);
const onDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const raw =
e.dataTransfer.getData(WAF_NODE_DRAG_MIME) ||
e.dataTransfer.getData('text/plain');
const type = parseAddableNodeType(raw);
if (!type || !instance.current) return;
const position = instance.current.screenToFlowPosition({
x: e.clientX,
y: e.clientY,
});
addNodeAt(type, position);
},
[addNodeAt],
);
```
Pass `onDragOver` / `onDrop` to `<ReactFlow ...>` (xyflow supports these on the component).
Update `<NodeLibrary />` — no `onAdd`.
- [ ] **Step 3: Run editor-related tests**
```bash
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor'
```
Expected: PASS (update any tests that assumed click-add)
- [ ] **Step 4: Format + commit**
```bash
make prettier
git add frontend/app/(main)/waf/rules/editor
git commit -m "feat(waf): drag-drop nodes onto rule canvas at cursor"
```
- [ ] **Step 5: Changelog** — under `docs/changelog/index.md` `[Unreleased]`:
```md
### 改进
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
```
```bash
git add docs/changelog/index.md
git commit -m "docs(changelog): WAF 编辑器节点命名与拖放添加"
```
---
## Spec coverage
| Spec item | Task |
|-----------|------|
| `label?` on TS types | 1 |
| Display title fallback | 1–2 |
| Properties 显示名称 | 2 |
| System nodes no rename | 2 |
| Drag-only library | 3 |
| Drop at cursor | 3 |
| No note / backend | N/A (omitted) |
| Tests | 1–3 |
| Changelog | 3 |
@@ -0,0 +1,42 @@
# WAF UA Check Node 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:** Add WAF graph node `ua_check` (require UA, browser/OS whitelist with and/or, bot/abnormal blocks) end-to-end: validate/compile, Lua runtime, editor UI.
**Architecture:** Match-node pattern like `geo_match`. Control plane stores `UACheckConfig`; edge classifies `http_user_agent` with analytics-equivalent token rules; evaluation order: require → block bots → block abnormal → whitelist.
**Tech Stack:** Go (waf package), Lua (OpenResty waf_runtime), React/TS editor, Vitest, Go tests.
**Spec:** `docs/superpowers/specs/2026-07-19-waf-ua-check-node-design.md`
## Global Constraints
- Type `ua_check`; handles `true`/`false`.
- Config fields: `require_ua`, `browsers`, `operating_systems`, `match_mode` (`and`|`or`, default `or`), `block_common_bots`, `block_abnormal_ua`.
- Closed enums for browser/OS labels matching analytics.
- Block before whitelist; empty lists = no whitelist constraint.
- No schema_version bump; no new HTTP API.
- Changelog + Chinese design doc update.
## File Map
| File | Role |
|------|------|
| `internal/apps/openflare/waf/graph_types.go` | Type + config |
| `internal/apps/openflare/waf/graph_validate.go` | Validate + handles |
| `internal/apps/openflare/waf/graph_compile.go` | Compile normalize |
| `internal/apps/openflare/waf/*_test.go` | Go tests |
| `internal/apps/agent/nginx/waf_runtime.lua` | Runtime eval |
| `internal/apps/agent/nginx/waf_runtime_spec.lua` | Lua specs |
| `internal/apps/agent/nginx/manager_test.go` | Embed smoke if needed |
| Frontend editor components + types | UI |
| `docs/design/waf-orchestration-design.md` | Node table |
| `docs/changelog/index.md` | Unreleased |
### Task 1: Backend types/validate/compile
### Task 2: Lua runtime + specs
### Task 3: Frontend editor
### Task 4: Docs + gates
(Detailed code follows during implementation; execute TDD per layer.)
@@ -0,0 +1,219 @@
# 边缘限流全局默认设计
日期:2026-07-19
状态:已评审待实现
方案:渲染时按站点合并全局默认(方案 A)
## 背景
当前边缘限流仅挂在站点(Proxy Route)上,字段为:
- `limit_conn_per_server`:站点并发连接上限
- `limit_conn_per_ip`:单 IP 并发连接上限
- `limit_rate`:单请求带宽
OpenResty 渲染行为:
- `http {}` 始终声明共享 `limit_conn_zone`
- 各站点 `location` 在字段 `>0` / 非空时输出 `limit_conn` / `limit_rate`
- 站点值为 `0` 或空表示**关闭**,无全局默认
期望:在全局增加默认限流策略;站点未设置时继承默认,可覆盖或显式关闭。
## 目标
1. 提供三项全局默认限流配置,覆盖全部现有维度。
2. 站点 `0`/空 = 继承全局;`-1` = 显式关闭;`>0`/合法带宽串 = 站点自定义。
3. 合并发生在配置渲染路径,仍在各站点 `location` 输出生效指令(不在 `http {}` 写默认 `limit_conn`/`limit_rate`)。
4. 管理入口:侧栏「安全性」下新增子页「限流」。
5. 全局默认初始为 `0`/空,存量发布行为与现网一致。
## 非目标
- 引入 `limit_req`(按 RPS 限流)
- 在 `http {}` 上下文直接写默认 `limit_conn` / `limit_rate`
- 按路径 / URI 差异化限流
- 改变 `limit_conn` zone 键模型(仍为 `$server_name` 与 `$binary_remote_addr`)
## 语义
### 站点字段
| 值 | `limit_conn_*` | `limit_rate` |
|----|----------------|--------------|
| `0` / 空 | 继承全局默认 | 空或 `"0"` 规范化为空串后继承 |
| `-1` | 显式关闭该维度 | 字面 `"-1"` 表示显式关闭 |
| `>0` / 合法带宽 | 使用站点值 | 合法 `^\d+[kKmM]?$` 使用站点值 |
### 全局默认
| 值 | 含义 |
|----|------|
| `0` / 空 | 默认关闭;继承方亦不输出指令 |
| `>0` / 合法带宽串 | 作为未配置站点的生效值 |
全局默认**不允许** `-1`(无意义);仅 `>=0` 或合法 rate / 空。
### 合并规则(逐字段)
```
if route == -1: effective = off
else if route is set: effective = route // conn > 0 或 rate 合法非空
else: effective = global // route 为 0/空
// global 为 0/空 → off(不输出)
```
`limit_rate` 的「set」判定:规范化后非空且不等于 `"-1"`。
## 配置存储
沿用 `system_configs` + Option API + 发布快照,与其它 OpenResty 选项一致。
| Key | 类型语义 | 默认 |
|-----|----------|------|
| `openresty_default_limit_conn_per_server` | 非负整数 | `0` |
| `openresty_default_limit_conn_per_ip` | 非负整数 | `0` |
| `openresty_default_limit_rate` | 空或 `^\d+[kKmM]?$` | `""` |
实现要点:
- `internal/model/system_configs.go` 增加 `ConfigKeyOpenRestyDefaultLimit*` 常量
- goose seed/升级迁移写入默认值
- `internal/apps/openflare/option` 注册校验器(conn ≥ 0;rate 与站点同一套 pattern,允许空)
- `openRestyConfigSnapshot` / `buildOpenRestyConfigSnapshot` 增加三字段
- 变更进入 OpenResty option diff;**需重新发布配置版本后下发节点**
## 站点模型与 API
- DB 列类型不变(`INTEGER` / `VARCHAR(32)`),无 schema 变更
- `normalizeProxyRouteLimitConnValue`:允许 `>= -1`(原 `>= 0`)
- `normalizeProxyRouteLimitRate`:允许 `"-1"` 存为关闭标记;空/`0` → `""`(继承)
- View / Input / 前端类型同步暴露 `-1` 语义
- 错误文案更新(非法负数除 `-1` 外拒绝)
## 渲染路径
合并**唯一**发生在 `pkg/render/openresty.RenderRouteConfig`:该函数已接收完整 `Document`,可从 `doc.OpenRestyConfig` 读取全局默认,与各 `doc.Routes[i]` 的站点字段合并。Server 预览渲染与 Agent 落地渲染共用同一路径,禁止在 snapshot 构建或其它层再合一次。
步骤:
1. 对每个 route:用站点限流字段 + `doc.OpenRestyConfig` 中的默认三项 → `routeLimitConfig`
2. `renderRouteLimitBlock` 保持「有值才输出」
3. 应用范围不变:
- HTTP/HTTPS 反代 `location /`
- Pages 相关 location
- **不含** HTTP→HTTPS 重定向-only server
4. `http {}` 仍只输出现有 `limit_conn_zone` 两行
快照 JSON **保留站点原始值**(含 `0`/`-1`),不把合并结果写回 route;节点 conf 中只看到最终指令。
伪代码:
```go
func mergeRouteLimit(route routeLimits, def defaultLimits) routeLimitConfig {
return routeLimitConfig{
LimitConnPerServer: mergeConn(route.LimitConnPerServer, def.LimitConnPerServer),
LimitConnPerIP: mergeConn(route.LimitConnPerIP, def.LimitConnPerIP),
LimitRate: mergeRate(route.LimitRate, def.LimitRate),
}
}
func mergeConn(route, def int) int {
if route == -1 {
return 0 // off
}
if route > 0 {
return route
}
if def > 0 {
return def
}
return 0
}
func mergeRate(route, def string) string {
r := strings.TrimSpace(strings.ToLower(route))
if r == "-1" {
return ""
}
if r != "" && r != "0" {
return r
}
d := strings.TrimSpace(strings.ToLower(def))
if d != "" && d != "0" {
return d
}
return ""
}
```
## 前端
### 安全性 → 限流
- 导航:`openflareSecurityNavGroup` 增加 `{ title: '限流', url: '/rate-limits' }`
- 页面:`frontend/app/(main)/rate-limits/page.tsx`
- 通过 `OptionService.list` / `updateBatch` 读写上述 3 个 key
- UI 模式对齐性能页:标题规范、卡片分区、保存反馈
- 文案说明:`0`/空 = 默认关闭;`>0` = 未单独配置站点的默认生效值;修改后需发布配置版本
### 站点流量限制
- 更新 `limits-section.tsx` 与校验 helpers:
- `0`/空 = 继承全局默认
- `-1` = 关闭
- `>0` / 合法 rate = 自定义
- 可选:展示当前全局默认值作提示(只读)
- 创建站点默认仍为 `0`/空(即继承)
## 兼容性
| 场景 | 结果 |
|------|------|
| 升级后全局默认 0,站点全 0 | 与升级前一致:不限流 |
| 管理员设置全局默认后发布 | 所有 `0`/空站点自动生效默认 |
| 站点需保持关闭 | 将该项改为 `-1` 后保存并发布 |
| 旧 API 客户端只写 `0` | 合法;语义变为继承 |
| 旧快照无默认字段 | 按 0/空处理 |
## 边界说明
- `limit_conn_per_ip` zone 仍按 `$binary_remote_addr` 全局共享;各 location 的 N 可不同,计数空间共享(现网行为,本设计不改)。
- 多域名共享一条路由 → 共享合并后策略(产品边界不变)。
- 仅改全局默认不自动 reload 节点;走标准「选项变更 → 配置版本 diff → 发布」。
## 测试计划
1. **render 表驱动**:继承 / 显式关 / 覆盖 / 全局关 × 三字段
2. **normalize**:`-1`、`0`、`>0`、非法负值、rate `"-1"` / 空 / 合法 / 非法
3. **snapshot**:默认字段进入 `openresty_config`;option diff 可检测变更
4. **option 校验**:非法全局 rate / 负 conn 拒绝
5. 前端:限流页读写与站点文案(可选手测)
## 文档与变更记录
- 本设计文档:`docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md`
- 实现时更新中文 changelog `[Unreleased]`(用户可见语义与新设置页)
- 如有配置参考页,补充三个 key 的中文说明
- 不要求同步英文文档
## 实现落点(文件索引)
| 区域 | 路径 |
|------|------|
| 配置键 / seed | `internal/model/system_configs.go`,goose 迁移 |
| 校验 | `internal/apps/openflare/option/openresty_validators.go` |
| 快照 | `internal/apps/openflare/config_version/snapshot.go` |
| 站点规范化 | `internal/apps/openflare/proxy_route/helpers.go` |
| 渲染合并 | `pkg/render/openresty/render.go`(及调用处传参) |
| 导航 | `frontend/lib/navigation/openflare-nav.ts` |
| 限流设置页 | `frontend/app/(main)/rate-limits/` |
| 站点 UI | `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx` |
## 验收标准
1. 全局默认可在「安全性 → 限流」读写,初始 0/空。
2. 全局设为有效值并发布后,站点限流为 0/空的 location 出现对应指令。
3. 站点 `-1` 在全局有默认时仍不输出该维度。
4. 站点 `>0` 覆盖全局。
5. 全局与站点均为 0/空时 conf 无 `limit_conn`/`limit_rate` 指令。
6. `make code-check` 通过;相关单测覆盖合并与规范化。
@@ -0,0 +1,217 @@
# 限流页请求压力分析设计
日期:2026-07-19
状态:已评审待实现
方案:Tabs(分析 / 配置)+ 专用 ECharts 双轴压力图(方案 A)
## 背景
`/rate-limits` 当前仅为管理员配置全局 OpenResty 默认限流(`limit_conn_*` / `limit_rate`),无请求压力可视化。
访问日志概览已提供:
- 过滤:时间预设 `24h | 7d | 15d | 30d` + 域名多选 `hosts[]`
- 数据:`GET /api/v1/d/access-logs/overview` → 小时桶 `trends.requests` / `trends.visits`,以及 `top_hosts` / `top_ips`(窗口总请求数)
- 图表:共享 `TrendChart` 为**单 Y 轴**;仓库内无 ECharts `dataZoom`、无双轴指标图
需求:在限流页展示当前请求压力(RPS),默认 24 小时,图表样式对齐「双轴时序面积折线 + 底部缩放条」描述,过滤复用访问日志概览组件,并增加域名/IP 平均 RPS 排行。
## 目标
1. `/rate-limits` 改为 Tabs:**分析**(默认)/ **配置**。
2. 分析 Tab:概览式过滤 + RPS/访客双轴主图 + 域名/IP 平均 RPS 排行。
3. 配置 Tab:迁入现有全局默认限流表单,行为不变。
4. 数据复用 `AccessLogService.getOverview`,不新增后端 API。
5. 主图为**专用** ECharts 组件,不扩展共享 `TrendChart`。
## 非目标
- 新 RPS 时序 API 或峰值桶 RPS 排行接口
- 给通用 `TrendChart` 增加双轴 / dataZoom
- 配置 Tab 限流语义变更
- 分析过滤支持 node_id / IP / path(概览亦无)
- 英文文档
## 页面信息架构
**路由:** `/rate-limits`(导航「安全性 → 限流」不变)
| Tab | 内容 |
|-----|------|
| **分析**(默认) | 过滤条 → `RatePressureChart` → 双排行榜 |
| **配置** | 现有三项全局默认限流表单 + 保存 + 链到版本发布 |
可选:`?tab=config` 直达配置;默认 `analysis`。
**权限:** 仅管理员(与现页一致)。
**分析 Tab 自上而下:**
1. **过滤条**(与访问日志概览一致)
- 时间:`24 | 168 | 360 | 720` 小时,默认 **24**
- 域名:Zone 树多选 → `hosts[]`
2. **主图卡片** `RatePressureChart`
3. **排行榜**(并排)
- 平均 RPS 最高域名
- 平均 RPS 最高 IP
## 数据与状态
### 查询
```ts
AccessLogService.getOverview({
hours: overviewHours,
hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
})
// queryKey: ['openflare', 'rate-limits', 'overview', hours, hosts]
```
- 过滤变更 → 重新请求 overview
- 图表 `dataZoom` **仅**前端缩放已加载序列,**不**改 `hours`、**不**触发 refetch
### 指标定义
| 序列 | 源字段 | 换算 | 轴 |
|------|--------|------|-----|
| 请求速率 (RPS) | `trends.requests[].value` | `value / 3600`(概览固定 1h 桶) | 左 Y |
| 独立访客 | `trends.visits[].value` | 桶内 UV,不换算 | 右 Y |
- 时间点:`bucket_started_at`
- Tooltip:时间 + RPS(如 `12.3 req/s`)+ 访客数
- 空数据 / 加载 / 错误:对齐访问日志概览空态与 `ErrorInline` / loading
### 排行口径
窗口**平均** RPS(与 dashboard `estimated_qps` 一致):
```
avgRps = total_requests / (hours * 3600)
```
- 域名:`top_hosts[]` 的 `value` 为窗口总请求数 → 换算后展示
- IP:`top_ips[]` 同理
- 标题:「平均 RPS 最高域名」「平均 RPS 最高 IP」
- 副文案标明窗口(如「近 24 小时平均」)
- UI 组件:现有 `RankCard` / `RankChart`
**不是**峰值小时桶 RPS;避免新 API。
## 主图组件 `RatePressureChart`
### 布局(对齐产品描述)
1. **外部卡片**:圆角、边框/轻阴影,扁平矩形
2. **顶部控制栏**
- 左:主标题「请求压力」(字号加粗)
- 右:时钟图标 + 当前查询窗口起止(由 `hours` 与「现在」推算本地时间,`YYYY-MM-DD HH:mm:ss`)
3. **图例与轴标识**
- 左上:左轴属性「RPS」
- 右上:图例圆点 +「请求速率」「独立访客」
- 最右:右轴单位「访客 / 桶」
4. **主绘制区**
- 双 Y 轴:左 RPS 从 0 递增;右访客从 0 递增
- X 轴:时间,标签两行(月-日 / 时:分),可复用 `formatOverviewTrendLabel` 思路
- 水平等距虚线网格
- 面积 + 折线,半透明填充,两序列可重叠
5. **底部 dataZoom slider**
- ECharts `dataZoom: [{ type: 'slider', ... }]`
- 宽度对齐绘图区;左右手柄;内嵌缩略波动线
- 仅影响可见区间
### 实现约束
- 新建专用组件,**不要**给 `TrendChart` 加 dualY/dataZoom
- 库:`echarts` + `echarts-for-react`(与看板一致)
- 颜色使用主题/CSS 变量或与访问日志趋势相近的语义色,避免硬编码与 shadcn 变体冲突时可参考现有 `TrendChart` 系列色
## 过滤组件复用
优先从 `frontend/app/(main)/access-logs/components/overview-tab.tsx` **抽出**:
- `OverviewToolbar`(或等价)
- `OverviewHostFilter`
- 依赖的 `OVERVIEW_RANGE_OPTIONS` / `OverviewRangeHours` 已在 `access-log-utils.ts`
落点建议:
- 仍放在 `access-logs/components/` 并 export,限流分析 import;或
- 若跨模块更清晰,迁到 `frontend/components/common/`(仅当确实跨页面复用且避免循环依赖时)
**验收:** 访问日志概览过滤行为与抽出前一致。
## 文件结构
```
frontend/app/(main)/rate-limits/
page.tsx # Tabs、权限、分析/配置挂载
components/
analysis-tab.tsx # 过滤 + 图 + 排行 + overview query
rate-pressure-chart.tsx # 双轴 + dataZoom
config-tab.tsx # 现有 Option 表单逻辑迁入
```
可选抽出:
```
frontend/app/(main)/access-logs/components/
overview-toolbar.tsx # 从 overview-tab 抽出
overview-host-filter.tsx
```
后端:无变更。
## 边界与兼容
| 场景 | 行为 |
|------|------|
| 无日志 / ClickHouse 空 | 图与排行空态 |
| 仅选域名 | overview 带 `hosts[]` |
| dataZoom 拖动 | 不请求后端 |
| 非管理员 | 空态「权限不足」 |
| 书签 `/rate-limits` | 默认分析 Tab |
| 配置保存 | 仍 invalidate options / config-preview / config-versions |
## 测试与验收
### 自动化(按项目习惯)
- 若有 vitest:过滤 props 透传、`avgRps` 换算纯函数单测
- 图表以手工/视觉验收为主(ECharts 难做快照)
### 验收标准
1. 默认进入分析 Tab,24h,主图展示 RPS + 访客
2. 切换 7d / 域名后图与排行刷新
3. dataZoom 仅改变可见时间范围
4. 排行展示平均 RPS,不是原始请求总数(文案明确「平均」)
5. 配置 Tab 可读写三项默认限流并保存
6. 访问日志概览过滤不回归
7. `make prettier`;相关 typecheck/lint 通过
## 文档
- 本设计:`docs/superpowers/specs/2026-07-19-rate-limit-analytics-design.md`
- 实现时:`docs/changelog/index.md` `[Unreleased]` 补充用户可见条目
- 纯 UI/分析展示,无新 system config 键
## 实现落点索引
| 区域 | 路径 |
|------|------|
| 限流页 | `frontend/app/(main)/rate-limits/` |
| 概览过滤复用 | `access-logs/components/overview-tab.tsx` 等 |
| Overview API | `AccessLogService.getOverview` |
| 排行 UI | `components/data/rank-card.tsx` |
| 趋势参考 | `components/data/trend-chart.tsx`(只参考样式,不扩展) |
## 决策摘要
| 决策 | 选择 |
|------|------|
| 页面结构 | Tabs:分析 / 配置 |
| 双轴 | 左 RPS,右 独立访客/桶 |
| 过滤 | 概览过滤 + 默认 24h 预设 |
| 排行 | 窗口平均 RPS = 总请求 / 窗口秒数 |
| 图表实现 | 专用 ECharts 组件(方案 A) |
| 后端 | 无新 API |
@@ -0,0 +1,122 @@
# WAF 规则编辑器:节点命名与拖放添加
日期:2026-07-19
范围:`/waf/rules/editor` 前端交互与类型对齐
状态:已确认,待实现
## 背景
当前 WAF 规则流图编辑器有两处体验问题:
1. 画布节点只显示类型固定名称(如「IP 匹配」),无法自定义命名,复杂规则难以区分。
2. 节点库通过点击添加,新节点落在固定偏移位置(`x: 240, y: 140 + n*24`),无法在目标位置放置。
后端 `RuleNode` 已具备 `label` 字段(`json:"label,omitempty"`),前端类型与 UI 尚未消费。
## 目标
1. 用户可为可编辑节点自定义**显示名称**(`label`),画布与属性栏一致展示。
2. 从节点库**拖放到画布**,在鼠标松手处生成节点;**取消点击固定位置添加**。
3. 不做备注字段、不做拖到连线中插入、不改后端 schema / `schema_version`。
## 非目标
- 节点备注 / note / remark
- 拖到边自动拆边插入
- 系统节点 `start` / `allow` 可改名
- 后端校验、编译或运行时语义变更
- 侧栏式节点库大改版
## 数据模型
### 后端(已有,不改)
```go
type RuleNode struct {
ID string `json:"id"`
Type RuleNodeType `json:"type"`
Label string `json:"label,omitempty"`
Position RulePosition `json:"position"`
Config json.RawMessage `json:"config"`
}
```
`label` 为空则 omit;现有大小限制与图校验保持不变。
### 前端
`WAFRuleNode` 各变体增加可选字段:
```ts
label?: string;
```
- 保存时:空字符串不写入或写 `undefined`,与 `omitempty` 对齐。
- 显示时:`label?.trim() || typeDefaultLabel`。
- 新建节点:不设 `label`(显示类型默认名)。
- `start` / `allow`:属性栏仍为「系统节点无需配置」,不提供改名输入;若历史数据带 `label`,画布仍可按上述规则显示,但不提供编辑入口。
## UI 行为
### 画布节点(`rule-node.tsx`)
| 区域 | 行为 |
|------|------|
| 主标题 | `label` 去空白后非空则用 `label`,否则用类型默认中文名 |
| 副标题 | 仍显示 `rule.id`(mono 小字) |
| 图标 / handle | 不变 |
### 属性栏(`node-properties.tsx`)
对非系统节点(`ip_match` | `geo_match` | `pow` | `block`),在类型专属配置**之上**增加:
- 字段标签:`显示名称`
- 控件:`Input`,受控绑定 `node.label ?? ''`
- 变更:`onChange({ ...node, label: value })`;清空时写 `''` 或去掉字段(实现任选其一,保存序列化时不落空 label)
系统节点保持现有文案。
### 节点库与添加(`node-library.tsx` + `rule-flow-canvas.tsx`)
1. 节点库项设为 `draggable`,`dragstart` 写入节点类型(如 `application/openflare-waf-node` 或等价自定义 MIME + `text/plain` 回退)。
2. 移除 `onClick` → `onAdd(type)` 的点击添加路径。
3. React Flow 画布容器:
- `onDragOver`:`preventDefault`,允许 drop
- `onDrop`:读取类型 → `screenToFlowPosition({ x: clientX, y: clientY })` → 创建节点(默认 config 逻辑与现有 `addNode` 相同,但 `position` 为落点)
4. 落点后选中新节点,清除边选中(与现有一致)。
5. 工具栏仍在画布左上角浮动区域,仅改为拖源,不改为侧栏。
## 实现落点(文件)
| 文件 | 变更 |
|------|------|
| `frontend/lib/services/openflare/types.ts` | `WAFRuleNode` 增加 `label?` |
| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | 标题显示逻辑 |
| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」字段 |
| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | 拖放源,去掉点击添加 |
| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | drop 落点创建;`addNode` 接受 position |
| 相关 `*.test.tsx` / `*.test.ts` | label 展示/编辑、拖放 payload、落点 |
可选:若序列化路径有显式字段白名单,确认 `label` 会进入保存 payload。
## 错误与边界
- 未知 / 非法 drag type:忽略 drop。
- 落在画布外:不创建。
- 超长 `label`:依赖后端既有图大小/字段限制;前端可不设硬上限,或与常见 Input 一致(如 64–128 字符)——实现阶段若后端有明确上限则对齐。
- Undo/脏检查:`label` 与 `position` 变更走现有 `onGraphChange` 路径,不新增独立历史机制。
## 测试要点
1. 有 `label` 的节点主标题为自定义名;无 `label` 为类型默认名。
2. 属性栏修改 `label` 后 graph 节点更新且画布同步。
3. 节点库项可拖;drop 后节点 `position` 接近 flow 坐标(允许测试中 mock `screenToFlowPosition`)。
4. 不再通过点击节点库按钮创建节点(无 click-add 行为)。
5. 系统节点属性栏仍无「显示名称」。
## 验收标准
- [ ] 可编辑节点可命名,保存再打开名称仍在。
- [ ] 画布显示自定义名(空则类型名)。
- [ ] 仅拖放添加,松手位置为节点位置。
- [ ] 无后端 API / schema 变更;`make code-check` 与相关 vitest 通过。
@@ -0,0 +1,191 @@
# WAF 规则节点:安全防护(security_check)
日期:2026-07-19
范围:WAF 编排图新节点 `security_check`(控制面校验/编译 + 边缘 Lua 特征检测 + 前端编辑器)
状态:已确认,待实现
## 背景
现有节点覆盖 IP / 地域 / UA / PoW,缺少请求载荷侧的基础攻击特征检测。产品需要在图中提供可编排的「安全防护」单元:多项基础规则可开关,**命中任意已启用规则返回 false**。
检测深度采用 **Lua 内置特征规则**(非 ModSecurity/CRS),能拦截常见扫描与明显 payload,允许有限误报/漏报。
## 目标
1. 新增 match 型节点 **`security_check`**,句柄 `true` / `false`。
2. 属性栏分组:**安全防护**说明 + **基础防护** 9 项 Switch。
3. 语义:**任一已启用规则命中 → false**;全部未命中 → true。
4. 默认仅开启误报较低的两项:**路径穿越**、**文件包含**;其余默认关闭。
## 非目标(v1)
- ModSecurity / OWASP CRS / libinjection 完整引擎
- 响应侧 XSS 检测、机器学习
- 自定义规则上传 / 严重级别评分 / 命中日志字段(可后续加)
- 无限制大 Body 全量扫描
## 节点模型
### 类型
| 字段 | 值 |
|------|-----|
| `type` | `security_check` |
| 句柄 | `true`, `false` |
| 可删除 / 可命名 / 可拖放 | 是 |
### Config
```json
{
"sql_injection": false,
"path_traversal": true,
"command_injection": false,
"xss": false,
"ssrf": false,
"file_inclusion": true,
"malicious_upload": false,
"xxe": false,
"crlf_injection": false
}
```
| 字段 | 默认 | UI 文案 | 检测面(v1) |
|------|------|---------|--------------|
| `sql_injection` | false | SQL 注入 | Query、Cookie、Referer、Body |
| `path_traversal` | **true** | 路径穿越防护 | Path(`uri`)、Query、Body |
| `command_injection` | false | 命令注入 | Query、Cookie、Referer、Body |
| `xss` | false | XSS | Query、Cookie、Referer、Body |
| `ssrf` | false | SSRF | Query、Cookie、Referer、Body 中 URL 形态 |
| `file_inclusion` | **true** | 文件包含(LFI/RFI) | Path(`uri`)、Query、Body |
| `malicious_upload` | false | 恶意文件上传 | Multipart Body |
| `xxe` | false | XXE | Body(Content-Type 含 xml 时) |
| `crlf_injection` | false | CRLF 注入 | Query、Cookie、Referer、Body |
全部关闭时:节点恒 **true**(空操作),合法。
## 求值语义
```
inputs := collect_inspection_strings(request) // 见下
for each enabled rule:
if rule_matches(rule, inputs) → return false
return true
```
- **false** = 命中攻击特征(接阻止)
- **true** = 未命中(接通过或其它节点)
### 采集与限制
| 来源 | 方式 |
|------|------|
| Path | 仅 `ngx.var.uri`(不重复扫完整 `request_uri`,避免与 Query 双计),URL 解码(含常见双重编码路径变体) |
| Query | `get_uri_args` 键与值(仅当已启用规则需要 Query) |
| Header | **不**扫描通用浏览器头(UA / Accept 等);注入类仅采 **Cookie、Referer** |
| Cookie | `ngx.var.http_cookie` |
| Body | 仅当已启用规则需要 Body 且 `Content-Length` > 0 且 ≤ **65536**;GET/零长度不 `read_body` |
Body 读取失败:跳过 Body 类检测并限频 warn(可用性优先,不 fail-closed 整图)。
### 规则特征方向(v1 模式包)
实现以可维护的模式表为准,下表为方向约束:
1. **SQL 注入**:`union select`、`or 1=1`、`sleep(`、`benchmark(`、注释符、十六进制/char 拼接等
2. **路径穿越**:`../`、`..\\`、`%2e%2e`、`%252e`、绝对路径探测
3. **命令注入**:`;` `|` `` ` `` `$()` 结合 shell 关键字、换行拼接
4. **XSS**:`<script`、`javascript:`、事件处理器 `onerror=` 等
5. **SSRF**:内网 IP、`localhost`、`169.254.`、`file://`、`gopher://`、`dict://`
6. **文件包含**:`php://`、`file://`、`/etc/passwd`、`%00` 等(可与路径穿越重叠)
7. **恶意上传**:multipart 文件名双扩展、危险扩展、可疑 Content-Type
8. **XXE**:`<!ENTITY`、`SYSTEM`、外部实体(仅 XML 类 Content-Type)
9. **CRLF**:`%0d%0a`、裸 `\r\n` 注入特征
模式在 worker 内缓存;大小写不敏感(除明确大小写敏感的协议串)。
## 控制面
### `graph_types.go`
- `RuleNodeSecurityCheck = "security_check"`
- `SecurityCheckConfig` 九个 `bool` 字段(JSON snake_case 如上)
### `graph_validate.go`
- `requiredHandles`: `true`, `false`
- 严格 JSON;仅允许已知布尔字段
### `graph_compile.go`
- 原样编译布尔字段进运行时配置
### 测试
- 合法全关 / 默认子集 / 全开
- 未知字段拒绝
- 编译保留默认
## 数据面
### `waf_runtime.lua`
```lua
elseif node.type == "security_check" then
handle = matches_security_check(node.config or {}) and "true" or "false"
```
`matches_security_check` 返回 **true 表示安全通过**(未命中),与 `ip_match` 的「条件成立」命名不同,但句柄语义与产品一致:命中攻击 → 走 `false` 边。
建议将模式表与匹配函数放在同文件或 `waf/security.lua`(若体积过大再拆,并在 `waf_assets.go` 嵌入)。
### `waf_runtime_spec.lua`
覆盖:默认配置拦路径穿越;全关放行;SQL/XSS 样例;Body 超限不炸;multipart 文件名危险扩展(若开启)。
## 前端
| 文件 | 变更 |
|------|------|
| `types.ts` | `security_check` + `SecurityCheckConfig` |
| `node-factory.ts` | 默认:path_traversal+file_inclusion true,其余 false |
| `node-library.tsx` | 「安全防护」+ 图标 |
| `rule-node.tsx` | `true`/`false` handles |
| `node-properties.tsx` | 显示名称;分组说明 + 9 Switch(问号 Tooltip) |
| `graph-validation.ts` / `editor-behavior.ts` | handles |
### 属性栏草图
```
显示名称
── 安全防护 ──
命中任意已启用规则返回 False [?]
── 基础防护 ──
[Switch] 路径穿越防护 [?]
[Switch] 文件包含(LFI/RFI) [?]
[Switch] SQL 注入 [?]
...
```
Tooltip 文案包含检测面与简要说明(与产品表一致)。
## 文档
- 更新 `docs/design/waf-orchestration-design.md` 节点表
- `docs/changelog/index.md` `[Unreleased]`
## 验收标准
- [ ] 可拖入并配置 9 开关,默认仅路径穿越+文件包含
- [ ] 保存/发布后 Agent 执行;命中 → false 边;未命中 → true
- [ ] 全关恒 true
- [ ] Lua/Go/前端相关测试与 `make code-check` 通过
## 风险
| 风险 | 缓解 |
|------|------|
| 误报 | 默认仅开低误报两项;模式偏保守 |
| 漏报 | 文档标明特征检测边界;后续可加强模式 |
| Body 性能 | 64KiB 上限;未启用 Body 规则不读 Body |
| 与路径/包含重叠 | 允许重叠;任一命中即 false |
@@ -0,0 +1,228 @@
# WAF 规则节点:UA 检查(ua_check)
日期:2026-07-19
范围:WAF 编排图新节点 `ua_check`(控制面校验/编译 + 边缘 Lua 运行时 + 前端编辑器)
状态:已确认,待实现
## 背景
访问日志概览已按 User-Agent 分类浏览器与操作系统(`internal/repository/analytics/browser.go`),但 WAF 规则图尚无基于 UA 的分支节点。运营需要在图中:
1. 要求请求必须携带 UA;
2. 按浏览器 / 操作系统做白名单匹配(and/or 可配);
3. 优先屏蔽常见爬虫与非正常 UA。
## 目标
- 新增 match 型节点 **`ua_check`**,输出 `true` / `false` 句柄(与 `ip_match` / `geo_match` 一致)。
- 属性栏交互与产品草图对齐:开启 UA 检查、匹配多选、屏蔽开关。
- 边缘分类标签与访问日志概览一致(同一套 token 规则)。
- 屏蔽逻辑优先级高于白名单匹配。
## 非目标
- 设备类型(Mobile/Tablet)维度。
- 原始 UA 正则 / 自由子串列表(PoW 列表已有,不并入本节点)。
- 在 Server 请求路径上执行 WAF 图(仍仅 Agent OpenResty)。
- 将 analytics 包直接 import 到 Agent(边缘用 Lua 复刻规则;Go 侧用同一规则表做校验与单测对拍)。
## 节点模型
### 类型
| 字段 | 值 |
|------|-----|
| `type` | `ua_check` |
| 句柄 | `true`, `false` |
| 可删除 | 是 |
| 可命名 | 是(`label`) |
| 可拖放添加 | 是 |
### Config(JSON)
```json
{
"require_ua": false,
"browsers": [],
"operating_systems": [],
"match_mode": "or",
"block_common_bots": false,
"block_abnormal_ua": false,
"block_custom_ua": false,
"custom_ua_patterns": []
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `require_ua` | bool | 开启后:请求头无 UA(空 / 仅空白)→ **false** |
| `browsers` | string[] | 白名单浏览器标签;空表示不限制浏览器 |
| `operating_systems` | string[] | 白名单操作系统标签;空表示不限制 OS |
| `match_mode` | `"and"` \| `"or"` | **浏览器条件与 OS 条件**之间的组合;默认 `"or"` |
| `block_common_bots` | bool | 屏蔽常见爬虫:分类 browser 或 os 为 `Bot` → **false** |
| `block_abnormal_ua` | bool | 屏蔽非正常 UA:browser ∈ `{Other, Unknown}`(**不含** Bot/搜索引擎爬虫)→ **false** |
| `block_custom_ua` | bool | 屏蔽自定义 UA:原始 UA 命中 `custom_ua_patterns` 任一条 → **false** |
| `custom_ua_patterns` | string[] | 正则列表(边缘为 Lua 模式);开启 `block_custom_ua` 时至少一条 |
默认值:开关全 `false`,列表空,`match_mode: "or"`。
### 允许的标签(封闭枚举)
与 `ParseBrowserName` / `ParseOSName` 输出对齐:
**browsers:**
`Chrome`, `Safari`, `Firefox`, `Edge`, `Opera`, `Chromium`, `WeChat`, `Postman`, `CLI`, `Bot`, `Unknown`, `Other`
**operating_systems:**
`Android`, `iOS`, `Windows`, `macOS`, `Chrome OS`, `Linux`, `Bot`, `Unknown`, `Other`
校验:列表元素必须属于上表;重复项编译时去重排序;未知字符串拒绝保存。
## 求值语义(边缘)
输入:`ua = http_user_agent`(trim 后判断空)。
分类:`browser = ParseBrowserName(ua)`,`os = ParseOSName(ua)`(空 UA → 二者均为 `Unknown`,与 analytics 一致)。
**严格顺序:**
```
1) if require_ua and ua 为空 → false
2) browser, os := classify(ua)
3) if block_common_bots and (browser == "Bot" or os == "Bot") → false
4) if block_abnormal_ua and browser in {"Other","Unknown"} → false
5) if block_custom_ua and UA matches any custom_ua_patterns → false
6) has_browsers := browsers 非空; has_os := operating_systems 非空
7) if not has_browsers and not has_os → true
8) browser_hit := browser ∈ browsers; os_hit := os ∈ operating_systems
9) if has_browsers and not has_os → browser_hit
10) if has_os and not has_browsers → os_hit
11) if both lists set:
match_mode == "and" → browser_hit and os_hit
match_mode == "or" → browser_hit or os_hit
```
说明:
- **屏蔽优先于匹配**:步骤 3–5 在白名单之前。
- **未配置匹配列表**:步骤 6 直接 true(仅受 require / block 约束)。
- **仅一侧列表有值**:只校验该侧是否命中;`match_mode` 仅在两侧都有值时生效。
- 节点本身不 allow/block,仅选句柄;下游连线决定动作。
### 示例
| 配置摘要 | 请求 | 结果 |
|----------|------|------|
| 仅 `require_ua` | 无 UA | false |
| 仅 `require_ua` | 正常 Chrome | true |
| `block_common_bots` | Googlebot | false |
| `block_abnormal_ua` | 无法识别 UA | false |
| browsers=`[Chrome]`, mode=or | Safari | false |
| browsers=`[Chrome]`, os=`[iOS]`, mode=and | Chrome Desktop | false(os 未命中) |
| browsers=`[Chrome]`, os=`[iOS]`, mode=or | Chrome Desktop | true |
| 列表皆空,无 block | 任意有 UA | true |
## 分类规则来源
权威实现(analytics):`internal/repository/analytics/browser.go` 中 `browserRules` / `osRules`。
实现要求:
1. **Lua 运行时**复刻相同 token 顺序与 `contains` / `noneOf` 语义(lower-case 子串)。
2. **Go 单测**用同一批样例 UA 对拍 `ParseBrowserName` / `ParseOSName` 与 Lua 或共享测试表,防止漂移。
3. 不强制本迭代抽取共享包;若抽取,须保持 analytics 与 WAF 行为不变。
## 控制面
### `graph_types.go`
- `RuleNodeUACheck RuleNodeType = "ua_check"`
- `UACheckConfig` 结构体对应上表 JSON 字段
### `graph_validate.go`
- `requiredHandles`: `true`, `false`
- `validateUACheckNodeConfig`:
- `match_mode` 仅 `and`/`or`(缺省按 `or` 或拒绝非法值)
- browsers / OS 标签 ∈ 封闭枚举
- 布尔字段默认 false
- `DisallowUnknownFields`
### `graph_compile.go`
- 编译进 `RuntimeRuleNode`,列表 `sortedUniqueStrings`
- 规范化 `match_mode`(非法不得编译成功)
### 测试
- validate:合法配置、非法标签、非法 mode、缺句柄
- compile:列表排序去重、默认值
## 数据面(Agent)
### `waf_runtime.lua`
在 `execute_graph` 增加:
```lua
elseif node.type == "ua_check" then
handle = matches_ua_check(node.config) and "true" or "false"
```
实现 `matches_ua_check` + 本地 classify 函数;读取 `ngx.var.http_user_agent`。
### `waf_runtime_spec.lua`
覆盖:空 UA + require;bot 屏蔽;abnormal;whitelist and/or;列表空;损坏边 fail-closed。
## 前端编辑器
| 文件 | 变更 |
|------|------|
| `types.ts` | `ua_check` 变体 + `UACheckConfig` |
| `node-factory.ts` | 标签「UA 检查」、默认 config、`AddableNodeType` |
| `node-library.tsx` | 拖放项 |
| `rule-node.tsx` | 图标 + `true`/`false` handles |
| `node-properties.tsx` | 属性 UI(见下) |
| `graph-validation.ts` | handles + 标签/mode 校验 |
| `editor-behavior.ts` | connection handles |
### 属性栏布局
```
显示名称
── UA 检查 ──
[Switch] 开启 UA 检查
说明:开启后如果请求头不携带 UA 返回 False
── UA 匹配 ──
匹配模式 Select: 或(or) / 且(and)
浏览器 MultiSelect(封闭枚举)
操作系统 MultiSelect(封闭枚举)
── 屏蔽 ──
说明:命中返回 false,优先级高于匹配
[Switch] 屏蔽常见爬虫 UA
[Switch] 屏蔽非正常 UA
```
前端选项列表写死与封闭枚举一致;展示可用中文副标题,**写入 config 的值必须是英文标签**(与 analytics / 边缘一致)。
## 文档
- 更新 `docs/design/waf-orchestration-design.md` 节点表(中文)。
- `docs/changelog/index.md` `[Unreleased]` 增加用户向说明。
- 纯设计文档不写 changelog 以外的英文同步。
## 验收标准
- [ ] 编辑器可拖入 `ua_check`,配置保存再打开一致。
- [ ] 图校验拒绝非法标签与非法 `match_mode`。
- [ ] 发布后 Agent Lua 按求值顺序分支;spec 全绿。
- [ ] 样例 UA 分类与访问日志 `ParseBrowserName`/`ParseOSName` 一致。
- [ ] `make code-check` 与相关 Go/前端/Lua 测试通过。
## 风险与缓解
| 风险 | 缓解 |
|------|------|
| Go/Lua 分类漂移 | 共享样例表单测对拍 |
| 「非正常」过严误伤 | 产品定义为 Bot/Other/Unknown;可关 switch |
| 白名单 + or 过宽 | UI 说明 and/or;默认 or 且列表空不限制 |
+595 -4
View File
@@ -2588,7 +2588,7 @@
"SessionCookie": []
}
],
"description": "返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限",
"description": "返回管理员可管理的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态;系统内部排程不会暴露,需要管理员权限",
"produces": [
"application/json"
],
@@ -2853,6 +2853,12 @@
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "定时任务不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "删除定时任务失败",
"schema": {
@@ -3731,6 +3737,12 @@
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "系统保留类型或存储只读",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
@@ -5549,6 +5561,12 @@
"description": "统计时间范围(小时)",
"name": "hours",
"in": "query"
},
{
"type": "integer",
"description": "趋势桶分钟数(1、5 或 60,默认 60)",
"name": "bucket_minutes",
"in": "query"
}
],
"responses": {
@@ -8571,7 +8589,7 @@
"SessionCookie": []
}
],
"description": "从用户提供的 HTTP(S) 链接下载部署包并创建部署记录;服务端使用浏览器伪装请求头拉取,允许内网地址与不安全 TLS 证书,需要管理员权限",
"description": "已弃用的一次性 URL 导入;使用 trusted_internal 策略兼容内网与自签名证书,不创建持久部署源",
"consumes": [
"application/json"
],
@@ -8582,6 +8600,7 @@
"openflare-pages"
],
"summary": "从 URL 导入 Pages 部署包",
"deprecated": true,
"parameters": [
{
"type": "integer",
@@ -8788,6 +8807,391 @@
}
}
},
"/api/v1/d/pages/{id}/source": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回项目部署源配置与运行状态,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-pages"
],
"summary": "获取 Pages 部署源",
"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/pages.SourceView"
}
}
}
]
}
},
"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/pages/{id}/source/check": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新",
"produces": [
"application/json"
],
"tags": [
"openflare-pages"
],
"summary": "检查 Pages 部署源",
"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/pages.SourceActionReceipt"
}
}
}
]
}
},
"400": {
"description": "当前来源不支持检查",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "部署源不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "来源任务正在执行",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/pages/{id}/source/delete": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "幂等删除持久部署源;已有部署历史与当前生产部署保持不变",
"produces": [
"application/json"
],
"tags": [
"openflare-pages"
],
"summary": "删除 Pages 部署源",
"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/pages.SourceView"
}
}
}
]
}
},
"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/pages/{id}/source/sync": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "异步下载、校验并原子激活来源部署包;空请求体与空 JSON 对象均有效",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-pages"
],
"summary": "同步并发布 Pages 部署源",
"parameters": [
{
"type": "integer",
"description": "项目 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "同步参数",
"name": "request",
"in": "body",
"schema": {
"$ref": "#/definitions/pages.SourceSyncInput"
}
}
],
"responses": {
"200": {
"description": "任务回执",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/pages.SourceActionReceipt"
}
}
}
]
}
},
"400": {
"description": "参数或来源类型无效",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "部署源不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "来源任务正在执行",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/pages/{id}/source/update": {
"post": {
"security": [
{
"SessionCookie": []
}
],
"description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"openflare-pages"
],
"summary": "更新 Pages 部署源",
"parameters": [
{
"type": "integer",
"description": "项目 ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "部署源配置",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/pages.SourceUpdateInput"
}
}
],
"responses": {
"200": {
"description": "更新结果",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/pages.SourceUpdateResult"
}
}
}
]
}
},
"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/pages/{id}/update": {
"post": {
"security": [
@@ -12528,6 +12932,12 @@
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "系统保留类型或存储只读",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
@@ -12722,6 +13132,12 @@
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"409": {
"description": "系统保留类型或存储只读",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
@@ -14567,11 +14983,20 @@
"deployment_id": {
"type": "integer"
},
"file_count": {
"type": "integer"
},
"hash": {
"type": "string"
},
"package_size": {
"type": "integer"
},
"project_id": {
"type": "integer"
},
"total_size": {
"type": "integer"
}
}
},
@@ -16448,6 +16873,9 @@
"observability.AccessLogOverview": {
"type": "object",
"properties": {
"bucket_minutes": {
"type": "integer"
},
"device_types": {
"type": "array",
"items": {
@@ -16561,9 +16989,15 @@
"observability.AccessLogView": {
"type": "object",
"properties": {
"bytes_sent": {
"type": "integer"
},
"cache_status": {
"type": "string"
},
"created_at": {
"type": "string"
},
"host": {
"type": "string"
},
@@ -16588,6 +17022,12 @@
"remote_addr": {
"type": "string"
},
"request_length": {
"type": "integer"
},
"request_time_ms": {
"type": "integer"
},
"status_code": {
"type": "integer"
},
@@ -17283,12 +17723,21 @@
"project_id": {
"type": "integer"
},
"source_label": {
"type": "string"
},
"source_type": {
"type": "string"
},
"status": {
"type": "string"
},
"total_size": {
"type": "integer"
},
"trigger_type": {
"type": "string"
},
"upload_id": {
"type": "string",
"example": "0"
@@ -17336,6 +17785,144 @@
}
}
},
"pages.SourceActionReceipt": {
"type": "object",
"properties": {
"action": {
"type": "string"
},
"execution_id": {
"type": "string"
},
"task_id": {
"type": "string"
}
}
},
"pages.SourceRevisionView": {
"type": "object",
"properties": {
"asset_name": {
"type": "string"
},
"label": {
"type": "string"
},
"revision": {
"type": "string"
}
}
},
"pages.SourceSyncInput": {
"type": "object",
"properties": {
"confirmed_revision": {
"type": "string"
}
}
},
"pages.SourceUpdateInput": {
"type": "object",
"properties": {
"allow_insecure": {
"type": "boolean"
},
"asset_name": {
"type": "string"
},
"auto_update_enabled": {
"type": "boolean"
},
"check_interval_minutes": {
"type": "integer"
},
"release_selector": {
"type": "string"
},
"release_tag": {
"type": "string"
},
"remote_url": {
"type": "string"
},
"repository_url": {
"type": "string"
},
"source_type": {
"type": "string"
}
}
},
"pages.SourceUpdateResult": {
"type": "object",
"properties": {
"check_task": {
"$ref": "#/definitions/pages.SourceActionReceipt"
},
"source": {
"$ref": "#/definitions/pages.SourceView"
},
"warning": {
"type": "string"
}
}
},
"pages.SourceView": {
"type": "object",
"properties": {
"allow_insecure": {
"type": "boolean"
},
"asset_name": {
"type": "string"
},
"auto_update_enabled": {
"type": "boolean"
},
"check_interval_minutes": {
"type": "integer"
},
"github_repository": {
"type": "string"
},
"last_applied": {
"$ref": "#/definitions/pages.SourceRevisionView"
},
"last_checked_at": {
"type": "string"
},
"last_error": {
"type": "string"
},
"last_seen": {
"$ref": "#/definitions/pages.SourceRevisionView"
},
"last_synced_at": {
"type": "string"
},
"next_check_at": {
"type": "string"
},
"release_selector": {
"type": "string"
},
"release_tag": {
"type": "string"
},
"remote_url": {
"type": "string"
},
"source_type": {
"type": "string"
},
"sync_status": {
"type": "string"
},
"update_available": {
"type": "boolean"
}
}
},
"pages.UploadFromURLInput": {
"type": "object",
"properties": {
@@ -19303,7 +19890,9 @@
"block",
"ip_match",
"geo_match",
"pow"
"pow",
"ua_check",
"security_check"
],
"x-enum-varnames": [
"RuleNodeStart",
@@ -19311,7 +19900,9 @@
"RuleNodeBlock",
"RuleNodeIPMatch",
"RuleNodeGeoMatch",
"RuleNodePoW"
"RuleNodePoW",
"RuleNodeUACheck",
"RuleNodeSecurityCheck"
]
},
"waf.RulePosition": {
+372 -2
View File
@@ -760,10 +760,16 @@ definitions:
properties:
deployment_id:
type: integer
file_count:
type: integer
hash:
type: string
package_size:
type: integer
project_id:
type: integer
total_size:
type: integer
type: object
github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup:
properties:
@@ -2011,6 +2017,8 @@ definitions:
type: object
observability.AccessLogOverview:
properties:
bucket_minutes:
type: integer
device_types:
items:
$ref: '#/definitions/observability.DistributionItem'
@@ -2085,8 +2093,12 @@ definitions:
type: object
observability.AccessLogView:
properties:
bytes_sent:
type: integer
cache_status:
type: string
created_at:
type: string
host:
type: string
id:
@@ -2103,6 +2115,10 @@ definitions:
type: string
remote_addr:
type: string
request_length:
type: integer
request_time_ms:
type: integer
status_code:
type: integer
user_agent:
@@ -2557,10 +2573,16 @@ definitions:
type: integer
project_id:
type: integer
source_label:
type: string
source_type:
type: string
status:
type: string
total_size:
type: integer
trigger_type:
type: string
upload_id:
example: "0"
type: string
@@ -2592,6 +2614,96 @@ definitions:
spa_fallback_path:
type: string
type: object
pages.SourceActionReceipt:
properties:
action:
type: string
execution_id:
type: string
task_id:
type: string
type: object
pages.SourceRevisionView:
properties:
asset_name:
type: string
label:
type: string
revision:
type: string
type: object
pages.SourceSyncInput:
properties:
confirmed_revision:
type: string
type: object
pages.SourceUpdateInput:
properties:
allow_insecure:
type: boolean
asset_name:
type: string
auto_update_enabled:
type: boolean
check_interval_minutes:
type: integer
release_selector:
type: string
release_tag:
type: string
remote_url:
type: string
repository_url:
type: string
source_type:
type: string
type: object
pages.SourceUpdateResult:
properties:
check_task:
$ref: '#/definitions/pages.SourceActionReceipt'
source:
$ref: '#/definitions/pages.SourceView'
warning:
type: string
type: object
pages.SourceView:
properties:
allow_insecure:
type: boolean
asset_name:
type: string
auto_update_enabled:
type: boolean
check_interval_minutes:
type: integer
github_repository:
type: string
last_applied:
$ref: '#/definitions/pages.SourceRevisionView'
last_checked_at:
type: string
last_error:
type: string
last_seen:
$ref: '#/definitions/pages.SourceRevisionView'
last_synced_at:
type: string
next_check_at:
type: string
release_selector:
type: string
release_tag:
type: string
remote_url:
type: string
source_type:
type: string
sync_status:
type: string
update_available:
type: boolean
type: object
pages.UploadFromURLInput:
properties:
url:
@@ -3908,6 +4020,8 @@ definitions:
- ip_match
- geo_match
- pow
- ua_check
- security_check
type: string
x-enum-varnames:
- RuleNodeStart
@@ -3916,6 +4030,8 @@ definitions:
- RuleNodeIPMatch
- RuleNodeGeoMatch
- RuleNodePoW
- RuleNodeUACheck
- RuleNodeSecurityCheck
waf.RulePosition:
properties:
x:
@@ -5601,7 +5717,7 @@ paths:
- admin
/api/v1/admin/tasks/schedules:
get:
description: 返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限
description: 返回管理员可管理的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态;系统内部排程不会暴露,需要管理员权限
produces:
- application/json
responses:
@@ -5706,6 +5822,10 @@ paths:
description: 无管理员权限
schema:
$ref: '#/definitions/response.Any'
"404":
description: 定时任务不存在
schema:
$ref: '#/definitions/response.Any'
"500":
description: 删除定时任务失败
schema:
@@ -6157,6 +6277,10 @@ paths:
description: 文件不存在
schema:
$ref: '#/definitions/response.Any'
"409":
description: 系统保留类型或存储只读
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 删除文件
@@ -7398,6 +7522,10 @@ paths:
in: query
name: hours
type: integer
- description: 趋势桶分钟数(1、5 或 60,默认 60)
in: query
name: bucket_minutes
type: integer
produces:
- application/json
responses:
@@ -9257,7 +9385,8 @@ paths:
post:
consumes:
- application/json
description: 从用户提供的 HTTP(S) 链接下载部署包并创建部署记录;服务端使用浏览器伪装请求头拉取,允许内网地址与不安全 TLS 证书,需要管理员权限
deprecated: true
description: 已弃用的一次性 URL 导入;使用 trusted_internal 策略兼容内网与自签名证书,不创建持久部署源
parameters:
- description: 项目 ID
in: path
@@ -9303,6 +9432,239 @@ paths:
summary: 从 URL 导入 Pages 部署包
tags:
- openflare-pages
/api/v1/d/pages/{id}/source:
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/pages.SourceView'
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: 获取 Pages 部署源
tags:
- openflare-pages
/api/v1/d/pages/{id}/source/check:
post:
description: 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新
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/pages.SourceActionReceipt'
type: object
"400":
description: 当前来源不支持检查
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 部署源不存在
schema:
$ref: '#/definitions/response.Any'
"409":
description: 来源任务正在执行
schema:
$ref: '#/definitions/response.Any'
"500":
description: 内部错误
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 检查 Pages 部署源
tags:
- openflare-pages
/api/v1/d/pages/{id}/source/delete:
post:
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/pages.SourceView'
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: 删除 Pages 部署源
tags:
- openflare-pages
/api/v1/d/pages/{id}/source/sync:
post:
consumes:
- application/json
description: 异步下载、校验并原子激活来源部署包;空请求体与空 JSON 对象均有效
parameters:
- description: 项目 ID
in: path
name: id
required: true
type: integer
- description: 同步参数
in: body
name: request
schema:
$ref: '#/definitions/pages.SourceSyncInput'
produces:
- application/json
responses:
"200":
description: 任务回执
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/pages.SourceActionReceipt'
type: object
"400":
description: 参数或来源类型无效
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 部署源不存在
schema:
$ref: '#/definitions/response.Any'
"409":
description: 来源任务正在执行
schema:
$ref: '#/definitions/response.Any'
"500":
description: 内部错误
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 同步并发布 Pages 部署源
tags:
- openflare-pages
/api/v1/d/pages/{id}/source/update:
post:
consumes:
- application/json
description: 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回
parameters:
- description: 项目 ID
in: path
name: id
required: true
type: integer
- description: 部署源配置
in: body
name: request
required: true
schema:
$ref: '#/definitions/pages.SourceUpdateInput'
produces:
- application/json
responses:
"200":
description: 更新结果
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/pages.SourceUpdateResult'
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: 更新 Pages 部署源
tags:
- openflare-pages
/api/v1/d/pages/{id}/update:
post:
consumes:
@@ -11589,6 +11951,10 @@ paths:
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"409":
description: 系统保留类型或存储只读
schema:
$ref: '#/definitions/response.Any'
"500":
description: 内部错误
schema:
@@ -11622,6 +11988,10 @@ paths:
description: 文件不存在
schema:
$ref: '#/definitions/response.Any'
"409":
description: 系统保留类型或存储只读
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 删除我的文件
@@ -9,6 +9,9 @@ export type SearchDraft = {
export type OverviewRangeHours = 24 | 168 | 360 | 720;
/** 限流分析等短窗口场景:仅 24 小时 / 3 天 */
export type RateLimitRangeHours = 24 | 72;
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
export const OVERVIEW_RANGE_OPTIONS: {
@@ -21,6 +24,14 @@ export const OVERVIEW_RANGE_OPTIONS: {
{ value: 720, label: '30 天' },
];
export const RATE_LIMIT_RANGE_OPTIONS: {
value: RateLimitRangeHours;
label: string;
}[] = [
{ value: 24, label: '24 小时' },
{ value: 72, label: '3 天' },
];
export const DETAIL_SORT_OPTIONS = [
{ value: 'logged_at:desc', label: '时间从新到旧' },
{ value: 'logged_at:asc', label: '时间从旧到新' },
@@ -1,10 +1,8 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import { ChevronDown, Filter, Loader2, X } from 'lucide-react';
import { Cell, Pie, PieChart } from 'recharts';
import { RankCard } from '@/components/data/rank-card';
@@ -12,8 +10,6 @@ import { TrendChart } from '@/components/data/trend-chart';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
@@ -29,34 +25,18 @@ import {
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
ZoneService,
zoneQueryKey,
type AccessLogOverview,
type DistributionItem,
import type {
AccessLogOverview,
DistributionItem,
} from '@/lib/services/openflare';
import { cn } from '@/lib/utils';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import {
formatOverviewRangeHint,
formatOverviewTrendLabel,
OVERVIEW_RANGE_OPTIONS,
type OverviewRangeHours,
} from './access-log-utils';
import { OverviewToolbar } from './overview-toolbar';
const DEVICE_COLORS = [
'#38bdf8',
@@ -274,327 +254,6 @@ function PieDistributionCard({
);
}
type ManagedZoneDomains = {
zoneId: number;
zoneName: string;
domains: string[];
};
function useManagedZoneDomains(enabled: boolean) {
return useQuery({
queryKey: [...zoneQueryKey, 'zone-domain-tree'],
enabled,
staleTime: 60_000,
queryFn: async (): Promise<ManagedZoneDomains[]> => {
const zones = await ZoneService.list();
const overviews = await Promise.all(
zones.map((zone) => ZoneService.getOverview(zone.id)),
);
return overviews
.map((overview) => {
const domainSet = new Set<string>();
for (const item of overview.domains ?? []) {
const domain = item.domain?.trim();
if (domain) domainSet.add(domain);
}
return {
zoneId: overview.zone.id,
zoneName: overview.zone.domain || `Zone #${overview.zone.id}`,
domains: Array.from(domainSet).sort((a, b) => a.localeCompare(b)),
};
})
.filter((zone) => zone.domains.length > 0)
.sort((a, b) => a.zoneName.localeCompare(b.zoneName));
},
});
}
function OverviewHostFilter({
hosts,
onHostsChange,
}: {
hosts: string[];
onHostsChange: (hosts: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [expandedZones, setExpandedZones] = useState<Record<number, boolean>>(
{},
);
const zonesQuery = useManagedZoneDomains(open);
const zones = useMemo(() => zonesQuery.data ?? [], [zonesQuery.data]);
const selectedSet = useMemo(() => new Set(hosts), [hosts]);
const filteredZones = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return zones;
return zones
.map((zone) => {
const zoneMatched = zone.zoneName.toLowerCase().includes(q);
const domains = zoneMatched
? zone.domains
: zone.domains.filter((domain) => domain.toLowerCase().includes(q));
return { ...zone, domains };
})
.filter((zone) => zone.domains.length > 0);
}, [query, zones]);
useEffect(() => {
if (!open || zones.length === 0) return;
setExpandedZones((prev) => {
const next = { ...prev };
let changed = false;
for (const zone of zones) {
if (next[zone.zoneId] === undefined) {
next[zone.zoneId] = true;
changed = true;
}
}
return changed ? next : prev;
});
}, [open, zones]);
useEffect(() => {
const q = query.trim();
if (!q || filteredZones.length === 0) return;
setExpandedZones((prev) => {
const next = { ...prev };
for (const zone of filteredZones) {
next[zone.zoneId] = true;
}
return next;
});
}, [filteredZones, query]);
const toggleHost = (domain: string, checked: boolean | 'indeterminate') => {
if (checked === true) {
if (selectedSet.has(domain)) return;
onHostsChange([...hosts, domain]);
return;
}
onHostsChange(hosts.filter((item) => item !== domain));
};
const toggleZone = (
zoneDomains: string[],
checked: boolean | 'indeterminate',
) => {
if (checked === true) {
const next = new Set(hosts);
for (const domain of zoneDomains) next.add(domain);
onHostsChange(Array.from(next));
return;
}
onHostsChange(hosts.filter((item) => !zoneDomains.includes(item)));
};
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery('');
}}
>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
size='icon'
className={cn(
'size-8 shrink-0',
hosts.length > 0 ? 'border-primary text-primary' : undefined,
)}
title={
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
}
aria-label={
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
}
>
<Filter className='size-3.5' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' className='w-96 space-y-3 p-3'>
<div className='flex items-center justify-between gap-2'>
<p className='text-sm font-medium'>筛选域名</p>
{hosts.length > 0 ? (
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 px-2 text-xs'
onClick={() => onHostsChange([])}
>
<X className='mr-1 size-3' />
清除
</Button>
) : null}
</div>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder='搜索 Zone 或域名'
className='h-8 text-xs'
/>
<div className='max-h-72 space-y-2 overflow-y-auto hide-scrollbar'>
{zonesQuery.isLoading ? (
<div className='flex items-center justify-center gap-2 py-6 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
加载域名…
</div>
) : zonesQuery.isError ? (
<div className='space-y-2 py-2'>
<p className='text-xs text-destructive'>加载域名失败</p>
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => void zonesQuery.refetch()}
>
重试
</Button>
</div>
) : filteredZones.length === 0 ? (
<p className='py-6 text-center text-xs text-muted-foreground'>
{zones.length === 0 ? '暂无已登记域名' : '没有匹配的域名'}
</p>
) : (
filteredZones.map((zone) => {
const selectedCount = zone.domains.filter((domain) =>
selectedSet.has(domain),
).length;
const allSelected = selectedCount === zone.domains.length;
const partialSelected =
selectedCount > 0 && selectedCount < zone.domains.length;
const expanded = expandedZones[zone.zoneId] ?? true;
return (
<Collapsible
key={zone.zoneId}
open={expanded}
onOpenChange={(next) =>
setExpandedZones((prev) => ({
...prev,
[zone.zoneId]: next,
}))
}
className='rounded-md border border-dashed'
>
<div className='flex items-center gap-1 px-2 py-1.5'>
<Checkbox
checked={
allSelected
? true
: partialSelected
? 'indeterminate'
: false
}
onCheckedChange={(checked) =>
toggleZone(zone.domains, checked)
}
aria-label={`选择 Zone ${zone.zoneName}`}
/>
<CollapsibleTrigger asChild>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-1 rounded-md px-1 py-0.5 text-left text-xs font-medium hover:bg-accent'
>
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition-transform',
expanded ? 'rotate-0' : '-rotate-90',
)}
/>
<span className='min-w-0 flex-1 truncate'>
{zone.zoneName}
</span>
<span className='text-[10px] text-muted-foreground'>
{selectedCount}/{zone.domains.length}
</span>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className='space-y-0.5 border-t border-dashed px-2 py-1.5'>
{zone.domains.map((domain) => {
const selected = selectedSet.has(domain);
return (
<label
key={domain}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent',
selected ? 'bg-accent/50' : undefined,
)}
>
<Checkbox
checked={selected}
onCheckedChange={(checked) =>
toggleHost(domain, checked)
}
aria-label={`选择域名 ${domain}`}
/>
<span className='min-w-0 flex-1 truncate font-mono'>
{domain}
</span>
</label>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}
function OverviewToolbar({
hours,
hosts,
onHoursChange,
onHostsChange,
}: {
hours: OverviewRangeHours;
hosts: string[];
onHoursChange: (hours: OverviewRangeHours) => void;
onHostsChange: (hosts: string[]) => void;
}) {
return (
<div className='flex flex-wrap items-center justify-end gap-2'>
{hosts.length > 0 ? (
<Badge variant='secondary' className='max-w-[260px] truncate'>
{hosts.length === 1 ? hosts[0] : `已选 ${hosts.length} 个域名`}
</Badge>
) : null}
<OverviewHostFilter hosts={hosts} onHostsChange={onHostsChange} />
<ToggleGroup
type='single'
value={String(hours)}
onValueChange={(value) => {
if (!value) return;
onHoursChange(Number.parseInt(value, 10) as OverviewRangeHours);
}}
variant='outline'
size='sm'
className='justify-end'
>
{OVERVIEW_RANGE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}
export function OverviewTab({
data,
loading,
@@ -0,0 +1,355 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ChevronDown, Filter, Loader2, X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { ZoneService, zoneQueryKey } from '@/lib/services/openflare';
import { cn } from '@/lib/utils';
import {
OVERVIEW_RANGE_OPTIONS,
RATE_LIMIT_RANGE_OPTIONS,
type OverviewRangeHours,
type RateLimitRangeHours,
} from './access-log-utils';
type ManagedZoneDomains = {
zoneId: number;
zoneName: string;
domains: string[];
};
function useManagedZoneDomains(enabled: boolean) {
return useQuery({
queryKey: [...zoneQueryKey, 'zone-domain-tree'],
enabled,
staleTime: 60_000,
queryFn: async (): Promise<ManagedZoneDomains[]> => {
const zones = await ZoneService.list();
const overviews = await Promise.all(
zones.map((zone) => ZoneService.getOverview(zone.id)),
);
return overviews
.map((overview) => {
const domainSet = new Set<string>();
for (const item of overview.domains ?? []) {
const domain = item.domain?.trim();
if (domain) domainSet.add(domain);
}
return {
zoneId: overview.zone.id,
zoneName: overview.zone.domain || `Zone #${overview.zone.id}`,
domains: Array.from(domainSet).sort((a, b) => a.localeCompare(b)),
};
})
.filter((zone) => zone.domains.length > 0)
.sort((a, b) => a.zoneName.localeCompare(b.zoneName));
},
});
}
export function OverviewHostFilter({
hosts,
onHostsChange,
}: {
hosts: string[];
onHostsChange: (hosts: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [expandedZones, setExpandedZones] = useState<Record<number, boolean>>(
{},
);
const zonesQuery = useManagedZoneDomains(open);
const zones = useMemo(() => zonesQuery.data ?? [], [zonesQuery.data]);
const selectedSet = useMemo(() => new Set(hosts), [hosts]);
const filteredZones = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return zones;
return zones
.map((zone) => {
const zoneMatched = zone.zoneName.toLowerCase().includes(q);
const domains = zoneMatched
? zone.domains
: zone.domains.filter((domain) => domain.toLowerCase().includes(q));
return { ...zone, domains };
})
.filter((zone) => zone.domains.length > 0);
}, [query, zones]);
useEffect(() => {
if (!open || zones.length === 0) return;
setExpandedZones((prev) => {
const next = { ...prev };
let changed = false;
for (const zone of zones) {
if (next[zone.zoneId] === undefined) {
next[zone.zoneId] = true;
changed = true;
}
}
return changed ? next : prev;
});
}, [open, zones]);
useEffect(() => {
const q = query.trim();
if (!q || filteredZones.length === 0) return;
setExpandedZones((prev) => {
const next = { ...prev };
for (const zone of filteredZones) {
next[zone.zoneId] = true;
}
return next;
});
}, [filteredZones, query]);
const toggleHost = (domain: string, checked: boolean | 'indeterminate') => {
if (checked === true) {
if (selectedSet.has(domain)) return;
onHostsChange([...hosts, domain]);
return;
}
onHostsChange(hosts.filter((item) => item !== domain));
};
const toggleZone = (
zoneDomains: string[],
checked: boolean | 'indeterminate',
) => {
if (checked === true) {
const next = new Set(hosts);
for (const domain of zoneDomains) next.add(domain);
onHostsChange(Array.from(next));
return;
}
onHostsChange(hosts.filter((item) => !zoneDomains.includes(item)));
};
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery('');
}}
>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
size='icon'
className={cn(
'size-8 shrink-0',
hosts.length > 0 ? 'border-primary text-primary' : undefined,
)}
title={
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
}
aria-label={
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
}
>
<Filter className='size-3.5' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' className='w-96 space-y-3 p-3'>
<div className='flex items-center justify-between gap-2'>
<p className='text-sm font-medium'>筛选域名</p>
{hosts.length > 0 ? (
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 px-2 text-xs'
onClick={() => onHostsChange([])}
>
<X className='mr-1 size-3' />
清除
</Button>
) : null}
</div>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder='搜索 Zone 或域名'
className='h-8 text-xs'
/>
<div className='max-h-72 space-y-2 overflow-y-auto hide-scrollbar'>
{zonesQuery.isLoading ? (
<div className='flex items-center justify-center gap-2 py-6 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
加载域名…
</div>
) : zonesQuery.isError ? (
<div className='space-y-2 py-2'>
<p className='text-xs text-destructive'>加载域名失败</p>
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => void zonesQuery.refetch()}
>
重试
</Button>
</div>
) : filteredZones.length === 0 ? (
<p className='py-6 text-center text-xs text-muted-foreground'>
{zones.length === 0 ? '暂无已登记域名' : '没有匹配的域名'}
</p>
) : (
filteredZones.map((zone) => {
const selectedCount = zone.domains.filter((domain) =>
selectedSet.has(domain),
).length;
const allSelected = selectedCount === zone.domains.length;
const partialSelected =
selectedCount > 0 && selectedCount < zone.domains.length;
const expanded = expandedZones[zone.zoneId] ?? true;
return (
<Collapsible
key={zone.zoneId}
open={expanded}
onOpenChange={(next) =>
setExpandedZones((prev) => ({
...prev,
[zone.zoneId]: next,
}))
}
className='rounded-md border border-dashed'
>
<div className='flex items-center gap-1 px-2 py-1.5'>
<Checkbox
checked={
allSelected
? true
: partialSelected
? 'indeterminate'
: false
}
onCheckedChange={(checked) =>
toggleZone(zone.domains, checked)
}
aria-label={`选择 Zone ${zone.zoneName}`}
/>
<CollapsibleTrigger asChild>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-1 rounded-md px-1 py-0.5 text-left text-xs font-medium hover:bg-accent'
>
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition-transform',
expanded ? 'rotate-0' : '-rotate-90',
)}
/>
<span className='min-w-0 flex-1 truncate'>
{zone.zoneName}
</span>
<span className='text-[10px] text-muted-foreground'>
{selectedCount}/{zone.domains.length}
</span>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className='space-y-0.5 border-t border-dashed px-2 py-1.5'>
{zone.domains.map((domain) => {
const selected = selectedSet.has(domain);
return (
<label
key={domain}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent',
selected ? 'bg-accent/50' : undefined,
)}
>
<Checkbox
checked={selected}
onCheckedChange={(checked) =>
toggleHost(domain, checked)
}
aria-label={`选择域名 ${domain}`}
/>
<span className='min-w-0 flex-1 truncate font-mono'>
{domain}
</span>
</label>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}
export function OverviewToolbar({
hours,
hosts,
onHoursChange,
onHostsChange,
rangeOptions = OVERVIEW_RANGE_OPTIONS,
}: {
hours: OverviewRangeHours | RateLimitRangeHours;
hosts: string[];
onHoursChange: (hours: OverviewRangeHours) => void;
onHostsChange: (hosts: string[]) => void;
rangeOptions?: ReadonlyArray<{ value: number; label: string }>;
}) {
return (
<div className='flex flex-wrap items-center justify-end gap-2'>
{hosts.length > 0 ? (
<Badge variant='secondary' className='max-w-[260px] truncate'>
{hosts.length === 1 ? hosts[0] : `已选 ${hosts.length} 个域名`}
</Badge>
) : null}
<OverviewHostFilter hosts={hosts} onHostsChange={onHostsChange} />
<ToggleGroup
type='single'
value={String(hours)}
onValueChange={(value) => {
if (!value) return;
onHoursChange(Number.parseInt(value, 10) as OverviewRangeHours);
}}
variant='outline'
size='sm'
className='justify-end'
>
{rangeOptions.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}
export { RATE_LIMIT_RANGE_OPTIONS };
@@ -37,6 +37,7 @@ import type {
import { NodeService } from '@/lib/services/openflare';
import { AgentUpdateDialog } from './agent-update-dialog';
import { InstallCommand } from './install-command';
import { NodeDetailShell } from './node-detail-shell';
import {
NodeErrorBanner,
@@ -199,7 +200,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
</>
);
const overviewTab = (
const manageTab = (
<div className='space-y-6'>
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
@@ -277,32 +278,8 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
</div>
</NodeSectionCard>
</div>
</div>
);
const manageTab = (
<div className='space-y-6'>
<NodeSectionCard
title='节点标识'
description='用于 Agent 接入与鉴别的核心字段'
>
<div className='divide-y'>
<NodeInfoRow label='节点 ID'>
<span className='font-mono text-xs break-all'>{node.node_id}</span>
</NodeInfoRow>
<NodeInfoRow label='Agent Token'>
<span className='font-mono text-xs break-all'>
{node.access_token || '暂无'}
</span>
</NodeInfoRow>
<NodeInfoRow label='创建时间'>
{formatDateTime(node.created_at)}
</NodeInfoRow>
<NodeInfoRow label='更新时间'>
{formatDateTime(node.updated_at)}
</NodeInfoRow>
</div>
</NodeSectionCard>
<InstallCommand node={node} variant='edge' />
<NodeSectionCard
title='运行消息'
@@ -361,8 +338,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
icon: Activity,
},
]}
overview={overviewTab}
dashboard={
overview={
<NodeObservability
nodeId={node.id}
node={node}
@@ -18,6 +18,7 @@ import { Label } from '@/components/ui/label';
import { type NodeItem, StatusService } from '@/lib/services/openflare';
import {
buildEdgeDockerInstallCommand,
buildRelayDockerInstallCommand,
buildRelayInstallCommand,
buildTunnelDockerInstallCommand,
@@ -25,7 +26,7 @@ import {
getServerUrl,
} from './node-utils';
type InstallVariant = 'relay' | 'tunnel';
type InstallVariant = 'edge' | 'relay' | 'tunnel';
const variantMeta: Record<
InstallVariant,
@@ -33,10 +34,16 @@ const variantMeta: Record<
title: string;
description: string;
tokenLabel: string;
scriptLabel: string;
scriptLabel?: string;
dockerLabel: string;
}
> = {
edge: {
title: '边缘节点部署',
description: '使用 Agent Token 将边缘节点接入控制端。',
tokenLabel: 'Agent Token',
dockerLabel: 'Docker 容器部署',
},
relay: {
title: '中继部署命令',
description: '使用 Discovery Token 将 frps 中继节点接入控制端。',
@@ -79,7 +86,7 @@ export function InstallCommand({
const normalizedServerUrl = getServerUrl(serverUrl);
const scriptCommand = useMemo(() => {
if (!normalizedServerUrl || !node.access_token) {
if (!normalizedServerUrl || !node.access_token || variant === 'edge') {
return '';
}
return variant === 'relay'
@@ -91,6 +98,13 @@ export function InstallCommand({
if (!normalizedServerUrl || !node.access_token) {
return '';
}
if (variant === 'edge') {
return buildEdgeDockerInstallCommand(
normalizedServerUrl,
node.access_token,
serverVersion,
);
}
return variant === 'relay'
? buildRelayDockerInstallCommand(
normalizedServerUrl,
@@ -190,12 +204,14 @@ export function InstallCommand({
</p>
) : (
<>
<div className='space-y-2'>
<p className='text-sm font-medium'>{meta.scriptLabel}</p>
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
{scriptCommand}
</pre>
</div>
{scriptCommand && meta.scriptLabel ? (
<div className='space-y-2'>
<p className='text-sm font-medium'>{meta.scriptLabel}</p>
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
{scriptCommand}
</pre>
</div>
) : null}
<div className='space-y-2'>
<p className='text-sm font-medium'>{meta.dockerLabel}</p>
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
@@ -14,7 +14,7 @@ import { NodeKpiCard } from './node-detail-primitives';
import { NodeStatusBadge } from './node-status-badge';
import type { StatusTone } from './node-utils';
export type NodeDetailTabId = 'overview' | 'dashboard' | 'manage';
export type NodeDetailTabId = 'overview' | 'manage';
export type NodeDetailTabConfig = {
id: NodeDetailTabId;
@@ -33,18 +33,20 @@ const TAB_CONFIGS: NodeDetailTabConfig[] = [
id: 'overview',
label: '概览',
},
{
id: 'dashboard',
label: '数据看板',
},
{
id: 'manage',
label: '配置与部署',
label: '状态与部署',
},
];
function isValidTab(value: string | null): value is NodeDetailTabId {
return value === 'overview' || value === 'dashboard' || value === 'manage';
function resolveTab(value: string | null): NodeDetailTabId | null {
if (value === 'overview' || value === 'dashboard') {
return 'overview';
}
if (value === 'manage') {
return 'manage';
}
return null;
}
export function NodeDetailShell({
@@ -55,7 +57,6 @@ export function NodeDetailShell({
actions,
kpis,
overview,
dashboard,
manage,
defaultTab = 'overview',
}: {
@@ -66,7 +67,6 @@ export function NodeDetailShell({
actions: ReactNode;
kpis: NodeDetailKpi[];
overview: ReactNode;
dashboard: ReactNode;
manage: ReactNode;
defaultTab?: NodeDetailTabId;
}) {
@@ -76,7 +76,7 @@ export function NodeDetailShell({
const activeTab = useMemo(() => {
const tab = searchParams.get('tab');
return isValidTab(tab) ? tab : defaultTab;
return resolveTab(tab) ?? defaultTab;
}, [defaultTab, searchParams]);
const setActiveTab = useCallback(
@@ -88,6 +88,16 @@ export function NodeDetailShell({
[pathname, router, searchParams],
);
const handleTabChange = useCallback(
(value: string) => {
const tab = resolveTab(value);
if (tab) {
setActiveTab(tab);
}
},
[setActiveTab],
);
return (
<div className='py-6 px-1 space-y-6'>
<section className='overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30'>
@@ -144,11 +154,7 @@ export function NodeDetailShell({
<Tabs
value={activeTab}
onValueChange={(value) => {
if (isValidTab(value)) {
setActiveTab(value);
}
}}
onValueChange={handleTabChange}
className='w-full gap-0'
>
<div className='space-y-3 pb-1'>
@@ -174,9 +180,6 @@ export function NodeDetailShell({
<TabsContent value='overview' className='mt-6 outline-none'>
{activeTab === 'overview' ? overview : null}
</TabsContent>
<TabsContent value='dashboard' className='mt-6 outline-none'>
{activeTab === 'dashboard' ? dashboard : null}
</TabsContent>
<TabsContent value='manage' className='mt-6 outline-none'>
{activeTab === 'manage' ? manage : null}
</TabsContent>
@@ -463,7 +463,7 @@ export function NodeObservability({
}
hint={
latestHealthEvent
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
? getHealthEventLabel(latestHealthEvent)
: '当前没有活动中的健康事件'
}
/>
@@ -597,7 +597,7 @@ export function NodeObservability({
}
hint={
latestHealthEvent
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
? getHealthEventLabel(latestHealthEvent)
: '当前没有活动中的健康事件'
}
/>
@@ -138,6 +138,26 @@ export function getImageTag(version?: string): string {
return 'latest';
}
export function buildEdgeDockerInstallCommand(
serverUrl: string,
agentToken: string,
version?: string,
) {
const tag = getImageTag(version);
const image = `ghcr.io/rain-kl/openflare-agent:${tag}`;
return [
`docker pull ${image}`,
`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=${serverUrl} \\`,
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
` ${image}`,
].join('\n');
}
export function buildRelayInstallCommand(
serverUrl: string,
discoveryToken: string,
@@ -278,7 +278,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
</>
);
const overviewTab = (
const manageTab = (
<div className='space-y-6'>
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
@@ -359,11 +359,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
</div>
</NodeSectionCard>
</div>
</div>
);
const manageTab = (
<div className='space-y-6'>
<NodeSectionCard
title='FRPS WebUI'
description='控制 frps 内置 Web 管理界面是否启用及其监听端口'
@@ -514,8 +510,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
icon: Activity,
},
]}
overview={overviewTab}
dashboard={
overview={
<NodeObservability
nodeId={node.id}
variant='compact'
@@ -183,7 +183,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
</>
);
const overviewTab = (
const manageTab = (
<div className='space-y-6'>
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
@@ -271,11 +271,9 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
</div>
</NodeSectionCard>
</div>
</div>
);
const manageTab = (
<div className='space-y-6'>
<InstallCommand node={node} variant='tunnel' />
<NodeSectionCard title='接入凭证' description='隧道客户端接入所需 Token'>
<div className='divide-y'>
<NodeInfoRow label='Tunnel Token'>
@@ -294,8 +292,6 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
</NodeInfoRow>
</div>
</NodeSectionCard>
<InstallCommand node={node} variant='tunnel' />
</div>
);
@@ -338,8 +334,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
icon: Activity,
},
]}
overview={overviewTab}
dashboard={
overview={
<NodeObservability
nodeId={node.id}
variant='compact'
@@ -2,7 +2,7 @@
import { useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link2, Loader2, UploadCloud } from 'lucide-react';
import { UploadCloud } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -14,10 +14,14 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Progress } from '@/components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Spinner } from '@/components/ui/spinner';
import { PagesService } from '@/lib/services/openflare';
import { cn } from '@/lib/utils';
@@ -46,52 +50,51 @@ const PAGES_PACKAGE_EXTENSIONS = [
function isSupportedPagesPackage(fileName: string) {
const lower = fileName.toLowerCase();
return PAGES_PACKAGE_EXTENSIONS.some((ext) => lower.endsWith(ext));
return PAGES_PACKAGE_EXTENSIONS.some((extension) =>
lower.endsWith(extension),
);
}
export function pagesEntryPath(rootDir: string, entryFile: string) {
const root = rootDir.trim().replace(/^\/+|\/+$/g, '');
const entry = entryFile.trim().replace(/^\/+/, '');
return root ? `${root}/${entry}` : entry;
}
interface DeploymentUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: number;
rootDir: string;
entryFile: string;
}
export function DeploymentUploadDialog({
open,
onOpenChange,
projectId,
rootDir,
entryFile,
}: DeploymentUploadDialogProps) {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<'file' | 'url'>('file');
const [file, setFile] = useState<File | null>(null);
const [packageURL, setPackageURL] = useState('');
const [isDragActive, setIsDragActive] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const resetForm = () => {
setFile(null);
setPackageURL('');
setIsDragActive(false);
setUploadProgress(null);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleClose = (nextOpen: boolean) => {
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
};
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(projectId),
}),
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
]);
};
const uploadFileMutation = useMutation({
const uploadMutation = useMutation({
mutationFn: () => {
if (!file) throw new Error('请选择部署包');
return PagesService.uploadDeployment(projectId, {
@@ -101,8 +104,14 @@ export function DeploymentUploadDialog({
},
onSuccess: async () => {
toast.success('部署包上传成功');
await invalidate();
handleClose(false);
await Promise.all([
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(projectId),
}),
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
]);
handleOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '上传失败');
@@ -110,27 +119,6 @@ export function DeploymentUploadDialog({
},
});
const uploadURLMutation = useMutation({
mutationFn: () => {
const url = packageURL.trim();
if (!url) throw new Error('请填写部署包下载链接');
if (!/^https?:\/\//i.test(url)) {
throw new Error('链接必须以 http:// 或 https:// 开头');
}
return PagesService.uploadDeploymentFromURL(projectId, { url });
},
onSuccess: async () => {
toast.success('已从链接下载并创建部署');
await invalidate();
handleClose(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '从链接导入失败');
},
});
const isPending = uploadFileMutation.isPending || uploadURLMutation.isPending;
const handleFileSelect = (selected: File | null) => {
if (!selected) return;
if (!isSupportedPagesPackage(selected.name)) {
@@ -140,150 +128,112 @@ export function DeploymentUploadDialog({
setFile(selected);
};
const handleSubmit = () => {
if (mode === 'file') {
uploadFileMutation.mutate();
return;
}
uploadURLMutation.mutate();
};
const canSubmit =
mode === 'file' ? Boolean(file) : packageURL.trim().length > 0;
return (
<Dialog open={open} onOpenChange={handleClose}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>上传部署包</DialogTitle>
<DialogDescription>
支持本地上传或从 URL 下载静态资源压缩包(zip / tar.gz / tar.xz /
tar.bz2 / tar / 7z),创建部署后可在列表中激活。
上传不可变的静态资源压缩包,完成后可在部署历史中激活。
</DialogDescription>
</DialogHeader>
<Tabs
value={mode}
onValueChange={(value) => setMode(value as 'file' | 'url')}
className='w-full'
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='file'>本地上传</TabsTrigger>
<TabsTrigger value='url'>从 URL 下载</TabsTrigger>
</TabsList>
<TabsContent value='file' className='space-y-4 mt-4'>
<div
<FieldGroup>
<Field>
<FieldLabel htmlFor='pages-package'>本地部署包</FieldLabel>
<button
type='button'
className={cn(
'rounded-lg border border-dashed p-8 text-center transition',
'flex min-h-52 w-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center transition-colors',
isDragActive ? 'border-primary bg-primary/5' : 'bg-muted/20',
)}
onDragEnter={(e) => {
e.preventDefault();
onClick={() => fileInputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault();
setIsDragActive(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={(e) => {
e.preventDefault();
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
event.preventDefault();
setIsDragActive(false);
}}
onDrop={(e) => {
e.preventDefault();
onDrop={(event) => {
event.preventDefault();
setIsDragActive(false);
handleFileSelect(e.dataTransfer.files[0] ?? null);
handleFileSelect(event.dataTransfer.files[0] ?? null);
}}
>
<UploadCloud className='size-8 mx-auto text-muted-foreground' />
<p className='mt-3 text-sm'>拖拽部署包到此处,或点击选择文件</p>
<p className='mt-1 text-xs text-muted-foreground'>
支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z
</p>
<Button
type='button'
variant='outline'
size='sm'
className='mt-3'
onClick={() => fileInputRef.current?.click()}
>
选择文件
</Button>
<input
ref={fileInputRef}
type='file'
accept={PAGES_PACKAGE_ACCEPT}
className='hidden'
onChange={(e) => handleFileSelect(e.target.files?.[0] ?? null)}
/>
</div>
<UploadCloud className='size-8 text-muted-foreground' />
<span className='text-sm font-medium'>
拖拽部署包到此处,或点击选择文件
</span>
<span className='text-xs text-muted-foreground'>
zip、tar.gz、tar.xz、tar.bz2、tar、7z
</span>
</button>
<input
ref={fileInputRef}
id='pages-package'
type='file'
accept={PAGES_PACKAGE_ACCEPT}
className='hidden'
onChange={(event) =>
handleFileSelect(event.target.files?.[0] ?? null)
}
/>
{file ? (
<div className='rounded-lg border border-dashed px-4 py-3 text-sm'>
<p className='font-medium'>{file.name}</p>
<p className='text-xs text-muted-foreground mt-1'>
{formatBytes(file.size)}
</p>
</div>
) : null}
<FieldDescription>
已选择 {file.name}({formatBytes(file.size)})
</FieldDescription>
) : (
<FieldDescription>请选择一个受支持的压缩包。</FieldDescription>
)}
</Field>
{uploadProgress !== null ? (
<div className='space-y-1.5'>
<div className='flex justify-between text-xs text-muted-foreground'>
<span>
{uploadProgress >= 100 ? '服务端处理中' : '上传进度'}
</span>
<span>
{uploadProgress >= 100 ? '请稍候' : `${uploadProgress}%`}
</span>
</div>
<Progress
value={uploadProgress >= 100 ? 100 : uploadProgress}
/>
</div>
) : null}
</TabsContent>
<TabsContent value='url' className='space-y-4 mt-4'>
<div className='space-y-2'>
<Label htmlFor='package-url'>部署包下载链接</Label>
<div className='relative'>
<Link2 className='absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
<Input
id='package-url'
className='pl-9'
placeholder='https://example.com/dist/site.zip'
value={packageURL}
onChange={(e) => setPackageURL(e.target.value)}
disabled={isPending}
/>
</div>
<p className='text-xs text-muted-foreground'>
服务端将使用浏览器环境请求头从该链接下载压缩包,支持内网地址与自签证书
HTTPS。
</p>
<Field>
<FieldLabel>部署入口</FieldLabel>
<div className='rounded-md border bg-muted/20 px-3 py-2 font-mono text-sm'>
{pagesEntryPath(rootDir, entryFile)}
</div>
</TabsContent>
</Tabs>
<FieldDescription>
入口来自项目设置;部署包上传不会覆盖该配置。
</FieldDescription>
</Field>
<div className='space-y-1.5'>
<Label htmlFor='entryFile'>入口文件</Label>
<Input id='entryFile' defaultValue='index.html' disabled />
</div>
{uploadProgress !== null ? (
<Field>
<div className='flex items-center justify-between text-xs text-muted-foreground'>
<span>
{uploadProgress >= 100 ? '服务端处理中' : '上传进度'}
</span>
<span>
{uploadProgress >= 100 ? '请稍候' : `${uploadProgress}%`}
</span>
</div>
<Progress value={Math.min(uploadProgress, 100)} />
</Field>
) : null}
</FieldGroup>
<DialogFooter>
<Button variant='outline' onClick={() => handleClose(false)}>
<Button
type='button'
variant='outline'
onClick={() => handleOpenChange(false)}
>
取消
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit || isPending}>
{isPending ? (
<>
<Loader2 className='size-4 animate-spin mr-1' />
{mode === 'url' ? '下载中...' : '上传中...'}
</>
) : mode === 'url' ? (
'下载并创建部署'
<Button
type='button'
disabled={!file || uploadMutation.isPending}
onClick={() => uploadMutation.mutate()}
>
{uploadMutation.isPending ? (
<Spinner data-icon='inline-start' />
) : (
'上传并创建部署'
<UploadCloud data-icon='inline-start' />
)}
{uploadMutation.isPending ? '上传中...' : '上传并创建部署'}
</Button>
</DialogFooter>
</DialogContent>
@@ -17,6 +17,10 @@ export function deploymentsQueryKey(projectId: number) {
return ['openflare', 'pages', 'deployments', projectId] as const;
}
export function sourceQueryKey(projectId: number) {
return ['openflare', 'pages', 'source', projectId] as const;
}
export function deploymentFilesQueryKey(
projectId: number,
deploymentId: number,
@@ -2,10 +2,7 @@
import { useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { z } from 'zod';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -17,86 +14,15 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { type PagesProject, PagesService } from '@/lib/services/openflare';
import { projectQueryKey, projectsQueryKey } from './pages-utils';
const pagesProjectSchema = z
.object({
name: z.string().trim().min(1, '请输入项目名称').max(255),
slug: z.string().trim().max(255).optional().or(z.literal('')),
description: z.string().trim().max(1000).optional().or(z.literal('')),
spa_fallback_enabled: z.boolean(),
spa_fallback_path: z.string().trim(),
api_proxy_enabled: z.boolean(),
api_proxy_path: z.string().trim(),
api_proxy_pass: z.string().trim(),
api_proxy_rewrite: z.string().trim(),
root_dir: z.string().trim().max(512).optional().or(z.literal('')),
entry_file: z.string().trim().min(1, '请输入入口文件').max(512),
})
.superRefine((data, ctx) => {
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['spa_fallback_path'],
message: '回退路径必须以 / 开头',
});
}
if (data.api_proxy_enabled) {
if (!data.api_proxy_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_path'],
message: '匹配路径必须以 / 开头',
});
}
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_pass'],
message: '后端地址必须以 http:// 或 https:// 开头',
});
}
}
});
type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
function toFormValues(project?: PagesProject | null): PagesProjectFormValues {
if (!project) {
return {
name: '',
slug: '',
description: '',
spa_fallback_enabled: false,
spa_fallback_path: '/index.html',
api_proxy_enabled: false,
api_proxy_path: '',
api_proxy_pass: '',
api_proxy_rewrite: '',
root_dir: '',
entry_file: 'index.html',
};
}
return {
name: project.name,
slug: project.slug,
description: project.description || '',
spa_fallback_enabled: project.spa_fallback_enabled,
spa_fallback_path: project.spa_fallback_path,
api_proxy_enabled: project.api_proxy_enabled || false,
api_proxy_path: project.api_proxy_path || '',
api_proxy_pass: project.api_proxy_pass || '',
api_proxy_rewrite: project.api_proxy_rewrite || '',
root_dir: project.root_dir || '',
entry_file: project.entry_file || 'index.html',
};
}
import {
buildProjectPayload,
ProjectFormFields,
toFormValues,
usePagesProjectForm,
} from './project-form';
interface ProjectEditorDialogProps {
open: boolean;
@@ -110,39 +36,15 @@ export function ProjectEditorDialog({
project,
}: ProjectEditorDialogProps) {
const queryClient = useQueryClient();
const form = useForm<PagesProjectFormValues>({
resolver: zodResolver(pagesProjectSchema),
defaultValues: toFormValues(project),
});
const form = usePagesProjectForm(project);
useEffect(() => {
if (open) form.reset(toFormValues(project));
}, [form, project, open]);
const mutation = useMutation({
mutationFn: async (values: PagesProjectFormValues) => {
const payload = {
name: values.name.trim(),
slug: values.slug?.trim() || '',
description: values.description?.trim() || '',
enabled: project ? project.enabled : true,
spa_fallback_enabled: values.spa_fallback_enabled,
spa_fallback_path: values.spa_fallback_enabled
? values.spa_fallback_path.trim()
: project?.spa_fallback_path || '/index.html',
api_proxy_enabled: values.api_proxy_enabled,
api_proxy_path: values.api_proxy_enabled
? values.api_proxy_path.trim()
: '',
api_proxy_pass: values.api_proxy_enabled
? values.api_proxy_pass.trim()
: '',
api_proxy_rewrite: values.api_proxy_enabled
? values.api_proxy_rewrite.trim()
: '',
root_dir: values.root_dir?.trim() || '',
entry_file: values.entry_file.trim(),
};
mutationFn: async (values: Parameters<typeof buildProjectPayload>[0]) => {
const payload = buildProjectPayload(values, project);
return project
? PagesService.updateProject(project.id, payload)
: PagesService.createProject(payload);
@@ -162,9 +64,6 @@ export function ProjectEditorDialog({
},
});
const spaEnabled = form.watch('spa_fallback_enabled');
const apiEnabled = form.watch('api_proxy_enabled');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl max-h-[90vh] overflow-y-auto'>
@@ -182,113 +81,7 @@ export function ProjectEditorDialog({
className='space-y-4'
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor='name'>项目名称</Label>
<Input id='name' {...form.register('name')} />
{form.formState.errors.name ? (
<p className='text-xs text-destructive'>
{form.formState.errors.name.message}
</p>
) : null}
</div>
<div className='space-y-1.5'>
<Label htmlFor='slug'>项目标识</Label>
<Input
id='slug'
placeholder='留空自动生成'
{...form.register('slug')}
/>
</div>
</div>
<div className='space-y-1.5'>
<Label htmlFor='description'>描述</Label>
<Textarea
id='description'
rows={2}
{...form.register('description')}
/>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor='entry_file'>入口文件</Label>
<Input id='entry_file' {...form.register('entry_file')} />
</div>
<div className='space-y-1.5'>
<Label htmlFor='root_dir'>根目录</Label>
<Input
id='root_dir'
placeholder='可选'
{...form.register('root_dir')}
/>
</div>
</div>
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div>
<p className='text-sm font-medium'>SPA fallback</p>
<p className='text-xs text-muted-foreground'>
未命中静态文件时回退到指定路径
</p>
</div>
<Switch
checked={spaEnabled}
onCheckedChange={(checked) =>
form.setValue('spa_fallback_enabled', checked)
}
/>
</div>
{spaEnabled ? (
<div className='space-y-1.5'>
<Label htmlFor='spa_fallback_path'>回退路径</Label>
<Input
id='spa_fallback_path'
{...form.register('spa_fallback_path')}
/>
</div>
) : null}
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div>
<p className='text-sm font-medium'>API 反向代理</p>
<p className='text-xs text-muted-foreground'>
为静态站点附加 API 反代规则
</p>
</div>
<Switch
checked={apiEnabled}
onCheckedChange={(checked) =>
form.setValue('api_proxy_enabled', checked)
}
/>
</div>
{apiEnabled ? (
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor='api_proxy_path'>匹配路径</Label>
<Input
id='api_proxy_path'
{...form.register('api_proxy_path')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='api_proxy_pass'>后端地址</Label>
<Input
id='api_proxy_pass'
{...form.register('api_proxy_pass')}
/>
</div>
<div className='space-y-1.5 md:col-span-2'>
<Label htmlFor='api_proxy_rewrite'>重写规则</Label>
<Input
id='api_proxy_rewrite'
{...form.register('api_proxy_rewrite')}
/>
</div>
</div>
) : null}
<ProjectFormFields form={form} idPrefix='dialog' />
</form>
<DialogFooter>
@@ -0,0 +1,236 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, type UseFormReturn } from 'react-hook-form';
import { z } from 'zod';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { type PagesProject } from '@/lib/services/openflare';
export const pagesProjectSchema = z
.object({
name: z.string().trim().min(1, '请输入项目名称').max(255),
slug: z.string().trim().max(255).optional().or(z.literal('')),
spa_fallback_enabled: z.boolean(),
spa_fallback_path: z.string().trim(),
api_proxy_enabled: z.boolean(),
api_proxy_path: z.string().trim(),
api_proxy_pass: z.string().trim(),
api_proxy_rewrite: z.string().trim(),
root_dir: z.string().trim().max(512).optional().or(z.literal('')),
entry_file: z.string().trim().min(1, '请输入入口文件').max(512),
})
.superRefine((data, ctx) => {
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['spa_fallback_path'],
message: '回退路径必须以 / 开头',
});
}
if (data.api_proxy_enabled) {
if (!data.api_proxy_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_path'],
message: '匹配路径必须以 / 开头',
});
}
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_pass'],
message: '后端地址必须以 http:// 或 https:// 开头',
});
}
}
});
export type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
export function toFormValues(
project?: PagesProject | null,
): PagesProjectFormValues {
if (!project) {
return {
name: '',
slug: '',
spa_fallback_enabled: false,
spa_fallback_path: '/index.html',
api_proxy_enabled: false,
api_proxy_path: '',
api_proxy_pass: '',
api_proxy_rewrite: '',
root_dir: '',
entry_file: 'index.html',
};
}
return {
name: project.name,
slug: project.slug,
spa_fallback_enabled: project.spa_fallback_enabled,
spa_fallback_path: project.spa_fallback_path,
api_proxy_enabled: project.api_proxy_enabled || false,
api_proxy_path: project.api_proxy_path || '',
api_proxy_pass: project.api_proxy_pass || '',
api_proxy_rewrite: project.api_proxy_rewrite || '',
root_dir: project.root_dir || '',
entry_file: project.entry_file || 'index.html',
};
}
export function buildProjectPayload(
values: PagesProjectFormValues,
project?: PagesProject | null,
) {
return {
name: values.name.trim(),
slug: values.slug?.trim() || '',
description: project?.description || '',
enabled: project ? project.enabled : true,
spa_fallback_enabled: values.spa_fallback_enabled,
spa_fallback_path: values.spa_fallback_enabled
? values.spa_fallback_path.trim()
: project?.spa_fallback_path || '/index.html',
api_proxy_enabled: values.api_proxy_enabled,
api_proxy_path: values.api_proxy_enabled
? values.api_proxy_path.trim()
: '',
api_proxy_pass: values.api_proxy_enabled
? values.api_proxy_pass.trim()
: '',
api_proxy_rewrite: values.api_proxy_enabled
? values.api_proxy_rewrite.trim()
: '',
root_dir: values.root_dir?.trim() || '',
entry_file: values.entry_file.trim(),
};
}
export function usePagesProjectForm(project?: PagesProject | null) {
return useForm<PagesProjectFormValues>({
resolver: zodResolver(pagesProjectSchema),
defaultValues: toFormValues(project),
});
}
interface ProjectFormFieldsProps {
form: UseFormReturn<PagesProjectFormValues>;
idPrefix?: string;
}
export function ProjectFormFields({
form,
idPrefix = '',
}: ProjectFormFieldsProps) {
const spaEnabled = form.watch('spa_fallback_enabled');
const apiEnabled = form.watch('api_proxy_enabled');
const fieldId = (name: string) => (idPrefix ? `${idPrefix}-${name}` : name);
return (
<div className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('name')}>项目名称</Label>
<Input id={fieldId('name')} {...form.register('name')} />
{form.formState.errors.name ? (
<p className='text-xs text-destructive'>
{form.formState.errors.name.message}
</p>
) : null}
</div>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('slug')}>项目标识</Label>
<Input
id={fieldId('slug')}
placeholder='留空自动生成'
{...form.register('slug')}
/>
</div>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('entry_file')}>入口文件</Label>
<Input id={fieldId('entry_file')} {...form.register('entry_file')} />
</div>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('root_dir')}>根目录</Label>
<Input
id={fieldId('root_dir')}
placeholder='可选'
{...form.register('root_dir')}
/>
</div>
</div>
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div>
<p className='text-sm font-medium'>SPA fallback</p>
<p className='text-xs text-muted-foreground'>
未命中静态文件时回退到指定路径
</p>
</div>
<Switch
checked={spaEnabled}
onCheckedChange={(checked) =>
form.setValue('spa_fallback_enabled', checked, {
shouldDirty: true,
})
}
/>
</div>
{spaEnabled ? (
<div className='space-y-1.5'>
<Label htmlFor={fieldId('spa_fallback_path')}>回退路径</Label>
<Input
id={fieldId('spa_fallback_path')}
{...form.register('spa_fallback_path')}
/>
</div>
) : null}
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div>
<p className='text-sm font-medium'>API 反向代理</p>
<p className='text-xs text-muted-foreground'>
为静态站点附加 API 反代规则
</p>
</div>
<Switch
checked={apiEnabled}
onCheckedChange={(checked) =>
form.setValue('api_proxy_enabled', checked, { shouldDirty: true })
}
/>
</div>
{apiEnabled ? (
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('api_proxy_path')}>匹配路径</Label>
<Input
id={fieldId('api_proxy_path')}
{...form.register('api_proxy_path')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('api_proxy_pass')}>后端地址</Label>
<Input
id={fieldId('api_proxy_pass')}
{...form.register('api_proxy_pass')}
/>
</div>
<div className='space-y-1.5 md:col-span-2'>
<Label htmlFor={fieldId('api_proxy_rewrite')}>重写规则</Label>
<Input
id={fieldId('api_proxy_rewrite')}
{...form.register('api_proxy_rewrite')}
/>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,101 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { type PagesProject, PagesService } from '@/lib/services/openflare';
import { projectsQueryKey } from '../../components/pages-utils';
interface DangerZoneCardProps {
project: PagesProject;
}
export function DangerZoneCard({ project }: DangerZoneCardProps) {
const router = useRouter();
const queryClient = useQueryClient();
const [deleteOpen, setDeleteOpen] = useState(false);
const deleteProjectMutation = useMutation({
mutationFn: () => PagesService.deleteProject(project.id),
onSuccess: async () => {
toast.success('项目已删除');
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
router.push('/pages');
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '删除失败');
},
});
return (
<>
<Card className='border-dashed border-destructive/30 bg-destructive/5 shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base text-destructive'>危险区域</CardTitle>
</CardHeader>
<CardContent className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-1'>
<p className='text-sm font-medium'>删除项目</p>
<p className='text-xs text-muted-foreground'>
将永久删除 {project.name}({project.slug})及其全部部署。
</p>
</div>
<Button
type='button'
size='sm'
variant='destructive'
onClick={() => setDeleteOpen(true)}
>
<Trash2 data-icon='inline-start' />
删除项目
</Button>
</CardContent>
</Card>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除 Pages 项目</AlertDialogTitle>
<AlertDialogDescription>
确认删除项目 {project.name} 吗?此操作不可恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteProjectMutation.isPending}
onClick={(event) => {
event.preventDefault();
deleteProjectMutation.mutate();
}}
>
{deleteProjectMutation.isPending ? (
<Spinner data-icon='inline-start' />
) : null}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,86 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { EmptyInline } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { PagesService } from '@/lib/services/openflare';
import {
deploymentFilesQueryKey,
formatBytes,
} from '../../components/pages-utils';
interface DeploymentFilesPanelProps {
projectId: number;
deploymentId: number;
}
export function DeploymentFilesPanel({
projectId,
deploymentId,
}: DeploymentFilesPanelProps) {
const filesQuery = useQuery({
queryKey: deploymentFilesQueryKey(projectId, deploymentId),
queryFn: () => PagesService.listDeploymentFiles(deploymentId),
});
if (filesQuery.isLoading) {
return (
<div className='flex flex-col gap-2 p-4'>
<Skeleton className='h-8 w-full' />
<Skeleton className='h-8 w-full' />
</div>
);
}
if (filesQuery.isError) {
return (
<div className='p-4'>
<ErrorInline
message={
filesQuery.error instanceof Error
? filesQuery.error.message
: '文件清单加载失败'
}
onRetry={() => void filesQuery.refetch()}
/>
</div>
);
}
const files = filesQuery.data ?? [];
if (files.length === 0) {
return <EmptyInline message='暂无文件记录' />;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>路径</TableHead>
<TableHead className='text-right'>大小</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<TableRow key={file.id}>
<TableCell className='font-mono text-xs'>{file.path}</TableCell>
<TableCell className='text-right text-xs text-muted-foreground'>
{formatBytes(file.size)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -0,0 +1,438 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, Rocket, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { type PagesDeployment, PagesService } from '@/lib/services/openflare';
import { cn, formatDateTime } from '@/lib/utils';
import { DeploymentUploadDialog } from '../../components/deployment-upload-dialog';
import {
deploymentFilesQueryKey,
deploymentsQueryKey,
formatBytes,
projectQueryKey,
projectsQueryKey,
sourceQueryKey,
} from '../../components/pages-utils';
import { DeploymentFilesPanel } from './deployment-files-panel';
const SOURCE_LABELS: Record<PagesDeployment['source_type'], string> = {
manual_upload: '本地上传',
manual_url: 'URL 导入',
remote_url: 'Remote URL',
github_release: 'GitHub',
};
const TRIGGER_LABELS: Record<PagesDeployment['trigger_type'], string> = {
manual_upload: '手动上传',
manual_url: '手动导入',
manual_sync: '手动同步',
scheduled_auto_update: '定时更新',
};
interface DeploymentHistoryProps {
projectId: number;
activeDeploymentId?: number | null;
rootDir?: string;
entryFile?: string;
}
type PendingAction = {
type: 'activate' | 'delete';
deployment: PagesDeployment;
};
function isActiveDeployment(
deployment: PagesDeployment,
activeDeploymentId?: number | null,
) {
return deployment.id === activeDeploymentId || deployment.status === 'active';
}
function deploymentSnapshot(deployment: PagesDeployment) {
return [
SOURCE_LABELS[deployment.source_type],
deployment.source_label,
TRIGGER_LABELS[deployment.trigger_type],
]
.filter(Boolean)
.join(' · ');
}
function DeploymentMeta({ deployment }: { deployment: PagesDeployment }) {
return (
<>
<p className='truncate text-xs text-muted-foreground'>
{deployment.checksum.slice(0, 16)} · {deployment.file_count} 个文件 ·{' '}
{formatBytes(deployment.total_size)}
</p>
<p className='text-xs text-muted-foreground'>
创建于 {formatDateTime(deployment.created_at)}
{deployment.activated_at
? ` · 激活于 ${formatDateTime(deployment.activated_at)}`
: ''}
</p>
</>
);
}
interface DeploymentRowProps {
deployment: PagesDeployment;
active: boolean;
expanded: boolean;
actionPending: boolean;
showActions: boolean;
onToggleExpand: () => void;
onActivate: () => void;
onDelete: () => void;
projectId: number;
}
function DeploymentRow({
deployment,
active,
expanded,
actionPending,
showActions,
onToggleExpand,
onActivate,
onDelete,
projectId,
}: DeploymentRowProps) {
return (
<div
className={cn(
'rounded-lg border border-dashed',
active && 'border-l-4 border-l-solid border-l-primary',
)}
>
<div className='flex flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between'>
<div className='flex min-w-0 items-start gap-2'>
<Button
type='button'
variant='ghost'
size='icon-sm'
aria-label={expanded ? '收起文件清单' : '展开文件清单'}
onClick={onToggleExpand}
>
{expanded ? <ChevronDown /> : <ChevronRight />}
</Button>
<div className='flex min-w-0 flex-col gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-sm font-medium'>
部署 #{deployment.deployment_number}
</span>
<Badge variant='secondary'>
{deploymentSnapshot(deployment)}
</Badge>
</div>
<DeploymentMeta deployment={deployment} />
</div>
</div>
{showActions ? (
<div className='flex gap-2 md:ml-10'>
<Button
type='button'
variant='outline'
size='sm'
disabled={active || actionPending}
onClick={onActivate}
>
激活
</Button>
<Button
type='button'
variant='destructive'
size='sm'
disabled={active || actionPending}
onClick={onDelete}
>
删除
</Button>
</div>
) : null}
</div>
{expanded ? (
<DeploymentFilesPanel
projectId={projectId}
deploymentId={deployment.id}
/>
) : null}
</div>
);
}
export function DeploymentHistory({
projectId,
activeDeploymentId,
rootDir = '',
entryFile = 'index.html',
}: DeploymentHistoryProps) {
const queryClient = useQueryClient();
const [uploadOpen, setUploadOpen] = useState(false);
const [expandedDeploymentId, setExpandedDeploymentId] = useState<
number | null
>(null);
const [pendingAction, setPendingAction] = useState<PendingAction | null>(
null,
);
const deploymentsQuery = useQuery({
queryKey: deploymentsQueryKey(projectId),
queryFn: () => PagesService.listDeployments(projectId),
});
const { productionDeployment, allDeployments } = useMemo(() => {
const records = [...(deploymentsQuery.data ?? [])];
records.sort(
(left, right) => right.deployment_number - left.deployment_number,
);
const production =
records.find((item) => isActiveDeployment(item, activeDeploymentId)) ??
null;
return {
productionDeployment: production,
allDeployments: records,
};
}, [activeDeploymentId, deploymentsQuery.data]);
const invalidateDeploymentState = async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(projectId),
}),
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
]);
};
const activateMutation = useMutation({
mutationFn: (deploymentId: number) =>
PagesService.activateDeployment(projectId, deploymentId),
onSuccess: async () => {
toast.success('历史部署已激活;自动更新(如已开启)已关闭');
await invalidateDeploymentState();
setPendingAction(null);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '激活失败');
},
});
const deleteMutation = useMutation({
mutationFn: (deploymentId: number) =>
PagesService.deleteDeployment(projectId, deploymentId),
onSuccess: async (_, deploymentId) => {
toast.success('部署已删除');
queryClient.removeQueries({
queryKey: deploymentFilesQueryKey(projectId, deploymentId),
});
await invalidateDeploymentState();
setPendingAction(null);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '删除失败');
},
});
const actionPending = activateMutation.isPending || deleteMutation.isPending;
const toggleExpand = (deploymentId: number) => {
setExpandedDeploymentId((current) =>
current === deploymentId ? null : deploymentId,
);
};
const dialogs = (
<>
<AlertDialog
open={pendingAction !== null}
onOpenChange={(open) => {
if (!open && !actionPending) setPendingAction(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingAction?.type === 'activate' ? '激活历史部署' : '删除部署'}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingAction?.type === 'activate'
? '激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。'
: `确认删除部署 #${pendingAction?.deployment.deployment_number} 吗?此操作不可恢复。`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={actionPending}>取消</AlertDialogCancel>
<AlertDialogAction
disabled={actionPending}
onClick={(event) => {
event.preventDefault();
if (!pendingAction) return;
if (pendingAction.type === 'activate') {
activateMutation.mutate(pendingAction.deployment.id);
} else {
deleteMutation.mutate(pendingAction.deployment.id);
}
}}
>
{actionPending ? <Spinner data-icon='inline-start' /> : null}
确认
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeploymentUploadDialog
open={uploadOpen}
onOpenChange={setUploadOpen}
projectId={projectId}
rootDir={rootDir}
entryFile={entryFile}
/>
</>
);
if (deploymentsQuery.isLoading) {
return (
<>
<LoadingStateWithBorder description='加载部署历史...' />
{dialogs}
</>
);
}
if (deploymentsQuery.isError) {
return (
<>
<div className='rounded-lg border p-4'>
<ErrorInline
message={
deploymentsQuery.error instanceof Error
? deploymentsQuery.error.message
: '部署历史加载失败'
}
onRetry={() => void deploymentsQuery.refetch()}
/>
</div>
{dialogs}
</>
);
}
return (
<div className='flex flex-col gap-6'>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Production</CardTitle>
<CardDescription>
当前对外生效的生产部署;切换激活状态后会立即生效。
</CardDescription>
<CardAction>
<Button
type='button'
size='sm'
className='whitespace-nowrap'
onClick={() => setUploadOpen(true)}
>
<Upload data-icon='inline-start' />
手动上传
</Button>
</CardAction>
</CardHeader>
<CardContent className='space-y-3'>
{productionDeployment ? (
<DeploymentRow
deployment={productionDeployment}
active
expanded={expandedDeploymentId === productionDeployment.id}
actionPending={actionPending}
showActions={false}
onToggleExpand={() => toggleExpand(productionDeployment.id)}
onActivate={() => undefined}
onDelete={() => undefined}
projectId={projectId}
/>
) : (
<EmptyStateWithBorder
icon={Rocket}
title='暂无 Production 部署'
description='上传本地部署包,或配置 Remote URL / GitHub Release 来源后同步发布。'
/>
)}
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>All deployments</CardTitle>
<CardDescription>
部署记录不可变,来源信息是创建部署时的安全快照。可从历史部署激活或删除。
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{allDeployments.length === 0 ? (
<EmptyStateWithBorder
title='暂无部署'
description='上传本地部署包,或配置 Remote URL / GitHub Release 来源后同步发布。'
/>
) : (
allDeployments.map((deployment) => {
const active = isActiveDeployment(deployment, activeDeploymentId);
return (
<DeploymentRow
key={deployment.id}
deployment={deployment}
active={active}
expanded={expandedDeploymentId === deployment.id}
actionPending={actionPending}
showActions
onToggleExpand={() => toggleExpand(deployment.id)}
onActivate={() =>
setPendingAction({ type: 'activate', deployment })
}
onDelete={() =>
setPendingAction({ type: 'delete', deployment })
}
projectId={projectId}
/>
);
})
)}
</CardContent>
</Card>
{dialogs}
</div>
);
}
@@ -0,0 +1,583 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Download, Pencil, RefreshCw, Search } from 'lucide-react';
import { toast } from 'sonner';
import { ErrorInline } from '@/components/layout/error';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { AdminTaskService } from '@/lib/services/admin';
import {
type PagesSource,
type PagesSourceActionPayload,
type PagesSourceActionReceipt,
type PagesSourceStatus,
PagesService,
} from '@/lib/services/openflare';
import {
deploymentsQueryKey,
projectQueryKey,
projectsQueryKey,
sourceQueryKey,
} from '../../components/pages-utils';
import { type PagesSourceMode, PagesSourceDialog } from './pages-source-dialog';
import {
GitHubSourceDetails,
RemoteSourceDetails,
} from './pages-source-details';
const ACTION_POLL_INTERVAL = 2_000;
const ACTION_MAX_WAIT = 16 * 60 * 1_000;
const LATEST_POLL_INTERVAL = 5 * 60 * 1_000;
const LATEST_NEAR_DUE_POLL_INTERVAL = 30_000;
const LATEST_OVERDUE_MAX_WAIT = 10 * 60 * 1_000;
const SOURCE_STATUS: Record<
PagesSourceStatus,
{
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
}
> = {
idle: { label: '空闲', variant: 'outline' },
checking: { label: '检查中', variant: 'secondary' },
update_available: { label: '有可用更新', variant: 'default' },
syncing: { label: '同步中', variant: 'secondary' },
failed: { label: '最近同步失败', variant: 'destructive' },
attention: { label: '需要确认', variant: 'destructive' },
};
interface ActiveAction {
receipt: PagesSourceActionReceipt;
startedAt: number;
}
export interface LatestSourceOverdueWindow {
nextCheckAt: string;
startedAt: number;
}
export interface LatestSourcePollingDecision {
interval: number | false;
overdueWindow: LatestSourceOverdueWindow | null;
}
export function getLatestSourceIdlePollingDecision(
source: PagesSource | undefined,
now: number,
overdueWindow: LatestSourceOverdueWindow | null,
): LatestSourcePollingDecision {
if (
source?.source_type !== 'github_release' ||
source.release_selector !== 'latest'
) {
return { interval: false, overdueWindow: null };
}
const nextCheckAt = source.next_check_at;
const nextCheckTime = nextCheckAt ? Date.parse(nextCheckAt) : Number.NaN;
if (!nextCheckAt || !Number.isFinite(nextCheckTime)) {
return { interval: LATEST_POLL_INTERVAL, overdueWindow: null };
}
const timeUntilCheck = nextCheckTime - now;
if (timeUntilCheck > LATEST_POLL_INTERVAL) {
return { interval: LATEST_POLL_INTERVAL, overdueWindow: null };
}
if (timeUntilCheck > 0) {
return { interval: LATEST_NEAR_DUE_POLL_INTERVAL, overdueWindow: null };
}
const currentWindow =
overdueWindow?.nextCheckAt === nextCheckAt
? overdueWindow
: { nextCheckAt, startedAt: now };
if (now - currentWindow.startedAt >= LATEST_OVERDUE_MAX_WAIT) {
return { interval: false, overdueWindow: currentWindow };
}
return {
interval: LATEST_NEAR_DUE_POLL_INTERVAL,
overdueWindow: currentWindow,
};
}
function sourceDeploymentFingerprint(source: PagesSource) {
if (source.source_type === 'manual') return '|';
return `${source.last_synced_at ?? ''}|${source.last_applied?.revision ?? ''}`;
}
function sourceActionLabel(action: PagesSourceActionReceipt['action']) {
return action === 'check' ? '检查' : '同步并发布';
}
export function PagesSourceCard({ projectId }: { projectId: number }) {
const queryClient = useQueryClient();
const handledExecutionID = useRef<string | null>(null);
const sourcePollingStartedAt = useRef<number | null>(null);
const latestOverdueWindow = useRef<LatestSourceOverdueWindow | null>(null);
const sourceDeploymentState = useRef<string | undefined>(undefined);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<PagesSourceMode>('manual');
const [activeAction, setActiveAction] = useState<ActiveAction | null>(null);
const [actionTimedOut, setActionTimedOut] = useState(false);
const [attentionDialogOpen, setAttentionDialogOpen] = useState(false);
const sourceQuery = useQuery({
queryKey: sourceQueryKey(projectId),
queryFn: () => PagesService.getSource(projectId),
refetchInterval: (query) => {
const source = query.state.data;
if (
source &&
source.source_type !== 'manual' &&
(source.sync_status === 'checking' || source.sync_status === 'syncing')
) {
sourcePollingStartedAt.current ??= Date.now();
latestOverdueWindow.current = null;
return Date.now() - sourcePollingStartedAt.current < ACTION_MAX_WAIT
? ACTION_POLL_INTERVAL
: false;
}
sourcePollingStartedAt.current = null;
const decision = getLatestSourceIdlePollingDecision(
source,
Date.now(),
latestOverdueWindow.current,
);
latestOverdueWindow.current = decision.overdueWindow;
return decision.interval;
},
});
const invalidateSourceState = useCallback(
() =>
Promise.all([
queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }),
queryClient.invalidateQueries({
queryKey: projectQueryKey(projectId),
}),
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(projectId),
}),
queryClient.invalidateQueries({
queryKey: ['openflare', 'pages', 'deployment-files', projectId],
}),
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
]),
[projectId, queryClient],
);
const executionQuery = useQuery({
queryKey: [
'admin',
'task-execution',
activeAction?.receipt.execution_id ?? '',
],
queryFn: () => {
const executionID = activeAction?.receipt.execution_id;
if (!executionID) throw new Error('缺少任务执行 ID');
return AdminTaskService.getTaskExecution(executionID);
},
enabled: Boolean(activeAction) && !actionTimedOut,
refetchInterval: (query) => {
if (actionTimedOut) return false;
const status = query.state.data?.status;
return status === 'pending' || status === 'running'
? ACTION_POLL_INTERVAL
: false;
},
});
const beginActionPolling = useCallback(
(receipt: PagesSourceActionReceipt) => {
handledExecutionID.current = null;
setActiveAction({ receipt, startedAt: Date.now() });
setActionTimedOut(false);
},
[],
);
useEffect(() => {
if (!activeAction || actionTimedOut) return;
const elapsed = Date.now() - activeAction.startedAt;
const remaining = Math.max(0, ACTION_MAX_WAIT - elapsed);
const timeout = window.setTimeout(() => setActionTimedOut(true), remaining);
return () => window.clearTimeout(timeout);
}, [actionTimedOut, activeAction]);
useEffect(() => {
const source = sourceQuery.data;
if (!source) return;
const fingerprint = sourceDeploymentFingerprint(source);
const previousFingerprint = sourceDeploymentState.current;
sourceDeploymentState.current = fingerprint;
if (
previousFingerprint === undefined ||
previousFingerprint === fingerprint
) {
return;
}
void invalidateSourceState();
}, [invalidateSourceState, sourceQuery.data]);
useEffect(() => {
const execution = executionQuery.data;
if (
!activeAction ||
!execution ||
!['succeeded', 'failed'].includes(execution.status)
) {
return;
}
if (handledExecutionID.current === execution.id) return;
handledExecutionID.current = execution.id;
void invalidateSourceState();
const actionLabel = sourceActionLabel(activeAction.receipt.action);
if (execution.status === 'succeeded') {
toast.success(`部署源${actionLabel}完成`);
} else {
toast.error(execution.error_message || `部署源${actionLabel}失败`);
}
setActiveAction(null);
setActionTimedOut(false);
}, [activeAction, executionQuery.data, invalidateSourceState]);
const checkMutation = useMutation({
mutationFn: () => PagesService.checkSource(projectId),
onSuccess: async (receipt) => {
beginActionPolling(receipt);
await queryClient.invalidateQueries({
queryKey: sourceQueryKey(projectId),
});
toast.success('检查任务已提交');
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '检查任务提交失败');
},
});
const syncMutation = useMutation({
mutationFn: (payload: PagesSourceActionPayload) =>
PagesService.syncSource(projectId, payload),
onSuccess: async (receipt) => {
setAttentionDialogOpen(false);
beginActionPolling(receipt);
await queryClient.invalidateQueries({
queryKey: sourceQueryKey(projectId),
});
toast.success('同步任务已提交');
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '同步任务提交失败');
},
});
const source = sourceQuery.data;
const executionBusy =
activeAction !== null &&
(executionQuery.data?.status === undefined ||
executionQuery.data.status === 'pending' ||
executionQuery.data.status === 'running');
const sourceBusy =
source?.source_type !== 'manual' &&
(source?.sync_status === 'checking' || source?.sync_status === 'syncing');
const actionsDisabled =
checkMutation.isPending ||
syncMutation.isPending ||
executionBusy ||
sourceBusy;
const checkBusy =
checkMutation.isPending ||
(executionBusy && activeAction?.receipt.action === 'check') ||
(source?.source_type !== 'manual' && source?.sync_status === 'checking');
const syncBusy =
syncMutation.isPending ||
(executionBusy && activeAction?.receipt.action === 'sync') ||
(source?.source_type !== 'manual' && source?.sync_status === 'syncing');
const dispatchError = checkMutation.error ?? syncMutation.error;
const openSourceDialog = (mode: PagesSourceMode) => {
setDialogMode(mode);
setDialogOpen(true);
};
const dispatchSync = () => {
checkMutation.reset();
if (
source?.source_type === 'github_release' &&
source.sync_status === 'attention'
) {
setAttentionDialogOpen(true);
return;
}
syncMutation.mutate({});
};
if (sourceQuery.isLoading) {
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>部署源</CardTitle>
<CardDescription>加载来源配置...</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-20 w-full' />
</CardContent>
</Card>
);
}
if (sourceQuery.isError || !source) {
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>部署源</CardTitle>
<CardDescription>来源配置与部署历史相互独立。</CardDescription>
</CardHeader>
<CardContent>
<ErrorInline
message={
sourceQuery.error instanceof Error
? sourceQuery.error.message
: '部署源加载失败'
}
onRetry={() => void sourceQuery.refetch()}
/>
</CardContent>
</Card>
);
}
const effectiveSourceStatus = executionBusy
? activeAction?.receipt.action === 'check'
? 'checking'
: 'syncing'
: source.source_type === 'manual'
? undefined
: (source.sync_status ?? 'idle');
const status =
source.source_type === 'manual'
? null
: SOURCE_STATUS[effectiveSourceStatus ?? 'idle'];
const attentionRevision =
source.source_type === 'github_release' &&
source.sync_status === 'attention'
? source.last_seen
: undefined;
return (
<>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<div className='flex items-start justify-between gap-3'>
<div>
<CardTitle className='text-base'>部署源</CardTitle>
<CardDescription>
配置远端来源并同步发布;发布结果见部署历史。
</CardDescription>
</div>
{status ? (
<Badge variant={status.variant}>{status.label}</Badge>
) : (
<Badge variant='outline'>手动部署</Badge>
)}
</div>
</CardHeader>
<CardContent className='space-y-4'>
{source.source_type === 'manual' ? (
<div className='rounded-lg border border-dashed bg-muted/20 p-4'>
<p className='text-sm font-medium'>本地部署包</p>
<p className='mt-1 text-sm text-muted-foreground'>
当前没有持久化远端来源。上传部署包后,再从部署历史显式激活。
</p>
</div>
) : source.source_type === 'remote_url' ? (
<RemoteSourceDetails source={source} />
) : (
<GitHubSourceDetails source={source} />
)}
{dispatchError ? (
<ErrorInline
message={
dispatchError instanceof Error
? dispatchError.message
: '来源任务提交失败'
}
/>
) : null}
{executionQuery.isError ? (
<ErrorInline
message={
executionQuery.error instanceof Error
? executionQuery.error.message
: '任务状态读取失败'
}
onRetry={() => void executionQuery.refetch()}
/>
) : null}
{actionTimedOut ? (
<div className='flex flex-col gap-2 rounded-lg border border-dashed p-3 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-xs text-muted-foreground'>
自动等待已停止,任务可能仍在后台运行。
</span>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
if (!activeAction) return;
setActiveAction({ ...activeAction, startedAt: Date.now() });
setActionTimedOut(false);
void executionQuery.refetch();
void sourceQuery.refetch();
}}
>
<RefreshCw data-icon='inline-start' />
刷新任务状态
</Button>
</div>
) : null}
</CardContent>
<CardFooter className='flex flex-wrap gap-2 border-t border-dashed'>
<Button
type='button'
size='sm'
variant='outline'
disabled={source.source_type !== 'manual' && actionsDisabled}
onClick={() => openSourceDialog(source.source_type)}
>
<Pencil data-icon='inline-start' />
配置
</Button>
{source.source_type !== 'manual' ? (
<>
{source.source_type === 'github_release' ? (
<Button
type='button'
size='sm'
variant='outline'
disabled={actionsDisabled}
onClick={() => {
syncMutation.reset();
checkMutation.mutate();
}}
>
{checkBusy ? (
<Spinner data-icon='inline-start' />
) : (
<Search data-icon='inline-start' />
)}
检查更新
</Button>
) : null}
<Button
type='button'
size='sm'
disabled={
actionsDisabled ||
(source.sync_status === 'attention' && !attentionRevision)
}
onClick={dispatchSync}
>
{syncBusy ? (
<Spinner data-icon='inline-start' />
) : (
<Download data-icon='inline-start' />
)}
同步并发布
</Button>
</>
) : null}
<Button
type='button'
variant='ghost'
size='sm'
className='ml-auto'
onClick={() => void sourceQuery.refetch()}
>
<RefreshCw data-icon='inline-start' />
刷新
</Button>
</CardFooter>
</Card>
<PagesSourceDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
projectId={projectId}
source={source}
initialMode={dialogMode}
onActionDispatched={beginActionPolling}
/>
<AlertDialog
open={attentionDialogOpen}
onOpenChange={(open) => {
if (!syncMutation.isPending) setAttentionDialogOpen(open);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认发布当前 GitHub revision</AlertDialogTitle>
<AlertDialogDescription>
<span>这将发布卡片当前显示的精确 revision:</span>{' '}
<span className='break-all font-mono'>
{attentionRevision?.revision ??
'当前 revision 已不可用,请刷新后重试'}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={syncMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
disabled={syncMutation.isPending || !attentionRevision}
onClick={(event) => {
event.preventDefault();
if (!attentionRevision) return;
syncMutation.mutate({
confirmed_revision: attentionRevision.revision,
});
}}
>
{syncMutation.isPending ? (
<Spinner data-icon='inline-start' />
) : null}
确认并发布
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,158 @@
import { TriangleAlert } from 'lucide-react';
import type { ReactNode } from 'react';
import { ErrorInline } from '@/components/layout/error';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import {
type PagesGitHubReleaseSource,
type PagesRemoteURLSource,
type PagesSourceRevision,
} from '@/lib/services/openflare';
import { cn, formatDateTime } from '@/lib/utils';
function revisionSummary(revision?: PagesSourceRevision) {
if (!revision) return '尚无记录';
const label = revision.label?.trim();
const short = revision.revision.slice(0, 12);
return label ? `${label} · ${short}` : short;
}
function formatOptionalTime(value?: string | null, empty = '—') {
return value ? formatDateTime(value) : empty;
}
function SourceMetaRow({
label,
children,
mono,
}: {
label: string;
children: ReactNode;
mono?: boolean;
}) {
return (
<div className='grid grid-cols-[4.75rem_minmax(0,1fr)] items-baseline gap-x-3 py-0.5 sm:grid-cols-[5.5rem_minmax(0,1fr)]'>
<dt className='text-xs text-muted-foreground'>{label}</dt>
<dd
className={cn(
'min-w-0 break-all text-sm leading-relaxed',
mono && 'font-mono text-[13px]',
)}
>
{children}
</dd>
</div>
);
}
export function RemoteSourceDetails({
source,
}: {
source: PagesRemoteURLSource;
}) {
return (
<div className='space-y-4'>
<div className='rounded-lg border border-dashed bg-muted/15 px-5 py-5'>
<dl className='space-y-3.5'>
<SourceMetaRow label='地址' mono>
{source.remote_url || '—'}
</SourceMetaRow>
<div className='grid gap-3.5 sm:grid-cols-2'>
<SourceMetaRow label='TLS'>
{source.allow_insecure ? '允许不安全连接' : '校验证书'}
</SourceMetaRow>
<SourceMetaRow label='最近同步'>
{formatOptionalTime(source.last_synced_at, '尚未同步')}
</SourceMetaRow>
</div>
<SourceMetaRow label='已应用' mono>
{revisionSummary(source.last_applied)}
</SourceMetaRow>
</dl>
</div>
{source.last_error ? <ErrorInline message={source.last_error} /> : null}
</div>
);
}
export function GitHubSourceDetails({
source,
}: {
source: PagesGitHubReleaseSource;
}) {
const attentionRevision =
source.sync_status === 'attention' ? source.last_seen : undefined;
const releaseLabel =
source.release_selector === 'latest'
? '最新 Release'
: `固定 Tag · ${source.release_tag || '未提供'}`;
return (
<div className='space-y-4'>
{attentionRevision ? (
<Alert variant='destructive'>
<TriangleAlert />
<AlertTitle>Release Asset 发生变化,需要显式确认</AlertTitle>
<AlertDescription>
<p>
当前远端 revision
与已发布内容不一致。请核对版本和资源后,再确认发布这一精确
revision。
</p>
<code className='break-all'>{attentionRevision.revision}</code>
</AlertDescription>
</Alert>
) : null}
<div className='rounded-lg border border-dashed bg-muted/15 px-5 py-5'>
<div className='mb-4 flex flex-wrap items-center gap-2 border-b border-dashed pb-4'>
<code className='text-sm font-medium'>
{source.github_repository}
</code>
<Badge variant='secondary' className='font-normal'>
{source.asset_name}
</Badge>
<Badge variant='outline' className='font-normal'>
{releaseLabel}
</Badge>
</div>
<dl className='grid gap-x-8 gap-y-3.5 sm:grid-cols-2'>
{source.release_selector === 'latest' ? (
<>
<SourceMetaRow label='自动更新'>
{source.auto_update_enabled ? '已开启' : '已关闭'}
</SourceMetaRow>
<SourceMetaRow label='检查间隔'>
{source.check_interval_minutes} 分钟
</SourceMetaRow>
<SourceMetaRow label='下次检查'>
{formatOptionalTime(source.next_check_at, '等待调度')}
</SourceMetaRow>
<SourceMetaRow label='最近检查'>
{formatOptionalTime(source.last_checked_at, '尚未检查')}
</SourceMetaRow>
</>
) : (
<SourceMetaRow label='最近检查'>
{formatOptionalTime(source.last_checked_at, '尚未检查')}
</SourceMetaRow>
)}
<SourceMetaRow label='远端' mono>
{revisionSummary(source.last_seen)}
</SourceMetaRow>
<SourceMetaRow label='已应用' mono>
{revisionSummary(source.last_applied)}
</SourceMetaRow>
<SourceMetaRow label='最近同步'>
{formatOptionalTime(source.last_synced_at, '尚未同步')}
</SourceMetaRow>
</dl>
</div>
{source.last_error ? <ErrorInline message={source.last_error} /> : null}
</div>
);
}
@@ -0,0 +1,472 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { Switch } from '@/components/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
type PagesSource,
type PagesSourceActionReceipt,
type PagesSourceUpdatePayload,
PagesService,
} from '@/lib/services/openflare';
import {
deploymentsQueryKey,
projectQueryKey,
projectsQueryKey,
sourceQueryKey,
} from '../../components/pages-utils';
import {
type PagesGitHubSourceFormErrors,
type PagesGitHubSourceFormValue,
PagesSourceGitHubFields,
} from './pages-source-github-fields';
import {
validGitHubAssetName,
validGitHubReleaseTag,
validGitHubRepositoryURL,
} from './pages-source-validation';
export type PagesSourceMode = 'manual' | 'remote_url' | 'github_release';
type Confirmation = 'manual' | null;
interface PagesSourceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: number;
source: PagesSource;
initialMode?: PagesSourceMode;
onActionDispatched?: (receipt: PagesSourceActionReceipt) => void;
}
const DEFAULT_GITHUB_ASSET = 'dist.zip';
const DEFAULT_GITHUB_CHECK_INTERVAL = 1440;
const EMPTY_GITHUB_ERRORS: PagesGitHubSourceFormErrors = {
repository: '',
releaseTag: '',
assetName: '',
checkInterval: '',
};
function githubRepositoryURL(repository: string) {
const value = repository.trim();
return value ? `https://github.com/${value}` : '';
}
export function PagesSourceDialog({
open,
onOpenChange,
projectId,
source,
initialMode,
onActionDispatched,
}: PagesSourceDialogProps) {
const queryClient = useQueryClient();
const [mode, setMode] = useState<PagesSourceMode>('manual');
const [allowInsecure, setAllowInsecure] = useState(false);
const [remoteURL, setRemoteURL] = useState('');
const [urlError, setURLError] = useState('');
const [githubForm, setGitHubForm] = useState<PagesGitHubSourceFormValue>({
repositoryURL: '',
releaseSelector: 'latest',
releaseTag: '',
assetName: DEFAULT_GITHUB_ASSET,
autoUpdateEnabled: false,
checkIntervalMinutes: String(DEFAULT_GITHUB_CHECK_INTERVAL),
});
const [githubErrors, setGitHubErrors] =
useState<PagesGitHubSourceFormErrors>(EMPTY_GITHUB_ERRORS);
const [confirmation, setConfirmation] = useState<Confirmation>(null);
const initializedForOpen = useRef(false);
useEffect(() => {
if (!open) {
initializedForOpen.current = false;
return;
}
// Runtime polling may replace the source view while the dialog is open.
// Initialize only on the open edge so it cannot overwrite an unsaved draft.
if (initializedForOpen.current) return;
initializedForOpen.current = true;
const nextMode = initialMode ?? source.source_type;
setMode(nextMode);
setAllowInsecure(
source.source_type === 'remote_url' && Boolean(source.allow_insecure),
);
setRemoteURL(
source.source_type === 'remote_url' ? (source.remote_url ?? '') : '',
);
setURLError('');
setGitHubForm({
repositoryURL:
source.source_type === 'github_release'
? githubRepositoryURL(source.github_repository)
: '',
releaseSelector:
source.source_type === 'github_release'
? source.release_selector
: 'latest',
releaseTag:
source.source_type === 'github_release'
? (source.release_tag ?? '')
: '',
assetName:
source.source_type === 'github_release'
? source.asset_name
: DEFAULT_GITHUB_ASSET,
autoUpdateEnabled:
source.source_type === 'github_release' &&
source.release_selector === 'latest'
? source.auto_update_enabled
: false,
checkIntervalMinutes:
source.source_type === 'github_release' &&
source.release_selector === 'latest'
? String(
source.check_interval_minutes || DEFAULT_GITHUB_CHECK_INTERVAL,
)
: String(DEFAULT_GITHUB_CHECK_INTERVAL),
});
setGitHubErrors(EMPTY_GITHUB_ERRORS);
setConfirmation(null);
}, [initialMode, open, source]);
const invalidateSourceState = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(projectId),
}),
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
]);
};
const updateMutation = useMutation({
mutationFn: (payload: PagesSourceUpdatePayload) =>
PagesService.updateSource(projectId, payload),
onSuccess: async (result) => {
queryClient.setQueryData(sourceQueryKey(projectId), result.source);
if (result.check_task) onActionDispatched?.(result.check_task);
await invalidateSourceState();
toast.success('部署源已更新');
if (result.warning) toast.warning(result.warning);
setConfirmation(null);
onOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '部署源更新失败');
},
});
const deleteMutation = useMutation({
mutationFn: () => PagesService.deleteSource(projectId),
onSuccess: async (manualSource) => {
queryClient.setQueryData(sourceQueryKey(projectId), manualSource);
await invalidateSourceState();
toast.success('已切换回手动部署');
setConfirmation(null);
onOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '切换失败');
},
});
const isPending = updateMutation.isPending || deleteMutation.isPending;
const remotePayload = (): PagesSourceUpdatePayload => ({
source_type: 'remote_url',
remote_url: remoteURL.trim(),
allow_insecure: allowInsecure,
});
const submitRemote = () => {
const value = remoteURL.trim();
if (!value) {
setURLError('请输入 Remote URL');
return;
}
try {
const parsed = new URL(value);
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error();
} catch {
setURLError('请输入有效的 HTTP(S) URL');
return;
}
setURLError('');
updateMutation.mutate(remotePayload());
};
const submitGitHub = () => {
const normalizedRepositoryURL = githubForm.repositoryURL.trim();
const nextRepositoryError = validGitHubRepositoryURL(
normalizedRepositoryURL,
)
? ''
: '请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址';
const nextReleaseTagError =
githubForm.releaseSelector === 'tag' &&
!validGitHubReleaseTag(githubForm.releaseTag)
? 'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)'
: '';
const nextAssetNameError = validGitHubAssetName(githubForm.assetName)
? ''
: 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符';
const checkIntervalMinutes = Number(githubForm.checkIntervalMinutes);
const nextCheckIntervalError =
githubForm.releaseSelector === 'latest' &&
(!Number.isInteger(checkIntervalMinutes) ||
checkIntervalMinutes < 5 ||
checkIntervalMinutes > 1440)
? '检查间隔须为 5–1440 分钟的整数'
: '';
setGitHubErrors({
repository: nextRepositoryError,
releaseTag: nextReleaseTagError,
assetName: nextAssetNameError,
checkInterval: nextCheckIntervalError,
});
if (
nextRepositoryError ||
nextReleaseTagError ||
nextAssetNameError ||
nextCheckIntervalError
) {
return;
}
const payload: PagesSourceUpdatePayload =
githubForm.releaseSelector === 'latest'
? {
source_type: 'github_release',
repository_url: normalizedRepositoryURL,
release_selector: 'latest',
release_tag: '',
asset_name: githubForm.assetName,
auto_update_enabled: githubForm.autoUpdateEnabled,
check_interval_minutes: checkIntervalMinutes,
}
: {
source_type: 'github_release',
repository_url: normalizedRepositoryURL,
release_selector: 'tag',
release_tag: githubForm.releaseTag,
asset_name: githubForm.assetName,
auto_update_enabled: false,
check_interval_minutes: 0,
};
updateMutation.mutate(payload);
};
const handleSubmit = () => {
switch (mode) {
case 'manual':
if (source.source_type === 'manual') {
onOpenChange(false);
} else {
setConfirmation('manual');
}
return;
case 'remote_url':
submitRemote();
return;
case 'github_release':
submitGitHub();
}
};
const submitLabel =
mode === 'manual'
? '使用手动部署'
: mode === 'remote_url'
? '保存 Remote 来源'
: '保存 GitHub 来源';
return (
<>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!isPending) onOpenChange(nextOpen);
}}
>
<DialogContent className='sm:max-w-xl'>
<DialogHeader>
<DialogTitle>部署源设置</DialogTitle>
<DialogDescription>选择部署来源。</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldTitle id='pages-source-mode'>来源类型</FieldTitle>
<ToggleGroup
type='single'
variant='outline'
value={mode}
aria-labelledby='pages-source-mode'
className='grid w-full grid-cols-1 sm:grid-cols-3'
onValueChange={(value) => {
if (
value === 'manual' ||
value === 'remote_url' ||
value === 'github_release'
) {
setMode(value);
if (value !== 'remote_url') {
setURLError('');
} else if (source.source_type === 'remote_url') {
setRemoteURL(source.remote_url ?? '');
}
}
}}
>
<ToggleGroupItem value='manual' className='w-full'>
手动部署
</ToggleGroupItem>
<ToggleGroupItem value='remote_url' className='w-full'>
Remote URL
</ToggleGroupItem>
<ToggleGroupItem value='github_release' className='w-full'>
GitHub Release
</ToggleGroupItem>
</ToggleGroup>
</Field>
{mode === 'manual' ? (
<Field>
<FieldLabel>手动部署</FieldLabel>
<div className='rounded-lg border bg-muted/20 p-4 text-sm text-muted-foreground'>
保留现有部署与当前生产版本,后续通过“上传部署包”创建新部署。
</div>
</Field>
) : mode === 'remote_url' ? (
<>
<Field data-invalid={Boolean(urlError)}>
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
<Input
id='pages-remote-url'
type='url'
placeholder='https://artifacts.example.com/site.zip'
value={remoteURL}
aria-invalid={Boolean(urlError)}
autoComplete='off'
onChange={(event) => {
setRemoteURL(event.target.value);
setURLError('');
}}
/>
<FieldDescription>
{urlError || '填写可直接下载的部署包 HTTP(S) 地址。'}
</FieldDescription>
</Field>
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div className='space-y-1 pr-4'>
<p className='text-sm font-medium'>允许不安全的连接</p>
<p className='text-xs text-muted-foreground'>
默认允许公网与内网地址;开启后跳过 TLS
证书校验,适用于自签名或私有 CA。
</p>
</div>
<Switch
checked={allowInsecure}
onCheckedChange={setAllowInsecure}
aria-label='允许不安全的连接'
/>
</div>
</>
) : (
<PagesSourceGitHubFields
value={githubForm}
errors={githubErrors}
defaultAssetName={DEFAULT_GITHUB_ASSET}
onChange={setGitHubForm}
onErrorsChange={setGitHubErrors}
/>
)}
</FieldGroup>
<DialogFooter>
<Button
type='button'
variant='outline'
disabled={isPending}
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button type='button' disabled={isPending} onClick={handleSubmit}>
{isPending ? <Spinner data-icon='inline-start' /> : null}
{submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={confirmation !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen && !isPending) setConfirmation(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>切换回手动部署</AlertDialogTitle>
<AlertDialogDescription>
当前来源配置将被删除,但已有部署与当前生产版本会保留。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>取消</AlertDialogCancel>
<AlertDialogAction
disabled={isPending}
onClick={(event) => {
event.preventDefault();
if (confirmation === 'manual') {
deleteMutation.mutate();
}
}}
>
{isPending ? <Spinner data-icon='inline-start' /> : null}
确认
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,203 @@
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldLabel,
FieldTitle,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { PagesGitHubReleaseSelector } from '@/lib/services/openflare';
export interface PagesGitHubSourceFormValue {
repositoryURL: string;
releaseSelector: PagesGitHubReleaseSelector;
releaseTag: string;
assetName: string;
autoUpdateEnabled: boolean;
checkIntervalMinutes: string;
}
export interface PagesGitHubSourceFormErrors {
repository: string;
releaseTag: string;
assetName: string;
checkInterval: string;
}
interface PagesSourceGitHubFieldsProps {
value: PagesGitHubSourceFormValue;
errors: PagesGitHubSourceFormErrors;
defaultAssetName: string;
onChange: (value: PagesGitHubSourceFormValue) => void;
onErrorsChange: (errors: PagesGitHubSourceFormErrors) => void;
}
export function PagesSourceGitHubFields({
value,
errors,
defaultAssetName,
onChange,
onErrorsChange,
}: PagesSourceGitHubFieldsProps) {
return (
<>
<Field data-invalid={Boolean(errors.repository)}>
<FieldLabel htmlFor='pages-github-repository'>
GitHub 仓库 URL
</FieldLabel>
<Input
id='pages-github-repository'
type='url'
placeholder='https://github.com/owner/repo'
value={value.repositoryURL}
aria-invalid={Boolean(errors.repository)}
aria-describedby='pages-github-repository-description pages-github-repository-error'
autoComplete='off'
onChange={(event) => {
onChange({ ...value, repositoryURL: event.target.value });
onErrorsChange({ ...errors, repository: '' });
}}
/>
<FieldDescription id='pages-github-repository-description'>
仅支持公开 github.com 仓库。
</FieldDescription>
<FieldError id='pages-github-repository-error'>
{errors.repository}
</FieldError>
</Field>
<Field>
<FieldTitle id='pages-github-selector'>Release 选择</FieldTitle>
<ToggleGroup
type='single'
variant='outline'
value={value.releaseSelector}
aria-labelledby='pages-github-selector'
className='grid w-full grid-cols-2'
onValueChange={(selector) => {
if (selector === 'latest' || selector === 'tag') {
onChange({
...value,
releaseSelector: selector,
autoUpdateEnabled:
selector === 'latest' ? value.autoUpdateEnabled : false,
});
onErrorsChange({
...errors,
releaseTag: '',
checkInterval: '',
});
}
}}
>
<ToggleGroupItem value='latest' className='w-full'>
最新 Release
</ToggleGroupItem>
<ToggleGroupItem value='tag' className='w-full'>
固定 Tag
</ToggleGroupItem>
</ToggleGroup>
</Field>
{value.releaseSelector === 'tag' ? (
<Field data-invalid={Boolean(errors.releaseTag)}>
<FieldLabel htmlFor='pages-github-tag'>Release tag</FieldLabel>
<Input
id='pages-github-tag'
placeholder='v1.2.3'
value={value.releaseTag}
aria-invalid={Boolean(errors.releaseTag)}
aria-describedby='pages-github-tag-description pages-github-tag-error'
autoComplete='off'
onChange={(event) => {
onChange({ ...value, releaseTag: event.target.value });
onErrorsChange({ ...errors, releaseTag: '' });
}}
/>
<FieldDescription id='pages-github-tag-description'>
精确检查并同步指定 tag。
</FieldDescription>
<FieldError id='pages-github-tag-error'>
{errors.releaseTag}
</FieldError>
</Field>
) : null}
{value.releaseSelector === 'latest' ? (
<>
<Field orientation='horizontal'>
<FieldContent>
<FieldLabel htmlFor='pages-github-auto-update'>
自动更新
</FieldLabel>
<FieldDescription>
检查到新的 Release 后自动同步并发布。
</FieldDescription>
</FieldContent>
<Switch
id='pages-github-auto-update'
checked={value.autoUpdateEnabled}
onCheckedChange={(checked) =>
onChange({ ...value, autoUpdateEnabled: checked })
}
/>
</Field>
<Field data-invalid={Boolean(errors.checkInterval)}>
<FieldLabel htmlFor='pages-github-check-interval'>
检查间隔(分钟)
</FieldLabel>
<Input
id='pages-github-check-interval'
type='number'
min={5}
max={1440}
step={1}
inputMode='numeric'
value={value.checkIntervalMinutes}
aria-invalid={Boolean(errors.checkInterval)}
aria-describedby='pages-github-check-interval-description pages-github-check-interval-error'
onChange={(event) => {
onChange({
...value,
checkIntervalMinutes: event.target.value,
});
onErrorsChange({ ...errors, checkInterval: '' });
}}
/>
<FieldDescription id='pages-github-check-interval-description'>
可设置为 5–1440 分钟。
</FieldDescription>
<FieldError id='pages-github-check-interval-error'>
{errors.checkInterval}
</FieldError>
</Field>
</>
) : null}
<Field data-invalid={Boolean(errors.assetName)}>
<FieldLabel htmlFor='pages-github-asset'>
Release Asset 文件名
</FieldLabel>
<Input
id='pages-github-asset'
placeholder={defaultAssetName}
value={value.assetName}
aria-invalid={Boolean(errors.assetName)}
aria-describedby='pages-github-asset-description pages-github-asset-error'
autoComplete='off'
onChange={(event) => {
onChange({ ...value, assetName: event.target.value });
onErrorsChange({ ...errors, assetName: '' });
}}
/>
<FieldError id='pages-github-asset-error'>
{errors.assetName}
</FieldError>
</Field>
</>
);
}
@@ -0,0 +1,68 @@
function hasWellFormedUnicode(value: string) {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
index += 1;
} else if (code >= 0xdc00 && code <= 0xdfff) {
return false;
}
}
return true;
}
function validGitHubSafeText(value: string) {
return (
value !== '' &&
new TextEncoder().encode(value).byteLength <= 255 &&
!/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(
value,
) &&
hasWellFormedUnicode(value)
);
}
export function validGitHubRepositoryURL(raw: string) {
const match = /^https:\/\/([^/]+)\/([^/]+)\/([^/]+)$/u.exec(raw);
if (!match) return false;
const [, host, owner, rawRepository] = match;
const repository = rawRepository.replace(/\.git$/u, '');
return (
host.toLowerCase() === 'github.com' &&
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/iu.test(owner) &&
/^[a-z0-9._-]+$/iu.test(repository) &&
repository.length <= 100 &&
!['.', '..'].includes(repository)
);
}
export function validGitHubAssetName(value: string) {
return (
validGitHubSafeText(value) &&
value !== '.' &&
value !== '..' &&
!value.includes('/') &&
!value.includes('\\')
);
}
export function validGitHubReleaseTag(value: string) {
const components = value.split('/');
return (
validGitHubSafeText(value) &&
!value.endsWith('.') &&
!value.includes('..') &&
!value.includes('@{') &&
![' ', '~', '^', ':', '?', '*', '[', '\\'].some((character) =>
value.includes(character),
) &&
components.every(
(component) =>
component !== '' &&
!component.startsWith('.') &&
!component.endsWith('.lock'),
)
);
}
@@ -0,0 +1,110 @@
'use client';
import { useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { type PagesProject, PagesService } from '@/lib/services/openflare';
import {
projectQueryKey,
projectsQueryKey,
sourceQueryKey,
} from '../../components/pages-utils';
import {
buildProjectPayload,
ProjectFormFields,
toFormValues,
usePagesProjectForm,
} from '../../components/project-form';
interface ProjectSettingsCardProps {
project: PagesProject;
}
export function ProjectSettingsCard({ project }: ProjectSettingsCardProps) {
const queryClient = useQueryClient();
const form = usePagesProjectForm(project);
useEffect(() => {
form.reset(toFormValues(project));
}, [form, project]);
const mutation = useMutation({
mutationFn: async (values: Parameters<typeof buildProjectPayload>[0]) => {
const payload = buildProjectPayload(values, project);
return PagesService.updateProject(project.id, payload);
},
onSuccess: async () => {
toast.success('项目已更新');
await Promise.all([
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
queryClient.invalidateQueries({
queryKey: projectQueryKey(project.id),
}),
queryClient.invalidateQueries({
queryKey: sourceQueryKey(project.id),
}),
]);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '保存失败');
},
});
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>编辑 Pages 项目</CardTitle>
<CardDescription>
配置静态站点托管参数,保存后会同步到项目详情与代理引用。
</CardDescription>
</div>
<div className='flex shrink-0 flex-wrap gap-2'>
<Button
type='button'
size='sm'
variant='outline'
disabled={!form.formState.isDirty || mutation.isPending}
onClick={() => form.reset(toFormValues(project))}
>
重置
</Button>
<Button
type='submit'
size='sm'
form='pages-project-settings-form'
disabled={!form.formState.isDirty || mutation.isPending}
>
{mutation.isPending ? (
<>
<Loader2 className='mr-1 size-4 animate-spin' />
保存中...
</>
) : (
'保存修改'
)}
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<form
id='pages-project-settings-form'
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<ProjectFormFields form={form} idPrefix='settings' />
</form>
</CardContent>
</Card>
);
}
@@ -1,505 +0,0 @@
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'next/navigation';
import {
ArrowLeft,
ChevronDown,
ChevronRight,
FileText,
Loader2,
Trash2,
Upload,
} from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { type PagesDeployment, PagesService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { DeploymentUploadDialog } from '../components/deployment-upload-dialog';
import { ProjectEditorDialog } from '../components/project-editor-dialog';
import {
deploymentFilesQueryKey,
deploymentsQueryKey,
formatBytes,
projectQueryKey,
projectsQueryKey,
} from '../components/pages-utils';
function DeploymentFilesPanel({
projectId,
deployment,
}: {
projectId: number;
deployment: PagesDeployment;
}) {
const filesQuery = useQuery({
queryKey: deploymentFilesQueryKey(projectId, deployment.id),
queryFn: () => PagesService.listDeploymentFiles(deployment.id),
});
if (filesQuery.isLoading) {
return (
<p className='px-4 py-3 text-xs text-muted-foreground'>加载文件清单...</p>
);
}
if (filesQuery.isError) {
return (
<p className='px-4 py-3 text-xs text-destructive'>
{filesQuery.error instanceof Error
? filesQuery.error.message
: '加载失败'}
</p>
);
}
const files = filesQuery.data ?? [];
if (files.length === 0) {
return (
<p className='px-4 py-3 text-xs text-muted-foreground'>暂无文件记录</p>
);
}
return (
<div className='border-t border-dashed bg-muted/10'>
<Table>
<TableHeader>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>路径</TableHead>
<TableHead className='text-xs text-right'>大小</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<TableRow key={file.id} className='border-dashed'>
<TableCell className='text-xs font-mono'>{file.path}</TableCell>
<TableCell className='text-xs text-right text-muted-foreground'>
{formatBytes(file.size)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export function PagesDetailPageClient() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const projectId = searchParams.get('id')?.trim() ?? '';
const parsedProjectId = Number(projectId);
const [editorOpen, setEditorOpen] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [expandedDeploymentId, setExpandedDeploymentId] = useState<
number | null
>(null);
const [deleteProjectOpen, setDeleteProjectOpen] = useState(false);
const [pendingDeploymentAction, setPendingDeploymentAction] = useState<{
type: 'activate' | 'delete';
deployment: PagesDeployment;
} | null>(null);
const enabled = projectId !== '' && Number.isFinite(parsedProjectId);
const projectQuery = useQuery({
queryKey: projectQueryKey(projectId),
queryFn: () => PagesService.getProject(parsedProjectId),
enabled,
});
const deploymentsQuery = useQuery({
queryKey: deploymentsQueryKey(parsedProjectId),
queryFn: () => PagesService.listDeployments(parsedProjectId),
enabled,
});
const activateMutation = useMutation({
mutationFn: (deploymentId: number) =>
PagesService.activateDeployment(parsedProjectId, deploymentId),
onSuccess: async () => {
toast.success('部署已激活');
await Promise.all([
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(parsedProjectId),
}),
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
]);
setPendingDeploymentAction(null);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '激活失败');
},
});
const deleteDeploymentMutation = useMutation({
mutationFn: (deploymentId: number) =>
PagesService.deleteDeployment(parsedProjectId, deploymentId),
onSuccess: async () => {
toast.success('部署已删除');
await Promise.all([
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(parsedProjectId),
}),
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
]);
setPendingDeploymentAction(null);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '删除失败');
},
});
const deleteProjectMutation = useMutation({
mutationFn: () => PagesService.deleteProject(parsedProjectId),
onSuccess: async () => {
toast.success('项目已删除');
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
window.location.href = '/pages';
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '删除失败');
},
});
const project = projectQuery.data;
const deployments = useMemo(
() => deploymentsQuery.data ?? [],
[deploymentsQuery.data],
);
if (!enabled) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder description='缺少有效的 Pages 项目 ID。' />
</div>
);
}
if (projectQuery.isLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder icon={FileText} description='加载项目详情...' />
</div>
);
}
if (projectQuery.isError) {
return (
<div className='py-6 px-1'>
<ErrorInline
message={
projectQuery.error instanceof Error
? projectQuery.error.message
: '加载失败'
}
onRetry={() => void projectQuery.refetch()}
/>
</div>
);
}
if (!project) {
return (
<div className='py-6 px-1 space-y-4'>
<Button variant='ghost' size='sm' asChild>
<Link href='/pages'>
<ArrowLeft className='size-4 mr-1' />
返回列表
</Link>
</Button>
<EmptyStateWithBorder description='Pages 项目不存在或已被删除。' />
</div>
);
}
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div className='space-y-2'>
<Button variant='ghost' size='sm' className='h-8 px-2 -ml-2' asChild>
<Link href='/pages'>
<ArrowLeft className='size-4 mr-1' />
返回列表
</Link>
</Button>
<div className='flex items-center gap-2'>
<FileText className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{project.name}
</h1>
</div>
<p className='text-sm text-muted-foreground'>
{project.slug} · {project.deployment_count} 个部署
</p>
</div>
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => setEditorOpen(true)}
>
编辑项目
</Button>
<Button size='sm' onClick={() => setUploadOpen(true)}>
<Upload className='size-3.5 mr-1' />
上传部署包
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => setDeleteProjectOpen(true)}
>
<Trash2 className='size-3.5 mr-1' />
删除项目
</Button>
</div>
</div>
<div className='grid gap-3 sm:grid-cols-3'>
<div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
状态
</p>
<Badge variant='outline' className='mt-2 text-[10px]'>
{project.enabled ? '已启用' : '已停用'}
</Badge>
</div>
<div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
当前激活
</p>
<p className='mt-2 text-sm font-semibold'>
{project.active_deployment
? `#${project.active_deployment.deployment_number}`
: '暂无'}
</p>
</div>
<div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
更新时间
</p>
<p className='mt-2 text-sm'>{formatDateTime(project.updated_at)}</p>
</div>
</div>
<div className='border border-dashed rounded-lg overflow-hidden bg-background'>
<div className='px-4 py-3 border-b border-dashed'>
<h2 className='text-sm font-semibold'>部署历史</h2>
<p className='text-xs text-muted-foreground mt-1'>
部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。
</p>
</div>
{deploymentsQuery.isLoading ? (
<LoadingStateWithBorder />
) : deployments.length === 0 ? (
<EmptyStateWithBorder
title='暂无部署'
description='上传 zip 部署包后,可以在这里激活某个部署版本。'
/>
) : (
<div className='divide-y divide-dashed'>
{deployments.map((deployment) => {
const expanded = expandedDeploymentId === deployment.id;
return (
<div key={deployment.id}>
<div className='flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between'>
<div className='flex items-start gap-2'>
<Button
variant='ghost'
size='icon'
className='size-7 shrink-0'
onClick={() =>
setExpandedDeploymentId(
expanded ? null : deployment.id,
)
}
>
{expanded ? (
<ChevronDown className='size-4' />
) : (
<ChevronRight className='size-4' />
)}
</Button>
<div>
<div className='flex items-center gap-2'>
<p className='text-sm font-medium'>
#{deployment.deployment_number}
</p>
{deployment.status === 'active' ? (
<Badge variant='outline' className='text-[10px]'>
已激活
</Badge>
) : null}
</div>
<p className='mt-1 text-xs text-muted-foreground'>
{deployment.checksum.slice(0, 16)} ·{' '}
{deployment.file_count} files ·{' '}
{formatBytes(deployment.total_size)}
</p>
<p className='mt-1 text-xs text-muted-foreground'>
创建于 {formatDateTime(deployment.created_at)}
</p>
</div>
</div>
<div className='flex gap-2 md:ml-9'>
<Button
variant='outline'
size='sm'
disabled={
deployment.status === 'active' ||
activateMutation.isPending
}
onClick={() =>
setPendingDeploymentAction({
type: 'activate',
deployment,
})
}
>
激活
</Button>
<Button
variant='destructive'
size='sm'
disabled={
deployment.status === 'active' ||
deleteDeploymentMutation.isPending
}
onClick={() =>
setPendingDeploymentAction({
type: 'delete',
deployment,
})
}
>
删除
</Button>
</div>
</div>
{expanded ? (
<DeploymentFilesPanel
projectId={parsedProjectId}
deployment={deployment}
/>
) : null}
</div>
);
})}
</div>
)}
</div>
<ProjectEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
project={project}
/>
<DeploymentUploadDialog
open={uploadOpen}
onOpenChange={setUploadOpen}
projectId={parsedProjectId}
/>
<AlertDialog open={deleteProjectOpen} onOpenChange={setDeleteProjectOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除 Pages 项目</AlertDialogTitle>
<AlertDialogDescription>
确认删除项目 {project.name} 吗?此操作不可恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() => deleteProjectMutation.mutate()}
>
{deleteProjectMutation.isPending ? (
<Loader2 className='size-4 animate-spin' />
) : (
'确认删除'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={pendingDeploymentAction !== null}
onOpenChange={(open) => {
if (!open) setPendingDeploymentAction(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingDeploymentAction?.type === 'activate'
? '激活部署'
: '删除部署'}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDeploymentAction?.type === 'activate'
? `确认激活部署 #${pendingDeploymentAction.deployment.deployment_number} 吗?`
: `确认删除部署 #${pendingDeploymentAction?.deployment.deployment_number} 吗?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className={
pendingDeploymentAction?.type === 'delete'
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: undefined
}
onClick={() => {
if (!pendingDeploymentAction) return;
if (pendingDeploymentAction.type === 'activate') {
activateMutation.mutate(
pendingDeploymentAction.deployment.id,
);
} else {
deleteDeploymentMutation.mutate(
pendingDeploymentAction.deployment.id,
);
}
}}
>
确认
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+157 -5
View File
@@ -1,23 +1,175 @@
'use client';
import Link from 'next/link';
import { Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft, FileText } from 'lucide-react';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PagesService } from '@/lib/services/openflare';
import { PagesDetailPageClient } from './page-client';
import { projectQueryKey } from '../components/pages-utils';
import { DeploymentsTab } from './tabs/deployments-tab';
import { SettingsTab } from './tabs/settings-tab';
type PagesDetailTab = 'deployments' | 'settings';
function resolveTab(value: string | null): PagesDetailTab {
if (value === 'settings') return 'settings';
return 'deployments';
}
function PagesDetailPageFallback() {
return (
<div className='py-6 px-1 space-y-6'>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-10 w-full max-w-xl' />
<div className='flex w-full flex-col gap-6 px-1 py-6'>
<Skeleton className='h-8 w-32' />
<Skeleton className='h-12 w-full max-w-xl' />
<Skeleton className='h-10 w-64' />
<Skeleton className='h-64 w-full' />
</div>
);
}
function PagesDetailRoute() {
const searchParams = useSearchParams();
const router = useRouter();
const rawProjectId = searchParams.get('id')?.trim() ?? '';
const projectId = Number(rawProjectId);
const validProjectId =
rawProjectId !== '' && Number.isInteger(projectId) && projectId > 0;
const tab = resolveTab(searchParams.get('tab'));
const projectQuery = useQuery({
queryKey: projectQueryKey(projectId),
queryFn: () => PagesService.getProject(projectId),
enabled: validProjectId,
});
const handleTabChange = (value: string) => {
const next = resolveTab(value);
const params = new URLSearchParams();
params.set('id', String(projectId));
if (next === 'settings') {
params.set('tab', 'settings');
}
router.replace(`/pages/detail?${params.toString()}`);
};
if (!validProjectId) {
return (
<div className='w-full px-1 py-6'>
<EmptyStateWithBorder description='缺少有效的 Pages 项目 ID。' />
</div>
);
}
if (projectQuery.isLoading) {
return (
<div className='w-full px-1 py-6'>
<LoadingStateWithBorder icon={FileText} description='加载项目详情...' />
</div>
);
}
if (projectQuery.isError) {
return (
<div className='w-full px-1 py-6'>
<div className='rounded-lg border p-4'>
<ErrorInline
message={
projectQuery.error instanceof Error
? projectQuery.error.message
: '项目详情加载失败'
}
onRetry={() => void projectQuery.refetch()}
/>
</div>
</div>
);
}
const project = projectQuery.data;
if (!project) {
return (
<div className='flex w-full flex-col gap-4 px-1 py-6'>
<Button variant='ghost' size='sm' asChild>
<Link href='/pages'>
<ArrowLeft data-icon='inline-start' />
返回列表
</Link>
</Button>
<EmptyStateWithBorder description='Pages 项目不存在或已被删除。' />
</div>
);
}
return (
<div className='flex w-full flex-col gap-6 px-1 py-6'>
<div className='flex flex-col gap-4'>
<Button variant='ghost' size='sm' className='self-start' asChild>
<Link href='/pages'>
<ArrowLeft data-icon='inline-start' />
返回列表
</Link>
</Button>
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<FileText className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{project.name}
</h1>
<Badge variant={project.enabled ? 'secondary' : 'outline'}>
{project.enabled ? '已启用' : '已停用'}
</Badge>
</div>
<p className='text-sm text-muted-foreground'>
{project.slug} · {project.deployment_count} 个部署
</p>
</div>
</div>
</div>
<Tabs value={tab} onValueChange={handleTabChange} className='w-full'>
<TabsList variant='line' className='mb-6 inline-flex w-fit gap-8'>
<TabsTrigger
value='deployments'
className='px-0 pb-2 text-xs font-semibold'
>
部署
</TabsTrigger>
<TabsTrigger
value='settings'
className='px-0 pb-2 text-xs font-semibold'
>
设置
</TabsTrigger>
</TabsList>
<TabsContent value='deployments' className='focus-visible:outline-none'>
<DeploymentsTab project={project} />
</TabsContent>
<TabsContent value='settings' className='focus-visible:outline-none'>
<SettingsTab project={project} />
</TabsContent>
</Tabs>
</div>
);
}
export default function PagesDetailPage() {
return (
<Suspense fallback={<PagesDetailPageFallback />}>
<PagesDetailPageClient />
<PagesDetailRoute />
</Suspense>
);
}
@@ -0,0 +1,21 @@
'use client';
import { type PagesProject } from '@/lib/services/openflare';
import { DeploymentHistory } from '../components/deployment-history';
interface DeploymentsTabProps {
project: PagesProject;
}
export function DeploymentsTab({ project }: DeploymentsTabProps) {
return (
<DeploymentHistory
key={`deployments-${project.id}`}
projectId={project.id}
activeDeploymentId={project.active_deployment_id}
rootDir={project.root_dir ?? ''}
entryFile={project.entry_file}
/>
);
}
@@ -0,0 +1,21 @@
'use client';
import { type PagesProject } from '@/lib/services/openflare';
import { DangerZoneCard } from '../components/danger-zone-card';
import { PagesSourceCard } from '../components/pages-source-card';
import { ProjectSettingsCard } from '../components/project-settings-card';
interface SettingsTabProps {
project: PagesProject;
}
export function SettingsTab({ project }: SettingsTabProps) {
return (
<div className='flex flex-col gap-6'>
<ProjectSettingsCard project={project} />
<PagesSourceCard key={`source-${project.id}`} projectId={project.id} />
<DangerZoneCard project={project} />
</div>
);
}
@@ -16,7 +16,7 @@ export const proxyRouteConfigSections = [
{
key: 'limits' as const,
label: '流量限制',
description: '设置连接数和限速。',
description: '设置连接数和限速(可继承全局默认)。',
},
{
key: 'proxy' as const,
@@ -263,18 +263,19 @@ export function customHeadersToText(headers: ProxyRouteCustomHeader[]) {
export function validateLimitRate(value: string) {
const normalized = value.trim();
if (!normalized || normalized === '0') {
if (!normalized || normalized === '0' || normalized === '-1') {
return null;
}
if (!limitRatePattern.test(normalized)) {
return '限速格式不合法,请使用 512k、1m 或纯数字';
return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭';
}
return null;
}
export function normalizeLimitRate(value: string) {
const normalized = value.trim().toLowerCase();
return normalized === '0' ? '' : normalized;
if (normalized === '0') return '';
return normalized;
}
export function validateCacheRules(
@@ -228,7 +228,7 @@ export function ProxyRouteCreateSheet({
limit_conn_per_server: 0,
limit_conn_per_ip: 0,
limit_rate: '',
cache_enabled: false,
cache_enabled: true,
cache_policy: 'static',
cache_rules: [],
custom_headers: [],
@@ -40,11 +40,11 @@ const rateLimitSchema = z
if (!rawValue) {
continue;
}
if (!/^\d+$/.test(rawValue)) {
if (!/^-1$|^\d+$/.test(rawValue)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: [field],
message: '请输入大于等于 0 的整数',
message: '请输入 -1、0 或正整数',
});
}
}
@@ -61,6 +61,21 @@ const rateLimitSchema = z
type RateLimitValues = z.infer<typeof rateLimitSchema>;
function formatConnValue(value: number | null | undefined) {
if (value === null || value === undefined || value === 0) {
return '';
}
return String(value);
}
function parseConnValue(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return 0;
}
return Number(trimmed);
}
interface LimitsSectionProps {
route: ProxyRouteItem;
onRouteUpdate: (route: ProxyRouteItem) => void;
@@ -81,24 +96,16 @@ export function LimitsSection({
const form = useForm<RateLimitValues>({
resolver: zodResolver(rateLimitSchema),
defaultValues: {
limit_conn_per_server: route.limit_conn_per_server
? String(route.limit_conn_per_server)
: '',
limit_conn_per_ip: route.limit_conn_per_ip
? String(route.limit_conn_per_ip)
: '',
limit_conn_per_server: formatConnValue(route.limit_conn_per_server),
limit_conn_per_ip: formatConnValue(route.limit_conn_per_ip),
limit_rate: route.limit_rate || '',
},
});
useEffect(() => {
form.reset({
limit_conn_per_server: route.limit_conn_per_server
? String(route.limit_conn_per_server)
: '',
limit_conn_per_ip: route.limit_conn_per_ip
? String(route.limit_conn_per_ip)
: '',
limit_conn_per_server: formatConnValue(route.limit_conn_per_server),
limit_conn_per_ip: formatConnValue(route.limit_conn_per_ip),
limit_rate: route.limit_rate || '',
});
}, [form, route]);
@@ -106,7 +113,7 @@ export function LimitsSection({
return (
<SectionShell
title='流量限制'
description='站点限流,空值或 0 表示关闭。'
description='站点限流。空或 0 继承全局默认;-1 显式关闭;大于 0 为自定义。'
formId={proxyRouteFormIds.limits}
saving={saving}
>
@@ -117,12 +124,10 @@ export function LimitsSection({
onSubmit={form.handleSubmit(async (values) => {
await save(
{
limit_conn_per_server: Number(
values.limit_conn_per_server.trim() || '0',
),
limit_conn_per_ip: Number(
values.limit_conn_per_ip.trim() || '0',
limit_conn_per_server: parseConnValue(
values.limit_conn_per_server,
),
limit_conn_per_ip: parseConnValue(values.limit_conn_per_ip),
limit_rate: normalizeLimitRate(values.limit_rate),
},
'流量限制已保存',
@@ -138,7 +143,9 @@ export function LimitsSection({
<FormControl>
<Input placeholder='120' {...field} />
</FormControl>
<FormDescription>限制当前站点最大并发连接数。</FormDescription>
<FormDescription>
空或 0 继承全局默认;-1 关闭;大于 0 为自定义并发上限。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -153,7 +160,9 @@ export function LimitsSection({
<FormControl>
<Input placeholder='12' {...field} />
</FormControl>
<FormDescription>限制单个 IP 的最大并发数。</FormDescription>
<FormDescription>
空或 0 继承全局默认;-1 关闭;大于 0 为单 IP 自定义上限。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -166,10 +175,10 @@ export function LimitsSection({
<FormItem className='md:col-span-2'>
<FormLabel>限速</FormLabel>
<FormControl>
<Input placeholder='512k/1m' {...field} />
<Input placeholder='512k/1m 或 -1' {...field} />
</FormControl>
<FormDescription>
限制单请求带宽,例如 512k 或 1m。
空或 0 继承全局默认;-1 关闭;例如 512k、1m 为自定义带宽。
</FormDescription>
<FormMessage />
</FormItem>
@@ -0,0 +1,107 @@
'use client';
import { Gauge } from 'lucide-react';
import { RankCard } from '@/components/data/rank-card';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type {
AccessLogOverview,
DistributionItem,
} from '@/lib/services/openflare';
import {
formatOverviewRangeHint,
RATE_LIMIT_RANGE_OPTIONS,
type RateLimitRangeHours,
} from '../../access-logs/components/access-log-utils';
import { OverviewToolbar } from '../../access-logs/components/overview-toolbar';
import { RatePressureChart } from './rate-pressure-chart';
function toAvgRpsItems(items: DistributionItem[] | undefined, hours: number) {
const windowSeconds = Math.max(hours, 1) * 3600;
return (items ?? []).map((item) => ({
label: item.key,
value: item.value / windowSeconds,
}));
}
function formatRps(value: number) {
if (!Number.isFinite(value)) return '—';
if (value >= 100) {
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
}
return value.toLocaleString('zh-CN', {
maximumFractionDigits: 3,
minimumFractionDigits: 0,
});
}
export function AnalysisTab({
data,
loading,
error,
hours,
hosts,
onHoursChange,
onHostsChange,
onRetry,
}: {
data?: AccessLogOverview;
loading: boolean;
error: Error | null;
hours: RateLimitRangeHours;
hosts: string[];
onHoursChange: (hours: RateLimitRangeHours) => void;
onHostsChange: (hosts: string[]) => void;
onRetry: () => void;
}) {
const rangeHint = formatOverviewRangeHint(hours);
const hostItems = toAvgRpsItems(data?.top_hosts, hours);
const ipItems = toAvgRpsItems(data?.top_ips, hours);
return (
<div className='space-y-6'>
<OverviewToolbar
hours={hours}
hosts={hosts}
onHoursChange={(next) => onHoursChange(next as RateLimitRangeHours)}
onHostsChange={onHostsChange}
rangeOptions={RATE_LIMIT_RANGE_OPTIONS}
/>
{loading ? (
<LoadingStateWithBorder icon={Gauge} description='加载请求压力...' />
) : error ? (
<ErrorInline message={error.message || '加载失败'} onRetry={onRetry} />
) : !data ? (
<EmptyStateWithBorder
icon={Gauge}
title='暂无数据'
description='当前时间范围内没有可用的访问日志。'
/>
) : (
<>
<RatePressureChart data={data} hours={hours} />
<div className='grid gap-4 lg:grid-cols-2'>
<RankCard
title='平均 RPS 最高域名'
description={`${rangeHint}平均请求速率`}
items={hostItems}
color='#38bdf8'
valueFormatter={(value) => `${formatRps(value)} req/s`}
/>
<RankCard
title='平均 RPS 最高 IP'
description={`${rangeHint}平均请求速率`}
items={ipItems}
color='#a78bfa'
valueFormatter={(value) => `${formatRps(value)} req/s`}
/>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,263 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ExternalLink, Gauge, Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { OptionService } from '@/lib/services/openflare';
const optionsQueryKey = ['openflare', 'options'] as const;
const KEY_CONN_PER_SERVER = 'openresty_default_limit_conn_per_server';
const KEY_CONN_PER_IP = 'openresty_default_limit_conn_per_ip';
const KEY_LIMIT_RATE = 'openresty_default_limit_rate';
const limitRatePattern = /^\d+(?:[kKmM])?$/;
type RateLimitFields = {
openresty_default_limit_conn_per_server: string;
openresty_default_limit_conn_per_ip: string;
openresty_default_limit_rate: string;
};
const defaultFields: RateLimitFields = {
openresty_default_limit_conn_per_server: '0',
openresty_default_limit_conn_per_ip: '0',
openresty_default_limit_rate: '',
};
function optionsToMap(options: Array<{ key: string; value: string }>) {
return options.reduce<Record<string, string>>((acc, option) => {
acc[option.key] = option.value;
return acc;
}, {});
}
function mapOptionsToFields(
optionMap: Record<string, string>,
): RateLimitFields {
return {
openresty_default_limit_conn_per_server:
optionMap[KEY_CONN_PER_SERVER] ?? '0',
openresty_default_limit_conn_per_ip: optionMap[KEY_CONN_PER_IP] ?? '0',
openresty_default_limit_rate: optionMap[KEY_LIMIT_RATE] ?? '',
};
}
function validateFields(fields: RateLimitFields) {
for (const key of [KEY_CONN_PER_SERVER, KEY_CONN_PER_IP] as const) {
const raw = fields[key].trim();
if (!raw) continue;
if (!/^\d+$/.test(raw)) {
throw new Error('并发限制请输入非负整数,或留空表示关闭');
}
}
const rate = fields.openresty_default_limit_rate.trim();
if (rate && rate !== '0' && !limitRatePattern.test(rate)) {
throw new Error('限速格式不合法,请使用 512k、1m、纯数字,或留空关闭');
}
}
function normalizeConnValue(value: string) {
const trimmed = value.trim();
if (!trimmed) return '0';
return trimmed;
}
function normalizeRateValue(value: string) {
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === '0') return '';
return normalized;
}
export function ConfigTab() {
const queryClient = useQueryClient();
const [fields, setFields] = useState<RateLimitFields>(defaultFields);
const [saving, setSaving] = useState(false);
const optionsQuery = useQuery({
queryKey: optionsQueryKey,
queryFn: () => OptionService.list(),
});
useEffect(() => {
if (!optionsQuery.data) return;
setFields(mapOptionsToFields(optionsToMap(optionsQuery.data)));
}, [optionsQuery.data]);
const saveMutation = useMutation({
mutationFn: async () => {
validateFields(fields);
setSaving(true);
await OptionService.updateBatch([
{
key: KEY_CONN_PER_SERVER,
value: normalizeConnValue(
fields.openresty_default_limit_conn_per_server,
),
},
{
key: KEY_CONN_PER_IP,
value: normalizeConnValue(fields.openresty_default_limit_conn_per_ip),
},
{
key: KEY_LIMIT_RATE,
value: normalizeRateValue(fields.openresty_default_limit_rate),
},
]);
},
onSuccess: async () => {
toast.success('限流参数已保存');
await Promise.all([
queryClient.invalidateQueries({ queryKey: optionsQueryKey }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'config-preview'],
}),
queryClient.invalidateQueries({
queryKey: ['openflare', 'config-versions'],
}),
]);
setSaving(false);
},
onError: (error) => {
setSaving(false);
toast.error(error instanceof Error ? error.message : '保存失败');
},
});
const updateField = <K extends keyof RateLimitFields>(
key: K,
value: RateLimitFields[K],
) => {
setFields((prev) => ({ ...prev, [key]: value }));
};
if (optionsQuery.isLoading) {
return (
<LoadingStateWithBorder icon={Gauge} description='加载限流参数...' />
);
}
if (optionsQuery.isError) {
return (
<ErrorInline
message={
optionsQuery.error instanceof Error
? optionsQuery.error.message
: '加载失败'
}
onRetry={() => void optionsQuery.refetch()}
/>
);
}
return (
<div className='space-y-4'>
<div className='flex justify-end'>
<Button variant='outline' size='sm' asChild>
<Link href='/config-versions'>
<ExternalLink className='size-3.5 mr-1' />
查看配置预览
</Link>
</Button>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-base'>全局默认限流</CardTitle>
<CardDescription>
0
或空表示默认关闭;站点未单独配置时继承此处设置。修改后需在版本发布中生效。
</CardDescription>
</div>
<Button
size='sm'
disabled={saving}
onClick={() => saveMutation.mutate()}
>
{saving ? (
<Loader2 className='size-4 animate-spin mr-1' />
) : (
<Save className='size-3.5 mr-1' />
)}
保存
</Button>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<div className='space-y-1.5'>
<Label className='text-xs text-muted-foreground'>
默认并发限制(每站点)
</Label>
<Input
type='number'
min={0}
value={fields.openresty_default_limit_conn_per_server}
placeholder='0'
onChange={(e) =>
updateField(
'openresty_default_limit_conn_per_server',
e.target.value,
)
}
className='h-9 text-xs'
/>
<p className='text-xs text-muted-foreground'>
站点最大并发连接数默认值
</p>
</div>
<div className='space-y-1.5'>
<Label className='text-xs text-muted-foreground'>
默认单 IP 并发
</Label>
<Input
type='number'
min={0}
value={fields.openresty_default_limit_conn_per_ip}
placeholder='0'
onChange={(e) =>
updateField(
'openresty_default_limit_conn_per_ip',
e.target.value,
)
}
className='h-9 text-xs'
/>
<p className='text-xs text-muted-foreground'>
单个 IP 最大并发连接数默认值
</p>
</div>
<div className='space-y-1.5'>
<Label className='text-xs text-muted-foreground'>默认限速</Label>
<Input
value={fields.openresty_default_limit_rate}
placeholder='512k / 1m'
onChange={(e) =>
updateField('openresty_default_limit_rate', e.target.value)
}
className='h-9 text-xs'
/>
<p className='text-xs text-muted-foreground'>
单请求带宽默认值,例如 512k 或 1m
</p>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,342 @@
'use client';
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import { Clock } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { AccessLogOverview } from '@/lib/services/openflare';
import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics';
import {
formatOverviewTrendLabel,
type RateLimitRangeHours,
} from '../../access-logs/components/access-log-utils';
const DEFAULT_BUCKET_SECONDS = 180;
const RPS_COLOR = '#38bdf8';
const VISITS_COLOR = '#a78bfa';
function formatDateTime(date: Date) {
const y = date.getFullYear();
const m = `${date.getMonth() + 1}`.padStart(2, '0');
const d = `${date.getDate()}`.padStart(2, '0');
const h = `${date.getHours()}`.padStart(2, '0');
const min = `${date.getMinutes()}`.padStart(2, '0');
const s = `${date.getSeconds()}`.padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}:${s}`;
}
function formatAxisTime(value: string, hours: number) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—\n—';
}
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hour = `${date.getHours()}`.padStart(2, '0');
const minute = `${date.getMinutes()}`.padStart(2, '0');
if (hours <= 24) {
return `${hour}:${minute}\n`;
}
return `${month}-${day}\n${hour}:${minute}`;
}
function formatRps(value: number) {
if (!Number.isFinite(value)) return '—';
if (value >= 100) return formatCompactNumber(value);
return value.toLocaleString('zh-CN', {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
});
}
type RatePressureChartProps = {
data?: AccessLogOverview;
hours: RateLimitRangeHours;
};
export function RatePressureChart({ data, hours }: RatePressureChartProps) {
const bucketSeconds =
(data?.bucket_minutes && data.bucket_minutes > 0
? data.bucket_minutes
: 3) * 60 || DEFAULT_BUCKET_SECONDS;
const rangeLabel = useMemo(() => {
const end = data?.generated_at ? new Date(data.generated_at) : new Date();
const start = new Date(end.getTime() - hours * 3600 * 1000);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return '—';
}
return `${formatDateTime(start)} — ${formatDateTime(end)}`;
}, [data?.generated_at, hours]);
const chartModel = useMemo(() => {
const requests = data?.trends.requests ?? [];
const visits = data?.trends.visits ?? [];
const length = Math.max(requests.length, visits.length);
const labels: string[] = [];
const rpsValues: number[] = [];
const visitValues: number[] = [];
const rawTimes: string[] = [];
for (let i = 0; i < length; i += 1) {
const req = requests[i];
const visit = visits[i];
const time = req?.bucket_started_at ?? visit?.bucket_started_at ?? '';
rawTimes.push(time);
labels.push(formatAxisTime(time, hours));
rpsValues.push((req?.value ?? 0) / bucketSeconds);
visitValues.push(visit?.value ?? 0);
}
return { labels, rpsValues, visitValues, rawTimes };
}, [bucketSeconds, data?.trends.requests, data?.trends.visits, hours]);
const option = useMemo<EChartsOption>(() => {
const rpsMax = calculateNiceAxisMax(chartModel.rpsValues);
const visitMax = calculateNiceAxisMax(chartModel.visitValues);
return {
animationDuration: 500,
animationEasing: 'cubicOut',
grid: {
left: 16,
right: 16,
top: 48,
bottom: 72,
containLabel: true,
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15, 23, 42, 0.92)',
borderWidth: 0,
textStyle: {
color: '#e2e8f0',
fontSize: 12,
},
formatter: (params: unknown) => {
const items = Array.isArray(params) ? params : [];
if (items.length === 0) return '';
const first = items[0] as {
dataIndex?: number;
axisValueLabel?: string;
};
const index =
typeof first.dataIndex === 'number' ? first.dataIndex : 0;
const rawTime = chartModel.rawTimes[index] ?? '';
const header = rawTime
? formatOverviewTrendLabel(rawTime, hours)
: (first.axisValueLabel ?? '');
const rows = items.map((item) => {
const row = item as {
seriesName?: string;
color?: string;
value?: number | string;
};
const numeric =
typeof row.value === 'number'
? row.value
: Number(row.value ?? 0);
const isRps = row.seriesName === '请求速率';
const formatted = isRps
? `${formatRps(numeric)} req/s`
: formatCompactNumber(numeric);
return [
'<span style="display:inline-flex;align-items:center;gap:8px;">',
`<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${row.color ?? '#94a3b8'};"></span>`,
`<span>${row.seriesName ?? ''}</span>`,
`<strong style="margin-left:8px;">${formatted}</strong>`,
'</span>',
].join('');
});
return [header, ...rows].join('<br/>');
},
},
legend: {
show: true,
top: 8,
right: 80,
itemWidth: 10,
itemHeight: 10,
icon: 'circle',
textStyle: {
color: '#94a3b8',
fontSize: 12,
},
data: ['请求速率', '独立访客'],
},
graphic: [
{
type: 'text',
left: 16,
top: 12,
style: {
text: 'RPS',
fill: '#94a3b8',
fontSize: 12,
},
},
{
type: 'text',
right: 16,
top: 12,
style: {
text: '访客 / 桶',
fill: '#94a3b8',
fontSize: 12,
},
},
],
xAxis: {
type: 'category',
boundaryGap: false,
data: chartModel.labels,
axisLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.24)',
},
},
axisTick: {
show: false,
},
axisLabel: {
color: '#94a3b8',
margin: 14,
lineHeight: 16,
},
},
yAxis: [
{
type: 'value',
min: 0,
max: rpsMax,
splitNumber: 4,
axisLabel: {
color: '#94a3b8',
formatter: (value: number) => formatRps(value),
},
splitLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.16)',
type: 'dashed',
},
},
},
{
type: 'value',
min: 0,
max: visitMax,
splitNumber: 4,
axisLabel: {
color: '#94a3b8',
formatter: (value: number) => formatCompactNumber(value),
},
splitLine: {
show: false,
},
},
],
dataZoom: [
{
type: 'slider',
height: 28,
bottom: 8,
borderColor: 'rgba(148, 163, 184, 0.2)',
backgroundColor: 'rgba(148, 163, 184, 0.06)',
fillerColor: 'rgba(56, 189, 248, 0.12)',
handleStyle: {
color: '#94a3b8',
},
textStyle: {
color: '#94a3b8',
fontSize: 10,
},
dataBackground: {
lineStyle: {
color: RPS_COLOR,
width: 1,
},
areaStyle: {
color: `${RPS_COLOR}33`,
},
},
},
{
type: 'inside',
},
],
series: [
{
name: '请求速率',
type: 'line',
yAxisIndex: 0,
smooth: true,
showSymbol: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
color: RPS_COLOR,
width: 2.5,
},
itemStyle: {
color: RPS_COLOR,
},
areaStyle: {
color: `${RPS_COLOR}33`,
},
data: chartModel.rpsValues,
},
{
name: '独立访客',
type: 'line',
yAxisIndex: 1,
smooth: true,
showSymbol: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
color: VISITS_COLOR,
width: 2,
},
itemStyle: {
color: VISITS_COLOR,
},
areaStyle: {
color: `${VISITS_COLOR}22`,
},
data: chartModel.visitValues,
},
],
};
}, [chartModel, hours]);
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between pb-2'>
<CardTitle className='text-base font-semibold tracking-tight'>
请求压力
</CardTitle>
<div className='flex items-center gap-1.5 text-xs text-muted-foreground font-mono'>
<Clock className='size-3.5 shrink-0' />
<span className='break-all'>{rangeLabel}</span>
</div>
</CardHeader>
<CardContent className='pt-0'>
{chartModel.labels.length === 0 ? (
<div className='flex h-[360px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground'>
暂无请求压力数据
</div>
) : (
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height: 360, width: '100%' }}
/>
)}
</CardContent>
</Card>
);
}
+124
View File
@@ -0,0 +1,124 @@
'use client';
import { Suspense, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Gauge } from 'lucide-react';
import { useAuth } from '@/components/providers/auth-provider';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AccessLogService } from '@/lib/services/openflare';
import type { RateLimitRangeHours } from '../access-logs/components/access-log-utils';
import { AnalysisTab } from './components/analysis-tab';
import { ConfigTab } from './components/config-tab';
type RateLimitTab = 'analysis' | 'config';
function resolveTab(value: string | null): RateLimitTab {
if (value === 'config') return 'config';
return 'analysis';
}
function RateLimitsPageContent() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const tab = resolveTab(searchParams.get('tab'));
const [hours, setHours] = useState<RateLimitRangeHours>(24);
const [hosts, setHosts] = useState<string[]>([]);
const overviewQuery = useQuery({
queryKey: ['openflare', 'rate-limits', 'overview', hours, hosts, 3],
queryFn: () =>
AccessLogService.getOverview({
hours,
hosts: hosts.length > 0 ? hosts : undefined,
bucket_minutes: 3,
}),
enabled: !!user?.is_admin && tab === 'analysis',
});
const handleTabChange = (value: string) => {
const next = resolveTab(value);
router.replace(
next === 'analysis' ? '/rate-limits' : '/rate-limits?tab=config',
);
};
if (authLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder icon={Gauge} description='加载权限信息...' />
</div>
);
}
if (!user?.is_admin) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder
icon={Gauge}
title='权限不足'
description='只有管理员可以访问限流设置。'
/>
</div>
);
}
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center gap-2'>
<Gauge className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>限流</h1>
<p className='text-sm text-muted-foreground'>
查看边缘请求压力,并配置站点默认并发与带宽限流。
</p>
</div>
</div>
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className='grid w-full max-w-md grid-cols-2'>
<TabsTrigger value='analysis'>分析</TabsTrigger>
<TabsTrigger value='config'>配置</TabsTrigger>
</TabsList>
<TabsContent value='analysis' className='mt-4'>
<AnalysisTab
data={overviewQuery.data}
loading={overviewQuery.isLoading}
error={
overviewQuery.error instanceof Error ? overviewQuery.error : null
}
hours={hours}
hosts={hosts}
onHoursChange={setHours}
onHostsChange={setHosts}
onRetry={() => void overviewQuery.refetch()}
/>
</TabsContent>
<TabsContent value='config' className='mt-4'>
<ConfigTab />
</TabsContent>
</Tabs>
</div>
);
}
export default function RateLimitsPage() {
return (
<Suspense
fallback={
<div className='py-6 px-1'>
<LoadingStateWithBorder icon={Gauge} description='加载限流页面...' />
</div>
}
>
<RateLimitsPageContent />
</Suspense>
);
}
@@ -1,23 +1,9 @@
'use client';
import {
Download,
Eye,
MoreHorizontal,
Pencil,
Play,
Trash2,
} from 'lucide-react';
import { Download, Eye, Pencil, Play, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
@@ -61,7 +47,7 @@ export function IPGroupsTable({
<TableHead>引用次数</TableHead>
<TableHead>同步状态</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className='w-[80px] text-right'>操作</TableHead>
<TableHead className='w-[168px] text-right'>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -78,60 +64,79 @@ export function IPGroupsTable({
</TableCell>
<TableCell>{group.ip_list.length}</TableCell>
<TableCell>{group.referenced_by_rule_count}</TableCell>
<TableCell className='text-sm text-muted-foreground max-w-[200px] truncate'>
<TableCell className='max-w-[200px] truncate text-sm text-muted-foreground'>
{group.last_sync_status
? `${group.last_sync_status}: ${group.last_sync_message}`
: '尚无同步记录'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
<TableCell className='text-sm text-muted-foreground'>
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className='text-right'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='icon' className='size-8'>
<MoreHorizontal className='size-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => onView(group)}>
<Eye className='size-4 mr-2' />
查看
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil className='size-4 mr-2' />
编辑
</DropdownMenuItem>
{group.type === 'automatic' ? (
<DropdownMenuItem onClick={() => onTest(group)}>
<Play className='size-4 mr-2' />
测试规则
</DropdownMenuItem>
) : null}
{group.type === 'subscription' ||
group.type === 'automatic' ? (
<DropdownMenuItem
disabled={syncingId === group.id}
onClick={() => onSync(group)}
>
<Download className='size-4 mr-2' />
{syncingId === group.id
? '同步中...'
: group.type === 'automatic'
? '立即执行'
: '立即同步'}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className='text-destructive focus:text-destructive'
onClick={() => onDelete(group)}
<div className='flex items-center justify-end gap-1'>
<Button
type='button'
variant='ghost'
size='icon'
className='size-8'
title='查看'
aria-label='查看'
onClick={() => onView(group)}
>
<Eye />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
className='size-8'
title='编辑'
aria-label='编辑'
onClick={() => onEdit(group)}
>
<Pencil />
</Button>
{group.type === 'automatic' ? (
<Button
type='button'
variant='ghost'
size='icon'
className='size-8'
title='测试规则'
aria-label='测试规则'
onClick={() => onTest(group)}
>
<Trash2 className='size-4 mr-2' />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Play />
</Button>
) : null}
{group.type === 'subscription' || group.type === 'automatic' ? (
<Button
type='button'
variant='ghost'
size='icon'
className='size-8'
title={group.type === 'automatic' ? '立即执行' : '立即同步'}
aria-label={
group.type === 'automatic' ? '立即执行' : '立即同步'
}
disabled={syncingId === group.id}
onClick={() => onSync(group)}
>
<Download />
</Button>
) : null}
<Button
type='button'
variant='ghost'
size='icon'
className='size-8 text-destructive hover:text-destructive'
title='删除'
aria-label='删除'
onClick={() => onDelete(group)}
>
<Trash2 />
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -1,23 +1,9 @@
'use client';
import {
Globe2,
MoreHorizontal,
Pencil,
ShieldCheck,
Trash2,
} from 'lucide-react';
import { Globe2, Pencil, ShieldCheck, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
@@ -50,7 +36,7 @@ export function RuleGroupsTable({
<TableHead>节点数</TableHead>
<TableHead>应用范围</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className='w-[80px] text-right'>操作</TableHead>
<TableHead className='w-[88px] text-right'>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -59,9 +45,9 @@ export function RuleGroupsTable({
<TableCell>
<div className='flex items-center gap-2'>
{group.is_global ? (
<Globe2 className='size-4 text-primary shrink-0' />
<Globe2 className='size-4 shrink-0 text-primary' />
) : (
<ShieldCheck className='size-4 text-muted-foreground shrink-0' />
<ShieldCheck className='size-4 shrink-0 text-muted-foreground' />
)}
<span className='font-medium'>{group.name}</span>
</div>
@@ -82,39 +68,36 @@ export function RuleGroupsTable({
? '全部网站'
: `${group.applied_site_count} 个网站`}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
<TableCell className='text-sm text-muted-foreground'>
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className='text-right'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='icon' className='size-8'>
<MoreHorizontal />
<div className='flex items-center justify-end gap-1'>
<Button
type='button'
variant='ghost'
size='icon'
className='size-8'
title='编排'
aria-label='编排'
onClick={() => onEdit(group)}
>
<Pencil />
</Button>
{!group.is_global ? (
<Button
type='button'
variant='ghost'
size='icon'
className='size-8 text-destructive hover:text-destructive'
title='删除'
aria-label='删除'
onClick={() => onDelete(group)}
>
<Trash2 />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil />
编排
</DropdownMenuItem>
</DropdownMenuGroup>
{!group.is_global ? (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
variant='destructive'
onClick={() => onDelete(group)}
>
<Trash2 />
删除
</DropdownMenuItem>
</DropdownMenuGroup>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</TableCell>
</TableRow>
))}
@@ -78,6 +78,8 @@ export function isConnectionAllowed(
start: ['next'],
ip_match: ['true', 'false'],
geo_match: ['true', 'false'],
ua_check: ['true', 'false'],
security_check: ['true', 'false'],
pow: ['next'],
};
return (
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import type { WAFRuleGraph } from '@/lib/services/openflare';
import { layoutRuleGraph } from './graph-layout';
const sampleGraph: WAFRuleGraph = {
schema_version: 1,
nodes: [
{
id: 'start',
type: 'start',
position: { x: 500, y: 400 },
config: {},
},
{
id: 'match',
type: 'ip_match',
position: { x: 10, y: 10 },
config: { ips: [], cidrs: [], ip_group_ids: [] },
},
{
id: 'allow',
type: 'allow',
position: { x: 0, y: 0 },
config: {},
},
{
id: 'block',
type: 'block',
position: { x: 99, y: 99 },
config: { status_code: 403, response_body: '' },
},
],
edges: [
{
id: 'e1',
source: 'start',
source_handle: 'next',
target: 'match',
},
{
id: 'e2',
source: 'match',
source_handle: 'true',
target: 'allow',
},
{
id: 'e3',
source: 'match',
source_handle: 'false',
target: 'block',
},
],
};
describe('layoutRuleGraph', () => {
it('places start left of match and match left of terminals', () => {
const laid = layoutRuleGraph(sampleGraph);
const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
expect(byId.start.position.x).toBeLessThan(byId.match.position.x);
expect(byId.match.position.x).toBeLessThan(byId.allow.position.x);
expect(byId.match.position.x).toBeLessThan(byId.block.position.x);
});
it('keeps edges unchanged', () => {
const laid = layoutRuleGraph(sampleGraph);
expect(laid.edges).toEqual(sampleGraph.edges);
});
it('separates sibling terminals on y axis', () => {
const laid = layoutRuleGraph(sampleGraph);
const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
expect(byId.allow.position.y).not.toBe(byId.block.position.y);
});
});
@@ -0,0 +1,105 @@
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
const NODE_WIDTH = 220;
const NODE_HEIGHT = 72;
const GAP_X = 120;
const GAP_Y = 48;
const ORIGIN_X = 48;
const ORIGIN_Y = 48;
/** Left-to-right layered layout for the WAF rule DAG. Edges are unchanged. */
export function layoutRuleGraph(graph: WAFRuleGraph): WAFRuleGraph {
if (graph.nodes.length === 0) return graph;
const children = new Map<string, string[]>();
const indegree = new Map<string, number>();
for (const node of graph.nodes) {
children.set(node.id, []);
indegree.set(node.id, 0);
}
for (const edge of graph.edges) {
if (!children.has(edge.source) || !indegree.has(edge.target)) continue;
children.get(edge.source)!.push(edge.target);
indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1);
}
const start =
graph.nodes.find((node) => node.type === 'start') ?? graph.nodes[0];
const depth = new Map<string, number>();
const queue: string[] = [start.id];
depth.set(start.id, 0);
while (queue.length > 0) {
const id = queue.shift()!;
const d = depth.get(id) ?? 0;
for (const child of children.get(id) ?? []) {
const next = d + 1;
const prev = depth.get(child);
if (prev === undefined || next > prev) {
depth.set(child, next);
queue.push(child);
}
}
}
// Unreachable nodes (no path from start) sit after the main layers.
let maxDepth = 0;
for (const value of depth.values()) maxDepth = Math.max(maxDepth, value);
let orphanColumn = maxDepth + 1;
for (const node of graph.nodes) {
if (!depth.has(node.id)) {
depth.set(node.id, orphanColumn);
orphanColumn += 1;
}
}
const columns = new Map<number, WAFRuleNode[]>();
for (const node of graph.nodes) {
const col = depth.get(node.id) ?? 0;
const list = columns.get(col) ?? [];
list.push(node);
columns.set(col, list);
}
for (const [, list] of columns) {
list.sort((a, b) => {
const rank = (node: WAFRuleNode) => {
if (node.type === 'start') return 0;
if (node.type === 'allow') return 2;
if (node.type === 'block') return 3;
return 1;
};
const diff = rank(a) - rank(b);
if (diff !== 0) return diff;
return a.id.localeCompare(b.id);
});
}
let maxRows = 1;
for (const list of columns.values()) maxRows = Math.max(maxRows, list.length);
const positions = new Map<string, { x: number; y: number }>();
const sortedCols = [...columns.keys()].sort((a, b) => a - b);
for (const col of sortedCols) {
const list = columns.get(col) ?? [];
const blockHeight =
list.length * NODE_HEIGHT + Math.max(0, list.length - 1) * GAP_Y;
const totalHeight =
maxRows * NODE_HEIGHT + Math.max(0, maxRows - 1) * GAP_Y;
const offsetY = ORIGIN_Y + (totalHeight - blockHeight) / 2;
list.forEach((node, index) => {
positions.set(node.id, {
x: ORIGIN_X + col * (NODE_WIDTH + GAP_X),
y: offsetY + index * (NODE_HEIGHT + GAP_Y),
});
});
}
return {
...graph,
nodes: graph.nodes.map((node) => ({
...node,
position: positions.get(node.id) ?? node.position,
})),
};
}
@@ -1,5 +1,7 @@
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
import { UA_BROWSER_LABELS, UA_OS_LABELS } from './ua-options';
export type GraphIssueCode =
| 'schema'
| 'size_limit'
@@ -28,6 +30,8 @@ const handles: Partial<Record<WAFRuleNode['type'], string[]>> = {
start: ['next'],
ip_match: ['true', 'false'],
geo_match: ['true', 'false'],
ua_check: ['true', 'false'],
security_check: ['true', 'false'],
pow: ['next'],
};
@@ -208,6 +212,31 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
new TextEncoder().encode(node.config.response_body).length > 16 * 1024)
)
return `节点 ${node.id} 的阻止响应配置无效`;
if (node.type === 'ua_check') {
if (!['and', 'or'].includes(node.config.match_mode))
return `节点 ${node.id} 的匹配模式必须为 and 或 or`;
if (node.config.browsers.some((label) => !UA_BROWSER_LABELS.has(label)))
return `节点 ${node.id} 包含无效浏览器标签`;
if (node.config.operating_systems.some((label) => !UA_OS_LABELS.has(label)))
return `节点 ${node.id} 包含无效操作系统标签`;
if (node.config.custom_ua_patterns.length > 32)
return `节点 ${node.id} 的自定义 UA 正则不能超过 32 条`;
for (const pattern of node.config.custom_ua_patterns) {
if (!pattern.trim()) return `节点 ${node.id} 的自定义 UA 正则不能为空`;
if (new TextEncoder().encode(pattern).length > 256)
return `节点 ${node.id} 的自定义 UA 正则过长`;
try {
void new RegExp(pattern);
} catch {
return `节点 ${node.id} 的自定义 UA 正则无效`;
}
}
if (
node.config.block_custom_ua &&
node.config.custom_ua_patterns.length === 0
)
return `节点 ${node.id} 开启屏蔽自定义 UA 时至少需要一条正则`;
}
return undefined;
}
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import {
createRuleNode,
displayNodeTitle,
NODE_TYPE_LABELS,
parseAddableNodeType,
} from './node-factory';
describe('displayNodeTitle', () => {
it('uses trimmed label when present', () => {
expect(
displayNodeTitle({
type: 'ip_match',
label: ' 办公室 ',
}),
).toBe('办公室');
});
it('falls back to type default when label empty', () => {
expect(
displayNodeTitle({
type: 'block',
label: ' ',
}),
).toBe(NODE_TYPE_LABELS.block);
});
});
describe('createRuleNode', () => {
it('creates typed node at position without label', () => {
const node = createRuleNode('pow', { x: 12, y: 34 });
expect(node.type).toBe('pow');
expect(node.position).toEqual({ x: 12, y: 34 });
expect(node.label).toBeUndefined();
expect(node.id.startsWith('pow-')).toBe(true);
if (node.type === 'pow') {
expect(node.config).toEqual({
algorithm: 'fast',
difficulty: 4,
session_ttl: 3600,
challenge_ttl: 300,
});
}
});
});
describe('parseAddableNodeType', () => {
it('accepts addable types and rejects others', () => {
expect(parseAddableNodeType('ip_match')).toBe('ip_match');
expect(parseAddableNodeType('ua_check')).toBe('ua_check');
expect(parseAddableNodeType('security_check')).toBe('security_check');
expect(parseAddableNodeType('start')).toBeNull();
expect(parseAddableNodeType('')).toBeNull();
});
});
describe('createRuleNode ua_check', () => {
it('creates default UA check config', () => {
const node = createRuleNode('ua_check', { x: 1, y: 2 });
expect(node.type).toBe('ua_check');
if (node.type === 'ua_check') {
expect(node.config).toEqual({
require_ua: false,
browsers: [],
operating_systems: [],
match_mode: 'or',
block_common_bots: false,
block_abnormal_ua: false,
block_custom_ua: false,
custom_ua_patterns: [],
});
}
});
});
@@ -0,0 +1,106 @@
import type { WAFRuleNode } from '@/lib/services/openflare';
export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node';
export type AddableNodeType = Extract<
WAFRuleNode['type'],
'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 function displayNodeTitle(
node: Pick<WAFRuleNode, 'type' | 'label'>,
): string {
const custom = node.label?.trim();
return custom || NODE_TYPE_LABELS[node.type];
}
export function createRuleNode(
type: AddableNodeType,
position: { x: number; y: number },
): WAFRuleNode {
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
if (type === 'ip_match')
return {
id,
type,
position,
config: { ips: [], cidrs: [], ip_group_ids: [] },
};
if (type === 'geo_match')
return { id, type, position, config: { countries: [], regions: [] } };
if (type === 'ua_check')
return {
id,
type,
position,
config: {
require_ua: false,
browsers: [],
operating_systems: [],
match_mode: 'or',
block_common_bots: false,
block_abnormal_ua: false,
block_custom_ua: false,
custom_ua_patterns: [],
},
};
if (type === 'security_check')
return {
id,
type,
position,
config: {
sql_injection: false,
path_traversal: true,
command_injection: false,
xss: false,
ssrf: false,
file_inclusion: true,
malicious_upload: false,
xxe: false,
crlf_injection: false,
},
};
if (type === 'pow')
return {
id,
type,
position,
config: {
algorithm: 'fast',
difficulty: 4,
session_ttl: 3600,
challenge_ttl: 300,
},
};
return {
id,
type: 'block',
position,
config: { status_code: 403, response_body: '' },
};
}
export function parseAddableNodeType(value: string): AddableNodeType | null {
if (
value === 'ip_match' ||
value === 'geo_match' ||
value === 'ua_check' ||
value === 'security_check' ||
value === 'pow' ||
value === 'block'
)
return value;
return null;
}
@@ -1,31 +1,48 @@
import { Ban, Fingerprint, Globe2, Plus, ShieldCheck } from 'lucide-react';
import {
Ban,
Fingerprint,
Globe2,
ScanSearch,
Shield,
ShieldCheck,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { WAFRuleNode } from '@/lib/services/openflare';
type AddableType = Extract<
WAFRuleNode['type'],
'ip_match' | 'geo_match' | 'pow' | 'block'
>;
import {
NODE_TYPE_LABELS,
WAF_NODE_DRAG_MIME,
type AddableNodeType,
} from './node-factory';
const items = [
{ type: 'ip_match', label: 'IP 匹配', icon: Fingerprint },
{ type: 'geo_match', label: '地域匹配', icon: Globe2 },
{ type: 'pow', label: 'PoW 挑战', icon: ShieldCheck },
{ type: 'block', label: '阻止', icon: Ban },
] satisfies { type: AddableType; label: string; icon: typeof Plus }[];
{ type: 'ip_match' as const, icon: Fingerprint },
{ type: 'geo_match' as const, icon: Globe2 },
{ type: 'ua_check' as const, icon: ScanSearch },
{ type: 'security_check' as const, icon: Shield },
{ type: 'pow' as const, icon: ShieldCheck },
{ type: 'block' as const, icon: Ban },
] satisfies { type: AddableNodeType; icon: typeof Fingerprint }[];
export function NodeLibrary({ onAdd }: { onAdd: (type: AddableType) => void }) {
export function NodeLibrary() {
return (
<div className='flex items-center gap-2'>
{items.map(({ type, label, icon: Icon }) => (
{items.map(({ type, icon: Icon }) => (
<Button
key={type}
type='button'
variant='outline'
size='sm'
onClick={() => onAdd(type)}
draggable
className='cursor-grab active:cursor-grabbing'
onDragStart={(event) => {
event.dataTransfer.setData(WAF_NODE_DRAG_MIME, type);
event.dataTransfer.setData('text/plain', type);
event.dataTransfer.effectAllowed = 'copy';
}}
>
<Icon data-icon='inline-start' />
{label}
{NODE_TYPE_LABELS[type]}
</Button>
))}
</div>
@@ -5,6 +5,84 @@ import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
import { NodeProperties } from './node-properties';
it('hides match and block until UA check is enabled', () => {
const node: WAFRuleNode = {
id: 'ua',
type: 'ua_check',
position: { x: 0, y: 0 },
config: {
require_ua: false,
browsers: [],
operating_systems: [],
match_mode: 'or',
block_common_bots: false,
block_abnormal_ua: false,
block_custom_ua: false,
custom_ua_patterns: [],
},
};
const onChange = vi.fn();
const { rerender } = render(
<NodeProperties node={node} ipGroups={[]} onChange={onChange} />,
);
expect(
screen.queryByRole('switch', { name: /屏蔽常见爬虫/ }),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('浏览器')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('switch', { name: /开启 UA 检查/ }));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({ require_ua: true }),
}),
);
rerender(
<NodeProperties
node={{ ...node, config: { ...node.config, require_ua: true } }}
ipGroups={[]}
onChange={onChange}
/>,
);
expect(
screen.getByRole('switch', { name: /屏蔽常见爬虫/ }),
).toBeInTheDocument();
expect(
screen.getByRole('switch', { name: /屏蔽非正常/ }),
).toBeInTheDocument();
expect(
screen.getByRole('switch', { name: /屏蔽自定义/ }),
).toBeInTheDocument();
expect(screen.getAllByLabelText('说明').length).toBeGreaterThan(0);
});
it('edits display name for configurable nodes', () => {
const node: WAFRuleNode = {
id: 'match',
type: 'ip_match',
position: { x: 0, y: 0 },
config: { ips: [], cidrs: [], ip_group_ids: [] },
};
const onChange = vi.fn();
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
fireEvent.change(screen.getByLabelText('显示名称'), {
target: { value: '内网放行' },
});
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ label: '内网放行' }),
);
});
it('hides display name for system nodes', () => {
const node: WAFRuleNode = {
id: 'start',
type: 'start',
position: { x: 0, y: 0 },
config: {},
};
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument();
expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument();
});
it('edits IP group config through a typed multi-select', async () => {
const node: WAFRuleNode = {
id: 'match',
@@ -1,4 +1,4 @@
import { Settings2 } from 'lucide-react';
import { CircleHelp, Settings2 } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
@@ -25,10 +25,18 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} 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 { UA_BROWSER_OPTIONS, UA_OS_OPTIONS } from './ua-options';
export function NodeProperties({
node,
@@ -72,6 +80,7 @@ function PropertyFields({
if (node.type === 'ip_match')
return (
<FieldGroup>
<DisplayNameField node={node} onChange={onChange} />
<CsvField
id={`${node.id}-ips`}
label='IP 地址'
@@ -109,6 +118,7 @@ function PropertyFields({
if (node.type === 'geo_match')
return (
<FieldGroup>
<DisplayNameField node={node} onChange={onChange} />
<MultiSelect
id={`${node.id}-countries`}
label='国家代码'
@@ -134,9 +144,299 @@ function PropertyFields({
/>
</FieldGroup>
);
if (node.type === 'ua_check')
return (
<FieldGroup>
<DisplayNameField node={node} onChange={onChange} />
<div className='space-y-1'>
<p className='text-xs font-medium text-muted-foreground'>UA 检查</p>
<Field
orientation='horizontal'
className='items-center justify-between'
>
<FieldLabel
htmlFor={`${node.id}-require-ua`}
className='flex items-center gap-1.5'
>
开启 UA 检查
<FieldHelp tip='开启后如果请求头不携带 UA 将返回 False' />
</FieldLabel>
<Switch
id={`${node.id}-require-ua`}
checked={node.config.require_ua}
onCheckedChange={(require_ua) =>
onChange({ ...node, config: { ...node.config, require_ua } })
}
/>
</Field>
</div>
{node.config.require_ua && (
<>
<Separator />
<div className='space-y-3'>
<p className='flex items-center gap-1.5 text-xs font-medium text-muted-foreground'>
屏蔽
<FieldHelp tip='命中返回 false,优先级高于匹配' />
</p>
<Field
orientation='horizontal'
className='items-center justify-between gap-3'
>
<FieldLabel
htmlFor={`${node.id}-block-bots`}
className='flex items-center gap-1.5'
>
屏蔽常见爬虫 UA
<FieldHelp tip='浏览器或操作系统分类为 Bot(含 bot / spider / crawler / slurp 等特征,如 Googlebot)' />
</FieldLabel>
<Switch
id={`${node.id}-block-bots`}
checked={node.config.block_common_bots}
onCheckedChange={(block_common_bots) =>
onChange({
...node,
config: { ...node.config, block_common_bots },
})
}
/>
</Field>
<Field
orientation='horizontal'
className='items-center justify-between gap-3'
>
<FieldLabel
htmlFor={`${node.id}-block-abnormal`}
className='flex items-center gap-1.5'
>
屏蔽非正常 UA
<FieldHelp tip='浏览器分类为 Other 或 Unknown(不含搜索引擎等爬虫 Bot,爬虫请用上方开关)' />
</FieldLabel>
<Switch
id={`${node.id}-block-abnormal`}
checked={node.config.block_abnormal_ua}
onCheckedChange={(block_abnormal_ua) =>
onChange({
...node,
config: { ...node.config, block_abnormal_ua },
})
}
/>
</Field>
<Field
orientation='horizontal'
className='items-center justify-between gap-3'
>
<FieldLabel
htmlFor={`${node.id}-block-custom`}
className='flex items-center gap-1.5'
>
屏蔽自定义 UA
<FieldHelp tip='原始 User-Agent 命中任一条正则时返回 false(Lua 模式语法)' />
</FieldLabel>
<Switch
id={`${node.id}-block-custom`}
checked={node.config.block_custom_ua}
onCheckedChange={(block_custom_ua) =>
onChange({
...node,
config: { ...node.config, block_custom_ua },
})
}
/>
</Field>
{node.config.block_custom_ua && (
<Field>
<FieldLabel
htmlFor={`${node.id}-custom-patterns`}
className='flex items-center gap-1.5'
>
自定义 UA 正则
<FieldHelp tip='每行一条 Lua 模式正则,命中任一条即 false;最多 32 条' />
</FieldLabel>
<Textarea
id={`${node.id}-custom-patterns`}
rows={4}
value={node.config.custom_ua_patterns.join('\n')}
placeholder={'python%-requests\ncurl/'}
onChange={(event) =>
onChange({
...node,
config: {
...node.config,
custom_ua_patterns: event.target.value
.split('\n')
.map((item) => item.trim())
.filter(Boolean),
},
})
}
/>
</Field>
)}
</div>
<Separator />
<div className='space-y-3'>
<p className='text-xs font-medium text-muted-foreground'>
UA 匹配
</p>
<Field>
<FieldLabel
htmlFor={`${node.id}-match-mode`}
className='flex items-center gap-1.5'
>
匹配模式
<FieldHelp tip='浏览器与操作系统两侧都有选择时生效' />
</FieldLabel>
<Select
value={node.config.match_mode}
onValueChange={(match_mode: 'and' | 'or') =>
onChange({
...node,
config: { ...node.config, match_mode },
})
}
>
<SelectTrigger
id={`${node.id}-match-mode`}
className='w-full'
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='or'>或(OR)</SelectItem>
<SelectItem value='and'>且(AND)</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<MultiSelect
id={`${node.id}-browsers`}
label='浏览器'
options={UA_BROWSER_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
searchText: `${option.label} ${option.value}`,
}))}
value={node.config.browsers}
onChange={(browsers) =>
onChange({ ...node, config: { ...node.config, browsers } })
}
/>
<MultiSelect
id={`${node.id}-os`}
label='操作系统'
options={UA_OS_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
searchText: `${option.label} ${option.value}`,
}))}
value={node.config.operating_systems}
onChange={(operating_systems) =>
onChange({
...node,
config: { ...node.config, operating_systems },
})
}
/>
</div>
</>
)}
</FieldGroup>
);
if (node.type === 'security_check')
return (
<FieldGroup>
<DisplayNameField node={node} onChange={onChange} />
<div className='space-y-1'>
<p className='flex items-center gap-1.5 text-xs font-medium text-muted-foreground'>
安全防护
<FieldHelp tip='命中任意已启用规则返回 False' />
</p>
</div>
<Separator />
<div className='space-y-3'>
<p className='text-xs font-medium text-muted-foreground'>基础防护</p>
{(
[
{
key: 'path_traversal',
label: '路径穿越防护',
tip: '检测 Path / Query / Body 中的 ../ 与编码变种',
},
{
key: 'file_inclusion',
label: '文件包含(LFI/RFI)',
tip: '检测 Path / Query / Body 中的 php://、file://、/etc/passwd 等',
},
{
key: 'sql_injection',
label: 'SQL 注入',
tip: '检测 Query / Body / Header / Cookie 中的 SQL 注入特征',
},
{
key: 'command_injection',
label: '命令注入',
tip: '检测 Query / Body / Header 中的 OS 命令注入特征',
},
{
key: 'xss',
label: 'XSS',
tip: '检测 Query / Body / Header 中的反射型 XSS 特征',
},
{
key: 'ssrf',
label: 'SSRF',
tip: '检测 Query / Body 中的内网地址与危险协议',
},
{
key: 'malicious_upload',
label: '恶意文件上传',
tip: '检测 Multipart 文件名与危险扩展名',
},
{
key: 'xxe',
label: 'XXE',
tip: '检测 XML Body 中的外部实体特征',
},
{
key: 'crlf_injection',
label: 'CRLF 注入',
tip: '检测 Header / Query / Body 中的换行注入',
},
] as const
).map((item) => (
<Field
key={item.key}
orientation='horizontal'
className='items-center justify-between gap-3'
>
<FieldLabel
htmlFor={`${node.id}-${item.key}`}
className='flex items-center gap-1.5'
>
{item.label}
<FieldHelp tip={item.tip} />
</FieldLabel>
<Switch
id={`${node.id}-${item.key}`}
checked={node.config[item.key]}
onCheckedChange={(checked) =>
onChange({
...node,
config: { ...node.config, [item.key]: checked },
})
}
/>
</Field>
))}
</div>
</FieldGroup>
);
if (node.type === 'pow')
return (
<FieldGroup>
<DisplayNameField node={node} onChange={onChange} />
<Field>
<FieldLabel htmlFor={`${node.id}-algorithm`}>算法</FieldLabel>
<Select
@@ -181,6 +481,7 @@ function PropertyFields({
);
return (
<FieldGroup>
<DisplayNameField node={node} onChange={onChange} />
<NumberField
id={`${node.id}-status`}
min={400}
@@ -213,6 +514,46 @@ function PropertyFields({
);
}
function FieldHelp({ tip }: { tip: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type='button'
className='inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground'
aria-label='说明'
onClick={(event) => event.preventDefault()}
>
<CircleHelp className='size-3.5' />
</button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-56 text-xs'>
{tip}
</TooltipContent>
</Tooltip>
);
}
function DisplayNameField({
node,
onChange,
}: {
node: WAFRuleNode;
onChange: (node: WAFRuleNode) => void;
}) {
return (
<Field>
<FieldLabel htmlFor={`${node.id}-label`}>显示名称</FieldLabel>
<Input
id={`${node.id}-label`}
value={node.label ?? ''}
placeholder={NODE_TYPE_LABELS[node.type]}
onChange={(event) => onChange({ ...node, label: event.target.value })}
/>
</Field>
);
}
function CsvField({
id,
label,
@@ -1,6 +1,13 @@
'use client';
import { useCallback, useEffect, useRef } from 'react';
import {
type DragEvent,
type MouseEvent as ReactMouseEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {
addEdge,
applyEdgeChanges,
@@ -21,12 +28,8 @@ import {
import { Trash2 } from 'lucide-react';
import '@xyflow/react/dist/style.css';
import { Button } from '@/components/ui/button';
import type {
WAFRuleEdge,
WAFRuleGraph,
WAFRuleNode,
} from '@/lib/services/openflare';
import type { WAFRuleEdge, WAFRuleGraph } from '@/lib/services/openflare';
import { cn } from '@/lib/utils';
import {
acceptedNodeChanges,
@@ -40,6 +43,12 @@ import {
removeEdgeFromGraph,
removeNodeFromGraph,
} from './graph-validation';
import {
createRuleNode,
parseAddableNodeType,
WAF_NODE_DRAG_MIME,
type AddableNodeType,
} from './node-factory';
import { NodeLibrary } from './node-library';
import { RuleNode, type RuleFlowNodeData } from './rule-node';
@@ -80,6 +89,11 @@ export function RuleFlowCanvas({
const [edges, setEdges, applyFlowEdgeChanges] = useEdgesState<FlowEdge>(
buildFlowEdges(graph, selectedId, selectedEdgeId),
);
const [contextMenu, setContextMenu] = useState<
| { kind: 'node'; id: string; x: number; y: number }
| { kind: 'edge'; id: string; x: number; y: number }
| null
>(null);
useEffect(() => {
setNodes((current) => buildFlowNodes(graph, issues, selectedId, current));
@@ -201,103 +215,136 @@ export function RuleFlowCanvas({
[edges, graph, isValidConnection, onGraphChange, setEdges],
);
const addNode = useCallback(
(type: 'ip_match' | 'geo_match' | 'pow' | 'block') => {
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
const config =
type === 'ip_match'
? { ips: [], cidrs: [], ip_group_ids: [] }
: type === 'geo_match'
? { countries: [], regions: [] }
: type === 'pow'
? {
algorithm: 'fast' as const,
difficulty: 4,
session_ttl: 3600,
challenge_ttl: 300,
}
: { status_code: 403, response_body: '' };
const addNodeAt = useCallback(
(type: AddableNodeType, position: { x: number; y: number }) => {
const node = createRuleNode(type, position);
onGraphChange({
...graph,
nodes: [
...graph.nodes,
{
id,
type,
position: { x: 240, y: 140 + graph.nodes.length * 24 },
config,
} as WAFRuleNode,
],
nodes: [...graph.nodes, node],
});
onSelectEdge(undefined);
onSelect(id);
onSelect(node.id);
},
[graph, onGraphChange, onSelect, onSelectEdge],
);
const selectedNode = graph.nodes.find((node) => node.id === selectedId);
const canDeleteNode = Boolean(
selectedNode && !['start', 'allow'].includes(selectedNode.type),
);
const canDeleteEdge = Boolean(
selectedEdgeId && graph.edges.some((edge) => edge.id === selectedEdgeId),
);
const canDelete = canDeleteNode || canDeleteEdge;
const deleteLabel = canDeleteEdge
? '删除连线'
: canDeleteNode
? '删除节点'
: selectedNode
? '系统节点不可删除'
: '请选择可删除项';
const onDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}, []);
const deleteSelection = useCallback(() => {
if (canDeleteEdge && selectedEdgeId) {
setEdges((current) =>
current.filter((edge) => edge.id !== selectedEdgeId),
);
onGraphChange(removeEdgeFromGraph(graph, selectedEdgeId));
onSelectEdge(undefined);
return;
}
if (canDeleteNode && selectedId) {
setNodes((current) => current.filter((node) => node.id !== selectedId));
const onDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
const raw =
event.dataTransfer.getData(WAF_NODE_DRAG_MIME) ||
event.dataTransfer.getData('text/plain');
const type = parseAddableNodeType(raw);
if (!type || !instance.current) return;
const position = instance.current.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
addNodeAt(type, position);
},
[addNodeAt],
);
const deleteNodeById = useCallback(
(nodeId: string) => {
const target = graph.nodes.find((node) => node.id === nodeId);
if (!target || ['start', 'allow'].includes(target.type)) return;
setNodes((current) => current.filter((node) => node.id !== nodeId));
setEdges((current) =>
current.filter(
(edge) => edge.source !== selectedId && edge.target !== selectedId,
(edge) => edge.source !== nodeId && edge.target !== nodeId,
),
);
onGraphChange(removeNodeFromGraph(graph, selectedId));
onGraphChange(removeNodeFromGraph(graph, nodeId));
if (selectedId === nodeId) onSelect(undefined);
if (selectedEdgeId) onSelectEdge(undefined);
},
[
graph,
onGraphChange,
onSelect,
onSelectEdge,
selectedEdgeId,
selectedId,
setEdges,
setNodes,
],
);
const deleteEdgeById = useCallback(
(edgeId: string) => {
if (!graph.edges.some((edge) => edge.id === edgeId)) return;
setEdges((current) => current.filter((edge) => edge.id !== edgeId));
onGraphChange(removeEdgeFromGraph(graph, edgeId));
if (selectedEdgeId === edgeId) onSelectEdge(undefined);
},
[graph, onGraphChange, onSelectEdge, selectedEdgeId, setEdges],
);
const openNodeContextMenu = useCallback(
(event: ReactMouseEvent, node: FlowNode) => {
event.preventDefault();
event.stopPropagation();
onSelectEdge(undefined);
onSelect(node.id);
setContextMenu({
kind: 'node',
id: node.id,
x: event.clientX,
y: event.clientY,
});
},
[onSelect, onSelectEdge],
);
const openEdgeContextMenu = useCallback(
(event: ReactMouseEvent, edge: FlowEdge) => {
event.preventDefault();
event.stopPropagation();
onSelect(undefined);
}
}, [
canDeleteEdge,
canDeleteNode,
graph,
onGraphChange,
onSelect,
onSelectEdge,
selectedEdgeId,
selectedId,
setEdges,
setNodes,
]);
onSelectEdge(edge.id);
setContextMenu({
kind: 'edge',
id: edge.id,
x: event.clientX,
y: event.clientY,
});
},
[onSelect, onSelectEdge],
);
const suppressBrowserContextMenu = useCallback(
(event: ReactMouseEvent | MouseEvent) => {
event.preventDefault();
setContextMenu(null);
},
[],
);
const contextNode =
contextMenu?.kind === 'node'
? graph.nodes.find((node) => node.id === contextMenu.id)
: undefined;
const contextCanDeleteNode = Boolean(
contextNode && !['start', 'allow'].includes(contextNode.type),
);
const contextCanDeleteEdge = Boolean(
contextMenu?.kind === 'edge' &&
graph.edges.some((edge) => edge.id === contextMenu.id),
);
return (
<section className='relative min-w-0 flex-1 bg-muted/20'>
<section
className='relative min-w-0 flex-1 bg-muted/20'
onContextMenu={suppressBrowserContextMenu}
>
<div className='absolute left-4 top-4 z-10 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur'>
<NodeLibrary onAdd={addNode} />
</div>
<div className='absolute right-4 top-4 z-10'>
<Button
variant='destructive'
size='sm'
disabled={!canDelete}
onClick={deleteSelection}
>
<Trash2 data-icon='inline-start' />
{deleteLabel}
</Button>
<NodeLibrary />
</div>
<ReactFlow
nodes={nodes}
@@ -318,23 +365,81 @@ export function RuleFlowCanvas({
onNodeClick={(_, node) => {
onSelectEdge(undefined);
onSelect(node.id);
setContextMenu(null);
}}
onEdgeClick={(_, edge) => {
onSelect(undefined);
onSelectEdge(edge.id);
setContextMenu(null);
}}
onPaneClick={() => {
onSelect(undefined);
onSelectEdge(undefined);
setContextMenu(null);
}}
onMoveStart={() => setContextMenu(null)}
onNodeContextMenu={openNodeContextMenu}
onEdgeContextMenu={openEdgeContextMenu}
onPaneContextMenu={suppressBrowserContextMenu}
onDragOver={onDragOver}
onDrop={onDrop}
fitView
fitViewOptions={initialFitViewOptions}
deleteKeyCode={['Backspace', 'Delete']}
className='h-full w-full'
>
<Background gap={20} size={1} />
<MiniMap pannable zoomable />
<Controls />
</ReactFlow>
{contextMenu && (
<div
className='fixed z-50 min-w-40 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md'
style={{ left: contextMenu.x, top: contextMenu.y }}
role='menu'
>
{contextMenu.kind === 'node' && (
<button
type='button'
role='menuitem'
disabled={!contextCanDeleteNode}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none',
contextCanDeleteNode
? 'text-destructive hover:bg-destructive/10'
: 'cursor-not-allowed text-muted-foreground opacity-60',
)}
onClick={() => {
deleteNodeById(contextMenu.id);
setContextMenu(null);
}}
>
<Trash2 className='size-4' />
{contextCanDeleteNode ? '删除节点' : '系统节点不可删除'}
</button>
)}
{contextMenu.kind === 'edge' && (
<button
type='button'
role='menuitem'
disabled={!contextCanDeleteEdge}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none',
contextCanDeleteEdge
? 'text-destructive hover:bg-destructive/10'
: 'cursor-not-allowed text-muted-foreground opacity-60',
)}
onClick={() => {
deleteEdgeById(contextMenu.id);
setContextMenu(null);
}}
>
<Trash2 className='size-4' />
删除连线
</button>
)}
</div>
)}
</section>
);
}
@@ -5,6 +5,8 @@ import {
Flag,
Globe2,
Play,
ScanSearch,
Shield,
ShieldCheck,
} from 'lucide-react';
@@ -12,31 +14,38 @@ import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { WAFRuleNode } from '@/lib/services/openflare';
import { displayNodeTitle } from './node-factory';
export interface RuleFlowNodeData extends Record<string, unknown> {
rule: WAFRuleNode;
issues: number;
}
const meta = {
start: { label: '开始', icon: Play },
ip_match: { label: 'IP 匹配', icon: Fingerprint },
geo_match: { label: '地域匹配', icon: Globe2 },
pow: { label: 'PoW 挑战', icon: ShieldCheck },
allow: { label: '通过', icon: Flag },
block: { label: '阻止', icon: Ban },
start: { icon: Play },
ip_match: { icon: Fingerprint },
geo_match: { icon: Globe2 },
ua_check: { icon: ScanSearch },
security_check: { icon: Shield },
pow: { icon: ShieldCheck },
allow: { icon: Flag },
block: { icon: Ban },
} as const;
const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {
start: ['next'],
ip_match: ['true', 'false'],
geo_match: ['true', 'false'],
ua_check: ['true', 'false'],
security_check: ['true', 'false'],
pow: ['next'],
};
export function RuleNode({ data, selected }: NodeProps) {
const value = data as RuleFlowNodeData;
const { rule, issues } = value;
const { label, icon: Icon } = meta[rule.type];
const { icon: Icon } = meta[rule.type];
const title = displayNodeTitle(rule);
return (
<div
className={cn(
@@ -51,7 +60,7 @@ export function RuleNode({ data, selected }: NodeProps) {
<div className='flex items-center gap-3 px-4 py-3'>
<Icon className='size-5 text-primary' />
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<span className='text-sm font-medium'>{label}</span>
<span className='text-sm font-medium'>{title}</span>
<span className='font-mono text-[10px] text-muted-foreground'>
{rule.id}
</span>
@@ -0,0 +1,33 @@
export const UA_BROWSER_OPTIONS = [
{ value: 'Chrome', label: 'Chrome' },
{ value: 'Safari', label: 'Safari' },
{ value: 'Firefox', label: 'Firefox' },
{ value: 'Edge', label: 'Edge' },
{ value: 'Opera', label: 'Opera' },
{ value: 'Chromium', label: 'Chromium' },
{ value: 'WeChat', label: '微信' },
{ value: 'Postman', label: 'Postman' },
{ value: 'CLI', label: 'CLI' },
{ value: 'Bot', label: 'Bot' },
{ value: 'Unknown', label: 'Unknown' },
{ value: 'Other', label: 'Other' },
] as const;
export const UA_OS_OPTIONS = [
{ value: 'Android', label: 'Android' },
{ value: 'iOS', label: 'iOS' },
{ value: 'Windows', label: 'Windows' },
{ 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' },
] as const;
export const UA_BROWSER_LABELS = new Set<string>(
UA_BROWSER_OPTIONS.map((option) => option.value),
);
export const UA_OS_LABELS = new Set<string>(
UA_OS_OPTIONS.map((option) => option.value),
);
+72 -51
View File
@@ -20,14 +20,15 @@ import {
} from '@/lib/services';
import { getErrorMessage } from '../../components/helpers';
import { validateGraph } from './components/graph-validation';
import { NodeProperties } from './components/node-properties';
import { RuleFlowCanvas } from './components/rule-flow-canvas';
import { UnsavedChanges } from './components/unsaved-changes';
import {
findGraphErrorTarget,
type GraphErrorTarget,
} from './components/editor-behavior';
import { layoutRuleGraph } from './components/graph-layout';
import { validateGraph } from './components/graph-validation';
import { NodeProperties } from './components/node-properties';
import { RuleFlowCanvas } from './components/rule-flow-canvas';
import { UnsavedChanges } from './components/unsaved-changes';
export default function WAFRuleEditorPage() {
return (
@@ -160,6 +161,10 @@ function EditorContent() {
},
[changeGraph, graph],
);
const formatLayout = useCallback(() => {
if (!graph) return;
changeGraph(layoutRuleGraph(graph));
}, [changeGraph, graph]);
const leave = () => {
if (!dirty || window.confirm('存在未保存的更改,确定离开吗?'))
router.push('/waf');
@@ -186,58 +191,74 @@ function EditorContent() {
return (
<div className='flex h-[calc(100dvh-8rem)] w-full flex-col px-1 py-6'>
<UnsavedChanges dirty={dirty} />
<header className='mb-4 flex items-center justify-between gap-4'>
<div className='flex min-w-0 items-center gap-2'>
<GitBranch className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{ruleQuery.data.name}
</h1>
<Badge variant={ruleQuery.data.enabled ? 'default' : 'secondary'}>
{ruleQuery.data.enabled ? '已启用' : '已停用'}
</Badge>
<Badge variant={issues.length === 0 ? 'outline' : 'destructive'}>
{issues.length === 0 ? '图校验通过' : `${issues.length} 个问题`}
</Badge>
{dirty && <Badge variant='secondary'>未保存</Badge>}
</div>
<div className='flex shrink-0 items-center gap-2'>
<div className='flex items-center gap-2'>
<Switch
id='rule-enabled'
checked={ruleQuery.data.enabled}
disabled={
dirty ||
issues.length > 0 ||
saveMutation.isPending ||
metaMutation.isPending
}
onCheckedChange={(enabled) => metaMutation.mutate(enabled)}
/>
<Label htmlFor='rule-enabled'>启用规则</Label>
<header className='mb-4 flex flex-col gap-4'>
<Button
variant='ghost'
size='sm'
className='h-8 w-fit gap-1.5 px-0 text-xs'
onClick={leave}
>
<ArrowLeft className='size-3.5' />
返回
</Button>
<div className='flex items-center justify-between gap-4'>
<div className='flex min-w-0 items-center gap-2'>
<GitBranch className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{ruleQuery.data.name}
</h1>
<Badge variant={ruleQuery.data.enabled ? 'default' : 'secondary'}>
{ruleQuery.data.enabled ? '已启用' : '已停用'}
</Badge>
<Badge variant={issues.length === 0 ? 'outline' : 'destructive'}>
{issues.length === 0 ? '图校验通过' : `${issues.length} 个问题`}
</Badge>
{dirty && <Badge variant='secondary'>未保存</Badge>}
</div>
<Button variant='outline' onClick={leave}>
<ArrowLeft data-icon='inline-start' />
返回
</Button>
{conflict && (
<div className='flex shrink-0 items-center gap-2'>
<div className='flex items-center gap-2'>
<Switch
id='rule-enabled'
checked={ruleQuery.data.enabled}
disabled={
dirty ||
issues.length > 0 ||
saveMutation.isPending ||
metaMutation.isPending
}
onCheckedChange={(enabled) => metaMutation.mutate(enabled)}
/>
<Label htmlFor='rule-enabled'>启用规则</Label>
</div>
{conflict && (
<Button
variant='outline'
onClick={() => {
setDirty(false);
setConflict(false);
void ruleQuery.refetch();
}}
>
重新加载
</Button>
)}
<Button
type='button'
variant='outline'
onClick={() => {
setDirty(false);
setConflict(false);
void ruleQuery.refetch();
}}
title='自动整理节点与连线布局'
disabled={!graph}
onClick={formatLayout}
>
重新加载
格式化
</Button>
)}
<Button
disabled={!dirty || issues.length > 0 || saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
<Save data-icon='inline-start' />
{saveMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button
disabled={!dirty || issues.length > 0 || saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
<Save data-icon='inline-start' />
{saveMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</div>
</header>
<div className='flex min-h-0 flex-1 overflow-hidden rounded-xl border bg-background shadow-sm'>
+3
View File
@@ -18,6 +18,7 @@ type RankCardProps = {
description: string;
items: RankCardItem[];
color?: string;
valueFormatter?: (value: number) => string;
};
export function RankCard({
@@ -25,6 +26,7 @@ export function RankCard({
description,
items,
color = '#3b82f6',
valueFormatter,
}: RankCardProps) {
return (
<Card className='border-dashed shadow-none'>
@@ -40,6 +42,7 @@ export function RankCard({
<RankChart
items={items}
color={color}
valueFormatter={valueFormatter}
emptyMessage={`暂无 ${title} 数据`}
/>
</CardContent>
+1 -1
View File
@@ -82,7 +82,7 @@ export function RankChart({
}}
/>
</div>
<span className='w-14 text-right tabular-nums text-foreground/80 text-[13px] font-medium'>
<span className='min-w-14 max-w-28 text-right tabular-nums text-foreground/80 text-[13px] font-medium'>
{valueFormatter(item.value)}
</span>
</div>
+2 -2
View File
@@ -246,9 +246,9 @@ function AppBanner() {
>
<div className='flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground'>
<p className='flex flex-wrap items-center justify-center gap-x-2 text-center'>
<span className='text-foreground'>最新通知</span>
<span className='text-foreground'>更新日志</span>
<a
href='https://example.com'
href='https://open-flare.pages.dev/changelog/'
target='_blank'
className='underline underline-offset-4 hover:text-foreground'
>
-1
View File
@@ -60,7 +60,6 @@ export function AppleSpinner({
* 用于统一显示加载状态
*/
export function LoadingPage(props: { text?: string; badgeText?: string }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _ = props;
return (
<div className='absolute inset-0 z-50 overflow-hidden font-sans bg-background/80 backdrop-blur-md text-foreground flex items-center justify-center'>
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot='alert'
role='alert'
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-title'
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-description'
className={cn(
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
className,
)}
{...props}
/>
);
}
export { Alert, AlertDescription, AlertTitle };
+252
View File
@@ -0,0 +1,252 @@
'use client';
import * as React from 'react';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { ContextMenu as ContextMenuPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot='context-menu-radio-group'
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot='context-menu-sub-trigger'
data-inset={inset}
className={cn(
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto' />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot='context-menu-sub-content'
className={cn(
'z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot='context-menu-content'
className={cn(
'z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<ContextMenuPrimitive.Item
data-slot='context-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot='context-menu-checkbox-item'
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot='context-menu-radio-item'
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot='context-menu-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:pl-8',
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot='context-menu-separator'
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='context-menu-shortcut'
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+20
View File
@@ -1,6 +1,7 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
import unusedImports from 'eslint-plugin-unused-imports';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -20,6 +21,25 @@ const eslintConfig = [
'next-env.d.ts',
],
},
{
plugins: {
'unused-imports': unusedImports,
},
rules: {
// Prefer unused-imports so unused imports can be auto-removed with --fix.
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
},
];
export default eslintConfig;
+1
View File
@@ -42,6 +42,7 @@ export const openflareSecurityNavGroup: OpenFlareNavGroup = {
items: [
{ title: 'WAF', url: '/waf' },
{ title: 'IP 组', url: '/ip-groups' },
{ title: '限流', url: '/rate-limits' },
],
};
+3
View File
@@ -251,6 +251,9 @@ export type {
DnsAccountItem,
PagesProject,
PagesDeployment,
PagesSource,
PagesSourceActionReceipt,
PagesSourceStatus,
OriginItem,
OriginDetail,
AccessLogList,
+15
View File
@@ -109,8 +109,23 @@ export type {
OriginMutationPayload,
PagesDeployment,
PagesDeploymentFile,
PagesGitHubLatestSourceUpdatePayload,
PagesGitHubReleaseSelector,
PagesGitHubReleaseSource,
PagesGitHubSourceUpdatePayload,
PagesGitHubTagSourceUpdatePayload,
PagesManualSource,
PagesProject,
PagesProjectPayload,
PagesRemoteSourceUpdatePayload,
PagesRemoteURLSource,
PagesSource,
PagesSourceActionPayload,
PagesSourceActionReceipt,
PagesSourceRevision,
PagesSourceStatus,
PagesSourceUpdatePayload,
PagesSourceUpdateResult,
AcmeAccountItem,
DnsAccountItem,
DnsAccountMutationPayload,
@@ -12,6 +12,11 @@ import type {
PagesDeploymentUploadPayload,
PagesProject,
PagesProjectPayload,
PagesSource,
PagesSourceActionPayload,
PagesSourceActionReceipt,
PagesSourceUpdatePayload,
PagesSourceUpdateResult,
} from './types';
export class PagesService extends OpenFlareBaseService {
@@ -40,6 +45,41 @@ export class PagesService extends OpenFlareBaseService {
return this.post<void>(`/${id}/delete`);
}
static getSource(projectId: number): Promise<PagesSource> {
return this.get<PagesSource>(`/${projectId}/source`);
}
static updateSource(
projectId: number,
payload: PagesSourceUpdatePayload,
): Promise<PagesSourceUpdateResult> {
return this.post<PagesSourceUpdateResult>(
`/${projectId}/source/update`,
payload,
);
}
static deleteSource(projectId: number): Promise<PagesSource> {
return this.post<PagesSource>(`/${projectId}/source/delete`);
}
static checkSource(projectId: number): Promise<PagesSourceActionReceipt> {
return this.post<PagesSourceActionReceipt>(
`/${projectId}/source/check`,
{},
);
}
static syncSource(
projectId: number,
payload: PagesSourceActionPayload = {},
): Promise<PagesSourceActionReceipt> {
return this.post<PagesSourceActionReceipt>(
`/${projectId}/source/sync`,
payload,
);
}
static listDeployments(projectId: number): Promise<PagesDeployment[]> {
return this.get<PagesDeployment[]>(`/${projectId}/deployments`);
}
@@ -58,8 +98,6 @@ export class PagesService extends OpenFlareBaseService {
): Promise<PagesDeployment> {
const formData = new FormData();
formData.append('package', payload.file);
formData.append('root_dir', payload.rootDir ?? '');
formData.append('entry_file', payload.entryFile ?? 'index.html');
return this.postFormData<PagesDeployment>(
`/${projectId}/deployments/upload`,
@@ -75,6 +113,7 @@ export class PagesService extends OpenFlareBaseService {
return this.post<PagesDeployment>(
`/${projectId}/deployments/upload-from-url`,
payload,
{ timeout: apiConfig.uploadTimeout } as InternalAxiosRequestConfig,
);
}
+169 -5
View File
@@ -394,9 +394,11 @@ export interface PagesDeployment {
status: 'uploaded' | 'active';
file_count: number;
total_size: number;
root_dir?: string;
entry_file: string;
created_by: string;
source_type: 'manual_upload' | 'manual_url' | 'remote_url' | 'github_release';
source_label: string;
trigger_type:
'manual_upload' | 'manual_url' | 'manual_sync' | 'scheduled_auto_update';
created_at: string;
activated_at?: string | null;
}
@@ -448,8 +450,6 @@ export interface PagesProjectPayload {
export interface PagesDeploymentUploadPayload {
file: File;
rootDir?: string;
entryFile?: string;
onProgress?: (percent: number) => void;
}
@@ -457,6 +457,120 @@ export interface PagesDeploymentUploadFromURLPayload {
url: string;
}
export type PagesSourceStatus =
'idle' | 'checking' | 'update_available' | 'syncing' | 'failed' | 'attention';
export type PagesGitHubReleaseSelector = 'latest' | 'tag';
export interface PagesSourceRevision {
revision: string;
label: string;
asset_name?: string;
}
interface PagesSourceRuntimeView {
sync_status?: PagesSourceStatus;
update_available?: boolean;
last_seen?: PagesSourceRevision;
last_applied?: PagesSourceRevision;
last_checked_at?: string | null;
last_synced_at?: string | null;
next_check_at?: string | null;
last_error?: string;
}
export interface PagesManualSource {
source_type: 'manual';
}
export interface PagesRemoteURLSource extends PagesSourceRuntimeView {
source_type: 'remote_url';
remote_url: string;
allow_insecure?: boolean;
}
interface PagesGitHubReleaseSourceBase extends PagesSourceRuntimeView {
source_type: 'github_release';
github_repository: string;
asset_name: string;
}
interface PagesGitHubLatestReleaseSource extends PagesGitHubReleaseSourceBase {
release_selector: 'latest';
release_tag?: '';
auto_update_enabled: boolean;
check_interval_minutes: number;
}
interface PagesGitHubTagReleaseSource extends PagesGitHubReleaseSourceBase {
release_selector: 'tag';
release_tag: string;
auto_update_enabled: false;
check_interval_minutes?: 0;
}
export type PagesGitHubReleaseSource =
PagesGitHubLatestReleaseSource | PagesGitHubTagReleaseSource;
/**
* 部署源使用判别联合,后续仓库构建来源只需增加独立 git_repository variant,
* 不需要向 Remote 或 GitHub Release 填入构建字段。
*/
export type PagesSource =
PagesManualSource | PagesRemoteURLSource | PagesGitHubReleaseSource;
export interface PagesRemoteSourceUpdatePayload {
source_type: 'remote_url';
remote_url: string;
allow_insecure?: boolean;
}
interface PagesGitHubSourceUpdateBase {
source_type: 'github_release';
repository_url: string;
asset_name: string;
}
export interface PagesGitHubLatestSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
release_selector: 'latest';
release_tag: '';
auto_update_enabled: boolean;
check_interval_minutes: number;
}
export interface PagesGitHubTagSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
release_selector: 'tag';
release_tag: string;
auto_update_enabled: false;
check_interval_minutes: 0;
}
export type PagesGitHubSourceUpdatePayload =
PagesGitHubLatestSourceUpdatePayload | PagesGitHubTagSourceUpdatePayload;
/**
* Source 更新请求保持 Provider 判别联合;未来仓库拉取构建使用独立 git_repository variant,
* 不向 Remote URL 或 GitHub Release payload 混入构建字段。
*/
export type PagesSourceUpdatePayload =
PagesRemoteSourceUpdatePayload | PagesGitHubSourceUpdatePayload;
export interface PagesSourceActionPayload {
confirmed_revision?: string;
}
export interface PagesSourceActionReceipt {
task_id: string;
execution_id: string;
action: 'check' | 'sync';
}
export interface PagesSourceUpdateResult {
source: PagesSource;
check_task: PagesSourceActionReceipt | null;
warning: string;
}
// ==================== Origins ====================
export interface OriginItem {
@@ -505,6 +619,7 @@ export interface AccessLogOverviewFilters {
host?: string;
hosts?: string[];
hours?: number;
bucket_minutes?: number;
}
export interface AccessLogOverviewMetricPoint {
@@ -515,6 +630,7 @@ export interface AccessLogOverviewMetricPoint {
export interface AccessLogOverview {
generated_at: string;
hours: number;
bucket_minutes?: number;
summary: {
total_requests: number;
total_visits: number;
@@ -779,35 +895,83 @@ export interface BlockNodeConfig {
response_body: string;
}
export interface UACheckConfig {
require_ua: boolean;
browsers: string[];
operating_systems: string[];
match_mode: 'and' | 'or';
block_common_bots: boolean;
block_abnormal_ua: boolean;
block_custom_ua: boolean;
custom_ua_patterns: string[];
}
export interface SecurityCheckConfig {
sql_injection: boolean;
path_traversal: boolean;
command_injection: boolean;
xss: boolean;
ssrf: boolean;
file_inclusion: boolean;
malicious_upload: boolean;
xxe: boolean;
crlf_injection: boolean;
}
export type WAFRuleNode =
| {
id: string;
type: 'start';
label?: string;
position: XYPosition;
config: Record<string, never>;
}
| {
id: string;
type: 'ip_match';
label?: string;
position: XYPosition;
config: IPMatchConfig;
}
| {
id: string;
type: 'geo_match';
label?: string;
position: XYPosition;
config: GeoMatchConfig;
}
| { id: string; type: 'pow'; position: XYPosition; config: PoWNodeConfig }
| {
id: string;
type: 'ua_check';
label?: string;
position: XYPosition;
config: UACheckConfig;
}
| {
id: string;
type: 'security_check';
label?: string;
position: XYPosition;
config: SecurityCheckConfig;
}
| {
id: string;
type: 'pow';
label?: string;
position: XYPosition;
config: PoWNodeConfig;
}
| {
id: string;
type: 'allow';
label?: string;
position: XYPosition;
config: Record<string, never>;
}
| {
id: string;
type: 'block';
label?: string;
position: XYPosition;
config: BlockNodeConfig;
};
+15
View File
@@ -146,6 +146,21 @@ export const searchData: SearchItem[] = [
category: 'page',
keywords: ['ip', 'ip group', 'ip组', '黑名单', '白名单', '访问控制'],
},
{
id: 'console-rate-limits',
title: '限流',
description: '配置边缘站点默认并发与带宽限流策略',
url: '/rate-limits',
category: 'page',
keywords: [
'限流',
'rate limit',
'limit_conn',
'limit_rate',
'并发',
'带宽',
],
},
{
id: 'console-pages',
title: 'Pages 静态托管',
+2 -1
View File
@@ -10,7 +10,7 @@
"build:embed": "NEXT_STANDALONE_EXPORT=true next build",
"start": "next start -p 3010",
"lint": "eslint",
"format": "prettier --write ."
"format": "eslint . --fix --max-warnings 0 && prettier --write ."
},
"dependencies": {
"@codemirror/lang-sql": "^6.10.0",
@@ -93,6 +93,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.6",
"eslint-plugin-unused-imports": "^4.4.1",
"jsdom": "^29.1.1",
"prettier": "^3.9.5",
"tailwindcss": "^4",
+18
View File
@@ -243,6 +243,9 @@ importers:
eslint-config-next:
specifier: 15.5.6
version: 15.5.6(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
eslint-plugin-unused-imports:
specifier: ^4.4.1
version: 4.4.1(@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))
jsdom:
specifier: ^29.1.1
version: 29.1.1
@@ -2746,6 +2749,15 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
eslint-plugin-unused-imports@4.4.1:
resolution: {integrity: sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==}
peerDependencies:
'@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0
eslint: ^10.0.0 || ^9.0.0 || ^8.0.0
peerDependenciesMeta:
'@typescript-eslint/eslint-plugin':
optional: true
eslint-scope@8.4.0:
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -7131,6 +7143,12 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
eslint-scope@8.4.0:
dependencies:
esrecurse: 4.3.0
+164 -7
View File
@@ -2,27 +2,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosResponse } from 'axios';
import apiClient from '@/lib/services/core/api-client';
import { apiConfig } from '@/lib/services/core/config';
import { PagesService } from '@/lib/services/openflare/pages.service';
vi.mock('@/lib/services/core/api-client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
function response<T>(data: T) {
return {
data: { error_msg: '', data },
status: 200,
statusText: 'OK',
headers: {},
config: { headers: {} },
} as AxiosResponse;
}
describe('PagesService', () => {
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
vi.mocked(apiClient.post).mockReset();
});
it('requests deployment files using the backend deployment route', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { error_msg: '', data: [] },
status: 200,
statusText: 'OK',
headers: {},
config: { headers: {} },
} as AxiosResponse);
vi.mocked(apiClient.get).mockResolvedValue(response([]));
await PagesService.listDeploymentFiles(7);
@@ -31,4 +38,154 @@ describe('PagesService', () => {
expect.objectContaining({ params: undefined }),
);
});
it('connects all source endpoints with explicit action payloads', async () => {
vi.mocked(apiClient.get).mockResolvedValue(
response({ source_type: 'manual' }),
);
vi.mocked(apiClient.post).mockResolvedValue(
response({ source_type: 'manual' }),
);
await PagesService.getSource(12);
await PagesService.updateSource(12, {
source_type: 'remote_url',
remote_url: 'https://example.com/site.zip?token=secret',
allow_insecure: false,
});
await PagesService.deleteSource(12);
await PagesService.checkSource(12);
await PagesService.syncSource(12);
expect(apiClient.get).toHaveBeenCalledWith(
'/api/v1/d/pages/12/source',
expect.objectContaining({ params: undefined }),
);
expect(apiClient.post).toHaveBeenNthCalledWith(
1,
'/api/v1/d/pages/12/source/update',
expect.objectContaining({
source_type: 'remote_url',
}),
undefined,
);
expect(apiClient.post).toHaveBeenNthCalledWith(
2,
'/api/v1/d/pages/12/source/delete',
undefined,
undefined,
);
expect(apiClient.post).toHaveBeenNthCalledWith(
3,
'/api/v1/d/pages/12/source/check',
{},
undefined,
);
expect(apiClient.post).toHaveBeenNthCalledWith(
4,
'/api/v1/d/pages/12/source/sync',
{},
undefined,
);
});
it('sends the complete GitHub latest discriminator payload', async () => {
vi.mocked(apiClient.post).mockResolvedValue(
response({
source: { source_type: 'manual' },
check_task: null,
warning: '',
}),
);
await PagesService.updateSource(12, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'latest',
release_tag: '',
asset_name: 'dist.zip',
auto_update_enabled: false,
check_interval_minutes: 1440,
});
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/pages/12/source/update',
{
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'latest',
release_tag: '',
asset_name: 'dist.zip',
auto_update_enabled: false,
check_interval_minutes: 1440,
},
undefined,
);
});
it('sends safe disabled defaults with the GitHub tag discriminator', async () => {
vi.mocked(apiClient.post).mockResolvedValue(
response({
source: { source_type: 'manual' },
check_task: null,
warning: '',
}),
);
await PagesService.updateSource(12, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'tag',
release_tag: 'v1.2.3',
asset_name: 'site.tar.gz',
auto_update_enabled: false,
check_interval_minutes: 0,
});
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/pages/12/source/update',
{
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'tag',
release_tag: 'v1.2.3',
asset_name: 'site.tar.gz',
auto_update_enabled: false,
check_interval_minutes: 0,
},
undefined,
);
});
it('uploads only the package multipart field', async () => {
vi.mocked(apiClient.post).mockResolvedValue(response({}));
const file = new File(['site'], 'site.zip', {
type: 'application/zip',
});
await PagesService.uploadDeployment(8, { file });
const formData = vi.mocked(apiClient.post).mock.calls[0]?.[1];
expect(formData).toBeInstanceOf(FormData);
expect(Array.from((formData as FormData).keys())).toEqual(['package']);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/pages/8/deployments/upload',
formData,
expect.objectContaining({ timeout: apiConfig.uploadTimeout }),
);
});
it('keeps the compatibility URL import on the long upload timeout', async () => {
vi.mocked(apiClient.post).mockResolvedValue(response({}));
await PagesService.uploadDeploymentFromURL(8, {
url: 'https://example.com/site.zip',
});
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/pages/8/deployments/upload-from-url',
{ url: 'https://example.com/site.zip' },
expect.objectContaining({ timeout: apiConfig.uploadTimeout }),
);
});
});
@@ -0,0 +1,355 @@
import { act } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
import {
getLatestSourceIdlePollingDecision,
PagesSourceCard,
} from '@/app/(main)/pages/detail/components/pages-source-card';
import {
deploymentsQueryKey,
projectQueryKey,
projectsQueryKey,
sourceQueryKey,
} from '@/app/(main)/pages/components/pages-utils';
import {
type PagesDeployment,
type PagesGitHubReleaseSource,
PagesService,
} from '@/lib/services/openflare';
vi.mock('@/lib/services/openflare', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/lib/services/openflare')>();
return {
...actual,
PagesService: {
getSource: vi.fn(),
updateSource: vi.fn(),
deleteSource: vi.fn(),
checkSource: vi.fn(),
syncSource: vi.fn(),
listDeployments: vi.fn(),
listDeploymentFiles: vi.fn(),
activateDeployment: vi.fn(),
deleteDeployment: vi.fn(),
},
};
});
function renderWithQuery(ui: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
const rendered = render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
return {
...rendered,
queryClient,
rerenderWithQuery: (nextUI: React.ReactNode) =>
rendered.rerender(
<QueryClientProvider client={queryClient}>
{nextUI}
</QueryClientProvider>,
),
};
}
const latestSource: PagesGitHubReleaseSource = {
source_type: 'github_release',
github_repository: 'openflare/site',
release_selector: 'latest',
asset_name: 'dist.zip',
auto_update_enabled: true,
check_interval_minutes: 120,
sync_status: 'idle',
update_available: false,
last_seen: {
revision: 'b'.repeat(64),
label: 'v1.2.3',
asset_name: 'dist.zip',
},
last_applied: {
revision: 'a'.repeat(64),
label: 'v1.2.2',
asset_name: 'dist.zip',
},
last_checked_at: '2026-07-19T10:00:00Z',
last_synced_at: '2026-07-19T09:00:00Z',
next_check_at: '2026-07-19T12:00:00Z',
last_error: '',
};
describe('Pages latest source automatic updates', () => {
beforeEach(() => {
vi.mocked(PagesService.getSource).mockReset();
vi.mocked(PagesService.updateSource).mockReset();
vi.mocked(PagesService.deleteSource).mockReset();
vi.mocked(PagesService.checkSource).mockReset();
vi.mocked(PagesService.syncSource).mockReset();
vi.mocked(PagesService.listDeployments).mockReset();
vi.mocked(PagesService.listDeploymentFiles).mockReset();
vi.mocked(PagesService.activateDeployment).mockReset();
vi.mocked(PagesService.deleteDeployment).mockReset();
});
it('backfills latest settings and hides them after selecting a fixed tag', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
expect(screen.getByRole('switch', { name: '自动更新' })).toBeChecked();
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(120);
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
expect(
screen.queryByRole('switch', { name: '自动更新' }),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument();
});
it('does not show automatic schedule details for a fixed tag source', async () => {
vi.mocked(PagesService.getSource).mockResolvedValue({
...latestSource,
release_selector: 'tag',
release_tag: 'v1.2.3',
auto_update_enabled: false,
check_interval_minutes: 0,
next_check_at: null,
});
renderWithQuery(<PagesSourceCard projectId={9} />);
expect(await screen.findByText('固定 Tag · v1.2.3')).toBeVisible();
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
expect(screen.queryByText('下次检查')).not.toBeInTheDocument();
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
});
it('rejects a latest interval outside 5–1440 minutes', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
await user.clear(intervalInput);
await user.type(intervalInput, '4');
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
expect(screen.getByRole('alert')).toHaveTextContent(
'检查间隔须为 5–1440 分钟的整数',
);
expect(intervalInput).toHaveAttribute(
'aria-describedby',
'pages-github-check-interval-description pages-github-check-interval-error',
);
expect(PagesService.updateSource).not.toHaveBeenCalled();
});
it('keeps an unsaved source draft when idle polling updates runtime fields', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
const { queryClient } = renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
const assetInput = screen.getByLabelText('Release Asset 文件名');
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
await user.clear(assetInput);
await user.type(assetInput, 'draft.zip');
await user.clear(intervalInput);
await user.type(intervalInput, '30');
await user.click(screen.getByRole('switch', { name: '自动更新' }));
act(() => {
queryClient.setQueryData(sourceQueryKey(9), {
...latestSource,
last_checked_at: '2026-07-19T10:30:00Z',
next_check_at: '2026-07-19T12:30:00Z',
});
});
await waitFor(() => {
expect(assetInput).toHaveValue('draft.zip');
expect(intervalInput).toHaveValue(30);
expect(
screen.getByRole('switch', { name: '自动更新' }),
).not.toBeChecked();
});
});
it('remounts project-scoped source state when the route project changes', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockImplementation(async (projectId) => ({
...latestSource,
github_repository:
projectId === 9 ? 'openflare/project-nine' : 'openflare/project-ten',
}));
const { rerenderWithQuery } = renderWithQuery(
<PagesSourceCard key='source-9' projectId={9} />,
);
await user.click(await screen.findByRole('button', { name: '配置' }));
expect(screen.getByRole('dialog')).toBeVisible();
rerenderWithQuery(<PagesSourceCard key='source-10' projectId={10} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(await screen.findByText('openflare/project-ten')).toBeVisible();
});
it('uses low-frequency, near-due and bounded overdue polling', () => {
const now = Date.parse('2026-07-19T10:00:00Z');
const farSource = {
...latestSource,
next_check_at: new Date(now + 60 * 60 * 1_000).toISOString(),
};
expect(getLatestSourceIdlePollingDecision(farSource, now, null)).toEqual({
interval: 5 * 60 * 1_000,
overdueWindow: null,
});
const nearSource = {
...latestSource,
next_check_at: new Date(now + 60_000).toISOString(),
};
expect(getLatestSourceIdlePollingDecision(nearSource, now, null)).toEqual({
interval: 30_000,
overdueWindow: null,
});
const overdueSource = {
...latestSource,
next_check_at: new Date(now - 1).toISOString(),
};
const overdue = getLatestSourceIdlePollingDecision(
overdueSource,
now,
null,
);
expect(overdue.interval).toBe(30_000);
expect(
getLatestSourceIdlePollingDecision(
overdueSource,
now + 10 * 60 * 1_000,
overdue.overdueWindow,
).interval,
).toBe(false);
expect(
getLatestSourceIdlePollingDecision(
{
...latestSource,
release_selector: 'tag',
release_tag: 'v1.2.3',
auto_update_enabled: false,
check_interval_minutes: 0,
},
now,
null,
).interval,
).toBe(false);
});
it('refreshes source consumers after a background deployment is applied', async () => {
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
const { queryClient } = renderWithQuery(<PagesSourceCard projectId={9} />);
expect(await screen.findByText('openflare/site')).toBeVisible();
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
const updatedSource: PagesGitHubReleaseSource = {
...latestSource,
last_applied: {
revision: 'b'.repeat(64),
label: 'v1.2.3',
asset_name: 'dist.zip',
},
last_synced_at: '2026-07-19T10:30:00Z',
};
vi.mocked(PagesService.getSource).mockResolvedValue(updatedSource);
act(() => {
queryClient.setQueryData(sourceQueryKey(9), updatedSource);
});
await waitFor(() => {
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: sourceQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: projectQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: deploymentsQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['openflare', 'pages', 'deployment-files', 9],
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: projectsQueryKey,
});
});
});
it('warns that rollback closes auto update and refreshes four query groups', async () => {
const user = userEvent.setup();
const deployment: PagesDeployment = {
id: 51,
project_id: 9,
deployment_number: 7,
checksum: 'd'.repeat(64),
status: 'uploaded',
file_count: 12,
total_size: 4_096,
created_by: 'user:1',
source_type: 'github_release',
source_label: 'v1.2.2',
trigger_type: 'scheduled_auto_update',
created_at: '2026-07-19T10:00:00Z',
activated_at: null,
};
vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]);
vi.mocked(PagesService.activateDeployment).mockResolvedValue({} as never);
const { queryClient } = renderWithQuery(
<DeploymentHistory projectId={9} activeDeploymentId={52} />,
);
await screen.findByText('GitHub · v1.2.2 · 定时更新');
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
await user.click(screen.getByRole('button', { name: '激活' }));
const dialog = screen.getByRole('alertdialog');
expect(
within(dialog).getByText(
'激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。',
),
).toBeVisible();
await user.click(within(dialog).getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(PagesService.activateDeployment).toHaveBeenCalledWith(9, 51);
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: deploymentsQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: projectQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: sourceQueryKey(9),
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: projectsQueryKey,
});
});
});
});
@@ -0,0 +1,639 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DeploymentUploadDialog } from '@/app/(main)/pages/components/deployment-upload-dialog';
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
import { PagesSourceCard } from '@/app/(main)/pages/detail/components/pages-source-card';
import {
AdminTaskService,
type TaskExecution,
type TaskExecutionStatus,
} from '@/lib/services/admin';
import {
type PagesDeployment,
type PagesGitHubReleaseSource,
type PagesRemoteURLSource,
PagesService,
} from '@/lib/services/openflare';
vi.mock('@/lib/services/openflare', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/lib/services/openflare')>();
return {
...actual,
PagesService: {
getSource: vi.fn(),
updateSource: vi.fn(),
deleteSource: vi.fn(),
checkSource: vi.fn(),
syncSource: vi.fn(),
listDeployments: vi.fn(),
listDeploymentFiles: vi.fn(),
activateDeployment: vi.fn(),
deleteDeployment: vi.fn(),
uploadDeployment: vi.fn(),
},
};
});
vi.mock('@/lib/services/admin', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/services/admin')>();
return {
...actual,
AdminTaskService: {
getTaskExecution: vi.fn(),
},
};
});
function renderWithQuery(ui: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
}
const remoteSource: PagesRemoteURLSource = {
source_type: 'remote_url',
remote_url: 'https://artifacts.example.com/site.zip?token=secret',
allow_insecure: false,
sync_status: 'idle',
last_applied: {
revision: 'a'.repeat(64),
label: 'site.zip',
},
last_synced_at: '2026-07-19T10:00:00Z',
last_error: '',
};
const githubLatestSource: PagesGitHubReleaseSource = {
source_type: 'github_release',
github_repository: 'openflare/site',
release_selector: 'latest',
asset_name: 'dist.zip',
auto_update_enabled: false,
check_interval_minutes: 1440,
sync_status: 'update_available',
update_available: true,
last_seen: {
revision: 'b'.repeat(64),
label: 'v1.2.3',
asset_name: 'dist.zip',
},
last_applied: {
revision: 'a'.repeat(64),
label: 'v1.2.2',
asset_name: 'dist.zip',
},
last_checked_at: '2026-07-19T10:00:00Z',
last_synced_at: '2026-07-18T10:00:00Z',
next_check_at: '2026-07-19T11:00:00Z',
last_error: '',
};
const githubAttentionRevision = 'c'.repeat(64);
const githubAttentionSource: PagesGitHubReleaseSource = {
...githubLatestSource,
sync_status: 'attention',
update_available: true,
last_seen: {
revision: githubAttentionRevision,
label: 'v1.2.3',
asset_name: 'dist.zip',
},
};
function taskExecution(
status: TaskExecutionStatus,
errorMessage = '',
): TaskExecution {
return {
id: '42',
task_id: 'manual_of_pages_source_action_1',
task_type: 'of_pages_source_action',
task_name: 'Pages 来源动作',
status,
retryable: false,
max_retry: 0,
retry_count: 0,
log: '',
error_message: errorMessage,
result: '',
duration: 1,
payload: '',
triggered_by: 'admin:1',
created_at: '2026-07-19T10:00:00Z',
updated_at: '2026-07-19T10:00:01Z',
};
}
describe('Pages source UI', () => {
beforeEach(() => {
vi.mocked(PagesService.getSource).mockReset();
vi.mocked(PagesService.updateSource).mockReset();
vi.mocked(PagesService.deleteSource).mockReset();
vi.mocked(PagesService.checkSource).mockReset();
vi.mocked(PagesService.syncSource).mockReset();
vi.mocked(PagesService.listDeployments).mockReset();
vi.mocked(PagesService.listDeploymentFiles).mockReset();
vi.mocked(PagesService.activateDeployment).mockReset();
vi.mocked(PagesService.deleteDeployment).mockReset();
vi.mocked(PagesService.uploadDeployment).mockReset();
vi.mocked(AdminTaskService.getTaskExecution).mockReset();
});
it('offers the three source types without future repository build controls', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
expect(await screen.findByText('本地部署包')).toBeVisible();
expect(screen.getByRole('button', { name: '配置' })).toBeVisible();
expect(screen.queryByText('检查更新')).not.toBeInTheDocument();
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '配置' }));
expect(screen.getByRole('radio', { name: '手动部署' })).toBeVisible();
expect(screen.getByRole('radio', { name: 'Remote URL' })).toBeVisible();
expect(screen.getByRole('radio', { name: 'GitHub Release' })).toBeVisible();
expect(screen.getByRole('radio', { name: '手动部署' })).toBeChecked();
expect(screen.queryByText('构建命令')).not.toBeInTheDocument();
expect(screen.queryByText('输出目录')).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
expect(screen.getByRole('switch', { name: '自动更新' })).not.toBeChecked();
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(1440);
});
it('submits the GitHub latest automatic update settings', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: githubLatestSource,
check_task: null,
warning: '',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
expect(screen.getByLabelText('Release Asset 文件名')).toHaveValue(
'dist.zip',
);
await user.click(screen.getByRole('switch', { name: '自动更新' }));
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
await user.clear(intervalInput);
await user.type(intervalInput, '15');
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
await waitFor(() => {
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'latest',
release_tag: '',
asset_name: 'dist.zip',
auto_update_enabled: true,
check_interval_minutes: 15,
});
});
});
it('rejects non-canonical GitHub repository URL paths', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
const repositoryInput = screen.getByLabelText('GitHub 仓库 URL');
await user.type(repositoryInput, 'https://github.com//openflare/site');
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
expect(
screen.getByText(
'请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址',
),
).toBeVisible();
await user.clear(repositoryInput);
await user.type(repositoryInput, 'https://github.com/openflare/site/');
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
expect(PagesService.updateSource).not.toHaveBeenCalled();
});
it('submits the GitHub tag discriminator with safe disabled defaults', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: {
...githubLatestSource,
release_selector: 'tag',
release_tag: 'v1.2.3',
auto_update_enabled: false,
check_interval_minutes: 0,
next_check_at: null,
},
check_task: null,
warning: '',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
expect(
screen.queryByRole('switch', { name: '自动更新' }),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument();
await user.type(
screen.getByLabelText('Release tag'),
'release/candidate#1&channel=stable',
);
await user.clear(screen.getByLabelText('Release Asset 文件名'));
await user.type(
screen.getByLabelText('Release Asset 文件名'),
' site?arch=amd64#stable.zip ',
);
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
await waitFor(() => {
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'tag',
release_tag: 'release/candidate#1&channel=stable',
asset_name: ' site?arch=amd64#stable.zip ',
auto_update_enabled: false,
check_interval_minutes: 0,
});
});
});
it('rejects unsafe GitHub tag and asset values before saving', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
await user.type(screen.getByLabelText('Release tag'), 'release//candidate');
await user.clear(screen.getByLabelText('Release Asset 文件名'));
await user.type(
screen.getByLabelText('Release Asset 文件名'),
'../dist.zip',
);
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
expect(
screen.getByText(
'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)',
),
).toBeVisible();
expect(
screen.getByText(
'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符',
),
).toBeVisible();
expect(PagesService.updateSource).not.toHaveBeenCalled();
});
it('polls the initial check receipt and represents the queued window locally', async () => {
const user = userEvent.setup();
const idleGitHubSource: PagesGitHubReleaseSource = {
...githubLatestSource,
sync_status: 'idle',
update_available: false,
};
vi.mocked(PagesService.getSource)
.mockResolvedValueOnce({ source_type: 'manual' })
.mockResolvedValue(idleGitHubSource);
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: idleGitHubSource,
check_task: {
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'check',
},
warning: '',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
taskExecution('pending'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
await waitFor(() => {
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
expect(screen.getByText('检查中')).toBeVisible();
});
const checkButton = await screen.findByRole('button', {
name: /检查更新/,
});
expect(checkButton).toBeDisabled();
expect(within(checkButton).getByRole('status')).toBeVisible();
expect(PagesService.checkSource).not.toHaveBeenCalled();
expect(PagesService.syncSource).not.toHaveBeenCalled();
});
it('shows GitHub latest automatic update state and schedule', async () => {
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
renderWithQuery(<PagesSourceCard projectId={9} />);
expect(await screen.findByText('openflare/site')).toBeVisible();
expect(screen.getByText('v1.2.3 · bbbbbbbbbbbb')).toBeVisible();
expect(screen.getByText('v1.2.2 · aaaaaaaaaaaa')).toBeVisible();
expect(screen.getByText('有可用更新')).toBeVisible();
expect(screen.getByText('下次检查')).toBeVisible();
expect(screen.getByText('自动更新')).toBeVisible();
expect(screen.getByText('已关闭')).toBeVisible();
expect(screen.getByText('检查间隔')).toBeVisible();
expect(screen.getByText('1440 分钟')).toBeVisible();
});
it('dispatches a GitHub check and starts TaskExecution polling', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
vi.mocked(PagesService.checkSource).mockResolvedValue({
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'check',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
taskExecution('succeeded'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '检查更新' }));
await waitFor(() => {
expect(PagesService.checkSource).toHaveBeenCalledWith(9);
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
expect(PagesService.syncSource).not.toHaveBeenCalled();
});
});
it('renders a GitHub check dispatch error', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
vi.mocked(PagesService.checkSource).mockRejectedValue(
new Error('GitHub API 暂不可用'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '检查更新' }));
expect(await screen.findByText('GitHub API 暂不可用')).toBeVisible();
});
it('renders a TaskExecution polling error with an explicit retry', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
vi.mocked(PagesService.checkSource).mockResolvedValue({
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'check',
});
vi.mocked(AdminTaskService.getTaskExecution).mockRejectedValue(
new Error('任务状态暂时不可读取'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '检查更新' }));
expect(await screen.findByText('任务状态暂时不可读取')).toBeVisible();
expect(screen.getByRole('button', { name: '重试' })).toBeVisible();
});
it('requires the exact currently displayed revision for attention sync', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubAttentionSource);
vi.mocked(PagesService.syncSource).mockResolvedValue({
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'sync',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
taskExecution('succeeded'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
const alert = await screen.findByRole('alert');
expect(
within(alert).getByText('Release Asset 发生变化,需要显式确认'),
).toBeVisible();
expect(within(alert).getByText(githubAttentionRevision)).toBeVisible();
await user.click(screen.getByRole('button', { name: '同步并发布' }));
const dialog = screen.getByRole('alertdialog');
expect(within(dialog).getByText(githubAttentionRevision)).toBeVisible();
await user.click(
within(dialog).getByRole('button', { name: '确认并发布' }),
);
await waitFor(() => {
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {
confirmed_revision: githubAttentionRevision,
});
});
});
it('edits the remote URL as plain text', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: {
...remoteSource,
remote_url: 'https://new.example.com/site.zip?token=new',
},
check_task: null,
warning: '',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
expect(
await screen.findByText(
'https://artifacts.example.com/site.zip?token=secret',
),
).toBeVisible();
await user.click(screen.getByRole('button', { name: '配置' }));
const input = screen.getByLabelText('Remote URL');
expect(input).toHaveAttribute('type', 'url');
expect(input).toHaveValue(
'https://artifacts.example.com/site.zip?token=secret',
);
await user.clear(input);
await user.type(input, 'https://new.example.com/site.zip?token=new');
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
await waitFor(() => {
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
source_type: 'remote_url',
remote_url: 'https://new.example.com/site.zip?token=new',
allow_insecure: false,
});
});
});
it('saves the allow-insecure connection switch for remote sources', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: { ...remoteSource, allow_insecure: true },
check_task: null,
warning: '',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '配置' }));
expect(
screen.getByRole('switch', { name: '允许不安全的连接' }),
).not.toBeChecked();
await user.click(screen.getByRole('switch', { name: '允许不安全的连接' }));
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
await waitFor(() => {
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
source_type: 'remote_url',
remote_url: 'https://artifacts.example.com/site.zip?token=secret',
allow_insecure: true,
});
});
});
it('polls the existing task execution detail after sync dispatch', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
vi.mocked(PagesService.syncSource).mockResolvedValue({
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'sync',
});
vi.mocked(AdminTaskService.getTaskExecution)
.mockResolvedValueOnce(taskExecution('pending'))
.mockResolvedValue(taskExecution('succeeded'));
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '同步并发布' }));
await waitFor(() => {
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {});
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
});
expect(screen.getByRole('button', { name: /同步并发布/ })).toBeDisabled();
await waitFor(
() => {
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledTimes(2);
},
{ timeout: 3_500 },
);
});
it('shows the actual project entry and no one-off URL upload tab', () => {
renderWithQuery(
<DeploymentUploadDialog
open
onOpenChange={vi.fn()}
projectId={9}
rootDir='dist/site'
entryFile='home.html'
/>,
);
expect(screen.getByText('dist/site/home.html')).toBeVisible();
expect(screen.queryByText('从 URL 下载')).not.toBeInTheDocument();
expect(screen.queryByText('部署包下载链接')).not.toBeInTheDocument();
});
it('renders a deployment query failure instead of an empty history', async () => {
vi.mocked(PagesService.listDeployments).mockRejectedValue(
new Error('部署历史暂时不可用'),
);
renderWithQuery(<DeploymentHistory projectId={9} />);
expect(await screen.findByText('部署历史暂时不可用')).toBeVisible();
expect(screen.queryByText('暂无部署')).not.toBeInTheDocument();
});
it('renders the immutable GitHub deployment provenance in Chinese', async () => {
const deployment: PagesDeployment = {
id: 51,
project_id: 9,
deployment_number: 7,
checksum: 'd'.repeat(64),
status: 'active',
file_count: 12,
total_size: 4_096,
created_by: 'user:1',
source_type: 'github_release',
source_label: 'v1.2.3',
trigger_type: 'manual_sync',
created_at: '2026-07-19T10:00:00Z',
activated_at: '2026-07-19T10:00:01Z',
};
vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]);
renderWithQuery(
<DeploymentHistory projectId={9} activeDeploymentId={51} />,
);
expect(
await screen.findAllByText('GitHub · v1.2.3 · 手动同步'),
).toHaveLength(2);
expect(screen.getByText('Production')).toBeVisible();
expect(screen.getByText('All deployments')).toBeVisible();
});
});
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import {
validGitHubAssetName,
validGitHubReleaseTag,
validGitHubRepositoryURL,
} from '@/app/(main)/pages/detail/components/pages-source-validation';
describe('Pages GitHub source validation', () => {
it('accepts only canonical public GitHub repository URLs', () => {
expect(validGitHubRepositoryURL('https://github.com/acme/site')).toBe(true);
expect(validGitHubRepositoryURL('https://GitHub.com/acme/site.git')).toBe(
true,
);
const invalid = [
'http://github.com/acme/site',
'https://github.com//acme/site',
'https://github.com/acme/site/',
'https://github.com/acme/site/extra',
'https://github.com/acme/./site',
'https://github.com/acme/../site',
'https://github.com/acme/%73ite',
'https://github.com/acme/site?token=secret',
'https://github.com:443/acme/site',
String.raw`https://github.com/acme\site`,
];
for (const value of invalid) {
expect(validGitHubRepositoryURL(value), value).toBe(false);
}
});
it('mirrors Git ref rules while preserving legal release tag characters', () => {
const valid = [
'@',
'release/v1#stable&channel=prod',
'foo.LOCK',
'中文/发布=稳定',
];
for (const value of valid) {
expect(validGitHubReleaseTag(value), value).toBe(true);
}
const invalid = [
'',
'release v1',
'release~v1',
'release^v1',
'release:v1',
'release?v1',
'release*v1',
'release[v1',
String.raw`release\v1`,
'release..v1',
'release@{v1',
'release//v1',
'/release',
'release/',
'release.',
'.release',
'release/.candidate',
'release/v1.lock',
'release\nsecret',
'release\u2028secret',
'release\u202esecret',
'a'.repeat(256),
];
for (const value of invalid) {
expect(validGitHubReleaseTag(value), value).toBe(false);
}
});
it('preserves exact legal asset names and rejects path or display controls', () => {
const valid = [
'dist.zip',
'dist?channel=stable&part#1.zip',
' dist.zip ',
'build=production.zip',
];
for (const value of valid) {
expect(validGitHubAssetName(value), value).toBe(true);
}
const invalid = [
'',
'.',
'..',
'../dist.zip',
String.raw`dir\dist.zip`,
'dist\n.zip',
'dist\u2028.zip',
'dist\u202e.zip',
'a'.repeat(256),
'\ud800',
];
for (const value of invalid) {
expect(validGitHubAssetName(value), value).toBe(false);
}
});
});
+35 -5
View File
@@ -66,7 +66,7 @@ func DispatchTask(c *gin.Context) {
return
}
meta := task.GetTaskMeta(req.TaskType)
meta := getAdminTaskMeta(req.TaskType)
if meta == nil {
response.AbortBadRequest(c, InvalidTaskType)
return
@@ -202,7 +202,7 @@ func RetryTask(c *gin.Context) {
// ListSchedules 获取定时任务列表
// @Summary 获取定时任务列表
// @Description 返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限
// @Description 返回管理员可管理的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态;系统内部排程不会暴露,需要管理员权限
// @Tags admin
// @Produce json
// @Security SessionCookie
@@ -216,7 +216,15 @@ func ListSchedules(c *gin.Context) {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(schedules))
visible := make([]model.Schedule, 0, len(schedules))
for _, schedule := range schedules {
meta := task.GetTaskMeta(schedule.TaskType)
if meta != nil && meta.InternalOnly {
continue
}
visible = append(visible, schedule)
}
c.JSON(http.StatusOK, response.OK(visible))
}
// CreateScheduleRequest 创建定时任务请求
@@ -256,7 +264,7 @@ func CreateSchedule(c *gin.Context) {
}
// 校验关联的异步任务类型
meta := task.GetTaskMeta(req.TaskType)
meta := getAdminTaskMeta(req.TaskType)
if meta == nil {
response.AbortBadRequest(c, InvalidTaskType)
return
@@ -338,6 +346,10 @@ func UpdateSchedule(c *gin.Context) {
response.AbortNotFound(c, ScheduleNotFound)
return
}
if existingMeta := task.GetTaskMeta(schedule.TaskType); existingMeta != nil && existingMeta.InternalOnly {
response.AbortBadRequest(c, InvalidTaskType)
return
}
// 校验 Cron 表达式
if _, err := cron.ParseStandard(req.Cron); err != nil {
@@ -346,7 +358,7 @@ func UpdateSchedule(c *gin.Context) {
}
// 校验关联的异步任务类型
meta := task.GetTaskMeta(req.TaskType)
meta := getAdminTaskMeta(req.TaskType)
if meta == nil {
response.AbortBadRequest(c, InvalidTaskType)
return
@@ -382,6 +394,14 @@ func UpdateSchedule(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(schedule))
}
func getAdminTaskMeta(taskType string) *task.TaskMeta {
meta := task.GetTaskMeta(taskType)
if meta == nil || meta.InternalOnly {
return nil
}
return meta
}
// DeleteSchedule 删除定时任务
// @Summary 删除定时任务
// @Description 删除指定的定时任务配置,并触发调度器热加载,需要管理员权限
@@ -393,6 +413,7 @@ func UpdateSchedule(c *gin.Context) {
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "定时任务不存在"
// @Failure 500 {object} response.Any "删除定时任务失败"
// @Router /api/v1/admin/tasks/schedules/{id} [delete]
func DeleteSchedule(c *gin.Context) {
@@ -401,6 +422,15 @@ func DeleteSchedule(c *gin.Context) {
response.AbortBadRequest(c, "无效的定时任务ID")
return
}
schedule, err := model.GetScheduleByID(c.Request.Context(), id)
if err != nil {
response.AbortNotFound(c, ScheduleNotFound)
return
}
if meta := task.GetTaskMeta(schedule.TaskType); meta != nil && meta.InternalOnly {
response.AbortBadRequest(c, InvalidTaskType)
return
}
if err := model.DeleteSchedule(c.Request.Context(), id); err != nil {
response.AbortInternal(c, fmt.Sprintf("%s: %v", ScheduleDeleteFailed, err))
+236
View File
@@ -29,6 +29,20 @@ import (
"github.com/Rain-kl/Wavelet/internal/common/response"
)
const (
testInternalOnlyTaskType = "test_internal_only_admin"
testInternalOnlyAsynqTask = "test:internal_only_admin"
)
func registerInternalOnlyTaskMeta() {
task.RegisterTaskMeta(task.TaskMeta{
Type: testInternalOnlyTaskType,
AsynqTask: testInternalOnlyAsynqTask,
Name: "内部测试任务",
InternalOnly: true,
})
}
func setupTaskTestEnvironment(t *testing.T) func() {
_, mr, cleanup := testhelper.SetupTestEnvironment(t)
bootstrap.RegisterTasks()
@@ -61,12 +75,17 @@ func setupTestRouter(authUser *model.User) *gin.Engine {
adminGroup.GET("/tasks/executions", ListTaskExecutions)
adminGroup.GET("/tasks/executions/:id", GetTaskExecution)
adminGroup.POST("/tasks/executions/:id/retry", RetryTask)
adminGroup.GET("/tasks/schedules", ListSchedules)
adminGroup.POST("/tasks/schedules", CreateSchedule)
adminGroup.PUT("/tasks/schedules/:id", UpdateSchedule)
adminGroup.DELETE("/tasks/schedules/:id", DeleteSchedule)
return r
}
func TestListTaskTypes(t *testing.T) {
cleanup := setupTaskTestEnvironment(t)
defer cleanup()
registerInternalOnlyTaskMeta()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
@@ -93,6 +112,9 @@ func TestListTaskTypes(t *testing.T) {
foundCleanup := false
foundWarmImageCache := false
for _, m := range taskMetas {
if m.Type == testInternalOnlyTaskType {
t.Errorf("internal-only task type %s must not be listed", testInternalOnlyTaskType)
}
if m.Type == uploadtask.TaskTypeSystemCleanup {
foundCleanup = true
}
@@ -108,6 +130,220 @@ func TestListTaskTypes(t *testing.T) {
}
}
func TestInternalOnlyTaskAdminBoundaries(t *testing.T) {
cleanup := setupTaskTestEnvironment(t)
defer cleanup()
registerInternalOnlyTaskMeta()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
router := setupTestRouter(adminUser)
ctx := context.Background()
t.Run("list hides internal-only schedule", func(t *testing.T) {
internalSchedule := &model.Schedule{
Name: "隐藏的系统内部排程",
TaskType: testInternalOnlyTaskType,
Cron: "*/5 * * * *",
Payload: "{}",
IsActive: true,
}
publicSchedule := &model.Schedule{
Name: "可见的公开排程",
TaskType: uploadtask.TaskTypeSystemCleanup,
Cron: "0 * * * *",
Payload: "{}",
IsActive: true,
}
require.NoError(t, model.CreateSchedule(ctx, internalSchedule))
require.NoError(t, model.CreateSchedule(ctx, publicSchedule))
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/tasks/schedules", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp response.Any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
data, err := json.Marshal(resp.Data)
require.NoError(t, err)
var schedules []model.Schedule
require.NoError(t, json.Unmarshal(data, &schedules))
assert.NotContains(t, scheduleIDs(schedules), internalSchedule.ID)
assert.Contains(t, scheduleIDs(schedules), publicSchedule.ID)
})
t.Run("dispatch rejects internal-only task", func(t *testing.T) {
body, err := json.Marshal(DispatchTaskRequest{TaskType: testInternalOnlyTaskType})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/tasks/dispatch", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp response.Any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, InvalidTaskType, resp.ErrorMsg)
})
t.Run("create schedule rejects internal-only task", func(t *testing.T) {
isActive := true
body, err := json.Marshal(CreateScheduleRequest{
Name: "内部任务排程",
TaskType: testInternalOnlyTaskType,
Cron: "0 * * * *",
IsActive: &isActive,
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/tasks/schedules", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp response.Any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, InvalidTaskType, resp.ErrorMsg)
})
t.Run("update cannot convert existing internal schedule to public task", func(t *testing.T) {
schedule := &model.Schedule{
Name: "系统内部排程",
TaskType: testInternalOnlyTaskType,
Cron: "0 * * * *",
IsActive: true,
}
require.NoError(t, model.CreateSchedule(ctx, schedule))
isActive := false
body, err := json.Marshal(UpdateScheduleRequest{
Name: "尝试修改内部排程",
TaskType: uploadtask.TaskTypeSystemCleanup,
Cron: "5 * * * *",
IsActive: &isActive,
})
require.NoError(t, err)
req := httptest.NewRequest(
http.MethodPut,
fmt.Sprintf("/api/v1/admin/tasks/schedules/%d", schedule.ID),
bytes.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
unchanged, err := model.GetScheduleByID(ctx, schedule.ID)
require.NoError(t, err)
assert.Equal(t, "系统内部排程", unchanged.Name)
assert.Equal(t, testInternalOnlyTaskType, unchanged.TaskType)
assert.True(t, unchanged.IsActive)
})
t.Run("update public schedule rejects internal-only target task", func(t *testing.T) {
schedule := &model.Schedule{
Name: "公开排程",
TaskType: uploadtask.TaskTypeSystemCleanup,
Cron: "0 * * * *",
IsActive: true,
}
require.NoError(t, model.CreateSchedule(ctx, schedule))
isActive := true
body, err := json.Marshal(UpdateScheduleRequest{
Name: "尝试切入内部任务",
TaskType: testInternalOnlyTaskType,
Cron: "10 * * * *",
IsActive: &isActive,
})
require.NoError(t, err)
req := httptest.NewRequest(
http.MethodPut,
fmt.Sprintf("/api/v1/admin/tasks/schedules/%d", schedule.ID),
bytes.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
unchanged, err := model.GetScheduleByID(ctx, schedule.ID)
require.NoError(t, err)
assert.Equal(t, "公开排程", unchanged.Name)
assert.Equal(t, uploadtask.TaskTypeSystemCleanup, unchanged.TaskType)
})
t.Run("delete rejects internal-only schedule", func(t *testing.T) {
schedule := &model.Schedule{
Name: "不可删除的系统内部排程",
TaskType: testInternalOnlyTaskType,
Cron: "*/5 * * * *",
IsActive: true,
}
require.NoError(t, model.CreateSchedule(ctx, schedule))
req := httptest.NewRequest(
http.MethodDelete,
fmt.Sprintf("/api/v1/admin/tasks/schedules/%d", schedule.ID),
nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp response.Any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, InvalidTaskType, resp.ErrorMsg)
preserved, err := model.GetScheduleByID(ctx, schedule.ID)
require.NoError(t, err)
assert.Equal(t, testInternalOnlyTaskType, preserved.TaskType)
})
t.Run("delete missing schedule returns not found", func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/tasks/schedules/999999", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
var resp response.Any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, ScheduleNotFound, resp.ErrorMsg)
})
t.Run("delete public schedule remains allowed", func(t *testing.T) {
schedule := &model.Schedule{
Name: "可删除的公开排程",
TaskType: uploadtask.TaskTypeSystemCleanup,
Cron: "0 * * * *",
IsActive: false,
}
require.NoError(t, model.CreateSchedule(ctx, schedule))
req := httptest.NewRequest(
http.MethodDelete,
fmt.Sprintf("/api/v1/admin/tasks/schedules/%d", schedule.ID),
nil,
)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
_, err := model.GetScheduleByID(ctx, schedule.ID)
assert.Error(t, err)
})
}
func scheduleIDs(schedules []model.Schedule) []uint64 {
ids := make([]uint64, 0, len(schedules))
for _, schedule := range schedules {
ids = append(ids, schedule.ID)
}
return ids
}
func TestDispatchTask(t *testing.T) {
cleanup := setupTaskTestEnvironment(t)
defer cleanup()
+113 -18
View File
@@ -1,9 +1,13 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package httpclient provides an authenticated HTTP client for the agent.
package httpclient
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -13,6 +17,8 @@ import (
edgehttp "github.com/Rain-kl/Wavelet/internal/apps/edge/httpclient"
)
const pagesControlResponseMaxBytes = int64(64 * 1024)
// Client is a HTTP client used by the agent to communicate with the control plane server.
type Client struct {
base *edgehttp.Client
@@ -98,23 +104,43 @@ func (c *Client) GetPagesDeploymentHash(ctx context.Context, deploymentID uint)
return resp.Data.Hash, nil
}
// DownloadPagesDeploymentPackage downloads the deployment package for the given Pages deployment ID.
func (c *Client) DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error) {
res, err := c.base.DoRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/agent/pages/deployments/%d/package", deploymentID), nil)
if err != nil {
return nil, err
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
return nil, edgehttp.ReadHTTPError(res)
}
return io.ReadAll(res.Body)
// DownloadPagesDeploymentPackage streams the deployment package into dst while
// enforcing maxBytes against both advertised and actual response sizes.
func (c *Client) DownloadPagesDeploymentPackage(
ctx context.Context,
deploymentID uint,
dst io.Writer,
maxBytes int64,
) (int64, error) {
return c.downloadPagesPackage(
ctx,
fmt.Sprintf("/api/v1/agent/pages/deployments/%d/package", deploymentID),
dst,
maxBytes,
)
}
// GetPagesProjectLatestHash returns the active deployment package hash for a Pages project.
func (c *Client) GetPagesProjectLatestHash(ctx context.Context, projectID uint) (*protocol.PagesProjectLatestHashResponse, error) {
res, err := c.base.DoRaw(
ctx,
http.MethodGet,
fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/hash", projectID),
nil,
)
if err != nil {
return nil, err
}
defer func() { _ = res.Body.Close() }()
body, err := readPagesControlResponse(res, pagesControlResponseMaxBytes)
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK {
return nil, edgehttp.ReadBodyError(body, res.Status)
}
resp := protocol.APIResponse[protocol.PagesProjectLatestHashResponse]{}
if err := c.base.GetJSON(ctx, fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/hash", projectID), &resp); err != nil {
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
if err := edgehttp.APIError(resp.ErrorMsg); err != nil {
@@ -123,17 +149,86 @@ func (c *Client) GetPagesProjectLatestHash(ctx context.Context, projectID uint)
return &resp.Data, nil
}
// DownloadPagesProjectLatestPackage downloads the active deployment package for a Pages project.
func (c *Client) DownloadPagesProjectLatestPackage(ctx context.Context, projectID uint) ([]byte, error) {
res, err := c.base.DoRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/package", projectID), nil)
// DownloadPagesProjectLatestPackage streams the active deployment package into
// dst while enforcing maxBytes against both advertised and actual sizes.
func (c *Client) DownloadPagesProjectLatestPackage(
ctx context.Context,
projectID uint,
dst io.Writer,
maxBytes int64,
) (int64, error) {
return c.downloadPagesPackage(
ctx,
fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/package", projectID),
dst,
maxBytes,
)
}
func (c *Client) downloadPagesPackage(
ctx context.Context,
path string,
dst io.Writer,
maxBytes int64,
) (int64, error) {
if dst == nil {
return 0, errors.New("pages package destination is required")
}
if maxBytes <= 0 {
return 0, errors.New("pages package byte limit must be positive")
}
res, err := c.base.DoRaw(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
return 0, err
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
return nil, edgehttp.ReadHTTPError(res)
body, readErr := readPagesControlResponse(res, pagesControlResponseMaxBytes)
if readErr != nil {
return 0, readErr
}
return 0, edgehttp.ReadBodyError(body, res.Status)
}
return io.ReadAll(res.Body)
return copyPagesPackageResponse(dst, res, maxBytes)
}
func readPagesControlResponse(res *http.Response, maxBytes int64) ([]byte, error) {
if res.ContentLength > maxBytes {
return nil, fmt.Errorf(
"pages control response Content-Length %d exceeds limit %d",
res.ContentLength,
maxBytes,
)
}
limited := &io.LimitedReader{R: res.Body, N: maxBytes + 1}
body, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("read pages control response: %w", err)
}
if int64(len(body)) > maxBytes {
return nil, fmt.Errorf("pages control response body exceeds limit %d", maxBytes)
}
return body, nil
}
func copyPagesPackageResponse(dst io.Writer, res *http.Response, maxBytes int64) (int64, error) {
if res.ContentLength > maxBytes {
return 0, fmt.Errorf(
"pages package Content-Length %d exceeds limit %d",
res.ContentLength,
maxBytes,
)
}
limited := &io.LimitedReader{R: res.Body, N: maxBytes + 1}
written, err := io.Copy(dst, limited)
if err != nil {
return written, fmt.Errorf("stream pages package: %w", err)
}
if written > maxBytes {
return written, fmt.Errorf("pages package body exceeds limit %d", maxBytes)
}
return written, nil
}
// SetToken updates the authentication token used for API requests.
@@ -0,0 +1,109 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package httpclient
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestDownloadPagesProjectLatestPackageRejectsChunkedBodyOverLimit(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
_, _ = io.WriteString(w, "123456")
}))
defer server.Close()
client := New(server.URL, "test-token", time.Second)
var dst bytes.Buffer
written, err := client.DownloadPagesProjectLatestPackage(
context.Background(),
7,
&dst,
4,
)
if err == nil || !strings.Contains(err.Error(), "body exceeds limit") {
t.Fatalf("DownloadPagesProjectLatestPackage(chunked, limit=4) error = %v, want body limit error", err)
}
if written != 5 {
t.Errorf("DownloadPagesProjectLatestPackage(chunked, limit=4) written = %d, want 5", written)
}
}
func TestCopyPagesPackageResponseRejectsAdvertisedContentLengthBeforeWrite(t *testing.T) {
response := &http.Response{
Body: io.NopCloser(strings.NewReader("123456")),
ContentLength: 6,
}
var dst bytes.Buffer
written, err := copyPagesPackageResponse(&dst, response, 4)
if err == nil || !strings.Contains(err.Error(), "Content-Length") {
t.Fatalf("copyPagesPackageResponse(Content-Length=6, limit=4) error = %v, want Content-Length limit error", err)
}
if written != 0 || dst.Len() != 0 {
t.Errorf("copyPagesPackageResponse(Content-Length=6, limit=4) wrote (%d, %d buffered), want no writes", written, dst.Len())
}
}
func TestCopyPagesPackageResponseRejectsForgedSmallContentLength(t *testing.T) {
response := &http.Response{
Body: io.NopCloser(strings.NewReader("123456")),
ContentLength: 2,
}
var dst bytes.Buffer
written, err := copyPagesPackageResponse(&dst, response, 4)
if err == nil || !strings.Contains(err.Error(), "body exceeds limit") {
t.Fatalf("copyPagesPackageResponse(forged Content-Length=2, limit=4) error = %v, want body limit error", err)
}
if written != 5 {
t.Errorf("copyPagesPackageResponse(forged Content-Length=2, limit=4) written = %d, want 5", written)
}
}
func TestDownloadPagesProjectLatestPackageBoundsChunkedErrorResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
_, _ = io.WriteString(w, strings.Repeat("x", int(pagesControlResponseMaxBytes+1)))
}))
defer server.Close()
client := New(server.URL, "test-token", time.Second)
var dst bytes.Buffer
_, err := client.DownloadPagesProjectLatestPackage(context.Background(), 7, &dst, 1024)
if err == nil || !strings.Contains(err.Error(), "control response body exceeds limit") {
t.Fatalf("DownloadPagesProjectLatestPackage(large chunked 400) error = %v, want bounded response error", err)
}
if dst.Len() != 0 {
t.Errorf("DownloadPagesProjectLatestPackage(large chunked 400) wrote %d package bytes, want 0", dst.Len())
}
}
func TestGetPagesProjectLatestHashBoundsChunkedMetadataResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
_, _ = io.WriteString(w, strings.Repeat("x", int(pagesControlResponseMaxBytes+1)))
}))
defer server.Close()
client := New(server.URL, "test-token", time.Second)
_, err := client.GetPagesProjectLatestHash(context.Background(), 7)
if err == nil || !strings.Contains(err.Error(), "control response body exceeds limit") {
t.Fatalf("GetPagesProjectLatestHash(large chunked metadata) error = %v, want bounded response error", err)
}
}

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