diff --git a/.gitignore b/.gitignore index 45b3be2..da854dc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ scripts/* !scripts/agent-install.sh !scripts/build-linux.ps1 !scripts/fonu-install.sh +!scripts/upgrade.sh # 本地压缩包与导出产物不入库(如 web/src.rar) *.rar *.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index 3729aa1..528cdb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ 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 `(不重新构建)与 `COMPOSE_FILE=`(host 网络那仃) + +### 变更 + +- 版本号提升到 1.2.15;`web/package.json` 同步对齐 +- 本轮不含 agent 行为变更,`AgentVersion` 仍为 0.12.7 + ## [v1.2.14] - 2026-09-27 ### 新增 diff --git a/Dockerfile b/Dockerfile index 9f8a235..5224300 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,7 @@ +# syntax=docker/dockerfile:1 +# ↑ 为了能用 RUN --mount=type=cache:把 Go 模块/编译缓存与 npm 缓存跨次构建复用, +# 在部署机(NAS)上重复构建通常从「几分钟」降到「几十秒」。 + ARG TARGETARCH # 版本单一来源:根目录 VERSION 文件,本地改版本号只需改此文件 ARG VERSION_FILE=VERSION @@ -18,7 +22,7 @@ COPY ${VERSION_FILE} /VERSION FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS web-builder WORKDIR /src/web COPY web/package.json web/package-lock.json* ./ -RUN npm ci +RUN --mount=type=cache,target=/root/.npm npm ci COPY web/ ./ COPY --from=version /VERSION /src/web/.version RUN VITE_APP_VERSION=$(tr -d ' \t\n\r' < /src/web/.version) npm run build @@ -29,14 +33,14 @@ ARG GOPROXY ENV GOPROXY=${GOPROXY} WORKDIR /src COPY go.mod go.sum ./ -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod go mod download COPY . . COPY --from=version /VERSION ./VERSION # 先交叉编译 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 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')" \ -o /fonu ./cmd/fonu diff --git a/VERSION b/VERSION index fd9d1a5..1fc5b82 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.14 +1.2.15 diff --git a/docker-compose.host.yml b/docker-compose.host.yml index 40b2b89..a934bf3 100644 --- a/docker-compose.host.yml +++ b/docker-compose.host.yml @@ -1,5 +1,6 @@ # 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口 # 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取 +name: fonu services: fonu: build: @@ -9,6 +10,7 @@ services: 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} GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} + image: fonu:${FONU_VERSION:-local} container_name: fonu restart: unless-stopped network_mode: host diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index a184ab2..88e9a80 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -1,5 +1,6 @@ # 本地构建并打标签(原为从 Docker Hub 拉取 muxui/fonu,本地项目无需拉取) -# 构建后镜像名为 fonu:local,可直接 docker save 导出或自行推送 +# 镜像 tag 随 VERSION 变(默认 fonu:local),可直接 docker save 导出或自行推送 +name: fonu services: fonu: build: @@ -9,7 +10,7 @@ services: 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} GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} - image: fonu:local + image: fonu:${FONU_VERSION:-local} container_name: fonu restart: unless-stopped ports: diff --git a/docker-compose.yml b/docker-compose.yml index 0eb061d..f30152d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: fonu: build: @@ -8,7 +14,7 @@ services: 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} GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} - image: fonu:local + image: fonu:${FONU_VERSION:-local} container_name: fonu restart: unless-stopped ports: diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh new file mode 100644 index 0000000..c5cbc02 --- /dev/null +++ b/scripts/upgrade.sh @@ -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 diff --git a/web/package.json b/web/package.json index d1b73e2..b0db6ca 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.2.14", + "version": "1.2.15", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 88cdbd7..4e37414 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -106,7 +106,23 @@ 实时流量 - 最近 5 分钟 +
+ 本机 + 公网 + 最近 5 分钟 +
@@ -427,6 +443,33 @@ const RATE_HISTORY_MS = 5 * 60 * 1000 let trafficTimer: ReturnType | null = null let statusTimer: ReturnType | null = null +// 实时流量的数据源:本机(Fonu 管理的本机 Nginx 访问日志)或公网(frps 侧的逐规则计数) +type TrafficSource = 'local' | 'public' +type TrafficRow = { name: string; uploadRate: number; downloadRate: number } +const trafficSource = ref('local') +// 公网侧:只取需要的两个速率字段,避免引额外类型;键为 frp 规则 ID +const publicTraffic = ref>({}) +const frpProxyNames = ref>({}) +// 公网源是否可选:得先有穿透规则(有规则才有名字) +const hasPublicTrafficSource = computed(() => Object.keys(frpProxyNames.value).length > 0) + +// activeTrafficRows 把两个源归一成同一形状,后面的汇总、Top 与采样都只认它 +const activeTrafficRows = computed(() => { + 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 { if (hour >= 5 && hour < 12) return '上午好' if (hour >= 12 && hour < 18) return '下午好' @@ -600,9 +643,9 @@ const remoteHealthMeta = computed(() => { const trafficTotals = computed(() => { let uploadRate = 0 let downloadRate = 0 - for (const stats of Object.values(trafficByRule.value)) { - uploadRate += stats.upload_rate - downloadRate += stats.download_rate + for (const row of activeTrafficRows.value) { + uploadRate += row.uploadRate + downloadRate += row.downloadRate } return { uploadRate, downloadRate } }) @@ -628,19 +671,13 @@ const trafficChart = computed(() => { return { labels, upload, download } }) -// 当前流量 Top 5:直接复用已有的逐规则流量轮询,无需额外接口 +// 当前流量 Top 5:直接复用已有轮询的数据,不额外请求接口 const trafficTop = computed(() => - 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, rate: stats.upload_rate + stats.download_rate } - }) - .filter((row) => row.rate > 0) - .sort((a, b) => b.rate - a.rate) + activeTrafficRows.value + .filter((row) => row.uploadRate + row.downloadRate > 0) + .sort((a, b) => b.uploadRate + b.downloadRate - (a.uploadRate + a.downloadRate)) .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) => @@ -808,13 +845,28 @@ async function refreshTraffic() { // 2 秒一轮且链路慢时会堆积并发请求:上一轮没回来就跳过这一轮 if (trafficInFlight) return trafficInFlight = true + // 首次轮询时顺手把公网规则名拉回来:用于判断「公网」源是否可选 + Top 展示 + void ensureFrpProxyNames() try { - const rows = asList(await api.getProxyTraffic()) - const next: Record = {} - for (const row of rows) { - next[row.rule_id] = row + if (trafficSource.value === 'public') { + // 公网侧:frps 返回的是累计计数,速率由后端基于两次采样算好(间隔需 ≥2 秒) + const payload = await api.listFrpProxiesTraffic() + const next: Record = {} + 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 next: Record = {} + for (const row of rows) { + next[row.rule_id] = row + } + trafficByRule.value = next } - trafficByRule.value = next recordRateSample() } catch { // 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 = {} + 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() { stopTrafficPoll() trafficTimer = setInterval(refreshTraffic, 2000) @@ -1457,6 +1532,8 @@ html.dark .hero-art { color: #14b8a6; } +/* 实时流量卡片的来源切换(本机 / 公网) */ +.traffic-source { display: flex; align-items: center; gap: 6px; } /* 趋势卡片表头里的时间档切换(对齐「实时流量」的 range-tag 位置) */ .trend-range { display: flex; gap: 2px; }