feat(web,docker): 仪表盘实时流量支持本机/公网来源切换,容器升级改为部署机直接构建(v1.2.15)
- 仪表盘「实时流量」新增数据源切换:本机读本地 Nginx 访问日志,公网读 frps 逐规则的 today_traffic_in/out 与 cur_conns(速率由后端两次采样算出,口径与公网反代页一致);两源归一成同一形状供汇总/Top/曲线共用,切换时清空 5 分钟曲线避免混采样,无穿透规则时公网按钮禁用 - Docker 交付(方案 1):Dockerfile 加 docker/dockerfile:1 语法与四处 RUN --mount=type=cache(npm/go 模块/编译缓存复用);三个 compose 统一 name: fonu(防止改目录名后 fonu-data 变新空卷)与 image: fonu:\(tag 随版本走便于回滚),.host.yml 补上缺失的 image - 新增 scripts/upgrade.sh:一条命令完成拉代码→构建→重建→健康检查,支持 --no-pull / --rollback <tag>(不重建) / COMPOSE_FILE=(host 网络那份) - 验证:bash -n 与 --help 实跑通过;容器构建与 compose 配置解析本机无 docker,需在 NAS 上执行一次确认
This commit is contained in:
@@ -13,6 +13,7 @@ scripts/*
|
|||||||
!scripts/agent-install.sh
|
!scripts/agent-install.sh
|
||||||
!scripts/build-linux.ps1
|
!scripts/build-linux.ps1
|
||||||
!scripts/fonu-install.sh
|
!scripts/fonu-install.sh
|
||||||
|
!scripts/upgrade.sh
|
||||||
# 本地压缩包与导出产物不入库(如 web/src.rar)
|
# 本地压缩包与导出产物不入库(如 web/src.rar)
|
||||||
*.rar
|
*.rar
|
||||||
*.zip
|
*.zip
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.2.15] - 2026-09-27
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **仪表盘「实时流量」支持切换数据源(本机 / 公网)**:本机读 Fonu 管理的本机 Nginx 访问日志;公网读 frps 侧逐规则的 `today_traffic_in/out` 与 `cur_conns`(速率由后端两次采样算出,口径与「公网反代」页的当前上传/下载一致)。切换时会清空 5 分钟曲线,避免两个来源的采样混在一张图里;还没有穿透规则时「公网」按钮禁用并给出说明
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- **容器交付:升级改为在部署机(NAS)上直接构建**(不再需要 save / 传文件 / load 镜像包)
|
||||||
|
- `Dockerfile`:加 `# syntax=docker/dockerfile:1` + 四处 `RUN --mount=type=cache`(npm 与 Go 模块/编译缓存跨次构建复用,重复构建从几分钟降到几十秒)
|
||||||
|
- 三个 compose 统一 `name: fonu`(否则改了目录名/项目名后 compose 会把它当成新项目,`fonu-data` 变成新空卷,现象就是「数据丢了」)与 `image: fonu:${FONU_VERSION:-local}`(镜像 tag 随版本走,便于回滚);`.host.yml` 补上原本缺失的 `image:`
|
||||||
|
- 新增 `scripts/upgrade.sh`:一条命令完成「拉代码 → 构建 → 重建 → 健康检查」,支持 `--no-pull`、`--rollback <tag>`(不重新构建)与 `COMPOSE_FILE=`(host 网络那仃)
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 版本号提升到 1.2.15;`web/package.json` 同步对齐
|
||||||
|
- 本轮不含 agent 行为变更,`AgentVersion` 仍为 0.12.7
|
||||||
|
|
||||||
## [v1.2.14] - 2026-09-27
|
## [v1.2.14] - 2026-09-27
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
+8
-4
@@ -1,3 +1,7 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# ↑ 为了能用 RUN --mount=type=cache:把 Go 模块/编译缓存与 npm 缓存跨次构建复用,
|
||||||
|
# 在部署机(NAS)上重复构建通常从「几分钟」降到「几十秒」。
|
||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
# 版本单一来源:根目录 VERSION 文件,本地改版本号只需改此文件
|
# 版本单一来源:根目录 VERSION 文件,本地改版本号只需改此文件
|
||||||
ARG VERSION_FILE=VERSION
|
ARG VERSION_FILE=VERSION
|
||||||
@@ -18,7 +22,7 @@ COPY ${VERSION_FILE} /VERSION
|
|||||||
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS web-builder
|
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS web-builder
|
||||||
WORKDIR /src/web
|
WORKDIR /src/web
|
||||||
COPY web/package.json web/package-lock.json* ./
|
COPY web/package.json web/package-lock.json* ./
|
||||||
RUN npm ci
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
COPY --from=version /VERSION /src/web/.version
|
COPY --from=version /VERSION /src/web/.version
|
||||||
RUN VITE_APP_VERSION=$(tr -d ' \t\n\r' < /src/web/.version) npm run build
|
RUN VITE_APP_VERSION=$(tr -d ' \t\n\r' < /src/web/.version) npm run build
|
||||||
@@ -29,14 +33,14 @@ ARG GOPROXY
|
|||||||
ENV GOPROXY=${GOPROXY}
|
ENV GOPROXY=${GOPROXY}
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=version /VERSION ./VERSION
|
COPY --from=version /VERSION ./VERSION
|
||||||
# 先交叉编译 fonu-agent 到 embed 目录(替换仓库中的占位文件),主程序构建时自动内嵌
|
# 先交叉编译 fonu-agent 到 embed 目录(替换仓库中的占位文件),主程序构建时自动内嵌
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-amd64 ./cmd/fonu-agent \
|
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-amd64 ./cmd/fonu-agent \
|
||||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-arm64 ./cmd/fonu-agent
|
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o internal/frp/agentbin/fonu-agent-linux-arm64 ./cmd/fonu-agent
|
||||||
COPY --from=web-builder /src/web/dist ./cmd/fonu/web/dist
|
COPY --from=web-builder /src/web/dist ./cmd/fonu/web/dist
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||||
-ldflags "-X github.com/fonu/fonu/internal/version.Version=$(cat /src/VERSION | tr -d ' \t\n\r')" \
|
-ldflags "-X github.com/fonu/fonu/internal/version.Version=$(cat /src/VERSION | tr -d ' \t\n\r')" \
|
||||||
-o /fonu ./cmd/fonu
|
-o /fonu ./cmd/fonu
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口
|
# 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口
|
||||||
# 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取
|
# 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取
|
||||||
|
name: fonu
|
||||||
services:
|
services:
|
||||||
fonu:
|
fonu:
|
||||||
build:
|
build:
|
||||||
@@ -9,6 +10,7 @@ services:
|
|||||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||||
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
||||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||||
|
image: fonu:${FONU_VERSION:-local}
|
||||||
container_name: fonu
|
container_name: fonu
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# 本地构建并打标签(原为从 Docker Hub 拉取 muxui/fonu,本地项目无需拉取)
|
# 本地构建并打标签(原为从 Docker Hub 拉取 muxui/fonu,本地项目无需拉取)
|
||||||
# 构建后镜像名为 fonu:local,可直接 docker save 导出或自行推送
|
# 镜像 tag 随 VERSION 变(默认 fonu:local),可直接 docker save 导出或自行推送
|
||||||
|
name: fonu
|
||||||
services:
|
services:
|
||||||
fonu:
|
fonu:
|
||||||
build:
|
build:
|
||||||
@@ -9,7 +10,7 @@ services:
|
|||||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||||
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
||||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||||
image: fonu:local
|
image: fonu:${FONU_VERSION:-local}
|
||||||
container_name: fonu
|
container_name: fonu
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+8
-2
@@ -1,4 +1,10 @@
|
|||||||
# 本地项目:用源码构建镜像(镜像名 fonu:local,便于导出 / 备份)
|
# 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目,
|
||||||
|
# fonu-data 会变成新空卷(看起来像「数据丢了」)。
|
||||||
|
name: fonu
|
||||||
|
|
||||||
|
# 本地项目:用源码构建镜像(镜像名随 VERSION 变,便于回滚;也便于导出 / 备份)
|
||||||
|
# 升级:docker compose up -d --build(或在部署机跑 scripts/upgrade.sh)
|
||||||
|
# 回滚:FONU_VERSION=上一个版本号 docker compose up -d(镜像 tag 还在本地)
|
||||||
services:
|
services:
|
||||||
fonu:
|
fonu:
|
||||||
build:
|
build:
|
||||||
@@ -8,7 +14,7 @@ services:
|
|||||||
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm}
|
||||||
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim}
|
||||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||||
image: fonu:local
|
image: fonu:${FONU_VERSION:-local}
|
||||||
container_name: fonu
|
container_name: fonu
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fonu 容器升级:在部署机(NAS)上直接构建,不需要再从别处传镜像包
|
||||||
|
#
|
||||||
|
# sudo bash scripts/upgrade.sh # 拉取最新源码 → 构建 → 重建容器
|
||||||
|
# sudo bash scripts/upgrade.sh --no-pull # 不拉代码,用当前源码重建
|
||||||
|
# sudo bash scripts/upgrade.sh --rollback 1.2.13 # 回滚到已存在的镜像 tag(不构建)
|
||||||
|
#
|
||||||
|
# 用 COMPOSE_FILE 指定编排文件(默认 docker-compose.yml):
|
||||||
|
# COMPOSE_FILE=docker-compose.host.yml sudo bash scripts/upgrade.sh
|
||||||
|
#
|
||||||
|
# 数据在 fonu-data 卷里,重建容器不会丢。每次构建按仓库 VERSION 打一个新 tag
|
||||||
|
# (旧的 tag 会留着),所以出问题可以直接 --rollback 回上一个版本。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||||
|
APP_CONTAINER="${APP_CONTAINER:-fonu}"
|
||||||
|
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6893/api/version}"
|
||||||
|
NO_PULL=0
|
||||||
|
ROLLBACK_TAG=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit "${1:-0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--no-pull) NO_PULL=1; shift ;;
|
||||||
|
--rollback) ROLLBACK_TAG="${2:?--rollback 需要一个镜像 tag,例如 1.2.13}"; shift 2 ;;
|
||||||
|
-h|--help) usage 0 ;;
|
||||||
|
*) echo "未知参数:$1" >&2; usage 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "没有找到 docker 命令:这个脚本要在运行 Fonu 容器的那台机器上执行" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||||
|
echo "找不到 $COMPOSE_FILE:请在仓库根目录运行(或用 COMPOSE_FILE=... 指定编排文件)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 当前容器实际使用的镜像 tag,升级前后各打印一次,回滚时心里有数
|
||||||
|
current_image() {
|
||||||
|
docker inspect --format '{{.Config.Image}}' "$APP_CONTAINER" 2>/dev/null || echo '(容器未运行)'
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> 升级前:$(current_image)"
|
||||||
|
|
||||||
|
if [ -n "$ROLLBACK_TAG" ]; then
|
||||||
|
if ! docker image inspect "fonu:$ROLLBACK_TAG" >/dev/null 2>&1; then
|
||||||
|
echo "本机没有 fonu:$ROLLBACK_TAG;已有 tag:$(docker images --format '{{.Tag}}' fonu | tr '\n' ' ')" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
FONU_VERSION="$ROLLBACK_TAG" docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
echo "==> 已切回 fonu:$ROLLBACK_TAG"
|
||||||
|
else
|
||||||
|
if [ "$NO_PULL" -eq 0 ]; then
|
||||||
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
echo '==> git pull --ff-only'
|
||||||
|
git pull --ff-only
|
||||||
|
else
|
||||||
|
echo '==> 当前目录不是 git 仓库,跳过拉取(请先自行更新源码)'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ ! -f VERSION ]; then
|
||||||
|
echo '找不到 VERSION 文件:请在仓库根目录运行' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
FONU_VERSION="$(tr -d ' \t\n\r' < VERSION)"
|
||||||
|
export FONU_VERSION
|
||||||
|
echo "==> 构建并重建:fonu:$FONU_VERSION(首次构建较慢,之后有构建缓存)"
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo '==> 等待服务就绪…'
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
|
||||||
|
echo "==> 升级后:$(current_image)"
|
||||||
|
echo " 版本接口:$(curl -fsS "$HEALTH_URL" 2>/dev/null)"
|
||||||
|
echo " 日志:docker logs --tail 50 $APP_CONTAINER"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "服务在 60 秒内没有响应 $HEALTH_URL,请看:docker logs --tail 100 $APP_CONTAINER" >&2
|
||||||
|
exit 1
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "fonu-web",
|
"name": "fonu-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.14",
|
"version": "1.2.15",
|
||||||
"packageManager": "npm@10.9.4",
|
"packageManager": "npm@10.9.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -106,8 +106,24 @@
|
|||||||
<n-icon :component="SwapVerticalOutline" class="panel-title__icon panel-title__icon--teal" />
|
<n-icon :component="SwapVerticalOutline" class="panel-title__icon panel-title__icon--teal" />
|
||||||
<span>实时流量</span>
|
<span>实时流量</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="traffic-source">
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
:quaternary="trafficSource !== 'local'"
|
||||||
|
:type="trafficSource === 'local' ? 'primary' : 'default'"
|
||||||
|
@click="switchTrafficSource('local')"
|
||||||
|
>本机</n-button>
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
:quaternary="trafficSource !== 'public'"
|
||||||
|
:type="trafficSource === 'public' ? 'primary' : 'default'"
|
||||||
|
:disabled="!hasPublicTrafficSource"
|
||||||
|
:title="hasPublicTrafficSource ? '公网(VPS 反代)侧流量,速率由 frps 两次采样得出' : '还没有穿透规则,公网侧暂无可显示的流量'"
|
||||||
|
@click="switchTrafficSource('public')"
|
||||||
|
>公网</n-button>
|
||||||
<n-tag size="small" :bordered="false" class="range-tag">最近 5 分钟</n-tag>
|
<n-tag size="small" :bordered="false" class="range-tag">最近 5 分钟</n-tag>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="panel__body traffic-panel__body">
|
<div class="panel__body traffic-panel__body">
|
||||||
<div class="traffic-rates">
|
<div class="traffic-rates">
|
||||||
<div class="traffic-rate traffic-rate--up">
|
<div class="traffic-rate traffic-rate--up">
|
||||||
@@ -427,6 +443,33 @@ const RATE_HISTORY_MS = 5 * 60 * 1000
|
|||||||
let trafficTimer: ReturnType<typeof setInterval> | null = null
|
let trafficTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let statusTimer: ReturnType<typeof setInterval> | null = null
|
let statusTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
// 实时流量的数据源:本机(Fonu 管理的本机 Nginx 访问日志)或公网(frps 侧的逐规则计数)
|
||||||
|
type TrafficSource = 'local' | 'public'
|
||||||
|
type TrafficRow = { name: string; uploadRate: number; downloadRate: number }
|
||||||
|
const trafficSource = ref<TrafficSource>('local')
|
||||||
|
// 公网侧:只取需要的两个速率字段,避免引额外类型;键为 frp 规则 ID
|
||||||
|
const publicTraffic = ref<Record<number, { traffic_in_rate: number; traffic_out_rate: number }>>({})
|
||||||
|
const frpProxyNames = ref<Record<number, string>>({})
|
||||||
|
// 公网源是否可选:得先有穿透规则(有规则才有名字)
|
||||||
|
const hasPublicTrafficSource = computed(() => Object.keys(frpProxyNames.value).length > 0)
|
||||||
|
|
||||||
|
// activeTrafficRows 把两个源归一成同一形状,后面的汇总、Top 与采样都只认它
|
||||||
|
const activeTrafficRows = computed<TrafficRow[]>(() => {
|
||||||
|
if (trafficSource.value === 'public') {
|
||||||
|
return Object.entries(publicTraffic.value).map(([key, item]) => ({
|
||||||
|
name: frpProxyNames.value[Number(key)] ?? `规则 #${key}`,
|
||||||
|
uploadRate: item.traffic_in_rate,
|
||||||
|
downloadRate: item.traffic_out_rate,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return Object.entries(trafficByRule.value).map(([key, stats]) => {
|
||||||
|
const id = Number(key)
|
||||||
|
const rule = proxyRules.value.find((item) => item.id === id)
|
||||||
|
const name = rule ? rule.name?.trim() || rule.domain || `规则 #${id}` : `规则 #${id}`
|
||||||
|
return { name, uploadRate: stats.upload_rate, downloadRate: stats.download_rate }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function timeGreeting(hour: number): string {
|
function timeGreeting(hour: number): string {
|
||||||
if (hour >= 5 && hour < 12) return '上午好'
|
if (hour >= 5 && hour < 12) return '上午好'
|
||||||
if (hour >= 12 && hour < 18) return '下午好'
|
if (hour >= 12 && hour < 18) return '下午好'
|
||||||
@@ -600,9 +643,9 @@ const remoteHealthMeta = computed(() => {
|
|||||||
const trafficTotals = computed(() => {
|
const trafficTotals = computed(() => {
|
||||||
let uploadRate = 0
|
let uploadRate = 0
|
||||||
let downloadRate = 0
|
let downloadRate = 0
|
||||||
for (const stats of Object.values(trafficByRule.value)) {
|
for (const row of activeTrafficRows.value) {
|
||||||
uploadRate += stats.upload_rate
|
uploadRate += row.uploadRate
|
||||||
downloadRate += stats.download_rate
|
downloadRate += row.downloadRate
|
||||||
}
|
}
|
||||||
return { uploadRate, downloadRate }
|
return { uploadRate, downloadRate }
|
||||||
})
|
})
|
||||||
@@ -628,19 +671,13 @@ const trafficChart = computed(() => {
|
|||||||
return { labels, upload, download }
|
return { labels, upload, download }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当前流量 Top 5:直接复用已有的逐规则流量轮询,无需额外接口
|
// 当前流量 Top 5:直接复用已有轮询的数据,不额外请求接口
|
||||||
const trafficTop = computed(() =>
|
const trafficTop = computed(() =>
|
||||||
Object.entries(trafficByRule.value)
|
activeTrafficRows.value
|
||||||
.map(([key, stats]) => {
|
.filter((row) => row.uploadRate + row.downloadRate > 0)
|
||||||
const id = Number(key)
|
.sort((a, b) => b.uploadRate + b.downloadRate - (a.uploadRate + a.downloadRate))
|
||||||
const rule = proxyRules.value.find((item) => item.id === id)
|
|
||||||
const name = rule ? rule.name?.trim() || rule.domain || `规则 #${id}` : `规则 #${id}`
|
|
||||||
return { name, rate: stats.upload_rate + stats.download_rate }
|
|
||||||
})
|
|
||||||
.filter((row) => row.rate > 0)
|
|
||||||
.sort((a, b) => b.rate - a.rate)
|
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map((row) => ({ name: row.name, rate: formatRate(row.rate) })),
|
.map((row) => ({ name: row.name, rate: formatRate(row.uploadRate + row.downloadRate) })),
|
||||||
)
|
)
|
||||||
|
|
||||||
const calendarHourLabels = Array.from({ length: 12 }, (_, i) =>
|
const calendarHourLabels = Array.from({ length: 12 }, (_, i) =>
|
||||||
@@ -808,13 +845,28 @@ async function refreshTraffic() {
|
|||||||
// 2 秒一轮且链路慢时会堆积并发请求:上一轮没回来就跳过这一轮
|
// 2 秒一轮且链路慢时会堆积并发请求:上一轮没回来就跳过这一轮
|
||||||
if (trafficInFlight) return
|
if (trafficInFlight) return
|
||||||
trafficInFlight = true
|
trafficInFlight = true
|
||||||
|
// 首次轮询时顺手把公网规则名拉回来:用于判断「公网」源是否可选 + Top 展示
|
||||||
|
void ensureFrpProxyNames()
|
||||||
try {
|
try {
|
||||||
|
if (trafficSource.value === 'public') {
|
||||||
|
// 公网侧:frps 返回的是累计计数,速率由后端基于两次采样算好(间隔需 ≥2 秒)
|
||||||
|
const payload = await api.listFrpProxiesTraffic()
|
||||||
|
const next: Record<number, { traffic_in_rate: number; traffic_out_rate: number }> = {}
|
||||||
|
for (const [key, value] of Object.entries(payload.items ?? {})) {
|
||||||
|
next[Number(key)] = {
|
||||||
|
traffic_in_rate: value.traffic_in_rate,
|
||||||
|
traffic_out_rate: value.traffic_out_rate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publicTraffic.value = next
|
||||||
|
} else {
|
||||||
const rows = asList(await api.getProxyTraffic())
|
const rows = asList(await api.getProxyTraffic())
|
||||||
const next: Record<number, ProxyTraffic> = {}
|
const next: Record<number, ProxyTraffic> = {}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
next[row.rule_id] = row
|
next[row.rule_id] = row
|
||||||
}
|
}
|
||||||
trafficByRule.value = next
|
trafficByRule.value = next
|
||||||
|
}
|
||||||
recordRateSample()
|
recordRateSample()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore polling errors
|
// ignore polling errors
|
||||||
@@ -823,6 +875,29 @@ async function refreshTraffic() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换数据源:先清空 5 分钟曲线,避免把两个来源的采样混在一张图里
|
||||||
|
function switchTrafficSource(next: TrafficSource) {
|
||||||
|
if (trafficSource.value === next) return
|
||||||
|
trafficSource.value = next
|
||||||
|
rateHistory.value = []
|
||||||
|
void refreshTraffic()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公网侧只需要规则名做 Top 展示;只拉一次,失败不阻断流量轮询
|
||||||
|
async function ensureFrpProxyNames() {
|
||||||
|
if (Object.keys(frpProxyNames.value).length > 0) return
|
||||||
|
try {
|
||||||
|
const proxies = asList(await api.listFrpProxies())
|
||||||
|
const names: Record<number, string> = {}
|
||||||
|
for (const item of proxies) {
|
||||||
|
names[item.id] = item.name?.trim() || item.custom_domains?.[0] || `规则 #${item.id}`
|
||||||
|
}
|
||||||
|
frpProxyNames.value = names
|
||||||
|
} catch {
|
||||||
|
// 静默:名称拿不到时 Top 里会退化成「规则 #id」
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startTrafficPoll() {
|
function startTrafficPoll() {
|
||||||
stopTrafficPoll()
|
stopTrafficPoll()
|
||||||
trafficTimer = setInterval(refreshTraffic, 2000)
|
trafficTimer = setInterval(refreshTraffic, 2000)
|
||||||
@@ -1457,6 +1532,8 @@ html.dark .hero-art {
|
|||||||
color: #14b8a6;
|
color: #14b8a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 实时流量卡片的来源切换(本机 / 公网) */
|
||||||
|
.traffic-source { display: flex; align-items: center; gap: 6px; }
|
||||||
/* 趋势卡片表头里的时间档切换(对齐「实时流量」的 range-tag 位置) */
|
/* 趋势卡片表头里的时间档切换(对齐「实时流量」的 range-tag 位置) */
|
||||||
.trend-range { display: flex; gap: 2px; }
|
.trend-range { display: flex; gap: 2px; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user