Compare commits

...

84 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
ryan 24862dcbed chore(release): v3.4.0
### 🛠 修复
- 修复了访问日志概览按域名筛选无效的问题,现已兼容 hosts 与 hosts[] 参数。
- 修复了 Agent 观测缓冲合并访问日志时忽略 cache_status 导致缓存状态被去重丢弃的问题。
- 修复了访问日志概览在 ClickHouse 查询失败时静默吞错的问题,现会输出错误日志便于排查。
- 修复了数据看板业务流量趋势与已提供数据口径不一致的问题,业务量统一由访问日志聚合。
- 修复了节点地图在缺少精确经纬度时,把香港/新加坡/台湾等地区错误标到占位坐标的问题。

### ⚡️ 优化与改进
- 访问日志重构为概览、IP 明细与日志明细:支持时间窗聚合 IP 请求数/2xx 比例/入出站流量与详情分析,明细展示完整请求字段。
- 边缘访问日志支持 User-Agent 与 cache_status(命中/回源/未缓存),概览增加设备/浏览器/系统与状态码分布。
- 新建站点开启缓存时推荐仅缓存标准静态资源(不含 HTML);存量空策略与按 URL 行为保留为所有可缓存 GET。
- 边缘观测以访问日志为业务唯一真相;Agent 仅上报明细与主机读数,升级需重建或替换 Agent。
- Pages 支持更多压缩格式上传、URL 导入部署包,以及可配置的包大小与历史保留策略。

### 💄 其他/体验
- 优化了访问日志排行榜与饼图布局,页签状态支持 URL 参数记忆。
- 启用 cache_status 与边缘缓存策略变更后,需执行相关迁移并重新发布节点配置。
2026-07-19 10:45:40 +08:00
ryan 920a530aa7 feat(access-logs): 新增 IP 明细 Tab 并完善日志详情字段
按时间窗聚合 IP 请求数/2xx 比例/入出站流量,支持排序与详情分析;
日志明细详情仅展示请求业务字段,IP 情报迁至独立详情弹窗。
2026-07-19 00:42:59 +08:00
ryan bfd9de69af fix(access-logs): 修复概览域名筛选参数 hosts[] 被 Gin 忽略
Axios 默认序列化为 hosts[]=,Gin QueryArray("hosts") 读不到导致筛选失效;
后端兼容 hosts/hosts[],前端改为重复键序列化。
2026-07-19 00:28:37 +08:00
ryan 204f6d9a8b fix(cache): 存量空/url 策略规范为 all,避免静默收窄
评审修复:enabled 且 policy 为空或 url 时,写入/展示/快照/渲染均映射为 all,
保证旧站点宽缓存范围不变;新建 UI 仍显式提交 static 作为推荐默认。
2026-07-19 00:02:52 +08:00
ryan 04f029c705 feat(cache): 开启缓存默认仅缓存标准静态资源
路由缓存策略新增 static(内置扩展名,不含 HTML)与 all;
存量 url 规范为 all。OpenResty 渲染与代理路由 UI 同步。
2026-07-18 23:32:49 +08:00
ryan 7401f5d0b4 docs(design): 边缘缓存默认可缓存范围对标 Cloudflare
约定开启缓存默认 static 扩展名策略,存量 url 映射为 all,
并明确第一期不做 Edge TTL/Purge/Cache Rules。
2026-07-18 23:24:23 +08:00
ryan 0bb6830047 feat(access-logs): 概览支持 Zone/域名多选筛选
概览可按 Zone→Domain 层级多选域名并折叠展开;明细列表 IP 旁展示地区。
后端 overview 支持 hosts 多域名精确匹配。
2026-07-18 23:07:19 +08:00
ryan 6f221b042e fix(agent): 观测缓冲去重纳入 cache_status 并保留原始 -
避免同一请求不同缓存状态被合并丢弃;OpenResty 的 - 原样入库便于详情区分。
2026-07-18 22:56:10 +08:00
ryan fb5a4e5b59 feat(access-logs): 上报并展示边缘缓存状态 cache_status
OpenResty 日志输出 $upstream_cache_status;Agent/协议/ClickHouse 贯通入库。
明细列表与详情按 HIT/MISS 等推导命中、回源、未缓存三态标签。
2026-07-18 22:50:46 +08:00
ryan ee9d651c8a docs(obs): 约定访问日志 cache_status 与明细三态展示
仅上报 $upstream_cache_status,不上报回源地址;UI 由原始值推导
命中缓存 / 回源 / 未使用缓存。
2026-07-18 22:46:46 +08:00
ryan 9aec984bee feat(access-logs): 明细详情支持 IP 分析与 URL Tab 记忆
- 新增单 IP 分析接口,趋势时间范围支持至 30 天
- 明细详情弹窗展示趋势、汇总与 Top 分布,可快捷管理 IP 组
- 访问日志页签改为 URL 参数记忆,筛选后保持当前 Tab
2026-07-18 22:40:21 +08:00
ryan bf71bc540b feat(access-logs): 优化概览饼图布局并在查询出错时增加日志记录
- 将设备类型与状态码饼图的断点由 xl 降为 lg,在大屏/笔记本视口下保持双列展示
- 修复 valueCountDistribution 在 ClickHouse 查询出错时静默吞掉错误的缺陷,引入 logger.ErrorF 捕获
- 补充 unreleased 变更日志
2026-07-18 22:02:09 +08:00
ryan e49078ac3b feat(access-logs): 接入 User-Agent 与设备/浏览器/状态码分布
- Agent 访问日志上报 user_agent,OpenResty log_format 输出 http_user_agent
- of_node_access_logs 新增 user_agent 列并写入 ClickHouse
- 访问日志概览新增:设备类型饼图、状态码饼图、浏览器/OS/User-Agent 排行
- 日志明细列表增加 User-Agent 列
- 扩展 UA 解析工具(browser/os/device)并支持 CLI 识别
2026-07-18 21:18:59 +08:00
ryan 177578ef4e feat(access-logs): 重构访问日志为概览与明细双 Tab
新增访问日志概览 API 与前端页面:汇总请求量/访问量/带宽趋势与 Top 排行,
明细列表保留检索;排行榜改为紧凑列表样式。
2026-07-18 20:58:32 +08:00
ryan 4c0c389122 chore(release): v3.3.1
### 🛠 修复
- 修复了看板业务流量趋势与 Zone 已提供数据口径不一致的问题,业务量统一由访问日志聚合,避免边缘预聚合窗口差分导致近 24 小时趋势严重偏低。
- 修复了节点地图在缺少精确经纬度时,将香港、新加坡、台湾等地区错误回退到南美等占位坐标的问题,补全质心数据并改进复合地名匹配。

### ⚡️ 优化与改进
- 重构边缘可观测模型:访问日志为业务唯一真相,Agent 仅上报明细、主机指标与 OpenResty 健康连接;新增 edge_health 与 access_log_hourly,删除请求预聚合与 OpenResty 吞吐路径。
- 协议去掉旧兼容层,Agent 升级需销毁重建或二进制替换;旧本地观测缓冲会自动删除并在运行中重建。
- 调整 Agent 默认心跳为 3 秒、离线判定为 60 秒、离线补传窗口为 60 分钟,使节点状态与观测数据刷新更及时。
- 看板 UV 改为窗口内真正去重,Zone 分桶 UV 明确不可跨桶相加;网络趋势仅保留已提供/接收数据,磁盘读写改为按秒速率展示。
- Pages 支持多压缩格式上传、URL 导入部署包,以及可配置的包大小上限与历史保留数量;边缘按项目只保留最新激活部署,切换版本无需重发主配置。
- 新建代理规则时可选择直连、隧道或 Pages 源站类型,与详情页一致。
- 优化 Pages 部署包校验性能,不再为包内每个文件计算哈希,改由整包校验和保障完整性。

