mirror of
https://github.com//cppla/ServerStatus
synced 2026-08-09 02:23:57 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0aae47efc | ||
|
|
a31fb176c4 | ||
|
|
f19d10966d | ||
|
|
9ac276d443 | ||
|
|
c58eaf6ff5 | ||
|
|
f24a70b9d7 | ||
|
|
23a620274d | ||
|
|
e903200d66 | ||
|
|
57c74b0c9e | ||
|
|
caf5d5c34d | ||
|
|
ed64656a06 | ||
|
|
bbde4cb1e9 | ||
|
|
678cf43077 | ||
|
|
4bbd54dd06 | ||
|
|
eea08f529e | ||
|
|
d1ad6b0f43 | ||
|
|
570e14c1fa | ||
|
|
7b9da31db0 | ||
|
|
6552959ef3 | ||
|
|
14ee075853 | ||
|
|
c1955d7ca5 | ||
|
|
41ec81bed3 | ||
|
|
1557673db2 | ||
|
|
565f8c7ce0 | ||
|
|
163c46f4de | ||
|
|
a46ed4ea1a | ||
|
|
4d9372768f | ||
|
|
eed03f641d | ||
|
|
25effc0e2f | ||
|
|
dcec9598c1 | ||
|
|
f8527cc297 | ||
|
|
a4fc285be6 | ||
|
|
195594b8c4 | ||
|
|
405e95bff8 | ||
|
|
72b621e277 | ||
|
|
c089ac7834 | ||
|
|
ae648fbe66 | ||
|
|
68a1d3719f | ||
|
|
9cd86ccaa3 | ||
|
|
956a5ace26 |
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.idea
|
||||||
|
*.sublime-workspace
|
||||||
|
server/obj
|
||||||
|
server/sergate
|
||||||
|
web/json/stats.json
|
||||||
|
web/json/stats.json~
|
||||||
|
*.bak-*
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
@@ -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 .
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
# The Dockerfile for build localhost source, not git repo
|
|
||||||
FROM debian:bookworm AS builder
|
|
||||||
|
|
||||||
LABEL maintainer="cppla <https://cpp.la>"
|
|
||||||
|
|
||||||
RUN apt-get update -y && apt-get -y install gcc g++ make libcurl4-openssl-dev
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
WORKDIR /server
|
|
||||||
|
|
||||||
RUN make -j
|
|
||||||
RUN pwd && ls -a
|
|
||||||
|
|
||||||
# glibc env run
|
|
||||||
FROM nginx:latest
|
|
||||||
|
|
||||||
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 server /ServerStatus/server/
|
|
||||||
COPY --from=builder web /usr/share/nginx/html/
|
|
||||||
|
|
||||||
# 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"]
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
FROM alpine:3.13
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Runtime deps for client-linux.py and client-psutil.py
|
||||||
|
RUN apk add --no-cache python3 py3-psutil iproute2 procps
|
||||||
|
|
||||||
|
COPY clients/client-linux.py /app/client-linux.py
|
||||||
|
COPY clients/client-psutil.py /app/client-psutil.py
|
||||||
|
COPY clients/entrypoint.sh /app/entrypoint.sh
|
||||||
|
|
||||||
|
# Default client and defaults (can be overridden by docker env)
|
||||||
|
ENV 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
|
||||||
|
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
CMD []
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
FROM python:3.12-slim-bookworm AS builder
|
||||||
|
|
||||||
|
LABEL maintainer="cppla <https://cpp.la>"
|
||||||
|
|
||||||
|
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 server/ /server/
|
||||||
|
|
||||||
|
WORKDIR /server
|
||||||
|
|
||||||
|
RUN make -j && strip /server/sergate
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
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/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
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
|
EXPOSE 80 35601
|
||||||
|
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"]
|
||||||
@@ -1,211 +1,339 @@
|
|||||||
# ServerStatus中文版:
|
# ServerStatus 中文版
|
||||||
|
|
||||||
* ServerStatus中文版是一个酷炫高逼格的云探针、云监控、服务器云监控、多服务器探针~。。
|
ServerStatus 是一个轻量的服务器探针和云监控面板,支持多节点在线状态、资源占用、三网延迟、服务监测、SSL 证书检查、Watchdog 告警和 Web 配置管理。
|
||||||
* 在线演示:https://tz.cloudcpp.com
|
|
||||||
|
在线演示:https://tz.cloudcpp.com
|
||||||
|
|
||||||
[](https://github.com/cppla/ServerStatus)
|
[](https://github.com/cppla/ServerStatus)
|
||||||
[](https://github.com/cppla/ServerStatus)
|
[](https://github.com/cppla/ServerStatus)
|
||||||
[](https://github.com/cppla/ServerStatus)
|
[](https://github.com/cppla/ServerStatus)
|
||||||
[](https://github.com/cppla/ServerStatus)
|
[](https://github.com/cppla/ServerStatus)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
`Watchdog触发式告警,interval只是为了防止频繁收到报警信息造成的骚扰,并不是探测间隔。值得注意的是,Exprtk库默认使用窄字符类型,中文等Unicode字符无法解析计算,等待修复。 `
|
`Watchdog 的 `interval` 是最小通知间隔,用于避免频繁报警,并不是探测间隔。`rule` 使用 Exprtk 表达式,当前窄字符解析对中文等 Unicode 字符不友好,规则中建议使用英文、数字和字段名。`
|
||||||
|
|
||||||
# 目录:
|
|
||||||
|
|
||||||
* clients 客户端文件
|
## 一、服务端
|
||||||
* server 服务端文件
|
|
||||||
* web 网站文件
|
|
||||||
* server/config.json 探针配置文件
|
|
||||||
* web/json 探针月流量
|
|
||||||
|
|
||||||
# 部署:
|
|
||||||
|
|
||||||
【服务端】:
|
|
||||||
```bash
|
```bash
|
||||||
|
# Docker Compose,本地构建加:--build
|
||||||
`Docker`:
|
ADMIN_TOKEN='your-strong-token' docker compose -f docker-compose-server.yml up -d
|
||||||
|
|
||||||
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 -v ~/serverstatus-config.json:/ServerStatus/server/config.json -v ~/serverstatus-monthtraffic:/usr/share/nginx/html/json -p 80:80 -p 35601:35601 cppla/serverstatus:latest
|
|
||||||
|
|
||||||
`Docker-compose(推荐)`: docker-compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
【客户端】:
|
|
||||||
```bash
|
```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={$SERVER} USER={$USER} PASSWORD={$PASSWORD} >/dev/null 2>&1 &
|
# Docker Run
|
||||||
|
wget --no-check-certificate -qO ~/serverstatus-config.json \
|
||||||
|
https://raw.githubusercontent.com/cppla/ServerStatus/master/server/config.json
|
||||||
|
mkdir -p ~/serverstatus-monthtraffic
|
||||||
|
|
||||||
eg:
|
docker run -d --restart=always --name=serverstatus-server \
|
||||||
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=45.79.67.132 USER=s04 >/dev/null 2>&1 &
|
-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
|
||||||
```
|
```
|
||||||
|
|
||||||
# 主题:
|
启动后访问:
|
||||||
|
|
||||||
* layui:https://github.com/zeyudada/StatusServerLayui ,预览:https://sslt.8zyw.cn
|
- WebUI:http://127.0.0.1:8080/
|
||||||
<img src=https://dl.cpp.la/Archive/serverstatus_layui.png width=200 height=100 />
|
- HTTP API 自检:http://127.0.0.1:8080/api/health
|
||||||
|
- HTTP API 文档:http://127.0.0.1:8080/api/schema
|
||||||
* light:https://github.com/orilights/ServerStatus-Theme-Light ,预览:https://tz.cloudcpp.com/index3.html
|
- HTTP 默认端口映射为`8080:80`,客户端连接端口为`35601`。`ADMIN_TOKEN` 可选:不设置时Web仅可查看监控数据,但web配置页无法修改。
|
||||||
<img src=https://dl.cpp.la/Archive/serverstatus_light.png width=200 height=100 />
|
|
||||||
|
|
||||||
|
|
||||||
# 手动安装教程:
|
## 二、客户端
|
||||||
|
|
||||||
**【服务端配置】**
|
|
||||||
|
|
||||||
#### 一、生成服务端程序
|
|
||||||
```
|
|
||||||
`Debian/Ubuntu`: apt-get -y install gcc g++ make libcurl4-openssl-dev
|
|
||||||
`Centos/Redhat`: yum -y install gcc gcc-c++ make libcurl-devel
|
|
||||||
|
|
||||||
cd ServerStatus/server && make
|
```bash
|
||||||
./sergate
|
# Docker Compose,本地构建加:--build
|
||||||
```
|
SERVER=127.0.0.1 USER=s01 docker compose -f docker-compose-client.yml up -d --force-recreate
|
||||||
如果没错误提示,OK,ctrl+c关闭;如果有错误提示,检查35601端口是否被占用
|
|
||||||
|
|
||||||
#### 二、修改配置文件
|
|
||||||
```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
|
||||||
|
# Docker Run
|
||||||
|
docker run -d --restart=always --name=serverstatus-client \
|
||||||
|
--network=host --pid=host \
|
||||||
|
-e SERVER=127.0.0.1 \
|
||||||
|
-e USER=s01 \
|
||||||
|
cppla/serverstatus:client
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Shell Run
|
||||||
|
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 >/dev/null 2>&1 &)
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端环境变量和注意事项:
|
||||||
|
`USER` 是常见的宿主机环境变量名。如果没有显式传递,Compose 可能会把系统里的 `$USER` 解析成本机用户名。推荐优先级:1. 运行命令显式传递 `USER=...`,2. 用户修改 `docker-compose-client.yml` 里的 `USER` 默认值
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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` |
|
||||||
|
|
||||||
|
## 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":
|
"servers": [
|
||||||
[
|
{
|
||||||
{
|
"username": "s01",
|
||||||
"username": "s01",
|
"name": "node1",
|
||||||
"name": "vps-1",
|
"type": "kvm",
|
||||||
"type": "kvm",
|
"host": "host1",
|
||||||
"host": "chengdu",
|
"location": "CN",
|
||||||
"location": "🇨🇳",
|
"password": "USER_DEFAULT_PASSWORD",
|
||||||
"password": "USER_DEFAULT_PASSWORD",
|
"monthstart": 1
|
||||||
"monthstart": 1
|
}
|
||||||
}
|
],
|
||||||
],
|
"monitors": [
|
||||||
"monitors": [
|
{
|
||||||
{
|
"name": "example",
|
||||||
"name": "监测网站,默认为一天在线率",
|
"host": "https://example.com",
|
||||||
"host": "https://www.baidu.com",
|
"interval": 600,
|
||||||
"interval": 1200,
|
"type": "https"
|
||||||
"type": "https"
|
}
|
||||||
},
|
],
|
||||||
{
|
"sslcerts": [
|
||||||
"name": "监测tcp服务端口",
|
{
|
||||||
"host": "1.1.1.1:80",
|
"name": "example",
|
||||||
"interval": 1200,
|
"domain": "https://example.com",
|
||||||
"type": "tcp"
|
"port": 443,
|
||||||
}
|
"interval": 7200,
|
||||||
],
|
"callback": "https://yourSMSurl"
|
||||||
"sslcerts": [
|
}
|
||||||
{
|
],
|
||||||
"name": "demo域名",
|
"watchdog": [
|
||||||
"domain": "https://demo.example.com",
|
{
|
||||||
"port": 443,
|
"name": "offline warning",
|
||||||
"interval": 600,
|
"rule": "online4=0&online6=0",
|
||||||
"callback": "https://yourSMSurl"
|
"interval": 600,
|
||||||
}
|
"callback": "https://yourSMSurl"
|
||||||
],
|
},
|
||||||
"watchdog":
|
{
|
||||||
[
|
"name": "cpu high warning",
|
||||||
{
|
"rule": "cpu>90&load_1>5&username!='s01'",
|
||||||
"name": "服务器负载高监控,排除内存大于32G物理机,同时排除node1机器",
|
"interval": 600,
|
||||||
"rule": "cpu>90&load_1>4&memory_total<33554432&name!='node1'",
|
"callback": "https://yourSMSurl"
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 三、拷贝ServerStatus/status到你的网站目录
|
常见 Watchdog 回调:
|
||||||
例如:
|
|
||||||
```
|
```text
|
||||||
sudo cp -r ServerStatus/web/* /home/wwwroot/default
|
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
|
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
```
|
```
|
||||||
|
|
||||||
**【客户端配置】**
|
编译并运行 `sergate`:
|
||||||
|
|
||||||
客户端有两个版本,client-linux为普通linux,client-psutil为跨平台版,普通版不成功,换成跨平台版即可。
|
```bash
|
||||||
|
cd ServerStatus/server
|
||||||
#### 一、client-linux版配置:
|
make
|
||||||
1、vim client-linux.py, 修改SERVER地址,username帐号, password密码
|
mkdir -p ../web/json
|
||||||
2、python3 client-linux.py 运行即可。
|
./sergate --config=config.json --web-dir=../web &
|
||||||
|
echo $! > /tmp/serverstatus-sergate.pid
|
||||||
#### 二、client-psutil版配置:
|
|
||||||
1、安装psutil跨平台依赖库
|
|
||||||
```
|
```
|
||||||
`Debian/Ubuntu`: apt -y install python3-pip && pip3 install psutil
|
|
||||||
`Centos/Redhat`: yum -y install python3-pip gcc python3-devel && pip3 install psutil
|
如果只需要 HTTP API,可以再启动 `manage_api.py`:
|
||||||
`Windows`: https://pypi.org/project/psutil/
|
|
||||||
|
```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
|
||||||
```
|
```
|
||||||
2、vim client-psutil.py, 修改SERVER地址,username帐号, password密码
|
|
||||||
3、python3 client-psutil.py 运行即可。
|
|
||||||
|
|
||||||
服务器和客户端自行加入开机启动,或进程守护,或后台方式运行。 例如: nohup python3 client-linux.py &
|
源码方式直连 API:
|
||||||
|
|
||||||
`extra scene (run web/ssview.py)`
|
```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/`。示例配置:
|
||||||
|
|
||||||
# Make Better
|
```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
|
||||||
|
|
||||||
|
python3 clients/client-psutil.py SERVER=127.0.0.1 USER=s01
|
||||||
|
```
|
||||||
|
|
||||||
|
后台运行与开机启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nohup python3 client-linux.py SERVER=127.0.0.1 USER=s01 &
|
||||||
|
|
||||||
|
# crontab -e
|
||||||
|
@reboot /usr/bin/python3 /path/to/client-linux.py SERVER=127.0.0.1 USER=s01
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地构建镜像
|
||||||
|
|
||||||
|
```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
|
* BotoX:https://github.com/BotoX/ServerStatus
|
||||||
* mojeda: https://github.com/mojeda
|
* mojeda:https://github.com/mojeda
|
||||||
* mojeda's ServerStatus: https://github.com/mojeda/ServerStatus
|
* mojeda's ServerStatus:https://github.com/mojeda/ServerStatus
|
||||||
* BlueVM's project: http://www.lowendtalk.com/discussion/comment/169690#Comment_169690
|
* BlueVM's project:http://www.lowendtalk.com/discussion/comment/169690#Comment_169690
|
||||||
|
|||||||
+158
-97
@@ -1,13 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
# Update by : https://github.com/cppla/ServerStatus, Update date: 20220530
|
# Update by : https://github.com/cppla/ServerStatus, Update date: 20250902
|
||||||
# 版本:1.0.3, 支持Python版本:2.7 to 3.10
|
# 版本:1.1.0, 支持Python版本:3.6+
|
||||||
# 支持操作系统: Linux, OSX, FreeBSD, OpenBSD and NetBSD, both 32-bit and 64-bit architectures
|
# 支持操作系统: Linux, OSX, FreeBSD, OpenBSD and NetBSD, both 32-bit and 64-bit architectures
|
||||||
# ONLINE_PACKET_HISTORY_LEN, 探测间隔1200s,记录24小时在线率(72);探测时间300s,记录24小时(288);探测间隔60s,记录7天(10080)
|
|
||||||
# 说明: 默认情况下修改server和user就可以了。丢包率监测方向可以自定义,例如:CU = "www.facebook.com"。
|
# 说明: 默认情况下修改server和user就可以了。丢包率监测方向可以自定义,例如:CU = "www.facebook.com"。
|
||||||
|
|
||||||
SERVER = "127.0.0.1"
|
SERVER = ""
|
||||||
USER = "s01"
|
USER = ""
|
||||||
|
|
||||||
|
|
||||||
PASSWORD = "USER_DEFAULT_PASSWORD"
|
PASSWORD = "USER_DEFAULT_PASSWORD"
|
||||||
@@ -18,11 +17,9 @@ CM = "cm.tz.cloudcpp.com"
|
|||||||
PROBEPORT = 80
|
PROBEPORT = 80
|
||||||
PROBE_PROTOCOL_PREFER = "ipv4" # ipv4, ipv6
|
PROBE_PROTOCOL_PREFER = "ipv4" # ipv4, ipv6
|
||||||
PING_PACKET_HISTORY_LEN = 100
|
PING_PACKET_HISTORY_LEN = 100
|
||||||
ONLINE_PACKET_HISTORY_LEN = 72
|
|
||||||
INTERVAL = 1
|
INTERVAL = 1
|
||||||
|
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
|
||||||
import time
|
import time
|
||||||
import timeit
|
import timeit
|
||||||
import re
|
import re
|
||||||
@@ -32,10 +29,36 @@ import json
|
|||||||
import errno
|
import errno
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
if sys.version_info.major == 3:
|
import platform
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
elif sys.version_info.major == 2:
|
|
||||||
from Queue import Queue
|
def _env_str(name, default):
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _env_int(name, default):
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
# Allow docker env overrides. 优先级:运行程序传递参数 > 用户修改的USER > Docker/系统
|
||||||
|
SERVER = _env_str("SERVER", SERVER) if SERVER == "" else SERVER
|
||||||
|
USER = _env_str("USER", USER) if USER == "" else USER
|
||||||
|
PASSWORD = _env_str("PASSWORD", PASSWORD)
|
||||||
|
PORT = _env_int("PORT", PORT)
|
||||||
|
INTERVAL = _env_int("INTERVAL", INTERVAL)
|
||||||
|
PROBEPORT = _env_int("PROBEPORT", PROBEPORT)
|
||||||
|
PROBE_PROTOCOL_PREFER = _env_str("PROBE_PROTOCOL_PREFER", PROBE_PROTOCOL_PREFER)
|
||||||
|
PING_PACKET_HISTORY_LEN = _env_int("PING_PACKET_HISTORY_LEN", PING_PACKET_HISTORY_LEN)
|
||||||
|
CU = _env_str("CU", CU)
|
||||||
|
CT = _env_str("CT", CT)
|
||||||
|
CM = _env_str("CM", CM)
|
||||||
|
|
||||||
def get_uptime():
|
def get_uptime():
|
||||||
with open('/proc/uptime', 'r') as f:
|
with open('/proc/uptime', 'r') as f:
|
||||||
@@ -58,11 +81,34 @@ def get_memory():
|
|||||||
return int(MemTotal), int(MemUsed), int(SwapTotal), int(SwapFree)
|
return int(MemTotal), int(MemUsed), int(SwapTotal), int(SwapFree)
|
||||||
|
|
||||||
def get_hdd():
|
def get_hdd():
|
||||||
p = subprocess.check_output(['df', '-Tlm', '--total', '-t', 'ext4', '-t', 'ext3', '-t', 'ext2', '-t', 'reiserfs', '-t', 'jfs', '-t', 'ntfs', '-t', 'fat32', '-t', 'btrfs', '-t', 'fuseblk', '-t', 'zfs', '-t', 'simfs', '-t', 'xfs']).decode("Utf-8")
|
valid_fs = {
|
||||||
total = p.splitlines()[-1]
|
"ext4", "ext3", "ext2", "reiserfs", "jfs", "btrfs", "fuseblk",
|
||||||
used = total.split()[3]
|
"zfs", "simfs", "ntfs", "fat32", "exfat", "xfs"
|
||||||
size = total.split()[2]
|
}
|
||||||
return int(size), int(used)
|
disks = {}
|
||||||
|
size = 0
|
||||||
|
used = 0
|
||||||
|
try:
|
||||||
|
with open("/proc/mounts", "r") as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
device = parts[0]
|
||||||
|
mountpoint = parts[1]
|
||||||
|
fstype = parts[2].lower()
|
||||||
|
if fstype not in valid_fs or device in disks:
|
||||||
|
continue
|
||||||
|
disks[device] = mountpoint
|
||||||
|
for mountpoint in disks.values():
|
||||||
|
st = os.statvfs(mountpoint)
|
||||||
|
total_bytes = st.f_blocks * st.f_frsize
|
||||||
|
used_bytes = (st.f_blocks - st.f_bavail) * st.f_frsize
|
||||||
|
size += total_bytes
|
||||||
|
used += used_bytes
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return int(size / 1024 / 1024), int(used / 1024 / 1024)
|
||||||
|
|
||||||
def get_time():
|
def get_time():
|
||||||
with open("/proc/stat", "r") as f:
|
with open("/proc/stat", "r") as f:
|
||||||
@@ -92,7 +138,7 @@ def liuliang():
|
|||||||
NET_OUT = 0
|
NET_OUT = 0
|
||||||
with open('/proc/net/dev') as f:
|
with open('/proc/net/dev') as f:
|
||||||
for line in f.readlines():
|
for line in f.readlines():
|
||||||
netinfo = re.findall('([^\s]+):[\s]{0,}(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)', line)
|
netinfo = re.findall(r'([^\s]+):[\s]{0,}(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)', line)
|
||||||
if netinfo:
|
if netinfo:
|
||||||
if netinfo[0][0] == 'lo' or 'tun' in netinfo[0][0] \
|
if netinfo[0][0] == 'lo' or 'tun' in netinfo[0][0] \
|
||||||
or 'docker' in netinfo[0][0] or 'veth' in netinfo[0][0] \
|
or 'docker' in netinfo[0][0] or 'veth' in netinfo[0][0] \
|
||||||
@@ -320,87 +366,66 @@ def get_realtime_data():
|
|||||||
|
|
||||||
|
|
||||||
def _monitor_thread(name, host, interval, type):
|
def _monitor_thread(name, host, interval, type):
|
||||||
lostPacket = 0
|
|
||||||
packet_queue = Queue(maxsize=ONLINE_PACKET_HISTORY_LEN)
|
|
||||||
while True:
|
while True:
|
||||||
if name not in monitorServer.keys():
|
if name not in monitorServer.keys():
|
||||||
break
|
break
|
||||||
if packet_queue.full():
|
|
||||||
if packet_queue.get() == 0:
|
|
||||||
lostPacket -= 1
|
|
||||||
try:
|
try:
|
||||||
if type == "http":
|
# 1) 解析目标 host 与端口
|
||||||
address = host.replace("http://", "")
|
if type == 'http':
|
||||||
m = timeit.default_timer()
|
addr = str(host).replace('http://','')
|
||||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
addr = addr.split('/',1)[0]
|
||||||
IP = socket.getaddrinfo(address, None, socket.AF_INET)[0][4][0]
|
port = 80
|
||||||
|
if ':' in addr and not addr.startswith('['):
|
||||||
|
a, p = addr.rsplit(':',1)
|
||||||
|
if p.isdigit():
|
||||||
|
addr, port = a, int(p)
|
||||||
|
elif type == 'https':
|
||||||
|
addr = str(host).replace('https://','')
|
||||||
|
addr = addr.split('/',1)[0]
|
||||||
|
port = 443
|
||||||
|
if ':' in addr and not addr.startswith('['):
|
||||||
|
a, p = addr.rsplit(':',1)
|
||||||
|
if p.isdigit():
|
||||||
|
addr, port = a, int(p)
|
||||||
|
elif type == 'tcp':
|
||||||
|
addr = str(host)
|
||||||
|
if addr.startswith('[') and ']' in addr:
|
||||||
|
a = addr[1:addr.index(']')]
|
||||||
|
rest = addr[addr.index(']')+1:]
|
||||||
|
if rest.startswith(':') and rest[1:].isdigit():
|
||||||
|
addr, port = a, int(rest[1:])
|
||||||
|
else:
|
||||||
|
raise Exception('bad tcp target')
|
||||||
else:
|
else:
|
||||||
IP = socket.getaddrinfo(address, None, socket.AF_INET6)[0][4][0]
|
a, p = addr.rsplit(':',1)
|
||||||
monitorServer[name]["dns_time"] = int((timeit.default_timer() - m) * 1000)
|
addr, port = a, int(p)
|
||||||
m = timeit.default_timer()
|
else:
|
||||||
k = socket.create_connection((IP, 80), timeout=6)
|
time.sleep(interval)
|
||||||
monitorServer[name]["connect_time"] = int((timeit.default_timer() - m) * 1000)
|
continue
|
||||||
m = timeit.default_timer()
|
|
||||||
k.sendall("GET / HTTP/1.2\r\nHost:{}\r\nUser-Agent:ServerStatus/cppla\r\nConnection:close\r\n\r\n".format(address).encode('utf-8'))
|
# 2) 解析 IP(按偏好族)
|
||||||
response = b""
|
IP = addr
|
||||||
while True:
|
if addr.count(':') < 1: # 非纯 IPv6
|
||||||
data = k.recv(4096)
|
try:
|
||||||
if not data:
|
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
||||||
break
|
IP = socket.getaddrinfo(addr, None, socket.AF_INET)[0][4][0]
|
||||||
response += data
|
else:
|
||||||
http_code = response.decode('utf-8').split('\r\n')[0].split()[1]
|
IP = socket.getaddrinfo(addr, None, socket.AF_INET6)[0][4][0]
|
||||||
monitorServer[name]["download_time"] = int((timeit.default_timer() - m) * 1000)
|
except Exception:
|
||||||
k.close()
|
pass
|
||||||
if http_code not in ['200', '204', '301', '302', '401']:
|
|
||||||
raise Exception("http code not in 200, 204, 301, 302, 401")
|
# 3) 建连耗时(timeout=1s),ECONNREFUSED 也计入
|
||||||
elif type == "https":
|
try:
|
||||||
context = ssl._create_unverified_context()
|
b = timeit.default_timer()
|
||||||
address = host.replace("https://", "")
|
socket.create_connection((IP, port), timeout=1).close()
|
||||||
m = timeit.default_timer()
|
monitorServer[name]["latency"] = int((timeit.default_timer() - b) * 1000)
|
||||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
except socket.error as error:
|
||||||
IP = socket.getaddrinfo(address, None, socket.AF_INET)[0][4][0]
|
if getattr(error, 'errno', None) == errno.ECONNREFUSED:
|
||||||
|
monitorServer[name]["latency"] = int((timeit.default_timer() - b) * 1000)
|
||||||
else:
|
else:
|
||||||
IP = socket.getaddrinfo(address, None, socket.AF_INET6)[0][4][0]
|
monitorServer[name]["latency"] = 0
|
||||||
monitorServer[name]["dns_time"] = int((timeit.default_timer() - m) * 1000)
|
except Exception:
|
||||||
m = timeit.default_timer()
|
monitorServer[name]["latency"] = 0
|
||||||
k = socket.create_connection((IP, 443), timeout=6)
|
|
||||||
monitorServer[name]["connect_time"] = int((timeit.default_timer() - m) * 1000)
|
|
||||||
m = timeit.default_timer()
|
|
||||||
kk = context.wrap_socket(k, server_hostname=address)
|
|
||||||
kk.sendall("GET / HTTP/1.2\r\nHost:{}\r\nUser-Agent:ServerStatus/cppla\r\nConnection:close\r\n\r\n".format(address).encode('utf-8'))
|
|
||||||
response = b""
|
|
||||||
while True:
|
|
||||||
data = kk.recv(4096)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
response += data
|
|
||||||
http_code = response.decode('utf-8').split('\r\n')[0].split()[1]
|
|
||||||
monitorServer[name]["download_time"] = int((timeit.default_timer() - m) * 1000)
|
|
||||||
kk.close()
|
|
||||||
k.close()
|
|
||||||
if http_code not in ['200', '204', '301', '302', '401']:
|
|
||||||
raise Exception("http code not in 200, 204, 301, 302, 401")
|
|
||||||
elif type == "tcp":
|
|
||||||
m = timeit.default_timer()
|
|
||||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
|
||||||
IP = socket.getaddrinfo(host.split(":")[0], None, socket.AF_INET)[0][4][0]
|
|
||||||
else:
|
|
||||||
IP = socket.getaddrinfo(host.split(":")[0], None, socket.AF_INET6)[0][4][0]
|
|
||||||
monitorServer[name]["dns_time"] = int((timeit.default_timer() - m) * 1000)
|
|
||||||
m = timeit.default_timer()
|
|
||||||
k = socket.create_connection((IP, int(host.split(":")[1])), timeout=6)
|
|
||||||
monitorServer[name]["connect_time"] = int((timeit.default_timer() - m) * 1000)
|
|
||||||
m = timeit.default_timer()
|
|
||||||
k.send(b"GET / HTTP/1.2\r\n\r\n")
|
|
||||||
k.recv(1024)
|
|
||||||
monitorServer[name]["download_time"] = int((timeit.default_timer() - m) * 1000)
|
|
||||||
k.close()
|
|
||||||
packet_queue.put(1)
|
|
||||||
except Exception as e:
|
|
||||||
lostPacket += 1
|
|
||||||
packet_queue.put(0)
|
|
||||||
if packet_queue.qsize() > 5:
|
|
||||||
monitorServer[name]["online_rate"] = 1 - float(lostPacket) / packet_queue.qsize()
|
|
||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
|
|
||||||
def byte_str(object):
|
def byte_str(object):
|
||||||
@@ -455,10 +480,8 @@ if __name__ == '__main__':
|
|||||||
jdata = json.loads(i[i.find("{"):i.find("}")+1])
|
jdata = json.loads(i[i.find("{"):i.find("}")+1])
|
||||||
monitorServer[jdata.get("name")] = {
|
monitorServer[jdata.get("name")] = {
|
||||||
"type": jdata.get("type"),
|
"type": jdata.get("type"),
|
||||||
"dns_time": 0,
|
"host": jdata.get("host"),
|
||||||
"connect_time": 0,
|
"latency": 0
|
||||||
"download_time": 0,
|
|
||||||
"online_rate": 1
|
|
||||||
}
|
}
|
||||||
t = threading.Thread(
|
t = threading.Thread(
|
||||||
target=_monitor_thread,
|
target=_monitor_thread,
|
||||||
@@ -520,7 +543,45 @@ if __name__ == '__main__':
|
|||||||
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
|
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
|
||||||
array['io_read'] = diskIO.get("read")
|
array['io_read'] = diskIO.get("read")
|
||||||
array['io_write'] = diskIO.get("write")
|
array['io_write'] = diskIO.get("write")
|
||||||
array['custom'] = "<br>".join(f"{k}\\t解析: {v['dns_time']}\\t连接: {v['connect_time']}\\t下载: {v['download_time']}\\t在线率: <code>{v['online_rate']*100:.1f}%</code>" for k, v in monitorServer.items())
|
# report OS (normalized)
|
||||||
|
try:
|
||||||
|
sysname = platform.system().lower()
|
||||||
|
if sysname.startswith('linux'):
|
||||||
|
os_name = 'linux'
|
||||||
|
# try distro from os-release
|
||||||
|
try:
|
||||||
|
with open('/etc/os-release') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith('ID='):
|
||||||
|
val = line.strip().split('=',1)[1].strip().strip('"')
|
||||||
|
if val: os_name = val
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif sysname.startswith('darwin'):
|
||||||
|
os_name = 'darwin'
|
||||||
|
elif sysname.startswith('freebsd'):
|
||||||
|
os_name = 'freebsd'
|
||||||
|
elif sysname.startswith('openbsd'):
|
||||||
|
os_name = 'openbsd'
|
||||||
|
elif sysname.startswith('netbsd'):
|
||||||
|
os_name = 'netbsd'
|
||||||
|
else:
|
||||||
|
os_name = sysname or 'unknown'
|
||||||
|
except Exception:
|
||||||
|
os_name = 'unknown'
|
||||||
|
array['os'] = os_name
|
||||||
|
items = []
|
||||||
|
for _n, st in monitorServer.items():
|
||||||
|
key = str(_n)
|
||||||
|
try:
|
||||||
|
ms = int(st.get('latency') or 0)
|
||||||
|
except Exception:
|
||||||
|
ms = 0
|
||||||
|
items.append((key, max(0, ms)))
|
||||||
|
# 稳定顺序:按 key 排序
|
||||||
|
items.sort(key=lambda x: x[0])
|
||||||
|
array['custom'] = ';'.join(f"{k}={v}" for k,v in items)
|
||||||
s.send(byte_str("update " + json.dumps(array) + "\n"))
|
s.send(byte_str("update " + json.dumps(array) + "\n"))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
raise
|
raise
|
||||||
|
|||||||
+129
-92
@@ -1,14 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
# Update by : https://github.com/cppla/ServerStatus, Update date: 20220530
|
# Update by : https://github.com/cppla/ServerStatus, Update date: 20250902
|
||||||
# 依赖于psutil跨平台库
|
# 依赖于psutil跨平台库
|
||||||
# 版本:1.0.3, 支持Python版本:2.7 to 3.10
|
# 版本:1.1.0, 支持Python版本:3.6+
|
||||||
# 支持操作系统: Linux, Windows, OSX, Sun Solaris, FreeBSD, OpenBSD and NetBSD, both 32-bit and 64-bit architectures
|
# 支持操作系统: Linux, Windows, OSX, Sun Solaris, FreeBSD, OpenBSD and NetBSD, both 32-bit and 64-bit architectures
|
||||||
# ONLINE_PACKET_HISTORY_LEN, 探测间隔1200s,记录24小时在线率(72);探测时间300s,记录24小时(288);探测间隔60s,记录7天(10080)
|
|
||||||
# 说明: 默认情况下修改server和user就可以了。丢包率监测方向可以自定义,例如:CU = "www.facebook.com"。
|
# 说明: 默认情况下修改server和user就可以了。丢包率监测方向可以自定义,例如:CU = "www.facebook.com"。
|
||||||
|
|
||||||
SERVER = "127.0.0.1"
|
SERVER = ""
|
||||||
USER = "s01"
|
USER = ""
|
||||||
|
|
||||||
|
|
||||||
PASSWORD = "USER_DEFAULT_PASSWORD"
|
PASSWORD = "USER_DEFAULT_PASSWORD"
|
||||||
@@ -19,11 +18,9 @@ CM = "cm.tz.cloudcpp.com"
|
|||||||
PROBEPORT = 80
|
PROBEPORT = 80
|
||||||
PROBE_PROTOCOL_PREFER = "ipv4" # ipv4, ipv6
|
PROBE_PROTOCOL_PREFER = "ipv4" # ipv4, ipv6
|
||||||
PING_PACKET_HISTORY_LEN = 100
|
PING_PACKET_HISTORY_LEN = 100
|
||||||
ONLINE_PACKET_HISTORY_LEN = 72
|
|
||||||
INTERVAL = 1
|
INTERVAL = 1
|
||||||
|
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
|
||||||
import time
|
import time
|
||||||
import timeit
|
import timeit
|
||||||
import os
|
import os
|
||||||
@@ -32,10 +29,36 @@ import json
|
|||||||
import errno
|
import errno
|
||||||
import psutil
|
import psutil
|
||||||
import threading
|
import threading
|
||||||
if sys.version_info.major == 3:
|
import platform
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
elif sys.version_info.major == 2:
|
|
||||||
from Queue import Queue
|
def _env_str(name, default):
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _env_int(name, default):
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
# Allow docker env overrides. 优先级:运行程序传递参数 > 用户修改的USER > Docker/系统
|
||||||
|
SERVER = _env_str("SERVER", SERVER) if SERVER == "" else SERVER
|
||||||
|
USER = _env_str("USER", USER) if USER == "" else USER
|
||||||
|
PASSWORD = _env_str("PASSWORD", PASSWORD)
|
||||||
|
PORT = _env_int("PORT", PORT)
|
||||||
|
INTERVAL = _env_int("INTERVAL", INTERVAL)
|
||||||
|
PROBEPORT = _env_int("PROBEPORT", PROBEPORT)
|
||||||
|
PROBE_PROTOCOL_PREFER = _env_str("PROBE_PROTOCOL_PREFER", PROBE_PROTOCOL_PREFER)
|
||||||
|
PING_PACKET_HISTORY_LEN = _env_int("PING_PACKET_HISTORY_LEN", PING_PACKET_HISTORY_LEN)
|
||||||
|
CU = _env_str("CU", CU)
|
||||||
|
CT = _env_str("CT", CT)
|
||||||
|
CM = _env_str("CM", CM)
|
||||||
|
|
||||||
def get_uptime():
|
def get_uptime():
|
||||||
return int(time.time() - psutil.boot_time())
|
return int(time.time() - psutil.boot_time())
|
||||||
@@ -308,87 +331,68 @@ def get_realtime_data():
|
|||||||
ti.start()
|
ti.start()
|
||||||
|
|
||||||
def _monitor_thread(name, host, interval, type):
|
def _monitor_thread(name, host, interval, type):
|
||||||
lostPacket = 0
|
# 参考 _ping_thread 风格:每轮解析一次目标,按协议族偏好解析 IP,测 TCP 建连耗时
|
||||||
packet_queue = Queue(maxsize=ONLINE_PACKET_HISTORY_LEN)
|
|
||||||
while True:
|
while True:
|
||||||
if name not in monitorServer.keys():
|
if name not in monitorServer:
|
||||||
break
|
break
|
||||||
if packet_queue.full():
|
|
||||||
if packet_queue.get() == 0:
|
|
||||||
lostPacket -= 1
|
|
||||||
try:
|
try:
|
||||||
if type == "http":
|
# 1) 解析目标 host 与端口
|
||||||
address = host.replace("http://", "")
|
if type == 'http':
|
||||||
m = timeit.default_timer()
|
addr = str(host).replace('http://','')
|
||||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
addr = addr.split('/',1)[0]
|
||||||
IP = socket.getaddrinfo(address, None, socket.AF_INET)[0][4][0]
|
port = 80
|
||||||
|
if ':' in addr and not addr.startswith('['):
|
||||||
|
a, p = addr.rsplit(':',1)
|
||||||
|
if p.isdigit():
|
||||||
|
addr, port = a, int(p)
|
||||||
|
elif type == 'https':
|
||||||
|
addr = str(host).replace('https://','')
|
||||||
|
addr = addr.split('/',1)[0]
|
||||||
|
port = 443
|
||||||
|
if ':' in addr and not addr.startswith('['):
|
||||||
|
a, p = addr.rsplit(':',1)
|
||||||
|
if p.isdigit():
|
||||||
|
addr, port = a, int(p)
|
||||||
|
elif type == 'tcp':
|
||||||
|
addr = str(host)
|
||||||
|
if addr.startswith('[') and ']' in addr:
|
||||||
|
# [v6]:port
|
||||||
|
a = addr[1:addr.index(']')]
|
||||||
|
rest = addr[addr.index(']')+1:]
|
||||||
|
if rest.startswith(':') and rest[1:].isdigit():
|
||||||
|
addr, port = a, int(rest[1:])
|
||||||
|
else:
|
||||||
|
raise Exception('bad tcp target')
|
||||||
else:
|
else:
|
||||||
IP = socket.getaddrinfo(address, None, socket.AF_INET6)[0][4][0]
|
a, p = addr.rsplit(':',1)
|
||||||
monitorServer[name]["dns_time"] = int((timeit.default_timer() - m) * 1000)
|
addr, port = a, int(p)
|
||||||
m = timeit.default_timer()
|
else:
|
||||||
k = socket.create_connection((IP, 80), timeout=6)
|
time.sleep(interval)
|
||||||
monitorServer[name]["connect_time"] = int((timeit.default_timer() - m) * 1000)
|
continue
|
||||||
m = timeit.default_timer()
|
|
||||||
k.sendall("GET / HTTP/1.2\r\nHost:{}\r\nUser-Agent:ServerStatus/cppla\r\nConnection:close\r\n\r\n".format(address).encode('utf-8'))
|
# 2) 解析 IP(按偏好族),与 _ping_thread 保持一致的判定
|
||||||
response = b""
|
IP = addr
|
||||||
while True:
|
if addr.count(':') < 1: # 非纯 IPv6,可能是 IPv4 或域名
|
||||||
data = k.recv(4096)
|
try:
|
||||||
if not data:
|
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
||||||
break
|
IP = socket.getaddrinfo(addr, None, socket.AF_INET)[0][4][0]
|
||||||
response += data
|
else:
|
||||||
http_code = response.decode('utf-8').split('\r\n')[0].split()[1]
|
IP = socket.getaddrinfo(addr, None, socket.AF_INET6)[0][4][0]
|
||||||
monitorServer[name]["download_time"] = int((timeit.default_timer() - m) * 1000)
|
except Exception:
|
||||||
k.close()
|
pass
|
||||||
if http_code not in ['200', '204', '301', '302', '401']:
|
|
||||||
raise Exception("http code not in 200, 204, 301, 302, 401")
|
# 3) 测 TCP 建连耗时(timeout=1s);ECONNREFUSED 也记为耗时
|
||||||
elif type == "https":
|
try:
|
||||||
context = ssl._create_unverified_context()
|
b = timeit.default_timer()
|
||||||
address = host.replace("https://", "")
|
socket.create_connection((IP, port), timeout=1).close()
|
||||||
m = timeit.default_timer()
|
monitorServer[name]['latency'] = int((timeit.default_timer() - b) * 1000)
|
||||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
except socket.error as error:
|
||||||
IP = socket.getaddrinfo(address, None, socket.AF_INET)[0][4][0]
|
if getattr(error, 'errno', None) == errno.ECONNREFUSED:
|
||||||
|
monitorServer[name]['latency'] = int((timeit.default_timer() - b) * 1000)
|
||||||
else:
|
else:
|
||||||
IP = socket.getaddrinfo(address, None, socket.AF_INET6)[0][4][0]
|
monitorServer[name]['latency'] = 0
|
||||||
monitorServer[name]["dns_time"] = int((timeit.default_timer() - m) * 1000)
|
except Exception:
|
||||||
m = timeit.default_timer()
|
monitorServer[name]['latency'] = 0
|
||||||
k = socket.create_connection((IP, 443), timeout=6)
|
|
||||||
monitorServer[name]["connect_time"] = int((timeit.default_timer() - m) * 1000)
|
|
||||||
m = timeit.default_timer()
|
|
||||||
kk = context.wrap_socket(k, server_hostname=address)
|
|
||||||
kk.sendall("GET / HTTP/1.2\r\nHost:{}\r\nUser-Agent:ServerStatus/cppla\r\nConnection:close\r\n\r\n".format(address).encode('utf-8'))
|
|
||||||
response = b""
|
|
||||||
while True:
|
|
||||||
data = kk.recv(4096)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
response += data
|
|
||||||
http_code = response.decode('utf-8').split('\r\n')[0].split()[1]
|
|
||||||
monitorServer[name]["download_time"] = int((timeit.default_timer() - m) * 1000)
|
|
||||||
kk.close()
|
|
||||||
k.close()
|
|
||||||
if http_code not in ['200', '204', '301', '302', '401']:
|
|
||||||
raise Exception("http code not in 200, 204, 301, 302, 401")
|
|
||||||
elif type == "tcp":
|
|
||||||
m = timeit.default_timer()
|
|
||||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
|
||||||
IP = socket.getaddrinfo(host.split(":")[0], None, socket.AF_INET)[0][4][0]
|
|
||||||
else:
|
|
||||||
IP = socket.getaddrinfo(host.split(":")[0], None, socket.AF_INET6)[0][4][0]
|
|
||||||
monitorServer[name]["dns_time"] = int((timeit.default_timer() - m) * 1000)
|
|
||||||
m = timeit.default_timer()
|
|
||||||
k = socket.create_connection((IP, int(host.split(":")[1])), timeout=6)
|
|
||||||
monitorServer[name]["connect_time"] = int((timeit.default_timer() - m) * 1000)
|
|
||||||
m = timeit.default_timer()
|
|
||||||
k.send(b"GET / HTTP/1.2\r\n\r\n")
|
|
||||||
k.recv(1024)
|
|
||||||
monitorServer[name]["download_time"] = int((timeit.default_timer() - m) * 1000)
|
|
||||||
k.close()
|
|
||||||
packet_queue.put(1)
|
|
||||||
except Exception as e:
|
|
||||||
lostPacket += 1
|
|
||||||
packet_queue.put(0)
|
|
||||||
if packet_queue.qsize() > 5:
|
|
||||||
monitorServer[name]["online_rate"] = 1 - float(lostPacket) / packet_queue.qsize()
|
|
||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
@@ -443,10 +447,8 @@ if __name__ == '__main__':
|
|||||||
jdata = json.loads(i[i.find("{"):i.find("}")+1])
|
jdata = json.loads(i[i.find("{"):i.find("}")+1])
|
||||||
monitorServer[jdata.get("name")] = {
|
monitorServer[jdata.get("name")] = {
|
||||||
"type": jdata.get("type"),
|
"type": jdata.get("type"),
|
||||||
"dns_time": 0,
|
"host": jdata.get("host"),
|
||||||
"connect_time": 0,
|
"latency": 0
|
||||||
"download_time": 0,
|
|
||||||
"online_rate": 1
|
|
||||||
}
|
}
|
||||||
t = threading.Thread(
|
t = threading.Thread(
|
||||||
target=_monitor_thread,
|
target=_monitor_thread,
|
||||||
@@ -509,7 +511,42 @@ if __name__ == '__main__':
|
|||||||
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
|
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
|
||||||
array['io_read'] = diskIO.get("read")
|
array['io_read'] = diskIO.get("read")
|
||||||
array['io_write'] = diskIO.get("write")
|
array['io_write'] = diskIO.get("write")
|
||||||
array['custom'] = "<br>".join(f"{k}\\t解析: {v['dns_time']}\\t连接: {v['connect_time']}\\t下载: {v['download_time']}\\t在线率: <code>{v['online_rate']*100:.2f}%</code>" for k, v in monitorServer.items())
|
# report OS (normalized)
|
||||||
|
try:
|
||||||
|
sysname = platform.system().lower()
|
||||||
|
if sysname.startswith('windows'):
|
||||||
|
os_name = 'windows'
|
||||||
|
elif sysname.startswith('darwin') or 'mac' in sysname:
|
||||||
|
os_name = 'darwin'
|
||||||
|
elif 'bsd' in sysname:
|
||||||
|
os_name = 'bsd'
|
||||||
|
elif sysname.startswith('linux'):
|
||||||
|
# try distro from os-release
|
||||||
|
try:
|
||||||
|
with open('/etc/os-release') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith('ID='):
|
||||||
|
val = line.strip().split('=',1)[1].strip().strip('"')
|
||||||
|
if val: os_name = val
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
os_name = 'linux'
|
||||||
|
else:
|
||||||
|
os_name = sysname or 'unknown'
|
||||||
|
except Exception:
|
||||||
|
os_name = 'unknown'
|
||||||
|
array['os'] = os_name
|
||||||
|
items = []
|
||||||
|
for _n, st in monitorServer.items():
|
||||||
|
key = str(_n)
|
||||||
|
try:
|
||||||
|
ms = int(st.get('latency') or 0)
|
||||||
|
except Exception:
|
||||||
|
ms = 0
|
||||||
|
items.append((key, max(0, ms)))
|
||||||
|
# 稳定顺序:按 key 排序
|
||||||
|
items.sort(key=lambda x: x[0])
|
||||||
|
array['custom'] = ';'.join(f"{k}={v}" for k,v in items)
|
||||||
s.send(byte_str("update " + json.dumps(array) + "\n"))
|
s.send(byte_str("update " + json.dumps(array) + "\n"))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${CLIENT:-linux}" in
|
||||||
|
linux|client-linux|client-linux.py)
|
||||||
|
exec python3 /app/client-linux.py
|
||||||
|
;;
|
||||||
|
psutil|client-psutil|client-psutil.py)
|
||||||
|
exec python3 /app/client-psutil.py
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown CLIENT='$CLIENT'. Use 'linux' or 'psutil'." >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
serverstatus-client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.client
|
||||||
|
image: cppla/serverstatus:client
|
||||||
|
container_name: serverstatus-client
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
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}"
|
||||||
|
INTERVAL: "${INTERVAL:-1}"
|
||||||
|
PROBEPORT: "${PROBEPORT:-80}"
|
||||||
|
PROBE_PROTOCOL_PREFER: "${PROBE_PROTOCOL_PREFER:-ipv4}"
|
||||||
|
PING_PACKET_HISTORY_LEN: "${PING_PACKET_HISTORY_LEN:-100}"
|
||||||
|
CU: "${CU:-cu.tz.cloudcpp.com}"
|
||||||
|
CT: "${CT:-ct.tz.cloudcpp.com}"
|
||||||
|
CM: "${CM:-cm.tz.cloudcpp.com}"
|
||||||
|
CLIENT: "${CLIENT:-psutil}"
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
version: "3"
|
|
||||||
services:
|
services:
|
||||||
serverstatus:
|
serverstatus-server:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.server
|
||||||
image: cppla/serverstatus:latest
|
image: cppla/serverstatus:server
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
container_name: serverstatus
|
container_name: serverstatus-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
ADMIN_TOKEN: "${ADMIN_TOKEN:-}"
|
||||||
networks:
|
networks:
|
||||||
serverstatus-network:
|
serverstatus-network:
|
||||||
ipv4_address: 172.23.0.2
|
ipv4_address: 172.23.0.2
|
||||||
+7
-3
@@ -1,4 +1,5 @@
|
|||||||
OUT = sergate
|
OUT = sergate
|
||||||
|
.DEFAULT_GOAL := $(OUT)
|
||||||
|
|
||||||
#CC = clang
|
#CC = clang
|
||||||
CC = gcc
|
CC = gcc
|
||||||
@@ -19,10 +20,13 @@ C_OBJS := $(patsubst $(SDIR)/%.c,$(ODIR)/%.o,$(C_SRCS))
|
|||||||
CXX_OBJS := $(patsubst $(SDIR)/%.cpp,$(ODIR)/%.o,$(CXX_SRCS))
|
CXX_OBJS := $(patsubst $(SDIR)/%.cpp,$(ODIR)/%.o,$(CXX_SRCS))
|
||||||
OBJS := $(C_OBJS) $(CXX_OBJS)
|
OBJS := $(C_OBJS) $(CXX_OBJS)
|
||||||
|
|
||||||
$(ODIR)/%.o: $(SDIR)/%.c
|
$(ODIR):
|
||||||
|
mkdir -p $(ODIR)
|
||||||
|
|
||||||
|
$(ODIR)/%.o: $(SDIR)/%.c | $(ODIR)
|
||||||
$(CC) -c $(INC) $(CFLAGS) $< -o $@
|
$(CC) -c $(INC) $(CFLAGS) $< -o $@
|
||||||
|
|
||||||
$(ODIR)/%.o: $(SDIR)/%.cpp
|
$(ODIR)/%.o: $(SDIR)/%.cpp | $(ODIR)
|
||||||
$(CXX) -c $(INC) $(CXXFLAGS) $< -o $@
|
$(CXX) -c $(INC) $(CXXFLAGS) $< -o $@
|
||||||
|
|
||||||
$(OUT): $(OBJS)
|
$(OUT): $(OBJS)
|
||||||
@@ -31,4 +35,4 @@ $(OUT): $(OBJS)
|
|||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(ODIR)/*.o $(OUT)
|
rm -f $(ODIR)/*.o $(OUT)
|
||||||
|
|||||||
+14
-14
@@ -19,14 +19,14 @@
|
|||||||
"monthstart": 1
|
"monthstart": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"disabled": true,
|
|
||||||
"username": "s03",
|
"username": "s03",
|
||||||
"name": "node3",
|
"name": "node3",
|
||||||
"type": "hyper",
|
"type": "hyper",
|
||||||
"host": "host3",
|
"host": "host3",
|
||||||
"location": "🇫🇷",
|
"location": "🇫🇷",
|
||||||
"password": "USER_DEFAULT_PASSWORD",
|
"password": "USER_DEFAULT_PASSWORD",
|
||||||
"monthstart": 1
|
"monthstart": 1,
|
||||||
|
"disabled": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "s04",
|
"username": "s04",
|
||||||
@@ -40,16 +40,16 @@
|
|||||||
],
|
],
|
||||||
"monitors": [
|
"monitors": [
|
||||||
{
|
{
|
||||||
"name": "baidu",
|
"name": "抖音",
|
||||||
"host": "https://www.baidu.com",
|
"host": "https://www.douyin.com",
|
||||||
"interval": 1200,
|
"interval": 600,
|
||||||
"type": "https"
|
"type": "https"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "1111",
|
"name": "百度",
|
||||||
"host": "1.1.1.1:80",
|
"host": "https://www.baidu.com",
|
||||||
"interval": 1200,
|
"interval": 600,
|
||||||
"type": "tcp"
|
"type": "https"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sslcerts": [
|
"sslcerts": [
|
||||||
@@ -57,21 +57,21 @@
|
|||||||
"name": "my.cloudcpp.com",
|
"name": "my.cloudcpp.com",
|
||||||
"domain": "https://my.cloudcpp.com",
|
"domain": "https://my.cloudcpp.com",
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"interval": 3600,
|
"interval": 7200,
|
||||||
"callback": "https://yourSMSurl"
|
"callback": "https://yourSMSurl"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tz.cloudcpp.com",
|
"name": "tz.cloudcpp.com",
|
||||||
"domain": "https://tz.cloudcpp.com",
|
"domain": "https://tz.cloudcpp.com",
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"interval": 3600,
|
"interval": 7200,
|
||||||
"callback": "https://yourSMSurl"
|
"callback": "https://yourSMSurl"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "3.0.2.1",
|
"name": "3.0.2.1",
|
||||||
"domain": "https://3.0.2.1",
|
"domain": "https://3.0.2.1",
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"interval": 3600,
|
"interval": 7200,
|
||||||
"callback": "https://yourSMSurl"
|
"callback": "https://yourSMSurl"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 <ADMIN_TOKEN>",
|
||||||
|
"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()
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-10
@@ -153,11 +153,13 @@ names_done:
|
|||||||
}
|
}
|
||||||
// alarm logic
|
// alarm logic
|
||||||
if(cert->m_aExpireTS>0){
|
if(cert->m_aExpireTS>0){
|
||||||
int days = (int)((cert->m_aExpireTS - nowt)/86400);
|
// 剩余天数: 向下取整 (floor) —— 与 JSON expire_days 保持一致,用于阈值分桶和消息显示
|
||||||
int64_t *lastAlarm = NULL; int need=0; int target=0;
|
int64_t secsLeft = cert->m_aExpireTS - nowt;
|
||||||
if(days <=7 && days >3){ lastAlarm=&cert->m_aLastAlarm7; target=7; }
|
int days = (int)(secsLeft/86400);
|
||||||
else if(days <=3 && days >1){ lastAlarm=&cert->m_aLastAlarm3; target=3; }
|
int64_t *lastAlarm = NULL; int need=0;
|
||||||
else if(days <=1){ lastAlarm=&cert->m_aLastAlarm1; target=1; }
|
if(days <=7 && days >3){ lastAlarm=&cert->m_aLastAlarm7; }
|
||||||
|
else if(days <=3 && days >1){ lastAlarm=&cert->m_aLastAlarm3; }
|
||||||
|
else if(days <=1){ lastAlarm=&cert->m_aLastAlarm1; }
|
||||||
if(lastAlarm && (*lastAlarm==0 || nowt - *lastAlarm > 20*3600)) need=1; // avoid spam, 20h
|
if(lastAlarm && (*lastAlarm==0 || nowt - *lastAlarm > 20*3600)) need=1; // avoid spam, 20h
|
||||||
if(need && strlen(cert->m_aCallback)>0){
|
if(need && strlen(cert->m_aCallback)>0){
|
||||||
CURL *curl = curl_easy_init();
|
CURL *curl = curl_easy_init();
|
||||||
@@ -166,7 +168,8 @@ names_done:
|
|||||||
char timebuf[32];
|
char timebuf[32];
|
||||||
time_t expt = (time_t)cert->m_aExpireTS;
|
time_t expt = (time_t)cert->m_aExpireTS;
|
||||||
strftime(timebuf,sizeof(timebuf),"%Y-%m-%d %H:%M:%S", gmtime(&expt));
|
strftime(timebuf,sizeof(timebuf),"%Y-%m-%d %H:%M:%S", gmtime(&expt));
|
||||||
snprintf(msg,sizeof(msg),"【SSL证书提醒】%s(%s) 将在 %d 天后(%s UTC) 到期", cert->m_aName, cert->m_aDomain, target, timebuf);
|
// 使用 floor(days)
|
||||||
|
snprintf(msg,sizeof(msg),"【SSL证书提醒】%s(%s) 将在 %d 天后(%s UTC) 到期", cert->m_aName, cert->m_aDomain, days, timebuf);
|
||||||
char *enc = curl_easy_escape(curl,msg,0);
|
char *enc = curl_easy_escape(curl,msg,0);
|
||||||
char url[1500]; snprintf(url,sizeof(url),"%s%s", cert->m_aCallback, enc?enc:"");
|
char url[1500]; snprintf(url,sizeof(url),"%s%s", cert->m_aCallback, enc?enc:"");
|
||||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||||
@@ -383,6 +386,9 @@ int CMain::HandleMessage(int ClientNetID, char *pMessage)
|
|||||||
pClient->m_Stats.m_Online6 = rStart["online6"].u.boolean;
|
pClient->m_Stats.m_Online6 = rStart["online6"].u.boolean;
|
||||||
if(rStart["custom"].type == json_string)
|
if(rStart["custom"].type == json_string)
|
||||||
str_copy(pClient->m_Stats.m_aCustom, rStart["custom"].u.string.ptr, sizeof(pClient->m_Stats.m_aCustom));
|
str_copy(pClient->m_Stats.m_aCustom, rStart["custom"].u.string.ptr, sizeof(pClient->m_Stats.m_aCustom));
|
||||||
|
// optional OS field from clients
|
||||||
|
if(rStart["os"].type == json_string)
|
||||||
|
str_copy(pClient->m_Stats.m_aOS, rStart["os"].u.string.ptr, sizeof(pClient->m_Stats.m_aOS));
|
||||||
|
|
||||||
//copy message for watchdog to analysis
|
//copy message for watchdog to analysis
|
||||||
WatchdogMessage(ClientNetID,
|
WatchdogMessage(ClientNetID,
|
||||||
@@ -630,7 +636,7 @@ void CMain::JSONUpdateThread(void *pUser)
|
|||||||
}
|
}
|
||||||
|
|
||||||
str_format(pBuf, sizeof(aFileBuf) - (pBuf - aFileBuf),
|
str_format(pBuf, sizeof(aFileBuf) - (pBuf - aFileBuf),
|
||||||
"{ \"name\": \"%s\",\"type\": \"%s\",\"host\": \"%s\",\"location\": \"%s\",\"online4\": %s, \"online6\": %s, \"uptime\": \"%s\",\"load_1\": %.2f, \"load_5\": %.2f, \"load_15\": %.2f,\"ping_10010\": %.2f, \"ping_189\": %.2f, \"ping_10086\": %.2f,\"time_10010\": %" PRId64 ", \"time_189\": %" PRId64 ", \"time_10086\": %" PRId64 ", \"tcp_count\": %" PRId64 ", \"udp_count\": %" PRId64 ", \"process_count\": %" PRId64 ", \"thread_count\": %" PRId64 ", \"network_rx\": %" PRId64 ", \"network_tx\": %" PRId64 ", \"network_in\": %" PRId64 ", \"network_out\": %" PRId64 ", \"cpu\": %d, \"memory_total\": %" PRId64 ", \"memory_used\": %" PRId64 ", \"swap_total\": %" PRId64 ", \"swap_used\": %" PRId64 ", \"hdd_total\": %" PRId64 ", \"hdd_used\": %" PRId64 ", \"last_network_in\": %" PRId64 ", \"last_network_out\": %" PRId64 ",\"io_read\": %" PRId64 ", \"io_write\": %" PRId64 ",\"custom\": \"%s\" },\n",
|
"{ \"name\": \"%s\",\"type\": \"%s\",\"host\": \"%s\",\"location\": \"%s\",\"online4\": %s, \"online6\": %s, \"uptime\": \"%s\",\"load_1\": %.2f, \"load_5\": %.2f, \"load_15\": %.2f,\"ping_10010\": %.2f, \"ping_189\": %.2f, \"ping_10086\": %.2f,\"time_10010\": %" PRId64 ", \"time_189\": %" PRId64 ", \"time_10086\": %" PRId64 ", \"tcp_count\": %" PRId64 ", \"udp_count\": %" PRId64 ", \"process_count\": %" PRId64 ", \"thread_count\": %" PRId64 ", \"network_rx\": %" PRId64 ", \"network_tx\": %" PRId64 ", \"network_in\": %" PRId64 ", \"network_out\": %" PRId64 ", \"cpu\": %d, \"memory_total\": %" PRId64 ", \"memory_used\": %" PRId64 ", \"swap_total\": %" PRId64 ", \"swap_used\": %" PRId64 ", \"hdd_total\": %" PRId64 ", \"hdd_used\": %" PRId64 ", \"last_network_in\": %" PRId64 ", \"last_network_out\": %" PRId64 ",\"io_read\": %" PRId64 ", \"io_write\": %" PRId64 ",\"custom\": \"%s\", \"os\": \"%s\" },\n",
|
||||||
pClients[i].m_aName,pClients[i].m_aType,pClients[i].m_aHost,pClients[i].m_aLocation,
|
pClients[i].m_aName,pClients[i].m_aType,pClients[i].m_aHost,pClients[i].m_aLocation,
|
||||||
pClients[i].m_Stats.m_Online4 ? "true" : "false",pClients[i].m_Stats.m_Online6 ? "true" : "false",
|
pClients[i].m_Stats.m_Online4 ? "true" : "false",pClients[i].m_Stats.m_Online6 ? "true" : "false",
|
||||||
aUptime, pClients[i].m_Stats.m_Load_1, pClients[i].m_Stats.m_Load_5, pClients[i].m_Stats.m_Load_15, pClients[i].m_Stats.m_ping_10010, pClients[i].m_Stats.m_ping_189, pClients[i].m_Stats.m_ping_10086,
|
aUptime, pClients[i].m_Stats.m_Load_1, pClients[i].m_Stats.m_Load_5, pClients[i].m_Stats.m_Load_15, pClients[i].m_Stats.m_ping_10010, pClients[i].m_Stats.m_ping_189, pClients[i].m_Stats.m_ping_10086,
|
||||||
@@ -640,15 +646,17 @@ void CMain::JSONUpdateThread(void *pUser)
|
|||||||
pClients[i].m_Stats.m_NetworkIN == 0 || pClients[i].m_LastNetworkIN == 0 ? pClients[i].m_Stats.m_NetworkIN : pClients[i].m_LastNetworkIN,
|
pClients[i].m_Stats.m_NetworkIN == 0 || pClients[i].m_LastNetworkIN == 0 ? pClients[i].m_Stats.m_NetworkIN : pClients[i].m_LastNetworkIN,
|
||||||
pClients[i].m_Stats.m_NetworkOUT == 0 || pClients[i].m_LastNetworkOUT == 0 ? pClients[i].m_Stats.m_NetworkOUT : pClients[i].m_LastNetworkOUT,
|
pClients[i].m_Stats.m_NetworkOUT == 0 || pClients[i].m_LastNetworkOUT == 0 ? pClients[i].m_Stats.m_NetworkOUT : pClients[i].m_LastNetworkOUT,
|
||||||
pClients[i].m_Stats.m_IORead, pClients[i].m_Stats.m_IOWrite,
|
pClients[i].m_Stats.m_IORead, pClients[i].m_Stats.m_IOWrite,
|
||||||
pClients[i].m_Stats.m_aCustom);
|
pClients[i].m_Stats.m_aCustom,
|
||||||
|
pClients[i].m_Stats.m_aOS[0] ? pClients[i].m_Stats.m_aOS : "");
|
||||||
pBuf += strlen(pBuf);
|
pBuf += strlen(pBuf);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// sava network traffic record to json when close client
|
// sava network traffic record to json when close client
|
||||||
// last_network_in == last network in record, last_network_out == last network out record
|
// last_network_in == last network in record, last_network_out == last network out record
|
||||||
str_format(pBuf, sizeof(aFileBuf) - (pBuf - aFileBuf), "{ \"name\": \"%s\", \"type\": \"%s\", \"host\": \"%s\", \"location\": \"%s\", \"online4\": false, \"online6\": false, \"last_network_in\": %" PRId64 ", \"last_network_out\": %" PRId64 " },\n",
|
str_format(pBuf, sizeof(aFileBuf) - (pBuf - aFileBuf), "{ \"name\": \"%s\", \"type\": \"%s\", \"host\": \"%s\", \"location\": \"%s\", \"online4\": false, \"online6\": false, \"last_network_in\": %" PRId64 ", \"last_network_out\": %" PRId64 ", \"os\": \"%s\" },\n",
|
||||||
pClients[i].m_aName, pClients[i].m_aType, pClients[i].m_aHost, pClients[i].m_aLocation, pClients[i].m_LastNetworkIN, pClients[i].m_LastNetworkOUT);
|
pClients[i].m_aName, pClients[i].m_aType, pClients[i].m_aHost, pClients[i].m_aLocation, pClients[i].m_LastNetworkIN, pClients[i].m_LastNetworkOUT,
|
||||||
|
pClients[i].m_Stats.m_aOS[0] ? pClients[i].m_Stats.m_aOS : "");
|
||||||
pBuf += strlen(pBuf);
|
pBuf += strlen(pBuf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ class CMain
|
|||||||
int64_t m_IOWrite;
|
int64_t m_IOWrite;
|
||||||
double m_CPU;
|
double m_CPU;
|
||||||
char m_aCustom[1024];
|
char m_aCustom[1024];
|
||||||
|
// OS name reported by client (e.g. linux/windows/darwin/freebsd)
|
||||||
|
char m_aOS[64];
|
||||||
// Options
|
// Options
|
||||||
bool m_Pong;
|
bool m_Pong;
|
||||||
} m_Stats;
|
} m_Stats;
|
||||||
|
|||||||
+198
-19
@@ -61,13 +61,13 @@ table.data th,table.data td{font-variant-numeric:tabular-nums}
|
|||||||
.caps-traffic .sep{opacity:.4;font-size:11px;display:none;}
|
.caps-traffic .sep{opacity:.4;font-size:11px;display:none;}
|
||||||
.caps-traffic.sm{padding:2px 8px 2px 7px;font-size:11px;gap:5px;}
|
.caps-traffic.sm{padding:2px 8px 2px 7px;font-size:11px;gap:5px;}
|
||||||
.caps-traffic.sm .io{font-size:11px;}
|
.caps-traffic.sm .io{font-size:11px;}
|
||||||
/* 双色胶囊:左红右黄 */
|
/* 双色胶囊 */
|
||||||
.caps-traffic.duo{background:none;border:0;gap:0;padding:0;box-shadow:none;position:relative;border-radius:999px;overflow:hidden;font-size:12px;}
|
.caps-traffic.duo{background:none;border:0;gap:0;padding:0;box-shadow:none;position:relative;border-radius:999px;overflow:hidden;font-size:12px;}
|
||||||
/* 宽度按内容自适应(不再拉伸占满列),每半边仅为其文本 + padding,可容纳最大 111.1MB */
|
/* 宽度按内容自适应(不再拉伸占满列),每半边仅为其文本 + padding,可容纳最大 111.1MB */
|
||||||
.caps-traffic.duo .half{flex:0 0 auto;display:flex;align-items:center;justify-content:center;padding:2px 4px;font-variant-numeric:tabular-nums;font-weight:600;line-height:1.25; /* 与 .pill 保持一致高度 */ letter-spacing:.25px;color:#fff;font-size:12px;white-space:nowrap;}
|
.caps-traffic.duo .half{flex:0 0 auto;display:flex;align-items:center;justify-content:center;padding:2px 4px;font-variant-numeric:tabular-nums;font-weight:600;line-height:1.25; /* 与 .pill 保持一致高度 */ letter-spacing:.25px;color:#fff;font-size:12px;white-space:nowrap;}
|
||||||
/* 双色胶囊配色:
|
/* 双色胶囊配色:
|
||||||
normal (默认): 左白(#fff) 右蓝(accent)
|
normal (默认): 左绿右蓝
|
||||||
heavy (>=500GB 任一方向): 左黄(warn) 右红(danger)
|
heavy (>=1000GB 总量): 黄色提醒,不再使用故障红
|
||||||
*/
|
*/
|
||||||
/* normal 初始:淡绿色(入) + 淡蓝色(出) */
|
/* normal 初始:淡绿色(入) + 淡蓝色(出) */
|
||||||
.caps-traffic.duo.normal .half.in{background:#d1fae5;color:#065f46;} /* emerald-100 / text-emerald-800 */
|
.caps-traffic.duo.normal .half.in{background:#d1fae5;color:#065f46;} /* emerald-100 / text-emerald-800 */
|
||||||
@@ -75,10 +75,10 @@ body.light .caps-traffic.duo.normal .half.in{background:#d1fae5;color:#065f46;}
|
|||||||
.caps-traffic.duo.normal .half.out{background:#bfdbfe;color:#1e3a8a;} /* blue-200 / text-blue-900 */
|
.caps-traffic.duo.normal .half.out{background:#bfdbfe;color:#1e3a8a;} /* blue-200 / text-blue-900 */
|
||||||
body.light .caps-traffic.duo.normal .half.out{background:#bfdbfe;color:#1e3a8a;}
|
body.light .caps-traffic.duo.normal .half.out{background:#bfdbfe;color:#1e3a8a;}
|
||||||
|
|
||||||
.caps-traffic.duo.heavy .half.in{background:var(--warn);color:#111;}
|
.caps-traffic.duo.heavy .half.in{background:#fde68a;color:#78350f;}
|
||||||
body.light .caps-traffic.duo.heavy .half.in{background:var(--warn);color:#111;}
|
body.light .caps-traffic.duo.heavy .half.in{background:#fde68a;color:#78350f;}
|
||||||
.caps-traffic.duo.heavy .half.out{background:var(--danger);color:#fff;}
|
.caps-traffic.duo.heavy .half.out{background:#fbbf24;color:#111827;}
|
||||||
body.light .caps-traffic.duo.heavy .half.out{color:#fff;}
|
body.light .caps-traffic.duo.heavy .half.out{background:#fbbf24;color:#111827;}
|
||||||
|
|
||||||
/* 半之间分隔线 */
|
/* 半之间分隔线 */
|
||||||
.caps-traffic.duo .half + .half{border-left:1px solid rgba(0,0,0,.18);}
|
.caps-traffic.duo .half + .half{border-left:1px solid rgba(0,0,0,.18);}
|
||||||
@@ -94,14 +94,15 @@ table.data tbody tr:hover{background:rgba(255,255,255,.04)}
|
|||||||
.footer a{color:var(--text-dim)}
|
.footer a{color:var(--text-dim)}
|
||||||
.footer a:hover{color:var(--accent)}
|
.footer a:hover{color:var(--accent)}
|
||||||
.muted{color:var(--text-dim)}
|
.muted{color:var(--text-dim)}
|
||||||
.status-off{color:var(--danger);font-weight:600}
|
/* 旧状态文字样式已不再使用(采用 pill) */
|
||||||
.status-on{color:var(--ok);font-weight:600}
|
|
||||||
@media (max-width:1100px){.nav{flex-wrap:wrap}.table-wrap{border-radius:8px}}
|
@media (max-width:1100px){.nav{flex-wrap:wrap}.table-wrap{border-radius:8px}}
|
||||||
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
||||||
|
|
||||||
/* modal styles */
|
/* modal styles */
|
||||||
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;padding:5vh 1rem;z-index:50;backdrop-filter:blur(4px)}
|
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:flex-start;justify-content:center;padding:5vh 1rem;z-index:50;backdrop-filter:blur(4px)}
|
||||||
.modal-box{position:relative;width:100%;max-width:560px;background:var(--bg-alt);border:1px solid var(--border);border-radius:16px;box-shadow:0 8px 30px -6px rgba(0,0,0,.6);padding:1.25rem 1.35rem;display:flex;flex-direction:column;gap:.9rem;animation:fade .25s ease}
|
.modal-box{position:relative;width:100%;max-width:560px;background:var(--bg-alt);border:1px solid var(--border);border-radius:16px;box-shadow:0 8px 30px -6px rgba(0,0,0,.6);padding:1.25rem 1.35rem;display:flex;flex-direction:column;gap:.9rem;animation:fade .25s ease}
|
||||||
|
.modal-box.high-load{border-color:rgba(239,68,68,.6);background:linear-gradient(180deg, rgba(239,68,68,.16), rgba(239,68,68,.08)), var(--bg-alt);box-shadow:0 0 0 1px rgba(239,68,68,.38),0 10px 28px -10px rgba(239,68,68,.28)}
|
||||||
|
body:not(.light) .modal-box.high-load{background:linear-gradient(180deg, rgba(239,68,68,.24), rgba(239,68,68,.12)), var(--bg-alt)}
|
||||||
.modal-title{margin:0;font-size:16px;font-weight:600;letter-spacing:.5px}
|
.modal-title{margin:0;font-size:16px;font-weight:600;letter-spacing:.5px}
|
||||||
.modal-close{position:absolute;top:10px;right:12px;background:transparent;border:0;color:var(--text-dim);font-size:20px;line-height:1;cursor:pointer;padding:4px;border-radius:8px;transition:var(--trans)}
|
.modal-close{position:absolute;top:10px;right:12px;background:transparent;border:0;color:var(--text-dim);font-size:20px;line-height:1;cursor:pointer;padding:4px;border-radius:8px;transition:var(--trans)}
|
||||||
.modal-close:hover{color:var(--text);background:var(--bg)}
|
.modal-close:hover{color:var(--text);background:var(--bg)}
|
||||||
@@ -128,8 +129,8 @@ table.data tbody tr:hover{background:rgba(255,255,255,.04)}
|
|||||||
.gauge-half path.track{stroke:color-mix(in srgb,var(--text-dim) 18%,transparent);stroke-width:6;}
|
.gauge-half path.track{stroke:color-mix(in srgb,var(--text-dim) 18%,transparent);stroke-width:6;}
|
||||||
.gauge-half path.arc{stroke:var(--gauge-base,#3b82f6);stroke-width:8;stroke-dasharray:126;stroke-dashoffset:calc(126*(1 - var(--p)));transition:stroke-dashoffset .8s cubic-bezier(.4,0,.2,1),stroke .35s;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45));}
|
.gauge-half path.arc{stroke:var(--gauge-base,#3b82f6);stroke-width:8;stroke-dasharray:126;stroke-dashoffset:calc(126*(1 - var(--p)));transition:stroke-dashoffset .8s cubic-bezier(.4,0,.2,1),stroke .35s;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45));}
|
||||||
.gauge-half[data-type=mem] path.arc{--gauge-base:#10b981}
|
.gauge-half[data-type=mem] path.arc{--gauge-base:#10b981}
|
||||||
.gauge-half[data-type=hdd] path.arc{--gauge-base:#f59e0b}
|
.gauge-half[data-type=hdd] path.arc{--gauge-base:#06b6d4}
|
||||||
/* 阈值颜色:>=50% 警告黄,>=90% 危险红 */
|
/* 阈值颜色:CPU>=75%、内存>=80%、硬盘>=85% 警告黄,>=90% 危险红 */
|
||||||
.gauge-half[data-warn] path.arc{stroke:var(--warn)}
|
.gauge-half[data-warn] path.arc{stroke:var(--warn)}
|
||||||
.gauge-half[data-bad] path.arc{stroke:var(--danger)}
|
.gauge-half[data-bad] path.arc{stroke:var(--danger)}
|
||||||
/* 指针:以中心(50,50)为原点旋转;半圆角度范围 180deg -> 从 180deg (左) 到 0deg(右) */
|
/* 指针:以中心(50,50)为原点旋转;半圆角度范围 180deg -> 从 180deg (左) 到 0deg(右) */
|
||||||
@@ -229,10 +230,53 @@ body.light .gauge-half .needle{background:linear-gradient(var(--text),var(--text
|
|||||||
font-variant-numeric:tabular-nums;
|
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{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,.55);box-shadow:0 0 0 1px rgba(239,68,68,.4),0 4px 16px -4px rgba(239,68,68,.3);}
|
.cards .card.high-load,
|
||||||
table.data tbody tr.high-load{background:rgba(239,68,68,.10);}
|
.cards .card.alert-critical{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:hover{background:rgba(239,68,68,.18);}
|
.cards .card.alert-warning{border-color:rgba(245,158,11,.55);background:linear-gradient(180deg, rgba(245,158,11,.16), rgba(245,158,11,.08)), var(--bg-alt);box-shadow:0 0 0 1px rgba(245,158,11,.32),0 6px 18px -8px rgba(245,158,11,.22);}
|
||||||
|
table.data tbody tr.high-load,
|
||||||
|
table.data tbody tr.alert-critical{background:rgba(239,68,68,.18) !important;}
|
||||||
|
table.data tbody tr.high-load:hover,
|
||||||
|
table.data tbody tr.alert-critical:hover{background:rgba(239,68,68,.26) !important;}
|
||||||
|
table.data tbody tr.alert-warning{background:rgba(245,158,11,.12) !important;}
|
||||||
|
table.data tbody tr.alert-warning:hover{background:rgba(245,158,11,.18) !important;}
|
||||||
|
|
||||||
|
/* SSL 域名告警底色:与高负载相同 */
|
||||||
|
#sslTable td.alert-domain{background:rgba(239,68,68,.18) !important;}
|
||||||
|
#sslTable tr:hover td.alert-domain{background:rgba(239,68,68,.26) !important;}
|
||||||
|
|
||||||
|
/* OS 着色(更明显):
|
||||||
|
1) 为各 OS 类定义 --os-color 变量
|
||||||
|
2) 行左侧使用 inset box-shadow 画 4px 彩条
|
||||||
|
3) 行背景叠加轻度渐变以提示 OS
|
||||||
|
*/
|
||||||
|
table.data tbody tr[class*="os-"]{box-shadow:inset 4px 0 0 0 var(--os-color, transparent);background:linear-gradient(180deg, color-mix(in srgb, var(--os-color, transparent) 10%, transparent), transparent 60%);}
|
||||||
|
table.data tbody tr[class*="os-"]:hover{background:linear-gradient(180deg, color-mix(in srgb, var(--os-color, transparent) 16%, transparent), transparent 65%);}
|
||||||
|
.cards .card[class*="os-"]{border-color:color-mix(in srgb, var(--os-color, var(--accent)) 60%, transparent);box-shadow:0 0 0 1px color-mix(in srgb, var(--os-color, var(--accent)) 40%, transparent),0 4px 16px -6px color-mix(in srgb, var(--os-color, #000) 35%, transparent);}
|
||||||
|
/* 弹窗 OS 着色:左侧彩条 + 渐变与卡片一致 */
|
||||||
|
/* 取消弹窗背景着色,改为仅在标题展示系统胶囊 */
|
||||||
|
.os-chip{display:inline-flex;align-items:center;padding:2px 8px;margin-left:.5rem;border-radius:999px;font-size:12px;font-weight:600;line-height:1.2;background:var(--os-color, var(--border));color:#fff;border:0;white-space:nowrap;}
|
||||||
|
|
||||||
|
/* 为常见系统赋色 */
|
||||||
|
.os-linux{--os-color: rgba(16,185,129,.85);} /* 绿色 (通用 Linux,保持不变) */
|
||||||
|
.os-ubuntu{--os-color: rgba(221,72,20,.9);} /* Ubuntu 橙 (#dd4814) */
|
||||||
|
.os-debian{--os-color: rgba(215,10,83,.9);} /* Debian 品红 (#d70a53) */
|
||||||
|
.os-centos{--os-color: rgba(102,0,153,.9);} /* CentOS 紫 (#660099) */
|
||||||
|
.os-rocky{--os-color: rgba(1,122,66,.9);} /* Rocky Linux 绿 (#017a42) */
|
||||||
|
.os-almalinux{--os-color: rgba(0,92,170,.9);} /* AlmaLinux 蓝 (#005caa) */
|
||||||
|
.os-rhel{--os-color: rgba(204,0,0,.9);} /* Red Hat 红 (#cc0000) */
|
||||||
|
.os-arch{--os-color: rgba(23,147,209,.9);} /* Arch Linux 蓝 (#1793d1) */
|
||||||
|
.os-alpine{--os-color: rgba(14,87,123,.9);} /* Alpine Linux 蓝 (#0e577b) */
|
||||||
|
.os-fedora{--os-color: rgba(60,110,180,.9);} /* Fedora 蓝 (#3c6eb4) */
|
||||||
|
.os-amazon{--os-color: rgba(255,153,0,.9);} /* Amazon Linux 橙 (#ff9900) */
|
||||||
|
.os-suse{--os-color: rgba(0,150,0,.9);} /* openSUSE 绿 (#009600) */
|
||||||
|
.os-freebsd{--os-color: rgba(166,31,47,.9);} /* FreeBSD 红 (#a61f2f) */
|
||||||
|
.os-openbsd{--os-color: rgba(255,204,0,.9);} /* OpenBSD 黄 (#ffcc00) */
|
||||||
|
.os-bsd{--os-color: rgba(166,31,47,.9);} /* BSD 系统一般跟 FreeBSD 接近 */
|
||||||
|
.os-darwin{--os-color: rgba(29,29,31,.95);} /* macOS 深空灰 (#1d1d1f) */
|
||||||
|
.os-windows{--os-color: rgba(0,120,215,.95);} /* Windows 蓝 (#0078d7) */
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* 旧进度条相关样式已清理 */
|
/* 旧进度条相关样式已清理 */
|
||||||
.cards .card-header{display:flex;align-items:center;justify-content:space-between;gap:.5rem;}
|
.cards .card-header{display:flex;align-items:center;justify-content:space-between;gap:.5rem;}
|
||||||
@@ -245,12 +289,30 @@ table.data tbody tr.high-load:hover{background:rgba(239,68,68,.18);}
|
|||||||
.cards .kvlist div{display:flex;flex-direction:column;}
|
.cards .kvlist div{display:flex;flex-direction:column;}
|
||||||
.cards .kvlist span.key{opacity:.6;}
|
.cards .kvlist span.key{opacity:.6;}
|
||||||
.cards .buckets{margin-top:.25rem;}
|
.cards .buckets{margin-top:.25rem;}
|
||||||
.cards .expand-btn{position:absolute;top:.5rem;right:.5rem;background:transparent;border:0;color:var(--text-dim);cursor:pointer;font-size:.9rem;padding:.2rem;}
|
/* 证书卡片:域名告警底色(与高负载卡片风格一致) */
|
||||||
.cards .expand-btn:focus, .cards .expand-btn:hover{color:var(--text);}
|
.cards .kvlist .alert-domain{background:rgba(239,68,68,.18);border:1px solid rgba(239,68,68,.35);border-radius:8px;padding:.4rem .5rem;}
|
||||||
.cards .expand-area{margin-top:.4rem;display:none;animation:fadeIn .25s ease;}
|
.cards .kvlist .alert-domain .key{opacity:.85}
|
||||||
.cards .card.expanded .expand-area{display:block;}
|
/* 移除移动端卡片展开箭头与展开区域(已按需简化交互) */
|
||||||
/* 旧移动端 latency spark 样式移除 */
|
/* 旧移动端 latency spark 样式移除 */
|
||||||
|
|
||||||
|
/* 简易信号格,用于服务连通性延迟展示 */
|
||||||
|
.sig{display:inline-flex;gap:2px;vertical-align:baseline;margin:0 4px 0 6px;align-items:flex-end;line-height:1}
|
||||||
|
.sig .b{width:3px;background:color-mix(in srgb,var(--text-dim) 35%,transparent);border-radius:2px;display:inline-block}
|
||||||
|
.sig .b:nth-child(1){height:7px}
|
||||||
|
.sig .b:nth-child(2){height:9px}
|
||||||
|
.sig .b:nth-child(3){height:11px}
|
||||||
|
.sig .b:nth-child(4){height:13px}
|
||||||
|
.sig .b:nth-child(5){height:15px}
|
||||||
|
.sig .b.on{background:var(--ok)}
|
||||||
|
.sig .b.off{opacity:.35}
|
||||||
|
|
||||||
|
/* 服务监测项:不同组竖排,同一组横排;不考虑自动换行 */
|
||||||
|
.mon-items{display:flex;flex-direction:column;gap:6px;align-items:flex-start}
|
||||||
|
.mon-item{display:inline-flex;align-items:center;white-space:nowrap;line-height:1}
|
||||||
|
.mon-item .name{margin-right:6px}
|
||||||
|
.mon-item .ms{margin-left:6px;font-variant-numeric:tabular-nums}
|
||||||
|
.mon-item .sig{margin:0 6px;transform:translateY(-1px)}
|
||||||
|
|
||||||
/* 新 Logo 样式 */
|
/* 新 Logo 样式 */
|
||||||
.brand{display:flex;align-items:center;gap:.55rem;font-weight:600;letter-spacing:.5px;font-size:16px;position:relative}
|
.brand{display:flex;align-items:center;gap:.55rem;font-weight:600;letter-spacing:.5px;font-size:16px;position:relative}
|
||||||
.brand .logo-mark{display:inline-flex;width:34px;height:34px;border-radius:10px;background:linear-gradient(145deg,var(--logo-start) 0%,var(--logo-end) 90%);color:#fff;align-items:center;justify-content:center;box-shadow:0 4px 12px -2px rgba(0,0,0,.45),0 0 0 1px rgba(255,255,255,.08);transition:var(--trans)}
|
.brand .logo-mark{display:inline-flex;width:34px;height:34px;border-radius:10px;background:linear-gradient(145deg,var(--logo-start) 0%,var(--logo-end) 90%);color:#fff;align-items:center;justify-content:center;box-shadow:0 4px 12px -2px rgba(0,0,0,.45),0 0 0 1px rgba(255,255,255,.08);transition:var(--trans)}
|
||||||
@@ -260,3 +322,120 @@ table.data tbody tr.high-load:hover{background:rgba(239,68,68,.18);}
|
|||||||
.brand:hover .logo-mark{transform:translateY(-2px) scale(1.05)}
|
.brand:hover .logo-mark{transform:translateY(-2px) scale(1.05)}
|
||||||
.brand:hover .logo-text{color:var(--text)}
|
.brand:hover .logo-text{color:var(--text)}
|
||||||
@media (max-width:640px){.brand .logo-text{font-size:15px}.brand .logo-mark{width:30px;height:30px}}
|
@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:100dvh;max-height:100dvh;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}
|
||||||
|
.detail-drawer.alert-critical{border-color:rgba(239,68,68,.6);background:linear-gradient(180deg, rgba(239,68,68,.16), rgba(239,68,68,.08)), var(--bg-alt);box-shadow:0 0 0 1px rgba(239,68,68,.38),0 10px 28px -10px rgba(239,68,68,.28)}
|
||||||
|
.detail-drawer.alert-warning{border-color:rgba(245,158,11,.55);background:linear-gradient(180deg, rgba(245,158,11,.14), rgba(245,158,11,.06)), var(--bg-alt);box-shadow:0 0 0 1px rgba(245,158,11,.28),0 10px 28px -10px rgba(245,158,11,.2)}
|
||||||
|
.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;height:100dvh;max-height:100dvh;padding:calc(.95rem + env(safe-area-inset-top)) .95rem calc(.95rem + env(safe-area-inset-bottom));}
|
||||||
|
.detail-drawer .modal-content{max-height:none;overflow:visible;}
|
||||||
|
}
|
||||||
|
|||||||
+6
-10
@@ -1,11 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 86" fill="none">
|
||||||
<defs>
|
<path d="M4 46H28L38 34L55 82L65 4L74 47H88M100 47H110M122 47H126"
|
||||||
<linearGradient id="g" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
|
stroke="#111827"
|
||||||
<stop offset="0" stop-color="#3b82f6" />
|
stroke-width="8"
|
||||||
<stop offset="1" stop-color="#2563eb" />
|
stroke-linecap="round"
|
||||||
</linearGradient>
|
stroke-linejoin="round" />
|
||||||
</defs>
|
|
||||||
<rect x="8" y="12" width="48" height="40" rx="12" fill="url(#g)" />
|
|
||||||
<path stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M22 38v-8l6 4 8-10 6 6v8" />
|
|
||||||
<circle cx="22" cy="22" r="4" fill="#fff" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 539 B After Width: | Height: | Size: 266 B |
+107
-22
@@ -6,17 +6,14 @@
|
|||||||
<meta name="description" content="云监控,ServerStatus中文版,ServerStatus,ServerStatus cppla" />
|
<meta name="description" content="云监控,ServerStatus中文版,ServerStatus,ServerStatus cppla" />
|
||||||
<title>云监控</title>
|
<title>云监控</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="alternate icon" href="favicon.ico" />
|
<link rel="stylesheet" href="css/app.css?v=20260630-7" />
|
||||||
<link rel="stylesheet" href="css/app.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand" title="云监控">
|
<div class="brand" title="云监控">
|
||||||
<span class="logo-mark" aria-hidden="true">
|
<span class="logo-mark" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg viewBox="0 0 128 86" width="24" height="18" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M7.5 17a4.5 4.5 0 0 1-.9-8.92A6 6 0 0 1 18.4 9.6 4 4 0 0 1 18 17H7.5Z" />
|
<path d="M4 46H28L38 34L55 82L65 4L74 47H88M100 47H110M122 47H126" />
|
||||||
<rect x="9" y="11" width="6" height="4" rx="1" />
|
|
||||||
<path d="M11 15v2.5a.5.5 0 0 0 .5.5h1" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="logo-text"><span class="logo-accent">云</span>监控</span>
|
<span class="logo-text"><span class="logo-accent">云</span>监控</span>
|
||||||
@@ -25,6 +22,7 @@
|
|||||||
<button data-tab="servers" class="active">主机</button>
|
<button data-tab="servers" class="active">主机</button>
|
||||||
<button data-tab="monitors">服务</button>
|
<button data-tab="monitors">服务</button>
|
||||||
<button data-tab="ssl">证书</button>
|
<button data-tab="ssl">证书</button>
|
||||||
|
<button data-tab="config">配置</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="themeToggle" title="切换主题 (当前: 自动或手动)" aria-label="切换主题">🌓</button>
|
<button id="themeToggle" title="切换主题 (当前: 自动或手动)" aria-label="切换主题">🌓</button>
|
||||||
@@ -35,24 +33,59 @@
|
|||||||
<main class="wrapper">
|
<main class="wrapper">
|
||||||
<div id="notice" class="notice info">初始化中...</div>
|
<div id="notice" class="notice info">初始化中...</div>
|
||||||
|
|
||||||
|
<section class="ops-overview" id="overviewCards" aria-label="运行概览"></section>
|
||||||
|
|
||||||
|
<section class="ops-toolbar" id="serversToolbar" aria-label="主机筛选">
|
||||||
|
<label class="search-field">
|
||||||
|
<span>搜索</span>
|
||||||
|
<input id="serverSearch" type="search" autocomplete="off" placeholder="节点 / 地区 / 系统 / 主机名" />
|
||||||
|
</label>
|
||||||
|
<div class="segmented" id="statusFilter" role="group" aria-label="状态筛选">
|
||||||
|
<button data-filter="all" class="active">全部</button>
|
||||||
|
<button data-filter="online">在线</button>
|
||||||
|
<button data-filter="offline">离线</button>
|
||||||
|
<button data-filter="alert" title="只显示在线但资源、丢包或流量异常的节点">异常</button>
|
||||||
|
</div>
|
||||||
|
<label class="select-field">
|
||||||
|
<span>系统</span>
|
||||||
|
<select id="osFilter">
|
||||||
|
<option value="all">全部系统</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="select-field">
|
||||||
|
<span>排序</span>
|
||||||
|
<select id="sortSelect">
|
||||||
|
<option value="name">节点</option>
|
||||||
|
<option value="status">在线状态</option>
|
||||||
|
<option value="load">负载</option>
|
||||||
|
<option value="cpu">CPU</option>
|
||||||
|
<option value="memory">内存</option>
|
||||||
|
<option value="hdd">硬盘</option>
|
||||||
|
<option value="traffic">月流量</option>
|
||||||
|
<option value="loss">丢包</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="sortDirection" class="icon-text" title="切换排序方向">降序</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="panel-servers" class="panel active" aria-labelledby="主机">
|
<section id="panel-servers" class="panel active" aria-labelledby="主机">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data" id="serversTable">
|
<table class="data" id="serversTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>协议</th>
|
<th data-sort="status">协议</th>
|
||||||
<th>月流量 ↓|↑</th>
|
<th data-sort="traffic">月流量 ↓|↑</th>
|
||||||
<th>节点</th>
|
<th data-sort="name">节点</th>
|
||||||
<th>虚拟化</th>
|
<th data-sort="type">虚拟化</th>
|
||||||
<th>位置</th>
|
<th data-sort="location">位置</th>
|
||||||
<th>在线</th>
|
<th>在线</th>
|
||||||
<th>负载</th>
|
<th data-sort="load">负载</th>
|
||||||
<th>当前网络 ↓|↑</th>
|
<th>当前网络 ↓|↑</th>
|
||||||
<th>总流量 ↓|↑</th>
|
<th>总流量 ↓|↑</th>
|
||||||
<th>CPU</th>
|
<th data-sort="cpu">CPU</th>
|
||||||
<th>内存</th>
|
<th data-sort="memory">内存</th>
|
||||||
<th>硬盘</th>
|
<th data-sort="hdd">硬盘</th>
|
||||||
<th style="text-align:center;">联通|电信|移动</th>
|
<th data-sort="loss" style="text-align:center;">联通|电信|移动</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="serversBody"></tbody>
|
<tbody id="serversBody"></tbody>
|
||||||
@@ -99,21 +132,73 @@
|
|||||||
<!-- 移动端卡片布局 (证书) -->
|
<!-- 移动端卡片布局 (证书) -->
|
||||||
<div id="sslCards" class="cards" style="display:none;"></div>
|
<div id="sslCards" class="cards" style="display:none;"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="panel-config" class="panel" aria-labelledby="配置">
|
||||||
|
<div class="config-grid">
|
||||||
|
<section class="admin-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>配置管理</h2>
|
||||||
|
<p class="muted">可编辑节点、服务监测、证书与告警规则,保存后自动重载。</p>
|
||||||
|
</div>
|
||||||
|
<div class="section-actions">
|
||||||
|
<button id="adminReload" class="icon-text">重载配置</button>
|
||||||
|
<button id="adminRestart" class="danger-btn">重启服务</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="adminStatus" class="admin-status">正在检测管理 API...</div>
|
||||||
|
<form id="adminTokenForm" class="admin-login">
|
||||||
|
<label class="search-field">
|
||||||
|
<span>管理令牌</span>
|
||||||
|
<input id="adminToken" type="password" autocomplete="current-password" placeholder="ADMIN_TOKEN" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="primary-btn">连接</button>
|
||||||
|
</form>
|
||||||
|
<div class="segmented config-tabs" id="configTypeTabs" role="group" aria-label="配置类型">
|
||||||
|
<button type="button" data-type="servers" class="active">节点</button>
|
||||||
|
<button type="button" data-type="monitors">监测</button>
|
||||||
|
<button type="button" data-type="sslcerts">证书</button>
|
||||||
|
<button type="button" data-type="watchdog">告警</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-actions">
|
||||||
|
<button id="addConfigItemBtn" class="primary-btn">新增节点</button>
|
||||||
|
<button id="refreshConfigBtn" class="icon-text">刷新配置</button>
|
||||||
|
</div>
|
||||||
|
<div id="configItemList" class="config-list"></div>
|
||||||
|
</section>
|
||||||
|
<section class="admin-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 id="configEditorTitle">新增节点</h2>
|
||||||
|
<p class="muted" id="configEditorHint">保存后会写入 config.json,并向 sergate 发送 SIGHUP 重载。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="configForm" class="config-form">
|
||||||
|
<div id="configFields" class="config-fields"></div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="primary-btn">保存配置</button>
|
||||||
|
<button type="button" id="deleteConfigItemBtn" class="danger-btn">删除配置</button>
|
||||||
|
<button type="button" id="resetConfigFormBtn" class="icon-text">清空</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- 详情弹窗 -->
|
<!-- 详情抽屉 -->
|
||||||
<div id="detailModal" class="modal-backdrop" style="display:none;">
|
<div id="detailModal" class="modal-backdrop drawer-backdrop" style="display:none;">
|
||||||
<div class="modal-box" role="dialog" aria-modal="true" aria-labelledby="detailTitle">
|
<aside class="modal-box detail-drawer" role="dialog" aria-modal="true" aria-labelledby="detailTitle">
|
||||||
<button class="modal-close" id="detailClose" aria-label="关闭">×</button>
|
<button class="modal-close" id="detailClose" aria-label="关闭">×</button>
|
||||||
<h3 id="detailTitle" class="modal-title">节点详情</h3>
|
<h3 id="detailTitle" class="modal-title">节点详情</h3>
|
||||||
<div id="detailContent" class="modal-content">加载中...</div>
|
<div id="detailContent" class="modal-content">加载中...</div>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<a href="https://github.com/cppla/ServerStatus" target="_blank" rel="noopener">ServerStatus中文版</a>
|
<a href="https://github.com/cppla/ServerStatus" target="_blank" rel="noopener">ServerStatus中文版</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="js/app.js" defer></script>
|
<script src="js/app.js?v=20260630-7" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+822
-484
File diff suppressed because it is too large
Load Diff
@@ -1,83 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# coding: utf-8
|
|
||||||
# Update by : https://github.com/cppla/ServerStatus, Update date: 20211009
|
|
||||||
# 支持Python版本:2.7 to 3.9; requirements.txt: requests, PrettyTable
|
|
||||||
# 主要是为了受到CC attack时候方便查看机器状态
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import requests
|
|
||||||
import time
|
|
||||||
from prettytable import PrettyTable
|
|
||||||
|
|
||||||
scroll = True
|
|
||||||
clear = lambda: os.system('clear' if 'linux' in sys.platform or 'darwin' in sys.platform else 'cls')
|
|
||||||
|
|
||||||
|
|
||||||
def sscmd(address):
|
|
||||||
while True:
|
|
||||||
r = requests.get(
|
|
||||||
url=address,
|
|
||||||
headers={
|
|
||||||
"User-Agent": "ServerStatus/20181203",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
jsonR = r.json()
|
|
||||||
|
|
||||||
ss = PrettyTable(
|
|
||||||
[
|
|
||||||
"月流量 ↓|↑",
|
|
||||||
"节点名",
|
|
||||||
"位置",
|
|
||||||
"在线时间",
|
|
||||||
"负载",
|
|
||||||
"网络 ↓|↑",
|
|
||||||
"总流量 ↓|↑",
|
|
||||||
"处理器",
|
|
||||||
"内存",
|
|
||||||
"硬盘"
|
|
||||||
],
|
|
||||||
)
|
|
||||||
for i in jsonR["servers"]:
|
|
||||||
if i["online4"] is False and i["online6"] is False:
|
|
||||||
ss.add_row(
|
|
||||||
[
|
|
||||||
'0.00G',
|
|
||||||
"%s" % i["name"],
|
|
||||||
"%s" % i["location"],
|
|
||||||
'-',
|
|
||||||
'-',
|
|
||||||
'-',
|
|
||||||
'-',
|
|
||||||
'-',
|
|
||||||
'-',
|
|
||||||
'-',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ss.add_row(
|
|
||||||
[
|
|
||||||
"%.2fG|%.2fG" % (float(i["last_network_in"]) / 1024 / 1024 / 1024, float(i["last_network_out"]) / 1024 / 1024 / 1024),
|
|
||||||
"%s" % i["name"],
|
|
||||||
# "%s" % i["type"],
|
|
||||||
"%s" % i["location"],
|
|
||||||
"%s" % i["uptime"],
|
|
||||||
"%s" % (i["load_1"]),
|
|
||||||
"%.2fM|%.2fM" % (float(i["network_rx"]) / 1000 / 1000, float(i["network_tx"]) / 1000 / 1000),
|
|
||||||
"%.2fG|%.2fG" % (
|
|
||||||
float(i["network_in"]) / 1024 / 1024 / 1024, float(i["network_out"]) / 1024 / 1024 / 1024),
|
|
||||||
"%d%%" % (i["cpu"]),
|
|
||||||
"%d%%" % (float(i["memory_used"]) / i["memory_total"] * 100),
|
|
||||||
"%d%%" % (float(i["hdd_used"]) / i["hdd_total"] * 100),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if scroll is True:
|
|
||||||
clear()
|
|
||||||
print(ss)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
default = 'https://tz.cloudcpp.com/json/stats.json'
|
|
||||||
ads = sys.argv[1] if len(sys.argv) == 2 else default
|
|
||||||
sscmd(ads)
|
|
||||||
Reference in New Issue
Block a user