From e01ca63ad81a17a22cb955405860d3bd20ffdd7a Mon Sep 17 00:00:00 2001 From: ZuoZuo <68836346+Mrz-sakura@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:56:12 +0800 Subject: [PATCH] Split gateway and internal monitor services --- .env.example | 6 + README.md | 33 +- gateway/__init__.py | 1 + gateway/http_app.py | 460 +++++++++++++++++++++++++ haiyi_monitor/__init__.py | 1 + haiyi_monitor/http_app.py | 179 ++++++++++ haiyi_server.py | 18 + monitor/config.py | 16 + monitor/http_app.py | 268 +++++---------- monitor/http_paths.py | 42 +++ ops/deploy_via_ssh.py | 4 +- ops/deploy_via_tat.py | 10 +- scripts/common.sh | 389 +++++++++++++++++---- scripts/install_systemd.sh | 40 ++- scripts/restart.sh | 5 +- scripts/run_foreground.sh | 7 +- scripts/run_haiyi_foreground.sh | 9 + scripts/run_yumi_foreground.sh | 14 + scripts/start.sh | 33 +- scripts/status.sh | 26 +- scripts/stop.sh | 32 +- scripts/uninstall_systemd.sh | 16 +- server.py | 2 +- static/app.css | 497 +++++++++++++++++++++++++-- static/app.js | 323 ++++++++++++++++- static/haiyi.html | 50 +++ static/haiyi.js | 129 +++++++ static/index.html | 291 ++++++++++------ static/login.html | 4 +- static/login.js | 13 +- static/select.html | 51 +++ static/select.js | 82 +++++ systemd/hy-app-monitor-haiyi.service | 16 + systemd/hy-app-monitor-yumi.service | 16 + systemd/hy-app-monitor.service | 6 +- yumi_server.py | 18 + 36 files changed, 2616 insertions(+), 491 deletions(-) create mode 100644 gateway/__init__.py create mode 100644 gateway/http_app.py create mode 100644 haiyi_monitor/__init__.py create mode 100644 haiyi_monitor/http_app.py create mode 100644 haiyi_server.py create mode 100644 monitor/http_paths.py mode change 100755 => 100644 scripts/common.sh mode change 100755 => 100644 scripts/install_systemd.sh mode change 100755 => 100644 scripts/restart.sh mode change 100755 => 100644 scripts/run_foreground.sh create mode 100644 scripts/run_haiyi_foreground.sh create mode 100644 scripts/run_yumi_foreground.sh mode change 100755 => 100644 scripts/start.sh mode change 100755 => 100644 scripts/status.sh mode change 100755 => 100644 scripts/stop.sh mode change 100755 => 100644 scripts/uninstall_systemd.sh create mode 100644 static/haiyi.html create mode 100644 static/haiyi.js create mode 100644 static/select.html create mode 100644 static/select.js create mode 100644 systemd/hy-app-monitor-haiyi.service create mode 100644 systemd/hy-app-monitor-yumi.service create mode 100644 yumi_server.py diff --git a/.env.example b/.env.example index 96111df..381bc40 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ APP_BIND=0.0.0.0 APP_PORT=2026 +YUMI_INTERNAL_HOST=127.0.0.1 +YUMI_INTERNAL_PORT=2027 +HAIYI_INTERNAL_HOST=127.0.0.1 +HAIYI_INTERNAL_PORT=2028 +INTERNAL_PROXY_SHARED_SECRET=change-me +GATEWAY_PROXY_TIMEOUT_SECONDS=600 AUTH_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3 AUTH_SESSION_TTL_HOURS=24 AUTH_COOKIE_SECURE=0 diff --git a/README.md b/README.md index 39118c4..0eb2359 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # hy-app-monitor -部署在 `deploy` 机器上的轻量监控面板。 +部署在 `deploy` 机器上的统一入口监控面板。 -- 页面端口:`2026` -- 页面入口:`http://43.164.75.199:2026/` +- 对外入口端口:`2026` - 登录入口:`http://43.164.75.199:2026/login` +- 入口选择页:`http://43.164.75.199:2026/select` +- Yumi 入口:`http://43.164.75.199:2026/yumi/` +- HaiYi 入口:`http://43.164.75.199:2026/haiyi/` +- 内部实例:`gateway:2026`、`yumi:2027`、`haiyi:2028` - 服务健康:直接探测各业务服务健康接口 - 主机资源:通过 `SSH` 采集 `CPU / 内存 / 磁盘 / 进程数` - Nacos:直连 Nacos OpenAPI,列出命名空间、服务和注册实例 @@ -14,8 +17,12 @@ ## 目录 -- `server.py`:后端入口 -- `monitor/`:Python 模块实现 +- `server.py`:gateway 入口 +- `yumi_server.py`:Yumi 内部实例入口 +- `haiyi_server.py`:HaiYi 内部实例入口 +- `gateway/`:统一登录、入口选择页和反代 +- `monitor/`:Yumi 监控与运维实现 +- `haiyi_monitor/`:HaiYi 占位实例 - `config/targets.json`:监控目标和实例 ID - `static/`:前端页面 - `scripts/`:启停、自启安装、前台运行脚本 @@ -32,6 +39,11 @@ ```bash APP_BIND=0.0.0.0 APP_PORT=2026 +YUMI_INTERNAL_HOST=127.0.0.1 +YUMI_INTERNAL_PORT=2027 +HAIYI_INTERNAL_HOST=127.0.0.1 +HAIYI_INTERNAL_PORT=2028 +INTERNAL_PROXY_SHARED_SECRET=*** AUTH_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3 AUTH_SESSION_TTL_HOURS=24 DEFAULT_REFRESH_INTERVAL_SECONDS=10 @@ -122,6 +134,12 @@ http://127.0.0.1:2026/ http://127.0.0.1:2026/login ``` +登录后会进入: + +```bash +http://127.0.0.1:2026/select +``` + ## systemd 开机自启 在远端项目目录执行: @@ -171,8 +189,9 @@ python3 ops/deploy_via_ssh.py 2. 把本地项目 `.env` 同步到远端项目 `.env` 3. 创建远端 `.venv` 4. 安装 `requirements-ops.txt` -5. 安装并启用 `systemd` -6. 重启服务并检查 `2026` 监听 +5. 安装并启用三套 `systemd` unit +6. 重启 `gateway / yumi / haiyi` +7. 检查 `2026` 监听 `ops/deploy_via_tat.py` 仍可保留成带外兜底,不建议继续作为日常主链路。 diff --git a/gateway/__init__.py b/gateway/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/gateway/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/gateway/http_app.py b/gateway/http_app.py new file mode 100644 index 0000000..6cb196b --- /dev/null +++ b/gateway/http_app.py @@ -0,0 +1,460 @@ +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + +from monitor.auth import ( + build_clear_session_cookie, + build_session_cookie, + create_session, + delete_session, + ensure_auth_store, + get_session, + parse_session_token, + authenticate_user, +) +from monitor.config import ( + APP_BIND, + APP_PORT, + GATEWAY_PROXY_TIMEOUT_SECONDS, + HAIYI_INTERNAL_HOST, + HAIYI_INTERNAL_PORT, + INTERNAL_PROXY_SHARED_SECRET, + STATIC_DIR, + YUMI_INTERNAL_HOST, + YUMI_INTERNAL_PORT, + utc_now, +) +from monitor.http_paths import login_redirect_target + + +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 GatewayHandler(BaseHTTPRequestHandler): + # 单独标出这是统一入口网关。 + server_version = "HyAppMonitorGateway/2.0" + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + # 默认访问日志关闭,避免轮询刷屏。 + return + + def current_session_token(self) -> str: + # 同一个请求对象里只解析一次 Cookie。 + if not hasattr(self, "_cached_session_token"): + self._cached_session_token = parse_session_token(self.headers.get("Cookie")) + return str(self._cached_session_token) + + def current_session(self) -> Any: + # 同一个请求对象里也只查一次会话库。 + if not hasattr(self, "_cached_session"): + self._cached_session = get_session(self.current_session_token()) + return self._cached_session + + 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) + path = parsed.path + + # 根路径只做分流,不再直接承载业务页面。 + if path in {"/", "/index.html"}: + if not self.current_session(): + return self.redirect("/login", send_body=send_body) + return self.redirect("/select", send_body=send_body) + + # 登录页对未登录用户开放,已登录时直接回选择页或 next。 + if path in {"/login", "/login.html"}: + if self.current_session(): + return self.redirect(self.next_location(), send_body=send_body) + return self.serve_static("login.html", "text/html; charset=utf-8", send_body=send_body) + + # 入口选择页必须先登录。 + if path in {"/select", "/select.html"}: + if not self.require_page_authentication(send_body=send_body): + return + return self.serve_static("select.html", "text/html; charset=utf-8", send_body=send_body) + + # 网关自有静态资源。 + if path == "/app.css": + return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body) + + if path == "/login.js": + return self.serve_static("login.js", "application/javascript; charset=utf-8", send_body=send_body) + + if path == "/select.js": + return self.serve_static("select.js", "application/javascript; charset=utf-8", send_body=send_body) + + # 根级会话接口给登录页和选择页使用。 + if path == "/api/auth/session": + return self.handle_auth_session(send_body=send_body) + + # 根级健康检查只检查网关自身。 + if path == "/api/health": + return self.send_payload( + HTTPStatus.OK, + response_json({"status": "ok", "time": utc_now(), "app": "gateway"}), + "application/json; charset=utf-8", + send_body=send_body, + ) + + # 子应用前缀下的认证状态统一由网关代答。 + if path in {"/yumi/api/auth/session", "/haiyi/api/auth/session"}: + return self.handle_auth_session(send_body=send_body) + + # favicon 直接返回空。 + if path == "/favicon.ico": + return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body) + + # 子应用其余请求统一走透明反代。 + target = self.proxy_target(path) + if target is not None: + if path in {target["basePath"], f"{target['basePath']}/"} and not self.require_page_authentication(send_body=send_body): + return + + if not self.ensure_prefixed_authentication(path, send_body=send_body): + return + return self.proxy_request(target, send_body=send_body) + + # 其他路径统一 404。 + return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body) + + def dispatch_post(self) -> None: + parsed = urlparse(self.path) + path = parsed.path + + # 退出接口允许空请求体,先单独处理。 + if path in {"/api/auth/logout", "/yumi/api/auth/logout", "/haiyi/api/auth/logout"}: + return self.handle_logout() + + # 登录接口只在根路径开放。 + if path == "/api/auth/login": + try: + payload = self.read_json_body() + except ValueError as exc: + return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True) + return self.handle_login(payload) + + # 其余子应用 POST 接口都走统一鉴权后再反代。 + target = self.proxy_target(path) + if target is not None: + if not self.ensure_prefixed_authentication(path, send_body=True): + return + try: + body = self.read_raw_body() + except ValueError as exc: + return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True) + return self.proxy_request(target, send_body=True, body=body) + + return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True) + + def proxy_target(self, path: str) -> dict[str, Any] | None: + # 按固定前缀把请求路由到对应内部实例。 + if path == "/yumi" or path.startswith("/yumi/"): + return { + "app": "yumi", + "basePath": "/yumi", + "host": YUMI_INTERNAL_HOST, + "port": YUMI_INTERNAL_PORT, + } + if path == "/haiyi" or path.startswith("/haiyi/"): + return { + "app": "haiyi", + "basePath": "/haiyi", + "host": HAIYI_INTERNAL_HOST, + "port": HAIYI_INTERNAL_PORT, + } + return None + + def ensure_prefixed_authentication(self, path: str, *, send_body: bool) -> bool: + # 前缀下的业务页面和 API 都统一要求先登录。 + if self.current_session() is not None: + return True + + # API 调用返回 401,页面请求走登录跳转。 + if "/api/" in path: + self.send_error_payload( + HTTPStatus.UNAUTHORIZED, + "authentication required", + send_body=send_body, + extra_headers=self.auth_cookie_cleanup_headers(), + ) + return False + + self.redirect(login_redirect_target(self.path), send_body=send_body) + return False + + def require_page_authentication(self, *, send_body: bool) -> bool: + # 选择页这类纯页面入口,未登录时统一跳到登录页。 + if self.current_session() is not None: + return True + + self.redirect(login_redirect_target(self.path), send_body=send_body) + return False + + def handle_auth_session(self, send_body: bool) -> None: + session = self.current_session() + + # 未登录时返回 401,前端据此回到登录页。 + if session is None: + return self.send_error_payload( + HTTPStatus.UNAUTHORIZED, + "authentication required", + send_body=send_body, + extra_headers=self.auth_cookie_cleanup_headers(), + ) + + return self.send_payload( + HTTPStatus.OK, + response_json(self.session_payload(session)), + "application/json; charset=utf-8", + send_body=send_body, + ) + + def handle_login(self, payload: dict[str, Any]) -> None: + username = str(payload.get("username") or "").strip() + password = payload.get("password") + + # 登录接口要求账号密码都以字符串传入。 + if not isinstance(password, str): + return self.send_error_payload(HTTPStatus.BAD_REQUEST, "password must be a string", send_body=True) + + canonical_username = authenticate_user(username, password) + if not canonical_username: + return self.send_error_payload(HTTPStatus.UNAUTHORIZED, "invalid username or password", send_body=True) + + # 同一浏览器重复登录时,先清掉旧 token 对应会话。 + delete_session(self.current_session_token()) + session = create_session( + canonical_username, + remote_addr=self.client_address[0] if self.client_address else "", + user_agent=self.headers.get("User-Agent", ""), + ) + cookie_header = build_session_cookie( + session.token, + max_age=max(1, session.expires_at_ts - int(time.time())), + ) + return self.send_payload( + HTTPStatus.OK, + response_json(self.session_payload(session)), + "application/json; charset=utf-8", + send_body=True, + extra_headers={"Set-Cookie": cookie_header}, + ) + + def handle_logout(self) -> None: + # 退出时带不带当前 Cookie 都按成功处理,方便前端幂等调用。 + delete_session(self.current_session_token()) + return self.send_payload( + HTTPStatus.OK, + response_json({"ok": True}), + "application/json; charset=utf-8", + send_body=True, + extra_headers={"Set-Cookie": build_clear_session_cookie()}, + ) + + def session_payload(self, session: Any) -> dict[str, Any]: + # 当前只给前端暴露最小必要字段。 + return { + "authenticated": True, + "username": session.username, + "expiresAt": session.expires_at, + } + + def auth_cookie_cleanup_headers(self) -> dict[str, str] | None: + # 只有请求里真的带了旧 token,才下发清 Cookie 头。 + if not self.current_session_token(): + return None + return {"Set-Cookie": build_clear_session_cookie()} + + def next_location(self) -> str: + # 登录成功后的回跳只允许站内绝对路径,避免被注入外部地址。 + parsed = urlparse(self.path) + next_value = str((parse_qs(parsed.query).get("next") or [""])[0]).strip() + if not next_value: + return "/select" + candidate = urllib.parse.unquote(next_value) + if candidate.startswith("/") and not candidate.startswith("//"): + return candidate + return "/select" + + def proxy_request(self, target: dict[str, Any], *, send_body: bool, body: bytes | None = None) -> None: + # 透传完整 path + query,保持前缀路径和下载地址都不变。 + url = f"http://{target['host']}:{target['port']}{self.path}" + headers = self.proxy_headers(target["basePath"]) + request = urllib.request.Request(url, data=body, method=self.command, headers=headers) + + try: + with urllib.request.urlopen(request, timeout=GATEWAY_PROXY_TIMEOUT_SECONDS) as response: + payload = response.read() + return self.send_payload( + HTTPStatus(response.status), + payload, + response.headers.get("Content-Type", "application/octet-stream"), + send_body=send_body, + extra_headers=self.forward_response_headers(response.headers), + ) + except urllib.error.HTTPError as exc: + payload = exc.read() + return self.send_payload( + HTTPStatus(exc.code), + payload, + exc.headers.get("Content-Type", "application/octet-stream"), + send_body=send_body, + extra_headers=self.forward_response_headers(exc.headers), + ) + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body) + + def proxy_headers(self, base_path: str) -> dict[str, str]: + # 只把内部实例真正需要的头透传过去。 + excluded = { + "host", + "connection", + "content-length", + "cookie", + "x-hy-internal-proxy-secret", + } + headers = { + key: value + for key, value in self.headers.items() + if str(key or "").strip().lower() not in excluded + } + headers["X-HY-Internal-Proxy-Secret"] = INTERNAL_PROXY_SHARED_SECRET + headers["X-Forwarded-For"] = self.client_address[0] if self.client_address else "" + headers["X-Forwarded-Prefix"] = base_path + headers["X-Forwarded-Proto"] = "http" + return headers + + def forward_response_headers(self, headers: Any) -> dict[str, str]: + # 过滤掉由当前网关自己生成的响应头,其他头继续透传。 + excluded = {"content-length", "connection", "server", "date", "cache-control"} + forwarded: dict[str, str] = {} + for key, value in headers.items(): + if str(key or "").strip().lower() in excluded: + continue + forwarded[str(key)] = str(value) + return forwarded + + def redirect(self, location: str, *, send_body: bool) -> None: + # 页面跳转只依赖 Location,响应体保持空即可。 + self.send_payload( + HTTPStatus.FOUND, + b"", + "text/plain; charset=utf-8", + send_body=send_body, + extra_headers={"Location": location}, + ) + + def read_raw_body(self) -> bytes | None: + # 原样读取请求体,用于透明代理 POST 请求。 + 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 + + if content_length <= 0: + return None + return self.rfile.read(content_length) + + def read_json_body(self) -> dict[str, Any]: + # 根级登录接口仍按标准 JSON 读请求体。 + raw_body = self.read_raw_body() + if not raw_body: + raise ValueError("request body is required") + + try: + payload = json.loads(raw_body.decode("utf-8", errors="replace")) + 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, + *, + extra_headers: dict[str, str] | None = None, + ) -> None: + # 错误响应也统一走 JSON。 + self.send_payload( + status, + response_json({"status": int(status), "error": message}), + "application/json; charset=utf-8", + send_body=send_body, + extra_headers=extra_headers, + ) + + def send_payload( + self, + status: HTTPStatus, + body: bytes, + content_type: str, + send_body: bool, + *, + extra_headers: dict[str, str] | None = None, + ) -> None: + # 写状态码。 + self.send_response(status) + # 写内容类型。 + self.send_header("Content-Type", content_type) + # 页面和接口都禁用缓存。 + self.send_header("Cache-Control", "no-store") + # 允许调用方补充下载文件名等额外头。 + for key, value in (extra_headers or {}).items(): + self.send_header(key, value) + # 写入长度,便于 HEAD 请求工作正常。 + self.send_header("Content-Length", str(len(body))) + # 结束响应头。 + self.end_headers() + + # 需要响应体时再写内容。 + if send_body and body: + self.wfile.write(body) + + +def main() -> None: + # 启动前先把账号库和默认账号准备好。 + ensure_auth_store() + # 创建线程型 HTTP 服务。 + server = ThreadingHTTPServer((APP_BIND, APP_PORT), GatewayHandler) + # 打印监听地址便于排查。 + print(f"hy-app-monitor gateway listening on http://{APP_BIND}:{APP_PORT}", flush=True) + # 进入永久监听。 + server.serve_forever() diff --git a/haiyi_monitor/__init__.py b/haiyi_monitor/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/haiyi_monitor/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/haiyi_monitor/http_app.py b/haiyi_monitor/http_app.py new file mode 100644 index 0000000..f44bc8f --- /dev/null +++ b/haiyi_monitor/http_app.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import ipaddress +import json +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.parse import urlparse + +from monitor.config import APP_BASE_PATH, APP_BIND, APP_PORT, INTERNAL_PROXY_SHARED_SECRET, STATIC_DIR, utc_now +from monitor.http_paths import join_base_path, strip_base_path + + +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 HaiYiHandler(BaseHTTPRequestHandler): + # 单独标出这是 HaiYi 内部实例。 + server_version = "HyAppMonitorHaiYi/2.0" + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + # 占位实例不打默认访问日志。 + return + + def do_GET(self) -> None: # noqa: N802 + # 只接受来自统一网关的代理请求。 + if not self.require_internal_proxy(send_body=True): + return + self.dispatch(send_body=True) + + def do_HEAD(self) -> None: # noqa: N802 + # HEAD 请求同样只允许内部网关进入。 + if not self.require_internal_proxy(send_body=False): + return + self.dispatch(send_body=False) + + def do_POST(self) -> None: # noqa: N802 + # 第一阶段 HaiYi 没有写操作接口。 + if not self.require_internal_proxy(send_body=True): + return + self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True) + + def require_internal_proxy(self, send_body: bool) -> bool: + # 内部实例除了共享密钥,还要求请求确实来自本机回环地址。 + # 这样就算端口配置错误暴露到外网,也不能直接跳过统一入口。 + actual_secret = str(self.headers.get("X-HY-Internal-Proxy-Secret") or "").strip() + if self.is_loopback_client() and actual_secret == INTERNAL_PROXY_SHARED_SECRET: + return True + + self.send_error_payload( + HTTPStatus.FORBIDDEN, + "internal proxy authorization required", + send_body=send_body, + ) + return False + + def is_loopback_client(self) -> bool: + # BaseHTTPRequestHandler 会把来源地址放在 client_address 第一个槽位。 + remote_host = str((self.client_address or ("", 0))[0]).strip() + if not remote_host: + return False + + try: + # IPv6 链路本地格式可能带 zone index,这里先切掉再判断。 + return ipaddress.ip_address(remote_host.split("%", 1)[0]).is_loopback + except ValueError: + return False + + def dispatch(self, send_body: bool) -> None: + # 统一先拆 URL,后面都只操作 path。 + parsed = urlparse(self.path) + local_path = strip_base_path(APP_BASE_PATH, parsed.path) + + # 不属于当前应用 base path 的请求直接 404。 + if local_path is None: + return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body) + + # /haiyi 必须补成 /haiyi/,这样相对静态资源路径才稳定。 + if APP_BASE_PATH and parsed.path == APP_BASE_PATH: + return self.redirect(join_base_path(APP_BASE_PATH, "/"), send_body=send_body) + + # HaiYi 当前只提供最小占位页。 + if local_path in {"/", "/index.html"}: + return self.serve_static("haiyi.html", "text/html; charset=utf-8", send_body=send_body) + + if local_path == "/app.css": + return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body) + + if local_path == "/haiyi.js": + return self.serve_static("haiyi.js", "application/javascript; charset=utf-8", send_body=send_body) + + if local_path == "/api/health": + return self.send_payload( + HTTPStatus.OK, + response_json({"status": "ok", "time": utc_now(), "app": "haiyi"}), + "application/json; charset=utf-8", + send_body=send_body, + ) + + if local_path == "/favicon.ico": + return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body) + + return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body) + + def redirect(self, location: str, *, send_body: bool) -> None: + # 页面跳转只依赖 Location,响应体保持空即可。 + self.send_payload( + HTTPStatus.FOUND, + b"", + "text/plain; charset=utf-8", + send_body=send_body, + extra_headers={"Location": location}, + ) + + 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, + *, + extra_headers: dict[str, str] | None = None, + ) -> None: + # 错误响应也统一走 JSON。 + self.send_payload( + status, + response_json({"status": int(status), "error": message}), + "application/json; charset=utf-8", + send_body=send_body, + extra_headers=extra_headers, + ) + + def send_payload( + self, + status: HTTPStatus, + body: bytes, + content_type: str, + send_body: bool, + *, + extra_headers: dict[str, str] | None = None, + ) -> None: + # 写状态码。 + self.send_response(status) + # 写内容类型。 + self.send_header("Content-Type", content_type) + # 页面和接口都禁用缓存。 + self.send_header("Cache-Control", "no-store") + # 允许调用方补充额外头。 + for key, value in (extra_headers or {}).items(): + self.send_header(key, value) + # 写入长度,便于 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), HaiYiHandler) + # 打印监听地址和挂载路径,部署排查时更直观。 + print(f"hy-app-monitor haiyi listening on http://{APP_BIND}:{APP_PORT}{APP_BASE_PATH or '/'}", flush=True) + # 进入永久监听。 + server.serve_forever() diff --git a/haiyi_server.py b/haiyi_server.py new file mode 100644 index 0000000..cb32db5 --- /dev/null +++ b/haiyi_server.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os + +# 直接执行 HaiYi 入口时,默认也固定到内部监听地址和 /haiyi 前缀。 +# 脚本或 systemd 已显式传值时继续沿用外部值,不覆盖运维配置。 +os.environ.setdefault("APP_BIND", os.environ.get("HAIYI_INTERNAL_HOST", "127.0.0.1")) +os.environ.setdefault("APP_PORT", os.environ.get("HAIYI_INTERNAL_PORT", "2028")) +os.environ["APP_BASE_PATH"] = "/haiyi" + +from haiyi_monitor.http_app import main + + +if __name__ == "__main__": + # 作为脚本执行时启动 HaiYi 内部实例。 + main() diff --git a/monitor/config.py b/monitor/config.py index 764ce03..8a8a020 100644 --- a/monitor/config.py +++ b/monitor/config.py @@ -28,6 +28,22 @@ CONFIG_PATH = Path(env_str("TARGETS_FILE", str(ROOT / "config" / "targets.json") APP_BIND = env_str("APP_BIND", "0.0.0.0") or "0.0.0.0" # HTTP 服务监听端口。 APP_PORT = env_int("APP_PORT", 2026) +# 当前 HTTP 应用挂载前缀原始值。 +RAW_APP_BASE_PATH = env_str("APP_BASE_PATH", "").strip() +# 当前 HTTP 应用挂载前缀;空串表示直接挂在根路径。 +APP_BASE_PATH = "/" + RAW_APP_BASE_PATH.strip("/") if RAW_APP_BASE_PATH else "" +# Yumi 内部实例固定走本机回环地址。 +YUMI_INTERNAL_HOST = env_str("YUMI_INTERNAL_HOST", "127.0.0.1") or "127.0.0.1" +# Yumi 内部实例监听端口。 +YUMI_INTERNAL_PORT = env_int("YUMI_INTERNAL_PORT", 2027) +# HaiYi 内部实例固定走本机回环地址。 +HAIYI_INTERNAL_HOST = env_str("HAIYI_INTERNAL_HOST", "127.0.0.1") or "127.0.0.1" +# HaiYi 内部实例监听端口。 +HAIYI_INTERNAL_PORT = env_int("HAIYI_INTERNAL_PORT", 2028) +# 网关反代到内部实例时统一带这一段共享密钥。 +INTERNAL_PROXY_SHARED_SECRET = env_str("INTERNAL_PROXY_SHARED_SECRET", "hy-app-monitor-internal") +# 网关反代内部实例时的统一超时秒数。 +GATEWAY_PROXY_TIMEOUT_SECONDS = env_int("GATEWAY_PROXY_TIMEOUT_SECONDS", 600) # 本地账号库统一落到 run 目录,避免写入版本库。 AUTH_DB_PATH = Path(env_str("AUTH_DB_PATH", str(RUNTIME_DIR / "auth.sqlite3"))).expanduser() # 会话 Cookie 名称固定成单值,前后端都按这个名字取。 diff --git a/monitor/http_app.py b/monitor/http_app.py index 654c0fa..b0961f8 100644 --- a/monitor/http_app.py +++ b/monitor/http_app.py @@ -1,24 +1,15 @@ from __future__ import annotations +import ipaddress import json -import time from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import parse_qs, urlparse -from .auth import ( - build_clear_session_cookie, - build_session_cookie, - create_session, - delete_session, - ensure_auth_store, - get_session, - parse_session_token, - authenticate_user, -) -from .config import APP_BIND, APP_PORT, STATIC_DIR, utc_now +from .config import APP_BASE_PATH, APP_BIND, APP_PORT, INTERNAL_PROXY_SHARED_SECRET, STATIC_DIR, utc_now from .dashboard import build_monitor_payload +from .http_paths import join_base_path, strip_base_path from .nacos import ( get_nacos_config_list_payload, get_nacos_config_payload, @@ -38,90 +29,96 @@ def response_json(payload: dict[str, Any] | list[Any]) -> bytes: class MonitorHandler(BaseHTTPRequestHandler): - # 自定义服务名,便于 curl 时识别来源。 - server_version = "HyAppMonitor/2.0" + # 单独标出这是 Yumi 内部实例,排查反代链路更直接。 + server_version = "HyAppMonitorYumi/2.0" def log_message(self, format: str, *args: Any) -> None: # noqa: A003 - # 关闭默认访问日志,避免轮询刷屏。 + # 内部轮询很多,关闭默认访问日志。 return - def current_session_token(self) -> str: - # 同一个请求对象里只解析一次 Cookie。 - if not hasattr(self, "_cached_session_token"): - self._cached_session_token = parse_session_token(self.headers.get("Cookie")) - return str(self._cached_session_token) - - def current_session(self) -> Any: - # 同一个请求对象里也只查一次会话库。 - if not hasattr(self, "_cached_session"): - self._cached_session = get_session(self.current_session_token()) - return self._cached_session - def do_GET(self) -> None: # noqa: N802 - # GET 请求需要响应头和响应体。 + # 只接受来自统一网关的代理请求。 + if not self.require_internal_proxy(send_body=True): + return self.dispatch(send_body=True) def do_HEAD(self) -> None: # noqa: N802 - # HEAD 请求只返回响应头。 + # HEAD 请求同样只允许内部网关进入。 + if not self.require_internal_proxy(send_body=False): + return self.dispatch(send_body=False) def do_POST(self) -> None: # noqa: N802 - # POST 请求主要用于配置保存。 + # POST 接口也必须经过统一入口。 + if not self.require_internal_proxy(send_body=True): + return self.dispatch_post() + def require_internal_proxy(self, send_body: bool) -> bool: + # 内部实例除了共享密钥,还要求请求确实来自本机回环地址。 + # 这样就算运维误把端口绑到了 0.0.0.0,外部流量也不能直接绕过网关。 + actual_secret = str(self.headers.get("X-HY-Internal-Proxy-Secret") or "").strip() + if self.is_loopback_client() and actual_secret == INTERNAL_PROXY_SHARED_SECRET: + return True + + self.send_error_payload( + HTTPStatus.FORBIDDEN, + "internal proxy authorization required", + send_body=send_body, + ) + return False + + def is_loopback_client(self) -> bool: + # BaseHTTPRequestHandler 会把来源地址放在 client_address 第一个槽位。 + remote_host = str((self.client_address or ("", 0))[0]).strip() + if not remote_host: + return False + + try: + # IPv6 链路本地格式可能带 zone index,这里先切掉再判断。 + return ipaddress.ip_address(remote_host.split("%", 1)[0]).is_loopback + except ValueError: + return False + def dispatch(self, send_body: bool) -> None: - # 只解析 URL 的 path 段。 + # 统一先拆 URL,后面都只操作 path 和 query。 parsed = urlparse(self.path) - # 顺手解析 query 参数,后面接口直接取。 query = parse_qs(parsed.query, keep_blank_values=True) + local_path = strip_base_path(APP_BASE_PATH, parsed.path) - # 登录页对未登录用户开放,已登录时直接回总览。 - if parsed.path in {"/login", "/login.html"}: - if self.current_session(): - return self.redirect("/", send_body=send_body) - return self.serve_static("login.html", "text/html; charset=utf-8", send_body=send_body) + # 不属于当前应用 base path 的请求直接 404。 + if local_path is None: + return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body) - # 总览页需要先过登录校验,未登录统一跳到登录页。 - if parsed.path in {"/", "/index.html"}: - if not self.current_session(): - return self.redirect("/login", send_body=send_body) + # /yumi 必须补成 /yumi/,这样相对静态资源路径才稳定。 + if APP_BASE_PATH and parsed.path == APP_BASE_PATH: + return self.redirect(join_base_path(APP_BASE_PATH, "/"), send_body=send_body) + + # Yumi 首页直接返回当前监控面板。 + if local_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": + # 前端静态资源统一挂在应用前缀下。 + if local_path == "/app.css": return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body) - # 登录页脚本。 - if parsed.path == "/login.js": - return self.serve_static("login.js", "application/javascript; charset=utf-8", send_body=send_body) - - # 前端业务 JS。 - if parsed.path == "/app.js": + if local_path == "/app.js": return self.serve_static("app.js", "application/javascript; charset=utf-8", send_body=send_body) - # 本地 Vue 运行时。 - if parsed.path == "/vue.js": + if local_path == "/vue.js": return self.serve_static("vue.js", "application/javascript; charset=utf-8", send_body=send_body) - # 会话查询接口单独开放给登录页和主页面做状态判断。 - if parsed.path == "/api/auth/session": - return self.handle_auth_session(send_body=send_body) - - # 除鉴权接口外,其余 API 一律要求带有效会话。 - if parsed.path.startswith("/api/") and not self.require_authentication(send_body=send_body): - return - - # 轻量健康检查。 - if parsed.path == "/api/health": + # 内部实例自身的健康检查。 + if local_path == "/api/health": return self.send_payload( HTTPStatus.OK, - response_json({"status": "ok", "time": utc_now()}), + response_json({"status": "ok", "time": utc_now(), "app": "yumi"}), "application/json; charset=utf-8", send_body=send_body, ) # 主监控接口。 - if parsed.path == "/api/monitor/services": + if local_path == "/api/monitor/services": return self.send_payload( HTTPStatus.OK, response_json(build_monitor_payload()), @@ -130,7 +127,7 @@ class MonitorHandler(BaseHTTPRequestHandler): ) # 返回当前 namespace 下全部 Nacos 配置元数据。 - if parsed.path == "/api/nacos/configs": + if local_path == "/api/nacos/configs": try: payload = get_nacos_config_list_payload() except Exception as exc: # noqa: BLE001 @@ -144,7 +141,7 @@ class MonitorHandler(BaseHTTPRequestHandler): ) # 返回单个配置的原始文本内容。 - if parsed.path == "/api/nacos/config": + if local_path == "/api/nacos/config": group_name = self.query_value(query, "group") data_id = self.query_value(query, "dataId") @@ -168,7 +165,7 @@ class MonitorHandler(BaseHTTPRequestHandler): ) # 返回当前线上 golang 的运行配置。 - if parsed.path == "/api/runtime/golang-config": + if local_path == "/api/runtime/golang-config": try: payload = get_go_config_payload() except Exception as exc: # noqa: BLE001 @@ -182,7 +179,7 @@ class MonitorHandler(BaseHTTPRequestHandler): ) # 返回可滚动重启的业务服务。 - if parsed.path == "/api/ops/restart-targets": + if local_path == "/api/ops/restart-targets": try: payload = build_restart_targets_payload() except Exception as exc: # noqa: BLE001 @@ -196,7 +193,7 @@ class MonitorHandler(BaseHTTPRequestHandler): ) # 返回可构建并发布的业务服务。 - if parsed.path == "/api/ops/update-targets": + if local_path == "/api/ops/update-targets": try: payload = build_update_targets_payload() except Exception as exc: # noqa: BLE001 @@ -210,7 +207,7 @@ class MonitorHandler(BaseHTTPRequestHandler): ) # 返回当前服务更新任务的阶段和日志。 - if parsed.path == "/api/ops/update-status": + if local_path == "/api/ops/update-status": operation_id = self.query_value(query, "id") if not operation_id: return self.send_error_payload( @@ -234,7 +231,7 @@ class MonitorHandler(BaseHTTPRequestHandler): ) # 返回单个服务更新任务的完整文本日志。 - if parsed.path == "/api/ops/update-log": + if local_path == "/api/ops/update-log": operation_id = self.query_value(query, "id") if not operation_id: return self.send_error_payload( @@ -258,7 +255,7 @@ class MonitorHandler(BaseHTTPRequestHandler): ) # 返回单个服务实例最近 1000 条容器日志。 - if parsed.path == "/api/ops/service-log": + if local_path == "/api/ops/service-log": service_name = self.query_value(query, "service") host_name = self.query_value(query, "host") if not service_name or not host_name: @@ -283,7 +280,7 @@ class MonitorHandler(BaseHTTPRequestHandler): ) # 导出单个服务实例指定条数的容器日志。 - if parsed.path == "/api/ops/service-log/export": + if local_path == "/api/ops/service-log/export": service_name = self.query_value(query, "service") host_name = self.query_value(query, "host") lines = self.query_value(query, "lines") @@ -313,7 +310,7 @@ class MonitorHandler(BaseHTTPRequestHandler): ) # favicon 直接返回空。 - if parsed.path == "/favicon.ico": + if local_path == "/favicon.ico": return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body) # 其他路径统一 404。 @@ -322,137 +319,36 @@ class MonitorHandler(BaseHTTPRequestHandler): def dispatch_post(self) -> None: # 只解析 path 段,POST 不处理复杂 query。 parsed = urlparse(self.path) + local_path = strip_base_path(APP_BASE_PATH, parsed.path) - # 退出接口允许空请求体,先单独处理。 - if parsed.path == "/api/auth/logout": - return self.handle_logout() - - # 登录接口对未登录用户开放,但要求标准 JSON 请求体。 - if parsed.path == "/api/auth/login": - try: - payload = self.read_json_body() - except ValueError as exc: - return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True) - return self.handle_login(payload) - - # 其余 POST 接口都要求先通过会话校验。 - if not self.require_authentication(send_body=True): - return + if local_path is None: + 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) - if parsed.path == "/api/nacos/config/validate": + if local_path == "/api/nacos/config/validate": return self.handle_nacos_validate(payload) - if parsed.path == "/api/nacos/config": + if local_path == "/api/nacos/config": return self.handle_nacos_save(payload) - if parsed.path == "/api/runtime/golang-config/validate": + if local_path == "/api/runtime/golang-config/validate": return self.handle_go_validate(payload) - if parsed.path == "/api/runtime/golang-config": + if local_path == "/api/runtime/golang-config": return self.handle_go_save(payload) - if parsed.path == "/api/ops/rolling-restart": + if local_path == "/api/ops/rolling-restart": return self.handle_rolling_restart(payload) - if parsed.path == "/api/ops/update-services": + if local_path == "/api/ops/update-services": return self.handle_update_services(payload) return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True) - def handle_auth_session(self, send_body: bool) -> None: - session = self.current_session() - - # 未登录时返回 401,前端据此回到登录页。 - if session is None: - return self.send_error_payload( - HTTPStatus.UNAUTHORIZED, - "authentication required", - send_body=send_body, - extra_headers=self.auth_cookie_cleanup_headers(), - ) - - return self.send_payload( - HTTPStatus.OK, - response_json(self.session_payload(session)), - "application/json; charset=utf-8", - send_body=send_body, - ) - - def handle_login(self, payload: dict[str, Any]) -> None: - username = str(payload.get("username") or "").strip() - password = payload.get("password") - - # 登录接口要求账号密码都以字符串传入。 - if not isinstance(password, str): - return self.send_error_payload(HTTPStatus.BAD_REQUEST, "password must be a string", send_body=True) - - canonical_username = authenticate_user(username, password) - if not canonical_username: - return self.send_error_payload(HTTPStatus.UNAUTHORIZED, "invalid username or password", send_body=True) - - # 同一浏览器重复登录时,先清掉旧 token 对应会话。 - delete_session(self.current_session_token()) - session = create_session( - canonical_username, - remote_addr=self.client_address[0] if self.client_address else "", - user_agent=self.headers.get("User-Agent", ""), - ) - cookie_header = build_session_cookie( - session.token, - max_age=max(1, session.expires_at_ts - int(time.time())), - ) - return self.send_payload( - HTTPStatus.OK, - response_json(self.session_payload(session)), - "application/json; charset=utf-8", - send_body=True, - extra_headers={"Set-Cookie": cookie_header}, - ) - - def handle_logout(self) -> None: - # 退出时带不带当前 Cookie 都按成功处理,方便前端幂等调用。 - delete_session(self.current_session_token()) - return self.send_payload( - HTTPStatus.OK, - response_json({"ok": True}), - "application/json; charset=utf-8", - send_body=True, - extra_headers={"Set-Cookie": build_clear_session_cookie()}, - ) - - def session_payload(self, session: Any) -> dict[str, Any]: - # 当前只给前端暴露最小必要字段。 - return { - "authenticated": True, - "username": session.username, - "expiresAt": session.expires_at, - } - - def require_authentication(self, send_body: bool) -> bool: - # 查到有效会话时,当前请求可以继续执行。 - if self.current_session() is not None: - return True - - # token 缺失或失效时统一打回 401。 - self.send_error_payload( - HTTPStatus.UNAUTHORIZED, - "authentication required", - send_body=send_body, - extra_headers=self.auth_cookie_cleanup_headers(), - ) - return False - - def auth_cookie_cleanup_headers(self) -> dict[str, str] | None: - # 只有请求里真的带了旧 token,才下发清 Cookie 头。 - if not self.current_session_token(): - return None - return {"Set-Cookie": build_clear_session_cookie()} - def redirect(self, location: str, *, send_body: bool) -> None: # 页面跳转只依赖 Location,响应体保持空即可。 self.send_payload( @@ -683,11 +579,9 @@ class MonitorHandler(BaseHTTPRequestHandler): def main() -> None: - # 启动前先把账号库和默认账号准备好。 - ensure_auth_store() # 创建线程型 HTTP 服务。 server = ThreadingHTTPServer((APP_BIND, APP_PORT), MonitorHandler) - # 打印监听地址便于排查。 - print(f"hy-app-monitor listening on http://{APP_BIND}:{APP_PORT}", flush=True) + # 打印监听地址和挂载路径,部署排查时更直观。 + print(f"hy-app-monitor yumi listening on http://{APP_BIND}:{APP_PORT}{APP_BASE_PATH or '/'}", flush=True) # 进入永久监听。 server.serve_forever() diff --git a/monitor/http_paths.py b/monitor/http_paths.py new file mode 100644 index 0000000..ecc0a24 --- /dev/null +++ b/monitor/http_paths.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from urllib.parse import quote + + +def normalize_base_path(raw_value: str) -> str: + # 空串表示应用直接挂在根路径。 + cleaned = str(raw_value or "").strip().strip("/") + if not cleaned: + return "" + return f"/{cleaned}" + + +def join_base_path(base_path: str, local_path: str) -> str: + # 统一保证 local path 至少以 / 开头,避免拼接出脏 URL。 + normalized_local = local_path if str(local_path or "").startswith("/") else f"/{local_path}" + return f"{normalize_base_path(base_path)}{normalized_local}" or "/" + + +def strip_base_path(base_path: str, request_path: str) -> str | None: + normalized_base = normalize_base_path(base_path) + normalized_request = request_path if str(request_path or "").startswith("/") else f"/{request_path}" + + # 根路径应用直接返回原路径。 + if not normalized_base: + return normalized_request + + # /yumi 本身视作根页面。 + if normalized_request == normalized_base: + return "/" + + # 其余请求必须严格落在当前 base path 下。 + required_prefix = f"{normalized_base}/" + if not normalized_request.startswith(required_prefix): + return None + + return normalized_request[len(normalized_base) :] or "/" + + +def login_redirect_target(request_target: str) -> str: + # 未登录回跳保留原始路径和 query。 + return f"/login?next={quote(str(request_target or '/'), safe='')}" diff --git a/ops/deploy_via_ssh.py b/ops/deploy_via_ssh.py index bcb2313..f5b5893 100644 --- a/ops/deploy_via_ssh.py +++ b/ops/deploy_via_ssh.py @@ -207,7 +207,9 @@ chmod +x scripts/*.sh python3 -m venv .venv .venv/bin/pip install -r requirements-ops.txt bash scripts/install_systemd.sh -systemctl restart hy-app-monitor.service +systemctl restart hy-app-monitor-yumi.service hy-app-monitor-haiyi.service hy-app-monitor.service +systemctl is-active hy-app-monitor-yumi.service +systemctl is-active hy-app-monitor-haiyi.service systemctl is-active hy-app-monitor.service ss -lntp | grep ":$APP_PORT" || true """ diff --git a/ops/deploy_via_tat.py b/ops/deploy_via_tat.py index 8366901..289c760 100755 --- a/ops/deploy_via_tat.py +++ b/ops/deploy_via_tat.py @@ -156,6 +156,12 @@ def load_runtime_env() -> str: 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"YUMI_INTERNAL_HOST={os.environ.get('YUMI_INTERNAL_HOST', '127.0.0.1')}", + f"YUMI_INTERNAL_PORT={os.environ.get('YUMI_INTERNAL_PORT', '2027')}", + f"HAIYI_INTERNAL_HOST={os.environ.get('HAIYI_INTERNAL_HOST', '127.0.0.1')}", + f"HAIYI_INTERNAL_PORT={os.environ.get('HAIYI_INTERNAL_PORT', '2028')}", + f"INTERNAL_PROXY_SHARED_SECRET={os.environ.get('INTERNAL_PROXY_SHARED_SECRET', 'hy-app-monitor-internal')}", + f"GATEWAY_PROXY_TIMEOUT_SECONDS={os.environ.get('GATEWAY_PROXY_TIMEOUT_SECONDS', '600')}", 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')}", @@ -214,7 +220,9 @@ 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 restart hy-app-monitor-yumi.service hy-app-monitor-haiyi.service hy-app-monitor.service +systemctl is-active hy-app-monitor-yumi.service +systemctl is-active hy-app-monitor-haiyi.service systemctl is-active hy-app-monitor.service ss -lntp | grep ":$APP_PORT" || true """ diff --git a/scripts/common.sh b/scripts/common.sh old mode 100755 new mode 100644 index 3bc280a..72225b3 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -3,10 +3,7 @@ set -euo pipefail 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 @@ -18,6 +15,16 @@ fi APP_BIND="${APP_BIND:-0.0.0.0}" APP_PORT="${APP_PORT:-2026}" TARGETS_FILE="${TARGETS_FILE:-$ROOT_DIR/config/targets.json}" +YUMI_INTERNAL_HOST="${YUMI_INTERNAL_HOST:-127.0.0.1}" +YUMI_INTERNAL_PORT="${YUMI_INTERNAL_PORT:-2027}" +HAIYI_INTERNAL_HOST="${HAIYI_INTERNAL_HOST:-127.0.0.1}" +HAIYI_INTERNAL_PORT="${HAIYI_INTERNAL_PORT:-2028}" +GATEWAY_BIND="$APP_BIND" +GATEWAY_PORT="$APP_PORT" + +GATEWAY_SYSTEMD_SERVICE_NAME="hy-app-monitor.service" +YUMI_SYSTEMD_SERVICE_NAME="hy-app-monitor-yumi.service" +HAIYI_SYSTEMD_SERVICE_NAME="hy-app-monitor-haiyi.service" if [ -x "$ROOT_DIR/.venv/bin/python" ]; then PYTHON_BIN_DEFAULT="$ROOT_DIR/.venv/bin/python" @@ -29,88 +36,133 @@ PYTHON_BIN="${PYTHON_BIN:-$PYTHON_BIN_DEFAULT}" mkdir -p "$RUN_DIR" -read_pid() { - if [ -f "$PID_FILE" ]; then - local pid - pid="$(cat "$PID_FILE")" - if pid_matches_project "$pid"; then - echo "$pid" - return 0 - fi - fi - - discover_project_pid +suite_service_names() { + printf '%s\n' gateway yumi haiyi } -is_running() { - local pid - pid="$(read_pid || true)" - if [ -z "${pid:-}" ]; then - rm -f "$PID_FILE" - return 1 - fi - if ! kill -0 "$pid" >/dev/null 2>&1; then - rm -f "$PID_FILE" - return 1 - fi +service_display_name() { + case "$1" in + gateway) echo "gateway" ;; + yumi) echo "yumi" ;; + haiyi) echo "haiyi" ;; + *) + echo "unknown service: $1" >&2 + return 1 + ;; + esac +} - echo "$pid" >"$PID_FILE" - return 0 +service_unit_name() { + case "$1" in + gateway) echo "$GATEWAY_SYSTEMD_SERVICE_NAME" ;; + yumi) echo "$YUMI_SYSTEMD_SERVICE_NAME" ;; + haiyi) echo "$HAIYI_SYSTEMD_SERVICE_NAME" ;; + *) + echo "unknown service: $1" >&2 + return 1 + ;; + esac +} + +service_entry_file() { + case "$1" in + gateway) echo "$ROOT_DIR/server.py" ;; + yumi) echo "$ROOT_DIR/yumi_server.py" ;; + haiyi) echo "$ROOT_DIR/haiyi_server.py" ;; + *) + echo "unknown service: $1" >&2 + return 1 + ;; + esac +} + +service_bind() { + case "$1" in + gateway) echo "$GATEWAY_BIND" ;; + yumi) echo "$YUMI_INTERNAL_HOST" ;; + haiyi) echo "$HAIYI_INTERNAL_HOST" ;; + *) + echo "unknown service: $1" >&2 + return 1 + ;; + esac +} + +service_port() { + case "$1" in + gateway) echo "$GATEWAY_PORT" ;; + yumi) echo "$YUMI_INTERNAL_PORT" ;; + haiyi) echo "$HAIYI_INTERNAL_PORT" ;; + *) + echo "unknown service: $1" >&2 + return 1 + ;; + esac +} + +service_base_path() { + case "$1" in + gateway) echo "" ;; + yumi) echo "/yumi" ;; + haiyi) echo "/haiyi" ;; + *) + echo "unknown service: $1" >&2 + return 1 + ;; + esac +} + +service_pid_file() { + echo "$RUN_DIR/hy-app-monitor-$1.pid" +} + +service_log_file() { + echo "$RUN_DIR/hy-app-monitor-$1.log" +} + +service_command_pattern() { + basename "$(service_entry_file "$1")" } has_systemd_unit() { + local name="$1" if ! command -v systemctl >/dev/null 2>&1; then return 1 fi - systemctl cat "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 + systemctl cat "$(service_unit_name "$name")" >/dev/null 2>&1 } -stop_legacy_process() { - local pid - pid="$(read_pid || true)" - - if [ -n "${pid:-}" ] && kill -0 "$pid" >/dev/null 2>&1; then - kill "$pid" >/dev/null 2>&1 || true - for _ in $(seq 1 10); do - if ! kill -0 "$pid" >/dev/null 2>&1; then - break - fi - sleep 1 - done - kill -9 "$pid" >/dev/null 2>&1 || true - fi - - rm -f "$PID_FILE" -} - -discover_project_pid() { - if ! command -v lsof >/dev/null 2>&1; then - return 1 - fi - - local pid - while IFS= read -r pid; do - if pid_matches_project "$pid"; then - echo "$pid" - return 0 +has_full_systemd_suite() { + local name + for name in $(suite_service_names); do + if ! has_systemd_unit "$name"; then + return 1 fi - done < <(lsof -tiTCP:"$APP_PORT" -sTCP:LISTEN 2>/dev/null || true) - - return 1 + done + return 0 } pid_matches_project() { - local pid="$1" + local name="$1" + local pid="$2" if [ -z "${pid:-}" ] || ! kill -0 "$pid" >/dev/null 2>&1; then return 1 fi local command command="$(ps -p "$pid" -o command= 2>/dev/null || true)" - if [[ "$command" != *"$ROOT_DIR/server.py"* ]] && [[ "$command" != *" server.py"* ]]; then + if [[ "$command" != *"$(service_command_pattern "$name")"* ]]; then return 1 fi + if command -v lsof >/dev/null 2>&1; then + local expected_port + expected_port="$(service_port "$name")" + if ! lsof -a -p "$pid" -iTCP:"$expected_port" -sTCP:LISTEN >/dev/null 2>&1; then + return 1 + fi + fi + if command -v lsof >/dev/null 2>&1; then local cwd cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)" @@ -121,3 +173,218 @@ pid_matches_project() { return 0 } + +discover_project_pid() { + local name="$1" + if ! command -v lsof >/dev/null 2>&1; then + return 1 + fi + + local pid + while IFS= read -r pid; do + if pid_matches_project "$name" "$pid"; then + echo "$pid" + return 0 + fi + done < <(lsof -tiTCP:"$(service_port "$name")" -sTCP:LISTEN 2>/dev/null || true) + + return 1 +} + +read_pid() { + local name="$1" + local pid_file + pid_file="$(service_pid_file "$name")" + + if [ -f "$pid_file" ]; then + local pid + pid="$(cat "$pid_file")" + if pid_matches_project "$name" "$pid"; then + echo "$pid" + return 0 + fi + fi + + discover_project_pid "$name" +} + +is_running() { + local name="$1" + local pid + local pid_file + pid_file="$(service_pid_file "$name")" + pid="$(read_pid "$name" || true)" + if [ -z "${pid:-}" ]; then + rm -f "$pid_file" + return 1 + fi + if ! kill -0 "$pid" >/dev/null 2>&1; then + rm -f "$pid_file" + return 1 + fi + + echo "$pid" >"$pid_file" + return 0 +} + +stop_legacy_process() { + local name="$1" + local pid + local pid_file + pid_file="$(service_pid_file "$name")" + pid="$(read_pid "$name" || true)" + + if [ -n "${pid:-}" ] && kill -0 "$pid" >/dev/null 2>&1; then + kill "$pid" >/dev/null 2>&1 || true + local _i + for _i in $(seq 1 10); do + if ! kill -0 "$pid" >/dev/null 2>&1; then + break + fi + sleep 1 + done + kill -9 "$pid" >/dev/null 2>&1 || true + fi + + rm -f "$pid_file" +} + +stop_all_legacy_processes() { + local name + for name in $(suite_service_names); do + stop_legacy_process "$name" + done +} + +export_service_env() { + local name="$1" + export APP_BIND + export APP_PORT + export APP_BASE_PATH + export TARGETS_FILE + export PYTHONUNBUFFERED=1 + + APP_BIND="$(service_bind "$name")" + APP_PORT="$(service_port "$name")" + APP_BASE_PATH="$(service_base_path "$name")" + export APP_BIND APP_PORT APP_BASE_PATH TARGETS_FILE PYTHONUNBUFFERED +} + +start_local_service() { + local name="$1" + local pid_file + local log_file + local entry_file + + if is_running "$name"; then + echo "$(service_display_name "$name") already running pid=$(read_pid "$name")" + return 0 + fi + + if [ "$name" = "yumi" ] && [ ! -f "$TARGETS_FILE" ]; then + echo "targets file not found: $TARGETS_FILE" >&2 + return 1 + fi + + pid_file="$(service_pid_file "$name")" + log_file="$(service_log_file "$name")" + entry_file="$(service_entry_file "$name")" + + export_service_env "$name" + + nohup "$PYTHON_BIN" "$entry_file" >>"$log_file" 2>&1 & + local pid=$! + echo "$pid" >"$pid_file" + + sleep 1 + if kill -0 "$pid" >/dev/null 2>&1; then + echo "$(service_display_name "$name") started pid=$pid port=$(service_port "$name")" + return 0 + fi + + echo "$(service_display_name "$name") failed to start" >&2 + tail -n 40 "$log_file" >&2 || true + return 1 +} + +stop_local_service() { + local name="$1" + local pid_file + pid_file="$(service_pid_file "$name")" + + if ! is_running "$name"; then + echo "$(service_display_name "$name") not running" + rm -f "$pid_file" + return 0 + fi + + local pid + pid="$(read_pid "$name")" + kill "$pid" >/dev/null 2>&1 || true + + local _i + for _i in $(seq 1 10); do + if ! kill -0 "$pid" >/dev/null 2>&1; then + rm -f "$pid_file" + echo "$(service_display_name "$name") stopped" + return 0 + fi + sleep 1 + done + + kill -9 "$pid" >/dev/null 2>&1 || true + rm -f "$pid_file" + echo "$(service_display_name "$name") force stopped" +} + +status_local_service() { + local name="$1" + if is_running "$name"; then + echo "$(service_display_name "$name") running pid=$(read_pid "$name") port=$(service_port "$name")" + return 0 + fi + + echo "$(service_display_name "$name") stopped" + return 1 +} + +start_systemd_suite() { + local name + for name in yumi haiyi gateway; do + systemctl start "$(service_unit_name "$name")" + done + for name in $(suite_service_names); do + systemctl is-active "$(service_unit_name "$name")" + done +} + +stop_systemd_suite() { + local name + for name in gateway yumi haiyi; do + systemctl stop "$(service_unit_name "$name")" >/dev/null 2>&1 || true + done +} + +restart_systemd_suite() { + local name + for name in yumi haiyi gateway; do + systemctl restart "$(service_unit_name "$name")" + done + for name in $(suite_service_names); do + systemctl is-active "$(service_unit_name "$name")" + done +} + +status_systemd_suite() { + local all_active=0 + local name + for name in $(suite_service_names); do + if systemctl is-active --quiet "$(service_unit_name "$name")"; then + echo "$(service_display_name "$name") running under systemd port=$(service_port "$name")" + else + echo "$(service_display_name "$name") stopped" + all_active=1 + fi + done + return "$all_active" +} diff --git a/scripts/install_systemd.sh b/scripts/install_systemd.sh old mode 100755 new mode 100644 index 2bdfbad..8ab5c0b --- a/scripts/install_systemd.sh +++ b/scripts/install_systemd.sh @@ -5,23 +5,37 @@ 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" +install_unit() { + local name="$1" + local src + local dst + src="$ROOT_DIR/systemd/$(service_unit_name "$name")" + dst="/etc/systemd/system/$(service_unit_name "$name")" + + if [ ! -f "$src" ]; then + echo "systemd unit missing: $src" >&2 + exit 1 + fi + + install -m 0644 "$src" "$dst" +} + +name="" +for name in $(suite_service_names); do + install_unit "$name" +done + chmod +x "$ROOT_DIR"/scripts/*.sh -stop_legacy_process +stop_all_legacy_processes systemctl daemon-reload -systemctl enable "$SYSTEMD_SERVICE_NAME" -systemctl restart "$SYSTEMD_SERVICE_NAME" -systemctl is-active "$SYSTEMD_SERVICE_NAME" + +for name in $(suite_service_names); do + systemctl enable "$(service_unit_name "$name")" +done + +restart_systemd_suite diff --git a/scripts/restart.sh b/scripts/restart.sh old mode 100755 new mode 100644 index 235de59..59d9b6c --- a/scripts/restart.sh +++ b/scripts/restart.sh @@ -5,9 +5,8 @@ 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" +if has_full_systemd_suite; then + restart_systemd_suite exit 0 fi diff --git a/scripts/run_foreground.sh b/scripts/run_foreground.sh old mode 100755 new mode 100644 index 4c9f2d7..0b9ce38 --- a/scripts/run_foreground.sh +++ b/scripts/run_foreground.sh @@ -5,10 +5,5 @@ 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 +export_service_env gateway exec "$PYTHON_BIN" "$ROOT_DIR/server.py" diff --git a/scripts/run_haiyi_foreground.sh b/scripts/run_haiyi_foreground.sh new file mode 100644 index 0000000..b8179bb --- /dev/null +++ b/scripts/run_haiyi_foreground.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/common.sh" + +export_service_env haiyi +exec "$PYTHON_BIN" "$ROOT_DIR/haiyi_server.py" diff --git a/scripts/run_yumi_foreground.sh b/scripts/run_yumi_foreground.sh new file mode 100644 index 0000000..f9203c1 --- /dev/null +++ b/scripts/run_yumi_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_service_env yumi +exec "$PYTHON_BIN" "$ROOT_DIR/yumi_server.py" diff --git a/scripts/start.sh b/scripts/start.sh old mode 100755 new mode 100644 index 4ba2a5f..93fbcd0 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -5,34 +5,11 @@ 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" +if has_full_systemd_suite; then + start_systemd_suite exit 0 fi -if is_running; then - echo "hy-app-monitor already running pid=$(read_pid)" - exit 0 -fi - -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 - -nohup "$PYTHON_BIN" "$ROOT_DIR/server.py" >>"$LOG_FILE" 2>&1 & -pid=$! -echo "$pid" >"$PID_FILE" - -sleep 1 -if kill -0 "$pid" >/dev/null 2>&1; then - echo "hy-app-monitor started pid=$pid port=$APP_PORT" - exit 0 -fi - -echo "hy-app-monitor failed to start" >&2 -tail -n 40 "$LOG_FILE" >&2 || true -exit 1 +start_local_service yumi +start_local_service haiyi +start_local_service gateway diff --git a/scripts/status.sh b/scripts/status.sh old mode 100755 new mode 100644 index 3b0d00a..c3fa2b8 --- a/scripts/status.sh +++ b/scripts/status.sh @@ -5,19 +5,17 @@ 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 +if has_full_systemd_suite; then + status_systemd_suite + exit $? +fi + +all_ok=0 +name="" +for name in $(suite_service_names); do + if ! status_local_service "$name"; then + all_ok=1 fi - echo "hy-app-monitor stopped" - exit 1 -fi +done -if is_running; then - echo "hy-app-monitor running pid=$(read_pid) port=$APP_PORT" - exit 0 -fi - -echo "hy-app-monitor stopped" -exit 1 +exit "$all_ok" diff --git a/scripts/stop.sh b/scripts/stop.sh old mode 100755 new mode 100644 index bdd2406..5fdd79a --- a/scripts/stop.sh +++ b/scripts/stop.sh @@ -5,31 +5,13 @@ 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" - stop_legacy_process - echo "hy-app-monitor stopped" +if has_full_systemd_suite; then + stop_systemd_suite + stop_all_legacy_processes + echo "hy-app-monitor suite stopped" exit 0 fi -if ! is_running; then - echo "hy-app-monitor not running" - rm -f "$PID_FILE" - exit 0 -fi - -pid="$(read_pid)" -kill "$pid" >/dev/null 2>&1 || true - -for _ in $(seq 1 10); do - if ! kill -0 "$pid" >/dev/null 2>&1; then - rm -f "$PID_FILE" - echo "hy-app-monitor stopped" - exit 0 - fi - sleep 1 -done - -kill -9 "$pid" >/dev/null 2>&1 || true -rm -f "$PID_FILE" -echo "hy-app-monitor force stopped" +stop_local_service gateway +stop_local_service yumi +stop_local_service haiyi diff --git a/scripts/uninstall_systemd.sh b/scripts/uninstall_systemd.sh old mode 100755 new mode 100644 index f8057d9..73f58e4 --- a/scripts/uninstall_systemd.sh +++ b/scripts/uninstall_systemd.sh @@ -5,12 +5,20 @@ 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 + name="" + for name in $(suite_service_names); do + systemctl disable --now "$(service_unit_name "$name")" >/dev/null 2>&1 || true + done +fi + +name="" +for name in $(suite_service_names); do + rm -f "/etc/systemd/system/$(service_unit_name "$name")" +done 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" +echo "systemd units removed" diff --git a/server.py b/server.py index 84eaaa6..4a87e18 100644 --- a/server.py +++ b/server.py @@ -2,7 +2,7 @@ from __future__ import annotations -from monitor.http_app import main +from gateway.http_app import main if __name__ == "__main__": diff --git a/static/app.css b/static/app.css index b2bcfda..7eac192 100644 --- a/static/app.css +++ b/static/app.css @@ -147,6 +147,72 @@ body.auth-page { font-size: 12px; } +.entry-panel { + display: grid; + gap: 18px; +} + +.entry-grid, +.entry-stack { + display: grid; + gap: 12px; +} + +.entry-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.entry-card { + display: grid; + gap: 8px; + min-height: 132px; + padding: 16px; + border: 1px solid var(--line-strong); + border-radius: 18px; + background: color-mix(in oklab, var(--panel-strong) 88%, var(--accent-soft)); + color: inherit; + text-decoration: none; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +.entry-card:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent) 42%, var(--line-strong)); + background: color-mix(in oklab, var(--accent-soft) 74%, var(--panel-strong)); +} + +.entry-card strong { + font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; + font-size: 1.1rem; + letter-spacing: -0.03em; +} + +.entry-card span { + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.entry-card-static { + min-height: 0; +} + +.entry-actions, +.entry-meta { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.entry-actions { + justify-content: flex-end; +} + +.entry-link { + text-decoration: none; +} + .topbar, .control-bar, .panel { @@ -292,6 +358,11 @@ body.auth-page { align-items: center; } +.release-section-nav { + flex: 1 1 auto; + min-width: 0; +} + .taskbar-context { display: flex; flex-direction: column; @@ -319,6 +390,35 @@ body.auth-page { flex-wrap: wrap; } +.section-link { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 2px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + cursor: pointer; + transition: color 180ms ease, opacity 180ms ease; +} + +.section-link:hover { + color: var(--text); +} + +.section-link + .section-link { + margin-left: 12px; +} + +.release-kind-select { + min-width: 98px; +} + .control-group-spread { justify-content: flex-end; flex: 1 1 auto; @@ -379,6 +479,7 @@ body.auth-page { .refresh:focus-visible, .search:focus-visible, .interval-select:focus-visible, +.section-link:focus-visible, .detail-log-input:focus-visible, .config-item:focus-visible, .config-textarea:focus-visible { @@ -959,6 +1060,14 @@ body.auth-page { gap: 8px; } +.section-caption-row { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + .section-caption { color: var(--muted); font-size: 10px; @@ -967,6 +1076,16 @@ body.auth-page { text-transform: uppercase; } +.section-caption-meta { + display: inline-flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + color: var(--muted); + font-size: 10px; + font-weight: 600; +} + .host-row-list { display: grid; gap: 6px; @@ -1005,11 +1124,11 @@ body.auth-page { .host-strip-main { display: grid; - grid-template-columns: minmax(164px, 208px) minmax(210px, 0.9fr) minmax(220px, 1.2fr) auto; - gap: 8px; + grid-template-columns: minmax(180px, 220px) minmax(320px, 1fr) minmax(180px, 0.9fr) auto; + gap: 10px; align-items: center; - min-height: 48px; - padding: 7px 11px 7px 16px; + min-height: 56px; + padding: 8px 11px 8px 16px; cursor: pointer; } @@ -1038,39 +1157,121 @@ body.auth-page { } .compact-metrics { - gap: 10px; + gap: 8px; color: var(--muted); font-size: 10px; font-weight: 600; } -.compact-metrics span { +.metric-ring-chip, +.metric-stat-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 0 9px 0 7px; + border: 1px solid var(--line); + border-radius: 999px; + background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)); +} + +.metric-ring-chip.tone-ok, +.metric-stat-chip.tone-ok { + border-color: color-mix(in oklab, var(--ok) 28%, var(--line)); +} + +.metric-ring-chip.tone-warn, +.metric-stat-chip.tone-warn { + border-color: color-mix(in oklab, var(--warn) 28%, var(--line)); +} + +.metric-ring-chip.tone-bad, +.metric-stat-chip.tone-bad { + border-color: color-mix(in oklab, var(--bad) 28%, var(--line)); +} + +.metric-ring { + --metric-color: var(--line-strong); + --metric-value: 0%; + position: relative; + width: 24px; + height: 24px; + border-radius: 999px; + background: + conic-gradient(var(--metric-color) 0 var(--metric-value), color-mix(in oklab, var(--line) 88%, transparent) var(--metric-value) 100%); +} + +.metric-ring-hole { + position: absolute; + inset: 4px; + border-radius: 999px; + background: var(--panel-strong); +} + +.metric-ring-copy, +.metric-stat-chip { + min-width: 0; +} + +.metric-ring-copy small, +.metric-ring-copy strong, +.metric-stat-chip small, +.metric-stat-chip strong { + display: block; +} + +.metric-ring-copy small, +.metric-stat-chip small { + color: var(--muted); + font-size: 9px; + line-height: 1; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.metric-ring-copy strong, +.metric-stat-chip strong { + margin-top: 3px; + font-size: 11px; + line-height: 1.1; white-space: nowrap; } .host-alert-summary { - display: flex; - align-items: center; + display: grid; + gap: 4px; min-width: 0; } +.host-alert-summary small { + color: var(--muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .host-alert-path { display: block; width: 100%; overflow: hidden; - color: var(--muted); - font-size: 10px; + color: var(--text); + font-size: 11px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } -.host-alert-summary.is-alert .host-alert-path { +.host-alert-summary.tone-bad .host-alert-path { color: color-mix(in oklab, var(--bad) 82%, var(--text)); } -.host-alert-summary.is-quiet .host-alert-path { - color: color-mix(in oklab, var(--muted) 88%, transparent); +.host-alert-summary.tone-warn .host-alert-path { + color: color-mix(in oklab, var(--warn) 72%, var(--text)); +} + +.host-alert-summary.tone-neutral .host-alert-path { + color: color-mix(in oklab, var(--muted) 76%, var(--text)); } .metric-chip, @@ -1282,6 +1483,19 @@ body.auth-page { .config-sidebar { display: grid; gap: 10px; + align-self: start; + position: sticky; + top: 124px; + max-height: calc(100vh - 140px); + padding: 10px; + border: 1px solid var(--line); + border-radius: 16px; + background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)); +} + +.config-sidebar-head { + display: grid; + gap: 8px; } .config-sidebar-meta { @@ -1290,8 +1504,27 @@ body.auth-page { align-items: center; } +.config-sidebar-path { + display: grid; + gap: 3px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-strong); +} + +.config-sidebar-path strong { + font-size: 12px; +} + +.config-sidebar-path span { + color: var(--muted); + font-size: 10px; +} + .config-list-ide { - gap: 8px; + gap: 10px; + padding-right: 4px; } .config-group { @@ -1328,7 +1561,9 @@ body.auth-page { .config-group-items { display: grid; - gap: 4px; + gap: 2px; + padding-left: 10px; + border-left: 1px solid color-mix(in oklab, var(--line) 88%, transparent); } .config-item-ide { @@ -1336,12 +1571,27 @@ body.auth-page { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; - padding: 9px 10px; - border-radius: 12px; + padding: 8px 9px 8px 12px; + border: 0; + border-radius: 10px; + background: transparent; + position: relative; + transition: background 180ms ease, box-shadow 180ms ease; +} + +.config-item-ide::before { + content: ""; + position: absolute; + left: 0; + top: 6px; + bottom: 6px; + width: 2px; + border-radius: 999px; + background: transparent; } .config-item-ide strong { - font-size: 12px; + font-size: 11px; word-break: break-word; } @@ -1352,8 +1602,38 @@ body.auth-page { } .config-item-ide small { - grid-column: 1 / -1; font-size: 10px; + color: var(--muted); +} + +.config-item-ide.active { + background: color-mix(in oklab, var(--accent-soft) 64%, var(--panel-strong)); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 26%, var(--line)); +} + +.config-item-ide:hover { + transform: none; + background: color-mix(in oklab, var(--accent-soft) 30%, var(--panel-strong)); +} + +.config-item-ide.active::before { + background: var(--accent); +} + +.config-item-main, +.config-item-side { + display: grid; + gap: 3px; +} + +.config-item-side { + justify-items: end; +} + +.config-jump-group { + display: inline-flex; + gap: 8px; + align-items: center; } .editor-toolbar { @@ -1498,6 +1778,25 @@ body.auth-page { z-index: 12; } +.release-action-bar { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + margin-top: 10px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 14px; + background: color-mix(in oklab, var(--panel-muted) 92%, var(--panel-strong)); +} + +.release-action-summary { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + .release-status-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); @@ -1554,6 +1853,71 @@ body.auth-page { margin-top: 10px; } +.release-log-preview { + display: grid; + gap: 10px; + margin-top: 12px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 16px; + background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)); +} + +.release-log-preview-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.release-log-preview-head strong { + font-size: 12px; +} + +.release-log-snippets { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.release-log-snippet { + display: grid; + gap: 4px; + padding: 10px 11px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-strong); +} + +.release-log-snippet strong, +.release-log-snippet span, +.release-log-snippet p { + margin: 0; +} + +.release-log-snippet strong { + font-size: 11px; +} + +.release-log-snippet span { + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.release-log-snippet p { + color: var(--text); + font-size: 11px; + line-height: 1.45; + word-break: break-word; +} + +.release-log-snippet.tone-bad { + border-color: color-mix(in oklab, var(--bad) 28%, var(--line)); + background: color-mix(in oklab, var(--bad-soft) 44%, var(--panel-strong)); +} + .release-table td strong { display: block; font-size: 12px; @@ -1924,6 +2288,60 @@ body.auth-page { word-break: break-word; } +.log-viewer-overlay { + position: fixed; + inset: 0; + z-index: 48; + display: grid; + place-items: center; + padding: 28px 20px; + background: color-mix(in oklab, var(--text) 14%, transparent); + backdrop-filter: blur(8px); +} + +.log-viewer { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 12px; + width: min(1120px, 100%); + max-height: min(88vh, 920px); + padding: 16px; + border: 1px solid var(--line); + border-radius: 22px; + background: color-mix(in oklab, var(--panel-strong) 96%, var(--panel-muted)); + box-shadow: 0 24px 64px rgba(29, 42, 68, 0.24); +} + +.log-viewer-head { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-start; +} + +.log-viewer-toolbar { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.log-viewer-output { + margin: 0; + min-height: 280px; + max-height: calc(88vh - 176px); + overflow: auto; + padding: 16px; + border: 1px solid color-mix(in oklab, var(--line-strong) 78%, transparent); + border-radius: 16px; + background: oklch(22% 0.01 240 / 0.98); + color: oklch(91% 0.01 230); + font-size: 12px; + line-height: 1.58; + white-space: pre-wrap; + word-break: break-word; +} + .badge { display: inline-flex; align-items: center; @@ -2017,7 +2435,8 @@ body.auth-page { .infra-overview-grid, .mongo-group-grid, - .release-status-grid { + .release-status-grid, + .release-log-snippets { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2036,6 +2455,11 @@ body.auth-page { max-height: none; } + .config-sidebar { + position: static; + max-height: none; + } + .summary-cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -2061,7 +2485,8 @@ body.auth-page { .release-status-grid, .infra-overview-grid, - .mongo-group-grid { + .mongo-group-grid, + .release-log-snippets { grid-template-columns: 1fr; } } @@ -2144,6 +2569,14 @@ body.auth-page { width: 100%; } + .section-caption-row { + align-items: flex-start; + } + + .entry-grid { + grid-template-columns: 1fr; + } + .host-service-pill, .restart-pills .config-item { width: 100%; @@ -2153,7 +2586,12 @@ body.auth-page { .taskbar-inline, .metadata-strip, .precheck-strip, - .activity-item-side { + .activity-item-side, + .release-action-bar, + .release-action-summary, + .log-viewer-head, + .log-viewer-toolbar, + .entry-actions { width: 100%; text-align: left; } @@ -2163,6 +2601,21 @@ body.auth-page { grid-template-columns: 1fr; } + .log-viewer-overlay { + padding: 14px 12px; + } + + .log-viewer { + max-height: calc(100vh - 28px); + padding: 14px; + } + + .release-action-bar, + .log-viewer-head { + flex-direction: column; + align-items: stretch; + } + .diff-row { grid-template-columns: 34px 34px 22px minmax(0, 1fr); gap: 8px; diff --git a/static/app.js b/static/app.js index 5f81c0c..3645d1c 100644 --- a/static/app.js +++ b/static/app.js @@ -1,6 +1,7 @@ const { createApp } = Vue; const APP_TEMPLATE = document.getElementById("app")?.innerHTML || ""; +const APP_BASE_PATH = window.location.pathname === "/yumi" || window.location.pathname.startsWith("/yumi/") ? "/yumi" : ""; const STORAGE_KEY = "hy-app-monitor-refresh-interval"; const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation"; const GROUP_LABELS = { @@ -52,6 +53,20 @@ const INFRA_TABS = [ { id: "tat", label: "TAT" }, ]; +function rootPath(path) { + const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`; + return normalized; +} + +function appPath(path) { + const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`; + return `${APP_BASE_PATH}${normalized}` || "/"; +} + +function loginRedirectPath() { + return rootPath(`/login?next=${encodeURIComponent(`${window.location.pathname}${window.location.search}`)}`); +} + createApp({ template: APP_TEMPLATE, data() { @@ -254,6 +269,10 @@ createApp({ exportMaxLines: 10000, exporting: false, }, + logViewer: { + open: false, + kind: "", + }, timer: null, }; }, @@ -297,6 +316,12 @@ createApp({ filteredHostRows() { return this.buildHostRows({ businessOnly: false, applyFilter: true, applyKeyword: true }); }, + filteredHostAlertTotal() { + return this.filteredHostRows.filter((host) => host.hasAlert).length; + }, + filteredHostMetricIssueTotal() { + return this.filteredHostRows.filter((host) => host.hasMetricIssue).length; + }, overviewHostRowGroups() { return this.groupHosts(this.overviewHostRows); }, @@ -422,6 +447,24 @@ createApp({ group.items.some((item) => this.configKey(item.group, item.dataId) === this.nacosConfig.selectedKey) )?.key || ""; }, + flatNacosConfigKeys() { + return this.nacosConfigGroups.flatMap((group) => + group.items.map((item) => this.configKey(item.group, item.dataId)) + ); + }, + selectedNacosConfigIndex() { + return this.flatNacosConfigKeys.indexOf(this.nacosConfig.selectedKey); + }, + selectedPrevNacosConfigKey() { + return this.selectedNacosConfigIndex > 0 + ? this.flatNacosConfigKeys[this.selectedNacosConfigIndex - 1] + : ""; + }, + selectedNextNacosConfigKey() { + return this.selectedNacosConfigIndex >= 0 && this.selectedNacosConfigIndex < this.flatNacosConfigKeys.length - 1 + ? this.flatNacosConfigKeys[this.selectedNacosConfigIndex + 1] + : ""; + }, configHistoryRows() { const keyword = this.nacosConfig.keyword.trim().toLowerCase(); return this.configHistory.filter((item) => ( @@ -634,7 +677,7 @@ createApp({ }, { label: "最近日志", - value: lastLog?.message || "-", + value: lastLog?.message ? this.truncateMiddle(lastLog.message, 24, 14) : "-", tone: lastLog?.level === "error" ? "bad" : "neutral", }, ]; @@ -837,6 +880,15 @@ createApp({ }) .join("\n"); }, + recentUpdateLogs() { + return (this.updateOps.logs || []) + .slice(-5) + .reverse() + .map((entry, index) => ({ + ...entry, + key: `${entry.time || "log"}-${entry.stage || "stage"}-${index}`, + })); + }, serviceLogDownloadUrl() { if (!this.serviceLog.service || !this.serviceLog.host) { return ""; @@ -854,6 +906,71 @@ createApp({ }); return `/api/ops/service-log/export?${query.toString()}`; }, + activeLogViewerTitle() { + if (this.logViewer.kind === "service") { + return `${this.serviceLog.service || "-"} · ${this.serviceLog.host || "-"}`; + } + return "执行日志"; + }, + activeLogViewerSubtitle() { + if (this.logViewer.kind === "service") { + const subtitleParts = []; + if (this.serviceLog.container) { + subtitleParts.push(this.serviceLog.container); + } + subtitleParts.push(`更新于 ${this.formatDateTime(this.serviceLog.updatedAt)}`); + return subtitleParts.join(" · "); + } + return `任务 ${this.updateOps.operationId || "-"} · ${this.updateStatusLabel()} · ${this.formatDateTime(this.updateOps.finishedAt || this.updateOps.startedAt)}`; + }, + activeLogViewerBadge() { + if (this.logViewer.kind === "service") { + return this.serviceLog.container || ""; + } + return this.updateOps.operationId || ""; + }, + activeLogViewerSecondaryBadge() { + if (this.logViewer.kind === "service") { + return `最近 ${this.serviceLog.previewLines} 条`; + } + return this.updateOps.stage ? `阶段 ${this.updateOps.stage}` : ""; + }, + activeLogViewerContent() { + if (this.logViewer.kind === "service") { + return this.serviceLog.content; + } + return this.updateLogText; + }, + activeLogViewerError() { + if (this.logViewer.kind === "service") { + return this.serviceLog.error; + } + return this.updateOps.error ? this.explainError(this.updateOps.error) : ""; + }, + activeLogViewerLoading() { + if (this.logViewer.kind === "service") { + return this.serviceLog.loading; + } + return this.updateOps.statusRequestInFlight; + }, + activeLogViewerExporting() { + if (this.logViewer.kind === "service") { + return this.serviceLog.exporting; + } + return false; + }, + activeLogViewerRefreshDisabled() { + if (this.logViewer.kind === "service") { + return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.loading; + } + return !this.updateOps.operationId || this.updateOps.statusRequestInFlight; + }, + activeLogViewerDownloadDisabled() { + if (this.logViewer.kind === "service") { + return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.exporting; + } + return !this.updateOps.operationId; + }, }, watch: { refreshIntervalSeconds() { @@ -884,9 +1001,20 @@ createApp({ group: host.group, label: this.groupLabel(host.group), items: [], + alertCount: 0, + metricIssueCount: 0, + serviceCount: 0, }); } - groups.get(host.group).items.push(host); + const target = groups.get(host.group); + target.items.push(host); + target.serviceCount += host.serviceTotal || 0; + if (host.hasAlert) { + target.alertCount += 1; + } + if (host.hasMetricIssue) { + target.metricIssueCount += 1; + } }); return [...groups.values()].sort((left, right) => this.groupRank(left.group) - this.groupRank(right.group)); }, @@ -1059,9 +1187,50 @@ createApp({ } this.collapsedConfigGroups = [...next]; }, + syncConfigExplorerState(selectedKey = this.nacosConfig.selectedKey, { force = false } = {}) { + const groupKeys = [...new Set((this.nacosConfig.items || []).map((item) => item.group || "default"))]; + if (groupKeys.length === 0) { + this.collapsedConfigGroups = []; + return; + } + + const selectedItem = (this.nacosConfig.items || []).find((item) => this.configKey(item.group, item.dataId) === selectedKey); + const selectedGroup = selectedItem?.group || groupKeys[0]; + + if (force || this.collapsedConfigGroups.length === 0) { + this.collapsedConfigGroups = groupKeys.filter((groupKey) => groupKey !== selectedGroup); + return; + } + + if (this.collapsedConfigGroups.includes(selectedGroup)) { + this.collapsedConfigGroups = this.collapsedConfigGroups.filter((groupKey) => groupKey !== selectedGroup); + } + }, isConfigGroupCollapsed(groupKey) { return this.collapsedConfigGroups.includes(groupKey); }, + scrollSelectedConfigItemIntoView() { + this.$nextTick(() => { + const node = document.getElementById(`config-item-${this.nacosConfig.selectedKey}`); + if (node) { + node.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + }); + }, + openPrevNacosConfig() { + if (!this.selectedPrevNacosConfigKey) { + return; + } + this.nacosConfig.selectedKey = this.selectedPrevNacosConfigKey; + this.openNacosConfigByKey(this.selectedPrevNacosConfigKey); + }, + openNextNacosConfig() { + if (!this.selectedNextNacosConfigKey) { + return; + } + this.nacosConfig.selectedKey = this.selectedNextNacosConfigKey; + this.openNacosConfigByKey(this.selectedNextNacosConfigKey); + }, toggleNacosService(serviceName) { const next = new Set(this.expandedNacosServices); if (next.has(serviceName)) { @@ -1138,6 +1307,47 @@ createApp({ this.selectedServiceKey = serviceKey; this.detailPanelOpen = true; }, + openUpdateLogViewer() { + this.logViewer.open = true; + this.logViewer.kind = "update"; + this.$nextTick(() => this.scrollUpdateLogToBottom()); + }, + closeLogViewer() { + const previousKind = this.logViewer.kind; + this.logViewer.open = false; + this.logViewer.kind = ""; + if (previousKind === "service") { + this.clearServiceLog(); + } + }, + async refreshActiveLogViewer() { + if (this.logViewer.kind === "service") { + if (!this.serviceLog.host) { + return; + } + await this.openServiceLog({ host: this.serviceLog.host }); + return; + } + if (!this.updateOps.operationId) { + return; + } + await this.fetchUpdateOperationStatus(this.updateOps.operationId); + this.scrollUpdateLogToBottom(); + }, + async downloadActiveLogViewer() { + if (this.logViewer.kind === "service") { + await this.downloadServiceLog(); + return; + } + if (!this.updateOps.operationId) { + return; + } + try { + await this.downloadFile(this.updateLogDownloadUrl(), `service-update-${this.updateOps.operationId}.log`); + } catch (error) { + this.updateOps.error = error instanceof Error ? error.message : String(error); + } + }, clearServiceLog() { this.serviceLog.loading = false; this.serviceLog.exporting = false; @@ -1156,12 +1366,20 @@ createApp({ return; } if (!this.selectedServiceDetail || this.selectedServiceDetail.service !== this.serviceLog.service) { - this.clearServiceLog(); + if (this.logViewer.kind === "service" && this.logViewer.open) { + this.closeLogViewer(); + } else { + this.clearServiceLog(); + } return; } const exists = (this.selectedServiceDetail.instances || []).some((instance) => instance.host === this.serviceLog.host); if (!exists) { - this.clearServiceLog(); + if (this.logViewer.kind === "service" && this.logViewer.open) { + this.closeLogViewer(); + } else { + this.clearServiceLog(); + } } }, isServiceLogActive(instance) { @@ -1174,6 +1392,8 @@ createApp({ return; } + this.logViewer.open = true; + this.logViewer.kind = "service"; this.serviceLog.loading = true; this.serviceLog.error = ""; this.serviceLog.service = serviceName; @@ -1199,6 +1419,7 @@ createApp({ Math.max(1, Number(this.serviceLog.exportLines || this.serviceLog.previewLines)), this.serviceLog.exportMaxLines, ); + this.$nextTick(() => this.scrollUpdateLogToBottom()); } catch (error) { this.serviceLog.error = error instanceof Error ? error.message : String(error); } finally { @@ -1265,6 +1486,81 @@ createApp({ } return parts.filter(Boolean).join(" · "); }, + hostMetricEntries(host) { + const metrics = host?.metrics || {}; + return [ + { + key: "cpu", + label: "CPU", + value: metrics.cpuPercent, + display: this.formatPercent(metrics.cpuPercent), + level: metrics.cpuLevel || this.thresholdTone("cpuPercent", metrics.cpuPercent), + }, + { + key: "memory", + label: "MEM", + value: metrics.memoryPercent, + display: this.formatPercent(metrics.memoryPercent), + level: metrics.memoryLevel || this.thresholdTone("memoryPercent", metrics.memoryPercent), + }, + { + key: "disk", + label: "DISK", + value: metrics.diskPercent, + display: this.formatPercent(metrics.diskPercent), + level: metrics.diskLevel || this.thresholdTone("diskPercent", metrics.diskPercent), + }, + ]; + }, + thresholdTone(metricKey, value) { + if (value == null || Number.isNaN(Number(value))) { + return "neutral"; + } + const rule = this.thresholds?.[metricKey]; + if (!rule) { + return "neutral"; + } + const amount = Number(value); + const bad = Number(rule.bad); + const warn = Number(rule.warn); + if (!Number.isNaN(bad) && amount >= bad) { + return "bad"; + } + if (!Number.isNaN(warn) && amount >= warn) { + return "warn"; + } + return "ok"; + }, + metricRingStyle(value, level = "neutral") { + const amount = value == null || Number.isNaN(Number(value)) + ? 0 + : Math.max(0, Math.min(100, Number(value))); + const tone = level || "neutral"; + return { + "--metric-value": `${amount}%`, + "--metric-color": `var(--${tone === "bad" ? "bad" : tone === "warn" ? "warn" : tone === "ok" ? "ok" : "line-strong"})`, + }; + }, + hostServiceSummary(host, { quiet = false } = {}) { + const source = quiet ? host.items : (host.alertItems.length > 0 ? host.alertItems : host.items); + const services = [...new Set((source || []).map((item) => item.service).filter(Boolean))]; + if (services.length === 0) { + return host.hasMetricIssue ? "指标采集异常" : "无服务"; + } + if (services.length <= 4) { + return services.join(" / "); + } + return `${services.slice(0, 4).join(" / ")} +${services.length - 4}`; + }, + hostSummaryTone(host) { + if (host.downCount > 0) { + return "bad"; + } + if (host.hasMetricIssue) { + return "warn"; + } + return "neutral"; + }, buildHostRows({ businessOnly = false, applyFilter = false, applyKeyword = false } = {}) { const keyword = applyKeyword ? this.normalizedKeyword : ""; @@ -1286,10 +1582,11 @@ createApp({ const hasMetricIssue = metrics.ok === false || Boolean(metrics.error); const alertServices = [...new Set(alertItems.map((item) => item.service))]; const alertSummary = alertServices.length > 0 - ? alertServices.join(" / ") + ? this.hostServiceSummary({ items, alertItems, hasMetricIssue }, { quiet: false }) : hasMetricIssue ? "指标采集异常" - : "全部正常"; + : "无服务异常"; + const quietSummary = this.hostServiceSummary({ items, alertItems, hasMetricIssue }, { quiet: true }); const searchText = [ host.host, host.ip, @@ -1313,6 +1610,7 @@ createApp({ items, alertItems, alertSummary, + quietSummary, groupLabel: this.groupLabel(host.group), serviceTotal: items.length, downCount, @@ -1831,7 +2129,7 @@ createApp({ } window.sessionStorage.removeItem(UPDATE_OPERATION_STORAGE_KEY); - window.location.replace("/login"); + window.location.replace(loginRedirectPath()); }, async authFetch(url, options = {}) { const requestOptions = { @@ -1843,7 +2141,8 @@ createApp({ requestOptions.cache = "no-store"; } - return fetch(url, requestOptions); + const actualUrl = typeof url === "string" && url.startsWith("/") ? appPath(url) : url; + return fetch(actualUrl, requestOptions); }, async requestJson(url, options = {}) { const response = await this.authFetch(url, options); @@ -1860,10 +2159,7 @@ createApp({ this.auth.checking = true; try { - const response = await fetch("/api/auth/session", { - cache: "no-store", - credentials: "same-origin", - }); + const response = await this.authFetch("/api/auth/session"); const payload = await this.readJsonResponse(response); if (response.status === 401) { @@ -2043,6 +2339,7 @@ createApp({ } this.nacosConfig.selectedKey = nextKey; + this.syncConfigExplorerState(nextKey, { force: !preserveSelection || this.collapsedConfigGroups.length === 0 }); const sameAsCurrent = this.configKey(this.nacosConfig.editor.group, this.nacosConfig.editor.dataId) === nextKey; if (!sameAsCurrent || reloadSelected || !this.nacosConfig.loadedContent) { @@ -2090,6 +2387,8 @@ createApp({ md5: item.md5 || "", content: this.nacosConfig.loadedContent, }; + this.syncConfigExplorerState(configKey); + this.scrollSelectedConfigItemIntoView(); this.recordConfigHistory("nacos", "打开配置", `${item.group}/${item.dataId}`, "neutral"); } catch (error) { this.nacosConfig.detailError = error instanceof Error ? error.message : String(error); diff --git a/static/haiyi.html b/static/haiyi.html new file mode 100644 index 0000000..ebe82a3 --- /dev/null +++ b/static/haiyi.html @@ -0,0 +1,50 @@ + + + + + + + HY App Monitor HaiYi + + + + + +
加载中...
+ + +
+
+
+
+

