{{ group.host }}
{{ group.ip }} · {{ group.groupLabel }}
{{ group.metrics.error }}
+diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8f331d5 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +APP_BIND=0.0.0.0 +APP_PORT=2026 +PROBE_TIMEOUT_SECONDS=3 +PROBE_CACHE_TTL_SECONDS=5 +PROBE_MAX_WORKERS=12 +HOST_METRIC_CACHE_TTL_SECONDS=25 +TAT_METRIC_TIMEOUT_SECONDS=30 +TAT_CPU_SAMPLE_SECONDS=1 +DEPLOY_REGION=me-saudi-arabia +TENCENT_SECRET_ID= +TENCENT_SECRET_KEY= diff --git a/.gitignore b/.gitignore index 6280d6a..11f89e7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ run/ __pycache__/ *.pyc .DS_Store +.env +.venv/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9eb0b07 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# hy-app-monitor Agent Guide + +## 项目目标 + +- 这是部署在 `deploy` 机器上的轻量监控面板。 +- 页面端口固定走 `2026`,不要再改回 `6666` 一类浏览器 unsafe port。 +- 服务健康检查直接走各业务机器的 HTTP 接口。 +- 主机 `CPU / 内存 / 磁盘 / 进程数` 统一走 `TAT` 远程采集,不额外安装 agent。 + +## 配置约定 + +- 项目根目录 `.env` 是运行时唯一配置源。 +- `.env` 必须保留在本地和远端项目目录里,但不能提交到 Git。 +- `.env.example` 只作为模板,不能写真实密钥。 +- `TENCENT_SECRET_ID` / `TENCENT_SECRET_KEY` / `DEPLOY_REGION` 从 `.env` 读取。 +- `config/targets.json` 里的 `instanceId` 不能删,`TAT` 主机采集依赖它。 + +## 运行约定 + +- 正式运行优先使用 `systemd`。 +- `scripts/install_systemd.sh` 负责安装 unit 并启用开机自启。 +- `scripts/start.sh` / `stop.sh` / `restart.sh` / `status.sh` 在检测到 unit 后应直接转调 `systemctl`。 +- `scripts/run_foreground.sh` 只给 `systemd` 用,不要把它改成后台模式。 + +## 部署约定 + +- 远端路径固定为 `/opt/hy-app-monitor`。 +- 部署入口是 `ops/deploy_via_tat.py`。 +- 该脚本需要把本地项目根目录 `.env` 同步到远端项目根目录 `.env`。 +- 远端 Python 运行时优先使用项目内 `.venv`。 +- `requirements-ops.txt` 里的依赖既服务于部署脚本,也服务于运行时 `TAT` 指标采集。 + +## 修改原则 + +- 不要把 `TAT` 采集替换成 agent 方案,除非用户明确要求。 +- 不要把密钥写进版本库。 +- 不要删除页面里的主机指标区块。 +- Python 文件保持高注释密度,优先写清楚每个步骤在做什么。 diff --git a/README.md b/README.md index 6d7246e..bcd4f3c 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,94 @@ # hy-app-monitor -一个给 `deploy` 机器使用的轻量监控页。 +部署在 `deploy` 机器上的轻量监控面板。 -- 前端:静态 HTML + Vue 3 CDN -- 后端:Python 3 标准库 HTTP 服务 -- 默认端口:`2026` -- 默认监听:`0.0.0.0` - -## 功能 - -- 从 `deploy` 机器内部探测内网 Java / Golang 服务健康状态 -- 对外提供监控页和 JSON API -- 提供启动 / 停止 / 重启 / 状态脚本 -- 提供通过 TAT 放开 2026 端口和远端拉取部署的脚本 +- 页面端口:`2026` +- 页面入口:`http://43.164.75.199:2026/` +- 服务健康:直接探测各业务服务健康接口 +- 主机资源:通过 `TAT` 批量采集 `CPU / 内存 / 磁盘 / 进程数` ## 目录 - `server.py`:后端入口 -- `config/targets.json`:监控目标配置 +- `config/targets.json`:监控目标和实例 ID - `static/`:前端页面 -- `scripts/`:启停脚本 -- `ops/`:TAT 相关脚本 +- `scripts/`:启停、自启安装、前台运行脚本 +- `systemd/`:systemd unit +- `ops/`:TAT 放行和远端部署脚本 +- `.env.example`:运行配置模板 -## 本地启动 +## 运行配置 + +项目根目录 `.env` 是唯一运行配置源,不入库。 + +最少需要: ```bash -cd /opt/hy-app-monitor -bash scripts/start.sh +APP_BIND=0.0.0.0 +APP_PORT=2026 +DEPLOY_REGION=me-saudi-arabia +TENCENT_SECRET_ID=*** +TENCENT_SECRET_KEY=*** ``` -默认访问: +可以直接复制模板: + +```bash +cp .env.example .env +``` + +## 本地运行 + +```bash +bash scripts/start.sh +bash scripts/status.sh +``` + +访问: ```bash http://127.0.0.1:2026/ ``` -## 脚本 +## systemd 开机自启 + +在远端项目目录执行: + +```bash +bash scripts/install_systemd.sh +``` + +常用命令: ```bash -bash scripts/start.sh -bash scripts/stop.sh bash scripts/restart.sh bash scripts/status.sh +bash scripts/uninstall_systemd.sh ``` -## 环境变量 - -- `APP_BIND`:默认 `0.0.0.0` -- `APP_PORT`:默认 `2026` -- `TARGETS_FILE`:默认 `config/targets.json` -- `PROBE_TIMEOUT_SECONDS`:默认 `3` -- `PROBE_CACHE_TTL_SECONDS`:默认 `5` -- `PROBE_MAX_WORKERS`:默认 `12` - -## 用 TAT 放开 2026 - -先确保本机有腾讯云密钥环境变量: - -```bash -export TENCENT_SECRET_ID=... -export TENCENT_SECRET_KEY=... -``` - -执行: +## 通过 TAT 放行端口 ```bash python3 ops/open_port_via_tat.py ``` -默认会针对 deploy 机器 `ins-ntmpcd3a` 在 `me-saudi-arabia` 区域: +默认会: -- 通过 TAT 在机器内尝试放开系统防火墙 -- 通过 TAT 调腾讯云 API 放开安全组 TCP `2026` +1. 解析 `deploy` 机器安全组 +2. 通过 `TAT` 放开机器内防火墙 +3. 通过腾讯云 API 放开安全组 TCP `2026` -## 用 TAT 拉取并启动 +## 通过 TAT 远端部署 ```bash python3 ops/deploy_via_tat.py ``` -默认远端目录: - -```bash -/opt/hy-app-monitor -``` - 默认会: -1. clone / pull 仓库 -2. 给脚本加执行权限 -3. 重启服务 -4. 检查 2026 端口监听 +1. 拉取或更新 `/opt/hy-app-monitor` +2. 把本地项目 `.env` 同步到远端项目 `.env` +3. 创建远端 `.venv` +4. 安装 `requirements-ops.txt` +5. 安装并启用 `systemd` +6. 重启服务并检查 `2026` 监听 diff --git a/config/targets.json b/config/targets.json index 472fbe8..974f3e8 100644 --- a/config/targets.json +++ b/config/targets.json @@ -1,154 +1,160 @@ { - "hosts": [ - { - "name": "gateway-1", - "ip": "10.2.1.8", - "group": "gateway", - "services": [ + "hosts": [ { - "name": "gateway", - "kind": "java", - "port": 8080, - "path": "/health" + "name": "gateway-1", + "ip": "10.2.1.8", + "group": "gateway", + "instanceId": "ins-aeyaoj40", + "services": [ + { + "name": "gateway", + "kind": "java", + "port": 8080, + "path": "/health" + } + ] + }, + { + "name": "gateway-2", + "ip": "10.2.2.17", + "group": "gateway", + "instanceId": "ins-irvi5b7u", + "services": [ + { + "name": "gateway", + "kind": "java", + "port": 8080, + "path": "/health" + } + ] + }, + { + "name": "app-1", + "ip": "10.2.11.3", + "group": "app", + "instanceId": "ins-n566mmeg", + "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", + "instanceId": "ins-kauk17la", + "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", + "instanceId": "ins-ceqfcxd2", + "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", + "instanceId": "ins-awhb74q6", + "services": [ + { + "name": "wallet", + "kind": "java", + "port": 2000, + "path": "/actuator/health" + }, + { + "name": "order", + "kind": "java", + "port": 2600, + "path": "/actuator/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 index 0b9ae87..9aaef55 100755 --- a/ops/deploy_via_tat.py +++ b/ops/deploy_via_tat.py @@ -2,37 +2,107 @@ from __future__ import annotations +# base64 用来安全传输远端脚本和 .env 内容。 import base64 +# json 用来构造腾讯云 SDK 请求。 import json +# 读取本地 .env 和运行环境变量。 import os +# TAT 轮询需要 sleep 和超时控制。 import time +# Path 用于稳定定位项目根目录。 +from pathlib import Path +# 类型提示只用于提高可读性。 from typing import Callable, TypeVar +# 腾讯云凭据对象。 from tencentcloud.common import credential +# 腾讯云 SDK 统一异常类型。 from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException +# 客户端公共配置。 from tencentcloud.common.profile.client_profile import ClientProfile +# HTTP endpoint 配置。 from tencentcloud.common.profile.http_profile import HttpProfile +# TAT 客户端和模型。 from tencentcloud.tat.v20201028 import models as tat_models, tat_client +# 项目根目录,后面要从这里读取 .env。 +ROOT = Path(__file__).resolve().parents[1] +# 默认 .env 路径。 +ENV_FILE = Path(os.environ.get("APP_ENV_FILE", ROOT / ".env")).expanduser() +# 默认 deploy 机器实例 ID。 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", "2026") +# 泛型只用于 invoke 的返回类型提示。 T = TypeVar("T") +def load_env_file(path: Path) -> None: + # .env 不存在时允许继续使用外部环境变量。 + if not path.exists(): + return + + # 逐行读取 .env。 + for raw_line in path.read_text(encoding="utf-8").splitlines(): + # 去掉两端空白。 + line = raw_line.strip() + # 空行直接跳过。 + if not line: + continue + # 注释直接跳过。 + if line.startswith("#"): + continue + # 没有等号的无效行直接跳过。 + if "=" not in line: + continue + + # 只按第一个等号拆分。 + key, value = line.split("=", 1) + # 清理 key 空白。 + key = key.strip() + # 清理 value 空白。 + value = value.strip() + + # 去掉成对双引号。 + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + + # 去掉成对单引号。 + if value.startswith("'") and value.endswith("'"): + value = value[1:-1] + + # 保持外部显式传入变量优先。 + os.environ.setdefault(key, value) + + +# 先加载项目根目录的 .env。 +load_env_file(ENV_FILE) + + 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) + # 返回 TAT 客户端。 return tat_client.TatClient( cred, region, @@ -41,27 +111,67 @@ def build_tat(secret_id: str, secret_key: str, region: str) -> tat_client.TatCli def invoke(action: Callable[[], T], label: str, retries: int = 4, delay_seconds: int = 2) -> T: + # 保存最后一次异常,便于最终抛出。 last_error: Exception | None = None + + # 按给定次数重试。 for attempt in range(1, retries + 1): try: + # 成功时直接返回。 return action() except TencentCloudSDKException as exc: + # 保存最后一次异常。 last_error = exc + # 最后一次不再等待。 if attempt == retries: break + # 做一个简单线性退避。 time.sleep(delay_seconds * attempt) + + # 有异常时直接抛出最后一次异常。 if last_error is not None: raise last_error + + # 理论上不会执行到这里,只做兜底。 raise RuntimeError(f"{label} failed without error") -def remote_script(repo_url: str, repo_ref: str, remote_dir: str, app_port: str) -> str: +def load_runtime_env() -> str: + # 如果项目根目录已经有 .env,就直接把整个文件同步到远端。 + if ENV_FILE.exists(): + return ENV_FILE.read_text(encoding="utf-8") + + # 没有 .env 时,就从当前环境里兜底构造一个最小版本。 + lines = [ + f"APP_BIND={os.environ.get('APP_BIND', '0.0.0.0')}", + f"APP_PORT={os.environ.get('APP_PORT', DEFAULT_APP_PORT)}", + f"PROBE_TIMEOUT_SECONDS={os.environ.get('PROBE_TIMEOUT_SECONDS', '3')}", + f"PROBE_CACHE_TTL_SECONDS={os.environ.get('PROBE_CACHE_TTL_SECONDS', '5')}", + f"PROBE_MAX_WORKERS={os.environ.get('PROBE_MAX_WORKERS', '12')}", + f"HOST_METRIC_CACHE_TTL_SECONDS={os.environ.get('HOST_METRIC_CACHE_TTL_SECONDS', '25')}", + f"TAT_METRIC_TIMEOUT_SECONDS={os.environ.get('TAT_METRIC_TIMEOUT_SECONDS', '30')}", + f"TAT_CPU_SAMPLE_SECONDS={os.environ.get('TAT_CPU_SAMPLE_SECONDS', '1')}", + f"DEPLOY_REGION={os.environ.get('DEPLOY_REGION', DEFAULT_REGION)}", + f"TENCENT_SECRET_ID={env_required('TENCENT_SECRET_ID')}", + f"TENCENT_SECRET_KEY={env_required('TENCENT_SECRET_KEY')}", + ] + + # 返回最终 .env 文本内容。 + return "\n".join(lines) + "\n" + + +def remote_script(repo_url: str, repo_ref: str, remote_dir: str, app_port: str, runtime_env_text: str) -> str: + # 把 .env 文本做 base64 编码,远端可以无损写入。 + env_blob = base64.b64encode(runtime_env_text.encode("utf-8")).decode("ascii") + + # 返回完整远端部署脚本。 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)} +ENV_BLOB={json.dumps(env_blob)} export GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=accept-new' @@ -77,15 +187,32 @@ else fi cd "$REMOTE_DIR" + +python3 - <<'PY' +import base64 +from pathlib import Path + +# 把本地同步过来的 .env 内容写入远端项目根目录。 +content = base64.b64decode({json.dumps(env_blob)}).decode('utf-8') +Path('.env').write_text(content, encoding='utf-8') +PY + chmod +x scripts/*.sh -APP_PORT="$APP_PORT" bash scripts/restart.sh -sleep 1 + +python3 -m venv .venv +.venv/bin/pip install --quiet --disable-pip-version-check -r requirements-ops.txt + +bash scripts/install_systemd.sh +systemctl restart hy-app-monitor.service +systemctl is-active hy-app-monitor.service 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() + # 把远端 shell 脚本下发到 deploy 机器执行。 req.from_json_string( json.dumps( { @@ -98,13 +225,21 @@ def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, } ) ) + + # 发起命令执行。 resp = json.loads(invoke(lambda: client.RunCommand(req), "RunCommand").to_json_string()) + # 获取 invocation id。 invocation_id = resp["InvocationId"] + # 轮询截止时间。 deadline = time.time() + timeout_seconds + 120 + # 开始轮询远端执行结果。 while time.time() < deadline: + # 两次轮询之间等待 3 秒。 time.sleep(3) + # 构造查询请求。 query = tat_models.DescribeInvocationTasksRequest() + # 按 invocation id 过滤本次任务。 query.from_json_string( json.dumps( { @@ -114,34 +249,63 @@ def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, } ) ) + + # 查询执行结果。 data = json.loads(invoke(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").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") + # 读取 deploy 实例 ID。 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) + # 读取要同步到远端的 .env 内容。 + runtime_env_text = load_runtime_env() + # 初始化 TAT 客户端。 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)) + # 远端执行部署脚本。 + output = run_tat_script(tat, instance_id, remote_script(repo_url, repo_ref, remote_dir, app_port, runtime_env_text)) + # 打印远端输出,便于观察部署结果。 print(output.strip()) if __name__ == "__main__": + # 作为脚本执行时进入主流程。 main() diff --git a/ops/open_port_via_tat.py b/ops/open_port_via_tat.py index 2f640a6..bc1143a 100755 --- a/ops/open_port_via_tat.py +++ b/ops/open_port_via_tat.py @@ -2,38 +2,105 @@ from __future__ import annotations +# base64 用来把远端脚本和 JSON 安全塞进 TAT 请求。 import base64 +# json 用来构造腾讯云 SDK 请求和解析响应。 import json +# 环境变量读取来自项目根目录的 .env。 import os +# 轮询 TAT 执行结果时需要 sleep 和超时控制。 import time +# Path 用于稳定定位项目根目录和 .env 文件。 from pathlib import Path +# 类型提示只用于提升可读性。 from typing import Callable, TypeVar +# 腾讯云凭据对象。 from tencentcloud.common import credential +# 腾讯云 SDK 统一异常类型。 from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException +# 客户端公共配置。 from tencentcloud.common.profile.client_profile import ClientProfile +# HTTP endpoint 配置。 from tencentcloud.common.profile.http_profile import HttpProfile +# CVM 用来查实例绑定的安全组。 from tencentcloud.cvm.v20170312 import cvm_client, models as cvm_models +# TAT 用来远程执行放行脚本。 from tencentcloud.tat.v20201028 import models as tat_models, tat_client +# 项目根目录,后面要从这里读 .env。 ROOT = Path(__file__).resolve().parents[1] +# 默认 .env 放在项目根目录,并且不入库。 +ENV_FILE = Path(os.environ.get("APP_ENV_FILE", ROOT / ".env")).expanduser() +# 默认 deploy 机器实例 ID。 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", "2026")) +# 默认允许所有来源访问该端口。 DEFAULT_CIDR = os.environ.get("OPEN_CIDR", "0.0.0.0/0") +# 泛型只用于 invoke 的返回类型提示。 T = TypeVar("T") +def load_env_file(path: Path) -> None: + # .env 不存在时允许继续使用外部环境变量。 + if not path.exists(): + return + + # 逐行解析 shell 风格的 KEY=VALUE。 + for raw_line in path.read_text(encoding="utf-8").splitlines(): + # 去掉空白方便判断。 + line = raw_line.strip() + # 空行直接跳过。 + if not line: + continue + # 注释行直接跳过。 + if line.startswith("#"): + continue + # 无等号行直接跳过。 + if "=" not in line: + continue + + # 只按第一个等号拆分。 + key, value = line.split("=", 1) + # 清理 key 空白。 + key = key.strip() + # 清理 value 空白。 + value = value.strip() + + # 去掉成对双引号。 + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + + # 去掉成对单引号。 + if value.startswith("'") and value.endswith("'"): + value = value[1:-1] + + # 只有环境里还没有这个 key 时才写入。 + os.environ.setdefault(key, value) + + +# 先从项目根目录加载 .env。 +load_env_file(ENV_FILE) + + def env_required(name: str) -> str: + # 读取环境变量并去掉两端空白。 value = os.environ.get(name, "").strip() + # 缺失时直接退出,避免后面报一串 SDK 错误。 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) + # 返回 CVM 客户端。 return cvm_client.CvmClient( cred, region, @@ -42,7 +109,9 @@ def build_cvm(secret_id: str, secret_key: str, region: str) -> cvm_client.CvmCli def build_tat(secret_id: str, secret_key: str, region: str) -> tat_client.TatClient: + # 构造腾讯云凭据对象。 cred = credential.Credential(secret_id, secret_key) + # 返回 TAT 客户端。 return tat_client.TatClient( cred, region, @@ -51,34 +120,58 @@ def build_tat(secret_id: str, secret_key: str, region: str) -> tat_client.TatCli def invoke(action: Callable[[], T], label: str, retries: int = 4, delay_seconds: int = 2) -> T: + # 保存最后一次异常,方便重试结束后抛出。 last_error: Exception | None = None + + # 按给定次数进行重试。 for attempt in range(1, retries + 1): try: + # 成功时直接返回结果。 return action() except TencentCloudSDKException as exc: + # 保存异常信息。 last_error = exc + # 最后一次重试不再 sleep,直接退出循环。 if attempt == retries: break + # 简单线性退避,降低偶发 EOF 的影响。 time.sleep(delay_seconds * attempt) + + # 有异常就把最后一次异常抛出去。 if last_error is not None: raise last_error + + # 理论上不会到这里,这里只是兜底。 raise RuntimeError(f"{label} failed without error") 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(invoke(lambda: client.DescribeInstances(req), "DescribeInstances").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}") + + # 返回第一个安全组 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: + # 把必要参数序列化后再 base64,方便塞进远端 Python 脚本。 payload = { "secret_id": secret_id, "secret_key": secret_key, @@ -87,7 +180,11 @@ def remote_script(secret_id: str, secret_key: str, region: str, security_group_i "port": port, "cidr": cidr, } + + # 远端脚本里直接解码这个 blob。 blob = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + + # 返回完整远端 shell 脚本。 return f"""set -euo pipefail if command -v firewall-cmd >/dev/null 2>&1; then @@ -103,26 +200,30 @@ 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 -m pip install --quiet --disable-pip-version-check tencentcloud-sdk-python >/dev/null 2>&1 || true python3 - <<'PY' import base64 import json from tencentcloud.common import credential +from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException 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 +from tencentcloud.vpc.v20170312 import models, vpc_client +# 读取本次放行需要的参数。 payload = json.loads(base64.b64decode({blob!r}).decode('utf-8')) +# 构造凭据对象。 cred = credential.Credential(payload['secret_id'], payload['secret_key']) +# 初始化 VPC 客户端,用来写安全组规则。 client = vpc_client.VpcClient( cred, payload['region'], ClientProfile(httpProfile=HttpProfile(endpoint='vpc.tencentcloudapi.com')), ) +# 构造创建安全组入站规则的请求。 req = models.CreateSecurityGroupPoliciesRequest() req.from_json_string(json.dumps({{ 'SecurityGroupId': payload['security_group_id'], @@ -132,15 +233,17 @@ req.from_json_string(json.dumps({{ 'Port': str(payload['port']), 'CidrBlock': payload['cidr'], 'Action': 'ACCEPT', - 'PolicyDescription': f"hy-app-monitor {payload['port']}", + 'PolicyDescription': f"hy-app-monitor {{payload['port']}}", }}] }} }})) try: + # 创建安全组规则。 client.CreateSecurityGroupPolicies(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: @@ -152,7 +255,9 @@ 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() + # 把 shell 脚本下发到目标实例执行。 req.from_json_string( json.dumps( { @@ -165,13 +270,21 @@ def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, } ) ) + + # 发起执行命令请求。 resp = json.loads(invoke(lambda: client.RunCommand(req), "RunCommand").to_json_string()) + # 取出这次执行的 invocation id。 invocation_id = resp["InvocationId"] + # 设置轮询截止时间。 deadline = time.time() + timeout_seconds + 120 + # 循环轮询任务结果。 while time.time() < deadline: + # 两次轮询之间稍微停一下。 time.sleep(3) + # 创建查询任务结果请求。 query = tat_models.DescribeInvocationTasksRequest() + # 按 invocation id 查询本次任务。 query.from_json_string( json.dumps( { @@ -181,37 +294,66 @@ def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, } ) ) + + # 读取任务执行状态。 data = json.loads(invoke(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").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") + # 非 SUCCESS 直接退出并带上远端输出。 if status != "SUCCESS": raise SystemExit(output or f"TAT task failed: {status}") + # SUCCESS 时返回远端输出。 return output + + # 超过截止时间还没完成就直接报超时。 raise SystemExit("TAT task timed out") def main() -> None: + # 从 .env 或外部环境中读取腾讯云密钥。 secret_id = env_required("TENCENT_SECRET_ID") + # 从 .env 或外部环境中读取腾讯云密钥。 secret_key = env_required("TENCENT_SECRET_KEY") + # 读取目标实例 ID。 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 客户端,用来查安全组。 cvm = build_cvm(secret_id, secret_key, region) + # 初始化 TAT 客户端,用来远程执行脚本。 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)) + # 打印实例 ID 方便确认目标机器。 print(f"instance_id={instance_id}") + # 打印实际使用的安全组 ID。 print(f"security_group_id={security_group_id}") + # 打印远端执行结果。 print(output.strip()) if __name__ == "__main__": + # 作为脚本执行时直接走主流程。 main() diff --git a/scripts/common.sh b/scripts/common.sh index 8d7ba81..ece8862 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -5,11 +5,28 @@ 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" +ENV_FILE="${APP_ENV_FILE:-$ROOT_DIR/.env}" +SYSTEMD_SERVICE_NAME="hy-app-monitor.service" + +if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a +fi + APP_BIND="${APP_BIND:-0.0.0.0}" APP_PORT="${APP_PORT:-2026}" -PYTHON_BIN="${PYTHON_BIN:-python3}" TARGETS_FILE="${TARGETS_FILE:-$ROOT_DIR/config/targets.json}" +if [ -x "$ROOT_DIR/.venv/bin/python" ]; then + PYTHON_BIN_DEFAULT="$ROOT_DIR/.venv/bin/python" +else + PYTHON_BIN_DEFAULT="python3" +fi + +PYTHON_BIN="${PYTHON_BIN:-$PYTHON_BIN_DEFAULT}" + mkdir -p "$RUN_DIR" read_pid() { @@ -26,3 +43,10 @@ is_running() { fi kill -0 "$pid" >/dev/null 2>&1 } + +has_systemd_unit() { + if ! command -v systemctl >/dev/null 2>&1; then + return 1 + fi + systemctl cat "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 +} diff --git a/scripts/install_systemd.sh b/scripts/install_systemd.sh new file mode 100755 index 0000000..fab6b54 --- /dev/null +++ b/scripts/install_systemd.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/common.sh" + +UNIT_SRC="$ROOT_DIR/systemd/hy-app-monitor.service" +UNIT_DST="/etc/systemd/system/$SYSTEMD_SERVICE_NAME" + +if [ ! -f "$UNIT_SRC" ]; then + echo "systemd unit missing: $UNIT_SRC" >&2 + exit 1 +fi + +if [ ! -f "$ENV_FILE" ]; then + echo "env file missing: $ENV_FILE" >&2 + exit 1 +fi + +install -m 0644 "$UNIT_SRC" "$UNIT_DST" +chmod +x "$ROOT_DIR"/scripts/*.sh +systemctl daemon-reload +systemctl enable "$SYSTEMD_SERVICE_NAME" +systemctl restart "$SYSTEMD_SERVICE_NAME" +systemctl is-active "$SYSTEMD_SERVICE_NAME" diff --git a/scripts/restart.sh b/scripts/restart.sh index bce51cf..235de59 100755 --- a/scripts/restart.sh +++ b/scripts/restart.sh @@ -2,6 +2,14 @@ set -euo pipefail SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/common.sh" + +if has_systemd_unit; then + systemctl restart "$SYSTEMD_SERVICE_NAME" + systemctl is-active "$SYSTEMD_SERVICE_NAME" + exit 0 +fi "$SCRIPT_DIR/stop.sh" || true sleep 1 diff --git a/scripts/run_foreground.sh b/scripts/run_foreground.sh new file mode 100755 index 0000000..4c9f2d7 --- /dev/null +++ b/scripts/run_foreground.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 [ ! -f "$TARGETS_FILE" ]; then + echo "targets file not found: $TARGETS_FILE" >&2 + exit 1 +fi + +export APP_BIND APP_PORT TARGETS_FILE PYTHONUNBUFFERED=1 +exec "$PYTHON_BIN" "$ROOT_DIR/server.py" diff --git a/scripts/start.sh b/scripts/start.sh index 7b29037..4ba2a5f 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -5,6 +5,12 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/common.sh" +if has_systemd_unit; then + systemctl start "$SYSTEMD_SERVICE_NAME" + systemctl is-active "$SYSTEMD_SERVICE_NAME" + exit 0 +fi + if is_running; then echo "hy-app-monitor already running pid=$(read_pid)" exit 0 diff --git a/scripts/status.sh b/scripts/status.sh index afe7711..3b0d00a 100755 --- a/scripts/status.sh +++ b/scripts/status.sh @@ -5,6 +5,15 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/common.sh" +if has_systemd_unit; then + if systemctl is-active --quiet "$SYSTEMD_SERVICE_NAME"; then + echo "hy-app-monitor running under systemd port=$APP_PORT" + exit 0 + fi + echo "hy-app-monitor stopped" + exit 1 +fi + if is_running; then echo "hy-app-monitor running pid=$(read_pid) port=$APP_PORT" exit 0 diff --git a/scripts/stop.sh b/scripts/stop.sh index a1edb7f..cfa8061 100755 --- a/scripts/stop.sh +++ b/scripts/stop.sh @@ -5,6 +5,12 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/common.sh" +if has_systemd_unit; then + systemctl stop "$SYSTEMD_SERVICE_NAME" + echo "hy-app-monitor stopped" + exit 0 +fi + if ! is_running; then echo "hy-app-monitor not running" rm -f "$PID_FILE" diff --git a/scripts/uninstall_systemd.sh b/scripts/uninstall_systemd.sh new file mode 100755 index 0000000..f8057d9 --- /dev/null +++ b/scripts/uninstall_systemd.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/common.sh" + +UNIT_DST="/etc/systemd/system/$SYSTEMD_SERVICE_NAME" + +if command -v systemctl >/dev/null 2>&1; then + systemctl disable --now "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 || true + systemctl daemon-reload || true +fi + +rm -f "$UNIT_DST" +echo "systemd unit removed: $UNIT_DST" diff --git a/server.py b/server.py index 7c556e5..1ad3d2b 100644 --- a/server.py +++ b/server.py @@ -2,91 +2,232 @@ from __future__ import annotations +# 标准库并发工具,用来并行探测各个服务健康状态。 import concurrent.futures +# 标准 JSON 编解码,用来读取配置和输出接口响应。 import json +# 进程环境变量读取,用来接收 .env 和 systemd 注入的运行参数。 import os +# 线程锁用来保护缓存,避免并发请求重复打 TAT 和健康检查。 import threading +# 时间模块用于缓存过期判断和 CPU 采样间隔。 import time +# urllib 用于直接探测各台机器上的 HTTP 健康检查接口。 import urllib.error import urllib.request +# 时间戳统一输出成 UTC ISO 字符串,前端展示更稳定。 from datetime import datetime, timezone +# HTTP 状态码常量,避免在代码里写魔法数字。 from http import HTTPStatus +# 使用标准库 HTTP 服务器承载监控页和 JSON API。 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +# 路径对象比字符串拼接更安全。 from pathlib import Path +# 类型提示用于提升代码可读性。 +from typing import Any +# 解析请求路径时只关心 path 段。 from urllib.parse import urlparse +# 项目根目录,后续所有相对路径都从这里展开。 ROOT = Path(__file__).resolve().parent +# 静态资源目录,存放 HTML、CSS、JS、Vue 文件。 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", "2026")) -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")) +# 默认 .env 文件放在项目根目录,并且不入库。 +DEFAULT_ENV_PATH = ROOT / ".env" -_CACHE_LOCK = threading.Lock() -_CACHE_PAYLOAD: dict | None = None -_CACHE_AT = 0.0 + +def load_env_file(path: Path) -> None: + # 如果 .env 不存在就直接跳过,允许只靠外部环境变量运行。 + if not path.exists(): + return + + # 逐行读取 .env,使用 shell 兼容的 KEY=VALUE 结构。 + for raw_line in path.read_text(encoding="utf-8").splitlines(): + # 去掉首尾空白,便于判断空行和注释。 + line = raw_line.strip() + # 空行直接跳过。 + if not line: + continue + # 注释行直接跳过。 + if line.startswith("#"): + continue + # 没有等号的无效行直接跳过,避免把异常内容灌进环境变量。 + if "=" not in line: + continue + + # 只按第一个等号拆分,避免 value 里出现额外等号时被截断。 + key, value = line.split("=", 1) + # 去掉 key 的两端空格。 + key = key.strip() + # 去掉 value 的两端空格。 + value = value.strip() + + # 去掉成对的双引号,兼容常见 .env 写法。 + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + + # 去掉成对的单引号,兼容常见 .env 写法。 + if value.startswith("'") and value.endswith("'"): + value = value[1:-1] + + # 只在环境变量不存在时写入,保证外部显式传入的值优先级更高。 + os.environ.setdefault(key, value) + + +# 先加载项目根目录的 .env,后面常量都基于这个环境初始化。 +load_env_file(Path(os.environ.get("APP_ENV_FILE", DEFAULT_ENV_PATH)).expanduser()) + +# 配置文件路径默认指向 config/targets.json,也允许用环境变量覆盖。 +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" +# 页面服务默认监听 2026,避开浏览器 unsafe port。 +APP_PORT = int(os.environ.get("APP_PORT", "2026")) +# 单个服务健康探测超时时间。 +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")) +# 主机指标缓存时间比服务探测更长,因为 TAT 成本更高。 +HOST_METRIC_CACHE_TTL_SECONDS = float(os.environ.get("HOST_METRIC_CACHE_TTL_SECONDS", "25")) +# TAT 执行超时时间,主机采集会做一次 CPU 采样,所以时间略长一点。 +TAT_METRIC_TIMEOUT_SECONDS = int(os.environ.get("TAT_METRIC_TIMEOUT_SECONDS", "30")) +# 采样 CPU 时的两次读数间隔。 +TAT_CPU_SAMPLE_SECONDS = float(os.environ.get("TAT_CPU_SAMPLE_SECONDS", "1")) + +# 服务健康结果缓存锁。 +_SERVICE_CACHE_LOCK = threading.Lock() +# 服务健康结果缓存内容。 +_SERVICE_CACHE_PAYLOAD: dict[str, Any] | None = None +# 服务健康结果最后生成时间。 +_SERVICE_CACHE_AT = 0.0 + +# 主机指标缓存锁。 +_HOST_CACHE_LOCK = threading.Lock() +# 主机指标缓存内容。 +_HOST_CACHE_PAYLOAD: dict[str, dict[str, Any]] | None = None +# 主机指标最后生成时间。 +_HOST_CACHE_AT = 0.0 def utc_now() -> str: + # 统一返回无微秒的 UTC ISO 时间,前后端更容易比较。 return datetime.now(timezone.utc).replace(microsecond=0).isoformat() -def load_targets() -> list[dict]: +def load_hosts() -> list[dict[str, Any]]: + # 读取监控配置文件。 payload = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + # hosts 节点不存在时回退成空列表。 hosts = payload.get("hosts") or [] - normalized: list[dict] = [] + # 规范化后的主机列表。 + normalized: list[dict[str, Any]] = [] + + # 逐台主机提取可用字段。 for host in hosts: - services = host.get("services") or [] - for service in services: - normalized.append( + # 统一整理字段,避免前端和后端在不同地方重复判断默认值。 + normalized.append( + { + "host": str(host.get("name") or "").strip(), + "group": str(host.get("group") or "").strip(), + "ip": str(host.get("ip") or "").strip(), + "instanceId": str(host.get("instanceId") or "").strip(), + "services": host.get("services") or [], + } + ) + + # 返回主机列表给后续服务探测和 TAT 指标采集复用。 + return normalized + + +def flatten_services(hosts: list[dict[str, Any]]) -> list[dict[str, Any]]: + # 扁平化后的服务列表,便于并发探测。 + services: list[dict[str, Any]] = [] + + # 按主机展开它上面的所有服务。 + for host in hosts: + # 逐个服务转换为统一结构。 + for service in host.get("services") or []: + services.append( { - "host": str(host.get("name") or "").strip(), - "group": str(host.get("group") or "").strip(), - "ip": str(host.get("ip") or "").strip(), + "host": host["host"], + "group": host["group"], + "ip": host["ip"], + "instanceId": host["instanceId"], "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 + + # 返回扁平服务列表供 HTTP 探测使用。 + return services -def _healthy_from_body(service_kind: str, status_code: int, body_text: str) -> bool: +def healthy_from_body(service_kind: str, status_code: int, body_text: str) -> bool: + # 先把响应体统一转成小写,便于后续模糊判断。 lowered = body_text.lower() + + # Golang 服务当前按项目里的 /health 返回体做判断。 if service_kind == "golang": - return status_code == 200 and ('"code":"ok"' in lowered or '"code": "ok"' in lowered or '"status":"ok"' in lowered) + return status_code == 200 and ( + '"code":"ok"' in lowered + or '"code": "ok"' in lowered + or '"status":"ok"' in lowered + or '"status": "ok"' in lowered + ) + + # 有些 Java 网关健康接口会返回 401/403,但服务本身是活的。 if status_code in {401, 403}: return True + + # 其余 Java 服务非 200 一律认为异常。 if status_code != 200: return False + + # Spring Boot actuator 常见格式是 status=UP。 if '"status":"up"' in lowered or '"status": "up"' in lowered: return True + + # 兼容只返回裸字符串 UP 的老接口。 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']}" +def probe_service(service: dict[str, Any]) -> dict[str, Any]: + # 拼出服务健康检查地址。 + url = f"http://{service['ip']}:{service['port']}{service['path']}" + # 记录开始时间,用来计算耗时。 started = time.perf_counter() + # 构造请求对象,带一个简单的 User-Agent 便于区分来源。 request = urllib.request.Request(url, headers={"User-Agent": "hy-app-monitor/1.0"}) + + # 先准备公共返回结构。 base = { - **target, + **service, "url": url, "statusCode": 0, "latencyMs": 0, "ok": False, "detail": "", } + try: + # 发起 HTTP 请求探测服务。 with urllib.request.urlopen(request, timeout=PROBE_TIMEOUT_SECONDS) as response: + # 读取响应体,并按 UTF-8 兜底解码。 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) + # 基于状态码和响应体判断服务是否健康。 + ok = healthy_from_body(service["kind"], response.status, body_text) + # 返回完整探测结果。 return { **base, "statusCode": int(response.status), @@ -95,9 +236,13 @@ def probe_target(target: dict) -> dict: "detail": body_text[:220].replace("\n", " ").strip(), } except urllib.error.HTTPError as exc: + # 即使是 HTTP 错误,也尽量读取响应体做更准确的健康判断。 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) + # 用 HTTP 错误码继续判断是否属于“服务活着但被拒绝访问”。 + ok = healthy_from_body(service["kind"], exc.code, body_text) + # 返回错误场景下的探测结果。 return { **base, "statusCode": int(exc.code), @@ -106,7 +251,9 @@ def probe_target(target: dict) -> dict: "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, @@ -114,64 +261,522 @@ def probe_target(target: dict) -> dict: } -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)) +def build_service_payload(hosts: list[dict[str, Any]]) -> dict[str, Any]: + # 把主机配置展开成服务配置。 + services = flatten_services(hosts) + # 并发数至少 1,且不超过配置值和服务总数。 + workers = max(1, min(PROBE_MAX_WORKERS, len(services) or 1)) + # 并发探测所有服务。 + with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: + items = list(executor.map(probe_service, services)) + + # 统计成功服务数量。 ok_count = sum(1 for item in items if item["ok"]) - payload = { + + # 返回服务层结果。 + return { "updatedAt": utc_now(), "summary": { "total": len(items), "ok": ok_count, "down": len(items) - ok_count, + "hostTotal": len(hosts), }, "items": items, } - return payload -def get_monitor_payload() -> dict: - global _CACHE_PAYLOAD, _CACHE_AT +def get_service_payload(hosts: list[dict[str, Any]]) -> dict[str, Any]: + # 使用独立缓存,减少频繁刷新带来的内网 HTTP 压力。 + global _SERVICE_CACHE_PAYLOAD, _SERVICE_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 + + # 先在锁内检查服务缓存是否还能复用。 + with _SERVICE_CACHE_LOCK: + if _SERVICE_CACHE_PAYLOAD is not None and now - _SERVICE_CACHE_AT < PROBE_CACHE_TTL_SECONDS: + return _SERVICE_CACHE_PAYLOAD + + # 缓存过期时重新构建服务探测结果。 + payload = build_service_payload(hosts) + + # 在锁内写回最新缓存。 + with _SERVICE_CACHE_LOCK: + _SERVICE_CACHE_PAYLOAD = payload + _SERVICE_CACHE_AT = now + + # 返回最新服务探测结果。 return payload -def response_json(payload: dict | list) -> bytes: +def decode_task_output(output: str) -> str: + # TAT 返回的输出是 base64 文本,这里统一做解码。 + if not output: + return "" + + # 解码成 UTF-8 文本,异常字符用 replace 兜底。 + import base64 + + return base64.b64decode(output).decode("utf-8", errors="replace") + + +def build_tat_client() -> tuple[Any | None, str]: + # 如果没有腾讯云密钥,直接返回错误说明,前端就显示“无主机指标”。 + secret_id = os.environ.get("TENCENT_SECRET_ID", "").strip() + secret_key = os.environ.get("TENCENT_SECRET_KEY", "").strip() + region = os.environ.get("DEPLOY_REGION", "").strip() + + # 密钥缺失时不再继续初始化 SDK。 + if not secret_id or not secret_key or not region: + return None, "missing TAT credentials in .env" + + try: + # 运行时再动态导入 SDK,避免没装 SDK 时整个页面直接起不来。 + from tencentcloud.common import credential + from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException + from tencentcloud.common.profile.client_profile import ClientProfile + from tencentcloud.common.profile.http_profile import HttpProfile + from tencentcloud.tat.v20201028 import tat_client + except Exception as exc: # noqa: BLE001 + # SDK 缺失时返回错误信息给前端。 + return None, f"tencentcloud sdk unavailable: {exc}" + + # 创建凭据对象。 + cred = credential.Credential(secret_id, secret_key) + # 创建 TAT 客户端,后续所有主机指标都通过它拉取。 + client = tat_client.TatClient( + cred, + region, + ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")), + ) + + # 成功时返回客户端和空错误。 + return client, "" + + +def invoke_tat(action: Any, label: str, retries: int = 3, delay_seconds: int = 2) -> Any: + # 这里延迟导入腾讯云异常类型,避免 SDK 缺失时模块导入直接失败。 + from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException + + # 保存最后一次异常,便于最终抛出。 + last_error: Exception | None = None + + # 按给定次数做简单重试。 + for attempt in range(1, retries + 1): + try: + # 成功时直接返回调用结果。 + return action() + except TencentCloudSDKException as exc: + # 保存最后一次异常。 + last_error = exc + # 最后一次重试不再 sleep。 + if attempt == retries: + break + # 做一个简单退避,避免瞬时网络抖动。 + time.sleep(delay_seconds * attempt) + + # 有异常时直接抛出最后一次异常。 + if last_error is not None: + raise last_error + + # 理论上不会执行到这里,这里只是兜底。 + raise RuntimeError(f"{label} failed without error") + + +def build_metrics_script() -> str: + # 远端脚本本身只依赖 Python 标准库,避免每台机器再装额外 agent。 + return f"""set -euo pipefail +python3 - <<'PY' +import json +import os +import shutil +import time + +# 读取 /proc/stat 的第一行来采样 CPU。 +def read_cpu(): + with open('/proc/stat', 'r', encoding='utf-8') as handle: + parts = handle.readline().split()[1:8] + values = [int(item) for item in parts] + idle = values[3] + values[4] + total = sum(values) + return idle, total + +# 第一次 CPU 采样。 +idle_1, total_1 = read_cpu() +# 等待一小段时间做第二次采样。 +time.sleep({TAT_CPU_SAMPLE_SECONDS}) +# 第二次 CPU 采样。 +idle_2, total_2 = read_cpu() + +# 计算总差值,防止极端情况下出现 0。 +total_diff = max(total_2 - total_1, 1) +# 计算 idle 差值。 +idle_diff = max(idle_2 - idle_1, 0) +# 算出 CPU 使用率百分比。 +cpu_percent = round((1 - idle_diff / total_diff) * 100, 2) + +# 解析内存信息。 +memory_info = {{}} +with open('/proc/meminfo', 'r', encoding='utf-8') as handle: + for line in handle: + key, value = line.split(':', 1) + memory_info[key] = int(value.strip().split()[0]) + +# 总内存,单位换成 GB。 +memory_total_gb = round(memory_info.get('MemTotal', 0) / 1024 / 1024, 2) +# 可用内存,单位换成 GB。 +memory_available_gb = round(memory_info.get('MemAvailable', 0) / 1024 / 1024, 2) +# 已用内存。 +memory_used_gb = round(max(memory_total_gb - memory_available_gb, 0), 2) +# 内存使用率。 +memory_percent = round((memory_used_gb / memory_total_gb) * 100, 2) if memory_total_gb else 0.0 + +# 读取根分区磁盘占用。 +disk = shutil.disk_usage('/') +# 总磁盘,单位换成 GB。 +disk_total_gb = round(disk.total / 1024 / 1024 / 1024, 2) +# 已用磁盘,单位换成 GB。 +disk_used_gb = round(disk.used / 1024 / 1024 / 1024, 2) +# 磁盘使用率。 +disk_percent = round((disk.used / disk.total) * 100, 2) if disk.total else 0.0 + +# 通过 /proc 目录估算当前进程数。 +process_count = len([name for name in os.listdir('/proc') if name.isdigit()]) + +# 输出统一 JSON,方便服务端直接解析。 +print(json.dumps({{ + 'ok': True, + 'hostname': os.uname().nodename, + 'cpuPercent': cpu_percent, + 'memoryPercent': memory_percent, + 'memoryUsedGb': memory_used_gb, + 'memoryTotalGb': memory_total_gb, + 'diskPercent': disk_percent, + 'diskUsedGb': disk_used_gb, + 'diskTotalGb': disk_total_gb, + 'processCount': process_count, +}})) +PY +""" + + +def collect_host_metrics(hosts: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + # 先把主机名和实例 ID 建立映射,后面 TAT 返回时要按实例回填。 + host_by_instance = { + host["instanceId"]: host + for host in hosts + if host.get("instanceId") + } + + # 去重后的实例 ID 列表。 + instance_ids = list(host_by_instance.keys()) + + # 如果配置里没有 instanceId,直接返回错误占位。 + if not instance_ids: + return { + host["host"]: { + "ok": False, + "error": "missing instanceId in config/targets.json", + "updatedAt": utc_now(), + } + for host in hosts + } + + # 初始化 TAT 客户端。 + client, error = build_tat_client() + + # 没拿到客户端时,给所有主机写统一错误。 + if client is None: + return { + host["host"]: { + "ok": False, + "error": error, + "updatedAt": utc_now(), + } + for host in hosts + } + + # 这里延迟导入 SDK model,避免上面客户端初始化失败时多余导入。 + from tencentcloud.tat.v20201028 import models as tat_models + + # 准备执行远端指标采集脚本。 + req = tat_models.RunCommandRequest() + # 批量下发到所有实例。 + req.from_json_string( + json.dumps( + { + "CommandName": "hy-app-monitor-host-metrics", + "CommandType": "SHELL", + "Content": __import__("base64").b64encode(build_metrics_script().encode("utf-8")).decode("ascii"), + "InstanceIds": instance_ids, + "Timeout": TAT_METRIC_TIMEOUT_SECONDS, + "WorkingDirectory": "/root", + } + ) + ) + + # 发起 TAT 命令。 + response = json.loads(invoke_tat(lambda: client.RunCommand(req), "RunCommand").to_json_string()) + # 拿到本次调用 ID,后面轮询它的执行结果。 + invocation_id = response["InvocationId"] + # 轮询截止时间比命令超时时间稍长一点。 + deadline = time.time() + TAT_METRIC_TIMEOUT_SECONDS + 60 + # 记录最终结果,key 是实例 ID。 + results: dict[str, dict[str, Any]] = {} + + # 开始轮询所有实例的执行结果。 + while time.time() < deadline: + # 轮询间隔不要太短,避免频繁打 API。 + time.sleep(2) + + # 构造查询任务结果的请求。 + query = tat_models.DescribeInvocationTasksRequest() + # 按 invocation id 过滤当前这次调用的所有实例结果。 + query.from_json_string( + json.dumps( + { + "HideOutput": False, + "Limit": 50, + "Filters": [{"Name": "invocation-id", "Values": [invocation_id]}], + } + ) + ) + + # 拉取本次调用的实例执行结果。 + data = json.loads(invoke_tat(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").to_json_string()) + # 任务列表可能还没完全生成。 + tasks = data.get("InvocationTaskSet") or [] + + # 没有任务结果时继续等待。 + if not tasks: + continue + + # 遍历当前已返回的所有实例结果。 + for task in tasks: + # 拿到实例 ID。 + instance_id = task.get("InstanceId", "") + # 读取当前实例执行状态。 + status = task.get("TaskStatus", "") + # 只处理终态,未完成的继续等下一轮。 + if status not in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}: + continue + + # 成功时解码输出并尝试解析成 JSON。 + if status == "SUCCESS": + try: + # 读取远端标准输出。 + output_text = decode_task_output(task.get("TaskResult", {}).get("Output", "")) + # 解析远端输出的 JSON。 + payload = json.loads(output_text or "{}") + # 补齐统一时间字段。 + payload["updatedAt"] = utc_now() + # 收下当前实例成功结果。 + results[instance_id] = payload + except Exception as exc: # noqa: BLE001 + # 输出格式异常时仍然要保留错误信息。 + results[instance_id] = { + "ok": False, + "error": f"metrics parse failed: {exc}", + "updatedAt": utc_now(), + } + continue + + # 失败时解码输出并记录错误。 + results[instance_id] = { + "ok": False, + "error": decode_task_output(task.get("TaskResult", {}).get("Output", "")) or status, + "updatedAt": utc_now(), + } + + # 如果已经收齐所有实例结果,就可以结束轮询。 + if len(results) >= len(instance_ids): + break + + # 按 host name 回填最终主机指标。 + payload_by_host: dict[str, dict[str, Any]] = {} + + # 对每个主机构造最终输出。 + for host in hosts: + # 读取主机对应实例 ID。 + instance_id = host.get("instanceId", "") + # 读取该实例的结果,没有就补超时错误。 + metrics = results.get( + instance_id, + { + "ok": False, + "error": "metrics timeout or no task result", + "updatedAt": utc_now(), + }, + ) + # 补上实例 ID 便于前端排查。 + metrics["instanceId"] = instance_id + # 以 host name 为 key 返回。 + payload_by_host[host["host"]] = metrics + + # 返回按 host name 索引的主机指标结果。 + return payload_by_host + + +def get_host_metrics(hosts: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + # 使用独立缓存,减少页面刷新时反复打 TAT。 + global _HOST_CACHE_PAYLOAD, _HOST_CACHE_AT + + # 记录当前时间。 + now = time.time() + + # 在锁内优先读取缓存。 + with _HOST_CACHE_LOCK: + if _HOST_CACHE_PAYLOAD is not None and now - _HOST_CACHE_AT < HOST_METRIC_CACHE_TTL_SECONDS: + return _HOST_CACHE_PAYLOAD + + # 缓存失效时重新采集。 + try: + # 真正执行主机指标采集。 + payload = collect_host_metrics(hosts) + except Exception as exc: # noqa: BLE001 + # 指标采集失败时不能拖垮整页,统一降级成错误占位。 + payload = { + host["host"]: { + "ok": False, + "error": f"metrics collection failed: {exc}", + "updatedAt": utc_now(), + "instanceId": host.get("instanceId", ""), + } + for host in hosts + } + + # 把最新指标写回缓存。 + with _HOST_CACHE_LOCK: + _HOST_CACHE_PAYLOAD = payload + _HOST_CACHE_AT = now + + # 返回最新指标。 + return payload + + +def build_host_payload( + hosts: list[dict[str, Any]], + service_payload: dict[str, Any], + metrics_by_host: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + # 把服务结果按 host 分组,便于构造主机视图。 + services_by_host: dict[str, list[dict[str, Any]]] = {} + + # 遍历所有服务结果。 + for item in service_payload.get("items") or []: + # 创建主机对应列表。 + services_by_host.setdefault(item["host"], []).append(item) + + # 最终主机视图列表。 + grouped_hosts: list[dict[str, Any]] = [] + + # 按配置顺序输出主机,页面顺序更稳定。 + for host in hosts: + # 读取该主机的服务列表。 + items = services_by_host.get(host["host"], []) + # 统计该主机健康服务数量。 + ok_count = sum(1 for item in items if item["ok"]) + # 读取主机指标。 + metrics = metrics_by_host.get( + host["host"], + { + "ok": False, + "error": "metrics unavailable", + "updatedAt": utc_now(), + "instanceId": host.get("instanceId", ""), + }, + ) + + # 构造主机对象。 + grouped_hosts.append( + { + "host": host["host"], + "ip": host["ip"], + "group": host["group"], + "instanceId": host["instanceId"], + "metrics": metrics, + "serviceSummary": { + "total": len(items), + "ok": ok_count, + "down": len(items) - ok_count, + }, + "items": items, + } + ) + + # 返回按主机分组后的完整结构。 + return grouped_hosts + + +def build_monitor_payload() -> dict[str, Any]: + # 先读取监控配置。 + hosts = load_hosts() + # 获取服务健康结果。 + service_payload = get_service_payload(hosts) + # 获取主机资源指标。 + metrics_by_host = get_host_metrics(hosts) + # 拼出主机维度视图。 + grouped_hosts = build_host_payload(hosts, service_payload, metrics_by_host) + # 统计主机指标成功数量。 + host_metric_ok = sum(1 for host in grouped_hosts if host["metrics"].get("ok")) + + # 返回给前端的统一结果。 + return { + "updatedAt": utc_now(), + "summary": { + **service_payload["summary"], + "hostMetricOk": host_metric_ok, + "hostMetricDown": len(grouped_hosts) - host_metric_ok, + }, + "hosts": grouped_hosts, + "items": service_payload["items"], + } + + +def response_json(payload: dict[str, Any] | list[Any]) -> bytes: + # 统一把 JSON 编码为 UTF-8 文本响应。 return (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8") class MonitorHandler(BaseHTTPRequestHandler): + # 自定义服务名,便于 curl 时识别当前响应来自哪套服务。 server_version = "HyAppMonitor/1.0" - def log_message(self, format: str, *args) -> None: # noqa: A003 + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + # 关闭默认访问日志,避免轮询接口把日志刷爆。 return def do_GET(self) -> None: # noqa: N802 - self._dispatch(send_body=True) + # GET 请求需要返回响应头和响应体。 + self.dispatch(send_body=True) def do_HEAD(self) -> None: # noqa: N802 - self._dispatch(send_body=False) + # HEAD 请求只返回响应头,不返回响应体。 + self.dispatch(send_body=False) - def _dispatch(self, send_body: bool) -> None: + def dispatch(self, send_body: bool) -> None: + # 只解析 URL 的 path 部分。 parsed = urlparse(self.path) + + # 首页直接返回静态 HTML。 if parsed.path in {"/", "/index.html"}: return self.serve_static("index.html", "text/html; charset=utf-8", send_body=send_body) + + # CSS 静态资源。 if parsed.path == "/app.css": return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body) + + # 前端业务 JS。 if parsed.path == "/app.js": return self.serve_static("app.js", "application/javascript; charset=utf-8", send_body=send_body) + + # 本地 Vue 运行时。 if parsed.path == "/vue.js": return self.serve_static("vue.js", "application/javascript; charset=utf-8", send_body=send_body) + + # 轻量健康检查接口。 if parsed.path == "/api/health": return self.send_payload( HTTPStatus.OK, @@ -179,25 +784,36 @@ class MonitorHandler(BaseHTTPRequestHandler): "application/json; charset=utf-8", send_body=send_body, ) + + # 主监控接口,返回服务和主机两类信息。 if parsed.path == "/api/monitor/services": - payload = get_monitor_payload() return self.send_payload( HTTPStatus.OK, - response_json(payload), + response_json(build_monitor_payload()), "application/json; charset=utf-8", send_body=send_body, ) + + # favicon 直接返回空内容即可。 if parsed.path == "/favicon.ico": return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body) + + # 其他路径统一返回 404 JSON。 return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body) def serve_static(self, name: str, content_type: str, send_body: bool) -> None: + # 计算静态文件实际路径。 path = STATIC_DIR / name + + # 文件不存在时直接返回 JSON 错误。 if not path.exists(): return self.send_error_payload(HTTPStatus.NOT_FOUND, "static file missing", send_body=send_body) + + # 静态文件存在时直接返回二进制内容。 self.send_payload(HTTPStatus.OK, path.read_bytes(), content_type, send_body=send_body) def send_error_payload(self, status: HTTPStatus, message: str, send_body: bool) -> None: + # 错误响应也走统一 JSON 结构,前端更容易处理。 self.send_payload( status, response_json({"status": int(status), "error": message}), @@ -206,20 +822,31 @@ class MonitorHandler(BaseHTTPRequestHandler): ) def send_payload(self, status: HTTPStatus, body: bytes, content_type: str, send_body: bool) -> None: + # 写入 HTTP 状态码。 self.send_response(status) + # 写入内容类型。 self.send_header("Content-Type", content_type) + # 页面和接口都不缓存,保证看到的是当前状态。 self.send_header("Cache-Control", "no-store") + # 写入内容长度,便于浏览器正确处理 HEAD 请求。 self.send_header("Content-Length", str(len(body))) + # 结束响应头。 self.end_headers() + + # 只有 GET 这类需要响应体的请求才真正写 body。 if send_body and body: self.wfile.write(body) def main() -> None: + # 创建线程型 HTTP 服务,允许多个请求并发处理。 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 index c56a689..ae9f30f 100644 --- a/static/app.css +++ b/static/app.css @@ -41,6 +41,12 @@ body { padding: 28px 18px 48px; } +.boot-indicator { + padding: 20px 24px 0; + color: var(--muted); + font-size: 14px; +} + .hero { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr); @@ -79,13 +85,6 @@ body { 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; @@ -120,6 +119,10 @@ body { color: var(--bad); } +.stat-block.neutral strong { + color: var(--accent); +} + .toolbar { display: flex; justify-content: space-between; @@ -217,6 +220,50 @@ body { flex-wrap: wrap; } +.host-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.metric-card { + padding: 14px 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: var(--panel-strong); +} + +.metric-card small, +.metric-card span { + color: var(--muted); +} + +.metric-card small { + display: block; + font-size: 12px; +} + +.metric-card strong { + display: block; + margin-top: 6px; + font-size: 1.1rem; + letter-spacing: -0.02em; +} + +.metric-card span { + display: block; + margin-top: 6px; + font-size: 12px; +} + +.metric-error { + margin: -4px 0 16px; + color: var(--bad); + font-size: 12px; + word-break: break-word; +} + .badge { display: inline-flex; align-items: center; @@ -342,6 +389,7 @@ body { grid-template-columns: 1fr; } + .host-metrics, .service-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -360,6 +408,7 @@ body { width: 100%; } + .host-metrics, .service-grid, .service-data { grid-template-columns: 1fr; diff --git a/static/app.js b/static/app.js index ead69e4..ef4e668 100644 --- a/static/app.js +++ b/static/app.js @@ -1,113 +1,130 @@ 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 }; + data() { + return { + loading: false, + error: "", + filter: "all", + keyword: "", + payload: { + updatedAt: "", + summary: { + total: 0, + ok: 0, + down: 0, + hostTotal: 0, + hostMetricOk: 0, + hostMetricDown: 0, }, - formattedUpdatedAt() { - if (!this.payload.updatedAt) { - return "-"; + hosts: [], + }, + timer: null, + }; + }, + computed: { + summary() { + return this.payload.summary || { + total: 0, + ok: 0, + down: 0, + hostTotal: 0, + hostMetricOk: 0, + hostMetricDown: 0, + }; + }, + formattedUpdatedAt() { + if (!this.payload.updatedAt) { + return "-"; + } + const date = new Date(this.payload.updatedAt); + return Number.isNaN(date.getTime()) ? this.payload.updatedAt : date.toLocaleString(); + }, + groupedHosts() { + const keyword = this.keyword.trim().toLowerCase(); + + return (this.payload.hosts || []) + .map((host) => { + const items = (host.items || []).filter((item) => { + if (this.filter === "down" && item.ok) { + return false; } - 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; - } + if (this.filter === "java" && item.kind !== "java") { + return false; } - 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; + 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); + }); + + return { + ...host, + groupLabel: this.groupLabel(host.group), + items, + downCount: items.filter((item) => !item.ok).length, + }; + }) + .filter((host) => host.items.length > 0 || !keyword); }, - mounted() { - const root = document.getElementById("app"); - const bootIndicator = document.getElementById("boot-indicator"); - if (root) { - root.removeAttribute("v-cloak"); - } - if (bootIndicator) { - bootIndicator.hidden = true; - } - this.refresh(); - this.timer = window.setInterval(() => this.refresh(), 10000); + }, + methods: { + groupLabel(group) { + return { + app: "应用节点", + pay: "支付节点", + gateway: "网关节点", + }[group] || group; }, - beforeUnmount() { - if (this.timer) { - window.clearInterval(this.timer); - } + formatPercent(value) { + if (value == null || Number.isNaN(Number(value))) { + return "-"; + } + return `${Number(value).toFixed(2)}%`; }, + formatGb(used, total) { + if (used == null || total == null) { + return "-"; + } + return `${Number(used).toFixed(2)} / ${Number(total).toFixed(2)} GB`; + }, + 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() { + const root = document.getElementById("app"); + const bootIndicator = document.getElementById("boot-indicator"); + if (root) { + root.removeAttribute("v-cloak"); + } + if (bootIndicator) { + bootIndicator.hidden = true; + } + 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 index c6b0ddd..85647f8 100644 --- a/static/index.html +++ b/static/index.html @@ -11,50 +11,43 @@
-HY / Deploy Monitor
-- 页面本身不直接请求内网服务。所有探测都由 deploy 机器代发,适合直接通过公网 2026 访问。 -
+HY APP MONITOR
+