237 lines
9.0 KiB
Python
237 lines
9.0 KiB
Python
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()
|