mirror of
https://github.com//cppla/ServerStatus
synced 2026-08-08 21:53: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 | ||
|
|
15fe753a0f | ||
|
|
0d185f628b | ||
|
|
83977b55bf | ||
|
|
49decdfed1 | ||
|
|
227e15fe48 | ||
|
|
73b3d16ff6 | ||
|
|
c04256535d | ||
|
|
3c8ebcf710 | ||
|
|
9a19d2f4bf | ||
|
|
d3c5e4a02a | ||
|
|
a03cda0ef0 | ||
|
|
d25e7871ba | ||
|
|
fd6c3532d9 | ||
|
|
89745f3c3a | ||
|
|
266230f5f0 | ||
|
|
4c17623dff | ||
|
|
34e30d9873 | ||
|
|
1e190878dd | ||
|
|
874c122a62 | ||
|
|
fa984cfae6 | ||
|
|
a544f72db0 | ||
|
|
eea993c6cc | ||
|
|
a4a3734c30 | ||
|
|
a10e39b6f2 | ||
|
|
360d8d008d | ||
|
|
830938eac9 | ||
|
|
1a764ed104 | ||
|
|
b0c543b44b | ||
|
|
f85c0a87f9 | ||
|
|
67d7c5ea0f | ||
|
|
8576f81404 | ||
|
|
fa0ee2bf57 | ||
|
|
e9776b0e69 | ||
|
|
e01cc118dc | ||
|
|
784c40b153 | ||
|
|
6068a2ba17 | ||
|
|
ced526824f | ||
|
|
7d2a73bf2e | ||
|
|
51e2f664cd | ||
|
|
937b1ea90f | ||
|
|
b95d1945bf | ||
|
|
e8446cd52f | ||
|
|
6098f0fb1a | ||
|
|
fc89a76cc2 | ||
|
|
f290947b2c | ||
|
|
b3adc6b782 | ||
|
|
705a957606 | ||
|
|
a307ad7d19 | ||
|
|
ad95e00723 | ||
|
|
31330168f3 | ||
|
|
d9e8f8a7c1 | ||
|
|
08f15ebdc5 | ||
|
|
adb05818b9 | ||
|
|
4e73e8185e | ||
|
|
0fe01064a4 | ||
|
|
510567eaec | ||
|
|
503037c7e2 | ||
|
|
d75d5438a3 | ||
|
|
91f11dad76 | ||
|
|
fdc5abacfc | ||
|
|
388938e02b | ||
|
|
f912794068 | ||
|
|
6331d7d45b | ||
|
|
25f878a38a | ||
|
|
a8b9b2d00d | ||
|
|
dc3868998a | ||
|
|
75d06c8666 | ||
|
|
360252182d | ||
|
|
aa0ccd254c | ||
|
|
f7b2e7db42 | ||
|
|
052c75ef23 | ||
|
|
1f7827cc21 | ||
|
|
c236aee5dc | ||
|
|
fb3ecd1796 | ||
|
|
810f6a0d8a | ||
|
|
eddf37c413 | ||
|
|
8dfc6d4718 | ||
|
|
03446b8f29 | ||
|
|
6ab046be51 | ||
|
|
94708c588c | ||
|
|
144987bcf1 | ||
|
|
7e46b1062e | ||
|
|
71cadcfebe | ||
|
|
d96e711a34 | ||
|
|
5ebf076330 | ||
|
|
4a9f5adb6b | ||
|
|
59fa2d8eab | ||
|
|
bd4c22edff | ||
|
|
88229b2f4a | ||
|
|
d70a56a767 | ||
|
|
a177345858 | ||
|
|
11a03a020c | ||
|
|
ed330bd785 | ||
|
|
77cb62ae80 | ||
|
|
7bbca5e881 | ||
|
|
2e0d1f7fb9 | ||
|
|
da83c54e5b | ||
|
|
3ef6cb52c1 | ||
|
|
67877bacfc | ||
|
|
a8383efe93 | ||
|
|
03682772a1 | ||
|
|
fdca4fd353 | ||
|
|
a5330dcf2f | ||
|
|
be78a7cc88 | ||
|
|
46d43c247f | ||
|
|
6ae278857a | ||
|
|
d5e59453f1 | ||
|
|
d242c6cea1 | ||
|
|
d7614ab168 | ||
|
|
ff06df8263 | ||
|
|
9ee1516f72 | ||
|
|
7603ce37af | ||
|
|
5cfd533daf | ||
|
|
9077f1058a | ||
|
|
e34ed05901 | ||
|
|
4ab545f646 | ||
|
|
a6b4248967 | ||
|
|
8ad927d88d | ||
|
|
4b1ec57c6d | ||
|
|
979a9b5d87 | ||
|
|
52d62afa59 | ||
|
|
806b60bd6b | ||
|
|
d5af5445df | ||
|
|
c77e00476c | ||
|
|
b551998eb8 | ||
|
|
1c606cc79e | ||
|
|
d602791b11 | ||
|
|
a695ef8b3a | ||
|
|
9f60b7962b | ||
|
|
34b1bca5b1 | ||
|
|
25917f0883 | ||
|
|
333bc29c88 | ||
|
|
c116067c39 | ||
|
|
14db7ec943 | ||
|
|
af8244f1d7 | ||
|
|
1004c9a6bd | ||
|
|
7f78af03f0 | ||
|
|
02bbdb18de | ||
|
|
6d813f932d | ||
|
|
19d6a5ea8b | ||
|
|
35de279e89 | ||
|
|
d5a047c781 | ||
|
|
10872059c5 | ||
|
|
44656e565f | ||
|
|
78c7da7361 | ||
|
|
b5edeea057 | ||
|
|
c2259f347d | ||
|
|
bb5f03047d | ||
|
|
6ab6e16d0f | ||
|
|
615cec88c2 | ||
|
|
94896bac80 | ||
|
|
a788b5da90 | ||
|
|
de0bc9dd74 | ||
|
|
ec16fc1fac | ||
|
|
cbd803b686 | ||
|
|
3eddb27d51 | ||
|
|
522809483e | ||
|
|
9d706f4da8 | ||
|
|
a0fe8ee33c | ||
|
|
c5eed8e4fa | ||
|
|
32d302580e | ||
|
|
b03d090a5c | ||
|
|
6151806141 | ||
|
|
2703993272 | ||
|
|
022e5edb28 | ||
|
|
135eb180c8 | ||
|
|
2ead43a0d8 | ||
|
|
baae11de3b | ||
|
|
2e62ffa593 | ||
|
|
f70705d872 | ||
|
|
7316dbdddb | ||
|
|
a1689acbf0 | ||
|
|
e2a59a5465 | ||
|
|
962d564c80 | ||
|
|
77973a5309 | ||
|
|
a9128b137d | ||
|
|
0a2a007e24 |
@@ -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 .
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
# The Dockerfile for build localhost source, not git repo
|
||||
FROM debian:buster as builder
|
||||
|
||||
MAINTAINER cppla https://cpp.la
|
||||
|
||||
RUN apt-get update -y && apt-get -y install gcc g++ make
|
||||
|
||||
COPY . .
|
||||
|
||||
WORKDIR /server
|
||||
|
||||
RUN make
|
||||
RUN pwd && ls -a
|
||||
|
||||
# glibc env run
|
||||
FROM nginx:latest
|
||||
|
||||
RUN mkdir -p /ServerStatus/server/
|
||||
|
||||
COPY --from=builder server /ServerStatus/server/
|
||||
COPY --from=builder web /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 80 35601
|
||||
|
||||
CMD nohup 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,139 +1,339 @@
|
||||
# ServerStatus中文版:
|
||||
# ServerStatus 中文版
|
||||
|
||||
* ServerStatus中文版是一个酷炫高逼格的云探针、云监控、服务器云监控、多服务器探针~。
|
||||
* 在线演示:https://tz.cloudcpp.com
|
||||
ServerStatus 是一个轻量的服务器探针和云监控面板,支持多节点在线状态、资源占用、三网延迟、服务监测、SSL 证书检查、Watchdog 告警和 Web 配置管理。
|
||||
|
||||
[](https://github.com/cppla/ServerStatus)
|
||||
在线演示: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)
|
||||
|
||||

|
||||