HY APP MONITOR

+ HAIYI +
+

HaiYi

+ +
+ +
+
+ 入口已预留 + 一期仅接统一登录、选择页和健康检查。 +
+
+ +
+ 返回入口 + + +
+ + +
+
+ + + diff --git a/static/haiyi.js b/static/haiyi.js new file mode 100644 index 0000000..09812e8 --- /dev/null +++ b/static/haiyi.js @@ -0,0 +1,129 @@ +const HAIYI_BASE_PATH = window.location.pathname === "/haiyi" || window.location.pathname.startsWith("/haiyi/") ? "/haiyi" : ""; + +function appPath(path) { + const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`; + return `${HAIYI_BASE_PATH}${normalized}` || "/"; +} + +function loginRedirectPath() { + return `/login?next=${encodeURIComponent(`${window.location.pathname}${window.location.search}`)}`; +} + +async function readJsonResponse(response) { + const text = await response.text(); + if (!text) { + return {}; + } + try { + return JSON.parse(text); + } catch (error) { + throw new Error(text); + } +} + +window.addEventListener("DOMContentLoaded", async () => { + const bootIndicator = document.getElementById("boot-indicator"); + const root = document.getElementById("haiyi-root"); + const errorNode = document.getElementById("haiyi-error"); + const usernameNode = document.getElementById("haiyi-username"); + const healthNode = document.getElementById("haiyi-health"); + const refreshButton = document.getElementById("haiyi-refresh"); + const logoutButton = document.getElementById("haiyi-logout"); + + const showPage = () => { + if (bootIndicator) { + bootIndicator.hidden = true; + } + if (root) { + root.hidden = false; + } + }; + + const setError = (message) => { + if (!errorNode) { + return; + } + errorNode.textContent = message || ""; + errorNode.hidden = !message; + }; + + const loadSession = async () => { + const response = await fetch(appPath("/api/auth/session"), { + cache: "no-store", + credentials: "same-origin", + }); + const payload = await readJsonResponse(response); + + if (response.status === 401) { + window.location.replace(loginRedirectPath()); + return false; + } + + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + if (usernameNode) { + usernameNode.textContent = payload.username || ""; + usernameNode.hidden = !payload.username; + } + return true; + }; + + const loadHealth = async () => { + const response = await fetch(appPath("/api/health"), { + cache: "no-store", + credentials: "same-origin", + }); + const payload = await readJsonResponse(response); + + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + if (healthNode) { + healthNode.textContent = payload.status === "ok" ? "在线" : "异常"; + healthNode.className = payload.status === "ok" ? "badge ok" : "badge bad"; + } + }; + + try { + const ok = await loadSession(); + if (!ok) { + return; + } + await loadHealth(); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } finally { + showPage(); + } + + if (refreshButton instanceof HTMLButtonElement) { + refreshButton.addEventListener("click", async () => { + refreshButton.disabled = true; + setError(""); + try { + await loadHealth(); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } finally { + refreshButton.disabled = false; + } + }); + } + + if (logoutButton instanceof HTMLButtonElement) { + logoutButton.addEventListener("click", async () => { + logoutButton.disabled = true; + try { + await fetch(appPath("/api/auth/logout"), { + method: "POST", + credentials: "same-origin", + }); + } finally { + window.location.replace("/login"); + } + }); + } +}); diff --git a/static/index.html b/static/index.html index bed3345..48546b1 100644 --- a/static/index.html +++ b/static/index.html @@ -5,9 +5,9 @@ HY App Monitor - - - + + + @@ -190,11 +190,11 @@ 构建参数 / 更新目标 / 执行日志 / 滚动重启 -
+
-
- - - - -
+ - - -
@@ -318,11 +314,11 @@
- -
-
- {{ serviceLog.service }} · {{ serviceLog.host }} - {{ formatDateTime(serviceLog.updatedAt) }} -
- -
- {{ serviceLog.container }} - 最近 {{ serviceLog.previewLines }} 条 - - - - -
- -

{{ serviceLog.error }}

-
{{ serviceLog.content || '暂无日志' }}
-
当前没有服务详情
@@ -459,7 +429,13 @@
-
异常主机
+
+
异常主机
+
+ {{ overviewAlertHosts.length }} 台 + {{ overviewAlertHosts.filter((host) => host.hasMetricIssue).length }} 采集异常 +
+
- CPU {{ formatPercent(host.metrics.cpuPercent) }} - MEM {{ formatPercent(host.metrics.memoryPercent) }} - DISK {{ formatPercent(host.metrics.diskPercent) }} - PROC {{ host.metrics.processCount ?? '-' }} +
+ + + + + {{ metric.label }} + {{ metric.display }} + +
+ + PROC + {{ host.metrics.processCount ?? '-' }} +
-
- {{ host.alertSummary }} +
+ {{ host.downCount > 0 ? '异常服务' : host.hasMetricIssue ? '采集状态' : '在线服务' }} + {{ host.downCount > 0 ? host.alertSummary : host.quietSummary }}
@@ -541,14 +531,28 @@
- CPU {{ formatPercent(host.metrics.cpuPercent) }} - MEM {{ formatPercent(host.metrics.memoryPercent) }} - DISK {{ formatPercent(host.metrics.diskPercent) }} - PROC {{ host.metrics.processCount ?? '-' }} +
+ + + + + {{ metric.label }} + {{ metric.display }} + +
+ + PROC + {{ host.metrics.processCount ?? '-' }} +
-
- {{ host.alertSummary }} +
+ 在线服务 + {{ host.quietSummary }}
@@ -580,13 +584,21 @@

{{ filteredHostRows.length }} 台主机

- 点击主机展开服务 + {{ filteredHostAlertTotal }} 台异常 + {{ filteredHostMetricIssueTotal }} 采集异常
-
{{ section.label }}
+
+
{{ section.label }}
+
+ {{ section.items.length }} 台 + {{ section.serviceCount }} 服务 + {{ section.alertCount }} 异常主机 +
+
- CPU {{ formatPercent(host.metrics.cpuPercent) }} - MEM {{ formatPercent(host.metrics.memoryPercent) }} - DISK {{ formatPercent(host.metrics.diskPercent) }} - PROC {{ host.metrics.processCount ?? '-' }} +
+ + + + + {{ metric.label }} + {{ metric.display }} + +
+ + PROC + {{ host.metrics.processCount ?? '-' }} +
-
- {{ host.alertSummary }} +
+ {{ host.downCount > 0 ? '异常服务' : host.hasMetricIssue ? '采集状态' : '在线服务' }} + {{ host.downCount > 0 ? host.alertSummary : host.quietSummary }}
@@ -1152,8 +1178,15 @@
@@ -1198,6 +1236,11 @@
+ +
+ + +
@@ -1362,6 +1405,28 @@ 已选 {{ restartOps.selectedServices.length }} 项重启
+ +
+
+ 更新目标 {{ releaseTargetRows.length }} + 重启目标 {{ restartTargetRows.length }} + + {{ releasePrecheck.ranAt ? releasePrecheck.title : '未预检查' }} + +
+
+ + + + +
+
@@ -1433,13 +1498,11 @@

更新目标

-

紧凑表格,长镜像悬停查看

+

紧凑表格

{{ releaseTargetRows.length }} 项 - + 已选 {{ updateOps.selectedServices.length }} 项
@@ -1501,22 +1564,12 @@

执行日志

-

任务、阶段、结果闭环

+

页内只看摘要,完整日志弹出

任务 {{ updateOps.operationId }} 阶段 {{ updateOps.stage || '-' }} - - - 下载日志 - +
@@ -1527,8 +1580,19 @@ 影响服务 {{ updateAffectedServicesCount }}
-
-
{{ updateLogText || '等待更新日志...' }}
+
+
+ {{ updateOps.logs.length }} 条日志 + 截断 {{ updateOps.logDroppedCount }} +
+
+
+ {{ formatDateTime(entry.time) }} + {{ entry.stage || '-' }} +

{{ entry.message || '-' }}

+
+
+
当前还没有可展示的执行日志
@@ -1632,17 +1696,14 @@

滚动重启

-

紧凑选择表

+

按服务勾选执行

{{ restartOps.error ? '存在错误' : restartOps.running ? '执行中' : '可操作' }} -
@@ -1722,6 +1783,38 @@
+ +
+ +
diff --git a/static/login.html b/static/login.html index 66fafd6..e917245 100644 --- a/static/login.html +++ b/static/login.html @@ -5,8 +5,8 @@ HY App Monitor Login - - + + diff --git a/static/login.js b/static/login.js index de735c3..edb07b2 100644 --- a/static/login.js +++ b/static/login.js @@ -10,6 +10,15 @@ async function readJsonResponse(response) { } } +function nextLocation() { + const params = new URLSearchParams(window.location.search); + const candidate = (params.get("next") || "").trim(); + if (candidate.startsWith("/") && !candidate.startsWith("//")) { + return candidate; + } + return "/select"; +} + window.addEventListener("DOMContentLoaded", async () => { const bootIndicator = document.getElementById("boot-indicator"); const root = document.getElementById("login-root"); @@ -42,7 +51,7 @@ window.addEventListener("DOMContentLoaded", async () => { credentials: "same-origin", }); if (response.ok) { - window.location.replace("/"); + window.location.replace(nextLocation()); return; } @@ -83,7 +92,7 @@ window.addEventListener("DOMContentLoaded", async () => { if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } - window.location.replace("/"); + window.location.replace(nextLocation()); } catch (error) { setError(error instanceof Error ? error.message : String(error)); } finally { diff --git a/static/select.html b/static/select.html new file mode 100644 index 0000000..37d6507 --- /dev/null +++ b/static/select.html @@ -0,0 +1,51 @@ + + + + + + + HY App Monitor Entry + + + + + +
检查登录态...
+ + +
+
+
+
+

HY APP MONITOR

+ DEPLOY +
+

选择入口

+ +
+ +
+ + Yumi + 监控 / 配置 / 发布 + + + HaiYi + 入口已预留 + +
+ +
+ +
+ + +
+
+ + + diff --git a/static/select.js b/static/select.js new file mode 100644 index 0000000..62b6d69 --- /dev/null +++ b/static/select.js @@ -0,0 +1,82 @@ +async function readJsonResponse(response) { + const text = await response.text(); + if (!text) { + return {}; + } + try { + return JSON.parse(text); + } catch (error) { + throw new Error(text); + } +} + +function loginRedirectPath() { + return `/login?next=${encodeURIComponent(`${window.location.pathname}${window.location.search}`)}`; +} + +window.addEventListener("DOMContentLoaded", async () => { + const bootIndicator = document.getElementById("boot-indicator"); + const root = document.getElementById("select-root"); + const errorNode = document.getElementById("select-error"); + const usernameNode = document.getElementById("select-username"); + const logoutButton = document.getElementById("select-logout"); + + const showPage = () => { + if (bootIndicator) { + bootIndicator.hidden = true; + } + if (root) { + root.hidden = false; + } + }; + + const setError = (message) => { + if (!errorNode) { + return; + } + errorNode.textContent = message || ""; + errorNode.hidden = !message; + }; + + try { + const response = await fetch("/api/auth/session", { + cache: "no-store", + credentials: "same-origin", + }); + const payload = await readJsonResponse(response); + + if (response.status === 401) { + window.location.replace(loginRedirectPath()); + return; + } + + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + if (usernameNode) { + usernameNode.textContent = payload.username || ""; + usernameNode.hidden = !payload.username; + } + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } finally { + showPage(); + } + + if (!(logoutButton instanceof HTMLButtonElement)) { + return; + } + + logoutButton.addEventListener("click", async () => { + logoutButton.disabled = true; + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "same-origin", + }); + } finally { + window.location.replace("/login"); + } + }); +}); diff --git a/systemd/hy-app-monitor-haiyi.service b/systemd/hy-app-monitor-haiyi.service new file mode 100644 index 0000000..798c7d3 --- /dev/null +++ b/systemd/hy-app-monitor-haiyi.service @@ -0,0 +1,16 @@ +[Unit] +Description=HY App Monitor HaiYi +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/hy-app-monitor +EnvironmentFile=-/opt/hy-app-monitor/.env +ExecStart=/opt/hy-app-monitor/scripts/run_haiyi_foreground.sh +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/hy-app-monitor-yumi.service b/systemd/hy-app-monitor-yumi.service new file mode 100644 index 0000000..2a7936d --- /dev/null +++ b/systemd/hy-app-monitor-yumi.service @@ -0,0 +1,16 @@ +[Unit] +Description=HY App Monitor Yumi +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/hy-app-monitor +EnvironmentFile=-/opt/hy-app-monitor/.env +ExecStart=/opt/hy-app-monitor/scripts/run_yumi_foreground.sh +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/hy-app-monitor.service b/systemd/hy-app-monitor.service index d79c382..14ecad7 100644 --- a/systemd/hy-app-monitor.service +++ b/systemd/hy-app-monitor.service @@ -1,7 +1,7 @@ [Unit] -Description=HY App Monitor -After=network-online.target -Wants=network-online.target +Description=HY App Monitor Gateway +After=network-online.target hy-app-monitor-yumi.service hy-app-monitor-haiyi.service +Wants=network-online.target hy-app-monitor-yumi.service hy-app-monitor-haiyi.service [Service] Type=simple diff --git a/yumi_server.py b/yumi_server.py new file mode 100644 index 0000000..e3f6ac1 --- /dev/null +++ b/yumi_server.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os + +# 直接执行 Yumi 入口时,默认也固定到内部监听地址和 /yumi 前缀。 +# 脚本或 systemd 已显式传值时继续沿用外部值,不覆盖运维配置。 +os.environ.setdefault("APP_BIND", os.environ.get("YUMI_INTERNAL_HOST", "127.0.0.1")) +os.environ.setdefault("APP_PORT", os.environ.get("YUMI_INTERNAL_PORT", "2027")) +os.environ["APP_BASE_PATH"] = "/yumi" + +from monitor.http_app import main + + +if __name__ == "__main__": + # 作为脚本执行时启动 Yumi 内部实例。 + main()