Compare commits

...
147 Commits
Author SHA1 Message Date
cppla e0aae47efc update css
CI / validate (push) Has been cancelled
2026-07-01 19:17:07 +08:00
cppla a31fb176c4 update
CI / validate (push) Has been cancelled
2026-07-01 17:53:28 +08:00
cppla f19d10966d update version 1.1.8 2026-07-01 17:13:30 +08:00
cppla 9ac276d443 更新说明 2026-07-01 15:32:17 +08:00
cppla c58eaf6ff5 version 1.1.8 2026-07-01 11:55:45 +08:00
cppla f24a70b9d7 修复传递参数优先级的问题,防止错误获取系统变量 2026-03-20 11:51:05 +08:00
cppla 23a620274d linux尝试从os中获取信息 2026-02-28 14:10:35 +08:00
cppla e903200d66 默认psutil,容器下获取更精准的宿主机读写情况 2026-02-28 11:58:00 +08:00
cppla 57c74b0c9e update 2026-02-27 18:40:30 +08:00
cppla caf5d5c34d update 2026-02-27 18:35:57 +08:00
cppla ed64656a06 修复bug 2026-02-27 17:57:31 +08:00
cppla bbde4cb1e9 fix bug 2026-02-27 17:52:03 +08:00
cppla 678cf43077 fix bug 2026-02-27 17:24:33 +08:00
cppla 4bbd54dd06 fix bug 2026-02-27 17:12:31 +08:00
cppla eea08f529e fix bug 2026-02-27 17:12:12 +08:00
cppla d1ad6b0f43 client alpine images 2026-02-27 15:36:01 +08:00
cppla 570e14c1fa update docker compose 2026-02-27 13:27:58 +08:00
cppla 7b9da31db0 add client docker 2026-02-26 18:10:24 +08:00
cppla 6552959ef3 解决总流量pb显示异常的问题 2026-02-10 16:56:33 +08:00
cppla 14ee075853 ai fix a bug 2025-10-16 10:03:06 +08:00
cppla c1955d7ca5 AI fix AI Bug 2025-10-09 10:46:35 +08:00
cppla 41ec81bed3 del not used variable 2025-09-12 10:38:56 +08:00
cppla 1557673db2 ssl expire tips bug 2025-09-12 10:33:09 +08:00
cppla 565f8c7ce0 修复ssl提醒的bug 2025-09-11 11:49:59 +08:00
cppla 163c46f4de 1.1.7 latest 2025-09-03 17:20:36 +08:00
cppla a46ed4ea1a update readme 2025-09-03 16:22:22 +08:00
cppla 4d9372768f update 2025-09-03 16:03:37 +08:00
cppla eed03f641d 1.1.7 2025-09-03 16:01:34 +08:00
cppla 25effc0e2f update 1.1.7 2025-09-02 17:44:30 +08:00
cppla dcec9598c1 1.1.7 update 2025-09-02 17:43:43 +08:00
cppla f8527cc297 update config 2025-09-01 19:39:33 +08:00
cppla a4fc285be6 系统着色更改 2025-09-01 15:26:05 +08:00
cppla 195594b8c4 update 2025-09-01 14:40:18 +08:00
cppla 405e95bff8 update 2025-09-01 14:39:44 +08:00
cppla 72b621e277 硬盘仪表盘初始化颜色为青色 2025-09-01 10:20:55 +08:00
cppla c089ac7834 update 2025-08-31 22:38:03 +08:00
cppla ae648fbe66 fix macos color 2025-08-29 23:41:04 +08:00
cppla 68a1d3719f update 2025-08-29 19:32:51 +08:00
cppla 9cd86ccaa3 update 2025-08-29 17:34:12 +08:00
cppla 956a5ace26 1.1.6 patch 2025-08-29 17:05:20 +08:00
cppla 15fe753a0f 1.1.6修复大部分bug 2025-08-15 19:05:28 +08:00
cppla 0d185f628b update appjs 2025-08-15 18:51:01 +08:00
cppla 83977b55bf update gpt agent 2025-08-15 14:14:23 +08:00
cppla 49decdfed1 gpt commit 2025-08-15 13:59:12 +08:00
cppla 227e15fe48 Merge branch 'master' of github.com:cppla/ServerStatus 2025-08-15 13:34:55 +08:00
cppla 73b3d16ff6 fix some bug 2025-08-15 13:33:39 +08:00
cpplaandGitHub c04256535d Update README.md 2025-08-14 20:51:14 +08:00
cppla 3c8ebcf710 update fix some 2025-08-14 19:45:14 +08:00
cpplaandGitHub 9a19d2f4bf Update README.md 2025-08-14 14:02:56 +08:00
cppla d3c5e4a02a update 1.1.6 screen 2025-08-14 13:34:21 +08:00
cppla a03cda0ef0 update 2025-08-14 10:12:23 +08:00
cppla d25e7871ba update Dockerfile 2025-08-13 19:46:57 +08:00
cppla fd6c3532d9 update 2025-08-13 14:16:29 +08:00
cppla 89745f3c3a 3600 2025-08-13 13:27:37 +08:00
cppla 266230f5f0 update theme 2025-08-12 19:04:44 +08:00
cppla 4c17623dff update bug 2025-08-12 17:56:55 +08:00
cppla 34e30d9873 del 2025-08-12 17:23:20 +08:00
cppla 1e190878dd update theme 2025-08-12 17:19:19 +08:00
cppla 874c122a62 del pch 2025-08-12 15:42:17 +08:00
cppla fa984cfae6 update 2025-08-12 15:26:45 +08:00
cppla a544f72db0 restore makefile 2025-08-12 14:55:54 +08:00
cppla eea993c6cc update 2025-08-12 14:30:01 +08:00
cppla a4a3734c30 update 2025-08-12 14:01:50 +08:00
cppla a10e39b6f2 update readme 2025-08-12 13:54:17 +08:00
cppla 360d8d008d add ssl check 2025-08-12 13:49:25 +08:00
cpplaandGitHub 830938eac9 Update README.md 2025-04-01 10:35:26 +08:00
cppla 1a764ed104 1111 2024-12-02 14:13:00 +08:00
cppla b0c543b44b update Dockerfile 2024-11-25 17:39:07 +08:00
cppla f85c0a87f9 update tcp example 2024-11-25 16:50:32 +08:00
cpplaandGitHub 67d7c5ea0f Merge pull request #261 from ArashPartow/arashpartow/update_exprtk_0.0.3
Update ExprTk to 0.0.3
2024-11-25 16:42:00 +08:00
cppla 8576f81404 some update for probe tcp slow 2024-11-18 13:56:01 +08:00
cppla fa0ee2bf57 重要线路丢包率告警例子 2024-09-18 13:24:06 +08:00
cppla e9776b0e69 增加重要节点丢包率监控 2024-09-18 10:07:43 +08:00
cppla e01cc118dc update css 2024-09-13 09:26:07 +08:00
cpplaandGitHub 784c40b153 Update README.md 2024-09-11 18:01:05 +08:00
cppla 6068a2ba17 yes 2024-09-11 17:54:24 +08:00
cppla ced526824f 增加阿里云免费20G告警示例,限制username是aliyun1和aliyun2 2024-09-09 19:18:42 +08:00
cppla 7d2a73bf2e 修复月流量监控的bug,月流量加入表达式计算 2024-09-09 18:55:11 +08:00
cppla 51e2f664cd update theme set bug 2024-08-18 21:16:38 +08:00
cppla 937b1ea90f fix some bug 2024-08-18 21:13:53 +08:00
cpplaandGitHub b95d1945bf Update README.md 2024-08-18 19:44:57 +08:00
cpplaandGitHub e8446cd52f 我震惊的是AI已经能够帮助重构前端页面
我震惊的是AI已经能够帮助重构前端页面
2024-08-18 13:09:25 +08:00
cppla 6098f0fb1a commit new css by ai 2024-08-18 13:00:45 +08:00
cppla fc89a76cc2 resize css 2024-08-17 18:50:43 +08:00
cppla f290947b2c update theme 2024-08-17 17:36:57 +08:00
cppla b3adc6b782 update all 2024-08-17 17:31:40 +08:00
cppla 705a957606 bai piao copilot 2024-08-15 19:32:50 +08:00
cppla a307ad7d19 ai niu bi 2024-08-15 15:59:26 +08:00
cppla ad95e00723 不更新代码 无法续Copilot 2024-08-15 14:46:49 +08:00
cppla 31330168f3 ai modify 2024-08-15 14:18:34 +08:00
cppla d9e8f8a7c1 del ie 2024-08-15 14:02:33 +08:00
cppla 08f15ebdc5 15s -> 25s 2024-04-18 15:34:43 +08:00
cppla adb05818b9 改为15秒再次判断是否离线 2024-04-08 14:25:53 +08:00
cpplaandGitHub 4e73e8185e Update README.md 2024-04-03 22:06:15 +08:00
cpplaandGitHub 0fe01064a4 Update README.md 2024-04-03 21:59:23 +08:00
cppla 510567eaec 修复驴头不对马嘴的bug 2024-04-03 21:31:35 +08:00
cppla 503037c7e2 add two logs 2024-04-03 19:53:04 +08:00
cppla d75d5438a3 docker compose healthcheck 2024-04-03 17:44:43 +08:00
cppla 91f11dad76 屏蔽无用的nginx日志,增加健康检查wq 2024-04-03 17:16:32 +08:00
cppla fdc5abacfc fix bug for offline 2024-04-03 16:42:40 +08:00
cppla 388938e02b 解决了网络闪断导致的错误报警 2024-04-03 16:39:08 +08:00
cppla f912794068 add todo 2024-04-02 10:24:12 +08:00
cppla 6331d7d45b update 2024-03-11 15:06:47 +08:00
cppla 25f878a38a 5分钟一次probe service 2024-03-11 15:03:57 +08:00
cppla a8b9b2d00d 默认一天在线率 2024-02-04 14:43:19 +08:00
cppla dc3868998a 256->512 2024-02-04 14:37:39 +08:00
cppla 75d06c8666 add some ua 2024-01-27 15:27:10 +08:00
cppla 360252182d Merge branch 'dev' 2024-01-23 18:51:49 +08:00
cppla aa0ccd254c update readme 2024-01-23 18:50:15 +08:00
cpplaandGitHub f7b2e7db42 Merge pull request #242 from cppla/dev
1.1.2 测试
2024-01-23 18:10:19 +08:00
cppla 052c75ef23 update 2024-01-23 18:09:23 +08:00
cppla 1f7827cc21 change 2024-01-23 18:00:24 +08:00
cppla c236aee5dc todo list 2024-01-23 17:52:59 +08:00
cppla fb3ecd1796 增加一个todo 2024-01-23 17:43:27 +08:00
cppla 810f6a0d8a fix 2024-01-23 16:47:26 +08:00
cppla eddf37c413 fix bug 2024-01-23 10:30:43 +08:00
cppla 8dfc6d4718 update 2024-01-22 19:49:29 +08:00
cppla 03446b8f29 update some for cl 2024-01-22 15:34:44 +08:00
cppla 6ab046be51 update 2024-01-22 15:32:20 +08:00
cppla 94708c588c css resize 2024-01-22 15:21:06 +08:00
cppla 144987bcf1 .2f 2024-01-21 22:55:07 +08:00
cppla 7e46b1062e 新增说明,比较粗糙不建议更新 2024-01-21 21:34:07 +08:00
cppla 71cadcfebe 测试监控重要服务 2024-01-21 21:09:37 +08:00
cppla d96e711a34 change ms and lost rate 2024-01-19 16:03:43 +08:00
Arash Partow 5ebf076330 Update ExprTk to 0.0.3 2024-01-01 00:00:00 +00:00
cpplaandGitHub 4a9f5adb6b Update index3.html
cppla/Serverstatus version modify
2023-10-17 09:49:44 +08:00
cppla 59fa2d8eab fix light bug 2023-10-17 01:46:19 +00:00
cpplaandGitHub bd4c22edff Merge pull request #233 from cppla/dev
内置一个light theme
2023-10-16 19:36:58 +08:00
windows11 88229b2f4a 内置一个light theme 2023-10-16 19:35:22 +08:00
cpplaandGitHub d70a56a767 更加丰富的限制条件和规则
更加丰富的限制条件和规则
2023-10-16 18:35:03 +08:00
cpplaandGitHub a177345858 添加静态字符串的计算支持
添加静态字符串的计算支持
username / name / type / host / location
2023-10-16 17:53:46 +08:00
cpplaandGitHub 11a03a020c Merge pull request #232 from cppla/dev
静态字符串参与计算,同时给出样例
2023-10-16 17:42:09 +08:00
windows11 ed330bd785 c style 2023-10-16 17:07:57 +08:00
windows11 77cb62ae80 测试是否支持静态字符串断言 2023-10-16 16:57:02 +08:00
cpplaandGitHub 7bbca5e881 Update README.md 2023-10-13 13:54:02 +08:00
cpplaandGitHub 2e0d1f7fb9 Update README.md 2023-10-13 13:53:20 +08:00
cpplaandGitHub da83c54e5b Update README.md 2023-10-13 13:34:18 +08:00
cpplaandGitHub 3ef6cb52c1 Update README.md 2023-10-13 13:13:05 +08:00
cpplaandGitHub 67877bacfc Update README.md 2023-10-13 13:11:46 +08:00
cpplaandGitHub a8383efe93 Update README.md 2023-10-13 12:47:24 +08:00
cpplaandGitHub 03682772a1 静态字符串无法参与计算
提交一个表达式说明,静态字符串无法参与计算
2023-10-13 12:46:25 +08:00
cpplaandGitHub fdca4fd353 静态字符串变量无法参与计算。
静态字符串变量无法参与计算。
2023-10-13 12:30:03 +08:00
windows11 a5330dcf2f 修复username等静态变量的表达式支持 2023-10-13 11:58:46 +08:00
cpplaandGitHub be78a7cc88 Update README.md 2023-10-13 11:45:12 +08:00
cpplaandGitHub 46d43c247f Update light.css 2023-10-09 15:51:45 +08:00
cpplaandGitHub 6ae278857a Merge pull request #229 from cppla/cppla-patch-1
Update dark.css
2023-10-09 15:48:30 +08:00
cpplaandGitHub d5e59453f1 Update dark.css 2023-10-09 15:47:45 +08:00
41 changed files with 13375 additions and 5943 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.github
.idea
*.sublime-workspace
server/obj
server/sergate
web/json/stats.json
web/json/stats.json~
*.bak-*
__pycache__
*.pyc
+33
View File
@@ -0,0 +1,33 @@
name: CI
on:
push:
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y gcc g++ make libcurl4-openssl-dev python3
- name: Build server
run: make -C server -j2
- name: Check scripts
run: |
python3 -m py_compile server/manage_api.py clients/client-linux.py clients/client-psutil.py plugin/bot-telegram.py
sh -n server/entrypoint-server.sh clients/entrypoint.sh status.sh
node --check web/js/app.js
- name: Validate compose files
run: |
docker compose -f docker-compose-server.yml config
docker compose -f docker-compose-client.yml config
- name: Build Docker images
run: |
docker build -f Dockerfile.server -t serverstatus-server:test .
docker build -f Dockerfile.client -t serverstatus-client:test .
-29
View File
@@ -1,29 +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 libcurl4-openssl-dev
COPY . .
WORKDIR /server
RUN make -j
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/
# china time
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
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'
+29
View File
@@ -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 []
+42
View File
@@ -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"]
+298 -140
View File
@@ -1,181 +1,339 @@
# ServerStatus中文版
# ServerStatus 中文版
* ServerStatus中文版是一个酷炫高逼格的云探针云监控、服务器云监控、多服务器探针~
* 在线演示:https://tz.cloudcpp.com
ServerStatus 是一个轻量的服务器探针云监控面板,支持多节点在线状态、资源占用、三网延迟、服务监测、SSL 证书检查、Watchdog 告警和 Web 配置管理
在线演示:https://tz.cloudcpp.com
[![Python Support](https://img.shields.io/badge/python-3.6%2B%20-blue.svg)](https://github.com/cppla/ServerStatus)
[![C++ Compiler](http://img.shields.io/badge/C++-GNU-blue.svg?style=flat&logo=cplusplus)](https://github.com/cppla/ServerStatus)
[![License](https://img.shields.io/badge/license-MIT-4EB1BA.svg?style=flat-square)](https://github.com/cppla/ServerStatus)
[![Version](https://img.shields.io/badge/Version-Build%201.1.0-red)](https://github.com/cppla/ServerStatus)
[![Version](https://img.shields.io/badge/Version-Build%201.1.8-red)](https://github.com/cppla/ServerStatus)
![Latest Version](http://dl.cpp.la/Archive/serverstatus_1.0.9.png)
![Latest Host Version](https://dl.cpp.la/Archive/serverstatus_1_1_8.png)
`Watchdog🐶已经加入,触发式告警。 interval只是为了防止频繁收到报警信息造成骚扰,并不是探测间隔。`
`Watchdog 的 `interval` 是最小通知间隔,用于避免频繁报警,并不是探测间隔。`rule` 使用 Exprtk 表达式,当前窄字符解析对中文等 Unicode 字符不友好,规则中建议使用英文、数字和字段名。`
# 目录:
* clients 客户端文件
* server 服务端文件
* web 网站文件
## 一、服务端
* server/config.json 探针配置文件      
* web/json 探针月流量
# 部署:
【服务端】:
```bash
`Docker`:
wget --no-check-certificate -qO ~/serverstatus-config.json https://raw.githubusercontent.com/cppla/ServerStatus/master/server/config.json && mkdir ~/serverstatus-monthtraffic
docker run -d --restart=always --name=serverstatus -v ~/serverstatus-config.json:/ServerStatus/server/config.json -v ~/serverstatus-monthtraffic:/usr/share/nginx/html/json -p 80:80 -p 35601:35601 cppla/serverstatus:latest
`Docker-compose(推荐)`: docker-compose up -d
# 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
```
# 主题:
启动后访问:
* layuihttps://github.com/zeyudada/StatusServerLayui ,预览https://sslt.8zyw.cn
<img src=https://dl.cpp.la/Archive/serverstatus_layui.png width=200 height=100 />
* lighthttps://github.com/orilights/ServerStatus-Theme-Light ,预览:https://tz.cloudcpp.com/index3.html
<img src=https://github.com/orilights/ServerStatus-Theme-Light/blob/main/docs/screenshot.png width=200 height=100 />
- WebUIhttp://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配置页无法修改。
# 手动安装教程:
## 二、客户端
**【服务端配置】**
#### 一、生成服务端程序
```
`Debian/Ubuntu`: apt-get -y install gcc g++ make libcurl4-openssl-dev
`Centos/Redhat`: yum -y install gcc gcc-c++ make libcurl-devel
cd ServerStatus/server && make
./sergate
```
如果没错误提示,OK,ctrl+c关闭;如果有错误提示,检查35601端口是否被占用
#### 二、修改配置文件
```diff
! watchdog rule 可以为任何已知字段的表达式。
! watchdog interval 最小通知间隔。
! watchdog callback 可自定义为Post方法的URL,告警内容将拼接其后并发起回调。
! watchdog callback Telegram: https://api.telegram.org/bot你自己的密钥/sendMessage?parse_mode=HTML&disable_web_page_preview=true&chat_id=你自己的标识&text=
! watchdog callback Server酱: https://sctapi.ftqq.com/你自己的密钥.send?title=ServerStatus&desp=
! watchdog callback PushDeer: https://api2.pushdeer.com/message/push?pushkey=你自己的密钥&text=
! watchdog callback BasicAuth: https://用户名:密码@你自己的域名/api/push?message=
```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": "vps-1",
"type": "kvm",
"host": "chengdu",
"location": "🇨🇳",
"password": "USER_DEFAULT_PASSWORD",
"monthstart": 1
},
],
"watchdog":
[
{
"name": "服务器负载高监控",
"rule": "cpu>90&load_5>3",
"interval": 600,
"callback": "https://yourSMSurl"
},
{
"name": "服务器内存使用率过高监控",
"rule": "(memory_used/memory_total)*100>90",
"interval": 600,
"callback": "https://yourSMSurl"
},
{
"name": "服务器宕机告警,排除经常掉线的",
"rule": "online4=0&online6=0&username!=s21",
"interval": 600,
"callback": "https://yourSMSurl"
},
{
"name": "DDOS和CC攻击监控",
"rule": "tcp_count>600",
"interval": 300,
"callback": "https://yourSMSurl"
},
{
"name": "服务器月出口流量999GB告警",
"rule": "(network_out-last_network_out)/1024/1024/1024>999",
"interval": 3600,
"callback": "https://yourSMSurl"
},
{
"name": "你可以组合任何已知字段的表达式",
"rule": "(hdd_used/hdd_total)*100>95",
"interval": 1800,
"callback": "https://yourSMSurl"
}
]
"servers": [
{
"username": "s01",
"name": "node1",
"type": "kvm",
"host": "host1",
"location": "CN",
"password": "USER_DEFAULT_PASSWORD",
"monthstart": 1
}
],
"monitors": [
{
"name": "example",
"host": "https://example.com",
"interval": 600,
"type": "https"
}
],
"sslcerts": [
{
"name": "example",
"domain": "https://example.com",
"port": 443,
"interval": 7200,
"callback": "https://yourSMSurl"
}
],
"watchdog": [
{
"name": "offline warning",
"rule": "online4=0&online6=0",
"interval": 600,
"callback": "https://yourSMSurl"
},
{
"name": "cpu high warning",
"rule": "cpu>90&load_1>5&username!='s01'",
"interval": 600,
"callback": "https://yourSMSurl"
}
]
}
```
#### 三、拷贝ServerStatus/status到你的网站目录
例如:
```
sudo cp -r ServerStatus/web/* /home/wwwroot/default
常见 Watchdog 回调:
```text
Telegram: https://api.telegram.org/bot你的密钥/sendMessage?parse_mode=HTML&disable_web_page_preview=true&chat_id=你的标识&text=
Server酱: https://sctapi.ftqq.com/你的密钥.send?title=ServerStatus&desp=
PushDeer: https://api2.pushdeer.com/message/push?pushkey=你的密钥&text=
HttpBasicAuth: https://用户名:密码@你的域名/api/push?message=
```
#### 四、运行服务端:
web-dir参数为上一步设置的网站根目录,务必修改成自己网站的路径
```
./sergate --config=config.json --web-dir=/home/wwwroot/default
## 源码编译和运行
服务端依赖:
```bash
# Debian/Ubuntu
apt-get -y install gcc g++ make libcurl4-openssl-dev python3 nginx openssl
# CentOS/RedHat
yum -y install gcc gcc-c++ make libcurl-devel python3 nginx openssl
```
**【客户端配置】**
编译并运行 `sergate`
客户端有两个版本,client-linux为普通linuxclient-psutil为跨平台版,普通版不成功,换成跨平台版即可。
#### 一、client-linux版配置:
1、vim client-linux.py, 修改SERVER地址,username帐号, password密码
2、python3 client-linux.py 运行即可。
#### 二、client-psutil版配置:
1、安装psutil跨平台依赖库
```bash
cd ServerStatus/server
make
mkdir -p ../web/json
./sergate --config=config.json --web-dir=../web &
echo $! > /tmp/serverstatus-sergate.pid
```
`Debian/Ubuntu`: apt -y install python3-pip && pip3 install psutil
`Centos/Redhat`: yum -y install python3-pip gcc python3-devel && pip3 install psutil
`Windows`: https://pypi.org/project/psutil/
如果只需要 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
```
2、vim client-psutil.py, 修改SERVER地址,username帐号, password密码
3、python3 client-psutil.py 运行即可。
服务器和客户端自行加入开机启动,或进程守护,或后台方式运行。 例如: nohup python3 client-linux.py &
源码方式直连 API
`extra scene (run web/ssview.py)`
![Shell View](https://dl.cpp.la/Archive/serverstatus-shell.png?version=2023)
```bash
curl http://127.0.0.1:35602/api/health
curl -H 'Authorization: Bearer your-strong-token' \
http://127.0.0.1:35602/api/config
```
如果要通过 WebUI 使用「配置」页,需要 nginx 同时提供静态文件并反代 `/api/`。示例配置:
# Make Better
```nginx
server {
listen 8080;
server_name _;
root /path/to/ServerStatus/web;
index index.html;
location / {
try_files $uri $uri/ =404;
}
location /json/ {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location /api/ {
proxy_pass http://127.0.0.1:35602;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
}
```
启动失败时优先检查端口占用:`35601``sergate` 客户端上报端口,`35602` 是源码方式的管理 API 端口,`8080` 是上面示例的 Web 端口。源码手动运行没有 Docker 入口脚本守护;调用 `/api/restart` 会向 `sergate` 发送 `SIGTERM`,需要你用 systemd、supervisor 或 shell 循环自行拉起。
## 客户端源码运行
`client-linux.py`
```bash
wget --no-check-certificate -qO client-linux.py \
https://raw.githubusercontent.com/cppla/ServerStatus/master/clients/client-linux.py
nohup python3 client-linux.py SERVER=127.0.0.1 USER=s01 PASSWORD=USER_DEFAULT_PASSWORD \
>/dev/null 2>&1 &
```
`client-psutil.py`
```bash
# Debian/Ubuntu
apt -y install python3-psutil
# CentOS/RedHat
yum -y install python3-pip gcc python3-devel
pip3 install psutil
python3 clients/client-psutil.py SERVER=127.0.0.1 USER=s01
```
后台运行与开机启动:
```bash
nohup python3 client-linux.py SERVER=127.0.0.1 USER=s01 &
# crontab -e
@reboot /usr/bin/python3 /path/to/client-linux.py SERVER=127.0.0.1 USER=s01
```
## 本地构建镜像
```bash
docker build -f Dockerfile.server -t cppla/serverstatus:server .
docker build -f Dockerfile.client -t cppla/serverstatus:client .
```
## Make Better
* BotoXhttps://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>
* mojedahttps://github.com/mojeda
* mojeda's ServerStatushttps://github.com/mojeda/ServerStatus
* BlueVM's projecthttp://www.lowendtalk.com/discussion/comment/169690#Comment_169690
+189 -18
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3
# coding: utf-8
# Update by : https://github.com/cppla/ServerStatus, Update date: 20220530
# 版本:1.0.3, 支持Python版本:2.7 to 3.10
# 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,6 +199,7 @@ diskIO = {
'read': 0,
'write': 0
}
monitorServer = {}
def _ping_thread(host, mark, port):
lostPacket = 0
@@ -314,6 +364,70 @@ def get_realtime_data():
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
@@ -360,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
@@ -378,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)
@@ -401,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
@@ -412,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
+158 -12
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
# coding: utf-8
# Update by : https://github.com/cppla/ServerStatus, Update date: 20220530
# Update by : https://github.com/cppla/ServerStatus, Update date: 20250902
# 依赖于psutil跨平台库
# 版本:1.0.3, 支持Python版本:2.7 to 3.10
# 版本:1.1.0, 支持Python版本:3.6+
# 支持操作系统: Linux, Windows, OSX, Sun Solaris, FreeBSD, OpenBSD and NetBSD, both 32-bit and 64-bit architectures
# 说明: 默认情况下修改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())
@@ -148,6 +174,7 @@ diskIO = {
'read': 0,
'write': 0
}
monitorServer = {}
def _ping_thread(host, mark, port):
lostPacket = 0
@@ -303,6 +330,72 @@ def get_realtime_data():
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
@@ -349,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
@@ -368,7 +480,6 @@ if __name__ == '__main__':
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)
@@ -391,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
@@ -402,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
+20
View File
@@ -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
+27
View File
@@ -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}"
+30
View File
@@ -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
-25
View File
@@ -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
+6 -2
View File
@@ -1,4 +1,5 @@
OUT = sergate
.DEFAULT_GOAL := $(OUT)
#CC = clang
CC = gcc
@@ -19,10 +20,13 @@ C_OBJS := $(patsubst $(SDIR)/%.c,$(ODIR)/%.o,$(C_SRCS))
CXX_OBJS := $(patsubst $(SDIR)/%.cpp,$(ODIR)/%.o,$(CXX_SRCS))
OBJS := $(C_OBJS) $(CXX_OBJS)
$(ODIR)/%.o: $(SDIR)/%.c
$(ODIR):
mkdir -p $(ODIR)
$(ODIR)/%.o: $(SDIR)/%.c | $(ODIR)
$(CC) -c $(INC) $(CFLAGS) $< -o $@
$(ODIR)/%.o: $(SDIR)/%.cpp
$(ODIR)/%.o: $(SDIR)/%.cpp | $(ODIR)
$(CXX) -c $(INC) $(CXXFLAGS) $< -o $@
$(OUT): $(OBJS)
+65 -16
View File
@@ -19,14 +19,14 @@
"monthstart": 1
},
{
"disabled": true,
"username": "s03",
"name": "node3",
"type": "hyper",
"host": "host3",
"location": "🇫🇷",
"password": "USER_DEFAULT_PASSWORD",
"monthstart": 1
"monthstart": 1,
"disabled": false
},
{
"username": "s04",
@@ -38,16 +38,53 @@
"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",
"rule": "cpu>90&load_1>3",
"name": "cpu high warning,exclude username s01",
"rule": "cpu>90&load_1>5&username!='s01'",
"interval": 600,
"callback": "https://yourSMSurl"
},
{
"name": "memory high warning",
"rule": "(memory_used/memory_total)*100>90",
"name": "memory high warning, exclude less than 1GB vps",
"rule": "(memory_used/memory_total)*100>90&memory_total>1048576",
"interval": 300,
"callback": "https://yourSMSurl"
},
@@ -58,17 +95,29 @@
"callback": "https://yourSMSurl"
},
{
"name": "ddcc attack",
"rule": "tcp_count>500",
"interval": 300,
"callback": "https://yourSMSurl"
},
"name": "ddcc attack,limit type Oracle",
"rule": "tcp_count>600&type='Oracle'",
"interval": 300,
"callback": "https://yourSMSurl"
},
{
"name": "month traffic warning",
"rule": "(network_out-last_network_out)/1024/1024/1024>999",
"interval": 3600,
"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",
+58
View File
@@ -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
+485
View File
@@ -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()
+26
View File
@@ -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;
}
}
+9892 -4572
View File
File diff suppressed because it is too large Load Diff
+16 -12
View File
@@ -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;
+430 -29
View File
@@ -9,6 +9,187 @@
#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>
@@ -18,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);
@@ -92,19 +270,24 @@ 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)
{
int ClientID = ClientNetToClient(ClientNetID);
dbg_msg("main", "OnDelClient(ncid=%d, cid=%d)", ClientNetID, ClientID);
//copy offline message for watchdog
WatchdogMessage(ClientNetID,
0, 0, 0, 0, 0, 0,
0, 0, 0,0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0,0, 0, 0,
0, 0, 0, 0);
if(ClientID >= 0 && ClientID < NET_MAX_CLIENTS)
{
Client(ClientID)->m_Connected = false;
@@ -112,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)
@@ -199,6 +386,9 @@ 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,
@@ -207,7 +397,8 @@ int CMain::HandleMessage(int ClientNetID, char *pMessage)
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_Stats.m_MemTotal,
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,
@@ -259,19 +450,51 @@ int CMain::HandleMessage(int ClientNetID, char *pMessage)
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 memory_total, double memory_used,
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);
@@ -289,6 +512,8 @@ void CMain::WatchdogMessage(int ClientNetID, double load_1, double load_5, doubl
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);
@@ -310,10 +535,14 @@ void CMain::WatchdogMessage(int ClientNetID, double load_1, double load_5, doubl
if (expression.value() > 0)
{
int ClientID = ClientNetToClient(ClientNetID);
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;
@@ -330,11 +559,11 @@ void CMain::WatchdogMessage(int ClientNetID, double load_1, double load_5, doubl
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,
Client(ClientID)->m_aUsername,
Client(ClientID)->m_aName,
Client(ClientID)->m_aType,
Client(ClientID)->m_aHost,
Client(ClientID)->m_aLocation);
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
@@ -407,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,
@@ -417,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];
@@ -459,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(&currentStamp));
//url encode, Rules conflict with url special characterseg&, 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
@@ -559,8 +903,61 @@ int CMain::ReadConfig()
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));
}
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 recordreset m_LastNetworkIN and m_LastNetworkOUT
// support by: https://cpp.la
@@ -635,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)
+34 -4
View File
@@ -77,7 +77,9 @@ 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;
@@ -90,14 +92,39 @@ class CMain
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);
@@ -108,11 +135,14 @@ public:
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 memory_total,
double memory_used,double swap_total, double swap_used, double hdd_total,
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]; }
+1 -1
View File
@@ -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
View File
@@ -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;}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-6
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-71
View File
@@ -1,71 +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; }
.container { width: 1280px; }
.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: 110px; }
#cpu, #ram, #hdd { min-width: 45px; max-width: 90px; }
#ping { max-width: 110px; }
@media only screen and (max-width: 1200px) {
#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; }
}
-68
View File
@@ -1,68 +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; }
.container { width: 1280px; }
.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: 110px; }
#cpu, #ram, #hdd { min-width: 45px; max-width: 90px; }
#ping { max-width: 110px; }
@media only screen and (max-width: 1200px) {
#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; }
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

+7
View File
@@ -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

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+200 -106
View File
@@ -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
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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);
-7
View File
File diff suppressed because one or more lines are too long
-326
View File
@@ -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));
-2
View File
File diff suppressed because one or more lines are too long
-5
View File
@@ -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='&shy;<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);
-399
View File
@@ -1,399 +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_hdd\">加载中</div>" +
"<div id=\"expand_tupd\">加载中</div>" +
"<div id=\"expand_ping\">加载中</div>" +
"<div id=\"expand_lost\">加载中</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 + "%";
// 内存|swap
ExpandRow[0].children["expand_mem"].innerHTML = "内存|虚存: " + bytesToSize(result.servers[i].memory_used*1024, 1) + " / " + bytesToSize(result.servers[i].memory_total*1024, 1) + " | " + bytesToSize(result.servers[i].swap_used*1024, 0) + " / " + bytesToSize(result.servers[i].swap_total*1024, 0);
// 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);
ExpandRow[0].children["expand_lost"].innerHTML = "丢包:联通/电信/移动: " + PING_10010 + "% / " + PING_189 + "% / " + PING_10086 + "%"
if (PING_10010 >= 20 || PING_189 >= 20 || PING_10086 >= 20)
TableRow.children["ping"].children[0].children[0].className = "progress-bar progress-bar-danger";
else if (PING_10010 >= 10 || PING_189 >= 10 || PING_10086 >= 10)
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);
}
}
-83
View File
@@ -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)