### 💄 其他/体验
- 更新可观测设计文档与运维说明,明确健康状态权威源与升级策略。
- 同步 Swagger 与变更日志,便于对照 API 与发布说明。
2026-07-18 16:37:20 +08:00
ryan a0ccafc6ee fix(geo): 修复香港等节点地图质心缺失落到南美占位
补全 Hong Kong/Singapore/Taiwan 质心数据,并改进复合地名与 ISO 匹配,
避免 geo 无精确经纬度时错误回退到巴西等地的占位坐标。
2026-07-18 13:52:20 +08:00
ryan 26057514a1 feat(obs): 去掉宿主机网卡趋势,磁盘读写改按速率展示
Agent 不再采集网卡累计字节,看板与节点网络图仅保留访问日志已提供/接收。
磁盘 IO 按小时换算为 B/s 曲线,摘要为近 24 小时平均速率,并同步 Swagger。
2026-07-18 13:17:13 +08:00
ryan 55f8c9a527 fix(obs): 对齐无兼容层与健康/UV 权威语义
Agent 本地旧观测缓冲直接删除并运行重建;设计文档去掉兼容期表述。
健康当前态以 PG status/message 为准,CH 仅存 status 与连接时序;
Zone 曲线标明分桶 UV,顶部为整窗独立访客。
2026-07-18 12:09:27 +08:00
ryan 802d516f5b docs: 观测重构 changelog 与 Swagger 同步
补充 unreleased 变更说明,并重新生成 Swagger 以匹配去兼容层后的 API。
2026-07-18 11:53:11 +08:00
ryan 71ce028f91 feat(frontend): 观测网络字节字段与 UV 文案对齐
网络图仅使用 bytes_provided/received;看板与节点 UV 改为 24h/查询窗口
独立访客;清理 traffic_reports 与 openresty 吞吐兼容字段;运维清理目标
改为 node_edge_health。
2026-07-18 11:53:11 +08:00
ryan f0e234df1f feat(obs): 访问日志 SSOT 与 edge_health,去掉协议兼容层
Agent 仅上报 host_metrics/edge_health/access_logs;业务流量与 UV 由
Server 侧访问日志聚合。新增 of_node_edge_health 与 of_access_log_hourly,
删除 request_reports/openresty 吞吐路径;API 不再暴露 traffic_reports
与 openresty_rx|tx。心跳/离线默认阈值与回填迁移一并入库。
2026-07-18 11:53:11 +08:00
ryan 9a0974cce8 docs(obs): 边缘可观测 SSOT 设计与实施计划
补充 observability 设计/数据模型/传输模型,更新架构与 Agent 文档侧栏,
并写入 M5 迁移与小时汇总回填运维说明。
2026-07-18 11:53:11 +08:00
ryan 3b9f4daa4e perf(pages): skip per-file hashes during package inspect
Inspect deployment archives via file handles and declared sizes instead of loading the whole package and hashing every member, while keeping whole-package checksums for Agent integrity checks.
2026-07-17 18:37:34 +08:00
ryan 285f127d48 feat(proxy-routes): align create form with upstream type selection
Let new proxy rules choose direct, tunnel, or Pages origin the same way as the detail reverse-proxy section, instead of only accepting a single upstream URL.
2026-07-17 18:20:35 +08:00
ryan 79820b33eb feat(pages): import deployment packages from URL
Add upload-from-url so admins can paste an HTTP(S) link and let the control
plane download the archive with browser-like headers. Private/LAN hosts and
insecure TLS certificates are allowed for internal artifact stores; package
size and format checks reuse the existing local-upload pipeline.
2026-07-17 17:55:12 +08:00
ryan ce736e2de4 feat(pages): pull latest by project and keep a single edge release
Agents now treat pages_project_id as the stable anchor and fetch the
control-plane active package via project latest APIs, so activating a
deployment updates edges without republishing main config. Local roots use
projects/{id}/current, only the newest release is retained after a successful
switch, hash/package races retry, and per-project failures no longer block
siblings.
2026-07-17 17:43:40 +08:00
ryan a0fcf9f627 feat(pages): configurable limits, multi-format packages, and dual versioning
Make Pages package size and history retention system-configurable, support
zip/tar.gz/tar.xz/tar.bz2/tar/7z uploads, prune history with clear keep-N
semantics, and rebind agent config to the live active Pages deployment so
main-config rollback never depends on pruned packages.
2026-07-17 17:16:48 +08:00
334 changed files with 45925 additions and 6054 deletions
+54 -4
View File
@@ -7,7 +7,18 @@ description: "Wavelet 项目专用:根据自上一个正式版本 Tag 以来
## 目标
当用户准备发布 Wavelet 新版本时,本 Skill 只负责生成用于版本提交的 Commit Message。
当用户准备发布 Wavelet 新版本时,本 Skill 负责:
1. 根据上一正式版本 Tag 以来的提交,整理面向用户的发版说明;
2. 新建 **独立的** `chore(release): vX.Y.Z` 提交(可附带将 `docs/changelog` 从 `[unreleased]` 落版)。
## 硬性约束(禁止改写历史)
- **禁止** `git commit --amend` 修改任何**已经 push 到远端**的提交。
- **禁止** 为了发版去改写已有功能/修复提交的 message 或内容。
- **禁止** 发版流程中的 force-push(除非用户明确要求且知晓后果)。
- 发版提交必须是 **新增 commit**:在当前 `HEAD` 之上 `git commit` 一次。
- 默认 **不要 push、不要打 tag**;生成并完成本地 release commit 后,把后续 `push` / `git tag` 命令交给用户确认执行。
## 生成提交信息
@@ -26,7 +37,11 @@ description: "Wavelet 项目专用:根据自上一个正式版本 Tag 以来
固定使用以下分类:
text ### 🛠 修复 ### ⚡️ 优化与改进 ### 💄 其他/体验
```text
### 🛠 修复
### ⚡️ 优化与改进
### 💄 其他/体验
```
分类规则:
@@ -37,6 +52,8 @@ text ### 🛠 修复 ### ⚡️ 优化与改进 ### 💄 其他/体验
示例:
```
chore(release): v3.3.0
### 🛠 修复
- 修复了通过 MCP 接口操作时笔记库范围限制未正确生效的问题。
- 修复了 MCP 接口返回数据格式不一致的问题。
@@ -49,6 +66,39 @@ text ### 🛠 修复 ### ⚡️ 优化与改进 ### 💄 其他/体验
- 优化了 WebSocket 错误日志,增加请求路径信息,方便问题排查。
```
## 提交
## 提交步骤
生成完 Commit Message 后调用命令工具修改最后一次提交信息, 不要提交, 不要推送, 你的任务到此结束。
1. 确认工作区干净,且 `HEAD` 与将要发布的代码一致(通常已与 `origin/main` 对齐或仅含未 push 的合法新提交)。
2. 将 `docs/changelog/index.md` 中 `[unreleased]` 落版为 `[vX.Y.Z] - YYYY-MM-DD`(按需整理条目)。
3. **新建** release 提交(不要 amend):
```bash
git add docs/changelog/index.md # 及其他发版所需文件
git commit -m "$(cat <<'EOF'
chore(release): vX.Y.Z
### 🛠 修复
- ...
### ⚡️ 优化与改进
- ...
### 💄 其他/体验
- ...
EOF
)"
```
4. 向用户展示完整 commit message,并说明后续可由用户执行:
```bash
git push origin main
git tag vX.Y.Z
git push origin vX.Y.Z
```
(打 tag 后由 CI 创建双语 Release。)
## 任务结束条件
本地已存在 **新的** `chore(release): vX.Y.Z` 提交,且**未**改写任何已 push 提交、**未**擅自 push/tag。
+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
+84 -4
View File
@@ -11,16 +11,98 @@ 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
### 新增
- 访问日志重构为「概览」「IP 明细」与「日志明细」:概览含请求量/访问量/带宽趋势与 Top 排行;IP 明细可按时间窗查看请求数、2xx 比例、入出站流量并支持详情分析;日志明细展示完整请求字段。
- 边缘访问日志支持 User-Agent 与 `cache_status`(命中/回源/未缓存);概览新增设备类型、浏览器、操作系统与状态码分布。
- 访问日志概览支持按 Zone/域名多选筛选;明细列表在 IP 旁展示地区信息。
- 新建站点开启缓存时推荐「标准静态资源」(不含 HTML);原按 URL/空策略存量行为保留为「所有可缓存 GET」。
- Pages 现支持上传 zip、tar.gz、tar.xz、tar.bz2、tar、7z 等常用压缩格式的部署包。
- 管理员可在运维设置中配置 Pages 部署包大小上限与每个项目的历史部署保留数量。
- Pages 支持从 URL 导入部署包:填写下载链接后由控制面代为拉取并创建部署。
- 观测存储新增 `of_node_edge_health` 与 `of_access_log_hourly`,业务趋势优先读访问日志小时汇总。
- 访问日志增加 `request_length` / `request_time_ms`,用于接收数据与耗时统计。
### 修复
- 修复访问日志概览按域名筛选无效的问题,现已兼容 `hosts` / `hosts[]` 参数。
- 修复 Agent 观测缓冲合并访问日志时忽略 `cache_status` 导致缓存状态被去重丢弃的问题。
- 修复访问日志概览在 ClickHouse 查询失败时静默吞错的问题,现会输出错误日志。
- 修复数据看板业务流量趋势与已提供数据口径不一致的问题:业务量统一由访问日志聚合。
- 修复节点地图在缺少精确经纬度时,把香港/新加坡/台湾等地区错误标到占位坐标的问题。
### 变更
- 边缘观测改为「访问日志为业务唯一真相」:Agent 仅上报明细、主机指标与 OpenResty 健康/连接;协议去掉旧兼容字段,**升级需重建或替换 Agent**。
- Agent 默认心跳改为 3 秒、离线判定 60 秒,离线补传窗口默认 60 分钟。
- 看板 UV 使用窗口内真正去重;Zone 曲线标明分桶 UV;磁盘读写改为按小时速率(B/s)展示。
- 不再采集或展示宿主机网卡入/出站;网络趋势仅保留访问日志已提供/接收数据。
- Pages 包大小与历史保留可配置,边缘按项目只保留最新激活部署;创建规则表单与详情一致支持直连/隧道/Pages 源站类型。
- 优化 Pages 部署包校验性能:不再为包内每个文件计算哈希,整包校验和保障完整性。
- 优化访问日志排行榜与饼图布局;页签状态支持 URL 参数记忆。
- 启用 `cache_status` 与边缘缓存策略变更需执行相关迁移并重新发布节点配置。
### 移除
- 移除请求预聚合表与 OpenResty 吞吐观测相关路径;管理端不再返回 `traffic_reports` 与 `openresty_rx|tx`。
- 访问日志已移除时间折叠视图;IP 情报从日志明细详情迁出至 IP 明细。
## [v3.3.0] - 2026-07-14
### 新增
@@ -39,7 +121,6 @@ sidebar: false
- 移除了 WAF 旧版固定名单与人机验证配置;升级后请在发布前使用新的可视化规则重新编排防护策略。
## [v3.2.0] - 2026-07-12
### 新增
@@ -126,7 +207,6 @@ sidebar: false
- 网站管理拆分为域名、路由、静态托管、WAF 和缓存等独立能力,更适合维护复杂站点配置。
- Tunnel 节点统一纳入节点管理,配置发布和运行状态查看更加一致。
## [v2.3.4] - 2026-06-17
### 变更
+4
View File
@@ -132,6 +132,10 @@ function sidebarDesign(): DefaultTheme.SidebarItem[] {
{ text: 'WAF 设计', link: 'waf-design' },
{ text: 'WAF 可编排规则设计', link: 'waf-orchestration-design' },
{ text: 'Pages 静态托管设计', link: 'pages-design' },
{ text: '边缘缓存策略设计', link: 'edge-cache-design' },
{ text: '边缘可观测与业务流量统计', link: 'observability-design' },
{ text: '观测数据传输模型', link: 'observability-transport-model' },
{ text: '观测上报协议与表结构', link: 'observability-data-model' },
{ text: 'Uptime Kuma 监控同步设计', link: 'kuma-design' },
{ text: '登录验证码设计', link: 'login-captcha' }
]
+8 -3
View File
@@ -90,8 +90,8 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
"data_dir": "./data",
"openresty_path": "openresty",
"openresty_observability_port": 18081,
"observability_replay_minutes": 15,
"heartbeat_interval": 10000,
"observability_replay_minutes": 60,
"heartbeat_interval": 3000,
"request_timeout": 10000
}
```
@@ -110,7 +110,7 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
"cert_dir": "/var/lib/openflare-agent/etc/nginx/certs",
"lua_dir": "/var/lib/openflare-agent/etc/nginx/lua",
"runtime_config_dir": "/var/lib/openflare-agent/etc/openflare",
"heartbeat_interval": 10000,
"heartbeat_interval": 3000,
"request_timeout": 10000
}
```
@@ -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` 运行。
+4 -1
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 特权端口。
@@ -194,7 +197,7 @@ export LOG_LEVEL='info'
"agent_token": "replace-with-node-auth-token",
"data_dir": "./data",
"openresty_path": "openresty",
"heartbeat_interval": 10000,
"heartbeat_interval": 3000,
"request_timeout": 10000
}
```
+4 -2
View File
@@ -27,7 +27,7 @@ Agent 主要由以下核心子模块组成,共同配合完成其完整的生
| **OpenResty 管控** | `nginx/` | 执行 Nginx 配置校验 (`openresty -t`)、重写、平滑重载 (`reload`) 及进程自启动。 |
| **本地状态库** | `state/` | 持久化记录本地应用版本、错误日志及未成功上报的可观测性指标缓冲。 |
| **自更新服务** | `updater/` | 监听 Server 自更新指令,安全拉取新版本二进制并完成原地热升级。 |
| **可观测性** | `observability/` | 采集系统宿主机 CPU/内存/磁盘及 Nginx 性能指标,处理访问日志并上报。 |
| **可观测性** | `observability/` | 采集宿主机资源读数、OpenResty 健康/连接,并 tail 访问日志明细上报;**不做** UV/TopN/吞吐等业务预聚合。详见 [边缘可观测与业务流量统计](./observability-design.md)。 |
| **GeoIP 维护** | `geoipdata/` `geoipupdate/` | 维护并定期更新本地 GeoIP 数据库,为 WAF 地域过滤提供支撑。 |
---
@@ -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 会下载部署 zip、校验 checksum、解压到部署 release 目录,并切换 `deployments/{deployment_id}/current` 供 OpenResty `root`/`try_files` 读取。
* `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` 临时目录下,保留完整的现场快照。
@@ -173,3 +173,5 @@ graph TD
1. **零特权指令通道**:Server 绝对禁止向 Agent 传递任何任意 shell 命令或远程执行脚本(如 exec/eval 等)。所有系统控制原语(如启动、停止、重载、更新)必须硬编码在 Agent 二进制内部。
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 命令,只拉取已经激活且带完整性元数据的部署包。
+29 -6
View File
@@ -65,8 +65,8 @@ OpenResty (Agent, TLS/WAF)
| 组件 | 职责 | 详细设计参考 |
| --------------- | ---------------------------------------------------------------------- | ------------ |
| **Server** | 管理端 UI/API、控制面状态持久化、配置编译渲染、发布版本控制、Pages 部署包存储、Uptime Kuma 监控同步与登录验证码防护 | [Agent 与发布模型](./agent-design.md) / [Uptime Kuma 监控同步设计](./kuma-design.md) / [登录验证码设计](./login-captcha.md) |
| **Agent** | 周期心跳与 WS 同步、静态资源包拉取与解压、OpenResty 配置写入/校验/重载与自愈 | [Agent 与发布模型](./agent-design.md) |
| **Server** | 管理端 UI/API、控制面状态持久化、配置编译渲染、发布版本控制、Pages 部署包存储、访问日志入库与业务流量聚合、Uptime Kuma 监控同步与登录验证码防护 | [Agent 与发布模型](./agent-design.md) / [边缘可观测与业务流量统计](./observability-design.md) / [Uptime Kuma 监控同步设计](./kuma-design.md) / [登录验证码设计](./login-captcha.md) |
| **Agent** | 周期心跳与 WS 同步、静态资源包拉取与解压、OpenResty 配置写入/校验/重载与自愈;观测仅上报访问明细与主机/健康读数,不做业务预聚合 | [Agent 与发布模型](./agent-design.md) / [边缘可观测与业务流量统计](./observability-design.md) |
| **OpenResty** | 接收真实流量,执行 WAF 过滤、PoW 防护、Basic Auth 认证与静态/反代服务 | [WAF 设计](./waf-design.md) / [Pages 设计](./pages-design.md) |
| **Relay** | 部署于边缘节点,管理 `frps` 守护进程生命周期,接受心跳派发的穿透中继配置 | [内网穿透设计](./tunnel-design.md) |
| **OpenFlared** | 部署于内网,管理 `frpc` 进程组,向多个 Relay 建立反向隧道,上报连接状态 | [内网穿透设计](./tunnel-design.md) |
@@ -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)*
@@ -95,7 +95,7 @@ OpenResty (Agent, TLS/WAF)
### 3. OpenResty (数据面)
接收访客流量并执行最终的业务落地:
* 流量入口,支持 HTTP/2、HTTP/3(QUIC)和 TLS 证书动态绑定。
* 嵌入 Lua 逻辑,在 `access_by_lua` 阶段高效过滤 WAF 规则、验证工作量证明 (PoW) 挑战,并在此之后执行连接数/速率限制及基础缓存。
* 嵌入 Lua 逻辑,在 `access_by_lua` 阶段高效过滤 WAF 规则、验证工作量证明 (PoW) 挑战,并在此之后执行连接数/速率限制及基础缓存(策略见 [边缘缓存策略设计](./edge-cache-design.md))。
* *详细设计请参阅:[WAF 设计文档](./waf-design.md) 与 [Pages 静态托管设计文档](./pages-design.md)*
### 4. Relay 与 OpenFlared (穿透组件)
@@ -124,8 +124,9 @@ OpenResty (Agent, TLS/WAF)
* *同步与自愈的精细时序及回滚模型详见:[Agent 与发布模型设计](./agent-design.md)*
### 2. 静态托管与 API 代理流
* 静态资源解压落地于 Agent 节点的 `deployments/{id}/current` 下,OpenResty 通过 `root`/`index`/`try_files` 指令在边缘直接向访客提供极低延迟的静态资源服务。
* 静态资源解压落地于 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 安全过滤流
@@ -135,6 +136,24 @@ OpenResty (Agent, TLS/WAF)
* IP 组成员独立热更新:协调 Worker 每 5 秒检查一次 checksum,仅在变化时加载完整快照,各 Worker 的请求路径始终读取本地内存对象。
* *IP 组来源与同步机制详见:[WAF 设计文档](./waf-design.md);图模型、执行语义与发布约束详见:[WAF 可编排规则设计](./waf-orchestration-design.md)。*
### 4. 边缘可观测与业务流量统计流
```text
OpenResty access.log(业务事实)
|
| Agent tail 增量明细(不 sum/count/uniq)
v
Server 入库 ClickHouse
|
+---> 全局聚合 --> 看板「已提供数据 / 请求 / UV」
+---> host∈Zone --> Zone「已提供数据」等(同一套语义)
+---> node_id 过滤 --> 节点业务量
主机 /proc 网卡与 CPU 等 --> Agent 读数快照 --> 宿主机资源趋势(与业务交付分开展示)
OpenResty 健康与连接数 --> 边缘健康(瞬时,不作 24h 业务总量)
```
* **原则**:Agent 只上报事实,Server 解释事实;业务流量唯一真相为访问日志。`openresty_tx` 与「已提供数据」不得双轨并存。
* *传输模型、示例与采集频率详见:[观测数据传输模型](./observability-transport-model.md);字段收敛与迁移详见:[边缘可观测与业务流量统计](./observability-design.md)*
---
## 核心对象
@@ -142,7 +161,7 @@ OpenResty (Agent, TLS/WAF)
当前系统核心实体包括:
* **反代与配置**:`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更新配置).
@@ -159,6 +178,9 @@ OpenResty (Agent, TLS/WAF)
| Zone 域名与路由策略分离 | Zone 提供根域入口与域名边界;路由仍可复用同一套站点级策略并按域名绑定证书 |
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道引起稳定性风险;其 Vhost 机制天然适配反代路由 |
| 运行时配置与控制库解耦 | WAF 规则发布时编译并随 OpenResty reload 加载;动态 IP 组通过 checksum 驱动的内存快照独立刷新 |
| 业务流量以访问日志为唯一真相 | Agent 禁止业务预聚合;看板与 Zone 共用 Server 侧聚合,避免 openresty_tx 与 bytes_sent 双轨 |
| 业务交付 / 边缘健康 / 主机资源分层 | 已提供数据≠宿主机网卡出站≠OpenResty 连接数,UI 与 API 分名分区 |
| Pages artifact 与仓库构建分离 | 现有来源只导入预构建产物;未来 checkout/build 由 Server 隔离 executor 完成并复用 artifact pipeline,Agent 不执行第三方构建 |
---
@@ -174,4 +196,5 @@ OpenResty (Agent, TLS/WAF)
* WAF 相关开发:阅读 [WAF 设计](./waf-design.md) 与 [WAF 可编排规则设计](./waf-orchestration-design.md)。
* Pages 托管开发:阅读 [Pages 静态托管设计](./pages-design.md)。
* 监控同步开发:阅读 [Uptime Kuma 监控同步设计](./kuma-design.md)。
* 看板/访问日志/节点指标开发:阅读 [观测数据传输模型](./observability-transport-model.md) 与 [边缘可观测与业务流量统计](./observability-design.md)。
5. **[仓库结构](./index.md#仓库结构)**:明确各个物理目录分层职责,避免堆砌和重复开发。
+190
View File
@@ -0,0 +1,190 @@
# 边缘缓存策略设计(对标 Cloudflare 默认可缓存范围)
你会学到:OpenFlare 边缘 `proxy_cache` 的产品边界、默认可缓存范围如何对齐 Cloudflare「静态资源默认可缓存」、策略枚举与渲染规则、兼容迁移,以及本阶段明确不做的能力。
本设计是 [系统架构](./architecture.md) 中「基础缓存」的产品化专章;访问日志中的缓存结果见 [观测数据模型 §3.5.1](./observability-data-model.md)。
---
## 1. 目标与非目标
### 1.1 目标(第一期)
* **开箱接近 CF 默认**:路由开启缓存后,**默认只缓存静态扩展名**,不默认缓存 HTML/无扩展名动态路径。
* **行为可解释**:与现有安全旁路(非 GET、Authorization、会话 Cookie、请求 `Cache-Control`)叠加,不削弱安全。
* **可观测一致**:继续依赖 `$upstream_cache_status` → `cache_status` 明细三态。
* **兼容存量**:旧路由 `cache_policy=url`(近似「过旁路即可缓存」)迁移为显式策略 `all`,行为不变。
### 1.2 非目标(后续迭代)
* Cache Rules 表达式引擎
* Edge TTL / `proxy_cache_valid` / 忽略源站 `Cache-Control`
* 可配置 Cookie 旁路列表、Query 忽略列表
* Purge(按 URL/前缀/全站)
* 浏览器 TTL 改写、客户端 `CF-Cache-Status` 响应头
* 命中率看板
---
## 2. 现状摘要
| 层 | 现状 |
| --- | --- |
| 全局 | `proxy_cache_path` / key / lock / stale(Performance 部分字段) |
| 路由 | `cache_enabled` + `cache_policy`:`url` \| `suffix` \| `path_prefix` \| `path_exact` |
| 旁路 | 渲染器硬编码:非 GET、Authorization、会话 Cookie、请求 Cache-Control |
| TTL | **无** `proxy_cache_valid`;存多久主要看源站头 + `inactive` |
| 观测 | 已上报 `cache_status`,UI 三态:命中 / 回源 / 未缓存 |
问题:默认策略 `url` 对「过旁路的 GET」范围过宽,与 CF「默认主要缓存静态扩展名、默认不缓存 HTML」不一致。
---
## 3. 产品语义
### 3.1 双层开关(不变)
* **全局** `openresty_cache_enabled`:生成 `proxy_cache_path` 等;关闭则路由级缓存指令不生效。
* **路由** `cache_enabled`:是否在该站点 `location` 启用 `proxy_cache`。
两者均开启时才进入缓存逻辑。
### 3.2 策略枚举(第一期)
| `cache_policy` | 含义 | 新建默认 | 旧值兼容 |
| --- | --- | --- | --- |
| **`static`** | 仅 URI 匹配**标准静态扩展名**(内置表)才允许缓存 | **是** | — |
| **`all`** | 过安全旁路后,不限制路径/扩展名(等同今日 `url`) | 否 | 存量 `url` → `all` |
| **`suffix`** | 自定义扩展名列表(`cache_rules`) | 否 | 保持 |
| **`path_prefix`** | 自定义路径前缀 | 否 | 保持 |
| **`path_exact`** | 自定义精确路径 | 否 | 保持 |
> 渲染层:读到历史值 `url` 时按 `all` 处理,避免未迁移数据行为突变;API 校验与 UI 只暴露上表枚举(写入时可将 `url` 规范为 `all`)。
### 3.3 标准静态扩展名(内置,V1 硬编码)
对齐 Cloudflare 常见「默认可缓存静态」集合,**默认不包含** `html` / `htm`:
```text
css js mjs map json
ico cur gif jpg jpeg png webp avif svg svgz
ttf otf woff woff2 eot
mp3 mp4 webm ogg flac
wasm pdf
zip 7z gz tar
```
* 匹配对象:`$uri` 的扩展名(大小写不敏感),实现上与现有 `suffix` 策略相同:
`if ($uri !~* \.(?:css|js|…)$) { set $openflare_skip_cache 1; }`
* **V1.1(可选)**:全局配置项覆盖该列表;第一期不强制。
### 3.4 安全旁路(保持硬编码)
在策略匹配之前/之外,仍设置 `$openflare_skip_cache=1`:
1. `$request_method != GET`(含 HEAD,与现网一致)
2. `$http_authorization != ""`
3. 会话类 Cookie 正则(现网列表)
4. 请求 `$http_cache_control` 匹配 `no-cache|no-store|private`
`proxy_cache_bypass` / `proxy_no_cache` 均绑定 `$openflare_skip_cache`。
### 3.5 与源站头的关系(本阶段不改)
* 仍不输出 `proxy_cache_valid`。
* 对象**是否进入缓存流程**由策略 + 旁路决定;**存多久**继续依赖源站 `Cache-Control` / `Expires` 等及全局 `inactive`。
* Edge TTL / 强制忽略源站头 → 后续专项。
---
## 4. 渲染与数据流
```text
全局 cache_enabled?
│ no → 不生成 proxy_cache_*
▼ yes
路由 cache_enabled?
│ no → location 无 proxy_cache
▼ yes
set $openflare_skip_cache 0
→ 安全旁路 if → 置 1
→ 策略 if(static/all/suffix/…)→ 可置 1
proxy_cache openflare_cache
proxy_cache_methods GET
proxy_cache_bypass / proxy_no_cache $openflare_skip_cache
→
access.log cache_status=$upstream_cache_status
```
### 4.1 策略 → Nginx 条件
| 策略 | 额外条件 |
| --- | --- |
| `static` | `$uri` 不匹配内置扩展名表 → skip |
| `all` | 无额外路径条件 |
| `suffix` | 不匹配 `cache_rules` 扩展名 → skip |
| `path_prefix` / `path_exact` | 同现实现 |
### 4.2 涉及代码面(实现时)
| 区域 | 路径 |
| --- | --- |
| 渲染 | `pkg/render/openresty/render.go`(策略分支 + 内置扩展名常量) |
| 校验 | `internal/apps/openflare/proxy_route/helpers.go` |
| 模型/默认 | 创建路由默认 `cache_policy=static`;读写时 `url`→`all` |
| 快照 | `config_version/snapshot.go` |
| UI | `proxy-routes/detail/components/cache-section.tsx` |
| 测试 | `pkg/render/openresty/render_test.go`、proxy_route helpers 测试 |
---
## 5. 兼容与迁移
| 数据 | 处理 |
| --- | --- |
| DB 中 `cache_policy=''` 或 `url`(且已启用缓存) | 读取 / 快照 / 渲染均规范为 **`all`**,保证存量「宽缓存」不变 |
| API 写入时 `enabled` 且 policy 为空 | 规范为 **`all`**(兼容旧客户端);UI 新建开启时**显式提交** `static` |
| 新建路由 | 默认 `cache_enabled=false`;表单开启缓存时默认策略 **`static`** |
| 已开启且 `url` 的站点 | 显示与发布为 `all`,**缓存范围不变** |
| 期望「只缓存静态」的旧站点 | 用户在 UI 改为 `static` 或自定义 `suffix` |
**发布说明建议:** 说明默认策略变更仅影响**新配置**;存量 `url` 视为 `all`。
---
## 6. UI 文案要点(缓存 Tab)
* 开启缓存后默认:**标准静态资源**(列出扩展名摘要,并写明不含 HTML)。
* 选项:**标准静态资源** / **所有可缓存 GET(高级)** / 自定义后缀 / 路径前缀 / 精确路径。
* 固定说明:非 GET、带 Authorization、常见登录 Cookie、请求禁止缓存头时跳过缓存。
* 提示:全局 Performance 中缓存总开关须开启,否则站点开关无效。
---
## 7. 验证要点
* 渲染:`static` 生成扩展名 `if`;`all`/`url` 无路径限制;旁路四条仍在。
* 单测:内置表含 `css`/`js`/`woff2`,不含 `html`。
* 手动:开启 `static` 后请求 `/a.css` 可出现 HIT/MISS;`/index.html` 或 `/api` 多为未缓存/BYPASS。
* 观测:access log `cache_status` 与列表三态一致。
---
## 8. 后续路线图(非本设计交付)
1. **Edge TTL / 尊重源站开关**(`proxy_cache_valid`、`proxy_ignore_headers`)
2. **可配置旁路**(Cookie/Query)
3. **Purge API**
4. **Cache Rules**(有序规则 + 动作)
5. **全局默认可缓存扩展名配置**
---
## 9. 决策记录
| 决策 | 选择 | 原因 |
| --- | --- | --- |
| 默认可缓存范围 | 开启缓存默认 `static` 扩展名表 | 对标 CF 开箱行为,降低 HTML/API 被误缓存 |
| 旧 `url` | 映射为 `all` | 避免存量站点行为变化 |
| HTML | 默认不在白名单 | 对齐 CF 默认不缓存 HTML |
| 第一期不做 Edge TTL/Purge | 明确 Out of Scope | 先收敛「谁可以进缓存」再优化「存多久/怎么清」 |
+6 -4
View File
@@ -22,13 +22,14 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
| 能力 | 说明 | 详细设计/使用指南 |
| --- | --- | --- |
| **反代配置管理** | 以网站规则(Proxy Route)为聚合边界,支持多域名与多上游负载均衡 | [新建反代配置](../guide/proxy-config.md) |
| **边缘缓存** | 单节点 OpenResty `proxy_cache`;开启后默认仅缓存标准静态扩展名(对标 CF 默认可缓存范围) | [边缘缓存策略设计](./edge-cache-design.md) |
| **Zone 与域名管理** | 以可注册根域为管理入口,聚合明确域名、域名证书与反代路由 | [Zone 与域名资源设计](./zone-design.md) |
| **配置版本控制** | 支持全局单一激活版本的预览、发布、不可变快照历史与秒级一键回滚 | [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 包,由边缘节点拉取并由 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) |
| **多节点监控与观测** | 收集节点资源快照、健康事件,聚合请求指标与访问日志明细 | [系统架构](./architecture.md) |
| **多节点监控与观测** | 访问日志为业务流量唯一真相;Agent 只上报明细与主机读数,Server 统一聚合;与 Zone/看板对账 | [观测数据传输模型](./observability-transport-model.md) / [边缘可观测与业务流量统计](./observability-design.md) / [上报协议与表结构](./observability-data-model.md) / [系统架构](./architecture.md) |
---
@@ -52,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. 系统与版本边界
* **全局单一激活版本**:所有节点拉取并消费同一份全局激活配置。不进行按节点分组的差异化配置发布。
+779
View File
@@ -0,0 +1,779 @@
# Agent 上报协议与观测落库数据模型
你会学到:重构后 Agent 心跳/WS 上报的 **数据结构**、Server **如何解析与写入**、ClickHouse / 关系库 **目标表结构**。
**无协议兼容层**:Agent 以销毁重建或二进制替换升级;旧字段不解析、旧缓冲整文件丢弃。
本设计是 [边缘可观测与业务流量统计重构](./observability-design.md) 的 **协议与存储专章**,实现时以本文字段与 DDL 为准。
**先读传输全景与示例:** [观测数据传输模型](./observability-transport-model.md)。
---
## 1. 设计目标
| 目标 | 说明 |
| --- | --- |
| Agent 只报事实 | 明细 + 主机读数 + 边缘健康瞬时态;无业务预聚合 |
| 一张业务明细表 | 访问日志是 L1 唯一写入路径 |
| 聚合在库内/控制面 | 小时汇总由 ClickHouse MV 或查询生成,Agent 不写汇总表 |
| 字段不重叠 | `bytes_sent` = 已提供数据;网卡 `network_*` = 宿主机;不再有业务 `openresty_tx` |
| 可演进 | 新字段可选;缺省数值填 0,不解析已删除的旧协议字段 |
---
## 2. 分层与写入总览
```text
Agent NodePayload (v2)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
access_logs host_metrics edge_health
(L1 明细) (L3 读数) (L2 瞬时)
│ │ │
▼ ▼ ▼
of_node_access_logs of_node_metric_ of_node_edge_health
│ snapshots │
│ │ │
▼ ▼ │
of_access_log_hourly of_node_metric_ │
(MV, Server 侧) capacity_hourly (MV) │
│ │ │
└─────── 管理端聚合 API ───────────┘
关系库 (PostgreSQL/SQLite):节点最新状态、Profile、健康事件(非明细湖)
```
| 层 | 含义 | Agent 上报块 | ClickHouse 事实表 |
| --- | --- | --- | --- |
| L1 | 业务交付 | `access_logs` | `of_node_access_logs` |
| L2 | 边缘健康 | `edge_health` | `of_node_edge_health` |
| L3 | 宿主机资源 | `host_metrics` | `of_node_metric_snapshots` |
---
## 3. Agent 上报数据结构(协议 v2)
### 3.1 顶层 `NodePayload`
传输:HTTP 心跳 body 与 WebSocket `status` 消息共用同一结构。
```json
{
"schema_version": 2,
"node_id": "n_xxx",
"name": "edge-1",
"ip": "1.2.3.4",
"version": "3.3.0",
"ext_version": "",
"current_version": "cfg-checksum-or-version",
"last_error": "",
"profile": { },
"host_metrics": { },
"edge_health": { },
"access_logs": [ ],
"buffered": [ ],
"health_events": [ ],
"waf_ip_group_checksums": { "1": "md5..." }
}
```
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `schema_version` | int | 建议 | 固定为 `2`(本设计) |
| `node_id` | string | ✅ | 节点 ID |
| `name` | string | ✅ | 显示名 |
| `ip` | string | ✅ | 上报 IP |
| `version` / `ext_version` | string | ✅ | Agent 版本 |
| `current_version` | string | | 本地激活配置版本摘要 |
| `last_error` | string | | 最近同步/运行错误,可空 |
| `openresty_status` | string | ✅(有 OpenResty 时) | **最新健康态权威字段** → 写 PG 节点表 |
| `openresty_message` | string | | **最新健康说明权威字段** → 写 PG 节点表(**不进 CH**) |
| `profile` | object | | 主机概况,变化时上报(可节流) |
| `host_metrics` | object | 建议每拍 | L3 资源快照 |
| `edge_health` | object | 建议每拍 | L2 连接时序 + 与顶层一致的 status |
| `access_logs` | array | | 本拍增量访问明细 |
| `buffered` | array | | 离线补传的事实批次(见 §3.6) |
| `health_events` | array | | 边缘健康事件 |
| `waf_ip_group_checksums` | map | | 差分同步用,非观测湖 |
**已删除、Server 不再解析的字段(无兼容层):**
| 旧字段 | 处置 |
| --- | --- |
| `traffic_report` | 不存在于协议;不落库 |
| `openresty_observation` | 不存在;连接与状态走 `edge_health` |
| `snapshot` | 不存在;仅用 `host_metrics` |
| `buffered_observability` | 不存在;仅用 `buffered` |
### 3.2 `profile` — 主机概况(低频)
对应关系库 `of_node_system_profiles`(或现有等价表),**不进 ClickHouse 明细湖**。
```json
{
"hostname": "edge-1",
"os_name": "linux",
"os_version": "...",
"kernel_version": "...",
"architecture": "amd64",
"cpu_model": "...",
"cpu_cores": 8,
"total_memory_bytes": 16106127360,
"total_disk_bytes": 107374182400,
"uptime_seconds": 864000,
"reported_at_unix": 1720000000
}
```
| 字段 | 语义 |
| --- | --- |
| 硬件/OS 描述字段 | 事实读数 |
| `reported_at_unix` | Agent 采集时刻(UTC 秒) |
### 3.3 `host_metrics` — 宿主机资源(L3)
**全部为读数,不做 24h 业务总量。**
网卡/磁盘字节为 **内核累计计数器原值**(单调递增,重启可归零);CPU 为瞬时百分比;内存/磁盘占用为当前用量。
```json
{
"captured_at_unix": 1720000000,
"cpu_usage_percent": 12.5,
"memory_used_bytes": 4294967296,
"memory_total_bytes": 16106127360,
"storage_used_bytes": 50000000000,
"storage_total_bytes": 107374182400,
"disk_read_bytes": 9000000000,
"disk_write_bytes": 12000000000,
"network_rx_bytes": 500000000000,
"network_tx_bytes": 800000000000
}
```
| 字段 | 类型 | 语义 | Server 如何用 |
| --- | --- | --- | --- |
| `captured_at_unix` | int64 | 采样时刻 | `captured_at` |
| `cpu_usage_percent` | float | 瞬时 CPU% | 直接存;趋势取平均 |
| `memory_*` / `storage_*` | int64 | 当前用量/总量 | 直接存;算占用率 |
| `disk_read_bytes` / `disk_write_bytes` | int64 | **累计** IO 字节 | 存原值;查询时相邻差分 |
| `network_rx_bytes` / `network_tx_bytes` | int64 | **累计** 网卡字节 | 存原值;查询时相邻差分 →「宿主机网卡入/出站」 |
> Agent **禁止** 在上报前对网卡/磁盘做「本周期增量」替换累计值(否则 Server 差分会错)。
### 3.4 `edge_health` — OpenResty 边缘健康(L2)
**仅瞬时态,不包含业务吞吐。**
```json
{
"captured_at_unix": 1720000000,
"status": "healthy",
"message": "",
"connections": 42
}
```
| 字段 | 类型 | 语义 |
| --- | --- | --- |
| `status` | string | `healthy` / `unhealthy` / `unknown`(须与顶层 `openresty_status` 一致) |
| `message` | string | 状态说明(上报可带;**仅用于回填 PG 最新态,不进 CH**) |
| `connections` | int64 | stub_status Active connections |
#### 健康状态权威源(收敛)
| 数据 | 权威存储 | 说明 |
| --- | --- | --- |
| **当前** OpenResty 是否健康 + 说明文案 | **PG 节点表** `openresty_status` / `openresty_message` | UI 徽章、列表、告警以这里为准 |
| **时序** 健康 status + 连接数 | **CH** `of_node_edge_health`(`status`, `connections`) | 连接曲线 / 健康状态历史;**无 message 列** |
| Agent 上报 | 顶层 status/message + `edge_health` | Server 归一化后二者 status 对齐;message **只写 PG** |
因此:查「现在是否 unhealthy」→ 读 PG;查「过去 24h 连接数」→ 读 CH。
### 3.5 `access_logs[]` — 访问明细(L1,业务唯一事实)
Agent:tail access.log → 解析 JSON 行 → 原样字段上报(可截断 path)。
```json
{
"logged_at_unix": 1720000001,
"remote_addr": "203.0.113.10",
"host": "www.example.com",
"path": "/api/v1/ping",
"status_code": 200,
"bytes_sent": 1024,
"request_length": 128,
"request_time_ms": 15,
"user_agent": "Mozilla/5.0 ...",
"cache_status": "HIT"
}
```
| 字段 | 类型 | 必填 | 来源(OpenResty) | 业务含义 |
| --- | --- | --- | --- | --- |
| `logged_at_unix` | int64 | ✅ | `$time_iso8601` 解析 | 请求完成时间 |
| `remote_addr` | string | ✅ | `$remote_addr` | 客户端 IP → UV |
| `host` | string | ✅ | `$host` | 域名 → Zone 归属 |
| `path` | string | ✅ | `$request_uri`,Agent 可截断 | 路径 |
| `status_code` | int | ✅ | `$status` | 状态码 |
| `bytes_sent` | int64 | ✅ | **`$body_bytes_sent`** | **已提供数据**(响应体) |
| `request_length` | int64 | 建议 | `$request_length` | **接收数据** |
| `request_time_ms` | int64 | 可选 | `$request_time * 1000` | 耗时;缺省 0 |
| `user_agent` | string | 建议 | `$http_user_agent` | UA;可截断入库 |
| `cache_status` | string | 建议 | **`$upstream_cache_status`** | 边缘缓存结果(见 §3.5.1) |
**明确不由 Agent 上报(由 Server 写入):**
* `region` / 国家:入库时 GeoIP 解析
* `id` / `created_at`:Server 生成
* `node_id`:取自 payload / 鉴权上下文
**明确不上报:**
* `upstream_addr` / 回源地址 / `origin_fetched`:不做回源端点追踪;「是否回源」仅由 `cache_status` 在控制面推导(§3.5.1)
### 3.5.1 `cache_status` — 缓存命中与回源(明细优先)
**目标(第一期):** 访问日志明细/详情能展示「是否命中缓存 / 是否回源 / 未使用缓存」。
**口径:** 只存 OpenResty `$upstream_cache_status` 原始值;**不上报** upstream 地址。
#### 原始值(入库)
| 值 | 含义(OpenResty) |
| --- | --- |
| `HIT` | 命中缓存 |
| `MISS` | 未命中,向 upstream 取内容 |
| `BYPASS` | 跳过缓存(如 method/cookie/策略导致 `$openflare_skip_cache`) |
| `EXPIRED` | 缓存过期后回源 |
| `STALE` | 提供陈旧缓存(stale) |
| `UPDATING` | 后台更新中,可能返回旧缓存 |
| `REVALIDATED` | 协商验证后仍用缓存 |
| `-` 或空 | 未经过 `proxy_cache`(如 Pages 本地静态、非代理 location) |
#### UI 三态推导(不落库)
控制面展示用派生枚举 `cache_outcome`,**不写 CH**:
| 三态 | 条件(`cache_status`) | 列表标签建议 |
| --- | --- | --- |
| **命中缓存** | `HIT` / `STALE` / `REVALIDATED` / `UPDATING` | 命中 |
| **回源** | `MISS` / `EXPIRED` | 回源 |
| **未使用缓存** | `BYPASS` / `-` / `""` | 未缓存 |
详情可同时显示三态 + 原始 `cache_status`。
#### 边界
* Pages 静态 / 无 `proxy_cache` 的 location:多为空或 `-` → **未使用缓存**,不得标成「命中」。
* 第一期只做明细可见;命中率看板、hourly 维度可后续用同一列聚合。
**单次心跳条数建议:**
* 软上限例如 2000 条/拍;超出进入 `buffered` 下一批,**禁止** 在 Agent 压成 TrafficReport。
### 3.6 `buffered[]` — 离线补传(只装事实)
```json
{
"captured_at_unix": 1719999900,
"host_metrics": { },
"edge_health": { },
"access_logs": [ ]
}
```
| 字段 | 说明 |
| --- | --- |
| `captured_at_unix` | 该批次采集/缓冲时刻,用于 ack 与去重窗口 |
| `host_metrics` / `edge_health` / `access_logs` | 与主 payload 同结构;可省略空块 |
**禁止** 在 buffered 中携带 `traffic_report` 或 rx/tx 吞吐。
### 3.7 `health_events[]`
```json
{
"event_type": "openresty_unhealthy",
"severity": "critical",
"message": "...",
"triggered_at_unix": 1720000000,
"metadata": { }
}
```
写入关系库健康事件表(现有模型即可),不进访问日志湖。
### 3.8 Go 协议草图(目标)
```go
// pkg/protocol/agent.go(目标形态,实现时替换旧类型)
type NodePayload struct {
SchemaVersion int `json:"schema_version,omitempty"`
NodeID string `json:"node_id"`
Name string `json:"name"`
IP string `json:"ip"`
Version string `json:"version"`
ExtVersion string `json:"ext_version"`
CurrentVersion string `json:"current_version"`
LastError string `json:"last_error"`
OpenrestyStatus string `json:"openresty_status"` // PG 最新态权威
OpenrestyMessage string `json:"openresty_message"` // PG 最新态权威;不进 CH
Profile *NodeSystemProfile `json:"profile,omitempty"`
HostMetrics *NodeHostMetrics `json:"host_metrics,omitempty"`
EdgeHealth *NodeEdgeHealth `json:"edge_health,omitempty"`
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
Buffered []BufferedFacts `json:"buffered,omitempty"`
HealthEvents []NodeHealthEvent `json:"health_events"`
WAFIPGroupChecksums map[string]string `json:"waf_ip_group_checksums,omitempty"`
}
type NodeHostMetrics struct {
CapturedAtUnix int64 `json:"captured_at_unix"`
CPUUsagePercent float64 `json:"cpu_usage_percent"`
MemoryUsedBytes int64 `json:"memory_used_bytes"`
MemoryTotalBytes int64 `json:"memory_total_bytes"`
StorageUsedBytes int64 `json:"storage_used_bytes"`
StorageTotalBytes int64 `json:"storage_total_bytes"`
DiskReadBytes int64 `json:"disk_read_bytes"`
DiskWriteBytes int64 `json:"disk_write_bytes"`
NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"`
}
type NodeEdgeHealth struct {
CapturedAtUnix int64 `json:"captured_at_unix"`
Status string `json:"status"`
Message string `json:"message"`
Connections int64 `json:"connections"`
}
type NodeAccessLog struct {
LoggedAtUnix int64 `json:"logged_at_unix"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Path string `json:"path"`
UserAgent string `json:"user_agent,omitempty"`
CacheStatus string `json:"cache_status,omitempty"` // $upstream_cache_status
StatusCode int `json:"status_code"`
BytesSent int64 `json:"bytes_sent"` // body_bytes_sent,已提供数据
RequestLength int64 `json:"request_length"` // 接收数据
RequestTimeMs int64 `json:"request_time_ms"` // 可选
}
type BufferedFacts struct {
CapturedAtUnix int64 `json:"captured_at_unix"`
HostMetrics *NodeHostMetrics `json:"host_metrics,omitempty"`
EdgeHealth *NodeEdgeHealth `json:"edge_health,omitempty"`
AccessLogs []NodeAccessLog `json:"access_logs,omitempty"`
}
```
---
## 4. Server 解析与落库流程
### 4.1 入口
* HTTP:`POST /api/v1/agent/...` 心跳(现有路径)
* WebSocket:`type=status` payload = `NodePayload`
* 鉴权:`X-Agent-Token` → 绑定 `node_id`(payload.node_id 必须与 token 节点一致)
### 4.2 处理流水线(单次 payload)
```text
1. 反序列化 NodePayload
2. 归一化(normalize)
- schema_version < 2:
host_metrics ← snapshot
edge_health.status ← openresty_status
edge_health.connections ← openresty_observation.connections(若有)
traffic_report → drop
openresty_observation.rx/tx → drop
buffered ← buffered_observability
- path 再截断、status 范围钳制、负数字节 → 0
3. 关系库事务(节点最新态)
- 更新 node 在线时间、IP、版本、edge_health.status/message
- upsert profile(若有)
- insert health_events(若有)
4. ClickHouse 异步 batch(失败记日志,不阻断心跳响应的配置下发)
a. access_logs + buffered[].access_logs
→ 补 region(GeoIP)
→ 分配 snowflake id
→ BatchInsert of_node_access_logs
b. host_metrics + buffered[].host_metrics
→ of_node_metric_snapshots
c. edge_health + buffered[].edge_health
→ of_node_edge_health(仅 connections + status 快照可选)
5. 返回心跳响应(settings / active_config / waf 差分)
6. 若使用 buffer ack:按 buffered.captured_at_unix 列表确认
```
### 4.3 归一化规则(硬约束)
| 规则 | 行为 |
| --- | --- |
| `logged_at` 超前 now+5m | 钳制为 now 或丢弃该条(实现选定一种并单测) |
| `logged_at` 早于 now−TTL | 仍可写入,依赖表 TTL 清理 |
| 空 `host` | 允许,聚合进「未归属」 |
| `bytes_sent` / `request_length` < 0 | 置 0 |
| 单批 access_logs > N | 截断并打点监控(或只入 buffer 队列),不改为预聚合 |
| 重复补传 | CH 允许少量重复行;查询用 sum 近似(不强制精确去重) |
### 4.4 字段映射表(上报 → 表)
| 上报路径 | 目标存储 | 列 |
| --- | --- | --- |
| `access_logs[]` | CH `of_node_access_logs` | 见 §5.1 |
| `host_metrics` | CH `of_node_metric_snapshots` | 见 §5.2 |
| `edge_health` | CH `of_node_edge_health` + PG node 最新状态 | 见 §5.3 / §5.6 |
| `profile` | PG `of_node_system_profiles` | 现有列 |
| `health_events` | PG 健康事件表 | 现有模型 |
| `waf_ip_group_checksums` | 不落观测表 | 同步逻辑 |
| `traffic_report`(旧) | **不写** | — |
| `openresty_rx/tx`(旧) | **不写** | — |
### 4.5 查询侧(不落新「业务出站」列)
| 产品指标 | SQL 语义(示意) |
| --- | --- |
| 已提供数据 | `sum(bytes_sent)` |
| 接收数据 | `sum(request_length)` |
| 请求数 | `count()` |
| UV | `uniqExact(remote_addr)` |
| 5xx | `countIf(status_code >= 500)` |
| 按域名/状态码/地区 | `GROUP BY host / status_code / region` |
| 宿主机网卡出站 | 对 `network_tx_bytes` 按 node 时间序非负差分后 sum |
| OpenResty 连接 | `of_node_edge_health.connections` 最新或平均 |
---
## 5. 表结构(目标 DDL)
> 引擎与 TTL 与现网一致倾向:访问日志 90 天,指标 30 天。
> `id` 使用控制面 Snowflake/唯一 UInt64。
### 5.1 L1 事实表:`of_node_access_logs`
```sql
CREATE TABLE IF NOT EXISTS of_node_access_logs
(
id UInt64,
node_id String,
logged_at DateTime64(3, 'UTC'),
remote_addr String,
region String, -- Server GeoIP 写入,Agent 不传
host String,
path String,
user_agent String DEFAULT '', -- $http_user_agent
cache_status String DEFAULT '', -- $upstream_cache_status
status_code Int32,
bytes_sent UInt64, -- 已提供数据(body)
request_length UInt64 DEFAULT 0, -- 接收数据
request_time_ms UInt32 DEFAULT 0, -- 可选
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(logged_at)
ORDER BY (node_id, logged_at, host, status_code, remote_addr)
TTL toDateTime(logged_at) + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;
```
| 列 | 类型 | 来源 |
| --- | --- | --- |
| `id` | UInt64 | Server |
| `node_id` | String | 鉴权/payload |
| `logged_at` | DateTime64(3) | `logged_at_unix` |
| `remote_addr` | String | 上报 |
| `region` | String | Server GeoIP |
| `host` | String | 上报 |
| `path` | String | 上报 |
| `user_agent` | String | 上报(可空) |
| `cache_status` | String | 上报(可空)→ **缓存状态** |
| `status_code` | Int32 | 上报 |
| `bytes_sent` | UInt64 | 上报 → **已提供数据** |
| `request_length` | UInt64 | 上报 → **接收数据** |
| `request_time_ms` | UInt32 | 上报可选 |
| `created_at` | DateTime64(3) | Server now |
**迁移:** 现表已有 `bytes_sent` / `request_length` / `request_time_ms` / `user_agent`;缓存状态新增:
```sql
ALTER TABLE of_node_access_logs
ADD COLUMN IF NOT EXISTS cache_status String DEFAULT '';
```
### 5.2 L1 小时汇总(Server 侧 MV)
**禁止 Agent 写入。** 供看板/节点 24h 快速查询请求数、错误数、字节量。
**已实现选型:`SummingMergeTree` + 不含 UV 列。**
```sql
CREATE TABLE IF NOT EXISTS of_access_log_hourly
(
node_id String,
hour DateTime('UTC'),
host String,
request_count UInt64,
error_count UInt64,
bytes_sent UInt64,
request_length UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (node_id, hour, host)
TTL hour + INTERVAL 90 DAY;
CREATE MATERIALIZED VIEW IF NOT EXISTS of_access_log_hourly_mv
TO of_access_log_hourly
AS
SELECT
node_id,
toStartOfHour(logged_at) AS hour,
host,
toUInt64(count()) AS request_count,
toUInt64(countIf(status_code >= 500)) AS error_count,
sum(bytes_sent) AS bytes_sent,
sum(request_length) AS request_length
FROM of_node_access_logs
GROUP BY node_id, hour, host;
```
历史小时(MV 创建前已入库的明细)需一次性回填,见迁移 `202607180003_backfill_access_log_hourly.sql`(ANTI JOIN 防重)。
#### UV 策略(必须遵守)
| 场景 | 数据源 | 算法 | 说明 |
| --- | --- | --- | --- |
| **窗口总 UV**(看板汇总、节点卡片、Zone 汇总) | `of_node_access_logs` 明细 | `uniqExact(remote_addr)`(`TrafficSummary` / 节点聚合) | **唯一权威**;不可用小时 UV 相加 |
| **24h 趋势折线请求/错误/字节** | `of_access_log_hourly` 优先,缺数据回落明细桶 | `sum(request_count)` 等 | 小时路径 **不填** `unique_visitor_count`(恒为 0) |
| **24h 趋势折线分时 UV** | 仅明细桶路径 | 桶内 `uniqExact` | 走 hourly 时 UI 应展示空/0 或隐藏 UV 序列,**禁止**对小时行做 `sum(UV)` |
**为何 hourly 不存 UV:**
1. `SummingMergeTree` 只能安全合并可加和计数;`uniqExact` 跨 part 合并需要 `AggregatingMergeTree` + state,实现与查询更重。
2. 即便存每小时 UV,对多小时窗口 **相加会严重高估**(同一 IP 跨小时重复计)。
3. 产品「24h 独立访客」只认整窗 `uniqExact`;趋势图主序列是请求量/错误/字节,分时 UV 非主指标。
可选未来:若需要分时 UV 曲线,再单独加 `AggregatingMergeTree` 状态表或查询时对明细做 `uniqExact` 按小时 group(成本更高,不阻塞当前看板)。
### 5.3 L3 事实表:`of_node_metric_snapshots`(保留,语义明确)
```sql
CREATE TABLE IF NOT EXISTS of_node_metric_snapshots
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
cpu_usage_percent Float64,
memory_used_bytes Int64,
memory_total_bytes Int64,
storage_used_bytes Int64,
storage_total_bytes Int64,
disk_read_bytes Int64, -- 累计原值
disk_write_bytes Int64,
network_rx_bytes Int64, -- 累计原值 → 宿主机网卡入站
network_tx_bytes Int64, -- 累计原值 → 宿主机网卡出站
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
TTL toDateTime(captured_at) + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
```
列与现网一致;**文档与 API 必须标注 network_* 为宿主机网卡累计值**。
### 5.4 L3 小时汇总:`of_node_metric_capacity_hourly`(保留)
现有 min/max 用于累计计数器小时增量近似 + CPU/内存平均。逻辑不变:
* `network_tx_max - network_tx_min` ≈ 该小时宿主机出站
* **不得** 用于「已提供数据」
### 5.5 L2 事实表:`of_node_edge_health`(新建,替换吞吐型 openresty 表)
```sql
CREATE TABLE IF NOT EXISTS of_node_edge_health
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
status LowCardinality(String), -- healthy / unhealthy / unknown
connections Int64,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
TTL toDateTime(captured_at) + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
```
| 列 | 说明 |
| --- | --- |
| `status` | 瞬时健康(与 PG 当前态同源;用于时序,非唯一 UI 权威) |
| `connections` | 当前连接数 |
**无** `message` 列(说明文案仅 PG 最新态)。
**无** `openresty_rx_bytes` / `openresty_tx_bytes`。
### 5.6 关系库(节点最新态,非分析湖)
与观测湖分离,保持「最新一份」:
| 表(逻辑名) | 用途 | 关键列 |
| --- | --- | --- |
| `of_nodes`(或现节点表) | 在线、版本、IP | `last_seen_at`, `openresty_status`, `openresty_message`, `agent_version` |
| `of_node_system_profiles` | profile upsert | hostname, cpu_cores, total_memory_bytes, ... |
| 健康事件表 | `health_events` | event_type, severity, message, triggered_at |
> 具体物理表名以仓库现有 GORM 模型为准;本设计不强制改名,只强制 **不再把业务吞吐写进节点表**。
### 5.7 废弃表(停止写入 → TTL 后删除)
| 表 | 原因 | 替代 |
| --- | --- | --- |
| `of_node_request_reports` | Agent 预聚合 | `of_node_access_logs` + hourly |
| `of_node_traffic_hourly` + MV | 依赖 request_reports | `of_access_log_hourly` |
| `of_node_obs_openresty` | 含业务 rx/tx | `of_node_edge_health` |
| `of_node_openresty_hourly` + MV | 业务吞吐差分 | `of_access_log_hourly` 的 bytes_* |
Relay 专用 `of_node_obs_frps` / `of_node_obs_frpc` **保留**(非本 Agent 主路径,但同属 CH 观测)。
---
## 6. 表与协议对照总表
| 产品概念 | 协议字段 | 表.列 | 聚合 |
| --- | --- | --- | --- |
| 已提供数据 | `access_logs[].bytes_sent` | `of_node_access_logs.bytes_sent` | `sum` |
| 接收数据 | `access_logs[].request_length` | `...request_length` | `sum` |
| 请求数 | 行数 | — | `count` |
| UV(窗口总) | `remote_addr` | 同左明细 | `uniqExact`(**禁止** sum 小时 UV) |
| Top 域名 | `host` | 同左 | `group by` |
| 状态码分布 | `status_code` | 同左 | `group by` |
| 来源地区 | — | `region`(Server) | `group by` |
| 宿主机网卡出站 | `host_metrics.network_tx_bytes` | `of_node_metric_snapshots.network_tx_bytes` | 时间序差分 |
| 宿主机网卡入站 | `network_rx_bytes` | 同左 | 差分 |
| 磁盘读/写 | `disk_*_bytes` | 同左 | 差分 |
| CPU/内存 | 瞬时字段 | 同左 | avg |
| OpenResty 连接 | `edge_health.connections` | `of_node_edge_health.connections` | 最新/avg |
| OpenResty 健康 | `edge_health.status` | 节点表 + 可选 CH | 最新 |
**不再存在的映射:**
| 旧概念 | 旧字段 | 处置 |
| --- | --- | --- |
| OpenResty 出站 | `openresty_tx_bytes` | 删除;用已提供数据 |
| OpenResty 入站 | `openresty_rx_bytes` | 删除;用接收数据 |
| 窗口请求报告 | `traffic_report` | 删除 |
---
## 7. OpenResty 日志格式(与明细对齐)
目标 `log_format`(保证 `bytes_sent` 键 = body;含 UA 与缓存状态):
```nginx
log_format openflare_json escape=json
'{"ts":"$time_iso8601","host":"$host","path":"$request_uri",'
'"remote_addr":"$remote_addr","status":$status,'
'"request_time":$request_time,'
'"bytes_sent":$body_bytes_sent,"request_length":$request_length,'
'"user_agent":"$http_user_agent",'
'"cache_status":"$upstream_cache_status"}';
```
Agent 解析:
* `ts` → `logged_at_unix`
* `bytes_sent` → 协议 `bytes_sent`(已提供)
* `request_length` → 协议 `request_length`
* `request_time` → 可选 `request_time_ms = round(sec * 1000)`
* `user_agent` → 协议 `user_agent`
* `cache_status` → 协议 `cache_status`(原样透传,不做三态压缩)
---
## 8. 升级策略(无兼容层)
| 项 | 策略 |
| --- | --- |
| Agent 升级 | **销毁重建**优先;允许**二进制替换** |
| 协议 | 仅 schema v2 字段;旧 JSON 字段不解析 |
| 本地观测缓冲 | 若仍是旧格式(含 `snapshot` / `openresty_observation` / `traffic_report`)或损坏 → **整文件删除**,运行中重建 |
| 读路径 | 业务 API **只读** access_logs(及 hourly);健康当前态读 PG;连接时序读 CH edge_health |
| 旧 Agent | 必须升级;控制面不提供 v1 双读路径 |
---
## 9. 示例:一次心跳的落库结果
**Agent 上报(节选):**
```json
{
"schema_version": 2,
"node_id": "n1",
"host_metrics": {
"captured_at_unix": 1720000000,
"cpu_usage_percent": 10,
"memory_used_bytes": 1,
"memory_total_bytes": 2,
"storage_used_bytes": 3,
"storage_total_bytes": 4,
"disk_read_bytes": 100,
"disk_write_bytes": 200,
"network_rx_bytes": 1000,
"network_tx_bytes": 2000
},
"edge_health": {
"captured_at_unix": 1720000000,
"status": "healthy",
"message": "",
"connections": 5
},
"access_logs": [
{
"logged_at_unix": 1720000001,
"remote_addr": "1.1.1.1",
"host": "a.example.com",
"path": "/",
"status_code": 200,
"bytes_sent": 500,
"request_length": 80
}
]
}
```
**写入:**
1. PG 节点最新态:`openresty_status` / `openresty_message`(若上报)
2. `of_node_metric_snapshots` 1 行(network_tx=2000 累计)
3. `of_node_edge_health` 1 行(status + connections=5;**无 message**)
4. `of_node_access_logs` 1 行(bytes_sent=500, request_length=80, region=Server 填充)
5. MV 异步计入 `of_access_log_hourly`
**查询 24h 已提供数据:** `sum(bytes_sent)` → 至少 500(加历史)
**查询宿主机出站:** 对 snapshots 差分,与 500 **无强制相等关系**。
---
## 10. 实现检查清单
- [x] `pkg/protocol`:仅 v2 字段,无兼容别名
- [x] Agent:只组 `host_metrics` / `edge_health` / `access_logs` / `buffered`
- [x] Server:无 request_reports / openresty 吞吐;健康当前态 PG、时序 CH
- [x] CH migration:`request_length`、`request_time_ms`、`of_node_edge_health`、`of_access_log_hourly`、hourly 回填
- [x] 看板/Zone API 统一读 access log 聚合
- [x] UV:整窗 uniqExact;Zone 曲线标明分桶 UV;小时趋势不绘 UV
---
## 11. 修订记录
| 日期 | 说明 |
| --- | --- |
| 2026-07-17 | 初稿:协议 v2、Server 落库流水线、CH/关系库目标表结构与废弃表清单 |
+585
View File
@@ -0,0 +1,585 @@
# 边缘可观测与业务流量统计重构设计
你会学到:当前观测链路为何出现「看板 OpenResty 出站」与「Zone 已提供数据」不一致、字段与聚合为何冗余,以及目标架构如何让 **Agent 只上报事实、Server 只解释事实**,业务流量以访问日志为唯一真相源。
---
## 1. 目标
### 1.1 要解决的问题
1. **双真相源**:业务吞吐同时来自访问日志聚合与 OpenResty 观测差分,数值长期对不上。
2. **Agent 越权计算**:边缘预聚合 `TrafficReport`、吞吐累计,控制面再聚合一遍,语义难演进、难对账。
3. **字段语义重叠**:「OpenResty 出站」与「已提供数据」对用户是同一业务问题,系统却用两套字段、两条管道。
4. **瞬时与累计混用**:60 秒窗口计数被当成进程累计做 24h 差分,造成严重偏低。
5. **UI 诱导错误对比**:看板与 Zone 页使用相近「流量/数据」文案,却未声明范围与口径差异。
### 1.2 重构目标
| 目标 | 说明 |
| --- | --- |
| **单一业务真相** | 请求数、已提供数据、UV、状态码分布、Top 域名等 **只** 从访问日志(及其 Server 侧派生汇总)得出 |
| **Agent 只上报事实** | 明细日志 + 机器读数 + 健康瞬时态;**禁止** 业务 UV/TopN/24h 总量等预聚合 |
| **字段收敛** | 一个业务概念对应一个权威字段;机器网卡与业务交付严格分名 |
| **可对账** | 全局「已提供数据」≈ 各 Zone「已提供数据」之和(差仅为未绑定/未知 Host) |
| **可演进** | 改时间窗、TopN、归属规则只改 Server,不升 Agent |
### 1.3 非目标(本设计不覆盖)
* 建成通用日志平台、全量日志长期归档或检索产品。
* 替换 ClickHouse / 取消分析库依赖。
* 改造 Relay / OpenFlared 的主机指标采集(可对齐原则,但不在本轮协议主路径)。
* 实时流式告警引擎、APM 链路追踪(OpenTelemetry 服务端已有,与本业务流量模型正交)。
---
## 2. 范围与约束
### 2.1 产品约束(继承)
* 单租户、全局单激活配置;观测不引入多租户计费隔离。
* ClickHouse 为访问日志与时序观测的强制分析存储。
* Agent 无入向控制、Pull 模型;离线期间本地 OpenResty 继续服务,观测可本地缓冲后补传。
### 2.2 工程约束
* Agent 保持轻量:解析日志行、读 `/proc`、健康检查;不做业务分析。
* 控制面 API 错误仍走统一信封与 `response.Abort*`。
* 访问日志字段变更须同时更新 OpenResty `log_format` 与 Agent 解析器;Agent 与控制面同版本发布,不保留旧协议解析。
---
## 3. 设计原则
### 原则 P1:Agent 上报事实,Server 解释事实
```text
Agent = 采集 + 可靠投递(原始/近原始)
Server = 入库 + 聚合 + 归属 + 趋势 + 对账
```
**允许的边缘处理(采集)**
* 将 JSON access.log 行解析为结构化字段
* path 长度上限、丢弃非法行、跳过观测端口自身请求
* 读取网卡/CPU/内存等计数器 **原值**
* 批量、压缩、离线缓冲与重试
**禁止的边缘处理(业务计算)**
* UV / Top 域名 / 状态码直方图 / 窗口 request_count 作为权威指标
* 为看板单独维护「业务入出站累计」
* Zone / 域名归属统计、国家分布(国家可在 Server 入库时解析)
### 原则 P2:业务流量唯一真相 = 访问日志
| 业务问题 | 唯一答案 |
| --- | --- |
| 提供了多少数据 | `sum(bytes_sent)` |
| 多少请求 | `count()` |
| 多少独立访客 | `uniqExact(remote_addr)`(或产品约定哈希) |
| 状态码 / Top 域名 | 对日志 `group by` |
### 原则 P3:三层指标互不混用
| 层 | 名称 | 用途 | 典型字段 |
| --- | --- | --- | --- |
| L1 业务交付 | Business Traffic | 用户与 Zone 对账、看板业务趋势 | access log |
| L2 边缘健康 | Edge Health | OpenResty 是否活着、当前连接 | status、connections |
| L3 宿主机资源 | Host Capacity | 容量规划、机器是否打满 | CPU、内存、磁盘、**网卡** |
禁止将 L3 网卡或 L2 瞬时计数命名为「已提供数据」;禁止将 L1 与 L3 画在同一摘要卡片上却不标注语义。
### 原则 P4:一个业务概念一个字段
* **已提供数据** ≡ 响应体交付量 ≡ 历史文案中的「OpenResty 出站(业务含义)」→ **只保留 `bytes_sent` 聚合**
* **接收数据**(可选)≡ 请求侧体量 → 日志 `request_length` 聚合
* **宿主机出站** ≡ `network_tx` 差分,文案必须含「宿主机/网卡」
---
## 4. 现状问题(基线)
### 4.1 当前数据流(冗余)
```text
一次 HTTP 请求
│
├─ access.log 一行
│ → Agent tail → AccessLogs[]
│ → CH of_node_access_logs
│ → Zone「已提供数据」✅
│
├─ Lua shared dict 窗口/累计计数
│ → /openflare/observability
│ → TrafficReport + OpenrestyObservation(rx/tx)
│ → CH request_reports / obs_openresty
│ → 看板「OpenResty 入/出站」❌ 易与 Zone 不一致
│
├─ access.log 二次汇总(观测 endpoint 失败时回退)
│ → 又一份 TrafficReport / 吞吐
│
└─ 宿主机 network_rx/tx
→ Snapshot → 网络趋势中的「主机」曲线
```
### 4.2 字段重叠
| 用户感知 | 系统字段 A | 系统字段 B | 问题 |
| --- | --- | --- | --- |
| 出站 / 已提供 | `openresty_tx_bytes` | `bytes_sent` | 业务语义重复 |
| 入站 | `openresty_rx_bytes` | `request_length`(日志) | 业务语义重复 |
| 请求数 | `TrafficReport.request_count` | `count(access_logs)` | 聚合重复且窗口易重计 |
| 出站(机器) | `network_tx_bytes` | (无业务对应) | 应单独命名,勿与业务对账 |
### 4.3 典型故障模式
1. 窗口计数被当累计差分 → 24h 业务吞吐严重偏低。
2. 小时 rollup `max−min` 对重置型计数失效。
3. Zone 用日志、看板用观测 → 用户认为系统算错。
4. 改口径需同步改 Lua、Agent 状态累计、Server 差分、前端文案。
---
## 5. 目标架构
### 5.1 目标数据流
```mermaid
flowchart TB
subgraph edge [边缘节点]
OR[OpenResty]
LOG[access.log]
PROC[主机 /proc 与磁盘]
STUB[stub_status 连接数]
AG[Agent]
OR -->|log_format 写行| LOG
LOG -->|仅 tail 增量明细| AG
PROC -->|读数快照| AG
STUB -->|瞬时连接| AG
OR -->|健康探测| AG
end
subgraph server [控制面 Server]
HB[心跳 / WS 接收]
CH[(ClickHouse)]
AGG[聚合查询层]
API[管理端 API]
HB --> CH
CH --> AGG
AGG --> API
end
subgraph ui [管理端]
DASH[看板:全局业务趋势]
ZONE[Zone:按域名过滤]
NODE[节点:主机资源 + 健康]
end
AG -->|AccessLogs + HostSnapshot + Health| HB
API --> DASH
API --> ZONE
API --> NODE
```
### 5.2 职责矩阵
| 能力 | Agent | Server | 前端 |
| --- | --- | --- | --- |
| 写 access.log | OpenResty | — | — |
| 读并上报明细 | ✅ | 入库 | — |
| sum/count/uniq/TopN | ❌ | ✅ | 展示 |
| Zone 域名过滤 | ❌ | ✅ | 选择 Zone |
| 主机 CPU/内存/网卡 | 读原值上报 | 差分/平均 | 节点/看板资源区 |
| OpenResty 连接数 | 读瞬时上报 | 最近值 | 节点健康 |
| 业务 24h 入出站 | ❌ | 日志聚合 | 统一称「已提供/接收数据」 |
---
## 6. 指标与字段模型
### 6.1 权威字段表(目标)
#### L1 业务交付(来自访问日志)
| 概念 | 存储字段 | 聚合 | 展示名 |
| --- | --- | --- | --- |
| 请求时间 | `logged_at` | 时间窗过滤 | — |
| 节点 | `node_id` | group | — |
| 客户端 IP | `remote_addr` | `uniq` → UV | 唯一访问者 |
| Host | `host` | group / Zone 映射 | 域名 |
| 路径 | `path` | 可选 | — |
| 状态码 | `status_code` | group | 状态码分布 |
| **已提供数据** | **`bytes_sent`** | **`sum`** | **已提供数据** |
| **接收数据** | **`request_length`** | **`sum`** | **接收数据**(可选展示) |
| 地区 | `region`(Server 解析写入) | group | 来源地区 |
> 说明:OpenResty `log_format` 中 JSON 键名可继续叫 `bytes_sent`,值必须来自 **`$body_bytes_sent`**(与现网一致),表示响应体交付量,即「已提供数据」。
#### L2 边缘健康(瞬时,不做 24h 业务总量)
| 概念 | 字段 | 说明 |
| --- | --- | --- |
| OpenResty 健康 | `openresty_status` / message | 已有 |
| 当前连接 | `openresty_connections` | stub_status |
| (可选)近窗 QPS 粗估 | 仅节点详情「此刻」,**不得**作为 24h 总量权威 | 若实现须标明「瞬时」 |
#### L3 宿主机资源
| 概念 | 字段 | 展示名 |
| --- | --- | --- |
| CPU / 内存 / 磁盘占用 | `host_metrics` | 保持 |
| 网卡累计字节 | `network_rx_bytes` / `network_tx_bytes` | **宿主机网卡入/出站** |
| 磁盘 IO 累计 | `disk_read_bytes` / `disk_write_bytes` | 磁盘读/写 |
### 6.2 已删除字段(无兼容层)
| 原字段 | 处置 | 原因 |
| --- | --- | --- |
| `openresty_tx_bytes` / `openresty_rx_bytes` | **删除** | 业务字节以 access log 为准 |
| `TrafficReport` 及 TopN/窗内 UV | **删除** | 边缘预聚合 |
| Agent state 内业务 lifetime 累计 | 删除 | 违背 P1 |
| Lua shared dict 业务吞吐/窗口请求计数 | 删除 | 非投递主路径 |
### 6.3 命名对照(前端文案强制)
| 禁止混用文案 | 正确文案 | 数据来源 |
| --- | --- | --- |
| OpenResty 出站(指业务量) | **已提供数据** | `sum(bytes_sent)` |
| OpenResty 入站(指业务量) | **接收数据** | `sum(request_length)` |
| 网络出站(未说明) | **宿主机网卡出站** | `network_tx` 差分 |
| 已提供数据 vs 出站 两套卡片 | **只保留一套业务卡片** | 日志 |
---
## 7. Agent 设计
### 7.1 心跳载荷(目标协议)
保留并强化:
```text
NodePayload
identity / version / openresty_status / openresty_message # 最新态 → PG
profile # 主机概况(低频)
host_metrics # L3 资源读数(含网卡累计原值)
edge_health # L2:status + connections(CH 时序;message 不进 CH)
access_logs[] # L1 明细(主路径)
health_events[]
buffered[] # 缓冲的是上述事实,不是报表
waf_ip_group_checksums
```
协议中已删除(无兼容层):
```text
traffic_report
openresty_observation
snapshot / buffered_observability 别名
```
### 7.2 Access log 上报要求
每条明细至少包含:
| 字段 | 必填 | 备注 |
| --- | --- | --- |
| `logged_at_unix` | ✅ | 请求完成时间 |
| `remote_addr` | ✅ | UV |
| `host` | ✅ | Zone 映射 |
| `path` | ✅ | 可截断 |
| `status_code` | ✅ | |
| `bytes_sent` | ✅ | body 字节,已提供数据 |
| `request_length` | ✅ | 接收数据 |
Agent 职责:
1. 按 offset tail `access.log`(截断/轮转时重置 offset,**只上报文件中仍存在的新行**)。
2. 结构化解析后批量放入心跳 / WS。
3. 离线写入本地 buffer,连通后按窗口补传。
4. **不对明细做 sum/count/uniq。**
### 7.3 主机 Snapshot
* 继续上报网卡/磁盘 **累计计数器原值**(非业务预聚合)。
* Server 侧对累计值做相邻采样非负差分 → 宿主机趋势。
* 这与「已提供数据」无关,UI 必须分区展示。
### 7.4 OpenResty 本地观测
**收敛后建议:**
* 保留:健康检查、`stub_status` 当前连接。
* 删除主路径依赖:`log.lua` 中对 request/status/domain/rx/tx 的 shared dict 业务计数,以及 `/openflare/observability` 作为 TrafficReport 来源。
* 若短期内保留 endpoint 供调试,不得再写入 Server 权威分析表。
### 7.5 与 Agent 设计文档的关系
本设计强化 [Agent 与发布模型](./agent-design.md) 中的「纯粹数据落地」:
* 配置与证书:落地与上报应用状态。
* 观测:只搬运事实,不搬运业务结论。
---
## 8. Server 设计
### 8.1 入库
| 输入 | 表 | 说明 |
| --- | --- | --- |
| `access_logs[]` | `of_node_access_logs` | 权威业务明细 |
| `host_metrics` | `of_node_metric_snapshots` | L3;网卡/磁盘累计 |
| `openresty_status` / `openresty_message` | **PG 节点表** | L2 **最新态权威**(message 仅此) |
| `edge_health` | `of_node_edge_health` | L2 时序:status + connections(**无 message**) |
GeoIP:继续在 Server 入库路径解析 `remote_addr` → `region`,不在 Agent 做。
### 8.2 聚合层(统一)
所有业务趋势与 Zone 统计共用同一查询语义:
```text
过滤:logged_at ∈ [since, until]
可选:node_id / host IN (...)
指标:
request_count = count()
unique_visitors = uniqExact(remote_addr)
bytes_provided = sum(bytes_sent) -- 已提供数据
bytes_received = sum(request_length) -- 接收数据
按 hour/bucket 折叠 series
按 status_code / host / region 分布
```
实现位置:
* Zone:`GET .../zones/:id/stats`(已有,对齐字段命名)
* 看板:overview 的 traffic / 业务网络趋势 **改为调用同一聚合**(全局、无 host 过滤或 Top 过滤)
* 节点详情:业务量 = 该 `node_id` 过滤的同一聚合;主机网卡仍走 metric 差分
### 8.3 派生汇总(可选性能路径)
当明细查询在 24h 全量节点上过重时,允许 **Server 侧** 物化视图:
```text
of_access_log_hourly
(hour, node_id, host, request_count, bytes_sent, bytes_received, ...)
```
约束:
* 仅由 CH 从 `of_node_access_logs` 派生,**禁止** Agent 直接写该表。
* Zone / 看板优先读 rollup,缺口回退明细(与现有 metric hourly 策略类似)。
### 8.4 停用的分析路径
| 路径 | 迁移后 |
| --- | --- |
| `BuildNetworkTrendPoints` 对 openresty_rx/tx 差分 | 删除或仅保留 network_* 主机曲线 |
| `of_node_obs_openresty` 吞吐字段 | 停止写入;TTL 过期后删表或缩列 |
| `of_node_request_reports` + traffic hourly | 业务趋势不再依赖;可整表废弃 |
| Dashboard compact 中 openresty_tx 序列 | 改为 bytes_provided 序列 |
---
## 9. API 与前端
### 9.1 语义统一的响应字段
建议在业务统计 API 中统一使用:
```json
{
"request_count": 0,
"unique_visitors": 0,
"bytes_provided": 0,
"bytes_received": 0,
"series": [
{
"bucket_started_at": "...",
"request_count": 0,
"unique_visitors": 0,
"bytes_provided": 0,
"bytes_received": 0
}
]
}
```
API 业务字节字段使用 `bytes_provided` / `bytes_received`(访问日志聚合);不再返回 openresty 吞吐别名。
### 9.2 看板
* **业务区**:请求趋势、已提供数据、接收数据(可选)、状态码、Top 域名、来源地区 —— 全部 L1。
* **资源区**:CPU/内存、**宿主机网卡**、磁盘 IO —— 全部 L3。
* **禁止**:在业务区展示「OpenResty 入/出站」作为与 Zone 对账的指标。
「24 小时网络与磁盘趋势」建议拆分或改标题:
* 「24 小时业务流量」→ `bytes_provided` / `bytes_received` / 请求
* 「24 小时宿主机网络与磁盘」→ `network_*` / `disk_*`
### 9.3 Zone `/websites/:id`
* 保持「已提供的数据总计」等卡片。
* 数据与看板业务区 **同一聚合函数**,仅 `hosts = zone 域名列表`。
* 文档与 UI 可注明:全局看板含全部 Host;本页仅本 Zone。
### 9.4 节点详情
* 业务吞吐:该节点 `sum(bytes_sent)` 等。
* OpenResty:健康 + 当前连接。
* 网卡:明确「宿主机」。
---
## 10. OpenResty 与日志格式
### 10.1 保持
现有 JSON `log_format` 核心字段:
```text
ts, host, path, remote_addr, status, request_time,
bytes_sent (= $body_bytes_sent), request_length
```
### 10.2 变更
* 不再依赖 log phase 写入业务 shared dict 计数作为控制面输入。
* 观测端口请求继续不写业务统计(或 access_log off)。
### 10.3 Agent 解析
* 协议 `NodeAccessLog` 增加 `request_length`。
* 旧日志行缺字段时按 0,不阻断整批。
---
## 11. 升级与迁移(无兼容层)
### 11.1 阶段回顾(已落地)
| 阶段 | 内容 |
| --- | --- |
| **M1–M5** | 读路径切 access log;协议 v2;停预聚合;edge_health + access_log_hourly;删旧表与 API 兼容字段 |
### 11.2 升级策略
* **Agent:销毁重建优先**;允许二进制替换。
* 二进制替换时:本地旧观测缓冲(含 `snapshot` / `openresty_observation` / `traffic_report`)**整文件删除**,运行后重建。
* Server **不**解析 v1 字段,**不**双读 request_reports / openresty 吞吐。
* 明细缺失时段:业务图为空或仅部分;**不得**用网卡或已删除的 openresty 吞吐冒充已提供数据。
### 11.3 数据回填
* 历史「已提供数据」以 access log 为准。
* `of_access_log_hourly` 创建前历史用 goose 回填 SQL(ANTI JOIN 防重)。
### 11.4 健康状态权威
* **当前态**:PG `openresty_status` / `openresty_message`。
* **时序**:CH `of_node_edge_health`(status + connections;无 message)。
### 11.5 UV
* **整窗独立访客**:`uniqExact(remote_addr)`(看板合计、Zone 合计)。
* **分桶 UV**(Zone 曲线):桶内 uniq,**不可跨桶相加**;UI 须标明。
* **小时趋势路径**:不绘 / 不填分时 UV(hourly 表不含 UV)。
---
## 12. 存储与容量
* 业务趋势依赖明细或 hourly rollup,需关注 `of_node_access_logs` TTL 与采样。
* 若明细量过大:优先 **Server 侧 rollup**,而不是恢复 Agent 预聚合。
* 可对 path 高基数场景限制明细 path 长度(已有),聚合默认不按完整 path 做全局 Top。
---
## 13. 验证标准
### 13.1 对账
在仅有单一 Zone 产生流量的环境:
```text
看板「已提供数据」(24h) ≈ Zone「已提供的数据总计」(24h)
误差仅来自时间窗对齐(整点截断)与未计入 Host
```
多 Zone 时:
```text
sum(各 Zone 已提供) + sum(未归属 Host) = 全局已提供
```
### 13.2 回归
* Agent 单测:只解析与 offset,不出现业务 sum 断言为「上报契约」。
* Server:Zone stats 与 dashboard business traffic 共用聚合测例。
* 前端:文案快照/测试中不再出现业务含义的「OpenResty 出站」与「已提供数据」双卡片。
### 13.3 性能
* 24h 看板聚合 P95 可接受(必要时 hourly MV)。
* 心跳 payload 体积:明细批量有上限;超限拆缓冲,不在 Agent 做摘要替代。
---
## 14. 风险与权衡
| 风险 | 缓解 |
| --- | --- |
| 明细量大导致 CH 与心跳变重 | 批量、压缩、采样策略评估;Server rollup;限制单次条数 |
| 短暂丢失日志导致业务量偏低 | 本地 buffer 与轮转处理;监控 access log 采集滞后 |
| 用户仍对比「网卡出站」与「已提供」 | UI 分区与文案强制「宿主机」前缀 |
| 旧 Agent 长期在线 | **无兼容层**;必须升级/重建 Agent |
**为何不保留 Agent 预聚合作为优化?**
* 省带宽的代价是再次分裂真相、口径漂移、本次问题重演。
* 优化应落在 Server 派生表与查询,而不是边缘业务计算。
---
## 15. 关键决策摘要
| 决策 | 选择 | 否决方案 |
| --- | --- | --- |
| 业务流量真相 | 访问日志 | OpenResty dict / TrafficReport |
| Agent 角色 | 只上报事实 | 边缘 UV/TopN/吞吐累计 |
| 「出站」与「已提供」 | 合并为已提供数据 | 双字段双管道长期并存 |
| 网卡流量 | 独立 L3,单独文案 | 与业务出站并列对账 |
| 性能 | CH rollup | Agent 预聚合 |
| 迁移 | 先切读路径再瘦身 Agent | 先删明细依赖预聚合 |
---
## 16. 文档与代码映射(落地时)
| 区域 | 主要路径 |
| --- | --- |
| 协议 | `pkg/protocol/agent.go` |
| Agent 采集 | `internal/apps/agent/observability/`、`heartbeat/` |
| OpenResty 日志与 Lua | `pkg/render/openresty/`、`internal/apps/agent/nginx/observability_assets.go` |
| Server 入库 | `internal/apps/openflare/agent/observability.go` |
| 日志聚合 | `internal/repository/analytics/node_access_log*.go`、`internal/apps/openflare/zone/stats.go` |
| 看板 | `internal/apps/openflare/dashboard/`、`internal/apps/openflare/observability/analytics.go` |
| 前端 | `frontend/app/(main)/page.tsx`、`components/dashboard/*`、`websites/.../zone-overview.tsx` |
实现计划见:`docs/plan/20260717-observability-redesign.md`。
**推荐阅读顺序:**
1. **[观测数据传输模型](./observability-transport-model.md)**(最新:传什么、从哪采、频率、示例 JSON)
2. [Agent 上报协议与观测落库数据模型](./observability-data-model.md)(协议字段与 DDL)
---
## 17. 修订记录
| 日期 | 说明 |
| --- | --- |
| 2026-07-17 | 初稿:针对双真相、Agent 预聚合、字段冗余给出目标架构与迁移阶段 |
| 2026-07-17 | 增补协议/表结构专章链接 `observability-data-model.md` |
@@ -0,0 +1,503 @@
# 边缘观测数据传输模型(现行目标版)
> **本文是「Agent ↔ Server 观测数据怎么传」的最新权威说明。**
> 读完应能回答:传什么、从哪采、多久采一次、Server 怎么存、产品指标从哪查。
> 协议字段与 DDL 细节另见 [观测上报协议与表结构](./observability-data-model.md);问题背景见 [边缘可观测与业务流量统计](./observability-design.md)。
---
## 0. 先记住三层(不要混)
| 层 | 回答的问题 | 唯一数据来源 | 产品例子 |
| --- | --- | --- | --- |
| **L1 业务交付** | 提供了多少数据?多少请求? | **access.log 明细** | 已提供数据、请求数、UV、状态码、Top 域名 |
| **L2 边缘健康** | OpenResty 活着吗?现在多少连接? | **本机 `/openflare/observability`** | 节点健康、当前连接 |
| **L3 宿主机资源** | CPU/内存/磁盘/网卡怎样? | **操作系统读数** | 容量趋势、宿主机网卡 |
**三层互不对账。**
「已提供数据」≠「当前连接」≠「宿主机网卡出站」。
---
## 1. 总览:谁采集、谁上报、谁聚合
```text
┌─────────────────────────────────────────────────────────────┐
│ 边缘节点 │
│ │
│ 访客请求 ──► OpenResty │
│ │ │
│ ├─ access.log(每请求一行) ←── L1 采集点 │
│ │ │
│ └─ 连接状态(进程内维护) │
│ │ │
│ ▼ │
│ GET /openflare/observability ←── L2 读快照 │
│ (不扫日志、不重算业务量) │
│ │
│ 操作系统 /proc 等 ────────────────────── L3 读快照 │
│ │
│ ┌────────── Agent ──────────┐ │
│ │ 默认每 3s 组一包 NodePayload │ │
│ │ · tail access.log 增量 │ │
│ │ · GET 本机 observability │ │
│ │ · 读 host_metrics │ │
│ └────────────┬──────────────┘ │
└─────────────────────────────│──────────────────────────────────┘
│ HTTP 心跳 或 WebSocket status
▼
┌─────────────────────────────────────────────────────────────┐
│ Server(控制面) │
│ · 明细 → ClickHouse of_node_access_logs │
│ · 健康 → 节点最新态 + of_node_edge_health │
│ · 主机 → of_node_metric_snapshots │
│ · 业务趋势 / Zone 统计 = 只对 access_logs 做 sum/count/uniq │
└─────────────────────────────────────────────────────────────┘
```
| 角色 | 做什么 | 不做什么 |
| --- | --- | --- |
| OpenResty | 写 access.log;维护连接数 | 不向控制面直接上报 |
| Agent | **采集事实并上报** | **不算** UV/TopN/24h 已提供数据 |
| Server | 入库 + **聚合解释** | 不信任边缘业务预汇总 |
---
## 2. 采集频率(默认)
| 动作 | 默认频率 | 配置 |
| --- | --- | --- |
| Agent → Server 上报 | **每 3 秒** 一次完整 payload | `heartbeat_interval` / 控制面 `agent_heartbeat_interval`(毫秒,默认 `3000`) |
| 组包时 tail access.log | **随上报**(两次上报之间的新行) | 同上 |
| 组包时 GET `/openflare/observability` | **随上报**(读**当前**连接快照) | 同上 |
| 组包时读主机指标 | **随上报** | 同上 |
| OpenResty 写 access.log | **每个请求结束时** 1 行 | 与心跳无关 |
| 连接数在进程内更新 | **连接变化时**(内核维护) | 与心跳无关 |
| 离线补传窗口 | 默认保留约 **60 分钟** | `observability_replay_minutes` |
| 节点离线判定 | 约 **60 秒** 无成功心跳 | `node_offline_threshold`(默认 `60000` 毫秒) |
**说明:**
- Agent **没有**单独的「采样时钟」;**采样点 = 上报点**(默认 3s)。
- access.log 是「请求级连续写入」;Agent 只是周期性 **搬运增量行**。
- `/openflare/observability` **不是**「被调用才开始统计业务」;对连接而言是 **读 Nginx 已有瞬时值**。
传输通道:
- **HTTP 心跳**:按间隔 POST 整包。
- **WebSocket**:连通后按同一间隔发 `status` 消息(内容同构);此时不再走 HTTP 心跳双发。
---
## 3. Agent → Server 数据包(NodePayload v2)
### 3.1 结构骨架
```json
{
"schema_version": 2,
"node_id": "n_01hxyz",
"name": "edge-shanghai-1",
"ip": "203.0.113.10",
"version": "3.4.0",
"ext_version": "",
"current_version": "20260718-abc",
"last_error": "",
"profile": { },
"host_metrics": { },
"edge_health": { },
"access_logs": [ ],
"buffered": [ ],
"health_events": [ ],
"waf_ip_group_checksums": { }
}
```
| 字段 | 层 | 含义 |
| --- | --- | --- |
| 身份/版本/last_error | 控制 | 节点是谁、跑什么版本 |
| `profile` | 低频概况 | 主机名、核数等(变化才报) |
| `access_logs` | **L1** | 访问明细增量 |
| `edge_health` | **L2** | OpenResty 健康 + 当前连接 |
| `host_metrics` | **L3** | CPU/内存/磁盘/网卡读数 |
| `buffered` | 补传 | 离线期间攒的事实批次 |
| `health_events` | 事件 | 如 openresty_unhealthy |
| `waf_ip_group_checksums` | 同步 | 非观测湖 |
**协议已删除(无兼容层,旧 Agent 必须升级):**
- `traffic_report`
- `openresty_observation`(含 rx/tx)
- `snapshot` / `buffered_observability`
- 业务含义的 openresty 吞吐字段
---
## 4. L1 业务:access_logs
### 4.1 采集从哪里来
| 步骤 | 位置 | 说明 |
| --- | --- | --- |
| 1 | OpenResty `log_format openflare_json` | 每请求写一行 JSON 到 `access_log_path` |
| 2 | Agent 按文件 offset **tail 增量** | 两次心跳之间的新行 |
| 3 | 解析后放入 `access_logs[]` | 可截断过长 path;**不做 sum/count** |
日志格式(OpenResty 变量):
```text
ts ← $time_iso8601
host ← $host
path ← $request_uri
remote_addr ← $remote_addr
status ← $status
request_time ← $request_time
bytes_sent ← $body_bytes_sent 【已提供数据 = 响应体字节】
request_length← $request_length 【接收数据】
user_agent ← $http_user_agent
cache_status ← $upstream_cache_status 【缓存状态;UI 可推导命中/回源/未缓存】
```
观测端口请求 **不写** 业务 access.log(独立 server `access_log off`)。
### 4.2 上报示例
```json
"access_logs": [
{
"logged_at_unix": 1721289601,
"remote_addr": "198.51.100.20",
"host": "www.example.com",
"path": "/api/v1/ping",
"status_code": 200,
"bytes_sent": 1024,
"request_length": 128,
"request_time_ms": 15,
"user_agent": "curl/8.0",
"cache_status": "MISS"
},
{
"logged_at_unix": 1721289602,
"remote_addr": "198.51.100.21",
"host": "www.example.com",
"path": "/index.html",
"status_code": 200,
"bytes_sent": 8192,
"request_length": 300,
"request_time_ms": 8,
"user_agent": "Mozilla/5.0",
"cache_status": "HIT"
}
]
```
| 字段 | 解释 |
| --- | --- |
| `bytes_sent` | **已提供数据**(单请求);全局/Zone 合计 = Server `sum` |
| `request_length` | **接收数据**(单请求) |
| `logged_at_unix` | 请求完成时间(业务时间轴) |
| `host` | 用于 Zone 域名过滤 |
| `cache_status` | `$upstream_cache_status` 原样;详情/列表可推导三态(命中/回源/未缓存);**不上报** upstream 地址 |
| 无 `region` | **Server 入库时** GeoIP 写入 |
### 4.3 Server 如何用(产品指标)
| 产品指标 | 算法(仅 L1) |
| --- | --- |
| 已提供数据 | `sum(bytes_sent)` |
| 接收数据 | `sum(request_length)` |
| 请求数 | `count()` |
| UV | `uniqExact(remote_addr)` |
| 状态码分布 | `group by status_code` |
| Top 域名 | `group by host` |
| Zone 页 | 同上 + `host IN (该 Zone 域名)` |
| 看板业务区 | 同上,全局或 Top 过滤 |
落库表:`of_node_access_logs`(可选 Server 侧 `of_access_log_hourly` 加速,**Agent 不写**)。
### 4.4 频率再强调
```text
请求发生 ──立即──► 写 access.log
Agent 每 3s ──搬运──► 这 3s 内新行(可能 0 行,也可能很多行)
Server ──立即/批量──► CH
```
业务量正确性 **不依赖** 3s 对齐;3s 只影响「明细到达控制面的延迟」和单包条数。
---
## 5. L2 健康:edge_health 与 `/openflare/observability`
### 5.1 本机监测口(合并后目标)
**只保留一个接口:**
```http
GET http://127.0.0.1:{openresty_observability_port}/openflare/observability
```
默认端口:**18081**(`openresty_observability_port`)。
**职责:** 回答「OpenResty 此刻怎样」,**不**回答业务已提供多少数据。
#### 返回示例(目标 JSON)
```json
{
"ok": true,
"captured_at_unix": 1721289600,
"connections": {
"active": 42,
"reading": 0,
"writing": 1,
"waiting": 41
}
}
```
| 字段 | 是否瞬时 | 从哪来 | 说明 |
| --- | --- | --- | --- |
| `ok` | 当次探测 | 能返回 200 即 true | 探活 |
| `captured_at_unix` | 采样时刻 | `ngx.time()` | 与上报对齐 |
| `connections.active` | **瞬时** | Nginx 连接状态(原 stub_status Active) | 当前活跃连接 |
| `reading` / `writing` / `waiting` | **瞬时** | 同上细分 | 可选但建议带 |
**不返回(已从目标模型删除):**
| 旧字段 | 原因 |
| --- | --- |
| `request_count` / `error_count` / UV / status_codes / top_domains | 业务窗汇总,改由 access log |
| `openresty_rx_bytes` / `openresty_tx_bytes` | 与已提供/接收数据重复且易错 |
| `source_countries` | 从未实现;国家走 Server GeoIP |
| `server.accepts/handled/requests` | 进程累计 counter,易与业务请求混淆;主路径不收录 |
**`/openflare/stub_status`:** 合并进上述 JSON 后 **删除**(过渡期可双挂,Agent 只打合并口)。
### 5.2 采集机制(读快照,不是「调用才开始统计业务」)
```text
Nginx 在连接建立/释放时维护 Active connections 等
│
Agent GET /openflare/observability
│
只读取「当前值」拼 JSON 返回
```
- **不是** GET 一次才去扫 access.log。
- **不是** 60 秒业务均值。
- 是 **瞬时 gauge 快照**。
### 5.3 上报示例(装进 NodePayload)
```json
"edge_health": {
"captured_at_unix": 1721289600,
"status": "healthy",
"message": "",
"connections": 42
}
```
| 字段 | 来源 |
| --- | --- |
| `status` / `message` | Agent 健康探测(配置校验/进程等,可与观测口 `ok` 配合);须与顶层 `openresty_status` / `openresty_message` 对齐 |
| `connections` | 观测口 `connections.active` |
**落库拆分(权威源):**
| 内容 | 写入 |
| --- | --- |
| 最新 `status` + `message` | **PG 节点表**(UI / 列表 / 告警) |
| 时序 `status` + `connections` | **CH `of_node_edge_health`**(**无 message**) |
---
## 6. L3 主机:host_metrics
### 6.1 采集从哪里来
Agent 读本机(如 `/proc`、磁盘统计等),**每次组包时读一次**。
| 字段 | 语义 | 说明 |
| --- | --- | --- |
| `cpu_usage_percent` | 瞬时 | 当前 CPU% |
| `memory_*` / `storage_*` | 瞬时用量/总量 | 占用率在 Server 或展示层算 |
| `disk_read_bytes` / `disk_write_bytes` | **累计 counter** | 内核累计 IO |
| `network_rx_bytes` / `network_tx_bytes` | **累计 counter** | **宿主机网卡**,不是已提供数据 |
### 6.2 上报示例
```json
"host_metrics": {
"captured_at_unix": 1721289600,
"cpu_usage_percent": 12.5,
"memory_used_bytes": 4294967296,
"memory_total_bytes": 16106127360,
"storage_used_bytes": 50000000000,
"storage_total_bytes": 107374182400,
"disk_read_bytes": 9000000000,
"disk_write_bytes": 12000000000,
"network_rx_bytes": 500000000000,
"network_tx_bytes": 800000000000
}
```
### 6.3 Server 如何处理累计字段
```text
存原值时间序列
展示「这段时间网卡出站」时:
delta = 本次 - 上次
若 delta < 0 → 视为重启/计数器归零,本段增量记 0,从新基线继续
若 delta >= 0 → 记入该时段增量
```
- Agent **上报原值**,不在边缘算 24h 总量。
- **禁止** 对累计原值做 `sum` 当业务量。
- 文案必须是 **「宿主机网卡」**,禁止叫「已提供数据 / OpenResty 出站」。
落库:`of_node_metric_snapshots`(可选 capacity hourly MV)。
---
## 7. 一次完整上报示例(拼起来)
```json
{
"schema_version": 2,
"node_id": "n_01hxyz",
"name": "edge-shanghai-1",
"ip": "203.0.113.10",
"version": "3.4.0",
"ext_version": "",
"current_version": "20260718-abc",
"last_error": "",
"host_metrics": {
"captured_at_unix": 1721289600,
"cpu_usage_percent": 12.5,
"memory_used_bytes": 4294967296,
"memory_total_bytes": 16106127360,
"storage_used_bytes": 50000000000,
"storage_total_bytes": 107374182400,
"disk_read_bytes": 9000000000,
"disk_write_bytes": 12000000000,
"network_rx_bytes": 500000000000,
"network_tx_bytes": 800000000000
},
"edge_health": {
"captured_at_unix": 1721289600,
"status": "healthy",
"message": "",
"connections": 42
},
"access_logs": [
{
"logged_at_unix": 1721289595,
"remote_addr": "198.51.100.20",
"host": "www.example.com",
"path": "/",
"status_code": 200,
"bytes_sent": 4096,
"request_length": 200,
"request_time_ms": 12
}
],
"buffered": [],
"health_events": [],
"waf_ip_group_checksums": {
"1": "d41d8cd98f00b204e9800998ecf8427e"
}
}
```
**Server 落库示意:**
| payload 块 | 写入 |
| --- | --- |
| `access_logs[0]` | CH 一行,`bytes_sent=4096`,`region` 由 GeoIP 填 |
| `edge_health` | 节点 `openresty_status=healthy`,connections=42 |
| `host_metrics` | CH metric 一行累计/瞬时字段 |
**产品查询示意(24h):**
- 已提供数据 = 该节点(或全局)日志 `sum(bytes_sent)`
- 当前连接 = 最新 `edge_health.connections`
- 宿主机网卡出站 = metric 上 `network_tx` 非负差分之和
三者数字 **不必相等**。
---
## 8. 离线补传 `buffered`
Agent 上报失败时,把 **同一类事实** 按窗口缓存在本地(默认约 60 分钟),恢复后塞进 `buffered[]`:
```json
"buffered": [
{
"captured_at_unix": 1721289500,
"host_metrics": { },
"edge_health": { },
"access_logs": [ ]
}
]
```
- 只装事实,不装旧 TrafficReport。
- Server 处理逻辑与主字段相同。
---
## 9. 端到端时序(默认 3s)
```text
t=0.0s 访客请求完成 → 写 access.log 一行;连接数可能变化
t=0.1s 又一请求 → 又一行 log
…
t=3s Agent 心跳:
· 读走 2 行 access_logs
· GET observability → connections=42
· 读 host_metrics
· 发给 Server
t=3s+ Server 入库;看板/Zone 查询时聚合日志
t=6s 下一轮…
```
---
## 10. 旧模型对照(帮助消歧)
| 旧做法 | 新模型 |
| --- | --- |
| Lua dict 60s 窗 request_count + Agent 10s 拉 + Server sum | **删除**;请求数 = 日志 count |
| openresty_tx 当「出站」 | **删除**;已提供数据 = `sum(bytes_sent)` |
| 两个口 observability + stub_status | **合并为一个** observability,只返回连接/探活 |
| TrafficReport 预聚合 | **删除**;协议与 API 均无此路径 |
| 业务与网卡混称「流量」 | **分文案、分 API、分表** |
| 健康 status/message | **PG 最新态权威**;CH 仅 status+连接时序 |
---
## 11. 配置与实现索引
| 项 | 位置/键 |
| --- | --- |
| 心跳间隔 | Agent `heartbeat_interval`;控制面 `agent_heartbeat_interval`(默认 3000ms) |
| 离线阈值 | 控制面 `node_offline_threshold`(默认 60000ms) |
| 观测端口 | `openresty_observability_port`(默认 18081) |
| access.log 路径 | `access_log_path` |
| 补传分钟数 | `observability_replay_minutes`(默认 60) |
| 协议类型 | `pkg/protocol/agent.go`(落地时按 v2 演进) |
| 表结构 DDL | [observability-data-model.md](./observability-data-model.md) |
---
## 12. 修订记录
| 日期 | 说明 |
| --- | --- |
| 2026-07-18 | 初稿:作为「最新传输模型」单页说明——三层、频率、示例 JSON、采集来源、与旧模型对照 |
| 2026-07-18 | 默认上报间隔 3s;离线阈值 60s;补传窗口 60 分钟 |
| 2026-07-18 | M5:edge_health 表、access_log_hourly、废弃 request_reports/obs_openresty 吞吐表 |
| 2026-07-18 | 无兼容层:删除「兼容期可忽略」表述;健康 message 仅 PG、CH 无 message |
+100 -59
View File
@@ -12,18 +12,30 @@
2. **多节点分发困难**:当控制面管理多台边缘节点时,将静态文件同步分发到所有节点,并确保文件一致性,需要维护复杂的同步脚本(如 rsync 等)。
3. **回滚缺乏一致性**:一旦新前端包发布失败或存在严重缺陷,不仅要恢复静态文件,还要恢复对应的反代规则,很难做到原子回滚。
为了解决这些问题,OpenFlare 引入了受 Cloudflare Pages 启发的 **Pages 静态托管** 功能。该功能将“前端部署包上传”与“网站代理规则配置”合二为一,依托 OpenFlare 的 pull-based(拉取式)协同架构,实现静态文件分发与反代配置发布的强一致性、不可变性与一键秒级回滚。
为了解决这些问题,OpenFlare 引入了受 Cloudflare Pages 启发的 **Pages 静态托管** 功能。该功能将“预构建产物导入”与“网站代理规则配置”纳入同一控制面,依托 OpenFlare 的 pull-based(拉取式)协同架构,以不可变 deployment、单节点原子切换和周期对账实现多 Agent 最终收敛,并支持快速回滚。
---
## 核心功能
Pages 静态托管子系统包含以下核心能力:
* **Direct Upload 部署模式**:支持直接上传预构建的 `.zip` 静态资源包,省去复杂的 Git 集成和构建环境依赖。
* **不可变部署快照**:每次上传产生一个带唯一 ID 和 SHA-256 Checksum 的不可变部署记录。历史包永久保留,支持随时激活和回滚。
* **预构建产物部署**:支持直接上传静态资源压缩包,也可为项目保存一个 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 代理,消除跨域问题,将请求转发给指定的后端服务。
* **安全包校验与解压缩**:内置 Zip-Slip 路径逃逸防御、防软链接劫持、文件大小/数量硬上限控制,保障节点物理安全。
* **安全包校验与解压缩**:内置路径逃逸防御、防软链接劫持、文件大小/数量上限与可配置上传包体积控制,保障节点物理安全。
* **可配置限额**:管理员可在运维设置中调整「部署包大小上限」与「历史部署保留数」。
### 部署源与未来构建边界
项目当前支持 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 项目创建后来源不可切换的限制。
---
@@ -39,33 +51,41 @@ graph TD
OpenResty -->|2. 转发 API 代理| BackEnd[4. 后端 API 服务]
%% 控制流与心跳
Server[OpenFlare Server 控制面] <-->|Agent API / Heartbeat| Agent[openflare-agent 进程]
Server -.->|5. 存储 ZIP 部署包| 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`)**:
* 存储每次部署的完整静态文件树路径、文件大小及单个文件哈希。用于审计和后续校验。
* **部署源配置 (`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` 且该项目存在已激活的部署时,才允许将该路由加入发布流程。
@@ -84,45 +104,69 @@ 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"
}
```
### 3. 与主配置版本的双轨关系(项目锚点 + latest 拉取)
* **主配置版本**与 **Pages 部署** 是两套独立的版本体系。
* 主配置中 Pages 路由的稳定锚点是 **`pages_project_id`(项目 ID)**,不是某次部署 ID。
* OpenResty `root` 使用项目级路径:`__OPENFLARE_PAGES_DIR__/projects/{project_id}/current`,激活切换时路径不变,无需为换包而重发主配置。
* Agent 按项目请求「最新激活包」(类似 `github/release/latest`):
* `GET /api/v1/agent/pages/projects/:project_id/latest/hash`
* `GET /api/v1/agent/pages/projects/:project_id/latest/package`
* 控制面根据该项目**当前激活部署**返回 deployment ID、哈希、包大小与展开清单元数据。Agent 用 deployment ID 与其它 latest 元数据识别下载期间的指针竞态,但主配置和本地目录的稳定锚点仍是 project ID。
* 因此:在项目内切换激活部署后,**不必发布主配置**;Agent 在周期性对账时轮询 latest hash,发现变化即下载并切换 `current`。
* 快照中的 `pages_deployment` 字段仍可记录发布时元数据(入口文件、SPA/API 代理等),但不作为 Agent 拉包的版本锁定。
---
## Server 端 (控制面) 职责与生命周期
### 1. ZIP 包安全校验与分析
为了避免不可信的用户上传恶意压缩包攻击服务器,控制面在 `UploadPagesDeployment` 时执行严格的流式校验:
* **大小限制**:ZIP 压缩包不得超过 25 MiB(保守的 V1 默认值),且展开后的解压总体积不得超过 100 MiB。
### 1. 部署包安全校验与分析
为了避免不可信产物攻击服务器,控制面对本地上传和所有外部来源执行同一套严格校验:
* **格式支持**:`zip`、`tar.gz` / `tgz`、`tar.xz` / `txz`、`tar.bz2` / `tbz2`、`tar`、`7z`。
* **大小限制**:压缩包体积由系统配置 `pages_max_package_size_mb` 控制(默认 100 MiB,范围 1~2048);展开后的单文件与总体积上限为「包大小 × 4」且不低于 100 MiB。inspect 始终流式读取常规文件体,核对声明大小与实际字节并按实际值执行上限。
* **数量限制**:压缩包中包含的静态文件总数不得超过 1,000 个。
* **软链接阻断**:遍历 ZIP 文件,一旦检测到任何软链接 (`os.ModeSymlink`),立即抛出错误并拒绝上传,防御软链接劫持攻击。
* **Zip-Slip 防御**:对每个压缩文件路径进行 `Clean` 并检查是否包含 `..` 或以 `/` 开头,防御目录跨越漏洞,防止写入系统敏感路径。
* **入口文件校验**:项目指定的入口文件(例如 `index.html`,可在 `project.RootDir` 下)必须在 ZIP 压缩包中存在,否则拒绝上传。
* **公共根目录去噪**:许多打包工具(如 GitHub 导出的 zip)会包含一个多余的主文件夹作为公共根前缀。控制面自动探测公共根前缀并将其安全剥离。
* **软链接阻断**:遍历归档文件,一旦检测到任何软链接,立即抛出错误并拒绝上传,防御软链接劫持攻击。
* **路径逃逸防御**:对每个压缩文件路径进行 `Clean` 并检查是否包含 `..` 或以 `/` 开头,防御目录跨越漏洞,防止写入系统敏感路径。
* **入口文件校验**:项目指定的入口文件(例如 `index.html`,可在 `project.RootDir` 下)必须在部署包中存在,否则拒绝上传。
* **公共根目录去噪**:许多打包工具会包含一个多余的主文件夹作为公共根前缀。控制面自动探测公共根前缀并将其安全剥离。
* **整包完整性**:上传/导入时对压缩包字节计算一次 SHA-256,写入部署记录;Agent 拉包后按整包 hash 对账。包内单文件不做内容哈希。
* **实际体积复核**:`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. 部署包存储规划
控制面仅将 zip 文件存储在本地存储目录 `artifacts/{project_slug}/{checksum}.zip`,并在数据库中记录路径和清单。**大体积静态包不写入 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. 校验式增量拉取
1. Agent 解析激活配置中的 `SourceConfigJSON`,检索出所有 `UpstreamType == "pages"` 的路由引用的部署 `DeploymentID` 和 `Checksum`。
2. 检查本地部署目录是否存在正确的版本标记文件 `.openflare-pages.json`,且 `Checksum` 匹配。
3. 若不匹配,通过专属接口 `GET /api/agent/pages/deployments/:id/package` 下载对应的部署包。下载请求头必须携带节点独有的 `X-Agent-Token` 用于 Server 鉴权。
### 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` 流式下载到临时文件,执行真实响应上限与 SHA-256;下载后 **再次请求 hash**,避免激活切换造成的竞态,不一致则有限次重试。
4. 请求头携带节点 `X-Agent-Token`。
### 2. 安全解压缩与原子切换
为了保证配置应用过程的“无缝”且能在出错时立即回滚:
1. Agent 将下载的部署包数据写入临时目录,并重新计算 SHA-256 Checksum。如果与配置指明的 checksum 不符,立即报错并阻断发布流程。
2. 解压部署包至临时目录 `releases/{checksum}.tmp`。解压时同样执行 Zip-Slip 目录跨越和软链接校验防御。
3. 解压成功后,写入标记文件 `.openflare-pages.json`。
4. 清理 `releases/{checksum}` 目录,将整个临时目录重命名为 `releases/{checksum}`。
5. **原子切换**:建立拷贝当前部署的物理副本到目标位置 `deployments/{deployment_id}/current`。切换前先备份上一版本的 `current`,一旦重载配置失败,Agent 能够快速恢复 `current` 目录并回滚 OpenResty。
6. **定时清理**:每次配置成功应用后,Agent 自动比对本地部署目录,将所有不活跃的(即未被当前激活版本引用的)历史部署包和文件夹进行物理删除,释放磁盘空间。
### 2. 安全解压缩、原子切换与只保留最新
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. 多项目对账时 **隔离失败**:单个项目失败记日志并继续其它项目,最后汇总返回错误。
---
@@ -132,13 +176,13 @@ Agent 运行在各边缘代理节点上,在应用配置版本前,必须先
### 1. 静态服务指令渲染
* **`root` 与 `index`**:
Server 根据配置将 `root` 指向 Agent 的 Pages 动态目录占位符 `__OPENFLARE_PAGES_DIR__/deployments/{deployment_id}/current`,并在此基础上追加项目的 `RootDir`。`index` 指向设置的入口文件。
Server 将 `root` 指向项目级占位路径 `__OPENFLARE_PAGES_DIR__/projects/{project_id}/current`(可再追加 `RootDir`)。激活切换只换目录内容,路径不变,无需为换包重发主配置。
```nginx
server {
listen 80;
server_name myapp.example.com;
root "/var/lib/openflare/pages/deployments/12/current";
root "/var/lib/openflare/pages/projects/3/current";
index "index.html";
...
}
@@ -193,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 一级行政区数据;国家选项同时显示本地化名称与代码,行政区支持按国家名、行政区名或代码搜索,避免一次渲染数千个选项。
+1277 -162
View File
File diff suppressed because it is too large Load Diff
+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,99 @@
# 边缘可观测与业务流量统计重构 — 实现计划
说明:本计划对应设计文档 [observability-design.md](../design/observability-design.md)。重大架构重构,按阶段交付,避免一次大爆炸。
---
## 0. 落地进度(2026-07-18)
* [x] M1 看板业务趋势改读 access log;网络图文案改为已提供/接收 + 宿主机网卡
* [x] M2 协议 v2 字段(host_metrics/edge_health/request_length);CH 列 `request_length`/`request_time_ms`
* [x] M3 Agent:观测口仅健康连接;payload 不再发 TrafficReport;access_logs 带 request_length
* [x] M4 Server:停写 TrafficReport;openresty 仅存 connections;明细入库带 request_length
* [x] 分布图 status/top domains + 节点行请求/UV 改 access log;24h UV 用 uniqExact;API bytes_provided/received
* [x] M5:`of_node_edge_health`、`of_access_log_hourly`(+MV);删除 request_reports/traffic_hourly/openresty_hourly/obs_openresty;写入/查询改道
* [x] 收尾:清 openresty hourly / request_report 死路径;edge_health 写全 status;cleanup 命名 `node_edge_health`;hourly 回填 SQL + UV 策略文档
* [x] 协议/API 去兼容层(Agent 销毁重建):删除 TrafficReport / openresty_observation / snapshot 别名 / request_reports API 字段 / openresty_rx|tx
* [x] 前端 UV 文案:24h/查询窗口独立访客;趋势图不绘分时 UV
* [ ] 真实环境 ClickHouse 迁移 + `202607180003` 回填(本机 Docker 未起时需运维执行)
## 1. 目标与背景 (Goal & Context)
* **需求背景**:看板「OpenResty 入/出站」与 Zone「已提供数据」不一致;Agent 预聚合与访问日志双轨;`openresty_tx` 与 `bytes_sent` 业务语义重复。
* **开发范围 (Scope)**:
* **必做**:业务趋势统一为访问日志聚合;UI 字段与文案收敛;协议补齐 `request_length`;停用预聚合作为权威源;Agent 瘦身。
* **后续**:废弃 CH 表清理、hourly rollup 性能优化、Relay 指标对齐。
* **Out of Scope**:通用日志平台、替换 ClickHouse、APM。
---
## 2. 设计与决策 (Design & Decisions)
* **核心对象**:以 `of_node_access_logs` 为 L1 权威;主机 snapshot 为 L3;OpenResty 仅健康/连接为 L2。
* **传输模型(示例与频率)**:见 [observability-transport-model.md](../design/observability-transport-model.md)。
* **协议与表结构**:见 [observability-data-model.md](../design/observability-data-model.md)(NodePayload v2、落库流水线、DDL、废弃表)。
* **API**:看板与 Zone 共用聚合语义;`bytes_provided` / `bytes_received`(兼容 `bytes_sent` 别名)。
* **数据流**:见 [observability-design.md](../design/observability-design.md) §5。
* **权衡**:性能用 Server 侧 rollup,不恢复 Agent 预聚合。
---
## 3. 阶段与修改清单 (Proposed Changes)
### 阶段 M1 — 读路径切换(优先对账)
* #### [MODIFY] `internal/apps/openflare/dashboard/*`、`observability/analytics.go`
* 业务 24h 趋势改为 access log 聚合(全局)。
* 网络趋势中业务曲线与主机网卡分离。
* #### [MODIFY] 前端 dashboard 组件与文案
* 「OpenResty 出站/入站」→「已提供数据/接收数据」或拆卡片。
* #### [MODIFY] Zone stats 字段对齐(如需别名)
* **验收**:单 Zone 流量时看板已提供 ≈ Zone 已提供。
### 阶段 M2 — 协议与入库补齐
* #### [MODIFY] `pkg/protocol/agent.go` — `NodeAccessLog.request_length`
* #### [MODIFY] Agent 解析与 CH 写入列
* #### [MODIFY] goose ClickHouse migration(如缺列)
### 阶段 M3 — 停写预聚合权威路径
* #### [MODIFY] Server persist:TrafficReport / openresty rx/tx 不再驱动看板
* 可选:直接停写以减 CH 压力
### 阶段 M4 — Agent 瘦身
* #### [MODIFY] 移除 TrafficReport 构建主路径、Lua 业务 dict 计数、state 内业务累计
* #### [MODIFY] 心跳仅明细 + snapshot + 连接/健康
### 阶段 M5 — 清理
* 删除废弃 API 字段、前端类型、CH 表/MV、相关测试夹具
* 更新 agent-design / changelog(代码变更时)
---
## 4. 验证计划 (Verification Plan)
### 自动化
* `go test`:zone stats、dashboard 聚合、agent access log 解析
* 前端:zone / dashboard 文案与字段测试
### 手动
* 制造已知大小响应,对比 Zone 与看板 24h 已提供数据
* 确认宿主机网卡曲线与业务已提供数据分区展示、数值可不一致且文案不诱导对账
### 质量门禁
* `make swagger`(若 API 变更)
* `make code-check`
* `make prettier`
---
## 5. 依赖与风险
* 明细量大时 M1 需同步评估 hourly rollup(仍 Server 侧)。
* 旧 Agent 无 `request_length` 时接收数据为空,需 UI 降级。
@@ -0,0 +1,63 @@
# 访问日志 cache_status 明细可见 — 实现计划
说明:对应设计 [observability-data-model.md §3.5.1](../design/observability-data-model.md)。第一期只做明细可见,不上报 upstream 地址。
---
## 1. 目标与背景
* **需求背景**:访问日志无法判断请求是否命中边缘缓存、是否回源。
* **开发范围 (Scope)**:
* **必做**:OpenResty 日志输出 `$upstream_cache_status`;Agent 上报;CH 入库;列表/详情展示三态标签。
* **Out of Scope**:命中率看板、hourly 维度、`upstream_addr`。
---
## 2. 设计与决策
* **唯一字段**:`cache_status` string(原始值)。
* **UI 三态(不落库)**:
* 命中:`HIT` / `STALE` / `REVALIDATED` / `UPDATING`
* 回源:`MISS` / `EXPIRED`
* 未缓存:`BYPASS` / `-` / 空
* **数据流**:log_format → Agent parse → protocol → Server model → CH → API → 前端明细。
---
## 3. 修改清单
### 边缘 / 协议
* `pkg/render/openresty/types.go`、`internal/model/openflare_option.go`:`log_format` 增加 `cache_status`
* `internal/apps/agent/observability/traffic.go`:解析与映射
* `pkg/protocol/agent.go`:`NodeAccessLog.CacheStatus`
### Server / CH
* goose:`202607180005_access_log_cache_status.sql`
* `internal/model/analytics/node_access_log.go`、writer、list/scan、store 映射
* `internal/model/openflare_observability.go`、agent build records
* API `AccessLogView` + list 响应带 `cache_status`
### 前端
* types / 明细列表标签 / 详情字段
* 三态 helper:`resolveCacheOutcome(cache_status)`
---
## 4. 验证
* `go test ./internal/apps/agent/observability/ ./internal/repository/analytics/ ./internal/apps/openflare/agent/`
* `make swagger`(若 Handler 响应结构变更)
* `make code-check` / `make prettier`
---
## 5. 落地进度
* [x] log_format + protocol + agent parse
* [x] CH migration + 写入/读取
* [x] API + 前端明细展示
* [x] 缓冲去重 key 含 cache_status;保留 `-` 原始值
* [x] 测试与提交
@@ -0,0 +1,38 @@
# 边缘缓存默认 static 策略 — 实现计划
对应设计:[edge-cache-design.md](../design/edge-cache-design.md)
## 目标
路由开启缓存后,**新建推荐**仅缓存标准静态扩展名(`static`);存量 `url`/空策略映射为 `all`,不收窄缓存范围。
## 兼容规则(评审后定稿)
| 场景 | 行为 |
| --- | --- |
| 已启用 + `''` / `url` | 读 API / 快照 / 渲染 → **`all`** |
| 写入时 enabled 且 policy 为空 | 规范为 **`all`**(旧客户端兼容) |
| UI 新建/推荐默认 | **显式提交** `static` |
| 关闭缓存 | policy 存 `''`,rules 清空 |
## 修改清单
1. **渲染** `pkg/render/openresty/render.go`:`static` 内置扩展名;空/`url`/`all` 无路径限制
2. **校验/展示** `proxy_route/helpers.go`:`normalizeCachePolicy` + `displayCachePolicy`
3. **快照** `config_version/logics.go`:`normalizeSnapshotCachePolicy`
4. **前端** `cache-section.tsx` + helpers:存量 empty/url→`all`;关闭时提交 `''`;新建默认 `static`
5. **测试** render + proxy_route
6. **设计/changelog** 同步兼容说明
## 验证
```bash
go test ./pkg/render/openresty/ ./internal/apps/openflare/proxy_route/ ./internal/apps/openflare/config_version/
# 已通过(2026-07-18)
```
## 状态
- [x] 功能实现 + 评审修复(empty→all,禁止静默收窄)
- [ ] 提交 `fix(cache): ...`(待用户确认)
- [ ] 合并 / 发布后需重新发布节点配置
@@ -0,0 +1,71 @@
# ClickHouse 观测表迁移与小时汇总回填(运维手册)
适用:M5 观测存储(`of_node_edge_health`、`of_access_log_hourly`、删旧表)及历史小时回填。
## 前提
* 控制面 `config.yaml` / 环境变量中 ClickHouse 已启用,账号可写 `openflare` 库。
* 备份策略已就绪(可选:对 `of_node_access_logs` 做快照)。
* **Agent 升级策略为销毁重建**;勿混跑旧 Agent(旧协议字段已从 Server 删除)。
## 1. 自动迁移(推荐)
进程启动时 `migrator.MigrateClickHouse()` 会按 goose 顺序执行:
| 版本 | 作用 |
| --- | --- |
| `202607180001` | access log 增加 `request_length` / `request_time_ms` |
| `202607180002` | 建 `of_node_edge_health`、`of_access_log_hourly`(+MV);删 request_reports / openresty 吞吐表 |
| `202607180003` | 从明细 ANTI JOIN 回填近 90 天 `of_access_log_hourly` |
启动 API / all 模式一次即可:
```bash
# 示例:本地
./bin/openflare api
# 或
make run # 以项目实际入口为准
```
查看 goose 版本表(ClickHouse)确认三版本均已应用。
## 2. 仅回填(迁移已执行、MV 创建前缺历史)
若只需重跑回填 SQL:
```bash
clickhouse-client --host 127.0.0.1 --port 9000 \
--user default --password "$CLICKHOUSE_PASSWORD" \
--database openflare \
--multiquery < internal/db/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql
```
(goose 文件含 `+goose Up` 注释,若 client 报错可去掉注释行后执行 INSERT 主体。)
回填可重复:`ANTI JOIN` 跳过已有 `(node_id, hour, host)`。
## 3. 验收
```sql
-- 新表存在
SHOW TABLES FROM openflare LIKE 'of_node_edge_health';
SHOW TABLES FROM openflare LIKE 'of_access_log_hourly';
-- 旧表应不存在
SHOW TABLES FROM openflare LIKE 'of_node_request_reports';
SHOW TABLES FROM openflare LIKE 'of_node_obs_openresty';
-- 小时汇总有数据(有历史访问时)
SELECT count() FROM of_access_log_hourly;
SELECT min(hour), max(hour), sum(request_count) FROM of_access_log_hourly;
```
看板 24h 请求趋势应优先走 hourly;UV 卡片为整窗独立访客,**不等于**小时 UV 之和。
## 4. 本机执行记录
| 日期 | 环境 | 结果 |
| --- | --- | --- |
| 2026-07-18 | 开发机 | Docker daemon 未启动,未能 live 迁移;SQL 与 goose 文件已入库 |
运维在目标环境按 §1–§3 执行后更新本表。
+161
View File
@@ -0,0 +1,161 @@
# 访问日志 IP 明细 Tab — 实现计划
## 1. 目标与背景 (Goal & Context)
* **需求背景**:运维需要按 IP 维度快速查看时间窗内的访问量与流量,并下钻单 IP 情报;原先 IP 分析嵌在「单条访问日志详情」中,入口弱、列表能力缺失。
* **开发范围 (Scope) V1**:
* 访问日志页新增第三 Tab **「IP 明细」**(`?tab=ips`)。
* IP 列表:时间筛选(快捷 24h/7d/15d/30d + 自定义 since/until)、分页、按请求数 / 入站 / 出站 / 最后访问 / 2xx 比例排序。
* 列表列:IP、地区、请求数、2xx 比例(2xx 数 / 总请求)、入站流量、出站流量、最后访问。
* 行详情:弹窗展示完整 **IP 情报**(分析 + 趋势 + Top 分布 + 加入 WAF IP 组)。
* **日志明细详情弹窗仅展示单条请求字段**,不再内嵌 IP 情报;如需分析请到 IP 明细。
* **Out of Scope(V1 不做)**:
* 独立 `/access-logs/ip` 子路由全页。
* IP 列表 UI 暴露节点 / host 筛选(后端可保留兼容参数,前端首版不放)。
* 入/出站带宽时间序列(趋势图仍为请求数)。
* 实时 GeoIP 二次查询(沿用入库 `region`)。
## 2. 设计与决策 (Design & Decisions)
### 2.1 页面与交互
| Tab | URL | 内容 |
| --- | --- | --- |
| 概览 | `/access-logs` | 不变 |
| IP 明细 | `/access-logs?tab=ips` | 新 |
| 日志明细 | `/access-logs?tab=list` | 不变;详情弹窗瘦身 |
* **时间筛选(IP 明细)**:
* 快捷:`hours` ∈ {24, 168, 360, 720},默认 168(7d)。
* 自定义:`since` + `until`(RFC3339);**同时提供时覆盖 hours**。
* **详情形态**:留在列表页的 Dialog(非独立子页)。
* **日志详情瘦身**:`access-log-detail-dialog` 只渲染请求字段(时间、节点、IP、地区、host、path、UA、cache、status 等)及必要操作;删除 analysis/trend/WAF 组内嵌区块。WAF「按 IP 加入组」仅保留在 IP 详情弹窗。
### 2.2 API 设计(扩展现有端点,不新建)
**`GET /api/v1/d/access-logs/ip-summary`**
| 参数 | 说明 |
| --- | --- |
| `hours` | 1–720;默认 168;在无 since/until 时生效 |
| `since` / `until` | 可选 RFC3339;同时有效时优先于 hours |
| `sort_by` | `total_requests`(默认)\| `request_length`(入站)\| `bytes_sent`(出站)\| `last_seen_at` \| `success_ratio` |
| `sort_order` | `asc` \| `desc` |
| `p` / `page_size` | 分页,page_size 上限 200 |
| `remote_addr` / `node_id` / `host` | 兼容保留;V1 UI 可不暴露 |
**响应行字段(扩展)**
```text
remote_addr string
region string // 窗内 argMax(region, logged_at) 或等价
total_requests uint64
success_2xx_count uint64 // status_code 200–299
success_ratio float64 // success_2xx_count / total_requests;total=0 时为 0
bytes_received uint64 // sum(request_length) 入站
bytes_sent uint64 // sum(bytes_sent) 出站
last_seen_at time
```
* `recent_requests`:可停止计算或固定返回 0;**UI 不展示**。避免与可配置时间窗语义冲突。
* 详情下钻仍用现有:
* `GET .../ip-summary/analysis?remote_addr=&hours=`(或 since/until,若后续扩展;V1 将列表当前窗映射为 hours 或 since/until 与后端约定一致)
* `GET .../ip-summary/trend?remote_addr=&hours=&bucket_minutes=`
**分析/趋势时间窗对齐**:打开 IP 详情时,将列表当前时间窗传入 analysis/trend(优先 since/until;仅有 hours 则传 hours)。
### 2.3 数据层(ClickHouse)
* 表:`of_node_access_logs`(已有 `bytes_sent`、`request_length`、`status_code`、`region`)。
* 聚合:`GROUP BY remote_addr`,在 `NodeAccessLogFilter.Since/Until` 上过滤。
* 2xx:`countIf(status_code >= 200 AND status_code < 300)`。
* region:`argMax(region, logged_at)`。
* 排序:服务端 ORDER BY 对应表达式;`success_ratio` 注意除零(`if(total=0,0,ratio)`)。
### 2.4 设计决策权衡
| 选项 | 结论 |
| --- | --- |
| 扩展 `/ip-summary` vs 新 `/ip-list` | **扩展现有**,前端 service 已有 `listIPSummaries` |
| 详情弹窗 vs 子页 | **弹窗**,与现有明细交互一致 |
| IP 情报放日志详情 vs 独立 IP 详情 | **仅 IP 明细详情**;日志详情只展示请求信息 |
| 时间:仅快捷 vs 仅自定义 | **两者都要**,自定义优先 |
### 2.5 数据流(示意)
```mermaid
flowchart LR
UI_IP[IP 明细 Tab] --> API_List[GET /ip-summary]
API_List --> CH[(of_node_access_logs)]
UI_IP --> UI_Dlg[IP 详情 Dialog]
UI_Dlg --> API_A[GET /ip-summary/analysis]
UI_Dlg --> API_T[GET /ip-summary/trend]
API_A --> CH
API_T --> CH
UI_List[日志明细 Tab] --> API_Logs[GET /access-logs]
UI_List --> UI_LogDlg[日志详情 Dialog]
UI_LogDlg -.->|不请求 IP 分析| X[仅请求字段]
```
## 3. 具体修改文件清单 (Proposed Changes)
### 后端 Server
* #### [MODIFY] `internal/repository/analytics/node_access_log_stats.go`(及 filter 如有)
* `IPSummariesNodeAccessLogs`:时间窗、sum 入/出、2xx count、ratio、region、扩展 sort。
* #### [MODIFY] `internal/apps/openflare/observability/access_log_logics.go`
* Query/View 类型扩展;解析 hours/since/until;去掉或忽略 recent 3h 硬编码。
* #### [MODIFY] `internal/apps/openflare/observability/routers.go` / handler
* 绑定新 query;Swagger 注释。
* #### [MODIFY] 相关单元测试(logics / repository 若有)
### 前端 Web
* #### [MODIFY] `frontend/app/(main)/access-logs/page.tsx`
* 第三 Tab `ips`;`resolveTab` / `handleTabChange`。
* #### [NEW] `frontend/app/(main)/access-logs/components/ip-tab.tsx`
* 列表、时间筛选、排序、分页、打开详情。
* #### [NEW] `frontend/app/(main)/access-logs/components/ip-detail-dialog.tsx`
* IP 入口详情壳。
* #### [NEW] `frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx`
* 从现有 `access-log-detail-dialog` **迁出** 分析/趋势/排行/WAF IP 组逻辑。
* #### [MODIFY] `frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx`
* **删除** IP 情报相关 UI 与 `getIPAnalysis` / `getIPTrend` 请求;仅请求日志字段展示。
* #### [MODIFY] `frontend/app/(main)/access-logs/components/access-log-utils.ts`
* tab 类型、IP 排序选项、时间筛选辅助。
* #### [MODIFY] `frontend/lib/services/openflare/access-log.service.ts` + `types.ts`
* `listIPSummaries` 参数与 `AccessLogIPSummaryItem` 字段同步。
### 文档
* #### [MODIFY] `docs/changelog/index.md` — `[Unreleased]` 用户可见说明
* #### [MODIFY] `docs/design/observability-design.md` 或 data-model(如有访问日志 UI 约定)— 补充 IP 明细 Tab 与 API 字段(中文)
* #### [MODIFY] `docs/plan/index.md` — 挂上本计划链接
## 4. 验证计划 (Verification Plan)
### 自动化
```bash
go test ./internal/apps/openflare/observability/ ./internal/repository/analytics/
# 前端:相关 tsc / 页面无类型错误
make code-check # 完成后按项目门禁
make prettier
make swagger # API 注释变更后
```
### 手动
1. `/access-logs?tab=ips` 默认 7d 列表有数据;切换 24h/自定义区间结果变化。
2. 分别按请求数、入站、出站、2xx 比例、最后访问排序正确。
3. 2xx 比例 = 2xx/总数;0 请求不出现 NaN/Infinity。
4. 点 IP 打开详情:指标/趋势/Top/WAF 组可用;时间窗与列表一致。
5. 日志明细 → 详情:仅请求信息,**无** IP 分析/趋势区块。
6. 概览 Tab 行为无回归。
## 5. 状态
- [x] 需求澄清与方案确认
- [x] 实现(后端 ip-summary 扩展 + 前端 IP 明细 Tab + 日志详情瘦身)
- [x] 测试与 changelog(`go test` 相关包通过;changelog 已更新)
- [ ] 提交合并
@@ -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 对比。
+9
View File
@@ -15,6 +15,15 @@
* [Zone 与域名资源重构](./20260712-zone-domain-refactor.md):以 Zone 和正规化 Zone 域名替代托管域名及反代路由中的域名/证书冗余字段。
* [WAF 可编排规则](./20260713-waf-orchestration.md):使用 React Flow 编辑 DAG 规则,发布时编译并由 OpenResty 纯内存执行。
* [边缘可观测与业务流量统计重构](./20260717-observability-redesign.md):访问日志为业务唯一真相;Agent 只上报明细与主机读数;收敛「出站/已提供」双字段。
* [访问日志 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 边界;生产环境验收边界见计划内验证记录。
## 使用建议
+7 -4
View File
@@ -192,9 +192,9 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
| 配置键 (Key) | 数据类型 | 作用说明 | 默认值 |
| --- | --- | --- | --- |
| `agent_discovery_token` | `string` | 新节点首次一键接入并自动注册的全局通用验证发现 Token | 无(系统初始化生成) |
| `agent_heartbeat_interval`| `int` | 控制并向所有接入 Agent 周期下发的标准心跳检测间隔(毫秒) | `10000` (10s) |
| `agent_heartbeat_interval`| `int` | 控制并向所有接入 Agent 周期下发的标准心跳检测间隔(毫秒) | `3000` (3s) |
| `agent_websocket_upgrade_enabled` | `bool` | 是否授权 Agent 在 HTTP 心跳握手成功后升级建立持久 WebSocket 实时连接 | `true` |
| `node_offline_threshold` | `int` | 在管理后台中判定节点失去心跳并标注为离线状态的无响应阈值(毫秒) | `120000` (120s) |
| `node_offline_threshold` | `int` | 在管理后台中判定节点失去心跳并标注为离线状态的无响应阈值(毫秒) | `60000` (60s) |
| `agent_update_repo` | `string` | Agent 节点更新下载自身二进制的 Release 仓库源 | `Rain-kl/OpenFlare` |
| `geoip_provider` | `string` | GeoIP 提供商,支持 `maxmind` 等,用于 WAF 防护时地域分析 | `ipinfo` |
| `database_auto_cleanup_enabled` | `bool` | 是否在每天凌晨 3:00 自动清理过期观测历史日志(降低数据库空间) | `true` |
@@ -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 的底层结构大骨架模板 | 空 (内置缺省骨架) |
---
@@ -323,9 +326,9 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
| `mmdb_download_url` | WAF GeoIP mmdb 周期更新地址 | 否 | GeoLite2 Country 更新地址;首次缺失时从程序内嵌数据库初始化 |
| `city_mmdb_download_url` | WAF City MMDB 周期更新地址 | 否 | GeoLite2 City 更新地址;首次缺失时从程序内嵌数据库初始化 |
| `observability_buffer_path` | 观测补报缓冲文件路径 | 否 | `data_dir/var/lib/openflare/observability-buffer.json` |
| `observability_replay_minutes` | 自动补传最近观测窗口分钟数 | 否 | `15` |
| `observability_replay_minutes` | 自动补传最近观测窗口分钟数 | 否 | `60` |
| `state_path` | Agent 本地状态文件路径 | 否 | `data_dir/var/lib/openflare/agent-state.json` |
| `heartbeat_interval` | 心跳间隔 | 否 | `10000` 毫秒 |
| `heartbeat_interval` | 心跳间隔 | 否 | `3000` 毫秒 |
| `request_timeout` | HTTP 请求超时 | 否 | `10000` 毫秒 |
---
@@ -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 且列表空不限制 |
+1277 -162
View File
File diff suppressed because it is too large Load Diff
+809 -111
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,163 @@
'use client';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { AccessLogItem } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { formatBytes } from '@/lib/utils/metrics';
import { cacheOutcomeLabel, resolveCacheOutcome } from './access-log-utils';
function DetailField({
label,
value,
mono,
full,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
full?: boolean;
}) {
return (
<div className={full ? 'sm:col-span-2 space-y-1' : 'space-y-1'}>
<p className='text-[11px] uppercase tracking-wider text-muted-foreground'>
{label}
</p>
<div
className={`text-sm break-all ${mono ? 'font-mono text-xs' : 'text-foreground'}`}
>
{value}
</div>
</div>
);
}
function formatRequestTimeMs(value: number | undefined | null) {
if (value == null || !Number.isFinite(value) || value < 0) {
return '—';
}
if (value < 1000) {
return `${Math.round(value)} ms`;
}
return `${(value / 1000).toFixed(2)} s`;
}
export function AccessLogDetailDialog({
open,
item,
onOpenChange,
}: {
open: boolean;
item: AccessLogItem | null;
onOpenChange: (open: boolean) => void;
}) {
// Keep last selected item while the dialog closes to avoid empty-state flash.
const [displayItem, setDisplayItem] = useState<AccessLogItem | null>(item);
if (item && item !== displayItem) {
setDisplayItem(item);
}
const activeItem = item ?? displayItem;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] sm:max-w-2xl overflow-y-auto hide-scrollbar'>
<DialogHeader>
<DialogTitle>访问日志详情</DialogTitle>
<DialogDescription>
本条请求的全部业务字段。IP 访问分析请前往「IP 明细」。
</DialogDescription>
</DialogHeader>
{activeItem ? (
<div className='grid gap-4 sm:grid-cols-2'>
<DetailField label='日志 ID' value={activeItem.id || '—'} mono />
<DetailField
label='请求时间'
value={formatDateTime(activeItem.logged_at)}
/>
<DetailField
label='入库时间'
value={
activeItem.created_at
? formatDateTime(activeItem.created_at)
: '—'
}
/>
<DetailField
label='节点'
value={activeItem.node_name || activeItem.node_id || '—'}
/>
<DetailField
label='节点 ID'
value={activeItem.node_id || '—'}
mono
/>
<DetailField
label='客户端 IP'
value={activeItem.remote_addr || '—'}
mono
/>
<DetailField label='地区' value={activeItem.region || '—'} />
<DetailField label='域名' value={activeItem.host || '—'} />
<DetailField
label='状态码'
value={
<Badge variant='outline' className='text-[10px]'>
{activeItem.status_code}
</Badge>
}
/>
<DetailField
label='缓存'
value={
<div className='flex flex-wrap items-center gap-2'>
<Badge variant='outline' className='text-[10px]'>
{cacheOutcomeLabel(
resolveCacheOutcome(activeItem.cache_status),
)}
</Badge>
<span className='font-mono text-xs text-muted-foreground'>
{activeItem.cache_status || '—'}
</span>
</div>
}
/>
<DetailField
label='出站流量'
value={formatBytes(activeItem.bytes_sent ?? 0)}
/>
<DetailField
label='入站流量'
value={formatBytes(activeItem.request_length ?? 0)}
/>
<DetailField
label='请求耗时'
value={formatRequestTimeMs(activeItem.request_time_ms)}
/>
<DetailField
label='路径'
value={activeItem.path || '—'}
full
mono
/>
<DetailField
label='User-Agent'
value={activeItem.user_agent || '—'}
full
mono
/>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -11,11 +11,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { AccessLogTab, SearchDraft } from './access-log-utils';
import type { SearchDraft } from './access-log-utils';
import { PAGE_SIZE_OPTIONS } from './access-log-utils';
interface AccessLogFiltersProps {
tab: AccessLogTab;
draft: SearchDraft;
pageSize: number;
onDraftChange: (draft: SearchDraft) => void;
@@ -25,7 +24,6 @@ interface AccessLogFiltersProps {
}
export function AccessLogFilters({
tab,
draft,
pageSize,
onDraftChange,
@@ -33,8 +31,6 @@ export function AccessLogFilters({
onSearch,
onReset,
}: AccessLogFiltersProps) {
const showPath = tab === 'list' || tab === 'folds';
return (
<div className='space-y-3'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
@@ -81,24 +77,18 @@ export function AccessLogFilters({
className='h-9 text-xs'
/>
</div>
{showPath ? (
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>
请求路径
</p>
<Input
value={draft.path}
onChange={(e) =>
onDraftChange({ ...draft, path: e.target.value })
}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='按路径搜索'
className='h-9 text-xs'
/>
</div>
) : null}
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>请求路径</p>
<Input
value={draft.path}
onChange={(e) => onDraftChange({ ...draft, path: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='按路径搜索'
className='h-9 text-xs'
/>
</div>
</div>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
@@ -1,4 +1,4 @@
export type AccessLogTab = 'list' | 'folds' | 'ip-summary' | 'ip-trend';
export type AccessLogTab = 'overview' | 'ips' | 'list';
export type SearchDraft = {
nodeId: string;
@@ -7,8 +7,31 @@ export type SearchDraft = {
path: string;
};
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: {
value: OverviewRangeHours;
label: string;
}[] = [
{ value: 24, label: '24 小时' },
{ value: 168, label: '7 天' },
{ value: 360, label: '15 天' },
{ 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: '时间从旧到新' },
@@ -18,18 +41,17 @@ export const DETAIL_SORT_OPTIONS = [
{ value: 'remote_addr:desc', label: 'IP 倒序' },
];
export const FOLD_SORT_OPTIONS = [
{ value: 'bucket_started_at:desc', label: '时间桶从新到旧' },
{ value: 'bucket_started_at:asc', label: '时间桶从旧到新' },
{ value: 'request_count:desc', label: '访问次数从高到低' },
{ value: 'request_count:asc', label: '访问次数从低到高' },
];
export const IP_SORT_OPTIONS = [
{ value: 'total_requests:desc', label: '总访问次数从高到低' },
{ value: 'total_requests:asc', label: '总访问次数从低到高' },
{ value: 'recent_requests:desc', label: '3 小时访问次数从高到低' },
{ value: 'last_seen_at:desc', label: '最后访问时间从新到旧' },
{ value: 'total_requests:desc', label: '请求数从高到低' },
{ value: 'total_requests:asc', label: '请求数从低到高' },
{ value: 'request_length:desc', label: '入站从高到低' },
{ value: 'request_length:asc', label: '入站从低到高' },
{ value: 'bytes_sent:desc', label: '出站从高到低' },
{ value: 'bytes_sent:asc', label: '出站从低到高' },
{ value: 'success_ratio:desc', label: '2xx 比例从高到低' },
{ value: 'success_ratio:asc', label: '2xx 比例从低到高' },
{ value: 'last_seen_at:desc', label: '最后访问从新到旧' },
{ value: 'last_seen_at:asc', label: '最后访问从旧到新' },
];
export function parseSortValue(value: string) {
@@ -46,3 +68,54 @@ export function formatCompactNumber(value: number) {
maximumFractionDigits: 1,
}).format(value);
}
export function formatOverviewRangeHint(hours: number) {
if (hours <= 24) return '近 24 小时';
if (hours % 24 === 0) return `近 ${hours / 24} 天`;
return `近 ${hours} 小时`;
}
export function formatOverviewTrendLabel(value: string, hours: number) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hour = `${date.getHours()}`.padStart(2, '0');
if (hours <= 24) {
return `${hour}:00`;
}
return `${month}/${day} ${hour}:00`;
}
export type CacheOutcome = 'hit' | 'origin' | 'uncached';
export function resolveCacheOutcome(
cacheStatus: string | undefined | null,
): CacheOutcome {
const status = (cacheStatus ?? '').trim().toUpperCase();
if (
status === 'HIT' ||
status === 'STALE' ||
status === 'REVALIDATED' ||
status === 'UPDATING'
) {
return 'hit';
}
if (status === 'MISS' || status === 'EXPIRED') {
return 'origin';
}
return 'uncached';
}
export function cacheOutcomeLabel(outcome: CacheOutcome) {
switch (outcome) {
case 'hit':
return '命中';
case 'origin':
return '回源';
default:
return '未缓存';
}
}
@@ -0,0 +1,243 @@
'use client';
import { useState } from 'react';
import { Eye } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { AccessLogItem, AccessLogList } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { AccessLogDetailDialog } from './access-log-detail-dialog';
import {
cacheOutcomeLabel,
DETAIL_SORT_OPTIONS,
resolveCacheOutcome,
type CacheOutcome,
} from './access-log-utils';
function cacheOutcomeVariant(
outcome: CacheOutcome,
): 'default' | 'secondary' | 'outline' | 'destructive' {
switch (outcome) {
case 'hit':
return 'default';
case 'origin':
return 'secondary';
default:
return 'outline';
}
}
function PaginationBar({
page,
hasMore,
loading,
onPrev,
onNext,
}: {
page: number;
hasMore: boolean;
loading: boolean;
onPrev: () => void;
onNext: () => void;
}) {
return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={loading || page <= 0}
onClick={onPrev}
>
上一页
</Button>
<Button
variant='outline'
size='sm'
disabled={loading || !hasMore}
onClick={onNext}
>
下一页
</Button>
</div>
</div>
);
}
export function DetailTab({
data,
loading,
error,
page,
detailSort,
onDetailSortChange,
onRetry,
onPrevPage,
onNextPage,
isFetching,
}: {
data?: AccessLogList;
loading: boolean;
error: Error | null;
page: number;
detailSort: string;
onDetailSortChange: (value: string) => void;
onRetry: () => void;
onPrevPage: () => void;
onNextPage: () => void;
isFetching: boolean;
}) {
const [selected, setSelected] = useState<AccessLogItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
return (
<>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>日志明细</p>
<Select value={detailSort} onValueChange={onDetailSortChange}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || '加载失败'}
onRetry={onRetry}
/>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无访问日志' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间</TableHead>
<TableHead className='text-xs'>节点</TableHead>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>域名</TableHead>
<TableHead className='text-xs'>路径</TableHead>
<TableHead className='text-xs'>缓存</TableHead>
<TableHead className='text-xs'>状态码</TableHead>
<TableHead className='w-12 text-center text-xs' />
</TableRow>
</TableHeader>
<TableBody>
{(data?.items ?? []).map((item) => {
const outcome = resolveCacheOutcome(item.cache_status);
return (
<TableRow key={item.id} className='border-dashed'>
<TableCell className='text-xs'>
{formatDateTime(item.logged_at)}
</TableCell>
<TableCell className='text-xs'>
{item.node_name || item.node_id}
</TableCell>
<TableCell className='text-xs'>
<div className='flex flex-col gap-0.5'>
<span className='font-mono'>{item.remote_addr}</span>
{item.region ? (
<span className='text-[10px] text-muted-foreground'>
{item.region}
</span>
) : null}
</div>
</TableCell>
<TableCell className='text-xs'>{item.host}</TableCell>
<TableCell className='text-xs max-w-48 truncate'>
{item.path}
</TableCell>
<TableCell>
<Badge
variant={cacheOutcomeVariant(outcome)}
className='text-[10px]'
title={item.cache_status || undefined}
>
{cacheOutcomeLabel(outcome)}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{item.status_code}
</Badge>
</TableCell>
<TableCell className='text-center'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => {
setSelected(item);
setDetailOpen(true);
}}
>
<Eye className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看详情
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={data?.has_more ?? false}
loading={isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
<AccessLogDetailDialog
open={detailOpen}
item={selected}
onOpenChange={setDetailOpen}
/>
</>
);
}
@@ -0,0 +1,607 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Loader2, ShieldPlus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { RankChart } from '@/components/data/rank-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 {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
AccessLogService,
type DistributionItem,
type WAFIPGroup,
WafService,
} from '@/lib/services/openflare';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import { buildIPGroupPayloadFromGroup } from '../../waf/components/helpers';
import {
formatOverviewRangeHint,
formatOverviewTrendLabel,
OVERVIEW_RANGE_OPTIONS,
type OverviewRangeHours,
} from './access-log-utils';
const trendChartConfig = {
requests: { label: '请求数', color: 'hsl(var(--primary))' },
} satisfies ChartConfig;
function resolveBucketMinutes(hours: number) {
if (hours <= 24) return 30;
return 60;
}
function groupsContainingIp(groups: WAFIPGroup[], ip: string) {
const target = ip.trim();
if (!target) return [];
return groups.filter((group) =>
(group.ip_list ?? []).some((entry) => entry.trim() === target),
);
}
function toRankItems(items: DistributionItem[] | undefined) {
return (items ?? []).map((item) => ({
label: item.key,
value: item.value,
}));
}
/** Clamp analysis/trend window to API limits (1–720 hours). */
function clampAnalysisHours(hours: number): number {
if (!Number.isFinite(hours) || hours <= 0) return 24;
return Math.min(720, Math.max(1, Math.round(hours)));
}
function isOverviewPreset(hours: number): hours is OverviewRangeHours {
return hours === 24 || hours === 168 || hours === 360 || hours === 720;
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className='rounded-lg border border-dashed px-3 py-2.5'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{label}
</p>
<p className='mt-1 text-lg font-semibold tracking-tight'>{value}</p>
</div>
);
}
function MiniRankCard({
title,
items,
color,
}: {
title: string;
items: { label: string; value: number }[];
color: string;
}) {
return (
<div className='rounded-lg border border-dashed p-3'>
<p className='mb-2 text-sm font-medium'>{title}</p>
<RankChart
items={items}
color={color}
className='!h-[220px]'
emptyMessage={`暂无 ${title} 数据`}
/>
</div>
);
}
function AddToIPGroupPanel({
ip,
open,
onClose,
}: {
ip: string;
open: boolean;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
const groupsQuery = useQuery({
queryKey: ['openflare', 'waf', 'ip-groups'],
queryFn: () => WafService.listIPGroups(),
enabled: open,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const matchedGroups = useMemo(
() => groupsContainingIp(groups, ip),
[groups, ip],
);
const manualGroups = useMemo(
() =>
groups.filter(
(group) => group.type === 'manual' && group.enabled !== false,
),
[groups],
);
const addableGroups = useMemo(
() =>
manualGroups.filter(
(group) =>
!(group.ip_list ?? []).some((entry) => entry.trim() === ip.trim()),
),
[manualGroups, ip],
);
const updateMutation = useMutation({
mutationFn: async ({
group,
nextList,
}: {
group: WAFIPGroup;
nextList: string[];
}) =>
WafService.updateIPGroup(
group.id,
buildIPGroupPayloadFromGroup(group, nextList),
),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['openflare', 'waf', 'ip-groups'],
});
},
});
const handleAdd = async () => {
const groupId = Number.parseInt(selectedGroupId, 10);
const group = addableGroups.find((item) => item.id === groupId);
if (!group) {
toast.error('请选择要加入的 IP 组');
return;
}
try {
await updateMutation.mutateAsync({
group,
nextList: [...(group.ip_list ?? []), ip.trim()],
});
toast.success(`已将 ${ip} 加入 IP 组「${group.name}」`);
setSelectedGroupId('');
} catch (error) {
toast.error(error instanceof Error ? error.message : '加入 IP 组失败');
}
};
const handleRemove = async (group: WAFIPGroup) => {
try {
await updateMutation.mutateAsync({
group,
nextList: (group.ip_list ?? []).filter(
(entry) => entry.trim() !== ip.trim(),
),
});
toast.success(`已从 IP 组「${group.name}」移除 ${ip}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : '移除失败');
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) {
setSelectedGroupId('');
onClose();
}
}}
>
<DialogContent className='max-w-lg'>
<DialogHeader>
<DialogTitle>将 IP 加入 IP 组</DialogTitle>
<DialogDescription>
目标 IP:
<span className='font-mono text-foreground'>{ip}</span>
</DialogDescription>
</DialogHeader>
{groupsQuery.isLoading ? (
<LoadingStateWithBorder title='加载 IP 组' />
) : groupsQuery.isError ? (
<ErrorInline
message={
groupsQuery.error instanceof Error
? groupsQuery.error.message
: '加载 IP 组失败'
}
onRetry={() => void groupsQuery.refetch()}
/>
) : (
<div className='space-y-4'>
{matchedGroups.length > 0 ? (
<div className='space-y-2 rounded-lg border border-dashed p-3'>
<p className='text-sm font-medium text-foreground'>
该 IP 已存在于以下 IP 组
</p>
<p className='text-xs text-muted-foreground'>
可选择删除,或继续添加到其他 IP 组。
</p>
<div className='space-y-2'>
{matchedGroups.map((group) => (
<div
key={group.id}
className='flex items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>
{group.name}
</p>
<p className='text-[11px] text-muted-foreground'>
{group.type} · {group.ip_list?.length ?? 0} 条
</p>
</div>
{group.type === 'manual' ? (
<Button
variant='outline'
size='sm'
className='h-8 shrink-0 text-destructive'
disabled={updateMutation.isPending}
onClick={() => void handleRemove(group)}
>
{updateMutation.isPending ? (
<Loader2 className='size-3.5 animate-spin' />
) : (
<>
<Trash2 className='mr-1 size-3.5' />
删除
</>
)}
</Button>
) : (
<Badge variant='outline' className='text-[10px]'>
不可手动删除
</Badge>
)}
</div>
))}
</div>
</div>
) : (
<p className='text-sm text-muted-foreground'>
该 IP 尚未加入任何 IP 组。
</p>
)}
<div className='space-y-2'>
<p className='text-sm font-medium'>添加到其他 IP 组</p>
{addableGroups.length === 0 ? (
<p className='text-xs text-muted-foreground'>
没有可写入的手动 IP 组(或已全部包含该 IP)。
</p>
) : (
<Select
value={selectedGroupId}
onValueChange={setSelectedGroupId}
>
<SelectTrigger className='h-9 text-xs'>
<SelectValue placeholder='选择手动 IP 组' />
</SelectTrigger>
<SelectContent>
{addableGroups.map((group) => (
<SelectItem key={group.id} value={String(group.id)}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={onClose}>
关闭
</Button>
<Button
onClick={() => void handleAdd()}
disabled={
!selectedGroupId ||
updateMutation.isPending ||
addableGroups.length === 0
}
>
{updateMutation.isPending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
处理中...
</>
) : (
'加入所选 IP 组'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function IpAnalysisPanel({
ip,
enabled,
initialHours = 24,
}: {
ip: string;
enabled: boolean;
/**
* Exact analysis window in hours (1–720), aligned with list filter duration.
* Remount with key={ip} when switching IPs so this re-initializes.
*/
initialHours?: number;
}) {
const [ipGroupOpen, setIpGroupOpen] = useState(false);
const [rangeHours, setRangeHours] = useState(() =>
clampAnalysisHours(initialHours),
);
const bucketMinutes = resolveBucketMinutes(rangeHours);
const rangeHint = isOverviewPreset(rangeHours)
? formatOverviewRangeHint(rangeHours)
: `近 ${rangeHours} 小时`;
const trendQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'ip-trend',
ip,
rangeHours,
bucketMinutes,
],
queryFn: () =>
AccessLogService.getIPTrend({
remote_addr: ip,
hours: rangeHours,
bucket_minutes: bucketMinutes,
}),
enabled: enabled && ip !== '',
});
const analysisQuery = useQuery({
queryKey: ['openflare', 'access-logs', 'ip-analysis', ip, rangeHours],
queryFn: () =>
AccessLogService.getIPAnalysis({
remote_addr: ip,
hours: rangeHours,
}),
enabled: enabled && ip !== '',
});
const trendChartData = useMemo(() => {
return (trendQuery.data?.points ?? []).map((point) => ({
label: formatOverviewTrendLabel(point.bucket_started_at, rangeHours),
requests: point.request_count,
}));
}, [rangeHours, trendQuery.data?.points]);
const analysis = analysisQuery.data;
const isLoadingIP = trendQuery.isLoading || analysisQuery.isLoading;
const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching;
if (!ip) {
return <EmptyStateWithBorder description='没有有效 IP。' />;
}
return (
<>
<div className='space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<Button
size='sm'
variant='outline'
onClick={() => setIpGroupOpen(true)}
>
<ShieldPlus className='mr-1 size-3.5' />将 IP 加入到 IP 组
</Button>
<ToggleGroup
type='single'
value={isOverviewPreset(rangeHours) ? String(rangeHours) : ''}
onValueChange={(value) => {
if (!value) return;
setRangeHours(clampAnalysisHours(Number.parseInt(value, 10)));
}}
variant='outline'
size='sm'
>
{OVERVIEW_RANGE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{isLoadingIP ? (
<LoadingStateWithBorder title='加载 IP 分析' />
) : (
<div className='space-y-4'>
{analysisQuery.isError ? (
<ErrorInline
message={
analysisQuery.error instanceof Error
? analysisQuery.error.message
: '加载 IP 分析失败'
}
onRetry={() => void analysisQuery.refetch()}
/>
) : analysis ? (
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
<MetricCard
label='总请求'
value={formatCompactNumber(analysis.summary.total_requests)}
/>
<MetricCard
label='错误数'
value={formatCompactNumber(analysis.summary.error_count)}
/>
<MetricCard
label='已提供带宽'
value={formatBytes(analysis.summary.bandwidth_served)}
/>
<MetricCard
label='接收数据'
value={formatBytes(analysis.summary.bytes_received)}
/>
<MetricCard
label='独立域名'
value={formatCompactNumber(analysis.summary.unique_hosts)}
/>
<MetricCard
label='独立路径'
value={formatCompactNumber(analysis.summary.unique_paths)}
/>
</div>
) : null}
<div className='space-y-3 rounded-lg border border-dashed p-4'>
<div className='flex items-center justify-between gap-2'>
<div>
<p className='text-sm font-medium'>IP 请求趋势</p>
<p className='text-xs text-muted-foreground'>
{ip} · {rangeHint} · {bucketMinutes} 分钟桶
</p>
</div>
<Button
size='sm'
variant='ghost'
disabled={isFetchingIP}
onClick={() => {
void trendQuery.refetch();
void analysisQuery.refetch();
}}
>
刷新
</Button>
</div>
{trendQuery.isError ? (
<ErrorInline
message={
trendQuery.error instanceof Error
? trendQuery.error.message
: '加载趋势失败'
}
onRetry={() => void trendQuery.refetch()}
/>
) : trendChartData.every((point) => point.requests === 0) ? (
<EmptyStateWithBorder
description={`该 IP 在${rangeHint}内没有访问记录。`}
/>
) : (
<ChartContainer
config={trendChartConfig}
className='h-56 w-full'
>
<AreaChart data={trendChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey='label'
tickLine={false}
axisLine={false}
fontSize={10}
minTickGap={24}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
width={40}
tickFormatter={(value) =>
formatCompactNumber(Number(value))
}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type='monotone'
dataKey='requests'
stroke='var(--color-requests)'
fill='var(--color-requests)'
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
)}
</div>
{analysis ? (
<div className='grid gap-3 md:grid-cols-2'>
<MiniRankCard
title='Top Paths'
color='#a78bfa'
items={toRankItems(analysis.top_paths)}
/>
<MiniRankCard
title='Top Hosts'
color='#34d399'
items={toRankItems(analysis.top_hosts)}
/>
<MiniRankCard
title='Status Codes'
color='#f59e0b'
items={toRankItems(analysis.status_codes)}
/>
<MiniRankCard
title='Top User-Agents'
color='#818cf8'
items={toRankItems(analysis.top_user_agents)}
/>
<MiniRankCard
title='Device Types'
color='#38bdf8'
items={toRankItems(analysis.device_types)}
/>
<MiniRankCard
title='Top Browsers'
color='#22c55e'
items={toRankItems(analysis.top_browsers)}
/>
</div>
) : null}
</div>
)}
</div>
<AddToIPGroupPanel
ip={ip}
open={ipGroupOpen}
onClose={() => setIpGroupOpen(false)}
/>
</>
);
}
@@ -0,0 +1,58 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IpAnalysisPanel } from './ip-analysis-panel';
export function IpDetailDialog({
open,
remoteAddr,
region,
initialHours,
onOpenChange,
}: {
open: boolean;
remoteAddr: string | null;
region?: string;
initialHours?: number;
onOpenChange: (open: boolean) => void;
}) {
const [displayAddr, setDisplayAddr] = useState<string | null>(remoteAddr);
const [displayRegion, setDisplayRegion] = useState(region);
if (remoteAddr && remoteAddr !== displayAddr) {
setDisplayAddr(remoteAddr);
setDisplayRegion(region);
}
const ip = remoteAddr ?? displayAddr ?? '';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] sm:max-w-6xl md:max-w-6xl overflow-y-auto hide-scrollbar'>
<DialogHeader>
<DialogTitle>IP 详情</DialogTitle>
<DialogDescription>
<span className='font-mono text-foreground'>{ip || '—'}</span>
{displayRegion ? (
<span className='text-muted-foreground'> · {displayRegion}</span>
) : null}
。查看该 IP 的访问趋势、分布与 WAF IP 组操作。
</DialogDescription>
</DialogHeader>
<IpAnalysisPanel
key={ip}
ip={ip}
enabled={open && ip !== ''}
initialHours={initialHours}
/>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,506 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Eye } from 'lucide-react';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type {
AccessLogIPSummaryItem,
AccessLogIPSummaryList,
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import {
formatOverviewRangeHint,
IP_SORT_OPTIONS,
OVERVIEW_RANGE_OPTIONS,
PAGE_SIZE_OPTIONS,
type OverviewRangeHours,
} from './access-log-utils';
import { IpDetailDialog } from './ip-detail-dialog';
export function toLocalInputValue(date: Date) {
const pad = (n: number) => `${n}`.padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/** Default local range ending now, spanning `hours`. */
export function defaultLocalRangeForHours(hours: number) {
const until = new Date();
const since = new Date(until.getTime() - hours * 3_600_000);
return {
since: toLocalInputValue(since),
until: toLocalInputValue(until),
};
}
function localInputToRFC3339(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toISOString();
}
const MAX_CUSTOM_RANGE_MS = 30 * 24 * 3_600_000;
/** Validate custom local datetime-local range; returns error message or null. */
export function validateCustomTimeRange(
sinceLocal: string,
untilLocal: string,
): string | null {
if (!sinceLocal.trim() || !untilLocal.trim()) {
return '请填写开始与结束时间';
}
const sinceMs = new Date(sinceLocal).getTime();
const untilMs = new Date(untilLocal).getTime();
if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) {
return '时间格式无效';
}
if (untilMs <= sinceMs) {
return '结束时间必须晚于开始时间';
}
if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) {
return '时间范围不能超过 30 天';
}
return null;
}
function formatRatio(ratio: number) {
if (!Number.isFinite(ratio) || ratio < 0) return '0%';
return `${(ratio * 100).toFixed(1)}%`;
}
/** Exact hours for analysis API (1–720), matching list window duration. */
export function resolveAnalysisHours(input: {
timeMode: IpTabTimeMode;
hours: OverviewRangeHours;
customSince: string;
customUntil: string;
}): number {
if (input.timeMode === 'custom' && input.customSince && input.customUntil) {
const ms =
new Date(input.customUntil).getTime() -
new Date(input.customSince).getTime();
if (Number.isFinite(ms) && ms > 0) {
return Math.min(720, Math.max(1, Math.ceil(ms / 3_600_000)));
}
}
return input.hours;
}
function PaginationBar({
page,
hasMore,
loading,
totalIp,
onPrev,
onNext,
}: {
page: number;
hasMore: boolean;
loading: boolean;
totalIp?: number;
onPrev: () => void;
onNext: () => void;
}) {
return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>
当前第 {page + 1} 页
{typeof totalIp === 'number' ? ` · 共 ${totalIp} 个 IP` : ''}
</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={loading || page <= 0}
onClick={onPrev}
>
上一页
</Button>
<Button
variant='outline'
size='sm'
disabled={loading || !hasMore}
onClick={onNext}
>
下一页
</Button>
</div>
</div>
);
}
export type IpTabTimeMode = 'preset' | 'custom';
export function IpTab({
data,
loading,
error,
page,
pageSize,
sort,
hours,
timeMode,
customSince,
customUntil,
onHoursChange,
onTimeModeChange,
onApplyCustomRange,
onPageSizeChange,
onSortChange,
onRetry,
onPrevPage,
onNextPage,
isFetching,
}: {
data?: AccessLogIPSummaryList;
loading: boolean;
error: Error | null;
page: number;
pageSize: number;
sort: string;
hours: OverviewRangeHours;
timeMode: IpTabTimeMode;
/** Applied custom range (local datetime-local strings). */
customSince: string;
customUntil: string;
onHoursChange: (hours: OverviewRangeHours) => void;
onTimeModeChange: (mode: IpTabTimeMode) => void;
/** Commit validated custom range for list query. */
onApplyCustomRange: (since: string, until: string) => void;
onPageSizeChange: (size: number) => void;
onSortChange: (value: string) => void;
onRetry: () => void;
onPrevPage: () => void;
onNextPage: () => void;
isFetching: boolean;
}) {
const [selected, setSelected] = useState<AccessLogIPSummaryItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [draftSince, setDraftSince] = useState(customSince);
const [draftUntil, setDraftUntil] = useState(customUntil);
const [customError, setCustomError] = useState<string | null>(null);
const defaultCustomRange = useMemo(
() => defaultLocalRangeForHours(hours),
[hours],
);
useEffect(() => {
if (timeMode !== 'custom') {
setCustomError(null);
return;
}
// Sync draft from applied range when entering custom or after apply.
setDraftSince(customSince || defaultCustomRange.since);
setDraftUntil(customUntil || defaultCustomRange.until);
setCustomError(null);
}, [timeMode, customSince, customUntil, defaultCustomRange]);
const analysisHours = resolveAnalysisHours({
timeMode,
hours,
customSince,
customUntil,
});
const rangeHint =
timeMode === 'custom' && customSince && customUntil
? '自定义区间'
: timeMode === 'custom'
? '自定义(未应用)'
: formatOverviewRangeHint(hours);
const handleApplyCustom = () => {
const message = validateCustomTimeRange(draftSince, draftUntil);
if (message) {
setCustomError(message);
return;
}
setCustomError(null);
onApplyCustomRange(draftSince, draftUntil);
};
return (
<>
<div className='space-y-4'>
<div className='rounded-lg border border-dashed bg-background p-4 space-y-3'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='space-y-1'>
<p className='text-sm font-medium'>时间范围</p>
<p className='text-xs text-muted-foreground'>
当前:{rangeHint}
{data?.total_ip != null
? ` · ${formatCompactNumber(data.total_ip)} 个 IP`
: ''}
</p>
</div>
<div className='flex flex-wrap items-center gap-2'>
<ToggleGroup
type='single'
value={timeMode === 'preset' ? String(hours) : ''}
onValueChange={(value) => {
if (!value) return;
onTimeModeChange('preset');
onHoursChange(
Number.parseInt(value, 10) as OverviewRangeHours,
);
}}
variant='outline'
size='sm'
>
{OVERVIEW_RANGE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
<Button
size='sm'
variant={timeMode === 'custom' ? 'default' : 'outline'}
className='h-8 text-xs'
onClick={() => onTimeModeChange('custom')}
>
自定义
</Button>
</div>
</div>
{timeMode === 'custom' ? (
<div className='space-y-2'>
<div className='flex flex-wrap items-end gap-3'>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>开始</p>
<Input
type='datetime-local'
className='h-9 w-52 text-xs'
value={draftSince || defaultCustomRange.since}
onChange={(e) => {
setDraftSince(e.target.value);
setCustomError(null);
}}
/>
</div>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>结束</p>
<Input
type='datetime-local'
className='h-9 w-52 text-xs'
value={draftUntil || defaultCustomRange.until}
onChange={(e) => {
setDraftUntil(e.target.value);
setCustomError(null);
}}
/>
</div>
<Button
size='sm'
className='h-9 text-xs'
onClick={handleApplyCustom}
>
应用
</Button>
</div>
{customError ? (
<p className='text-xs text-destructive'>{customError}</p>
) : (
<p className='text-xs text-muted-foreground'>
修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。
</p>
)}
</div>
) : null}
<div className='flex flex-wrap items-center gap-3'>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>排序</p>
<Select value={sort} onValueChange={onSortChange}>
<SelectTrigger className='h-8 w-48 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{IP_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>每页</p>
<Select
value={String(pageSize)}
onValueChange={(value) =>
onPageSizeChange(Number.parseInt(value, 10))
}
>
<SelectTrigger className='h-8 w-24 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>IP 明细</p>
</div>
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || '加载失败'}
onRetry={onRetry}
/>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无 IP 数据' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>地区</TableHead>
<TableHead className='text-xs text-right'>请求数</TableHead>
<TableHead className='text-xs text-right'>2xx 比例</TableHead>
<TableHead className='text-xs text-right'>入站</TableHead>
<TableHead className='text-xs text-right'>出站</TableHead>
<TableHead className='text-xs'>最后访问</TableHead>
<TableHead className='w-12 text-center text-xs' />
</TableRow>
</TableHeader>
<TableBody>
{(data?.items ?? []).map((item) => (
<TableRow key={item.remote_addr} className='border-dashed'>
<TableCell className='font-mono text-xs'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{item.region || '—'}
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
{formatCompactNumber(item.total_requests)}
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
<span
title={`${item.success_2xx_count}/${item.total_requests}`}
>
{formatRatio(item.success_ratio)}
</span>
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
{formatBytes(item.bytes_received)}
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
{formatBytes(item.bytes_sent)}
</TableCell>
<TableCell className='text-xs'>
{formatDateTime(item.last_seen_at)}
</TableCell>
<TableCell className='text-center'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => {
setSelected(item);
setDetailOpen(true);
}}
>
<Eye className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看 IP 详情
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={data?.has_more ?? false}
loading={isFetching}
totalIp={data?.total_ip}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
</div>
<IpDetailDialog
open={detailOpen}
remoteAddr={selected?.remote_addr ?? null}
region={selected?.region}
initialHours={analysisHours}
onOpenChange={setDetailOpen}
/>
</>
);
}
/** Build list API time params from IP tab state (applied values only). */
export function buildIpSummaryTimeParams(input: {
timeMode: IpTabTimeMode;
hours: OverviewRangeHours;
customSince: string;
customUntil: string;
}): { hours?: number; since?: string; until?: string } | null {
if (input.timeMode === 'custom') {
if (validateCustomTimeRange(input.customSince, input.customUntil)) {
return null;
}
const since = localInputToRFC3339(input.customSince);
const until = localInputToRFC3339(input.customUntil);
if (!since || !until) {
return null;
}
return { since, until };
}
return { hours: input.hours };
}
@@ -0,0 +1,434 @@
'use client';
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import { Cell, Pie, PieChart } from 'recharts';
import { RankCard } from '@/components/data/rank-card';
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import type {
AccessLogOverview,
DistributionItem,
} from '@/lib/services/openflare';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import {
formatOverviewRangeHint,
formatOverviewTrendLabel,
type OverviewRangeHours,
} from './access-log-utils';
import { OverviewToolbar } from './overview-toolbar';
const DEVICE_COLORS = [
'#38bdf8',
'#34d399',
'#f59e0b',
'#a78bfa',
'#f472b6',
'#94a3b8',
];
function SparklineMetricCard({
title,
value,
hint,
color,
fillColor,
labels,
values,
valueFormatter,
}: {
title: string;
value: string;
hint: string;
color: string;
fillColor: string;
labels: string[];
values: number[];
valueFormatter?: (value: number) => string;
}) {
const option = useMemo<EChartsOption>(
() => ({
animationDuration: 400,
grid: {
left: 0,
right: 0,
top: 8,
bottom: 0,
},
xAxis: {
type: 'category',
show: false,
boundaryGap: false,
data: labels,
},
yAxis: {
type: 'value',
show: false,
min: 0,
},
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 : [];
const item = items[0] as
{ axisValueLabel?: string; value?: number } | undefined;
if (!item) return '';
const raw = typeof item.value === 'number' ? item.value : 0;
const formatted = valueFormatter
? valueFormatter(raw)
: formatCompactNumber(raw);
return `${item.axisValueLabel ?? ''}<br/>${formatted}`;
},
},
series: [
{
type: 'line',
data: values,
smooth: true,
showSymbol: false,
lineStyle: {
color,
width: 2,
},
areaStyle: {
color: fillColor,
},
},
],
}),
[color, fillColor, labels, valueFormatter, values],
);
return (
<Card className='border-dashed shadow-none'>
<CardContent className='p-4'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0 space-y-1'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{title}
</p>
<p className='text-2xl font-semibold tracking-tight'>{value}</p>
<p className='text-[11px] text-muted-foreground'>{hint}</p>
</div>
<div className='h-16 w-28 shrink-0 sm:w-36'>
{labels.length > 0 ? (
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height: '100%', width: '100%' }}
/>
) : null}
</div>
</div>
</CardContent>
</Card>
);
}
function toRankItems(items: DistributionItem[] | undefined) {
return (items ?? []).map((item) => ({
label: item.key,
value: item.value,
}));
}
function PieDistributionCard({
title,
description,
items,
emptyMessage,
}: {
title: string;
description: string;
items: DistributionItem[];
emptyMessage: string;
}) {
const chartData = useMemo(
() =>
items.map((item, index) => ({
name: item.key,
value: item.value,
fill: DEVICE_COLORS[index % DEVICE_COLORS.length],
})),
[items],
);
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
chartData.forEach((item) => {
config[item.name] = {
label: item.name,
color: item.fill,
};
});
return config;
}, [chartData]);
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold text-foreground'>
{title}
</CardTitle>
<CardDescription className='text-xs text-muted-foreground'>
{description}
</CardDescription>
</CardHeader>
<CardContent className='pt-0'>
{chartData.length === 0 ? (
<div className='flex h-[300px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground'>
{emptyMessage}
</div>
) : (
<ChartContainer
config={chartConfig}
className='mx-auto h-[300px] w-full'
>
<PieChart>
<Pie
data={chartData}
dataKey='value'
nameKey='name'
cx='50%'
cy='46%'
innerRadius={50}
outerRadius={80}
paddingAngle={2}
>
{chartData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Pie>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => (
<>
<span className='text-muted-foreground'>{name}</span>
<span className='ml-auto font-mono font-medium tabular-nums text-foreground'>
{formatCompactNumber(Number(value ?? 0))}
</span>
</>
)}
/>
}
/>
<ChartLegend
content={<ChartLegendContent nameKey='name' />}
className='flex-wrap justify-center gap-x-4 gap-y-1 pt-2 text-[11px]'
/>
</PieChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
}
export function OverviewTab({
data,
loading,
error,
hours,
hosts,
onHoursChange,
onHostsChange,
onRetry,
}: {
data?: AccessLogOverview;
loading: boolean;
error: Error | null;
hours: OverviewRangeHours;
hosts: string[];
onHoursChange: (hours: OverviewRangeHours) => void;
onHostsChange: (hosts: string[]) => void;
onRetry: () => void;
}) {
return (
<div className='space-y-6'>
<OverviewToolbar
hours={hours}
hosts={hosts}
onHoursChange={onHoursChange}
onHostsChange={onHostsChange}
/>
{loading ? (
<LoadingStateWithBorder
title='加载访问概览'
description='正在聚合请求量、访问量与带宽趋势...'
/>
) : error ? (
<ErrorInline
message={error.message || '加载访问概览失败'}
onRetry={onRetry}
/>
) : !data ? (
<EmptyStateWithBorder
title='暂无概览数据'
description='当前时间范围内没有可展示的访问统计。'
/>
) : (
<OverviewContent data={data} hours={hours} />
)}
</div>
);
}
function OverviewContent({
data,
hours,
}: {
data: AccessLogOverview;
hours: number;
}) {
const requestLabels = data.trends.requests.map((point) =>
formatOverviewTrendLabel(point.bucket_started_at, hours),
);
const requestValues = data.trends.requests.map((point) => point.value);
const visitValues = data.trends.visits.map((point) => point.value);
const bandwidthValues = data.trends.bandwidth.map((point) => point.value);
const hint = formatOverviewRangeHint(hours);
return (
<>
<div className='grid gap-4 lg:grid-cols-3'>
<SparklineMetricCard
title='Total Requests'
value={formatCompactNumber(data.summary.total_requests)}
hint={hint}
color='#f59e0b'
fillColor='rgba(245, 158, 11, 0.18)'
labels={requestLabels}
values={requestValues}
/>
<SparklineMetricCard
title='Total Visits'
value={formatCompactNumber(data.summary.total_visits)}
hint={`${hint} · 独立访客`}
color='#38bdf8'
fillColor='rgba(56, 189, 248, 0.16)'
labels={requestLabels}
values={visitValues}
/>
<SparklineMetricCard
title='Bandwidth Served'
value={formatBytes(data.summary.bandwidth_served)}
hint={`${hint} · 已提供数据`}
color='#34d399'
fillColor='rgba(52, 211, 153, 0.16)'
labels={requestLabels}
values={bandwidthValues}
valueFormatter={formatBytes}
/>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
Requests over time
</CardTitle>
<CardDescription className='text-xs'>
观察请求量是否出现异常抬升或回落。
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={requestLabels}
height={280}
showSummary={false}
series={[
{
label: '请求量',
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values: requestValues,
},
]}
/>
</CardContent>
</Card>
<div className='grid gap-6 xl:grid-cols-2'>
<PieDistributionCard
title='Requests by device type'
description='按设备类型统计请求占比。'
items={data.device_types ?? []}
emptyMessage='暂无设备类型数据'
/>
<PieDistributionCard
title='Status code'
description='按 HTTP 状态码统计请求占比。'
items={data.status_codes ?? []}
emptyMessage='暂无状态码分布数据'
/>
</div>
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top Paths'
description='访问量最高的请求路径。'
items={toRankItems(data.top_paths)}
/>
<RankCard
title='Top Hosts'
description='流量集中的访问域名。'
items={toRankItems(data.top_hosts)}
/>
<RankCard
title='Top IPs'
description='请求次数最多的来源 IP。'
items={toRankItems(data.top_ips)}
/>
</div>
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top browsers'
description='按浏览器聚合的请求排行。'
items={toRankItems(data.top_browsers)}
/>
<RankCard
title='Top Operating System'
description='按操作系统聚合的请求排行。'
items={toRankItems(data.top_operating_systems)}
/>
<RankCard
title='Top User-Agent'
description='原始 User-Agent 请求排行。'
items={toRankItems(data.top_user_agents)}
/>
</div>
</>
);
}
@@ -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 };
+174 -543
View File
@@ -1,56 +1,32 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Suspense, useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { RefreshCw, ScrollText, Trash2 } 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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AccessLogService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { AccessLogFilters } from './components/access-log-filters';
import {
type AccessLogTab,
DETAIL_SORT_OPTIONS,
FOLD_SORT_OPTIONS,
formatCompactNumber,
IP_SORT_OPTIONS,
type OverviewRangeHours,
parseSortValue,
type SearchDraft,
} from './components/access-log-utils';
import { CleanupDialog } from './components/cleanup-dialog';
const trendChartConfig = {
requests: { label: '请求数', color: 'hsl(var(--primary))' },
} satisfies ChartConfig;
import { DetailTab } from './components/detail-tab';
import {
buildIpSummaryTimeParams,
defaultLocalRangeForHours,
IpTab,
type IpTabTimeMode,
} from './components/ip-tab';
import { OverviewTab } from './components/overview-tab';
const emptyDraft: SearchDraft = {
nodeId: '',
@@ -59,62 +35,65 @@ const emptyDraft: SearchDraft = {
path: '',
};
function PaginationBar({
page,
hasMore,
loading,
onPrev,
onNext,
}: {
page: number;
hasMore: boolean;
loading: boolean;
onPrev: () => void;
onNext: () => void;
}) {
return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={loading || page <= 0}
onClick={onPrev}
>
上一页
</Button>
<Button
variant='outline'
size='sm'
disabled={loading || !hasMore}
onClick={onNext}
>
下一页
</Button>
</div>
</div>
);
function resolveTab(value: string | null): AccessLogTab {
if (value === 'list') return 'list';
if (value === 'ips') return 'ips';
return 'overview';
}
export default function AccessLogsPage() {
function AccessLogsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [tab, setTab] = useState<AccessLogTab>('list');
const tab = resolveTab(searchParams.get('tab'));
const [draft, setDraft] = useState<SearchDraft>(emptyDraft);
const [filters, setFilters] = useState<SearchDraft>(emptyDraft);
const [pageSize, setPageSize] = useState(20);
const [page, setPage] = useState(0);
const [detailSort, setDetailSort] = useState('logged_at:desc');
const [foldSort, setFoldSort] = useState('bucket_started_at:desc');
const [ipSort, setIpSort] = useState('total_requests:desc');
const [foldMinutes, setFoldMinutes] = useState<3 | 5>(3);
const [trendIp, setTrendIp] = useState('');
const [appliedTrendIp, setAppliedTrendIp] = useState('');
const [ipPageSize, setIpPageSize] = useState(20);
const [ipPage, setIpPage] = useState(0);
const [ipHours, setIpHours] = useState<OverviewRangeHours>(168);
const [ipTimeMode, setIpTimeMode] = useState<IpTabTimeMode>('preset');
const [ipCustomSince, setIpCustomSince] = useState('');
const [ipCustomUntil, setIpCustomUntil] = useState('');
const [overviewHours, setOverviewHours] = useState<OverviewRangeHours>(24);
const [overviewHosts, setOverviewHosts] = useState<string[]>([]);
const [cleanupOpen, setCleanupOpen] = useState(false);
const detailSortState = parseSortValue(detailSort);
const foldSortState = parseSortValue(foldSort);
const ipSortState = parseSortValue(ipSort);
const ipTimeParams = buildIpSummaryTimeParams({
timeMode: ipTimeMode,
hours: ipHours,
customSince: ipCustomSince,
customUntil: ipCustomUntil,
});
const ipQueryEnabled = tab === 'ips' && ipTimeParams != null;
const handleTabChange = (value: string) => {
const next = resolveTab(value);
router.replace(
next === 'overview' ? '/access-logs' : `/access-logs?tab=${next}`,
);
};
const overviewQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'overview',
overviewHours,
overviewHosts,
],
queryFn: () =>
AccessLogService.getOverview({
hours: overviewHours,
hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
}),
enabled: tab === 'overview',
});
const listQuery = useQuery({
queryKey: [
@@ -140,66 +119,25 @@ export default function AccessLogsPage() {
enabled: tab === 'list',
});
const foldsQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'folds',
filters,
page,
pageSize,
foldSort,
foldMinutes,
],
queryFn: () =>
AccessLogService.listFolds({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
p: page,
page_size: pageSize,
sort_by: foldSortState.sortBy,
sort_order: foldSortState.sortOrder,
fold_minutes: foldMinutes,
}),
enabled: tab === 'folds',
});
const ipSummaryQuery = useQuery({
const ipQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'ip-summary',
filters,
page,
pageSize,
ipTimeParams,
ipPage,
ipPageSize,
ipSort,
],
queryFn: () =>
AccessLogService.listIPSummaries({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
p: page,
page_size: pageSize,
...(ipTimeParams as NonNullable<typeof ipTimeParams>),
p: ipPage,
page_size: ipPageSize,
sort_by: ipSortState.sortBy,
sort_order: ipSortState.sortOrder,
}),
enabled: tab === 'ip-summary',
});
const ipTrendQuery = useQuery({
queryKey: ['openflare', 'access-logs', 'ip-trend', filters, appliedTrendIp],
queryFn: () =>
AccessLogService.getIPTrend({
node_id: filters.nodeId || undefined,
remote_addr: appliedTrendIp,
host: filters.host || undefined,
hours: 24,
bucket_minutes: 30,
}),
enabled: tab === 'ip-trend' && appliedTrendIp !== '',
enabled: ipQueryEnabled,
});
const cleanupMutation = useMutation({
@@ -217,35 +155,6 @@ export default function AccessLogsPage() {
},
});
const activeSummary = useMemo(() => {
if (tab === 'list' && listQuery.data) {
return {
totalRecord: listQuery.data.total_record,
totalIp: listQuery.data.total_ip,
};
}
if (tab === 'folds' && foldsQuery.data) {
return {
totalRecord: foldsQuery.data.total_record,
totalIp: foldsQuery.data.total_ip,
};
}
if (tab === 'ip-summary' && ipSummaryQuery.data) {
return {
totalRecord: 0,
totalIp: ipSummaryQuery.data.total_ip,
};
}
return { totalRecord: 0, totalIp: 0 };
}, [tab, listQuery.data, foldsQuery.data, ipSummaryQuery.data]);
const trendChartData = useMemo(() => {
return (ipTrendQuery.data?.points ?? []).map((point) => ({
label: formatDateTime(point.bucket_started_at).slice(5),
requests: point.request_count,
}));
}, [ipTrendQuery.data?.points]);
const handleSearch = useCallback(() => {
setFilters({
nodeId: draft.nodeId.trim(),
@@ -260,26 +169,32 @@ export default function AccessLogsPage() {
setDraft(emptyDraft);
setFilters(emptyDraft);
setPage(0);
setTrendIp('');
setAppliedTrendIp('');
};
useEffect(() => {
setPage(0);
}, [tab, pageSize]);
useEffect(() => {
setIpPage(0);
}, [
tab,
ipPageSize,
ipHours,
ipTimeMode,
ipCustomSince,
ipCustomUntil,
ipSort,
]);
const refreshActive = () => {
if (tab === 'overview') void overviewQuery.refetch();
if (tab === 'list') void listQuery.refetch();
if (tab === 'folds') void foldsQuery.refetch();
if (tab === 'ip-summary') void ipSummaryQuery.refetch();
if (tab === 'ip-trend') void ipTrendQuery.refetch();
if (tab === 'ips') void ipQuery.refetch();
};
const isFetching =
listQuery.isFetching ||
foldsQuery.isFetching ||
ipSummaryQuery.isFetching ||
ipTrendQuery.isFetching;
overviewQuery.isFetching || listQuery.isFetching || ipQuery.isFetching;
return (
<div className='py-6 px-1 space-y-6'>
@@ -289,7 +204,7 @@ export default function AccessLogsPage() {
<div>
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
<p className='text-sm text-muted-foreground'>
按节点、IP、域名与路径检索,支持时间折叠、IP 汇总与趋势分析。
查看访问概览、IP 明细与请求日志。
</p>
</div>
</div>
@@ -316,392 +231,93 @@ export default function AccessLogsPage() {
</div>
</div>
<div className='grid gap-3 sm:grid-cols-3'>
{[
{
label: '访问记录',
value: formatCompactNumber(activeSummary.totalRecord),
},
{
label: '来源 IP',
value: formatCompactNumber(activeSummary.totalIp),
},
{
label: '当前视图',
value:
tab === 'list'
? '明细日志'
: tab === 'folds'
? '时间折叠'
: tab === 'ip-summary'
? 'IP 汇总'
: 'IP 趋势',
},
].map((item) => (
<div
key={item.label}
className='rounded-lg border border-dashed px-4 py-3'
>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{item.label}
</p>
<p className='mt-1 text-lg font-semibold'>{item.value}</p>
</div>
))}
</div>
<Tabs
value={tab}
onValueChange={(value) => setTab(value as AccessLogTab)}
>
<TabsList className='grid w-full grid-cols-2 lg:grid-cols-4'>
<TabsTrigger value='list'>明细列表</TabsTrigger>
<TabsTrigger value='folds'>时间折叠</TabsTrigger>
<TabsTrigger value='ip-summary'>IP 汇总</TabsTrigger>
<TabsTrigger value='ip-trend'>IP 趋势</TabsTrigger>
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className='grid w-full max-w-lg grid-cols-3'>
<TabsTrigger value='overview'>概览</TabsTrigger>
<TabsTrigger value='ips'>IP 明细</TabsTrigger>
<TabsTrigger value='list'>日志明细</TabsTrigger>
</TabsList>
<div className='mt-4 rounded-lg border border-dashed bg-background p-4'>
<AccessLogFilters
tab={tab}
draft={draft}
pageSize={pageSize}
onDraftChange={setDraft}
onPageSizeChange={setPageSize}
onSearch={handleSearch}
onReset={handleReset}
<TabsContent value='overview' className='mt-4'>
<OverviewTab
data={overviewQuery.data}
loading={overviewQuery.isLoading}
error={
overviewQuery.error instanceof Error ? overviewQuery.error : null
}
hours={overviewHours}
hosts={overviewHosts}
onHoursChange={setOverviewHours}
onHostsChange={setOverviewHosts}
onRetry={() => void overviewQuery.refetch()}
/>
</div>
<TabsContent value='list' className='mt-4'>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>明细日志</p>
<Select value={detailSort} onValueChange={setDetailSort}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{listQuery.isError ? (
<div className='p-4'>
<ErrorInline
message={
listQuery.error instanceof Error
? listQuery.error.message
: '加载失败'
}
onRetry={() => void listQuery.refetch()}
/>
</div>
) : listQuery.isLoading ? (
<LoadingStateWithBorder />
) : (listQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无访问日志' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间</TableHead>
<TableHead className='text-xs'>节点</TableHead>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>域名</TableHead>
<TableHead className='text-xs'>路径</TableHead>
<TableHead className='text-xs'>状态码</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(listQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id} className='border-dashed'>
<TableCell className='text-xs'>
{formatDateTime(item.logged_at)}
</TableCell>
<TableCell className='text-xs'>
{item.node_name || item.node_id}
</TableCell>
<TableCell className='text-xs font-mono'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs'>{item.host}</TableCell>
<TableCell className='text-xs max-w-48 truncate'>
{item.path}
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{item.status_code}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={listQuery.data?.has_more ?? false}
loading={listQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
</TabsContent>
<TabsContent value='folds' className='mt-4'>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-b border-dashed'>
<div className='flex items-center gap-2'>
<p className='text-sm font-medium'>时间折叠</p>
<Select
value={String(foldMinutes)}
onValueChange={(value) =>
setFoldMinutes(Number(value) as 3 | 5)
}
>
<SelectTrigger className='h-8 w-36 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='3'>3 分钟桶</SelectItem>
<SelectItem value='5'>5 分钟桶</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={foldSort} onValueChange={setFoldSort}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FOLD_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{foldsQuery.isError ? (
<div className='p-4'>
<ErrorInline
message={
foldsQuery.error instanceof Error
? foldsQuery.error.message
: '加载失败'
}
onRetry={() => void foldsQuery.refetch()}
/>
</div>
) : foldsQuery.isLoading ? (
<LoadingStateWithBorder />
) : (foldsQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无折叠数据' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间桶</TableHead>
<TableHead className='text-xs'>请求数</TableHead>
<TableHead className='text-xs'>独立 IP</TableHead>
<TableHead className='text-xs'>独立域名</TableHead>
<TableHead className='text-xs'>2xx</TableHead>
<TableHead className='text-xs'>4xx</TableHead>
<TableHead className='text-xs'>5xx</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(foldsQuery.data?.items ?? []).map((item) => (
<TableRow
key={item.bucket_started_at}
className='border-dashed'
>
<TableCell className='text-xs'>
{formatDateTime(item.bucket_started_at)}
</TableCell>
<TableCell className='text-xs'>
{item.request_count}
</TableCell>
<TableCell className='text-xs'>
{item.unique_ip_count}
</TableCell>
<TableCell className='text-xs'>
{item.unique_host_count}
</TableCell>
<TableCell className='text-xs'>
{item.success_count}
</TableCell>
<TableCell className='text-xs'>
{item.client_error_count}
</TableCell>
<TableCell className='text-xs'>
{item.server_error_count}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={foldsQuery.data?.has_more ?? false}
loading={foldsQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
<TabsContent value='ips' className='mt-4'>
<IpTab
data={ipQuery.data}
loading={ipQueryEnabled && ipQuery.isLoading}
error={ipQuery.error instanceof Error ? ipQuery.error : null}
page={ipPage}
pageSize={ipPageSize}
sort={ipSort}
hours={ipHours}
timeMode={ipTimeMode}
customSince={ipCustomSince}
customUntil={ipCustomUntil}
onHoursChange={(next) => {
setIpTimeMode('preset');
setIpHours(next);
}}
onTimeModeChange={(mode) => {
setIpTimeMode(mode);
if (
mode === 'custom' &&
(!ipCustomSince.trim() || !ipCustomUntil.trim())
) {
const range = defaultLocalRangeForHours(ipHours);
setIpCustomSince(range.since);
setIpCustomUntil(range.until);
}
}}
onApplyCustomRange={(since, until) => {
setIpCustomSince(since);
setIpCustomUntil(until);
setIpTimeMode('custom');
setIpPage(0);
}}
onPageSizeChange={setIpPageSize}
onSortChange={setIpSort}
onRetry={() => void ipQuery.refetch()}
onPrevPage={() => setIpPage((p) => Math.max(0, p - 1))}
onNextPage={() => setIpPage((p) => p + 1)}
isFetching={ipQuery.isFetching}
/>
</TabsContent>
<TabsContent value='ip-summary' className='mt-4'>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>IP 汇总</p>
<Select value={ipSort} onValueChange={setIpSort}>
<SelectTrigger className='h-8 w-52 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{IP_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{ipSummaryQuery.isError ? (
<div className='p-4'>
<ErrorInline
message={
ipSummaryQuery.error instanceof Error
? ipSummaryQuery.error.message
: '加载失败'
}
onRetry={() => void ipSummaryQuery.refetch()}
/>
</div>
) : ipSummaryQuery.isLoading ? (
<LoadingStateWithBorder />
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无 IP 汇总数据' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>总请求数</TableHead>
<TableHead className='text-xs'>近 3 小时</TableHead>
<TableHead className='text-xs'>最后访问</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(ipSummaryQuery.data?.items ?? []).map((item) => (
<TableRow key={item.remote_addr} className='border-dashed'>
<TableCell className='text-xs font-mono'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs'>
{item.total_requests}
</TableCell>
<TableCell className='text-xs'>
{item.recent_requests}
</TableCell>
<TableCell className='text-xs'>
{formatDateTime(item.last_seen_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={ipSummaryQuery.data?.has_more ?? false}
loading={ipSummaryQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
<TabsContent value='list' className='mt-4 space-y-4'>
<div className='rounded-lg border border-dashed bg-background p-4'>
<AccessLogFilters
draft={draft}
pageSize={pageSize}
onDraftChange={setDraft}
onPageSizeChange={setPageSize}
onSearch={handleSearch}
onReset={handleReset}
/>
</div>
</TabsContent>
<TabsContent value='ip-trend' className='mt-4 space-y-4'>
<div className='rounded-lg border border-dashed bg-background p-4 flex flex-col gap-3 sm:flex-row sm:items-end'>
<div className='space-y-1.5 flex-1'>
<p className='text-xs font-medium text-muted-foreground'>
趋势 IP
</p>
<input
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-xs'
value={trendIp}
onChange={(e) => setTrendIp(e.target.value)}
placeholder='输入要分析趋势的 IP 地址'
/>
</div>
<Button
size='sm'
onClick={() => setAppliedTrendIp(trendIp.trim())}
disabled={!trendIp.trim()}
>
查看趋势
</Button>
</div>
<div className='rounded-lg border border-dashed overflow-hidden bg-background p-4'>
{!appliedTrendIp ? (
<EmptyStateWithBorder description='请输入 IP 地址后查看 24 小时访问趋势。' />
) : ipTrendQuery.isLoading ? (
<LoadingStateWithBorder />
) : ipTrendQuery.isError ? (
<ErrorInline
message={
ipTrendQuery.error instanceof Error
? ipTrendQuery.error.message
: '加载失败'
}
onRetry={() => void ipTrendQuery.refetch()}
/>
) : trendChartData.length === 0 ? (
<EmptyStateWithBorder description='该 IP 在选定时间范围内没有访问记录。' />
) : (
<div className='space-y-3'>
<p className='text-sm font-medium'>
{appliedTrendIp} · 近 {ipTrendQuery.data?.hours ?? 24} 小时
</p>
<ChartContainer
config={trendChartConfig}
className='h-64 w-full'
>
<AreaChart data={trendChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey='label'
tickLine={false}
axisLine={false}
fontSize={10}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
width={40}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type='monotone'
dataKey='requests'
stroke='var(--color-requests)'
fill='var(--color-requests)'
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
</div>
)}
</div>
<DetailTab
data={listQuery.data}
loading={listQuery.isLoading}
error={listQuery.error instanceof Error ? listQuery.error : null}
page={page}
detailSort={detailSort}
onDetailSortChange={setDetailSort}
onRetry={() => void listQuery.refetch()}
onPrevPage={() => setPage((p) => Math.max(0, p - 1))}
onNextPage={() => setPage((p) => p + 1)}
isFetching={listQuery.isFetching}
/>
</TabsContent>
</Tabs>
@@ -714,3 +330,18 @@ export default function AccessLogsPage() {
</div>
);
}
export default function AccessLogsPage() {
return (
<Suspense
fallback={
<LoadingStateWithBorder
title='加载访问日志'
description='正在准备页面...'
/>
}
>
<AccessLogsPageContent />
</Suspense>
);
}
@@ -20,12 +20,14 @@ export type OpenFlareOpsFields = {
uptime_kuma_timeout: string;
database_auto_cleanup_enabled: boolean;
database_auto_cleanup_retention_days: string;
pages_max_package_size_mb: string;
pages_max_history_count: string;
};
export const defaultOpenFlareOpsFields: OpenFlareOpsFields = {
agent_heartbeat_interval: '10000',
agent_heartbeat_interval: '3000',
agent_websocket_upgrade_enabled: true,
node_offline_threshold: '120000',
node_offline_threshold: '60000',
agent_update_repo: 'Rain-kl/OpenFlare',
geoip_provider: 'ipinfo',
server_address: '',
@@ -42,6 +44,8 @@ export const defaultOpenFlareOpsFields: OpenFlareOpsFields = {
uptime_kuma_timeout: '48',
database_auto_cleanup_enabled: false,
database_auto_cleanup_retention_days: '30',
pages_max_package_size_mb: '100',
pages_max_history_count: '20',
};
export const INSTALLER_SCRIPT_URL =
@@ -64,12 +68,12 @@ export function mapOptionsToOpsFields(
serverAddress = '',
): OpenFlareOpsFields {
return {
agent_heartbeat_interval: optionMap.agent_heartbeat_interval ?? '10000',
agent_heartbeat_interval: optionMap.agent_heartbeat_interval ?? '3000',
agent_websocket_upgrade_enabled: toBoolean(
optionMap.agent_websocket_upgrade_enabled,
true,
),
node_offline_threshold: optionMap.node_offline_threshold ?? '120000',
node_offline_threshold: optionMap.node_offline_threshold ?? '60000',
agent_update_repo: optionMap.agent_update_repo ?? 'Rain-kl/OpenFlare',
geoip_provider: optionMap.geoip_provider ?? 'ipinfo',
server_address: optionMap.server_address || serverAddress,
@@ -90,6 +94,8 @@ export function mapOptionsToOpsFields(
),
database_auto_cleanup_retention_days:
optionMap.database_auto_cleanup_retention_days ?? '30',
pages_max_package_size_mb: optionMap.pages_max_package_size_mb ?? '100',
pages_max_history_count: optionMap.pages_max_history_count ?? '20',
};
}
@@ -123,12 +129,15 @@ export function buildDiscoveryCommand(
export function validateAgentFields(fields: OpenFlareOpsFields) {
const heartbeat = Number.parseInt(fields.agent_heartbeat_interval, 10);
const offline = Number.parseInt(fields.node_offline_threshold, 10);
if (Number.isNaN(heartbeat) || heartbeat < 5000) {
throw new Error('心跳间隔不能小于 5000 毫秒。');
if (Number.isNaN(heartbeat) || heartbeat < 1000) {
throw new Error('心跳间隔不能小于 1000 毫秒。');
}
if (Number.isNaN(offline) || offline < 10000) {
throw new Error('离线阈值不能小于 10000 毫秒。');
}
if (offline < heartbeat * 3) {
throw new Error('离线阈值建议至少为心跳间隔的 3 倍。');
}
}
export function validateUptimeKumaFields(fields: OpenFlareOpsFields) {
@@ -226,3 +235,30 @@ export function databaseAutoCleanupEntries(
},
];
}
export function validatePagesFields(fields: OpenFlareOpsFields) {
const packageSize = Number.parseInt(fields.pages_max_package_size_mb, 10);
const historyCount = Number.parseInt(fields.pages_max_history_count, 10);
if (Number.isNaN(packageSize) || packageSize < 1 || packageSize > 2048) {
throw new Error('Pages 部署包大小上限必须为 1~2048 MiB。');
}
if (Number.isNaN(historyCount) || historyCount < 0) {
throw new Error(
'Pages 历史保留数必须为大于等于 0 的整数(0 表示不限制)。',
);
}
}
export function pagesOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
validatePagesFields(fields);
return [
{
key: 'pages_max_package_size_mb',
value: String(Number.parseInt(fields.pages_max_package_size_mb, 10)),
},
{
key: 'pages_max_history_count',
value: String(Number.parseInt(fields.pages_max_history_count, 10)),
},
];
}
@@ -64,6 +64,7 @@ import {
mapOptionsToOpsFields,
type OpenFlareOpsFields,
optionsToMap,
pagesOptionEntries,
uptimeKumaOptionEntries,
} from './openflare-ops-utils';
import { UptimeKumaSiteSelectModal } from './uptimekuma-site-modal';
@@ -89,10 +90,10 @@ const cleanupTargets: Array<{
'清理 node_metric_snapshots,影响节点资源趋势;表 TTL 为 30 天。',
},
{
target: 'node_request_reports',
label: '请求聚合',
target: 'node_edge_health',
label: 'OpenResty 健康',
description:
'清理 node_request_reports,影响请求量与错误量统计;表 TTL 为 30 天。',
'清理 node_edge_health(OpenResty 连接/健康快照);表 TTL 为 30 天。业务流量请清理访问日志。',
},
];
@@ -258,6 +259,17 @@ export function OpenFlareOpsSettings() {
}
};
const savePagesSettings = () => {
try {
saveMutation.mutate({
section: 'pages',
entries: pagesOptionEntries(fields),
});
} catch (error) {
toast.error(error instanceof Error ? error.message : '参数校验失败');
}
};
if (optionsQuery.isLoading) {
return (
<LoadingStateWithBorder
@@ -413,6 +425,56 @@ export function OpenFlareOpsSettings() {
</Card>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>Pages 静态托管</CardTitle>
<CardDescription>
配置部署包上传体积上限与每个项目的历史部署保留数量。
</CardDescription>
</div>
<Button
size='sm'
disabled={savingSection === 'pages'}
onClick={savePagesSettings}
>
{savingSection === 'pages' ? (
<Loader2 className='size-4 animate-spin mr-1' />
) : (
<Save className='size-3.5 mr-1' />
)}
保存
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<FieldInput
label='部署包大小上限 (MiB)'
value={fields.pages_max_package_size_mb}
type='number'
onChange={(value) =>
updateField('pages_max_package_size_mb', value)
}
placeholder='100'
/>
<FieldInput
label='历史部署保留数(0 不限制)'
value={fields.pages_max_history_count}
type='number'
onChange={(value) =>
updateField('pages_max_history_count', value)
}
placeholder='20'
/>
</div>
<p className='text-xs text-muted-foreground'>
每个项目最多保留 N
条部署:激活部署始终保留,其余按从新到旧填充;超出的非激活部署会在上传成功后自动清理。支持
zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式。
</p>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
@@ -1,172 +1,177 @@
{
"Afghanistan": [65.99254367483304, 33.83349410470984],
"Angola": [17.563198329790602, -12.334557680834592],
"Albania": [20.035803636476235, 41.179283471608585],
"United Arab Emirates": [54.30345828396012, 23.905698042805522],
"Algeria": [2.6175172555729325, 28.158202786113698],
"Angola": [17.563198329790602, -12.334557680834592],
"Argentina": [-65.15670576062132, -35.178771590782524],
"Armenia": [44.929379894609674, 40.29884592895426],
"France": [165.43817082439585, -21.285375782391018],
"Australia": [134.361180723562, -25.574955774991995],
"Austria": [14.10165807418182, 47.57042831528493],
"Azerbaijan": [47.636777782137415, 40.3311508093757],
"Burundi": [29.873065896828848, -3.3802825292327188],
"Belgium": [4.628129604331132, 50.646494813466056],
"Benin": [2.323507643298733, 9.63048313950481],
"Burkina Faso": [-1.7757430610210276, 12.260370131848955],
"Bangladesh": [90.21009840315206, 23.88793565572049],
"Bulgaria": [25.225364036830744, 42.77785706066581],
"The Bahamas": [-78.02733865351972, 24.719920319716515],
"Bosnia and Herzegovina": [17.762461846176542, 44.16398853722456],
"Belarus": [28.04895281233843, 53.53432933090482],
"Belgium": [4.628129604331132, 50.646494813466056],
"Belize": [-88.72096138346826, 17.17073148225863],
"Benin": [2.323507643298733, 9.63048313950481],
"Bhutan": [90.40562361681495, 27.40133623508629],
"Bolivia": [-64.69104972918655, -16.70141507507877],
"Bosnia and Herzegovina": [17.762461846176542, 44.16398853722456],
"Botswana": [23.791788508036337, -22.185333985163894],
"Brazil": [-53.113858151998855, -10.827995867833263],
"Brunei": [114.54229268997476, 4.451218476864121],
"Bhutan": [90.40562361681495, 27.40133623508629],
"Botswana": [23.791788508036337, -22.185333985163894],
"Central African Republic": [20.490781591297914, 6.563625165826992],
"Bulgaria": [25.225364036830744, 42.77785706066581],
"Burkina Faso": [-1.7757430610210276, 12.260370131848955],
"Burundi": [29.873065896828848, -3.3802825292327188],
"Cambodia": [104.90702345739547, 12.717932969523966],
"Cameroon": [12.739868035578002, 5.692692453534944],
"Canada": [-102.31777616484295, 57.94457487676815],
"Switzerland": [8.205812860319684, 46.805544047201984],
"Central African Republic": [20.490781591297914, 6.563625165826992],
"Chad": [18.645815630757973, 15.32729896930035],
"Chile": [-71.34891661948937, -35.98028631773855],
"China": [103.79804331240364, 36.61094167680319],
"Ivory Coast": [-5.569771898429842, 7.626172775954684],
"Cameroon": [12.739868035578002, 5.692692453534944],
"Democratic Republic of the Congo": [23.64811688254194, -2.8775533309965056],
"Republic of the Congo": [15.21207418503586, -0.8564004333096924],
"Colombia": [-73.07794094917405, 3.9235618261252663],
"Costa Rica": [-84.21742467558424, 9.986463158070535],
"Croatia": [16.458685366615978, 45.18567759026696],
"Cuba": [-78.8954025482625, 21.62136808001903],
"Northern Cyprus": [33.39079188058263, 35.225753269439686],
"Cyprus": [33.02409208254226, 34.91304424540028],
"Czech Republic": [15.330767057694615, 49.70160772219921],
"Germany": [10.380496796525602, 51.083853487429664],
"Djibouti": [42.56788631653962, 11.745988995518626],
"Democratic Republic of the Congo": [23.64811688254194, -2.8775533309965056],
"Denmark": [-41.29088868109839, 74.72535220460091],
"Djibouti": [42.56788631653962, 11.745988995518626],
"Dominican Republic": [-70.48595892120278, 18.896879062003393],
"Algeria": [2.6175172555729325, 28.158202786113698],
"East Timor": [125.78222644041995, -8.858373836074785],
"Ecuador": [-78.38384779532852, -1.4454571869341688],
"Egypt": [29.86739912685152, 26.49286512797635],
"El Salvador": [-88.86593554283397, 13.734573052971413],
"Equatorial Guinea": [10.481670786028316, 1.5660796013647418],
"Eritrea": [38.835037148217566, 15.359809600209143],
"Spain": [-3.5708365129134876, 40.391564705240064],
"Estonia": [25.811350172874032, 58.69303019372469],
"Ethiopia": [39.60372599651433, 8.621595995236198],
"Finland": [26.28958351526606, 64.52279699210293],
"Fiji": [177.98415179869738, -17.827004078184217],
"United Kingdom": [-36.367082569942795, -54.468348288665524],
"Finland": [26.28958351526606, 64.52279699210293],
"France": [165.43817082439585, -21.285375782391018],
"Gabon": [11.774048593462979, -0.5859798536474902],
"Georgia": [43.48650319000057, 42.175027555818694],
"Ghana": [-1.2150159067556041, 7.94078880601944],
"Guinea": [-10.943568403941383, 10.44078010915303],
"Gambia": [-16.21304964555284, 13.37062695465985],
"Guinea Bissau": [-14.929646023453557, 12.05088889829685],
"Equatorial Guinea": [10.481670786028316, 1.5660796013647418],
"Georgia": [43.48650319000057, 42.175027555818694],
"Germany": [10.380496796525602, 51.083853487429664],
"Ghana": [-1.2150159067556041, 7.94078880601944],
"Greece": [22.53435219348475, 39.517734832602365],
"Guatemala": [-90.35344030476544, 15.679044168855697],
"Guinea": [-10.943568403941383, 10.44078010915303],
"Guinea Bissau": [-14.929646023453557, 12.05088889829685],
"Guyana": [-58.978019347627985, 4.800329726037504],
"Honduras": [-86.65651796671759, 14.821029518594349],
"Croatia": [16.458685366615978, 45.18567759026696],
"Haiti": [-72.67866559866681, 18.916195586860297],
"Honduras": [-86.65651796671759, 14.821029518594349],
"Hong Kong": [114.1747, 22.2783],
"Hungary": [19.410867145831855, 47.168542742918525],
"Indonesia": [101.52237404492608, -0.44681277438152994],
"Iceland": [-18.509670948264166, 65.03034972428229],
"India": [79.58636900833257, 22.901586798523514],
"Ireland": [-8.117695195030898, 53.2341315203433],
"Indonesia": [101.52237404492608, -0.44681277438152994],
"Iran": [54.266246557433305, 32.57616445788996],
"Iraq": [43.74006501790808, 33.0341456153028],
"Iceland": [-18.509670948264166, 65.03034972428229],
"Ireland": [-8.117695195030898, 53.2341315203433],
"Israel": [35.241573878810335, 31.94758544099519],
"Italy": [12.16233842748749, 43.51281131035235],
"Ivory Coast": [-5.569771898429842, 7.626172775954684],
"Jamaica": [-77.32850498837172, 18.172957893241634],
"Jordan": [36.76131486689134, 31.237145277490367],
"Japan": [137.97352453982398, 36.589925009528365],
"Jordan": [36.76131486689134, 31.237145277490367],
"Kashmir": [77.08733723957188, 35.415738932286466],
"Kazakhstan": [67.27815427190085, 48.155989088140196],
"Kenya": [37.795078751163835, 0.5967194222965451],
"Kyrgyzstan": [74.58224715420873, 41.48363475100716],
"Cambodia": [104.90702345739547, 12.717932969523966],
"South Korea": [127.87280447602005, 36.475157799464604],
"Kosovo": [20.851519165302378, 42.51783231278167],
"Kuwait": [47.566177395781736, 29.316145203902064],
"Kyrgyzstan": [74.58224715420873, 41.48363475100716],
"Laos": [103.75263811609112, 18.494354885305256],
"Latvia": [24.89496154477673, 56.853143698564296],
"Lebanon": [35.88502277707876, 33.91528991779158],
"Lesotho": [28.24303757633595, -29.57351232727891],
"Liberia": [-9.325867879201954, 6.453996218817175],
"Libya": [18.008785486000946, 27.031457363755713],
"Sri Lanka": [80.70055059693914, 7.649883097905021],
"Lesotho": [28.24303757633595, -29.57351232727891],
"Lithuania": [23.872793668340726, 55.3142460584835],
"Luxembourg": [6.070340274408408, 49.76746140228473],
"Latvia": [24.89496154477673, 56.853143698564296],
"Morocco": [-8.459192831832421, 29.83932361976915],
"Moldova": [28.446472517363826, 47.2017924696845],
"Madagascar": [46.70172536870331, -19.374776628372288],
"Mexico": [-102.51849775988345, 23.943713432682014],
"Macao": [113.5439, 22.1987],
"Macau": [113.5439, 22.1987],
"Macedonia": [21.704642152886493, 41.60373573782414],
"Mali": [-3.5321833143642736, 17.347810147640345],
"Myanmar": [96.49588816533908, 21.22201469728619],
"Montenegro": [19.233365413523355, 42.78395456212118],
"Mongolia": [103.06423533091326, 46.82560864032323],
"Mozambique": [35.53173663088568, -17.27297477254694],
"Mauritania": [-10.34307896458181, 20.259793585678146],
"Madagascar": [46.70172536870331, -19.374776628372288],
"Malawi": [34.288911351438394, -13.200363393826267],
"Malaysia": [114.69201508943004, 3.57724156754415],
"Mali": [-3.5321833143642736, 17.347810147640345],
"Mauritania": [-10.34307896458181, 20.259793585678146],
"Mexico": [-102.51849775988345, 23.943713432682014],
"Moldova": [28.446472517363826, 47.2017924696845],
"Mongolia": [103.06423533091326, 46.82560864032323],
"Montenegro": [19.233365413523355, 42.78395456212118],
"Morocco": [-8.459192831832421, 29.83932361976915],
"Mozambique": [35.53173663088568, -17.27297477254694],
"Myanmar": [96.49588816533908, 21.22201469728619],
"Namibia": [17.208945346875193, -22.132302890947404],
"Nepal": [83.9234099472594, 28.243335814044734],
"Netherlands": [5.646194278986089, 52.27734132368374],
"New Zealand": [170.54047059084724, -43.955381757717255],
"Nicaragua": [-85.03229930746807, 12.835548864379284],
"Niger": [9.384787308614277, 17.41758190683491],
"Nigeria": [8.074235448092892, 9.58958974174772],
"Nicaragua": [-85.03229930746807, 12.835548864379284],
"Netherlands": [5.646194278986089, 52.27734132368374],
"North Korea": [127.21551783512186, 40.144856985239926],
"Northern Cyprus": [33.39079188058263, 35.225753269439686],
"Norway": [13.994247649210363, 64.32249798565987],
"Nepal": [83.9234099472594, 28.243335814044734],
"New Zealand": [170.54047059084724, -43.955381757717255],
"Oman": [56.08271927344639, 20.573738446088598],
"Pakistan": [69.32988114530586, 29.951585862562073],
"Panama": [-80.05970573973497, 8.521692369504816],
"Papua New Guinea": [144.22896759838426, -6.607045399379529],
"Paraguay": [-58.39900641582761, -23.223895588708068],
"Peru": [-74.38850244798846, -9.156316252308201],
"Philippines": [121.45754828266669, 15.948824272939182],
"Papua New Guinea": [144.22896759838426, -6.607045399379529],
"Poland": [19.379892383012464, 52.11963086628078],
"United States of America": [-99.14669730710926, 39.52689381707972],
"North Korea": [127.21551783512186, 40.144856985239926],
"Portugal": [-7.977838817691485, 39.6854900148663],
"Paraguay": [-58.39900641582761, -23.223895588708068],
"Qatar": [51.170816022848, 25.32876608728452],
"Republic of Serbia": [20.766249889622852, 44.19998417744864],
"Republic of the Congo": [15.21207418503586, -0.8564004333096924],
"Romania": [24.971632366248883, 45.84560772065747],
"Russia": [99.05228241954488, 61.677043847105494],
"Rwanda": [29.91765145538139, -1.9953628964377972],
"Western Sahara": [-12.218085234851758, 24.221135708939354],
"Saudi Arabia": [44.5342871199886, 24.122854227726673],
"Sudan": [29.946243665370346, 15.99205876641883],
"South Sudan": [30.253827921202955, 7.307277152400154],
"Senegal": [-14.472411019597182, 14.338613732351318],
"Solomon Islands": [160.156396875585, -9.628589831637283],
"Sierra Leone": [-11.82013204896783, 8.575093438325089],
"El Salvador": [-88.86593554283397, 13.734573052971413],
"Somaliland": [46.25513292885051, 9.729995248781385],
"Somalia": [45.706539856671256, 4.747960517798881],
"Republic of Serbia": [20.766249889622852, 44.19998417744864],
"Suriname": [-55.91029765824739, 4.126766010517478],
"Singapore": [103.8198, 1.3521],
"Slovakia": [19.47296461136286, 48.71540683451534],
"Slovenia": [14.771193461021065, 46.10807043333343],
"Sweden": [16.752551082675275, 62.8354879894394],
"Solomon Islands": [160.156396875585, -9.628589831637283],
"Somalia": [45.706539856671256, 4.747960517798881],
"Somaliland": [46.25513292885051, 9.729995248781385],
"South Africa": [25.160630055943862, -29.008774283210798],
"South Korea": [127.87280447602005, 36.475157799464604],
"South Sudan": [30.253827921202955, 7.307277152400154],
"Spain": [-3.5708365129134876, 40.391564705240064],
"Sri Lanka": [80.70055059693914, 7.649883097905021],
"Sudan": [29.946243665370346, 15.99205876641883],
"Suriname": [-55.91029765824739, 4.126766010517478],
"Swaziland": [31.47664514492297, -26.563545981108437],
"Sweden": [16.752551082675275, 62.8354879894394],
"Switzerland": [8.205812860319684, 46.805544047201984],
"Syria": [38.50449031631095, 35.01769370592341],
"Chad": [18.645815630757973, 15.32729896930035],
"Togo": [0.949513544830056, 8.576560980195756],
"Thailand": [101.01060253224972, 15.132319216387051],
"Taiwan": [120.9605, 23.6978],
"Tajikistan": [70.98712700685866, 38.55204366339685],
"Turkmenistan": [59.39292286662992, 39.1098173228857],
"East Timor": [125.78222644041995, -8.858373836074785],
"Thailand": [101.01060253224972, 15.132319216387051],
"The Bahamas": [-78.02733865351972, 24.719920319716515],
"Togo": [0.949513544830056, 8.576560980195756],
"Trinidad and Tobago": [-61.270458833911896, 10.339930045317969],
"Tunisia": [9.546832905099203, 34.12673069029241],
"Turkey": [35.424986274948736, 38.998407240706534],
"United Republic of Tanzania": [34.79490109621088, -6.276146393386636],
"Turkmenistan": [59.39292286662992, 39.1098173228857],
"Uganda": [32.36575488492427, 1.2757695499320103],
"Ukraine": [31.381193375918794, 49.01345298489402],
"United Arab Emirates": [54.30345828396012, 23.905698042805522],
"United Kingdom": [-36.367082569942795, -54.468348288665524],
"United Republic of Tanzania": [34.79490109621088, -6.276146393386636],
"United States of America": [-99.14669730710926, 39.52689381707972],
"Uruguay": [-56.01665573850212, -32.80651677440885],
"Uzbekistan": [63.148826727968626, 41.7502616897204],
"Vanuatu": [166.84228659210697, -15.266627310669035],
"Venezuela": [-66.1882174671549, 7.118299933056809],
"Vietnam": [106.29939096235873, 16.63088189612042],
"Vanuatu": [166.84228659210697, -15.266627310669035],
"Western Sahara": [-12.218085234851758, 24.221135708939354],
"Yemen": [47.546351590294876, 15.935825615412996],
"South Africa": [25.160630055943862, -29.008774283210798],
"Zambia": [27.775321721593084, -13.460954386993254],
"Zimbabwe": [29.850564118889274, -19.00677451616593]
}
@@ -7,7 +7,22 @@ import type {
NetworkTrendPoint,
} from '@/lib/services/openflare';
import { formatBytes, formatTrendHour } from './dashboard-utils';
import {
formatBytes,
formatBytesPerSecond,
formatTrendHour,
} from './dashboard-utils';
/** Backend disk points are per-hour totals; chart displays bytes/s within each hour. */
const DISK_BUCKET_SECONDS = 3600;
function diskBytesToRate(bytes: number) {
return bytes > 0 ? bytes / DISK_BUCKET_SECONDS : 0;
}
function formatDiskRate(bytesPerSecond: number) {
return formatBytesPerSecond(bytesPerSecond, 1, { zeroText: '0 B' });
}
export function NetworkDiskTrendChart({
networkPoints,
@@ -20,7 +35,7 @@ export function NetworkDiskTrendChart({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
24 小时网络与磁盘趋势
24 小时业务流量与宿主机磁盘
</CardTitle>
</CardHeader>
<CardContent className='space-y-6'>
@@ -30,21 +45,21 @@ export function NetworkDiskTrendChart({
)}
height={180}
summaryScope='total'
summaryHint='近 24 小时'
summaryHint='近 24 小时 · 来自访问日志'
yAxisValueFormatter={formatBytes}
series={[
{
label: 'OpenResty 入站',
label: '接收数据',
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
values: networkPoints.map((point) => point.openresty_rx_bytes),
values: networkPoints.map((point) => point.bytes_received),
valueFormatter: formatBytes,
},
{
label: 'OpenResty 出站',
label: '已提供数据',
color: '#38bdf8',
values: networkPoints.map((point) => point.openresty_tx_bytes),
values: networkPoints.map((point) => point.bytes_provided),
valueFormatter: formatBytes,
},
]}
@@ -55,23 +70,27 @@ export function NetworkDiskTrendChart({
formatTrendHour(point.bucket_started_at),
)}
height={180}
summaryScope='total'
summaryHint='近 24 小时'
yAxisValueFormatter={formatBytes}
summaryScope='average'
summaryHint='近 24 小时 · 宿主机磁盘 · 平均速率'
yAxisValueFormatter={formatDiskRate}
series={[
{
label: '磁盘读',
color: '#a78bfa',
fillColor: 'rgba(167, 139, 250, 0.14)',
variant: 'area',
values: diskPoints.map((point) => point.disk_read_bytes),
valueFormatter: formatBytes,
values: diskPoints.map((point) =>
diskBytesToRate(point.disk_read_bytes),
),
valueFormatter: formatDiskRate,
},
{
label: '磁盘写',
color: '#fb7185',
values: diskPoints.map((point) => point.disk_write_bytes),
valueFormatter: formatBytes,
values: diskPoints.map((point) =>
diskBytesToRate(point.disk_write_bytes),
),
valueFormatter: formatDiskRate,
},
]}
/>
@@ -40,7 +40,7 @@ export function DashboardStatCards({
{formatCompactNumber(traffic.request_count)}
</div>
<p className='text-[10px] text-muted-foreground'>
窗口UV(估) {formatCompactNumber(traffic.unique_visitors)} · 错误{' '}
24h 独立访客 {formatCompactNumber(traffic.unique_visitors)} · 错误{' '}
{formatCompactNumber(traffic.error_count)} · 估算 QPS{' '}
{traffic.estimated_qps.toFixed(2)}
</p>
@@ -153,17 +153,50 @@ function resolveCountryCentroid(geoName: string): CountryCentroid | null {
if (!trimmed) {
return null;
}
const direct = toCountryCentroid(countryCentroids[trimmed]);
if (direct) {
return direct;
}
const lower = trimmed.toLowerCase();
for (const [name, coordinates] of Object.entries(countryCentroids)) {
if (name.toLowerCase() === lower) {
return toCountryCentroid(coordinates);
const candidates = new Set<string>([trimmed]);
// ipinfo-style names: "Hong Kong, Hong Kong, HK"
for (const part of trimmed.split(',').map((item) => item.trim())) {
if (part) {
candidates.add(part);
}
}
return null;
// trailing ISO2 often present after city/region
const isoMatch = trimmed.match(/\b([A-Z]{2})\b/g);
if (isoMatch) {
for (const code of isoMatch) {
candidates.add(code);
}
}
for (const candidate of candidates) {
const direct = toCountryCentroid(countryCentroids[candidate]);
if (direct) {
return direct;
}
const lower = candidate.toLowerCase();
for (const [name, coordinates] of Object.entries(countryCentroids)) {
if (name.toLowerCase() === lower) {
return toCountryCentroid(coordinates);
}
}
}
// Soft contains match for longer composite labels (prefer longer country names).
const lowerFull = trimmed.toLowerCase();
let best: { length: number; coords: CountryCentroid } | null = null;
for (const [name, coordinates] of Object.entries(countryCentroids)) {
const lowerName = name.toLowerCase();
if (lowerFull.includes(lowerName) && lowerName.length >= 4) {
const coords = toCountryCentroid(coordinates);
if (!coords) {
continue;
}
if (!best || lowerName.length > best.length) {
best = { length: lowerName.length, coords };
}
}
}
return best?.coords ?? null;
}
function getNodeCoordinates(node: DashboardNodeHealth, index: number) {
@@ -9,16 +9,25 @@ import {
CardTitle,
} from '@/components/ui/card';
import type { DiskIOTrendPoint } from '@/lib/services/openflare';
import { formatBytesPerSecond } from '@/lib/utils/metrics';
import {
formatBytes,
formatTrendHour,
} from '../../components/dashboard/dashboard-utils';
import { formatTrendHour } from '../../components/dashboard/dashboard-utils';
/** Backend disk points are per-hour totals; chart displays bytes/s within each hour. */
const DISK_BUCKET_SECONDS = 3600;
function diskBytesToRate(bytes: number) {
return bytes > 0 ? bytes / DISK_BUCKET_SECONDS : 0;
}
function formatDiskRate(bytesPerSecond: number) {
return formatBytesPerSecond(bytesPerSecond, 1, { zeroText: '0 B' });
}
export function DiskIOTrendChart({
points,
title = '24 小时磁盘 IO 趋势',
description = '观察磁盘读写变化,辅助判断日志放大、缓存抖动或磁盘压力。',
description = '按小时展示磁盘读写速率(B/s),辅助判断日志放大、缓存抖动或磁盘压力。',
}: {
points: DiskIOTrendPoint[];
title?: string;
@@ -35,23 +44,27 @@ export function DiskIOTrendChart({
labels={points.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
summaryScope='total'
summaryHint='近 24 小时'
yAxisValueFormatter={formatBytes}
summaryScope='average'
summaryHint='近 24 小时 · 平均速率'
yAxisValueFormatter={formatDiskRate}
series={[
{
label: '磁盘读',
color: '#a78bfa',
fillColor: 'rgba(167, 139, 250, 0.14)',
variant: 'area',
values: points.map((point) => point.disk_read_bytes),
valueFormatter: formatBytes,
values: points.map((point) =>
diskBytesToRate(point.disk_read_bytes),
),
valueFormatter: formatDiskRate,
},
{
label: '磁盘写',
color: '#fb7185',
values: points.map((point) => point.disk_write_bytes),
valueFormatter: formatBytes,
values: points.map((point) =>
diskBytesToRate(point.disk_write_bytes),
),
valueFormatter: formatDiskRate,
},
]}
/>
@@ -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'>
@@ -18,7 +18,7 @@ import {
export function NetworkTrendChart({
points,
title = '24 小时网络趋势',
description = '按小时展示 OpenResty 入站/出站流量累计,摘要为近 24 小时总量。',
description = '按小时展示已提供/接收数据(访问日志);摘要为近 24 小时总量。',
}: {
points: NetworkTrendPoint[];
title?: string;
@@ -40,17 +40,17 @@ export function NetworkTrendChart({
yAxisValueFormatter={formatBytes}
series={[
{
label: 'OpenResty 入站',
label: '接收数据',
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
values: points.map((point) => point.openresty_rx_bytes),
values: points.map((point) => point.bytes_received),
valueFormatter: formatBytes,
},
{
label: 'OpenResty 出站',
label: '已提供数据',
color: '#38bdf8',
values: points.map((point) => point.openresty_tx_bytes),
values: points.map((point) => point.bytes_provided),
valueFormatter: formatBytes,
},
]}
@@ -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>
@@ -38,9 +38,7 @@ import { DistributionList } from './distribution-list';
import { NetworkTrendChart } from './network-trend-chart';
import { NodeStatusBadge } from './node-status-badge';
import {
aggregateTrafficBreakdown,
formatBytes,
formatBytesPerSecond,
formatMetricCount,
formatPercent,
formatRelativeTime,
@@ -395,26 +393,26 @@ export function NodeObservability({
resolvedHealthEvents,
]);
const trafficSummary = observability?.analytics?.traffic ?? null;
const healthSummary = observability?.analytics?.health ?? null;
const distributions = observability?.analytics?.distributions;
const statusCodeDistribution = useMemo(
() =>
aggregateTrafficBreakdown(
observability?.traffic_reports,
'status_codes_json',
),
[observability?.traffic_reports],
(distributions?.status_codes ?? []).map((item) => ({
label: item.key,
value: item.value,
})),
[distributions?.status_codes],
);
const topDomains = useMemo(
() =>
aggregateTrafficBreakdown(
observability?.traffic_reports,
'top_domains_json',
),
[observability?.traffic_reports],
(distributions?.top_domains ?? []).map((item) => ({
label: item.key,
value: item.value,
})),
[distributions?.top_domains],
);
const trafficSummary = observability?.analytics?.traffic ?? null;
const healthSummary = observability?.analytics?.health ?? null;
const topSourceCountry =
observability?.analytics?.distributions?.source_countries?.[0] ?? null;
const topSourceCountry = distributions?.source_countries?.[0] ?? null;
const latestHealthEvent = activeHealthEvents[0] ?? null;
const dominantStatusCode = statusCodeDistribution[0] ?? null;
const dominantDomain = topDomains[0] ?? null;
@@ -465,7 +463,7 @@ export function NodeObservability({
}
hint={
latestHealthEvent
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
? getHealthEventLabel(latestHealthEvent)
: '当前没有活动中的健康事件'
}
/>
@@ -599,16 +597,16 @@ export function NodeObservability({
}
hint={
latestHealthEvent
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
? getHealthEventLabel(latestHealthEvent)
: '当前没有活动中的健康事件'
}
/>
<SummaryStat
label='请求/分钟'
label='查询窗口请求'
value={formatMetricCount(trafficSummary?.request_count)}
hint={
trafficSummary
? `近 60 秒 · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
? `查询窗口独立访客 ${formatMetricCount(trafficSummary.unique_visitor_count)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '当前没有可展示的请求窗口摘要'
}
/>
@@ -719,55 +717,39 @@ export function NodeObservability({
) : null}
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border px-3 py-3'>
<div className='rounded-lg border px-3 py-3 sm:col-span-2'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
OpenResty 吞吐
OpenResty 连接
</p>
<div className='mt-3 space-y-2 text-sm text-muted-foreground'>
<p>
入站:
{formatBytesPerSecond(
latestMetric.openresty_rx_bytes,
60,
)}
当前连接:
{latestMetric.openresty_connections ?? 0}
</p>
<p>
出站:
{formatBytesPerSecond(
latestMetric.openresty_tx_bytes,
60,
)}
<p className='text-xs'>
业务吞吐请看访问日志趋势(已提供/接收数据)
</p>
</div>
</div>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
节点网络
</p>
<div className='mt-3 space-y-2 text-sm text-muted-foreground'>
<p>入站:{formatBytes(latestMetric.network_rx_bytes)}</p>
<p>出站:{formatBytes(latestMetric.network_tx_bytes)}</p>
</div>
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
请求/分钟
查询窗口独立访客
</p>
<p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.request_count)}
{formatMetricCount(trafficSummary?.unique_visitor_count)}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{trafficSummary
? `近 60 秒 · 窗口UV ${formatMetricCount(trafficSummary.unique_visitor_count)}`
? `请求 ${formatMetricCount(trafficSummary.request_count)} · 估算 QPS ${trafficSummary.estimated_qps.toFixed(2)}`
: '暂无窗口流量摘要'}
</p>
</div>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
近 60 秒错误
查询窗口错误
</p>
<p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.error_count)}
@@ -2,7 +2,6 @@ import type {
ApplyResult,
NodeItem,
NodeStatus,
NodeTrafficReport,
OpenrestyStatus,
} from '@/lib/services/openflare';
@@ -139,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,
@@ -238,48 +257,6 @@ export function formatBytesPerSecond(value?: number | null, windowSeconds = 1) {
return `${formatBytes(value / windowSeconds)}/s`;
}
export function parseTrafficMap(value?: string | null) {
if (!value) {
return {} as Record<string, number>;
}
try {
const parsed = JSON.parse(value) as Record<string, number>;
return Object.entries(parsed).reduce<Record<string, number>>(
(result, [key, count]) => {
if (typeof count === 'number' && Number.isFinite(count)) {
result[key] = count;
}
return result;
},
{},
);
} catch {
return {} as Record<string, number>;
}
}
export function aggregateTrafficBreakdown(
reports: NodeTrafficReport[] | undefined,
field: 'status_codes_json' | 'top_domains_json',
) {
const summary = new Map<string, number>();
for (const report of reports ?? []) {
const parsed = parseTrafficMap(report[field]);
for (const [key, value] of Object.entries(parsed)) {
summary.set(key, (summary.get(key) ?? 0) + value);
}
}
return Array.from(summary.entries())
.sort((left, right) => {
if (right[1] === left[1]) {
return left[0].localeCompare(right[0]);
}
return right[1] - left[1];
})
.slice(0, 6)
.map(([label, value]) => ({ label, value }));
}
export function formatUsageRatio(used?: number | null, total?: number | null) {
if (!used || !total || total <= 0) {
return null;
@@ -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 { Loader2, UploadCloud } from 'lucide-react';
import { UploadCloud } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -14,9 +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 { Spinner } from '@/components/ui/spinner';
import { PagesService } from '@/lib/services/openflare';
import { cn } from '@/lib/utils';
@@ -27,16 +32,49 @@ import {
projectsQueryKey,
} from './pages-utils';
const PAGES_PACKAGE_ACCEPT =
'.zip,.tar.gz,.tgz,.tar.xz,.txz,.tar.bz2,.tbz2,.tbz,.tar,.7z';
const PAGES_PACKAGE_EXTENSIONS = [
'.zip',
'.tar.gz',
'.tgz',
'.tar.xz',
'.txz',
'.tar.bz2',
'.tbz2',
'.tbz',
'.tar',
'.7z',
] as const;
function isSupportedPagesPackage(fileName: string) {
const lower = fileName.toLowerCase();
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);
@@ -51,14 +89,14 @@ export function DeploymentUploadDialog({
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleClose = (nextOpen: boolean) => {
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
};
const uploadMutation = useMutation({
mutationFn: () => {
if (!file) throw new Error('请选择 zip 部署包');
if (!file) throw new Error('请选择部署包');
return PagesService.uploadDeployment(projectId, {
file,
onProgress: setUploadProgress,
@@ -73,7 +111,7 @@ export function DeploymentUploadDialog({
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
]);
handleClose(false);
handleOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '上传失败');
@@ -83,105 +121,119 @@ export function DeploymentUploadDialog({
const handleFileSelect = (selected: File | null) => {
if (!selected) return;
if (!selected.name.toLowerCase().endsWith('.zip')) {
toast.error('仅支持 zip 格式的文件');
if (!isSupportedPagesPackage(selected.name)) {
toast.error('仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式的部署包');
return;
}
setFile(selected);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>上传部署包</DialogTitle>
<DialogDescription>
上传已构建的 zip 静态资源包,部署后可在列表中激活。
上传不可变的静态资源压缩包,完成后可在部署历史中激活。
</DialogDescription>
</DialogHeader>
<div
className={cn(
'rounded-lg border border-dashed p-8 text-center transition',
isDragActive ? 'border-primary bg-primary/5' : 'bg-muted/20',
)}
onDragEnter={(e) => {
e.preventDefault();
setIsDragActive(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={(e) => {
e.preventDefault();
setIsDragActive(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragActive(false);
handleFileSelect(e.dataTransfer.files[0] ?? null);
}}
>
<UploadCloud className='size-8 mx-auto text-muted-foreground' />
<p className='mt-3 text-sm'>拖拽 zip 文件到此处,或点击选择文件</p>
<FieldGroup>
<Field>
<FieldLabel htmlFor='pages-package'>本地部署包</FieldLabel>
<button
type='button'
className={cn(
'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',
)}
onClick={() => fileInputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault();
setIsDragActive(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
event.preventDefault();
setIsDragActive(false);
}}
onDrop={(event) => {
event.preventDefault();
setIsDragActive(false);
handleFileSelect(event.dataTransfer.files[0] ?? null);
}}
>
<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 ? (
<FieldDescription>
已选择 {file.name}({formatBytes(file.size)})
</FieldDescription>
) : (
<FieldDescription>请选择一个受支持的压缩包。</FieldDescription>
)}
</Field>
<Field>
<FieldLabel>部署入口</FieldLabel>
<div className='rounded-md border bg-muted/20 px-3 py-2 font-mono text-sm'>
{pagesEntryPath(rootDir, entryFile)}
</div>
<FieldDescription>
入口来自项目设置;部署包上传不会覆盖该配置。
</FieldDescription>
</Field>
{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
type='button'
variant='outline'
size='sm'
className='mt-3'
onClick={() => fileInputRef.current?.click()}
onClick={() => handleOpenChange(false)}
>
选择文件
</Button>
<input
ref={fileInputRef}
type='file'
accept='.zip'
className='hidden'
onChange={(e) => handleFileSelect(e.target.files?.[0] ?? null)}
/>
</div>
{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}
{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}
<div className='space-y-1.5'>
<Label htmlFor='entryFile'>入口文件</Label>
<Input id='entryFile' defaultValue='index.html' disabled />
</div>
<DialogFooter>
<Button variant='outline' onClick={() => handleClose(false)}>
取消
</Button>
<Button
onClick={() => uploadMutation.mutate()}
type='button'
disabled={!file || uploadMutation.isPending}
onClick={() => uploadMutation.mutate()}
>
{uploadMutation.isPending ? (
<>
<Loader2 className='size-4 animate-spin mr-1' />
上传中...
</>
<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>
);
}
+2 -1
View File
@@ -30,7 +30,8 @@ export default function PagesPage() {
<div>
<h1 className='text-2xl font-semibold tracking-tight'>Pages</h1>
<p className='text-sm text-muted-foreground'>
边缘静态站点托管,上传 zip 部署包并在代理规则中选择 Pages 上游。
边缘静态站点托管,上传静态资源部署包并在代理规则中选择 Pages
上游。
</p>
</div>
</div>
@@ -16,7 +16,7 @@ export const proxyRouteConfigSections = [
{
key: 'limits' as const,
label: '流量限制',
description: '设置连接数和限速。',
description: '设置连接数和限速(可继承全局默认)。',
},
{
key: 'proxy' as const,
@@ -263,25 +263,26 @@ 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(
policy: 'url' | 'suffix' | 'path_prefix' | 'path_exact',
policy: 'static' | 'all' | 'url' | 'suffix' | 'path_prefix' | 'path_exact',
rules: string[],
) {
if (policy === 'url') {
if (policy === 'static' || policy === 'all' || policy === 'url') {
return null;
}
@@ -335,7 +336,17 @@ export function buildPayloadFromRoute(
limit_conn_per_ip: route.limit_conn_per_ip,
limit_rate: route.limit_rate,
cache_enabled: route.cache_enabled,
cache_policy: route.cache_policy || 'url',
cache_policy: (() => {
if (!route.cache_enabled) {
return 'static';
}
const policy = (route.cache_policy || '').trim();
// Legacy empty/url → all (same as backend displayCachePolicy).
if (!policy || policy === 'url' || policy === 'all') {
return 'all';
}
return policy;
})(),
cache_rules: route.cache_rule_list ?? [],
custom_headers: route.custom_header_list ?? [],
basic_auth_enabled: route.basic_auth_enabled,
@@ -17,6 +17,14 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
@@ -26,14 +34,17 @@ import {
SheetTitle,
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type { ProxyRouteItem } from '@/lib/services/openflare';
import {
NodeService,
PagesService,
ProxyRouteService,
ZoneService,
zoneQueryKey,
} from '@/lib/services/openflare';
import { listAllZoneDomains, parseOriginUrl } from './helpers';
import { listAllZoneDomains, parseOriginUrl, parseOriginUrls } from './helpers';
import { ZoneDomainSelector } from './zone-domain-selector';
const createProxyRouteSchema = z
@@ -42,24 +53,56 @@ const createProxyRouteSchema = z
zone_domain_ids: z
.array(z.number().int().positive())
.min(1, '请至少选择一个域名'),
origin_url: z.string().trim().min(1, '请输入上游地址'),
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
origin_urls_text: z.string().trim(),
tunnel_id: z.string().optional(),
tunnel_target_addr: z.string().trim().optional(),
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
pages_project_id: z.string().optional(),
enabled: z.boolean(),
})
.superRefine((value, context) => {
try {
const parsed = new URL(value.origin_url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
if (value.upstream_type === 'direct') {
if (!value.origin_urls_text.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_url'],
message: '上游地址必须以 http:// 或 https:// 开头',
path: ['origin_urls_text'],
message: '请至少填写一个上游地址',
});
} else {
const { error } = parseOriginUrls(value.origin_urls_text);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
}
}
return;
}
if (value.upstream_type === 'tunnel') {
if (!value.tunnel_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_id'],
message: '请选择内网穿透隧道',
});
}
} catch {
if (!value.tunnel_target_addr?.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_target_addr'],
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
});
}
return;
}
if (!value.pages_project_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_url'],
message: '上游地址格式不合法',
path: ['pages_project_id'],
message: '请选择 Pages 项目',
});
}
});
@@ -69,7 +112,12 @@ type CreateProxyRouteFormValues = z.infer<typeof createProxyRouteSchema>;
const defaultValues: CreateProxyRouteFormValues = {
site_name: '',
zone_domain_ids: [],
origin_url: '',
upstream_type: 'direct',
origin_urls_text: '',
tunnel_id: '',
tunnel_target_addr: '',
tunnel_target_protocol: 'http',
pages_project_id: '',
enabled: true,
};
@@ -101,6 +149,27 @@ export function ProxyRouteCreateSheet({
enabled: open,
});
const tunnelsQuery = useQuery({
queryKey: ['openflare', 'nodes'],
queryFn: () => NodeService.listNodes(),
enabled: open,
});
const pagesProjectsQuery = useQuery({
queryKey: ['openflare', 'pages-projects'],
queryFn: () => PagesService.listProjects(),
enabled: open,
});
const tunnelClients = (tunnelsQuery.data ?? []).filter(
(node) => node.node_type === 'tunnel_client',
);
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
(project) => project.enabled && project.active_deployment_id,
);
const upstreamType = form.watch('upstream_type');
useEffect(() => {
if (!open) {
form.reset(defaultValues);
@@ -108,7 +177,33 @@ export function ProxyRouteCreateSheet({
}, [form, open]);
const handleSubmit = form.handleSubmit(async (values) => {
const origin = parseOriginUrl(values.origin_url.trim());
let originUrl = '';
let originScheme: 'http' | 'https' = 'http';
let originAddress = '';
let originPort = '';
let originUri = '';
let upstreams: string[] = [];
if (values.upstream_type === 'direct') {
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
originUrl = urls[0];
originScheme = primaryOrigin.scheme;
originAddress = primaryOrigin.address;
originPort = primaryOrigin.port;
originUri = primaryOrigin.uri;
upstreams = urls.slice(1);
} else if (values.upstream_type === 'tunnel') {
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
originScheme = values.tunnel_target_protocol as 'http' | 'https';
originAddress = values.tunnel_target_addr || '';
} else {
originUrl = 'http://127.0.0.1';
originScheme = 'http';
originAddress = '127.0.0.1';
originPort = '80';
}
const selectedDomains = (domainsQuery.data ?? []).filter((domain) =>
values.zone_domain_ids.includes(domain.id),
);
@@ -120,25 +215,39 @@ export function ProxyRouteCreateSheet({
site_name: values.site_name.trim() || primaryDomain,
zone_domain_ids: values.zone_domain_ids,
origin_id: null,
origin_url: values.origin_url.trim(),
origin_scheme: origin.scheme,
origin_address: origin.address,
origin_port: origin.port,
origin_uri: origin.uri,
origin_url: originUrl,
origin_scheme: originScheme,
origin_address: originAddress,
origin_port: originPort,
origin_uri: originUri,
origin_host: '',
upstreams: [],
upstreams,
enabled: values.enabled,
enable_https: hasCert,
redirect_http: false,
limit_conn_per_server: 0,
limit_conn_per_ip: 0,
limit_rate: '',
cache_enabled: false,
cache_policy: 'url',
cache_enabled: true,
cache_policy: 'static',
cache_rules: [],
custom_headers: [],
basic_auth_enabled: false,
upstream_type: 'direct',
upstream_type: values.upstream_type,
tunnel_node_id:
values.upstream_type === 'tunnel' && values.tunnel_id
? Number(values.tunnel_id)
: null,
tunnel_target_addr:
values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol:
values.upstream_type === 'tunnel'
? values.tunnel_target_protocol
: '',
pages_project_id:
values.upstream_type === 'pages' && values.pages_project_id
? Number(values.pages_project_id)
: null,
});
form.reset(defaultValues);
@@ -200,9 +309,6 @@ export function ProxyRouteCreateSheet({
}}
/>
</FormControl>
<FormDescription>
勾选已登记域名,或使用「快捷新增域名」创建后自动勾选。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -210,18 +316,187 @@ export function ProxyRouteCreateSheet({
<FormField
control={form.control}
name='origin_url'
name='upstream_type'
render={({ field }) => (
<FormItem>
<FormLabel>上游地址</FormLabel>
<FormControl>
<Input placeholder='http://127.0.0.1:8080' {...field} />
</FormControl>
<FormItem className='space-y-3'>
<FormLabel>回源方式</FormLabel>
<div className='flex flex-wrap gap-4'>
{(
[
['direct', '直连上游'],
['tunnel', '内网穿透 (Tunnel)'],
['pages', 'Pages 静态站点'],
] as const
).map(([value, label]) => (
<label
key={value}
className='flex cursor-pointer items-center gap-2 text-sm'
>
<input
type='radio'
value={value}
checked={field.value === value}
onChange={() => field.onChange(value)}
className='size-4 accent-primary'
/>
<Label className='font-normal'>{label}</Label>
</label>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
{upstreamType === 'direct' ? (
<FormField
control={form.control}
name='origin_urls_text'
render={({ field }) => (
<FormItem>
<FormLabel>上游地址</FormLabel>
<FormControl>
<Textarea
className='min-h-32 font-mono text-xs'
placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...field}
/>
</FormControl>
<FormDescription>
每行一个完整
URL。第一行作为主回源,多上游模式请保持相同协议且不要包含
path 或 query。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : null}
{upstreamType === 'tunnel' ? (
<div className='space-y-4 rounded-lg border border-dashed bg-muted/30 p-4'>
<FormField
control={form.control}
name='tunnel_id'
render={({ field }) => (
<FormItem>
<FormLabel>选择内网穿透隧道</FormLabel>
<Select
value={field.value || 'none'}
onValueChange={(value) =>
field.onChange(value === 'none' ? '' : value)
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='请选择...' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='none'>请选择...</SelectItem>
{tunnelClients.map((tunnel) => (
<SelectItem
key={tunnel.id}
value={String(tunnel.id)}
>
{tunnel.name} (
{tunnel.status === 'online' ? '在线' : '离线'})
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
将请求转发到该隧道连接的客户端节点。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='tunnel_target_protocol'
render={({ field }) => (
<FormItem>
<FormLabel>内网服务协议</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='http'>HTTP</SelectItem>
<SelectItem value='https'>HTTPS</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='tunnel_target_addr'
render={({ field }) => (
<FormItem>
<FormLabel>内网服务地址</FormLabel>
<FormControl>
<Input placeholder='127.0.0.1:8080' {...field} />
</FormControl>
<FormDescription>
例如: 127.0.0.1:8080 或 192.168.1.10:80
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
{upstreamType === 'pages' ? (
<div className='rounded-lg border border-dashed bg-muted/30 p-4'>
<FormField
control={form.control}
name='pages_project_id'
render={({ field }) => (
<FormItem>
<FormLabel>选择 Pages 项目</FormLabel>
<Select
value={field.value || 'none'}
onValueChange={(value) =>
field.onChange(value === 'none' ? '' : value)
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='请选择...' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='none'>请选择...</SelectItem>
{pagesProjects.map((project) => (
<SelectItem
key={project.id}
value={String(project.id)}
>
{project.name} ({project.slug})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null}
<FormField
control={form.control}
name='enabled'
@@ -36,7 +36,13 @@ import { SectionShell } from './section-shell';
const cacheSchema = z
.object({
cache_enabled: z.boolean(),
cache_policy: z.enum(['url', 'suffix', 'path_prefix', 'path_exact']),
cache_policy: z.enum([
'static',
'all',
'suffix',
'path_prefix',
'path_exact',
]),
cache_rules_text: z.string(),
})
.superRefine((value, context) => {
@@ -63,6 +69,29 @@ interface CacheSectionProps {
onSavingChange?: (saving: boolean) => void;
}
/** Map API/DB values for the form. Legacy empty/url → all (compat). */
function normalizeCachePolicyValue(
policy: string | undefined | null,
enabled = true,
) {
if (!enabled) {
return 'static';
}
const value = (policy || '').trim();
if (!value || value === 'url' || value === 'all') return 'all';
if (value === 'static') return 'static';
if (value === 'suffix' || value === 'path_prefix' || value === 'path_exact') {
return value;
}
return 'static';
}
function needsRulesForPolicy(policy: string) {
return (
policy === 'suffix' || policy === 'path_prefix' || policy === 'path_exact'
);
}
export function CacheSection({
route,
onRouteUpdate,
@@ -78,8 +107,10 @@ export function CacheSection({
resolver: zodResolver(cacheSchema),
defaultValues: {
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy ||
'url') as CacheValues['cache_policy'],
cache_policy: normalizeCachePolicyValue(
route.cache_policy,
route.cache_enabled,
) as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
},
});
@@ -87,14 +118,20 @@ export function CacheSection({
useEffect(() => {
form.reset({
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy ||
'url') as CacheValues['cache_policy'],
cache_policy: normalizeCachePolicyValue(
route.cache_policy,
route.cache_enabled,
) as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
});
}, [form, route]);
const watchedEnabled = form.watch('cache_enabled');
const watchedPolicy = form.watch('cache_policy');
const needsRules =
watchedPolicy === 'suffix' ||
watchedPolicy === 'path_prefix' ||
watchedPolicy === 'path_exact';
const rulesHint =
watchedPolicy === 'suffix'
@@ -103,7 +140,9 @@ export function CacheSection({
? '每行一个路径前缀,例如 /assets、/static。'
: watchedPolicy === 'path_exact'
? '每行一个精确路径,例如 /robots.txt。'
: '按 URL 缓存时无需额外规则。';
: watchedPolicy === 'static'
? '标准静态资源使用内置扩展名列表(不含 HTML),无需填写规则。'
: '所有可缓存 GET 无需额外规则(仍会绕过登录态与 Authorization)。';
const rulesPlaceholder =
watchedPolicy === 'suffix'
@@ -112,7 +151,7 @@ export function CacheSection({
? '/assets\n/static'
: watchedPolicy === 'path_exact'
? '/robots.txt\n/manifest.json'
: '按 URL 缓存时无需额外规则';
: '当前策略无需额外规则';
return (
<SectionShell
@@ -130,11 +169,10 @@ export function CacheSection({
await save(
{
cache_enabled: values.cache_enabled,
cache_policy: values.cache_enabled
? values.cache_policy
: 'url',
cache_policy: values.cache_enabled ? values.cache_policy : '',
cache_rules:
values.cache_enabled && values.cache_policy !== 'url'
values.cache_enabled &&
needsRulesForPolicy(values.cache_policy)
? rules
: [],
},
@@ -150,8 +188,9 @@ export function CacheSection({
<div className='space-y-0.5'>
<FormLabel>启用站点缓存</FormLabel>
<FormDescription>
系统仍会自动绕过非 GET、带 Authorization 或常见登录态 Cookie
的请求。
新建推荐「标准静态资源」(不含
HTML)。须同时开启性能设置中的全局 OpenResty
缓存。仍会绕过非 GET、Authorization 与常见登录 Cookie。
</FormDescription>
</div>
<FormControl>
@@ -181,12 +220,17 @@ export function CacheSection({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='url'>按 URL 缓存</SelectItem>
<SelectItem value='suffix'>按后缀缓存</SelectItem>
<SelectItem value='path_prefix'>按路径前缀缓存</SelectItem>
<SelectItem value='path_exact'>按精确路径缓存</SelectItem>
<SelectItem value='static'>标准静态资源(推荐)</SelectItem>
<SelectItem value='all'>所有可缓存 GET(高级)</SelectItem>
<SelectItem value='suffix'>自定义后缀</SelectItem>
<SelectItem value='path_prefix'>路径前缀</SelectItem>
<SelectItem value='path_exact'>精确路径</SelectItem>
</SelectContent>
</Select>
<FormDescription>
标准静态资源含 css/js/图片/字体/媒体等,默认不缓存 HTML
与接口路径。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -201,7 +245,7 @@ export function CacheSection({
<FormControl>
<Textarea
className='min-h-32'
disabled={!watchedEnabled || watchedPolicy === 'url'}
disabled={!watchedEnabled || !needsRules}
placeholder={rulesPlaceholder}
{...field}
/>
@@ -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',

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