mirror of
https://github.com//cppla/ServerStatus
synced 2026-09-20 16:00:15 +08:00
chore: complete 2.0.2 maintenance baseline
This commit is contained in:
@@ -15,6 +15,15 @@ jobs:
|
|||||||
go-version-file: server/go.mod
|
go-version-file: server/go.mod
|
||||||
cache-dependency-path: server/go.sum
|
cache-dependency-path: server/go.sum
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 11.19.0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
- name: Test Go server
|
- name: Test Go server
|
||||||
working-directory: server
|
working-directory: server
|
||||||
run: |
|
run: |
|
||||||
@@ -26,10 +35,19 @@ jobs:
|
|||||||
- name: Check clients, WebUI and shell scripts
|
- name: Check clients, WebUI and shell scripts
|
||||||
run: |
|
run: |
|
||||||
python3 -m py_compile clients/client-linux.py clients/client-psutil.py
|
python3 -m py_compile clients/client-linux.py clients/client-psutil.py
|
||||||
python3 -m unittest clients/test_client_args.py
|
python3 -m unittest discover -s clients -p 'test_*.py'
|
||||||
sh -n clients/entrypoint.sh
|
sh -n clients/entrypoint.sh
|
||||||
|
sh -n tests/run-webui-server.sh tests/docker-smoke.sh
|
||||||
bash -n status.sh
|
bash -n status.sh
|
||||||
node --check web/js/app.js
|
node --check web/js/app.js
|
||||||
|
node --check tests/webui/webui.spec.js
|
||||||
|
node --check playwright.config.js
|
||||||
|
|
||||||
|
- name: Test WebUI in Chromium
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm exec playwright install --with-deps chromium
|
||||||
|
pnpm test:webui
|
||||||
|
|
||||||
- name: Validate compose files
|
- name: Validate compose files
|
||||||
run: |
|
run: |
|
||||||
@@ -40,3 +58,18 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker build -f Dockerfile.server -t serverstatus-server:test .
|
docker build -f Dockerfile.server -t serverstatus-server:test .
|
||||||
docker build -f Dockerfile.client -t serverstatus-client:test .
|
docker build -f Dockerfile.client -t serverstatus-client:test .
|
||||||
|
|
||||||
|
- name: Test Docker server-client connection
|
||||||
|
run: tests/docker-smoke.sh
|
||||||
|
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build client image for AMD64 and ARM64
|
||||||
|
run: |
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--file Dockerfile.client \
|
||||||
|
--output type=cacheonly \
|
||||||
|
.
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
default.sublime-workspace
|
default.sublime-workspace
|
||||||
|
|
||||||
|
# Browser test dependencies and artifacts
|
||||||
|
node_modules/
|
||||||
|
output/
|
||||||
|
|
||||||
# pycharm
|
# pycharm
|
||||||
.idea
|
.idea
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM alpine:3.13
|
FROM alpine:3.22
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,8 @@ Systemd 示例位于 `service/status-server.service`。一键脚本 `status.sh`
|
|||||||
|
|
||||||
## 构建和测试
|
## 构建和测试
|
||||||
|
|
||||||
|
Go 测试需要 Go `1.25+`;WebUI 行为测试需要 Node.js `20+` 和 pnpm。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Go 单元、协议、API、TLS 和回调测试
|
# Go 单元、协议、API、TLS 和回调测试
|
||||||
cd server
|
cd server
|
||||||
@@ -311,17 +313,32 @@ go test ./...
|
|||||||
go test -race ./...
|
go test -race ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
# Docker 镜像
|
# Python 客户端指标、参数与平台识别测试
|
||||||
cd ..
|
cd ..
|
||||||
|
python3 -m unittest discover -s clients -p 'test_*.py'
|
||||||
|
|
||||||
|
# WebUI Chromium 行为测试
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm exec playwright install chromium
|
||||||
|
pnpm test:webui
|
||||||
|
|
||||||
|
# Docker 镜像
|
||||||
docker build -f Dockerfile.server -t cppla/serverstatus:server .
|
docker build -f Dockerfile.server -t cppla/serverstatus:server .
|
||||||
docker build -f Dockerfile.client -t cppla/serverstatus:client .
|
docker build -f Dockerfile.client -t cppla/serverstatus:client .
|
||||||
|
|
||||||
|
# 服务端与客户端真实连接、认证和指标上报
|
||||||
|
SERVER_IMAGE=cppla/serverstatus:server \
|
||||||
|
CLIENT_IMAGE=cppla/serverstatus:client \
|
||||||
|
tests/docker-smoke.sh
|
||||||
|
|
||||||
# Compose 配置
|
# Compose 配置
|
||||||
docker compose -f docker-compose-server.yml config
|
docker compose -f docker-compose-server.yml config
|
||||||
docker compose -f docker-compose-client.yml config
|
docker compose -f docker-compose-client.yml config
|
||||||
```
|
```
|
||||||
|
|
||||||
CI 还会检查 Go 格式、Python 客户端、Shell 脚本、WebUI JavaScript、服务端/客户端 Compose 文件和两个 Docker 镜像。
|
Docker 联通测试在 Linux 上覆盖客户端的 `host` 网络和 `pid` 模式;Docker Desktop 未启用 Host Networking 时会明确提示并使用隔离 bridge 完成本地协议测试。
|
||||||
|
|
||||||
|
CI 还会运行 Chromium 行为测试、Docker 服务端/客户端联通测试,并验证客户端镜像可同时构建为 AMD64 和 ARM64。
|
||||||
|
|
||||||
## 从旧服务端迁移
|
## 从旧服务端迁移
|
||||||
|
|
||||||
|
|||||||
+46
-35
@@ -208,6 +208,34 @@ def get_cpu_model():
|
|||||||
return vendor
|
return vendor
|
||||||
return normalize_cpu_model(lscpu.get('architecture') or platform.machine() or platform.processor())
|
return normalize_cpu_model(lscpu.get('architecture') or platform.machine() or platform.processor())
|
||||||
|
|
||||||
|
def get_os_name():
|
||||||
|
try:
|
||||||
|
sysname = platform.system().lower()
|
||||||
|
if sysname.startswith('linux'):
|
||||||
|
os_name = 'linux'
|
||||||
|
try:
|
||||||
|
with open('/etc/os-release') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith('ID='):
|
||||||
|
value = line.strip().split('=', 1)[1].strip().strip('"')
|
||||||
|
if value:
|
||||||
|
os_name = value
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return os_name
|
||||||
|
if sysname.startswith('darwin'):
|
||||||
|
return 'darwin'
|
||||||
|
if sysname.startswith('freebsd'):
|
||||||
|
return 'freebsd'
|
||||||
|
if sysname.startswith('openbsd'):
|
||||||
|
return 'openbsd'
|
||||||
|
if sysname.startswith('netbsd'):
|
||||||
|
return 'netbsd'
|
||||||
|
return sysname or 'unknown'
|
||||||
|
except Exception:
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
def liuliang():
|
def liuliang():
|
||||||
NET_IN = 0
|
NET_IN = 0
|
||||||
NET_OUT = 0
|
NET_OUT = 0
|
||||||
@@ -276,6 +304,22 @@ diskIO = {
|
|||||||
}
|
}
|
||||||
monitorServer = {}
|
monitorServer = {}
|
||||||
|
|
||||||
|
def update_net_speed(avgrx, avgtx, now_clock=None):
|
||||||
|
if now_clock is None:
|
||||||
|
now_clock = time.monotonic()
|
||||||
|
previous_clock = netSpeed.get("clock", 0.0)
|
||||||
|
previous_rx = netSpeed.get("avgrx", 0)
|
||||||
|
previous_tx = netSpeed.get("avgtx", 0)
|
||||||
|
diff = now_clock - previous_clock
|
||||||
|
initialized = previous_clock > 0 and diff > 0
|
||||||
|
netSpeed["diff"] = diff if initialized else 0.0
|
||||||
|
netSpeed["clock"] = now_clock
|
||||||
|
netSpeed["netrx"] = int((avgrx - previous_rx) / diff) if initialized and avgrx >= previous_rx else 0
|
||||||
|
netSpeed["nettx"] = int((avgtx - previous_tx) / diff) if initialized and avgtx >= previous_tx else 0
|
||||||
|
netSpeed["avgrx"] = avgrx
|
||||||
|
netSpeed["avgtx"] = avgtx
|
||||||
|
return netSpeed["netrx"], netSpeed["nettx"]
|
||||||
|
|
||||||
def _ping_thread(host, mark, port):
|
def _ping_thread(host, mark, port):
|
||||||
lostPacket = 0
|
lostPacket = 0
|
||||||
packet_queue = Queue(maxsize=PING_PACKET_HISTORY_LEN)
|
packet_queue = Queue(maxsize=PING_PACKET_HISTORY_LEN)
|
||||||
@@ -330,13 +374,7 @@ def _net_speed():
|
|||||||
dev = dev[1].split()
|
dev = dev[1].split()
|
||||||
avgrx += int(dev[0])
|
avgrx += int(dev[0])
|
||||||
avgtx += int(dev[8])
|
avgtx += int(dev[8])
|
||||||
now_clock = time.time()
|
update_net_speed(avgrx, avgtx)
|
||||||
netSpeed["diff"] = now_clock - netSpeed["clock"]
|
|
||||||
netSpeed["clock"] = now_clock
|
|
||||||
netSpeed["netrx"] = int((avgrx - netSpeed["avgrx"]) / netSpeed["diff"])
|
|
||||||
netSpeed["nettx"] = int((avgtx - netSpeed["avgtx"]) / netSpeed["diff"])
|
|
||||||
netSpeed["avgrx"] = avgrx
|
|
||||||
netSpeed["avgtx"] = avgtx
|
|
||||||
time.sleep(INTERVAL)
|
time.sleep(INTERVAL)
|
||||||
|
|
||||||
def _disk_io():
|
def _disk_io():
|
||||||
@@ -617,34 +655,7 @@ if __name__ == '__main__':
|
|||||||
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
|
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
|
||||||
array['io_read'] = diskIO.get("read")
|
array['io_read'] = diskIO.get("read")
|
||||||
array['io_write'] = diskIO.get("write")
|
array['io_write'] = diskIO.get("write")
|
||||||
# report OS (normalized)
|
array['os'] = get_os_name()
|
||||||
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 = []
|
items = []
|
||||||
for _n, st in monitorServer.items():
|
for _n, st in monitorServer.items():
|
||||||
key = str(_n)
|
key = str(_n)
|
||||||
|
|||||||
+44
-32
@@ -148,6 +148,32 @@ def get_cpu_model():
|
|||||||
return vendor
|
return vendor
|
||||||
return get_platform_cpu_arch()
|
return get_platform_cpu_arch()
|
||||||
|
|
||||||
|
def get_os_name():
|
||||||
|
try:
|
||||||
|
sysname = platform.system().lower()
|
||||||
|
if sysname.startswith('windows'):
|
||||||
|
return 'windows'
|
||||||
|
if sysname.startswith('darwin') or 'mac' in sysname:
|
||||||
|
return 'darwin'
|
||||||
|
if 'bsd' in sysname:
|
||||||
|
return 'bsd'
|
||||||
|
if sysname.startswith('linux'):
|
||||||
|
os_name = 'linux'
|
||||||
|
try:
|
||||||
|
with open('/etc/os-release') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith('ID='):
|
||||||
|
value = line.strip().split('=', 1)[1].strip().strip('"')
|
||||||
|
if value:
|
||||||
|
os_name = value
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return os_name
|
||||||
|
return sysname or 'unknown'
|
||||||
|
except Exception:
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
def _get_net_io_counters():
|
def _get_net_io_counters():
|
||||||
with _net_io_counters_lock:
|
with _net_io_counters_lock:
|
||||||
return psutil.net_io_counters(pernic=True)
|
return psutil.net_io_counters(pernic=True)
|
||||||
@@ -236,6 +262,22 @@ diskIO = {
|
|||||||
}
|
}
|
||||||
monitorServer = {}
|
monitorServer = {}
|
||||||
|
|
||||||
|
def update_net_speed(avgrx, avgtx, now_clock=None):
|
||||||
|
if now_clock is None:
|
||||||
|
now_clock = time.monotonic()
|
||||||
|
previous_clock = netSpeed.get("clock", 0.0)
|
||||||
|
previous_rx = netSpeed.get("avgrx", 0)
|
||||||
|
previous_tx = netSpeed.get("avgtx", 0)
|
||||||
|
diff = now_clock - previous_clock
|
||||||
|
initialized = previous_clock > 0 and diff > 0
|
||||||
|
netSpeed["diff"] = diff if initialized else 0.0
|
||||||
|
netSpeed["clock"] = now_clock
|
||||||
|
netSpeed["netrx"] = int((avgrx - previous_rx) / diff) if initialized and avgrx >= previous_rx else 0
|
||||||
|
netSpeed["nettx"] = int((avgtx - previous_tx) / diff) if initialized and avgtx >= previous_tx else 0
|
||||||
|
netSpeed["avgrx"] = avgrx
|
||||||
|
netSpeed["avgtx"] = avgtx
|
||||||
|
return netSpeed["netrx"], netSpeed["nettx"]
|
||||||
|
|
||||||
def _ping_thread(host, mark, port):
|
def _ping_thread(host, mark, port):
|
||||||
lostPacket = 0
|
lostPacket = 0
|
||||||
packet_queue = Queue(maxsize=PING_PACKET_HISTORY_LEN)
|
packet_queue = Queue(maxsize=PING_PACKET_HISTORY_LEN)
|
||||||
@@ -286,13 +328,7 @@ def _net_speed():
|
|||||||
continue
|
continue
|
||||||
avgrx += stats.bytes_recv
|
avgrx += stats.bytes_recv
|
||||||
avgtx += stats.bytes_sent
|
avgtx += stats.bytes_sent
|
||||||
now_clock = time.time()
|
update_net_speed(avgrx, avgtx)
|
||||||
netSpeed["diff"] = now_clock - netSpeed["clock"]
|
|
||||||
netSpeed["clock"] = now_clock
|
|
||||||
netSpeed["netrx"] = int((avgrx - netSpeed["avgrx"]) / netSpeed["diff"])
|
|
||||||
netSpeed["nettx"] = int((avgtx - netSpeed["avgtx"]) / netSpeed["diff"])
|
|
||||||
netSpeed["avgrx"] = avgrx
|
|
||||||
netSpeed["avgtx"] = avgtx
|
|
||||||
time.sleep(INTERVAL)
|
time.sleep(INTERVAL)
|
||||||
|
|
||||||
def _disk_io():
|
def _disk_io():
|
||||||
@@ -570,31 +606,7 @@ if __name__ == '__main__':
|
|||||||
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
|
array['tcp'], array['udp'], array['process'], array['thread'] = tupd()
|
||||||
array['io_read'] = diskIO.get("read")
|
array['io_read'] = diskIO.get("read")
|
||||||
array['io_write'] = diskIO.get("write")
|
array['io_write'] = diskIO.get("write")
|
||||||
# report OS (normalized)
|
array['os'] = get_os_name()
|
||||||
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 = []
|
items = []
|
||||||
for _n, st in monitorServer.items():
|
for _n, st in monitorServer.items():
|
||||||
key = str(_n)
|
key = str(_n)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ CLIENT_DIR = Path(__file__).resolve().parent
|
|||||||
|
|
||||||
class ClientArgumentTests(unittest.TestCase):
|
class ClientArgumentTests(unittest.TestCase):
|
||||||
def test_password_with_user_text_does_not_replace_username(self):
|
def test_password_with_user_text_does_not_replace_username(self):
|
||||||
if importlib.util.find_spec("psutil") is None:
|
if "psutil" not in sys.modules and importlib.util.find_spec("psutil") is None:
|
||||||
sys.modules["psutil"] = types.ModuleType("psutil")
|
sys.modules["psutil"] = types.ModuleType("psutil")
|
||||||
|
|
||||||
arguments = [
|
arguments = [
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import runpy
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from collections import namedtuple
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
CLIENT_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def load_client(filename):
|
||||||
|
if filename == "client-psutil.py" and "psutil" not in sys.modules:
|
||||||
|
try:
|
||||||
|
__import__("psutil")
|
||||||
|
except ImportError:
|
||||||
|
sys.modules["psutil"] = types.ModuleType("psutil")
|
||||||
|
return runpy.run_path(str(CLIENT_DIR / filename))
|
||||||
|
|
||||||
|
|
||||||
|
class ClientMetricTests(unittest.TestCase):
|
||||||
|
def test_psutil_counter_reads_are_serialized_and_keep_nowrap_enabled(self):
|
||||||
|
client = load_client("client-psutil.py")
|
||||||
|
active = 0
|
||||||
|
maximum_active = 0
|
||||||
|
calls = []
|
||||||
|
state_lock = threading.Lock()
|
||||||
|
|
||||||
|
def fake_counters(*args, **kwargs):
|
||||||
|
nonlocal active, maximum_active
|
||||||
|
with state_lock:
|
||||||
|
active += 1
|
||||||
|
maximum_active = max(maximum_active, active)
|
||||||
|
calls.append((args, kwargs))
|
||||||
|
time.sleep(0.002)
|
||||||
|
with state_lock:
|
||||||
|
active -= 1
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with mock.patch.object(client["psutil"], "net_io_counters", side_effect=fake_counters, create=True):
|
||||||
|
with ThreadPoolExecutor(max_workers=12) as executor:
|
||||||
|
list(executor.map(lambda _index: client["_get_net_io_counters"](), range(48)))
|
||||||
|
|
||||||
|
self.assertEqual(maximum_active, 1)
|
||||||
|
self.assertEqual(len(calls), 48)
|
||||||
|
self.assertTrue(all(kwargs == {"pernic": True} for _args, kwargs in calls))
|
||||||
|
|
||||||
|
def test_psutil_totals_exclude_virtual_interfaces(self):
|
||||||
|
client = load_client("client-psutil.py")
|
||||||
|
counters = namedtuple("Counters", "bytes_sent bytes_recv")
|
||||||
|
values = {
|
||||||
|
"eth0": counters(500, 1000),
|
||||||
|
"ens5": counters(300, 700),
|
||||||
|
"lo": counters(9000, 9000),
|
||||||
|
"docker0": counters(8000, 8000),
|
||||||
|
"veth123": counters(7000, 7000),
|
||||||
|
}
|
||||||
|
with mock.patch.object(client["psutil"], "net_io_counters", return_value=values, create=True):
|
||||||
|
self.assertEqual(client["liuliang"](), (1700, 800))
|
||||||
|
|
||||||
|
def test_linux_totals_read_proc_and_exclude_virtual_interfaces(self):
|
||||||
|
client = load_client("client-linux.py")
|
||||||
|
proc_net_dev = """Inter-| Receive | Transmit
|
||||||
|
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
||||||
|
eth0: 1000 10 0 0 0 0 0 0 500 5 0 0 0 0 0 0
|
||||||
|
ens5: 700 7 0 0 0 0 0 0 300 3 0 0 0 0 0 0
|
||||||
|
lo: 9000 9 0 0 0 0 0 0 9000 9 0 0 0 0 0 0
|
||||||
|
veth123: 8000 8 0 0 0 0 0 0 8000 8 0 0 0 0 0 0
|
||||||
|
"""
|
||||||
|
with mock.patch("builtins.open", mock.mock_open(read_data=proc_net_dev)):
|
||||||
|
self.assertEqual(client["liuliang"](), (1700, 800))
|
||||||
|
|
||||||
|
def test_network_speed_starts_and_resets_at_zero(self):
|
||||||
|
for filename in ("client-linux.py", "client-psutil.py"):
|
||||||
|
with self.subTest(client=filename):
|
||||||
|
client = load_client(filename)
|
||||||
|
state = client["update_net_speed"].__globals__["netSpeed"]
|
||||||
|
state.update({"clock": 0.0, "diff": 0.0, "avgrx": 0, "avgtx": 0, "netrx": 0, "nettx": 0})
|
||||||
|
|
||||||
|
self.assertEqual(client["update_net_speed"](1000, 1000, 100.0), (0, 0))
|
||||||
|
self.assertEqual(client["update_net_speed"](1400, 1300, 102.0), (200, 150))
|
||||||
|
self.assertEqual(client["update_net_speed"](10, 1500, 103.0), (0, 200))
|
||||||
|
self.assertEqual(client["update_net_speed"](5, 4, 104.0), (0, 0))
|
||||||
|
|
||||||
|
def test_os_detection_uses_linux_distribution_id(self):
|
||||||
|
os_release = 'NAME="Alpine Linux"\nID=alpine\nVERSION_ID=3.22\n'
|
||||||
|
for filename in ("client-linux.py", "client-psutil.py"):
|
||||||
|
with self.subTest(client=filename):
|
||||||
|
client = load_client(filename)
|
||||||
|
with mock.patch.object(client["platform"], "system", return_value="Linux"), \
|
||||||
|
mock.patch("builtins.open", mock.mock_open(read_data=os_release)):
|
||||||
|
self.assertEqual(client["get_os_name"](), "alpine")
|
||||||
|
|
||||||
|
def test_os_detection_has_platform_fallbacks(self):
|
||||||
|
psutil_client = load_client("client-psutil.py")
|
||||||
|
with mock.patch.object(psutil_client["platform"], "system", return_value="Windows Server 2022"):
|
||||||
|
self.assertEqual(psutil_client["get_os_name"](), "windows")
|
||||||
|
|
||||||
|
linux_client = load_client("client-linux.py")
|
||||||
|
with mock.patch.object(linux_client["platform"], "system", return_value="FreeBSD"):
|
||||||
|
self.assertEqual(linux_client["get_os_name"](), "freebsd")
|
||||||
|
|
||||||
|
def test_cpu_model_prefers_specific_model_and_has_vendor_fallback(self):
|
||||||
|
linux_client = load_client("client-linux.py")
|
||||||
|
linux_globals = linux_client["get_cpu_model"].__globals__
|
||||||
|
with mock.patch.dict(linux_globals, {
|
||||||
|
"get_cpuinfo_values": lambda: {"model name": "AMD EPYC 7B13"},
|
||||||
|
"get_lscpu_info": lambda: {"vendor id": "AuthenticAMD", "architecture": "x86_64"},
|
||||||
|
}):
|
||||||
|
self.assertEqual(linux_client["get_cpu_model"](), "AMD EPYC 7B13")
|
||||||
|
|
||||||
|
psutil_client = load_client("client-psutil.py")
|
||||||
|
platform_module = psutil_client["platform"]
|
||||||
|
uname = types.SimpleNamespace(processor="", machine="x86_64")
|
||||||
|
with mock.patch.object(platform_module, "processor", return_value=""), \
|
||||||
|
mock.patch.object(platform_module, "uname", return_value=uname), \
|
||||||
|
mock.patch.object(platform_module, "machine", return_value="x86_64"), \
|
||||||
|
mock.patch.object(platform_module, "platform", return_value="Linux GenuineIntel"):
|
||||||
|
self.assertEqual(psutil_client["get_cpu_model"](), "GenuineIntel")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -15,18 +15,9 @@ services:
|
|||||||
ADMIN_TOKEN: "${ADMIN_TOKEN:-}"
|
ADMIN_TOKEN: "${ADMIN_TOKEN:-}"
|
||||||
HTTP_ADDR: ":80"
|
HTTP_ADDR: ":80"
|
||||||
AGENT_ADDR: ":35601"
|
AGENT_ADDR: ":35601"
|
||||||
networks:
|
|
||||||
serverstatus-network:
|
|
||||||
ipv4_address: 172.23.0.2
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./server/config.json:/app/config/config.json
|
- ./server/config.json:/app/config/config.json
|
||||||
- ./web/json:/app/data
|
- ./web/json:/app/data
|
||||||
ports:
|
ports:
|
||||||
- 35601:35601
|
- 35601:35601
|
||||||
- 8080:80
|
- 8080:80
|
||||||
|
|
||||||
networks:
|
|
||||||
serverstatus-network:
|
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 172.23.0.0/24
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "serverstatus-webui-tests",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@11.19.0",
|
||||||
|
"scripts": {
|
||||||
|
"test:webui": "playwright test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.62.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests/webui',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: { timeout: 5_000 },
|
||||||
|
outputDir: path.join('output', 'playwright', 'results'),
|
||||||
|
reporter: process.env.CI
|
||||||
|
? [['line'], ['html', { outputFolder: 'output/playwright/report', open: 'never' }]]
|
||||||
|
: 'line',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:18080',
|
||||||
|
browserName: 'chromium',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
trace: 'retain-on-failure'
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'sh tests/run-webui-server.sh',
|
||||||
|
url: 'http://127.0.0.1:18080/api/health',
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+52
@@ -0,0 +1,52 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
devDependencies:
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: 1.62.1
|
||||||
|
version: 1.62.1
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@playwright/test@1.62.1':
|
||||||
|
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
playwright-core@1.62.1:
|
||||||
|
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.62.1:
|
||||||
|
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@playwright/test@1.62.1':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.62.1
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
playwright-core@1.62.1: {}
|
||||||
|
|
||||||
|
playwright@1.62.1:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.62.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
Executable
+136
@@ -0,0 +1,136 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd)
|
||||||
|
SERVER_IMAGE=${SERVER_IMAGE:-serverstatus-server:test}
|
||||||
|
CLIENT_IMAGE=${CLIENT_IMAGE:-serverstatus-client:test}
|
||||||
|
SUFFIX=$$
|
||||||
|
NETWORK="serverstatus-smoke-$SUFFIX"
|
||||||
|
SERVER_NAME="serverstatus-smoke-server-$SUFFIX"
|
||||||
|
CLIENT_NAME="serverstatus-smoke-client-$SUFFIX"
|
||||||
|
mkdir -p "$ROOT/output"
|
||||||
|
TEST_DIR=$(mktemp -d "$ROOT/output/docker-smoke.XXXXXX")
|
||||||
|
|
||||||
|
# Invoked by trap.
|
||||||
|
# shellcheck disable=SC2329
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
trap - EXIT INT TERM HUP
|
||||||
|
if [ "$status" -ne 0 ]; then
|
||||||
|
docker logs "$SERVER_NAME" 2>/dev/null || true
|
||||||
|
docker logs "$CLIENT_NAME" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
docker rm -f "$CLIENT_NAME" "$SERVER_NAME" >/dev/null 2>&1 || true
|
||||||
|
docker network rm "$NETWORK" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$TEST_DIR"
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM HUP
|
||||||
|
|
||||||
|
cp "$ROOT/tests/fixtures/config.json" "$TEST_DIR/config.json"
|
||||||
|
mkdir -p "$TEST_DIR/data"
|
||||||
|
|
||||||
|
docker run --rm --entrypoint python3 "$CLIENT_IMAGE" -c \
|
||||||
|
'import platform, psutil; assert psutil.cpu_count(); print(platform.machine(), psutil.__version__)'
|
||||||
|
|
||||||
|
docker_os=$(docker info --format '{{.OperatingSystem}}')
|
||||||
|
if printf '%s' "$docker_os" | grep -q 'Docker Desktop'; then
|
||||||
|
mode=bridge
|
||||||
|
docker network create "$NETWORK" >/dev/null
|
||||||
|
docker run -d \
|
||||||
|
--name "$SERVER_NAME" \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
-e ADMIN_TOKEN=test-token \
|
||||||
|
-v "$TEST_DIR/config.json:/app/config/config.json" \
|
||||||
|
-v "$TEST_DIR/data:/app/data" \
|
||||||
|
"$SERVER_IMAGE" >/dev/null
|
||||||
|
else
|
||||||
|
mode=host
|
||||||
|
docker run -d \
|
||||||
|
--name "$SERVER_NAME" \
|
||||||
|
-e ADMIN_TOKEN=test-token \
|
||||||
|
-v "$TEST_DIR/config.json:/app/config/config.json" \
|
||||||
|
-v "$TEST_DIR/data:/app/data" \
|
||||||
|
-p 127.0.0.1::80 \
|
||||||
|
-p 127.0.0.1::35601 \
|
||||||
|
"$SERVER_IMAGE" >/dev/null
|
||||||
|
HTTP_PORT=$(docker port "$SERVER_NAME" 80/tcp | awk -F: 'NR == 1 { print $NF }')
|
||||||
|
AGENT_PORT=$(docker port "$SERVER_NAME" 35601/tcp | awk -F: 'NR == 1 { print $NF }')
|
||||||
|
if [ -z "$HTTP_PORT" ] || [ -z "$AGENT_PORT" ] || [ "$HTTP_PORT" = "$AGENT_PORT" ]; then
|
||||||
|
echo "could not determine distinct published server ports" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
attempt=0
|
||||||
|
until docker exec "$SERVER_NAME" wget -q -O /dev/null http://127.0.0.1/api/health; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
if [ "$attempt" -ge 30 ]; then
|
||||||
|
echo "server container did not become healthy" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$mode" = host ]; then
|
||||||
|
docker run -d \
|
||||||
|
--name "$CLIENT_NAME" \
|
||||||
|
--network host \
|
||||||
|
--pid host \
|
||||||
|
-e SERVER=127.0.0.1 \
|
||||||
|
-e PORT="$AGENT_PORT" \
|
||||||
|
-e USER=s01 \
|
||||||
|
-e PASSWORD=fixture-password \
|
||||||
|
-e CLIENT=psutil \
|
||||||
|
-e PYTHONUNBUFFERED=1 \
|
||||||
|
-e INTERVAL=1 \
|
||||||
|
-e CU=127.0.0.1 \
|
||||||
|
-e CT=127.0.0.1 \
|
||||||
|
-e CM=127.0.0.1 \
|
||||||
|
-e PROBEPORT="$HTTP_PORT" \
|
||||||
|
"$CLIENT_IMAGE" >/dev/null
|
||||||
|
else
|
||||||
|
echo "Docker Desktop: host networking unavailable; using bridge fallback locally."
|
||||||
|
docker run -d \
|
||||||
|
--name "$CLIENT_NAME" \
|
||||||
|
--network "$NETWORK" \
|
||||||
|
--pid host \
|
||||||
|
-e SERVER="$SERVER_NAME" \
|
||||||
|
-e PORT=35601 \
|
||||||
|
-e USER=s01 \
|
||||||
|
-e PASSWORD=fixture-password \
|
||||||
|
-e CLIENT=psutil \
|
||||||
|
-e PYTHONUNBUFFERED=1 \
|
||||||
|
-e INTERVAL=1 \
|
||||||
|
-e CU="$SERVER_NAME" \
|
||||||
|
-e CT="$SERVER_NAME" \
|
||||||
|
-e CM="$SERVER_NAME" \
|
||||||
|
-e PROBEPORT=80 \
|
||||||
|
"$CLIENT_IMAGE" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
attempt=0
|
||||||
|
while [ "$attempt" -lt 45 ]; do
|
||||||
|
stats=$(docker exec "$SERVER_NAME" wget -q -O - http://127.0.0.1/json/stats.json 2>/dev/null || true)
|
||||||
|
if printf '%s' "$stats" | python3 -c '
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
document = json.load(sys.stdin)
|
||||||
|
node = next(item for item in document.get("servers", []) if item.get("name") == "fixture-node")
|
||||||
|
assert node.get("online4") or node.get("online6")
|
||||||
|
assert int(node.get("cpu_cores") or 0) > 0
|
||||||
|
assert str(node.get("cpu_model") or "").strip()
|
||||||
|
assert node.get("os") == "alpine"
|
||||||
|
assert int(node.get("network_in") or 0) > 0
|
||||||
|
assert int(node.get("network_out") or 0) > 0
|
||||||
|
' 2>/dev/null; then
|
||||||
|
echo "Docker smoke passed ($mode): client authenticated and reported live metrics."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "client did not report live metrics before timeout" >&2
|
||||||
|
exit 1
|
||||||
Vendored
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"username": "s01",
|
||||||
|
"name": "fixture-node",
|
||||||
|
"type": "kvm",
|
||||||
|
"host": "fixture-host",
|
||||||
|
"location": "CN",
|
||||||
|
"password": "fixture-password",
|
||||||
|
"monthstart": 1,
|
||||||
|
"disabled": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"monitors": [],
|
||||||
|
"sslcerts": [],
|
||||||
|
"watchdog": []
|
||||||
|
}
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd)
|
||||||
|
TEST_DIR=$(mktemp -d "${TMPDIR:-/tmp}/serverstatus-webui.XXXXXX")
|
||||||
|
SERVER_PID=""
|
||||||
|
|
||||||
|
# Invoked by trap.
|
||||||
|
# shellcheck disable=SC2329
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
trap - EXIT INT TERM HUP
|
||||||
|
if [ -n "$SERVER_PID" ]; then
|
||||||
|
kill "$SERVER_PID" 2>/dev/null || true
|
||||||
|
wait "$SERVER_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "$TEST_DIR"
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM HUP
|
||||||
|
|
||||||
|
cp "$ROOT/tests/fixtures/config.json" "$TEST_DIR/config.json"
|
||||||
|
mkdir -p "$TEST_DIR/data"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$ROOT/server"
|
||||||
|
go build -trimpath -o "$TEST_DIR/serverstatus" .
|
||||||
|
)
|
||||||
|
|
||||||
|
ADMIN_TOKEN=test-token "$TEST_DIR/serverstatus" \
|
||||||
|
--config="$TEST_DIR/config.json" \
|
||||||
|
--stats="$TEST_DIR/data/stats.json" \
|
||||||
|
--web-dir="$ROOT/web" \
|
||||||
|
--http=127.0.0.1:18080 \
|
||||||
|
--agent=127.0.0.1:35699 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
wait "$SERVER_PID"
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const serverSpecs = [
|
||||||
|
{ name: 'alpha-online', location: 'SG-Singapore', os: 'ubuntu', online4: true },
|
||||||
|
{ name: 'bravo-online', location: 'JP-Tokyo', os: 'debian', online4: true },
|
||||||
|
{ name: 'charlie-online', location: 'US-LosAngeles', os: 'windows', online4: true },
|
||||||
|
{ name: 'delta-online', location: 'HK-HongKong', os: 'alpine', online4: true },
|
||||||
|
{ name: 'echo-offline', location: 'FR-Paris', os: 'ubuntu' },
|
||||||
|
{ name: 'foxtrot-alert', location: 'DE-Frankfurt', os: 'debian', online4: true, cpu: 95 },
|
||||||
|
{ name: 'golf-blocked', location: 'UK-London', os: 'ubuntu', online4: true, loss: 100 },
|
||||||
|
{ name: 'hotel-online', location: 'AU-Sydney', os: 'windows', online4: true },
|
||||||
|
{ name: 'india-online', location: 'CA-Toronto', os: 'debian', online4: true },
|
||||||
|
{ name: 'juliet-online', location: 'KR-Seoul', os: 'alpine', online4: true },
|
||||||
|
{ name: 'kilo-online', location: 'SG-Singapore', os: 'ubuntu', online4: true, online6: true },
|
||||||
|
{ name: 'zulu-online', location: 'JP-Osaka', os: 'debian', online4: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
function serverFixture(spec, index) {
|
||||||
|
const received = 2_000_000_000 + index * 100_000_000;
|
||||||
|
const sent = 1_000_000_000 + index * 50_000_000;
|
||||||
|
return {
|
||||||
|
name: spec.name,
|
||||||
|
type: index % 2 ? 'kvm' : 'docker',
|
||||||
|
host: `host-${index + 1}`,
|
||||||
|
location: spec.location,
|
||||||
|
os: spec.os,
|
||||||
|
online4: !!spec.online4,
|
||||||
|
online6: !!spec.online6,
|
||||||
|
uptime: spec.online4 || spec.online6 ? `${index + 1} 天` : '-',
|
||||||
|
load_1: 0.05 + index / 100,
|
||||||
|
load_5: 0.04 + index / 100,
|
||||||
|
load_15: 0.03 + index / 100,
|
||||||
|
cpu: spec.cpu || 12 + index,
|
||||||
|
cpu_cores: 2 + index % 4,
|
||||||
|
cpu_model: 'Test CPU Model',
|
||||||
|
memory_total: 8 * 1024 * 1024,
|
||||||
|
memory_used: (2 + index / 10) * 1024 * 1024,
|
||||||
|
swap_total: 1024 * 1024,
|
||||||
|
swap_used: 128 * 1024,
|
||||||
|
hdd_total: 100 * 1024,
|
||||||
|
hdd_used: (20 + index) * 1024,
|
||||||
|
network_rx: 20_000 + index * 100,
|
||||||
|
network_tx: 10_000 + index * 100,
|
||||||
|
network_in: received,
|
||||||
|
network_out: sent,
|
||||||
|
last_network_in: received - 500_000_000,
|
||||||
|
last_network_out: sent - 250_000_000,
|
||||||
|
ping_10010: spec.loss || 1,
|
||||||
|
ping_189: spec.loss || 2,
|
||||||
|
ping_10086: spec.loss || 3,
|
||||||
|
time_10010: 35 + index,
|
||||||
|
time_189: 45 + index,
|
||||||
|
time_10086: 55 + index,
|
||||||
|
tcp_count: 20 + index,
|
||||||
|
udp_count: 5 + index,
|
||||||
|
process_count: 80 + index,
|
||||||
|
thread_count: 160 + index,
|
||||||
|
io_read: 100_000,
|
||||||
|
io_write: 50_000,
|
||||||
|
custom: 'example=35'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const statsFixture = {
|
||||||
|
updated: String(Math.floor(Date.now() / 1000)),
|
||||||
|
servers: serverSpecs.map(serverFixture),
|
||||||
|
sslcerts: []
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route(/\/json\/stats\.json(?:\?|$)/, route => route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(statsFixture)
|
||||||
|
}));
|
||||||
|
await page.goto('/');
|
||||||
|
if (page.viewportSize().width <= 700) {
|
||||||
|
await expect(page.locator('#serversCards .card')).toHaveCount(12);
|
||||||
|
} else {
|
||||||
|
await expect(page.locator('#serversBody .row-server')).toHaveCount(12);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters, focused OS selector and sorting stay stable across refreshes', async ({ page }) => {
|
||||||
|
await expect(page.locator('#serversToolbar')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '离线', exact: true }).click();
|
||||||
|
await expect(page.locator('#serversBody .row-server')).toHaveCount(1);
|
||||||
|
await expect(page.locator('#serversBody')).toContainText('echo-offline');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '全部', exact: true }).click();
|
||||||
|
await page.locator('#osFilter').selectOption({ label: 'Windows' });
|
||||||
|
await expect(page.locator('#serversBody .row-server')).toHaveCount(2);
|
||||||
|
|
||||||
|
await page.locator('#osFilter').focus();
|
||||||
|
const optionCount = await page.locator('#osFilter option').count();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await expect(page.locator('#osFilter option')).toHaveCount(optionCount);
|
||||||
|
await expect(page.locator('#osFilter')).toBeFocused();
|
||||||
|
|
||||||
|
await page.locator('#osFilter').selectOption('all');
|
||||||
|
await page.locator('#serverSearch').fill('KR-Seoul');
|
||||||
|
await expect(page.locator('#serversBody .row-server')).toHaveCount(1);
|
||||||
|
await expect(page.locator('#serversBody')).toContainText('juliet-online');
|
||||||
|
|
||||||
|
await page.locator('#serverSearch').fill('');
|
||||||
|
await page.locator('#sortSelect').selectOption('name');
|
||||||
|
await page.locator('#sortDirection').click();
|
||||||
|
await expect(page.locator('#serversBody .row-server').first().locator('.node-name')).toHaveText('zulu-online');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status filters distinguish offline and online alerts', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: '异常', exact: true }).click();
|
||||||
|
await expect(page.locator('#serversBody .row-server')).toHaveCount(2);
|
||||||
|
await expect(page.locator('#serversBody')).toContainText('foxtrot-alert');
|
||||||
|
await expect(page.locator('#serversBody')).toContainText('golf-blocked');
|
||||||
|
await expect(page.locator('#serversBody')).not.toContainText('echo-offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('online node detail shows resources and labeled charts', async ({ page }) => {
|
||||||
|
await page.locator('#serversBody .row-server', { hasText: 'alpha-online' }).click();
|
||||||
|
await expect(page.locator('#detailModal')).toBeVisible();
|
||||||
|
await expect(page.locator('#detailTitle')).toContainText('alpha-online');
|
||||||
|
await expect(page.locator('#detailContent h4')).toHaveText([
|
||||||
|
'身份', '资源', '网络', '连接', '负载趋势', '三网延迟'
|
||||||
|
]);
|
||||||
|
await expect(page.locator('.chart-legend')).toContainText(['load1load5load15', '联通电信移动']);
|
||||||
|
await expect(page.locator('.resource-meter')).toHaveCount(4);
|
||||||
|
|
||||||
|
await page.locator('#detailClose').click();
|
||||||
|
await expect(page.locator('#detailModal')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('theme choice persists after reload', async ({ page }) => {
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/light/);
|
||||||
|
await page.locator('#themeToggle').click();
|
||||||
|
await expect(page.locator('body')).toHaveClass(/light/);
|
||||||
|
await expect.poll(() => page.evaluate(() => localStorage.getItem('theme'))).toBe('light');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('body')).toHaveClass(/light/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configuration page creates, updates and deletes a node through the real API', async ({ page }) => {
|
||||||
|
await page.locator('#navTabs button[data-tab="config"]').click();
|
||||||
|
await expect(page.locator('#adminStatus')).toContainText('管理 API 已启用');
|
||||||
|
await page.locator('#adminToken').fill('test-token');
|
||||||
|
await page.locator('#adminTokenForm button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#adminStatus')).toContainText('已连接管理 API');
|
||||||
|
|
||||||
|
await page.locator('#configForm [name="username"]').fill('web-e2e');
|
||||||
|
await page.locator('#configForm [name="name"]').fill('web-e2e-node');
|
||||||
|
await page.locator('#configForm [name="type"]').fill('kvm');
|
||||||
|
await page.locator('#configForm [name="host"]').fill('web-e2e-host');
|
||||||
|
await page.locator('#configForm [name="location"]').fill('TEST');
|
||||||
|
await page.locator('#configForm [name="password"]').fill('web-e2e-password');
|
||||||
|
const created = page.waitForResponse(response => response.url().endsWith('/api/servers') && response.request().method() === 'POST');
|
||||||
|
await page.locator('#configForm button[type="submit"]').click();
|
||||||
|
await expect((await created).status()).toBe(201);
|
||||||
|
await expect(page.locator('#configItemList')).toContainText('web-e2e-node');
|
||||||
|
|
||||||
|
await page.locator('#configForm [name="name"]').fill('web-e2e-node-updated');
|
||||||
|
const updated = page.waitForResponse(response => response.url().endsWith('/api/servers/web-e2e') && response.request().method() === 'PUT');
|
||||||
|
await page.locator('#configForm button[type="submit"]').click();
|
||||||
|
await expect((await updated).status()).toBe(200);
|
||||||
|
await expect(page.locator('#configItemList')).toContainText('web-e2e-node-updated');
|
||||||
|
|
||||||
|
page.once('dialog', dialog => dialog.accept());
|
||||||
|
const deleted = page.waitForResponse(response => response.url().endsWith('/api/servers/web-e2e') && response.request().method() === 'DELETE');
|
||||||
|
await page.locator('#deleteConfigItemBtn').click();
|
||||||
|
await expect((await deleted).status()).toBe(200);
|
||||||
|
await expect(page.locator('#configItemList')).not.toContainText('web-e2e-node-updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('mobile layout', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
|
test('uses cards, hides overview and keeps the detail drawer inside the viewport', async ({ page }) => {
|
||||||
|
await expect(page.locator('#overviewCards')).toBeHidden();
|
||||||
|
await expect(page.locator('#serversCards')).toBeVisible();
|
||||||
|
await expect(page.locator('#serversCards .card')).toHaveCount(12);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391);
|
||||||
|
|
||||||
|
await page.locator('#serversCards .card', { hasText: 'alpha-online' }).click();
|
||||||
|
await expect(page.locator('#detailModal')).toBeVisible();
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const drawer = await page.locator('#detailModal .detail-drawer').boundingBox();
|
||||||
|
return Math.ceil(drawer.x + drawer.width);
|
||||||
|
}).toBeLessThanOrEqual(391);
|
||||||
|
const drawer = await page.locator('#detailModal .detail-drawer').boundingBox();
|
||||||
|
expect(drawer.x).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user