hy-app-monitor/server.py

853 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
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"
# 默认 .env 文件放在项目根目录,并且不入库。
DEFAULT_ENV_PATH = ROOT / ".env"
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_hosts() -> list[dict[str, Any]]:
# 读取监控配置文件。
payload = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
# hosts 节点不存在时回退成空列表。
hosts = payload.get("hosts") or []
# 规范化后的主机列表。
normalized: list[dict[str, Any]] = []
# 逐台主机提取可用字段。
for host in hosts:
# 统一整理字段,避免前端和后端在不同地方重复判断默认值。
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": 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(),
}
)
# 返回扁平服务列表供 HTTP 探测使用。
return services
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
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_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 = {
**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(service["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:
# 即使是 HTTP 错误,也尽量读取响应体做更准确的健康判断。
body_text = exc.read().decode("utf-8", errors="replace")
# 计算失败请求耗时。
latency_ms = int((time.perf_counter() - started) * 1000)
# 用 HTTP 错误码继续判断是否属于“服务活着但被拒绝访问”。
ok = healthy_from_body(service["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_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"])
# 返回服务层结果。
return {
"updatedAt": utc_now(),
"summary": {
"total": len(items),
"ok": ok_count,
"down": len(items) - ok_count,
"hostTotal": len(hosts),
},
"items": items,
}
def get_service_payload(hosts: list[dict[str, Any]]) -> dict[str, Any]:
# 使用独立缓存,减少频繁刷新带来的内网 HTTP 压力。
global _SERVICE_CACHE_PAYLOAD, _SERVICE_CACHE_AT
# 记录当前时间,用来判断缓存是否过期。
now = time.time()
# 先在锁内检查服务缓存是否还能复用。
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 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: Any) -> None: # noqa: A003
# 关闭默认访问日志,避免轮询接口把日志刷爆。
return
def do_GET(self) -> None: # noqa: N802
# GET 请求需要返回响应头和响应体。
self.dispatch(send_body=True)
def do_HEAD(self) -> None: # noqa: N802
# HEAD 请求只返回响应头,不返回响应体。
self.dispatch(send_body=False)
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,
response_json({"status": "ok", "time": utc_now()}),
"application/json; charset=utf-8",
send_body=send_body,
)
# 主监控接口,返回服务和主机两类信息。
if parsed.path == "/api/monitor/services":
return self.send_payload(
HTTPStatus.OK,
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}),
"application/json; charset=utf-8",
send_body=send_body,
)
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()