diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ccb9344 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +.idea +*.sublime-workspace +server/obj +server/sergate +web/json/stats.json +web/json/stats.json~ +*.bak-* +__pycache__ +*.pyc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..548cbcd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies + run: sudo apt-get update && sudo apt-get install -y gcc g++ make libcurl4-openssl-dev python3 + + - name: Build server + run: make -C server -j2 + + - name: Check scripts + run: | + python3 -m py_compile server/manage_api.py clients/client-linux.py clients/client-psutil.py plugin/bot-telegram.py + sh -n server/entrypoint-server.sh clients/entrypoint.sh status.sh + node --check web/js/app.js + + - name: Validate compose files + run: | + docker compose -f docker-compose-server.yml config + docker compose -f docker-compose-client.yml config + + - name: Build Docker images + run: | + docker build -f Dockerfile.server -t serverstatus-server:test . + docker build -f Dockerfile.client -t serverstatus-client:test . diff --git a/Dockerfile.client b/Dockerfile.client index 6440a6d..e156121 100644 --- a/Dockerfile.client +++ b/Dockerfile.client @@ -21,7 +21,7 @@ ENV SERVER=127.0.0.1 \ CU=cu.tz.cloudcpp.com \ CT=ct.tz.cloudcpp.com \ CM=cm.tz.cloudcpp.com \ - CLIENT=linux + CLIENT=psutil RUN chmod +x /app/entrypoint.sh diff --git a/Dockerfile.server b/Dockerfile.server index 77abda9..b9a384f 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -1,29 +1,42 @@ -# The Dockerfile for build localhost source, not git repo -FROM debian:bookworm AS builder +FROM python:3.12-slim-bookworm AS builder LABEL maintainer="cppla " -RUN apt-get update -y && apt-get -y install gcc g++ make libcurl4-openssl-dev +RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \ + && apt-get -o Acquire::Retries=3 update -y \ + && apt-get install -y --no-install-recommends gcc g++ make libcurl4-openssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* -COPY . . +COPY server/ /server/ WORKDIR /server -RUN make -j -RUN pwd && ls -a +RUN make -j && strip /server/sergate -# glibc env run -FROM nginx:latest +FROM debian:bookworm-slim -RUN mkdir -p /ServerStatus/server/ && ln -sf /dev/null /var/log/nginx/access.log && ln -sf /dev/null /var/log/nginx/error.log +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \ + && apt-get -o Acquire::Retries=3 update -y \ + && apt-get install -y --no-install-recommends nginx-light python3 openssl libcurl4 libstdc++6 tzdata \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /ServerStatus/server/ \ + && ln -sf /dev/null /var/log/nginx/access.log \ + && ln -sf /dev/null /var/log/nginx/error.log \ + && rm -f /etc/nginx/sites-enabled/default -COPY --from=builder server /ServerStatus/server/ -COPY --from=builder web /usr/share/nginx/html/ +COPY --from=builder /server/sergate /ServerStatus/server/sergate +COPY server/config.json /ServerStatus/server/config.json +COPY server/manage_api.py /ServerStatus/server/manage_api.py +COPY server/entrypoint-server.sh /ServerStatus/server/entrypoint-server.sh +COPY web /usr/share/nginx/html/ +COPY server/nginx-serverstatus.conf /etc/nginx/conf.d/default.conf + +RUN chmod +x /ServerStatus/server/entrypoint-server.sh -# china time ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone EXPOSE 80 35601 -HEALTHCHECK --interval=5s --timeout=3s --retries=3 CMD curl --fail http://localhost:80 || bash -c 'kill -s 15 -1 && (sleep 10; kill -s 9 -1)' -CMD ["sh", "-c", "/etc/init.d/nginx start && /ServerStatus/server/sergate --config=/ServerStatus/server/config.json --web-dir=/usr/share/nginx/html"] +HEALTHCHECK --interval=10s --timeout=3s --retries=3 CMD python3 -c "import os,urllib.request; pid=int(open('/tmp/serverstatus-sergate.pid').read().strip()); os.kill(pid,0); urllib.request.urlopen('http://127.0.0.1/',timeout=2).read(1)" +CMD ["/ServerStatus/server/entrypoint-server.sh"] diff --git a/README.md b/README.md index 06b54d4..59889ad 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ -# ServerStatus中文版: +# ServerStatus 中文版 -* ServerStatus中文版是一个酷炫高逼格的云探针、云监控、服务器云监控、多服务器探针~。 -* 在线演示:https://tz.cloudcpp.com +ServerStatus 是一个轻量的服务器探针和云监控面板,支持多节点在线状态、资源占用、三网延迟、服务监测、SSL 证书检查、Watchdog 告警和 Web 配置管理。 + +在线演示:https://tz.cloudcpp.com [![Python Support](https://img.shields.io/badge/python-3.6%2B%20-blue.svg)](https://github.com/cppla/ServerStatus) [![C++ Compiler](http://img.shields.io/badge/C++-GNU-blue.svg?style=flat&logo=cplusplus)](https://github.com/cppla/ServerStatus) @@ -10,219 +11,371 @@ ![Latest Host Version](https://dl.cpp.la/Archive/serverstatus_1_1_7.png) -`Watchdog触发式告警,interval只是为了防止频繁收到报警,并不是探测间隔。值得注意的是Exprtk使用窄字符类型,中文等Unicode字符无法解析计算。 AI已经能够取代大部分程序员` - +## 功能 -# 部署: +- 主机监控:在线状态、CPU、内存、虚存、硬盘、IO、负载、进程、连接、流量。 +- 网络质量:联通、电信、移动三网延迟和丢包。 +- 服务监测:通过客户端上报自定义监测结果。 +- SSL 证书:检查证书剩余天数、域名不匹配和过期风险。 +- Watchdog:基于表达式规则触发告警回调。 +- WebUI:主机、服务、证书、配置四个页面;配置页可增删改节点、监测、证书和 Watchdog 规则。 +- HTTP 管理 API:方便 WebUI 或 AI Agent 读写配置、重载和重启服务。 + +注意:Watchdog 的 `interval` 是最小通知间隔,用于避免频繁报警,并不是探测间隔。`rule` 使用 Exprtk 表达式,当前窄字符解析对中文等 Unicode 字符不友好,规则中建议使用英文、数字和字段名。 + +## 快速启动 + +### 服务端 Docker Compose + +推荐用 Compose 启动服务端。默认 Web 端口映射为 `8080:80`,客户端连接端口为 `35601`。 -【服务端】: ```bash - -`Docker`: - -wget --no-check-certificate -qO ~/serverstatus-config.json https://raw.githubusercontent.com/cppla/ServerStatus/master/server/config.json && mkdir ~/serverstatus-monthtraffic -docker run -d --restart=always --name=serverstatus-server -v ~/serverstatus-config.json:/ServerStatus/server/config.json -v ~/serverstatus-monthtraffic:/usr/share/nginx/html/json -p 80:80 -p 35601:35601 cppla/serverstatus:server - -`Docker-compose`: -docker compose -f docker-compose-server.yml up -d - -`Dockerfile.server`(本地构建服务端): -docker build -f Dockerfile.server -t serverstatus-server . +ADMIN_TOKEN='your-strong-token' docker compose -f docker-compose-server.yml up -d --build ``` -【客户端】: +启动后访问: + +- WebUI:http://127.0.0.1:8080/ +- HTTP API 自检:http://127.0.0.1:8080/api/health +- HTTP API 文档:http://127.0.0.1:8080/api/schema + +`ADMIN_TOKEN` 可选;不设置时 WebUI 仍可查看监控数据,但「配置」页和写入类 API 不允许修改配置。 + +`docker-compose-server.yml` 默认挂载: + +```text +./server/config.json -> /ServerStatus/server/config.json +./web/json -> /usr/share/nginx/html/json +``` + +### 服务端 Docker Run + ```bash +wget --no-check-certificate -qO ~/serverstatus-config.json \ + https://raw.githubusercontent.com/cppla/ServerStatus/master/server/config.json +mkdir -p ~/serverstatus-monthtraffic -`Shell`: - -wget --no-check-certificate -qO client-linux.py 'https://raw.githubusercontent.com/cppla/ServerStatus/master/clients/client-linux.py' && nohup python3 client-linux.py SERVER={$SERVER} USER={$USER} >/dev/null 2>&1 & - -`Docker`: -docker run -d --restart=always --name=serverstatus-client --network=host --pid=host -e SERVER=127.0.0.1 -e USER=s01 cppla/serverstatus:client - -`Docker-compose`: -SERVER=127.0.0.1 USER=s01 docker compose -f docker-compose-client.yml up -d --force-recreate - -`Dockerfile.client`(本地构建客户端): -docker build -f Dockerfile.client -t serverstatus-client . - -`docker环境变量`: -SERVER --- 可选 - 默认 127.0.0.1 -USER --- 可选 - 默认 s01 -PORT --- 可选 - 默认 35601 -PASSWORD --- 可选 - 默认 USER_DEFAULT_PASSWORD -INTERVAL --- 可选 - 默认 1 -PROBEPORT --- 可选 - 默认 80 -PROBE_PROTOCOL_PREFER --- 可选 - 默认 ipv4 -PING_PACKET_HISTORY_LEN --- 可选 - 默认 100 -CU --- 可选 - 默认 cu.tz.cloudcpp.com -CT --- 可选 - 默认 ct.tz.cloudcpp.com -CM --- 可选 - 默认 cm.tz.cloudcpp.com -CLIENT --- 可选 - 默认psutil, client可选 +docker run -d --restart=always --name=serverstatus-server \ + -e ADMIN_TOKEN='your-strong-token' \ + -v ~/serverstatus-config.json:/ServerStatus/server/config.json \ + -v ~/serverstatus-monthtraffic:/usr/share/nginx/html/json \ + -p 8080:80 -p 35601:35601 \ + cppla/serverstatus:server ``` -# 教程: - -**【服务端配置】** - -#### 一、生成服务端程序 -``` -`Debian/Ubuntu`: apt-get -y install gcc g++ make libcurl4-openssl-dev -`Centos/Redhat`: yum -y install gcc gcc-c++ make libcurl-devel +### 客户端 Docker Compose -cd ServerStatus/server && make -./sergate -``` -如果没错误提示,OK,ctrl+c关闭;如果有错误提示,检查35601端口是否被占用 +客户端必须使用服务端 `config.json` 中存在的 `username/password`。默认示例用户名是 `s01`。 -#### 二、修改配置文件 -```diff -! watchdog rule 可以为任何已知字段的表达式。注意Exprtk库默认使用窄字符类型,中文等Unicode字符无法解析计算,等待修复 -! watchdog interval 最小通知间隔 -! watchdog callback 可自定义为Post方法的URL,告警内容将拼接其后并发起回调 - -! Telegram: https://api.telegram.org/bot你自己的密钥/sendMessage?parse_mode=HTML&disable_web_page_preview=true&chat_id=你自己的标识&text= -! Server酱: https://sctapi.ftqq.com/你自己的密钥.send?title=ServerStatus&desp= -! PushDeer: https://api2.pushdeer.com/message/push?pushkey=你自己的密钥&text= -! HttpBasicAuth: https://用户名:密码@你自己的域名/api/push?message= +```bash +SERVER=127.0.0.1 USER=s01 PASSWORD=USER_DEFAULT_PASSWORD \ + docker compose -f docker-compose-client.yml up -d --force-recreate ``` +注意:`USER` 是 docker compose 常见的宿主机环境变量名。如果没有在运行命令里显式传递,或传递方式不正确,Compose 可能会把系统里的 `$USER` 解析成本机用户名,而不是默认 `s01`。推荐优先级: + +1. 运行命令显式传递 `USER=...` +2. 用户修改 `docker-compose-client.yml` 里的 `USER` 默认值 +3. 依赖 Docker/系统环境 + +### 客户端 Docker Run + +```bash +docker run -d --restart=always --name=serverstatus-client \ + --network=host --pid=host \ + -e SERVER=127.0.0.1 \ + -e USER=s01 \ + -e PASSWORD=USER_DEFAULT_PASSWORD \ + cppla/serverstatus:client ``` + +### 客户端环境变量 + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `SERVER` | `127.0.0.1` | 服务端地址 | +| `USER` | `s01` | 客户端用户名,必须匹配服务端配置 | +| `PORT` | `35601` | 服务端 sergate 端口 | +| `PASSWORD` | `USER_DEFAULT_PASSWORD` | 客户端密码,必须匹配服务端配置 | +| `INTERVAL` | `1` | 上报间隔 | +| `PROBEPORT` | `80` | 探测端口 | +| `PROBE_PROTOCOL_PREFER` | `ipv4` | 探测协议偏好 | +| `PING_PACKET_HISTORY_LEN` | `100` | Ping 历史长度 | +| `CU` | `cu.tz.cloudcpp.com` | 联通探测地址 | +| `CT` | `ct.tz.cloudcpp.com` | 电信探测地址 | +| `CM` | `cm.tz.cloudcpp.com` | 移动探测地址 | +| `CLIENT` | `psutil` | 客户端实现,可选 `psutil` 或 `linux` | + +## WebUI 配置管理 + +WebUI 顶部保留四个入口: + +- `主机`:节点列表、筛选、排序和右侧详情。 +- `服务`:服务监测结果。 +- `证书`:SSL 证书检查结果。 +- `配置`:编辑 `servers`、`monitors`、`sslcerts`、`watchdog`。 + +使用配置页前需要: + +1. 服务端设置 `ADMIN_TOKEN`。 +2. 打开 http://127.0.0.1:8080/。 +3. 进入「配置」页,输入 `ADMIN_TOKEN`。 +4. 保存后服务端会写入 `config.json` 并向 `sergate` 发送 `SIGHUP` 重载。 + +如果你使用 Docker 单文件 bind mount 挂载 `config.json`,管理 API 会自动兼容 Docker 的 `EBUSY` 写入限制:先备份,再原地写回。 + +## HTTP 管理 API + +Docker 服务端镜像已内置 HTTP API,并通过 nginx 暴露在 Web 端口下。源码手动运行时需要单独启动 `server/manage_api.py`;如果要让 WebUI 的「配置」页可用,还需要把 `/api/` 反代到 `manage_api.py`。认证方式: + +```bash +Authorization: Bearer your-strong-token +``` + +无需认证的接口: + +```bash +curl http://127.0.0.1:8080/api/health +curl http://127.0.0.1:8080/api/schema +``` + +读取完整配置: + +```bash +curl -H 'Authorization: Bearer your-strong-token' \ + http://127.0.0.1:8080/api/config +``` + +节点 CRUD: + +```bash +curl -X POST http://127.0.0.1:8080/api/servers \ + -H 'Authorization: Bearer your-strong-token' \ + -H 'Content-Type: application/json' \ + -d '{"username":"s05","name":"node5","type":"kvm","host":"host5","location":"SG","password":"USER_DEFAULT_PASSWORD","monthstart":1}' + +curl -X PUT http://127.0.0.1:8080/api/servers/s05 \ + -H 'Authorization: Bearer your-strong-token' \ + -H 'Content-Type: application/json' \ + -d '{"username":"s05","name":"node5-new","type":"kvm","host":"host5","location":"SG","password":"USER_DEFAULT_PASSWORD","monthstart":1}' + +curl -X DELETE -H 'Authorization: Bearer your-strong-token' \ + http://127.0.0.1:8080/api/servers/s05 +``` + +`monitors`、`sslcerts`、`watchdog` 也支持细粒度 CRUD。更新和删除可以用数字 `index`;如果 `name` 唯一,也可以用 URL 编码后的 `name`。 + +```bash +curl -X POST http://127.0.0.1:8080/api/monitors \ + -H 'Authorization: Bearer your-strong-token' \ + -H 'Content-Type: application/json' \ + -d '{"name":"demo","host":"https://example.com","type":"https","interval":600}' + +curl -X PUT http://127.0.0.1:8080/api/sslcerts/0 \ + -H 'Authorization: Bearer your-strong-token' \ + -H 'Content-Type: application/json' \ + -d '{"name":"example","domain":"https://example.com","port":443,"interval":7200,"callback":"https://yourSMSurl"}' + +curl -X DELETE -H 'Authorization: Bearer your-strong-token' \ + http://127.0.0.1:8080/api/watchdog/0 +``` + +重载配置或重启 `sergate`: + +```bash +curl -X POST -H 'Authorization: Bearer your-strong-token' \ + http://127.0.0.1:8080/api/reload + +curl -X POST -H 'Authorization: Bearer your-strong-token' \ + http://127.0.0.1:8080/api/restart +``` + +完整端点以 `/api/schema` 输出为准。 + +## 配置文件 + +配置文件默认路径: + +- Docker 服务端:`/ServerStatus/server/config.json` +- 源码运行:`server/config.json`,或通过 `--config` 指定 + +基础示例: + +```json { - "servers": - [ - { - "username": "s01", - "name": "vps-1", - "type": "kvm", - "host": "chengdu", - "location": "🇨🇳", - "password": "USER_DEFAULT_PASSWORD", - "monthstart": 1 - } - ], - "monitors": [ - { - "name": "抖音", - "host": "https://www.douyin.com", - "interval": 600, - "type": "https" - }, - { - "name": "百度", - "host": "https://www.baidu.com", - "interval": 600, - "type": "https" - } - ], - "sslcerts": [ - { - "name": "demo域名", - "domain": "https://demo.example.com", - "port": 443, - "interval": 600, - "callback": "https://yourSMSurl" - } - ], - "watchdog": - [ - { - "name": "服务器负载高监控,排除内存大于32G物理机,同时排除node1机器", - "rule": "cpu>90&load_1>4&memory_total<33554432&name!='node1'", - "interval": 600, - "callback": "https://yourSMSurl" - }, - { - "name": "服务器内存使用率过高监控,排除小于1G的机器", - "rule": "(memory_used/memory_total)*100>90&memory_total>1048576", - "interval": 600, - "callback": "https://yourSMSurl" - }, - { - "name": "服务器宕机告警", - "rule": "online4=0&online6=0", - "interval": 600, - "callback": "https://yourSMSurl" - }, - { - "name": "DDOS和CC攻击监控,限制甲骨文机器", - "rule": "tcp_count>600&type='Oracle'", - "interval": 300, - "callback": "https://yourSMSurl" - }, - { - "name": "服务器月出口流量999GB告警", - "rule": "(network_out-last_network_out)/1024/1024/1024>999", - "interval": 3600, - "callback": "https://yourSMSurl" - }, - { - "name": "阿里云服务器流量18GB告警,限制username为乌兰察布", - "rule": "(network_out-last_network_out)/1024/1024/1024>18&(username='wlcb1'|username='wlcb2'|username='wlcb3'|username='wlcb4')", - "interval": 3600, - "callback": "https://yourSMSurl" - }, - { - "name": "重要线路丢包率过高检查", - "rule": "(ping_10010>10|ping_189>10|ping_10086>10)&(host='sgp'|host='qqhk'|host='hk-21-x'|host='hk-31-x')", - "interval": 600, - "callback": "https://yourSMSurl" - }, - { - "name": "你可以组合任何已知字段的表达式", - "rule": "(hdd_used/hdd_total)*100>95", - "interval": 1800, - "callback": "https://yourSMSurl" - } - ] -} + "servers": [ + { + "username": "s01", + "name": "node1", + "type": "kvm", + "host": "host1", + "location": "CN", + "password": "USER_DEFAULT_PASSWORD", + "monthstart": 1 + } + ], + "monitors": [ + { + "name": "example", + "host": "https://example.com", + "interval": 600, + "type": "https" + } + ], + "sslcerts": [ + { + "name": "example", + "domain": "https://example.com", + "port": 443, + "interval": 7200, + "callback": "https://yourSMSurl" + } + ], + "watchdog": [ + { + "name": "offline warning", + "rule": "online4=0&online6=0", + "interval": 600, + "callback": "https://yourSMSurl" + }, + { + "name": "cpu high warning", + "rule": "cpu>90&load_1>5&username!='s01'", + "interval": 600, + "callback": "https://yourSMSurl" + } + ] +} ``` -#### 三、拷贝ServerStatus/status到你的网站目录 -例如: -``` -sudo cp -r ServerStatus/web/* /home/wwwroot/default +常见 Watchdog 回调: + +```text +Telegram: https://api.telegram.org/bot你的密钥/sendMessage?parse_mode=HTML&disable_web_page_preview=true&chat_id=你的标识&text= +Server酱: https://sctapi.ftqq.com/你的密钥.send?title=ServerStatus&desp= +PushDeer: https://api2.pushdeer.com/message/push?pushkey=你的密钥&text= +HttpBasicAuth: https://用户名:密码@你的域名/api/push?message= ``` -#### 四、运行服务端: -web-dir参数为上一步设置的网站根目录,务必修改成自己网站的路径 -``` -./sergate --config=config.json --web-dir=/home/wwwroot/default -``` +## 源码编译和运行 + +服务端依赖: -**【客户端配置】** - -#### client-linux.py Linux版 ```bash -# 1、修改 client-linux.py 中的 SERVER、username、password -python3 client-linux.py -# 2、以传参的方式启动 -python3 client-linux.py SERVER=127.0.0.1 USER=s01 +# Debian/Ubuntu +apt-get -y install gcc g++ make libcurl4-openssl-dev python3 nginx openssl +# CentOS/RedHat +yum -y install gcc gcc-c++ make libcurl-devel python3 nginx openssl ``` -#### client-psutil.py 跨平台版 +编译并运行 `sergate`: + +```bash +cd ServerStatus/server +make +mkdir -p ../web/json +./sergate --config=config.json --web-dir=../web & +echo $! > /tmp/serverstatus-sergate.pid +``` + +如果只需要 HTTP API,可以再启动 `manage_api.py`: + +```bash +cd ServerStatus/server +ADMIN_TOKEN='your-strong-token' \ +CONFIG_PATH="$PWD/config.json" \ +SERGATE_PID_FILE=/tmp/serverstatus-sergate.pid \ +ADMIN_API_BIND=127.0.0.1 \ +ADMIN_API_PORT=35602 \ +python3 manage_api.py +``` + +源码方式直连 API: + +```bash +curl http://127.0.0.1:35602/api/health +curl -H 'Authorization: Bearer your-strong-token' \ + http://127.0.0.1:35602/api/config +``` + +如果要通过 WebUI 使用「配置」页,需要 nginx 同时提供静态文件并反代 `/api/`。示例配置: + +```nginx +server { + listen 8080; + server_name _; + + root /path/to/ServerStatus/web; + index index.html; + + location / { + try_files $uri $uri/ =404; + } + + location /json/ { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location /api/ { + proxy_pass http://127.0.0.1:35602; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + } +} +``` + +启动失败时优先检查端口占用:`35601` 是 `sergate` 客户端上报端口,`35602` 是源码方式的管理 API 端口,`8080` 是上面示例的 Web 端口。源码手动运行没有 Docker 入口脚本守护;调用 `/api/restart` 会向 `sergate` 发送 `SIGTERM`,需要你用 systemd、supervisor 或 shell 循环自行拉起。 + +## 客户端源码运行 + +`client-linux.py`: + +```bash +wget --no-check-certificate -qO client-linux.py \ + https://raw.githubusercontent.com/cppla/ServerStatus/master/clients/client-linux.py + +nohup python3 client-linux.py SERVER=127.0.0.1 USER=s01 PASSWORD=USER_DEFAULT_PASSWORD \ + >/dev/null 2>&1 & +``` + +`client-psutil.py`: + ```bash -# 安装依赖 # Debian/Ubuntu apt -y install python3-psutil -# Centos/Redhat -yum -y install python3-pip gcc python3-devel && pip3 install psutil -# Windows: 从 https://pypi.org/project/psutil/ 安装 + +# CentOS/RedHat +yum -y install python3-pip gcc python3-devel +pip3 install psutil + +python3 clients/client-psutil.py SERVER=127.0.0.1 USER=s01 PASSWORD=USER_DEFAULT_PASSWORD ``` -#### 后台运行与开机启动 +后台运行与开机启动: + ```bash -# 后台运行 -nohup python3 client-linux.py & +nohup python3 client-linux.py SERVER=127.0.0.1 USER=s01 PASSWORD=USER_DEFAULT_PASSWORD & -# 开机启动 (crontab -e) -@reboot /usr/bin/python3 /path/to/client-linux.py +# crontab -e +@reboot /usr/bin/python3 /path/to/client-linux.py SERVER=127.0.0.1 USER=s01 PASSWORD=USER_DEFAULT_PASSWORD ``` -# Make Better +## 本地构建镜像 + +```bash +docker build -f Dockerfile.server -t cppla/serverstatus:server . +docker build -f Dockerfile.client -t cppla/serverstatus:client . +``` + +## Make Better * BotoX:https://github.com/BotoX/ServerStatus -* mojeda: https://github.com/mojeda -* mojeda's ServerStatus: https://github.com/mojeda/ServerStatus -* BlueVM's project: http://www.lowendtalk.com/discussion/comment/169690#Comment_169690 +* mojeda:https://github.com/mojeda +* mojeda's ServerStatus:https://github.com/mojeda/ServerStatus +* BlueVM's project:http://www.lowendtalk.com/discussion/comment/169690#Comment_169690 diff --git a/docker-compose-client.yml b/docker-compose-client.yml index 1d348dd..8a09a89 100644 --- a/docker-compose-client.yml +++ b/docker-compose-client.yml @@ -10,6 +10,10 @@ services: pid: host environment: SERVER: "${SERVER:-127.0.0.1}" + # 注意:USER 会被 docker compose 在宿主机环境中先插值。 + # 如果运行时没有显式传递,或传递方式不正确,可能会被 Docker/系统环境中的 $USER + # 替换成本机用户名,而不是期望的默认 s01。 + # 推荐优先级:运行程序时显式传递 USER > 用户修改这里的 USER 默认值 > Docker/系统环境。 USER: "${USER:-s01}" PORT: "${PORT:-35601}" PASSWORD: "${PASSWORD:-USER_DEFAULT_PASSWORD}" diff --git a/docker-compose-server.yml b/docker-compose-server.yml index 509fbcf..18e03a5 100644 --- a/docker-compose-server.yml +++ b/docker-compose-server.yml @@ -5,12 +5,14 @@ services: dockerfile: Dockerfile.server image: cppla/serverstatus:server healthcheck: - test: curl --fail http://localhost:80 || bash -c 'kill -s 15 -1 && (sleep 10; kill -s 9 -1)' + test: ["CMD-SHELL", "python3 -c \"import os,urllib.request; pid=int(open('/tmp/serverstatus-sergate.pid').read().strip()); os.kill(pid,0); urllib.request.urlopen('http://127.0.0.1/',timeout=2).read(1)\""] interval: 30s timeout: 10s retries: 5 container_name: serverstatus-server restart: unless-stopped + environment: + ADMIN_TOKEN: "${ADMIN_TOKEN:-}" networks: serverstatus-network: ipv4_address: 172.23.0.2 diff --git a/server/Makefile b/server/Makefile index 83f25c1..dddaedc 100644 --- a/server/Makefile +++ b/server/Makefile @@ -1,4 +1,5 @@ OUT = sergate +.DEFAULT_GOAL := $(OUT) #CC = clang CC = gcc @@ -19,10 +20,13 @@ C_OBJS := $(patsubst $(SDIR)/%.c,$(ODIR)/%.o,$(C_SRCS)) CXX_OBJS := $(patsubst $(SDIR)/%.cpp,$(ODIR)/%.o,$(CXX_SRCS)) OBJS := $(C_OBJS) $(CXX_OBJS) -$(ODIR)/%.o: $(SDIR)/%.c +$(ODIR): + mkdir -p $(ODIR) + +$(ODIR)/%.o: $(SDIR)/%.c | $(ODIR) $(CC) -c $(INC) $(CFLAGS) $< -o $@ -$(ODIR)/%.o: $(SDIR)/%.cpp +$(ODIR)/%.o: $(SDIR)/%.cpp | $(ODIR) $(CXX) -c $(INC) $(CXXFLAGS) $< -o $@ $(OUT): $(OBJS) @@ -31,4 +35,4 @@ $(OUT): $(OBJS) .PHONY: clean clean: - rm -f $(ODIR)/*.o $(OUT) \ No newline at end of file + rm -f $(ODIR)/*.o $(OUT) diff --git a/server/config.json b/server/config.json index acc5622..9ec27a1 100644 --- a/server/config.json +++ b/server/config.json @@ -19,14 +19,14 @@ "monthstart": 1 }, { - "disabled": true, "username": "s03", "name": "node3", "type": "hyper", "host": "host3", "location": "🇫🇷", "password": "USER_DEFAULT_PASSWORD", - "monthstart": 1 + "monthstart": 1, + "disabled": false }, { "username": "s04", @@ -58,14 +58,14 @@ "domain": "https://my.cloudcpp.com", "port": 443, "interval": 7200, - "callback": "https://yourSMSurl" + "callback": "https://yourSMSurl" }, { "name": "tz.cloudcpp.com", "domain": "https://tz.cloudcpp.com", "port": 443, "interval": 7200, - "callback": "https://yourSMSurl" + "callback": "https://yourSMSurl" }, { "name": "3.0.2.1", diff --git a/server/entrypoint-server.sh b/server/entrypoint-server.sh new file mode 100644 index 0000000..f14a7fd --- /dev/null +++ b/server/entrypoint-server.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -eu + +: "${CONFIG_PATH:=/ServerStatus/server/config.json}" +: "${WEB_DIR:=/usr/share/nginx/html}" +: "${SERGATE_PID_FILE:=/tmp/serverstatus-sergate.pid}" +: "${ADMIN_API_BIND:=127.0.0.1}" +: "${ADMIN_API_PORT:=35602}" + +STOPPING=0 +SERGATE_PID="" +API_PID="" + +stop_all() { + STOPPING=1 + if [ -n "$SERGATE_PID" ] && kill -0 "$SERGATE_PID" 2>/dev/null; then + kill -TERM "$SERGATE_PID" 2>/dev/null || true + fi + if [ -n "$API_PID" ] && kill -0 "$API_PID" 2>/dev/null; then + kill -TERM "$API_PID" 2>/dev/null || true + fi + nginx -s quit 2>/dev/null || true +} + +trap 'stop_all; exit 0' INT TERM QUIT + +mkdir -p "$WEB_DIR/json" +nginx + +CONFIG_PATH="$CONFIG_PATH" \ +SERGATE_PID_FILE="$SERGATE_PID_FILE" \ +ADMIN_API_BIND="$ADMIN_API_BIND" \ +ADMIN_API_PORT="$ADMIN_API_PORT" \ +ADMIN_TOKEN="${ADMIN_TOKEN:-}" \ +ADMIN_CORS_ORIGIN="${ADMIN_CORS_ORIGIN:-}" \ + python3 /ServerStatus/server/manage_api.py & +API_PID="$!" +if [ -n "${ADMIN_TOKEN:-}" ]; then + echo "management API enabled on ${ADMIN_API_BIND}:${ADMIN_API_PORT}" +else + echo "management API running in read-only discovery mode; set ADMIN_TOKEN to enable writes" +fi + +while [ "$STOPPING" -eq 0 ]; do + /ServerStatus/server/sergate --config="$CONFIG_PATH" --web-dir="$WEB_DIR" & + SERGATE_PID="$!" + echo "$SERGATE_PID" > "$SERGATE_PID_FILE" + set +e + wait "$SERGATE_PID" + STATUS="$?" + set -e + rm -f "$SERGATE_PID_FILE" + if [ "$STOPPING" -eq 1 ]; then + exit "$STATUS" + fi + echo "sergate exited with status ${STATUS}; restarting in 1s" + sleep 1 +done diff --git a/server/manage_api.py b/server/manage_api.py new file mode 100644 index 0000000..edae951 --- /dev/null +++ b/server/manage_api.py @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +import json +import os +import shutil +import signal +import tempfile +import time +import errno +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import unquote, urlparse + + +CONFIG_PATH = os.environ.get("CONFIG_PATH", "/ServerStatus/server/config.json") +SERGATE_PID_FILE = os.environ.get("SERGATE_PID_FILE", "/tmp/serverstatus-sergate.pid") +ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "") +API_BIND = os.environ.get("ADMIN_API_BIND", "127.0.0.1") +API_PORT = int(os.environ.get("ADMIN_API_PORT", "35602")) +CORS_ORIGIN = os.environ.get("ADMIN_CORS_ORIGIN", "") + + +class ApiError(Exception): + def __init__(self, status, message, details=None): + super().__init__(message) + self.status = status + self.message = message + self.details = details + + +def load_config(): + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + + +def normalize_required_strings(item, required, kind, index=None): + missing = [field for field in required if item.get(field) is None or not str(item.get(field, "")).strip()] + if missing: + raise ApiError(400, f"{kind} has missing required fields", {"missing": missing, "index": index}) + normalized = dict(item) + for field in required: + normalized[field] = str(normalized[field]).strip() + return normalized + + +def normalize_optional_string(item, field): + value = item.get(field, "") + item[field] = "" if value is None else str(value).strip() + + +def normalize_int(value, field, index=None, default=None, min_value=None, max_value=None): + if value in (None, ""): + value = default + try: + value = int(value) + except (TypeError, ValueError): + raise ApiError(400, f"{field} must be an integer", {"index": index}) + if min_value is not None: + value = max(min_value, value) + if max_value is not None: + value = min(max_value, value) + return value + + +def validate_server(server, index=None): + if not isinstance(server, dict): + raise ApiError(400, "server must be an object", {"index": index}) + required = ["username", "name", "type", "host", "location", "password"] + normalized = normalize_required_strings(server, required, "server", index=index) + normalized["monthstart"] = normalize_int(normalized.get("monthstart"), "monthstart", index=index, default=1, min_value=1, max_value=28) + if "disabled" in normalized: + normalized["disabled"] = bool(normalized["disabled"]) + return normalized + + +def validate_monitor(monitor, index=None): + if not isinstance(monitor, dict): + raise ApiError(400, "monitor must be an object", {"index": index}) + normalized = normalize_required_strings(monitor, ["name", "host", "type"], "monitor", index=index) + normalized["interval"] = normalize_int(normalized.get("interval"), "interval", index=index, default=600, min_value=1) + return normalized + + +def validate_sslcert(sslcert, index=None): + if not isinstance(sslcert, dict): + raise ApiError(400, "sslcert must be an object", {"index": index}) + normalized = normalize_required_strings(sslcert, ["name", "domain"], "sslcert", index=index) + normalized["port"] = normalize_int(normalized.get("port"), "port", index=index, default=443, min_value=1, max_value=65535) + normalized["interval"] = normalize_int(normalized.get("interval"), "interval", index=index, default=7200, min_value=1) + normalize_optional_string(normalized, "callback") + return normalized + + +def validate_watchdog(watchdog, index=None): + if not isinstance(watchdog, dict): + raise ApiError(400, "watchdog must be an object", {"index": index}) + normalized = normalize_required_strings(watchdog, ["name", "rule"], "watchdog", index=index) + normalized["interval"] = normalize_int(normalized.get("interval"), "interval", index=index, default=600, min_value=1) + normalize_optional_string(normalized, "callback") + return normalized + + +COLLECTIONS = { + "servers": { + "item": "server", + "id_field": "username", + "validator": validate_server, + "required": ["username", "name", "type", "host", "location", "password"], + "optional": ["monthstart", "disabled"], + }, + "monitors": { + "item": "monitor", + "id_field": "name", + "validator": validate_monitor, + "required": ["name", "host", "type"], + "optional": ["interval"], + }, + "sslcerts": { + "item": "sslcert", + "id_field": "name", + "validator": validate_sslcert, + "required": ["name", "domain"], + "optional": ["port", "interval", "callback"], + }, + "watchdog": { + "item": "watchdog", + "id_field": "name", + "validator": validate_watchdog, + "required": ["name", "rule"], + "optional": ["interval", "callback"], + }, +} + + +def validate_config(config): + if not isinstance(config, dict): + raise ApiError(400, "config must be a JSON object") + config = dict(config) + for key, meta in COLLECTIONS.items(): + items = config.get(key, []) + if not isinstance(items, list): + raise ApiError(400, f"{key} must be an array") + normalized = [] + seen = set() + for index, item in enumerate(items): + entry = meta["validator"](item, index=index) + if key == "servers": + username = entry["username"] + if username in seen: + raise ApiError(409, "duplicate server username", {"username": username}) + seen.add(username) + normalized.append(entry) + config[key] = normalized + return config + + +def write_config(config): + config = validate_config(config) + directory = os.path.dirname(CONFIG_PATH) or "." + os.makedirs(directory, exist_ok=True) + timestamp = time.strftime("%Y%m%d-%H%M%S") + if os.path.exists(CONFIG_PATH): + shutil.copy2(CONFIG_PATH, f"{CONFIG_PATH}.bak-{timestamp}") + mode = os.stat(CONFIG_PATH).st_mode + else: + mode = 0o644 + data = json.dumps(config, ensure_ascii=False, indent="\t") + "\n" + fd, tmp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp_path, mode) + try: + os.replace(tmp_path, CONFIG_PATH) + except OSError as exc: + if exc.errno != errno.EBUSY: + raise + # A single-file Docker bind mount cannot be atomically replaced. + # Keep the backup above, then rewrite the mounted file in place. + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + return config + + +def write_and_reload(config): + config = write_config(config) + pid = signal_sergate(signal.SIGHUP) + return config, pid + + +def read_body(handler): + length = int(handler.headers.get("Content-Length", "0") or "0") + if length <= 0: + return None + if length > 1024 * 1024: + raise ApiError(413, "request body is too large") + raw = handler.rfile.read(length).decode("utf-8") + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise ApiError(400, "invalid JSON body", {"error": str(exc)}) + + +def get_sergate_pid(): + try: + with open(SERGATE_PID_FILE, "r", encoding="utf-8") as f: + pid = int(f.read().strip()) + os.kill(pid, 0) + return pid + except Exception: + pass + proc_dir = "/proc" + if not os.path.isdir(proc_dir): + return None + for name in os.listdir(proc_dir): + if not name.isdigit(): + continue + try: + with open(os.path.join(proc_dir, name, "cmdline"), "rb") as f: + cmdline = f.read().replace(b"\x00", b" ").decode("utf-8", "ignore") + except Exception: + continue + if "sergate" in cmdline: + return int(name) + return None + + +def signal_sergate(sig): + pid = get_sergate_pid() + if not pid: + raise ApiError(503, "sergate process was not found") + os.kill(pid, sig) + return pid + + +def find_server(config, username): + servers = config.get("servers", []) + for index, server in enumerate(servers): + if server.get("username") == username: + return index, server + return -1, None + + +def find_collection_item(config, key, item_id): + items = config.get(key, []) + if item_id.isdigit(): + index = int(item_id) + if 0 <= index < len(items): + return index, items[index] + return -1, None + id_field = COLLECTIONS[key]["id_field"] + matches = [(index, item) for index, item in enumerate(items) if str(item.get(id_field, "")) == item_id] + if len(matches) > 1: + raise ApiError(409, f"{key} has duplicate {id_field}; use numeric index instead", {"id": item_id}) + if matches: + return matches[0] + return -1, None + + +def collection_routes(): + endpoints = [] + for key in ["monitors", "sslcerts", "watchdog"]: + endpoints.extend([ + {"method": "GET", "path": f"/api/{key}", "auth": True}, + {"method": "POST", "path": f"/api/{key}", "auth": True, "body": f"{COLLECTIONS[key]['item']} JSON"}, + {"method": "PUT", "path": f"/api/{key}/{{index-or-name}}", "auth": True, "body": f"{COLLECTIONS[key]['item']} JSON"}, + {"method": "DELETE", "path": f"/api/{key}/{{index-or-name}}", "auth": True}, + ]) + return endpoints + + +def api_schema(): + return { + "auth": { + "type": "bearer", + "header": "Authorization: Bearer ", + "enabled": bool(ADMIN_TOKEN), + }, + "endpoints": [ + {"method": "GET", "path": "/api/health", "auth": False}, + {"method": "GET", "path": "/api/schema", "auth": False}, + {"method": "GET", "path": "/api/config", "auth": True}, + {"method": "PUT", "path": "/api/config", "auth": True, "body": "full config JSON"}, + {"method": "GET", "path": "/api/servers", "auth": True}, + {"method": "POST", "path": "/api/servers", "auth": True, "body": "server JSON"}, + {"method": "PUT", "path": "/api/servers/{username}", "auth": True, "body": "server JSON"}, + {"method": "DELETE", "path": "/api/servers/{username}", "auth": True}, + *collection_routes(), + {"method": "POST", "path": "/api/reload", "auth": True}, + {"method": "POST", "path": "/api/restart", "auth": True}, + ], + "collections": { + key: { + "item": meta["item"], + "idField": meta["id_field"], + "required": meta["required"], + "optional": meta["optional"], + } + for key, meta in COLLECTIONS.items() + }, + } + + +class Handler(BaseHTTPRequestHandler): + server_version = "ServerStatusManageAPI/1.0" + + def log_message(self, fmt, *args): + print("%s - %s" % (self.address_string(), fmt % args), flush=True) + + def end_headers(self): + self.send_header("Cache-Control", "no-store") + if CORS_ORIGIN: + self.send_header("Access-Control-Allow-Origin", CORS_ORIGIN) + self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Admin-Token") + self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + super().end_headers() + + def send_json(self, status, payload): + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def send_error_json(self, err): + payload = {"ok": False, "error": err.message} + if err.details is not None: + payload["details"] = err.details + self.send_json(err.status, payload) + + def route(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") or "/" + method = self.command.upper() + if method == "OPTIONS": + self.send_response(204) + self.end_headers() + return + try: + if path == "/api/health" and method == "GET": + pid = get_sergate_pid() + self.send_json(200, { + "ok": True, + "enabled": bool(ADMIN_TOKEN), + "sergate": {"running": bool(pid), "pid": pid}, + "configPath": CONFIG_PATH, + }) + return + if path == "/api/schema" and method == "GET": + self.send_json(200, {"ok": True, "schema": api_schema()}) + return + self.require_auth() + if path == "/api/config": + if method == "GET": + self.send_json(200, {"ok": True, "config": load_config()}) + return + if method == "PUT": + config = read_body(self) + config, pid = write_and_reload(config) + self.send_json(200, {"ok": True, "reloaded": True, "pid": pid, "config": config}) + return + if path == "/api/servers": + if method == "GET": + config = load_config() + self.send_json(200, {"ok": True, "servers": config.get("servers", [])}) + return + if method == "POST": + server = validate_server(read_body(self)) + config = load_config() + if find_server(config, server["username"])[1] is not None: + raise ApiError(409, "server username already exists", {"username": server["username"]}) + config.setdefault("servers", []).append(server) + config, pid = write_and_reload(config) + self.send_json(201, {"ok": True, "server": server, "reloaded": True, "pid": pid, "config": config}) + return + if path.startswith("/api/servers/"): + username = unquote(path[len("/api/servers/"):]) + if not username: + raise ApiError(400, "username is required") + config = load_config() + index, existing = find_server(config, username) + if index < 0: + raise ApiError(404, "server was not found", {"username": username}) + if method == "PUT": + server = validate_server(read_body(self)) + if server["username"] != username and find_server(config, server["username"])[1] is not None: + raise ApiError(409, "server username already exists", {"username": server["username"]}) + config["servers"][index] = server + config, pid = write_and_reload(config) + self.send_json(200, {"ok": True, "server": server, "reloaded": True, "pid": pid, "config": config}) + return + if method == "DELETE": + removed = config["servers"].pop(index) + config, pid = write_and_reload(config) + self.send_json(200, {"ok": True, "removed": removed, "reloaded": True, "pid": pid, "config": config}) + return + for key in ["monitors", "sslcerts", "watchdog"]: + base = f"/api/{key}" + meta = COLLECTIONS[key] + if path == base: + if method == "GET": + config = load_config() + self.send_json(200, {"ok": True, key: config.get(key, [])}) + return + if method == "POST": + item = meta["validator"](read_body(self)) + config = load_config() + config.setdefault(key, []).append(item) + config, pid = write_and_reload(config) + self.send_json(201, {"ok": True, meta["item"]: item, "reloaded": True, "pid": pid, "config": config}) + return + if path.startswith(base + "/"): + item_id = unquote(path[len(base) + 1:]) + if not item_id: + raise ApiError(400, "item id is required") + config = load_config() + index, existing = find_collection_item(config, key, item_id) + if index < 0: + raise ApiError(404, f"{meta['item']} was not found", {"id": item_id}) + if method == "PUT": + item = meta["validator"](read_body(self)) + config[key][index] = item + config, pid = write_and_reload(config) + self.send_json(200, {"ok": True, meta["item"]: item, "reloaded": True, "pid": pid, "config": config}) + return + if method == "DELETE": + removed = config[key].pop(index) + config, pid = write_and_reload(config) + self.send_json(200, {"ok": True, "removed": removed, "reloaded": True, "pid": pid, "config": config}) + return + if path == "/api/reload" and method == "POST": + pid = signal_sergate(signal.SIGHUP) + self.send_json(200, {"ok": True, "operation": "reload", "pid": pid}) + return + if path == "/api/restart" and method == "POST": + pid = signal_sergate(signal.SIGTERM) + self.send_json(202, {"ok": True, "operation": "restart", "pid": pid}) + return + raise ApiError(404, "endpoint was not found") + except ApiError as err: + self.send_error_json(err) + except Exception as exc: + self.send_error_json(ApiError(500, "internal server error", {"error": str(exc)})) + + def require_auth(self): + if not ADMIN_TOKEN: + raise ApiError(503, "management API is disabled; set ADMIN_TOKEN to enable it") + auth = self.headers.get("Authorization", "") + token = "" + if auth.lower().startswith("bearer "): + token = auth[7:].strip() + token = token or self.headers.get("X-Admin-Token", "").strip() + if token != ADMIN_TOKEN: + raise ApiError(401, "invalid or missing admin token") + + def do_GET(self): + self.route() + + def do_POST(self): + self.route() + + def do_PUT(self): + self.route() + + def do_DELETE(self): + self.route() + + def do_OPTIONS(self): + self.route() + + +def main(): + httpd = ThreadingHTTPServer((API_BIND, API_PORT), Handler) + print(f"manage-api listening on {API_BIND}:{API_PORT}", flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/server/nginx-serverstatus.conf b/server/nginx-serverstatus.conf new file mode 100644 index 0000000..0333173 --- /dev/null +++ b/server/nginx-serverstatus.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ =404; + } + + location /json/ { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location /api/ { + proxy_pass http://127.0.0.1:35602; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + } +} diff --git a/web/css/app.css b/web/css/app.css index 395fa08..4002102 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -230,7 +230,7 @@ body.light .gauge-half .needle{background:linear-gradient(var(--text),var(--text font-variant-numeric:tabular-nums; } .cards .card{border:1px solid var(--border);border-radius:12px;padding:.75rem .85rem;background:linear-gradient(145deg,var(--bg),var(--bg-alt));display:flex;flex-direction:column;gap:.45rem;position:relative;} -.cards .card.offline{opacity:.6;} +.cards .card.offline{filter:saturate(.92);} .cards .card.high-load{border-color:rgba(239,68,68,.6);background:linear-gradient(180deg, rgba(239,68,68,.22), rgba(239,68,68,.12)), var(--bg-alt);box-shadow:0 0 0 1px rgba(239,68,68,.48),0 6px 18px -6px rgba(239,68,68,.28);} table.data tbody tr.high-load{background:rgba(239,68,68,.18) !important;} table.data tbody tr.high-load:hover{background:rgba(239,68,68,.26) !important;} @@ -316,3 +316,117 @@ table.data tbody tr[class*="os-"]:hover{background:linear-gradient(180deg, color .brand:hover .logo-mark{transform:translateY(-2px) scale(1.05)} .brand:hover .logo-text{color:var(--text)} @media (max-width:640px){.brand .logo-text{font-size:15px}.brand .logo-mark{width:30px;height:30px}} + +/* 运维控制台增强 */ +.ops-overview{display:grid;grid-template-columns:repeat(5,minmax(140px,1fr));gap:.75rem} +.overview-card{border:1px solid var(--border);background:var(--bg-alt);border-radius:8px;padding:.8rem .9rem;display:flex;flex-direction:column;gap:.25rem;min-height:82px} +.overview-card .label{font-size:12px;color:var(--text-dim)} +.overview-card .value{font-size:24px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums} +.overview-card .hint{font-size:12px;color:var(--text-dim);line-height:1.3} +.overview-card.ok{border-color:rgba(16,185,129,.35)} +.overview-card.warn{border-color:rgba(245,158,11,.45)} +.overview-card.err{border-color:rgba(239,68,68,.5)} +.ops-toolbar{display:flex;align-items:end;gap:.75rem;flex-wrap:wrap;border:1px solid var(--border);background:var(--bg-alt);border-radius:8px;padding:.75rem} +.search-field,.select-field{display:flex;flex-direction:column;gap:.3rem;color:var(--text-dim);font-size:12px} +.search-field input,.select-field select,.config-form input,.config-form textarea{border:1px solid var(--border);background:var(--bg);color:var(--text);border-radius:6px;padding:0 .65rem;outline:none;transition:var(--trans)} +.search-field input,.select-field select,.config-form input{height:36px} +.config-form textarea{min-height:92px;resize:vertical;padding:.55rem .65rem;line-height:1.45;font-family:ui-monospace,SFMono-Regular,Menlo,monospace} +.search-field input:focus,.select-field select:focus,.config-form input:focus,.config-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 18%,transparent)} +.search-field input{min-width:280px} +.segmented{display:inline-flex;border:1px solid var(--border);border-radius:7px;overflow:hidden;background:var(--bg)} +.segmented button{height:36px;border:0;border-right:1px solid var(--border);background:transparent;color:var(--text-dim);padding:0 .8rem;cursor:pointer} +.segmented button:last-child{border-right:0} +.segmented button.active,.segmented button:hover{background:var(--accent);color:#fff} +.icon-text,.primary-btn,.danger-btn{height:36px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);padding:0 .75rem;cursor:pointer;transition:var(--trans);font-weight:600} +.icon-text:hover{border-color:var(--accent);color:var(--accent)} +.primary-btn{background:var(--accent);border-color:var(--accent);color:#fff} +.primary-btn:hover{filter:brightness(1.08)} +.danger-btn{background:rgba(239,68,68,.14);border-color:rgba(239,68,68,.38);color:var(--danger)} +.danger-btn:hover{background:var(--danger);color:#fff} +th[data-sort]{cursor:pointer;user-select:none} +th[data-sort]:after{content:"";display:inline-block;margin-left:6px;border:4px solid transparent;border-top-color:color-mix(in srgb,var(--text-dim) 70%,transparent);transform:translateY(2px);opacity:.45} +th[data-sort].sorted-asc:after{border-top-color:transparent;border-bottom-color:var(--accent);transform:translateY(-2px)} +th[data-sort].sorted-desc:after{border-top-color:var(--accent);opacity:1} +.section-head h2{margin:0;font-size:18px} +.section-head p{margin:.25rem 0 0} +.empty-state{border:1px dashed var(--border);border-radius:8px;background:var(--bg-alt);padding:1.25rem;color:var(--text-dim);text-align:center} + +/* 右侧详情抽屉 */ +.drawer-backdrop{align-items:stretch;justify-content:flex-end;padding:0;background:rgba(0,0,0,.48)} +.detail-drawer{height:100vh;max-height:100vh;width:min(620px,100vw);max-width:min(620px,100vw);border-radius:0;border-top:0;border-right:0;border-bottom:0;padding:1.1rem;overflow:auto;animation:slideIn .24s ease} +.modal-title{padding-right:2.2rem} +.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.55rem} +.detail-section{border:1px solid var(--border);border-radius:8px;background:linear-gradient(145deg,var(--bg),var(--bg-alt));padding:.75rem;display:flex;flex-direction:column;gap:.65rem} +.detail-section h4{margin:0;font-size:13px;color:var(--text-dim);font-weight:700;letter-spacing:.4px} +.detail-section .kv{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:flex-start;gap:.6rem;min-width:0} +.detail-section .kv span{min-width:0;white-space:normal} +.detail-section .kv>span:last-child{text-align:right;overflow-wrap:anywhere;word-break:break-word} +.detail-section .mono{display:block;white-space:normal;overflow-wrap:anywhere;word-break:break-word;line-height:1.45} +.detail-inline{display:inline-flex;align-items:center;justify-content:flex-end;gap:.45rem;white-space:nowrap!important} +.detail-inline .mono{display:inline!important;white-space:nowrap!important;line-height:1} +.detail-values{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:.15rem .55rem} +.detail-values span{white-space:nowrap!important} +.resource-section{gap:.5rem} +.resource-meter{display:flex;flex-direction:column;gap:.26rem;min-width:0} +.resource-meter-head,.resource-mini{display:flex;align-items:center;justify-content:space-between;gap:.55rem;font-size:12px;min-width:0} +.resource-meter-head span,.resource-mini span{color:var(--text-dim);white-space:nowrap} +.resource-meter-head strong,.resource-mini strong{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;font-weight:700;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.resource-track{height:7px;border-radius:999px;background:color-mix(in srgb,var(--text-dim) 16%,transparent);overflow:hidden;border:1px solid color-mix(in srgb,var(--border) 75%,transparent)} +.resource-track i{display:block;height:100%;width:var(--p);border-radius:inherit;background:var(--accent);transition:width .5s ease} +.resource-meter[data-kind=mem] .resource-track i{background:var(--ok)} +.resource-meter[data-kind=swap] .resource-track i{background:#14b8a6} +.resource-meter[data-kind=hdd] .resource-track i{background:#06b6d4} +.resource-meter[data-level=warn] .resource-track i{background:var(--warn)} +.resource-meter[data-level=bad] .resource-track i{background:var(--danger)} +.resource-mini{border-top:1px solid var(--border);padding-top:.45rem} +.chart-section{gap:.55rem} +.chart-head{display:flex;align-items:center;justify-content:space-between;gap:.75rem;flex-wrap:wrap} +.chart-head h4{margin:0} +.chart-legend{display:inline-flex;align-items:center;justify-content:flex-end;gap:.7rem;flex-wrap:wrap;font-size:11px;color:var(--text-dim)} +.chart-legend span{display:inline-flex;align-items:center;gap:.28rem;white-space:nowrap} +.chart-legend i{display:inline-block;width:10px;height:3px;border-radius:999px;box-shadow:0 0 0 1px rgba(0,0,0,.16)} +.detail-chart{width:100%;border:1px solid var(--border);border-radius:8px;background:var(--bg)} +@keyframes slideIn{from{transform:translateX(22px);opacity:.7}to{transform:translateX(0);opacity:1}} + +/* 管理配置 */ +.config-grid{display:grid;grid-template-columns:minmax(320px,.95fr) minmax(320px,1.05fr);gap:.9rem;align-items:start} +.admin-card{border:1px solid var(--border);border-radius:8px;background:var(--bg-alt);padding:1rem;display:flex;flex-direction:column;gap:.85rem} +.section-head{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem} +.section-actions{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;justify-content:flex-end} +.admin-status{border:1px solid var(--border);border-radius:8px;background:var(--bg);padding:.75rem;font-size:13px;color:var(--text-dim)} +.admin-status.ok{border-color:rgba(16,185,129,.35);color:var(--ok)} +.admin-status.err{border-color:rgba(239,68,68,.45);color:var(--danger)} +.admin-login,.config-actions,.form-actions{display:flex;align-items:end;gap:.65rem;flex-wrap:wrap} +.config-tabs{align-self:flex-start;max-width:100%} +.config-list{display:flex;flex-direction:column;gap:.5rem} +.config-row{display:grid;grid-template-columns:1fr auto;gap:.75rem;align-items:center;border:1px solid var(--border);border-radius:8px;background:var(--bg);padding:.7rem;cursor:pointer} +.config-row:hover,.config-row.active{border-color:var(--accent)} +.config-row .name{font-weight:700} +.config-row .meta{font-size:12px;color:var(--text-dim);margin-top:.2rem;word-break:break-all} +.config-form{display:flex;flex-direction:column;gap:.75rem} +.config-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem} +.config-form label{display:flex;flex-direction:column;gap:.3rem;color:var(--text-dim);font-size:12px} +.config-form label.wide{grid-column:1/-1} +.config-form .check-row{flex-direction:row;align-items:center;gap:.5rem} +.config-form .check-row input{height:auto} +.config-form .form-actions{grid-column:1/-1} +.config-form input[name=password],.config-form textarea[name=rule]{font-family:ui-monospace,SFMono-Regular,Menlo,monospace} +.config-form button:disabled{opacity:.5;cursor:not-allowed} + +@media (max-width:980px){ + .ops-overview{grid-template-columns:repeat(2,minmax(0,1fr))} + .config-grid{grid-template-columns:1fr} + .search-field input{min-width:220px} +} +@media (max-width:700px){ + .ops-overview{display:none} +} +@media (max-width:640px){ + .ops-toolbar{align-items:stretch} + .search-field,.select-field,.search-field input,.select-field select,.segmented,.icon-text,.primary-btn,.danger-btn{width:100%} + .segmented button{flex:1;padding:0 .35rem} + .section-head{flex-direction:column} + .section-actions{width:100%;justify-content:stretch} + .detail-grid,.config-fields{grid-template-columns:1fr} + .detail-drawer{width:100vw} +} diff --git a/web/favicon.svg b/web/favicon.svg index 545f257..7373695 100644 --- a/web/favicon.svg +++ b/web/favicon.svg @@ -1,11 +1,7 @@ - - - - - - - - - - + + diff --git a/web/index.html b/web/index.html index 8838adf..47e57d6 100644 --- a/web/index.html +++ b/web/index.html @@ -6,17 +6,14 @@ 云监控 - - +
监控 @@ -25,6 +22,7 @@ +
@@ -35,24 +33,59 @@
初始化中...
+
+ +
+ +
+ + + + +
+ + + +
+
- - - - - + + + + + - + - - - - + + + + @@ -99,21 +132,73 @@ + +
+
+
+
+
+

