hy-app-monitor/monitor/http_app.py
2026-04-22 14:39:40 +08:00

237 lines
9.0 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.

from __future__ import annotations
import json
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
from .config import APP_BIND, APP_PORT, STATIC_DIR, utc_now
from .dashboard import build_monitor_payload
from .nacos import get_nacos_config_list_payload, get_nacos_config_payload, save_nacos_config_payload
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/2.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 do_POST(self) -> None: # noqa: N802
# POST 请求主要用于配置保存。
self.dispatch_post()
def dispatch(self, send_body: bool) -> None:
# 只解析 URL 的 path 段。
parsed = urlparse(self.path)
# 顺手解析 query 参数,后面接口直接取。
query = parse_qs(parsed.query, keep_blank_values=True)
# 首页返回静态 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,
)
# 返回当前 namespace 下全部 Nacos 配置元数据。
if parsed.path == "/api/nacos/configs":
try:
payload = get_nacos_config_list_payload()
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 返回单个配置的原始文本内容。
if parsed.path == "/api/nacos/config":
group_name = self.query_value(query, "group")
data_id = self.query_value(query, "dataId")
if not group_name or not data_id:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"group and dataId are required",
send_body=send_body,
)
try:
payload = get_nacos_config_payload(group_name, data_id)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(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。
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
def dispatch_post(self) -> None:
# 只解析 path 段POST 不处理复杂 query。
parsed = urlparse(self.path)
# 目前只开放 Nacos 配置保存接口。
if parsed.path != "/api/nacos/config":
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
try:
payload = self.read_json_body()
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
group_name = str(payload.get("group") or "").strip()
data_id = str(payload.get("dataId") or "").strip()
content = payload.get("content")
config_type = str(payload.get("type") or "").strip() or None
# group / dataId 是保存现有配置的唯一键。
if not group_name or not data_id:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "group and dataId are required", send_body=True)
# content 必须是字符串;允许保存空串。
if not isinstance(content, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
try:
result = save_nacos_config_payload(group_name, data_id, content, config_type=config_type)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
return self.send_payload(
HTTPStatus.OK,
response_json(result),
"application/json; charset=utf-8",
send_body=True,
)
def query_value(self, query: dict[str, list[str]], key: str) -> str:
# 统一读取单值 query 参数。
return str((query.get(key) or [""])[0]).strip()
def read_json_body(self) -> dict[str, Any]:
# Content-Length 缺失时按空请求体处理。
raw_length = self.headers.get("Content-Length", "0").strip() or "0"
try:
content_length = int(raw_length)
except ValueError as exc:
raise ValueError("invalid Content-Length") from exc
# 空请求体不符合当前 JSON 接口要求。
if content_length <= 0:
raise ValueError("request body is required")
# 读取原始请求体。
body = self.rfile.read(content_length).decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
raise ValueError("request body must be valid json") from exc
# 顶层必须是对象。
if not isinstance(payload, dict):
raise ValueError("request body must be a json object")
return payload
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:
# 写状态码。
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()
# 需要响应体时再写内容。
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()