feat(api): 新增节点流量悬浮详情与即时自动上线同步

为 server/manage/getNodes 返回节点级今日、本月与累计流量统计,
并在节点管理页名称悬浮层展示上行、下行和合计流量。

同时为自动上线补齐单节点同步入口,在管理端保存、
批量更新以及 REST/WS 心跳后立即同步 show 状态,
避免复制节点后开启自动上线仍需等待定时任务。

另优化管理端前端 Docker 发布流程,默认仅构建 amd64,
并收敛 BuildKit 缓存导出以缩短发布时间
This commit is contained in:
yinjianm
2026-04-28 16:51:35 +08:00
parent a62a124710
commit 1739f7a2f9
21 changed files with 966 additions and 65 deletions
@@ -31,15 +31,10 @@ jobs:
fetch-depth: 1
fetch-tags: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
driver-opts: |
image=moby/buildkit:v0.20.0
network=host
@@ -77,12 +72,11 @@ jobs:
context: ./admin-frontend
file: ./admin-frontend/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
cache-from: type=gha,scope=admin-frontend-docker-publish-${{ github.ref_name }}
cache-to: type=gha,mode=max,scope=admin-frontend-docker-publish-${{ github.ref_name }}
cache-to: type=gha,mode=min,scope=admin-frontend-docker-publish-${{ github.ref_name }},ignore-error=true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
BUILDKIT_INLINE_CACHE=1
BUILDKIT_MULTI_PLATFORM=1
provenance: false
+27
View File
@@ -1,5 +1,32 @@
# CHANGELOG
## [0.6.14] - 2026-04-28
### 修复
- **[admin-frontend]**: 修复复制节点后在自动上线开启状态下不会立即显示的问题;自动上线同步现在可针对单节点执行,管理端保存 / 开启自动上线、REST 心跳和 WebSocket 状态上报都会立即按在线与墙状态同步 `show``sync:server-auto-online` 继续作为定时兜底 — by yinjianm
- 方案: [202604281632_admin-frontend-node-auto-online-immediate-sync](archive/2026-04/202604281632_admin-frontend-node-auto-online-immediate-sync/)
## [0.6.13] - 2026-04-28
### 新增
- **[admin-frontend]**: 为节点管理页补齐节点名称 hover 流量详情;`server/manage/getNodes` 现在返回节点级 `traffic_stats.today/month/total`,前端展示今日、本月、累计的上行、下行和合计流量 — by yinjianm
- 方案: [202604281625_admin-frontend-node-traffic-hover](archive/2026-04/202604281625_admin-frontend-node-traffic-hover/)
- 决策: admin-frontend-node-traffic-hover#D001(在 getNodes 聚合节点流量而不是 hover 拉取)
## [0.6.12] - 2026-04-28
### 快速修改
- **[admin-frontend]**: 为节点管理页搜索过滤增加显隐条件,可按全部、显示中、已隐藏筛选节点,并同步重置与分页刷新逻辑 — by yinjianm
- 类型: 快速修改(无方案包)
- 文件: admin-frontend/src/utils/nodes.ts, admin-frontend/src/views/nodes/NodesView.vue
## [0.6.11] - 2026-04-28
### 快速修改
- **[ci-workflows]**: 优化管理端前端 Docker 发布耗时,默认只构建 `linux/amd64`,移除 QEMU/ARM64 跨架构构建,并将 BuildKit GHA 缓存导出收敛为 `mode=min` — by yinjianm
- 类型: 快速修改(无方案包)
- 文件: .github/workflows/admin-frontend-docker-publish.yml:34-82, .helloagents/modules/ci-workflows.md:14-15
## [0.6.10] - 2026-04-28
### 修复
@@ -0,0 +1,10 @@
{
"status": "completed",
"completed": 5,
"failed": 0,
"pending": 0,
"total": 5,
"percent": 100,
"current": "代码实现、知识库同步与前端构建验证已完成;PHP CLI 不可用,后端语法检查未执行",
"updated_at": "2026-04-28 16:34:10"
}
@@ -0,0 +1,175 @@
# 变更提案: admin-frontend-node-traffic-hover
## 元信息
```yaml
类型: 新功能
方案类型: implementation
优先级: P1
状态: 已规划
创建: 2026-04-28
```
---
## 1. 需求
### 背景
节点管理页 `#/nodes` 已能展示节点在线状态、墙检测状态、地址、在线人数、倍率和权限组,但鼠标悬停节点时的详情还没有展示节点自身的流量统计。管理员排查节点负载或流量异常时,需要直接看到今日、本月和累计流量,并区分上行、下行。
### 目标
`admin-frontend` 节点列表的节点详情 hover 区域新增流量统计信息:
- 今日流量:上行、下行、合计
- 本月流量:上行、下行、合计
- 累计流量:上行、下行、合计
### 约束条件
```yaml
时间约束:
性能约束: server/manage/getNodes 不能引入按节点循环查询;流量聚合需批量完成
兼容性约束: 旧接口字段缺失时前端以 0 B 或 -- 兜底,不破坏现有节点列表
业务约束: 后端接口契约以 Controller/Model/StatServer 为真相源,不在前端伪造统计
```
### 验收标准
- [ ] `server/manage/getNodes` 返回每个节点的 `traffic_stats.today/month/total`,每组包含 `upload/download/total`
- [ ] `AdminNodeItem` 类型覆盖新增流量统计字段
- [ ] 节点列表中鼠标移动到节点名称区域时显示流量详情卡,包含日、月、总的上行、下行和合计
- [ ] 流量值按 B/KB/MB/GB/TB 自适应格式化,空值不产生 `NaN`
- [ ] `npm run build``admin-frontend` 下通过
---
## 2. 方案
### 技术方案
在后端 `ManageController::getNodes()` 中基于当前节点 ID 集合批量聚合 `v2_stat_server`,按今日起点和本月起点计算 `SUM(u)``SUM(d)``SUM(u + d)`;累计流量以 `v2_server.u/d` 为节点当前累计真相源。三组结果统一挂载到每个节点的 `traffic_stats` 字段。前端扩展节点类型定义和 `nodes.ts` 工具函数,提供统一的流量格式化与详情数据结构;`NodesView.vue` 将节点名称区域包裹为 Element Plus popover,并以 Apple 风格的克制统计网格展示日、月、总三组上下行数据。
### 影响范围
```yaml
涉及模块:
- backend-server-manage: server/manage/getNodes 新增节点级流量统计字段
- admin-frontend: 节点类型、格式化工具和节点 hover 详情 UI
预计变更文件: 5
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 节点数量较多时聚合查询变慢 | 中 | 使用 `whereIn(server_id, ids)` + `groupBy(server_id)` 批量聚合,避免 N+1 |
| 历史节点没有统计记录 | 低 | 后端返回 0 结构,前端格式化兜底 |
| Element Plus popover 导致表格行布局抖动 | 低 | 只包裹节点名称展示区,详情层使用固定宽度和轻量样式 |
### 方案取舍
```yaml
唯一方案理由: 当前需求需要“日、月、总”三种统计,后端已有 StatServer 日志表和 Server.u/d 累计字段,最合理路径是在 getNodes 返回时一次性挂载聚合结果,前端只负责展示。
放弃的替代路径:
- 前端逐节点请求统计接口: 会产生 N+1 网络请求,表格分页和 hover 体验不稳定
- 只展示 v2_server.u/d: 只能表示节点当前累计字段,不能满足日/月维度
- 新增独立详情接口按 hover 拉取: 可减少列表 payload,但 hover 时延迟明显,且本次统计字段较小
回滚边界: 可独立回退 ManageController 的 traffic_stats 挂载、前端类型/工具函数和 NodesView hover UI,不影响节点 CRUD、排序、墙检测和自动上线逻辑。
```
---
## 3. 技术设计
### 数据流
```mermaid
flowchart TD
A[StatServer v2_stat_server] --> B[ManageController getNodes 批量聚合]
G[Server u/d] --> B
C[ServerService getAllServers] --> B
B --> D[traffic_stats today/month/total]
D --> E[AdminNodeItem 类型]
E --> F[NodesView 节点 hover 详情]
```
### API 设计
#### GET server/manage/getNodes
- **请求**: 沿用现有请求,无新增参数
- **响应新增字段**:
```ts
traffic_stats?: {
today: { upload: number; download: number; total: number }
month: { upload: number; download: number; total: number }
total: { upload: number; download: number; total: number }
}
```
### 数据模型
| 字段 | 类型 | 说明 |
|------|------|------|
| `traffic_stats.today.upload` | number | 今日节点上行字节数 |
| `traffic_stats.today.download` | number | 今日节点下行字节数 |
| `traffic_stats.today.total` | number | 今日节点总流量 |
| `traffic_stats.month.*` | number | 本月节点流量统计 |
| `traffic_stats.total.*` | number | 节点当前累计流量统计,来源为 `v2_server.u/d` |
---
## 4. 核心场景
### 场景: 节点 hover 查看流量明细
**模块**: admin-frontend
**条件**: 管理员进入 `#/nodes`,节点列表加载成功
**行为**: 鼠标移动到节点名称区域
**结果**: 弹出详情卡展示今日、本月、累计的上行、下行和合计流量,数值单位自动格式化。
---
## 5. 技术决策
### admin-frontend-node-traffic-hover#D001: 在 getNodes 聚合节点流量而不是 hover 拉取
**日期**: 2026-04-28
**状态**: ✅采纳
**背景**: 节点列表已通过 `server/manage/getNodes` 获取全部节点,需求要求 hover 即可看到日、月、总流量。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: `getNodes` 批量挂载统计 | hover 无延迟;接口契约集中;可避免前端 N+1 | 列表 payload 略增加;后端查询增加三组聚合 |
| B: hover 时请求单节点详情 | 初始列表轻 | hover 有网络延迟;高频移动会造成请求风暴 |
| C: 前端只展示 `v2_server.u/d` | 改动最小 | 无法提供今日/本月维度 |
**决策**: 选择方案 A
**理由**: 当前统计来自同一张 `v2_stat_server` 表,可按当前节点集合批量聚合;相比 hover 拉取,运营查看详情的反馈更稳定。
**影响**: `server/manage/getNodes` 响应增加 `traffic_stats`;前端节点类型与 hover UI 同步适配。
---
## 6. 验证策略
```yaml
verifyMode: review-first
reviewerFocus:
- app/Http/Controllers/V2/Admin/Server/ManageController.php 聚合查询是否避免 N+1
- admin-frontend/src/utils/nodes.ts 流量格式化是否处理 null/undefined/NaN
- admin-frontend/src/views/nodes/NodesView.vue popover 是否不影响表格操作
testerFocus:
- npm run build
- PHP 语法检查: php -l app/Http/Controllers/V2/Admin/Server/ManageController.php
- 人工核对 #/nodes 节点名称 hover 详情包含今日、本月、累计三组上下行数据
uiValidation: optional
riskBoundary:
- 不执行真实数据库写入
- 不重置节点流量
- 不修改节点 CRUD、批量删除、墙检测或自动上线行为
```
---
## 7. 成果设计
### 设计方向
- **美学基调**: Apple 式极简运营详情层。以白色轻表面、近黑文字和单一蓝色强调呈现统计信息,不引入额外色彩系统。
- **记忆点**: hover 卡片用三组紧凑统计带呈现“今日 / 本月 / 累计”,每组内部清晰区分上行、下行和合计。
- **参考**: `apple/DESIGN.md`
### 视觉要素
- **配色**: 背景 `#ffffff` / `#f5f5f7`,正文 `#1d1d1f`,次要文字 `rgba(0,0,0,0.48)`,强调色 `#0071e3`
- **字体**: 沿用当前项目 Apple 风格系统字体栈,不额外引入远程字体,避免管理端性能成本
- **布局**: 详情卡固定宽度,三行统计分组纵向排列;每行左侧为维度标签,右侧为合计,下方用两列展示上行/下行
- **动效**: 使用 Element Plus popover 的轻量浮层出现/消失,不新增高成本动画
- **氛围**: 低阴影、浅灰分区、无纹理无渐变,保持管理端可读性
### 技术约束
- **可访问性**: popover 内容为可读文本;触发区域保留节点名称文本,不用图标替代语义
- **响应式**: 表格本身横向滚动时 popover 保持固定宽度,不挤压列宽
@@ -0,0 +1,85 @@
# 任务清单: admin-frontend-node-traffic-hover
> **@status:** completed | 2026-04-28 16:48
```yaml
@feature: admin-frontend-node-traffic-hover
@created: 2026-04-28
@status: in_progress
@mode: R2
```
## LIVE_STATUS
```json
{"status":"in_progress","completed":5,"failed":0,"pending":0,"total":5,"percent":100,"current":"代码实现、知识库同步与前端构建验证已完成;PHP CLI 不可用,后端语法检查未执行","updated_at":"2026-04-28 16:34:10"}
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 5 | 0 | 0 | 5 |
---
## 任务列表
### 1. 后端接口
- [√] 1.1 在 `app/Http/Controllers/V2/Admin/Server/ManageController.php` 中为 `getNodes` 批量挂载节点流量统计
- 预期变更: 基于当前节点 ID 集合批量聚合 `StatServer` 的今日、本月、总计上行/下行/合计,并写入 `traffic_stats`
- 完成标准: 每个节点响应都包含 `traffic_stats.today/month/total.upload/download/total`
- 验证方式: `php -l app/Http/Controllers/V2/Admin/Server/ManageController.php`
- depends_on: []
### 2. 前端数据模型与工具
- [√] 2.1 在 `admin-frontend/src/types/api.d.ts` 中扩展节点流量统计类型
- 预期变更: 新增节点流量统计接口,并将其挂到 `AdminNodeItem`
- 完成标准: TypeScript 能识别 `row.traffic_stats.today.upload` 等字段
- 验证方式: `npm run build`
- depends_on: [1.1]
- [√] 2.2 在 `admin-frontend/src/utils/nodes.ts` 中新增节点流量格式化与 hover 详情数据构造
- 预期变更: 提供字节自适应格式化和 `today/month/total` 三组详情行
- 完成标准: null、undefined、非数字输入不会输出 `NaN`
- 验证方式: `npm run build`
- depends_on: [2.1]
### 3. 节点页展示
- [√] 3.1 在 `admin-frontend/src/views/nodes/NodesView.vue` 中把节点名称区域改为 hover 流量详情卡
- 预期变更: 节点名称 hover 时显示今日、本月、累计的上行、下行和合计,样式遵循 `apple/DESIGN.md`
- 完成标准: 不影响节点状态标签、墙状态标签、类型展示和行级操作
- 验证方式: `npm run build`,必要时本地预览人工核对
- depends_on: [2.2]
### 4. 文档与验收
- [√] 4.1 同步知识库并执行构建/语法验证
- 预期变更: 更新 `.helloagents/modules/admin-frontend.md``CHANGELOG.md`,记录本次节点 hover 流量详情能力
- 完成标准: 知识库与代码事实一致;验证命令完成并记录结果
- 验证方式: `npm run build``php -l app/Http/Controllers/V2/Admin/Server/ManageController.php`
- depends_on: [3.1]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-04-28 16:25 | DESIGN | completed | 已完成上下文收集、唯一方案设计与任务拆分 |
| 2026-04-28 16:30 | 1.1 | completed | getNodes 已挂载 traffic_stats;累计流量使用 v2_server.u/d |
| 2026-04-28 16:31 | 2.1/2.2 | completed | 已扩展类型与节点流量格式化工具 |
| 2026-04-28 16:32 | 3.1 | completed | 节点名称 hover 详情卡已接入 |
| 2026-04-28 16:34 | 4.1 | completed | npm run build 通过;当前环境缺少 php/composerPHP 语法检查未执行 |
---
## 执行备注
- 现有 `server/manage/getNodes` 已返回节点运行态、墙检测和权限组信息,本次只新增只读统计字段,不改变节点管理写操作。
- 当前工作树存在与本次无关的 `.github/workflows/admin-frontend-docker-publish.yml``.helloagents/CHANGELOG.md``.helloagents/modules/ci-workflows.md``public/assets/admin` 改动,执行时必须保留这些用户已有变更。
- `npm run build` 会刷新 `public/assets/admin` 构建产物,并更新 `admin-frontend/src/types/components.d.ts` 中的 `ElPopover` 自动组件声明。
- 当前执行环境没有 `php``composer` 命令,后端 PHP 语法检查无法在本机执行。
@@ -0,0 +1 @@
{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"percent":100,"current":"开发实施完成,方案包已归档","updated_at":"2026-04-28 16:43:00"}
@@ -0,0 +1,149 @@
# 变更提案: admin-frontend-node-auto-online-immediate-sync
## 元信息
```yaml
类型: 修复
方案类型: implementation
优先级: P1
状态: 已规划
创建: 2026-04-28
```
---
## 1. 需求
### 背景
节点管理页复制节点后,新节点会保留原节点的 `auto_online` 状态但被复制接口强制设为隐藏。管理员修改新节点信息并安装节点后,即使自动上线处于开启状态,节点也不会立即显示,需要手动关闭自动上线、打开显隐、再重新开启自动上线。
代码事实:
- `ManageController::copy()` 复制节点后设置 `show = 0`
- `NodeEditorDialog` 保存会提交 `show``auto_online`,但后端 `save()` 只落库,不主动执行自动上线同步。
- `ServerAutoOnlineService::sync()` 目前只由 `sync:server-auto-online` 定时命令每 5 分钟调用。
- REST 节点心跳 `ServerService::touchNode()` 只更新在线缓存,不会触发自动上线同步;WebSocket 状态上报还绕过了 `touchNode()` 直接写缓存。
### 目标
- 自动上线开启的节点在管理端保存、开启自动上线或节点心跳上报后,立即按当前在线状态和墙检测状态同步 `show`
- 保持现有定时任务兜底能力,避免依赖管理员手动切换显隐。
- 保持墙检测自动隐藏规则不被绕过。
### 约束条件
```yaml
时间约束:
性能约束: 单节点心跳只能同步当前节点,不能触发全量扫描
兼容性约束: 保持 sync:server-auto-online 命令输出结构不变
业务约束: 自动上线仍以 available_status 和墙状态为准;未开启 auto_online 的节点不改变手动显隐
```
### 验收标准
- [ ] `ServerAutoOnlineService` 支持单节点同步,并复用定时全量同步的同一套判定逻辑。
- [ ] 管理端保存节点、开启自动上线、批量开启自动上线后,若节点当前为在线或待同步且未被墙检测否决,应立即 `show=1`
- [ ] 节点心跳上报后,若该节点开启自动上线,应立即同步 `show`,不必等待 Scheduler。
- [ ] 被墙状态或 `gfw_auto_hidden` 未恢复正常时,自动上线不会把节点重新显示。
- [ ] 单元测试覆盖单节点同步和心跳触发场景。
---
## 2. 方案
### 技术方案
`ServerAutoOnlineService` 中的自动上线判定抽出为可复用的 `syncServer(Server $server)` 单节点入口。全量定时命令继续调用 `sync()`,内部复用同一判定函数。
在两个关键触发点调用单节点同步:
- 管理端 `ManageController``save()``update()``batchUpdate()` 在节点开启 `auto_online` 后立即调用。
- `ServerService::touchNode()` 在节点心跳刷新后,如果节点开启 `auto_online`,立即调用;WebSocket 状态上报改为复用 `touchNode()`
### 影响范围
```yaml
涉及模块:
- server-auto-online: 抽取单节点同步入口,复用全量同步逻辑
- admin-server-manage: 保存/开关/批量更新自动上线时立即同步
- server-heartbeat: REST 与 WebSocket 节点心跳后触发当前节点同步
- tests: 补充自动上线单节点与心跳行为验证
预计变更文件: 5
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 节点心跳频繁导致额外数据库写入 | 中 | 只在 `auto_online=true` 时执行;状态无变化时不保存 |
| 保存接口中 `show``auto_online` 同时提交时语义冲突 | 低 | 自动上线开启时以自动同步判定为准,符合 UI 中显隐开关被托管的文案 |
| 墙检测自动隐藏被误清除 | 中 | 单节点同步复用原有 `isGfwBlocked/isGfwHeld` 判定,只在正常状态时清除 hold |
### 方案取舍
```yaml
唯一方案理由: 问题根因在后端自动上线语义只由定时任务触发;把同步能力收敛到服务层并在业务事件中调用,可以同时覆盖管理端保存、开关和节点真实上线心跳。
放弃的替代路径:
- 仅前端保存后强制提交 show=1: 会绕过离线/被墙判断,且无法覆盖节点稍后才心跳上线的场景。
- 缩短 Scheduler 间隔: 仍不是立即上线,并增加全量扫描频率。
- 复制节点时默认关闭 auto_online: 会改变复制语义,仍要求管理员额外操作。
回滚边界: 可独立回退 ServerAutoOnlineService 单节点入口、ManageController 调用点和 ServerService::touchNode 调用点;数据库结构不变。
```
---
## 3. 技术设计
### 架构设计
```mermaid
flowchart TD
A[管理端保存/开启自动上线] --> B[ManageController]
C[REST/WS 节点心跳上报] --> D[ServerService::touchNode]
B --> E[ServerAutoOnlineService::syncServer]
D --> E
F[sync:server-auto-online] --> G[ServerAutoOnlineService::sync]
G --> E
E --> H[按 available_status + GFW 状态同步 show]
```
### API 设计
不新增外部 API。现有接口保持不变:
- `POST /server/manage/save`
- `POST /server/manage/update`
- `POST /server/manage/batchUpdate`
- 节点心跳/报告接口
### 数据模型
不新增字段。
---
## 4. 核心场景
### 场景: 复制节点后自动上线立即生效
**模块**: admin-frontend / server-auto-online
**条件**: 新节点由复制产生,`show=0``auto_online=1`,节点已经心跳在线且未被墙检测否决。
**行为**: 管理员在节点管理页编辑并保存该节点。
**结果**: 后端保存后立即执行单节点自动上线同步,节点 `show=1`,前端刷新列表后显示为上线。
### 场景: 节点稍后才上线
**模块**: server-heartbeat / server-auto-online
**条件**: 节点保存时尚未心跳,`auto_online=1``show=0`
**行为**: 节点安装完成并上报心跳。
**结果**: REST 与 WebSocket 心跳都会通过 `touchNode()` 更新在线缓存并立即同步当前节点,节点不需要等待下一轮 Scheduler。
---
## 5. 验证策略
```yaml
verifyMode: test-first
reviewerFocus:
- app/Services/ServerAutoOnlineService.php 单节点与全量同步是否共用判定逻辑
- app/Services/ServerService.php 与 app/WebSocket/NodeEventHandlers.php 心跳触发是否只影响 auto_online 节点
- app/Http/Controllers/V2/Admin/Server/ManageController.php 管理端更新后的同步时机
testerFocus:
- vendor/bin/phpunit tests/Unit/ServerAutoOnlineServiceTest.php
- npm --prefix admin-frontend run build
uiValidation: none
riskBoundary:
- 不修改数据库结构
- 不修改节点列表视觉结构
- 不改变未开启 auto_online 节点的手动显隐行为
```
---
## 6. 成果设计
N/A。此任务不包含视觉产出。
@@ -0,0 +1,82 @@
# 任务清单: admin-frontend-node-auto-online-immediate-sync
> **@status:** completed | 2026-04-28 16:37
```yaml
@feature: admin-frontend-node-auto-online-immediate-sync
@created: 2026-04-28
@status: completed
@mode: R2
```
## LIVE_STATUS
```json
{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"percent":100,"current":"开发实施完成,方案包已归档","updated_at":"2026-04-28 16:43:00"}
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 5 | 0 | 0 | 5 |
---
## 任务列表
### 1. 自动上线服务
- [√] 1.1 修改 `app/Services/ServerAutoOnlineService.php`
- 预期变更: 抽出单节点同步入口 `syncServer(Server $server)`,让全量 `sync()` 复用同一判定逻辑。
- 完成标准: 单节点和全量同步返回结构一致,仍包含 `total/updated/shown/hidden/unchanged`
- 验证方式: `vendor/bin/phpunit tests/Unit/ServerAutoOnlineServiceTest.php`
- depends_on: []
### 2. 触发点接入
- [√] 2.1 修改 `app/Services/ServerService.php`
- 预期变更: `touchNode()` 更新节点心跳缓存后,对 `auto_online=true` 的节点立即调用单节点同步。
- 完成标准: 自动上线节点心跳后无需等待 Scheduler 即可同步 `show`,未开启自动上线的节点不受影响。
- 验证方式: `vendor/bin/phpunit tests/Unit/ServerAutoOnlineServiceTest.php`
- depends_on: [1.1]
- [√] 2.2 修改 `app/WebSocket/NodeEventHandlers.php`
- 预期变更: WebSocket 节点状态上报改为复用 `ServerService::touchNode()`,避免绕过自动上线即时同步。
- 完成标准: REST 与 WebSocket 心跳入口都收敛到同一个自动上线触发点。
- 验证方式: 代码检查 + `vendor/bin/phpunit tests/Unit/ServerAutoOnlineServiceTest.php`
- depends_on: [2.1]
- [√] 2.3 修改 `app/Http/Controllers/V2/Admin/Server/ManageController.php`
- 预期变更: `save()``update()``batchUpdate()` 在保存或开启 `auto_online` 后立即执行单节点同步。
- 完成标准: 管理端保存复制节点、行级开启自动上线、批量开启自动上线后会立即按当前状态同步 `show`
- 验证方式: 代码检查 + `vendor/bin/phpunit tests/Unit/ServerAutoOnlineServiceTest.php`
- depends_on: [1.1]
### 3. 验证覆盖
- [√] 3.1 修改 `tests/Unit/ServerAutoOnlineServiceTest.php`
- 预期变更: 增加单节点同步和 `touchNode()` 触发自动上线的测试。
- 完成标准: 新测试能复现复制节点 `show=0 + auto_online=1` 在线后立即 `show=1` 的核心行为。
- 验证方式: `vendor/bin/phpunit tests/Unit/ServerAutoOnlineServiceTest.php`
- depends_on: [1.1, 2.1]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-04-28 16:32 | DESIGN | completed | 已确认唯一方案并创建任务清单 |
| 2026-04-28 16:37 | 1.1 | completed | 已抽出单节点自动上线同步入口并复用全量同步逻辑 |
| 2026-04-28 16:38 | 2.1/2.2 | completed | 已接入节点心跳、管理端保存、行级更新和批量更新触发点 |
| 2026-04-28 16:39 | 3.1 | completed | 已补充单节点同步和心跳即时同步测试 |
| 2026-04-28 16:40 | 验证 | warning | `npm --prefix admin-frontend run build` 通过;本机缺少 PHP/vendorPHPUnit 未运行 |
| 2026-04-28 16:43 | 2.2 | completed | 已补齐 WebSocket 状态上报入口,统一复用 `touchNode()` |
---
## 执行备注
- 本次不修改前端视觉和接口签名,前端现有 `@success="() => loadNodeBoard()"` 会在保存成功后刷新列表。
- 后端自动上线仍以 `available_status` 和墙状态为准,不做无条件显示。
+2
View File
@@ -7,6 +7,8 @@
| 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 |
|--------|------|------|---------|------|------|
| 202604281625 | admin-frontend-node-traffic-hover | - | - | - | ✅完成 |
| 202604281632 | admin-frontend-node-auto-online-immediate-sync | - | - | - | ✅完成 |
| 202604281441 | fix-admin-node-gfw-null-enabled | implementation | node-gfw-check,admin-frontend | fix-admin-node-gfw-null-enabled#D001 | ✅完成 |
| 202604281432 | ci-ignore-helloagents-for-backend-docker | implementation | ci-workflows | ci-ignore-helloagents-for-backend-docker#D001 | ✅完成 |
| 202604281303 | xboard-reusable-server-deploy | implementation | deploy,node-gfw-check | xboard-reusable-server-deploy#D001,#D002 | ✅完成 |
+2 -2
View File
@@ -114,8 +114,8 @@
- 管理端路由使用 Hash 模式
- 管理端当前业务路由包含 `/dashboard``/users``/tickets``/nodes``/node-groups``/node-routes``/subscriptions/plans``/subscriptions/orders``/subscriptions/coupons``/subscriptions/gift-cards``/system/config``/system/notices``/system/payments``/system/plugins``/system/themes``/system/knowledge`
- `#/nodes` 当前已升级为真实节点工作台:支持搜索、在线 / 离线筛选、父/子节点筛选、墙状态筛选、分页浏览、显隐切换、自动上线托管开关、墙检测托管开关、刷新数据、复制、单节点置顶、仅对已勾选节点生效的批量修改 / 批量删除,以及 11 种协议的新增 / 编辑弹窗和排序对话框
- 节点自动上线由后端 `sync:server-auto-online` 定时命令执行,只处理 `auto_online=1` 的节点:在线 / 待同步时自动 `show=1`,离线时自动 `show=0`;未开启自动上线的节点继续保持手动显隐控制;墙状态为 `blocked` 或仍处于 `gfw_auto_hidden` 且未恢复正常时会否决自动显示
- `#/nodes` 当前已升级为真实节点工作台:支持搜索、在线 / 离线筛选、显隐筛选、父/子节点筛选、墙状态筛选、分页浏览、显隐切换、自动上线托管开关、墙检测托管开关、刷新数据、复制、单节点置顶、仅对已勾选节点生效的批量修改 / 批量删除,以及 11 种协议的新增 / 编辑弹窗和排序对话框
- 节点自动上线由后端 `ServerAutoOnlineService` 统一执行,只处理 `auto_online=1` 的节点:在线 / 待同步时自动 `show=1`,离线时自动 `show=0`管理端保存 / 开启自动上线、REST 心跳和 WebSocket 状态上报会触发当前节点即时同步,`sync:server-auto-online` 每 5 分钟继续兜底;未开启自动上线的节点继续保持手动显隐控制;墙状态为 `blocked` 或仍处于 `gfw_auto_hidden` 且未恢复正常时会否决自动显示
- 节点自动墙检测由后端 `sync:server-gfw-checks` 定时命令执行,只为开启 `gfw_check_enabled` 的父节点创建检测任务;父节点兼容 `parent_id IS NULL` 与历史 `parent_id=0` 两种表示,`gfw_check_enabled` 仅明确为 `false` 时关闭;子节点不独立检测,但可控制是否随父节点自动隐藏 / 恢复
- Compose 部署必须确保 Laravel Scheduler 持续运行;`deploy/xboard-server/compose.yaml` 通过独立 `scheduler` 服务执行 `php artisan schedule:work`,否则自动墙检测只会在手动触发时创建任务
- Bearer Token 存储于 `sessionStorage/localStorage`
+3 -2
View File
@@ -41,11 +41,12 @@
- 节点新增 / 编辑 / 批量修改保存 `group_ids / route_ids` 时统一向后端提交字符串 ID,后端 `Server::whereGroupId()` 同时兼容历史字符串与数字 JSON 值,避免权限组保存后订阅侧无法命中节点
- TUIC 表单默认以 V5 / V4 版本选择、`h3 / h2 / http/1.1` ALPN 选项和 `native / quic` UDP Relay Mode 对齐后端协议模板;AnyTLS Padding Scheme 默认值与 `Server` 模型完整模板保持一致
- 节点排序采用本地草稿 + 上移 / 下移模式,保存时向 `server/manage/sort` 提交 `{ id, order }[]` 顺序 payload
- 节点列表现支持本地分页、在线 / 离线筛选、父/子节点筛选,以及跨分页稳定勾选;批量修改 / 批量删除仅作用于已勾选节点,其中批量修改可统一更新 `host / group_ids / rate / auto_online`
- 节点管理页新增“自动上线”托管开关;开启后后台 `sync:server-auto-online` 会按节点在线状态自动同步 `show`,在线 / 待同步时显示、离线时隐藏,未开启的节点仍保持手动显隐控制;疑似被墙或仍处于墙检测自动隐藏状态的节点不会被自动上线重新发布
- 节点列表现支持本地分页、在线 / 离线筛选、显隐筛选、父/子节点筛选,以及跨分页稳定勾选;批量修改 / 批量删除仅作用于已勾选节点,其中批量修改可统一更新 `host / group_ids / rate / auto_online`
- 节点管理页新增“自动上线”托管开关;开启后后台会按节点在线状态自动同步 `show`,在线 / 待同步时显示、离线时隐藏,未开启的节点仍保持手动显隐控制;管理端保存 / 开启自动上线、REST 心跳和 WebSocket 状态上报会触发当前节点即时同步,`sync:server-auto-online` 继续作为定时兜底;疑似被墙或仍处于墙检测自动隐藏状态的节点不会被自动上线重新发布
- 节点管理页现支持墙状态展示、墙状态筛选与关键词搜索;父节点可通过行级或批量操作发起检测,子节点不单独检测并显示“随父节点”的继承状态
- 节点管理页现支持“墙检测托管”开关、批量设置和刷新数据按钮;父节点开启后参与 `sync:server-gfw-checks` 自动检测,自动墙检统计只计算父节点;子节点不独立检测但可控制是否随父节点自动隐藏 / 恢复
- 节点行级菜单现已补齐“置顶节点”,会复用当前排序结果生成新的顺序 payload 并提交到 `server/manage/sort`
- 节点列表中鼠标悬停节点名称会显示节点流量详情卡;`server/manage/getNodes` 会返回 `traffic_stats.today/month/total`,其中今日和本月来自 `v2_stat_server` 按节点聚合,累计来自 `v2_server.u/d`,前端统一按 B/KB/MB/GB/TB 自适应格式化展示上行、下行和合计
- 权限组管理页使用真实后端 `server/group/fetch``server/group/save``server/group/drop`,支持关键字搜索、新增/编辑中央弹窗、删除确认,以及从节点数量列跳转到 `#/nodes?group={id}` 的筛选联动
- 路由管理页使用真实后端 `server/route/fetch``server/route/save``server/route/drop`,支持路由列表、关键词搜索、新增/编辑中央弹窗、删除与动作值展示
- 路由管理页的节点引用摘要由 `server/manage/getNodes` 返回的 `route_ids` 推导,不在前端伪造额外接口
+2
View File
@@ -13,6 +13,8 @@
- 后端 workflow 使用 `paths-ignore` 排除 `admin-frontend/**``.helloagents/**``.github/workflows/admin-frontend-docker-publish.yml`
- GitHub Actions 的 `paths-ignore` 语义是:push 中所有变更路径都被 ignore 覆盖时跳过 workflow;只要混有未被 ignore 的后端相关路径,后端 workflow 仍会运行
- 管理端前端镜像发布工作流位于 `.github/workflows/admin-frontend-docker-publish.yml`,只关注 `admin-frontend/**` 和自身 workflow 变更
- 管理端前端镜像发布默认只构建 `linux/amd64`,不启用 QEMU/ARM64 跨架构构建,以压缩 push 后的前端镜像发布时间
- 管理端前端镜像发布使用 GitHub Actions Cache 作为 BuildKit 缓存来源,缓存导出采用 `mode=min`,避免每次发布完整导出多阶段构建缓存拖慢总耗时
## 依赖关系
+10
View File
@@ -862,6 +862,12 @@ export interface AdminNodeMetrics {
updated_at?: number
}
export interface AdminNodeTrafficStats {
today: TrafficAmount
month: TrafficAmount
total: TrafficAmount
}
export interface AdminNodeRateTimeRange {
start: string
end: string
@@ -887,6 +893,9 @@ export interface AdminNodeItem {
enabled?: boolean
parent_id?: number | null
rate?: number | null
transfer_enable?: number | null
u?: number | null
d?: number | null
rate_time_enable?: boolean
rate_time_ranges?: AdminNodeRateTimeRange[] | null
sort?: number | null
@@ -898,6 +907,7 @@ export interface AdminNodeItem {
last_check_at?: number | null
last_push_at?: number | null
metrics?: AdminNodeMetrics | null
traffic_stats?: AdminNodeTrafficStats | null
groups?: AdminServerGroupItem[]
parent?: AdminNodeParentRef | null
gfw_check?: AdminNodeGfwCheck | null
+1
View File
@@ -35,6 +35,7 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
+82 -1
View File
@@ -1,8 +1,9 @@
import type { AdminNodeItem } from '@/types/api'
import type { AdminNodeItem, TrafficAmount } from '@/types/api'
export type NodeRelationFilter = 'all' | 'parent' | 'child'
export type NodeGfwFilter = 'all' | 'normal' | 'blocked' | 'partial' | 'failed' | 'unchecked' | 'checking' | 'inherited'
export type NodeStatusFilter = 'all' | 'online' | 'offline'
export type NodeVisibilityFilter = 'all' | 'visible' | 'hidden'
export interface NodeStatusMeta {
label: string
@@ -20,6 +21,20 @@ export interface NodeGfwMeta {
inherited: boolean
}
export interface NodeTrafficDetail {
key: 'today' | 'month' | 'total'
label: string
upload: string
download: string
total: string
}
type TrafficAmountLike = {
upload?: number | string | null
download?: number | string | null
total?: number | string | null
}
const NODE_TYPE_LABELS: Record<string, string> = {
shadowsocks: 'Shadowsocks',
trojan: 'Trojan',
@@ -34,10 +49,41 @@ const NODE_TYPE_LABELS: Record<string, string> = {
mieru: 'Mieru',
}
const TRAFFIC_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
function normalizeText(value: unknown): string {
return String(value ?? '').trim().toLowerCase()
}
function normalizeTrafficValue(value: unknown): number {
const normalized = Number(value)
return Number.isFinite(normalized) && normalized > 0 ? normalized : 0
}
function normalizeTrafficAmount(amount?: TrafficAmountLike | null): TrafficAmount {
const upload = normalizeTrafficValue(amount?.upload)
const download = normalizeTrafficValue(amount?.download)
const total = normalizeTrafficValue(amount?.total) || upload + download
return { upload, download, total }
}
export function formatTrafficBytes(value?: number | string | null): string {
let amount = normalizeTrafficValue(value)
let unitIndex = 0
while (amount >= 1024 && unitIndex < TRAFFIC_UNITS.length - 1) {
amount /= 1024
unitIndex += 1
}
if (unitIndex === 0) {
return `${Math.round(amount)} B`
}
return `${amount >= 100 ? amount.toFixed(0) : amount.toFixed(2)} ${TRAFFIC_UNITS[unitIndex]}`
}
export function getNodeTypeLabel(type: string): string {
const normalized = normalizeText(type)
return NODE_TYPE_LABELS[normalized] ?? String(type || '未知协议').toUpperCase()
@@ -190,6 +236,31 @@ export function formatNodeRate(rate?: number | null): string {
return `${normalized.toFixed(2)} x`
}
export function getNodeTrafficDetails(node: AdminNodeItem): NodeTrafficDetail[] {
const stats = node.traffic_stats
const totalFallback = normalizeTrafficAmount({
upload: node.u ?? 0,
download: node.d ?? 0,
})
const rows: Array<{ key: NodeTrafficDetail['key']; label: string; source?: TrafficAmountLike | null }> = [
{ key: 'today', label: '今日', source: stats?.today },
{ key: 'month', label: '本月', source: stats?.month },
{ key: 'total', label: '累计', source: stats?.total ?? totalFallback },
]
return rows.map((row) => {
const amount = normalizeTrafficAmount(row.source)
return {
key: row.key,
label: row.label,
upload: formatTrafficBytes(amount.upload),
download: formatTrafficBytes(amount.download),
total: formatTrafficBytes(amount.total),
}
})
}
export function getNodeGroupNames(node: AdminNodeItem): string[] {
return (node.groups ?? [])
.map((group) => group.name)
@@ -231,6 +302,7 @@ export function filterNodes(
typeFilter: string,
groupFilter: string,
statusFilter: NodeStatusFilter = 'all',
visibilityFilter: NodeVisibilityFilter = 'all',
relationFilter: NodeRelationFilter = 'all',
gfwFilter: NodeGfwFilter = 'all',
): AdminNodeItem[] {
@@ -238,6 +310,7 @@ export function filterNodes(
const normalizedType = normalizeText(typeFilter)
const normalizedGroup = normalizeText(groupFilter)
const normalizedStatus = normalizeText(statusFilter)
const normalizedVisibility = normalizeText(visibilityFilter)
const normalizedRelation = normalizeText(relationFilter)
const normalizedGfw = normalizeText(gfwFilter)
@@ -266,6 +339,14 @@ export function filterNodes(
return false
}
if (normalizedVisibility === 'visible' && !Boolean(node.show)) {
return false
}
if (normalizedVisibility === 'hidden' && Boolean(node.show)) {
return false
}
if (normalizedRelation === 'parent' && node.parent_id) {
return false
}
+138 -5
View File
@@ -47,10 +47,12 @@ import {
getNodeGroupNames,
getNodeIdLabel,
getNodeStatusMeta,
getNodeTrafficDetails,
getNodeTypeLabel,
type NodeRelationFilter,
type NodeGfwFilter,
type NodeStatusFilter,
type NodeVisibilityFilter,
} from '@/utils/nodes'
import { sortNodesByOrder } from '@/utils/nodeEditor'
@@ -70,6 +72,7 @@ const keyword = ref('')
const typeFilter = ref('all')
const groupFilter = ref('all')
const statusFilter = ref<NodeStatusFilter>('all')
const visibilityFilter = ref<NodeVisibilityFilter>('all')
const relationFilter = ref<NodeRelationFilter>('all')
const gfwFilter = ref<NodeGfwFilter>('all')
const currentPage = ref(1)
@@ -99,6 +102,7 @@ const filteredNodes = computed(() => sortNodesByOrder(filterNodes(
typeFilter.value,
groupFilter.value,
statusFilter.value,
visibilityFilter.value,
relationFilter.value,
gfwFilter.value,
)))
@@ -116,6 +120,7 @@ const hasActiveFilters = computed(() => (
|| typeFilter.value !== 'all'
|| groupFilter.value !== 'all'
|| statusFilter.value !== 'all'
|| visibilityFilter.value !== 'all'
|| relationFilter.value !== 'all'
|| gfwFilter.value !== 'all'
))
@@ -304,6 +309,7 @@ function handleReset() {
typeFilter.value = 'all'
groupFilter.value = 'all'
statusFilter.value = 'all'
visibilityFilter.value = 'all'
relationFilter.value = 'all'
gfwFilter.value = 'all'
currentPage.value = 1
@@ -647,7 +653,7 @@ watch(
},
)
watch([keyword, typeFilter, groupFilter, statusFilter, relationFilter, gfwFilter], () => {
watch([keyword, typeFilter, groupFilter, statusFilter, visibilityFilter, relationFilter, gfwFilter], () => {
currentPage.value = 1
})
@@ -735,6 +741,12 @@ watch(
<ElOption label="离线节点" value="offline" />
</ElSelect>
<ElSelect v-model="visibilityFilter" class="toolbar-select" placeholder="显隐">
<ElOption label="全部显隐" value="all" />
<ElOption label="显示中" value="visible" />
<ElOption label="已隐藏" value="hidden" />
</ElSelect>
<ElSelect v-model="relationFilter" class="toolbar-select" placeholder="节点关系">
<ElOption label="全部节点" value="all" />
<ElOption label="父节点" value="parent" />
@@ -878,10 +890,39 @@ watch(
<ElTableColumn label="节点" min-width="280">
<template #default="{ row }">
<div class="node-cell">
<div class="node-cell__main">
<span class="node-dot" :class="getNodeStatusMeta(row).dotClass" />
<strong>{{ row.name }}</strong>
</div>
<ElPopover
placement="right-start"
trigger="hover"
popper-class="node-traffic-popover"
:width="360"
>
<template #reference>
<button class="node-cell__main node-name-trigger" type="button">
<span class="node-dot" :class="getNodeStatusMeta(row).dotClass" />
<strong>{{ row.name }}</strong>
</button>
</template>
<div class="node-traffic-card">
<header class="node-traffic-card__header">
<span>流量统计</span>
<strong>{{ row.name }}</strong>
</header>
<article
v-for="traffic in getNodeTrafficDetails(row)"
:key="`${row.id}-${traffic.key}`"
class="node-traffic-row"
>
<div class="node-traffic-row__summary">
<span>{{ traffic.label }}</span>
<strong>{{ traffic.total }}</strong>
</div>
<div class="node-traffic-row__split">
<span>上行 {{ traffic.upload }}</span>
<span>下行 {{ traffic.download }}</span>
</div>
</article>
</div>
</ElPopover>
<div class="node-cell__sub">
<ElTag round effect="plain" :type="getNodeStatusMeta(row).tagType">
{{ getNodeStatusMeta(row).label }}
@@ -1227,6 +1268,32 @@ watch(
gap: 8px;
}
.node-name-trigger {
width: fit-content;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
font: inherit;
text-align: left;
cursor: default;
}
.node-name-trigger strong {
transition: color 0.18s ease;
}
.node-name-trigger:hover strong,
.node-name-trigger:focus-visible strong {
color: #0071e3;
}
.node-name-trigger:focus-visible {
outline: 2px solid #0071e3;
outline-offset: 3px;
border-radius: 8px;
}
.node-cell__sub {
flex-wrap: wrap;
}
@@ -1305,6 +1372,72 @@ watch(
color: var(--xboard-text-muted);
}
:global(.node-traffic-popover) {
padding: 0 !important;
border: 0 !important;
border-radius: 18px !important;
box-shadow: rgba(0, 0, 0, 0.18) 0 12px 36px 0 !important;
}
:global(.node-traffic-popover .node-traffic-card) {
display: grid;
gap: 10px;
padding: 14px;
color: #1d1d1f;
}
:global(.node-traffic-card__header) {
display: grid;
gap: 3px;
padding: 2px 2px 6px;
}
:global(.node-traffic-card__header span) {
color: rgba(0, 0, 0, 0.48);
font-size: 12px;
line-height: 1.33;
}
:global(.node-traffic-card__header strong) {
color: #1d1d1f;
font-size: 15px;
line-height: 1.25;
}
:global(.node-traffic-row) {
display: grid;
gap: 8px;
padding: 12px;
border-radius: 12px;
background: #f5f5f7;
}
:global(.node-traffic-row__summary),
:global(.node-traffic-row__split) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
:global(.node-traffic-row__summary span),
:global(.node-traffic-row__split span) {
color: rgba(0, 0, 0, 0.56);
font-size: 12px;
line-height: 1.33;
}
:global(.node-traffic-row__summary strong) {
color: #0071e3;
font-size: 17px;
line-height: 1.19;
font-variant-numeric: tabular-nums;
}
:global(.node-traffic-row__split span) {
font-variant-numeric: tabular-nums;
}
@media (max-width: 1180px) {
.nodes-hero,
.board-toolbar,
@@ -7,6 +7,8 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\ServerSave;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Models\StatServer;
use App\Services\ServerAutoOnlineService;
use App\Services\ServerGfwCheckService;
use App\Services\ServerService;
use Illuminate\Http\Request;
@@ -17,14 +19,80 @@ class ManageController extends Controller
{
public function getNodes(Request $request)
{
$servers = app(ServerGfwCheckService::class)->decorateServers(ServerService::getAllServers())->map(function ($item) {
$servers = ServerService::getAllServers();
$trafficStats = $this->buildNodeTrafficStats($servers);
$servers = app(ServerGfwCheckService::class)->decorateServers($servers)->map(function ($item) use ($trafficStats) {
$item['groups'] = ServerGroup::whereIn('id', $item['group_ids'] ?? [])->get(['name', 'id']);
$item['parent'] = $item->parent;
$item['traffic_stats'] = $trafficStats[(int) $item['id']] ?? $this->emptyNodeTrafficStats();
return $item;
});
return $this->success($servers);
}
private function buildNodeTrafficStats($servers): array
{
$stats = [];
foreach ($servers as $server) {
$serverId = (int) $server->id;
$stats[$serverId] = $this->emptyNodeTrafficStats();
$stats[$serverId]['total'] = $this->buildTrafficAmount($server->u ?? 0, $server->d ?? 0);
}
if (empty($stats)) {
return [];
}
$this->fillTrafficWindow($stats, 'today', strtotime('today'));
$this->fillTrafficWindow($stats, 'month', strtotime(date('Y-m-01')));
return $stats;
}
private function fillTrafficWindow(array &$stats, string $key, int $startAt): void
{
$rows = StatServer::query()
->selectRaw('server_id, COALESCE(SUM(u), 0) as upload, COALESCE(SUM(d), 0) as download')
->whereIn('server_id', array_keys($stats))
->where('record_type', 'd')
->where('record_at', '>=', $startAt)
->groupBy('server_id')
->get();
foreach ($rows as $row) {
$stats[(int) $row->server_id][$key] = $this->buildTrafficAmount($row->upload, $row->download);
}
}
private function emptyNodeTrafficStats(): array
{
return [
'today' => $this->buildTrafficAmount(0, 0),
'month' => $this->buildTrafficAmount(0, 0),
'total' => $this->buildTrafficAmount(0, 0),
];
}
private function buildTrafficAmount($upload, $download): array
{
$upload = max(0, (int) $upload);
$download = max(0, (int) $download);
return [
'upload' => $upload,
'download' => $download,
'total' => $upload + $download,
];
}
private function syncAutoOnlineIfEnabled(Server $server): void
{
if ((bool) $server->auto_online) {
app(ServerAutoOnlineService::class)->syncServer($server);
}
}
public function sort(Request $request)
{
ini_set('post_max_size', '1m');
@@ -64,6 +132,7 @@ class ManageController extends Controller
$params['gfw_auto_action_at'] = null;
}
$server->update($params);
$this->syncAutoOnlineIfEnabled($server);
return $this->success(true);
} catch (\Exception $e) {
Log::error($e);
@@ -72,7 +141,8 @@ class ManageController extends Controller
}
try {
Server::create($params);
$server = Server::create($params);
$this->syncAutoOnlineIfEnabled($server);
return $this->success(true);
} catch (\Exception $e) {
Log::error($e);
@@ -118,6 +188,8 @@ class ManageController extends Controller
return $this->fail([500, '保存失败']);
}
$this->syncAutoOnlineIfEnabled($server);
return $this->success(true);
}
@@ -295,6 +367,12 @@ class ManageController extends Controller
$server->update($update);
}
});
$servers->each(function (Server $server) {
$freshServer = $server->fresh();
if ($freshServer) {
$this->syncAutoOnlineIfEnabled($freshServer);
}
});
return $this->success(true);
} catch (\Exception $e) {
Log::error($e);
+64 -39
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\Server;
use App\Models\ServerGfwCheck;
use Illuminate\Support\Collection;
class ServerAutoOnlineService
{
@@ -12,50 +13,74 @@ class ServerAutoOnlineService
$servers = Server::query()
->where('auto_online', true)
->get();
$gfwStatuses = app(ServerGfwCheckService::class)->getLatestStatusesForServers($servers);
$result = [
'total' => $servers->count(),
return $this->syncServers($servers);
}
public function syncServer(Server $server): array
{
if (!(bool) $server->auto_online) {
return $this->emptyResult();
}
return $this->syncServers(collect([$server]));
}
private function syncServers(Collection $servers): array
{
$gfwStatuses = app(ServerGfwCheckService::class)->getLatestStatusesForServers($servers);
$result = $this->emptyResult($servers->count());
foreach ($servers as $server) {
$this->syncServerWithStatuses($server, $gfwStatuses, $result);
}
return $result;
}
private function syncServerWithStatuses(Server $server, array $gfwStatuses, array &$result): void
{
$sourceNodeId = (int) ($server->parent_id ?: $server->id);
$gfwStatus = $gfwStatuses[$sourceNodeId] ?? null;
$isGfwManaged = (bool) ($server->gfw_check_enabled ?? true) && $gfwStatus !== null;
$isGfwBlocked = $isGfwManaged && $gfwStatus === ServerGfwCheck::STATUS_BLOCKED;
$isGfwHeld = $isGfwManaged
&& (bool) $server->gfw_auto_hidden
&& $gfwStatus !== ServerGfwCheck::STATUS_NORMAL;
$shouldShow = !$isGfwBlocked && !$isGfwHeld && (int) $server->available_status !== Server::STATUS_OFFLINE;
$shouldClearGfwAutoHidden = $gfwStatus === ServerGfwCheck::STATUS_NORMAL
&& (bool) $server->gfw_auto_hidden;
$wasShown = (bool) $server->show;
if ($wasShown === $shouldShow && !$shouldClearGfwAutoHidden) {
$result['unchanged']++;
return;
}
$server->show = $shouldShow;
if ($isGfwBlocked) {
$server->gfw_auto_hidden = true;
$server->gfw_auto_action_at = time();
} elseif ($shouldClearGfwAutoHidden) {
$server->gfw_auto_hidden = false;
$server->gfw_auto_action_at = time();
}
$server->save();
$result['updated']++;
if ($wasShown !== $shouldShow) {
$shouldShow ? $result['shown']++ : $result['hidden']++;
}
}
private function emptyResult(int $total = 0): array
{
return [
'total' => $total,
'updated' => 0,
'shown' => 0,
'hidden' => 0,
'unchanged' => 0,
];
foreach ($servers as $server) {
$sourceNodeId = (int) ($server->parent_id ?: $server->id);
$gfwStatus = $gfwStatuses[$sourceNodeId] ?? null;
$isGfwManaged = (bool) ($server->gfw_check_enabled ?? true) && $gfwStatus !== null;
$isGfwBlocked = $isGfwManaged && $gfwStatus === ServerGfwCheck::STATUS_BLOCKED;
$isGfwHeld = $isGfwManaged
&& (bool) $server->gfw_auto_hidden
&& $gfwStatus !== ServerGfwCheck::STATUS_NORMAL;
$shouldShow = !$isGfwBlocked && !$isGfwHeld && (int) $server->available_status !== Server::STATUS_OFFLINE;
$shouldClearGfwAutoHidden = $gfwStatus === ServerGfwCheck::STATUS_NORMAL
&& (bool) $server->gfw_auto_hidden;
$wasShown = (bool) $server->show;
if ($wasShown === $shouldShow && !$shouldClearGfwAutoHidden) {
$result['unchanged']++;
continue;
}
$server->show = $shouldShow;
if ($isGfwBlocked) {
$server->gfw_auto_hidden = true;
$server->gfw_auto_action_at = time();
} elseif ($shouldClearGfwAutoHidden) {
$server->gfw_auto_hidden = false;
$server->gfw_auto_action_at = time();
}
$server->save();
$result['updated']++;
if ($wasShown !== $shouldShow) {
$shouldShow ? $result['shown']++ : $result['hidden']++;
}
}
return $result;
}
}
+4
View File
@@ -210,6 +210,10 @@ class ServerService
time(),
3600
);
if ((bool) $node->auto_online) {
app(ServerAutoOnlineService::class)->syncServer($node);
}
}
/**
+1 -2
View File
@@ -29,8 +29,7 @@ class NodeEventHandlers
$node = Server::find($nodeId);
if (!$node) return;
$nodeType = strtoupper($node->type);
Cache::put(\App\Utils\CacheKey::get('SERVER_' . $nodeType . '_LAST_CHECK_AT', $nodeId), time(), 3600);
ServerService::touchNode($node);
ServerService::updateMetrics($node, $data);
Log::debug("[WS] Node#{$nodeId} status updated");
+45 -3
View File
@@ -6,7 +6,9 @@ use App\Models\Server;
use App\Models\ServerGfwCheck;
use App\Services\ServerAutoOnlineService;
use App\Services\ServerService;
use App\Utils\CacheKey;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class ServerAutoOnlineServiceTest extends TestCase
@@ -31,7 +33,7 @@ class ServerAutoOnlineServiceTest extends TestCase
'auto_online' => false,
]);
ServerService::touchNode($managedOnline);
$this->markNodeOnline($managedOnline);
$result = app(ServerAutoOnlineService::class)->sync();
@@ -54,7 +56,7 @@ class ServerAutoOnlineServiceTest extends TestCase
'gfw_check_enabled' => true,
]);
ServerService::touchNode($managedOnline);
$this->markNodeOnline($managedOnline);
ServerGfwCheck::create([
'server_id' => $managedOnline->id,
'status' => ServerGfwCheck::STATUS_BLOCKED,
@@ -80,7 +82,7 @@ class ServerAutoOnlineServiceTest extends TestCase
'gfw_check_enabled' => false,
]);
ServerService::touchNode($managedOnline);
$this->markNodeOnline($managedOnline);
ServerGfwCheck::create([
'server_id' => $managedOnline->id,
'status' => ServerGfwCheck::STATUS_BLOCKED,
@@ -95,6 +97,37 @@ class ServerAutoOnlineServiceTest extends TestCase
$this->assertTrue($managedOnline->fresh()->show);
}
public function test_sync_server_updates_single_auto_online_node(): void
{
$managedOnline = $this->makeServer([
'name' => 'single-managed-online',
'show' => false,
'auto_online' => true,
]);
$this->markNodeOnline($managedOnline);
$result = app(ServerAutoOnlineService::class)->syncServer($managedOnline);
$this->assertSame(1, $result['total']);
$this->assertSame(1, $result['updated']);
$this->assertSame(1, $result['shown']);
$this->assertTrue($managedOnline->fresh()->show);
}
public function test_touch_node_syncs_auto_online_node_immediately(): void
{
$managedOnline = $this->makeServer([
'name' => 'heartbeat-managed-online',
'show' => false,
'auto_online' => true,
]);
ServerService::touchNode($managedOnline);
$this->assertTrue($managedOnline->fresh()->show);
}
private function makeServer(array $attributes = []): Server
{
return Server::create(array_merge([
@@ -112,4 +145,13 @@ class ServerAutoOnlineServiceTest extends TestCase
'enabled' => true,
], $attributes));
}
private function markNodeOnline(Server $server): void
{
Cache::put(
CacheKey::get('SERVER_' . strtoupper($server->type) . '_LAST_CHECK_AT', $server->id),
time(),
3600
);
}
}