配置管理

+

可编辑节点、服务监测、证书与告警规则,保存后自动重载。

+
+
+ + +
+
+
正在检测管理 API...
+ + + + +
+ + + + +
+
+ + +
+
+
+
+
+
+

新增节点

+

保存后会写入 config.json,并向 sergate 发送 SIGHUP 重载。

+
+
+
+
+
+ + + +
+ +
+
+
- - - - - - - - - + const rows = visibleServers(); + document.querySelectorAll('#serversTable th[data-sort]').forEach(th => { + th.classList.toggle('sorted-asc', th.dataset.sort === S.filters.sort && S.filters.dir === 'asc'); + th.classList.toggle('sorted-desc', th.dataset.sort === S.filters.sort && S.filters.dir === 'desc'); + }); + $('serversBody').innerHTML = rows.map(s => { + const m = metrics(s); + const netNow = `${humanMinKBFromB(s.network_rx)} | ${humanMinKBFromB(s.network_tx)}`; + const netTotal = `${humanMinMBFromB(s.network_in)} | ${humanMinMBFromB(s.network_out)}`; + return ` + + + + + + + - - - - + + + + `; - }); - tbody.innerHTML = html || ``; - - // 绑定行点击 - tbody.querySelectorAll('tr.row-server').forEach(tr=>{ - tr.addEventListener('click',()=>{ - if(tr.getAttribute('data-online')!=='1') return; // 离线不弹出 - const i = parseInt(tr.getAttribute('data-idx')); - openDetail(i); - }); - }); - - // 仪表盘无需历史 spark 小图 + }).join('') || ``; + document.querySelectorAll('#serversBody .row-server').forEach(row => row.addEventListener('click', () => { + if(row.dataset.online !== '1') return; + openDetail(row.dataset.key); + })); } -// 生成仪表盘 (圆形 conic-gradient) -function gaugeHTML(type,val){ - const pct = Math.max(0,Math.min(100,val)); - const p = (pct/100).toFixed(3); - const warnAttr = pct>=90? 'data-bad' : (pct>=50? 'data-warn' : ''); - return `
- - ${pct.toFixed(0)}% -
`; -} -function labelOf(t){ return t==='cpu'?'CPU': t==='mem'?'内存':'硬盘'; } + function renderServersCards(){ - const wrap = document.getElementById('serversCards'); - if(!wrap) return; - // 仅在窄屏时显示 (和 CSS 一致判断, 可稍放宽避免闪烁) - if(window.innerWidth>700){ wrap.innerHTML=''; return; } - let html=''; - S.servers.forEach((s,idx)=>{ - const online = s.online4||s.online6; - const proto = online ? (s.online4 && s.online6? '双栈': s.online4? 'IPv4':'IPv6') : '离线'; - const pill = `${proto}`; - const memPct = s.memory_total? (s.memory_used/s.memory_total*100):0; - const hddPct = s.hdd_total? (s.hdd_used/s.hdd_total*100):0; - // 月流量(移动端)并应用 500GB 阈值配色逻辑 - const monthInBytes = (s.network_in - s.last_network_in) || 0; // B - const monthOutBytes = (s.network_out - s.last_network_out) || 0; - const monthIn = humanMinMBFromB(monthInBytes); - const monthOut = humanMinMBFromB(monthOutBytes); - const HEAVY_THRESHOLD = 500 * 1000 * 1000 * 1000; // 500GB - const heavy = monthInBytes >= HEAVY_THRESHOLD || monthOutBytes >= HEAVY_THRESHOLD; - const trafficCls = heavy ? 'caps-traffic duo heavy sm' : 'caps-traffic duo normal sm'; - const netNow = humanMinKBFromB(s.network_rx)+' | '+humanMinKBFromB(s.network_tx); - const netTotal = humanMinMBFromB(s.network_in)+' | '+humanMinMBFromB(s.network_out); - const p1 = (s.ping_10010||0); const p2=(s.ping_189||0); const p3=(s.ping_10086||0); - function bucket(p){ const v=Math.max(0,Math.min(100,p)); const level = v>=20?'bad':(v>=10?'warn':'ok'); return `
`; } - const buckets = `
${bucket(p1)}${bucket(p2)}${bucket(p3)}
`; - // 唯一 key 已附加为 s._key(如需使用) - const highLoad = online && ( (s.cpu||0)>=90 || (memPct)>=90 || (hddPct)>=90 ); - html += `
\n
\n
${s.name||'-'} ${s.location||'-'}
\n ${pill}\n
\n
\n
负载${s.load_1==-1?'–':s.load_1?.toFixed(2)}
\n
在线${s.uptime||'-'}
\n
月流量${monthIn}${monthOut}
\n
网络${netNow}
\n
总流量${netTotal}
\n
CPU${s.cpu||0}%
\n
内存${memPct.toFixed(0)}%
\n
硬盘${hddPct.toFixed(0)}%
\n
\n ${buckets}\n
`; - }); - wrap.innerHTML = html || '
无数据
'; - wrap.querySelectorAll('.card').forEach(card=>{ - const idx = parseInt(card.getAttribute('data-idx')); - card.addEventListener('click', ()=>{ - if(card.getAttribute('data-online')!=='1') return; // 离线不弹 - openDetail(idx); - }); - }); + const wrap = $('serversCards'); + if(window.innerWidth > 700){ wrap.innerHTML = ''; return; } + wrap.innerHTML = visibleServers().map(s => { + const m = metrics(s); + return `
+
${esc(s.name || '-')} ${esc(s.location || '-')}
${protoPill(s)}
+
+
负载${s.load_1 === -1 ? '–' : num(s.load_1).toFixed(2)}
+
在线${esc(s.uptime || '-')}
+
月流量${trafficCaps(s, true)}
+
网络${humanMinKBFromB(s.network_rx)} | ${humanMinKBFromB(s.network_tx)}
+
CPU${num(s.cpu).toFixed(0)}%
+
内存${m.memPct.toFixed(0)}%
+
+ ${buckets(s)} +
`; + }).join('') || '
无数据
'; + wrap.querySelectorAll('.card').forEach(card => card.addEventListener('click', () => { + if(card.dataset.online !== '1') return; + openDetail(card.dataset.key); + })); } +function parseCustom(str){ + if(typeof str !== 'string' || !str.trim()) return []; + return str.split(';').map(seg => { + const [name, val] = seg.split('='); + const ms = parseInt((val || '').trim(), 10); + return name && Number.isFinite(ms) ? { name: name.trim(), ms: Math.max(0, ms) } : null; + }).filter(Boolean); +} +function signalBars(ms){ + const levels = [20, 50, 100, 160]; + let on = typeof ms === 'number' ? (ms <= levels[0] ? 5 : ms <= levels[1] ? 4 : ms <= levels[2] ? 3 : ms <= levels[3] ? 2 : 1) : 0; + return `${[0,1,2,3,4].map(i => ``).join('')}`; +} +function monitorItems(s){ + const items = parseCustom(s.custom); + return items.map(item => `${esc(item.name)}${signalBars(item.ms)}${item.ms}ms`).join('') || '-'; +} function renderMonitors(){ - const tbody = els.monitorsBody(); - let html=''; - function parseCustom(str){ - const items = []; - if(typeof str !== 'string' || !str.trim()) return {items:[]}; - str.split(';').forEach(seg=>{ - if(!seg) return; - const [rawK,rawV] = seg.split('='); - if(!rawK) return; - const k = String(rawK).trim(); - const v = parseInt((rawV||'').trim(),10); - if(!isNaN(v)) items.push({key:k, label:k, ms:Math.max(0,v)}); - }); - return {items}; - } - function bars(ms){ - const levels = [20,50,100,160]; - let on = 0; if(typeof ms==='number'){ if(ms<=levels[0]) on=5; else if(ms<=levels[1]) on=4; else if(ms<=levels[2]) on=3; else if(ms<=levels[3]) on=2; else on=1; } - return ''+[0,1,2,3,4].map(i=>``).join('')+''; - } - S.servers.forEach(s=>{ - const isOnline = (s.online4||s.online6); - const proto = isOnline ? (s.online4 && s.online6 ? '双栈' : (s.online4 ? 'IPv4' : 'IPv6')) : '离线'; - const pill = isOnline ? `${proto}` : `${proto}`; - const parsed = parseCustom(s.custom||''); - const row = parsed.items.map(it=> `${it.label}${bars(it.ms)}${it.ms}ms`).join(''); - html += ` - - - - - `; - }); - tbody.innerHTML = html || ``; + $('monitorsBody').innerHTML = S.servers.map(s => ``).join('') || ``; } - -// 服务卡片 (移动端) function renderMonitorsCards(){ - const wrap = document.getElementById('monitorsCards'); - if(!wrap) return; if(window.innerWidth>700){ wrap.innerHTML=''; return; } - let html=''; - function parseCustom(str){ - const items = []; - if(typeof str !== 'string' || !str.trim()) return {items:[]}; - str.split(';').forEach(seg=>{ - if(!seg) return; - const [rawK,rawV] = seg.split('='); - if(!rawK) return; - const k = String(rawK).trim(); - const v = parseInt((rawV||'').trim(),10); - if(!isNaN(v)) items.push({key:k, label:k, ms:Math.max(0,v)}); - }); - return {items}; - } - function bars(ms){ - const levels = [20,50,100,160]; - let on = 0; if(typeof ms==='number'){ if(ms<=levels[0]) on=5; else if(ms<=levels[1]) on=4; else if(ms<=levels[2]) on=3; else if(ms<=levels[3]) on=2; else on=1; } - return ''+[0,1,2,3,4].map(i=>``).join('')+''; - } - S.servers.forEach(s=>{ - const isOnline = (s.online4||s.online6); - const proto = isOnline ? (s.online4 && s.online6 ? '双栈' : (s.online4 ? 'IPv4' : 'IPv6')) : '离线'; - const pill = `${proto}`; - const parsed = parseCustom(s.custom||''); - const row = parsed.items.map(it=> `${it.label}${bars(it.ms)}${it.ms}ms`).join(''); - html += `
-
${s.name||'-'} ${s.location||'-'}
${pill}
-
-
监测内容${row||'-'}
-
-
`; - }); - wrap.innerHTML = html || '
无数据
'; + const wrap = $('monitorsCards'); + if(window.innerWidth > 700){ wrap.innerHTML = ''; return; } + wrap.innerHTML = S.servers.map(s => `
${esc(s.name || '-')} ${esc(s.location || '-')}
${protoPill(s)}
监测内容${monitorItems(s)}
`).join('') || '
无数据
'; } - function renderSSL(){ - const tbody = els.sslBody(); - let html=''; - S.ssl.forEach(c=>{ - const cls = c.expire_days<=0? 'err': c.expire_days<=7? 'warn':'ok'; - const status = c.expire_days<=0? '已过期': c.expire_days<=7? '将到期':'正常'; - const dt = c.expire_ts? new Date(c.expire_ts*1000).toISOString().replace('T',' ').replace(/\.\d+Z/,''):'-'; - // 当证书进入警告/错误状态时,高亮整行底色(复用 high-load 行样式) - const rowCls = (cls !== 'ok') ? 'high-load' : ''; - html += ` - - - - - - - `; - }); - tbody.innerHTML = html || ``; + $('sslBody').innerHTML = S.ssl.map(c => { + const cls = c.mismatch || c.expire_days <= 0 ? 'err' : c.expire_days <= 7 ? 'warn' : 'ok'; + const status = c.mismatch ? '域名不匹配' : c.expire_days <= 0 ? '已过期' : c.expire_days <= 7 ? '将到期' : '正常'; + const dt = c.expire_ts ? new Date(c.expire_ts * 1000).toISOString().replace('T',' ').replace(/\.\d+Z/,'') : '-'; + return ``; + }).join('') || ``; +} +function renderSSLCards(){ + const wrap = $('sslCards'); + if(window.innerWidth > 700){ wrap.innerHTML = ''; return; } + wrap.innerHTML = S.ssl.map(c => { + const cls = c.mismatch || c.expire_days <= 0 ? 'err' : c.expire_days <= 7 ? 'warn' : 'ok'; + const status = c.mismatch ? '域名不匹配' : c.expire_days <= 0 ? '已过期' : c.expire_days <= 7 ? '将到期' : '正常'; + const dt = c.expire_ts ? new Date(c.expire_ts * 1000).toISOString().slice(0,10) : '-'; + return `
${esc(c.name || '-')}
${status}
域名${esc(String(c.domain || '').replace(/^https?:\/\//,''))}
端口${esc(c.port || 443)}
剩余${esc(c.expire_days ?? '-')} 天
到期${dt}
`; + }).join('') || '
无证书数据
'; } -// 证书卡片 (移动端) -function renderSSLCards(){ - const wrap = document.getElementById('sslCards'); - if(!wrap) return; if(window.innerWidth>700){ wrap.innerHTML=''; return; } - let html=''; - S.ssl.forEach(c=>{ - const cls = c.expire_days<=0? 'err': c.expire_days<=7? 'warn':'ok'; - const status = c.expire_days<=0? '已过期': c.expire_days<=7? '将到期':'正常'; - const dt = c.expire_ts? new Date(c.expire_ts*1000).toISOString().replace('T',' ').replace(/\.\d+Z/,''):'-'; - const cardHigh = (cls !== 'ok') ? ' high-load' : ''; - html += `
-
${c.name||'-'}
${status}
-
-
域名${(c.domain||'').replace(/^https?:\/\//,'')}
-
端口${c.port||443}
-
剩余(天)${c.expire_days??'-'}
-
到期${dt.split(' ')[0]||dt}
-
-
`; +function openDetail(key){ + S.openDetailKey = key; + $('detailModal').style.display = 'flex'; + refreshDetail(); + document.addEventListener('keydown', escCloseOnce); +} +function findServer(key){ return S.servers.find(s => s._key === key); } +function refreshDetail(){ + const s = findServer(S.openDetailKey); + if(!s){ closeDetail(); return; } + const m = metrics(s); + const title = $('detailTitle'); + title.innerHTML = `${esc(s.name || '-')} 详情${s.os ? `${esc(osLabel(s.os))}` : ''}`; + const modalBox = document.querySelector('#detailModal .modal-box'); + if(modalBox) modalBox.classList.toggle('high-load', m.highlight); + const loadSeries = [ + { data: S.loadHist[s._key]?.l1 || [], color:'#8b5cf6', label:'load1' }, + { data: S.loadHist[s._key]?.l5 || [], color:'#10b981', label:'load5' }, + { data: S.loadHist[s._key]?.l15 || [], color:'#f59e0b', label:'load15' } + ]; + const latencySeries = [ + { data: S.hist[s._key]?.cu || [], color:'#3b82f6', label:'联通' }, + { data: S.hist[s._key]?.ct || [], color:'#10b981', label:'电信' }, + { data: S.hist[s._key]?.cm || [], color:'#f59e0b', label:'移动' } + ]; + $('detailContent').innerHTML = ` +
+

身份

+
节点 / 位置${esc(s.name || '-')} / ${esc(s.location || '-')}
+
虚拟化 / 主机${esc(s.type || '-')} / ${esc(s.host || '-')}
+
协议 / 在线${protoPill(s)}${esc(s.uptime || '-')}
+
+

资源

+ ${detailResourceHTML(s, m)} +
+

网络

+
当前 ↓|↑${humanMinKBFromB(s.network_rx)} | ${humanMinKBFromB(s.network_tx)}
+
总流量 ↓|↑${humanMinMBFromB(s.network_in)} | ${humanMinMBFromB(s.network_out)}
+
本月 ↓|↑${trafficCaps(s, true)}
+
+

连接

+
TCP / UDP${num(s.tcp_count)} / ${num(s.udp_count)}
+
进程 / 线程${num(s.process_count)} / ${num(s.thread_count)}
+
联通|电信|移动${packetLossLine(s)}
+
+
+

负载趋势

${chartLegend(loadSeries)}
+

三网延迟

${chartLegend(latencySeries)}
`; + drawLineChart('loadChart', loadSeries, '暂无负载数据'); + drawLineChart('latChart', latencySeries, '暂无延迟数据', 'ms'); +} +function closeDetail(){ + $('detailModal').style.display = 'none'; + S.openDetailKey = null; + document.removeEventListener('keydown', escCloseOnce); +} +function escCloseOnce(e){ if(e.key === 'Escape') closeDetail(); } + +function drawLineChart(id, series, emptyText, unit = ''){ + const canvas = $(id); + if(!canvas) return; + const ctx = canvas.getContext('2d'); + const W = canvas.clientWidth || canvas.width; + const H = canvas.height; + canvas.width = W; + ctx.clearRect(0,0,W,H); + const all = series.flatMap(s => s.data).filter(v => Number.isFinite(v)); + if(all.length < 2){ + ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text-dim') || '#7a899d'; + ctx.font = '12px system-ui'; + ctx.fillText(emptyText, Math.max(12, W / 2 - 42), H / 2); + return; + } + const padL = unit ? 50 : 42, padR = 10, padT = 12, padB = 20; + let min = Math.min(0, ...all), max = Math.max(...all); + if(max - min < 1) max = min + 1; + const range = max - min; + const n = Math.max(...series.map(s => s.data.length)); + const xStep = (W - padL - padR) / Math.max(1, n - 1); + const light = document.body.classList.contains('light'); + const axis = light ? 'rgba(0,0,0,.22)' : 'rgba(255,255,255,.18)'; + const grid = light ? 'rgba(0,0,0,.08)' : 'rgba(255,255,255,.10)'; + const text = light ? 'rgba(30,41,59,.7)' : 'rgba(226,232,240,.82)'; + ctx.strokeStyle = axis; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padL,padT); ctx.lineTo(padL,H-padB); ctx.lineTo(W-padR,H-padB); ctx.stroke(); + ctx.fillStyle = text; ctx.font = '10px system-ui'; + for(let i=0;i<=4;i++){ + const y = padT + (H-padT-padB) * i / 4; + const val = max - range * i / 4; + ctx.fillText(val.toFixed(max < 10 ? 1 : 0) + unit, 4, y + 3); + ctx.strokeStyle = grid; ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(W-padR,y); ctx.stroke(); + } + series.forEach(item => { + if(item.data.length < 2) return; + ctx.strokeStyle = item.color; ctx.lineWidth = 1.7; ctx.beginPath(); + item.data.forEach((v, i) => { + const x = padL + xStep * i; + const y = padT + (H-padT-padB) * (1 - (v - min) / range); + if(i === 0) ctx.moveTo(x,y); else ctx.lineTo(x,y); + }); + ctx.stroke(); }); - wrap.innerHTML = html || '
无证书数据
'; } function updateTime(){ - const el = els.last(); - if(S.updated){ el.textContent = '最后更新: '+ humanAgo(S.updated); } + const last = $('lastUpdate'); + if(last) last.textContent = '最后更新: ' + humanAgo(S.updated); } function bindTabs(){ - document.getElementById('navTabs').addEventListener('click',e=>{ - if(e.target.tagName!=='BUTTON') return; const tab=e.target.dataset.tab; - document.querySelectorAll('.nav button').forEach(b=>b.classList.toggle('active',b===e.target)); - document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active', p.id==='panel-'+tab)); + $('navTabs').addEventListener('click', e => { + if(e.target.tagName !== 'BUTTON') return; + const tab = e.target.dataset.tab; + S.activeTab = tab; + document.querySelectorAll('.nav button').forEach(btn => btn.classList.toggle('active', btn === e.target)); + document.querySelectorAll('.panel').forEach(panel => panel.classList.toggle('active', panel.id === 'panel-' + tab)); + $('serversToolbar').style.display = tab === 'servers' ? 'flex' : 'none'; + if(tab === 'config') ensureAdminChecked(); }); } function bindTheme(){ - const btn = document.getElementById('themeToggle'); + const btn = $('themeToggle'); const mql = window.matchMedia('(prefers-color-scheme: light)'); - const saved = localStorage.getItem('theme'); // 'light' | 'dark' | null (auto) - - const apply = (isLight)=>{ document.body.classList.toggle('light', isLight); document.documentElement.classList.toggle('light', isLight); }; - - if(!saved){ - // 自动跟随系统 - apply(mql.matches); - // 监听系统偏好变化(仅在未手动选择时) - mql.addEventListener('change', e=>{ if(!localStorage.getItem('theme')) apply(e.matches); }); - } else { - apply(saved==='light'); - } - - btn.addEventListener('click',()=>{ - // 用户手动切换后即固定,不再自动 + const apply = (light) => { document.body.classList.toggle('light', light); document.documentElement.classList.toggle('light', light); }; + const saved = localStorage.getItem('theme'); + apply(saved ? saved === 'light' : mql.matches); + mql.addEventListener('change', e => { if(!localStorage.getItem('theme')) apply(e.matches); }); + btn.addEventListener('click', () => { const toLight = !document.body.classList.contains('light'); apply(toLight); - localStorage.setItem('theme', toLight?'light':'dark'); + localStorage.setItem('theme', toLight ? 'light' : 'dark'); + if(S.openDetailKey) refreshDetail(); }); } +function bindFilters(){ + $('serverSearch').addEventListener('input', e => { S.filters.query = e.target.value; renderServers(); renderServersCards(); }); + $('statusFilter').addEventListener('click', e => { + if(e.target.tagName !== 'BUTTON') return; + S.filters.status = e.target.dataset.filter; + document.querySelectorAll('#statusFilter button').forEach(btn => btn.classList.toggle('active', btn === e.target)); + renderServers(); renderServersCards(); + }); + $('osFilter').addEventListener('change', e => { S.filters.os = e.target.value; renderServers(); renderServersCards(); }); + $('osFilter').addEventListener('blur', renderOsOptions); + $('sortSelect').addEventListener('change', e => { S.filters.sort = e.target.value; renderServers(); renderServersCards(); }); + $('sortDirection').addEventListener('click', () => { + S.filters.dir = S.filters.dir === 'desc' ? 'asc' : 'desc'; + $('sortDirection').textContent = S.filters.dir === 'desc' ? '降序' : '升序'; + renderServers(); renderServersCards(); + }); + document.querySelectorAll('#serversTable th[data-sort]').forEach(th => th.addEventListener('click', () => { + if(S.filters.sort === th.dataset.sort) S.filters.dir = S.filters.dir === 'desc' ? 'asc' : 'desc'; + else S.filters.sort = th.dataset.sort; + $('sortSelect').value = S.filters.sort; + $('sortDirection').textContent = S.filters.dir === 'desc' ? '降序' : '升序'; + renderServers(); renderServersCards(); + })); +} + +function adminHeaders(){ + return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${S.admin.token}` }; +} +async function api(path, options = {}){ + const res = await fetch(path, { ...options, headers: { ...(options.headers || {}), ...(options.auth === false ? {} : adminHeaders()) } }); + const data = await res.json().catch(() => ({})); + if(!res.ok || data.ok === false) throw new Error(data.error || res.statusText); + return data; +} +async function ensureAdminChecked(){ + if(S.admin._checking) return; + S.admin._checking = true; + try{ + const health = await api('/api/health', { auth:false }); + S.admin.enabled = !!health.enabled; + setAdminStatus(health.enabled ? '管理 API 已启用,输入 token 后可编辑配置。' : '管理 API 未启用:请在容器环境变量设置 ADMIN_TOKEN。', health.enabled ? '' : 'err'); + if(S.admin.enabled && S.admin.token) await loadConfig(); + }catch(err){ + setAdminStatus('无法连接管理 API:' + err.message, 'err'); + }finally{ + S.admin._checking = false; + } +} +function setAdminStatus(text, cls){ + const el = $('adminStatus'); + el.className = 'admin-status' + (cls ? ' ' + cls : ''); + el.textContent = text; +} +async function loadConfig(){ + const data = await api('/api/config'); + S.admin.config = normalizeAdminConfig(data.config); + S.admin.connected = true; + setAdminStatus('已连接管理 API,配置可编辑。', 'ok'); + renderConfigList(); + renderConfigEditor(); +} + +const CONFIG_TYPES = { + servers: { + label: '节点', + addLabel: '新增节点', + empty: '暂无节点配置', + hint: '客户端登录使用 username/password,保存后自动重载 sergate。', + fields: [ + { name:'username', label:'用户名', required:true, max:120 }, + { name:'name', label:'节点名', required:true, max:120 }, + { name:'type', label:'虚拟化', required:true, max:120, placeholder:'kvm / xen / vmware' }, + { name:'host', label:'主机名', required:true, max:120 }, + { name:'location', label:'位置', required:true, max:120, placeholder:'🇨🇳 / 上海 / hk-01' }, + { name:'password', label:'密码', required:true, max:120, keepRaw:true }, + { name:'monthstart', label:'月初日', type:'number', min:1, max:28, default:1 }, + { name:'disabled', label:'禁用节点', type:'checkbox' } + ], + title: item => item?.name || item?.username || '未命名节点', + meta: item => `${item?.location || '-'} / ${item?.type || '-'} / ${item?.host || '-'}`, + badge: item => ({ text: item?.disabled ? '禁用' : '启用', cls: item?.disabled ? 'warn' : 'ok' }) + }, + monitors: { + label: '监测', + addLabel: '新增监测', + empty: '暂无服务监测', + hint: '服务监测会写入 config.json 的 monitors 数组。', + fields: [ + { name:'name', label:'名称', required:true, max:120 }, + { name:'host', label:'地址', required:true, max:300, placeholder:'https://example.com' }, + { name:'type', label:'类型', required:true, max:40, placeholder:'http / https / tcp' }, + { name:'interval', label:'间隔秒', type:'number', min:1, default:600 } + ], + title: item => item?.name || item?.host || '未命名监测', + meta: item => `${item?.type || '-'} / ${item?.host || '-'} / ${item?.interval || '-'}s`, + badge: item => ({ text: item?.type || '监测', cls: 'ok' }) + }, + sslcerts: { + label: '证书', + addLabel: '新增证书', + empty: '暂无证书配置', + hint: '证书配置会写入 config.json 的 sslcerts 数组。', + fields: [ + { name:'name', label:'名称', required:true, max:120 }, + { name:'domain', label:'域名', required:true, max:300, placeholder:'https://example.com' }, + { name:'port', label:'端口', type:'number', min:1, max:65535, default:443 }, + { name:'interval', label:'间隔秒', type:'number', min:1, default:7200 }, + { name:'callback', label:'回调地址', max:500, placeholder:'https://yourSMSurl' } + ], + title: item => item?.name || item?.domain || '未命名证书', + meta: item => `${item?.domain || '-'} / ${item?.port || 443} / ${item?.interval || '-'}s`, + badge: () => ({ text: 'SSL', cls: 'ok' }) + }, + watchdog: { + label: '告警', + addLabel: '新增告警', + empty: '暂无告警规则', + hint: '告警规则会写入 config.json 的 watchdog 数组。', + fields: [ + { name:'name', label:'名称', required:true, max:160 }, + { name:'rule', label:'规则表达式', type:'textarea', required:true, placeholder:"online4=0&online6=0" }, + { name:'interval', label:'间隔秒', type:'number', min:1, default:600 }, + { name:'callback', label:'回调地址', max:500, placeholder:'https://yourSMSurl' } + ], + title: item => item?.name || '未命名告警', + meta: item => item?.rule || '-', + badge: item => ({ text: `${item?.interval || '-'}s`, cls: 'warn' }) + } +}; + +function normalizeAdminConfig(config){ + const normalized = config && typeof config === 'object' ? config : {}; + ['servers','monitors','sslcerts','watchdog'].forEach(key => { + if(!Array.isArray(normalized[key])) normalized[key] = []; + }); + return normalized; +} +function activeConfigDef(){ + return CONFIG_TYPES[S.admin.selectedType] || CONFIG_TYPES.servers; +} +function configItems(){ + if(!S.admin.config) S.admin.config = normalizeAdminConfig({}); + const key = S.admin.selectedType; + if(!Array.isArray(S.admin.config[key])) S.admin.config[key] = []; + return S.admin.config[key]; +} +function renderConfigList(){ + const list = $('configItemList'); + const def = activeConfigDef(); + const items = configItems(); + if(S.admin.selectedIndex >= items.length) S.admin.selectedIndex = -1; + $('addConfigItemBtn').textContent = def.addLabel; + document.querySelectorAll('#configTypeTabs button').forEach(btn => btn.classList.toggle('active', btn.dataset.type === S.admin.selectedType)); + list.innerHTML = items.map((item, index) => { + const badge = def.badge(item, index); + return `
${esc(def.title(item, index))}
${esc(def.meta(item, index))}
${esc(badge.text)}
`; + }).join('') || `
${def.empty}
`; + list.querySelectorAll('.config-row').forEach(row => row.addEventListener('click', () => selectConfigItem(Number(row.dataset.index)))); +} +function selectConfigItem(index){ + const item = configItems()[index]; + if(!item) return; + S.admin.selectedIndex = index; + renderConfigList(); + renderConfigEditor(item); +} +function renderConfigEditor(item){ + const def = activeConfigDef(); + const editing = S.admin.selectedIndex >= 0 && item; + const current = item || {}; + $('configEditorTitle').textContent = `${editing ? '编辑' : '新增'}${def.label}`; + $('configEditorHint').textContent = def.hint; + $('configFields').innerHTML = def.fields.map(field => fieldHTML(field, current)).join(''); + $('deleteConfigItemBtn').disabled = !editing; +} +function fieldHTML(field, item){ + const value = item[field.name] ?? field.default ?? ''; + if(field.type === 'checkbox'){ + return ``; + } + const required = field.required ? ' required' : ''; + const min = field.min != null ? ` min="${field.min}"` : ''; + const max = field.max != null ? (field.type === 'number' ? ` max="${field.max}"` : ` maxlength="${field.max}"`) : ''; + const placeholder = field.placeholder ? ` placeholder="${esc(field.placeholder)}"` : ''; + if(field.type === 'textarea'){ + return ``; + } + return ``; +} +function clearConfigForm(){ + S.admin.selectedIndex = -1; + renderConfigList(); + renderConfigEditor(); +} +function formConfigItem(){ + const elements = $('configForm').elements; + const item = {}; + activeConfigDef().fields.forEach(field => { + const el = elements[field.name]; + if(!el) return; + if(field.type === 'checkbox'){ + item[field.name] = el.checked; + return; + } + if(field.type === 'number'){ + let value = el.value === '' ? (field.default ?? 0) : Number(el.value); + if(!Number.isFinite(value)) value = field.default ?? 0; + if(field.min != null) value = Math.max(field.min, value); + if(field.max != null) value = Math.min(field.max, value); + item[field.name] = value; + return; + } + item[field.name] = field.keepRaw ? el.value : el.value.trim(); + }); + return item; +} +function configItemPath(key, index){ + if(index < 0) return `/api/${key}`; + const current = configItems()[index] || {}; + const id = key === 'servers' ? current.username : String(index); + return `/api/${key}/${encodeURIComponent(id || String(index))}`; +} +async function saveConfigItem(key, index, item){ + S.admin.saving = true; + S.suppressStatsReloadUntil = Date.now() + 8000; + try{ + const data = await api(configItemPath(key, index), { method: index >= 0 ? 'PUT' : 'POST', body: JSON.stringify(item) }); + if(data.config) S.admin.config = normalizeAdminConfig(data.config); + S.suppressStatsReloadUntil = Date.now() + 8000; + return data; + }finally{ + S.admin.saving = false; + } +} +async function deleteConfigItem(key, index){ + S.admin.saving = true; + S.suppressStatsReloadUntil = Date.now() + 8000; + try{ + const data = await api(configItemPath(key, index), { method:'DELETE' }); + if(data.config) S.admin.config = normalizeAdminConfig(data.config); + S.suppressStatsReloadUntil = Date.now() + 8000; + return data; + }finally{ + S.admin.saving = false; + } +} +function bindAdmin(){ + $('adminToken').value = S.admin.token; + $('adminTokenForm').addEventListener('submit', async e => { + e.preventDefault(); + S.admin.token = $('adminToken').value.trim(); + localStorage.setItem('serverstatusAdminToken', S.admin.token); + try{ await loadConfig(); }catch(err){ setAdminStatus('认证失败:' + err.message, 'err'); } + }); + $('refreshConfigBtn').addEventListener('click', async () => { try{ await loadConfig(); }catch(err){ setAdminStatus('刷新失败:' + err.message, 'err'); } }); + $('configTypeTabs').addEventListener('click', e => { + if(e.target.tagName !== 'BUTTON') return; + S.admin.selectedType = e.target.dataset.type; + S.admin.selectedIndex = -1; + renderConfigList(); + renderConfigEditor(); + }); + $('addConfigItemBtn').addEventListener('click', clearConfigForm); + $('resetConfigFormBtn').addEventListener('click', clearConfigForm); + $('adminReload').addEventListener('click', async () => { + try{ await api('/api/reload', { method:'POST' }); setAdminStatus('配置重载已触发。', 'ok'); } + catch(err){ setAdminStatus('重载失败:' + err.message, 'err'); } + }); + $('adminRestart').addEventListener('click', async () => { + if(!confirm('重启 sergate 采集服务?客户端会短暂断开后自动重连。')) return; + try{ await api('/api/restart', { method:'POST' }); setAdminStatus('服务重启已触发,等待容器入口脚本拉起 sergate。', 'ok'); } + catch(err){ setAdminStatus('重启失败:' + err.message, 'err'); } + }); + $('configForm').addEventListener('submit', async e => { + e.preventDefault(); + const def = activeConfigDef(); + const key = S.admin.selectedType; + const item = formConfigItem(); + const index = S.admin.selectedIndex; + try{ + await saveConfigItem(key, index, item); + S.admin.selectedIndex = index >= 0 ? index : configItems().length - 1; + renderConfigList(); + renderConfigEditor(configItems()[S.admin.selectedIndex]); + setAdminStatus(`${def.label}配置已保存并重载。`, 'ok'); + }catch(err){ setAdminStatus('保存失败:' + err.message, 'err'); } + }); + $('deleteConfigItemBtn').addEventListener('click', async () => { + const def = activeConfigDef(); + const key = S.admin.selectedType; + const index = S.admin.selectedIndex; + if(index < 0) return setAdminStatus(`请先选择要删除的${def.label}。`, 'err'); + const item = configItems()[index]; + if(!confirm(`删除${def.label} ${def.title(item, index)}?`)) return; + try{ + await deleteConfigItem(key, index); + S.admin.selectedIndex = -1; + renderConfigList(); + renderConfigEditor(); + setAdminStatus(`${def.label}配置已删除并重载。`, 'ok'); + }catch(err){ setAdminStatus('删除失败:' + err.message, 'err'); } + }); +} + +$('detailClose').addEventListener('click', closeDetail); +$('detailModal').addEventListener('click', e => { if(e.target.id === 'detailModal') closeDetail(); }); +window.addEventListener('resize', () => { renderServersCards(); renderMonitorsCards(); renderSSLCards(); if(S.openDetailKey) refreshDetail(); }); bindTabs(); bindTheme(); +bindFilters(); +bindAdmin(); fetchData(); setInterval(fetchData, 1000); setInterval(updateTime, 60000); - -// 详情弹窗逻辑 -function openDetail(i){ - const s = S.servers[i]; if(!s) return; - const box = document.getElementById('detailContent'); - const modal = document.getElementById('detailModal'); - const modalBox = modal.querySelector('.modal-box'); - const osText = osLabel(s.os); - const titleEl = document.getElementById('detailTitle'); - titleEl.textContent = s.name + ' 详情'; - if(osText){ - const chip = document.createElement('span'); - chip.className = 'os-chip' + osClass(s.os); - chip.textContent = osText; - titleEl.appendChild(chip); - } - const offline = !(s.online4||s.online6); - const memPct = s.memory_total? (s.memory_used/s.memory_total*100):0; - const swapPct = s.swap_total? (s.swap_used/s.swap_total*100):0; - const hddPct = s.hdd_total? (s.hdd_used/s.hdd_total*100):0; - const ioRead = (typeof s.io_read==='number')? s.io_read:0; - const ioWrite = (typeof s.io_write==='number')? s.io_write:0; - const procLine = `${num(s.tcp_count)} / ${num(s.udp_count)} / ${num(s.process_count)} / ${num(s.thread_count)}`; - // 保留延迟数据用于图表 - const key = s._key || [s.name||'-', s.location||'-', s.type||'-'].join('|')+'#1'; - - let latencyBlock = ''; - if(!offline){ - latencyBlock = ` -
- -
- ● 联通 (${num(s.time_10010)}ms) - ● 电信 (${num(s.time_189)}ms) - ● 移动 (${num(s.time_10086)}ms) - (~${(S.hist[key]?Math.max(S.hist[key].cu.length, S.hist[key].ct.length, S.hist[key].cm.length):0)} 条) -
-
`; - } else { - latencyBlock = ` -
- -
离线,无联通/电信/移动延迟数据
-
`; - } - - // 旧进度条函数 barHTML/ioBar 已弃用 - // 资源行(移除百分比显示,仅显示 已用 / 总量) - // 资源行(单独拆分 span 便于后续动态刷新) - // 单位来源:memory/swap: KB; hdd: MB; io: B (速率) -> 统一最小单位显示为 MB,并向上进位 (MB/GB/TB) - const memUsed = s.memory_total!=null? humanMinMBFromKB(s.memory_used||0):'-'; - const memTotal = s.memory_total!=null? humanMinMBFromKB(s.memory_total):'-'; - const swapUsed = s.swap_total!=null? humanMinMBFromKB(s.swap_used||0):'-'; - const swapTotal = s.swap_total!=null? humanMinMBFromKB(s.swap_total):'-'; - const hddUsed = s.hdd_total!=null? humanMinMBFromMB(s.hdd_used||0):'-'; - const hddTotal = s.hdd_total!=null? humanMinMBFromMB(s.hdd_total):'-'; - const ioReadLine = (ioRead!=null)? humanRateMinMBFromB(ioRead):'-'; - const ioWriteLine = (ioWrite!=null)? humanRateMinMBFromB(ioWrite):'-'; - const memColor = memPct>80? ' style="color:var(--danger)"':''; // 已用/总量显示为单一块,所以对已用着色 - const swapColor = swapPct>80? ' style="color:var(--danger)"':''; - const hddColor = hddPct>80? ' style="color:var(--danger)"':''; - // IO 阈值:>100MB (原始单位 B) -> >100*1000*1000 B - const readColor = ioRead>100*1000*1000? ' style="color:var(--danger)"':''; - const writeColor = ioWrite>100*1000*1000? ' style="color:var(--danger)"':''; - box.innerHTML = ` -
TCP/UDP/进/线${procLine}
-
内存 / 虚存 - ${memUsed} / ${memTotal} - | ${swapUsed} / ${swapTotal} -
-
硬盘 / 读写 - ${hddUsed} / ${hddTotal} - | ${ioReadLine} / ${ioWriteLine} -
-
- -
- ● load1 (${s.load_1==-1?'–':Math.max(0,(s.load_1||0)).toFixed(2)}) - ● load5 (${s.load_5==-1?'–':Math.max(0,(s.load_5||0)).toFixed(2)}) - ● load15 (${s.load_15==-1?'–':Math.max(0,(s.load_15||0)).toFixed(2)}) - (~${(S.loadHist[key]?Math.max(S.loadHist[key].l1.length, S.loadHist[key].l5.length, S.loadHist[key].l15.length):0)} 条) -
-
- - ${latencyBlock} - `; - modal.style.display='flex'; - // 根据高负载阈值(>=90% 任一项)给弹窗加高亮底色 - const highLoad = (s.cpu||0) >= 90 || memPct >= 90 || hddPct >= 90; - if(modalBox){ modalBox.classList.toggle('high-load', highLoad); } - document.addEventListener('keydown', escCloseOnce); - if(!offline){ - drawLatencyChart(key); - drawLoadChart(key); - S._openDetailKey = key; // 记录当前弹窗对应节点(唯一 key) - startDetailAutoUpdate(); - } else { - S._openDetailKey = null; - stopDetailAutoUpdate(); - } -} -function escCloseOnce(e){ if(e.key==='Escape'){ closeDetail(); } } -function closeDetail(){ - const m=document.getElementById('detailModal'); - m.style.display='none'; - const b = m.querySelector('.modal-box'); - if(b) b.classList.remove('high-load'); - document.removeEventListener('keydown', escCloseOnce); - stopDetailAutoUpdate(); -} -document.getElementById('detailClose').addEventListener('click', closeDetail); -document.getElementById('detailModal').addEventListener('click', e=>{ if(e.target.id==='detailModal') closeDetail(); }); - -// 绘制三网延迟折线图 (简易实现) -function drawLatencyChart(key){ - const data = S.hist[key]; - const canvas = document.getElementById('latChart'); - if(!canvas || !data) return; - const ctx = canvas.getContext('2d'); - const W = canvas.clientWidth; const H = canvas.height; canvas.width = W; // 适配宽度 - ctx.clearRect(0,0,W,H); - const padL=40, padR=10, padT=10, padB=18; - const isLight = document.body.classList.contains('light'); - const axisColor = isLight? 'rgba(0,0,0,0.22)' : 'rgba(255,255,255,0.18)'; - const gridColor = isLight? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.10)'; - const textColor = isLight? 'var(--text-dim)' : 'rgba(226,232,240,0.85)'; - const series = [ {arr:data.cu,color:'#3b82f6'}, {arr:data.ct,color:'#10b981'}, {arr:data.cm,color:'#f59e0b'} ]; - const allVals = series.flatMap(s=>s.arr); - if(!allVals.length){ ctx.fillStyle='var(--text-dim)'; ctx.font='12px system-ui'; ctx.fillText('暂无数据', W/2-30, H/2); return; } - const max = Math.max(...allVals); - const min = Math.min(...allVals); - const range = Math.max(1, max-min); - const n = Math.max(...series.map(s=>s.arr.length)); - const xStep = (W - padL - padR) / Math.max(1,n-1); - // 网格与轴 (增强暗色对比) - ctx.strokeStyle=axisColor; ctx.lineWidth=1.1; - ctx.beginPath(); ctx.moveTo(padL,padT); ctx.lineTo(padL,H-padB); ctx.lineTo(W-padR,H-padB); ctx.stroke(); - ctx.fillStyle=textColor; ctx.font='10px system-ui'; - const yMarks=4; for(let i=0;i<=yMarks;i++){ const y = padT + (H-padT-padB)*i/yMarks; const val = (max - range*i/yMarks).toFixed(0)+'ms'; ctx.fillText(val,4,y+3); ctx.strokeStyle=gridColor; ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(W-padR,y); ctx.stroke(); } - // 绘制线 - series.forEach(s=>{ - if(s.arr.length<2) return; - ctx.strokeStyle = s.color; ctx.lineWidth=1.6; ctx.beginPath(); - s.arr.forEach((v,i)=>{ const x = padL + xStep*i; const y = padT + (H-padT-padB)*(1-(v-min)/range); if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); }); - ctx.stroke(); - }); -} - -// 在每次 render 后若弹窗打开则重绘最新图 -const _oldRender = render; -render = function(){ _oldRender(); if(S._openDetailKey){ drawLatencyChart(S._openDetailKey); drawLoadChart(S._openDetailKey); } }; -window.addEventListener('resize', ()=>{ - if(S._openDetailKey){ drawLatencyChart(S._openDetailKey); drawLoadChart(S._openDetailKey); } - renderServersCards(); - renderMonitorsCards(); - renderSSLCards(); -}); - -// (清理) drawSparks 已移除 - -// 负载折线图 (load1 历史) -function drawLoadChart(key){ - const L = S.loadHist[key]; - const canvas = document.getElementById('loadChart'); - if(!canvas) return; const ctx = canvas.getContext('2d'); - if(!L){ ctx.clearRect(0,0,canvas.width,canvas.height); return; } - const l1=L.l1||[], l5=L.l5||[], l15=L.l15||[]; - const canvasW = canvas.clientWidth; const H = canvas.height; canvas.width = canvasW; const W=canvasW; - ctx.clearRect(0,0,W,H); - if(l1.length<2){ ctx.fillStyle='var(--text-dim)'; ctx.font='12px system-ui'; ctx.fillText('暂无负载数据', W/2-42, H/2); return; } - const all = [...l1,...l5,...l15]; - const padL=38,padR=8,padT=8,padB=16; - // 修正:纵轴下限不小于 0,且当真实 range <0.5 时向上扩展 max 而不是向下产生负刻度 - const rawMax = all.length? Math.max(...all):0; - const rawMin = all.length? Math.min(...all):0; - const min = 0; // 我们只显示 >=0 - let max = Math.max(rawMax,0); - let range = max - min; - const MIN_RANGE = 0.5; - if(range < MIN_RANGE){ max = MIN_RANGE; range = MIN_RANGE; } - const n = Math.max(l1.length,l5.length,l15.length); const xStep=(W-padL-padR)/Math.max(1,n-1); - const isLight = document.body.classList.contains('light'); - const axisColor = isLight? 'rgba(0,0,0,0.22)' : 'rgba(255,255,255,0.18)'; - const gridColor = isLight? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.10)'; - const textColor = isLight? 'var(--text-dim)' : 'rgba(226,232,240,0.85)'; - // 轴 & 网格 (增强暗色对比) - ctx.strokeStyle=axisColor; ctx.lineWidth=1.1; ctx.beginPath(); ctx.moveTo(padL,padT); ctx.lineTo(padL,H-padB); ctx.lineTo(W-padR,H-padB); ctx.stroke(); - ctx.fillStyle=textColor; ctx.font='10px system-ui'; - const yMarks=4; for(let i=0;i<=yMarks;i++){ - const y=padT+(H-padT-padB)*i/yMarks; - const val=(max - range*i/yMarks); // top -> bottom - const labelVal = (Math.abs(val) < 0.005 ? 0 : val).toFixed(2); - ctx.fillText(labelVal,4,y+3); - ctx.strokeStyle=gridColor; ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(W-padR,y); ctx.stroke(); - } - const series=[{arr:l1,color:'#8b5cf6',fill:true},{arr:l5,color:'#10b981'},{arr:l15,color:'#f59e0b'}]; - // 面积先画 load1 - series.forEach(s=>{ - if(s.arr.length<2) return; - ctx.beginPath(); ctx.lineWidth=1.5; ctx.strokeStyle=s.color; - s.arr.forEach((v,i)=>{ const vClamped = Math.max(0, v); const x=padL+xStep*i; const y=padT+(H-padT-padB)*(1-(vClamped-min)/range); if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); }); - ctx.stroke(); - if(s.fill){ - const lastX = padL + xStep*(s.arr.length-1); - ctx.lineTo(lastX,H-padB); ctx.lineTo(padL,H-padB); ctx.closePath(); - const grd = ctx.createLinearGradient(0,padT,0,H-padB); grd.addColorStop(0,'rgba(139,92,246,0.25)'); grd.addColorStop(1,'rgba(139,92,246,0)'); - ctx.fillStyle=grd; ctx.fill(); - } - }); -} - -// source map 注释移除,避免 404 请求 - -// ====== 详情动态刷新 ====== -function findServerByKey(key){ return S.servers.find(x=> (x._key)===key); } -function updateDetailMetrics(key){ - const s = findServerByKey(key); if(!s) return; if(!(s.online4||s.online6)) return; // 离线不更新 - const procLine = `${num(s.tcp_count)} / ${num(s.udp_count)} / ${num(s.process_count)} / ${num(s.thread_count)}`; - const procEl = document.getElementById('detail-proc'); if(procEl) procEl.textContent = procLine; - // 延迟动态刷新 (若存在) - const cuEl=document.getElementById('lat-cu'); if(cuEl) cuEl.textContent = num(s.time_10010)+'ms'; - const ctEl=document.getElementById('lat-ct'); if(ctEl) ctEl.textContent = num(s.time_189)+'ms'; - const cmEl=document.getElementById('lat-cm'); if(cmEl) cmEl.textContent = num(s.time_10086)+'ms'; - // 刷新联通/电信/移动历史计数(取三者最大长度) - const latCntEl = document.getElementById('lat-count'); - if(latCntEl){ - const H = S.hist[key]; - const n = H ? Math.max(H.cu.length||0, H.ct.length||0, H.cm.length||0) : 0; - latCntEl.textContent = n; - } - // 资源动态刷新 - const memLineEl = document.getElementById('mem-line'); - if(memLineEl){ - const pct = s.memory_total? (s.memory_used/s.memory_total*100):0; - document.getElementById('mem-used').textContent = s.memory_total!=null? humanMinMBFromKB(s.memory_used||0):'-'; - document.getElementById('mem-total').textContent = s.memory_total!=null? humanMinMBFromKB(s.memory_total):'-'; - if(pct>80) memLineEl.style.color='var(--danger)'; else memLineEl.style.color=''; - } - const swapLineEl = document.getElementById('swap-line'); - if(swapLineEl){ - const pct = s.swap_total? (s.swap_used/s.swap_total*100):0; - document.getElementById('swap-used').textContent = s.swap_total!=null? humanMinMBFromKB(s.swap_used||0):'-'; - document.getElementById('swap-total').textContent = s.swap_total!=null? humanMinMBFromKB(s.swap_total):'-'; - if(pct>80) swapLineEl.style.color='var(--danger)'; else swapLineEl.style.color=''; - } - const diskLineEl = document.getElementById('disk-line'); - if(diskLineEl){ - const pct = s.hdd_total? (s.hdd_used/s.hdd_total*100):0; - document.getElementById('hdd-used').textContent = s.hdd_total!=null? humanMinMBFromMB(s.hdd_used||0):'-'; - document.getElementById('hdd-total').textContent = s.hdd_total!=null? humanMinMBFromMB(s.hdd_total):'-'; - if(pct>80) diskLineEl.style.color='var(--danger)'; else diskLineEl.style.color=''; - } - const ioReadEl = document.getElementById('io-read'); if(ioReadEl){ const v = (typeof s.io_read==='number')? s.io_read:0; ioReadEl.textContent = humanRateMinMBFromB(v); ioReadEl.style.color = v>100*1000*1000? 'var(--danger)':''; } - const ioWriteEl = document.getElementById('io-write'); if(ioWriteEl){ const v = (typeof s.io_write==='number')? s.io_write:0; ioWriteEl.textContent = humanRateMinMBFromB(v); ioWriteEl.style.color = v>100*1000*1000? 'var(--danger)':''; } - // 动态刷新负载标签与条数 - const l1El = document.getElementById('load1-val'); if(l1El) l1El.textContent = s.load_1==-1?'–':Math.max(0,(s.load_1||0)).toFixed(2); - const l5El = document.getElementById('load5-val'); if(l5El) l5El.textContent = s.load_5==-1?'–':Math.max(0,(s.load_5||0)).toFixed(2); - const l15El = document.getElementById('load15-val'); if(l15El) l15El.textContent = s.load_15==-1?'–':Math.max(0,(s.load_15||0)).toFixed(2); - const cntEl = document.getElementById('load-count'); - if(cntEl){ const L=S.loadHist[key]; const n = L? Math.max(L.l1.length, L.l5.length, L.l15.length):0; cntEl.textContent = n; } -} -function startDetailAutoUpdate(){ stopDetailAutoUpdate(); S._detailTimer = setInterval(()=>{ if(S._openDetailKey) updateDetailMetrics(S._openDetailKey); }, 1000); } -function stopDetailAutoUpdate(){ if(S._detailTimer){ clearInterval(S._detailTimer); S._detailTimer=null; } }
协议月流量 ↓|↑节点虚拟化位置协议月流量 ↓|↑节点虚拟化位置 在线负载负载 当前网络 ↓|↑ 总流量 ↓|↑CPU内存硬盘联通|电信|移动CPU内存硬盘联通|电信|移动
${statusPill}${monthIn}${monthOut}${s.name||'-'}${s.type||'-'}${s.location||'-'}${s.uptime||'-'}${s.load_1==-1?'–':Math.max(0,(s.load_1||0)).toFixed(2)}
${protoPill(s)}${trafficCaps(s)}${esc(s.name || '-')}${esc(s.type || '-')}${esc(s.location || '-')}${esc(s.uptime || '-')}${s.load_1 === -1 ? '–' : num(s.load_1).toFixed(2)} ${netNow} ${netTotal}${online?gaugeHTML('cpu', s.cpu||0):'-'}${online?gaugeHTML('mem', memPct):'-'}${online?gaugeHTML('hdd', hddPct):'-'}${pingBuckets}${m.online ? gaugeHTML('cpu', s.cpu) : '-'}${m.online ? gaugeHTML('mem', m.memPct) : '-'}${m.online ? gaugeHTML('hdd', m.hddPct) : '-'}${buckets(s)}
无数据
无数据
${pill}${s.name||'-'}${s.location||'-'}
${row||'-'}
无数据
${protoPill(s)}${esc(s.name || '-')}${esc(s.location || '-')}
${monitorItems(s)}
无数据
${c.name||'-'}${(c.domain||'').replace(/^https?:\/\//,'')}${c.port||443}${c.expire_days??'-'}${dt}${status}
无证书数据
${esc(c.name || '-')}${esc(String(c.domain || '').replace(/^https?:\/\//,''))}${esc(c.port || 443)}${esc(c.expire_days ?? '-')}${dt}${status}
无证书数据