Compare commits

...
27 Commits
Author SHA1 Message Date
naiba 106d58575b 增加透传给前端的自定义字段方便前端扩展功能 2024-09-28 00:16:54 +08:00
UUBulb bdf36276da improve: use sync.Pool for buffer allocation (#423) 2024-09-27 18:50:19 +08:00
nap0o f32f127dfc improve: status-server主题network页 (#422) 2024-09-11 21:50:40 +08:00
nap0o f78ba281fb Default主题fixbug (#421)
* default主题fixbug
1.补上误删的网页title
2.修复network页纵坐标数值显示不全
3.修复network,多个legend导致的遮挡主图表问题

* fix

* fix

* fix

* fix

* fix

* fix
2024-09-07 14:44:30 +08:00
github-actions[bot] 7f9f9a9129 update contributors[no ci] 2024-09-06 15:31:54 +00:00
nap0o b701efd9b5 server-status和default主题:feat & improve & fix (#420)
* feat & improve & fix
1. 增加WebAppManifest文件,实现把哪吒监控网页伪装成app放在移动端桌面
2. vps地图分布图增加数据异步加载loading效果
3. 修复echart图表纵坐标轴因数值过大显示不全bug

* 刷新CDN缓存

* fix footer

* 4.提升service页echarts图表相关体验
5.用设置基准1vh的方法解决footer页脚位置在移动端各种浏览器显示不一致的问题
6.修复section标签的使用位置

* 刷新CDN缓存
2024-09-06 23:31:38 +08:00
UUBulb 511f43784e fix dashboard install script (#419) 2024-09-03 08:54:07 +08:00
UUBulb 3f6112a919 fix: docker image (#418) 2024-09-02 23:19:43 +08:00
UUBulb 23751440c6 [ci][docker]: use matrix to reduce build time & change base image to busybox (#417)
* ci: use matrix to reduce build time

* docker: change base image to busybox

* reuse workflows

* only download ipinfo db on tag pushes

* goreleaser
2024-09-02 22:13:13 +08:00
UUBulb 989c9f9740 fix/fm: reset upload state after completion (#416) 2024-08-28 23:23:23 +08:00
UUBulb 71508f8724 修复多CPU在默认主题和AngelKanade下显示的问题,完善内网穿透本地化文本 (#415) 2024-08-25 11:33:25 +08:00
UUBulb eb6dd2855e refactor: ddns (#414)
* refactor ddns

* update webhook
2024-08-24 11:11:06 +08:00
naiba 64da3c7438 chore: back to attach addon 2024-08-21 23:42:05 +08:00
naiba fd85f599ba chore: 移除 trzsz 支持 (不能上传) 2024-08-21 22:20:47 +08:00
UUBulb cae443d5c8 fm: store file to OPFS temporarily (#413) 2024-08-21 22:19:40 +08:00
github-actions[bot] 0ec1bb2c54 update contributors[no ci] 2024-08-20 14:25:53 +00:00
UUBulb 47f8447a22 feat: framed fm for webshell (#411)
* feat: framed fm for webshell

* 1MB buffer
2024-08-20 22:25:29 +08:00
UUBulb 9c986d06cb ci增加重传机制 (#410) 2024-08-18 08:56:52 +08:00
nap0o e9428d5757 improve & fix : 主题优化及bug修复 (#409)
* 主题优化及bug修复

default主题
1.修复主box过宽导致的系列问题(pc和移动端)
2.network页面适配深色模式和浅色模式

status-server主题
1. network页面折线图取数逻辑优化,丢包率一直是100%时,不显示丢包markline线
2. 隐藏所有table横向滚动条
3. 关闭折线图hover效果,大幅提升echarts图表渲染速度
4. 移动端页底显示位置优化

* 刷新cdn缓存

* 修复default深色模式在ios设备不生效bug

* 1. 恢复主baox 1680px宽度,用其他方式修复上一版存在bug(移动端左右留空不一致等)
2. 首页echarts自动适配深色浅色模式

* fix
2024-08-17 11:14:28 +08:00
naiba daab64d232 feat: 管理后台自定义代码 2024-08-14 00:24:17 +08:00
naiba da8fb57268 feat: 默认主题 dark mode 2024-08-13 23:50:55 +08:00
naiba fda0dad5f1 chore: set ws binaryType 2024-08-13 13:53:44 +08:00
github-actions[bot] c6fb3196ea update contributors[no ci] 2024-08-13 05:51:41 +00:00
hiDandelion 711d162a46 Add replacing fields to notification content in notification.go (#408) 2024-08-13 13:51:19 +08:00
naiba d667ce8afe feat: WebSSH 上传下载文件 (by trzsz) 2024-08-13 00:01:39 +08:00
naiba 6abc0fce51 fix: 流量统计异常 2024-08-12 10:06:55 +08:00
naiba b1d77a1d27 improve: 优化可能造成流量统计异常的情况 2024-08-11 10:35:19 +08:00
80 changed files with 2442 additions and 960 deletions
+4
View File
@@ -97,6 +97,9 @@ def sync_to_gitee(tag: str, body: str, files: slice):
asset_api_uri = f"{release_api_uri}/{release_id}/attach_files" asset_api_uri = f"{release_api_uri}/{release_id}/attach_files"
for file_path in files: for file_path in files:
success = False
while not success:
files = { files = {
'file': open(file_path, 'rb') 'file': open(file_path, 'rb')
} }
@@ -108,6 +111,7 @@ def sync_to_gitee(tag: str, body: str, files: slice):
asset_info = asset_api_response.json() asset_info = asset_api_response.json()
asset_name = asset_info.get('name') asset_name = asset_info.get('name')
print(f"Successfully uploaded {asset_name}!") print(f"Successfully uploaded {asset_name}!")
success = True
else: else:
print( print(
f"Request failed with status code {asset_api_response.status_code}") f"Request failed with status code {asset_api_response.status_code}")
+116 -71
View File
@@ -4,20 +4,125 @@ on:
push: push:
tags: tags:
- "v*" - "v*"
workflow_dispatch:
jobs: jobs:
release: build:
strategy:
fail-fast: true
matrix:
goos: [linux, windows]
goarch: [amd64]
include:
- goos: linux
goarch: s390x
- goos: linux
goarch: arm64
name: Build artifacts
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: goreleaser/goreleaser-cross:v1.21
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
outputs:
tag_name: ${{ steps.extract_branch.outputs.tag }}
steps: steps:
- run: git config --global --add safe.directory /__w/nezha/nezha
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Fetch IPInfo GeoIP Database - name: Fetch IPInfo GeoIP Database
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
env: env:
IPINFO_TOKEN: ${{ secrets.IPINFO_TOKEN }} IPINFO_TOKEN: ${{ secrets.IPINFO_TOKEN }}
run: | run: |
rm pkg/geoip/geoip.db rm pkg/geoip/geoip.db
wget -O pkg/geoip/geoip.db https://ipinfo.io/data/free/country.mmdb?token=${IPINFO_TOKEN} wget -qO pkg/geoip/geoip.db https://ipinfo.io/data/free/country.mmdb?token=${IPINFO_TOKEN}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.21.x"
- name: Build
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: build --single-target --clean --skip=validate
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dashboard-${{ env.GOOS }}-${{ env.GOARCH }}
path: |
./dist/*/*
release:
runs-on: ubuntu-latest
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
needs: build
name: Release
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: ./assets
- name: Archive and compress binaries
run: |
for file in assets/*/*/*; do
if [ -f "$file" ]; then
chmod +x "$file"
zip -jr "$file.zip" "$file"
fi
done
- name: Release
uses: ncipollo/release-action@v1
with:
artifacts: "assets/*/*/*.zip"
generateReleaseNotes: true
- name: Purge jsdelivr cache
run: |
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/install.sh
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/nezha-agent.service
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/docker-compose.yaml
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/config.yaml
LOWER_USERNAME=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/install.sh
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/nezha-agent.service
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/docker-compose.yaml
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/config.yaml
- name: Trigger sync
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
GH_DEBUG: api
run: |
gh workflow run sync-release.yml
release-docker:
runs-on: ubuntu-latest
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
needs: build
name: Release Docker images
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: ./assets
- name: Fix permissions
run: |
chmod -R +x ./assets/*
mkdir dist
mv ./assets/*/*/* ./dist
- name: Extract branch name - name: Extract branch name
run: | run: |
@@ -25,33 +130,12 @@ jobs:
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
id: extract_branch id: extract_branch
- name: xgo build - name: Log into GHCR
uses: crazy-max/ghaction-xgo@v2
with:
xgo_version: latest
go_version: 1.21.x
dest: dist
pkg: cmd/dashboard
prefix: dashboard
targets: linux/amd64,linux/arm64,linux/arm-7,linux/s390x,linux/riscv64,windows/amd64,windows/386,windows/arm64 # linux/386,
v: true
x: false
race: false
tags: timetzdata
ldflags: -s -w --extldflags '-static -fpic' -X github.com/naiba/nezha/service/singleton.Version=${{ steps.extract_branch.outputs.tag }}
buildmode: default
- name: fix dist
run: |
ls -al dist/
mv dist/dashboard-linux-arm-7 dist/dashboard-linux-arm
- name: Log in to the GHCR
uses: docker/login-action@master uses: docker/login-action@master
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ github.token }}
- name: Log in to the AliyunCS - name: Log in to the AliyunCS
uses: docker/login-action@master uses: docker/login-action@master
@@ -68,13 +152,13 @@ jobs:
- name: Set up image name - name: Set up image name
run: | run: |
GHRC_IMAGE_NAME=$(echo "ghcr.io/${{ github.repository_owner }}/nezha-dashboard" | tr '[:upper:]' '[:lower:]') GHCR_IMAGE_NAME=$(echo "ghcr.io/${{ github.repository_owner }}/nezha-dashboard" | tr '[:upper:]' '[:lower:]')
if [ ${{ github.repository_owner }} = "naiba" ] if [ ${{ github.repository_owner }} = "naiba" ]
then ALI_IMAGE_NAME=$(echo "registry.cn-shanghai.aliyuncs.com/naibahq/nezha-dashboard") then ALI_IMAGE_NAME=$(echo "registry.cn-shanghai.aliyuncs.com/naibahq/nezha-dashboard")
else ALI_IMAGE_NAME=$(echo "registry.cn-shanghai.aliyuncs.com/${{ github.repository_owner }}/nezha-dashboard" | tr '[:upper:]' '[:lower:]') else ALI_IMAGE_NAME=$(echo "registry.cn-shanghai.aliyuncs.com/${{ github.repository_owner }}/nezha-dashboard" | tr '[:upper:]' '[:lower:]')
fi fi
echo "::set-output name=GHRC_IMAGE_NAME::$GHRC_IMAGE_NAME" echo "GHCR_IMAGE_NAME=$GHCR_IMAGE_NAME" >> $GITHUB_OUTPUT
echo "::set-output name=ALI_IMAGE_NAME::$ALI_IMAGE_NAME" echo "ALI_IMAGE_NAME=$ALI_IMAGE_NAME" >> $GITHUB_OUTPUT
id: image-name id: image-name
- name: Build dasbboard image And Push - name: Build dasbboard image And Push
@@ -82,49 +166,10 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm,linux/s390x,linux/riscv64 # linux/386, platforms: linux/amd64,linux/arm64,linux/s390x
push: true push: true
tags: | tags: |
${{ steps.image-name.outputs.GHRC_IMAGE_NAME }}:latest ${{ steps.image-name.outputs.GHCR_IMAGE_NAME }}:latest
${{ steps.image-name.outputs.GHRC_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }} ${{ steps.image-name.outputs.GHCR_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }}
${{ steps.image-name.outputs.ALI_IMAGE_NAME }}:latest ${{ steps.image-name.outputs.ALI_IMAGE_NAME }}:latest
${{ steps.image-name.outputs.ALI_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }} ${{ steps.image-name.outputs.ALI_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }}
- name: Compress dist files
run: |
for file in dist/*; do
if [ -f "$file" ]; then
zip -r "$file.zip" "$file"
fi
done
- name: Release
uses: ncipollo/release-action@v1
with:
artifacts: "dist/*.zip"
generateReleaseNotes: true
- name: Purge jsdelivr cache
run: |
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/install.sh
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/nezha-agent.service
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/docker-compose.yaml
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/config.yaml
LOWER_USERNAME=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/install.sh
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/nezha-agent.service
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/docker-compose.yaml
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/config.yaml
- name: Trigger sync
if: ${{ env.SYNCED == 0 }}
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'naiba',
repo: 'nezha',
workflow_id: 'sync-release.yml',
ref: 'master'
})
-43
View File
@@ -1,43 +0,0 @@
name: Run Tests on PR
on:
pull_request:
branches:
- master
jobs:
tests:
runs-on: ubuntu-latest
env:
GO111MODULE: on
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: "^1.21.3"
- name: Unit test
run: |
go test -v ./...
- name: Run Gosec Security Scanner
uses: securego/gosec@master
with:
args: --exclude=G104,G402 ./...
- name: xgo build
uses: crazy-max/ghaction-xgo@v2
with:
xgo_version: latest
go_version: 1.21.x
dest: dist
pkg: cmd/dashboard
prefix: dashboard
targets: linux/amd64,linux/arm64,linux/arm-7,linux/s390x,linux/riscv64,windows/amd64,windows/386,windows/arm64 # linux/386,
v: true
x: false
race: false
tags: timetzdata
ldflags: -s -w --extldflags '-static -fpic' -X github.com/naiba/nezha/service/singleton.Version=test
buildmode: default
- name: fix dist
run: |
ls -al dist/
mv dist/dashboard-linux-arm-7 dist/dashboard-linux-arm
+18 -22
View File
@@ -10,40 +10,36 @@ on:
- "go.sum" - "go.sum"
- "resource/**" - "resource/**"
- ".github/workflows/test.yml" - ".github/workflows/test.yml"
pull_request:
branches:
- master
jobs: jobs:
tests: tests:
runs-on: ubuntu-latest strategy:
fail-fast: true
matrix:
os: [ubuntu, windows, macos]
runs-on: ${{ matrix.os }}-latest
env: env:
GO111MODULE: on GO111MODULE: on
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v4
- uses: actions/setup-go@v5
with: with:
go-version: "^1.21.3" go-version: "1.21.x"
- name: Unit test - name: Unit test
run: | run: |
go test -v ./... go test -v ./...
- name: Build test
run: go build -v ./cmd/dashboard
- name: Run Gosec Security Scanner - name: Run Gosec Security Scanner
if: runner.os == 'Linux'
uses: securego/gosec@master uses: securego/gosec@master
with: with:
args: --exclude=G104,G402 ./... args: --exclude=G104,G402 ./...
- name: xgo build
uses: crazy-max/ghaction-xgo@v2
with:
xgo_version: latest
go_version: 1.21.x
dest: dist
pkg: cmd/dashboard
prefix: dashboard
targets: linux/amd64,linux/arm64,linux/arm-7,linux/s390x,linux/riscv64,windows/amd64,windows/386,windows/arm64 # linux/386,
v: true
x: false
race: false
tags: timetzdata
ldflags: -s -w --extldflags '-static -fpic' -X github.com/naiba/nezha/service/singleton.Version=test
buildmode: default
- name: fix dist
run: |
ls -al dist/
mv dist/dashboard-linux-arm-7 dist/dashboard-linux-arm
+71
View File
@@ -0,0 +1,71 @@
version: 2
before:
hooks:
- go mod tidy -v
builds:
- id: linux_arm64
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
ldflags:
- -s -w
- -X github.com/naiba/nezha/service/singleton.Version={{.Version}}
- -extldflags "-static -fpic"
flags:
- -trimpath
goos:
- linux
goarch:
- arm64
main: ./cmd/dashboard
binary: dashboard-{{ .Os }}-{{ .Arch }}
- id: linux_amd64
env:
- CGO_ENABLED=1
- CC=x86_64-linux-gnu-gcc
ldflags:
- -s -w
- -X github.com/naiba/nezha/service/singleton.Version={{.Version}}
- -extldflags "-static -fpic"
flags:
- -trimpath
goos:
- linux
goarch:
- amd64
main: ./cmd/dashboard
binary: dashboard-{{ .Os }}-{{ .Arch }}
- id: linux_s390x
env:
- CGO_ENABLED=1
- CC=s390x-linux-gnu-gcc
ldflags:
- -s -w
- -X github.com/naiba/nezha/service/singleton.Version={{.Version}}
- -extldflags "-static -fpic"
flags:
- -trimpath
goos:
- linux
goarch:
- s390x
main: ./cmd/dashboard
binary: dashboard-{{ .Os }}-{{ .Arch }}
- id: windows_amd64
env:
- CGO_ENABLED=1
- CC=x86_64-w64-mingw32-gcc
ldflags:
- -s -w
- -X github.com/naiba/nezha/service/singleton.Version={{.Version}}
- -extldflags "-static -fpic"
flags:
- -trimpath
goos:
- windows
goarch:
- amd64
main: ./cmd/dashboard
binary: dashboard-{{ .Os }}-{{ .Arch }}
snapshot:
version_template: "dashboard"
+7 -9
View File
@@ -1,16 +1,12 @@
# latest 镜像还没有 riscv64 https://hub.docker.com/_/alpine/tags FROM alpine AS certs
FROM alpine:edge RUN apk update && apk add ca-certificates
FROM busybox:stable-musl
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
RUN apk update && \ COPY --from=certs /etc/ssl/certs /etc/ssl/certs
apk upgrade --no-cache && \
apk add --no-cache tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo 'Asia/Shanghai' >/etc/timezone && \
rm -rf /var/cache/apk/*
COPY ./script/entrypoint.sh /entrypoint.sh COPY ./script/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
@@ -19,4 +15,6 @@ COPY dist/dashboard-${TARGETOS}-${TARGETARCH} ./app
VOLUME ["/dashboard/data"] VOLUME ["/dashboard/data"]
EXPOSE 80 5555 EXPOSE 80 5555
ARG TZ=Asia/Shanghai
ENV TZ=$TZ
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+4 -3
View File
@@ -4,7 +4,7 @@
<br> <br>
<small><i>LOGO designed by <a href="https://xio.ng" target="_blank">熊大</a> .</i></small> <small><i>LOGO designed by <a href="https://xio.ng" target="_blank">熊大</a> .</i></small>
<br><br> <br><br>
<img alt="GitHub release (with filter)" src="https://img.shields.io/github/v/release/naiba/nezha?color=brightgreen&style=for-the-badge&logo=github&label=Dashboard">&nbsp;<img src="https://img.shields.io/github/v/release/nezhahq/agent?color=brightgreen&label=Agent&style=for-the-badge&logo=github">&nbsp;<img src="https://img.shields.io/github/actions/workflow/status/nezhahq/agent/agent.yml?label=Agent%20CI&logo=github&style=for-the-badge">&nbsp;<img src="https://img.shields.io/badge/Installer-v0.18.3-brightgreen?style=for-the-badge&logo=linux"> <img alt="GitHub release (with filter)" src="https://img.shields.io/github/v/release/naiba/nezha?color=brightgreen&style=for-the-badge&logo=github&label=Dashboard">&nbsp;<img src="https://img.shields.io/github/v/release/nezhahq/agent?color=brightgreen&label=Agent&style=for-the-badge&logo=github">&nbsp;<img src="https://img.shields.io/github/actions/workflow/status/nezhahq/agent/agent.yml?label=Agent%20CI&logo=github&style=for-the-badge">&nbsp;<img src="https://img.shields.io/badge/Installer-v0.19.0-brightgreen?style=for-the-badge&logo=linux">
<br> <br>
<br> <br>
<p>:trollface: <b>Nezha Monitoring: Self-hostable, lightweight, servers and websites monitoring and O&M tool.</b></p> <p>:trollface: <b>Nezha Monitoring: Self-hostable, lightweight, servers and websites monitoring and O&M tool.</b></p>
@@ -44,8 +44,8 @@ You can change the dashboard language in the settings page (`/setting`) after th
<a href="https://github.com/AkkiaS7" title="Akkia"><img src="https://avatars.githubusercontent.com/u/68485070?v=4" width="50;" alt="Akkia"/></a> <a href="https://github.com/AkkiaS7" title="Akkia"><img src="https://avatars.githubusercontent.com/u/68485070?v=4" width="50;" alt="Akkia"/></a>
<a href="https://github.com/Erope" title="卖女孩的小火柴"><img src="https://avatars.githubusercontent.com/u/44471469?v=4" width="50;" alt="卖女孩的小火柴"/></a> <a href="https://github.com/Erope" title="卖女孩的小火柴"><img src="https://avatars.githubusercontent.com/u/44471469?v=4" width="50;" alt="卖女孩的小火柴"/></a>
<a href="https://github.com/uubulb" title="UUBulb"><img src="https://avatars.githubusercontent.com/u/35923940?v=4" width="50;" alt="UUBulb"/></a> <a href="https://github.com/uubulb" title="UUBulb"><img src="https://avatars.githubusercontent.com/u/35923940?v=4" width="50;" alt="UUBulb"/></a>
<a href="https://github.com/dysf888" title="黑歌"><img src="https://avatars.githubusercontent.com/u/47450409?v=4" width="50;" alt="黑歌"/></a>
<a href="https://github.com/nap0o" title="nap0o"><img src="https://avatars.githubusercontent.com/u/144927971?v=4" width="50;" alt="nap0o"/></a> <a href="https://github.com/nap0o" title="nap0o"><img src="https://avatars.githubusercontent.com/u/144927971?v=4" width="50;" alt="nap0o"/></a>
<a href="https://github.com/dysf888" title="黑歌"><img src="https://avatars.githubusercontent.com/u/47450409?v=4" width="50;" alt="黑歌"/></a>
<a href="https://github.com/xykt" title="xykt"><img src="https://avatars.githubusercontent.com/u/152045469?v=4" width="50;" alt="xykt"/></a> <a href="https://github.com/xykt" title="xykt"><img src="https://avatars.githubusercontent.com/u/152045469?v=4" width="50;" alt="xykt"/></a>
<a href="https://github.com/MikoyChinese" title="MikoyChinese"><img src="https://avatars.githubusercontent.com/u/22676744?v=4" width="50;" alt="MikoyChinese"/></a> <a href="https://github.com/MikoyChinese" title="MikoyChinese"><img src="https://avatars.githubusercontent.com/u/22676744?v=4" width="50;" alt="MikoyChinese"/></a>
<a href="https://github.com/JackieSung4ev" title="JackieSung4ev"><img src="https://avatars.githubusercontent.com/u/24974735?v=4" width="50;" alt="JackieSung4ev"/></a> <a href="https://github.com/JackieSung4ev" title="JackieSung4ev"><img src="https://avatars.githubusercontent.com/u/24974735?v=4" width="50;" alt="JackieSung4ev"/></a>
@@ -72,6 +72,7 @@ You can change the dashboard language in the settings page (`/setting`) after th
<a href="https://github.com/acgpiano" title="Acgpiano"><img src="https://avatars.githubusercontent.com/u/15900800?v=4" width="50;" alt="Acgpiano"/></a> <a href="https://github.com/acgpiano" title="Acgpiano"><img src="https://avatars.githubusercontent.com/u/15900800?v=4" width="50;" alt="Acgpiano"/></a>
<a href="https://github.com/eya46" title="eya46"><img src="https://avatars.githubusercontent.com/u/61458340?v=4" width="50;" alt="eya46"/></a> <a href="https://github.com/eya46" title="eya46"><img src="https://avatars.githubusercontent.com/u/61458340?v=4" width="50;" alt="eya46"/></a>
<a href="https://github.com/guoyongchang" title="guoyongchang"><img src="https://avatars.githubusercontent.com/u/10484506?v=4" width="50;" alt="guoyongchang"/></a> <a href="https://github.com/guoyongchang" title="guoyongchang"><img src="https://avatars.githubusercontent.com/u/10484506?v=4" width="50;" alt="guoyongchang"/></a>
<a href="https://github.com/hiDandelion" title="hiDandelion"><img src="https://avatars.githubusercontent.com/u/77157418?v=4" width="50;" alt="hiDandelion"/></a>
<a href="https://github.com/yuanweize" title="I"><img src="https://avatars.githubusercontent.com/u/30067203?v=4" width="50;" alt="I"/></a> <a href="https://github.com/yuanweize" title="I"><img src="https://avatars.githubusercontent.com/u/30067203?v=4" width="50;" alt="I"/></a>
<a href="https://github.com/lvyaoting" title="lvyaoting"><img src="https://avatars.githubusercontent.com/u/166296299?v=4" width="50;" alt="lvyaoting"/></a> <a href="https://github.com/lvyaoting" title="lvyaoting"><img src="https://avatars.githubusercontent.com/u/166296299?v=4" width="50;" alt="lvyaoting"/></a>
<a href="https://github.com/unclezs" title="unclezs"><img src="https://avatars.githubusercontent.com/u/42318775?v=4" width="50;" alt="unclezs"/></a> <a href="https://github.com/unclezs" title="unclezs"><img src="https://avatars.githubusercontent.com/u/42318775?v=4" width="50;" alt="unclezs"/></a>
@@ -90,7 +91,7 @@ You can change the dashboard language in the settings page (`/setting`) after th
<a href="https://github.com/techotaku" title="Ian Li"><img src="https://avatars.githubusercontent.com/u/1948179?v=4" width="50;" alt="Ian Li"/></a> <a href="https://github.com/techotaku" title="Ian Li"><img src="https://avatars.githubusercontent.com/u/1948179?v=4" width="50;" alt="Ian Li"/></a>
<a href="https://github.com/GreenTeodoro839" title="GreenTeodoro839"><img src="https://avatars.githubusercontent.com/u/77104800?v=4" width="50;" alt="GreenTeodoro839"/></a> <a href="https://github.com/GreenTeodoro839" title="GreenTeodoro839"><img src="https://avatars.githubusercontent.com/u/77104800?v=4" width="50;" alt="GreenTeodoro839"/></a>
<a href="https://github.com/Es-dese" title="Esdese"><img src="https://avatars.githubusercontent.com/u/71542548?v=4" width="50;" alt="Esdese"/></a> <a href="https://github.com/Es-dese" title="Esdese"><img src="https://avatars.githubusercontent.com/u/71542548?v=4" width="50;" alt="Esdese"/></a>
<a href="https://github.com/wwng2333" title="Crazy"><img src="https://avatars.githubusercontent.com/u/17147265?v=4" width="50;" alt="Crazy"/></a> <a href="https://github.com/wwng2333" title=":D"><img src="https://avatars.githubusercontent.com/u/17147265?v=4" width="50;" alt=":D"/></a>
<a href="https://github.com/wellcoming" title="Coming"><img src="https://avatars.githubusercontent.com/u/74850890?v=4" width="50;" alt="Coming"/></a><!--GAMFC_DELIMITER_END--> <a href="https://github.com/wellcoming" title="Coming"><img src="https://avatars.githubusercontent.com/u/74850890?v=4" width="50;" alt="Coming"/></a><!--GAMFC_DELIMITER_END-->
## Special Thanks ## Special Thanks
+140 -17
View File
@@ -46,6 +46,8 @@ func (cp *commonPage) serve() {
cr.GET("/network", cp.network) cr.GET("/network", cp.network)
cr.GET("/ws", cp.ws) cr.GET("/ws", cp.ws)
cr.POST("/terminal", cp.createTerminal) cr.POST("/terminal", cp.createTerminal)
cr.GET("/file", cp.createFM)
cr.GET("/file/:id", cp.fm)
} }
type viewPasswordForm struct { type viewPasswordForm struct {
@@ -99,7 +101,6 @@ func (p *commonPage) service(c *gin.Context) {
"Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "ServicesStatus"}), "Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "ServicesStatus"}),
"Services": res.([]interface{})[0], "Services": res.([]interface{})[0],
"CycleTransferStats": res.([]interface{})[1], "CycleTransferStats": res.([]interface{})[1],
"CustomCode": singleton.Conf.Site.CustomCode,
})) }))
} }
@@ -204,12 +205,11 @@ func (cp *commonPage) network(c *gin.Context) {
c.HTML(http.StatusOK, mygin.GetPreferredTheme(c, "/network"), mygin.CommonEnvironment(c, gin.H{ c.HTML(http.StatusOK, mygin.GetPreferredTheme(c, "/network"), mygin.CommonEnvironment(c, gin.H{
"Servers": string(serversBytes), "Servers": string(serversBytes),
"MonitorInfos": string(monitorInfos), "MonitorInfos": string(monitorInfos),
"CustomCode": singleton.Conf.Site.CustomCode,
"MaxTCPPingValue": singleton.Conf.MaxTCPPingValue, "MaxTCPPingValue": singleton.Conf.MaxTCPPingValue,
})) }))
} }
func (cp *commonPage) getServerStat(c *gin.Context) ([]byte, error) { func (cp *commonPage) getServerStat(c *gin.Context, withPublicNote bool) ([]byte, error) {
_, isMember := c.Get(model.CtxKeyAuthorizedUser) _, isMember := c.Get(model.CtxKeyAuthorizedUser)
_, isViewPasswordVerfied := c.Get(model.CtxKeyViewPasswordVerified) _, isViewPasswordVerfied := c.Get(model.CtxKeyViewPasswordVerified)
authorized := isMember || isViewPasswordVerfied authorized := isMember || isViewPasswordVerfied
@@ -219,16 +219,15 @@ func (cp *commonPage) getServerStat(c *gin.Context) ([]byte, error) {
var servers []*model.Server var servers []*model.Server
if authorized { for _, server := range singleton.SortedServerListForGuest {
servers = singleton.SortedServerList item := *server
} else { if item.HideForGuest && !authorized {
filteredServers := make([]*model.Server, len(singleton.SortedServerListForGuest)) continue
for i, server := range singleton.SortedServerListForGuest {
filteredServer := *server
filteredServer.DDNSDomain = "redacted"
filteredServers[i] = &filteredServer
} }
servers = filteredServers if !withPublicNote {
item.PublicNote = ""
}
servers = append(servers, &item)
} }
return utils.Json.Marshal(Data{ return utils.Json.Marshal(Data{
@@ -240,7 +239,7 @@ func (cp *commonPage) getServerStat(c *gin.Context) ([]byte, error) {
} }
func (cp *commonPage) home(c *gin.Context) { func (cp *commonPage) home(c *gin.Context) {
stat, err := cp.getServerStat(c) stat, err := cp.getServerStat(c, true)
if err != nil { if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{ mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusInternalServerError, Code: http.StatusInternalServerError,
@@ -255,13 +254,12 @@ func (cp *commonPage) home(c *gin.Context) {
} }
c.HTML(http.StatusOK, mygin.GetPreferredTheme(c, "/home"), mygin.CommonEnvironment(c, gin.H{ c.HTML(http.StatusOK, mygin.GetPreferredTheme(c, "/home"), mygin.CommonEnvironment(c, gin.H{
"Servers": string(stat), "Servers": string(stat),
"CustomCode": singleton.Conf.Site.CustomCode,
})) }))
} }
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
ReadBufferSize: 10240, ReadBufferSize: 32768,
WriteBufferSize: 10240, WriteBufferSize: 32768,
} }
type Data struct { type Data struct {
@@ -286,7 +284,7 @@ func (cp *commonPage) ws(c *gin.Context) {
defer conn.Close() defer conn.Close()
count := 0 count := 0
for { for {
stat, err := cp.getServerStat(c) stat, err := cp.getServerStat(c, false)
if err != nil { if err != nil {
continue continue
} }
@@ -430,5 +428,130 @@ func (cp *commonPage) createTerminal(c *gin.Context) {
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/terminal", mygin.CommonEnvironment(c, gin.H{ c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/terminal", mygin.CommonEnvironment(c, gin.H{
"SessionID": streamId, "SessionID": streamId,
"ServerName": server.Name, "ServerName": server.Name,
"ServerID": server.ID,
}))
}
func (cp *commonPage) fm(c *gin.Context) {
streamId := c.Param("id")
if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "无权访问",
Msg: "FM会话不存在",
Link: "/",
Btn: "返回首页",
}, true)
return
}
defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusInternalServerError,
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
MessageID: "NetworkError",
}),
Msg: "Websocket协议切换失败",
Link: "/",
Btn: "返回首页",
}, true)
return
}
defer wsConn.Close()
conn := websocketx.NewConn(wsConn)
go func() {
// PING 保活
for {
if err = conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
return
}
time.Sleep(time.Second * 10)
}
}()
if err = rpc.NezhaHandlerSingleton.UserConnected(streamId, conn); err != nil {
return
}
rpc.NezhaHandlerSingleton.StartStream(streamId, time.Second*10)
}
func (cp *commonPage) createFM(c *gin.Context) {
IdString := c.Query("id")
if _, authorized := c.Get(model.CtxKeyAuthorizedUser); !authorized {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "无权访问",
Msg: "用户未登录",
Link: "/login",
Btn: "去登录",
}, true)
return
}
streamId, err := uuid.GenerateUUID()
if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusInternalServerError,
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
MessageID: "SystemError",
}),
Msg: "生成会话ID失败",
Link: "/server",
Btn: "返回重试",
}, true)
return
}
rpc.NezhaHandlerSingleton.CreateStream(streamId)
serverId, err := strconv.Atoi(IdString)
if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "请求失败",
Msg: "请求参数有误:" + err.Error(),
Link: "/server",
Btn: "返回重试",
}, true)
return
}
singleton.ServerLock.RLock()
server := singleton.ServerList[uint64(serverId)]
singleton.ServerLock.RUnlock()
if server == nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "请求失败",
Msg: "服务器不存在或处于离线状态",
Link: "/server",
Btn: "返回重试",
}, true)
return
}
fmData, _ := utils.Json.Marshal(&model.TaskFM{
StreamID: streamId,
})
if err := server.TaskStream.Send(&proto.Task{
Type: model.TaskTypeFM,
Data: string(fmData),
}); err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "请求失败",
Msg: "Agent信令下发失败",
Link: "/server",
Btn: "返回重试",
}, true)
return
}
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/file", mygin.CommonEnvironment(c, gin.H{
"SessionID": streamId,
})) }))
} }
+8 -2
View File
@@ -1,7 +1,6 @@
package controller package controller
import ( import (
"encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"io/fs" "io/fs"
@@ -35,6 +34,12 @@ func ServeWeb(port uint) *http.Server {
pprof.Register(r) pprof.Register(r)
} }
r.Use(natGateway) r.Use(natGateway)
if os.Getenv("NZ_LOCAL_TEMPLATE") == "true" {
r.SetFuncMap(funcMap)
r.Use(mygin.RecordPath)
r.Static("/static", "resource/static")
r.LoadHTMLGlob("resource/template/**/*.html")
} else {
tmpl := template.New("").Funcs(funcMap) tmpl := template.New("").Funcs(funcMap)
var err error var err error
tmpl, err = tmpl.ParseFS(resource.TemplateFS, "template/**/*.html") tmpl, err = tmpl.ParseFS(resource.TemplateFS, "template/**/*.html")
@@ -49,6 +54,7 @@ func ServeWeb(port uint) *http.Server {
panic(err) panic(err)
} }
r.StaticFS("/static", http.FS(staticFs)) r.StaticFS("/static", http.FS(staticFs))
}
r.Static("/static-custom", "resource/static/custom") r.Static("/static-custom", "resource/static/custom")
routers(r) routers(r)
page404 := func(c *gin.Context) { page404 := func(c *gin.Context) {
@@ -270,7 +276,7 @@ func natGateway(c *gin.Context) {
rpc.NezhaHandlerSingleton.CreateStream(streamId) rpc.NezhaHandlerSingleton.CreateStream(streamId)
defer rpc.NezhaHandlerSingleton.CloseStream(streamId) defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
taskData, err := json.Marshal(model.TaskNAT{ taskData, err := utils.Json.Marshal(model.TaskNAT{
StreamID: streamId, StreamID: streamId,
Host: natConfig.Host, Host: natConfig.Host,
}) })
+6
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -305,6 +306,7 @@ type serverForm struct {
Secret string Secret string
Tag string Tag string
Note string Note string
PublicNote string
HideForGuest string HideForGuest string
EnableDDNS string EnableDDNS string
EnableIPv4 string EnableIPv4 string
@@ -325,6 +327,7 @@ func (ma *memberAPI) addOrEditServer(c *gin.Context) {
s.ID = sf.ID s.ID = sf.ID
s.Tag = sf.Tag s.Tag = sf.Tag
s.Note = sf.Note s.Note = sf.Note
s.PublicNote = sf.PublicNote
s.HideForGuest = sf.HideForGuest == "on" s.HideForGuest = sf.HideForGuest == "on"
s.EnableDDNS = sf.EnableDDNS == "on" s.EnableDDNS = sf.EnableDDNS == "on"
s.EnableIPv4 = sf.EnableIPv4 == "on" s.EnableIPv4 = sf.EnableIPv4 == "on"
@@ -384,6 +387,7 @@ func (ma *memberAPI) addOrEditServer(c *gin.Context) {
} else { } else {
s.Host = &model.Host{} s.Host = &model.Host{}
s.State = &model.HostState{} s.State = &model.HostState{}
s.TaskCloseLock = new(sync.Mutex)
singleton.ServerLock.Lock() singleton.ServerLock.Lock()
singleton.SecretToID[s.Secret] = s.ID singleton.SecretToID[s.Secret] = s.ID
singleton.ServerList[s.ID] = &s singleton.ServerList[s.ID] = &s
@@ -906,6 +910,7 @@ type settingForm struct {
Theme string Theme string
DashboardTheme string DashboardTheme string
CustomCode string CustomCode string
CustomCodeDashboard string
ViewPassword string ViewPassword string
IgnoredIPNotification string IgnoredIPNotification string
IPChangeNotificationTag string // IP变更提醒的通知组 IPChangeNotificationTag string // IP变更提醒的通知组
@@ -971,6 +976,7 @@ func (ma *memberAPI) updateSetting(c *gin.Context) {
singleton.Conf.Site.Theme = sf.Theme singleton.Conf.Site.Theme = sf.Theme
singleton.Conf.Site.DashboardTheme = sf.DashboardTheme singleton.Conf.Site.DashboardTheme = sf.DashboardTheme
singleton.Conf.Site.CustomCode = sf.CustomCode singleton.Conf.Site.CustomCode = sf.CustomCode
singleton.Conf.Site.CustomCodeDashboard = sf.CustomCodeDashboard
singleton.Conf.Site.ViewPassword = sf.ViewPassword singleton.Conf.Site.ViewPassword = sf.ViewPassword
singleton.Conf.Oauth2.Admin = sf.Admin singleton.Conf.Oauth2.Admin = sf.Admin
// 保证NotificationTag不为空 // 保证NotificationTag不为空
+1 -2
View File
@@ -2,7 +2,6 @@ package controller
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -199,7 +198,7 @@ func (oa *oauth2controller) callback(c *gin.Context) {
if err == nil { if err == nil {
defer resp.Body.Close() defer resp.Body.Close()
var cloudflareUserInfo *cloudflare.UserInfo var cloudflareUserInfo *cloudflare.UserInfo
if err := json.NewDecoder(resp.Body).Decode(&cloudflareUserInfo); err == nil { if err := utils.Json.NewDecoder(resp.Body).Decode(&cloudflareUserInfo); err == nil {
user = cloudflareUserInfo.MapToNezhaUser() user = cloudflareUserInfo.MapToNezhaUser()
} }
} }
+1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log" "log"
"time" "time"
_ "time/tzdata"
"github.com/naiba/nezha/cmd/dashboard/controller" "github.com/naiba/nezha/cmd/dashboard/controller"
"github.com/naiba/nezha/cmd/dashboard/rpc" "github.com/naiba/nezha/cmd/dashboard/rpc"
+1
View File
@@ -85,6 +85,7 @@ type Config struct {
Theme string Theme string
DashboardTheme string DashboardTheme string
CustomCode string CustomCode string
CustomCodeDashboard string
ViewPassword string // 前台查看密码 ViewPassword string // 前台查看密码
} }
Oauth2 struct { Oauth2 struct {
+5
View File
@@ -23,6 +23,7 @@ const (
TaskTypeTerminalGRPC TaskTypeTerminalGRPC
TaskTypeNAT TaskTypeNAT
TaskTypeReportHostInfo TaskTypeReportHostInfo
TaskTypeFM
) )
type TerminalTask struct { type TerminalTask struct {
@@ -34,6 +35,10 @@ type TaskNAT struct {
Host string Host string
} }
type TaskFM struct {
StreamID string
}
const ( const (
MonitorCoverAll = iota MonitorCoverAll = iota
MonitorCoverIgnoreAll MonitorCoverIgnoreAll
+9
View File
@@ -170,14 +170,23 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
if ns.Server != nil { if ns.Server != nil {
str = strings.ReplaceAll(str, "#SERVER.NAME#", mod(ns.Server.Name)) str = strings.ReplaceAll(str, "#SERVER.NAME#", mod(ns.Server.Name))
str = strings.ReplaceAll(str, "#SERVER.ID#", mod(fmt.Sprintf("%d", ns.Server.ID)))
str = strings.ReplaceAll(str, "#SERVER.CPU#", mod(fmt.Sprintf("%f", ns.Server.State.CPU))) str = strings.ReplaceAll(str, "#SERVER.CPU#", mod(fmt.Sprintf("%f", ns.Server.State.CPU)))
str = strings.ReplaceAll(str, "#SERVER.MEM#", mod(fmt.Sprintf("%d", ns.Server.State.MemUsed))) str = strings.ReplaceAll(str, "#SERVER.MEM#", mod(fmt.Sprintf("%d", ns.Server.State.MemUsed)))
str = strings.ReplaceAll(str, "#SERVER.SWAP#", mod(fmt.Sprintf("%d", ns.Server.State.SwapUsed))) str = strings.ReplaceAll(str, "#SERVER.SWAP#", mod(fmt.Sprintf("%d", ns.Server.State.SwapUsed)))
str = strings.ReplaceAll(str, "#SERVER.DISK#", mod(fmt.Sprintf("%d", ns.Server.State.DiskUsed))) str = strings.ReplaceAll(str, "#SERVER.DISK#", mod(fmt.Sprintf("%d", ns.Server.State.DiskUsed)))
str = strings.ReplaceAll(str, "#SERVER.MEMUSED#", mod(fmt.Sprintf("%d", ns.Server.State.MemUsed)))
str = strings.ReplaceAll(str, "#SERVER.SWAPUSED#", mod(fmt.Sprintf("%d", ns.Server.State.SwapUsed)))
str = strings.ReplaceAll(str, "#SERVER.DISKUSED#", mod(fmt.Sprintf("%d", ns.Server.State.DiskUsed)))
str = strings.ReplaceAll(str, "#SERVER.MEMTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.MemTotal)))
str = strings.ReplaceAll(str, "#SERVER.SWAPTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.SwapTotal)))
str = strings.ReplaceAll(str, "#SERVER.DISKTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.DiskTotal)))
str = strings.ReplaceAll(str, "#SERVER.NETINSPEED#", mod(fmt.Sprintf("%d", ns.Server.State.NetInSpeed))) str = strings.ReplaceAll(str, "#SERVER.NETINSPEED#", mod(fmt.Sprintf("%d", ns.Server.State.NetInSpeed)))
str = strings.ReplaceAll(str, "#SERVER.NETOUTSPEED#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutSpeed))) str = strings.ReplaceAll(str, "#SERVER.NETOUTSPEED#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutSpeed)))
str = strings.ReplaceAll(str, "#SERVER.TRANSFERIN#", mod(fmt.Sprintf("%d", ns.Server.State.NetInTransfer))) str = strings.ReplaceAll(str, "#SERVER.TRANSFERIN#", mod(fmt.Sprintf("%d", ns.Server.State.NetInTransfer)))
str = strings.ReplaceAll(str, "#SERVER.TRANSFEROUT#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutTransfer))) str = strings.ReplaceAll(str, "#SERVER.TRANSFEROUT#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutTransfer)))
str = strings.ReplaceAll(str, "#SERVER.NETINTRANSFER#", mod(fmt.Sprintf("%d", ns.Server.State.NetInTransfer)))
str = strings.ReplaceAll(str, "#SERVER.NETOUTTRANSFER#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutTransfer)))
str = strings.ReplaceAll(str, "#SERVER.LOAD1#", mod(fmt.Sprintf("%f", ns.Server.State.Load1))) str = strings.ReplaceAll(str, "#SERVER.LOAD1#", mod(fmt.Sprintf("%f", ns.Server.State.Load1)))
str = strings.ReplaceAll(str, "#SERVER.LOAD5#", mod(fmt.Sprintf("%f", ns.Server.State.Load5))) str = strings.ReplaceAll(str, "#SERVER.LOAD5#", mod(fmt.Sprintf("%f", ns.Server.State.Load5)))
str = strings.ReplaceAll(str, "#SERVER.LOAD15#", mod(fmt.Sprintf("%f", ns.Server.State.Load15))) str = strings.ReplaceAll(str, "#SERVER.LOAD15#", mod(fmt.Sprintf("%f", ns.Server.State.Load15)))
+2 -2
View File
@@ -75,8 +75,8 @@ func execCase(t *testing.T, item testSt) {
LastActive: time.Time{}, LastActive: time.Time{},
TaskClose: nil, TaskClose: nil,
TaskStream: nil, TaskStream: nil,
PrevHourlyTransferIn: 0, PrevTransferInSnapshot: 0,
PrevHourlyTransferOut: 0, PrevTransferOutSnapshot: 0,
} }
ns := NotificationServerBundle{ ns := NotificationServerBundle{
Notification: &n, Notification: &n,
+7 -17
View File
@@ -1,10 +1,13 @@
package model package model
import ( import (
"slices"
"strings" "strings"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/naiba/nezha/pkg/utils"
) )
const ( const (
@@ -42,19 +45,6 @@ func percentage(used, total uint64) float64 {
return float64(used) * 100 / float64(total) return float64(used) * 100 / float64(total)
} }
func maxSliceValue(slice []float64) float64 {
if len(slice) != 0 {
max := slice[0]
for _, val := range slice {
if max < val {
max = val
}
}
return max
}
return 0
}
// Snapshot 未通过规则返回 struct{}{}, 通过返回 nil // Snapshot 未通过规则返回 struct{}{}, 通过返回 nil
func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) interface{} { func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) interface{} {
// 监控全部但是排除了此服务器 // 监控全部但是排除了此服务器
@@ -103,21 +93,21 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
src = float64(server.LastActive.Unix()) src = float64(server.LastActive.Unix())
} }
case "transfer_in_cycle": case "transfer_in_cycle":
src = float64(server.State.NetInTransfer - uint64(server.PrevHourlyTransferIn)) src = float64(utils.Uint64SubInt64(server.State.NetInTransfer, server.PrevTransferInSnapshot))
if u.CycleInterval != 0 { if u.CycleInterval != 0 {
var res NResult var res NResult
db.Model(&Transfer{}).Select("SUM(`in`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res) db.Model(&Transfer{}).Select("SUM(`in`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
src += float64(res.N) src += float64(res.N)
} }
case "transfer_out_cycle": case "transfer_out_cycle":
src = float64(server.State.NetOutTransfer - uint64(server.PrevHourlyTransferOut)) src = float64(utils.Uint64SubInt64(server.State.NetOutTransfer, server.PrevTransferOutSnapshot))
if u.CycleInterval != 0 { if u.CycleInterval != 0 {
var res NResult var res NResult
db.Model(&Transfer{}).Select("SUM(`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res) db.Model(&Transfer{}).Select("SUM(`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
src += float64(res.N) src += float64(res.N)
} }
case "transfer_all_cycle": case "transfer_all_cycle":
src = float64(server.State.NetOutTransfer - uint64(server.PrevHourlyTransferOut) + server.State.NetInTransfer - uint64(server.PrevHourlyTransferIn)) src = float64(utils.Uint64SubInt64(server.State.NetOutTransfer, server.PrevTransferOutSnapshot) + utils.Uint64SubInt64(server.State.NetInTransfer, server.PrevTransferInSnapshot))
if u.CycleInterval != 0 { if u.CycleInterval != 0 {
var res NResult var res NResult
db.Model(&Transfer{}).Select("SUM(`in`+`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res) db.Model(&Transfer{}).Select("SUM(`in`+`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
@@ -143,7 +133,7 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
temp = append(temp, tempStat.Temperature) temp = append(temp, tempStat.Temperature)
} }
} }
src = maxSliceValue(temp) src = slices.Max(temp)
} }
} }
+11 -6
View File
@@ -3,6 +3,7 @@ package model
import ( import (
"fmt" "fmt"
"html/template" "html/template"
"sync"
"time" "time"
"github.com/naiba/nezha/pkg/utils" "github.com/naiba/nezha/pkg/utils"
@@ -15,6 +16,7 @@ type Server struct {
Tag string // 分组名 Tag string // 分组名
Secret string `gorm:"uniqueIndex" json:"-"` Secret string `gorm:"uniqueIndex" json:"-"`
Note string `json:"-"` // 管理员可见备注 Note string `json:"-"` // 管理员可见备注
PublicNote string `json:"PublicNote,omitempty"` // 公开备注
DisplayIndex int // 展示排序,越大越靠前 DisplayIndex int // 展示排序,越大越靠前
HideForGuest bool // 对游客隐藏 HideForGuest bool // 对游客隐藏
EnableDDNS bool `json:"-"` // 是否启用DDNS 未在配置文件中启用DDNS 或 DDNS检查时间为0时此项无效 EnableDDNS bool `json:"-"` // 是否启用DDNS 未在配置文件中启用DDNS 或 DDNS检查时间为0时此项无效
@@ -28,10 +30,11 @@ type Server struct {
LastActive time.Time `gorm:"-"` LastActive time.Time `gorm:"-"`
TaskClose chan error `gorm:"-" json:"-"` TaskClose chan error `gorm:"-" json:"-"`
TaskCloseLock *sync.Mutex `gorm:"-" json:"-"`
TaskStream pb.NezhaService_RequestTaskServer `gorm:"-" json:"-"` TaskStream pb.NezhaService_RequestTaskServer `gorm:"-" json:"-"`
PrevHourlyTransferIn int64 `gorm:"-" json:"-"` // 上次数据点时的入站使用量 PrevTransferInSnapshot int64 `gorm:"-" json:"-"` // 上次数据点时的入站使用量
PrevHourlyTransferOut int64 `gorm:"-" json:"-"` // 上次数据点时的出站使用量 PrevTransferOutSnapshot int64 `gorm:"-" json:"-"` // 上次数据点时的出站使用量
} }
func (s *Server) CopyFromRunningServer(old *Server) { func (s *Server) CopyFromRunningServer(old *Server) {
@@ -39,9 +42,10 @@ func (s *Server) CopyFromRunningServer(old *Server) {
s.State = old.State s.State = old.State
s.LastActive = old.LastActive s.LastActive = old.LastActive
s.TaskClose = old.TaskClose s.TaskClose = old.TaskClose
s.TaskCloseLock = old.TaskCloseLock
s.TaskStream = old.TaskStream s.TaskStream = old.TaskStream
s.PrevHourlyTransferIn = old.PrevHourlyTransferIn s.PrevTransferInSnapshot = old.PrevTransferInSnapshot
s.PrevHourlyTransferOut = old.PrevHourlyTransferOut s.PrevTransferOutSnapshot = old.PrevTransferOutSnapshot
} }
func boolToString(b bool) string { func boolToString(b bool) string {
@@ -51,12 +55,13 @@ func boolToString(b bool) string {
return "false" return "false"
} }
func (s Server) Marshal() template.JS { func (s Server) MarshalForDashboard() template.JS {
name, _ := utils.Json.Marshal(s.Name) name, _ := utils.Json.Marshal(s.Name)
tag, _ := utils.Json.Marshal(s.Tag) tag, _ := utils.Json.Marshal(s.Tag)
note, _ := utils.Json.Marshal(s.Note) note, _ := utils.Json.Marshal(s.Note)
secret, _ := utils.Json.Marshal(s.Secret) secret, _ := utils.Json.Marshal(s.Secret)
ddnsDomain, _ := utils.Json.Marshal(s.DDNSDomain) ddnsDomain, _ := utils.Json.Marshal(s.DDNSDomain)
ddnsProfile, _ := utils.Json.Marshal(s.DDNSProfile) ddnsProfile, _ := utils.Json.Marshal(s.DDNSProfile)
return template.JS(fmt.Sprintf(`{"ID":%d,"Name":%s,"Secret":%s,"DisplayIndex":%d,"Tag":%s,"Note":%s,"HideForGuest": %s,"EnableDDNS": %s,"EnableIPv4": %s,"EnableIpv6": %s,"DDNSDomain": %s,"DDNSProfile": %s}`, s.ID, name, secret, s.DisplayIndex, tag, note, boolToString(s.HideForGuest), boolToString(s.EnableDDNS), boolToString(s.EnableIPv4), boolToString(s.EnableIpv6), ddnsDomain, ddnsProfile)) // #nosec publicNote, _ := utils.Json.Marshal(s.PublicNote)
return template.JS(fmt.Sprintf(`{"ID":%d,"Name":%s,"Secret":%s,"DisplayIndex":%d,"Tag":%s,"Note":%s,"HideForGuest": %s,"EnableDDNS": %s,"EnableIPv4": %s,"EnableIpv6": %s,"DDNSDomain": %s,"DDNSProfile": %s,"PublicNote": %s}`, s.ID, name, secret, s.DisplayIndex, tag, note, boolToString(s.HideForGuest), boolToString(s.EnableDDNS), boolToString(s.EnableIPv4), boolToString(s.EnableIpv6), ddnsDomain, ddnsProfile, publicNote))
} }
+1 -1
View File
@@ -18,7 +18,7 @@ func TestServerMarshal(t *testing.T) {
Name: patterns[i], Name: patterns[i],
Tag: patterns[i], Tag: patterns[i],
} }
serverStr := string(server.Marshal()) serverStr := string(server.MarshalForDashboard())
var serverRestore Server var serverRestore Server
if utils.Json.Unmarshal([]byte(serverStr), &serverRestore) != nil { if utils.Json.Unmarshal([]byte(serverStr), &serverRestore) != nil {
t.Fatalf("Error: %s", serverStr) t.Fatalf("Error: %s", serverStr)
+131 -83
View File
@@ -2,169 +2,217 @@ package ddns
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"github.com/naiba/nezha/pkg/utils"
) )
const baseEndpoint = "https://api.cloudflare.com/client/v4/zones"
type ProviderCloudflare struct { type ProviderCloudflare struct {
Secret string secret string
zoneId string
recordId string
domainConfig *DomainConfig
} }
func (provider *ProviderCloudflare) UpdateDomain(domainConfig *DomainConfig) bool { type cfReq struct {
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
TTL uint32 `json:"ttl"`
Proxied bool `json:"proxied"`
}
type cfResp struct {
Result []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"result"`
}
func NewProviderCloudflare(s string) *ProviderCloudflare {
return &ProviderCloudflare{
secret: s,
}
}
func (provider *ProviderCloudflare) UpdateDomain(domainConfig *DomainConfig) error {
if domainConfig == nil { if domainConfig == nil {
return false return fmt.Errorf("获取 DDNS 配置失败")
} }
provider.domainConfig = domainConfig
zoneID, err := provider.getZoneID(domainConfig.FullDomain) err := provider.getZoneID()
if err != nil { if err != nil {
log.Printf("无法获取 zone ID: %s\n", err) return fmt.Errorf("无法获取 zone ID: %s", err)
return false
} }
// 当IPv4和IPv6同时成功才算作成功 // 当IPv4和IPv6同时成功才算作成功
var resultV4 = true if provider.domainConfig.EnableIPv4 {
var resultV6 = true if err = provider.addDomainRecord(true); err != nil {
if domainConfig.EnableIPv4 { return err
if !provider.addDomainRecord(zoneID, domainConfig, true) {
resultV4 = false
} }
} }
if domainConfig.EnableIpv6 { if provider.domainConfig.EnableIpv6 {
if !provider.addDomainRecord(zoneID, domainConfig, false) { if err = provider.addDomainRecord(false); err != nil {
resultV6 = false return err
} }
} }
return resultV4 && resultV6 return nil
} }
func (provider *ProviderCloudflare) addDomainRecord(zoneID string, domainConfig *DomainConfig, isIpv4 bool) bool { func (provider *ProviderCloudflare) addDomainRecord(isIpv4 bool) error {
record, err := provider.findDNSRecord(zoneID, domainConfig.FullDomain, isIpv4) err := provider.findDNSRecord(isIpv4)
if err != nil { if err != nil {
log.Printf("查找 DNS 记录时出错: %s\n", err) return fmt.Errorf("查找 DNS 记录时出错: %s", err)
return false
} }
if record == nil { if provider.recordId == "" {
// 添加 DNS 记录 // 添加 DNS 记录
return provider.createDNSRecord(zoneID, domainConfig, isIpv4) return provider.createDNSRecord(isIpv4)
} else { } else {
// 更新 DNS 记录 // 更新 DNS 记录
return provider.updateDNSRecord(zoneID, record["id"].(string), domainConfig, isIpv4) return provider.updateDNSRecord(isIpv4)
} }
} }
func (provider *ProviderCloudflare) getZoneID(domain string) (string, error) { func (provider *ProviderCloudflare) getZoneID() error {
_, realDomain := SplitDomain(domain) _, realDomain := splitDomain(provider.domainConfig.FullDomain)
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones?name=%s", realDomain) zu, _ := url.Parse(baseEndpoint)
body, err := provider.sendRequest("GET", url, nil)
q := zu.Query()
q.Set("name", realDomain)
zu.RawQuery = q.Encode()
body, err := provider.sendRequest("GET", zu.String(), nil)
if err != nil { if err != nil {
return "", err return err
} }
var res map[string]interface{} res := &cfResp{}
err = json.Unmarshal(body, &res) err = utils.Json.Unmarshal(body, res)
if err != nil { if err != nil {
return "", err return err
} }
result := res["result"].([]interface{}) result := res.Result
if len(result) > 0 { if len(result) > 0 {
zoneID := result[0].(map[string]interface{})["id"].(string) provider.zoneId = result[0].ID
return zoneID, nil return nil
} }
return "", fmt.Errorf("找不到 Zone ID") return fmt.Errorf("找不到 Zone ID")
} }
func (provider *ProviderCloudflare) findDNSRecord(zoneID string, domain string, isIPv4 bool) (map[string]interface{}, error) { func (provider *ProviderCloudflare) findDNSRecord(isIPv4 bool) error {
var ipType = "A" var ipType string
if !isIPv4 { if isIPv4 {
ipType = "A"
} else {
ipType = "AAAA" ipType = "AAAA"
} }
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, ipType, domain)
body, err := provider.sendRequest("GET", url, nil) de, _ := url.JoinPath(baseEndpoint, provider.zoneId, "dns_records")
du, _ := url.Parse(de)
q := du.Query()
q.Set("name", provider.domainConfig.FullDomain)
q.Set("type", ipType)
du.RawQuery = q.Encode()
body, err := provider.sendRequest("GET", du.String(), nil)
if err != nil { if err != nil {
return nil, err return err
} }
var res map[string]interface{} res := &cfResp{}
err = json.Unmarshal(body, &res) err = utils.Json.Unmarshal(body, res)
if err != nil { if err != nil {
return nil, err return err
} }
result := res["result"].([]interface{}) result := res.Result
if len(result) > 0 { if len(result) > 0 {
return result[0].(map[string]interface{}), nil provider.recordId = result[0].ID
return nil
} }
return nil, nil // 没有找到 DNS 记录 return nil
} }
func (provider *ProviderCloudflare) createDNSRecord(zoneID string, domainConfig *DomainConfig, isIPv4 bool) bool { func (provider *ProviderCloudflare) createDNSRecord(isIPv4 bool) error {
var ipType = "A" var ipType, ipAddr string
var ipAddr = domainConfig.Ipv4Addr if isIPv4 {
if !isIPv4 { ipType = "A"
ipAddr = provider.domainConfig.Ipv4Addr
} else {
ipType = "AAAA" ipType = "AAAA"
ipAddr = domainConfig.Ipv6Addr ipAddr = provider.domainConfig.Ipv6Addr
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID)
data := map[string]interface{}{
"type": ipType,
"name": domainConfig.FullDomain,
"content": ipAddr,
"ttl": 60,
"proxied": false,
}
jsonData, _ := json.Marshal(data)
_, err := provider.sendRequest("POST", url, jsonData)
return err == nil
} }
func (provider *ProviderCloudflare) updateDNSRecord(zoneID string, recordID string, domainConfig *DomainConfig, isIPv4 bool) bool { de, _ := url.JoinPath(baseEndpoint, provider.zoneId, "dns_records")
var ipType = "A" data := &cfReq{
var ipAddr = domainConfig.Ipv4Addr Name: provider.domainConfig.FullDomain,
if !isIPv4 { Type: ipType,
Content: ipAddr,
TTL: 60,
Proxied: false,
}
jsonData, _ := utils.Json.Marshal(data)
_, err := provider.sendRequest("POST", de, jsonData)
return err
}
func (provider *ProviderCloudflare) updateDNSRecord(isIPv4 bool) error {
var ipType, ipAddr string
if isIPv4 {
ipType = "A"
ipAddr = provider.domainConfig.Ipv4Addr
} else {
ipType = "AAAA" ipType = "AAAA"
ipAddr = domainConfig.Ipv6Addr ipAddr = provider.domainConfig.Ipv6Addr
} }
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID)
data := map[string]interface{}{ de, _ := url.JoinPath(baseEndpoint, provider.zoneId, "dns_records", provider.recordId)
"type": ipType, data := &cfReq{
"name": domainConfig.FullDomain, Name: provider.domainConfig.FullDomain,
"content": ipAddr, Type: ipType,
"ttl": 60, Content: ipAddr,
"proxied": false, TTL: 60,
Proxied: false,
} }
jsonData, _ := json.Marshal(data)
_, err := provider.sendRequest("PATCH", url, jsonData) jsonData, _ := utils.Json.Marshal(data)
return err == nil _, err := provider.sendRequest("PATCH", de, jsonData)
return err
} }
// 以下为辅助方法,如发送 HTTP 请求等 // 以下为辅助方法,如发送 HTTP 请求等
func (provider *ProviderCloudflare) sendRequest(method string, url string, data []byte) ([]byte, error) { func (provider *ProviderCloudflare) sendRequest(method string, url string, data []byte) ([]byte, error) {
client := &http.Client{}
req, err := http.NewRequest(method, url, bytes.NewBuffer(data)) req, err := http.NewRequest(method, url, bytes.NewBuffer(data))
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", provider.Secret)) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", provider.secret))
req.Header.Add("Content-Type", "application/json") req.Header.Add("Content-Type", "application/json")
resp, err := client.Do(req) resp, err := utils.HttpClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func(Body io.ReadCloser) { defer func(Body io.ReadCloser) {
err := Body.Close() err := Body.Close()
if err != nil { if err != nil {
log.Printf("NEZHA>> 无法关闭HTTP响应体流: %s\n", err.Error()) log.Printf("NEZHA>> 无法关闭HTTP响应体流: %s", err.Error())
} }
}(resp.Body) }(resp.Body)
+9 -1
View File
@@ -1,5 +1,7 @@
package ddns package ddns
import "golang.org/x/net/publicsuffix"
type DomainConfig struct { type DomainConfig struct {
EnableIPv4 bool EnableIPv4 bool
EnableIpv6 bool EnableIpv6 bool
@@ -10,5 +12,11 @@ type DomainConfig struct {
type Provider interface { type Provider interface {
// UpdateDomain Return is updated // UpdateDomain Return is updated
UpdateDomain(domainConfig *DomainConfig) bool UpdateDomain(*DomainConfig) error
}
func splitDomain(domain string) (prefix string, realDomain string) {
realDomain, _ = publicsuffix.EffectiveTLDPlusOne(domain)
prefix = domain[:len(domain)-len(realDomain)-1]
return prefix, realDomain
} }
+2 -2
View File
@@ -2,6 +2,6 @@ package ddns
type ProviderDummy struct{} type ProviderDummy struct{}
func (provider *ProviderDummy) UpdateDomain(domainConfig *DomainConfig) bool { func (provider *ProviderDummy) UpdateDomain(domainConfig *DomainConfig) error {
return false return nil
} }
-40
View File
@@ -1,40 +0,0 @@
package ddns
import (
"golang.org/x/net/publicsuffix"
"net/http"
"strings"
)
func (provider ProviderWebHook) FormatWebhookString(s string, config *DomainConfig, ipType string) string {
if config == nil {
return s
}
result := strings.TrimSpace(s)
result = strings.Replace(s, "{ip}", config.Ipv4Addr, -1)
result = strings.Replace(result, "{domain}", config.FullDomain, -1)
result = strings.Replace(result, "{type}", ipType, -1)
// remove \r
result = strings.Replace(result, "\r", "", -1)
return result
}
func SetStringHeadersToRequest(req *http.Request, headers []string) {
if req == nil {
return
}
for _, element := range headers {
kv := strings.SplitN(element, ":", 2)
if len(kv) == 2 {
req.Header.Add(kv[0], kv[1])
}
}
}
// SplitDomain 分割域名为前缀和一级域名
func SplitDomain(domain string) (prefix string, realDomain string) {
realDomain, _ = publicsuffix.EffectiveTLDPlusOne(domain)
prefix = domain[:len(domain)-len(realDomain)-1]
return prefix, realDomain
}
+119 -84
View File
@@ -5,145 +5,180 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/naiba/nezha/pkg/utils"
) )
const ( const te = "https://dnspod.tencentcloudapi.com"
url = "https://dnspod.tencentcloudapi.com"
)
type ProviderTencentCloud struct { type ProviderTencentCloud struct {
SecretID string secretID string
SecretKey string secretKey string
domainConfig *DomainConfig
resp *tcResp
} }
func (provider *ProviderTencentCloud) UpdateDomain(domainConfig *DomainConfig) bool { type tcReq struct {
if domainConfig == nil { RecordType string `json:"RecordType"`
return false Domain string `json:"Domain"`
RecordLine string `json:"RecordLine"`
Subdomain string `json:"Subdomain,omitempty"`
SubDomain string `json:"SubDomain,omitempty"` // As is
Value string `json:"Value,omitempty"`
TTL uint32 `json:"TTL,omitempty"`
RecordId uint64 `json:"RecordId,omitempty"`
} }
type tcResp struct {
Response struct {
RecordList []struct {
RecordId uint64
Value string
}
Error struct {
Code string
}
}
}
func NewProviderTencentCloud(id, key string) *ProviderTencentCloud {
return &ProviderTencentCloud{
secretID: id,
secretKey: key,
}
}
func (provider *ProviderTencentCloud) UpdateDomain(domainConfig *DomainConfig) error {
if domainConfig == nil {
return fmt.Errorf("获取 DDNS 配置失败")
}
provider.domainConfig = domainConfig
// 当IPv4和IPv6同时成功才算作成功 // 当IPv4和IPv6同时成功才算作成功
var resultV4 = true var err error
var resultV6 = true if provider.domainConfig.EnableIPv4 {
if domainConfig.EnableIPv4 { if err = provider.addDomainRecord(true); err != nil {
if !provider.addDomainRecord(domainConfig, true) { return err
resultV4 = false
} }
} }
if domainConfig.EnableIpv6 { if provider.domainConfig.EnableIpv6 {
if !provider.addDomainRecord(domainConfig, false) { if err = provider.addDomainRecord(false); err != nil {
resultV6 = false return err
} }
} }
return resultV4 && resultV6 return err
} }
func (provider *ProviderTencentCloud) addDomainRecord(domainConfig *DomainConfig, isIpv4 bool) bool { func (provider *ProviderTencentCloud) addDomainRecord(isIpv4 bool) error {
record, err := provider.findDNSRecord(domainConfig.FullDomain, isIpv4) err := provider.findDNSRecord(isIpv4)
if err != nil { if err != nil {
log.Printf("查找 DNS 记录时出错: %s\n", err) return fmt.Errorf("查找 DNS 记录时出错: %s", err)
return false
} }
if errResponse, ok := record["Error"].(map[string]interface{}); ok { if provider.resp.Response.Error.Code == "ResourceNotFound.NoDataOfRecord" { // 没有找到 DNS 记录
if errCode, ok := errResponse["Code"].(string); ok && errCode == "ResourceNotFound.NoDataOfRecord" { // 没有找到 DNS 记录 return provider.createDNSRecord(isIpv4)
// 添加 DNS 记录 } else if provider.resp.Response.Error.Code != "" {
return provider.createDNSRecord(domainConfig.FullDomain, domainConfig, isIpv4) return fmt.Errorf("查询 DNS 记录时出错,错误代码为: %s", provider.resp.Response.Error.Code)
} else {
log.Printf("查询 DNS 记录时出错,错误代码为: %s\n", errCode)
}
} }
// 默认情况下更新 DNS 记录 // 默认情况下更新 DNS 记录
return provider.updateDNSRecord(domainConfig.FullDomain, record["RecordList"].([]interface{})[0].(map[string]interface{})["RecordId"].(float64), domainConfig, isIpv4) return provider.updateDNSRecord(isIpv4)
} }
func (provider *ProviderTencentCloud) findDNSRecord(domain string, isIPv4 bool) (map[string]interface{}, error) { func (provider *ProviderTencentCloud) findDNSRecord(isIPv4 bool) error {
var ipType = "A" var ipType string
if !isIPv4 { if isIPv4 {
ipType = "A"
} else {
ipType = "AAAA" ipType = "AAAA"
} }
_, realDomain := SplitDomain(domain)
prefix, _ := SplitDomain(domain) prefix, realDomain := splitDomain(provider.domainConfig.FullDomain)
data := map[string]interface{}{ data := &tcReq{
"RecordType": ipType, RecordType: ipType,
"Domain": realDomain, Domain: realDomain,
"RecordLine": "默认", RecordLine: "默认",
"Subdomain": prefix, Subdomain: prefix,
} }
jsonData, _ := json.Marshal(data)
jsonData, _ := utils.Json.Marshal(data)
body, err := provider.sendRequest("DescribeRecordList", jsonData) body, err := provider.sendRequest("DescribeRecordList", jsonData)
if err != nil { if err != nil {
return nil, err return err
} }
var res map[string]interface{} provider.resp = &tcResp{}
err = json.Unmarshal(body, &res) err = utils.Json.Unmarshal(body, provider.resp)
if err != nil { if err != nil {
return nil, err return err
} }
result := res["Response"].(map[string]interface{}) return nil
return result, nil
} }
func (provider *ProviderTencentCloud) createDNSRecord(domain string, domainConfig *DomainConfig, isIPv4 bool) bool { func (provider *ProviderTencentCloud) createDNSRecord(isIPv4 bool) error {
var ipType = "A" var ipType, ipAddr string
var ipAddr = domainConfig.Ipv4Addr if isIPv4 {
if !isIPv4 { ipType = "A"
ipAddr = provider.domainConfig.Ipv4Addr
} else {
ipType = "AAAA" ipType = "AAAA"
ipAddr = domainConfig.Ipv6Addr ipAddr = provider.domainConfig.Ipv6Addr
} }
_, realDomain := SplitDomain(domain)
prefix, _ := SplitDomain(domain) prefix, realDomain := splitDomain(provider.domainConfig.FullDomain)
data := map[string]interface{}{ data := &tcReq{
"RecordType": ipType, RecordType: ipType,
"RecordLine": "默认", RecordLine: "默认",
"Domain": realDomain, Domain: realDomain,
"SubDomain": prefix, SubDomain: prefix,
"Value": ipAddr, Value: ipAddr,
"TTL": 600, TTL: 600,
} }
jsonData, _ := json.Marshal(data)
jsonData, _ := utils.Json.Marshal(data)
_, err := provider.sendRequest("CreateRecord", jsonData) _, err := provider.sendRequest("CreateRecord", jsonData)
return err == nil return err
} }
func (provider *ProviderTencentCloud) updateDNSRecord(domain string, recordID float64, domainConfig *DomainConfig, isIPv4 bool) bool { func (provider *ProviderTencentCloud) updateDNSRecord(isIPv4 bool) error {
var ipType = "A" var ipType, ipAddr string
var ipAddr = domainConfig.Ipv4Addr if isIPv4 {
if !isIPv4 { ipType = "A"
ipAddr = provider.domainConfig.Ipv4Addr
} else {
ipType = "AAAA" ipType = "AAAA"
ipAddr = domainConfig.Ipv6Addr ipAddr = provider.domainConfig.Ipv6Addr
} }
_, realDomain := SplitDomain(domain)
prefix, _ := SplitDomain(domain) prefix, realDomain := splitDomain(provider.domainConfig.FullDomain)
data := map[string]interface{}{ data := &tcReq{
"RecordType": ipType, RecordType: ipType,
"RecordLine": "默认", RecordLine: "默认",
"Domain": realDomain, Domain: realDomain,
"SubDomain": prefix, SubDomain: prefix,
"Value": ipAddr, Value: ipAddr,
"TTL": 600, TTL: 600,
"RecordId": recordID, RecordId: provider.resp.Response.RecordList[0].RecordId,
} }
jsonData, _ := json.Marshal(data)
jsonData, _ := utils.Json.Marshal(data)
_, err := provider.sendRequest("ModifyRecord", jsonData) _, err := provider.sendRequest("ModifyRecord", jsonData)
return err == nil return err
} }
// 以下为辅助方法,如发送 HTTP 请求等 // 以下为辅助方法,如发送 HTTP 请求等
func (provider *ProviderTencentCloud) sendRequest(action string, data []byte) ([]byte, error) { func (provider *ProviderTencentCloud) sendRequest(action string, data []byte) ([]byte, error) {
client := &http.Client{} req, err := http.NewRequest("POST", te, bytes.NewBuffer(data))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -151,8 +186,8 @@ func (provider *ProviderTencentCloud) sendRequest(action string, data []byte) ([
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-TC-Version", "2021-03-23") req.Header.Set("X-TC-Version", "2021-03-23")
provider.signRequest(provider.SecretID, provider.SecretKey, req, action, string(data)) provider.signRequest(provider.secretID, provider.secretKey, req, action, string(data))
resp, err := client.Do(req) resp, err := utils.HttpClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+91 -40
View File
@@ -2,58 +2,109 @@ package ddns
import ( import (
"bytes" "bytes"
"log" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/naiba/nezha/pkg/utils"
) )
type ProviderWebHook struct { type ProviderWebHook struct {
URL string url string
RequestMethod string requestMethod string
RequestBody string requestBody string
RequestHeader string requestHeader string
domainConfig *DomainConfig
} }
func (provider *ProviderWebHook) UpdateDomain(domainConfig *DomainConfig) bool { func NewProviderWebHook(s, rm, rb, rh string) *ProviderWebHook {
return &ProviderWebHook{
url: s,
requestMethod: rm,
requestBody: rb,
requestHeader: rh,
}
}
func (provider *ProviderWebHook) UpdateDomain(domainConfig *DomainConfig) error {
if domainConfig == nil { if domainConfig == nil {
return false return fmt.Errorf("获取 DDNS 配置失败")
}
provider.domainConfig = domainConfig
if provider.domainConfig.FullDomain == "" {
return fmt.Errorf("failed to update an empty domain")
} }
if domainConfig.FullDomain == "" { if provider.domainConfig.EnableIPv4 && provider.domainConfig.Ipv4Addr != "" {
log.Println("NEZHA>> Failed to update an empty domain") req, err := provider.prepareRequest(true)
return false if err != nil {
return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err)
} }
updated := false if _, err := utils.HttpClient.Do(req); err != nil {
client := &http.Client{} return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err)
if domainConfig.EnableIPv4 && domainConfig.Ipv4Addr != "" { }
url := provider.FormatWebhookString(provider.URL, domainConfig, "ipv4") }
body := provider.FormatWebhookString(provider.RequestBody, domainConfig, "ipv4")
header := provider.FormatWebhookString(provider.RequestHeader, domainConfig, "ipv4") if provider.domainConfig.EnableIpv6 && provider.domainConfig.Ipv6Addr != "" {
req, err := provider.prepareRequest(false)
if err != nil {
return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err)
}
if _, err := utils.HttpClient.Do(req); err != nil {
return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err)
}
}
return nil
}
func (provider *ProviderWebHook) prepareRequest(isIPv4 bool) (*http.Request, error) {
u, err := url.Parse(provider.url)
if err != nil {
return nil, fmt.Errorf("failed parsing url: %v", err)
}
// Only handle queries here
q := u.Query()
for p, vals := range q {
for n, v := range vals {
vals[n] = provider.formatWebhookString(v, isIPv4)
}
q[p] = vals
}
u.RawQuery = q.Encode()
body := provider.formatWebhookString(provider.requestBody, isIPv4)
header := provider.formatWebhookString(provider.requestHeader, isIPv4)
headers := strings.Split(header, "\n") headers := strings.Split(header, "\n")
req, err := http.NewRequest(provider.RequestMethod, url, bytes.NewBufferString(body))
if err == nil && req != nil { req, err := http.NewRequest(provider.requestMethod, u.String(), bytes.NewBufferString(body))
SetStringHeadersToRequest(req, headers) if err != nil {
if _, err := client.Do(req); err != nil { return nil, fmt.Errorf("failed creating new request: %v", err)
log.Printf("NEZHA>> Failed to update a domain: %s. Cause by: %s\n", domainConfig.FullDomain, err.Error()) }
utils.SetStringHeadersToRequest(req, headers)
return req, nil
}
func (provider *ProviderWebHook) formatWebhookString(s string, isIPv4 bool) string {
var ipAddr, ipType string
if isIPv4 {
ipAddr = provider.domainConfig.Ipv4Addr
ipType = "ipv4"
} else { } else {
updated = true ipAddr = provider.domainConfig.Ipv6Addr
ipType = "ipv6"
} }
}
} r := strings.NewReplacer(
if domainConfig.EnableIpv6 && domainConfig.Ipv6Addr != "" { "{ip}", ipAddr,
url := provider.FormatWebhookString(provider.URL, domainConfig, "ipv6") "{domain}", provider.domainConfig.FullDomain,
body := provider.FormatWebhookString(provider.RequestBody, domainConfig, "ipv6") "{type}", ipType,
header := provider.FormatWebhookString(provider.RequestHeader, domainConfig, "ipv6") "\r", "",
headers := strings.Split(header, "\n") )
req, err := http.NewRequest(provider.RequestMethod, url, bytes.NewBufferString(body))
if err == nil && req != nil { result := r.Replace(strings.TrimSpace(s))
SetStringHeadersToRequest(req, headers) return result
if _, err := client.Do(req); err != nil {
log.Printf("NEZHA>> Failed to update a domain: %s. Cause by: %s\n", domainConfig.FullDomain, err.Error())
} else {
updated = true
}
}
}
return updated
} }
+2
View File
@@ -26,6 +26,8 @@ func CommonEnvironment(c *gin.Context, data map[string]interface{}) gin.H {
data["Version"] = singleton.Version data["Version"] = singleton.Version
data["Conf"] = singleton.Conf data["Conf"] = singleton.Conf
data["Themes"] = model.Themes data["Themes"] = model.Themes
data["CustomCode"] = singleton.Conf.Site.CustomCode
data["CustomCodeDashboard"] = singleton.Conf.Site.CustomCodeDashboard
// 是否是管理页面 // 是否是管理页面
data["IsAdminPage"] = adminPage[data["MatchedPath"].(string)] data["IsAdminPage"] = adminPage[data["MatchedPath"].(string)]
// 站点标题 // 站点标题
-1
View File
@@ -38,7 +38,6 @@ func ValidateViewPassword(opt ValidateViewPasswordOption) gin.HandlerFunc {
if opt.IsPage { if opt.IsPage {
c.HTML(http.StatusOK, GetPreferredTheme(c, "/viewpassword"), CommonEnvironment(c, gin.H{ c.HTML(http.StatusOK, GetPreferredTheme(c, "/viewpassword"), CommonEnvironment(c, gin.H{
"Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "VerifyPassword"}), "Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "VerifyPassword"}),
"CustomCode": singleton.Conf.Site.CustomCode,
})) }))
} else { } else {
+23
View File
@@ -3,6 +3,7 @@ package utils
import ( import (
"crypto/rand" "crypto/rand"
"math/big" "math/big"
"net/http"
"os" "os"
"regexp" "regexp"
"strings" "strings"
@@ -76,3 +77,25 @@ func GenerateRandomString(n int) (string, error) {
} }
return string(ret), nil return string(ret), nil
} }
func Uint64SubInt64(a uint64, b int64) uint64 {
if b < 0 {
return a + uint64(-b)
}
if a < uint64(b) {
return 0
}
return a - uint64(b)
}
func SetStringHeadersToRequest(req *http.Request, headers []string) {
if req == nil {
return
}
for _, element := range headers {
kv := strings.SplitN(element, ":", 2)
if len(kv) == 2 {
req.Header.Add(kv[0], kv[1])
}
}
}
+37 -1
View File
@@ -226,6 +226,9 @@ other = "Secret"
[Note] [Note]
other = "Note" other = "Note"
[PublicNote]
other = "Public Note"
[LinuxOneKeyInstall] [LinuxOneKeyInstall]
other = "Linux One-Command Install" other = "Linux One-Command Install"
@@ -331,6 +334,9 @@ other = "Frontend Theme"
[CustomCodes] [CustomCodes]
other = "Custom Codes (Style and Script)" other = "Custom Codes (Style and Script)"
[CustomCodesDashboard]
other = "Custom Codes for Dashboard"
[AccessPassword] [AccessPassword]
other = "Frontend Access Password" other = "Frontend Access Password"
@@ -650,7 +656,37 @@ other = "Disable Switch Template in Frontend"
other = "Servers On World Map" other = "Servers On World Map"
[NAT] [NAT]
other = "NAT" other = "NAT Traversal"
[LocalService]
other = "Local service"
[LocalServicePlaceholder]
other = "192.168.1.1:80 (with port)"
[BindHostname]
other = "Bind hostname"
[NetworkSpiterList] [NetworkSpiterList]
other = "Network Monitor" other = "Network Monitor"
[Refresh]
other = "Refresh"
[CopyPath]
other = "Copy Path"
[Goto]
other = "Go to"
[GotoHeadline]
other = "Go to a Folder"
[GotoGo]
other = "Go"
[GotoClose]
other = "Cancel"
[FMError]
other = "Agent returned an error, please view the console for details. To open a new connection, reopen the FM again."
+38 -2
View File
@@ -226,6 +226,9 @@ other = "Secreto"
[Note] [Note]
other = "Nota" other = "Nota"
[PublicNote]
other = "Nota Pública"
[LinuxOneKeyInstall] [LinuxOneKeyInstall]
other = "Instalación Linux con Un Solo Clic" other = "Instalación Linux con Un Solo Clic"
@@ -331,6 +334,9 @@ other = "Tema de Frontend"
[CustomCodes] [CustomCodes]
other = "Códigos Personalizados (Incluye style y script)" other = "Códigos Personalizados (Incluye style y script)"
[CustomCodesDashboard]
other = "Custom Codes for Dashboard"
[AccessPassword] [AccessPassword]
other = "Contraseña de Acceso al Frontend" other = "Contraseña de Acceso al Frontend"
@@ -650,7 +656,37 @@ other = "Deshabilitar Cambio de Plantilla en Frontend"
other = "Servidores en el mapa mundial" other = "Servidores en el mapa mundial"
[NAT] [NAT]
other = "NAT" other = "NAT traversal"
[LocalService]
other = "Servicio de red local"
[LocalServicePlaceholder]
other = "192.168.1.1:80 (con puerto)"
[BindHostname]
other = "Vincular nombre de host"
[NetworkSpiterList] [NetworkSpiterList]
other = "Red Monitor" other = "Monitor de red"
[Refresh]
other = "Actualizar"
[CopyPath]
other = "Copiar ruta"
[Goto]
other = "Ir a"
[GotoHeadline]
other = "Ir a una carpeta"
[GotoGo]
other = "Ir"
[GotoClose]
other = "Cancelar"
[FMError]
other = "Agent devolvió un error, consulte la consola para obtener más detalles. Para abrir una nueva conexión, vuelva a abrir el FM."
+36
View File
@@ -226,6 +226,9 @@ other = "密钥"
[Note] [Note]
other = "备注" other = "备注"
[PublicNote]
other = "公开备注"
[LinuxOneKeyInstall] [LinuxOneKeyInstall]
other = "Linux 一键安装" other = "Linux 一键安装"
@@ -331,6 +334,9 @@ other = "前台界面主题"
[CustomCodes] [CustomCodes]
other = "自定义代码(包括 style 和 script" other = "自定义代码(包括 style 和 script"
[CustomCodesDashboard]
other = "Custom Codes for Dashboard"
[AccessPassword] [AccessPassword]
other = "前台访问密码" other = "前台访问密码"
@@ -652,5 +658,35 @@ other = "服务器世界分布图"
[NAT] [NAT]
other = "内网穿透" other = "内网穿透"
[LocalService]
other = "内网服务"
[LocalServicePlaceholder]
other = "192.168.1.1:80(带端口)"
[BindHostname]
other = "绑定域名"
[NetworkSpiterList] [NetworkSpiterList]
other = "网络监控" other = "网络监控"
[Refresh]
other = "刷新"
[CopyPath]
other = "复制路径"
[Goto]
other = "跳往"
[GotoHeadline]
other = "跳往文件夹"
[GotoGo]
other = "确认"
[GotoClose]
other = "取消"
[FMError]
other = "Agent 返回了错误,请查看控制台获取详细信息。要建立新连接,请重新打开 FM。"
+56 -20
View File
@@ -50,7 +50,7 @@ other = "新增計劃任務"
other = "名稱" other = "名稱"
[Scheduler] [Scheduler]
other = "計劃" other = "排程"
[BackUp] [BackUp]
other = "備份" other = "備份"
@@ -80,37 +80,37 @@ other = "特定伺服器"
other = "輸入ID/名稱以搜尋" other = "輸入ID/名稱以搜尋"
[NotificationMethodGroup] [NotificationMethodGroup]
other = "通知方式組" other = "通知組"
[PushSuccessMessages] [PushSuccessMessages]
other = "推送成功的息" other = "推送成功的息"
[TaskType] [TaskType]
other = "任務類型" other = "任務類型"
[CronTask] [CronTask]
other = "計劃任務" other = "排程任務"
[TriggerTask] [TriggerTask]
other = "觸發任務" other = "觸發任務"
[TheFormaOfTheScheduleIs] [TheFormaOfTheScheduleIs]
other = "計劃的格式為:" other = "排程的格式為:"
[SecondsMinutesHoursDaysMonthsWeeksSeeDetails] [SecondsMinutesHoursDaysMonthsWeeksSeeDetails]
other = "秒 分 時 天 月 星期,詳情見" other = "秒 分 時 天 月 星期,詳情見"
[ScheduleExpressionFormat] [ScheduleExpressionFormat]
other = "計劃表達式格式" other = "排程表達式格式"
[IntroductionOfCommands] [IntroductionOfCommands]
other = "命令說明:編寫命令時類似於 shell/bat 腳本。建議不要換行,多個命令可用 <code>&&</code> 或 <code>&</code> 連接,若出現命令無法找到的情況,可能是由於 <code>PATH</code> 環境變配置問題。在 <code>Linux</code> 伺服器上,可在命令開頭加入 <code>source ~/.bashrc</code>,或使用命令的絕對路徑執行。" other = "命令說明:編寫命令時類似於 shell/bat 腳本。建議不要換行,多個命令可用 <code>&&</code> 或 <code>&</code> 連接,若出現命令無法找到的情況,可能是由於 <code>PATH</code> 環境變配置問題。在 <code>Linux</code> 伺服器上,可在命令開頭加入 <code>source ~/.bashrc</code>,或使用命令的絕對路徑執行。"
[AddMonitor] [AddMonitor]
other = "新增監控" other = "新增監控"
[Blog] [Blog]
other = "博客" other = "部落格"
[Target] [Target]
other = "目標" other = "目標"
@@ -158,7 +158,7 @@ other = "新增通知方式"
other = "分組" other = "分組"
[DoNotSendTestMessages] [DoNotSendTestMessages]
other = "不發送測試息" other = "不發送測試息"
[RequestMethod] [RequestMethod]
other = "請求方式" other = "請求方式"
@@ -221,11 +221,14 @@ other = "排序"
other = "越大越靠前" other = "越大越靠前"
[Secret] [Secret]
other = "鑰" other = "鑰"
[Note] [Note]
other = "備註" other = "備註"
[PublicNote]
other = "公開備註"
[LinuxOneKeyInstall] [LinuxOneKeyInstall]
other = "Linux 一鍵安裝" other = "Linux 一鍵安裝"
@@ -254,10 +257,10 @@ other = "忽略所有"
other = "觸發執行" other = "觸發執行"
[DeleteScheduledTask] [DeleteScheduledTask]
other = "刪除計劃任務" other = "刪除排程任務"
[ConfirmToDeleteThisScheduledTask] [ConfirmToDeleteThisScheduledTask]
other = "確認刪除此計劃任務?" other = "確認刪除此排程任務?"
[AccessDenied] [AccessDenied]
other = "訪問被拒絕" other = "訪問被拒絕"
@@ -331,6 +334,9 @@ other = "前台界面主題"
[CustomCodes] [CustomCodes]
other = "自定義代碼(包括 style 和 script" other = "自定義代碼(包括 style 和 script"
[CustomCodesDashboard]
other = "Custom Codes for Dashboard"
[AccessPassword] [AccessPassword]
other = "前台訪問密碼" other = "前台訪問密碼"
@@ -404,7 +410,7 @@ other = "流量"
other = "負載" other = "負載"
[ProcessCount] [ProcessCount]
other = "程數" other = "程數"
[ConnCount] [ConnCount]
other = "連接數" other = "連接數"
@@ -419,7 +425,7 @@ other = "活動"
other = "版本" other = "版本"
[NetSpeed] [NetSpeed]
other = "網" other = "網"
[Uptime] [Uptime]
other = "在線" other = "在線"
@@ -455,7 +461,7 @@ other = "狀態"
other = "可用性" other = "可用性"
[AverageLatency] [AverageLatency]
other = "平均應時間" other = "平均應時間"
[CycleTransferStats] [CycleTransferStats]
other = "周期性流量統計" other = "周期性流量統計"
@@ -521,7 +527,7 @@ other = "發生錯誤"
other = "系統錯誤" other = "系統錯誤"
[NetworkError] [NetworkError]
other = "網錯誤" other = "網錯誤"
[ServicesStatus] [ServicesStatus]
other = "服務狀態" other = "服務狀態"
@@ -533,7 +539,7 @@ other = "伺服器管理"
other = "服務監控" other = "服務監控"
[ScheduledTasks] [ScheduledTasks]
other = "計劃任務" other = "排程任務"
[ApiManagement] [ApiManagement]
other = "API 管理" other = "API 管理"
@@ -611,7 +617,7 @@ other = "對遊客隱藏"
other = "菜單" other = "菜單"
[NetworkSpiter] [NetworkSpiter]
other = "網" other = "網"
[EnableShowInService] [EnableShowInService]
other = "在服務中顯示" other = "在服務中顯示"
@@ -650,7 +656,37 @@ other = "禁止前台切換主題"
other = "伺服器世界分布圖" other = "伺服器世界分布圖"
[NAT] [NAT]
other = "NAT" other = "NAT穿透"
[LocalService]
other = "內網服務"
[LocalServicePlaceholder]
other = "192.168.1.1:80(帶埠號)"
[BindHostname]
other = "綁定網域"
[NetworkSpiterList] [NetworkSpiterList]
other = "網監控" other = "網監控"
[Refresh]
other = "重新整理"
[CopyPath]
other = "複製路徑"
[Goto]
other = "跳至"
[GotoHeadline]
other = "跳至資料夾"
[GotoGo]
other = "確定"
[GotoClose]
other = "取消"
[FMError]
other = "Agent 回傳了錯誤,請查看主控台獲取詳細資訊。要建立新連線,請重新開啟 FM。"
+141
View File
@@ -0,0 +1,141 @@
html[nz-theme='dark'] body {
background-color: #121212 !important;
color: #fff !important;
}
html[nz-theme='dark'] .ui.menu {
background-color: #282828 !important;
}
html[nz-theme='dark'] .ui.menu * {
color: #fff !important;
}
html[nz-theme='dark'] .accordion {
background-color: #282828 !important;
}
html[nz-theme='dark'] .accordion .title {
color: #fff !important;
}
html[nz-theme='dark'] .ui.card {
background-color: #3f3f3f !important;
border: none !important;
box-shadow: none !important;
}
html[nz-theme='dark'] .header {
color: #fff !important;
}
html[nz-theme='dark'] .description {
color: #fff !important;
}
html[nz-theme='dark'] .icon {
color: #fff !important;
}
html[nz-theme='dark'] .ui.popup {
background-color: #575757 !important;
color: #fff !important;
}
html[nz-theme='dark'] .ui.table {
background-color: #282828 !important;
color: #fff !important;
}
html[nz-theme='dark'] .ui thead th {
background-color: #3f3f3f !important;
color: #fff !important;
}
html[nz-theme='dark'] .ui.buttons .button {
background-color: #3f3f3f !important;
color: #fff !important;
}
html[nz-theme='dark'] .ui.modal {
background-color: #282828 !important;
}
html[nz-theme='dark'] .ui.modal * {
color: #fff !important;
}
html[nz-theme='dark'] textarea,
html[nz-theme='dark'] input,
html[nz-theme='dark'] select,
html[nz-theme='dark'] .dropdown {
background-color: #3f3f3f !important;
color: #fff !important;
}
html[nz-theme='dark'] .ui.message {
background-color: unset !important;
}
html[nz-theme='dark'] .ui.dropdown .menu {
background-color: #575757 !important;
color: #fff !important;
}
html[nz-theme='dark'] .ui.modal>.header {
background-color: #3f3f3f !important;
}
html[nz-theme='dark'] .ui.modal>.content {
background-color: #282828 !important;
}
html[nz-theme='dark'] .ui.modal>.actions {
background-color: #3f3f3f !important;
}
html[nz-theme='dark'] #alert {
background-color: #3f3f3f !important;
color: #fff !important;
}
html[nz-theme='dark'] .ui.form .field>label {
color: unset !important;
}
html[nz-theme='dark'] .ui.segment {
background-color: #3f3f3f !important;
}
html[nz-theme='dark'] .ui.segment textarea,
html[nz-theme='dark'] input,
html[nz-theme='dark'] select,
html[nz-theme='dark'] .dropdown {
background-color: #575757 !important;
color: #fff !important;
}
html[nz-theme='dark'] form label {
color: #fff !important;
}
html[nz-theme='dark'] .ui.inverted.segment {
background-color: #121212 !important;
}
html[nz-theme='dark'] .ui.inverted.segment * {
color: #8b8b8b !important;
}
html[nz-theme='dark'] .menu .dropdown {
background-color: #282828 !important;
color: #fff !important;
}
html[nz-theme='dark'] .ui.menu .ui.dropdown .menu>.item {
color: #fff !important;
}
html[nz-theme='dark'] .login .ui.message{
color: #8b8b8b !important;
}
+70
View File
@@ -0,0 +1,70 @@
let receivedLength = 0;
let expectedLength = 0;
let root;
let draftHandle;
let accessHandle;
const Operation = Object.freeze({
WriteHeader: 1,
WriteChunks: 2,
DeleteFiles: 3
});
onmessage = async function (event) {
try {
const { operation, arrayBuffer, fileName } = event.data;
switch (operation) {
case Operation.WriteHeader: {
const dataView = new DataView(arrayBuffer);
expectedLength = Number(dataView.getBigUint64(4, false));
receivedLength = 0;
// Create a new temporary file
root = await navigator.storage.getDirectory();
draftHandle = await root.getFileHandle(fileName, { create: true });
accessHandle = await draftHandle.createSyncAccessHandle();
// Inform that file handle is created
const dataChunk = arrayBuffer.slice(12);
receivedLength += dataChunk.byteLength;
accessHandle.write(dataChunk, { at: 0 });
const progress = 'got handle';
postMessage({ type: 'progress', progress: progress });
break;
}
case Operation.WriteChunks: {
if (!accessHandle) {
throw new Error('accessHandle is undefined');
}
const dataChunk = arrayBuffer;
accessHandle.write(dataChunk, { at: receivedLength });
receivedLength += dataChunk.byteLength;
if (receivedLength === expectedLength) {
accessHandle.flush();
accessHandle.close();
const fileBlob = await draftHandle.getFile();
const blob = new Blob([fileBlob], { type: 'application/octet-stream' });
postMessage({ type: 'result', blob: blob, fileName: fileName });
}
break;
}
case Operation.DeleteFiles: {
for await (const [name, handle] of root.entries()) {
if (handle.kind === 'file') {
await root.removeEntry(name);
} else if (handle.kind === 'directory') {
await root.removeEntry(name, { recursive: true });
}
}
break;
}
}
} catch (error) {
postMessage({ error: error.message });
}
};
+5
View File
@@ -9,6 +9,11 @@ td {
word-break: break-all; word-break: break-all;
} }
.ui.container{
width: 95vw !important;
max-width: 1680px !important;
}
.nb-container { .nb-container {
padding-top: 75px; padding-top: 75px;
min-height: 100vh; min-height: 100vh;
+1
View File
@@ -331,6 +331,7 @@ function addOrEditServer(server, conf) {
.find("input[name=DisplayIndex]") .find("input[name=DisplayIndex]")
.val(server ? server.DisplayIndex : null); .val(server ? server.DisplayIndex : null);
modal.find("textarea[name=Note]").val(server ? server.Note : null); modal.find("textarea[name=Note]").val(server ? server.Note : null);
modal.find("textarea[name=PublicNote]").val(server ? server.PublicNote : null);
if (server) { if (server) {
modal.find(".secret.field").attr("style", ""); modal.find(".secret.field").attr("style", "");
modal.find(".command.field").attr("style", ""); modal.find(".command.field").attr("style", "");
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+36
View File
@@ -0,0 +1,36 @@
{
"name": "Nezha",
"short_name": "Nezha",
"icons": [
{
"src": "/static/manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"start_url": "/",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#000000",
"lang": "eu-US",
"dir": "auto"
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "哪吒监控",
"short_name": "哪吒监控",
"icons": [
{
"src": "/static/manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"start_url": "/",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#000000",
"lang": "zh-CN",
"dir": "auto"
}
+14 -19
View File
@@ -1,24 +1,22 @@
/* 屏幕适配 */ /* 屏幕适配 */
@media only screen and (min-width:1200px) {
.ui.container {
width:95% !important;
font-size: 90% !important;
max-width: 1300px !important;
}
}
@media only screen and (max-width:767px) { @media only screen and (max-width:767px) {
.ui.card>.content>.header:not(.ui),.ui.cards>.card>.content>.header:not(.ui) { .ui.card>.content>.header:not(.ui),
.ui.cards>.card>.content>.header:not(.ui) {
margin-top: 0.4em !important; margin-top: 0.4em !important;
} }
.ui.menu .item>img:not(.ui) { .ui.menu .item>img:not(.ui) {
width: 2.2rem; width: 2.2rem;
} }
.ui.menu .item:before { .ui.menu .item:before {
width: 0.5px; width: 0.5px;
} }
.ui.menu .item { .ui.menu .item {
padding: 0.9rem 0.55rem; padding: 0.9rem 0.55rem;
} }
.ui.large.menu { .ui.large.menu {
font-size: 1rem; font-size: 1rem;
} }
@@ -28,21 +26,12 @@ i.icon {
color: #000; color: #000;
width: 1.2em !important; width: 1.2em !important;
} }
i.fi { i.fi {
width: 0.9em; width: 0.9em;
margin: 0px 6px 0px 2px; margin: 0px 6px 0px 2px;
} }
body {
content:" " !important;
background:fixed !important;
z-index:-1 !important;
top:0 !important;
right:0 !important;
bottom:0 !important;
left:0 !important;
}
td { td {
word-wrap: break-word; word-wrap: break-word;
word-break: break-all; word-break: break-all;
@@ -55,6 +44,11 @@ td {
margin-bottom: -47px; margin-bottom: -47px;
} }
.ui.container {
width: 95%;
max-width: 1680px !important;
}
#app .ui.fluid.accordion { #app .ui.fluid.accordion {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -91,7 +85,8 @@ td {
margin-bottom: -0.5em margin-bottom: -0.5em
} }
.ui.card>.content>.header:not(.ui), .ui.cards>.card>.content>.header:not(.ui){ .ui.card>.content>.header:not(.ui),
.ui.cards>.card>.content>.header:not(.ui) {
line-height: 1em; line-height: 1em;
} }
+11
View File
@@ -13,7 +13,18 @@ const mixinsVue = {
this.isMobile = this.checkIsMobile(); this.isMobile = this.checkIsMobile();
this.preferredTemplate = this.getCookie('preferred_theme') ? this.getCookie('preferred_theme') : this.$root.defaultTemplate; this.preferredTemplate = this.getCookie('preferred_theme') ? this.getCookie('preferred_theme') : this.$root.defaultTemplate;
}, },
mounted() {
this.initDropdown();
},
methods: { methods: {
initDropdown() {
if(this.isMobile) $('.ui.dropdown').dropdown({
action: 'hide',
on: 'click',
duration: 100,
direction: 'direction'
});
},
toggleTemplate(template) { toggleTemplate(template) {
if( template != this.preferredTemplate){ if( template != this.preferredTemplate){
this.preferredTemplate = template; this.preferredTemplate = template;
+11 -2
View File
@@ -9,6 +9,10 @@ body {
margin: 0 auto; margin: 0 auto;
} }
.container-fluid::-webkit-scrollbar {
display: none;
}
.nezha { .nezha {
min-height: calc(100vh - 100px); min-height: calc(100vh - 100px);
} }
@@ -377,6 +381,7 @@ td.ping-network-quality {
} }
.network-box .chartTitle { .network-box .chartTitle {
cursor: pointer;
text-align: center; text-align: center;
font-size: 18px; font-size: 18px;
margin: 18px 0px 15px 0px; margin: 18px 0px 15px 0px;
@@ -470,14 +475,18 @@ footer p{
background-image: url(/static/theme-server-status/img/rb.png); background-image: url(/static/theme-server-status/img/rb.png);
} }
@media only screen and (max-width: 767px) { @media only screen and (max-width: 767px) {
body { body {
font-size: 10px !important; font-size: 10px !important;
padding-top:60px !important; padding-top:60px !important;
} }
.nezha { .nezha {
min-height: calc(100vh - 80px); min-height: calc(100vh - 90px);
min-height: calc(var(--vh, 1vh) * 100 - 90px);
}
#chartbox {
min-height: calc(100vh - 170px);
min-height: calc(var(--vh, 1vh) * 100 - 170px);
} }
.content { .content {
padding: 0; padding: 0;
+6
View File
@@ -26,7 +26,9 @@ const mixinsVue = {
this.semiTransparent = this.initSemiTransparent(); this.semiTransparent = this.initSemiTransparent();
this.preferredTemplate = this.getCookie('preferred_theme') ? this.getCookie('preferred_theme') : this.$root.defaultTemplate; this.preferredTemplate = this.getCookie('preferred_theme') ? this.getCookie('preferred_theme') : this.$root.defaultTemplate;
this.colors = this.theme == "dark" ? this.colorsDark : this.colorsLight; this.colors = this.theme == "dark" ? this.colorsDark : this.colorsLight;
this.setBenchmarkHeight();
window.addEventListener('scroll', this.handleScroll); window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.setBenchmarkHeight());
}, },
destroyed() { destroyed() {
window.removeEventListener('scroll', this.handleScroll); window.removeEventListener('scroll', this.handleScroll);
@@ -152,6 +154,10 @@ const mixinsVue = {
}, },
checkIsMobile() { // 检测设备类型,页面宽度小于768px认为是移动设备 checkIsMobile() { // 检测设备类型,页面宽度小于768px认为是移动设备
return window.innerWidth <= 768; return window.innerWidth <= 768;
},
setBenchmarkHeight() {
let vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
} }
} }
} }
+2 -1
View File
@@ -10,12 +10,13 @@
<script src="https://unpkg.com/[email protected]/dist/semantic.min.js"></script> <script src="https://unpkg.com/[email protected]/dist/semantic.min.js"></script>
<script src="/static/semantic-ui-alerts.min.js"></script> <script src="/static/semantic-ui-alerts.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/vue.min.js"></script> <script src="https://unpkg.com/[email protected]/dist/vue.min.js"></script>
<script src="/static/main.js?v20240714"></script> <script src="/static/main.js?v2024927"></script>
<script> <script>
(function () { (function () {
updateLang({{.LANG }}); updateLang({{.LANG }});
})(); })();
</script> </script>
</body> </body>
</html> </html>
{{end}} {{end}}
+6 -1
View File
@@ -11,7 +11,12 @@
<link rel="stylesheet" type="text/css" href="https://unpkg.com/[email protected]/dist/semantic.min.css"> <link rel="stylesheet" type="text/css" href="https://unpkg.com/[email protected]/dist/semantic.min.css">
<link rel="stylesheet" type="text/css" href="https://unpkg.com/[email protected]/assets/font-logos.css"> <link rel="stylesheet" type="text/css" href="https://unpkg.com/[email protected]/assets/font-logos.css">
<link rel="stylesheet" type="text/css" href="/static/semantic-ui-alerts.min.css"> <link rel="stylesheet" type="text/css" href="/static/semantic-ui-alerts.min.css">
<link rel="stylesheet" type="text/css" href="/static/main.css?v2022042314"> <link rel="stylesheet" type="text/css" href="/static/main.css?v20240813">
<link rel="stylesheet" type="text/css" href="/static/darkmode.css?v20240813">
<script>
document.documentElement.setAttribute('nz-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
</script>
</head> </head>
{{if ts .CustomCodeDashboard}} {{.CustomCodeDashboard|safe}} {{end}}
<body> <body>
{{end}} {{end}}
+3 -3
View File
@@ -13,11 +13,11 @@
<input type="number" name="ServerID" placeholder="1"> <input type="number" name="ServerID" placeholder="1">
</div> </div>
<div class="field"> <div class="field">
<label>内网服务</label> <label>{{tr "LocalService"}}</label>
<input type="text" name="Host" placeholder="192.168.1.1:80(带端口)"> <input type="text" name="Host" placeholder="{{tr "LocalServicePlaceholder"}}">
</div> </div>
<div class="field"> <div class="field">
<label>绑定域名</label> <label>{{tr "BindHostname"}}</label>
<input type="text" name="Domain" placeholder="router.app.yourdomain.com"> <input type="text" name="Domain" placeholder="router.app.yourdomain.com">
</div> </div>
</form> </form>
+4
View File
@@ -56,6 +56,10 @@
<label>{{tr "Note"}}</label> <label>{{tr "Note"}}</label>
<textarea name="Note"></textarea> <textarea name="Note"></textarea>
</div> </div>
<div class="field">
<label>{{tr "PublicNote"}}</label>
<textarea name="PublicNote"></textarea>
</div>
<div class="command field"> <div class="command field">
<label>{{tr "LinuxOneKeyInstall"}}</label> <label>{{tr "LinuxOneKeyInstall"}}</label>
<div class="ui message"> <div class="ui message">
+1 -1
View File
@@ -10,7 +10,7 @@
</button> </button>
</div> </div>
</div> </div>
<table class="ui very basic table"> <table class="ui basic table">
<thead> <thead>
<tr> <tr>
<th>{{tr "Token"}}</th> <th>{{tr "Token"}}</th>
+1 -1
View File
@@ -10,7 +10,7 @@
</button> </button>
</div> </div>
</div> </div>
<table class="ui very basic table"> <table class="ui basic table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
+495
View File
@@ -0,0 +1,495 @@
{{define "dashboard-default/file"}}
<!DOCTYPE html>
<html lang="{{.Conf.Language}}">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File List</title>
<link rel="shortcut icon" type="image/png" href="/static/logo.svg?v20210804" />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://unpkg.com/mdui@2/mdui.css" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<script src="https://unpkg.com/mdui@2/mdui.global.js"></script>
</head>
<style>
body {
font-family: 'Roboto', sans-serif;
}
.file-list {
list-style-type: none;
padding: 0;
}
.file-item {
margin: 5px 0;
border: 1px solid #ccc;
border-radius: 5px;
overflow: hidden;
text-overflow: ellipsis;
display: block;
width: 100%;
box-sizing: border-box;
}
#top-app-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
}
#current-directory {
font-size: 1rem;
font-weight: normal;
margin: 0 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100% - 100px);
box-sizing: border-box;
}
</style>
<body>
<div id="top-app-bar">
<mdui-dropdown>
<mdui-button-icon slot="trigger" icon="menu"></mdui-button-icon>
<mdui-menu>
<mdui-menu-item id="refresh">{{tr "Refresh"}}</mdui-menu-item>
<mdui-menu-item id="copy">{{tr "CopyPath"}}</mdui-menu-item>
<mdui-menu-item id="goto">{{tr "Goto"}}</mdui-menu-item>
</mdui-menu>
</mdui-dropdown>
<span id="current-directory"></span>
<mdui-button-icon id="upload" icon="upload"></mdui-button-icon>
</div>
<mdui-list id="file-list" class="file-list"></mdui-list>
<mdui-dialog id="error-dialog" headline="Error"
description="{{tr "FMError"}}"></mdui-dialog>
<mdui-dialog id="upd-modal" class="modal">
<mdui-linear-progress id="upd-progress"></mdui-linear-progress>
</mdui-dialog>
<mdui-dialog id="goto-dialog" headline="{{tr "GotoHeadline"}}" close-on-overlay-click>
<mdui-text-field id="goto-text" variant="outlined" value=""></mdui-text-field>
<mdui-button id="goto-go" slot="action" variant="text">{{tr "GotoGo"}}</mdui-button>
<mdui-button id="goto-close" slot="action" variant="tonal">{{tr "GotoClose"}}</mdui-button>
</mdui-dialog>
<script>
let currentPath = '/opt/nezha/';
let fileName = '';
let receivedBuffer = []; // 用于缓存数据块
let expectedLength = 0;
let receivedLength = 0;
let isFirstChunk = true;
let isUpCompleted = false;
let handleReady = false;
let worker;
function updateDirectoryTitle() {
const directoryTitle = document.getElementById('current-directory');
directoryTitle.textContent = `${currentPath}`;
}
function updateFileList(items) {
const fileListElement = document.getElementById('file-list');
fileListElement.innerHTML = '';
if (currentPath !== '/') {
const upItem = document.createElement('mdui-list-item');
upItem.className = 'file-item up-directory';
upItem.setAttribute('icon', 'arrow_back');
upItem.textContent = "..";
upItem.onclick = function () {
const lastSlashIndex = currentPath.lastIndexOf('/', currentPath.length - 2);
currentPath = currentPath.substring(0, lastSlashIndex + 1) || '/';
listFile();
};
fileListElement.appendChild(upItem);
}
items.sort((a, b) => {
if (a.fileType === 'dir' && b.fileType !== 'dir') {
return -1;
}
if (a.fileType !== 'dir' && b.fileType === 'dir') {
return 1;
}
return a.name.localeCompare(b.name);
});
items.forEach(item => {
const listItem = document.createElement('mdui-list-item');
listItem.className = `file-item ${item.fileType.toLowerCase()}`;
listItem.setAttribute('nonclickable', 'true');
listItem.setAttribute('icon', 'insert_drive_file');
listItem.textContent = `${item.name}`;
if (item.fileType === 'dir') {
listItem.setAttribute('nonclickable', 'false');
listItem.setAttribute('icon', 'folder');
listItem.style.cursor = 'pointer';
listItem.onclick = function () {
currentPath += `${item.name}/`;
listFile();
};
} else {
const downloadButton = document.createElement('mdui-button-icon');
downloadButton.setAttribute('slot', 'end-icon');
downloadButton.setAttribute('icon', 'download');
downloadButton.onclick = function () {
const filePath = currentPath + item.name;
fileName = item.name;
downloadFile(filePath);
};
listItem.appendChild(downloadButton);
}
fileListElement.appendChild(listItem);
});
updateDirectoryTitle();
}
function resetUpdState() {
receivedBuffer = [];
expectedLength = 0;
receivedLength = 0;
isFirstChunk = true;
}
function downloadFile(filePath) {
showUpdModal('d');
const prefix = new Int8Array([1]); // Request download
const filePathMessage = new TextEncoder().encode(filePath);
const msg = new Int8Array(prefix.length + filePathMessage.length);
msg.set(prefix);
msg.set(filePathMessage, prefix.length);
socket.send(msg);
}
function listFile() {
const prefix = new Int8Array([0]);
const resizeMessage = new TextEncoder().encode(currentPath);
const msg = new Int8Array(prefix.length + resizeMessage.length);
msg.set(prefix);
msg.set(resizeMessage, prefix.length);
socket.send(msg);
}
async function uploadFile(file) {
showUpdModal('u');
const chunkSize = 1048576; // 1MB chunk
let offset = 0;
const filePath = `${currentPath}${file.name}`;
const fileSize = file.size;
const messageType = 2;
// Build header (type + file size + path)
const filePathBytes = new TextEncoder().encode(filePath);
const header = new ArrayBuffer(1 + 8 + filePathBytes.length);
const headerView = new DataView(header);
headerView.setUint8(0, messageType);
headerView.setBigUint64(1, BigInt(fileSize), false);
new Uint8Array(header, 9).set(filePathBytes);
// Send header
socket.send(header);
// Send data chunks
while (offset < fileSize) {
const chunk = file.slice(offset, offset + chunkSize);
const arrayBuffer = await readFileAsArrayBuffer(chunk);
socket.send(arrayBuffer);
offset += chunkSize;
}
const checkCompletion = setInterval(() => {
if (isUpCompleted) {
clearInterval(checkCompletion);
hideUpdModal();
resetUpdState();
listFile();
}
}, 100);
}
async function parseFileList(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
const items = [];
let offset = 4;
const pathLength = dataView.getUint32(offset);
offset += 4;
const pathArray = new Uint8Array(arrayBuffer, offset, pathLength);
currentPath = new TextDecoder('utf-8').decode(pathArray);
offset += pathLength;
while (offset < dataView.byteLength) {
const fileType = dataView.getUint8(offset);
offset += 1;
const nameLength = dataView.getUint8(offset);
offset += 1;
if (offset + nameLength > dataView.byteLength) {
console.error('Error: Name length exceeds buffer size');
break;
}
const nameArray = new Uint8Array(arrayBuffer, offset, nameLength);
const name = new TextDecoder('utf-8').decode(nameArray);
offset += nameLength;
items.push({
fileType: fileType === 0x01 ? 'dir' : 'f',
name: name,
});
}
return { items };
}
function readFileAsArrayBuffer(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(blob);
});
}
function concatenateArrayBuffers(buffers) {
let totalLength = 0;
buffers.forEach(buf => totalLength += buf.byteLength);
const result = new Uint8Array(totalLength);
let offset = 0;
buffers.forEach(buf => {
result.set(new Uint8Array(buf), offset);
offset += buf.byteLength;
});
return result.buffer;
}
function arraysEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
async function copyTextToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy text to clipboard: ', err);
}
}
async function handleError(errMsg) {
try {
console.error('Received error: ', errMsg);
hideUpdModal();
const errorDialog = document.getElementById('error-dialog');
errorDialog.open = true;
if (socket.readyState === WebSocket.OPEN) {
socket.close(1000, 'Closing due to error');
}
} catch (error) {
console.error('Error while handling error and closing WebSocket:', error);
}
}
function showUpdModal(operation) {
const modal = document.getElementById('upd-modal');
modal.open = true;
if (operation === 'd') {
modal.setAttribute('headline', 'Downloading...');
} else if (operation === 'u') {
modal.setAttribute('headline', 'Uploading...');
}
}
function hideUpdModal() {
const modal = document.getElementById('upd-modal');
modal.open = false;
}
function waitForHandleReady() {
return new Promise(resolve => {
const checkReady = () => {
if (handleReady) {
resolve();
} else {
setTimeout(checkReady, 10);
}
};
checkReady();
});
}
const socket = new WebSocket((window.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/file/' + '{{.SessionID}}');
socket.binaryType = 'arraybuffer';
socket.onmessage = async function (event) {
try {
const arrayBuffer = event.data;
if (isFirstChunk) {
const identifier = new Uint8Array(arrayBuffer, 0, 4);
const fileIdentifier = new Uint8Array([0x4E, 0x5A, 0x54, 0x44]); // NZTD
const fileNameIdentifier = new Uint8Array([0x4E, 0x5A, 0x46, 0x4E]); // NZFN
const errorIdentifier = new Uint8Array([0x4E, 0x45, 0x52, 0x52]); // NERR
const completeIdentifier = new Uint8Array([0x4E, 0x5A, 0x55, 0x50]); // NZUP
if (arraysEqual(identifier, fileIdentifier)) {
worker = new Worker('/static/file.js');
worker.onmessage = async function (event) {
switch (event.data.type) {
case 'error':
console.error('Error from worker:', event.data.error);
break;
case 'progress':
handleReady = true;
break;
case 'result':
handleReady = false;
const url = URL.createObjectURL(event.data.blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = event.data.fileName;
anchor.click();
URL.revokeObjectURL(url);
// Delete the file in OPFS
window.addEventListener('beforeunload', async () => {
await worker.postMessage({ operation: 3, arrayBuffer: null, fileName: event.data.fileName });
});
hideUpdModal();
resetUpdState();
break;
}
};
await worker.postMessage({ operation: 1, arrayBuffer: arrayBuffer, fileName: fileName });
isFirstChunk = false;
} else if (arraysEqual(identifier, fileNameIdentifier)) {
// List files
const { items } = await parseFileList(arrayBuffer);
updateFileList(items);
return;
} else if (arraysEqual(identifier, errorIdentifier)) {
// Handle error
const errBytes = arrayBuffer.slice(4);
const errMsg = new TextDecoder('utf-8').decode(errBytes);
await handleError(errMsg);
return;
} else if (arraysEqual(identifier, completeIdentifier)) {
// Upload is completed
isUpCompleted = true;
return;
} else {
console.log('Unknown identifier');
return;
}
} else {
await waitForHandleReady();
await worker.postMessage({ operation: 2, arrayBuffer: arrayBuffer, fileName: fileName });
}
} catch (error) {
console.error('Error processing received data:', error);
}
};
socket.onopen = function (event) {
listFile();
};
socket.onerror = function (event) {
console.error('WebSocket error:', event);
};
socket.onclose = function (event) {
console.log('WebSocket connection closed:', event);
};
document.getElementById('refresh').addEventListener('click', listFile);
document.getElementById('copy').addEventListener('click', async () => {
await copyTextToClipboard(currentPath);
});
document.getElementById('goto').addEventListener('click', function () {
const dialog = document.getElementById('goto-dialog');
const textField = document.getElementById('goto-text');
const goButton = document.getElementById('goto-go');
const closeButton = document.getElementById('goto-close');
dialog.open = true;
// Ensure the path ends with a separator
const updateText = function (event) {
let text = event.target.value;
if (!text.endsWith('/')) {
text += '/';
}
return text;
};
const handleGoClick = function () {
let text = updateText({ target: textField });
currentPath = text;
listFile();
dialog.open = false;
};
textField.removeEventListener('change', updateText);
textField.addEventListener('change', updateText);
goButton.removeEventListener('click', handleGoClick);
goButton.addEventListener('click', handleGoClick);
closeButton.addEventListener("click", () => dialog.open = false);
});
document.getElementById('upload').addEventListener('click', async function () {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.style.display = 'none';
fileInput.addEventListener('change', async function (event) {
const file = event.target.files[0];
if (file) {
await uploadFile(file);
isUpCompleted = false;
}
});
document.body.appendChild(fileInput);
fileInput.click();
document.body.removeChild(fileInput);
});
</script>
</body>
</html>
{{end}}
+1 -1
View File
@@ -9,7 +9,7 @@
</button> </button>
</div> </div>
</div> </div>
<table class="ui very basic table"> <table class="ui basic table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
+3 -3
View File
@@ -10,14 +10,14 @@
</button> </button>
</div> </div>
</div> </div>
<table class="ui very basic table"> <table class="ui basic table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>{{tr "Name"}}</th> <th>{{tr "Name"}}</th>
<th>Agent ID</th> <th>Agent ID</th>
<th>内网服务</th> <th>{{tr "LocalService"}}</th>
<th>绑定域名</th> <th>{{tr "BindHostname"}}</th>
<th>{{tr "Administration"}}</th> <th>{{tr "Administration"}}</th>
</tr> </tr>
</thead> </thead>
+2 -2
View File
@@ -10,7 +10,7 @@
</button> </button>
</div> </div>
</div> </div>
<table class="ui very basic table"> <table class="ui basic table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -52,7 +52,7 @@
</button> </button>
</div> </div>
</div> </div>
<table class="ui very basic table"> <table class="ui basic table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
+4 -2
View File
@@ -19,7 +19,7 @@
</button> </button>
</div> </div>
</div> </div>
<table class="ui very basic table"> <table class="ui basic table">
<thead> <thead>
<tr> <tr>
<th><button onclick="checkAllServer()" class="ui mini nezha-primary-btn button">{{tr "SelectAll"}}</button></th> <th><button onclick="checkAllServer()" class="ui mini nezha-primary-btn button">{{tr "SelectAll"}}</button></th>
@@ -36,6 +36,7 @@
<th>{{tr "Secret"}}</th> <th>{{tr "Secret"}}</th>
<th>{{tr "OneKeyInstall"}}</th> <th>{{tr "OneKeyInstall"}}</th>
<th>{{tr "Note"}}</th> <th>{{tr "Note"}}</th>
<th>{{tr "PublicNote"}}</th>
<th>{{tr "Administration"}}</th> <th>{{tr "Administration"}}</th>
</tr> </tr>
</thead> </thead>
@@ -76,12 +77,13 @@
</button> </button>
</td> </td>
<td style="word-break: break-word;white-space: pre-wrap;">{{$server.Note}}</td> <td style="word-break: break-word;white-space: pre-wrap;">{{$server.Note}}</td>
<td style="word-break: break-word;white-space: pre-wrap;">{{$server.PublicNote}}</td>
<td> <td>
<div class="ui mini icon buttons"> <div class="ui mini icon buttons">
<button class="ui button" onclick="connectToServer({{$server.ID}})"> <button class="ui button" onclick="connectToServer({{$server.ID}})">
<i class="terminal icon"></i> <i class="terminal icon"></i>
</button> </button>
<button class="ui button" onclick="addOrEditServer({{$server.Marshal}})"> <button class="ui button" onclick="addOrEditServer({{$server.MarshalForDashboard}})">
<i class="edit icon"></i> <i class="edit icon"></i>
</button> </button>
<button class="ui button" <button class="ui button"
+4
View File
@@ -41,6 +41,10 @@
<label>{{tr "CustomCodes"}}</label> <label>{{tr "CustomCodes"}}</label>
<textarea name="CustomCode">{{.Conf.Site.CustomCode}}</textarea> <textarea name="CustomCode">{{.Conf.Site.CustomCode}}</textarea>
</div> </div>
<div class="field">
<label>{{tr "CustomCodesDashboard"}}</label>
<textarea name="CustomCodeDashboard">{{.Conf.Site.CustomCodeDashboard}}</textarea>
</div>
<div class="field"> <div class="field">
<label>{{tr "AccessPassword"}}</label> <label>{{tr "AccessPassword"}}</label>
<input type="text" name="ViewPassword" placeholder="" value="{{.Conf.Site.ViewPassword}}"> <input type="text" name="ViewPassword" placeholder="" value="{{.Conf.Site.ViewPassword}}">
+64 -2
View File
@@ -1,6 +1,7 @@
{{define "dashboard-default/terminal"}} {{define "dashboard-default/terminal"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{.Conf.Language}}"> <html lang="{{.Conf.Language}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
@@ -22,17 +23,53 @@
body { body {
background-color: black; background-color: black;
} }
#file-list-iframe {
position: absolute;
top: 0;
right: 0;
width: 30%;
height: 100%;
border: none;
background-color: white;
display: none;
z-index: 10;
}
#folder-button {
position: absolute;
bottom: 20px;
right: 20px;
width: 50px;
height: 50px;
background-color: #007bff;
color: white;
border: none;
border-radius: 25px;
font-size: 24px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
z-index: 20;
}
</style> </style>
<body onresize="onResize()"> <body onresize="onResize()">
<div id="terminal-container"></div> <div id="terminal-container"></div>
<iframe id="file-list-iframe" src=""></iframe>
<button id="folder-button">📁</button>
<script src="https://unpkg.com/[email protected]/lib/xterm.js"></script> <script src="https://unpkg.com/[email protected]/lib/xterm.js"></script>
<script src="https://unpkg.com/@xterm/[email protected]/lib/addon-attach.js"></script>
<script src="https://unpkg.com/@xterm/[email protected]/lib/addon-fit.js"></script> <script src="https://unpkg.com/@xterm/[email protected]/lib/addon-fit.js"></script>
<script src="https://unpkg.com/@xterm/[email protected]/lib/addon-web-links.js"></script>
<script src="https://unpkg.com/@xterm/[email protected]/lib/addon-attach.js"></script>
<script> <script>
let sendResizing = false; let sendResizing = false;
function doResize() { function doResize() {
fitAddon.fit() fitAddon.fit()
const w = fitAddon.proposeDimensions(); const w = fitAddon.proposeDimensions();
const prefix = new Int8Array([1]); const prefix = new Int8Array([1]);
const resizeMessage = new TextEncoder().encode(JSON.stringify({ const resizeMessage = new TextEncoder().encode(JSON.stringify({
@@ -70,10 +107,13 @@
cursorBlink: true, cursorBlink: true,
}); });
const socket = new WebSocket((window.location.protocol == 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/terminal/' + '{{.SessionID}}'); const socket = new WebSocket((window.location.protocol == 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/terminal/' + '{{.SessionID}}');
const attachAddon = new AttachAddon.AttachAddon(socket); const attachAddon = new AttachAddon.AttachAddon(socket);
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(attachAddon); term.loadAddon(attachAddon);
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
term.open(document.getElementById('terminal-container')); term.open(document.getElementById('terminal-container'));
socket.onopen = () => { socket.onopen = () => {
@@ -88,7 +128,29 @@
socket.onerror = () => { socket.onerror = () => {
alert('{{tr "TerminalConnectionFailed"}}') alert('{{tr "TerminalConnectionFailed"}}')
} }
// 处理文件夹按钮点击事件
const folderButton = document.getElementById('folder-button');
const fileListIframe = document.getElementById('file-list-iframe');
let fileListVisible = false;
folderButton.addEventListener('click', () => {
if (!fileListVisible) {
// 显示文件列表
const params = new URLSearchParams({
id: "{{.ServerID}}"
}).toString();
fileListIframe.src = `/file?${params}`;
fileListIframe.style.display = 'block';
fileListVisible = true;
} else {
// 隐藏文件列表
fileListIframe.style.display = 'none';
fileListVisible = false;
}
});
</script> </script>
</body> </body>
</html> </html>
{{end}} {{end}}
+14 -13
View File
@@ -261,22 +261,23 @@
const x = readableBytes(bs) const x = readableBytes(bs)
return x != "NaN undefined" ? x : '0B' return x != "NaN undefined" ? x : '0B'
}, },
getCoreAndGHz(str){ getCoreAndGHz(arr) {
if((str || []).hasOwnProperty(0) === false){ if ((arr || []).length === 0) {
return ''; return '';
} }
str = str[0]; let totalCores = 0;
let GHz = str.match(/(\d|\.)+GHz/g); arr.forEach(str => {
let Core = str.match(/(\d|\.)+ Physical/g); let coreMatch = str.match(/(\d+(\.\d+)?) Physical/g);
GHz = GHz!==null?GHz.hasOwnProperty(0)===false?'':GHz[0]:'' let coreCount = 0;
Core = Core!==null?Core.hasOwnProperty(0)===false?'?':Core[0]:'?' if (coreMatch) {
if(Core === '?'){ coreCount = parseFloat(coreMatch[0]);
let Core = str.match(/(\d|\.)+ Virtual/g); } else {
Core = Core!==null?Core.hasOwnProperty(0)===false?'?':Core[0]:'?' let coreMatch = str.match(/(\d+(\.\d+)?) Virtual/g);
return Core.replace('Virtual','Core') coreCount = coreMatch ? parseFloat(coreMatch[0]) : 0;
} }
return Core.replace('Physical','Core'); totalCores += coreCount;
});
return `${totalCores} Cores`;
}, },
getByteToGB(bs){ getByteToGB(bs){
return (bs/1024/1024/1024).toFixed(2) + 'GB' return (bs/1024/1024/1024).toFixed(2) + 'GB'
+2 -4
View File
@@ -10,14 +10,12 @@
{{ if not .Conf.DisableSwitchTemplateInFrontend }} {{ if not .Conf.DisableSwitchTemplateInFrontend }}
<script> <script>
function showSwitchTemplate(list, currentBackendTheme) { function showSwitchTemplate(list, currentBackendTheme) {
console.log(list, currentBackendTheme); // console.log(list, currentBackendTheme);
console.log("currentBackendTheme:",currentBackendTheme); // console.log("currentBackendTheme:",currentBackendTheme);
} }
showSwitchTemplate({{ .Themes }}, {{ .Conf.Site.Theme }}) showSwitchTemplate({{ .Themes }}, {{ .Conf.Site.Theme }})
</script> </script>
{{ end }} {{ end }}
<script>
</script>
</body> </body>
</html> </html>
{{end}} {{end}}
+21 -7
View File
@@ -1,26 +1,40 @@
{{define "theme-default/header"}} {{define "theme-default/header"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{.Conf.Language}}"> <html lang="{{.Conf.Language}}">
<head> <head>
<meta charset="UTF-8"> <title>{{ .Title }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta content="telephone=no" name="format-detection"> <meta content="telephone=no" name="format-detection">
<title>{{.Title}}</title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" type="image/png" href="/static/logo.svg" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#000000" />
{{ if eq .Conf.Language "zh-CN" }}
<link rel="manifest" href="/static/manifest-zh-CN.json?v20240905" />
{{ else }}
<link rel="manifest" href="/static/manifest-en-US.json?v20240905" />
{{ end }}
<link rel="apple-touch-startup-image" href="/static/logo.svg" />
<link rel="shortcut icon" type="image/png" href="/static/logo.svg">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/semantic.min.css"> <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/semantic.min.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/assets/font-logos.css"> <link rel="stylesheet" href="https://unpkg.com/[email protected]/assets/font-logos.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/font/bootstrap-icons.min.css"> <link rel="stylesheet" href="https://unpkg.com/[email protected]/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/css/flag-icons.min.css"> <link rel="stylesheet" href="https://unpkg.com/[email protected]/css/flag-icons.min.css">
<link rel="stylesheet" type="text/css" href="/static/semantic-ui-alerts.min.css"> <link rel="stylesheet" type="text/css" href="/static/semantic-ui-alerts.min.css">
<link rel="stylesheet" type="text/css" href="/static/theme-default/css/main.css?v20240616"> <link rel="stylesheet" type="text/css" href="/static/theme-default/css/main.css?v20240816">
<link rel="stylesheet" type="text/css" href="/static/darkmode.css?v20240816">
<script src="https://unpkg.com/[email protected]/dist/jquery.min.js"></script> <script src="https://unpkg.com/[email protected]/dist/jquery.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/semantic.min.js"></script> <script src="https://unpkg.com/[email protected]/dist/semantic.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/vue.min.js"></script> <script src="https://unpkg.com/[email protected]/dist/vue.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/echarts.min.js"></script> <script src="https://unpkg.com/[email protected]/dist/echarts.min.js"></script>
<script src="/static/semantic-ui-alerts.min.js"></script> <script src="/static/semantic-ui-alerts.min.js"></script>
<script src="/static/theme-default/js/mixin.js?v20240302"></script> <script src="/static/theme-default/js/mixin.js?v20240911"></script>
<script>
document.documentElement.setAttribute('nz-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
</script>
</head> </head>
<body> <body>
{{end}} {{end}}
+16 -17
View File
@@ -240,12 +240,12 @@
const itemHeight = isMobile ? 10 : 10; const itemHeight = isMobile ? 10 : 10;
const gridLeft = 25; const gridLeft = 25;
const gridRight = 12; const gridRight = 12;
const fontColor = "rgba(0, 0, 0, 0.68)";
const backgroundColor = ''; const backgroundColor = '';
const borderColor = "#ffffff"; const borderColor = "#ffffff";
const chartData = this.chartDataList[id - 1]; const chartData = this.chartDataList[id - 1];
const chartContainer = this.$refs[`chart${id}`][0]; const chartContainer = this.$refs[`chart${id}`][0];
const chart = echarts.init(chartContainer, null, { const chartTheme = $('html').attr('nz-theme') == "dark" ? "dark" : "";
const chart = echarts.init(chartContainer, chartTheme, {
renderer: 'canvas', renderer: 'canvas',
useDirtyRect: false, useDirtyRect: false,
width: 'auto', width: 'auto',
@@ -283,7 +283,6 @@
trigger: 'axis', trigger: 'axis',
textStyle: { textStyle: {
fontSize: fontSize, fontSize: fontSize,
color: fontColor
} }
}, },
legend: { legend: {
@@ -292,7 +291,6 @@
show: true, show: true,
textStyle: { textStyle: {
fontSize: fontSize, fontSize: fontSize,
color: fontColor
}, },
lineStyle: { lineStyle: {
cap: 'butt' cap: 'butt'
@@ -332,7 +330,6 @@
series: seriesData, series: seriesData,
textStyle: { textStyle: {
fontSize: fontSize, fontSize: fontSize,
color: fontColor
}, },
grid: { grid: {
top: '30', top: '30',
@@ -477,21 +474,23 @@
const sizes = ["B", "K", "M", "G", "T", "P", "E", "Z", "Y"]; const sizes = ["B", "K", "M", "G", "T", "P", "E", "Z", "Y"];
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + sizes[i]; return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + sizes[i];
}, },
getCoreAndGHz(str){ getCoreAndGHz(arr) {
if ((str || []).hasOwnProperty(0) === false) { if ((arr || []).length === 0) {
return ''; return '';
} }
str = str[0]; let totalCores = 0;
let GHz = str.match(/(\d|\.)+GHz/g); arr.forEach(str => {
let Core = str.match(/(\d|\.)+ Physical/g); let coreMatch = str.match(/(\d+(\.\d+)?) Physical/g);
GHz = GHz !== null ? GHz.hasOwnProperty(0) === false ? '' : GHz[0] : '' let coreCount = 0;
Core = Core !== null ? Core.hasOwnProperty(0) === false ? '?' : Core[0] : '?' if (coreMatch) {
if (Core === '?') { coreCount = parseFloat(coreMatch[0]);
let Core = str.match(/(\d|\.)+ Virtual/g); } else {
Core = Core !== null ? Core.hasOwnProperty(0) === false ? '?' : Core[0] : '?' let coreMatch = str.match(/(\d+(\.\d+)?) Virtual/g);
return Core.replace('Virtual', 'Core') coreCount = coreMatch ? parseFloat(coreMatch[0]) : 0;
} }
return Core.replace('Physical', 'Core'); totalCores += coreCount;
});
return `${totalCores} Cores`;
}, },
getK2Gb(bs){ getK2Gb(bs){
bs = bs / 1024 / 1024 / 1024; bs = bs / 1024 / 1024 / 1024;
+2 -2
View File
@@ -7,7 +7,7 @@
</a> </a>
<a class='item' href="/"><i class="home icon"></i>{{tr "Home"}}</a> <a class='item' href="/"><i class="home icon"></i>{{tr "Home"}}</a>
<template v-if="isMobile"> <template v-if="isMobile">
<div class="item ui simple dropdown"> <div class="item ui dropdown" :class="{ simple: !isMobile }">
<div class="text"><i class="bi bi-gear-wide-connected icon" style="margin-right:3px;"></i>{{tr "Feature" }}<i class="dropdown icon" style="margin-right:0px;"></i></div> <div class="text"><i class="bi bi-gear-wide-connected icon" style="margin-right:3px;"></i>{{tr "Feature" }}<i class="dropdown icon" style="margin-right:0px;"></i></div>
<div class="menu"> <div class="menu">
<a href="/service" class="item"><i class="rss icon"></i>{{tr "Services" }}</a> <a href="/service" class="item"><i class="rss icon"></i>{{tr "Services" }}</a>
@@ -20,7 +20,7 @@
<a href="/network" class="item"><i class="bi bi-hdd-network icon"></i>{{tr "NetworkSpiter"}}</a> <a href="/network" class="item"><i class="bi bi-hdd-network icon"></i>{{tr "NetworkSpiter"}}</a>
</template> </template>
{{ if not .Conf.DisableSwitchTemplateInFrontend }} {{ if not .Conf.DisableSwitchTemplateInFrontend }}
<div class="item ui simple dropdown"> <div class="item ui dropdown" :class="{ simple: !isMobile }">
<div class="text"><i class="bi bi-incognito icon" style="margin-right:3px;"></i>{{tr "Template" }}<i class="dropdown icon" style="margin-right:0px;"></i></div> <div class="text"><i class="bi bi-incognito icon" style="margin-right:3px;"></i>{{tr "Template" }}<i class="dropdown icon" style="margin-right:0px;"></i></div>
<div class="menu"> <div class="menu">
<a v-for="(item, index) in adaptedTemplates" :key="index" @click="toggleTemplate(item.key)" class="item"> <a v-for="(item, index) in adaptedTemplates" :key="index" @click="toggleTemplate(item.key)" class="item">
+27 -18
View File
@@ -18,7 +18,7 @@
</div> </div>
</div> </div>
<div class="ui container"> <div class="ui container">
<div ref="chartDom" style="border-radius: 28px; margin-top: 15px;height: 520px;max-width: 1400px;overflow: hidden"></div> <div ref="chartDom" style="margin-top: 15px;height: auto;overflow: hidden"></div>
</div> </div>
</div> </div>
@@ -36,7 +36,11 @@
defaultTemplate: {{.Conf.Site.Theme}}, defaultTemplate: {{.Conf.Site.Theme}},
templates: {{.Themes}}, templates: {{.Themes}},
servers: initData, servers: initData,
option: { option: {}
},
mixins: [mixinsVue],
created() {
this.option = {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
position: function (pt) { position: function (pt) {
@@ -65,21 +69,13 @@
} }
}, },
grid: { grid: {
left: '8%', left: this.isMobile ? '8%' : '3.8%',
right: '8%', right: this.isMobile ? '8%' : '3.8%',
},
backgroundColor: 'rgba(255, 255, 255, 0.8)',
toolbox: {
feature: {
dataZoom: {
yAxisIndex: 'none'
},
restore: {},
saveAsImage: {}
}
}, },
backgroundColor: '',
dataZoom: [ dataZoom: [
{ {
type: 'slider',
start: 0, start: 0,
end: 100 end: 100
} }
@@ -93,10 +89,8 @@
boundaryGap: false boundaryGap: false
}, },
series: [], series: [],
}
}, },
chartOnOff: true,
},
mixins: [mixinsVue],
mounted() { mounted() {
this.renderChart(); this.renderChart();
this.parseMonitorInfo(monitorInfo); this.parseMonitorInfo(monitorInfo);
@@ -236,6 +230,20 @@
this.option.title.text = monitorInfo.result[0].server_name; this.option.title.text = monitorInfo.result[0].server_name;
this.option.series = tSeries; this.option.series = tSeries;
this.option.legend.data = tLegendData; this.option.legend.data = tLegendData;
const maxLegendsPerRowMobile = localStorage.getItem("maxLegendsPerRowMobile") ? localStorage.getItem("maxLegendsPerRowMobile") : 2;
const maxLegendsPerRowPc = localStorage.getItem("maxLegendsPerRowPc") ? localStorage.getItem("maxLegendsPerRowPc") : 6;
const autoIncrement = Math.floor((tLegendData.length - 1) / (this.isMobile ? maxLegendsPerRowMobile : maxLegendsPerRowPc)) * (this.isMobile ? 28 : 34);
const height = 520 + autoIncrement;
const gridTop = 60 + autoIncrement;
this.option.grid = {
left: this.isMobile ? '8%' : '3.8%',
right: this.isMobile ? '8%' : '3.8%',
top: gridTop
};
this.myChart.resize({
width: 'auto',
height: height
});
this.myChart.clear(); this.myChart.clear();
this.myChart.setOption(this.option); this.myChart.setOption(this.option);
}, },
@@ -243,7 +251,8 @@
return str.includes('Windows') return str.includes('Windows')
}, },
renderChart() { renderChart() {
this.myChart = echarts.init(this.$refs.chartDom); const chartTheme = $('html').attr('nz-theme') == "dark" ? "dark" : "";
this.myChart = echarts.init(this.$refs.chartDom,chartTheme);
this.myChart.setOption(this.option); this.myChart.setOption(this.option);
}, },
resizeHandle () { resizeHandle () {
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "theme-server-status/footer"}} {{define "theme-server-status/footer"}}
</div> </section>
<footer class="container-fluid"> <footer class="container-fluid">
<p>{{ .Conf.Site.Brand }} | Theme ServerStatus | Powered by <a target="_blank" href="https://github.com/naiba/nezha">{{tr "NezhaMonitoring"}}</a> {{.Version}}</p> <p>{{ .Conf.Site.Brand }} | Theme ServerStatus | Powered by <a target="_blank" href="https://github.com/naiba/nezha">{{tr "NezhaMonitoring"}}</a> {{.Version}}</p>
</footer> </footer>
+14 -4
View File
@@ -4,9 +4,19 @@
<head> <head>
<title>{{.Title}}</title> <title>{{.Title}}</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta content="telephone=no" name="format-detection">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" type="image/png" href="/static/logo.svg" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#000000" />
{{if eq .Conf.Language "zh-CN"}}
<link rel="manifest" href="/static/manifest-zh-CN.json?v20240905" />
{{else}}
<link rel="manifest" href="/static/manifest-en-US.json?v20240905" />
{{end}}
<link rel="apple-touch-startup-image" href="/static/logo.svg" />
<link rel="shortcut icon" type="image/png" href="/static/logo.svg">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/font/bootstrap-icons.min.css"> <link rel="stylesheet" href="https://unpkg.com/[email protected]/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/assets/font-logos.css"> <link rel="stylesheet" href="https://unpkg.com/[email protected]/assets/font-logos.css">
@@ -16,10 +26,10 @@
<script src="https://unpkg.com/[email protected]/dist/js/bootstrap.min.js"></script> <script src="https://unpkg.com/[email protected]/dist/js/bootstrap.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/vue.min.js"></script> <script src="https://unpkg.com/[email protected]/dist/vue.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/echarts.min.js"></script> <script src="https://unpkg.com/[email protected]/dist/echarts.min.js"></script>
<link rel="stylesheet" href="/static/theme-server-status/css/main.css?v202408011"> <link rel="stylesheet" href="/static/theme-server-status/css/main.css?v20240909">
<link rel="stylesheet" href="/static/theme-server-status/css/dark.css?v202408011"> <link rel="stylesheet" href="/static/theme-server-status/css/dark.css?v202408011">
<link rel="stylesheet" href="/static/theme-server-status/css/light.css?v20240811"> <link rel="stylesheet" href="/static/theme-server-status/css/light.css?v20240811">
<script src="/static/theme-server-status/js/mixin.js?v20240811"></script> <script src="/static/theme-server-status/js/mixin.js?v20240907"></script>
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -18,7 +18,7 @@
<tbody id="servers"> <tbody id="servers">
<template v-for="(node,index) in nodesNoTag"> <template v-for="(node,index) in nodesNoTag">
<tr :id="'r'+node.ID" data-toggle="collapse" :data-target="'#rt'+node.ID" class="accordion-toggle" :class="index % 2 === 0 ? 'odd': 'even'" <tr :id="'r'+node.ID" data-toggle="collapse" :data-target="'#rt'+node.ID" class="accordion-toggle" :class="index % 2 === 0 ? 'odd': 'even'"
aria-expanded="false" @click="showCharts($event, node.ID)"> aria-expanded="false" @click="showCharts(node.ID)">
<td class="node-cell status center"> <td class="node-cell status center">
<div class="status-container"> <div class="status-container">
<div v-if="node.online" class="status-icon online"></div> <div v-if="node.online" class="status-icon online"></div>
@@ -143,7 +143,7 @@
@#node.host.Version#@ @#node.host.Version#@
</span> </span>
<span class="node-echarts-expand"> <span class="node-echarts-expand">
<div class="chartbox" chartbox-show="0" :key="node.ID" :ref="`chart${node.ID}`" style="width: 100%; height: auto;"></div> <div class="chartbox" :id="`chart-${node.ID}`" chartbox-show="0" :key="node.ID" style="width: 100%; height: auto;"></div>
</span> </span>
</div> </div>
</div> </div>
+2 -2
View File
@@ -21,7 +21,7 @@
<tbody id="servers"> <tbody id="servers">
<template v-for="(node,index) in group.data"> <template v-for="(node,index) in group.data">
<tr :id="'r'+node.ID" data-toggle="collapse" :data-target="'#rt'+node.ID" class="accordion-toggle" <tr :id="'r'+node.ID" data-toggle="collapse" :data-target="'#rt'+node.ID" class="accordion-toggle"
:class="index % 2 === 0 ? 'odd': 'even'" aria-expanded="false" @click="showCharts($event, node.ID)"> :class="index % 2 === 0 ? 'odd': 'even'" aria-expanded="false" @click="showCharts(node.ID)">
<td class="node-cell status center"> <td class="node-cell status center">
<div class="status-container"> <div class="status-container">
<div v-if="node.online" class="status-icon online"></div> <div v-if="node.online" class="status-icon online"></div>
@@ -146,7 +146,7 @@
@#node.host.Version#@ @#node.host.Version#@
</span> </span>
<span class="node-echarts-expand"> <span class="node-echarts-expand">
<div class="chartbox" chartbox-show="0" :key="node.ID" :ref="`chart${node.ID}`" style="width: 100%; height: auto;"></div> <div class="chartbox" :id="`chart-${node.ID}`" chartbox-show="0" :key="node.ID" style="width: 100%; height: auto;"></div>
</span> </span>
</div> </div>
</div> </div>
+151 -79
View File
@@ -3,15 +3,15 @@
{{template "theme-server-status/menu" .}} {{template "theme-server-status/menu" .}}
<!-- showGroup true --> <!-- showGroup true -->
<template v-if="showGroup"> <template v-if="showGroup">
<section class="container-fluid table-responsive content" v-for="group in nodesTag"> <div class="container-fluid table-responsive content" v-for="group in nodesTag">
{{template "theme-server-status/home-group-true" .}} {{template "theme-server-status/home-group-true" .}}
</section> </div>
</template> </template>
<!-- showGroup false --> <!-- showGroup false -->
<template v-else> <template v-else>
<section class="container-fluid table-responsive content"> <div class="container-fluid table-responsive content">
{{template "theme-server-status/home-group-false" .}} {{template "theme-server-status/home-group-false" .}}
</section> </div>
</template> </template>
<div class="modal fade" id="mapChartBox" tabindex="-1" role="dialog" aria-labelledby="mapChartTitle" aria-hidden="true" style="padding-left:0"> <div class="modal fade" id="mapChartBox" tabindex="-1" role="dialog" aria-labelledby="mapChartTitle" aria-hidden="true" style="padding-left:0">
<div class="modal-dialog modal-lg modal-dialog-centered"> <div class="modal-dialog modal-lg modal-dialog-centered">
@@ -163,6 +163,9 @@
const tooltipBorderColor = this.theme == "dark" ? "#ffffff" : "#ffffff"; const tooltipBorderColor = this.theme == "dark" ? "#ffffff" : "#ffffff";
const fontSize = this.isMobile ? 10 : 12; const fontSize = this.isMobile ? 10 : 12;
const fontColor = this.theme == "dark" ? "#000000" : "#000000"; const fontColor = this.theme == "dark" ? "#000000" : "#000000";
const showLoadingMaskColor = this.theme == "dark" ? 'rgba(28, 29, 38, 1)' : 'rgba(249, 249, 249, 1)';
const showLoadingTextColor = this.theme == "dark" ? 'rgba(241, 241, 241, 1)' : 'rgba(0, 0, 0, 1)';
const showLoadingColor = inRangeColor;
const chartContainer = document.getElementById('mapChart'); const chartContainer = document.getElementById('mapChart');
const mapChart = echarts.init(chartContainer, '', { // init图表 const mapChart = echarts.init(chartContainer, '', { // init图表
renderer: 'canvas', renderer: 'canvas',
@@ -170,6 +173,13 @@
width: width, width: width,
height: height, height: height,
}); });
mapChart.showLoading({
text: 'loading',
textColor: showLoadingTextColor,
color: showLoadingColor,
maskColor: showLoadingMaskColor,
zlevel: 2
});
fetch(this.staticUrl + '/maps/' + this.countryMapGeoFile) fetch(this.staticUrl + '/maps/' + this.countryMapGeoFile)
.then(response => response.json()) .then(response => response.json())
.then(worldMap => { .then(worldMap => {
@@ -224,7 +234,10 @@
} }
] ]
}; };
setTimeout(() => {
mapChart.hideLoading();
mapChart.setOption(option); mapChart.setOption(option);
}, 1000);
}); });
}, },
isWindowsPlatform(str) { isWindowsPlatform(str) {
@@ -470,24 +483,27 @@
// 如果所有元素的 Temperature 都为 0,则返回一个默认值 0 // 如果所有元素的 Temperature 都为 0,则返回一个默认值 0
return 0; return 0;
}, },
showCharts(event, id) { showCharts(id,changeChartboxShow=true) {
const chartContainer = this.$refs[`chart${id}`][0]; const chartContainer = document.getElementById(`chart-${id}`);
if(changeChartboxShow){
const chartboxShow = chartContainer.getAttribute('chartbox-show'); const chartboxShow = chartContainer.getAttribute('chartbox-show');
chartContainer.setAttribute('chartbox-show', chartboxShow === '0' ? '1' : '0'); chartContainer.setAttribute('chartbox-show', chartboxShow === '0' ? '1' : '0');
const isAriaExpandedFalse = event.currentTarget.getAttribute('aria-expanded') === 'false'; const collapseContainer = document.getElementById(`r${id}`);
const isAriaExpandedFalse = collapseContainer.getAttribute('aria-expanded') === 'false';
if (!isAriaExpandedFalse) return; if (!isAriaExpandedFalse) return;
}
// 发起数据请求 // 发起数据请求
const url = `/api/v1/monitor/${id}`; const url = `/api/v1/monitor/${id}`;
fetch(url) fetch(url)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.result) { // 数据请求成功,更新数据并渲染图表 if (data.result) { // 数据请求成功,更新数据并渲染图表
this.chartDataList[id - 1] = data.result; this.chartDataList[id] = data.result;
this.$nextTick(() => { this.$nextTick(() => {
this.renderCharts(id); this.renderCharts(id);
}); });
} else { } else {
console.log('this agent (id:'+ id + ') has no monitor.'); console.log('this server (id:'+ id + ') has no monitor.');
} }
}) })
.catch(error => { .catch(error => {
@@ -495,72 +511,116 @@
}); });
}, },
renderCharts(id, reload = false) { renderCharts(id, reload = false) {
if (!this.chartDataList[id - 1]) return; if (!this.chartDataList[id]) return;
const chartData = this.chartDataList[id - 1]; const chartData = this.chartDataList[id];
const chartContainer = this.$refs[`chart${id}`][0]; const chartContainer = document.getElementById(`chart-${id}`);
if (reload) { //点击切换亮色/暗色风格模式时,重新载入echarts图表的逻辑, if (reload) {
// 第一步,查找已经渲染出的图表容器,并销毁它
const existingChart = echarts.getInstanceByDom(chartContainer); const existingChart = echarts.getInstanceByDom(chartContainer);
if (existingChart) existingChart.dispose(); if (existingChart) existingChart.dispose();
// 第二步,如果图表容器处于不可见状态chartboxShow=0,不重新渲染出新的图表,
// 如果图表容器处于可见状态chartboxShow=1,重新渲染出新的图表
const chartboxShow = chartContainer.getAttribute('chartbox-show'); const chartboxShow = chartContainer.getAttribute('chartbox-show');
if ( chartboxShow === '0' ) return; if ( chartboxShow === '0' ) return;
} }
// 定义图表参数值
const MaxTCPPingValue = {{.Conf.MaxTCPPingValue}} ? {{.Conf.MaxTCPPingValue}} : 300; const MaxTCPPingValue = {{.Conf.MaxTCPPingValue}} ? {{.Conf.MaxTCPPingValue}} : 300;
const fontSize = this.isMobile ? 10 : 14; const fontSize = this.isMobile ? 10 : 14;
const gridLeft = this.isMobile ? 25 : 36; const gridLeft = (MaxTCPPingValue > 500) ? (this.isMobile ? 36 : 42) : (this.isMobile ? 25 : 36);
const gridRight = this.isMobile ? 5 : 20; const gridRight = this.isMobile ? 5 : 20;
const legendLeft = this.isMobile ? 'center' : 'center'; const legendLeft = this.isMobile ? 'center' : 'center';
const legendTop = this.isMobile ? 5 : 5; const legendTop = this.isMobile ? 5 : 5;
const legendPadding= this.isMobile ? [5,0,5,0] : [5,0,5,0]; const legendPadding= this.isMobile ? [5,0,5,0] : [5,0,5,0];
const systemDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; const systemDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const theme = localStorage.getItem("theme") ? localStorage.getItem("theme") : systemDarkMode; const theme = localStorage.getItem("theme") ? localStorage.getItem("theme") : systemDarkMode;
const chartTheme = theme == "dark" ? "dark" : "default"; const chartTheme = this.theme == "dark" ? "dark" : "default";
const fontColor = theme == "dark" ? "#f1f1f1" : "#000000"; const fontColor = this.theme == "dark" ? "#f1f1f1" : "#000000";
const backgroundColor = theme == "dark" ? '' : ''; const backgroundColor = theme == "dark" ? '' : '';
const tooltipBackgroundColor = this.theme == "dark" ? (this.semiTransparent ? "rgba(28,29,38,0.85)" : "rgba(28,29,38,1)") : (this.semiTransparent ? "rgba(255,255,255,0.85)" : "rgba(255,255,255,1)"); const tooltipBackgroundColor = this.theme == "dark" ? (this.semiTransparent ? "rgba(28,29,38,0.85)" : "rgba(28,29,38,1)") : (this.semiTransparent ? "rgba(255,255,255,0.85)" : "rgba(255,255,255,1)");
const tooltipBorderColor = this.theme == "dark" ? (this.semiTransparent ? "rgba(28,29,38,0.9)" : "rgba(28,29,38,1)") : (this.semiTransparent ? "rgba(255,255,255,0.9)" : "rgba(255,255,255,1)"); const tooltipBorderColor = this.theme == "dark" ? (this.semiTransparent ? "rgba(28,29,38,0.9)" : "rgba(28,29,38,1)") : (this.semiTransparent ? "rgba(255,255,255,0.9)" : "rgba(255,255,255,1)");
const lineStyleWidth = this.isMobile ? 1 : 2; const lineStyleWidth = this.isMobile ? 1 : 2;
const splitLineWidth = this.isMobile ? 0.5 : 1; const splitLineWidth = this.isMobile ? 0.5 : 1;
// 渲染图表 const markLineItemStyleOpacity = this.semiTransparent ? 1 : 0.75;
const chart = echarts.init(chartContainer, chartTheme, { const markLineLineStyleWidth = this.isMobile ? 0.15 : 0.3;
const chart = echarts.init(chartContainer, chartTheme, { // init图表
renderer: 'canvas', renderer: 'canvas',
useDirtyRect: false, useDirtyRect: false,
width: 'auto', width: 'auto',
height: 300, height: 300
}); });
const xAxisData = chartData[0].created_at.map(time => new Date(time).toLocaleString()); let legendData = [];
const seriesData = chartData.map(item => { let seriesData = [];
chartData.forEach((item,key)=> {
let loss = 0; let loss = 0;
const data = item.avg_delay.map((avgDelay, index) => { let totalLossRate = 0;
if(avgDelay > 0 && avgDelay < MaxTCPPingValue){ let legendName = '';
loss += avgDelay > 0.9 * MaxTCPPingValue ? 1 : 0; let data = { main: [], markLine: []};
return [item.created_at[index], avgDelay.toFixed(2)]; item.avg_delay.forEach((avgDelay, index) => {
}else{ const threshold = 0.9 * MaxTCPPingValue; // 定义阀值,用于判断是否丢包
// 定义丢包 1. avgDelay==0 2. avgDelay>=MaxTCPPingValue 3. avgDelay>=threshold
if(avgDelay == 0 || avgDelay >= MaxTCPPingValue){ //绝对丢包
loss += 1; loss += 1;
const lossrate = 100 * loss / (index + 1);
if(lossrate != 100) {
data['markLine'].push({
xAxis: item.created_at[index],
label: { show: false },
emphasis: { disabled: true },
lineStyle: { type: "solid" }
});
}
} else if (avgDelay >= threshold && avgDelay < MaxTCPPingValue){ // 相对丢包
loss += 1;
const lossrate = 100 * loss / (index + 1);
if(lossrate != 100) {
data['main'].push(
[item.created_at[index], avgDelay, lossrate]
);
data['markLine'].push({
xAxis: item.created_at[index],
label: { show: false },
emphasis: { disabled: true },
lineStyle: { type: "solid" }
});
}
} else { // 未丢包
const lossrate = 100 * loss / (index + 1);
data['main'].push(
[item.created_at[index], avgDelay, lossrate]
);
} }
}); });
const lossRate = ((loss / item.created_at.length) * 100).toFixed(1); totalLossRate = ((loss / item.created_at.length) * 100).toFixed(1);
item.monitor_name = item.monitor_name.includes("%") ? item.monitor_name : `${item.monitor_name} ${lossRate}%`; legendName = `${item.monitor_name} ${totalLossRate}%`;
return { legendData.push(legendName);
name: item.monitor_name, seriesData.push(
{
name: legendName,
type: 'line', type: 'line',
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
data: data,
connectNulls: true, connectNulls: true,
legendHoverLink: false,
emphasis: {
disabled: true
},
lineStyle: { lineStyle: {
width: lineStyleWidth width: lineStyleWidth
},
data: data['main'],
markLine: {
symbol: "none",
symbolSize :0,
data: data['markLine'],
itemStyle: {
opacity: markLineItemStyleOpacity
},
lineStyle:{
width: markLineLineStyleWidth
} }
}; }
}
);
}); });
const legendData = chartData.map(item => item.monitor_name);
const maxLegendsPerRowMobile = localStorage.getItem("maxLegendsPerRowMobile") ? localStorage.getItem("maxLegendsPerRowMobile") : 3; const maxLegendsPerRowMobile = localStorage.getItem("maxLegendsPerRowMobile") ? localStorage.getItem("maxLegendsPerRowMobile") : 3;
const maxLegendsPerRowPc = localStorage.getItem("maxLegendsPerRowPc") ? localStorage.getItem("maxLegendsPerRowPc") : 6; const maxLegendsPerRowPc = localStorage.getItem("maxLegendsPerRowPc") ? localStorage.getItem("maxLegendsPerRowPc") : 6;
const autoIncrement = Math.floor((legendData.length - 1) / (this.isMobile ? maxLegendsPerRowMobile : maxLegendsPerRowPc)) * (this.isMobile ? 20 : 28) const autoIncrement = Math.floor((legendData.length - 1) / (this.isMobile ? maxLegendsPerRowMobile : maxLegendsPerRowPc)) * (this.isMobile ? 20 : 28);
const height = 300 + autoIncrement; const height = 300 + autoIncrement;
const gridTop = 40 + autoIncrement; const gridTop = 40 + autoIncrement;
const legendIcon = this.isMobile ? 'rect' : ""; const legendIcon = this.isMobile ? 'rect' : "";
@@ -570,39 +630,24 @@
width: 'auto', width: 'auto',
height: height height: height
}); });
const option = { const option = {
color: this.colors,
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
textStyle: {
fontSize: fontSize,
color: fontColor
},
grid: {
top: gridTop,
left: gridLeft,
right: gridRight,
},
title: { title: {
show: false show: false,
},
tooltip: {
trigger: 'axis',
backgroundColor: tooltipBackgroundColor,
borderColor: tooltipBorderColor,
textStyle: {
fontSize: fontSize,
color: fontColor
}
},
legend: {
data: legendData,
show: true,
icon: legendIcon,
textStyle: {
fontSize: fontSize,
color: fontColor
},
top: legendTop,
bottom: 0,
left: legendLeft,
padding: legendPadding,
itemWidth: itemWidth,
itemHeight: itemHeight,
}, },
series: seriesData.flat(),
xAxis: { xAxis: {
type: 'time', type: 'time',
data: xAxisData,
axisLabel: { axisLabel: {
textStyle: { textStyle: {
fontSize: fontSize fontSize: fontSize
@@ -622,30 +667,57 @@
} }
} }
}, },
legend: {
data: legendData,
show: true,
icon: legendIcon,
textStyle: {
fontSize: fontSize,
color: fontColor
},
top: legendTop,
bottom: 0,
left: legendLeft,
padding: legendPadding,
itemWidth: itemWidth,
itemHeight: itemHeight,
},
tooltip: {
trigger: 'axis',
backgroundColor: tooltipBackgroundColor,
borderColor: tooltipBorderColor,
textStyle: {
fontSize: fontSize,
color: fontColor
},
formatter: function (params) {
let tooltipContent = '';
const formattedTime = new Date(params[0].value[0]).toLocaleString();
tooltipContent += `<span style="line-height:2em">${formattedTime}</span><br>`;
params.forEach(param => {
const formattedTime = new Date(param.value[0]).toLocaleString();
if (!param.seriesName.includes('stack')) {
const name = param.seriesName.replace(/\s\d+(\.\d+)?%$/, '');
tooltipContent += `<span style="line-height:2em">${param.marker} ${name} ${param.value[2].toFixed(1)}% ${param.value[1].toFixed(2)}</span><br>`;
}
});
return tooltipContent;
}
},
dataZoom: [ dataZoom: [
{ {
type: 'slider', type: 'slider',
start: 0, start: 0,
end: 100 end: 100
} }
], ]
series: seriesData,
textStyle: {
fontSize: fontSize,
color: fontColor
},
grid: {
top: gridTop,
left: gridLeft,
right: gridRight
}
}; };
chart.setOption(option); chart.setOption(option);
}, },
reloadCharts() { // 重新加载所有图表 reloadCharts() {
this.servers.forEach(node => { this.servers.forEach(node => {
const id = node.ID; const id = node.ID;
const chartData = this.chartDataList[id - 1]; const chartData = this.chartDataList[id];
if (chartData) { if (chartData) {
this.renderCharts(id,true); this.renderCharts(id,true);
} }
+1 -1
View File
@@ -64,6 +64,6 @@
</nav> </nav>
</div> </div>
</header> </header>
<div class="nezha"> <section class="nezha">
{{end}} {{end}}
+60 -59
View File
@@ -15,7 +15,7 @@
</li> </li>
</ul> </ul>
</div> </div>
<div class="chartTitle"><i class="chartCountryCode" :class="'fi fi-' + chartCountryCode"></i> @#chartTitle#@</div> <div class="chartTitle" @click="showCharts(nextServerId)"><i class="chartCountryCode" :class="'fi fi-' + chartCountryCode"></i> @#chartTitle#@</div>
<div id="chartbox" style="width:100%;height:auto;"></div> <div id="chartbox" style="width:100%;height:auto;"></div>
</div> </div>
{{template "theme-server-status/footer" .}} {{template "theme-server-status/footer" .}}
@@ -32,7 +32,8 @@
chartTitle: '', chartTitle: '',
chartCountryCode: '', chartCountryCode: '',
chart: null, chart: null,
currentServerId: '' currentServerId: '',
nextServerId: '',
}, },
mixins: [mixinsVue], mixins: [mixinsVue],
created() { created() {
@@ -44,7 +45,6 @@
}, },
methods: { methods: {
showCharts(id) { showCharts(id) {
const chartContainer = document.getElementById('chartbox');
// 发起数据请求 // 发起数据请求
const url = `/api/v1/monitor/${id}`; const url = `/api/v1/monitor/${id}`;
fetch(url) fetch(url)
@@ -65,43 +65,49 @@
}, },
renderCharts(id, reload = false) { renderCharts(id, reload = false) {
if(!this.chartDataList[id]) return; if(!this.chartDataList[id]) return;
this.disposeCharts(); if(this.chart) this.disposeCharts(this.chart);
this.currentServerId = id; this.currentServerId = id;
this.nextServerId = this.getNextServerId(id);
this.chartCountryCode = this.getServerCountryCode(id); this.chartCountryCode = this.getServerCountryCode(id);
this.chartTitle = this.chartDataList[id][0].server_name;
const chartData = this.chartDataList[id]; const chartData = this.chartDataList[id];
const chartContainer = document.getElementById('chartbox'); const chartContainer = document.getElementById('chartbox');
this.chartTitle = chartData[0].server_name;
if (reload) {
const existingChart = echarts.getInstanceByDom(chartContainer);
if (existingChart) existingChart.dispose();
}
// 定义图表参数值
const MaxTCPPingValue = {{.Conf.MaxTCPPingValue}} ? {{.Conf.MaxTCPPingValue}} : 300; const MaxTCPPingValue = {{.Conf.MaxTCPPingValue}} ? {{.Conf.MaxTCPPingValue}} : 300;
const autoheight = this.isMobile ? (window.innerHeight - 200) : (window.innerHeight - 250); const autoheight = this.isMobile ? (window.innerHeight - 180) : (window.innerHeight - 250);
const fontSize = this.isMobile ? 10 : 14; const fontSize = this.isMobile ? 10 : 14;
const gridLeft = this.isMobile ? 30 : 36; const gridLeft = (MaxTCPPingValue > 500) ? (this.isMobile ? 36 : 42) : (this.isMobile ? 30 : 36);
const gridRight = this.isMobile ? 12 : 20; const gridRight = this.isMobile ? 12 : 20;
const legendLeft = this.isMobile ? 'center' : 'center'; const legendLeft = this.isMobile ? 'center' : 'center';
const legendTop = this.isMobile ? 5 : 5; const legendTop = this.isMobile ? 5 : 5;
const legendPadding= this.isMobile ? [5,0,5,0] : [5,0,5,0]; const legendPadding= this.isMobile ? [5,0,5,0] : [5,0,5,0];
const fontColor = this.theme == "dark" ? "#f1f1f1" : "#000000"; const fontColor = this.theme == "dark" ? "#f1f1f1" : "#000000";
const chartTheme = this.theme == "dark" ? "dark" : "default";
const backgroundColor = this.theme == "dark" ? '' : ''; const backgroundColor = this.theme == "dark" ? '' : '';
const tooltipBackgroundColor = this.theme == "dark" ? (this.semiTransparent ? "rgba(28,29,38,0.85)" : "rgba(28,29,38,1)") : (this.semiTransparent ? "rgba(255,255,255,0.85)" : "rgba(255,255,255,1)"); const tooltipBackgroundColor = this.theme == "dark" ? (this.semiTransparent ? "rgba(28,29,38,0.85)" : "rgba(28,29,38,1)") : (this.semiTransparent ? "rgba(255,255,255,0.85)" : "rgba(255,255,255,1)");
const tooltipBorderColor = this.theme == "dark" ? (this.semiTransparent ? "rgba(28,29,38,0.9)" : "rgba(28,29,38,1)") : (this.semiTransparent ? "rgba(255,255,255,0.9)" : "rgba(255,255,255,1)"); const tooltipBorderColor = this.theme == "dark" ? (this.semiTransparent ? "rgba(28,29,38,0.9)" : "rgba(28,29,38,1)") : (this.semiTransparent ? "rgba(255,255,255,0.9)" : "rgba(255,255,255,1)");
const lineStyleWidth = this.isMobile ? 1 : 2; const lineStyleWidth = this.isMobile ? 1 : 2;
const splitLineWidth = this.isMobile ? 0.5 : 1; const splitLineWidth = this.isMobile ? 0.5 : 1;
const splitLineColor = this.theme == "dark" ? "rgba(110, 112, 121, 0.95)" : "rgba(224, 230, 241, 0.95)"; const markPointSymbolSize = this.isMobile ? 36 : 42;
const markPointLabelColor = this.theme == "dark" ? "#111111" : "#000000"; const markPointItemStyleOpacity = this.semiTransparent ? 1 : 1;
const markPointItemStyleOpacity = this.semiTransparent ? 1 : 0.75; const markPointFontSize = this.isMobile ? 8 : 10;
const markLineItemStyleOpacity = this.semiTransparent ? 1 : 0.75; const markLineItemStyleOpacity = this.semiTransparent ? 1 : 0.75;
const markLineLineStyleWidth = this.isMobile ? 0.15 : 0.3; const markLineLineStyleWidth = this.isMobile ? 0.15 : 0.3;
this.chart = echarts.init(chartContainer, '', { // init图表 const showLoadingMaskColor = this.theme == "dark" ? 'rgba(0, 0, 0, 0)' : 'rgba(255, 255, 255, 0)';
const showLoadingTextColor = this.theme == "dark" ? 'rgba(241, 241, 241, 1)' : 'rgba(0, 0, 0, 1)';
const showLoadingColor = this.theme == "dark" ? '#D2B206' : '#FFDF32';
this.chart = echarts.init(chartContainer, chartTheme, { // init图表
renderer: 'canvas', renderer: 'canvas',
useDirtyRect: false, useDirtyRect: false,
width: 'auto', width: 'auto',
height: autoheight, height: autoheight,
}); });
// 获取图表数据 this.chart.showLoading({
text: 'loading',
textColor: showLoadingTextColor,
color: showLoadingColor,
maskColor: showLoadingMaskColor,
zlevel: 2
});
let legendData = []; let legendData = [];
let seriesData = []; let seriesData = [];
chartData.forEach((item,key)=> { chartData.forEach((item,key)=> {
@@ -111,25 +117,22 @@
let data = { main: [], markLine: []}; let data = { main: [], markLine: []};
item.avg_delay.forEach((avgDelay, index) => { item.avg_delay.forEach((avgDelay, index) => {
const threshold = 0.9 * MaxTCPPingValue; // 定义阀值,用于判断是否丢包 const threshold = 0.9 * MaxTCPPingValue; // 定义阀值,用于判断是否丢包
const filterAvgDelay = item.avg_delay.filter(value => value !== 0 && value !== MaxTCPPingValue);
const max = Math.max(...filterAvgDelay).toFixed(1);
const autoAvgDelay = 1.05 * max > 0.91 * MaxTCPPingValue ? 1.05 * max : 0.91 * MaxTCPPingValue;
// 定义丢包 1. avgDelay==0 2. avgDelay>=MaxTCPPingValue 3. avgDelay>=threshold // 定义丢包 1. avgDelay==0 2. avgDelay>=MaxTCPPingValue 3. avgDelay>=threshold
if(avgDelay == 0 || avgDelay >= MaxTCPPingValue){ //绝对丢包 if(avgDelay == 0 || avgDelay >= MaxTCPPingValue){ //绝对丢包
loss += 1; loss += 1;
const lossrate = 100 * loss / (index + 1); const lossrate = 100 * loss / (index + 1);
data['main'].push( if(lossrate != 100) {
[item.created_at[index], autoAvgDelay, lossrate]
);
data['markLine'].push({ data['markLine'].push({
xAxis: item.created_at[index], xAxis: item.created_at[index],
label: { show: false }, label: { show: false },
emphasis: { disabled: true }, emphasis: { disabled: true },
lineStyle: { type: "solid" } lineStyle: { type: "solid" }
}); });
}
} else if (avgDelay >= threshold && avgDelay < MaxTCPPingValue){ // 相对丢包 } else if (avgDelay >= threshold && avgDelay < MaxTCPPingValue){ // 相对丢包
loss += 1; loss += 1;
const lossrate = 100 * loss / (index + 1); const lossrate = 100 * loss / (index + 1);
if(lossrate != 100) {
data['main'].push( data['main'].push(
[item.created_at[index], avgDelay, lossrate] [item.created_at[index], avgDelay, lossrate]
); );
@@ -139,6 +142,7 @@
emphasis: { disabled: true }, emphasis: { disabled: true },
lineStyle: { type: "solid" } lineStyle: { type: "solid" }
}); });
}
} else { // 未丢包 } else { // 未丢包
const lossrate = 100 * loss / (index + 1); const lossrate = 100 * loss / (index + 1);
data['main'].push( data['main'].push(
@@ -146,11 +150,9 @@
); );
} }
}); });
// 处理legendData
totalLossRate = ((loss / item.created_at.length) * 100).toFixed(1); totalLossRate = ((loss / item.created_at.length) * 100).toFixed(1);
legendName = `${item.monitor_name} ${totalLossRate}%`; legendName = `${item.monitor_name} ${totalLossRate}%`;
legendData.push(legendName); legendData.push(legendName);
// 处理seriesData
seriesData.push( seriesData.push(
{ {
name: legendName, name: legendName,
@@ -158,6 +160,10 @@
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
connectNulls: true, connectNulls: true,
legendHoverLink: false,
emphasis: {
disabled: true
},
lineStyle: { lineStyle: {
width: lineStyleWidth width: lineStyleWidth
}, },
@@ -182,10 +188,9 @@
itemStyle: { itemStyle: {
opacity: markPointItemStyleOpacity opacity: markPointItemStyleOpacity
}, },
symbolSize: 30, symbolSize: markPointSymbolSize,
label: { label: {
fontSize: 8, fontSize: markPointFontSize,
color: markPointLabelColor,
formatter: function (params) { formatter: function (params) {
return Math.round(params.value); return Math.round(params.value);
} }
@@ -198,10 +203,9 @@
itemStyle: { itemStyle: {
opacity: markPointItemStyleOpacity opacity: markPointItemStyleOpacity
}, },
symbolSize: 30, symbolSize: markPointSymbolSize,
label: { label: {
fontSize: 8, fontSize: markPointFontSize,
color: markPointLabelColor,
offset: [0, 8], offset: [0, 8],
formatter: function (params) { formatter: function (params) {
return Math.round(params.value); return Math.round(params.value);
@@ -216,9 +220,10 @@
}); });
const maxLegendsPerRowMobile = localStorage.getItem("maxLegendsPerRowMobile") ? localStorage.getItem("maxLegendsPerRowMobile") : 3; const maxLegendsPerRowMobile = localStorage.getItem("maxLegendsPerRowMobile") ? localStorage.getItem("maxLegendsPerRowMobile") : 3;
const maxLegendsPerRowPc = localStorage.getItem("maxLegendsPerRowPc") ? localStorage.getItem("maxLegendsPerRowPc") : 6; const maxLegendsPerRowPc = localStorage.getItem("maxLegendsPerRowPc") ? localStorage.getItem("maxLegendsPerRowPc") : 6;
const autoIncrement = Math.floor((legendData.length - 1) / (this.isMobile ? maxLegendsPerRowMobile : maxLegendsPerRowPc)) * (this.isMobile ? 20 : 28) const autoIncrement = Math.floor((legendData.length - 1) / (this.isMobile ? maxLegendsPerRowMobile : maxLegendsPerRowPc)) * (this.isMobile ? 20 : 28);
const height = autoheight + autoIncrement; const height = autoheight + autoIncrement;
const gridTop = 40 + autoIncrement; const gridTop = this.isMobile ? ( 60 + autoIncrement) : (80 + autoIncrement);
const gridBottom = this.isMobile ? 70 : 90;
const legendIcon = this.isMobile ? 'rect' : ""; const legendIcon = this.isMobile ? 'rect' : "";
const itemWidth = this.isMobile ? 10 : 25; const itemWidth = this.isMobile ? 10 : 25;
const itemHeight = this.isMobile ? 10 : 14; const itemHeight = this.isMobile ? 10 : 14;
@@ -228,28 +233,22 @@
}); });
// 设置图表配置项 // 设置图表配置项
const option = { const option = {
// 全局调色盘
color: this.colors, color: this.colors,
// 背景颜色
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
// 文字样式
textStyle: { textStyle: {
fontSize: fontSize, fontSize: fontSize,
color: fontColor color: fontColor
}, },
// 图表网格设置
grid: { grid: {
top: gridTop, top: gridTop,
left: gridLeft, left: gridLeft,
right: gridRight right: gridRight,
bottom: gridBottom
}, },
// 图表标题设置
title: { title: {
show: false, show: false,
}, },
// 图表系列数据设置
series: seriesData.flat(), series: seriesData.flat(),
// X轴设置
xAxis: { xAxis: {
type: 'time', type: 'time',
axisLabel: { axisLabel: {
@@ -258,7 +257,6 @@
} }
} }
}, },
// Y轴设置
yAxis: { yAxis: {
type: 'value', type: 'value',
axisLabel: { axisLabel: {
@@ -268,12 +266,10 @@
}, },
splitLine: { splitLine: {
lineStyle: { lineStyle: {
width: splitLineWidth, width: splitLineWidth
color: splitLineColor
} }
} }
}, },
// 图例设置
legend: { legend: {
data: legendData, data: legendData,
show: true, show: true,
@@ -289,7 +285,6 @@
itemWidth: itemWidth, itemWidth: itemWidth,
itemHeight: itemHeight, itemHeight: itemHeight,
}, },
// 提示框设置
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: tooltipBackgroundColor, backgroundColor: tooltipBackgroundColor,
@@ -312,7 +307,6 @@
return tooltipContent; return tooltipContent;
} }
}, },
// 数据缩放设置
dataZoom: [ dataZoom: [
{ {
type: 'slider', type: 'slider',
@@ -321,28 +315,35 @@
} }
] ]
}; };
// 设置图表的配置选项 setTimeout(() => {
this.chart.hideLoading();
this.chart.setOption(option); this.chart.setOption(option);
}, 1000);
}, },
reloadCharts() { // 重新加载所有图表 reloadCharts() {
this.servers.forEach(node => { const chartData = this.chartDataList[this.currentServerId];
const id = node.ID;
const chartData = this.chartDataList[id];
if (chartData) { if (chartData) {
this.renderCharts(id,true); this.renderCharts(this.currentServerId,true);
} }
});
}, },
disposeCharts(){ disposeCharts(chart){
if(this.chart) { chart.dispose();
this.chart.dispose(); chart = null;
this.chart = null;
}
}, },
getServerCountryCode(id){ getServerCountryCode(id){
const result = this.servers.find(item => item.ID == id); const result = this.servers.find(item => item.ID == id);
return result.Host.CountryCode ? result.Host.CountryCode : 'rb'; return result.Host.CountryCode ? result.Host.CountryCode : 'rb';
}, },
getNextServerId(id) {
const currentIndex = this.servers.findIndex(item => item.ID === id);
if (currentIndex === -1) {
return this.servers[0].ID;
}
// 判断是否有下一个元素
const nextIndex = currentIndex + 1;
// 如果有下一个元素,返回下一个元素的 ID;否则返回第一个元素的 ID
return nextIndex < this.servers.length ? this.servers[nextIndex].ID : this.servers[0].ID;
},
initSearch() { initSearch() {
$('#dropdown-search').on('keyup', function() { $('#dropdown-search').on('keyup', function() {
var searchTerm = $(this).val().toLowerCase(); var searchTerm = $(this).val().toLowerCase();
+10 -10
View File
@@ -3,24 +3,24 @@
{{template "theme-server-status/menu" .}} {{template "theme-server-status/menu" .}}
<!-- showGroup true --> <!-- showGroup true -->
<template v-if="showGroup"> <template v-if="showGroup">
<section v-if="servicesTag.length === 0" class="container-fluid content" style="min-height: .01%;overflow-x: auto;"> <div v-if="servicesTag.length === 0" class="container-fluid content" style="min-height: .01%;overflow-x: auto;">
<p>No Valid Service Monitor Configuration Entries Found. Please Verify in the <a href="/monitor">Admin Panel</a>.</p> <p>No Valid Service Monitor Configuration Entries Found. Please Verify in the <a href="/monitor">Admin Panel</a>.</p>
</section> </div>
<section v-else class="container-fluid content" style="min-height: .01%;overflow-x: auto;" v-for="group in servicesTag"> <div v-else class="container-fluid content" style="min-height: .01%;overflow-x: auto;" v-for="group in servicesTag">
{{template "theme-server-status/service-group-true" .}} {{template "theme-server-status/service-group-true" .}}
</section> </div>
</template> </template>
<!-- showGroup false --> <!-- showGroup false -->
<template v-else> <template v-else>
<section v-if="servicesNoTag.length === 0" class="container-fluid content" style="min-height: .01%;overflow-x: auto;"> <div v-if="servicesNoTag.length === 0" class="container-fluid content" style="min-height: .01%;overflow-x: auto;">
<p>No Valid Service Monitor Configuration Entries Found. Please Verify in the <a href="/monitor">Admin Panel</a>.</p> <p>No Valid Service Monitor Configuration Entries Found. Please Verify in the <a href="/monitor">Admin Panel</a>.</p>
</section> </div>
<section v-else class="container-fluid content" style="min-height: .01%;overflow-x: auto;"> <div v-else class="container-fluid content" style="min-height: .01%;overflow-x: auto;">
{{template "theme-server-status/service-group-false" .}} {{template "theme-server-status/service-group-false" .}}
</section> </div>
</template> </template>
{{if .CycleTransferStats}} {{if .CycleTransferStats}}
<section class="container-fluid content table-responsive"> <div class="container-fluid content table-responsive">
<table class="table table-striped table-condensed table-hover"> <table class="table table-striped table-condensed table-hover">
<thead> <thead>
<tr class="node-group-tag"> <tr class="node-group-tag">
@@ -67,7 +67,7 @@
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</section> </div>
{{end}} {{end}}
{{template "theme-server-status/footer" .}} {{template "theme-server-status/footer" .}}
<script> <script>
+2 -2
View File
@@ -12,7 +12,7 @@ NZ_DASHBOARD_PATH="${NZ_BASE_PATH}/dashboard"
NZ_AGENT_PATH="${NZ_BASE_PATH}/agent" NZ_AGENT_PATH="${NZ_BASE_PATH}/agent"
NZ_DASHBOARD_SERVICE="/etc/systemd/system/nezha-dashboard.service" NZ_DASHBOARD_SERVICE="/etc/systemd/system/nezha-dashboard.service"
NZ_DASHBOARD_SERVICERC="/etc/init.d/nezha-dashboard" NZ_DASHBOARD_SERVICERC="/etc/init.d/nezha-dashboard"
NZ_VERSION="v0.18.3" NZ_VERSION="v0.19.0"
red='\033[0;31m' red='\033[0;31m'
green='\033[0;32m' green='\033[0;32m'
@@ -604,7 +604,7 @@ restart_and_update_standalone() {
NZ_DASHBOARD_URL="https://${GITHUB_URL}/naibahq/nezha/releases/download/$version/dashboard-linux-$os_arch.zip" NZ_DASHBOARD_URL="https://${GITHUB_URL}/naibahq/nezha/releases/download/$version/dashboard-linux-$os_arch.zip"
fi fi
sudo wget -qO $NZ_DASHBOARD_PATH/app.zip $NZ_DASHBOARD_URL >/dev/null 2>&1 && sudo unzip -qq $NZ_DASHBOARD_PATH/app.zip -d $NZ_DASHBOARD_PATH && sudo mv $NZ_DASHBOARD_PATH/dist/dashboard-linux-$os_arch $NZ_DASHBOARD_PATH/app && sudo rm -r $NZ_DASHBOARD_PATH/app.zip $NZ_DASHBOARD_PATH/dist sudo wget -qO $NZ_DASHBOARD_PATH/app.zip $NZ_DASHBOARD_URL >/dev/null 2>&1 && sudo unzip -qq -o $NZ_DASHBOARD_PATH/app.zip -d $NZ_DASHBOARD_PATH && sudo mv $NZ_DASHBOARD_PATH/dashboard-linux-$os_arch $NZ_DASHBOARD_PATH/app && sudo rm $NZ_DASHBOARD_PATH/app.zip
if [ "$os_alpine" != 1 ]; then if [ "$os_alpine" != 1 ]; then
sudo systemctl enable nezha-dashboard sudo systemctl enable nezha-dashboard
+2 -2
View File
@@ -12,7 +12,7 @@ NZ_DASHBOARD_PATH="${NZ_BASE_PATH}/dashboard"
NZ_AGENT_PATH="${NZ_BASE_PATH}/agent" NZ_AGENT_PATH="${NZ_BASE_PATH}/agent"
NZ_DASHBOARD_SERVICE="/etc/systemd/system/nezha-dashboard.service" NZ_DASHBOARD_SERVICE="/etc/systemd/system/nezha-dashboard.service"
NZ_DASHBOARD_SERVICERC="/etc/init.d/nezha-dashboard" NZ_DASHBOARD_SERVICERC="/etc/init.d/nezha-dashboard"
NZ_VERSION="v0.18.3" NZ_VERSION="v0.19.0"
red='\033[0;31m' red='\033[0;31m'
green='\033[0;32m' green='\033[0;32m'
@@ -598,7 +598,7 @@ restart_and_update_standalone() {
NZ_DASHBOARD_URL="https://${GITHUB_URL}/naibahq/nezha/releases/download/$version/dashboard-linux-$os_arch.zip" NZ_DASHBOARD_URL="https://${GITHUB_URL}/naibahq/nezha/releases/download/$version/dashboard-linux-$os_arch.zip"
fi fi
sudo wget -qO $NZ_DASHBOARD_PATH/app.zip $NZ_DASHBOARD_URL >/dev/null 2>&1 && sudo unzip -qq $NZ_DASHBOARD_PATH/app.zip -d $NZ_DASHBOARD_PATH && sudo mv $NZ_DASHBOARD_PATH/dist/dashboard-linux-$os_arch $NZ_DASHBOARD_PATH/app && sudo rm -r $NZ_DASHBOARD_PATH/app.zip $NZ_DASHBOARD_PATH/dist sudo wget -qO $NZ_DASHBOARD_PATH/app.zip $NZ_DASHBOARD_URL >/dev/null 2>&1 && sudo unzip -qq -o $NZ_DASHBOARD_PATH/app.zip -d $NZ_DASHBOARD_PATH && sudo mv $NZ_DASHBOARD_PATH/dashboard-linux-$os_arch $NZ_DASHBOARD_PATH/app && sudo rm $NZ_DASHBOARD_PATH/app.zip
if [ "$os_alpine" != 1 ]; then if [ "$os_alpine" != 1 ]; then
sudo systemctl enable nezha-dashboard sudo systemctl enable nezha-dashboard
+19 -2
View File
@@ -3,6 +3,7 @@ package rpc
import ( import (
"errors" "errors"
"io" "io"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
) )
@@ -14,6 +15,18 @@ type ioStreamContext struct {
agentIoConnectCh chan struct{} agentIoConnectCh chan struct{}
} }
type bp struct {
buf []byte
}
var bufPool = sync.Pool{
New: func() any {
return &bp{
buf: make([]byte, 1024*1024),
}
},
}
func (s *NezhaHandler) CreateStream(streamId string) { func (s *NezhaHandler) CreateStream(streamId string) {
s.ioStreamMutex.Lock() s.ioStreamMutex.Lock()
defer s.ioStreamMutex.Unlock() defer s.ioStreamMutex.Unlock()
@@ -117,7 +130,9 @@ LOOP:
endCh := make(chan struct{}) endCh := make(chan struct{})
go func() { go func() {
_, innerErr := io.Copy(stream.userIo, stream.agentIo) bp := bufPool.Get().(*bp)
defer bufPool.Put(bp)
_, innerErr := io.CopyBuffer(stream.userIo, stream.agentIo, bp.buf)
if innerErr != nil { if innerErr != nil {
err = innerErr err = innerErr
} }
@@ -126,7 +141,9 @@ LOOP:
} }
}() }()
go func() { go func() {
_, innerErr := io.Copy(stream.agentIo, stream.userIo) bp := bufPool.Get().(*bp)
defer bufPool.Put(bp)
_, innerErr := io.CopyBuffer(stream.agentIo, stream.userIo, bp.buf)
if innerErr != nil { if innerErr != nil {
err = innerErr err = innerErr
} }
+17 -13
View File
@@ -90,12 +90,14 @@ func (s *NezhaHandler) RequestTask(h *pb.Host, stream pb.NezhaService_RequestTas
} }
closeCh := make(chan error) closeCh := make(chan error)
singleton.ServerLock.RLock() singleton.ServerLock.RLock()
singleton.ServerList[clientID].TaskCloseLock.Lock()
// 修复不断的请求 task 但是没有 return 导致内存泄漏 // 修复不断的请求 task 但是没有 return 导致内存泄漏
if singleton.ServerList[clientID].TaskClose != nil { if singleton.ServerList[clientID].TaskClose != nil {
close(singleton.ServerList[clientID].TaskClose) close(singleton.ServerList[clientID].TaskClose)
} }
singleton.ServerList[clientID].TaskStream = stream singleton.ServerList[clientID].TaskStream = stream
singleton.ServerList[clientID].TaskClose = closeCh singleton.ServerList[clientID].TaskClose = closeCh
singleton.ServerList[clientID].TaskCloseLock.Unlock()
singleton.ServerLock.RUnlock() singleton.ServerLock.RUnlock()
return <-closeCh return <-closeCh
} }
@@ -112,10 +114,10 @@ func (s *NezhaHandler) ReportSystemState(c context.Context, r *pb.State) (*pb.Re
singleton.ServerList[clientID].LastActive = time.Now() singleton.ServerList[clientID].LastActive = time.Now()
singleton.ServerList[clientID].State = &state singleton.ServerList[clientID].State = &state
// 如果从未记录过,先打点,等到小时时间点时入库 // 应对 dashboard 重启的情况,如果从未记录过,先打点,等到小时时间点时入库
if singleton.ServerList[clientID].PrevHourlyTransferIn == 0 || singleton.ServerList[clientID].PrevHourlyTransferOut == 0 { if singleton.ServerList[clientID].PrevTransferInSnapshot == 0 || singleton.ServerList[clientID].PrevTransferOutSnapshot == 0 {
singleton.ServerList[clientID].PrevHourlyTransferIn = int64(state.NetInTransfer) singleton.ServerList[clientID].PrevTransferInSnapshot = int64(state.NetInTransfer)
singleton.ServerList[clientID].PrevHourlyTransferOut = int64(state.NetOutTransfer) singleton.ServerList[clientID].PrevTransferOutSnapshot = int64(state.NetOutTransfer)
} }
return &pb.Receipt{Proced: true}, nil return &pb.Receipt{Proced: true}, nil
@@ -135,9 +137,8 @@ func (s *NezhaHandler) ReportSystemInfo(c context.Context, r *pb.Host) (*pb.Rece
// 检查并更新DDNS // 检查并更新DDNS
if singleton.Conf.DDNS.Enable && if singleton.Conf.DDNS.Enable &&
singleton.ServerList[clientID].EnableDDNS && singleton.ServerList[clientID].EnableDDNS &&
singleton.ServerList[clientID].Host != nil &&
host.IP != "" && host.IP != "" &&
singleton.ServerList[clientID].Host.IP != host.IP { (singleton.ServerList[clientID].Host == nil || singleton.ServerList[clientID].Host.IP != host.IP) {
serverDomain := singleton.ServerList[clientID].DDNSDomain serverDomain := singleton.ServerList[clientID].DDNSDomain
if singleton.Conf.DDNS.Provider == "" { if singleton.Conf.DDNS.Provider == "" {
provider, err = singleton.GetDDNSProviderFromProfile(singleton.ServerList[clientID].DDNSProfile) provider, err = singleton.GetDDNSProviderFromProfile(singleton.ServerList[clientID].DDNSProfile)
@@ -158,16 +159,15 @@ func (s *NezhaHandler) ReportSystemInfo(c context.Context, r *pb.Host) (*pb.Rece
} else { } else {
// 虽然会在启动时panic, 可以断言不会走这个分支, 但是考虑到动态加载配置或者其它情况, 这里输出一下方便检查奇奇怪怪的BUG // 虽然会在启动时panic, 可以断言不会走这个分支, 但是考虑到动态加载配置或者其它情况, 这里输出一下方便检查奇奇怪怪的BUG
log.Printf("NEZHA>> 未找到对应的DDNS配置(%s), 或者是provider填写不正确, 请前往config.yml检查你的设置\n", singleton.ServerList[clientID].DDNSProfile) log.Printf("NEZHA>> 未找到对应的DDNS配置(%s), 或者是provider填写不正确, 请前往config.yml检查你的设置", singleton.ServerList[clientID].DDNSProfile)
} }
} }
// 发送IP变动通知 // 发送IP变动通知
if singleton.Conf.EnableIPChangeNotification && if singleton.ServerList[clientID].Host != nil && singleton.Conf.EnableIPChangeNotification &&
((singleton.Conf.Cover == model.ConfigCoverAll && !singleton.Conf.IgnoredIPNotificationServerIDs[clientID]) || ((singleton.Conf.Cover == model.ConfigCoverAll && !singleton.Conf.IgnoredIPNotificationServerIDs[clientID]) ||
(singleton.Conf.Cover == model.ConfigCoverIgnoreAll && singleton.Conf.IgnoredIPNotificationServerIDs[clientID])) && (singleton.Conf.Cover == model.ConfigCoverIgnoreAll && singleton.Conf.IgnoredIPNotificationServerIDs[clientID])) &&
singleton.ServerList[clientID].Host != nil &&
singleton.ServerList[clientID].Host.IP != "" && singleton.ServerList[clientID].Host.IP != "" &&
host.IP != "" && host.IP != "" &&
singleton.ServerList[clientID].Host.IP != host.IP { singleton.ServerList[clientID].Host.IP != host.IP {
@@ -184,10 +184,14 @@ func (s *NezhaHandler) ReportSystemInfo(c context.Context, r *pb.Host) (*pb.Rece
nil) nil)
} }
// 判断是否是机器重启,如果是机器重启要录入最后记录的流量里面 /**
if singleton.ServerList[clientID].Host.BootTime < host.BootTime { * 这里的 singleton 中的数据都是关机前的旧数据
singleton.ServerList[clientID].PrevHourlyTransferIn = singleton.ServerList[clientID].PrevHourlyTransferIn - int64(singleton.ServerList[clientID].State.NetInTransfer) * agent 重启时bootTime 变大agent 端会先上报 host 信息然后上报 state 信息
singleton.ServerList[clientID].PrevHourlyTransferOut = singleton.ServerList[clientID].PrevHourlyTransferOut - int64(singleton.ServerList[clientID].State.NetOutTransfer) * 这是可以借助上报顺序的空档将停机前的流量统计数据标记下来加到下一个小时的数据点上
*/
if singleton.ServerList[clientID].Host != nil && singleton.ServerList[clientID].Host.BootTime < host.BootTime {
singleton.ServerList[clientID].PrevTransferInSnapshot = singleton.ServerList[clientID].PrevTransferInSnapshot - int64(singleton.ServerList[clientID].State.NetInTransfer)
singleton.ServerList[clientID].PrevTransferOutSnapshot = singleton.ServerList[clientID].PrevTransferOutSnapshot - int64(singleton.ServerList[clientID].State.NetOutTransfer)
} }
// 不要冲掉国家码 // 不要冲掉国家码
+39 -53
View File
@@ -3,86 +3,72 @@ package singleton
import ( import (
"fmt" "fmt"
"log" "log"
"slices"
ddns2 "github.com/naiba/nezha/pkg/ddns" ddns2 "github.com/naiba/nezha/pkg/ddns"
) )
func RetryableUpdateDomain(provider ddns2.Provider, config *ddns2.DomainConfig, maxRetries int) bool { const (
if nil == config { ProviderWebHook = "webhook"
return false ProviderCloudflare = "cloudflare"
ProviderTencentCloud = "tencentcloud"
)
type ProviderFunc func(*ddns2.DomainConfig) ddns2.Provider
func RetryableUpdateDomain(provider ddns2.Provider, domainConfig *ddns2.DomainConfig, maxRetries int) {
if domainConfig == nil {
return
} }
for retries := 0; retries < maxRetries; retries++ { for retries := 0; retries < maxRetries; retries++ {
log.Printf("NEZHA>> 正在尝试更新域名(%s)DDNS(%d/%d)\n", config.FullDomain, retries+1, maxRetries) log.Printf("NEZHA>> 正在尝试更新域名(%s)DDNS(%d/%d)", domainConfig.FullDomain, retries+1, maxRetries)
if provider.UpdateDomain(config) { if err := provider.UpdateDomain(domainConfig); err != nil {
log.Printf("NEZHA>> 尝试更新域名(%s)DDNS成功\n", config.FullDomain) log.Printf("NEZHA>> 尝试更新域名(%s)DDNS失败: %v", domainConfig.FullDomain, err)
return true } else {
log.Printf("NEZHA>> 尝试更新域名(%s)DDNS成功", domainConfig.FullDomain)
break
} }
} }
log.Printf("NEZHA>> 尝试更新域名(%s)DDNS失败\n", config.FullDomain)
return false
} }
// Deprecated
func GetDDNSProviderFromString(provider string) (ddns2.Provider, error) { func GetDDNSProviderFromString(provider string) (ddns2.Provider, error) {
switch provider { switch provider {
case "webhook": case ProviderWebHook:
return &ddns2.ProviderWebHook{ return ddns2.NewProviderWebHook(Conf.DDNS.WebhookURL, Conf.DDNS.WebhookMethod, Conf.DDNS.WebhookRequestBody, Conf.DDNS.WebhookHeaders), nil
URL: Conf.DDNS.WebhookURL, case ProviderCloudflare:
RequestMethod: Conf.DDNS.WebhookMethod, return ddns2.NewProviderCloudflare(Conf.DDNS.AccessSecret), nil
RequestBody: Conf.DDNS.WebhookRequestBody, case ProviderTencentCloud:
RequestHeader: Conf.DDNS.WebhookHeaders, return ddns2.NewProviderTencentCloud(Conf.DDNS.AccessID, Conf.DDNS.AccessSecret), nil
}, nil default:
case "dummy": return new(ddns2.ProviderDummy), fmt.Errorf("无法找到配置的DDNS提供者 %s", provider)
return &ddns2.ProviderDummy{}, nil
case "cloudflare":
return &ddns2.ProviderCloudflare{
Secret: Conf.DDNS.AccessSecret,
}, nil
case "tencentcloud":
return &ddns2.ProviderTencentCloud{
SecretID: Conf.DDNS.AccessID,
SecretKey: Conf.DDNS.AccessSecret,
}, nil
} }
return &ddns2.ProviderDummy{}, fmt.Errorf("无法找到配置的DDNS提供者%s", Conf.DDNS.Provider)
} }
func GetDDNSProviderFromProfile(profileName string) (ddns2.Provider, error) { func GetDDNSProviderFromProfile(profileName string) (ddns2.Provider, error) {
profile, ok := Conf.DDNS.Profiles[profileName] profile, ok := Conf.DDNS.Profiles[profileName]
if !ok { if !ok {
return &ddns2.ProviderDummy{}, fmt.Errorf("未找到配置项 %s", profileName) return new(ddns2.ProviderDummy), fmt.Errorf("未找到配置项 %s", profileName)
} }
switch profile.Provider { switch profile.Provider {
case "webhook": case ProviderWebHook:
return &ddns2.ProviderWebHook{ return ddns2.NewProviderWebHook(profile.WebhookURL, profile.WebhookMethod, profile.WebhookRequestBody, profile.WebhookHeaders), nil
URL: profile.WebhookURL, case ProviderCloudflare:
RequestMethod: profile.WebhookMethod, return ddns2.NewProviderCloudflare(profile.AccessSecret), nil
RequestBody: profile.WebhookRequestBody, case ProviderTencentCloud:
RequestHeader: profile.WebhookHeaders, return ddns2.NewProviderTencentCloud(profile.AccessID, profile.AccessSecret), nil
}, nil default:
case "dummy": return new(ddns2.ProviderDummy), fmt.Errorf("无法找到配置的DDNS提供者 %s", profile.Provider)
return &ddns2.ProviderDummy{}, nil
case "cloudflare":
return &ddns2.ProviderCloudflare{
Secret: profile.AccessSecret,
}, nil
case "tencentcloud":
return &ddns2.ProviderTencentCloud{
SecretID: profile.AccessID,
SecretKey: profile.AccessSecret,
}, nil
} }
return &ddns2.ProviderDummy{}, fmt.Errorf("无法找到配置的DDNS提供者%s", profile.Provider)
} }
func ValidateDDNSProvidersFromProfiles() error { func ValidateDDNSProvidersFromProfiles() error {
validProviders := map[string]bool{"webhook": true, "dummy": true, "cloudflare": true, "tencentcloud": true} validProviders := []string{ProviderWebHook, ProviderCloudflare, ProviderTencentCloud}
providers := make(map[string]string) for _, profile := range Conf.DDNS.Profiles {
for profileName, profile := range Conf.DDNS.Profiles { if ok := slices.Contains(validProviders, profile.Provider); !ok {
if _, ok := validProviders[profile.Provider]; !ok {
return fmt.Errorf("无法找到配置的DDNS提供者%s", profile.Provider) return fmt.Errorf("无法找到配置的DDNS提供者%s", profile.Provider)
} }
providers[profileName] = profile.Provider
} }
return nil return nil
} }
+1
View File
@@ -34,6 +34,7 @@ func loadServers() {
innerS := s innerS := s
innerS.Host = &model.Host{} innerS.Host = &model.Host{}
innerS.State = &model.HostState{} innerS.State = &model.HostState{}
innerS.TaskCloseLock = new(sync.Mutex)
ServerList[innerS.ID] = &innerS ServerList[innerS.ID] = &innerS
SecretToID[innerS.Secret] = innerS.ID SecretToID[innerS.Secret] = innerS.ID
ServerTagToIDList[innerS.Tag] = append(ServerTagToIDList[innerS.Tag], innerS.ID) ServerTagToIDList[innerS.Tag] = append(ServerTagToIDList[innerS.Tag], innerS.ID)
+4 -4
View File
@@ -100,14 +100,14 @@ func RecordTransferHourlyUsage() {
for id, server := range ServerList { for id, server := range ServerList {
tx := model.Transfer{ tx := model.Transfer{
ServerID: id, ServerID: id,
In: server.State.NetInTransfer - uint64(server.PrevHourlyTransferIn), In: utils.Uint64SubInt64(server.State.NetInTransfer, server.PrevTransferInSnapshot),
Out: server.State.NetOutTransfer - uint64(server.PrevHourlyTransferOut), Out: utils.Uint64SubInt64(server.State.NetOutTransfer, server.PrevTransferOutSnapshot),
} }
if tx.In == 0 && tx.Out == 0 { if tx.In == 0 && tx.Out == 0 {
continue continue
} }
server.PrevHourlyTransferIn = int64(server.State.NetInTransfer) server.PrevTransferInSnapshot = int64(server.State.NetInTransfer)
server.PrevHourlyTransferOut = int64(server.State.NetOutTransfer) server.PrevTransferOutSnapshot = int64(server.State.NetOutTransfer)
tx.CreatedAt = nowTrimSeconds tx.CreatedAt = nowTrimSeconds
txs = append(txs, tx) txs = append(txs, tx)
} }