{{ group.host }}
+{{ group.ip }} · {{ group.groupLabel }}
+From c60a546246ba982eed62b4d86b3bcd02545e9579 Mon Sep 17 00:00:00 2001 From: ZuoZuo <68836346+Mrz-sakura@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:07:30 +0800 Subject: [PATCH] feat: add hy app monitor --- .gitignore | 4 + README.md | 93 ++++++++++ config/targets.json | 154 ++++++++++++++++ ops/deploy_via_tat.py | 129 ++++++++++++++ ops/open_port_via_tat.py | 199 +++++++++++++++++++++ requirements-ops.txt | 1 + scripts/common.sh | 28 +++ scripts/restart.sh | 8 + scripts/start.sh | 32 ++++ scripts/status.sh | 14 ++ scripts/stop.sh | 28 +++ server.py | 206 ++++++++++++++++++++++ static/app.css | 367 +++++++++++++++++++++++++++++++++++++++ static/app.js | 105 +++++++++++ static/index.html | 107 ++++++++++++ 15 files changed, 1475 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/targets.json create mode 100755 ops/deploy_via_tat.py create mode 100755 ops/open_port_via_tat.py create mode 100644 requirements-ops.txt create mode 100755 scripts/common.sh create mode 100755 scripts/restart.sh create mode 100755 scripts/start.sh create mode 100755 scripts/status.sh create mode 100755 scripts/stop.sh create mode 100644 server.py create mode 100644 static/app.css create mode 100644 static/app.js create mode 100644 static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6280d6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +run/ +__pycache__/ +*.pyc +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..54ef3fc --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# hy-app-monitor + +一个给 `deploy` 机器使用的轻量监控页。 + +- 前端:静态 HTML + Vue 3 CDN +- 后端:Python 3 标准库 HTTP 服务 +- 默认端口:`6666` +- 默认监听:`0.0.0.0` + +## 功能 + +- 从 `deploy` 机器内部探测内网 Java / Golang 服务健康状态 +- 对外提供监控页和 JSON API +- 提供启动 / 停止 / 重启 / 状态脚本 +- 提供通过 TAT 放开 6666 端口和远端拉取部署的脚本 + +## 目录 + +- `server.py`:后端入口 +- `config/targets.json`:监控目标配置 +- `static/`:前端页面 +- `scripts/`:启停脚本 +- `ops/`:TAT 相关脚本 + +## 本地启动 + +```bash +cd /opt/hy-app-monitor +bash scripts/start.sh +``` + +默认访问: + +```bash +http://127.0.0.1:6666/ +``` + +## 脚本 + +```bash +bash scripts/start.sh +bash scripts/stop.sh +bash scripts/restart.sh +bash scripts/status.sh +``` + +## 环境变量 + +- `APP_BIND`:默认 `0.0.0.0` +- `APP_PORT`:默认 `6666` +- `TARGETS_FILE`:默认 `config/targets.json` +- `PROBE_TIMEOUT_SECONDS`:默认 `3` +- `PROBE_CACHE_TTL_SECONDS`:默认 `5` +- `PROBE_MAX_WORKERS`:默认 `12` + +## 用 TAT 放开 6666 + +先确保本机有腾讯云密钥环境变量: + +```bash +export TENCENT_SECRET_ID=... +export TENCENT_SECRET_KEY=... +``` + +执行: + +```bash +python3 ops/open_port_via_tat.py +``` + +默认会针对 deploy 机器 `ins-ntmpcd3a` 在 `me-saudi-arabia` 区域: + +- 通过 TAT 在机器内尝试放开系统防火墙 +- 通过 TAT 调腾讯云 API 放开安全组 TCP `6666` + +## 用 TAT 拉取并启动 + +```bash +python3 ops/deploy_via_tat.py +``` + +默认远端目录: + +```bash +/opt/hy-app-monitor +``` + +默认会: + +1. clone / pull 仓库 +2. 给脚本加执行权限 +3. 重启服务 +4. 检查 6666 端口监听 diff --git a/config/targets.json b/config/targets.json new file mode 100644 index 0000000..472fbe8 --- /dev/null +++ b/config/targets.json @@ -0,0 +1,154 @@ +{ + "hosts": [ + { + "name": "gateway-1", + "ip": "10.2.1.8", + "group": "gateway", + "services": [ + { + "name": "gateway", + "kind": "java", + "port": 8080, + "path": "/health" + } + ] + }, + { + "name": "gateway-2", + "ip": "10.2.2.17", + "group": "gateway", + "services": [ + { + "name": "gateway", + "kind": "java", + "port": 8080, + "path": "/health" + } + ] + }, + { + "name": "app-1", + "ip": "10.2.11.3", + "group": "app", + "services": [ + { + "name": "auth", + "kind": "java", + "port": 1000, + "path": "/actuator/health" + }, + { + "name": "other", + "kind": "java", + "port": 2400, + "path": "/actuator/health" + }, + { + "name": "live", + "kind": "java", + "port": 2500, + "path": "/actuator/health" + }, + { + "name": "console", + "kind": "java", + "port": 2700, + "path": "/console/actuator/health" + }, + { + "name": "external", + "kind": "java", + "port": 3000, + "path": "/actuator/health" + }, + { + "name": "golang", + "kind": "golang", + "port": 2900, + "path": "/health" + } + ] + }, + { + "name": "app-2", + "ip": "10.2.12.6", + "group": "app", + "services": [ + { + "name": "auth", + "kind": "java", + "port": 1000, + "path": "/actuator/health" + }, + { + "name": "other", + "kind": "java", + "port": 2400, + "path": "/actuator/health" + }, + { + "name": "live", + "kind": "java", + "port": 2500, + "path": "/actuator/health" + }, + { + "name": "console", + "kind": "java", + "port": 2700, + "path": "/console/actuator/health" + }, + { + "name": "external", + "kind": "java", + "port": 3000, + "path": "/actuator/health" + }, + { + "name": "golang", + "kind": "golang", + "port": 2900, + "path": "/health" + } + ] + }, + { + "name": "pay-1", + "ip": "10.2.11.14", + "group": "pay", + "services": [ + { + "name": "wallet", + "kind": "java", + "port": 2000, + "path": "/actuator/health" + }, + { + "name": "order", + "kind": "java", + "port": 2600, + "path": "/actuator/health" + } + ] + }, + { + "name": "pay-2", + "ip": "10.2.12.5", + "group": "pay", + "services": [ + { + "name": "wallet", + "kind": "java", + "port": 2000, + "path": "/actuator/health" + }, + { + "name": "order", + "kind": "java", + "port": 2600, + "path": "/actuator/health" + } + ] + } + ] +} diff --git a/ops/deploy_via_tat.py b/ops/deploy_via_tat.py new file mode 100755 index 0000000..46e3701 --- /dev/null +++ b/ops/deploy_via_tat.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import base64 +import json +import os +import time + +from tencentcloud.common import credential +from tencentcloud.common.profile.client_profile import ClientProfile +from tencentcloud.common.profile.http_profile import HttpProfile +from tencentcloud.tat.v20201028 import models as tat_models, tat_client + + +DEFAULT_INSTANCE_ID = os.environ.get("DEPLOY_INSTANCE_ID", "ins-ntmpcd3a") +DEFAULT_REGION = os.environ.get("DEPLOY_REGION", "me-saudi-arabia") +DEFAULT_REPO_URL = os.environ.get("MONITOR_REPO_URL", "git@gitea.haiyihy.com:hy/hy-app-monitor.git") +DEFAULT_REPO_REF = os.environ.get("MONITOR_REPO_REF", "main") +DEFAULT_REMOTE_DIR = os.environ.get("MONITOR_REMOTE_DIR", "/opt/hy-app-monitor") +DEFAULT_APP_PORT = os.environ.get("APP_PORT", "6666") + + +def env_required(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise SystemExit(f"missing env: {name}") + return value + + +def build_tat(secret_id: str, secret_key: str, region: str) -> tat_client.TatClient: + cred = credential.Credential(secret_id, secret_key) + return tat_client.TatClient( + cred, + region, + ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")), + ) + + +def remote_script(repo_url: str, repo_ref: str, remote_dir: str, app_port: str) -> str: + return f"""set -euo pipefail + +REPO_URL={json.dumps(repo_url)} +REPO_REF={json.dumps(repo_ref)} +REMOTE_DIR={json.dumps(remote_dir)} +APP_PORT={json.dumps(app_port)} + +export GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=accept-new' + +mkdir -p "$(dirname "$REMOTE_DIR")" + +if [ -d "$REMOTE_DIR/.git" ]; then + git -C "$REMOTE_DIR" fetch --all --prune + git -C "$REMOTE_DIR" checkout "$REPO_REF" + git -C "$REMOTE_DIR" pull --ff-only origin "$REPO_REF" +else + rm -rf "$REMOTE_DIR" + git clone --branch "$REPO_REF" "$REPO_URL" "$REMOTE_DIR" +fi + +cd "$REMOTE_DIR" +chmod +x scripts/*.sh +APP_PORT="$APP_PORT" bash scripts/restart.sh +sleep 1 +ss -lntp | grep ":$APP_PORT" || true +""" + + +def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, timeout_seconds: int = 600) -> str: + req = tat_models.RunCommandRequest() + req.from_json_string( + json.dumps( + { + "CommandName": "hy-app-monitor-deploy", + "CommandType": "SHELL", + "Content": base64.b64encode(script.encode("utf-8")).decode("ascii"), + "InstanceIds": [instance_id], + "Timeout": timeout_seconds, + "WorkingDirectory": "/root", + } + ) + ) + resp = json.loads(client.RunCommand(req).to_json_string()) + invocation_id = resp["InvocationId"] + deadline = time.time() + timeout_seconds + 120 + + while time.time() < deadline: + time.sleep(3) + query = tat_models.DescribeInvocationTasksRequest() + query.from_json_string( + json.dumps( + { + "HideOutput": False, + "Limit": 20, + "Filters": [{"Name": "invocation-id", "Values": [invocation_id]}], + } + ) + ) + data = json.loads(client.DescribeInvocationTasks(query).to_json_string()) + tasks = data.get("InvocationTaskSet") or [] + if not tasks: + continue + task = tasks[0] + status = task["TaskStatus"] + if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}: + output = base64.b64decode(task.get("TaskResult", {}).get("Output", "")).decode("utf-8", errors="replace") + if status != "SUCCESS": + raise SystemExit(output or f"TAT task failed: {status}") + return output + raise SystemExit("TAT task timed out") + + +def main() -> None: + secret_id = env_required("TENCENT_SECRET_ID") + secret_key = env_required("TENCENT_SECRET_KEY") + instance_id = os.environ.get("DEPLOY_INSTANCE_ID", DEFAULT_INSTANCE_ID) + region = os.environ.get("DEPLOY_REGION", DEFAULT_REGION) + repo_url = os.environ.get("MONITOR_REPO_URL", DEFAULT_REPO_URL) + repo_ref = os.environ.get("MONITOR_REPO_REF", DEFAULT_REPO_REF) + remote_dir = os.environ.get("MONITOR_REMOTE_DIR", DEFAULT_REMOTE_DIR) + app_port = os.environ.get("APP_PORT", DEFAULT_APP_PORT) + + tat = build_tat(secret_id, secret_key, region) + output = run_tat_script(tat, instance_id, remote_script(repo_url, repo_ref, remote_dir, app_port)) + print(output.strip()) + + +if __name__ == "__main__": + main() diff --git a/ops/open_port_via_tat.py b/ops/open_port_via_tat.py new file mode 100755 index 0000000..445932e --- /dev/null +++ b/ops/open_port_via_tat.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import base64 +import json +import os +import time +from pathlib import Path + +from tencentcloud.common import credential +from tencentcloud.common.profile.client_profile import ClientProfile +from tencentcloud.common.profile.http_profile import HttpProfile +from tencentcloud.cvm.v20170312 import cvm_client, models as cvm_models +from tencentcloud.tat.v20201028 import models as tat_models, tat_client + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_INSTANCE_ID = os.environ.get("DEPLOY_INSTANCE_ID", "ins-ntmpcd3a") +DEFAULT_REGION = os.environ.get("DEPLOY_REGION", "me-saudi-arabia") +DEFAULT_PORT = int(os.environ.get("OPEN_PORT", "6666")) +DEFAULT_CIDR = os.environ.get("OPEN_CIDR", "0.0.0.0/0") + + +def env_required(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise SystemExit(f"missing env: {name}") + return value + + +def build_cvm(secret_id: str, secret_key: str, region: str) -> cvm_client.CvmClient: + cred = credential.Credential(secret_id, secret_key) + return cvm_client.CvmClient( + cred, + region, + ClientProfile(httpProfile=HttpProfile(endpoint="cvm.tencentcloudapi.com")), + ) + + +def build_tat(secret_id: str, secret_key: str, region: str) -> tat_client.TatClient: + cred = credential.Credential(secret_id, secret_key) + return tat_client.TatClient( + cred, + region, + ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")), + ) + + +def resolve_security_group_id(client: cvm_client.CvmClient, instance_id: str) -> str: + req = cvm_models.DescribeInstancesRequest() + req.from_json_string(json.dumps({"InstanceIds": [instance_id]})) + resp = json.loads(client.DescribeInstances(req).to_json_string()) + instances = resp.get("InstanceSet") or [] + if not instances: + raise SystemExit(f"instance not found: {instance_id}") + security_group_ids = instances[0].get("SecurityGroupIds") or [] + if not security_group_ids: + raise SystemExit(f"no security group attached: {instance_id}") + return str(security_group_ids[0]) + + +def remote_script(secret_id: str, secret_key: str, region: str, security_group_id: str, port: int, cidr: str) -> str: + payload = { + "secret_id": secret_id, + "secret_key": secret_key, + "region": region, + "security_group_id": security_group_id, + "port": port, + "cidr": cidr, + } + blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + return f"""set -euo pipefail + +if command -v firewall-cmd >/dev/null 2>&1; then + firewall-cmd --add-port={port}/tcp --permanent >/dev/null 2>&1 || true + firewall-cmd --reload >/dev/null 2>&1 || true +fi + +if command -v ufw >/dev/null 2>&1; then + ufw allow {port}/tcp >/dev/null 2>&1 || true +fi + +if command -v iptables >/dev/null 2>&1; then + iptables -C INPUT -p tcp --dport {port} -j ACCEPT >/dev/null 2>&1 || iptables -I INPUT -p tcp --dport {port} -j ACCEPT || true +fi + +python3 -m pip install --user -q tencentcloud-sdk-python >/dev/null 2>&1 || true + +python3 - <<'PY' +import base64 +import json + +from tencentcloud.common import credential +from tencentcloud.common.profile.client_profile import ClientProfile +from tencentcloud.common.profile.http_profile import HttpProfile +from tencentcloud.vpc.v20170312 import vpc_client, models +from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException + +payload = json.loads(base64.b64decode({blob!r}).decode('utf-8')) +cred = credential.Credential(payload['secret_id'], payload['secret_key']) +client = vpc_client.VpcClient( + cred, + payload['region'], + ClientProfile(httpProfile=HttpProfile(endpoint='vpc.tencentcloudapi.com')), +) + +req = models.AuthorizeSecurityGroupPoliciesRequest() +req.from_json_string(json.dumps({{ + 'SecurityGroupId': payload['security_group_id'], + 'SecurityGroupPolicySet': {{ + 'Ingress': [{{ + 'Protocol': 'TCP', + 'Port': str(payload['port']), + 'CidrBlock': payload['cidr'], + 'Action': 'ACCEPT', + 'PolicyDescription': 'hy-app-monitor 6666', + }}] + }} +}})) + +try: + client.AuthorizeSecurityGroupPolicies(req) + print('security_group_rule=created') +except TencentCloudSDKException as exc: + if 'Duplicate' in str(exc) or 'already exists' in str(exc): + print('security_group_rule=exists') + else: + raise +PY + +echo "port_opened={port}" +""" + + +def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, timeout_seconds: int = 300) -> str: + req = tat_models.RunCommandRequest() + req.from_json_string( + json.dumps( + { + "CommandName": "hy-app-monitor-open-port", + "CommandType": "SHELL", + "Content": base64.b64encode(script.encode("utf-8")).decode("ascii"), + "InstanceIds": [instance_id], + "Timeout": timeout_seconds, + "WorkingDirectory": "/root", + } + ) + ) + resp = json.loads(client.RunCommand(req).to_json_string()) + invocation_id = resp["InvocationId"] + deadline = time.time() + timeout_seconds + 120 + + while time.time() < deadline: + time.sleep(3) + query = tat_models.DescribeInvocationTasksRequest() + query.from_json_string( + json.dumps( + { + "HideOutput": False, + "Limit": 20, + "Filters": [{"Name": "invocation-id", "Values": [invocation_id]}], + } + ) + ) + data = json.loads(client.DescribeInvocationTasks(query).to_json_string()) + tasks = data.get("InvocationTaskSet") or [] + if not tasks: + continue + task = tasks[0] + status = task["TaskStatus"] + if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}: + output = base64.b64decode(task.get("TaskResult", {}).get("Output", "")).decode("utf-8", errors="replace") + if status != "SUCCESS": + raise SystemExit(output or f"TAT task failed: {status}") + return output + raise SystemExit("TAT task timed out") + + +def main() -> None: + secret_id = env_required("TENCENT_SECRET_ID") + secret_key = env_required("TENCENT_SECRET_KEY") + instance_id = os.environ.get("DEPLOY_INSTANCE_ID", DEFAULT_INSTANCE_ID) + region = os.environ.get("DEPLOY_REGION", DEFAULT_REGION) + port = int(os.environ.get("OPEN_PORT", str(DEFAULT_PORT))) + cidr = os.environ.get("OPEN_CIDR", DEFAULT_CIDR) + + cvm = build_cvm(secret_id, secret_key, region) + tat = build_tat(secret_id, secret_key, region) + security_group_id = os.environ.get("SECURITY_GROUP_ID", "").strip() or resolve_security_group_id(cvm, instance_id) + + output = run_tat_script(tat, instance_id, remote_script(secret_id, secret_key, region, security_group_id, port, cidr)) + print(f"instance_id={instance_id}") + print(f"security_group_id={security_group_id}") + print(output.strip()) + + +if __name__ == "__main__": + main() diff --git a/requirements-ops.txt b/requirements-ops.txt new file mode 100644 index 0000000..938940a --- /dev/null +++ b/requirements-ops.txt @@ -0,0 +1 @@ +tencentcloud-sdk-python>=3.0.0 diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100755 index 0000000..385247d --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +RUN_DIR="$ROOT_DIR/run" +PID_FILE="$RUN_DIR/hy-app-monitor.pid" +LOG_FILE="$RUN_DIR/hy-app-monitor.log" +APP_BIND="${APP_BIND:-0.0.0.0}" +APP_PORT="${APP_PORT:-6666}" +PYTHON_BIN="${PYTHON_BIN:-python3}" +TARGETS_FILE="${TARGETS_FILE:-$ROOT_DIR/config/targets.json}" + +mkdir -p "$RUN_DIR" + +read_pid() { + if [ -f "$PID_FILE" ]; then + cat "$PID_FILE" + fi +} + +is_running() { + local pid + pid="$(read_pid || true)" + if [ -z "${pid:-}" ]; then + return 1 + fi + kill -0 "$pid" >/dev/null 2>&1 +} diff --git a/scripts/restart.sh b/scripts/restart.sh new file mode 100755 index 0000000..bce51cf --- /dev/null +++ b/scripts/restart.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + +"$SCRIPT_DIR/stop.sh" || true +sleep 1 +exec "$SCRIPT_DIR/start.sh" diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..7b29037 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/common.sh" + +if is_running; then + echo "hy-app-monitor already running pid=$(read_pid)" + exit 0 +fi + +if [ ! -f "$TARGETS_FILE" ]; then + echo "targets file not found: $TARGETS_FILE" >&2 + exit 1 +fi + +export APP_BIND APP_PORT TARGETS_FILE PYTHONUNBUFFERED=1 + +nohup "$PYTHON_BIN" "$ROOT_DIR/server.py" >>"$LOG_FILE" 2>&1 & +pid=$! +echo "$pid" >"$PID_FILE" + +sleep 1 +if kill -0 "$pid" >/dev/null 2>&1; then + echo "hy-app-monitor started pid=$pid port=$APP_PORT" + exit 0 +fi + +echo "hy-app-monitor failed to start" >&2 +tail -n 40 "$LOG_FILE" >&2 || true +exit 1 diff --git a/scripts/status.sh b/scripts/status.sh new file mode 100755 index 0000000..afe7711 --- /dev/null +++ b/scripts/status.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/common.sh" + +if is_running; then + echo "hy-app-monitor running pid=$(read_pid) port=$APP_PORT" + exit 0 +fi + +echo "hy-app-monitor stopped" +exit 1 diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100755 index 0000000..a1edb7f --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/common.sh" + +if ! is_running; then + echo "hy-app-monitor not running" + rm -f "$PID_FILE" + exit 0 +fi + +pid="$(read_pid)" +kill "$pid" >/dev/null 2>&1 || true + +for _ in $(seq 1 10); do + if ! kill -0 "$pid" >/dev/null 2>&1; then + rm -f "$PID_FILE" + echo "hy-app-monitor stopped" + exit 0 + fi + sleep 1 +done + +kill -9 "$pid" >/dev/null 2>&1 || true +rm -f "$PID_FILE" +echo "hy-app-monitor force stopped" diff --git a/server.py b/server.py new file mode 100644 index 0000000..56eaa3b --- /dev/null +++ b/server.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import concurrent.futures +import json +import os +import threading +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse + + +ROOT = Path(__file__).resolve().parent +STATIC_DIR = ROOT / "static" +CONFIG_PATH = Path(os.environ.get("TARGETS_FILE", ROOT / "config" / "targets.json")).expanduser() +APP_BIND = os.environ.get("APP_BIND", "0.0.0.0").strip() or "0.0.0.0" +APP_PORT = int(os.environ.get("APP_PORT", "6666")) +PROBE_TIMEOUT_SECONDS = float(os.environ.get("PROBE_TIMEOUT_SECONDS", "3")) +PROBE_CACHE_TTL_SECONDS = float(os.environ.get("PROBE_CACHE_TTL_SECONDS", "5")) +PROBE_MAX_WORKERS = int(os.environ.get("PROBE_MAX_WORKERS", "12")) + +_CACHE_LOCK = threading.Lock() +_CACHE_PAYLOAD: dict | None = None +_CACHE_AT = 0.0 + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def load_targets() -> list[dict]: + payload = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + hosts = payload.get("hosts") or [] + normalized: list[dict] = [] + for host in hosts: + services = host.get("services") or [] + for service in services: + normalized.append( + { + "host": str(host.get("name") or "").strip(), + "group": str(host.get("group") or "").strip(), + "ip": str(host.get("ip") or "").strip(), + "service": str(service.get("name") or "").strip(), + "kind": str(service.get("kind") or "java").strip(), + "port": int(service.get("port")), + "path": str(service.get("path") or "/").strip(), + } + ) + return normalized + + +def _healthy_from_body(service_kind: str, status_code: int, body_text: str) -> bool: + lowered = body_text.lower() + if service_kind == "golang": + return status_code == 200 and ('"code":"ok"' in lowered or '"code": "ok"' in lowered or '"status":"ok"' in lowered) + if status_code in {401, 403}: + return True + if status_code != 200: + return False + if '"status":"up"' in lowered or '"status": "up"' in lowered: + return True + if body_text.strip() == "UP": + return True + return "up" in lowered and "status" in lowered + + +def probe_target(target: dict) -> dict: + url = f"http://{target['ip']}:{target['port']}{target['path']}" + started = time.perf_counter() + request = urllib.request.Request(url, headers={"User-Agent": "hy-app-monitor/1.0"}) + base = { + **target, + "url": url, + "statusCode": 0, + "latencyMs": 0, + "ok": False, + "detail": "", + } + try: + with urllib.request.urlopen(request, timeout=PROBE_TIMEOUT_SECONDS) as response: + body_text = response.read().decode("utf-8", errors="replace") + latency_ms = int((time.perf_counter() - started) * 1000) + ok = _healthy_from_body(target["kind"], response.status, body_text) + return { + **base, + "statusCode": int(response.status), + "latencyMs": latency_ms, + "ok": ok, + "detail": body_text[:220].replace("\n", " ").strip(), + } + except urllib.error.HTTPError as exc: + body_text = exc.read().decode("utf-8", errors="replace") + latency_ms = int((time.perf_counter() - started) * 1000) + ok = _healthy_from_body(target["kind"], exc.code, body_text) + return { + **base, + "statusCode": int(exc.code), + "latencyMs": latency_ms, + "ok": ok, + "detail": body_text[:220].replace("\n", " ").strip() or str(exc), + } + except Exception as exc: # noqa: BLE001 + latency_ms = int((time.perf_counter() - started) * 1000) + return { + **base, + "latencyMs": latency_ms, + "detail": str(exc), + } + + +def build_monitor_payload() -> dict: + targets = load_targets() + workers = max(1, min(PROBE_MAX_WORKERS, len(targets) or 1)) + with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: + items = list(executor.map(probe_target, targets)) + + ok_count = sum(1 for item in items if item["ok"]) + payload = { + "updatedAt": utc_now(), + "summary": { + "total": len(items), + "ok": ok_count, + "down": len(items) - ok_count, + }, + "items": items, + } + return payload + + +def get_monitor_payload() -> dict: + global _CACHE_PAYLOAD, _CACHE_AT + now = time.time() + with _CACHE_LOCK: + if _CACHE_PAYLOAD is not None and now - _CACHE_AT < PROBE_CACHE_TTL_SECONDS: + return _CACHE_PAYLOAD + payload = build_monitor_payload() + with _CACHE_LOCK: + _CACHE_PAYLOAD = payload + _CACHE_AT = now + return payload + + +def response_json(payload: dict | list) -> bytes: + return (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + + +class MonitorHandler(BaseHTTPRequestHandler): + server_version = "HyAppMonitor/1.0" + + def log_message(self, format: str, *args) -> None: # noqa: A003 + return + + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path in {"/", "/index.html"}: + return self.serve_static("index.html", "text/html; charset=utf-8") + if parsed.path == "/app.css": + return self.serve_static("app.css", "text/css; charset=utf-8") + if parsed.path == "/app.js": + return self.serve_static("app.js", "application/javascript; charset=utf-8") + if parsed.path == "/api/health": + return self.send_payload(HTTPStatus.OK, response_json({"status": "ok", "time": utc_now()}), "application/json; charset=utf-8") + if parsed.path == "/api/monitor/services": + payload = get_monitor_payload() + return self.send_payload(HTTPStatus.OK, response_json(payload), "application/json; charset=utf-8") + if parsed.path == "/favicon.ico": + return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon") + return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found") + + def serve_static(self, name: str, content_type: str) -> None: + path = STATIC_DIR / name + if not path.exists(): + return self.send_error_payload(HTTPStatus.NOT_FOUND, "static file missing") + self.send_payload(HTTPStatus.OK, path.read_bytes(), content_type) + + def send_error_payload(self, status: HTTPStatus, message: str) -> None: + self.send_payload( + status, + response_json({"status": int(status), "error": message}), + "application/json; charset=utf-8", + ) + + def send_payload(self, status: HTTPStatus, body: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + +def main() -> None: + server = ThreadingHTTPServer((APP_BIND, APP_PORT), MonitorHandler) + print(f"hy-app-monitor listening on http://{APP_BIND}:{APP_PORT}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..c56a689 --- /dev/null +++ b/static/app.css @@ -0,0 +1,367 @@ +:root { + --bg: oklch(19% 0.02 248); + --bg-soft: oklch(23% 0.02 248); + --panel: oklch(27% 0.024 248 / 0.9); + --panel-strong: oklch(31% 0.028 248 / 0.95); + --line: oklch(46% 0.03 248 / 0.34); + --text: oklch(94% 0.01 248); + --muted: oklch(76% 0.02 248); + --accent: oklch(70% 0.13 206); + --ok: oklch(76% 0.16 151); + --bad: oklch(68% 0.18 24); + --shadow: 0 24px 70px rgba(5, 10, 24, 0.35); +} + +* { + box-sizing: border-box; +} + +[v-cloak] { + display: none; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + color: var(--text); + font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif; + background: + radial-gradient(circle at top left, rgba(84, 165, 255, 0.14), transparent 24%), + radial-gradient(circle at bottom right, rgba(33, 211, 146, 0.14), transparent 22%), + linear-gradient(180deg, var(--bg-soft) 0%, var(--bg) 100%); +} + +.shell { + max-width: 1360px; + margin: 0 auto; + padding: 28px 18px 48px; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr); + gap: 18px; + margin-bottom: 18px; +} + +.hero-copy, +.hero-side, +.toolbar, +.host-panel { + border: 1px solid var(--line); + border-radius: 24px; + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(14px); +} + +.hero-copy { + padding: 26px 28px; +} + +.eyebrow { + margin: 0 0 12px; + color: var(--accent); + font-size: 13px; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.hero-copy h1 { + margin: 0; + font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; + font-size: clamp(2rem, 4vw, 3.7rem); + line-height: 0.96; + letter-spacing: -0.05em; +} + +.hero-text { + max-width: 60ch; + margin: 14px 0 0; + color: var(--muted); + line-height: 1.7; +} + +.hero-side { + display: grid; + gap: 12px; + padding: 18px; +} + +.stat-block { + padding: 16px 18px; + border-radius: 18px; + background: var(--panel-strong); + border: 1px solid var(--line); +} + +.stat-block span { + display: block; + color: var(--muted); + font-size: 13px; +} + +.stat-block strong { + display: block; + margin-top: 8px; + font-size: 2rem; + letter-spacing: -0.04em; +} + +.stat-block.ok strong { + color: var(--ok); +} + +.stat-block.bad strong { + color: var(--bad); +} + +.toolbar { + display: flex; + justify-content: space-between; + gap: 14px; + padding: 14px; + margin-bottom: 12px; +} + +.toolbar-left, +.toolbar-right { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.filter-chip, +.refresh { + border: 1px solid var(--line); + background: var(--panel-strong); + color: var(--text); + border-radius: 999px; + padding: 10px 14px; + font: inherit; + cursor: pointer; + transition: transform 180ms ease, background 180ms ease, border-color 180ms ease; +} + +.filter-chip:hover, +.refresh:hover { + transform: translateY(-1px); +} + +.filter-chip.active { + border-color: color-mix(in oklab, var(--accent) 65%, white 15%); + background: color-mix(in oklab, var(--accent) 18%, var(--panel-strong)); +} + +.search { + min-width: 260px; + border: 1px solid var(--line); + background: var(--panel-strong); + color: var(--text); + border-radius: 999px; + padding: 10px 14px; + font: inherit; +} + +.meta-strip { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 2px 4px 16px; + color: var(--muted); + font-size: 13px; +} + +.bad-text { + color: var(--bad); +} + +.hosts { + display: grid; + gap: 16px; +} + +.host-panel { + padding: 18px; +} + +.host-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + margin-bottom: 16px; +} + +.host-header h2 { + margin: 0; + font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; + font-size: 1.45rem; + letter-spacing: -0.03em; +} + +.host-header p { + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; +} + +.host-badges { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.badge { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 12px; + border-radius: 999px; + font-size: 12px; + border: 1px solid var(--line); +} + +.badge.neutral { + color: var(--muted); +} + +.badge.ok { + color: var(--ok); + border-color: color-mix(in oklab, var(--ok) 50%, var(--line)); +} + +.badge.bad { + color: var(--bad); + border-color: color-mix(in oklab, var(--bad) 50%, var(--line)); +} + +.service-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.service-card { + padding: 16px; + border-radius: 20px; + border: 1px solid var(--line); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)), + var(--panel-strong); +} + +.service-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.service-name { + font-size: 1.08rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.service-kind { + margin-top: 4px; + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 0 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.status-pill.ok { + color: var(--ok); + background: color-mix(in oklab, var(--ok) 14%, transparent); +} + +.status-pill.bad { + color: var(--bad); + background: color-mix(in oklab, var(--bad) 14%, transparent); +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.service-data { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; +} + +.service-data small, +.service-url, +.service-detail { + color: var(--muted); +} + +.service-data strong { + display: block; + margin-top: 4px; + font-size: 1rem; +} + +.service-url { + margin-top: 16px; + font-size: 12px; + word-break: break-all; +} + +.service-detail { + margin-top: 8px; + min-height: 40px; + font-size: 12px; + line-height: 1.55; +} + +@media (max-width: 1080px) { + .hero { + grid-template-columns: 1fr; + } + + .service-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .toolbar, + .host-header, + .meta-strip { + flex-direction: column; + align-items: stretch; + } + + .search { + min-width: 0; + width: 100%; + } + + .service-grid, + .service-data { + grid-template-columns: 1fr; + } +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..866d8c9 --- /dev/null +++ b/static/app.js @@ -0,0 +1,105 @@ +const { createApp } = Vue; + +createApp({ + data() { + return { + loading: false, + error: "", + filter: "all", + keyword: "", + payload: { + updatedAt: "", + summary: { total: 0, ok: 0, down: 0 }, + items: [], + }, + timer: null, + }; + }, + computed: { + summary() { + return this.payload.summary || { total: 0, ok: 0, down: 0 }; + }, + formattedUpdatedAt() { + if (!this.payload.updatedAt) { + return "-"; + } + const date = new Date(this.payload.updatedAt); + return Number.isNaN(date.getTime()) ? this.payload.updatedAt : date.toLocaleString(); + }, + filteredItems() { + const keyword = this.keyword.toLowerCase(); + return (this.payload.items || []).filter((item) => { + if (this.filter === "down" && item.ok) { + return false; + } + if (this.filter === "java" && item.kind !== "java") { + return false; + } + if (this.filter === "golang" && item.kind !== "golang") { + return false; + } + if (!keyword) { + return true; + } + return [item.host, item.ip, item.service, item.kind, item.group] + .join(" ") + .toLowerCase() + .includes(keyword); + }); + }, + groupedHosts() { + const map = new Map(); + for (const item of this.filteredItems) { + if (!map.has(item.host)) { + map.set(item.host, { + host: item.host, + ip: item.ip, + group: item.group, + groupLabel: this.groupLabel(item.group), + downCount: 0, + items: [], + }); + } + const group = map.get(item.host); + group.items.push(item); + if (!item.ok) { + group.downCount += 1; + } + } + return [...map.values()]; + }, + }, + methods: { + groupLabel(group) { + return { + app: "应用节点", + pay: "支付节点", + gateway: "网关节点", + }[group] || group; + }, + async refresh() { + this.loading = true; + this.error = ""; + try { + const response = await fetch("/api/monitor/services", { cache: "no-store" }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + this.payload = await response.json(); + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } finally { + this.loading = false; + } + }, + }, + mounted() { + this.refresh(); + this.timer = window.setInterval(() => this.refresh(), 10000); + }, + beforeUnmount() { + if (this.timer) { + window.clearInterval(this.timer); + } + }, +}).mount("#app"); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..aadc86e --- /dev/null +++ b/static/index.html @@ -0,0 +1,107 @@ + + +
+ + +HY / Deploy Monitor
++ 页面本身不直接请求内网服务。所有探测都由 deploy 机器代发,适合直接通过公网 6666 访问。 +
+{{ group.ip }} · {{ group.groupLabel }}
+