#!/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()