|
||||
|
||||
`curl -sSL https://get.docker.com/ | sh && apt -y install docker-compose`
|
||||
`Watchdog 的 `interval` 是最小通知间隔,用于避免频繁报警,并不是探测间隔。`rule` 使用 Exprtk 表达式,当前窄字符解析对中文等 Unicode 字符不友好,规则中建议使用英文、数字和字段名。`
|
||||
|
||||
# 目录介绍:
|
||||
|
||||
* clients 客户端文件
|
||||
* server 服务端文件
|
||||
* web 网站文件
|
||||
## 一、服务端
|
||||
|
||||
* server/config.json 探针配置文件
|
||||
* web/json 探针月流量
|
||||
|
||||
# 自动部署:
|
||||
|
||||
【服务端】:
|
||||
```bash
|
||||
|
||||
`OneTouch`:
|
||||
|
||||
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
|
||||
|
||||
`ServerStatus`: docker-compose up -d
|
||||
|
||||
`ServerStatus with tgbot`: TG_CHAT_ID=你的电报ID TG_BOT_TOKEN=你的电报密钥 docker-compose -f docker-compose-telegram.yml up -d
|
||||
|
||||
# Docker Compose,本地构建加:--build
|
||||
ADMIN_TOKEN='your-strong-token' docker compose -f docker-compose-server.yml up -d
|
||||
```
|
||||
|
||||
【客户端】:
|
||||
```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:
|
||||
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 &
|
||||
docker run -d --restart=always --name=serverstatus-server \
|
||||
-e ADMIN_TOKEN='your-strong-token' \
|
||||
-v ~/serverstatus-config.json:/ServerStatus/server/config.json \
|
||||
-v ~/serverstatus-monthtraffic:/usr/share/nginx/html/json \
|
||||
-p 8080:80 -p 35601:35601 \
|
||||
cppla/serverstatus:server
|
||||
```
|
||||
|
||||
# 手动安装教程:
|
||||
|
||||
【克隆代码】:
|
||||
```
|
||||
git clone https://github.com/cppla/ServerStatus.git
|
||||
启动后访问:
|
||||
|
||||
- WebUI:http://127.0.0.1:8080/
|
||||
- HTTP API 自检:http://127.0.0.1:8080/api/health
|
||||
- HTTP API 文档:http://127.0.0.1:8080/api/schema
|
||||
- HTTP 默认端口映射为`8080:80`,客户端连接端口为`35601`。`ADMIN_TOKEN` 可选:不设置时Web仅可查看监控数据,但web配置页无法修改。
|
||||
|
||||
|
||||
## 二、客户端
|
||||
|
||||
```bash
|
||||
# Docker Compose,本地构建加:--build
|
||||
SERVER=127.0.0.1 USER=s01 docker compose -f docker-compose-client.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
【服务端配置】:
|
||||
|
||||
一、生成服务端程序
|
||||
```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": [
|
||||
{
|
||||
"username": "s01",
|
||||
"name": "node1",
|
||||
"type": "kvm",
|
||||
"host": "host1",
|
||||
"location": "CN",
|
||||
"password": "USER_DEFAULT_PASSWORD",
|
||||
"monthstart": 1
|
||||
}
|
||||
],
|
||||
"monitors": [
|
||||
{
|
||||
"name": "example",
|
||||
"host": "https://example.com",
|
||||
"interval": 600,
|
||||
"type": "https"
|
||||
}
|
||||
],
|
||||
"sslcerts": [
|
||||
{
|
||||
"name": "example",
|
||||
"domain": "https://example.com",
|
||||
"port": 443,
|
||||
"interval": 7200,
|
||||
"callback": "https://yourSMSurl"
|
||||
}
|
||||
],
|
||||
"watchdog": [
|
||||
{
|
||||
"name": "offline warning",
|
||||
"rule": "online4=0&online6=0",
|
||||
"interval": 600,
|
||||
"callback": "https://yourSMSurl"
|
||||
},
|
||||
{
|
||||
"name": "cpu high warning",
|
||||
"rule": "cpu>90&load_1>5&username!='s01'",
|
||||
"interval": 600,
|
||||
"callback": "https://yourSMSurl"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
常见 Watchdog 回调:
|
||||
|
||||
```text
|
||||
Telegram: https://api.telegram.org/bot你的密钥/sendMessage?parse_mode=HTML&disable_web_page_preview=true&chat_id=你的标识&text=
|
||||
Server酱: https://sctapi.ftqq.com/你的密钥.send?title=ServerStatus&desp=
|
||||
PushDeer: https://api2.pushdeer.com/message/push?pushkey=你的密钥&text=
|
||||
HttpBasicAuth: https://用户名:密码@你的域名/api/push?message=
|
||||
```
|
||||
|
||||
## 源码编译和运行
|
||||
|
||||
服务端依赖:
|
||||
|
||||
```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`:
|
||||
|
||||
```bash
|
||||
cd ServerStatus/server
|
||||
make
|
||||
./sergate
|
||||
```
|
||||
如果没错误提示,OK,ctrl+c关闭;如果有错误提示,检查35601端口是否被占用
|
||||
|
||||
二、修改配置文件
|
||||
修改config.json文件,注意username, password的值需要和客户端对应一致
|
||||
```
|
||||
{"servers":
|
||||
[
|
||||
{
|
||||
"username": "s01",
|
||||
"name": "vps-1",
|
||||
"type": "kvm",
|
||||
"host": "chengdu",
|
||||
"location": "🇨🇳",
|
||||
"password": "USER_DEFAULT_PASSWORD",
|
||||
"monthstart": 1
|
||||
},
|
||||
]
|
||||
}
|
||||
mkdir -p ../web/json
|
||||
./sergate --config=config.json --web-dir=../web &
|
||||
echo $! > /tmp/serverstatus-sergate.pid
|
||||
```
|
||||
|
||||
三、拷贝ServerStatus/status到你的网站目录
|
||||
例如:
|
||||
```
|
||||
sudo cp -r ServerStatus/web/* /home/wwwroot/default
|
||||
如果只需要 HTTP API,可以再启动 `manage_api.py`:
|
||||
|
||||
```bash
|
||||
cd ServerStatus/server
|
||||
ADMIN_TOKEN='your-strong-token' \
|
||||
CONFIG_PATH="$PWD/config.json" \
|
||||
SERGATE_PID_FILE=/tmp/serverstatus-sergate.pid \
|
||||
ADMIN_API_BIND=127.0.0.1 \
|
||||
ADMIN_API_PORT=35602 \
|
||||
python3 manage_api.py
|
||||
```
|
||||
|
||||
四、运行服务端:
|
||||
web-dir参数为上一步设置的网站根目录,务必修改成自己网站的路径
|
||||
```
|
||||
./sergate --config=config.json --web-dir=/home/wwwroot/default
|
||||
源码方式直连 API:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:35602/api/health
|
||||
curl -H 'Authorization: Bearer your-strong-token' \
|
||||
http://127.0.0.1:35602/api/config
|
||||
```
|
||||
|
||||
【客户端配置】:
|
||||
客户端有两个版本,client-linux为普通linux,client-psutil为跨平台版,普通版不成功,换成跨平台版即可。
|
||||
如果要通过 WebUI 使用「配置」页,需要 nginx 同时提供静态文件并反代 `/api/`。示例配置:
|
||||
|
||||
一、client-linux版配置:
|
||||
1、vim client-linux.py, 修改SERVER地址,username帐号, password密码
|
||||
2、python3 client-linux.py 运行即可。
|
||||
```nginx
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
二、client-psutil版配置:
|
||||
1、安装psutil跨平台依赖库
|
||||
2、vim client-psutil.py, 修改SERVER地址,username帐号, password密码
|
||||
3、python3 client-psutil.py 运行即可。
|
||||
```
|
||||
### for Centos:
|
||||
sudo yum -y install epel-release
|
||||
sudo yum -y install python3-pip
|
||||
sudo yum clean all
|
||||
sudo yum -y install gcc
|
||||
sudo yum -y install python3-devel
|
||||
sudo pip3 install psutil
|
||||
root /path/to/ServerStatus/web;
|
||||
index index.html;
|
||||
|
||||
### for Ubuntu/Debian:
|
||||
sudo apt -y install python3-pip
|
||||
sudo pip3 install psutil
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
### for Windows:
|
||||
地址:https://pypi.org/project/psutil/
|
||||
下载psutil for windows, 安装即可
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
打开云探针页面,就可以正常的监控。接下来把服务器和客户端脚本自行加入开机启动,或者进程守护,或以后台方式运行即可!例如: nohup python3 client-linux.py &
|
||||
启动失败时优先检查端口占用:`35601` 是 `sergate` 客户端上报端口,`35602` 是源码方式的管理 API 端口,`8080` 是上面示例的 Web 端口。源码手动运行没有 Docker 入口脚本守护;调用 `/api/restart` 会向 `sergate` 发送 `SIGTERM`,需要你用 systemd、supervisor 或 shell 循环自行拉起。
|
||||
|
||||
`extra scene (run web/ssview.py)`
|
||||

|
||||
## 客户端源码运行
|
||||
|
||||
`client-linux.py`:
|
||||
|
||||
# Make Better
|
||||
```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
|
||||
* mojeda: https://github.com/mojeda
|
||||
* mojeda's ServerStatus: https://github.com/mojeda/ServerStatus
|
||||
* BlueVM's project: http://www.lowendtalk.com/discussion/comment/169690#Comment_169690
|
||||
|
||||
# Jetbrains
|
||||
|
||||
<a href="https://www.jetbrains.com/?from=ServerStatus"><img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_square.png" width="100px"></a>
|
||||
* mojeda:https://github.com/mojeda
|
||||
* mojeda's ServerStatus:https://github.com/mojeda/ServerStatus
|
||||
* BlueVM's project:http://www.lowendtalk.com/discussion/comment/169690#Comment_169690
|
||||
|
||||
+200
-28
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding: utf-8
|
||||
# Update by : https://github.com/cppla/ServerStatus, Update date: 20220323
|
||||
# 版本:1.0.3, 支持Python版本:2.7 to 3.9
|
||||
# Update by : https://github.com/cppla/ServerStatus, Update date: 20250902
|
||||
# 版本:1.1.0, 支持Python版本:3.6+
|
||||
# 支持操作系统: Linux, OSX, FreeBSD, OpenBSD and NetBSD, both 32-bit and 64-bit architectures
|
||||
# 说明: 默认情况下修改server和user就可以了。丢包率监测方向可以自定义,例如:CU = "www.facebook.com"。
|
||||
|
||||
SERVER = "127.0.0.1"
|
||||
USER = "s01"
|
||||
SERVER = ""
|
||||
USER = ""
|
||||
|
||||
|
||||
PASSWORD = "USER_DEFAULT_PASSWORD"
|
||||
@@ -29,10 +29,36 @@ import json
|
||||
import errno
|
||||
import subprocess
|
||||
import threading
|
||||
try:
|
||||
from queue import Queue # python3
|
||||
except ImportError:
|
||||
from Queue import Queue # python2
|
||||
import platform
|
||||
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():
|
||||
with open('/proc/uptime', 'r') as f:
|
||||
@@ -55,11 +81,34 @@ def get_memory():
|
||||
return int(MemTotal), int(MemUsed), int(SwapTotal), int(SwapFree)
|
||||
|
||||
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")
|
||||
total = p.splitlines()[-1]
|
||||
used = total.split()[3]
|
||||
size = total.split()[2]
|
||||
return int(size), int(used)
|
||||
valid_fs = {
|
||||
"ext4", "ext3", "ext2", "reiserfs", "jfs", "btrfs", "fuseblk",
|
||||
"zfs", "simfs", "ntfs", "fat32", "exfat", "xfs"
|
||||
}
|
||||
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():
|
||||
with open("/proc/stat", "r") as f:
|
||||
@@ -89,7 +138,7 @@ def liuliang():
|
||||
NET_OUT = 0
|
||||
with open('/proc/net/dev') as f:
|
||||
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[0][0] == 'lo' or 'tun' in netinfo[0][0] \
|
||||
or 'docker' in netinfo[0][0] or 'veth' in netinfo[0][0] \
|
||||
@@ -150,22 +199,24 @@ diskIO = {
|
||||
'read': 0,
|
||||
'write': 0
|
||||
}
|
||||
monitorServer = {}
|
||||
|
||||
def _ping_thread(host, mark, port):
|
||||
lostPacket = 0
|
||||
packet_queue = Queue(maxsize=PING_PACKET_HISTORY_LEN)
|
||||
|
||||
IP = host
|
||||
if host.count(':') < 1: # if not plain ipv6 address, means ipv4 address or hostname
|
||||
try:
|
||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
||||
IP = socket.getaddrinfo(host, None, socket.AF_INET)[0][4][0]
|
||||
else:
|
||||
IP = socket.getaddrinfo(host, None, socket.AF_INET6)[0][4][0]
|
||||
except Exception:
|
||||
while True:
|
||||
# flush dns , every time.
|
||||
IP = host
|
||||
if host.count(':') < 1: # if not plain ipv6 address, means ipv4 address or hostname
|
||||
try:
|
||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
||||
IP = socket.getaddrinfo(host, None, socket.AF_INET)[0][4][0]
|
||||
else:
|
||||
IP = socket.getaddrinfo(host, None, socket.AF_INET6)[0][4][0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while True:
|
||||
if packet_queue.full():
|
||||
if packet_queue.get() == 0:
|
||||
lostPacket -= 1
|
||||
@@ -310,9 +361,73 @@ def get_realtime_data():
|
||||
target=_disk_io,
|
||||
)
|
||||
for ti in [t1, t2, t3, t4, t5]:
|
||||
ti.setDaemon(True)
|
||||
ti.daemon = True
|
||||
ti.start()
|
||||
|
||||
|
||||
def _monitor_thread(name, host, interval, type):
|
||||
while True:
|
||||
if name not in monitorServer.keys():
|
||||
break
|
||||
try:
|
||||
# 1) 解析目标 host 与端口
|
||||
if type == 'http':
|
||||
addr = str(host).replace('http://','')
|
||||
addr = addr.split('/',1)[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:
|
||||
a, p = addr.rsplit(':',1)
|
||||
addr, port = a, int(p)
|
||||
else:
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
# 2) 解析 IP(按偏好族)
|
||||
IP = addr
|
||||
if addr.count(':') < 1: # 非纯 IPv6
|
||||
try:
|
||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
||||
IP = socket.getaddrinfo(addr, None, socket.AF_INET)[0][4][0]
|
||||
else:
|
||||
IP = socket.getaddrinfo(addr, None, socket.AF_INET6)[0][4][0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) 建连耗时(timeout=1s),ECONNREFUSED 也计入
|
||||
try:
|
||||
b = timeit.default_timer()
|
||||
socket.create_connection((IP, port), timeout=1).close()
|
||||
monitorServer[name]["latency"] = int((timeit.default_timer() - b) * 1000)
|
||||
except socket.error as error:
|
||||
if getattr(error, 'errno', None) == errno.ECONNREFUSED:
|
||||
monitorServer[name]["latency"] = int((timeit.default_timer() - b) * 1000)
|
||||
else:
|
||||
monitorServer[name]["latency"] = 0
|
||||
except Exception:
|
||||
monitorServer[name]["latency"] = 0
|
||||
time.sleep(interval)
|
||||
|
||||
def byte_str(object):
|
||||
'''
|
||||
bytes to str, str to bytes
|
||||
@@ -359,6 +474,26 @@ if __name__ == '__main__':
|
||||
if data.find("You are connecting via") < 0:
|
||||
data = byte_str(s.recv(1024))
|
||||
print(data)
|
||||
monitorServer.clear()
|
||||
for i in data.split('\n'):
|
||||
if "monitor" in i and "type" in i and "{" in i and "}" in i:
|
||||
jdata = json.loads(i[i.find("{"):i.find("}")+1])
|
||||
monitorServer[jdata.get("name")] = {
|
||||
"type": jdata.get("type"),
|
||||
"host": jdata.get("host"),
|
||||
"latency": 0
|
||||
}
|
||||
t = threading.Thread(
|
||||
target=_monitor_thread,
|
||||
kwargs={
|
||||
'name': jdata.get("name"),
|
||||
'host': jdata.get("host"),
|
||||
'interval': jdata.get("interval"),
|
||||
'type': jdata.get("type")
|
||||
}
|
||||
)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
timer = 0
|
||||
check_ip = 0
|
||||
@@ -377,7 +512,6 @@ if __name__ == '__main__':
|
||||
Load_1, Load_5, Load_15 = os.getloadavg()
|
||||
MemoryTotal, MemoryUsed, SwapTotal, SwapFree = get_memory()
|
||||
HDDTotal, HDDUsed = get_hdd()
|
||||
|
||||
array = {}
|
||||
if not timer:
|
||||
array['online' + str(check_ip)] = get_network(check_ip)
|
||||
@@ -400,8 +534,6 @@ if __name__ == '__main__':
|
||||
array['network_tx'] = netSpeed.get("nettx")
|
||||
array['network_in'] = NET_IN
|
||||
array['network_out'] = NET_OUT
|
||||
# todo:兼容旧版本,下个版本删除ip_status
|
||||
array['ip_status'] = True
|
||||
array['ping_10010'] = lostRate.get('10010') * 100
|
||||
array['ping_189'] = lostRate.get('189') * 100
|
||||
array['ping_10086'] = lostRate.get('10086') * 100
|
||||
@@ -411,16 +543,56 @@ if __name__ == '__main__':
|
||||
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
|
||||
array['io_read'] = diskIO.get("read")
|
||||
array['io_write'] = diskIO.get("write")
|
||||
|
||||
# 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"))
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except socket.error:
|
||||
monitorServer.clear()
|
||||
print("Disconnected...")
|
||||
if 's' in locals().keys():
|
||||
del s
|
||||
time.sleep(3)
|
||||
except Exception as e:
|
||||
monitorServer.clear()
|
||||
print("Caught Exception:", e)
|
||||
if 's' in locals().keys():
|
||||
del s
|
||||
|
||||
+237
-71
@@ -1,13 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
# coding: utf-8
|
||||
# Update by : https://github.com/cppla/ServerStatus, Update date: 20220323
|
||||
# Update by : https://github.com/cppla/ServerStatus, Update date: 20250902
|
||||
# 依赖于psutil跨平台库
|
||||
# 版本:1.0.3, 支持Python版本:2.7 to 3.9
|
||||
# 版本:1.1.0, 支持Python版本:3.6+
|
||||
# 支持操作系统: Linux, Windows, OSX, Sun Solaris, FreeBSD, OpenBSD and NetBSD, both 32-bit and 64-bit architectures
|
||||
# 说明: 默认情况下修改server和user就可以了。丢包率监测方向可以自定义,例如:CU = "www.facebook.com"。
|
||||
|
||||
SERVER = "127.0.0.1"
|
||||
USER = "s01"
|
||||
SERVER = ""
|
||||
USER = ""
|
||||
|
||||
|
||||
PASSWORD = "USER_DEFAULT_PASSWORD"
|
||||
@@ -29,10 +29,36 @@ import json
|
||||
import errno
|
||||
import psutil
|
||||
import threading
|
||||
try:
|
||||
from queue import Queue # python3
|
||||
except ImportError:
|
||||
from Queue import Queue # python2
|
||||
import platform
|
||||
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():
|
||||
return int(time.time() - psutil.boot_time())
|
||||
@@ -46,18 +72,22 @@ def get_swap():
|
||||
return int(Mem.total/1024.0), int(Mem.used/1024.0)
|
||||
|
||||
def get_hdd():
|
||||
valid_fs = [ "ext4", "ext3", "ext2", "reiserfs", "jfs", "btrfs", "fuseblk", "zfs", "simfs", "ntfs", "fat32", "exfat", "xfs" ]
|
||||
disks = dict()
|
||||
size = 0
|
||||
used = 0
|
||||
for disk in psutil.disk_partitions():
|
||||
if not disk.device in disks and disk.fstype.lower() in valid_fs:
|
||||
disks[disk.device] = disk.mountpoint
|
||||
for disk in disks.values():
|
||||
usage = psutil.disk_usage(disk)
|
||||
size += usage.total
|
||||
used += usage.used
|
||||
return int(size/1024.0/1024.0), int(used/1024.0/1024.0)
|
||||
if "darwin" in sys.platform:
|
||||
return int(psutil.disk_usage("/").total/1024.0/1024.0), int((psutil.disk_usage("/").total-psutil.disk_usage("/").free)/1024.0/1024.0)
|
||||
else:
|
||||
valid_fs = ["ext4", "ext3", "ext2", "reiserfs", "jfs", "btrfs", "fuseblk", "zfs", "simfs", "ntfs", "fat32",
|
||||
"exfat", "xfs"]
|
||||
disks = dict()
|
||||
size = 0
|
||||
used = 0
|
||||
for disk in psutil.disk_partitions():
|
||||
if not disk.device in disks and disk.fstype.lower() in valid_fs:
|
||||
disks[disk.device] = disk.mountpoint
|
||||
for disk in disks.values():
|
||||
usage = psutil.disk_usage(disk)
|
||||
size += usage.total
|
||||
used += usage.used
|
||||
return int(size/1024.0/1024.0), int(used/1024.0/1024.0)
|
||||
|
||||
def get_cpu():
|
||||
return psutil.cpu_percent(interval=INTERVAL)
|
||||
@@ -88,13 +118,23 @@ def tupd():
|
||||
u = int(os.popen('ss -u|wc -l').read()[:-1])-1
|
||||
p = int(os.popen('ps -ef|wc -l').read()[:-1])-2
|
||||
d = int(os.popen('ps -eLf|wc -l').read()[:-1])-2
|
||||
elif sys.platform.startswith("darwin") is True:
|
||||
t = int(os.popen('lsof -nP -iTCP | wc -l').read()[:-1]) - 1
|
||||
u = int(os.popen('lsof -nP -iUDP | wc -l').read()[:-1]) - 1
|
||||
p = len(psutil.pids())
|
||||
d = 0
|
||||
for k in psutil.pids():
|
||||
try:
|
||||
d += psutil.Process(k).num_threads()
|
||||
except:
|
||||
pass
|
||||
|
||||
elif sys.platform.startswith("win") is True:
|
||||
t = int(os.popen('netstat -an|find "TCP" /c').read()[:-1])-1
|
||||
u = int(os.popen('netstat -an|find "UDP" /c').read()[:-1])-1
|
||||
p = len(psutil.pids())
|
||||
d = 0
|
||||
# cpu is high, default: 0
|
||||
# d = sum([psutil.Process(k).num_threads() for k in [x for x in psutil.pids()]])
|
||||
# if you find cpu is high, please set d=0
|
||||
d = sum([psutil.Process(k).num_threads() for k in psutil.pids()])
|
||||
else:
|
||||
t,u,p,d = 0,0,0,0
|
||||
return t,u,p,d
|
||||
@@ -134,22 +174,24 @@ diskIO = {
|
||||
'read': 0,
|
||||
'write': 0
|
||||
}
|
||||
monitorServer = {}
|
||||
|
||||
def _ping_thread(host, mark, port):
|
||||
lostPacket = 0
|
||||
packet_queue = Queue(maxsize=PING_PACKET_HISTORY_LEN)
|
||||
|
||||
IP = host
|
||||
if host.count(':') < 1: # if not plain ipv6 address, means ipv4 address or hostname
|
||||
try:
|
||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
||||
IP = socket.getaddrinfo(host, None, socket.AF_INET)[0][4][0]
|
||||
else:
|
||||
IP = socket.getaddrinfo(host, None, socket.AF_INET6)[0][4][0]
|
||||
except Exception:
|
||||
while True:
|
||||
# flush dns, every time.
|
||||
IP = host
|
||||
if host.count(':') < 1: # if not plain ipv6 address, means ipv4 address or hostname
|
||||
try:
|
||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
||||
IP = socket.getaddrinfo(host, None, socket.AF_INET)[0][4][0]
|
||||
else:
|
||||
IP = socket.getaddrinfo(host, None, socket.AF_INET6)[0][4][0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while True:
|
||||
if packet_queue.full():
|
||||
if packet_queue.get() == 0:
|
||||
lostPacket -= 1
|
||||
@@ -205,44 +247,49 @@ def _disk_io():
|
||||
比如我这里是机械硬盘,大量做随机小文件读写,那么很低的读写也就能造成硬盘长时间的等待。
|
||||
如果这里做连续性IO,那么普通机械硬盘写入到100Mb/s,那么也能造成硬盘长时间的等待。
|
||||
磁盘读写有误差:4k,8k ,https://stackoverflow.com/questions/34413926/psutil-vs-dd-monitoring-disk-i-o
|
||||
macos/win,暂不处理。
|
||||
"""
|
||||
while True:
|
||||
# first get a list of all processes and disk io counters
|
||||
procs = [p for p in psutil.process_iter()]
|
||||
for p in procs[:]:
|
||||
try:
|
||||
p._before = p.io_counters()
|
||||
except psutil.Error:
|
||||
procs.remove(p)
|
||||
continue
|
||||
disks_before = psutil.disk_io_counters()
|
||||
|
||||
# sleep some time, only when INTERVAL==1 , io read/write per_sec.
|
||||
# when INTERVAL > 1, io read/write per_INTERVAL
|
||||
time.sleep(INTERVAL)
|
||||
|
||||
# then retrieve the same info again
|
||||
for p in procs[:]:
|
||||
with p.oneshot():
|
||||
if "darwin" in sys.platform or "win" in sys.platform:
|
||||
diskIO["read"] = 0
|
||||
diskIO["write"] = 0
|
||||
else:
|
||||
while True:
|
||||
# first get a list of all processes and disk io counters
|
||||
procs = [p for p in psutil.process_iter()]
|
||||
for p in procs[:]:
|
||||
try:
|
||||
p._after = p.io_counters()
|
||||
p._cmdline = ' '.join(p.cmdline())
|
||||
if not p._cmdline:
|
||||
p._cmdline = p.name()
|
||||
p._username = p.username()
|
||||
except (psutil.NoSuchProcess, psutil.ZombieProcess):
|
||||
p._before = p.io_counters()
|
||||
except psutil.Error:
|
||||
procs.remove(p)
|
||||
disks_after = psutil.disk_io_counters()
|
||||
continue
|
||||
disks_before = psutil.disk_io_counters()
|
||||
|
||||
# finally calculate results by comparing data before and
|
||||
# after the interval
|
||||
for p in procs:
|
||||
p._read_per_sec = p._after.read_bytes - p._before.read_bytes
|
||||
p._write_per_sec = p._after.write_bytes - p._before.write_bytes
|
||||
p._total = p._read_per_sec + p._write_per_sec
|
||||
# sleep some time, only when INTERVAL==1 , io read/write per_sec.
|
||||
# when INTERVAL > 1, io read/write per_INTERVAL
|
||||
time.sleep(INTERVAL)
|
||||
|
||||
diskIO["read"] = disks_after.read_bytes - disks_before.read_bytes
|
||||
diskIO["write"] = disks_after.write_bytes - disks_before.write_bytes
|
||||
# then retrieve the same info again
|
||||
for p in procs[:]:
|
||||
with p.oneshot():
|
||||
try:
|
||||
p._after = p.io_counters()
|
||||
p._cmdline = ' '.join(p.cmdline())
|
||||
if not p._cmdline:
|
||||
p._cmdline = p.name()
|
||||
p._username = p.username()
|
||||
except (psutil.NoSuchProcess, psutil.ZombieProcess):
|
||||
procs.remove(p)
|
||||
disks_after = psutil.disk_io_counters()
|
||||
|
||||
# finally calculate results by comparing data before and
|
||||
# after the interval
|
||||
for p in procs:
|
||||
p._read_per_sec = p._after.read_bytes - p._before.read_bytes
|
||||
p._write_per_sec = p._after.write_bytes - p._before.write_bytes
|
||||
p._total = p._read_per_sec + p._write_per_sec
|
||||
|
||||
diskIO["read"] = disks_after.read_bytes - disks_before.read_bytes
|
||||
diskIO["write"] = disks_after.write_bytes - disks_before.write_bytes
|
||||
|
||||
def get_realtime_data():
|
||||
'''
|
||||
@@ -280,9 +327,75 @@ def get_realtime_data():
|
||||
target=_disk_io,
|
||||
)
|
||||
for ti in [t1, t2, t3, t4, t5]:
|
||||
ti.setDaemon(True)
|
||||
ti.daemon = True
|
||||
ti.start()
|
||||
|
||||
def _monitor_thread(name, host, interval, type):
|
||||
# 参考 _ping_thread 风格:每轮解析一次目标,按协议族偏好解析 IP,测 TCP 建连耗时
|
||||
while True:
|
||||
if name not in monitorServer:
|
||||
break
|
||||
try:
|
||||
# 1) 解析目标 host 与端口
|
||||
if type == 'http':
|
||||
addr = str(host).replace('http://','')
|
||||
addr = addr.split('/',1)[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:
|
||||
a, p = addr.rsplit(':',1)
|
||||
addr, port = a, int(p)
|
||||
else:
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
# 2) 解析 IP(按偏好族),与 _ping_thread 保持一致的判定
|
||||
IP = addr
|
||||
if addr.count(':') < 1: # 非纯 IPv6,可能是 IPv4 或域名
|
||||
try:
|
||||
if PROBE_PROTOCOL_PREFER == 'ipv4':
|
||||
IP = socket.getaddrinfo(addr, None, socket.AF_INET)[0][4][0]
|
||||
else:
|
||||
IP = socket.getaddrinfo(addr, None, socket.AF_INET6)[0][4][0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) 测 TCP 建连耗时(timeout=1s);ECONNREFUSED 也记为耗时
|
||||
try:
|
||||
b = timeit.default_timer()
|
||||
socket.create_connection((IP, port), timeout=1).close()
|
||||
monitorServer[name]['latency'] = int((timeit.default_timer() - b) * 1000)
|
||||
except socket.error as error:
|
||||
if getattr(error, 'errno', None) == errno.ECONNREFUSED:
|
||||
monitorServer[name]['latency'] = int((timeit.default_timer() - b) * 1000)
|
||||
else:
|
||||
monitorServer[name]['latency'] = 0
|
||||
except Exception:
|
||||
monitorServer[name]['latency'] = 0
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def byte_str(object):
|
||||
'''
|
||||
bytes to str, str to bytes
|
||||
@@ -329,6 +442,25 @@ if __name__ == '__main__':
|
||||
if data.find("You are connecting via") < 0:
|
||||
data = byte_str(s.recv(1024))
|
||||
print(data)
|
||||
for i in data.split('\n'):
|
||||
if "monitor" in i and "type" in i and "{" in i and "}" in i:
|
||||
jdata = json.loads(i[i.find("{"):i.find("}")+1])
|
||||
monitorServer[jdata.get("name")] = {
|
||||
"type": jdata.get("type"),
|
||||
"host": jdata.get("host"),
|
||||
"latency": 0
|
||||
}
|
||||
t = threading.Thread(
|
||||
target=_monitor_thread,
|
||||
kwargs={
|
||||
'name': jdata.get("name"),
|
||||
'host': jdata.get("host"),
|
||||
'interval': jdata.get("interval"),
|
||||
'type': jdata.get("type")
|
||||
}
|
||||
)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
timer = 0
|
||||
check_ip = 0
|
||||
@@ -344,11 +476,10 @@ if __name__ == '__main__':
|
||||
CPU = get_cpu()
|
||||
NET_IN, NET_OUT = liuliang()
|
||||
Uptime = get_uptime()
|
||||
Load_1, Load_5, Load_15 = os.getloadavg() if 'linux' in sys.platform else (0.0, 0.0, 0.0)
|
||||
Load_1, Load_5, Load_15 = os.getloadavg() if 'linux' in sys.platform or 'darwin' in sys.platform else (0.0, 0.0, 0.0)
|
||||
MemoryTotal, MemoryUsed = get_memory()
|
||||
SwapTotal, SwapUsed = get_swap()
|
||||
HDDTotal, HDDUsed = get_hdd()
|
||||
|
||||
array = {}
|
||||
if not timer:
|
||||
array['online' + str(check_ip)] = get_network(check_ip)
|
||||
@@ -371,8 +502,6 @@ if __name__ == '__main__':
|
||||
array['network_tx'] = netSpeed.get("nettx")
|
||||
array['network_in'] = NET_IN
|
||||
array['network_out'] = NET_OUT
|
||||
# todo:兼容旧版本,下个版本删除ip_status
|
||||
array['ip_status'] = True
|
||||
array['ping_10010'] = lostRate.get('10010') * 100
|
||||
array['ping_189'] = lostRate.get('189') * 100
|
||||
array['ping_10086'] = lostRate.get('10086') * 100
|
||||
@@ -382,16 +511,53 @@ if __name__ == '__main__':
|
||||
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
|
||||
array['io_read'] = diskIO.get("read")
|
||||
array['io_write'] = diskIO.get("write")
|
||||
|
||||
# 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"))
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except socket.error:
|
||||
monitorServer.clear()
|
||||
print("Disconnected...")
|
||||
if 's' in locals().keys():
|
||||
del s
|
||||
time.sleep(3)
|
||||
except Exception as e:
|
||||
monitorServer.clear()
|
||||
print("Caught Exception:", e)
|
||||
if 's' in locals().keys():
|
||||
del s
|
||||
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
serverstatus-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.server
|
||||
image: cppla/serverstatus:server
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python3 -c \"import os,urllib.request; pid=int(open('/tmp/serverstatus-sergate.pid').read().strip()); os.kill(pid,0); urllib.request.urlopen('http://127.0.0.1/',timeout=2).read(1)\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
container_name: serverstatus-server
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ADMIN_TOKEN: "${ADMIN_TOKEN:-}"
|
||||
networks:
|
||||
serverstatus-network:
|
||||
ipv4_address: 172.23.0.2
|
||||
volumes:
|
||||
- ./server/config.json:/ServerStatus/server/config.json
|
||||
- ./web/json:/usr/share/nginx/html/json
|
||||
ports:
|
||||
- 35601:35601
|
||||
- 8080:80
|
||||
|
||||
networks:
|
||||
serverstatus-network:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.23.0.0/24
|
||||
@@ -1,25 +0,0 @@
|
||||
version: "3"
|
||||
services:
|
||||
serverstatus:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: serverstatus_server
|
||||
container_name: serverstatus
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
serverstatus-network:
|
||||
ipv4_address: 172.23.0.2
|
||||
volumes:
|
||||
- ./server/config.json:/ServerStatus/server/config.json
|
||||
- ./web/json:/usr/share/nginx/html/json
|
||||
ports:
|
||||
- 35601:35601
|
||||
- 8080:80
|
||||
|
||||
networks:
|
||||
serverstatus-network:
|
||||
name: serverstatus-network
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.23.0.0/24
|
||||
@@ -2,7 +2,7 @@ version: "3"
|
||||
services:
|
||||
serverstatus:
|
||||
build:
|
||||
context: .
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
image: serverstatus_server
|
||||
container_name: serverstatus
|
||||
@@ -11,14 +11,14 @@ services:
|
||||
serverstatus-network:
|
||||
ipv4_address: 172.23.0.2
|
||||
volumes:
|
||||
- ./server/config.json:/ServerStatus/server/config.json
|
||||
- ./web/json:/usr/share/nginx/html/json
|
||||
- ../server/config.json:/ServerStatus/server/config.json
|
||||
- ../web/json:/usr/share/nginx/html/json
|
||||
ports:
|
||||
- 35601:35601
|
||||
- 8080:80
|
||||
bot:
|
||||
build:
|
||||
context: ./plugin
|
||||
context: .
|
||||
dockerfile: Dockerfile-telegram
|
||||
image: serverstatus_bot
|
||||
container_name: bot4sss
|
||||
+8
-4
@@ -1,4 +1,5 @@
|
||||
OUT = sergate
|
||||
.DEFAULT_GOAL := $(OUT)
|
||||
|
||||
#CC = clang
|
||||
CC = gcc
|
||||
@@ -6,7 +7,7 @@ CFLAGS = -Wall -O2
|
||||
|
||||
#CXX = clang++
|
||||
CXX = g++
|
||||
CXXFLAGS = -Wall -O2
|
||||
CXXFLAGS = -Wall -O2 -std=c++11
|
||||
|
||||
ODIR = obj
|
||||
SDIR = src
|
||||
@@ -19,14 +20,17 @@ C_OBJS := $(patsubst $(SDIR)/%.c,$(ODIR)/%.o,$(C_SRCS))
|
||||
CXX_OBJS := $(patsubst $(SDIR)/%.cpp,$(ODIR)/%.o,$(CXX_SRCS))
|
||||
OBJS := $(C_OBJS) $(CXX_OBJS)
|
||||
|
||||
$(ODIR)/%.o: $(SDIR)/%.c
|
||||
$(ODIR):
|
||||
mkdir -p $(ODIR)
|
||||
|
||||
$(ODIR)/%.o: $(SDIR)/%.c | $(ODIR)
|
||||
$(CC) -c $(INC) $(CFLAGS) $< -o $@
|
||||
|
||||
$(ODIR)/%.o: $(SDIR)/%.cpp
|
||||
$(ODIR)/%.o: $(SDIR)/%.cpp | $(ODIR)
|
||||
$(CXX) -c $(INC) $(CXXFLAGS) $< -o $@
|
||||
|
||||
$(OUT): $(OBJS)
|
||||
$(CXX) $(LIBS) $^ -o $(OUT)
|
||||
$(CXX) $(LIBS) $^ -o $(OUT) -lcurl
|
||||
|
||||
.PHONY: clean
|
||||
|
||||
|
||||
+92
-5
@@ -1,5 +1,5 @@
|
||||
{"servers":
|
||||
[
|
||||
{
|
||||
"servers": [
|
||||
{
|
||||
"username": "s01",
|
||||
"name": "node1",
|
||||
@@ -19,14 +19,14 @@
|
||||
"monthstart": 1
|
||||
},
|
||||
{
|
||||
"disabled": true,
|
||||
"username": "s03",
|
||||
"name": "node3",
|
||||
"type": "Nothing",
|
||||
"type": "hyper",
|
||||
"host": "host3",
|
||||
"location": "🇫🇷",
|
||||
"password": "USER_DEFAULT_PASSWORD",
|
||||
"monthstart": 1
|
||||
"monthstart": 1,
|
||||
"disabled": false
|
||||
},
|
||||
{
|
||||
"username": "s04",
|
||||
@@ -37,5 +37,92 @@
|
||||
"password": "USER_DEFAULT_PASSWORD",
|
||||
"monthstart": 1
|
||||
}
|
||||
],
|
||||
"monitors": [
|
||||
{
|
||||
"name": "抖音",
|
||||
"host": "https://www.douyin.com",
|
||||
"interval": 600,
|
||||
"type": "https"
|
||||
},
|
||||
{
|
||||
"name": "百度",
|
||||
"host": "https://www.baidu.com",
|
||||
"interval": 600,
|
||||
"type": "https"
|
||||
}
|
||||
],
|
||||
"sslcerts": [
|
||||
{
|
||||
"name": "my.cloudcpp.com",
|
||||
"domain": "https://my.cloudcpp.com",
|
||||
"port": 443,
|
||||
"interval": 7200,
|
||||
"callback": "https://yourSMSurl"
|
||||
},
|
||||
{
|
||||
"name": "tz.cloudcpp.com",
|
||||
"domain": "https://tz.cloudcpp.com",
|
||||
"port": 443,
|
||||
"interval": 7200,
|
||||
"callback": "https://yourSMSurl"
|
||||
},
|
||||
{
|
||||
"name": "3.0.2.1",
|
||||
"domain": "https://3.0.2.1",
|
||||
"port": 443,
|
||||
"interval": 7200,
|
||||
"callback": "https://yourSMSurl"
|
||||
}
|
||||
],
|
||||
"watchdog": [
|
||||
{
|
||||
"name": "cpu high warning,exclude username s01",
|
||||
"rule": "cpu>90&load_1>5&username!='s01'",
|
||||
"interval": 600,
|
||||
"callback": "https://yourSMSurl"
|
||||
},
|
||||
{
|
||||
"name": "memory high warning, exclude less than 1GB vps",
|
||||
"rule": "(memory_used/memory_total)*100>90&memory_total>1048576",
|
||||
"interval": 300,
|
||||
"callback": "https://yourSMSurl"
|
||||
},
|
||||
{
|
||||
"name": "offline warning",
|
||||
"rule": "online4=0&online6=0",
|
||||
"interval": 600,
|
||||
"callback": "https://yourSMSurl"
|
||||
},
|
||||
{
|
||||
"name": "ddcc attack,limit type Oracle",
|
||||
"rule": "tcp_count>600&type='Oracle'",
|
||||
"interval": 300,
|
||||
"callback": "https://yourSMSurl"
|
||||
},
|
||||
{
|
||||
"name": "month 999GB traffic warning",
|
||||
"rule": "(network_out-last_network_out)/1024/1024/1024>999",
|
||||
"interval": 3600,
|
||||
"callback": "https://yourSMSurl"
|
||||
},
|
||||
{
|
||||
"name": "aliyun china free 18GB traffic warning",
|
||||
"rule": "(network_out-last_network_out)/1024/1024/1024>18&(username='aliyun1'|username='aliyun2')",
|
||||
"interval": 3600,
|
||||
"callback": "https://yourSMSurl"
|
||||
},
|
||||
{
|
||||
"name": "packet loss rate warning",
|
||||
"rule": "(ping_10010>10|ping_189>10|ping_10086>10)&(host='sgp'|host='qqhk'|host='hk-21-x'|host='hk-31-x')",
|
||||
"interval": 3600,
|
||||
"callback": "https://yourSMSurl"
|
||||
},
|
||||
{
|
||||
"name": "you can parse an expression combining any known field",
|
||||
"rule": "load_5>3",
|
||||
"interval": 900,
|
||||
"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;
|
||||
}
|
||||
}
|
||||
+46066
File diff suppressed because it is too large
Load Diff
+17
-13
@@ -131,13 +131,15 @@ static int new_value
|
||||
|
||||
values_size = sizeof (*value->u.object.values) * value->u.object.length;
|
||||
|
||||
if (! ((*(void **) &value->u.object.values) = json_alloc
|
||||
(state, values_size + ((unsigned long) value->u.object.values), 0)) )
|
||||
void *tmp_alloc = json_alloc(state, values_size + ((unsigned long) value->u.object.values), 0);
|
||||
if (!tmp_alloc)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
value->_reserved.object_mem = (*(char **) &value->u.object.values) + values_size;
|
||||
/* 避免违反严格别名:通过中间变量复制 */
|
||||
memcpy(&value->u.object.values, &tmp_alloc, sizeof(void*));
|
||||
char *obj_mem = (char*)value->u.object.values + values_size;
|
||||
memcpy(&value->_reserved.object_mem, &obj_mem, sizeof(char*));
|
||||
|
||||
value->u.object.length = 0;
|
||||
break;
|
||||
@@ -361,16 +363,18 @@ json_value * json_parse_ex (json_settings * settings,
|
||||
case json_object:
|
||||
|
||||
if (state.first_pass)
|
||||
(*(json_char **) &top->u.object.values) += string_length + 1;
|
||||
{
|
||||
json_char *adv = (json_char*)top->u.object.values;
|
||||
adv += string_length + 1;
|
||||
memcpy(&top->u.object.values, &adv, sizeof(json_char*));
|
||||
}
|
||||
else
|
||||
{
|
||||
top->u.object.values [top->u.object.length].name
|
||||
= (json_char *) top->_reserved.object_mem;
|
||||
|
||||
top->u.object.values [top->u.object.length].name_length
|
||||
= string_length;
|
||||
|
||||
(*(json_char **) &top->_reserved.object_mem) += string_length + 1;
|
||||
{
|
||||
top->u.object.values[top->u.object.length].name = (json_char *)top->_reserved.object_mem;
|
||||
top->u.object.values[top->u.object.length].name_length = string_length;
|
||||
json_char *adv2 = (json_char*)top->_reserved.object_mem;
|
||||
adv2 += string_length + 1;
|
||||
memcpy(&top->_reserved.object_mem, &adv2, sizeof(json_char*));
|
||||
}
|
||||
|
||||
flags |= flag_seek_value | flag_need_colon;
|
||||
|
||||
+565
-12
@@ -7,6 +7,189 @@
|
||||
#include <json.h>
|
||||
#include "server.h"
|
||||
#include "main.h"
|
||||
#include "exprtk.hpp"
|
||||
#include "curl/curl.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string>
|
||||
|
||||
// 全局运行标志(需在 SSLCheckThread 定义前初始化)
|
||||
static volatile int gs_Running = 1;
|
||||
static volatile int gs_ReloadConfig = 0;
|
||||
|
||||
static int64_t ParseOpenSSLEnddate(const char *line)
|
||||
{
|
||||
// line format: notAfter=Aug 12 23:59:59 2025 GMT
|
||||
const char *p = strstr(line, "notAfter=");
|
||||
if(!p) return 0;
|
||||
p += 9;
|
||||
struct tm tmv; memset(&tmv,0,sizeof(tmv));
|
||||
char month[4]={0};
|
||||
int day, hour, min, sec, year;
|
||||
if(sscanf(p, "%3s %d %d:%d:%d %d GMT", month, &day, &hour, &min, &sec, &year)!=6) return 0;
|
||||
const char *months="JanFebMarAprMayJunJulAugSepOctNovDec";
|
||||
const char *mpos = strstr(months, month);
|
||||
if(!mpos) return 0;
|
||||
int mon = (int)((mpos - months)/3);
|
||||
tmv.tm_year = year - 1900;
|
||||
tmv.tm_mon = mon;
|
||||
tmv.tm_mday = day;
|
||||
tmv.tm_hour = hour; tmv.tm_min = min; tmv.tm_sec = sec;
|
||||
time_t t = timegm(&tmv);
|
||||
return (int64_t)t;
|
||||
}
|
||||
|
||||
struct SSLCheckThreadData { CMain *pMain; };
|
||||
static void SSLCheckThread(void *pUser)
|
||||
{
|
||||
SSLCheckThreadData *pData = (SSLCheckThreadData*)pUser;
|
||||
while(gs_Running){
|
||||
for(int i=0;i<NET_MAX_CLIENTS;i++){
|
||||
if(!pData->pMain->SSLCert(i) || !strcmp(pData->pMain->SSLCert(i)->m_aName, "NULL")) break;
|
||||
CMain::CSSLCerts *cert = pData->pMain->SSLCert(i);
|
||||
time_t nowt = time(0);
|
||||
if(cert->m_aLastCheck !=0 && (nowt - cert->m_aLastCheck) < cert->m_aInterval) continue;
|
||||
cert->m_aLastCheck = nowt;
|
||||
char cmd[1024];
|
||||
// 说明: 通过 s_client 获取证书,再用 x509 解析到期时间;统一屏蔽 stderr 以防握手失败/非 TLS 端口时刷屏。
|
||||
// 若配置中写成 https://domain/path 则需要清洗。
|
||||
char cleanHost[256];
|
||||
str_copy(cleanHost, cert->m_aDomain, sizeof(cleanHost));
|
||||
// 去协议
|
||||
if(!strncasecmp(cleanHost, "https://", 8)) memmove(cleanHost, cleanHost+8, strlen(cleanHost+8)+1);
|
||||
else if(!strncasecmp(cleanHost, "http://", 7)) memmove(cleanHost, cleanHost+7, strlen(cleanHost+7)+1);
|
||||
// 去路径
|
||||
char *slash = strchr(cleanHost, '/'); if(slash) *slash='\0';
|
||||
// 若含 :port 再截取主机部分(端口由配置提供)
|
||||
char *colon = strchr(cleanHost, ':'); if(colon) *colon='\0';
|
||||
int n = snprintf(cmd,sizeof(cmd),"echo | openssl s_client -servername %s -connect %s:%d </dev/null 2>/dev/null | openssl x509 -noout -enddate -text 2>/dev/null", cleanHost, cleanHost, cert->m_aPort);
|
||||
if(n <= 0 || n >= (int)sizeof(cmd)) continue; // 避免截断执行
|
||||
FILE *fp = popen(cmd, "r");
|
||||
if(!fp) continue;
|
||||
char line[1024]={0};
|
||||
int foundEnddate=0;
|
||||
int mismatch = 1; // 默认视为不匹配,发现任一匹配域名再置0
|
||||
int haveNames = 0;
|
||||
// 将目标域名转为小写
|
||||
char target[256]; str_copy(target, cleanHost, sizeof(target));
|
||||
for(char *p=target; *p; ++p) *p=tolower(*p);
|
||||
while(fgets(line,sizeof(line),fp)){
|
||||
if(!foundEnddate){
|
||||
int64_t expire = ParseOpenSSLEnddate(line);
|
||||
if(expire>0){ cert->m_aExpireTS = expire; foundEnddate=1; }
|
||||
}
|
||||
// 解析 subjectAltName
|
||||
// 解析 Subject 中的 CN(备用)
|
||||
char *subj = strstr(line, "Subject:");
|
||||
if(subj){
|
||||
char *cn = strstr(subj, " CN=");
|
||||
if(cn){
|
||||
cn += 4; // 跳过 ' CN='
|
||||
char name[256]={0}; int ni=0;
|
||||
while(*cn && *cn!='/' && *cn!=',' && *cn!='\n' && ni<(int)sizeof(name)-1){ name[ni++]=*cn++; }
|
||||
name[ni]='\0';
|
||||
while(ni>0 && (name[ni-1]==' '||name[ni-1]=='\r'||name[ni-1]=='\t')){ name[--ni]='\0'; }
|
||||
for(char *q=name; *q; ++q) *q=tolower(*q);
|
||||
if(ni>0){
|
||||
haveNames=1;
|
||||
int match=0;
|
||||
if(name[0]=='*' && name[1]=='.'){
|
||||
const char *sub = strchr(target,'.');
|
||||
if(sub && !strcmp(sub+1, name+2)) match=1;
|
||||
}else if(!strcmp(name,target)) match=1;
|
||||
if(match){ mismatch=0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if(strstr(line, "DNS:")){
|
||||
char *p = line;
|
||||
while((p = strstr(p, "DNS:"))){
|
||||
p += 4; while(*p==' '){p++;}
|
||||
char name[256]={0}; int ni=0;
|
||||
while(*p && *p!=',' && *p!='\n' && ni<(int)sizeof(name)-1){ name[ni++]=*p++; }
|
||||
name[ni]='\0';
|
||||
// 去空白
|
||||
while(ni>0 && (name[ni-1]==' '||name[ni-1]=='\r'||name[ni-1]=='\t')){ name[--ni]='\0'; }
|
||||
for(char *q=name; *q; ++q) *q=tolower(*q);
|
||||
haveNames=1;
|
||||
// 通配符匹配 *.example.com
|
||||
int match=0;
|
||||
if(name[0]=='*' && name[1]=='.'){
|
||||
const char *sub = strchr(target,'.');
|
||||
if(sub && !strcmp(sub+1, name+2)) match=1;
|
||||
}else if(!strcmp(name,target)) match=1;
|
||||
if(match){ mismatch=0; goto names_done; }
|
||||
}
|
||||
}
|
||||
}
|
||||
names_done:
|
||||
pclose(fp);
|
||||
if(haveNames){ cert->m_aHostnameMismatch = mismatch ? 1 : 0; }
|
||||
else { /* 未能提取任何域名,保留原状态,不触发误报 */ }
|
||||
// 告警: 仅在不匹配且 24h 冷却
|
||||
if(cert->m_aHostnameMismatch==1){
|
||||
if(cert->m_aLastAlarmMismatch==0 || nowt - cert->m_aLastAlarmMismatch > 24*3600){
|
||||
if(strlen(cert->m_aCallback)>0){
|
||||
CURL *curl = curl_easy_init();
|
||||
if(curl){
|
||||
char msg[1024];
|
||||
snprintf(msg,sizeof(msg),"【SSL证书域名不匹配】%s(%s) 证书域名与配置不一致", cert->m_aName, cert->m_aDomain);
|
||||
char *enc = curl_easy_escape(curl,msg,0);
|
||||
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_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "signature=ServerStatusSSL");
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 6L);
|
||||
curl_easy_perform(curl);
|
||||
if(enc) curl_free(enc);
|
||||
curl_easy_cleanup(curl);
|
||||
}
|
||||
}
|
||||
cert->m_aLastAlarmMismatch = nowt;
|
||||
}
|
||||
}
|
||||
// alarm logic
|
||||
if(cert->m_aExpireTS>0){
|
||||
// 剩余天数: 向下取整 (floor) —— 与 JSON expire_days 保持一致,用于阈值分桶和消息显示
|
||||
int64_t secsLeft = cert->m_aExpireTS - nowt;
|
||||
int days = (int)(secsLeft/86400);
|
||||
int64_t *lastAlarm = NULL; int need=0;
|
||||
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(need && strlen(cert->m_aCallback)>0){
|
||||
CURL *curl = curl_easy_init();
|
||||
if(curl){
|
||||
char msg[1024];
|
||||
char timebuf[32];
|
||||
time_t expt = (time_t)cert->m_aExpireTS;
|
||||
strftime(timebuf,sizeof(timebuf),"%Y-%m-%d %H:%M:%S", gmtime(&expt));
|
||||
// 使用 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 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_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "signature=ServerStatusSSL");
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 6L);
|
||||
curl_easy_perform(curl);
|
||||
if(enc) curl_free(enc);
|
||||
curl_easy_cleanup(curl);
|
||||
}
|
||||
*lastAlarm = nowt;
|
||||
}
|
||||
}
|
||||
}
|
||||
thread_sleep(5000);
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(CONF_FAMILY_UNIX)
|
||||
#include <signal.h>
|
||||
@@ -16,9 +199,6 @@
|
||||
#define PRId64 "I64d"
|
||||
#endif
|
||||
|
||||
static volatile int gs_Running = 1;
|
||||
static volatile int gs_ReloadConfig = 0;
|
||||
|
||||
static void ExitFunc(int Signal)
|
||||
{
|
||||
printf("[EXIT] Caught signal %d\n", Signal);
|
||||
@@ -90,6 +270,18 @@ void CMain::OnNewClient(int ClientNetID, int ClientID)
|
||||
Client(ClientID)->m_Stats.m_Online4 = true;
|
||||
else if(Client(ClientID)->m_ClientNetType == NETTYPE_IPV6)
|
||||
Client(ClientID)->m_Stats.m_Online6 = true;
|
||||
|
||||
// Send monitor to client
|
||||
// support by cpp.la
|
||||
int ID = 0;
|
||||
char monitorBuffer[2048];
|
||||
while (strcmp(Monitors(ID)->m_aName, "NULL"))
|
||||
{
|
||||
memset(monitorBuffer, 0, sizeof(monitorBuffer));
|
||||
sprintf(monitorBuffer, "{\"name\":\"%s\",\"host\":\"%s\",\"interval\":%d,\"type\":\"%s\",\"monitor\":%d}", Monitors(ID)->m_aName, Monitors(ID)->m_aHost, Monitors(ID)->m_aInterval, Monitors(ID)->m_aType, ID);
|
||||
m_Server.Network()->Send(ClientNetID, monitorBuffer);
|
||||
ID++;
|
||||
}
|
||||
}
|
||||
|
||||
void CMain::OnDelClient(int ClientNetID)
|
||||
@@ -103,6 +295,10 @@ void CMain::OnDelClient(int ClientNetID)
|
||||
Client(ClientID)->m_ClientNetType = NETTYPE_INVALID;
|
||||
mem_zero(&Client(ClientID)->m_Stats, sizeof(CClient::CStats));
|
||||
}
|
||||
m_OfflineAlarmThreadData.pClients = m_aClients;
|
||||
m_OfflineAlarmThreadData.pWatchDogs = m_aCWatchDogs;
|
||||
m_OfflineAlarmThreadData.m_ReloadRequired = ClientID;
|
||||
thread_create(offlineAlarmThread, &m_OfflineAlarmThreadData);
|
||||
}
|
||||
|
||||
int CMain::HandleMessage(int ClientNetID, char *pMessage)
|
||||
@@ -190,6 +386,23 @@ int CMain::HandleMessage(int ClientNetID, char *pMessage)
|
||||
pClient->m_Stats.m_Online6 = rStart["online6"].u.boolean;
|
||||
if(rStart["custom"].type == json_string)
|
||||
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
|
||||
WatchdogMessage(ClientNetID,
|
||||
pClient->m_Stats.m_Load_1, pClient->m_Stats.m_Load_5, pClient->m_Stats.m_Load_15,
|
||||
pClient->m_Stats.m_ping_10010, pClient->m_Stats.m_ping_189, pClient->m_Stats.m_ping_10086,
|
||||
pClient->m_Stats.m_time_10010, pClient->m_Stats.m_time_189, pClient->m_Stats.m_time_10086,
|
||||
pClient->m_Stats.m_tcpCount, pClient->m_Stats.m_udpCount, pClient->m_Stats.m_processCount,
|
||||
pClient->m_Stats.m_threadCount, pClient->m_Stats.m_NetworkRx, pClient->m_Stats.m_NetworkTx,
|
||||
pClient->m_Stats.m_NetworkIN, pClient->m_Stats.m_NetworkOUT,
|
||||
pClient->m_LastNetworkIN, pClient->m_LastNetworkOUT, pClient->m_Stats.m_MemTotal,
|
||||
pClient->m_Stats.m_MemUsed, pClient->m_Stats.m_SwapTotal, pClient->m_Stats.m_SwapUsed,
|
||||
pClient->m_Stats.m_HDDTotal, pClient->m_Stats.m_HDDUsed, pClient->m_Stats.m_IORead,
|
||||
pClient->m_Stats.m_IOWrite, pClient->m_Stats.m_CPU, pClient->m_Stats.m_Online4,
|
||||
pClient->m_Stats.m_Online6);
|
||||
|
||||
if(m_Config.m_Verbose)
|
||||
{
|
||||
@@ -234,6 +447,151 @@ int CMain::HandleMessage(int ClientNetID, char *pMessage)
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
void CMain::WatchdogMessage(int ClientNetID, double load_1, double load_5, double load_15, double ping_10010, double ping_189, double ping_10086,
|
||||
double time_10010, double time_189, double time_10086, double tcp_count, double udp_count, double process_count, double thread_count,
|
||||
double network_rx, double network_tx, double network_in, double network_out, double last_network_in, double last_network_out, double memory_total, double memory_used,
|
||||
double swap_total, double swap_used, double hdd_total, double hdd_used, double io_read, double io_write, double cpu,
|
||||
double online4, double online6)
|
||||
{
|
||||
int ID = 0;
|
||||
while (strcmp(Watchdog(ID)->m_aName, "NULL"))
|
||||
{
|
||||
// Exprtk库默认使用窄字符类型,但可能会出现中文等Unicode字符无法正确解析的问题。
|
||||
// todo: 为解决此问题,可以使用宽字符类型替换Exprtk库中默认的窄字符类型。
|
||||
// #include <string>
|
||||
// #include <vector>
|
||||
// #include <exprtk.hpp>
|
||||
// typedef exprtk::expression<wchar_t> expression_type;
|
||||
// typedef exprtk::parser<wchar_t> parser_type;
|
||||
// int main()
|
||||
// {
|
||||
// std::wstring expression_string = L"sin(x)";
|
||||
// expression_type expression;
|
||||
// parser_type parser;
|
||||
// parser.compile(expression_string, expression);
|
||||
// double x = 3.14;
|
||||
// double result = expression.value();
|
||||
// return 0;
|
||||
// }
|
||||
typedef exprtk::symbol_table<double> symbol_table_t;
|
||||
typedef exprtk::expression<double> expression_t;
|
||||
typedef exprtk::parser<double> parser_t;
|
||||
const std::string expression_string = Watchdog(ID)->m_aRule;
|
||||
int ClientID = ClientNetToClient(ClientNetID);
|
||||
if(ClientID < 0 || ClientID >= NET_MAX_CLIENTS) {
|
||||
ID++;
|
||||
continue; // 无效客户端,跳过当前 watchdog 规则
|
||||
}
|
||||
std::string username = Client(ClientID)->m_aUsername;
|
||||
std::string name = Client(ClientID)->m_aName;
|
||||
std::string type = Client(ClientID)->m_aType;
|
||||
std::string host = Client(ClientID)->m_aHost;
|
||||
std::string location = Client(ClientID)->m_aLocation;
|
||||
|
||||
symbol_table_t symbol_table;
|
||||
symbol_table.add_stringvar("username", username);
|
||||
symbol_table.add_stringvar("name", name);
|
||||
symbol_table.add_stringvar("type", type);
|
||||
symbol_table.add_stringvar("host", host);
|
||||
symbol_table.add_stringvar("location", location);
|
||||
symbol_table.add_variable("load_1",load_1);
|
||||
symbol_table.add_variable("load_5",load_5);
|
||||
symbol_table.add_variable("load_15",load_15);
|
||||
symbol_table.add_variable("ping_10010",ping_10010);
|
||||
symbol_table.add_variable("ping_189",ping_189);
|
||||
symbol_table.add_variable("ping_10086",ping_10086);
|
||||
symbol_table.add_variable("time_10010",time_10010);
|
||||
symbol_table.add_variable("time_189",time_189);
|
||||
symbol_table.add_variable("time_10086",time_10086);
|
||||
symbol_table.add_variable("tcp_count",tcp_count);
|
||||
symbol_table.add_variable("udp_count",udp_count);
|
||||
symbol_table.add_variable("process_count",process_count);
|
||||
symbol_table.add_variable("thread_count",thread_count);
|
||||
symbol_table.add_variable("network_rx",network_rx);
|
||||
symbol_table.add_variable("network_tx",network_tx);
|
||||
symbol_table.add_variable("network_in",network_in);
|
||||
symbol_table.add_variable("network_out",network_out);
|
||||
symbol_table.add_variable("last_network_in",last_network_in);
|
||||
symbol_table.add_variable("last_network_out",last_network_out);
|
||||
symbol_table.add_variable("memory_total",memory_total);
|
||||
symbol_table.add_variable("memory_used",memory_used);
|
||||
symbol_table.add_variable("swap_total",swap_total);
|
||||
symbol_table.add_variable("swap_used",swap_used);
|
||||
symbol_table.add_variable("hdd_total",hdd_total);
|
||||
symbol_table.add_variable("hdd_used",hdd_used);
|
||||
symbol_table.add_variable("io_read",io_read);
|
||||
symbol_table.add_variable("io_write",io_write);
|
||||
symbol_table.add_variable("cpu",cpu);
|
||||
symbol_table.add_variable("online4",online4);
|
||||
symbol_table.add_variable("online6",online6);
|
||||
symbol_table.add_constants();
|
||||
|
||||
expression_t expression;
|
||||
expression.register_symbol_table(symbol_table);
|
||||
|
||||
parser_t parser;
|
||||
parser.compile(expression_string,expression);
|
||||
|
||||
if (expression.value() > 0)
|
||||
{
|
||||
time_t currentStamp = (long long)time(/*ago*/0);
|
||||
if ((currentStamp-Client(ClientID)->m_AlarmLastTime) > Watchdog(ID)->m_aInterval)
|
||||
{
|
||||
if (!Client(ClientID)->m_Stats.m_Online4 && !Client(ClientID)->m_Stats.m_Online6)
|
||||
{
|
||||
//休眠5分钟如果5分钟后状态发生了变更,消息不发出。
|
||||
printf("download\n");
|
||||
}
|
||||
Client(ClientID)->m_AlarmLastTime = currentStamp;
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
curl_global_init(CURL_GLOBAL_ALL);
|
||||
|
||||
curl = curl_easy_init();
|
||||
if(curl) {
|
||||
//standard time
|
||||
char standardTime[32]= { 0 };
|
||||
strftime(standardTime, sizeof(standardTime), "%Y-%m-%d %H:%M:%S",localtime(¤tStamp));
|
||||
|
||||
//url encode, Rules conflict with url special characters,eg:&, del rules, by https://cpp.la, 2023-10-09
|
||||
char encodeBuffer[2048] = { 0 };
|
||||
sprintf(encodeBuffer, "【告警名称】 %s \n\n【告警时间】 %s \n\n【用户名】 %s \n\n【节点名】 %s \n\n【虚拟化】 %s \n\n【主机名】 %s \n\n【位 置】 %s",
|
||||
Watchdog(ID)->m_aName,
|
||||
standardTime,
|
||||
username.c_str(),
|
||||
name.c_str(),
|
||||
type.c_str(),
|
||||
host.c_str(),
|
||||
location.c_str());
|
||||
char *encodeUrl = curl_easy_escape(curl, encodeBuffer, strlen(encodeBuffer));
|
||||
|
||||
//standard url
|
||||
char urlBuffer[2048] = { 0 };
|
||||
sprintf(urlBuffer, "%s%s",Watchdog(ID)->m_aCallback, encodeUrl);
|
||||
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_URL, urlBuffer);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS,"signature=ServerStatus");
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 6L);
|
||||
res = curl_easy_perform(curl);
|
||||
if(res != CURLE_OK)
|
||||
fprintf(stderr, "watchdog failed: %s\n", curl_easy_strerror(res));
|
||||
if(encodeUrl)
|
||||
curl_free(encodeUrl);
|
||||
curl_easy_cleanup(curl);
|
||||
}
|
||||
curl_global_cleanup();
|
||||
}
|
||||
}
|
||||
ID++;
|
||||
}
|
||||
}
|
||||
|
||||
void CMain::JSONUpdateThread(void *pUser)
|
||||
{
|
||||
CJSONUpdateThreadData *m_pJSONUpdateThreadData = (CJSONUpdateThreadData *)pUser;
|
||||
@@ -278,7 +636,7 @@ void CMain::JSONUpdateThread(void *pUser)
|
||||
}
|
||||
|
||||
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_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,
|
||||
@@ -288,25 +646,37 @@ 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_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_aCustom);
|
||||
pClients[i].m_Stats.m_aCustom,
|
||||
pClients[i].m_Stats.m_aOS[0] ? pClients[i].m_Stats.m_aOS : "");
|
||||
pBuf += strlen(pBuf);
|
||||
}
|
||||
else
|
||||
{
|
||||
// sava network traffic record to json when close client
|
||||
// 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",
|
||||
pClients[i].m_aName, pClients[i].m_aType, pClients[i].m_aHost, pClients[i].m_aLocation, pClients[i].m_LastNetworkIN, pClients[i].m_LastNetworkOUT);
|
||||
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_Stats.m_aOS[0] ? pClients[i].m_Stats.m_aOS : "");
|
||||
pBuf += strlen(pBuf);
|
||||
}
|
||||
}
|
||||
if(!m_pJSONUpdateThreadData->m_ReloadRequired)
|
||||
str_format(pBuf - 2, sizeof(aFileBuf) - (pBuf - aFileBuf), "\n],\n\"updated\": \"%lld\"\n}", (long long)time(/*ago*/0));
|
||||
else
|
||||
// append ssl certs data
|
||||
str_format(pBuf - 2, sizeof(aFileBuf) - (pBuf - aFileBuf), "\n],\n\"sslcerts\": [\n");
|
||||
pBuf += strlen(pBuf);
|
||||
for(int si = 0; si < NET_MAX_CLIENTS; si++)
|
||||
{
|
||||
str_format(pBuf - 2, sizeof(aFileBuf) - (pBuf - aFileBuf), "\n],\n\"updated\": \"%lld\",\n\"reload\": true\n}", (long long)time(/*ago*/0));
|
||||
m_pJSONUpdateThreadData->m_ReloadRequired--;
|
||||
if(!m_pJSONUpdateThreadData->pMain->SSLCert(si) || !strcmp(m_pJSONUpdateThreadData->pMain->SSLCert(si)->m_aName, "NULL")) break;
|
||||
int64_t expire_ts = m_pJSONUpdateThreadData->pMain->SSLCert(si)->m_aExpireTS;
|
||||
int expire_days = 0;
|
||||
if(expire_ts>0){
|
||||
int64_t nowts = (long long)time(/*ago*/0);
|
||||
expire_days = (int)((expire_ts - nowts)/86400);
|
||||
}
|
||||
str_format(pBuf, sizeof(aFileBuf) - (pBuf - aFileBuf), "{ \"name\": \"%s\", \"domain\": \"%s\", \"port\": %d, \"expire_ts\": %lld, \"expire_days\": %d, \"mismatch\": %s },\n", m_pJSONUpdateThreadData->pMain->SSLCert(si)->m_aName, m_pJSONUpdateThreadData->pMain->SSLCert(si)->m_aDomain, m_pJSONUpdateThreadData->pMain->SSLCert(si)->m_aPort, (long long)expire_ts, expire_days, m_pJSONUpdateThreadData->pMain->SSLCert(si)->m_aHostnameMismatch?"true":"false");
|
||||
pBuf += strlen(pBuf);
|
||||
}
|
||||
if(pBuf - aFileBuf >= 2) str_format(pBuf - 2, sizeof(aFileBuf) - (pBuf - aFileBuf), "\n],\n\"updated\": \"%lld\"%s\n}", (long long)time(/*ago*/0), m_pJSONUpdateThreadData->m_ReloadRequired?",\n\"reload\": true":"");
|
||||
if(m_pJSONUpdateThreadData->m_ReloadRequired) m_pJSONUpdateThreadData->m_ReloadRequired--;
|
||||
pBuf += strlen(pBuf);
|
||||
|
||||
char aJSONFileTmp[1024];
|
||||
@@ -330,6 +700,109 @@ void CMain::JSONUpdateThread(void *pUser)
|
||||
fs_rename(pConfig->m_aJSONFile, aJSONFileTmp);
|
||||
}
|
||||
|
||||
void CMain::offlineAlarmThread(void *pUser)
|
||||
{
|
||||
CJSONUpdateThreadData *m_OfflineAlarmThreadData = (CJSONUpdateThreadData *)pUser;
|
||||
CClient *pClients = m_OfflineAlarmThreadData->pClients;
|
||||
CWatchDog *pWatchDogs = m_OfflineAlarmThreadData->pWatchDogs;
|
||||
volatile short ClientID = m_OfflineAlarmThreadData->m_ReloadRequired;
|
||||
thread_sleep(25000);
|
||||
if(!pClients[ClientID].m_Connected)
|
||||
{
|
||||
int ID = 0;
|
||||
while (strcmp(pWatchDogs[ID].m_aName, "NULL"))
|
||||
{
|
||||
typedef exprtk::symbol_table<double> symbol_table_t;
|
||||
typedef exprtk::expression<double> expression_t;
|
||||
typedef exprtk::parser<double> parser_t;
|
||||
const std::string expression_string = pWatchDogs[ID].m_aRule;
|
||||
std::string username = pClients[ClientID].m_aUsername;
|
||||
std::string name = pClients[ClientID].m_aName;
|
||||
std::string type = pClients[ClientID].m_aType;
|
||||
std::string host = pClients[ClientID].m_aHost;
|
||||
std::string location = pClients[ClientID].m_aLocation;
|
||||
std::double_t online4 = pClients[ClientID].m_Stats.m_Online4;
|
||||
std::double_t online6 = pClients[ClientID].m_Stats.m_Online6;
|
||||
|
||||
symbol_table_t symbol_table;
|
||||
symbol_table.add_stringvar("username", username);
|
||||
symbol_table.add_stringvar("name", name);
|
||||
symbol_table.add_stringvar("type", type);
|
||||
symbol_table.add_stringvar("host", host);
|
||||
symbol_table.add_stringvar("location", location);
|
||||
symbol_table.add_variable("online4",online4);
|
||||
symbol_table.add_variable("online6",online6);
|
||||
symbol_table.add_constants();
|
||||
|
||||
expression_t expression;
|
||||
expression.register_symbol_table(symbol_table);
|
||||
|
||||
parser_t parser;
|
||||
parser.compile(expression_string,expression);
|
||||
|
||||
if (expression.value() > 0)
|
||||
{
|
||||
time_t currentStamp = (long long)time(/*ago*/0);
|
||||
if ((currentStamp-pClients[ClientID].m_AlarmLastTime) > pWatchDogs[ID].m_aInterval)
|
||||
{
|
||||
printf("客户端下线且超过阈值, Client disconnects and sends alert information\n");
|
||||
pClients[ClientID].m_AlarmLastTime = currentStamp;
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
curl_global_init(CURL_GLOBAL_ALL);
|
||||
|
||||
curl = curl_easy_init();
|
||||
if(curl) {
|
||||
//standard time
|
||||
char standardTime[32]= { 0 };
|
||||
strftime(standardTime, sizeof(standardTime), "%Y-%m-%d %H:%M:%S",localtime(¤tStamp));
|
||||
|
||||
//url encode, Rules conflict with url special characters,eg:&, del rules, by https://cpp.la, 2023-10-09
|
||||
char encodeBuffer[2048] = { 0 };
|
||||
sprintf(encodeBuffer, "【告警名称】 %s \n\n【告警时间】 %s \n\n【用户名】 %s \n\n【节点名】 %s \n\n【虚拟化】 %s \n\n【主机名】 %s \n\n【位 置】 %s",
|
||||
pWatchDogs[ID].m_aName,
|
||||
standardTime,
|
||||
pClients[ClientID].m_aUsername,
|
||||
pClients[ClientID].m_aName,
|
||||
pClients[ClientID].m_aType,
|
||||
pClients[ClientID].m_aHost,
|
||||
pClients[ClientID].m_aLocation);
|
||||
char *encodeUrl = curl_easy_escape(curl, encodeBuffer, strlen(encodeBuffer));
|
||||
|
||||
//standard url
|
||||
char urlBuffer[2048] = { 0 };
|
||||
sprintf(urlBuffer, "%s%s",pWatchDogs[ID].m_aCallback, encodeUrl);
|
||||
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_URL, urlBuffer);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS,"signature=ServerStatus");
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 6L);
|
||||
res = curl_easy_perform(curl);
|
||||
if(res != CURLE_OK)
|
||||
fprintf(stderr, "watchdog failed: %s\n", curl_easy_strerror(res));
|
||||
if(encodeUrl)
|
||||
curl_free(encodeUrl);
|
||||
curl_easy_cleanup(curl);
|
||||
}
|
||||
curl_global_cleanup();
|
||||
}
|
||||
else
|
||||
printf("客户端下线但未超过阈值,No alarm if the threshold is not exceeded\n");
|
||||
}
|
||||
ID++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
printf("网络波动,No alarm information is sent due to network fluctuations\n");
|
||||
}
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
int CMain::ReadConfig()
|
||||
{
|
||||
// read and parse config
|
||||
@@ -411,6 +884,81 @@ int CMain::ReadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// watch dog
|
||||
// support by: https://cpp.la
|
||||
ID = 0;
|
||||
const json_value &jStart = (*pJsonData)["watchdog"];
|
||||
if(jStart.type == json_array)
|
||||
{
|
||||
for(unsigned i = 0; i < jStart.u.array.length; i++)
|
||||
{
|
||||
if(ID < 0 || ID >= NET_MAX_CLIENTS)
|
||||
continue;
|
||||
|
||||
str_copy(Watchdog(ID)->m_aName, jStart[i]["name"].u.string.ptr, sizeof(Watchdog(ID)->m_aName));
|
||||
str_copy(Watchdog(ID)->m_aRule, jStart[i]["rule"].u.string.ptr, sizeof(Watchdog(ID)->m_aRule));
|
||||
Watchdog(ID)->m_aInterval = jStart[i]["interval"].u.integer;
|
||||
str_copy(Watchdog(ID)->m_aCallback, jStart[i]["callback"].u.string.ptr, sizeof(Watchdog(ID)->m_aCallback));
|
||||
|
||||
ID++;
|
||||
}
|
||||
str_copy(Watchdog(ID)->m_aName, "NULL", sizeof(Watchdog(ID)->m_aName));
|
||||
}
|
||||
else
|
||||
{
|
||||
str_copy(Watchdog(ID)->m_aName, "NULL", sizeof(Watchdog(ID)->m_aName));
|
||||
}
|
||||
|
||||
// monitor
|
||||
// support by: https://cpp.la
|
||||
ID = 0;
|
||||
const json_value &mStart = (*pJsonData)["monitors"];
|
||||
if(mStart.type == json_array)
|
||||
{
|
||||
for(unsigned i = 0; i < mStart.u.array.length; i++)
|
||||
{
|
||||
if(ID < 0 || ID >= NET_MAX_CLIENTS)
|
||||
continue;
|
||||
|
||||
str_copy(Monitors(ID)->m_aName, mStart[i]["name"].u.string.ptr, sizeof(Monitors(ID)->m_aName));
|
||||
str_copy(Monitors(ID)->m_aHost, mStart[i]["host"].u.string.ptr, sizeof(Monitors(ID)->m_aHost));
|
||||
Monitors(ID)->m_aInterval = mStart[i]["interval"].u.integer;
|
||||
str_copy(Monitors(ID)->m_aType, mStart[i]["type"].u.string.ptr, sizeof(Monitors(ID)->m_aType));
|
||||
|
||||
ID++;
|
||||
}
|
||||
str_copy(Monitors(ID)->m_aName, "NULL", sizeof(Monitors(ID)->m_aName));
|
||||
}
|
||||
else
|
||||
{
|
||||
str_copy(Monitors(ID)->m_aName, "NULL", sizeof(Monitors(ID)->m_aName));
|
||||
}
|
||||
|
||||
// sslcerts
|
||||
ID = 0;
|
||||
const json_value &sStart = (*pJsonData)["sslcerts"];
|
||||
if(sStart.type == json_array)
|
||||
{
|
||||
for(unsigned i = 0; i < sStart.u.array.length; i++)
|
||||
{
|
||||
if(ID < 0 || ID >= NET_MAX_CLIENTS)
|
||||
continue;
|
||||
str_copy(SSLCert(ID)->m_aName, sStart[i]["name"].u.string.ptr, sizeof(SSLCert(ID)->m_aName));
|
||||
str_copy(SSLCert(ID)->m_aDomain, sStart[i]["domain"].u.string.ptr, sizeof(SSLCert(ID)->m_aDomain));
|
||||
SSLCert(ID)->m_aPort = sStart[i]["port"].u.integer;
|
||||
SSLCert(ID)->m_aInterval = sStart[i]["interval"].u.integer;
|
||||
str_copy(SSLCert(ID)->m_aCallback, sStart[i]["callback"].u.string.ptr, sizeof(SSLCert(ID)->m_aCallback));
|
||||
SSLCert(ID)->m_aExpireTS = 0; // reset
|
||||
SSLCert(ID)->m_aLastCheck = 0;
|
||||
SSLCert(ID)->m_aLastAlarm7 = 0;
|
||||
SSLCert(ID)->m_aLastAlarm3 = 0;
|
||||
SSLCert(ID)->m_aLastAlarm1 = 0;
|
||||
ID++;
|
||||
}
|
||||
str_copy(SSLCert(ID)->m_aName, "NULL", sizeof(SSLCert(ID)->m_aName));
|
||||
}else
|
||||
str_copy(SSLCert(ID)->m_aName, "NULL", sizeof(SSLCert(ID)->m_aName));
|
||||
|
||||
// if file exists, read last network traffic record,reset m_LastNetworkIN and m_LastNetworkOUT
|
||||
// support by: https://cpp.la
|
||||
IOHANDLE nFile = io_open(m_Config.m_aJSONFile, IOFLAG_READ);
|
||||
@@ -484,7 +1032,11 @@ int CMain::Run()
|
||||
m_JSONUpdateThreadData.m_ReloadRequired = 2;
|
||||
m_JSONUpdateThreadData.pClients = m_aClients;
|
||||
m_JSONUpdateThreadData.pConfig = &m_Config;
|
||||
m_JSONUpdateThreadData.pWatchDogs = m_aCWatchDogs;
|
||||
m_JSONUpdateThreadData.pMain = this;
|
||||
void *LoadThread = thread_create(JSONUpdateThread, &m_JSONUpdateThreadData);
|
||||
// Start SSL check thread
|
||||
static SSLCheckThreadData sslData; sslData.pMain = this; thread_create(SSLCheckThread, &sslData);
|
||||
//thread_detach(LoadThread);
|
||||
|
||||
while(gs_Running)
|
||||
@@ -566,3 +1118,4 @@ int main(int argc, const char *argv[])
|
||||
|
||||
return RetVal;
|
||||
}
|
||||
|
||||
|
||||
+52
-6
@@ -38,10 +38,11 @@ class CMain
|
||||
char m_aPassword[128];
|
||||
int m_aMonthStart; //track month network traffic. by: https://cpp.la
|
||||
|
||||
int64_t m_LastNetworkIN;
|
||||
int64_t m_LastNetworkOUT;
|
||||
int64 m_TimeConnected;
|
||||
int64 m_LastUpdate;
|
||||
int64_t m_LastNetworkIN; //restore month traffic info.
|
||||
int64_t m_LastNetworkOUT; //restore month traffic info.
|
||||
int64_t m_TimeConnected;
|
||||
int64_t m_LastUpdate;
|
||||
int64_t m_AlarmLastTime; //record last alarm time.
|
||||
|
||||
struct CStats
|
||||
{
|
||||
@@ -76,20 +77,54 @@ class CMain
|
||||
int64_t m_IORead;
|
||||
int64_t m_IOWrite;
|
||||
double m_CPU;
|
||||
char m_aCustom[512];
|
||||
char m_aCustom[1024];
|
||||
// OS name reported by client (e.g. linux/windows/darwin/freebsd)
|
||||
char m_aOS[64];
|
||||
// Options
|
||||
bool m_Pong;
|
||||
} m_Stats;
|
||||
} m_aClients[NET_MAX_CLIENTS];
|
||||
|
||||
struct CWatchDog{
|
||||
char m_aName[128];
|
||||
char m_aRule[128];
|
||||
int m_aInterval;
|
||||
char m_aCallback[1024];
|
||||
} m_aCWatchDogs[NET_MAX_CLIENTS];
|
||||
|
||||
struct CMonitors{
|
||||
char m_aName[128];
|
||||
char m_aHost[128];
|
||||
int m_aInterval;
|
||||
char m_aType[128];
|
||||
} m_aCMonitors[NET_MAX_CLIENTS];
|
||||
public:
|
||||
struct CSSLCerts{
|
||||
char m_aName[128];
|
||||
char m_aDomain[256];
|
||||
int m_aPort;
|
||||
int m_aInterval; // seconds
|
||||
char m_aCallback[1024];
|
||||
int64_t m_aExpireTS; // epoch seconds cache
|
||||
int64_t m_aLastCheck; // last check time
|
||||
int64_t m_aLastAlarm7;
|
||||
int64_t m_aLastAlarm3;
|
||||
int64_t m_aLastAlarm1;
|
||||
int m_aHostnameMismatch; // 1: 域名与证书不匹配
|
||||
int64_t m_aLastAlarmMismatch; // 上次不匹配告警时间
|
||||
} m_aCSSLCerts[NET_MAX_CLIENTS];
|
||||
|
||||
struct CJSONUpdateThreadData
|
||||
{
|
||||
CClient *pClients;
|
||||
CConfig *pConfig;
|
||||
CWatchDog *pWatchDogs;
|
||||
CMain *pMain;
|
||||
volatile short m_ReloadRequired;
|
||||
} m_JSONUpdateThreadData;
|
||||
} m_JSONUpdateThreadData, m_OfflineAlarmThreadData;
|
||||
|
||||
static void JSONUpdateThread(void *pUser);
|
||||
static void offlineAlarmThread(void *pUser);
|
||||
public:
|
||||
CMain(CConfig Config);
|
||||
|
||||
@@ -99,6 +134,17 @@ public:
|
||||
int ReadConfig();
|
||||
int Run();
|
||||
|
||||
CWatchDog *Watchdog(int ruleID) { return &m_aCWatchDogs[ruleID]; }
|
||||
CMonitors *Monitors(int ruleID) { return &m_aCMonitors[ruleID]; }
|
||||
CSSLCerts *SSLCert(int ruleID) { return &m_aCSSLCerts[ruleID]; }
|
||||
|
||||
void WatchdogMessage(int ClientNetID,
|
||||
double load_1, double load_5, double load_15, double ping_10010, double ping_189, double ping_10086,
|
||||
double time_10010, double time_189, double time_10086, double tcp_count, double udp_count, double process_count, double thread_count,
|
||||
double network_rx, double network_tx, double network_in, double network_out, double last_network_in, double last_network_out,
|
||||
double memory_total, double memory_used,double swap_total, double swap_used, double hdd_total,
|
||||
double hdd_used, double io_read, double io_write, double cpu,double online4, double online6);
|
||||
|
||||
CClient *Client(int ClientID) { return &m_aClients[ClientID]; }
|
||||
CClient *ClientNet(int ClientNetID);
|
||||
const CConfig *Config() const { return &m_Config; }
|
||||
|
||||
@@ -10,7 +10,7 @@ enum
|
||||
NET_CONNSTATE_ERROR=4,
|
||||
|
||||
NET_MAX_PACKETSIZE = 1400,
|
||||
NET_MAX_CLIENTS = 256
|
||||
NET_MAX_CLIENTS = 512
|
||||
};
|
||||
|
||||
typedef int (*NETFUNC_DELCLIENT)(int ClientID, const char* pReason, void *pUser);
|
||||
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
:root{--bg:#0f1115;--bg-alt:#171a21;--border:#262a33;--text:#e2e8f0;--text-dim:#7a899d;--accent:#3b82f6;--accent-glow:#60a5fa;--danger:#ef4444;--warn:#f59e0b;--ok:#10b981;--radius:10px;--radius-sm:4px;--shadow:0 4px 12px -2px rgba(0,0,0,.4);--font:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,'Noto Sans SC',sans-serif;--trans:.25s cubic-bezier(.4,0,.2,1);--logo-start:#3b82f6;--logo-end:#2563eb;--logo-accent-grad-start:#5fa8ff;--logo-accent-grad-end:#93c5fd}
|
||||
body.light{--bg:#f6f7f9;--bg-alt:#ffffff;--border:#e2e8f0;--text:#1e293b;--text-dim:#64748b;--accent:#2563eb;--accent-glow:#3b82f6;--shadow:0 4px 20px -4px rgba(0,0,0,.08);--logo-start:#2563eb;--logo-end:#1d4ed8;--logo-accent-grad-start:#1d4ed8;--logo-accent-grad-end:#60a5fa}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%;margin:0;padding:0;font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
|
||||
html.light{background:var(--bg)}
|
||||
body,button{font-size:14px;line-height:1.35}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
a:hover{color:var(--accent-glow)}
|
||||
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:1rem;padding:.75rem 1.25rem;background:var(--bg-alt);border-bottom:1px solid var(--border)}
|
||||
.brand{font-weight:600;letter-spacing:.5px;font-size:15px}
|
||||
.nav{display:flex;gap:.5rem}
|
||||
.nav button{background:transparent;border:1px solid var(--border);color:var(--text-dim);padding:.45rem .9rem;border-radius:var(--radius-sm);cursor:pointer;display:flex;align-items:center;gap:.35rem;transition:var(--trans);font-weight:500}
|
||||
.nav button.active,.nav button:hover{color:var(--text);background:var(--accent);border-color:var(--accent);box-shadow:0 0 0 1px var(--accent-glow),0 4px 10px -2px rgba(0,0,0,.5)}
|
||||
.actions{margin-left:auto;display:flex;align-items:center;gap:.75rem}
|
||||
.actions button{background:var(--bg);border:1px solid var(--border);color:var(--text-dim);height:32px;width:38px;border-radius:8px;cursor:pointer;display:grid;place-items:center;transition:var(--trans)}
|
||||
.actions button:hover{color:var(--text);border-color:var(--accent);background:var(--accent)}
|
||||
.wrapper{max-width:1680px;margin:1.2rem auto;padding:0 1.2rem;display:flex;flex-direction:column;gap:1.25rem}
|
||||
.notice{padding:.9rem 1rem;border:1px solid var(--border);background:linear-gradient(145deg,var(--bg-alt),var(--bg));border-radius:var(--radius);display:flex;align-items:center;gap:.75rem;font-size:13px}
|
||||
.notice.info:before{content:"";width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 4px color-mix(in srgb,var(--accent) 20%,transparent)}
|
||||
.panel{display:none;flex-direction:column;gap:.75rem;animation:fade .4s ease}
|
||||
.panel.active{display:flex}
|
||||
.table-wrap{overflow:auto;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-alt);box-shadow:var(--shadow)}
|
||||
table.data{width:100%;border-collapse:separate;border-spacing:0;min-width:960px}
|
||||
table.data thead th{position:sticky;top:0;background:var(--bg-alt);font-weight:500;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);padding:.7rem .75rem;border-bottom:1px solid var(--border);white-space:nowrap}
|
||||
table.data tbody td{padding:.55rem .75rem;border-bottom:1px solid var(--border);font-size:13px;vertical-align:middle;white-space:nowrap}
|
||||
/* 防止数值变化导致列抖动:为月流量(7)/当前网络(8)/总流量(9)设置固定宽度并使用等宽数字 */
|
||||
table.data th,table.data td{font-variant-numeric:tabular-nums}
|
||||
/* 月流量(2) 左对齐;当前网络(8)/总流量(9) 居中 */
|
||||
#serversTable thead th:nth-child(2),#serversTable tbody td:nth-child(2){
|
||||
/* 月流量列:向左贴近协议(减小左 padding),同时加大右 padding 拉开与节点距离 */
|
||||
width:128px;min-width:128px;max-width:128px;font-variant-numeric:tabular-nums;letter-spacing:.3px;text-align:center;padding:0 1.05rem 0 .15rem;
|
||||
}
|
||||
/* 节点列加宽,避免被月流量胶囊视觉挤压 */
|
||||
#serversTable thead th:nth-child(3),#serversTable tbody td:nth-child(3){
|
||||
width:160px;min-width:160px;max-width:160px;
|
||||
}
|
||||
/* 协议列继续收紧右侧 padding 与固定宽度 */
|
||||
#serversTable thead th:nth-child(1),#serversTable tbody td:nth-child(1){
|
||||
padding-right:.14rem;width:78px;min-width:78px;max-width:78px; /* 扩大协议列并恢复适度间距 */
|
||||
}
|
||||
/* 让双色胶囊更靠近协议列 */
|
||||
#serversTable tbody td:nth-child(2) .caps-traffic.duo{margin-left:-6px;} /* 向协议方向微移,视觉更靠近;右侧 padding 增大避免靠近节点 */
|
||||
#serversTable thead th:nth-child(8),#serversTable tbody td:nth-child(8),
|
||||
#serversTable thead th:nth-child(9),#serversTable tbody td:nth-child(9){
|
||||
width:132px;min-width:132px;max-width:132px;font-variant-numeric:tabular-nums;letter-spacing:.3px;text-align:center;
|
||||
/* 进一步拉开与 CPU/内存/硬盘 组的视觉距离 */
|
||||
padding-right:1.95rem;
|
||||
}
|
||||
/* CPU / 内存 / 硬盘 列:居中 + 固定宽度 与仪表盘一致 */
|
||||
#serversTable thead th:nth-child(10),#serversTable tbody td:nth-child(10),
|
||||
#serversTable thead th:nth-child(11),#serversTable tbody td:nth-child(11),
|
||||
#serversTable thead th:nth-child(12),#serversTable tbody td:nth-child(12){
|
||||
width:70px;min-width:70px;max-width:70px;text-align:center;padding-left:1.1rem;padding-right:0; /* 继续右移并贴近右侧列 */
|
||||
}
|
||||
/* 月流量胶囊 */
|
||||
.caps-traffic{display:inline-flex;align-items:center;gap:6px;background:linear-gradient(145deg,var(--bg),var(--bg-alt));border:1px solid var(--border);padding:3px 12px 3px 10px;border-radius:999px;font-size:12px;line-height:1;font-weight:500;position:relative;box-shadow:0 2px 4px -2px rgba(0,0,0,.35),0 0 0 1px rgba(255,255,255,.03);}
|
||||
.caps-traffic:before{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(circle at 20% 20%,rgba(255,255,255,.06),transparent 70%);pointer-events:none;}
|
||||
.caps-traffic .io{display:inline-flex;align-items:center;gap:2px;font-variant-numeric:tabular-nums;letter-spacing:.3px;}
|
||||
.caps-traffic .io.in{color:var(--ok);}
|
||||
.caps-traffic .io.out{color:var(--accent);}
|
||||
.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 .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;}
|
||||
/* 宽度按内容自适应(不再拉伸占满列),每半边仅为其文本 + 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;}
|
||||
/* 双色胶囊配色:
|
||||
normal (默认): 左绿右蓝
|
||||
heavy (>=1000GB 总量): 黄色提醒,不再使用故障红
|
||||
*/
|
||||
/* normal 初始:淡绿色(入) + 淡蓝色(出) */
|
||||
.caps-traffic.duo.normal .half.in{background:#d1fae5;color:#065f46;} /* emerald-100 / text-emerald-800 */
|
||||
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 */
|
||||
body.light .caps-traffic.duo.normal .half.out{background:#bfdbfe;color:#1e3a8a;}
|
||||
|
||||
.caps-traffic.duo.heavy .half.in{background:#fde68a;color:#78350f;}
|
||||
body.light .caps-traffic.duo.heavy .half.in{background:#fde68a;color:#78350f;}
|
||||
.caps-traffic.duo.heavy .half.out{background:#fbbf24;color:#111827;}
|
||||
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);}
|
||||
body.light .caps-traffic.duo .half + .half{border-left:1px solid rgba(0,0,0,.08);}
|
||||
.caps-traffic.duo.sm .half{padding:1px 4px;font-size:10px;min-width:0;}
|
||||
table.data tbody tr:last-child td{border-bottom:none}
|
||||
table.data tbody tr:hover{background:rgba(255,255,255,.04)}
|
||||
.badge{display:inline-block;padding:2px 6px;font-size:11px;border-radius:12px;font-weight:500;line-height:1.2;background:var(--bg);border:1px solid var(--border);color:var(--text-dim)}
|
||||
.badge.ok{background:rgba(16,185,129,.15);color:var(--ok);border-color:rgba(16,185,129,.3)}
|
||||
.badge.warn{background:rgba(245,158,11,.15);color:var(--warn);border-color:rgba(245,158,11,.4)}
|
||||
.badge.err{background:rgba(239,68,68,.15);color:var(--danger);border-color:rgba(239,68,68,.4)}
|
||||
.footer{margin:2rem 0 2.5rem;display:flex;align-items:center;justify-content:center;gap:.5rem;font-size:12px;color:var(--text-dim)}
|
||||
.footer a{color:var(--text-dim)}
|
||||
.footer a:hover{color:var(--accent)}
|
||||
.muted{color:var(--text-dim)}
|
||||
/* 旧状态文字样式已不再使用(采用 pill) */
|
||||
@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)}}
|
||||
|
||||
/* 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-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-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-content{font-size:13px;line-height:1.5;display:grid;gap:.6rem}
|
||||
.kv{display:flex;justify-content:space-between;gap:1rem;padding:.5rem .75rem;background:linear-gradient(145deg,var(--bg),var(--bg-alt));border:1px solid var(--border);border-radius:10px}
|
||||
.kv span{white-space:nowrap}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
|
||||
/* 资源使用百分比色彩标签 */
|
||||
/* 回退:移除资源使用百分比彩色标签样式 */
|
||||
|
||||
/* 详情弹窗三列信息行 */
|
||||
|
||||
/* 旧 spark 样式已移除,现使用半圆仪表盘 */
|
||||
/* 全圆旧样式(保留以便回退) */
|
||||
.gauge{--p:0;--col:var(--accent);width:74px;height:74px;position:relative;display:grid;place-items:center;font-size:11px;font-family:ui-monospace,monospace;font-weight:600;color:var(--text);}
|
||||
.gauge:before{content:"";position:absolute;inset:0;border-radius:50%;background:conic-gradient(var(--col) calc(var(--p)*1turn),rgba(255,255,255,0.06) 0);mask:radial-gradient(circle at 50% 50%,transparent 58%,#000 59%);-webkit-mask:radial-gradient(circle at 50% 50%,transparent 58%,#000 59%);border:1px solid var(--border);box-shadow:0 2px 6px -2px rgba(0,0,0,.4),0 0 0 1px rgba(255,255,255,.05);}
|
||||
.gauge span{position:relative;z-index:1}
|
||||
|
||||
/* 半圆仪表盘 */
|
||||
.gauge-half{--p:0;width:60px;height:34px;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;font-family:ui-monospace,monospace;font-size:10px;font-weight:600;gap:0;color:var(--text);}
|
||||
.gauge-half svg{width:100%;height:28px;overflow:visible;}
|
||||
.gauge-half path{fill:none;stroke-linecap:round;}
|
||||
/* 基础颜色 (暗色/亮色自动过渡) */
|
||||
.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[data-type=mem] path.arc{--gauge-base:#10b981}
|
||||
.gauge-half[data-type=hdd] path.arc{--gauge-base:#06b6d4}
|
||||
/* 阈值颜色:CPU>=75%、内存>=80%、硬盘>=85% 警告黄,>=90% 危险红 */
|
||||
.gauge-half[data-warn] path.arc{stroke:var(--warn)}
|
||||
.gauge-half[data-bad] path.arc{stroke:var(--danger)}
|
||||
/* 指针:以中心(50,50)为原点旋转;半圆角度范围 180deg -> 从 180deg (左) 到 0deg(右) */
|
||||
.gauge-half span{line-height:1;position:relative;top:-6px;font-size:12px;}
|
||||
/* 亮色模式细化对比度 */
|
||||
body.light .gauge-half path.track{stroke:color-mix(in srgb,var(--text-dim) 28%,transparent)}
|
||||
body.light .gauge-half path.arc{filter:none}
|
||||
body.light .gauge-half .needle{background:linear-gradient(var(--text),var(--text-dim))}
|
||||
|
||||
/* status pill */
|
||||
.pill{display:inline-block;padding:2px 8px;font-size:12px;font-weight:600;border-radius:999px;letter-spacing:.45px;min-width:48px;text-align:center;line-height:1.25;border:0;box-shadow:0 2px 4px -1px rgba(0,0,0,.4),0 0 0 1px rgba(255,255,255,.04);transition:var(--trans);color:#fff}
|
||||
.pill.on{background:var(--ok)}
|
||||
.pill.off{background:var(--danger)}
|
||||
.pill.on:hover{filter:brightness(1.1)}
|
||||
.pill.off:hover{filter:brightness(1.1)}
|
||||
|
||||
/* buckets CU/CT/CM (simple version) */
|
||||
.buckets{display:flex;align-items:flex-end;gap:8px;min-width:140px}
|
||||
.bucket{position:relative;width:26px;height:34px;background:linear-gradient(145deg,var(--bg),var(--bg-alt));border:1px solid var(--border);border-radius:8px;padding:4px 4px 16px;box-sizing:border-box;display:flex;justify-content:flex-end}
|
||||
.bucket span{display:block;width:100%;background:var(--accent);border-radius:4px 4px 6px 6px;height:var(--h);align-self:flex-end;transition:height .8s cubic-bezier(.4,0,.2,1),background .3s}
|
||||
.bucket[data-lv=warn] span{background:var(--warn)}
|
||||
.bucket[data-lv=bad] span{background:var(--danger)}
|
||||
.bucket label{position:absolute;left:0;right:0;bottom:2px;font-size:10px;text-align:center;color:var(--text-dim);pointer-events:none}
|
||||
.bucket:hover label{color:var(--text)}
|
||||
|
||||
/* 居中联通电信移动列 */
|
||||
#serversTable thead th:last-child, #serversTable tbody td:last-child { text-align:center; }
|
||||
/* 放大第13列宽度以容纳更宽水桶 */
|
||||
#serversTable thead th:nth-child(13),#serversTable tbody td:nth-child(13){
|
||||
width:150px;min-width:150px;max-width:150px;padding-left:0;padding-right:.55rem; /* 去除左 padding 进一步贴近 */
|
||||
}
|
||||
/* 调整“总流量”表头(第9列)padding 使标题文字居中,不受正文额外右 padding 影响 */
|
||||
#serversTable thead th:nth-child(9){padding-left:.75rem;padding-right:.75rem;}
|
||||
.buckets{justify-content:center}
|
||||
|
||||
/* 响应式隐藏非关键列,保持可读性 */
|
||||
@media (max-width:1100px){
|
||||
#serversTable{min-width:100%;}
|
||||
#serversTable thead th:nth-child(3),
|
||||
#serversTable tbody td:nth-child(3), /* 节点 */
|
||||
#serversTable thead th:nth-child(4),
|
||||
#serversTable tbody td:nth-child(4), /* 虚拟化 */
|
||||
#serversTable thead th:nth-child(8),
|
||||
#serversTable tbody td:nth-child(8) /* 当前网络 */ {display:none}
|
||||
}
|
||||
@media (max-width:820px){
|
||||
#serversTable thead th:nth-child(5),
|
||||
#serversTable tbody td:nth-child(5), /* 在线 */
|
||||
#serversTable thead th:nth-child(6),
|
||||
#serversTable tbody td:nth-child(6) /* 负载 */ {display:none}
|
||||
.buckets{gap:6px;min-width:100px}
|
||||
}
|
||||
@media (max-width:640px){
|
||||
#serversTable thead th:nth-child(4),
|
||||
#serversTable tbody td:nth-child(4) /* 位置 */ {display:none}
|
||||
.topbar{flex-wrap:wrap;padding:.6rem .8rem}
|
||||
.actions{width:100%;justify-content:flex-end;margin-top:.4rem}
|
||||
.wrapper{padding:0 .7rem}
|
||||
#panel-servers .table-wrap{display:none;}
|
||||
#serversCards{display:grid!important;grid-template-columns:1fr;gap:.75rem;margin-top:.5rem;}
|
||||
/* 服务与证书移动端卡片 */
|
||||
#panel-monitors .table-wrap, #panel-ssl .table-wrap{display:none;}
|
||||
#monitorsCards,#sslCards{display:grid!important;grid-template-columns:1fr;gap:.75rem;margin-top:.5rem;}
|
||||
.modal-box{max-width:100%;border-radius:14px;padding:1rem .95rem;}
|
||||
.modal-content{max-height:65vh;overflow:auto;}
|
||||
}
|
||||
|
||||
/* SSL 表(证书)列宽与换行修正,避免复用服务器列宽导致名称与域名重叠 */
|
||||
#sslTable th,#sslTable td{white-space:nowrap;padding:.55rem .75rem;}
|
||||
#sslTable th:nth-child(1),#sslTable td:nth-child(1){width:140px;min-width:120px;}
|
||||
/* 域名允许换行以防过长挤压 */
|
||||
#sslTable th:nth-child(2),#sslTable td:nth-child(2){white-space:normal;max-width:320px;overflow-wrap:anywhere;}
|
||||
|
||||
/* === 覆盖:要求三个表 (servers / monitors / ssl) 全部改为自动宽度 === */
|
||||
/* 1. 取消全局 table.data 的 min-width 对这三个表的影响 */
|
||||
#serversTable,#monitorsTable,#sslTable{min-width:0;}
|
||||
/* 2. 统一去除之前为 serversTable 设定的列固定宽度,允许浏览器自动分配 */
|
||||
#serversTable thead th,#serversTable tbody td,
|
||||
#monitorsTable thead th,#monitorsTable tbody td,
|
||||
#sslTable thead th,#sslTable tbody td{
|
||||
width:auto!important;min-width:0!important;max-width:none!important;
|
||||
padding:.55rem .7rem;
|
||||
}
|
||||
/* 3. 允许证书域名不受 max-width 限制(如仍需换行可保留 overflow-wrap) */
|
||||
#sslTable th:nth-child(2),#sslTable td:nth-child(2){max-width:none;white-space:normal;overflow-wrap:anywhere;}
|
||||
/* 4. 取消“联通|电信|移动”列固定宽度 */
|
||||
#serversTable thead th:nth-child(13),#serversTable tbody td:nth-child(13){width:auto!important;min-width:0!important;}
|
||||
/* 5. 如需稍微限制仪表盘相关列最小可读宽度,可设定一个较小下限 (可选) -- 暂不设置,完全交由自动布局 */
|
||||
|
||||
/* === 新增:为“月流量 / 当前网络 / 总流量”三列设置最小宽度,防止内容被压缩换行或挤压 === */
|
||||
/* 目标最小宽度按示例 "111.1GB|111.1GB" 设计(13 个字符左右),取 16ch 留余量 */
|
||||
#serversTable thead th:nth-child(2),#serversTable tbody td:nth-child(2),
|
||||
#serversTable thead th:nth-child(8),#serversTable tbody td:nth-child(8),
|
||||
#serversTable thead th:nth-child(9),#serversTable tbody td:nth-child(9){
|
||||
min-width:22ch !important; /* 保留自动宽度,但不小于此值 */
|
||||
text-align:center;
|
||||
font-variant-numeric:tabular-nums;
|
||||
}
|
||||
.cards .card{border:1px solid var(--border);border-radius:12px;padding:.75rem .85rem;background:linear-gradient(145deg,var(--bg),var(--bg-alt));display:flex;flex-direction:column;gap:.45rem;position:relative;}
|
||||
.cards .card.offline{filter:saturate(.92);}
|
||||
.cards .card.high-load,
|
||||
.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);}
|
||||
.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-title{font-weight:600;font-size:.95rem;}
|
||||
.cards .tag{font-size:.65rem;padding:.15rem .4rem;border-radius:4px;background:var(--border);letter-spacing:.5px;}
|
||||
.cards .status-pill{font-size:.6rem;padding:.2rem .45rem;border-radius:999px;font-weight:500;}
|
||||
.cards .status-pill.on{background:var(--ok);color:#fff;}
|
||||
.cards .status-pill.off{background:var(--danger);color:#fff;}
|
||||
.cards .kvlist{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.35rem .75rem;font-size:.7rem;line-height:1.25;}
|
||||
.cards .kvlist div{display:flex;flex-direction:column;}
|
||||
.cards .kvlist span.key{opacity:.6;}
|
||||
.cards .buckets{margin-top:.25rem;}
|
||||
/* 证书卡片:域名告警底色(与高负载卡片风格一致) */
|
||||
.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 .kvlist .alert-domain .key{opacity:.85}
|
||||
/* 移除移动端卡片展开箭头与展开区域(已按需简化交互) */
|
||||
/* 旧移动端 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 样式 */
|
||||
.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 svg{display:block}
|
||||
.brand .logo-text{font-size:17px;font-weight:700;line-height:1;display:flex;align-items:baseline;gap:2px;letter-spacing:1px}
|
||||
.brand .logo-text .logo-accent{background:linear-gradient(90deg,var(--logo-accent-grad-start),var(--logo-accent-grad-end));-webkit-background-clip:text;background-clip:text;color:transparent;filter:drop-shadow(0 2px 4px rgba(0,0,0,.3))}
|
||||
.brand:hover .logo-mark{transform:translateY(-2px) scale(1.05)}
|
||||
.brand:hover .logo-text{color:var(--text)}
|
||||
@media (max-width:640px){.brand .logo-text{font-size:15px}.brand .logo-mark{width:30px;height:30px}}
|
||||
|
||||
/* 运维控制台增强 */
|
||||
.ops-overview{display:grid;grid-template-columns:repeat(5,minmax(140px,1fr));gap:.75rem}
|
||||
.overview-card{border:1px solid var(--border);background:var(--bg-alt);border-radius:8px;padding:.8rem .9rem;display:flex;flex-direction:column;gap:.25rem;min-height:82px}
|
||||
.overview-card .label{font-size:12px;color:var(--text-dim)}
|
||||
.overview-card .value{font-size:24px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.overview-card .hint{font-size:12px;color:var(--text-dim);line-height:1.3}
|
||||
.overview-card.ok{border-color:rgba(16,185,129,.35)}
|
||||
.overview-card.warn{border-color:rgba(245,158,11,.45)}
|
||||
.overview-card.err{border-color:rgba(239,68,68,.5)}
|
||||
.ops-toolbar{display:flex;align-items:end;gap:.75rem;flex-wrap:wrap;border:1px solid var(--border);background:var(--bg-alt);border-radius:8px;padding:.75rem}
|
||||
.search-field,.select-field{display:flex;flex-direction:column;gap:.3rem;color:var(--text-dim);font-size:12px}
|
||||
.search-field input,.select-field select,.config-form input,.config-form textarea{border:1px solid var(--border);background:var(--bg);color:var(--text);border-radius:6px;padding:0 .65rem;outline:none;transition:var(--trans)}
|
||||
.search-field input,.select-field select,.config-form input{height:36px}
|
||||
.config-form textarea{min-height:92px;resize:vertical;padding:.55rem .65rem;line-height:1.45;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.search-field input:focus,.select-field select:focus,.config-form input:focus,.config-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 18%,transparent)}
|
||||
.search-field input{min-width:280px}
|
||||
.segmented{display:inline-flex;border:1px solid var(--border);border-radius:7px;overflow:hidden;background:var(--bg)}
|
||||
.segmented button{height:36px;border:0;border-right:1px solid var(--border);background:transparent;color:var(--text-dim);padding:0 .8rem;cursor:pointer}
|
||||
.segmented button:last-child{border-right:0}
|
||||
.segmented button.active,.segmented button:hover{background:var(--accent);color:#fff}
|
||||
.icon-text,.primary-btn,.danger-btn{height:36px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);padding:0 .75rem;cursor:pointer;transition:var(--trans);font-weight:600}
|
||||
.icon-text:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.primary-btn{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
.primary-btn:hover{filter:brightness(1.08)}
|
||||
.danger-btn{background:rgba(239,68,68,.14);border-color:rgba(239,68,68,.38);color:var(--danger)}
|
||||
.danger-btn:hover{background:var(--danger);color:#fff}
|
||||
th[data-sort]{cursor:pointer;user-select:none}
|
||||
th[data-sort]:after{content:"";display:inline-block;margin-left:6px;border:4px solid transparent;border-top-color:color-mix(in srgb,var(--text-dim) 70%,transparent);transform:translateY(2px);opacity:.45}
|
||||
th[data-sort].sorted-asc:after{border-top-color:transparent;border-bottom-color:var(--accent);transform:translateY(-2px)}
|
||||
th[data-sort].sorted-desc:after{border-top-color:var(--accent);opacity:1}
|
||||
.section-head h2{margin:0;font-size:18px}
|
||||
.section-head p{margin:.25rem 0 0}
|
||||
.empty-state{border:1px dashed var(--border);border-radius:8px;background:var(--bg-alt);padding:1.25rem;color:var(--text-dim);text-align:center}
|
||||
|
||||
/* 右侧详情抽屉 */
|
||||
.drawer-backdrop{align-items:stretch;justify-content:flex-end;padding:0;background:rgba(0,0,0,.48)}
|
||||
.detail-drawer{height: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;}
|
||||
}
|
||||
Vendored
-6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
-6
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,70 +0,0 @@
|
||||
body { background: #212e36 url('../img/dark.png'); color: #dcdcdc; }
|
||||
.navbar { min-height: 40px; }
|
||||
.navbar-brand { color: #FFF !important; padding: 10px; font-size: 20px; }
|
||||
.dropdown .dropdown-toggle { padding-bottom: 10px; padding-top: 10px; }
|
||||
.dropdown-menu > li > a { color: #FFF !important; background-color: #222222 !important; }
|
||||
.dropdown-menu > li > a:hover { color: #FFF !important; background: #000 !important; }
|
||||
.dropdown-menu { background: #222 !important; background-color: #222222 !important; }
|
||||
.navbar-inverse .navbar-inner { background-color:#1B1B1B; background-image:-moz-linear-gradient(top, #222222, #111111); background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#222222), to(#111111)); background-image:-webkit-linear-gradient(top, #222222, #111111); background-image:-o-linear-gradient(top, #222222, #111111); background-image:linear-gradient(to bottom, #212e36, #212e36); background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222', endColorstr='#ff111111', GradientType=0); border-color: #252525; }
|
||||
.content { background: #212e36; padding: 20px; border-radius: 5px; border: 1px #212e36 solid; -webkit-box-shadow: 0 0px 0px rgba(0, 0, 0, 0); -moz-box-shadow: 0 0px 0px rgba(0, 0, 0, 0); box-shadow: 0 0px 0px rgba(0, 0, 0, 0); margin-bottom: 20px; }
|
||||
.table { background: #363b40; margin-bottom: 0; border-collapse: collapse; border-radius: 3px; }
|
||||
.table th { text-align: center; }
|
||||
.table-striped tbody > tr.even > td, .table-striped tbody > tr.even > th { background-color: #212e36; }
|
||||
.table-striped tbody > tr.odd > td, .table-striped tbody > tr.odd > th { background-color: #212e36; }
|
||||
.table td { text-align: center; border-color: #2F2F2F; }
|
||||
.progress { margin-bottom: 0; background: #363b40; }
|
||||
.table-hover > tbody > tr:hover > td { background: #414141; }
|
||||
tr.even.expandRow > :hover { background: #212e36 !important; }
|
||||
tr.odd.expandRow > :hover { background: #212e36 !important; }
|
||||
.expandRow > td { padding: 0 !important; border-top: 0px !important; }
|
||||
#month_traffic { min-width: 85px; max-width: 95px;}
|
||||
#network { min-width: 115px; }
|
||||
#cpu, #ram, #hdd { min-width: 45px; max-width: 90px; }
|
||||
#ping { max-width: 95px; }
|
||||
|
||||
@media only screen and (max-width: 1080px) {
|
||||
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
|
||||
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
|
||||
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
|
||||
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
|
||||
}
|
||||
@media only screen and (max-width: 720px) {
|
||||
body { font-size: 10px; }
|
||||
.content { padding: 0; }
|
||||
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
|
||||
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
|
||||
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
|
||||
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
|
||||
}
|
||||
@media only screen and (max-width: 620px) {
|
||||
body { font-size: 10px; }
|
||||
.content { padding: 0; }
|
||||
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
|
||||
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
|
||||
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
|
||||
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
|
||||
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
|
||||
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
|
||||
}
|
||||
@media only screen and (max-width: 533px) {
|
||||
body { font-size: 10px; }
|
||||
.content { padding: 0; }
|
||||
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
|
||||
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
|
||||
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
|
||||
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
|
||||
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
|
||||
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
|
||||
}
|
||||
@media only screen and (max-width: 450px) {
|
||||
body { font-size: 10px; }
|
||||
.content { padding: 0; }
|
||||
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
|
||||
#name, tr td:nth-child(3) { min-width: 55px; max-width: 85px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; }
|
||||
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
|
||||
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
|
||||
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
|
||||
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
|
||||
#cpu, #ram, #hdd { min-width: 25px; max-width: 50px; }
|
||||
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
body { background: #ebebeb url('../img/light.png'); }
|
||||
.navbar { min-height: 40px; }
|
||||
.navbar-brand { color: #fff; padding: 10px; font-size: 20px; }
|
||||
.dropdown .dropdown-toggle { padding-bottom: 10px; padding-top: 10px; }
|
||||
.navbar-inverse .navbar-brand { color: #fff; padding: 10px; font-size: 20px; }
|
||||
.content { background: #ffffff; padding: 20px; border-radius: 5px; border: 1px #cecece solid; -webkit-box-shadow: 0 1px 10px rgba(0, 0, 0, .1); -moz-box-shadow: 0 1px 10px rgba(0, 0, 0, .1); box-shadow: 0 1px 10px rgba(0, 0, 0, .1); margin-bottom: 20px; }
|
||||
.table { background: #ffffff; margin-bottom: 0; border-collapse: collapse; border-radius: 3px; }
|
||||
.table th, .table td { text-align: center; }
|
||||
.table-striped tbody > tr.even > td, .table-striped tbody > tr.even > th { background-color: #F9F9F9; }
|
||||
.table-striped tbody > tr.odd > td, .table-striped tbody > tr.odd > th { background-color: #FFF; }
|
||||
.progress { margin-bottom: 0; }
|
||||
.progress-bar { color: #000; }
|
||||
.table-hover > tbody > tr:hover > td { background: #E6E6E6; }
|
||||
tr.even.expandRow > :hover { background: #F9F9F9 !important; }
|
||||
tr.odd.expandRow > :hover { background: #FFF !important; }
|
||||
.expandRow > td { padding: 0 !important; border-top: 0px !important; }
|
||||
#month_traffic { min-width: 85px; max-width: 95px;}
|
||||
#network { min-width: 115px; }
|
||||
#cpu, #ram, #hdd { min-width: 45px; max-width: 90px; }
|
||||
#ping { max-width: 95px; }
|
||||
|
||||
@media only screen and (max-width: 1080px) {
|
||||
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
|
||||
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
|
||||
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
|
||||
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
|
||||
}
|
||||
@media only screen and (max-width: 720px) {
|
||||
body { font-size: 10px; }
|
||||
.content { padding: 0; }
|
||||
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
|
||||
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
|
||||
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
|
||||
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
|
||||
}
|
||||
@media only screen and (max-width: 620px) {
|
||||
body { font-size: 10px; }
|
||||
.content { padding: 0; }
|
||||
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
|
||||
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
|
||||
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
|
||||
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
|
||||
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
|
||||
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
|
||||
}
|
||||
@media only screen and (max-width: 533px) {
|
||||
body { font-size: 10px; }
|
||||
.content { padding: 0; }
|
||||
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
|
||||
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
|
||||
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
|
||||
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
|
||||
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
|
||||
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
|
||||
}
|
||||
@media only screen and (max-width: 450px) {
|
||||
body { font-size: 10px; }
|
||||
.content { padding: 0; }
|
||||
#month_traffic, tr td:nth-child(2) { display:none; visibility:hidden; }
|
||||
#name, tr td:nth-child(3) { min-width: 55px; max-width: 85px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; }
|
||||
#type, tr td:nth-child(4) { display:none; visibility:hidden; }
|
||||
#location, tr td:nth-child(5) { display:none; visibility:hidden; }
|
||||
#uptime, tr td:nth-child(6) { display:none; visibility:hidden; }
|
||||
#traffic, tr td:nth-child(9) { display:none; visibility:hidden; }
|
||||
#cpu, #ram, #hdd { min-width: 25px; max-width: 50px; }
|
||||
#ping, tr td:nth-child(13) { display:none; visibility:hidden; }
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 86" fill="none">
|
||||
<path d="M4 46H28L38 34L55 82L65 4L74 47H88M100 47H110M122 47H126"
|
||||
stroke="#111827"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
+200
-106
@@ -1,110 +1,204 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
json字段保持完整, 自行自定义前端展示
|
||||
ლ(•̀ _ •́ ლ)
|
||||
ლ(•̀ _ •́ ლ)ლ(•̀ _ •́ ლ)
|
||||
ლ(•̀ _ •́ ლ)ლ(•̀ _ •́ ლ)ლ(•̀ _ •́ ლ)
|
||||
ლ(•̀ _ •́ ლ)ლ(•̀ _ •́ ლ)
|
||||
ლ(•̀ _ •́ ლ)
|
||||
follow me, better solution for you. by:https://cpp.la
|
||||
-->
|
||||
<html>
|
||||
<head>
|
||||
<title>云监控</title>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="云监控">
|
||||
<meta name="author" content="BotoX">
|
||||
<link rel="stylesheet" href="css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="css/bootstrap-theme.min.css">
|
||||
<link rel="stylesheet" href="css/light.css" title="light">
|
||||
<link rel="stylesheet" href="css/dark.css" title="dark">
|
||||
<style>
|
||||
body {
|
||||
padding-top: 70px;
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
</style>
|
||||
<!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="js/html5shiv.js"></script>
|
||||
<script src="js/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body>
|
||||
<div role="navigation" class="navbar navbar-inverse navbar-fixed-top">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<button data-target=".navbar-collapse" data-toggle="collapse" class="navbar-toggle" type="button">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a href="#" class="navbar-brand">云监控</a>
|
||||
</div>
|
||||
<div class="navbar-collapse collapse">
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="dropdown">
|
||||
<a data-toggle="dropdown" class="dropdown-toggle" href="#">风格<b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#" onclick="setActiveStyleSheet('dark', true)">黑夜</a></li>
|
||||
<li><a href="#" onclick="setActiveStyleSheet('light', true)">白天</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div><!--/.nav-collapse -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="description" content="云监控,ServerStatus中文版,ServerStatus,ServerStatus cppla" />
|
||||
<title>云监控</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="stylesheet" href="css/app.css?v=20260630-7" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand" title="云监控">
|
||||
<span class="logo-mark" aria-hidden="true">
|
||||
<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="M4 46H28L38 34L55 82L65 4L74 47H88M100 47H110M122 47H126" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="logo-text"><span class="logo-accent">云</span>监控</span>
|
||||
</div>
|
||||
<nav class="nav" id="navTabs">
|
||||
<button data-tab="servers" class="active">主机</button>
|
||||
<button data-tab="monitors">服务</button>
|
||||
<button data-tab="ssl">证书</button>
|
||||
<button data-tab="config">配置</button>
|
||||
</nav>
|
||||
<div class="actions">
|
||||
<button id="themeToggle" title="切换主题 (当前: 自动或手动)" aria-label="切换主题">🌓</button>
|
||||
<span id="lastUpdate" class="muted">更新中...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container content">
|
||||
<div id="loading-notice">
|
||||
<noscript>
|
||||
<div class="alert alert-danger" style="text-align: center;">
|
||||
<strong>Enable JavaScript</strong> , please do it.
|
||||
</div>
|
||||
</noscript>
|
||||
<div style="text-align: center;">
|
||||
警告:如果出现此消息,请确保您已启用Javascript! <br />否则云监控主服务没启动或已关闭.
|
||||
</div>
|
||||
<p></p>
|
||||
</div>
|
||||
<table class="table table-striped table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="online_status" style="text-align: center;">🔗协议</th>
|
||||
<th id="month_traffic" style="text-align: center;">📊月流量↓|↑</th>
|
||||
<th id="name">📌节点</th>
|
||||
<th id="type">🗂️虚拟化</th>
|
||||
<th id="location">🌍位置</th>
|
||||
<th id="uptime">⏱️在线</th>
|
||||
<th id="load">负载</th>
|
||||
<th id="network">🚦网络↓|↑</th>
|
||||
<th id="traffic">📋总流量↓|↑</th>
|
||||
<th id="cpu">🎯核芯</th>
|
||||
<th id="ram">⚡️内存</th>
|
||||
<th id="hdd">💾硬盘</th>
|
||||
<th id="ping">🌐CU|CT|CM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="servers">
|
||||
<!-- Servers here \o/ -->
|
||||
</tbody>
|
||||
</table>
|
||||
<br />
|
||||
<div id="updated">Updating...</div>
|
||||
</div>
|
||||
<main class="wrapper">
|
||||
<div id="notice" class="notice info">初始化中...</div>
|
||||
|
||||
<div class="container">
|
||||
<p style="text-align: center; font-size: 10px;">
|
||||
<a href="https://github.com/cppla/ServerStatus">ServerStatus中文版</a>
|
||||
</p>
|
||||
</div>
|
||||
<script src="js/jquery.min.js"></script>
|
||||
<script src="js/bootstrap.min.js"></script>
|
||||
<script src="js/serverstatus.js"></script>
|
||||
</body>
|
||||
<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="主机">
|
||||
<div class="table-wrap">
|
||||
<table class="data" id="serversTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="status">协议</th>
|
||||
<th data-sort="traffic">月流量 ↓|↑</th>
|
||||
<th data-sort="name">节点</th>
|
||||
<th data-sort="type">虚拟化</th>
|
||||
<th data-sort="location">位置</th>
|
||||
<th>在线</th>
|
||||
<th data-sort="load">负载</th>
|
||||
<th>当前网络 ↓|↑</th>
|
||||
<th>总流量 ↓|↑</th>
|
||||
<th data-sort="cpu">CPU</th>
|
||||
<th data-sort="memory">内存</th>
|
||||
<th data-sort="hdd">硬盘</th>
|
||||
<th data-sort="loss" style="text-align:center;">联通|电信|移动</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="serversBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 移动端卡片布局 -->
|
||||
<div id="serversCards" class="cards" style="display:none;"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-monitors" class="panel" aria-labelledby="服务">
|
||||
<div class="table-wrap">
|
||||
<table class="data" id="monitorsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>协议</th>
|
||||
<th>监测节点</th>
|
||||
<th>监测位置</th>
|
||||
<th>监测内容</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="monitorsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 移动端卡片布局 (服务) -->
|
||||
<div id="monitorsCards" class="cards" style="display:none;"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-ssl" class="panel" aria-labelledby="证书">
|
||||
<div class="table-wrap">
|
||||
<table class="data" id="sslTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>域名</th>
|
||||
<th>端口</th>
|
||||
<th>剩余(天)</th>
|
||||
<th>到期(UTC)</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sslBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 移动端卡片布局 (证书) -->
|
||||
<div id="sslCards" class="cards" style="display:none;"></div>
|
||||
</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>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<div id="detailModal" class="modal-backdrop drawer-backdrop" style="display:none;">
|
||||
<aside class="modal-box detail-drawer" role="dialog" aria-modal="true" aria-labelledby="detailTitle">
|
||||
<button class="modal-close" id="detailClose" aria-label="关闭">×</button>
|
||||
<h3 id="detailTitle" class="modal-title">节点详情</h3>
|
||||
<div id="detailContent" class="modal-content">加载中...</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<a href="https://github.com/cppla/ServerStatus" target="_blank" rel="noopener">ServerStatus中文版</a>
|
||||
</footer>
|
||||
|
||||
<script src="js/app.js?v=20260630-7" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+875
@@ -0,0 +1,875 @@
|
||||
const S = {
|
||||
updated: 0,
|
||||
servers: [],
|
||||
ssl: [],
|
||||
hist: {},
|
||||
loadHist: {},
|
||||
openDetailKey: null,
|
||||
activeTab: 'servers',
|
||||
osOptionsSignature: '',
|
||||
suppressStatsReloadUntil: 0,
|
||||
filters: { query: '', status: 'all', os: 'all', sort: 'name', dir: 'desc' },
|
||||
admin: {
|
||||
token: localStorage.getItem('serverstatusAdminToken') || '',
|
||||
enabled: false,
|
||||
connected: false,
|
||||
config: null,
|
||||
selectedType: 'servers',
|
||||
selectedIndex: -1,
|
||||
saving: false
|
||||
}
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
||||
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
||||
const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
||||
|
||||
function humanMinMBFromKB(kb){ return humanBytes(num(kb) * 1000, 1000 * 1000); }
|
||||
function humanMinMBFromMB(mb){ return humanBytes(num(mb) * 1000 * 1000, 1000 * 1000); }
|
||||
function humanMinMBFromB(bytes){ return humanBytes(num(bytes), 1000 * 1000); }
|
||||
function humanMinKBFromB(bytes){ return humanBytes(num(bytes), 1000); }
|
||||
function humanBytes(bytes, minUnit){
|
||||
if(!Number.isFinite(bytes)) return '-';
|
||||
const units = ['B','KB','MB','GB','TB','PB'];
|
||||
let index = minUnit >= 1000 * 1000 ? 2 : minUnit >= 1000 ? 1 : 0;
|
||||
let divisor = Math.pow(1000, index);
|
||||
let value = Math.max(0, bytes) / divisor;
|
||||
while(value >= 1000 && index < units.length - 1){ value /= 1000; index++; }
|
||||
const out = value >= 100 ? value.toFixed(0) : value.toFixed(1);
|
||||
return out + units[index];
|
||||
}
|
||||
function humanAgo(ts){
|
||||
if(!ts) return '-';
|
||||
const sec = Math.max(0, Math.floor(Date.now() / 1000 - Number(ts)));
|
||||
if(sec < 60) return '几秒前';
|
||||
const min = Math.floor(sec / 60);
|
||||
if(min < 60) return `${min} 分钟前`;
|
||||
return `${Math.floor(min / 60)} 小时前`;
|
||||
}
|
||||
|
||||
function osClass(os){
|
||||
if(!os) return '';
|
||||
const v = String(os).toLowerCase();
|
||||
const pick = (k)=>' os-'+k;
|
||||
if(v.includes('ubuntu')) return pick('ubuntu');
|
||||
if(v.includes('debian')) return pick('debian');
|
||||
if(v.includes('centos')) return pick('centos');
|
||||
if(v.includes('rocky')) return pick('rocky');
|
||||
if(v.includes('alma')) return pick('almalinux');
|
||||
if(v.includes('arch')) return pick('arch');
|
||||
if(v.includes('alpine')) return pick('alpine');
|
||||
if(v.includes('fedora')) return pick('fedora');
|
||||
if(v.includes('rhel') || v.includes('redhat')) return pick('rhel');
|
||||
if(v.includes('suse')) return pick('suse');
|
||||
if(v.includes('amazon')) return pick('amazon');
|
||||
if(v.includes('freebsd')) return pick('freebsd');
|
||||
if(v.includes('openbsd')) return pick('openbsd');
|
||||
if(v.includes('netbsd') || v.includes('bsd')) return pick('bsd');
|
||||
if(v.includes('darwin') || v.includes('macos') || v.includes('os x') || v.includes('osx') || v.includes('apple') || v.includes('mac')) return pick('darwin');
|
||||
if(v.includes('win')) return pick('windows');
|
||||
if(v.includes('linux')) return pick('linux');
|
||||
return pick(v.replace(/[^a-z0-9_-]+/g,'-').slice(0,20));
|
||||
}
|
||||
function osLabel(os){
|
||||
if(!os) return '';
|
||||
const v = String(os).toLowerCase();
|
||||
if(v.includes('ubuntu')) return 'Ubuntu';
|
||||
if(v.includes('debian')) return 'Debian';
|
||||
if(v.includes('centos')) return 'CentOS';
|
||||
if(v.includes('rocky')) return 'Rocky Linux';
|
||||
if(v.includes('alma')) return 'AlmaLinux';
|
||||
if(v.includes('rhel') || v.includes('redhat')) return 'Red Hat Enterprise Linux';
|
||||
if(v.includes('arch')) return 'Arch Linux';
|
||||
if(v.includes('alpine')) return 'Alpine Linux';
|
||||
if(v.includes('fedora')) return 'Fedora';
|
||||
if(v.includes('amazon')) return 'Amazon Linux';
|
||||
if(v.includes('suse')) return 'SUSE Linux';
|
||||
if(v.includes('freebsd')) return 'FreeBSD';
|
||||
if(v.includes('openbsd')) return 'OpenBSD';
|
||||
if(v.includes('netbsd') || v.includes('bsd')) return 'BSD';
|
||||
if(v.includes('darwin') || v.includes('macos') || v.includes('os x') || v.includes('osx') || v.includes('apple') || v.includes('mac')) return 'macOS';
|
||||
if(v.includes('win')) return 'Windows';
|
||||
if(v.includes('linux')) return 'Linux';
|
||||
return String(os).charAt(0).toUpperCase() + String(os).slice(1);
|
||||
}
|
||||
|
||||
function lossValues(s){
|
||||
return [s.ping_10010, s.ping_189, s.ping_10086].map(p => clamp(num(p), 0, 100));
|
||||
}
|
||||
|
||||
function isBlocked(s){
|
||||
return lossValues(s).every(p => p >= 100);
|
||||
}
|
||||
|
||||
function metrics(s){
|
||||
const online = !!(s.online4 || s.online6);
|
||||
const memPct = s.memory_total ? s.memory_used / s.memory_total * 100 : 0;
|
||||
const hddPct = s.hdd_total ? s.hdd_used / s.hdd_total * 100 : 0;
|
||||
const monthIn = Math.max(0, num(s.network_in) - num(s.last_network_in));
|
||||
const monthOut = Math.max(0, num(s.network_out) - num(s.last_network_out));
|
||||
const traffic = monthIn + monthOut;
|
||||
const losses = lossValues(s);
|
||||
const loss = Math.max(...losses);
|
||||
const lossBadCount = losses.filter(p => p >= 40).length;
|
||||
const lossWarnCount = losses.filter(p => p >= 30).length;
|
||||
const blocked = online && losses.every(p => p >= 100);
|
||||
const resourceCritical = online && (num(s.cpu) >= 90 || memPct >= 90 || hddPct >= 90);
|
||||
const resourceWarning = online && (num(s.cpu) >= 75 || memPct >= 80 || hddPct >= 85);
|
||||
const trafficWarning = online && traffic >= 1000 * 1000 * 1000 * 1000;
|
||||
const lossCritical = online && loss >= 40;
|
||||
const lossWarning = online && !lossCritical && loss >= 30;
|
||||
const critical = online && (resourceCritical || blocked || lossCritical);
|
||||
const warning = online && (resourceWarning || lossWarning);
|
||||
const rowLevel = !online || critical ? 'critical' : (resourceWarning || lossWarning ? 'warning' : '');
|
||||
return { online, memPct, hddPct, monthIn, monthOut, traffic, loss, lossBadCount, lossWarnCount, blocked, resourceCritical, resourceWarning, trafficWarning, lossCritical, lossWarning, critical, warning, alert: critical || warning, highlight: !!rowLevel, rowLevel };
|
||||
}
|
||||
|
||||
async function fetchData(){
|
||||
try{
|
||||
const res = await fetch('json/stats.json?_=' + Date.now(), { cache: 'no-store' });
|
||||
if(!res.ok) throw new Error(String(res.status));
|
||||
const data = await res.json();
|
||||
if(data.reload && !shouldSuppressStatsReload()) {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
S.updated = Number(data.updated || 0);
|
||||
S.servers = (data.servers || []).map((server, index) => {
|
||||
const base = [server.name || '-', server.location || '-', server.type || '-'].join('|');
|
||||
const key = `${base}#${index + 1}`;
|
||||
server._key = key;
|
||||
if(!S.hist[key]) S.hist[key] = { cu: [], ct: [], cm: [] };
|
||||
if(!S.loadHist[key]) S.loadHist[key] = { l1: [], l5: [], l15: [] };
|
||||
pushHistory(S.hist[key].cu, server.time_10010);
|
||||
pushHistory(S.hist[key].ct, server.time_189);
|
||||
pushHistory(S.hist[key].cm, server.time_10086);
|
||||
pushHistory(S.loadHist[key].l1, server.load_1);
|
||||
pushHistory(S.loadHist[key].l5, server.load_5);
|
||||
pushHistory(S.loadHist[key].l15, server.load_15);
|
||||
return server;
|
||||
});
|
||||
S.ssl = data.sslcerts || [];
|
||||
render();
|
||||
}catch(err){
|
||||
const notice = $('notice');
|
||||
notice.style.display = 'flex';
|
||||
notice.textContent = '数据获取失败: ' + err.message;
|
||||
}
|
||||
}
|
||||
function shouldSuppressStatsReload(){
|
||||
return S.admin.saving || S.activeTab === 'config' || Date.now() < S.suppressStatsReloadUntil;
|
||||
}
|
||||
function pushHistory(arr, value){
|
||||
if(typeof value === 'number' && Number.isFinite(value) && value >= 0) arr.push(value);
|
||||
if(arr.length > 120) arr.splice(0, arr.length - 120);
|
||||
}
|
||||
|
||||
function visibleServers(){
|
||||
const q = S.filters.query.trim().toLowerCase();
|
||||
let rows = S.servers.filter(server => {
|
||||
const m = metrics(server);
|
||||
if(S.filters.status === 'online' && !m.online) return false;
|
||||
if(S.filters.status === 'offline' && m.online) return false;
|
||||
if(S.filters.status === 'alert' && !m.alert) return false;
|
||||
const os = osLabel(server.os);
|
||||
if(S.filters.os !== 'all' && os !== S.filters.os) return false;
|
||||
if(!q) return true;
|
||||
return [server.name, server.type, server.host, server.location, os].some(v => String(v || '').toLowerCase().includes(q));
|
||||
});
|
||||
rows.sort((a, b) => {
|
||||
const dir = S.filters.dir === 'asc' ? 1 : -1;
|
||||
const ma = metrics(a), mb = metrics(b);
|
||||
const value = (server, m) => ({
|
||||
name: String(server.name || ''),
|
||||
type: String(server.type || ''),
|
||||
location: String(server.location || ''),
|
||||
status: m.online ? 1 : 0,
|
||||
load: num(server.load_1),
|
||||
cpu: num(server.cpu),
|
||||
memory: m.memPct,
|
||||
hdd: m.hddPct,
|
||||
traffic: m.traffic,
|
||||
loss: m.loss
|
||||
})[S.filters.sort];
|
||||
const va = value(a, ma), vb = value(b, mb);
|
||||
if(typeof va === 'string') return va.localeCompare(vb, 'zh-CN') * dir;
|
||||
return (va - vb) * dir;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function render(){
|
||||
$('notice').style.display = 'none';
|
||||
renderOverview();
|
||||
renderOsOptions();
|
||||
renderServers();
|
||||
renderServersCards();
|
||||
renderMonitors();
|
||||
renderMonitorsCards();
|
||||
renderSSL();
|
||||
renderSSLCards();
|
||||
updateTime();
|
||||
if(S.openDetailKey) refreshDetail();
|
||||
}
|
||||
|
||||
function renderOverview(){
|
||||
const total = S.servers.length;
|
||||
const online = S.servers.filter(s => metrics(s).online).length;
|
||||
const alerts = alertStats();
|
||||
const sslWarn = S.ssl.filter(c => c.mismatch || c.expire_days <= 7).length;
|
||||
const monthDown = S.servers.reduce((sum, s) => sum + metrics(s).monthIn, 0);
|
||||
const monthUp = S.servers.reduce((sum, s) => sum + metrics(s).monthOut, 0);
|
||||
$('overviewCards').innerHTML = [
|
||||
card('在线主机', `${online}/${total}`, '当前在线节点', online === total ? 'ok' : 'warn'),
|
||||
card('证书风险', sslWarn, sslWarn ? '过期或域名不匹配' : '证书正常', sslWarn ? 'warn' : 'ok'),
|
||||
card('本月上行', humanMinMBFromB(monthUp), '上传累计', ''),
|
||||
card('本月下行', humanMinMBFromB(monthDown), '下载累计', ''),
|
||||
card('活跃告警', alerts.total, `离线 ${alerts.offline} / 异常 ${alerts.abnormal} / 被墙 ${alerts.blocked}`, alerts.total ? (alerts.offline || alerts.blocked ? 'err' : 'warn') : 'ok')
|
||||
].join('');
|
||||
}
|
||||
function card(label, value, hint, cls){ return `<div class="overview-card ${cls}"><span class="label">${esc(label)}</span><span class="value">${esc(value)}</span><span class="hint">${esc(hint)}</span></div>`; }
|
||||
|
||||
function alertStats(){
|
||||
const stats = { offline: 0, abnormal: 0, blocked: 0, total: 0 };
|
||||
S.servers.forEach(s => {
|
||||
const m = metrics(s);
|
||||
if(!m.online) stats.offline++;
|
||||
else if(m.blocked) stats.blocked++;
|
||||
else if(m.alert) stats.abnormal++;
|
||||
});
|
||||
stats.total = stats.offline + stats.abnormal + stats.blocked;
|
||||
return stats;
|
||||
}
|
||||
|
||||
function renderOsOptions(){
|
||||
const select = $('osFilter');
|
||||
const labels = [...new Set(S.servers.map(s => osLabel(s.os)).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'zh-CN'));
|
||||
const signature = labels.join('\u001f');
|
||||
const current = labels.includes(S.filters.os) ? S.filters.os : 'all';
|
||||
if(document.activeElement === select) return;
|
||||
if(S.osOptionsSignature !== signature){
|
||||
select.innerHTML = '<option value="all">全部系统</option>' + labels.map(label => `<option value="${esc(label)}">${esc(label)}</option>`).join('');
|
||||
S.osOptionsSignature = signature;
|
||||
}
|
||||
if(select.value !== current) select.value = current;
|
||||
S.filters.os = select.value;
|
||||
}
|
||||
|
||||
function protoPill(s){
|
||||
const m = metrics(s);
|
||||
const proto = m.online ? (s.online4 && s.online6 ? '双栈' : (s.online4 ? 'IPv4' : 'IPv6')) : '离线';
|
||||
return `<span class="pill ${m.online ? 'on' : 'off'}">${proto}</span>`;
|
||||
}
|
||||
function trafficCaps(s, small){
|
||||
const m = metrics(s);
|
||||
const heavy = m.traffic >= 1000 * 1000 * 1000 * 1000;
|
||||
return `<span class="caps-traffic duo ${heavy ? 'heavy' : 'normal'}${small ? ' sm' : ''}" title="本月下行 | 上行"><span class="half in">${humanMinMBFromB(m.monthIn)}</span><span class="half out">${humanMinMBFromB(m.monthOut)}</span></span>`;
|
||||
}
|
||||
function gaugeHTML(type, value){
|
||||
const pct = clamp(num(value), 0, 100);
|
||||
const thresholds = {
|
||||
cpu: { warn: 75, bad: 90 },
|
||||
mem: { warn: 80, bad: 90 },
|
||||
hdd: { warn: 85, bad: 90 }
|
||||
}[type] || { warn: 75, bad: 90 };
|
||||
const warnAttr = pct >= thresholds.bad ? 'data-bad' : (pct >= thresholds.warn ? 'data-warn' : '');
|
||||
const label = type === 'cpu' ? 'CPU' : type === 'mem' ? '内存' : '硬盘';
|
||||
return `<div class="gauge-half" data-type="${type}" ${warnAttr} style="--p:${(pct / 100).toFixed(3)}" title="${label} ${pct.toFixed(0)}%">
|
||||
<svg viewBox="0 0 100 50" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><path class="track" d="M10 50 A40 40 0 0 1 90 50" /><path class="arc" d="M10 50 A40 40 0 0 1 90 50" /></svg>
|
||||
<span>${pct.toFixed(0)}%</span>
|
||||
</div>`;
|
||||
}
|
||||
function resourceMeter(label, value, pct, kind){
|
||||
const safePct = clamp(num(pct), 0, 100);
|
||||
const thresholds = {
|
||||
cpu: { warn: 75, bad: 90 },
|
||||
mem: { warn: 80, bad: 90 },
|
||||
swap: { warn: 80, bad: 90 },
|
||||
hdd: { warn: 85, bad: 90 }
|
||||
}[kind] || { warn: 75, bad: 90 };
|
||||
const level = safePct >= thresholds.bad ? 'bad' : (safePct >= thresholds.warn ? 'warn' : 'ok');
|
||||
return `<div class="resource-meter" data-kind="${esc(kind)}" data-level="${level}" style="--p:${safePct.toFixed(1)}%">
|
||||
<div class="resource-meter-head"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>
|
||||
<div class="resource-track"><i></i></div>
|
||||
</div>`;
|
||||
}
|
||||
function detailResourceHTML(s, m){
|
||||
const swapPct = s.swap_total ? num(s.swap_used) / num(s.swap_total) * 100 : 0;
|
||||
return `
|
||||
${resourceMeter('CPU', `${num(s.cpu).toFixed(0)}%`, num(s.cpu), 'cpu')}
|
||||
${resourceMeter('内存', `${humanMinMBFromKB(s.memory_used)} / ${humanMinMBFromKB(s.memory_total)}`, m.memPct, 'mem')}
|
||||
${resourceMeter('虚存', `${humanMinMBFromKB(s.swap_used)} / ${humanMinMBFromKB(s.swap_total)}`, swapPct, 'swap')}
|
||||
${resourceMeter('硬盘', `${humanMinMBFromMB(s.hdd_used)} / ${humanMinMBFromMB(s.hdd_total)}`, m.hddPct, 'hdd')}
|
||||
<div class="resource-mini"><span>IO</span><strong>读 ${humanMinMBFromB(s.io_read)} / 写 ${humanMinMBFromB(s.io_write)}</strong></div>`;
|
||||
}
|
||||
function packetLossLine(s){
|
||||
return [s.ping_10010, s.ping_189, s.ping_10086].map(p => `${clamp(num(p), 0, 100).toFixed(0)}%`).join('|');
|
||||
}
|
||||
function chartLegend(series){
|
||||
return `<div class="chart-legend">${series.map(item => `<span><i style="background:${esc(item.color)}"></i>${esc(item.label)}</span>`).join('')}</div>`;
|
||||
}
|
||||
function buckets(s){
|
||||
return `<div class="buckets" title="联通 / 电信 / 移动">${[s.ping_10010, s.ping_189, s.ping_10086].map(p => {
|
||||
const v = clamp(num(p), 0, 100);
|
||||
const level = v >= 40 ? 'bad' : (v >= 30 ? 'warn' : 'ok');
|
||||
return `<div class="bucket" data-lv="${level}"><span style="--h:${v}%"></span><label>${v.toFixed(0)}%</label></div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderServers(){
|
||||
const rows = visibleServers();
|
||||
document.querySelectorAll('#serversTable th[data-sort]').forEach(th => {
|
||||
th.classList.toggle('sorted-asc', th.dataset.sort === S.filters.sort && S.filters.dir === 'asc');
|
||||
th.classList.toggle('sorted-desc', th.dataset.sort === S.filters.sort && S.filters.dir === 'desc');
|
||||
});
|
||||
$('serversBody').innerHTML = rows.map(s => {
|
||||
const m = metrics(s);
|
||||
const netNow = `${humanMinKBFromB(s.network_rx)} | ${humanMinKBFromB(s.network_tx)}`;
|
||||
const netTotal = `${humanMinMBFromB(s.network_in)} | ${humanMinMBFromB(s.network_out)}`;
|
||||
const alertClass = m.rowLevel ? ` alert-${m.rowLevel}` : '';
|
||||
return `<tr data-key="${esc(s._key)}" data-online="${m.online ? 1 : 0}" class="row-server${alertClass}${osClass(s.os)}" style="cursor:${m.online ? 'pointer' : 'default'};">
|
||||
<td>${protoPill(s)}</td>
|
||||
<td>${trafficCaps(s)}</td>
|
||||
<td>${esc(s.name || '-')}</td>
|
||||
<td>${esc(s.type || '-')}</td>
|
||||
<td>${esc(s.location || '-')}</td>
|
||||
<td>${esc(s.uptime || '-')}</td>
|
||||
<td>${s.load_1 === -1 ? '–' : num(s.load_1).toFixed(2)}</td>
|
||||
<td>${netNow}</td>
|
||||
<td>${netTotal}</td>
|
||||
<td>${m.online ? gaugeHTML('cpu', s.cpu) : '-'}</td>
|
||||
<td>${m.online ? gaugeHTML('mem', m.memPct) : '-'}</td>
|
||||
<td>${m.online ? gaugeHTML('hdd', m.hddPct) : '-'}</td>
|
||||
<td>${buckets(s)}</td>
|
||||
</tr>`;
|
||||
}).join('') || `<tr><td colspan="13" class="muted" style="text-align:center;padding:1rem;">无数据</td></tr>`;
|
||||
document.querySelectorAll('#serversBody .row-server').forEach(row => row.addEventListener('click', () => {
|
||||
if(row.dataset.online !== '1') return;
|
||||
openDetail(row.dataset.key);
|
||||
}));
|
||||
}
|
||||
|
||||
function renderServersCards(){
|
||||
const wrap = $('serversCards');
|
||||
if(window.innerWidth > 700){ wrap.innerHTML = ''; return; }
|
||||
wrap.innerHTML = visibleServers().map(s => {
|
||||
const m = metrics(s);
|
||||
const alertClass = m.rowLevel ? ` alert-${m.rowLevel}` : '';
|
||||
return `<div class="card${m.online ? '' : ' offline'}${alertClass}${osClass(s.os)}" data-key="${esc(s._key)}" data-online="${m.online ? 1 : 0}">
|
||||
<div class="card-header"><div class="card-title">${esc(s.name || '-')} <span class="tag">${esc(s.location || '-')}</span></div>${protoPill(s)}</div>
|
||||
<div class="kvlist">
|
||||
<div><span class="key">负载</span><span>${s.load_1 === -1 ? '–' : num(s.load_1).toFixed(2)}</span></div>
|
||||
<div><span class="key">在线</span><span>${esc(s.uptime || '-')}</span></div>
|
||||
<div><span class="key">月流量</span><span>${trafficCaps(s, true)}</span></div>
|
||||
<div><span class="key">网络</span><span>${humanMinKBFromB(s.network_rx)} | ${humanMinKBFromB(s.network_tx)}</span></div>
|
||||
<div><span class="key">CPU</span><span>${num(s.cpu).toFixed(0)}%</span></div>
|
||||
<div><span class="key">内存</span><span>${m.memPct.toFixed(0)}%</span></div>
|
||||
</div>
|
||||
${buckets(s)}
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-state">无数据</div>';
|
||||
wrap.querySelectorAll('.card').forEach(card => card.addEventListener('click', () => {
|
||||
if(card.dataset.online !== '1') return;
|
||||
openDetail(card.dataset.key);
|
||||
}));
|
||||
}
|
||||
|
||||
function parseCustom(str){
|
||||
if(typeof str !== 'string' || !str.trim()) return [];
|
||||
return str.split(';').map(seg => {
|
||||
const [name, val] = seg.split('=');
|
||||
const ms = parseInt((val || '').trim(), 10);
|
||||
return name && Number.isFinite(ms) ? { name: name.trim(), ms: Math.max(0, ms) } : null;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
function signalBars(ms){
|
||||
const levels = [20, 50, 100, 160];
|
||||
let on = typeof ms === 'number' ? (ms <= levels[0] ? 5 : ms <= levels[1] ? 4 : ms <= levels[2] ? 3 : ms <= levels[3] ? 2 : 1) : 0;
|
||||
return `<span class="sig">${[0,1,2,3,4].map(i => `<i class="b ${i < on ? 'on' : 'off'}"></i>`).join('')}</span>`;
|
||||
}
|
||||
function monitorItems(s){
|
||||
const items = parseCustom(s.custom);
|
||||
return items.map(item => `<span class="mon-item"><span class="name">${esc(item.name)}</span>${signalBars(item.ms)}<span class="ms">${item.ms}ms</span></span>`).join('') || '-';
|
||||
}
|
||||
function renderMonitors(){
|
||||
$('monitorsBody').innerHTML = S.servers.map(s => `<tr><td>${protoPill(s)}</td><td>${esc(s.name || '-')}</td><td>${esc(s.location || '-')}</td><td><div class="mon-items">${monitorItems(s)}</div></td></tr>`).join('') || `<tr><td colspan="4" class="muted" style="text-align:center;padding:1rem;">无数据</td></tr>`;
|
||||
}
|
||||
function renderMonitorsCards(){
|
||||
const wrap = $('monitorsCards');
|
||||
if(window.innerWidth > 700){ wrap.innerHTML = ''; return; }
|
||||
wrap.innerHTML = S.servers.map(s => `<div class="card"><div class="card-header"><div class="card-title">${esc(s.name || '-')} <span class="tag">${esc(s.location || '-')}</span></div>${protoPill(s)}</div><div class="kvlist"><div><span class="key">监测内容</span><span class="mon-items">${monitorItems(s)}</span></div></div></div>`).join('') || '<div class="empty-state">无数据</div>';
|
||||
}
|
||||
function renderSSL(){
|
||||
$('sslBody').innerHTML = S.ssl.map(c => {
|
||||
const cls = c.mismatch || c.expire_days <= 0 ? 'err' : c.expire_days <= 7 ? 'warn' : 'ok';
|
||||
const status = c.mismatch ? '域名不匹配' : c.expire_days <= 0 ? '已过期' : c.expire_days <= 7 ? '将到期' : '正常';
|
||||
const dt = c.expire_ts ? new Date(c.expire_ts * 1000).toISOString().replace('T',' ').replace(/\.\d+Z/,'') : '-';
|
||||
const alertClass = cls === 'err' ? 'alert-critical' : cls === 'warn' ? 'alert-warning' : '';
|
||||
return `<tr class="${alertClass}"><td>${esc(c.name || '-')}</td><td>${esc(String(c.domain || '').replace(/^https?:\/\//,''))}</td><td>${esc(c.port || 443)}</td><td><span class="badge ${cls}">${esc(c.expire_days ?? '-')}</span></td><td>${dt}</td><td><span class="badge ${cls}">${status}</span></td></tr>`;
|
||||
}).join('') || `<tr><td colspan="6" class="muted" style="text-align:center;padding:1rem;">无证书数据</td></tr>`;
|
||||
}
|
||||
function renderSSLCards(){
|
||||
const wrap = $('sslCards');
|
||||
if(window.innerWidth > 700){ wrap.innerHTML = ''; return; }
|
||||
wrap.innerHTML = S.ssl.map(c => {
|
||||
const cls = c.mismatch || c.expire_days <= 0 ? 'err' : c.expire_days <= 7 ? 'warn' : 'ok';
|
||||
const status = c.mismatch ? '域名不匹配' : c.expire_days <= 0 ? '已过期' : c.expire_days <= 7 ? '将到期' : '正常';
|
||||
const dt = c.expire_ts ? new Date(c.expire_ts * 1000).toISOString().slice(0,10) : '-';
|
||||
const alertClass = cls === 'err' ? ' alert-critical' : cls === 'warn' ? ' alert-warning' : '';
|
||||
return `<div class="card${alertClass}"><div class="card-header"><div class="card-title">${esc(c.name || '-')}</div><span class="status-pill ${cls === 'err' ? 'off' : 'on'}">${status}</span></div><div class="kvlist"><div><span class="key">域名</span><span>${esc(String(c.domain || '').replace(/^https?:\/\//,''))}</span></div><div><span class="key">端口</span><span>${esc(c.port || 443)}</span></div><div><span class="key">剩余</span><span>${esc(c.expire_days ?? '-')} 天</span></div><div><span class="key">到期</span><span>${dt}</span></div></div></div>`;
|
||||
}).join('') || '<div class="empty-state">无证书数据</div>';
|
||||
}
|
||||
|
||||
function openDetail(key){
|
||||
S.openDetailKey = key;
|
||||
$('detailModal').style.display = 'flex';
|
||||
refreshDetail();
|
||||
document.addEventListener('keydown', escCloseOnce);
|
||||
}
|
||||
function findServer(key){ return S.servers.find(s => s._key === key); }
|
||||
function refreshDetail(){
|
||||
const s = findServer(S.openDetailKey);
|
||||
if(!s){ closeDetail(); return; }
|
||||
const m = metrics(s);
|
||||
const title = $('detailTitle');
|
||||
title.innerHTML = `${esc(s.name || '-')} 详情${s.os ? `<span class="os-chip${osClass(s.os)}">${esc(osLabel(s.os))}</span>` : ''}`;
|
||||
const modalBox = document.querySelector('#detailModal .modal-box');
|
||||
if(modalBox){
|
||||
modalBox.classList.remove('high-load');
|
||||
modalBox.classList.toggle('alert-critical', m.rowLevel === 'critical');
|
||||
modalBox.classList.toggle('alert-warning', m.rowLevel === 'warning');
|
||||
}
|
||||
const loadSeries = [
|
||||
{ data: S.loadHist[s._key]?.l1 || [], color:'#8b5cf6', label:'load1' },
|
||||
{ data: S.loadHist[s._key]?.l5 || [], color:'#10b981', label:'load5' },
|
||||
{ data: S.loadHist[s._key]?.l15 || [], color:'#f59e0b', label:'load15' }
|
||||
];
|
||||
const latencySeries = [
|
||||
{ data: S.hist[s._key]?.cu || [], color:'#3b82f6', label:'联通' },
|
||||
{ data: S.hist[s._key]?.ct || [], color:'#10b981', label:'电信' },
|
||||
{ data: S.hist[s._key]?.cm || [], color:'#f59e0b', label:'移动' }
|
||||
];
|
||||
$('detailContent').innerHTML = `
|
||||
<div class="detail-grid">
|
||||
<section class="detail-section"><h4>身份</h4>
|
||||
<div class="kv"><span>节点 / 位置</span><span class="mono">${esc(s.name || '-')} / ${esc(s.location || '-')}</span></div>
|
||||
<div class="kv"><span>虚拟化 / 主机</span><span class="mono">${esc(s.type || '-')} / ${esc(s.host || '-')}</span></div>
|
||||
<div class="kv"><span>协议 / 在线</span><span class="detail-inline">${protoPill(s)}<span class="mono">${esc(s.uptime || '-')}</span></span></div>
|
||||
</section>
|
||||
<section class="detail-section resource-section"><h4>资源</h4>
|
||||
${detailResourceHTML(s, m)}
|
||||
</section>
|
||||
<section class="detail-section"><h4>网络</h4>
|
||||
<div class="kv"><span>当前 ↓|↑</span><span class="mono">${humanMinKBFromB(s.network_rx)} | ${humanMinKBFromB(s.network_tx)}</span></div>
|
||||
<div class="kv"><span>总流量 ↓|↑</span><span class="mono">${humanMinMBFromB(s.network_in)} | ${humanMinMBFromB(s.network_out)}</span></div>
|
||||
<div class="kv"><span>本月 ↓|↑</span><span>${trafficCaps(s, true)}</span></div>
|
||||
</section>
|
||||
<section class="detail-section"><h4>连接</h4>
|
||||
<div class="kv"><span>TCP / UDP</span><span class="mono">${num(s.tcp_count)} / ${num(s.udp_count)}</span></div>
|
||||
<div class="kv"><span>进程 / 线程</span><span class="mono">${num(s.process_count)} / ${num(s.thread_count)}</span></div>
|
||||
<div class="kv"><span>联通|电信|移动</span><span class="mono">${packetLossLine(s)}</span></div>
|
||||
</section>
|
||||
</div>
|
||||
<section class="detail-section chart-section"><div class="chart-head"><h4>负载趋势</h4>${chartLegend(loadSeries)}</div><canvas id="loadChart" class="detail-chart" height="130"></canvas></section>
|
||||
<section class="detail-section chart-section"><div class="chart-head"><h4>三网延迟</h4>${chartLegend(latencySeries)}</div><canvas id="latChart" class="detail-chart" height="150"></canvas></section>`;
|
||||
drawLineChart('loadChart', loadSeries, '暂无负载数据');
|
||||
drawLineChart('latChart', latencySeries, '暂无延迟数据', 'ms');
|
||||
}
|
||||
function closeDetail(){
|
||||
$('detailModal').style.display = 'none';
|
||||
S.openDetailKey = null;
|
||||
document.removeEventListener('keydown', escCloseOnce);
|
||||
}
|
||||
function escCloseOnce(e){ if(e.key === 'Escape') closeDetail(); }
|
||||
|
||||
function drawLineChart(id, series, emptyText, unit = ''){
|
||||
const canvas = $(id);
|
||||
if(!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.clientWidth || canvas.width;
|
||||
const H = canvas.height;
|
||||
canvas.width = W;
|
||||
ctx.clearRect(0,0,W,H);
|
||||
const all = series.flatMap(s => s.data).filter(v => Number.isFinite(v));
|
||||
if(all.length < 2){
|
||||
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text-dim') || '#7a899d';
|
||||
ctx.font = '12px system-ui';
|
||||
ctx.fillText(emptyText, Math.max(12, W / 2 - 42), H / 2);
|
||||
return;
|
||||
}
|
||||
const padL = unit ? 50 : 42, padR = 10, padT = 12, padB = 20;
|
||||
let min = Math.min(0, ...all), max = Math.max(...all);
|
||||
if(max - min < 1) max = min + 1;
|
||||
const range = max - min;
|
||||
const n = Math.max(...series.map(s => s.data.length));
|
||||
const xStep = (W - padL - padR) / Math.max(1, n - 1);
|
||||
const light = document.body.classList.contains('light');
|
||||
const axis = light ? 'rgba(0,0,0,.22)' : 'rgba(255,255,255,.18)';
|
||||
const grid = light ? 'rgba(0,0,0,.08)' : 'rgba(255,255,255,.10)';
|
||||
const text = light ? 'rgba(30,41,59,.7)' : 'rgba(226,232,240,.82)';
|
||||
ctx.strokeStyle = axis; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padL,padT); ctx.lineTo(padL,H-padB); ctx.lineTo(W-padR,H-padB); ctx.stroke();
|
||||
ctx.fillStyle = text; ctx.font = '10px system-ui';
|
||||
for(let i=0;i<=4;i++){
|
||||
const y = padT + (H-padT-padB) * i / 4;
|
||||
const val = max - range * i / 4;
|
||||
ctx.fillText(val.toFixed(max < 10 ? 1 : 0) + unit, 4, y + 3);
|
||||
ctx.strokeStyle = grid; ctx.beginPath(); ctx.moveTo(padL,y); ctx.lineTo(W-padR,y); ctx.stroke();
|
||||
}
|
||||
series.forEach(item => {
|
||||
if(item.data.length < 2) return;
|
||||
ctx.strokeStyle = item.color; ctx.lineWidth = 1.7; ctx.beginPath();
|
||||
item.data.forEach((v, i) => {
|
||||
const x = padL + xStep * i;
|
||||
const y = padT + (H-padT-padB) * (1 - (v - min) / range);
|
||||
if(i === 0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
||||
});
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
function updateTime(){
|
||||
const last = $('lastUpdate');
|
||||
if(last) last.textContent = '最后更新: ' + humanAgo(S.updated);
|
||||
}
|
||||
|
||||
function bindTabs(){
|
||||
$('navTabs').addEventListener('click', e => {
|
||||
if(e.target.tagName !== 'BUTTON') return;
|
||||
const tab = e.target.dataset.tab;
|
||||
S.activeTab = tab;
|
||||
document.querySelectorAll('.nav button').forEach(btn => btn.classList.toggle('active', btn === e.target));
|
||||
document.querySelectorAll('.panel').forEach(panel => panel.classList.toggle('active', panel.id === 'panel-' + tab));
|
||||
$('serversToolbar').style.display = tab === 'servers' ? 'flex' : 'none';
|
||||
if(tab === 'config') ensureAdminChecked();
|
||||
});
|
||||
}
|
||||
function bindTheme(){
|
||||
const btn = $('themeToggle');
|
||||
const mql = window.matchMedia('(prefers-color-scheme: light)');
|
||||
const apply = (light) => { document.body.classList.toggle('light', light); document.documentElement.classList.toggle('light', light); };
|
||||
const saved = localStorage.getItem('theme');
|
||||
apply(saved ? saved === 'light' : mql.matches);
|
||||
mql.addEventListener('change', e => { if(!localStorage.getItem('theme')) apply(e.matches); });
|
||||
btn.addEventListener('click', () => {
|
||||
const toLight = !document.body.classList.contains('light');
|
||||
apply(toLight);
|
||||
localStorage.setItem('theme', toLight ? 'light' : 'dark');
|
||||
if(S.openDetailKey) refreshDetail();
|
||||
});
|
||||
}
|
||||
function bindFilters(){
|
||||
$('serverSearch').addEventListener('input', e => { S.filters.query = e.target.value; renderServers(); renderServersCards(); });
|
||||
$('statusFilter').addEventListener('click', e => {
|
||||
if(e.target.tagName !== 'BUTTON') return;
|
||||
S.filters.status = e.target.dataset.filter;
|
||||
document.querySelectorAll('#statusFilter button').forEach(btn => btn.classList.toggle('active', btn === e.target));
|
||||
renderServers(); renderServersCards();
|
||||
});
|
||||
$('osFilter').addEventListener('change', e => { S.filters.os = e.target.value; renderServers(); renderServersCards(); });
|
||||
$('osFilter').addEventListener('blur', renderOsOptions);
|
||||
$('sortSelect').addEventListener('change', e => { S.filters.sort = e.target.value; renderServers(); renderServersCards(); });
|
||||
$('sortDirection').addEventListener('click', () => {
|
||||
S.filters.dir = S.filters.dir === 'desc' ? 'asc' : 'desc';
|
||||
$('sortDirection').textContent = S.filters.dir === 'desc' ? '降序' : '升序';
|
||||
renderServers(); renderServersCards();
|
||||
});
|
||||
document.querySelectorAll('#serversTable th[data-sort]').forEach(th => th.addEventListener('click', () => {
|
||||
if(S.filters.sort === th.dataset.sort) S.filters.dir = S.filters.dir === 'desc' ? 'asc' : 'desc';
|
||||
else S.filters.sort = th.dataset.sort;
|
||||
$('sortSelect').value = S.filters.sort;
|
||||
$('sortDirection').textContent = S.filters.dir === 'desc' ? '降序' : '升序';
|
||||
renderServers(); renderServersCards();
|
||||
}));
|
||||
}
|
||||
|
||||
function adminHeaders(){
|
||||
return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${S.admin.token}` };
|
||||
}
|
||||
async function api(path, options = {}){
|
||||
const res = await fetch(path, { ...options, headers: { ...(options.headers || {}), ...(options.auth === false ? {} : adminHeaders()) } });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if(!res.ok || data.ok === false) throw new Error(data.error || res.statusText);
|
||||
return data;
|
||||
}
|
||||
async function ensureAdminChecked(){
|
||||
if(S.admin._checking) return;
|
||||
S.admin._checking = true;
|
||||
try{
|
||||
const health = await api('/api/health', { auth:false });
|
||||
S.admin.enabled = !!health.enabled;
|
||||
setAdminStatus(health.enabled ? '管理 API 已启用,输入 token 后可编辑配置。' : '管理 API 未启用:请在容器环境变量设置 ADMIN_TOKEN。', health.enabled ? '' : 'err');
|
||||
if(S.admin.enabled && S.admin.token) await loadConfig();
|
||||
}catch(err){
|
||||
setAdminStatus('无法连接管理 API:' + err.message, 'err');
|
||||
}finally{
|
||||
S.admin._checking = false;
|
||||
}
|
||||
}
|
||||
function setAdminStatus(text, cls){
|
||||
const el = $('adminStatus');
|
||||
el.className = 'admin-status' + (cls ? ' ' + cls : '');
|
||||
el.textContent = text;
|
||||
}
|
||||
async function loadConfig(){
|
||||
const data = await api('/api/config');
|
||||
S.admin.config = normalizeAdminConfig(data.config);
|
||||
S.admin.connected = true;
|
||||
setAdminStatus('已连接管理 API,配置可编辑。', 'ok');
|
||||
renderConfigList();
|
||||
renderConfigEditor();
|
||||
}
|
||||
|
||||
const CONFIG_TYPES = {
|
||||
servers: {
|
||||
label: '节点',
|
||||
addLabel: '新增节点',
|
||||
empty: '暂无节点配置',
|
||||
hint: '客户端登录使用 username/password,保存后自动重载 sergate。',
|
||||
fields: [
|
||||
{ name:'username', label:'用户名', required:true, max:120 },
|
||||
{ name:'name', label:'节点名', required:true, max:120 },
|
||||
{ name:'type', label:'虚拟化', required:true, max:120, placeholder:'kvm / xen / vmware' },
|
||||
{ name:'host', label:'主机名', required:true, max:120 },
|
||||
{ name:'location', label:'位置', required:true, max:120, placeholder:'🇨🇳 / 上海 / hk-01' },
|
||||
{ name:'password', label:'密码', required:true, max:120, keepRaw:true },
|
||||
{ name:'monthstart', label:'月初日', type:'number', min:1, max:28, default:1 },
|
||||
{ name:'disabled', label:'禁用节点', type:'checkbox' }
|
||||
],
|
||||
title: item => item?.name || item?.username || '未命名节点',
|
||||
meta: item => `${item?.location || '-'} / ${item?.type || '-'} / ${item?.host || '-'}`,
|
||||
badge: item => ({ text: item?.disabled ? '禁用' : '启用', cls: item?.disabled ? 'warn' : 'ok' })
|
||||
},
|
||||
monitors: {
|
||||
label: '监测',
|
||||
addLabel: '新增监测',
|
||||
empty: '暂无服务监测',
|
||||
hint: '服务监测会写入 config.json 的 monitors 数组。',
|
||||
fields: [
|
||||
{ name:'name', label:'名称', required:true, max:120 },
|
||||
{ name:'host', label:'地址', required:true, max:300, placeholder:'https://example.com' },
|
||||
{ name:'type', label:'类型', required:true, max:40, placeholder:'http / https / tcp' },
|
||||
{ name:'interval', label:'间隔秒', type:'number', min:1, default:600 }
|
||||
],
|
||||
title: item => item?.name || item?.host || '未命名监测',
|
||||
meta: item => `${item?.type || '-'} / ${item?.host || '-'} / ${item?.interval || '-'}s`,
|
||||
badge: item => ({ text: item?.type || '监测', cls: 'ok' })
|
||||
},
|
||||
sslcerts: {
|
||||
label: '证书',
|
||||
addLabel: '新增证书',
|
||||
empty: '暂无证书配置',
|
||||
hint: '证书配置会写入 config.json 的 sslcerts 数组。',
|
||||
fields: [
|
||||
{ name:'name', label:'名称', required:true, max:120 },
|
||||
{ name:'domain', label:'域名', required:true, max:300, placeholder:'https://example.com' },
|
||||
{ name:'port', label:'端口', type:'number', min:1, max:65535, default:443 },
|
||||
{ name:'interval', label:'间隔秒', type:'number', min:1, default:7200 },
|
||||
{ name:'callback', label:'回调地址', max:500, placeholder:'https://yourSMSurl' }
|
||||
],
|
||||
title: item => item?.name || item?.domain || '未命名证书',
|
||||
meta: item => `${item?.domain || '-'} / ${item?.port || 443} / ${item?.interval || '-'}s`,
|
||||
badge: () => ({ text: 'SSL', cls: 'ok' })
|
||||
},
|
||||
watchdog: {
|
||||
label: '告警',
|
||||
addLabel: '新增告警',
|
||||
empty: '暂无告警规则',
|
||||
hint: '告警规则会写入 config.json 的 watchdog 数组。',
|
||||
fields: [
|
||||
{ name:'name', label:'名称', required:true, max:160 },
|
||||
{ name:'rule', label:'规则表达式', type:'textarea', required:true, placeholder:"online4=0&online6=0" },
|
||||
{ name:'interval', label:'间隔秒', type:'number', min:1, default:600 },
|
||||
{ name:'callback', label:'回调地址', max:500, placeholder:'https://yourSMSurl' }
|
||||
],
|
||||
title: item => item?.name || '未命名告警',
|
||||
meta: item => item?.rule || '-',
|
||||
badge: item => ({ text: `${item?.interval || '-'}s`, cls: 'warn' })
|
||||
}
|
||||
};
|
||||
|
||||
function normalizeAdminConfig(config){
|
||||
const normalized = config && typeof config === 'object' ? config : {};
|
||||
['servers','monitors','sslcerts','watchdog'].forEach(key => {
|
||||
if(!Array.isArray(normalized[key])) normalized[key] = [];
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
function activeConfigDef(){
|
||||
return CONFIG_TYPES[S.admin.selectedType] || CONFIG_TYPES.servers;
|
||||
}
|
||||
function configItems(){
|
||||
if(!S.admin.config) S.admin.config = normalizeAdminConfig({});
|
||||
const key = S.admin.selectedType;
|
||||
if(!Array.isArray(S.admin.config[key])) S.admin.config[key] = [];
|
||||
return S.admin.config[key];
|
||||
}
|
||||
function renderConfigList(){
|
||||
const list = $('configItemList');
|
||||
const def = activeConfigDef();
|
||||
const items = configItems();
|
||||
if(S.admin.selectedIndex >= items.length) S.admin.selectedIndex = -1;
|
||||
$('addConfigItemBtn').textContent = def.addLabel;
|
||||
document.querySelectorAll('#configTypeTabs button').forEach(btn => btn.classList.toggle('active', btn.dataset.type === S.admin.selectedType));
|
||||
list.innerHTML = items.map((item, index) => {
|
||||
const badge = def.badge(item, index);
|
||||
return `<div class="config-row${S.admin.selectedIndex === index ? ' active' : ''}" data-index="${index}"><div><div class="name">${esc(def.title(item, index))}</div><div class="meta">${esc(def.meta(item, index))}</div></div><span class="badge ${badge.cls}">${esc(badge.text)}</span></div>`;
|
||||
}).join('') || `<div class="empty-state">${def.empty}</div>`;
|
||||
list.querySelectorAll('.config-row').forEach(row => row.addEventListener('click', () => selectConfigItem(Number(row.dataset.index))));
|
||||
}
|
||||
function selectConfigItem(index){
|
||||
const item = configItems()[index];
|
||||
if(!item) return;
|
||||
S.admin.selectedIndex = index;
|
||||
renderConfigList();
|
||||
renderConfigEditor(item);
|
||||
}
|
||||
function renderConfigEditor(item){
|
||||
const def = activeConfigDef();
|
||||
const editing = S.admin.selectedIndex >= 0 && item;
|
||||
const current = item || {};
|
||||
$('configEditorTitle').textContent = `${editing ? '编辑' : '新增'}${def.label}`;
|
||||
$('configEditorHint').textContent = def.hint;
|
||||
$('configFields').innerHTML = def.fields.map(field => fieldHTML(field, current)).join('');
|
||||
$('deleteConfigItemBtn').disabled = !editing;
|
||||
}
|
||||
function fieldHTML(field, item){
|
||||
const value = item[field.name] ?? field.default ?? '';
|
||||
if(field.type === 'checkbox'){
|
||||
return `<label class="check-row"><input name="${esc(field.name)}" type="checkbox" ${value ? 'checked' : ''} /> <span>${esc(field.label)}</span></label>`;
|
||||
}
|
||||
const required = field.required ? ' required' : '';
|
||||
const min = field.min != null ? ` min="${field.min}"` : '';
|
||||
const max = field.max != null ? (field.type === 'number' ? ` max="${field.max}"` : ` maxlength="${field.max}"`) : '';
|
||||
const placeholder = field.placeholder ? ` placeholder="${esc(field.placeholder)}"` : '';
|
||||
if(field.type === 'textarea'){
|
||||
return `<label class="wide"><span>${esc(field.label)}</span><textarea name="${esc(field.name)}"${required}${placeholder}>${esc(value)}</textarea></label>`;
|
||||
}
|
||||
return `<label><span>${esc(field.label)}</span><input name="${esc(field.name)}" type="${field.type || 'text'}" value="${esc(value)}"${required}${min}${max}${placeholder} /></label>`;
|
||||
}
|
||||
function clearConfigForm(){
|
||||
S.admin.selectedIndex = -1;
|
||||
renderConfigList();
|
||||
renderConfigEditor();
|
||||
}
|
||||
function formConfigItem(){
|
||||
const elements = $('configForm').elements;
|
||||
const item = {};
|
||||
activeConfigDef().fields.forEach(field => {
|
||||
const el = elements[field.name];
|
||||
if(!el) return;
|
||||
if(field.type === 'checkbox'){
|
||||
item[field.name] = el.checked;
|
||||
return;
|
||||
}
|
||||
if(field.type === 'number'){
|
||||
let value = el.value === '' ? (field.default ?? 0) : Number(el.value);
|
||||
if(!Number.isFinite(value)) value = field.default ?? 0;
|
||||
if(field.min != null) value = Math.max(field.min, value);
|
||||
if(field.max != null) value = Math.min(field.max, value);
|
||||
item[field.name] = value;
|
||||
return;
|
||||
}
|
||||
item[field.name] = field.keepRaw ? el.value : el.value.trim();
|
||||
});
|
||||
return item;
|
||||
}
|
||||
function configItemPath(key, index){
|
||||
if(index < 0) return `/api/${key}`;
|
||||
const current = configItems()[index] || {};
|
||||
const id = key === 'servers' ? current.username : String(index);
|
||||
return `/api/${key}/${encodeURIComponent(id || String(index))}`;
|
||||
}
|
||||
async function saveConfigItem(key, index, item){
|
||||
S.admin.saving = true;
|
||||
S.suppressStatsReloadUntil = Date.now() + 8000;
|
||||
try{
|
||||
const data = await api(configItemPath(key, index), { method: index >= 0 ? 'PUT' : 'POST', body: JSON.stringify(item) });
|
||||
if(data.config) S.admin.config = normalizeAdminConfig(data.config);
|
||||
S.suppressStatsReloadUntil = Date.now() + 8000;
|
||||
return data;
|
||||
}finally{
|
||||
S.admin.saving = false;
|
||||
}
|
||||
}
|
||||
async function deleteConfigItem(key, index){
|
||||
S.admin.saving = true;
|
||||
S.suppressStatsReloadUntil = Date.now() + 8000;
|
||||
try{
|
||||
const data = await api(configItemPath(key, index), { method:'DELETE' });
|
||||
if(data.config) S.admin.config = normalizeAdminConfig(data.config);
|
||||
S.suppressStatsReloadUntil = Date.now() + 8000;
|
||||
return data;
|
||||
}finally{
|
||||
S.admin.saving = false;
|
||||
}
|
||||
}
|
||||
function bindAdmin(){
|
||||
$('adminToken').value = S.admin.token;
|
||||
$('adminTokenForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
S.admin.token = $('adminToken').value.trim();
|
||||
localStorage.setItem('serverstatusAdminToken', S.admin.token);
|
||||
try{ await loadConfig(); }catch(err){ setAdminStatus('认证失败:' + err.message, 'err'); }
|
||||
});
|
||||
$('refreshConfigBtn').addEventListener('click', async () => { try{ await loadConfig(); }catch(err){ setAdminStatus('刷新失败:' + err.message, 'err'); } });
|
||||
$('configTypeTabs').addEventListener('click', e => {
|
||||
if(e.target.tagName !== 'BUTTON') return;
|
||||
S.admin.selectedType = e.target.dataset.type;
|
||||
S.admin.selectedIndex = -1;
|
||||
renderConfigList();
|
||||
renderConfigEditor();
|
||||
});
|
||||
$('addConfigItemBtn').addEventListener('click', clearConfigForm);
|
||||
$('resetConfigFormBtn').addEventListener('click', clearConfigForm);
|
||||
$('adminReload').addEventListener('click', async () => {
|
||||
try{ await api('/api/reload', { method:'POST' }); setAdminStatus('配置重载已触发。', 'ok'); }
|
||||
catch(err){ setAdminStatus('重载失败:' + err.message, 'err'); }
|
||||
});
|
||||
$('adminRestart').addEventListener('click', async () => {
|
||||
if(!confirm('重启 sergate 采集服务?客户端会短暂断开后自动重连。')) return;
|
||||
try{ await api('/api/restart', { method:'POST' }); setAdminStatus('服务重启已触发,等待容器入口脚本拉起 sergate。', 'ok'); }
|
||||
catch(err){ setAdminStatus('重启失败:' + err.message, 'err'); }
|
||||
});
|
||||
$('configForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const def = activeConfigDef();
|
||||
const key = S.admin.selectedType;
|
||||
const item = formConfigItem();
|
||||
const index = S.admin.selectedIndex;
|
||||
try{
|
||||
await saveConfigItem(key, index, item);
|
||||
S.admin.selectedIndex = index >= 0 ? index : configItems().length - 1;
|
||||
renderConfigList();
|
||||
renderConfigEditor(configItems()[S.admin.selectedIndex]);
|
||||
setAdminStatus(`${def.label}配置已保存并重载。`, 'ok');
|
||||
}catch(err){ setAdminStatus('保存失败:' + err.message, 'err'); }
|
||||
});
|
||||
$('deleteConfigItemBtn').addEventListener('click', async () => {
|
||||
const def = activeConfigDef();
|
||||
const key = S.admin.selectedType;
|
||||
const index = S.admin.selectedIndex;
|
||||
if(index < 0) return setAdminStatus(`请先选择要删除的${def.label}。`, 'err');
|
||||
const item = configItems()[index];
|
||||
if(!confirm(`删除${def.label} ${def.title(item, index)}?`)) return;
|
||||
try{
|
||||
await deleteConfigItem(key, index);
|
||||
S.admin.selectedIndex = -1;
|
||||
renderConfigList();
|
||||
renderConfigEditor();
|
||||
setAdminStatus(`${def.label}配置已删除并重载。`, 'ok');
|
||||
}catch(err){ setAdminStatus('删除失败:' + err.message, 'err'); }
|
||||
});
|
||||
}
|
||||
|
||||
$('detailClose').addEventListener('click', closeDetail);
|
||||
$('detailModal').addEventListener('click', e => { if(e.target.id === 'detailModal') closeDetail(); });
|
||||
window.addEventListener('resize', () => { renderServersCards(); renderMonitorsCards(); renderSSLCards(); if(S.openDetailKey) refreshDetail(); });
|
||||
|
||||
bindTabs();
|
||||
bindTheme();
|
||||
bindFilters();
|
||||
bindAdmin();
|
||||
fetchData();
|
||||
setInterval(fetchData, 1000);
|
||||
setInterval(updateTime, 60000);
|
||||
Vendored
-7
File diff suppressed because one or more lines are too long
Vendored
-326
@@ -1,326 +0,0 @@
|
||||
/**
|
||||
* @preserve HTML5 Shiv 3.7.3 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed
|
||||
*/
|
||||
;(function(window, document) {
|
||||
/*jshint evil:true */
|
||||
/** version */
|
||||
var version = '3.7.3';
|
||||
|
||||
/** Preset options */
|
||||
var options = window.html5 || {};
|
||||
|
||||
/** Used to skip problem elements */
|
||||
var reSkip = /^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i;
|
||||
|
||||
/** Not all elements can be cloned in IE **/
|
||||
var saveClones = /^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i;
|
||||
|
||||
/** Detect whether the browser supports default html5 styles */
|
||||
var supportsHtml5Styles;
|
||||
|
||||
/** Name of the expando, to work with multiple documents or to re-shiv one document */
|
||||
var expando = '_html5shiv';
|
||||
|
||||
/** The id for the the documents expando */
|
||||
var expanID = 0;
|
||||
|
||||
/** Cached data for each document */
|
||||
var expandoData = {};
|
||||
|
||||
/** Detect whether the browser supports unknown elements */
|
||||
var supportsUnknownElements;
|
||||
|
||||
(function() {
|
||||
try {
|
||||
var a = document.createElement('a');
|
||||
a.innerHTML = '<xyz></xyz>';
|
||||
//if the hidden property is implemented we can assume, that the browser supports basic HTML5 Styles
|
||||
supportsHtml5Styles = ('hidden' in a);
|
||||
|
||||
supportsUnknownElements = a.childNodes.length == 1 || (function() {
|
||||
// assign a false positive if unable to shiv
|
||||
(document.createElement)('a');
|
||||
var frag = document.createDocumentFragment();
|
||||
return (
|
||||
typeof frag.cloneNode == 'undefined' ||
|
||||
typeof frag.createDocumentFragment == 'undefined' ||
|
||||
typeof frag.createElement == 'undefined'
|
||||
);
|
||||
}());
|
||||
} catch(e) {
|
||||
// assign a false positive if detection fails => unable to shiv
|
||||
supportsHtml5Styles = true;
|
||||
supportsUnknownElements = true;
|
||||
}
|
||||
|
||||
}());
|
||||
|
||||
/*--------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Creates a style sheet with the given CSS text and adds it to the document.
|
||||
* @private
|
||||
* @param {Document} ownerDocument The document.
|
||||
* @param {String} cssText The CSS text.
|
||||
* @returns {StyleSheet} The style element.
|
||||
*/
|
||||
function addStyleSheet(ownerDocument, cssText) {
|
||||
var p = ownerDocument.createElement('p'),
|
||||
parent = ownerDocument.getElementsByTagName('head')[0] || ownerDocument.documentElement;
|
||||
|
||||
p.innerHTML = 'x<style>' + cssText + '</style>';
|
||||
return parent.insertBefore(p.lastChild, parent.firstChild);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of `html5.elements` as an array.
|
||||
* @private
|
||||
* @returns {Array} An array of shived element node names.
|
||||
*/
|
||||
function getElements() {
|
||||
var elements = html5.elements;
|
||||
return typeof elements == 'string' ? elements.split(' ') : elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the built-in list of html5 elements
|
||||
* @memberOf html5
|
||||
* @param {String|Array} newElements whitespace separated list or array of new element names to shiv
|
||||
* @param {Document} ownerDocument The context document.
|
||||
*/
|
||||
function addElements(newElements, ownerDocument) {
|
||||
var elements = html5.elements;
|
||||
if(typeof elements != 'string'){
|
||||
elements = elements.join(' ');
|
||||
}
|
||||
if(typeof newElements != 'string'){
|
||||
newElements = newElements.join(' ');
|
||||
}
|
||||
html5.elements = elements +' '+ newElements;
|
||||
shivDocument(ownerDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data associated to the given document
|
||||
* @private
|
||||
* @param {Document} ownerDocument The document.
|
||||
* @returns {Object} An object of data.
|
||||
*/
|
||||
function getExpandoData(ownerDocument) {
|
||||
var data = expandoData[ownerDocument[expando]];
|
||||
if (!data) {
|
||||
data = {};
|
||||
expanID++;
|
||||
ownerDocument[expando] = expanID;
|
||||
expandoData[expanID] = data;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns a shived element for the given nodeName and document
|
||||
* @memberOf html5
|
||||
* @param {String} nodeName name of the element
|
||||
* @param {Document|DocumentFragment} ownerDocument The context document.
|
||||
* @returns {Object} The shived element.
|
||||
*/
|
||||
function createElement(nodeName, ownerDocument, data){
|
||||
if (!ownerDocument) {
|
||||
ownerDocument = document;
|
||||
}
|
||||
if(supportsUnknownElements){
|
||||
return ownerDocument.createElement(nodeName);
|
||||
}
|
||||
if (!data) {
|
||||
data = getExpandoData(ownerDocument);
|
||||
}
|
||||
var node;
|
||||
|
||||
if (data.cache[nodeName]) {
|
||||
node = data.cache[nodeName].cloneNode();
|
||||
} else if (saveClones.test(nodeName)) {
|
||||
node = (data.cache[nodeName] = data.createElem(nodeName)).cloneNode();
|
||||
} else {
|
||||
node = data.createElem(nodeName);
|
||||
}
|
||||
|
||||
// Avoid adding some elements to fragments in IE < 9 because
|
||||
// * Attributes like `name` or `type` cannot be set/changed once an element
|
||||
// is inserted into a document/fragment
|
||||
// * Link elements with `src` attributes that are inaccessible, as with
|
||||
// a 403 response, will cause the tab/window to crash
|
||||
// * Script elements appended to fragments will execute when their `src`
|
||||
// or `text` property is set
|
||||
return node.canHaveChildren && !reSkip.test(nodeName) && !node.tagUrn ? data.frag.appendChild(node) : node;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns a shived DocumentFragment for the given document
|
||||
* @memberOf html5
|
||||
* @param {Document} ownerDocument The context document.
|
||||
* @returns {Object} The shived DocumentFragment.
|
||||
*/
|
||||
function createDocumentFragment(ownerDocument, data){
|
||||
if (!ownerDocument) {
|
||||
ownerDocument = document;
|
||||
}
|
||||
if(supportsUnknownElements){
|
||||
return ownerDocument.createDocumentFragment();
|
||||
}
|
||||
data = data || getExpandoData(ownerDocument);
|
||||
var clone = data.frag.cloneNode(),
|
||||
i = 0,
|
||||
elems = getElements(),
|
||||
l = elems.length;
|
||||
for(;i<l;i++){
|
||||
clone.createElement(elems[i]);
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shivs the `createElement` and `createDocumentFragment` methods of the document.
|
||||
* @private
|
||||
* @param {Document|DocumentFragment} ownerDocument The document.
|
||||
* @param {Object} data of the document.
|
||||
*/
|
||||
function shivMethods(ownerDocument, data) {
|
||||
if (!data.cache) {
|
||||
data.cache = {};
|
||||
data.createElem = ownerDocument.createElement;
|
||||
data.createFrag = ownerDocument.createDocumentFragment;
|
||||
data.frag = data.createFrag();
|
||||
}
|
||||
|
||||
|
||||
ownerDocument.createElement = function(nodeName) {
|
||||
//abort shiv
|
||||
if (!html5.shivMethods) {
|
||||
return data.createElem(nodeName);
|
||||
}
|
||||
return createElement(nodeName, ownerDocument, data);
|
||||
};
|
||||
|
||||
ownerDocument.createDocumentFragment = Function('h,f', 'return function(){' +
|
||||
'var n=f.cloneNode(),c=n.createElement;' +
|
||||
'h.shivMethods&&(' +
|
||||
// unroll the `createElement` calls
|
||||
getElements().join().replace(/[\w\-:]+/g, function(nodeName) {
|
||||
data.createElem(nodeName);
|
||||
data.frag.createElement(nodeName);
|
||||
return 'c("' + nodeName + '")';
|
||||
}) +
|
||||
');return n}'
|
||||
)(html5, data.frag);
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Shivs the given document.
|
||||
* @memberOf html5
|
||||
* @param {Document} ownerDocument The document to shiv.
|
||||
* @returns {Document} The shived document.
|
||||
*/
|
||||
function shivDocument(ownerDocument) {
|
||||
if (!ownerDocument) {
|
||||
ownerDocument = document;
|
||||
}
|
||||
var data = getExpandoData(ownerDocument);
|
||||
|
||||
if (html5.shivCSS && !supportsHtml5Styles && !data.hasCSS) {
|
||||
data.hasCSS = !!addStyleSheet(ownerDocument,
|
||||
// corrects block display not defined in IE6/7/8/9
|
||||
'article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}' +
|
||||
// adds styling not present in IE6/7/8/9
|
||||
'mark{background:#FF0;color:#000}' +
|
||||
// hides non-rendered elements
|
||||
'template{display:none}'
|
||||
);
|
||||
}
|
||||
if (!supportsUnknownElements) {
|
||||
shivMethods(ownerDocument, data);
|
||||
}
|
||||
return ownerDocument;
|
||||
}
|
||||
|
||||
/*--------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* The `html5` object is exposed so that more elements can be shived and
|
||||
* existing shiving can be detected on iframes.
|
||||
* @type Object
|
||||
* @example
|
||||
*
|
||||
* // options can be changed before the script is included
|
||||
* html5 = { 'elements': 'mark section', 'shivCSS': false, 'shivMethods': false };
|
||||
*/
|
||||
var html5 = {
|
||||
|
||||
/**
|
||||
* An array or space separated string of node names of the elements to shiv.
|
||||
* @memberOf html5
|
||||
* @type Array|String
|
||||
*/
|
||||
'elements': options.elements || 'abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video',
|
||||
|
||||
/**
|
||||
* current version of html5shiv
|
||||
*/
|
||||
'version': version,
|
||||
|
||||
/**
|
||||
* A flag to indicate that the HTML5 style sheet should be inserted.
|
||||
* @memberOf html5
|
||||
* @type Boolean
|
||||
*/
|
||||
'shivCSS': (options.shivCSS !== false),
|
||||
|
||||
/**
|
||||
* Is equal to true if a browser supports creating unknown/HTML5 elements
|
||||
* @memberOf html5
|
||||
* @type boolean
|
||||
*/
|
||||
'supportsUnknownElements': supportsUnknownElements,
|
||||
|
||||
/**
|
||||
* A flag to indicate that the document's `createElement` and `createDocumentFragment`
|
||||
* methods should be overwritten.
|
||||
* @memberOf html5
|
||||
* @type Boolean
|
||||
*/
|
||||
'shivMethods': (options.shivMethods !== false),
|
||||
|
||||
/**
|
||||
* A string to describe the type of `html5` object ("default" or "default print").
|
||||
* @memberOf html5
|
||||
* @type String
|
||||
*/
|
||||
'type': 'default',
|
||||
|
||||
// shivs the document according to the specified `html5` object options
|
||||
'shivDocument': shivDocument,
|
||||
|
||||
//creates a shived element
|
||||
createElement: createElement,
|
||||
|
||||
//creates a shived documentFragment
|
||||
createDocumentFragment: createDocumentFragment,
|
||||
|
||||
//extends list of elements
|
||||
addElements: addElements
|
||||
};
|
||||
|
||||
/*--------------------------------------------------------------------------*/
|
||||
|
||||
// expose html5
|
||||
window.html5 = html5;
|
||||
|
||||
// shiv the document
|
||||
shivDocument(document);
|
||||
|
||||
if(typeof module == 'object' && module.exports){
|
||||
module.exports = html5;
|
||||
}
|
||||
|
||||
}(typeof window !== "undefined" ? window : this, document));
|
||||
Vendored
-2
File diff suppressed because one or more lines are too long
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
/*! Respond.js v1.4.2: min/max-width media query polyfill * Copyright 2013 Scott Jehl
|
||||
* Licensed under https://github.com/scottjehl/Respond/blob/master/LICENSE-MIT
|
||||
* */
|
||||
|
||||
!function(a){"use strict";a.matchMedia=a.matchMedia||function(a){var b,c=a.documentElement,d=c.firstElementChild||c.firstChild,e=a.createElement("body"),f=a.createElement("div");return f.id="mq-test-1",f.style.cssText="position:absolute;top:-100em",e.style.background="none",e.appendChild(f),function(a){return f.innerHTML='­<style media="'+a+'"> #mq-test-1 { width: 42px; }</style>',c.insertBefore(e,d),b=42===f.offsetWidth,c.removeChild(e),{matches:b,media:a}}}(a.document)}(this),function(a){"use strict";function b(){u(!0)}var c={};a.respond=c,c.update=function(){};var d=[],e=function(){var b=!1;try{b=new a.XMLHttpRequest}catch(c){b=new a.ActiveXObject("Microsoft.XMLHTTP")}return function(){return b}}(),f=function(a,b){var c=e();c&&(c.open("GET",a,!0),c.onreadystatechange=function(){4!==c.readyState||200!==c.status&&304!==c.status||b(c.responseText)},4!==c.readyState&&c.send(null))};if(c.ajax=f,c.queue=d,c.regex={media:/@media[^\{]+\{([^\{\}]*\{[^\}\{]*\})+/gi,keyframes:/@(?:\-(?:o|moz|webkit)\-)?keyframes[^\{]+\{(?:[^\{\}]*\{[^\}\{]*\})+[^\}]*\}/gi,urls:/(url\()['"]?([^\/\)'"][^:\)'"]+)['"]?(\))/g,findStyles:/@media *([^\{]+)\{([\S\s]+?)$/,only:/(only\s+)?([a-zA-Z]+)\s?/,minw:/\([\s]*min\-width\s*:[\s]*([\s]*[0-9\.]+)(px|em)[\s]*\)/,maxw:/\([\s]*max\-width\s*:[\s]*([\s]*[0-9\.]+)(px|em)[\s]*\)/},c.mediaQueriesSupported=a.matchMedia&&null!==a.matchMedia("only all")&&a.matchMedia("only all").matches,!c.mediaQueriesSupported){var g,h,i,j=a.document,k=j.documentElement,l=[],m=[],n=[],o={},p=30,q=j.getElementsByTagName("head")[0]||k,r=j.getElementsByTagName("base")[0],s=q.getElementsByTagName("link"),t=function(){var a,b=j.createElement("div"),c=j.body,d=k.style.fontSize,e=c&&c.style.fontSize,f=!1;return b.style.cssText="position:absolute;font-size:1em;width:1em",c||(c=f=j.createElement("body"),c.style.background="none"),k.style.fontSize="100%",c.style.fontSize="100%",c.appendChild(b),f&&k.insertBefore(c,k.firstChild),a=b.offsetWidth,f?k.removeChild(c):c.removeChild(b),k.style.fontSize=d,e&&(c.style.fontSize=e),a=i=parseFloat(a)},u=function(b){var c="clientWidth",d=k[c],e="CSS1Compat"===j.compatMode&&d||j.body[c]||d,f={},o=s[s.length-1],r=(new Date).getTime();if(b&&g&&p>r-g)return a.clearTimeout(h),h=a.setTimeout(u,p),void 0;g=r;for(var v in l)if(l.hasOwnProperty(v)){var w=l[v],x=w.minw,y=w.maxw,z=null===x,A=null===y,B="em";x&&(x=parseFloat(x)*(x.indexOf(B)>-1?i||t():1)),y&&(y=parseFloat(y)*(y.indexOf(B)>-1?i||t():1)),w.hasquery&&(z&&A||!(z||e>=x)||!(A||y>=e))||(f[w.media]||(f[w.media]=[]),f[w.media].push(m[w.rules]))}for(var C in n)n.hasOwnProperty(C)&&n[C]&&n[C].parentNode===q&&q.removeChild(n[C]);n.length=0;for(var D in f)if(f.hasOwnProperty(D)){var E=j.createElement("style"),F=f[D].join("\n");E.type="text/css",E.media=D,q.insertBefore(E,o.nextSibling),E.styleSheet?E.styleSheet.cssText=F:E.appendChild(j.createTextNode(F)),n.push(E)}},v=function(a,b,d){var e=a.replace(c.regex.keyframes,"").match(c.regex.media),f=e&&e.length||0;b=b.substring(0,b.lastIndexOf("/"));var g=function(a){return a.replace(c.regex.urls,"$1"+b+"$2$3")},h=!f&&d;b.length&&(b+="/"),h&&(f=1);for(var i=0;f>i;i++){var j,k,n,o;h?(j=d,m.push(g(a))):(j=e[i].match(c.regex.findStyles)&&RegExp.$1,m.push(RegExp.$2&&g(RegExp.$2))),n=j.split(","),o=n.length;for(var p=0;o>p;p++)k=n[p],l.push({media:k.split("(")[0].match(c.regex.only)&&RegExp.$2||"all",rules:m.length-1,hasquery:k.indexOf("(")>-1,minw:k.match(c.regex.minw)&&parseFloat(RegExp.$1)+(RegExp.$2||""),maxw:k.match(c.regex.maxw)&&parseFloat(RegExp.$1)+(RegExp.$2||"")})}u()},w=function(){if(d.length){var b=d.shift();f(b.href,function(c){v(c,b.href,b.media),o[b.href]=!0,a.setTimeout(function(){w()},0)})}},x=function(){for(var b=0;b<s.length;b++){var c=s[b],e=c.href,f=c.media,g=c.rel&&"stylesheet"===c.rel.toLowerCase();e&&g&&!o[e]&&(c.styleSheet&&c.styleSheet.rawCssText?(v(c.styleSheet.rawCssText,e,f),o[e]=!0):(!/^([a-zA-Z:]*\/\/)/.test(e)&&!r||e.replace(RegExp.$1,"").split("/")[0]===a.location.host)&&("//"===e.substring(0,2)&&(e=a.location.protocol+e),d.push({href:e,media:f})))}w()};x(),c.update=x,c.getEmValue=t,a.addEventListener?a.addEventListener("resize",b,!1):a.attachEvent&&a.attachEvent("onresize",b)}}(this);
|
||||
@@ -1,396 +0,0 @@
|
||||
// serverstatus.js. big data boom today.
|
||||
var error = 0;
|
||||
var d = 0;
|
||||
var server_status = new Array();
|
||||
|
||||
function timeSince(date) {
|
||||
if(date == 0)
|
||||
return "从未.";
|
||||
|
||||
var seconds = Math.floor((new Date() - date) / 1000);
|
||||
var interval = Math.floor(seconds / 60);
|
||||
if (interval > 1)
|
||||
return interval + " 分钟前.";
|
||||
else
|
||||
return "几秒前.";
|
||||
}
|
||||
|
||||
function bytesToSize(bytes, precision, si)
|
||||
{
|
||||
var ret;
|
||||
si = typeof si !== 'undefined' ? si : 0;
|
||||
if(si != 0) {
|
||||
var megabyte = 1000 * 1000;
|
||||
var gigabyte = megabyte * 1000;
|
||||
var terabyte = gigabyte * 1000;
|
||||
} else {
|
||||
var megabyte = 1024 * 1024;
|
||||
var gigabyte = megabyte * 1024;
|
||||
var terabyte = gigabyte * 1024;
|
||||
}
|
||||
|
||||
if ((bytes >= megabyte) && (bytes < gigabyte)) {
|
||||
ret = (bytes / megabyte).toFixed(precision) + ' M';
|
||||
|
||||
} else if ((bytes >= gigabyte) && (bytes < terabyte)) {
|
||||
ret = (bytes / gigabyte).toFixed(precision) + ' G';
|
||||
|
||||
} else if (bytes >= terabyte) {
|
||||
ret = (bytes / terabyte).toFixed(precision) + ' T';
|
||||
|
||||
} else {
|
||||
return bytes + ' B';
|
||||
}
|
||||
return ret;
|
||||
/*if(si != 0) {
|
||||
return ret + 'B';
|
||||
} else {
|
||||
return ret + 'iB';
|
||||
}*/
|
||||
}
|
||||
|
||||
function uptime() {
|
||||
$.getJSON("json/stats.json", function(result) {
|
||||
$("#loading-notice").remove();
|
||||
if(result.reload)
|
||||
setTimeout(function() { location.reload() }, 1000);
|
||||
|
||||
for (var i = 0, rlen=result.servers.length; i < rlen; i++) {
|
||||
var TableRow = $("#servers tr#r" + i);
|
||||
var ExpandRow = $("#servers #rt" + i);
|
||||
var hack; // fuck CSS for making me do this
|
||||
if(i%2) hack="odd"; else hack="even";
|
||||
if (!TableRow.length) {
|
||||
$("#servers").append(
|
||||
"<tr id=\"r" + i + "\" data-toggle=\"collapse\" data-target=\"#rt" + i + "\" class=\"accordion-toggle " + hack + "\">" +
|
||||
"<td id=\"online_status\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
|
||||
"<td id=\"month_traffic\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
|
||||
"<td id=\"name\">加载中</td>" +
|
||||
"<td id=\"type\">加载中</td>" +
|
||||
"<td id=\"location\">加载中</td>" +
|
||||
"<td id=\"uptime\">加载中</td>" +
|
||||
"<td id=\"load\">加载中</td>" +
|
||||
"<td id=\"network\">加载中</td>" +
|
||||
"<td id=\"traffic\">加载中</td>" +
|
||||
"<td id=\"cpu\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
|
||||
"<td id=\"memory\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
|
||||
"<td id=\"hdd\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
|
||||
"<td id=\"ping\"><div class=\"progress\"><div style=\"width: 100%;\" class=\"progress-bar progress-bar-warning\"><small>加载中</small></div></div></td>" +
|
||||
"</tr>" +
|
||||
"<tr class=\"expandRow " + hack + "\"><td colspan=\"16\"><div class=\"accordian-body collapse\" id=\"rt" + i + "\">" +
|
||||
"<div id=\"expand_mem\">加载中</div>" +
|
||||
"<div id=\"expand_swap\">加载中</div>" +
|
||||
"<div id=\"expand_hdd\">加载中</div>" +
|
||||
"<div id=\"expand_tupd\">加载中</div>" +
|
||||
"<div id=\"expand_ping\">加载中</div>" +
|
||||
"<div id=\"expand_custom\">加载中</div>" +
|
||||
"</div></td></tr>"
|
||||
);
|
||||
TableRow = $("#servers tr#r" + i);
|
||||
ExpandRow = $("#servers #rt" + i);
|
||||
server_status[i] = true;
|
||||
}
|
||||
TableRow = TableRow[0];
|
||||
if(error) {
|
||||
TableRow.setAttribute("data-target", "#rt" + i);
|
||||
server_status[i] = true;
|
||||
}
|
||||
|
||||
// online_status
|
||||
if (result.servers[i].online4 && !result.servers[i].online6) {
|
||||
TableRow.children["online_status"].children[0].children[0].className = "progress-bar progress-bar-success";
|
||||
TableRow.children["online_status"].children[0].children[0].innerHTML = "<small>IPv4</small>";
|
||||
} else if (result.servers[i].online4 && result.servers[i].online6) {
|
||||
TableRow.children["online_status"].children[0].children[0].className = "progress-bar progress-bar-success";
|
||||
TableRow.children["online_status"].children[0].children[0].innerHTML = "<small>双栈</small>";
|
||||
} else if (!result.servers[i].online4 && result.servers[i].online6) {
|
||||
TableRow.children["online_status"].children[0].children[0].className = "progress-bar progress-bar-success";
|
||||
TableRow.children["online_status"].children[0].children[0].innerHTML = "<small>IPv6</small>";
|
||||
} else {
|
||||
TableRow.children["online_status"].children[0].children[0].className = "progress-bar progress-bar-danger";
|
||||
TableRow.children["online_status"].children[0].children[0].innerHTML = "<small>关闭</small>";
|
||||
}
|
||||
|
||||
// Name
|
||||
TableRow.children["name"].innerHTML = result.servers[i].name;
|
||||
|
||||
// Type
|
||||
TableRow.children["type"].innerHTML = result.servers[i].type;
|
||||
|
||||
// Location
|
||||
TableRow.children["location"].innerHTML = result.servers[i].location;
|
||||
if (!result.servers[i].online4 && !result.servers[i].online6) {
|
||||
if (server_status[i]) {
|
||||
TableRow.children["uptime"].innerHTML = "–";
|
||||
TableRow.children["load"].innerHTML = "–";
|
||||
TableRow.children["network"].innerHTML = "–";
|
||||
TableRow.children["traffic"].innerHTML = "–";
|
||||
TableRow.children["month_traffic"].children[0].children[0].className = "progress-bar progress-bar-warning";
|
||||
TableRow.children["month_traffic"].children[0].children[0].innerHTML = "<small>关闭</small>";
|
||||
TableRow.children["cpu"].children[0].children[0].className = "progress-bar progress-bar-danger";
|
||||
TableRow.children["cpu"].children[0].children[0].style.width = "100%";
|
||||
TableRow.children["cpu"].children[0].children[0].innerHTML = "<small>关闭</small>";
|
||||
TableRow.children["memory"].children[0].children[0].className = "progress-bar progress-bar-danger";
|
||||
TableRow.children["memory"].children[0].children[0].style.width = "100%";
|
||||
TableRow.children["memory"].children[0].children[0].innerHTML = "<small>关闭</small>";
|
||||
TableRow.children["hdd"].children[0].children[0].className = "progress-bar progress-bar-danger";
|
||||
TableRow.children["hdd"].children[0].children[0].style.width = "100%";
|
||||
TableRow.children["hdd"].children[0].children[0].innerHTML = "<small>关闭</small>";
|
||||
TableRow.children["ping"].children[0].children[0].className = "progress-bar progress-bar-danger";
|
||||
TableRow.children["ping"].children[0].children[0].style.width = "100%";
|
||||
TableRow.children["ping"].children[0].children[0].innerHTML = "<small>关闭</small>";
|
||||
if(ExpandRow.hasClass("in")) {
|
||||
ExpandRow.collapse("hide");
|
||||
}
|
||||
TableRow.setAttribute("data-target", "");
|
||||
server_status[i] = false;
|
||||
}
|
||||
} else {
|
||||
if (!server_status[i]) {
|
||||
TableRow.setAttribute("data-target", "#rt" + i);
|
||||
server_status[i] = true;
|
||||
}
|
||||
|
||||
// month traffic
|
||||
var monthtraffic = "";
|
||||
var trafficdiff_in = result.servers[i].network_in - result.servers[i].last_network_in;
|
||||
var trafficdiff_out = result.servers[i].network_out - result.servers[i].last_network_out;
|
||||
if(trafficdiff_in < 1024*1024*1024*1024)
|
||||
monthtraffic += (trafficdiff_in/1024/1024/1024).toFixed(1) + "G";
|
||||
else
|
||||
monthtraffic += (trafficdiff_in/1024/1024/1024/1024).toFixed(1) + "T";
|
||||
monthtraffic += " | "
|
||||
if(trafficdiff_out < 1024*1024*1024*1024)
|
||||
monthtraffic += (trafficdiff_out/1024/1024/1024).toFixed(1) + "G";
|
||||
else
|
||||
monthtraffic += (trafficdiff_out/1024/1024/1024/1024).toFixed(1) + "T";
|
||||
TableRow.children["month_traffic"].children[0].children[0].className = "progress-bar progress-bar-success";
|
||||
TableRow.children["month_traffic"].children[0].children[0].innerHTML = "<small>"+monthtraffic+"</small>";
|
||||
|
||||
// Uptime
|
||||
TableRow.children["uptime"].innerHTML = result.servers[i].uptime;
|
||||
|
||||
// Load: default load_1, you can change show: load_1, load_5, load_15
|
||||
if(result.servers[i].load == -1) {
|
||||
TableRow.children["load"].innerHTML = "–";
|
||||
} else {
|
||||
TableRow.children["load"].innerHTML = result.servers[i].load_1.toFixed(2);
|
||||
}
|
||||
|
||||
// Network
|
||||
var netstr = "";
|
||||
if(result.servers[i].network_rx < 1024*1024)
|
||||
netstr += (result.servers[i].network_rx/1024).toFixed(1) + "K";
|
||||
else
|
||||
netstr += (result.servers[i].network_rx/1024/1024).toFixed(1) + "M";
|
||||
netstr += " | "
|
||||
if(result.servers[i].network_tx < 1024*1024)
|
||||
netstr += (result.servers[i].network_tx/1024).toFixed(1) + "K";
|
||||
else
|
||||
netstr += (result.servers[i].network_tx/1024/1024).toFixed(1) + "M";
|
||||
TableRow.children["network"].innerHTML = netstr;
|
||||
|
||||
//Traffic
|
||||
var trafficstr = "";
|
||||
if(result.servers[i].network_in < 1024*1024*1024*1024)
|
||||
trafficstr += (result.servers[i].network_in/1024/1024/1024).toFixed(1) + "G";
|
||||
else
|
||||
trafficstr += (result.servers[i].network_in/1024/1024/1024/1024).toFixed(1) + "T";
|
||||
trafficstr += " | "
|
||||
if(result.servers[i].network_out < 1024*1024*1024*1024)
|
||||
trafficstr += (result.servers[i].network_out/1024/1024/1024).toFixed(1) + "G";
|
||||
else
|
||||
trafficstr += (result.servers[i].network_out/1024/1024/1024/1024).toFixed(1) + "T";
|
||||
TableRow.children["traffic"].innerHTML = trafficstr;
|
||||
|
||||
// CPU
|
||||
if (result.servers[i].cpu >= 90)
|
||||
TableRow.children["cpu"].children[0].children[0].className = "progress-bar progress-bar-danger";
|
||||
else if (result.servers[i].cpu >= 80)
|
||||
TableRow.children["cpu"].children[0].children[0].className = "progress-bar progress-bar-warning";
|
||||
else
|
||||
TableRow.children["cpu"].children[0].children[0].className = "progress-bar progress-bar-success";
|
||||
TableRow.children["cpu"].children[0].children[0].style.width = result.servers[i].cpu + "%";
|
||||
TableRow.children["cpu"].children[0].children[0].innerHTML = result.servers[i].cpu + "%";
|
||||
|
||||
// Memory
|
||||
var Mem = ((result.servers[i].memory_used/result.servers[i].memory_total)*100.0).toFixed(0);
|
||||
if (Mem >= 90)
|
||||
TableRow.children["memory"].children[0].children[0].className = "progress-bar progress-bar-danger";
|
||||
else if (Mem >= 80)
|
||||
TableRow.children["memory"].children[0].children[0].className = "progress-bar progress-bar-warning";
|
||||
else
|
||||
TableRow.children["memory"].children[0].children[0].className = "progress-bar progress-bar-success";
|
||||
TableRow.children["memory"].children[0].children[0].style.width = Mem + "%";
|
||||
TableRow.children["memory"].children[0].children[0].innerHTML = Mem + "%";
|
||||
ExpandRow[0].children["expand_mem"].innerHTML = "内存: " + bytesToSize(result.servers[i].memory_used*1024, 2) + " / " + bytesToSize(result.servers[i].memory_total*1024, 2);
|
||||
// Swap
|
||||
ExpandRow[0].children["expand_swap"].innerHTML = "交换分区: " + bytesToSize(result.servers[i].swap_used*1024, 2) + " / " + bytesToSize(result.servers[i].swap_total*1024, 2);
|
||||
|
||||
// HDD
|
||||
var HDD = ((result.servers[i].hdd_used/result.servers[i].hdd_total)*100.0).toFixed(0);
|
||||
if (HDD >= 90)
|
||||
TableRow.children["hdd"].children[0].children[0].className = "progress-bar progress-bar-danger";
|
||||
else if (HDD >= 80)
|
||||
TableRow.children["hdd"].children[0].children[0].className = "progress-bar progress-bar-warning";
|
||||
else
|
||||
TableRow.children["hdd"].children[0].children[0].className = "progress-bar progress-bar-success";
|
||||
TableRow.children["hdd"].children[0].children[0].style.width = HDD + "%";
|
||||
TableRow.children["hdd"].children[0].children[0].innerHTML = HDD + "%";
|
||||
// IO Speed for HDD.
|
||||
// IO, 过小的B字节单位没有意义
|
||||
var io = "";
|
||||
if(result.servers[i].io_read < 1024*1024)
|
||||
io += parseInt(result.servers[i].io_read/1024) + "K";
|
||||
else
|
||||
io += parseInt(result.servers[i].io_read/1024/1024) + "M";
|
||||
io += " / "
|
||||
if(result.servers[i].io_write < 1024*1024)
|
||||
io += parseInt(result.servers[i].io_write/1024) + "K";
|
||||
else
|
||||
io += parseInt(result.servers[i].io_write/1024/1024) + "M";
|
||||
// Expand for HDD.
|
||||
ExpandRow[0].children["expand_hdd"].innerHTML = "硬盘|读写: " + bytesToSize(result.servers[i].hdd_used*1024*1024, 2) + " / " + bytesToSize(result.servers[i].hdd_total*1024*1024, 2) + " | " + io;
|
||||
|
||||
// delay time
|
||||
|
||||
// tcp, udp, process, thread count
|
||||
ExpandRow[0].children["expand_tupd"].innerHTML = "TCP/UDP/进/线: " + result.servers[i].tcp_count + " / " + result.servers[i].udp_count + " / " + result.servers[i].process_count+ " / " + result.servers[i].thread_count;
|
||||
ExpandRow[0].children["expand_ping"].innerHTML = "联通/电信/移动: " + result.servers[i].time_10010 + "ms / " + result.servers[i].time_189 + "ms / " + result.servers[i].time_10086 + "ms"
|
||||
|
||||
// ping
|
||||
var PING_10010 = result.servers[i].ping_10010.toFixed(0);
|
||||
var PING_189 = result.servers[i].ping_189.toFixed(0);
|
||||
var PING_10086 = result.servers[i].ping_10086.toFixed(0);
|
||||
if (PING_10010 >= 20 || PING_189 >= 20 || PING_10086 >= 20)
|
||||
TableRow.children["ping"].children[0].children[0].className = "progress-bar progress-bar-warning";
|
||||
else
|
||||
TableRow.children["ping"].children[0].children[0].className = "progress-bar progress-bar-success";
|
||||
TableRow.children["ping"].children[0].children[0].innerHTML = PING_10010 + "%💻" + PING_189 + "%💻" + PING_10086 + "%";
|
||||
|
||||
// Custom
|
||||
if (result.servers[i].custom) {
|
||||
ExpandRow[0].children["expand_custom"].innerHTML = result.servers[i].custom
|
||||
} else {
|
||||
ExpandRow[0].children["expand_custom"].innerHTML = ""
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
d = new Date(result.updated*1000);
|
||||
error = 0;
|
||||
}).fail(function(update_error) {
|
||||
if (!error) {
|
||||
$("#servers > tr.accordion-toggle").each(function(i) {
|
||||
var TableRow = $("#servers tr#r" + i)[0];
|
||||
var ExpandRow = $("#servers #rt" + i);
|
||||
TableRow.children["online_status"].children[0].children[0].className = "progress-bar progress-bar-error";
|
||||
TableRow.children["online_status"].children[0].children[0].innerHTML = "<small>错误</small>";
|
||||
TableRow.children["month_traffic"].children[0].children[0].className = "progress-bar progress-bar-error";
|
||||
TableRow.children["month_traffic"].children[0].children[0].innerHTML = "<small>错误</small>";
|
||||
TableRow.children["uptime"].children[0].children[0].className = "progress-bar progress-bar-error";
|
||||
TableRow.children["uptime"].children[0].children[0].innerHTML = "<small>错误</small>";
|
||||
TableRow.children["load"].children[0].children[0].className = "progress-bar progress-bar-error";
|
||||
TableRow.children["load"].children[0].children[0].innerHTML = "<small>错误</small>";
|
||||
TableRow.children["network"].children[0].children[0].className = "progress-bar progress-bar-error";
|
||||
TableRow.children["network"].children[0].children[0].innerHTML = "<small>错误</small>";
|
||||
TableRow.children["traffic"].children[0].children[0].className = "progress-bar progress-bar-error";
|
||||
TableRow.children["traffic"].children[0].children[0].innerHTML = "<small>错误</small>";
|
||||
TableRow.children["cpu"].children[0].children[0].className = "progress-bar progress-bar-error";
|
||||
TableRow.children["cpu"].children[0].children[0].style.width = "100%";
|
||||
TableRow.children["cpu"].children[0].children[0].innerHTML = "<small>错误</small>";
|
||||
TableRow.children["memory"].children[0].children[0].className = "progress-bar progress-bar-error";
|
||||
TableRow.children["memory"].children[0].children[0].style.width = "100%";
|
||||
TableRow.children["memory"].children[0].children[0].innerHTML = "<small>错误</small>";
|
||||
TableRow.children["hdd"].children[0].children[0].className = "progress-bar progress-bar-error";
|
||||
TableRow.children["hdd"].children[0].children[0].style.width = "100%";
|
||||
TableRow.children["hdd"].children[0].children[0].innerHTML = "<small>错误</small>";
|
||||
TableRow.children["ping"].children[0].children[0].className = "progress-bar progress-bar-error";
|
||||
TableRow.children["ping"].children[0].children[0].style.width = "100%";
|
||||
TableRow.children["ping"].children[0].children[0].innerHTML = "<small>错误</small>";
|
||||
if(ExpandRow.hasClass("in")) {
|
||||
ExpandRow.collapse("hide");
|
||||
}
|
||||
TableRow.setAttribute("data-target", "");
|
||||
server_status[i] = false;
|
||||
});
|
||||
}
|
||||
error = 1;
|
||||
$("#updated").html("更新错误.");
|
||||
});
|
||||
}
|
||||
|
||||
function updateTime() {
|
||||
if (!error)
|
||||
$("#updated").html("最后更新: " + timeSince(d));
|
||||
}
|
||||
|
||||
uptime();
|
||||
updateTime();
|
||||
setInterval(uptime, 2000);
|
||||
setInterval(updateTime, 2000);
|
||||
|
||||
|
||||
// styleswitcher.js
|
||||
function setActiveStyleSheet(title, cookie=false) {
|
||||
var i, a, main;
|
||||
for(i=0; (a = document.getElementsByTagName("link")[i]); i++) {
|
||||
if(a.getAttribute("rel").indexOf("style") != -1 && a.getAttribute("title")) {
|
||||
a.disabled = true;
|
||||
if(a.getAttribute("title") == title) a.disabled = false;
|
||||
}
|
||||
}
|
||||
if (true==cookie) {
|
||||
createCookie("style", title, 365);
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveStyleSheet() {
|
||||
var i, a;
|
||||
for(i=0; (a = document.getElementsByTagName("link")[i]); i++) {
|
||||
if(a.getAttribute("rel").indexOf("style") != -1 && a.getAttribute("title") && !a.disabled)
|
||||
return a.getAttribute("title");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createCookie(name,value,days) {
|
||||
if (days) {
|
||||
var date = new Date();
|
||||
date.setTime(date.getTime()+(days*24*60*60*1000));
|
||||
var expires = "; expires="+date.toGMTString();
|
||||
}
|
||||
else expires = "";
|
||||
document.cookie = name+"="+value+expires+"; path=/";
|
||||
}
|
||||
|
||||
function readCookie(name) {
|
||||
var nameEQ = name + "=";
|
||||
var ca = document.cookie.split(';');
|
||||
for(var i=0;i < ca.length;i++) {
|
||||
var c = ca[i];
|
||||
while (c.charAt(0)==' ')
|
||||
c = c.substring(1,c.length);
|
||||
if (c.indexOf(nameEQ) == 0)
|
||||
return c.substring(nameEQ.length,c.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
window.onload = function(e) {
|
||||
var cookie = readCookie("style");
|
||||
if (cookie && cookie != 'null' ) {
|
||||
setActiveStyleSheet(cookie);
|
||||
} else {
|
||||
function handleChange (mediaQueryListEvent) {
|
||||
if (mediaQueryListEvent.matches) {
|
||||
setActiveStyleSheet('dark');
|
||||
} else {
|
||||
setActiveStyleSheet('light');
|
||||
}
|
||||
}
|
||||
const mediaQueryListDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
setActiveStyleSheet(mediaQueryListDark.matches ? 'dark' : 'light');
|
||||
mediaQueryListDark.addEventListener("change",handleChange);
|
||||
}
|
||||
}
|
||||
@@ -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