diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 291d900..18227b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,15 @@ jobs: go-version-file: server/go.mod 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 working-directory: server run: | @@ -26,10 +35,19 @@ jobs: - name: Check clients, WebUI and shell scripts run: | 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 tests/run-webui-server.sh tests/docker-smoke.sh bash -n status.sh 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 run: | @@ -40,3 +58,18 @@ jobs: run: | docker build -f Dockerfile.server -t serverstatus-server: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 \ + . diff --git a/.gitignore b/.gitignore index d8d1b67..90a62d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ default.sublime-workspace +# Browser test dependencies and artifacts +node_modules/ +output/ + # pycharm .idea diff --git a/Dockerfile.client b/Dockerfile.client index 16f577e..4e8da60 100644 --- a/Dockerfile.client +++ b/Dockerfile.client @@ -1,4 +1,4 @@ -FROM alpine:3.13 +FROM alpine:3.22 WORKDIR /app diff --git a/README.md b/README.md index c8aead1..435aeb5 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,8 @@ Systemd 示例位于 `service/status-server.service`。一键脚本 `status.sh` ## 构建和测试 +Go 测试需要 Go `1.25+`;WebUI 行为测试需要 Node.js `20+` 和 pnpm。 + ```bash # Go 单元、协议、API、TLS 和回调测试 cd server @@ -311,17 +313,32 @@ go test ./... go test -race ./... go vet ./... -# Docker 镜像 +# Python 客户端指标、参数与平台识别测试 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.client -t cppla/serverstatus:client . +# 服务端与客户端真实连接、认证和指标上报 +SERVER_IMAGE=cppla/serverstatus:server \ +CLIENT_IMAGE=cppla/serverstatus:client \ +tests/docker-smoke.sh + # Compose 配置 docker compose -f docker-compose-server.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。 ## 从旧服务端迁移 diff --git a/clients/client-linux.py b/clients/client-linux.py index 315296f..85273c4 100755 --- a/clients/client-linux.py +++ b/clients/client-linux.py @@ -208,6 +208,34 @@ def get_cpu_model(): return vendor 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(): NET_IN = 0 NET_OUT = 0 @@ -276,6 +304,22 @@ diskIO = { } 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): lostPacket = 0 packet_queue = Queue(maxsize=PING_PACKET_HISTORY_LEN) @@ -330,13 +374,7 @@ def _net_speed(): dev = dev[1].split() avgrx += int(dev[0]) avgtx += int(dev[8]) - now_clock = time.time() - 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 + update_net_speed(avgrx, avgtx) time.sleep(INTERVAL) def _disk_io(): @@ -617,34 +655,7 @@ 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 + array['os'] = get_os_name() items = [] for _n, st in monitorServer.items(): key = str(_n) diff --git a/clients/client-psutil.py b/clients/client-psutil.py index bca334f..d07b1d7 100755 --- a/clients/client-psutil.py +++ b/clients/client-psutil.py @@ -148,6 +148,32 @@ def get_cpu_model(): return vendor 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(): with _net_io_counters_lock: return psutil.net_io_counters(pernic=True) @@ -236,6 +262,22 @@ diskIO = { } 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): lostPacket = 0 packet_queue = Queue(maxsize=PING_PACKET_HISTORY_LEN) @@ -286,13 +328,7 @@ def _net_speed(): continue avgrx += stats.bytes_recv avgtx += stats.bytes_sent - now_clock = time.time() - 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 + update_net_speed(avgrx, avgtx) time.sleep(INTERVAL) def _disk_io(): @@ -570,31 +606,7 @@ 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 + array['os'] = get_os_name() items = [] for _n, st in monitorServer.items(): key = str(_n) diff --git a/clients/test_client_args.py b/clients/test_client_args.py index 289a0d8..b2480cb 100644 --- a/clients/test_client_args.py +++ b/clients/test_client_args.py @@ -11,7 +11,7 @@ CLIENT_DIR = Path(__file__).resolve().parent class ClientArgumentTests(unittest.TestCase): 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") arguments = [ diff --git a/clients/test_client_metrics.py b/clients/test_client_metrics.py new file mode 100644 index 0000000..4aaf470 --- /dev/null +++ b/clients/test_client_metrics.py @@ -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() diff --git a/docker-compose-server.yml b/docker-compose-server.yml index a2116ce..c2cb534 100644 --- a/docker-compose-server.yml +++ b/docker-compose-server.yml @@ -15,18 +15,9 @@ services: ADMIN_TOKEN: "${ADMIN_TOKEN:-}" HTTP_ADDR: ":80" AGENT_ADDR: ":35601" - networks: - serverstatus-network: - ipv4_address: 172.23.0.2 volumes: - ./server/config.json:/app/config/config.json - ./web/json:/app/data ports: - 35601:35601 - 8080:80 - -networks: - serverstatus-network: - ipam: - config: - - subnet: 172.23.0.0/24 diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b5ad88 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..6503f71 --- /dev/null +++ b/playwright.config.js @@ -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 + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..6b7b518 --- /dev/null +++ b/pnpm-lock.yaml @@ -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 diff --git a/tests/docker-smoke.sh b/tests/docker-smoke.sh new file mode 100755 index 0000000..b474a7b --- /dev/null +++ b/tests/docker-smoke.sh @@ -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 diff --git a/tests/fixtures/config.json b/tests/fixtures/config.json new file mode 100644 index 0000000..4d0a7ee --- /dev/null +++ b/tests/fixtures/config.json @@ -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": [] +} diff --git a/tests/run-webui-server.sh b/tests/run-webui-server.sh new file mode 100755 index 0000000..abf6bb7 --- /dev/null +++ b/tests/run-webui-server.sh @@ -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" diff --git a/tests/webui/webui.spec.js b/tests/webui/webui.spec.js new file mode 100644 index 0000000..37cc0ff --- /dev/null +++ b/tests/webui/webui.spec.js @@ -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); + }); +});