mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 894745d43a | |||
| 39d54c2fe4 | |||
| fdadd76945 | |||
| 6e109fd3f7 | |||
| f14ba66a11 | |||
| 6b1d2e8af9 | |||
| a0fff76fcb |
@@ -1,5 +1 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 赞赏支持
|
||||
url: https://iamazing.cn/page/reward
|
||||
about: 请作者喝杯咖啡,以激励作者持续开发
|
||||
blank_issues_enabled: false
|
||||
+2
-1
@@ -131,7 +131,7 @@ Agent 当前支持:
|
||||
| `agent_token` | 节点专属认证 Token | 与 `discovery_token` 二选一 | 空 |
|
||||
| `discovery_token` | 首次自动注册使用的全局 Token | 与 `agent_token` 二选一 | 空 |
|
||||
| `node_name` | 节点名称 | 否 | 自动使用主机名 |
|
||||
| `node_ip` | 节点 IP | 否 | 自动探测 |
|
||||
| `node_ip` | 节点 IP | 否 | 自动探测,优先选择公网 IPv4;仅无公网地址时退回可用内网地址 |
|
||||
| `openresty_path` | 本机 OpenResty 路径 | 否 | 空,未设置时走 Docker 模式 |
|
||||
| `openresty_container_name` | Docker 模式下的容器名 | 否 | `openflare-openresty` |
|
||||
| `openresty_docker_image` | Docker 模式下的镜像 | 否 | `openresty/openresty:alpine` |
|
||||
@@ -155,6 +155,7 @@ Agent 当前支持:
|
||||
* `agent_token` 与 `discovery_token` 不能同时为空
|
||||
* `heartbeat_interval` 与 `request_timeout` 支持毫秒整数或 Go duration 字符串
|
||||
* 未配置 `openresty_path` 时默认使用 Docker OpenResty 模式
|
||||
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址
|
||||
|
||||
## 3. 维护要求
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
|
||||
### 1.2 Agent
|
||||
|
||||
* Go 1.23+
|
||||
* Go 1.24+
|
||||
* 对 Agent 数据目录有写权限
|
||||
* 本机模式下可执行 `openresty -t` 与 `openresty -s reload`
|
||||
* Docker 模式下具备 Docker 执行权限
|
||||
|
||||
+8
-25
@@ -9,6 +9,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
|
||||
当前稳定能力包括:
|
||||
|
||||
* 反代规则管理
|
||||
* 网站级配置与多域名绑定
|
||||
* 源站管理与复用
|
||||
* 配置预览、发布、激活与回滚
|
||||
* Agent 注册、心跳、同步、应用结果上报
|
||||
@@ -24,29 +25,6 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
|
||||
* Server 保存配置与状态,不直接 SSH 管理节点
|
||||
* Agent 是节点侧唯一受控落地入口
|
||||
|
||||
## 2. 范围边界
|
||||
|
||||
当前明确不做:
|
||||
|
||||
* 多租户
|
||||
* CDN SaaS 化能力
|
||||
* GeoDNS、全球调度、智能选路
|
||||
* WAF、Bot 管理、限流平台化
|
||||
* 灰度百分比发布、按节点差异化配置
|
||||
* 对象存储、消息队列、Prometheus、ClickHouse、Kafka 等前置基础设施
|
||||
* 通用日志检索平台、APM、调用链系统、任意 BI 报表
|
||||
* 证书自动签发与自动续期
|
||||
* 平台化抽象对象,如 `zone`、`origin_pool`、`policy`、`deployment`
|
||||
|
||||
边界补充:
|
||||
|
||||
* OpenResty 代理缓存只作为当前反代链路优化能力存在,不扩展为独立缓存产品
|
||||
* 源站对象只用于沉淀可复用的回源地址,不扩展为带健康检查、权重、策略编排的 `origin_pool`
|
||||
* 主配置文件由 Server 统一渲染并由 Agent 受控写入,不支持节点侧手工编辑后回传合并
|
||||
* 节点观测聚焦运营与运维排障所需的摘要、趋势和受控窗口数据,不提供长期日志托管
|
||||
|
||||
新增能力如果超出上述边界,先更新本文档,再进入实现。
|
||||
|
||||
## 3. 技术基线
|
||||
|
||||
### 3.1 Server
|
||||
@@ -122,13 +100,17 @@ Origin
|
||||
|
||||
稳定约束:
|
||||
|
||||
* 一个域名只对应一条 `proxy_routes` 规则
|
||||
* `proxy_routes` 从“单域名规则”升级为“网站配置”聚合对象;一条记录对应一个网站,可绑定一个或多个域名,并共享一组站点级配置
|
||||
* `proxy_routes.site_name` 是网站的业务唯一标识;新建时默认取 `domains[0]`,后续允许独立维护,不随域名改动自动重写
|
||||
* `proxy_routes.domains` 至少包含一个域名,且 `domains[0]` 作为主域名;任一域名全局只能属于一个 `proxy_routes`
|
||||
* 为兼容历史数据,迁移期可保留 `proxy_routes.domain` 作为 `domains[0]` 的镜像字段,但业务读写与后续扩展必须以 `site_name` + `domains` 为准
|
||||
* `origins` 只保存源站地址、展示名与备注,不承载协议、端口、路径、权重或健康检查策略
|
||||
* `proxy_routes` 可选关联一个 `origins` 记录,用于复用源站地址;规则仍保存完整 `origin_url` 快照以参与渲染与版本快照
|
||||
* `proxy_routes` 至少包含一个上游地址;为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡
|
||||
* `proxy_routes` 上游统一渲染为带 keepalive 的 named `upstream`;单上游可附带 base path 或 query 并在 `proxy_pass` 中追加,多上游仍限定为纯 `scheme://host[:port]`
|
||||
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名
|
||||
* `proxy_routes.domain` 必须唯一
|
||||
* 网站级流量限制、反向代理、HTTPS 与缓存配置当前按站点共享,不在同一网站内做域名级差异化配置
|
||||
* 发布渲染时必须将 `proxy_routes.domains` 中的全部域名一并纳入同一站点配置,避免同站点在版本快照中被拆散
|
||||
* 所有上游地址都必须为合法 `http://` 或 `https://`
|
||||
* `config_versions` 必须保存完整快照、渲染结果与 `checksum`
|
||||
* 全局同时只能有一个激活版本
|
||||
@@ -194,3 +176,4 @@ Origin
|
||||
* 产品范围或系统边界变化时更新本文档
|
||||
* 已完成阶段不再以“版本计划”形式回填
|
||||
* 新阶段开始前,先补设计,再进入实现
|
||||
* 涉及网站级规则改造的详细需求与实施顺序,见 [docs/website-configuration-redesign.md](./website-configuration-redesign.md)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
`openflare_agent` 继续作为 Go 单体程序:
|
||||
|
||||
* Go 1.23+
|
||||
* Go 1.24+
|
||||
* 单二进制
|
||||
* 节点本地执行
|
||||
* `openresty_path` 优先
|
||||
@@ -117,10 +117,14 @@
|
||||
|
||||
* 不新增平台化对象,除非设计文档明确要求
|
||||
* `origins` 仅作为可复用源站地址目录,字段保持轻量;协议、端口、路径与查询参数继续归属具体 `proxy_routes`
|
||||
* `proxy_routes` 维持一条域名对应一条规则;规则内允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`
|
||||
* `proxy_routes` 以“网站配置”作为聚合边界,必须包含唯一 `site_name` 与非空 `domains` 列表;数据库内部 `id` 可继续作为技术主键,但不能替代 `site_name` 的业务唯一性
|
||||
* `proxy_routes.domains` 中的每个域名都必须全局唯一;列表第一项视为主域名,创建时若未显式填写 `site_name`,则默认使用主域名
|
||||
* `proxy_routes` 继续允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`
|
||||
* 迁移期如保留遗留 `domain` 字段,只能作为 `domains[0]` 的兼容镜像;新代码不得继续以该字段作为唯一业务输入
|
||||
* `proxy_routes` 如关联 `origins`,必须同时保存可直接渲染的 `origin_url`;源站地址变更时,由 service 负责同步更新引用该源站的规则快照
|
||||
* `proxy_routes` 的上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]`
|
||||
* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象
|
||||
* 流量限制、反向代理、HTTPS 与缓存配置当前都归属站点级 `proxy_routes`,同一网站内不拆分域名级差异配置
|
||||
* `config_versions` 必须保存完整快照与渲染结果
|
||||
* 全局同时只能有一个激活版本
|
||||
* 回滚通过重新激活旧版本实现
|
||||
|
||||
@@ -48,3 +48,14 @@
|
||||
* 部署与配置变动:更新 `README.md`、`docs/deployment.md`、`docs/app-config.md`
|
||||
|
||||
如果未来出现明确的新阶段目标,再单独新增专项计划文档;不要把已完成的历史计划继续堆回本文件。
|
||||
|
||||
## 6. 当前专项计划
|
||||
|
||||
已确认需要推进“网站级规则与配置界面改造”专项,详细需求、实施顺序与验收标准见 [docs/website-configuration-redesign.md](./website-configuration-redesign.md)。
|
||||
|
||||
本专项的执行顺序固定为:
|
||||
|
||||
1. 先完成数据模型与配置渲染兼容方案
|
||||
2. 再调整接口、校验与版本 diff 语义
|
||||
3. 然后改造规则列表与网站配置子页面
|
||||
4. 最后补齐迁移、回归测试与文档联动
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
* 默认使用 App Router
|
||||
* 前端必须支持 `light`、`dark`、`system` 三种主题模式
|
||||
|
||||
禁止:
|
||||
|
||||
* 引入 Semantic UI
|
||||
* 新增大型 UI 框架破坏现有组件基线
|
||||
* 使用 jQuery 风格 DOM 操作
|
||||
|
||||
## 2. 目录与分层
|
||||
|
||||
@@ -131,24 +126,3 @@ tests/
|
||||
* 同时支持 `light`、`dark`、`system`
|
||||
* 用户选择必须持久化
|
||||
* 首屏尽量避免主题闪烁
|
||||
|
||||
## 7. 测试与交付
|
||||
|
||||
每个页面至少具备:
|
||||
|
||||
* 加载态
|
||||
* 空态
|
||||
* 错误态
|
||||
* 成功反馈
|
||||
|
||||
测试要求:
|
||||
|
||||
* 公共工具、类型转换、主题逻辑补单元测试
|
||||
* 关键页面交互补组件测试
|
||||
* 核心主链路补 Playwright 或等效联调验证
|
||||
|
||||
交付要求:
|
||||
|
||||
* 构建产物保持可静态导出
|
||||
* 构建结果可被 Go Server 托管
|
||||
* 新页面默认通过亮色与暗色模式验收
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
# 网站配置改造需求与开发计划
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前规则模块以“一个域名对应一条规则”为中心,已经支持单域名绑定一个或多个上游,但无法表达“多个域名共享同一套站点配置”的场景。
|
||||
|
||||
现阶段已经出现以下真实需求:
|
||||
|
||||
* 多个域名指向同一站点,并共享反向代理、HTTPS、缓存等设置
|
||||
* 后续希望围绕“网站”继续叠加更多功能,而不是持续在规则列表中堆积字段
|
||||
* 现有抽屉式编辑界面已经不适合承载更复杂的配置结构
|
||||
|
||||
因此,本轮改造将 `proxy_routes` 从“单域名规则”升级为“网站配置”视角,并引入独立的配置子页面。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本轮改造的目标如下:
|
||||
|
||||
* 支持一个网站绑定多个域名
|
||||
* 支持一个网站绑定一个或多个上游
|
||||
* 引入 `site_name` 作为网站业务唯一标识
|
||||
* 将原列表页的“编辑”操作替换为“配置”,进入独立子页面管理
|
||||
* 将网站配置拆分为更清晰的功能分区,为后续扩展预留结构
|
||||
|
||||
## 3. 本轮范围
|
||||
|
||||
本轮仅覆盖以下站点级配置能力:
|
||||
|
||||
* 域名设置
|
||||
* 流量限制
|
||||
* 反向代理
|
||||
* HTTPS
|
||||
* 缓存
|
||||
|
||||
## 4. 核心模型要求
|
||||
|
||||
### 4.1 网站标识
|
||||
|
||||
* `site_name` 为网站业务唯一标识
|
||||
* 新建网站时,若用户未输入 `site_name`,默认取域名列表第一项
|
||||
* `site_name` 在首次生成后允许独立编辑,不随域名变更自动同步,避免影响引用、跳转和审计
|
||||
* 数据库内部主键可以继续使用现有数值 `id`,但业务层必须校验 `site_name` 唯一性
|
||||
|
||||
### 4.2 域名列表
|
||||
|
||||
* 网站的域名字段改为 `domains` 列表
|
||||
* `domains` 至少包含一个有效域名
|
||||
* `domains[0]` 视为主域名,用于列表摘要、默认展示和兼容历史逻辑
|
||||
* 同一网站内域名不能重复
|
||||
* 任一域名在全局只能属于一个网站
|
||||
* 域名列表需要支持新增、删除和调整顺序
|
||||
|
||||
### 4.3 历史兼容
|
||||
|
||||
* 存量单域名数据迁移后应自动转换为:
|
||||
`site_name = domain`
|
||||
`domains = [domain]`
|
||||
* 若迁移期保留旧 `domain` 字段,该字段仅作为 `domains[0]` 的兼容镜像,不再作为主要业务输入
|
||||
* 版本渲染、差异预览、接口返回和前端展示都应逐步以 `site_name + domains` 为准
|
||||
|
||||
## 5. 功能需求
|
||||
|
||||
### 5.1 列表页改造
|
||||
|
||||
规则列表改造为“网站列表”视图,要求如下:
|
||||
|
||||
* 保留当前列表页入口,但展示对象改为网站
|
||||
* 原“编辑”按钮替换为“配置”按钮
|
||||
* 点击“配置”进入网站配置子页面
|
||||
* 列表项至少展示:
|
||||
`site_name`
|
||||
主域名
|
||||
域名数量
|
||||
上游摘要
|
||||
HTTPS/缓存/启用状态摘要
|
||||
* 删除、发布等现有高风险操作仍保留明确确认
|
||||
|
||||
### 5.2 网站配置子页面
|
||||
|
||||
网站配置采用左右布局:
|
||||
|
||||
* 左侧为菜单栏,用于切换配置分区
|
||||
* 右侧为当前分区的设置面板
|
||||
* 默认进入“域名设置”分区
|
||||
* 建议基于 App Router 子路由或稳定的 tab 路由参数实现,保证可直接访问和刷新恢复
|
||||
|
||||
建议左侧菜单项固定为:
|
||||
|
||||
1. 域名设置
|
||||
2. 流量限制
|
||||
3. 反向代理
|
||||
4. HTTPS
|
||||
5. 缓存
|
||||
|
||||
为降低跨分区校验干扰,每个分区应支持独立保存与反馈;若采用统一保存,也必须提供未保存修改提示。
|
||||
|
||||
### 5.3 域名设置
|
||||
|
||||
域名设置分区负责维护网站身份与域名列表,要求如下:
|
||||
|
||||
* 可编辑 `site_name`
|
||||
* 可维护 `domains` 列表
|
||||
* 可新增、删除、排序域名
|
||||
* 明确提示第一项为主域名
|
||||
* 保存前校验:
|
||||
`site_name` 非空且唯一
|
||||
`domains` 非空
|
||||
每个域名格式合法
|
||||
域名在当前站点内不重复
|
||||
域名在全局不与其他网站冲突
|
||||
|
||||
### 5.4 流量限制
|
||||
|
||||
流量限制分区用于配置站点级限流,第一期要求覆盖以下字段:
|
||||
|
||||
* `limit_conn perserver`
|
||||
* `limit_conn perip`
|
||||
* `limit_rate`
|
||||
|
||||
要求如下:
|
||||
|
||||
* 采用结构化字段存储,不允许直接录入原始 Nginx 片段
|
||||
* `limit_conn perserver` 与 `limit_conn perip` 为整数;空值或 `0` 视为未启用
|
||||
* `limit_rate` 采用人类可读格式录入,例如 `512k`、`1m`
|
||||
* 保存前进行格式校验,并在页面中提供示例说明
|
||||
* 配置发布后渲染为对应的 OpenResty/Nginx 指令
|
||||
|
||||
### 5.5 反向代理
|
||||
|
||||
反向代理分区负责维护网站回源配置,要求如下:
|
||||
|
||||
* 支持一个或多个上游
|
||||
* 至少保留一个上游
|
||||
* 支持维护回源主机名 `origin_host`
|
||||
* 继续兼容当前单上游带 path/query、多上游做负载均衡的模式
|
||||
* 若复用 `origins` 目录,只作为地址候选来源,不改变网站配置为主的编辑模型
|
||||
|
||||
建议继续保留当前兼容约束:
|
||||
|
||||
* 单上游可附带 path/query
|
||||
* 多上游模式下,上游项保持 `scheme://host[:port]` 形式
|
||||
* 同一网站的多个上游在多上游模式下维持统一协议,降低渲染复杂度
|
||||
|
||||
### 5.6 HTTPS
|
||||
|
||||
HTTPS 分区负责维护站点级 TLS 行为,要求如下:
|
||||
|
||||
* 支持开启或关闭 HTTPS
|
||||
* 支持选择证书
|
||||
* 支持保留现有 `HTTP -> HTTPS` 跳转能力
|
||||
* 当 HTTPS 开启时必须明确证书来源
|
||||
* 应校验证书是否覆盖当前网站的全部域名;若无法覆盖,应阻止保存或给出不可忽略的错误提示
|
||||
|
||||
### 5.7 缓存
|
||||
|
||||
缓存分区负责维护站点级缓存策略,要求如下:
|
||||
|
||||
* 支持开启或关闭缓存
|
||||
* 支持多种缓存策略
|
||||
* 第一阶段至少兼容当前已存在的策略:
|
||||
`url`
|
||||
`suffix`
|
||||
`path_prefix`
|
||||
`path_exact`
|
||||
* 缓存规则继续采用结构化配置,不直接暴露原始 Nginx 片段
|
||||
* 保持当前安全绕过逻辑,不因界面改造改变默认缓存边界
|
||||
|
||||
## 6. 接口与渲染要求
|
||||
|
||||
* 列表接口需要返回 `site_name`、`domains`、主域名、状态摘要等字段
|
||||
* 详情接口需要按分区所需字段返回完整站点配置
|
||||
* 更新接口需要支持按分区或按网站整体更新,但服务端必须统一做跨字段校验
|
||||
* 配置 diff 不再只关注单个域名变更,还要能识别:
|
||||
网站新增/删除
|
||||
域名列表变更
|
||||
站点级配置变更
|
||||
* 发布渲染时,同一网站的全部域名必须落入同一份站点配置上下文中
|
||||
|
||||
## 7. 前端实现要求
|
||||
|
||||
* 列表页负责导航与摘要,不再承载完整编辑表单
|
||||
* 网站配置子页面中的每个分区表单继续遵循 `React Hook Form + Zod`
|
||||
* API 请求统一收敛在 `lib/api/`
|
||||
* 站点级数据查询与缓存继续使用 TanStack Query
|
||||
* 左侧菜单切换时需要明确处理未保存状态,避免无提示丢失修改
|
||||
* 页面至少覆盖加载态、空态、错误态和保存成功反馈
|
||||
|
||||
## 8. 数据迁移要求
|
||||
|
||||
实施前必须准备显式数据库迁移与校验逻辑,至少包含:
|
||||
|
||||
1. 新增 `site_name` 与 `domains` 存储结构
|
||||
2. 将旧数据从单域名回填到站点结构
|
||||
3. 为 `site_name` 建立唯一约束
|
||||
4. 为域名唯一性建立可校验约束
|
||||
5. 对迁移结果做一致性校验
|
||||
|
||||
迁移失败时,启动流程必须中止,不允许带半迁移状态继续运行。
|
||||
|
||||
## 9. 开发计划
|
||||
|
||||
### 阶段一:模型与渲染改造
|
||||
|
||||
目标:
|
||||
|
||||
* 定义网站级 `proxy_routes` 数据结构
|
||||
* 完成存量数据迁移
|
||||
* 调整配置渲染与发布链路,支持多域名同站点输出
|
||||
|
||||
交付物:
|
||||
|
||||
* 数据库迁移
|
||||
* model/service 调整
|
||||
* 配置渲染兼容实现
|
||||
* 迁移与渲染测试
|
||||
|
||||
### 阶段二:接口与校验改造
|
||||
|
||||
目标:
|
||||
|
||||
* 更新列表、详情、创建、更新接口的数据结构
|
||||
* 引入 `site_name`、`domains`、流量限制等字段校验
|
||||
* 调整版本 diff 与发布预览语义
|
||||
|
||||
交付物:
|
||||
|
||||
* API 契约更新
|
||||
* 服务端参数校验与错误消息
|
||||
* diff/preview 适配
|
||||
* 接口回归测试
|
||||
|
||||
### 阶段三:前端网站列表与配置子页面
|
||||
|
||||
目标:
|
||||
|
||||
* 将规则列表切换为网站列表
|
||||
* 用“配置”按钮替代“编辑”按钮
|
||||
* 落地左右布局的网站配置子页面与五个分区
|
||||
|
||||
交付物:
|
||||
|
||||
* 列表页 UI 改造
|
||||
* 子页面路由与布局
|
||||
* 域名设置、流量限制、反向代理、HTTPS、缓存五个分区
|
||||
* 前端交互与表单测试
|
||||
|
||||
### 阶段四:联调、发布验证与文档收口
|
||||
|
||||
目标:
|
||||
|
||||
* 验证从创建网站到发布配置的全链路
|
||||
* 验证 Agent 拉取、应用与回滚不受影响
|
||||
* 收口文档与测试
|
||||
|
||||
交付物:
|
||||
|
||||
* 联调记录
|
||||
* 发布/回滚回归验证
|
||||
* 文档同步更新
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
满足以下条件后,本专项可视为完成:
|
||||
|
||||
* 可以创建一个网站,并绑定多个域名
|
||||
* 一个网站可以绑定单个或多个上游
|
||||
* `site_name` 唯一,且创建时默认取第一个域名
|
||||
* 原列表页已用“配置”按钮替代“编辑”按钮
|
||||
* 网站配置子页面已经采用左侧菜单、右侧设置的布局
|
||||
* 五个分区均可独立完成基本配置与保存
|
||||
* 发布后的渲染结果可正确覆盖同一网站的全部域名
|
||||
* Agent 同步、应用、回滚链路不被破坏
|
||||
* 迁移、接口、渲染与前端关键路径均有对应测试或等效回归验证
|
||||
@@ -1,3 +1,7 @@
|
||||
module openflare-agent
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
require openflare v0.0.0
|
||||
|
||||
replace openflare => ../openflare_server
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"openflare/utils/geoip/iputil"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
@@ -331,6 +332,8 @@ func detectNodeIP() string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
bestIP := ""
|
||||
bestPriority := -1
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
@@ -344,11 +347,27 @@ func detectNodeIP() string {
|
||||
if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
ipv4 := ipNet.IP.To4()
|
||||
if ipv4 != nil {
|
||||
return ipv4.String()
|
||||
ipv4 := normalizeIPv4(ipNet.IP)
|
||||
priority := nodeIPPriority(ipv4)
|
||||
if priority > bestPriority {
|
||||
bestIP = ipv4.String()
|
||||
bestPriority = priority
|
||||
}
|
||||
if bestPriority == 2 {
|
||||
return bestIP
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return bestIP
|
||||
}
|
||||
|
||||
func normalizeIPv4(ip net.IP) net.IP {
|
||||
if ip == nil {
|
||||
return nil
|
||||
}
|
||||
return ip.To4()
|
||||
}
|
||||
|
||||
func nodeIPPriority(ip net.IP) int {
|
||||
return iputil.Score(ip)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -326,3 +327,49 @@ func TestInitialAuthToken(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeIPPriority(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "public ipv4 preferred",
|
||||
ip: "8.8.8.8",
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
name: "private ipv4 fallback",
|
||||
ip: "10.0.0.8",
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "link local ignored",
|
||||
ip: "169.254.1.10",
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
name: "loopback ignored",
|
||||
ip: "127.0.0.1",
|
||||
expected: -1,
|
||||
},
|
||||
{
|
||||
name: "nil ignored",
|
||||
ip: "",
|
||||
expected: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var parsed net.IP
|
||||
if tt.ip != "" {
|
||||
parsed = net.ParseIP(tt.ip)
|
||||
}
|
||||
if got := nodeIPPriority(parsed); got != tt.expected {
|
||||
t.Fatalf("unexpected priority for %q: got %d want %d", tt.ip, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func AgentRegister(c *gin.Context) {
|
||||
respondBadRequest(c, "")
|
||||
return
|
||||
}
|
||||
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
|
||||
|
||||
var (
|
||||
result *service.AgentRegistrationResponse
|
||||
@@ -57,6 +58,7 @@ func AgentHeartbeat(c *gin.Context) {
|
||||
respondBadRequest(c, "")
|
||||
return
|
||||
}
|
||||
payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr)
|
||||
|
||||
authNode, ok := c.Get("agent_node")
|
||||
if !ok {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module openflare
|
||||
|
||||
// +heroku goVersion go1.18
|
||||
// +heroku goVersion go1.24
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
|
||||
@@ -212,10 +212,10 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
"current_version": "",
|
||||
"last_error": "",
|
||||
}
|
||||
resp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
|
||||
resp := performAgentJSONRequestWithTokenAndRemote(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload, "198.51.100.10:1234")
|
||||
var registeredNode model.Node
|
||||
decodeResponseData(t, resp, ®isteredNode)
|
||||
if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
|
||||
if registeredNode.IP != "198.51.100.10" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
|
||||
t.Fatal("expected heartbeat to update node metadata")
|
||||
}
|
||||
if registeredNode.OpenrestyStatus != service.OpenrestyStatusUnhealthy {
|
||||
@@ -330,6 +330,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
||||
restartHeartbeatReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader(rawHeartbeatPayload))
|
||||
restartHeartbeatReq.Header.Set("Content-Type", "application/json")
|
||||
restartHeartbeatReq.Header.Set("X-Agent-Token", createdNode.AgentToken)
|
||||
restartHeartbeatReq.RemoteAddr = "198.51.100.10:1234"
|
||||
restartHeartbeatRecorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(restartHeartbeatRecorder, restartHeartbeatReq)
|
||||
if restartHeartbeatRecorder.Code != http.StatusOK {
|
||||
@@ -533,7 +534,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
|
||||
t.Fatal("expected global discovery token to be available")
|
||||
}
|
||||
|
||||
resp := performAgentJSONRequestWithToken(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{
|
||||
resp := performAgentJSONRequestWithTokenAndRemote(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{
|
||||
"node_id": "local-node-id",
|
||||
"name": "bulk-edge-1",
|
||||
"ip": "10.0.0.18",
|
||||
@@ -541,7 +542,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
|
||||
"nginx_version": "1.25.5",
|
||||
"current_version": "",
|
||||
"last_error": "",
|
||||
})
|
||||
}, "203.0.113.18:4321")
|
||||
var registration service.AgentRegistrationResponse
|
||||
decodeResponseData(t, resp, ®istration)
|
||||
if registration.AgentToken == "" || registration.NodeID == "" {
|
||||
@@ -557,9 +558,16 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
|
||||
if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Status != service.NodeStatusOnline {
|
||||
t.Fatal("expected discovered node to be created online with issued agent token")
|
||||
}
|
||||
if nodes[0].IP != "203.0.113.18" {
|
||||
t.Fatalf("expected discovered node to keep public source ip, got %s", nodes[0].IP)
|
||||
}
|
||||
}
|
||||
|
||||
func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
|
||||
return performAgentJSONRequestWithTokenAndRemote(t, engine, token, method, path, body, "")
|
||||
}
|
||||
|
||||
func performAgentJSONRequestWithTokenAndRemote(t *testing.T, engine http.Handler, token string, method string, path string, body any, remoteAddr string) apiResponse {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
var err error
|
||||
@@ -573,6 +581,9 @@ func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token s
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if remoteAddr != "" {
|
||||
req.RemoteAddr = remoteAddr
|
||||
}
|
||||
req.Header.Set("X-Agent-Token", token)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/utils/geoip"
|
||||
"openflare/utils/geoip/iputil"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -332,6 +333,29 @@ func cloneCoordinate(value *float64) *float64 {
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func ResolveReportedNodeIP(reportedIP string, remoteAddr string) string {
|
||||
reported := iputil.NormalizeIP(reportedIP)
|
||||
remote := iputil.NormalizeRemoteAddr(remoteAddr)
|
||||
if reported == "" {
|
||||
return remote
|
||||
}
|
||||
if !shouldPreferRemoteNodeIP(reported) {
|
||||
return reported
|
||||
}
|
||||
if isPublicNodeIP(remote) {
|
||||
return remote
|
||||
}
|
||||
return reported
|
||||
}
|
||||
|
||||
func shouldPreferRemoteNodeIP(ip string) bool {
|
||||
return !isPublicNodeIP(ip)
|
||||
}
|
||||
|
||||
func isPublicNodeIP(raw string) bool {
|
||||
return iputil.IsPublicString(raw)
|
||||
}
|
||||
|
||||
func buildNodeAgentReleaseView(node *model.Node, release *githubReleaseResponse, channel ReleaseChannel) *NodeAgentReleaseInfo {
|
||||
currentVersion := strings.TrimSpace(node.AgentVersion)
|
||||
view := &NodeAgentReleaseInfo{
|
||||
@@ -428,6 +452,9 @@ func validateAgentNodePayload(payload AgentNodePayload) error {
|
||||
if payload.IP == "" {
|
||||
return errors.New("ip 不能为空")
|
||||
}
|
||||
if net.ParseIP(payload.IP) == nil {
|
||||
return errors.New("ip 格式无效")
|
||||
}
|
||||
if payload.AgentVersion == "" {
|
||||
return errors.New("agent_version 不能为空")
|
||||
}
|
||||
|
||||
@@ -328,6 +328,27 @@ func TestUpdateNodeCanChangeIPAndAutoResolveGeo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportedNodeIPPrefersPublicRemoteAddr(t *testing.T) {
|
||||
resolved := ResolveReportedNodeIP("10.0.0.8", "198.51.100.20:9000")
|
||||
if resolved != "198.51.100.20" {
|
||||
t.Fatalf("expected public remote ip to override private reported ip, got %q", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportedNodeIPKeepsPublicReportedAddr(t *testing.T) {
|
||||
resolved := ResolveReportedNodeIP("8.8.8.8", "198.51.100.20:9000")
|
||||
if resolved != "8.8.8.8" {
|
||||
t.Fatalf("expected reported public ip to be preserved, got %q", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportedNodeIPKeepsPrivateReportedAddrWhenRemoteIsPrivate(t *testing.T) {
|
||||
resolved := ResolveReportedNodeIP("10.0.0.8", "172.16.1.10:9000")
|
||||
if resolved != "10.0.0.8" {
|
||||
t.Fatalf("expected private reported ip to be preserved when remote is also private, got %q", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatNodeResolvesGeoMetadataFromIPWhenNotManuallyOverridden(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
withFakeGeoIPProvider(t, &geoip.GeoInfo{
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package iputil
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeIP(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
ip := net.ParseIP(trimmed)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
return ipv4.String()
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
func NormalizeRemoteAddr(remoteAddr string) string {
|
||||
trimmed := strings.TrimSpace(remoteAddr)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(trimmed); err == nil {
|
||||
return NormalizeIP(host)
|
||||
}
|
||||
return NormalizeIP(trimmed)
|
||||
}
|
||||
|
||||
func IsPublic(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
ip = ipv4
|
||||
}
|
||||
if !ip.IsGlobalUnicast() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func IsPublicString(raw string) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(raw))
|
||||
return IsPublic(ip)
|
||||
}
|
||||
|
||||
func Score(ip net.IP) int {
|
||||
if ip == nil {
|
||||
return -1
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
ip = ipv4
|
||||
}
|
||||
if !ip.IsGlobalUnicast() || ip.IsLoopback() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return -1
|
||||
}
|
||||
if IsPublic(ip) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package iputil
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeIP(t *testing.T) {
|
||||
if got := NormalizeIP(" 8.8.8.8 "); got != "8.8.8.8" {
|
||||
t.Fatalf("unexpected normalized ipv4: %q", got)
|
||||
}
|
||||
if got := NormalizeIP("[::1]"); got != "" {
|
||||
t.Fatalf("expected invalid bracketed host to be rejected, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRemoteAddr(t *testing.T) {
|
||||
if got := NormalizeRemoteAddr("203.0.113.10:8443"); got != "203.0.113.10" {
|
||||
t.Fatalf("unexpected remote addr normalization: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPublic(t *testing.T) {
|
||||
if !IsPublic(net.ParseIP("8.8.8.8")) {
|
||||
t.Fatal("expected public ip to be detected")
|
||||
}
|
||||
if IsPublic(net.ParseIP("10.0.0.8")) {
|
||||
t.Fatal("expected private ip to be rejected")
|
||||
}
|
||||
if IsPublic(net.ParseIP("127.0.0.1")) {
|
||||
t.Fatal("expected loopback ip to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore(t *testing.T) {
|
||||
if got := Score(net.ParseIP("8.8.8.8")); got != 2 {
|
||||
t.Fatalf("unexpected score for public ip: %d", got)
|
||||
}
|
||||
if got := Score(net.ParseIP("10.0.0.8")); got != 1 {
|
||||
t.Fatalf("unexpected score for private ip: %d", got)
|
||||
}
|
||||
if got := Score(net.ParseIP("127.0.0.1")); got != -1 {
|
||||
t.Fatalf("unexpected score for loopback ip: %d", got)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
@import '../styles/tokens.css';
|
||||
@import 'tailwindcss';
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import '../styles/tokens.css';
|
||||
@import 'tailwindcss';
|
||||
@import '@heroui/styles';
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -169,4 +170,4 @@ button {
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type DrawerDirection = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
type DrawerContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
direction: DrawerDirection;
|
||||
};
|
||||
|
||||
const DrawerContext = createContext<DrawerContextValue | null>(null);
|
||||
|
||||
function useDrawerContext() {
|
||||
const context = useContext(DrawerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Drawer components must be used within Drawer.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function renderWithOptionalChild(
|
||||
child: ReactNode,
|
||||
props: Record<string, unknown>,
|
||||
fallback: ReactNode,
|
||||
) {
|
||||
if (isValidElement(child)) {
|
||||
return cloneElement(child as ReactElement, props);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function Drawer({
|
||||
children,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
direction = 'bottom',
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
direction?: DrawerDirection;
|
||||
title?: string;
|
||||
description?: string;
|
||||
footer?: ReactNode;
|
||||
size?: 'md' | 'lg' | 'xl';
|
||||
}) {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const isControlled = open !== undefined;
|
||||
const resolvedOpen = isControlled ? open : internalOpen;
|
||||
|
||||
const value = useMemo<DrawerContextValue>(
|
||||
() => ({
|
||||
open: resolvedOpen,
|
||||
direction,
|
||||
setOpen: (nextOpen) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
}),
|
||||
[direction, isControlled, onOpenChange, resolvedOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerContext.Provider value={value}>
|
||||
{title || description || footer ? (
|
||||
<DrawerContent
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
'w-full md:w-[50vw] md:max-w-none',
|
||||
direction === 'right' || direction === 'left' ? '' : 'max-h-[85vh]',
|
||||
)}
|
||||
>
|
||||
<DrawerHeader className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
{title ? <DrawerTitle>{title}</DrawerTitle> : null}
|
||||
{description ? (
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => value.setOpen(false)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
aria-label="关闭抽屉"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
{footer ? <DrawerFooter>{footer}</DrawerFooter> : null}
|
||||
</DrawerContent>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</DrawerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerTrigger({
|
||||
children,
|
||||
asChild = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const { setOpen } = useDrawerContext();
|
||||
|
||||
if (asChild) {
|
||||
return renderWithOptionalChild(
|
||||
children,
|
||||
{
|
||||
onClick: () => setOpen(true),
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerClose({
|
||||
children,
|
||||
asChild = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const { setOpen } = useDrawerContext();
|
||||
|
||||
if (asChild) {
|
||||
return renderWithOptionalChild(
|
||||
children,
|
||||
{
|
||||
onClick: () => setOpen(false),
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerContent({
|
||||
children,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
const { open, setOpen, direction } = useDrawerContext();
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const positionClassName =
|
||||
direction === 'right'
|
||||
? 'inset-y-0 right-0 h-full border-l'
|
||||
: direction === 'left'
|
||||
? 'inset-y-0 left-0 h-full border-r'
|
||||
: direction === 'top'
|
||||
? 'inset-x-0 top-0 border-b'
|
||||
: 'inset-x-0 bottom-0 border-t';
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-slate-950/45 backdrop-blur-[2px]"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="关闭抽屉"
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : titleId}
|
||||
aria-describedby={descriptionId}
|
||||
className={cn(
|
||||
'absolute flex w-full flex-col overflow-hidden border-[var(--border-default)] bg-[var(--surface-panel)] shadow-2xl',
|
||||
positionClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DrawerMetaContext.Provider value={{ titleId, descriptionId }}>
|
||||
{children}
|
||||
</DrawerMetaContext.Provider>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type DrawerMetaContextValue = {
|
||||
titleId: string;
|
||||
descriptionId: string;
|
||||
};
|
||||
|
||||
const DrawerMetaContext = createContext<DrawerMetaContextValue | null>(null);
|
||||
|
||||
function useDrawerMetaContext() {
|
||||
const context = useContext(DrawerMetaContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Drawer title and description must be used within DrawerContent.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function DrawerHeader({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('border-b border-[var(--border-default)] px-6 py-5', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function DrawerFooter({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('border-t border-[var(--border-default)] px-6 py-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLHeadingElement>) {
|
||||
const { titleId } = useDrawerMetaContext();
|
||||
|
||||
return (
|
||||
<h2
|
||||
id={titleId}
|
||||
className={cn('text-xl font-semibold text-[var(--foreground-primary)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLParagraphElement>) {
|
||||
const { descriptionId } = useDrawerMetaContext();
|
||||
|
||||
return (
|
||||
<p
|
||||
id={descriptionId}
|
||||
className={cn('mt-2 text-sm leading-6 text-[var(--foreground-secondary)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { deleteOrigin, getOrigin } from '@/features/origins/api/origins';
|
||||
import { OriginEditorModal } from '@/features/origins/components/origin-editor-modal';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Select, SelectItem, SelectSection } from '@heroui/select';
|
||||
import { ListBox, Select } from '@heroui/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -14,9 +14,9 @@ import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { Drawer } from '@/components/ui/drawer';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -58,6 +58,8 @@ import {
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
const SelectItem = ListBox.Item;
|
||||
|
||||
const customHeaderSchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
@@ -278,22 +280,20 @@ type SectionIconProps = {
|
||||
const INPUT_CLASS_NAME = 'h-10 rounded-xl px-3 py-2 text-sm';
|
||||
const PANEL_CLASS_NAME =
|
||||
'rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] p-4 shadow-[var(--shadow-soft)]';
|
||||
const HERO_SELECT_CLASS_NAMES = {
|
||||
base: 'w-full',
|
||||
trigger: cn(
|
||||
INPUT_CLASS_NAME,
|
||||
'min-h-10 rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 shadow-none data-[hover=true]:bg-[var(--surface-elevated)] data-[open=true]:border-[var(--border-strong)]',
|
||||
),
|
||||
value: 'text-sm text-[var(--foreground-primary)]',
|
||||
popoverContent:
|
||||
'rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] shadow-[var(--shadow-soft)]',
|
||||
listboxWrapper: 'p-1',
|
||||
} as const;
|
||||
const HERO_SELECT_TRIGGER_CLASS_NAME = cn(
|
||||
INPUT_CLASS_NAME,
|
||||
'flex min-h-10 w-full items-center justify-between rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 shadow-none transition-colors data-[hovered=true]:bg-[var(--surface-elevated)] data-[focus-visible=true]:border-[var(--border-strong)] data-[open=true]:border-[var(--border-strong)]',
|
||||
);
|
||||
const HERO_SELECT_VALUE_CLASS_NAME =
|
||||
'truncate text-left text-sm text-[var(--foreground-primary)]';
|
||||
const HERO_SELECT_POPOVER_CLASS_NAME =
|
||||
'rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] shadow-[var(--shadow-soft)]';
|
||||
const HERO_SELECT_LISTBOX_CLASS_NAME = 'p-1';
|
||||
|
||||
const defaultValues: ProxyRouteFormValues = {
|
||||
managed_domain_id: '',
|
||||
subdomain_label: '',
|
||||
origin_rows: [{ scheme: 'https', address: '', port: '443' }],
|
||||
origin_rows: [{ scheme: 'http', address: '', port: '80' }],
|
||||
origin_uri: '',
|
||||
origin_host: '',
|
||||
enabled: true,
|
||||
@@ -516,19 +516,6 @@ function parseUpstreams(rawValue: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildCachePolicyLabel(policy: string) {
|
||||
switch (policy) {
|
||||
case 'suffix':
|
||||
return '按后缀';
|
||||
case 'path_prefix':
|
||||
return '按前缀';
|
||||
case 'path_exact':
|
||||
return '按路径';
|
||||
default:
|
||||
return '按 URL';
|
||||
}
|
||||
}
|
||||
|
||||
function getCacheRulesHint(policy: string) {
|
||||
switch (policy) {
|
||||
case 'suffix':
|
||||
@@ -1087,44 +1074,47 @@ function HeroTextArea(
|
||||
);
|
||||
}
|
||||
|
||||
function getSelectedKeys(value: string) {
|
||||
return value ? new Set([value]) : new Set<string>();
|
||||
}
|
||||
|
||||
function HeroSelectField({
|
||||
ariaLabel,
|
||||
value,
|
||||
displayValue,
|
||||
disabled,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
value: string;
|
||||
displayValue?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
children: React.ComponentProps<typeof Select>['children'];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
key={`${ariaLabel}-${value || 'empty'}`}
|
||||
aria-label={ariaLabel}
|
||||
variant="bordered"
|
||||
radius="lg"
|
||||
disallowEmptySelection
|
||||
selectedKeys={getSelectedKeys(value)}
|
||||
variant="primary"
|
||||
selectedKey={value || null}
|
||||
isDisabled={disabled}
|
||||
classNames={HERO_SELECT_CLASS_NAMES}
|
||||
onSelectionChange={(keys) => {
|
||||
if (keys === 'all') {
|
||||
className="w-full"
|
||||
onSelectionChange={(key) => {
|
||||
if (typeof key !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const [nextValue] = Array.from(keys);
|
||||
if (typeof nextValue === 'string') {
|
||||
onChange(nextValue);
|
||||
}
|
||||
onChange(key);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Select.Trigger className={HERO_SELECT_TRIGGER_CLASS_NAME}>
|
||||
{displayValue ? (
|
||||
<span className={HERO_SELECT_VALUE_CLASS_NAME}>{displayValue}</span>
|
||||
) : (
|
||||
<Select.Value className={HERO_SELECT_VALUE_CLASS_NAME} />
|
||||
)}
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover className={HERO_SELECT_POPOVER_CLASS_NAME}>
|
||||
<ListBox className={HERO_SELECT_LISTBOX_CLASS_NAME}>{children}</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1212,10 +1202,6 @@ export function ProxyRoutesPage() {
|
||||
control: form.control,
|
||||
name: 'origin_rows',
|
||||
});
|
||||
const watchedOriginURI = useWatch({
|
||||
control: form.control,
|
||||
name: 'origin_uri',
|
||||
});
|
||||
const watchedEnabled = useWatch({ control: form.control, name: 'enabled' });
|
||||
const watchedEnableHttps = useWatch({
|
||||
control: form.control,
|
||||
@@ -1284,6 +1270,16 @@ export function ProxyRoutesPage() {
|
||||
|
||||
const primaryOriginRow =
|
||||
watchedOriginRows?.[0] ?? defaultValues.origin_rows[0];
|
||||
const primaryOriginPreview = buildOriginUrl(
|
||||
primaryOriginRow.scheme,
|
||||
primaryOriginRow.address,
|
||||
primaryOriginRow.port,
|
||||
'',
|
||||
);
|
||||
const certificatePreview =
|
||||
certificates.find((item) => String(item.id) === watchedCertId)?.name ??
|
||||
matchResult?.candidate?.certificate_name ??
|
||||
(watchedEnableHttps ? '待选择' : '未启用');
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: ProxyRouteFormValues) => {
|
||||
const payload = toPayload(values, origins);
|
||||
@@ -1299,7 +1295,7 @@ export function ProxyRoutesPage() {
|
||||
setEditingRouteId(null);
|
||||
setIsEditorOpen(false);
|
||||
setMatchResult(null);
|
||||
setIsAdvancedOpen(false);
|
||||
setIsAdvancedOpen(true);
|
||||
form.reset(defaultValues);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: routesQueryKey }),
|
||||
@@ -1408,7 +1404,7 @@ export function ProxyRoutesPage() {
|
||||
setEditingRouteId(null);
|
||||
setIsEditorOpen(false);
|
||||
setMatchResult(null);
|
||||
setIsAdvancedOpen(false);
|
||||
setIsAdvancedOpen(true);
|
||||
form.reset(defaultValues);
|
||||
};
|
||||
|
||||
@@ -1416,7 +1412,7 @@ export function ProxyRoutesPage() {
|
||||
setFeedback(null);
|
||||
setEditingRouteId(null);
|
||||
setMatchResult(null);
|
||||
setIsAdvancedOpen(false);
|
||||
setIsAdvancedOpen(true);
|
||||
form.reset(defaultValues);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
@@ -1432,7 +1428,7 @@ export function ProxyRoutesPage() {
|
||||
setMatchResult(null);
|
||||
try {
|
||||
form.reset(toFormValues(route, managedDomains));
|
||||
setIsAdvancedOpen(Boolean(route.origin_host || route.remark));
|
||||
setIsAdvancedOpen(true);
|
||||
setIsEditorOpen(true);
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
@@ -1537,35 +1533,20 @@ export function ProxyRoutesPage() {
|
||||
<th className="px-3 py-3 font-medium">域名</th>
|
||||
<th className="px-3 py-3 font-medium">源站地址</th>
|
||||
<th className="px-3 py-3 font-medium">HTTPS</th>
|
||||
<th className="px-3 py-3 font-medium">缓存</th>
|
||||
<th className="px-3 py-3 font-medium">请求头</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">备注</th>
|
||||
<th className="px-3 py-3 font-medium">更新时间</th>
|
||||
<th className="px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{routes.map((route) => {
|
||||
const headers = parseCustomHeaders(route.custom_headers);
|
||||
const cacheRules = parseCacheRules(route.cache_rules);
|
||||
const upstreams = parseUpstreams(route.upstreams);
|
||||
|
||||
return (
|
||||
<tr key={route.id} className="align-top">
|
||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||
{route.domain}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
<div className="space-y-1">
|
||||
<p>{route.origin_url}</p>
|
||||
<p className="text-xs text-[var(--foreground-muted)]">
|
||||
回源主机名: {route.origin_host || '$host'}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-muted)]">
|
||||
上游数量: {Math.max(upstreams.length, 1)}
|
||||
</p>
|
||||
</div>
|
||||
<p>{route.origin_url}</p>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
{route.enable_https ? (
|
||||
@@ -1583,33 +1564,6 @@ export function ProxyRoutesPage() {
|
||||
<StatusBadge label="HTTP" variant="warning" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
{route.cache_enabled ? (
|
||||
<div className="space-y-2">
|
||||
<StatusBadge
|
||||
label={buildCachePolicyLabel(
|
||||
route.cache_policy,
|
||||
)}
|
||||
variant="success"
|
||||
/>
|
||||
<p className="text-xs text-[var(--foreground-muted)]">
|
||||
{cacheRules.length > 0
|
||||
? `${cacheRules.length} 条规则`
|
||||
: '按 URL 粒度缓存'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<StatusBadge label="关闭" variant="warning" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={
|
||||
headers.length > 0 ? `${headers.length} 条` : '无'
|
||||
}
|
||||
variant={headers.length > 0 ? 'success' : 'warning'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={route.enabled ? '启用' : '停用'}
|
||||
@@ -1619,9 +1573,6 @@ export function ProxyRoutesPage() {
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{route.remark || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(route.updated_at)}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
@@ -1651,9 +1602,14 @@ export function ProxyRoutesPage() {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={handleReset}
|
||||
<Drawer
|
||||
open={isEditorOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleReset();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
title={editingRouteId ? '编辑规则' : '新增规则'}
|
||||
description="新增或修改反代规则后,可直接回到列表页继续发布。"
|
||||
size="xl"
|
||||
@@ -1685,6 +1641,35 @@ export function ProxyRoutesPage() {
|
||||
className="space-y-6"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<section className="grid gap-3 rounded-3xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_84%,white_16%)] p-4 md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_auto]">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||
Rule
|
||||
</p>
|
||||
<p className="truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{effectiveDomain || '未选择域名'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
||||
Origin
|
||||
</p>
|
||||
<p className="truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{primaryOriginPreview || '未填写源站'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-2 md:justify-end">
|
||||
<StatusBadge
|
||||
label={watchedEnabled ? '已启用' : '已停用'}
|
||||
variant={watchedEnabled ? 'success' : 'warning'}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={watchedEnableHttps ? `HTTPS · ${certificatePreview}` : 'HTTP'}
|
||||
variant={watchedEnableHttps ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HeroSwitchField
|
||||
id="proxy-route-enabled"
|
||||
label="启用规则"
|
||||
@@ -1765,6 +1750,13 @@ export function ProxyRoutesPage() {
|
||||
<HeroSelectField
|
||||
ariaLabel={`协议 ${index + 1}`}
|
||||
value={currentRow.scheme}
|
||||
displayValue={
|
||||
currentRow.scheme === 'https'
|
||||
? 'HTTPS'
|
||||
: currentRow.scheme === 'http'
|
||||
? 'HTTP'
|
||||
: undefined
|
||||
}
|
||||
onChange={(nextValue) => {
|
||||
const nextScheme =
|
||||
nextValue as OriginRowFormValue['scheme'];
|
||||
@@ -1796,8 +1788,12 @@ export function ProxyRoutesPage() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem key="https">HTTPS</SelectItem>
|
||||
<SelectItem key="http">HTTP</SelectItem>
|
||||
<SelectItem key="https" textValue="HTTPS">
|
||||
HTTPS
|
||||
</SelectItem>
|
||||
<SelectItem key="http" textValue="HTTP">
|
||||
HTTP
|
||||
</SelectItem>
|
||||
</HeroSelectField>
|
||||
</div>
|
||||
|
||||
@@ -1943,28 +1939,35 @@ export function ProxyRoutesPage() {
|
||||
}
|
||||
>
|
||||
{matchResult?.candidate ? (
|
||||
<SelectSection title="自动匹配">
|
||||
<SelectItem key={String(matchResult.candidate.certificate_id)}>
|
||||
<>
|
||||
<SelectItem key="__auto_match_label__" isDisabled textValue="自动匹配">
|
||||
自动匹配
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
key={String(matchResult.candidate.certificate_id)}
|
||||
textValue={matchResult.candidate.certificate_name}
|
||||
>
|
||||
{matchResult.candidate.certificate_name}
|
||||
</SelectItem>
|
||||
</SelectSection>
|
||||
</>
|
||||
) : null}
|
||||
<SelectSection title="全部证书">
|
||||
{certificates
|
||||
.filter(
|
||||
(certificate) =>
|
||||
String(certificate.id) !==
|
||||
String(matchResult?.candidate?.certificate_id ?? ''),
|
||||
)
|
||||
.map((certificate) => (
|
||||
<SelectItem
|
||||
key={String(certificate.id)}
|
||||
textValue={buildCertificateLabel(certificate)}
|
||||
>
|
||||
{buildCertificateLabel(certificate)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectSection>
|
||||
<SelectItem key="__all_certificates_label__" isDisabled textValue="全部证书">
|
||||
全部证书
|
||||
</SelectItem>
|
||||
{certificates
|
||||
.filter(
|
||||
(certificate) =>
|
||||
String(certificate.id) !==
|
||||
String(matchResult?.candidate?.certificate_id ?? ''),
|
||||
)
|
||||
.map((certificate) => (
|
||||
<SelectItem
|
||||
key={String(certificate.id)}
|
||||
textValue={buildCertificateLabel(certificate)}
|
||||
>
|
||||
{buildCertificateLabel(certificate)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</HeroSelectField>
|
||||
</ResourceField>
|
||||
|
||||
@@ -2186,7 +2189,7 @@ export function ProxyRoutesPage() {
|
||||
) : null}
|
||||
</section>
|
||||
</form>
|
||||
</AppModal>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"packageManager": "pnpm@10.6.2",
|
||||
"scripts": {
|
||||
"dev": "NEXT_DEV_BACKEND_URL=${NEXT_DEV_BACKEND_URL:-http://127.0.0.1:3000} node scripts/dev-server.mjs",
|
||||
"dev": "node scripts/dev-server.mjs",
|
||||
"build": "next build",
|
||||
"postbuild": "node scripts/prepare-static-export.mjs",
|
||||
"start": "next start",
|
||||
@@ -18,9 +18,8 @@
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/input": "^2.4.33",
|
||||
"@heroui/react": "^2.8.10",
|
||||
"@heroui/select": "^2.4.33",
|
||||
"@heroui/react": "^3.0.1",
|
||||
"@heroui/styles": "^3.0.1",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@tanstack/react-query": "^5.66.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
Generated
+665
-1692
File diff suppressed because it is too large
Load Diff
@@ -270,6 +270,36 @@ describe('ProxyRoutesPage', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens create drawer with advanced section expanded and http selected by default', async () => {
|
||||
stubMatchMedia();
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
buildBaseFetchStub({
|
||||
managedDomains: [
|
||||
{
|
||||
id: 1,
|
||||
domain: '*.example.com',
|
||||
cert_id: 1,
|
||||
enabled: true,
|
||||
remark: '',
|
||||
created_at: '2026-03-20T08:00:00Z',
|
||||
updated_at: '2026-03-20T08:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderProxyRoutesPage();
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole('button', { name: '新增规则' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '新增规则' });
|
||||
|
||||
expect(within(dialog).getByRole('heading', { name: 'Advanced' })).toBeInTheDocument();
|
||||
expect(within(dialog).getByLabelText('协议 1')).toHaveTextContent('HTTP');
|
||||
});
|
||||
|
||||
it('closes managed domain suggestions when clicking outside', async () => {
|
||||
stubMatchMedia();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user