Split gateway and internal monitor services

This commit is contained in:
ZuoZuo 2026-04-23 01:56:12 +08:00
parent c1f528a72e
commit e01ca63ad8
36 changed files with 2616 additions and 491 deletions

View File

@ -1,5 +1,11 @@
APP_BIND=0.0.0.0 APP_BIND=0.0.0.0
APP_PORT=2026 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_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3
AUTH_SESSION_TTL_HOURS=24 AUTH_SESSION_TTL_HOURS=24
AUTH_COOKIE_SECURE=0 AUTH_COOKIE_SECURE=0

View File

@ -1,10 +1,13 @@
# hy-app-monitor # hy-app-monitor
部署在 `deploy` 机器上的轻量监控面板。 部署在 `deploy` 机器上的统一入口监控面板。
- 页面端口:`2026` - 对外入口端口:`2026`
- 页面入口:`http://43.164.75.199:2026/`
- 登录入口:`http://43.164.75.199:2026/login` - 登录入口:`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 / 内存 / 磁盘 / 进程数` - 主机资源:通过 `SSH` 采集 `CPU / 内存 / 磁盘 / 进程数`
- Nacos直连 Nacos OpenAPI列出命名空间、服务和注册实例 - Nacos直连 Nacos OpenAPI列出命名空间、服务和注册实例
@ -14,8 +17,12 @@
## 目录 ## 目录
- `server.py`:后端入口 - `server.py`gateway 入口
- `monitor/`Python 模块实现 - `yumi_server.py`Yumi 内部实例入口
- `haiyi_server.py`HaiYi 内部实例入口
- `gateway/`:统一登录、入口选择页和反代
- `monitor/`Yumi 监控与运维实现
- `haiyi_monitor/`HaiYi 占位实例
- `config/targets.json`:监控目标和实例 ID - `config/targets.json`:监控目标和实例 ID
- `static/`:前端页面 - `static/`:前端页面
- `scripts/`:启停、自启安装、前台运行脚本 - `scripts/`:启停、自启安装、前台运行脚本
@ -32,6 +39,11 @@
```bash ```bash
APP_BIND=0.0.0.0 APP_BIND=0.0.0.0
APP_PORT=2026 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_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3
AUTH_SESSION_TTL_HOURS=24 AUTH_SESSION_TTL_HOURS=24
DEFAULT_REFRESH_INTERVAL_SECONDS=10 DEFAULT_REFRESH_INTERVAL_SECONDS=10
@ -122,6 +134,12 @@ http://127.0.0.1:2026/
http://127.0.0.1:2026/login http://127.0.0.1:2026/login
``` ```
登录后会进入:
```bash
http://127.0.0.1:2026/select
```
## systemd 开机自启 ## systemd 开机自启
在远端项目目录执行: 在远端项目目录执行:
@ -171,8 +189,9 @@ python3 ops/deploy_via_ssh.py
2. 把本地项目 `.env` 同步到远端项目 `.env` 2. 把本地项目 `.env` 同步到远端项目 `.env`
3. 创建远端 `.venv` 3. 创建远端 `.venv`
4. 安装 `requirements-ops.txt` 4. 安装 `requirements-ops.txt`
5. 安装并启用 `systemd` 5. 安装并启用三套 `systemd` unit
6. 重启服务并检查 `2026` 监听 6. 重启 `gateway / yumi / haiyi`
7. 检查 `2026` 监听
`ops/deploy_via_tat.py` 仍可保留成带外兜底,不建议继续作为日常主链路。 `ops/deploy_via_tat.py` 仍可保留成带外兜底,不建议继续作为日常主链路。

1
gateway/__init__.py Normal file
View File

@ -0,0 +1 @@
from __future__ import annotations

460
gateway/http_app.py Normal file
View File

@ -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()

View File

@ -0,0 +1 @@
from __future__ import annotations

179
haiyi_monitor/http_app.py Normal file
View File

@ -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()

18
haiyi_server.py Normal file
View File

@ -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()

View File

@ -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" APP_BIND = env_str("APP_BIND", "0.0.0.0") or "0.0.0.0"
# HTTP 服务监听端口。 # HTTP 服务监听端口。
APP_PORT = env_int("APP_PORT", 2026) 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 目录,避免写入版本库。 # 本地账号库统一落到 run 目录,避免写入版本库。
AUTH_DB_PATH = Path(env_str("AUTH_DB_PATH", str(RUNTIME_DIR / "auth.sqlite3"))).expanduser() AUTH_DB_PATH = Path(env_str("AUTH_DB_PATH", str(RUNTIME_DIR / "auth.sqlite3"))).expanduser()
# 会话 Cookie 名称固定成单值,前后端都按这个名字取。 # 会话 Cookie 名称固定成单值,前后端都按这个名字取。

View File

@ -1,24 +1,15 @@
from __future__ import annotations from __future__ import annotations
import ipaddress
import json import json
import time
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any from typing import Any
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from .auth import ( from .config import APP_BASE_PATH, APP_BIND, APP_PORT, INTERNAL_PROXY_SHARED_SECRET, STATIC_DIR, utc_now
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 .dashboard import build_monitor_payload from .dashboard import build_monitor_payload
from .http_paths import join_base_path, strip_base_path
from .nacos import ( from .nacos import (
get_nacos_config_list_payload, get_nacos_config_list_payload,
get_nacos_config_payload, get_nacos_config_payload,
@ -38,90 +29,96 @@ def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
class MonitorHandler(BaseHTTPRequestHandler): class MonitorHandler(BaseHTTPRequestHandler):
# 自定义服务名,便于 curl 时识别来源 # 单独标出这是 Yumi 内部实例,排查反代链路更直接
server_version = "HyAppMonitor/2.0" server_version = "HyAppMonitorYumi/2.0"
def log_message(self, format: str, *args: Any) -> None: # noqa: A003 def log_message(self, format: str, *args: Any) -> None: # noqa: A003
# 关闭默认访问日志,避免轮询刷屏 # 内部轮询很多,关闭默认访问日志。
return 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 def do_GET(self) -> None: # noqa: N802
# GET 请求需要响应头和响应体。 # 只接受来自统一网关的代理请求。
if not self.require_internal_proxy(send_body=True):
return
self.dispatch(send_body=True) self.dispatch(send_body=True)
def do_HEAD(self) -> None: # noqa: N802 def do_HEAD(self) -> None: # noqa: N802
# HEAD 请求只返回响应头。 # HEAD 请求同样只允许内部网关进入。
if not self.require_internal_proxy(send_body=False):
return
self.dispatch(send_body=False) self.dispatch(send_body=False)
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802
# POST 请求主要用于配置保存。 # POST 接口也必须经过统一入口。
if not self.require_internal_proxy(send_body=True):
return
self.dispatch_post() 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: def dispatch(self, send_body: bool) -> None:
# 只解析 URL 的 path 段。 # 统一先拆 URL后面都只操作 path 和 query
parsed = urlparse(self.path) parsed = urlparse(self.path)
# 顺手解析 query 参数,后面接口直接取。
query = parse_qs(parsed.query, keep_blank_values=True) query = parse_qs(parsed.query, keep_blank_values=True)
local_path = strip_base_path(APP_BASE_PATH, parsed.path)
# 登录页对未登录用户开放,已登录时直接回总览。 # 不属于当前应用 base path 的请求直接 404。
if parsed.path in {"/login", "/login.html"}: if local_path is None:
if self.current_session(): return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
return self.redirect("/", send_body=send_body)
return self.serve_static("login.html", "text/html; charset=utf-8", send_body=send_body)
# 总览页需要先过登录校验,未登录统一跳到登录页。 # /yumi 必须补成 /yumi/,这样相对静态资源路径才稳定。
if parsed.path in {"/", "/index.html"}: if APP_BASE_PATH and parsed.path == APP_BASE_PATH:
if not self.current_session(): return self.redirect(join_base_path(APP_BASE_PATH, "/"), send_body=send_body)
return self.redirect("/login", 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) 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) return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body)
# 登录页脚本。 if local_path == "/app.js":
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":
return self.serve_static("app.js", "application/javascript; charset=utf-8", send_body=send_body) return self.serve_static("app.js", "application/javascript; charset=utf-8", send_body=send_body)
# 本地 Vue 运行时。 if local_path == "/vue.js":
if parsed.path == "/vue.js":
return self.serve_static("vue.js", "application/javascript; charset=utf-8", send_body=send_body) return self.serve_static("vue.js", "application/javascript; charset=utf-8", send_body=send_body)
# 会话查询接口单独开放给登录页和主页面做状态判断。 # 内部实例自身的健康检查。
if parsed.path == "/api/auth/session": if local_path == "/api/health":
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":
return self.send_payload( return self.send_payload(
HTTPStatus.OK, HTTPStatus.OK,
response_json({"status": "ok", "time": utc_now()}), response_json({"status": "ok", "time": utc_now(), "app": "yumi"}),
"application/json; charset=utf-8", "application/json; charset=utf-8",
send_body=send_body, send_body=send_body,
) )
# 主监控接口。 # 主监控接口。
if parsed.path == "/api/monitor/services": if local_path == "/api/monitor/services":
return self.send_payload( return self.send_payload(
HTTPStatus.OK, HTTPStatus.OK,
response_json(build_monitor_payload()), response_json(build_monitor_payload()),
@ -130,7 +127,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
) )
# 返回当前 namespace 下全部 Nacos 配置元数据。 # 返回当前 namespace 下全部 Nacos 配置元数据。
if parsed.path == "/api/nacos/configs": if local_path == "/api/nacos/configs":
try: try:
payload = get_nacos_config_list_payload() payload = get_nacos_config_list_payload()
except Exception as exc: # noqa: BLE001 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") group_name = self.query_value(query, "group")
data_id = self.query_value(query, "dataId") data_id = self.query_value(query, "dataId")
@ -168,7 +165,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
) )
# 返回当前线上 golang 的运行配置。 # 返回当前线上 golang 的运行配置。
if parsed.path == "/api/runtime/golang-config": if local_path == "/api/runtime/golang-config":
try: try:
payload = get_go_config_payload() payload = get_go_config_payload()
except Exception as exc: # noqa: BLE001 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: try:
payload = build_restart_targets_payload() payload = build_restart_targets_payload()
except Exception as exc: # noqa: BLE001 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: try:
payload = build_update_targets_payload() payload = build_update_targets_payload()
except Exception as exc: # noqa: BLE001 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") operation_id = self.query_value(query, "id")
if not operation_id: if not operation_id:
return self.send_error_payload( 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") operation_id = self.query_value(query, "id")
if not operation_id: if not operation_id:
return self.send_error_payload( return self.send_error_payload(
@ -258,7 +255,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
) )
# 返回单个服务实例最近 1000 条容器日志。 # 返回单个服务实例最近 1000 条容器日志。
if parsed.path == "/api/ops/service-log": if local_path == "/api/ops/service-log":
service_name = self.query_value(query, "service") service_name = self.query_value(query, "service")
host_name = self.query_value(query, "host") host_name = self.query_value(query, "host")
if not service_name or not host_name: 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") service_name = self.query_value(query, "service")
host_name = self.query_value(query, "host") host_name = self.query_value(query, "host")
lines = self.query_value(query, "lines") lines = self.query_value(query, "lines")
@ -313,7 +310,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
) )
# favicon 直接返回空。 # 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) return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body)
# 其他路径统一 404。 # 其他路径统一 404。
@ -322,137 +319,36 @@ class MonitorHandler(BaseHTTPRequestHandler):
def dispatch_post(self) -> None: def dispatch_post(self) -> None:
# 只解析 path 段POST 不处理复杂 query。 # 只解析 path 段POST 不处理复杂 query。
parsed = urlparse(self.path) parsed = urlparse(self.path)
local_path = strip_base_path(APP_BASE_PATH, parsed.path)
# 退出接口允许空请求体,先单独处理。 if local_path is None:
if parsed.path == "/api/auth/logout": return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
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
try: try:
payload = self.read_json_body() payload = self.read_json_body()
except ValueError as exc: except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True) 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) return self.handle_nacos_validate(payload)
if parsed.path == "/api/nacos/config": if local_path == "/api/nacos/config":
return self.handle_nacos_save(payload) 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) 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) 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) 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.handle_update_services(payload)
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True) 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: def redirect(self, location: str, *, send_body: bool) -> None:
# 页面跳转只依赖 Location响应体保持空即可。 # 页面跳转只依赖 Location响应体保持空即可。
self.send_payload( self.send_payload(
@ -683,11 +579,9 @@ class MonitorHandler(BaseHTTPRequestHandler):
def main() -> None: def main() -> None:
# 启动前先把账号库和默认账号准备好。
ensure_auth_store()
# 创建线程型 HTTP 服务。 # 创建线程型 HTTP 服务。
server = ThreadingHTTPServer((APP_BIND, APP_PORT), MonitorHandler) 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() server.serve_forever()

42
monitor/http_paths.py Normal file
View File

@ -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='')}"

View File

@ -207,7 +207,9 @@ chmod +x scripts/*.sh
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install -r requirements-ops.txt .venv/bin/pip install -r requirements-ops.txt
bash scripts/install_systemd.sh 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 systemctl is-active hy-app-monitor.service
ss -lntp | grep ":$APP_PORT" || true ss -lntp | grep ":$APP_PORT" || true
""" """

View File

@ -156,6 +156,12 @@ def load_runtime_env() -> str:
lines = [ lines = [
f"APP_BIND={os.environ.get('APP_BIND', '0.0.0.0')}", f"APP_BIND={os.environ.get('APP_BIND', '0.0.0.0')}",
f"APP_PORT={os.environ.get('APP_PORT', DEFAULT_APP_PORT)}", 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_TIMEOUT_SECONDS={os.environ.get('PROBE_TIMEOUT_SECONDS', '3')}",
f"PROBE_CACHE_TTL_SECONDS={os.environ.get('PROBE_CACHE_TTL_SECONDS', '5')}", f"PROBE_CACHE_TTL_SECONDS={os.environ.get('PROBE_CACHE_TTL_SECONDS', '5')}",
f"PROBE_MAX_WORKERS={os.environ.get('PROBE_MAX_WORKERS', '12')}", 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 .venv/bin/pip install --quiet --disable-pip-version-check -r requirements-ops.txt
bash scripts/install_systemd.sh 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 systemctl is-active hy-app-monitor.service
ss -lntp | grep ":$APP_PORT" || true ss -lntp | grep ":$APP_PORT" || true
""" """

383
scripts/common.sh Executable file → Normal file
View File

@ -3,10 +3,7 @@ set -euo pipefail
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
RUN_DIR="$ROOT_DIR/run" 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}" ENV_FILE="${APP_ENV_FILE:-$ROOT_DIR/.env}"
SYSTEMD_SERVICE_NAME="hy-app-monitor.service"
if [ -f "$ENV_FILE" ]; then if [ -f "$ENV_FILE" ]; then
set -a set -a
@ -18,6 +15,16 @@ fi
APP_BIND="${APP_BIND:-0.0.0.0}" APP_BIND="${APP_BIND:-0.0.0.0}"
APP_PORT="${APP_PORT:-2026}" APP_PORT="${APP_PORT:-2026}"
TARGETS_FILE="${TARGETS_FILE:-$ROOT_DIR/config/targets.json}" 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 if [ -x "$ROOT_DIR/.venv/bin/python" ]; then
PYTHON_BIN_DEFAULT="$ROOT_DIR/.venv/bin/python" PYTHON_BIN_DEFAULT="$ROOT_DIR/.venv/bin/python"
@ -29,88 +36,133 @@ PYTHON_BIN="${PYTHON_BIN:-$PYTHON_BIN_DEFAULT}"
mkdir -p "$RUN_DIR" mkdir -p "$RUN_DIR"
read_pid() { suite_service_names() {
if [ -f "$PID_FILE" ]; then printf '%s\n' gateway yumi haiyi
local pid
pid="$(cat "$PID_FILE")"
if pid_matches_project "$pid"; then
echo "$pid"
return 0
fi
fi
discover_project_pid
} }
is_running() { service_display_name() {
local pid case "$1" in
pid="$(read_pid || true)" gateway) echo "gateway" ;;
if [ -z "${pid:-}" ]; then yumi) echo "yumi" ;;
rm -f "$PID_FILE" haiyi) echo "haiyi" ;;
*)
echo "unknown service: $1" >&2
return 1 return 1
fi ;;
if ! kill -0 "$pid" >/dev/null 2>&1; then esac
rm -f "$PID_FILE" }
return 1
fi
echo "$pid" >"$PID_FILE" service_unit_name() {
return 0 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() { has_systemd_unit() {
local name="$1"
if ! command -v systemctl >/dev/null 2>&1; then if ! command -v systemctl >/dev/null 2>&1; then
return 1 return 1
fi fi
systemctl cat "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 systemctl cat "$(service_unit_name "$name")" >/dev/null 2>&1
} }
stop_legacy_process() { has_full_systemd_suite() {
local pid local name
pid="$(read_pid || true)" for name in $(suite_service_names); do
if ! has_systemd_unit "$name"; then
if [ -n "${pid:-}" ] && kill -0 "$pid" >/dev/null 2>&1; then return 1
kill "$pid" >/dev/null 2>&1 || true
for _ in $(seq 1 10); do
if ! kill -0 "$pid" >/dev/null 2>&1; then
break
fi fi
sleep 1
done 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 return 0
fi
done < <(lsof -tiTCP:"$APP_PORT" -sTCP:LISTEN 2>/dev/null || true)
return 1
} }
pid_matches_project() { pid_matches_project() {
local pid="$1" local name="$1"
local pid="$2"
if [ -z "${pid:-}" ] || ! kill -0 "$pid" >/dev/null 2>&1; then if [ -z "${pid:-}" ] || ! kill -0 "$pid" >/dev/null 2>&1; then
return 1 return 1
fi fi
local command local command
command="$(ps -p "$pid" -o command= 2>/dev/null || true)" 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 return 1
fi 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 if command -v lsof >/dev/null 2>&1; then
local cwd local cwd
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)" 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 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"
}

40
scripts/install_systemd.sh Executable file → Normal file
View File

@ -5,23 +5,37 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source "$SCRIPT_DIR/common.sh" 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 if [ ! -f "$ENV_FILE" ]; then
echo "env file missing: $ENV_FILE" >&2 echo "env file missing: $ENV_FILE" >&2
exit 1 exit 1
fi 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 chmod +x "$ROOT_DIR"/scripts/*.sh
stop_legacy_process stop_all_legacy_processes
systemctl daemon-reload systemctl daemon-reload
systemctl enable "$SYSTEMD_SERVICE_NAME"
systemctl restart "$SYSTEMD_SERVICE_NAME" for name in $(suite_service_names); do
systemctl is-active "$SYSTEMD_SERVICE_NAME" systemctl enable "$(service_unit_name "$name")"
done
restart_systemd_suite

5
scripts/restart.sh Executable file → Normal file
View File

@ -5,9 +5,8 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
if has_systemd_unit; then if has_full_systemd_suite; then
systemctl restart "$SYSTEMD_SERVICE_NAME" restart_systemd_suite
systemctl is-active "$SYSTEMD_SERVICE_NAME"
exit 0 exit 0
fi fi

7
scripts/run_foreground.sh Executable file → Normal file
View File

@ -5,10 +5,5 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
if [ ! -f "$TARGETS_FILE" ]; then export_service_env gateway
echo "targets file not found: $TARGETS_FILE" >&2
exit 1
fi
export APP_BIND APP_PORT TARGETS_FILE PYTHONUNBUFFERED=1
exec "$PYTHON_BIN" "$ROOT_DIR/server.py" exec "$PYTHON_BIN" "$ROOT_DIR/server.py"

View File

@ -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"

View File

@ -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"

33
scripts/start.sh Executable file → Normal file
View File

@ -5,34 +5,11 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
if has_systemd_unit; then if has_full_systemd_suite; then
systemctl start "$SYSTEMD_SERVICE_NAME" start_systemd_suite
systemctl is-active "$SYSTEMD_SERVICE_NAME"
exit 0 exit 0
fi fi
if is_running; then start_local_service yumi
echo "hy-app-monitor already running pid=$(read_pid)" start_local_service haiyi
exit 0 start_local_service gateway
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

22
scripts/status.sh Executable file → Normal file
View File

@ -5,19 +5,17 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
if has_systemd_unit; then if has_full_systemd_suite; then
if systemctl is-active --quiet "$SYSTEMD_SERVICE_NAME"; then status_systemd_suite
echo "hy-app-monitor running under systemd port=$APP_PORT" exit $?
exit 0
fi
echo "hy-app-monitor stopped"
exit 1
fi fi
if is_running; then all_ok=0
echo "hy-app-monitor running pid=$(read_pid) port=$APP_PORT" name=""
exit 0 for name in $(suite_service_names); do
if ! status_local_service "$name"; then
all_ok=1
fi fi
done
echo "hy-app-monitor stopped" exit "$all_ok"
exit 1

32
scripts/stop.sh Executable file → Normal file
View File

@ -5,31 +5,13 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
if has_systemd_unit; then if has_full_systemd_suite; then
systemctl stop "$SYSTEMD_SERVICE_NAME" stop_systemd_suite
stop_legacy_process stop_all_legacy_processes
echo "hy-app-monitor stopped" echo "hy-app-monitor suite stopped"
exit 0 exit 0
fi fi
if ! is_running; then stop_local_service gateway
echo "hy-app-monitor not running" stop_local_service yumi
rm -f "$PID_FILE" stop_local_service haiyi
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"

16
scripts/uninstall_systemd.sh Executable file → Normal file
View File

@ -5,12 +5,20 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source "$SCRIPT_DIR/common.sh" 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 if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 || true
systemctl daemon-reload || true systemctl daemon-reload || true
fi fi
rm -f "$UNIT_DST" echo "systemd units removed"
echo "systemd unit removed: $UNIT_DST"

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from monitor.http_app import main from gateway.http_app import main
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -147,6 +147,72 @@ body.auth-page {
font-size: 12px; 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, .topbar,
.control-bar, .control-bar,
.panel { .panel {
@ -292,6 +358,11 @@ body.auth-page {
align-items: center; align-items: center;
} }
.release-section-nav {
flex: 1 1 auto;
min-width: 0;
}
.taskbar-context { .taskbar-context {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -319,6 +390,35 @@ body.auth-page {
flex-wrap: wrap; 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 { .control-group-spread {
justify-content: flex-end; justify-content: flex-end;
flex: 1 1 auto; flex: 1 1 auto;
@ -379,6 +479,7 @@ body.auth-page {
.refresh:focus-visible, .refresh:focus-visible,
.search:focus-visible, .search:focus-visible,
.interval-select:focus-visible, .interval-select:focus-visible,
.section-link:focus-visible,
.detail-log-input:focus-visible, .detail-log-input:focus-visible,
.config-item:focus-visible, .config-item:focus-visible,
.config-textarea:focus-visible { .config-textarea:focus-visible {
@ -959,6 +1060,14 @@ body.auth-page {
gap: 8px; gap: 8px;
} }
.section-caption-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.section-caption { .section-caption {
color: var(--muted); color: var(--muted);
font-size: 10px; font-size: 10px;
@ -967,6 +1076,16 @@ body.auth-page {
text-transform: uppercase; 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 { .host-row-list {
display: grid; display: grid;
gap: 6px; gap: 6px;
@ -1005,11 +1124,11 @@ body.auth-page {
.host-strip-main { .host-strip-main {
display: grid; display: grid;
grid-template-columns: minmax(164px, 208px) minmax(210px, 0.9fr) minmax(220px, 1.2fr) auto; grid-template-columns: minmax(180px, 220px) minmax(320px, 1fr) minmax(180px, 0.9fr) auto;
gap: 8px; gap: 10px;
align-items: center; align-items: center;
min-height: 48px; min-height: 56px;
padding: 7px 11px 7px 16px; padding: 8px 11px 8px 16px;
cursor: pointer; cursor: pointer;
} }
@ -1038,39 +1157,121 @@ body.auth-page {
} }
.compact-metrics { .compact-metrics {
gap: 10px; gap: 8px;
color: var(--muted); color: var(--muted);
font-size: 10px; font-size: 10px;
font-weight: 600; 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; white-space: nowrap;
} }
.host-alert-summary { .host-alert-summary {
display: flex; display: grid;
align-items: center; gap: 4px;
min-width: 0; 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 { .host-alert-path {
display: block; display: block;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
color: var(--muted); color: var(--text);
font-size: 10px; font-size: 11px;
font-weight: 600; font-weight: 600;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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)); color: color-mix(in oklab, var(--bad) 82%, var(--text));
} }
.host-alert-summary.is-quiet .host-alert-path { .host-alert-summary.tone-warn .host-alert-path {
color: color-mix(in oklab, var(--muted) 88%, transparent); 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, .metric-chip,
@ -1282,6 +1483,19 @@ body.auth-page {
.config-sidebar { .config-sidebar {
display: grid; display: grid;
gap: 10px; 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 { .config-sidebar-meta {
@ -1290,8 +1504,27 @@ body.auth-page {
align-items: center; 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 { .config-list-ide {
gap: 8px; gap: 10px;
padding-right: 4px;
} }
.config-group { .config-group {
@ -1328,7 +1561,9 @@ body.auth-page {
.config-group-items { .config-group-items {
display: grid; display: grid;
gap: 4px; gap: 2px;
padding-left: 10px;
border-left: 1px solid color-mix(in oklab, var(--line) 88%, transparent);
} }
.config-item-ide { .config-item-ide {
@ -1336,12 +1571,27 @@ body.auth-page {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
padding: 9px 10px; padding: 8px 9px 8px 12px;
border-radius: 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 { .config-item-ide strong {
font-size: 12px; font-size: 11px;
word-break: break-word; word-break: break-word;
} }
@ -1352,8 +1602,38 @@ body.auth-page {
} }
.config-item-ide small { .config-item-ide small {
grid-column: 1 / -1;
font-size: 10px; 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 { .editor-toolbar {
@ -1498,6 +1778,25 @@ body.auth-page {
z-index: 12; 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 { .release-status-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
@ -1554,6 +1853,71 @@ body.auth-page {
margin-top: 10px; 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 { .release-table td strong {
display: block; display: block;
font-size: 12px; font-size: 12px;
@ -1924,6 +2288,60 @@ body.auth-page {
word-break: break-word; 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 { .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -2017,7 +2435,8 @@ body.auth-page {
.infra-overview-grid, .infra-overview-grid,
.mongo-group-grid, .mongo-group-grid,
.release-status-grid { .release-status-grid,
.release-log-snippets {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@ -2036,6 +2455,11 @@ body.auth-page {
max-height: none; max-height: none;
} }
.config-sidebar {
position: static;
max-height: none;
}
.summary-cards { .summary-cards {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@ -2061,7 +2485,8 @@ body.auth-page {
.release-status-grid, .release-status-grid,
.infra-overview-grid, .infra-overview-grid,
.mongo-group-grid { .mongo-group-grid,
.release-log-snippets {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@ -2144,6 +2569,14 @@ body.auth-page {
width: 100%; width: 100%;
} }
.section-caption-row {
align-items: flex-start;
}
.entry-grid {
grid-template-columns: 1fr;
}
.host-service-pill, .host-service-pill,
.restart-pills .config-item { .restart-pills .config-item {
width: 100%; width: 100%;
@ -2153,7 +2586,12 @@ body.auth-page {
.taskbar-inline, .taskbar-inline,
.metadata-strip, .metadata-strip,
.precheck-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%; width: 100%;
text-align: left; text-align: left;
} }
@ -2163,6 +2601,21 @@ body.auth-page {
grid-template-columns: 1fr; 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 { .diff-row {
grid-template-columns: 34px 34px 22px minmax(0, 1fr); grid-template-columns: 34px 34px 22px minmax(0, 1fr);
gap: 8px; gap: 8px;

View File

@ -1,6 +1,7 @@
const { createApp } = Vue; const { createApp } = Vue;
const APP_TEMPLATE = document.getElementById("app")?.innerHTML || ""; 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 STORAGE_KEY = "hy-app-monitor-refresh-interval";
const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation"; const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation";
const GROUP_LABELS = { const GROUP_LABELS = {
@ -52,6 +53,20 @@ const INFRA_TABS = [
{ id: "tat", label: "TAT" }, { 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({ createApp({
template: APP_TEMPLATE, template: APP_TEMPLATE,
data() { data() {
@ -254,6 +269,10 @@ createApp({
exportMaxLines: 10000, exportMaxLines: 10000,
exporting: false, exporting: false,
}, },
logViewer: {
open: false,
kind: "",
},
timer: null, timer: null,
}; };
}, },
@ -297,6 +316,12 @@ createApp({
filteredHostRows() { filteredHostRows() {
return this.buildHostRows({ businessOnly: false, applyFilter: true, applyKeyword: true }); 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() { overviewHostRowGroups() {
return this.groupHosts(this.overviewHostRows); return this.groupHosts(this.overviewHostRows);
}, },
@ -422,6 +447,24 @@ createApp({
group.items.some((item) => this.configKey(item.group, item.dataId) === this.nacosConfig.selectedKey) group.items.some((item) => this.configKey(item.group, item.dataId) === this.nacosConfig.selectedKey)
)?.key || ""; )?.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() { configHistoryRows() {
const keyword = this.nacosConfig.keyword.trim().toLowerCase(); const keyword = this.nacosConfig.keyword.trim().toLowerCase();
return this.configHistory.filter((item) => ( return this.configHistory.filter((item) => (
@ -634,7 +677,7 @@ createApp({
}, },
{ {
label: "最近日志", label: "最近日志",
value: lastLog?.message || "-", value: lastLog?.message ? this.truncateMiddle(lastLog.message, 24, 14) : "-",
tone: lastLog?.level === "error" ? "bad" : "neutral", tone: lastLog?.level === "error" ? "bad" : "neutral",
}, },
]; ];
@ -837,6 +880,15 @@ createApp({
}) })
.join("\n"); .join("\n");
}, },
recentUpdateLogs() {
return (this.updateOps.logs || [])
.slice(-5)
.reverse()
.map((entry, index) => ({
...entry,
key: `${entry.time || "log"}-${entry.stage || "stage"}-${index}`,
}));
},
serviceLogDownloadUrl() { serviceLogDownloadUrl() {
if (!this.serviceLog.service || !this.serviceLog.host) { if (!this.serviceLog.service || !this.serviceLog.host) {
return ""; return "";
@ -854,6 +906,71 @@ createApp({
}); });
return `/api/ops/service-log/export?${query.toString()}`; 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: { watch: {
refreshIntervalSeconds() { refreshIntervalSeconds() {
@ -884,9 +1001,20 @@ createApp({
group: host.group, group: host.group,
label: this.groupLabel(host.group), label: this.groupLabel(host.group),
items: [], 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)); return [...groups.values()].sort((left, right) => this.groupRank(left.group) - this.groupRank(right.group));
}, },
@ -1059,9 +1187,50 @@ createApp({
} }
this.collapsedConfigGroups = [...next]; 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) { isConfigGroupCollapsed(groupKey) {
return this.collapsedConfigGroups.includes(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) { toggleNacosService(serviceName) {
const next = new Set(this.expandedNacosServices); const next = new Set(this.expandedNacosServices);
if (next.has(serviceName)) { if (next.has(serviceName)) {
@ -1138,6 +1307,47 @@ createApp({
this.selectedServiceKey = serviceKey; this.selectedServiceKey = serviceKey;
this.detailPanelOpen = true; 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() { clearServiceLog() {
this.serviceLog.loading = false; this.serviceLog.loading = false;
this.serviceLog.exporting = false; this.serviceLog.exporting = false;
@ -1156,13 +1366,21 @@ createApp({
return; return;
} }
if (!this.selectedServiceDetail || this.selectedServiceDetail.service !== this.serviceLog.service) { if (!this.selectedServiceDetail || this.selectedServiceDetail.service !== this.serviceLog.service) {
if (this.logViewer.kind === "service" && this.logViewer.open) {
this.closeLogViewer();
} else {
this.clearServiceLog(); this.clearServiceLog();
}
return; return;
} }
const exists = (this.selectedServiceDetail.instances || []).some((instance) => instance.host === this.serviceLog.host); const exists = (this.selectedServiceDetail.instances || []).some((instance) => instance.host === this.serviceLog.host);
if (!exists) { if (!exists) {
if (this.logViewer.kind === "service" && this.logViewer.open) {
this.closeLogViewer();
} else {
this.clearServiceLog(); this.clearServiceLog();
} }
}
}, },
isServiceLogActive(instance) { isServiceLogActive(instance) {
return this.serviceLog.service === (this.selectedServiceDetail?.service || "") && this.serviceLog.host === instance.host; return this.serviceLog.service === (this.selectedServiceDetail?.service || "") && this.serviceLog.host === instance.host;
@ -1174,6 +1392,8 @@ createApp({
return; return;
} }
this.logViewer.open = true;
this.logViewer.kind = "service";
this.serviceLog.loading = true; this.serviceLog.loading = true;
this.serviceLog.error = ""; this.serviceLog.error = "";
this.serviceLog.service = serviceName; this.serviceLog.service = serviceName;
@ -1199,6 +1419,7 @@ createApp({
Math.max(1, Number(this.serviceLog.exportLines || this.serviceLog.previewLines)), Math.max(1, Number(this.serviceLog.exportLines || this.serviceLog.previewLines)),
this.serviceLog.exportMaxLines, this.serviceLog.exportMaxLines,
); );
this.$nextTick(() => this.scrollUpdateLogToBottom());
} catch (error) { } catch (error) {
this.serviceLog.error = error instanceof Error ? error.message : String(error); this.serviceLog.error = error instanceof Error ? error.message : String(error);
} finally { } finally {
@ -1265,6 +1486,81 @@ createApp({
} }
return parts.filter(Boolean).join(" · "); 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 } = {}) { buildHostRows({ businessOnly = false, applyFilter = false, applyKeyword = false } = {}) {
const keyword = applyKeyword ? this.normalizedKeyword : ""; const keyword = applyKeyword ? this.normalizedKeyword : "";
@ -1286,10 +1582,11 @@ createApp({
const hasMetricIssue = metrics.ok === false || Boolean(metrics.error); const hasMetricIssue = metrics.ok === false || Boolean(metrics.error);
const alertServices = [...new Set(alertItems.map((item) => item.service))]; const alertServices = [...new Set(alertItems.map((item) => item.service))];
const alertSummary = alertServices.length > 0 const alertSummary = alertServices.length > 0
? alertServices.join(" / ") ? this.hostServiceSummary({ items, alertItems, hasMetricIssue }, { quiet: false })
: hasMetricIssue : hasMetricIssue
? "指标采集异常" ? "指标采集异常"
: "全部正常"; : "无服务异常";
const quietSummary = this.hostServiceSummary({ items, alertItems, hasMetricIssue }, { quiet: true });
const searchText = [ const searchText = [
host.host, host.host,
host.ip, host.ip,
@ -1313,6 +1610,7 @@ createApp({
items, items,
alertItems, alertItems,
alertSummary, alertSummary,
quietSummary,
groupLabel: this.groupLabel(host.group), groupLabel: this.groupLabel(host.group),
serviceTotal: items.length, serviceTotal: items.length,
downCount, downCount,
@ -1831,7 +2129,7 @@ createApp({
} }
window.sessionStorage.removeItem(UPDATE_OPERATION_STORAGE_KEY); window.sessionStorage.removeItem(UPDATE_OPERATION_STORAGE_KEY);
window.location.replace("/login"); window.location.replace(loginRedirectPath());
}, },
async authFetch(url, options = {}) { async authFetch(url, options = {}) {
const requestOptions = { const requestOptions = {
@ -1843,7 +2141,8 @@ createApp({
requestOptions.cache = "no-store"; 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 = {}) { async requestJson(url, options = {}) {
const response = await this.authFetch(url, options); const response = await this.authFetch(url, options);
@ -1860,10 +2159,7 @@ createApp({
this.auth.checking = true; this.auth.checking = true;
try { try {
const response = await fetch("/api/auth/session", { const response = await this.authFetch("/api/auth/session");
cache: "no-store",
credentials: "same-origin",
});
const payload = await this.readJsonResponse(response); const payload = await this.readJsonResponse(response);
if (response.status === 401) { if (response.status === 401) {
@ -2043,6 +2339,7 @@ createApp({
} }
this.nacosConfig.selectedKey = nextKey; 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; const sameAsCurrent = this.configKey(this.nacosConfig.editor.group, this.nacosConfig.editor.dataId) === nextKey;
if (!sameAsCurrent || reloadSelected || !this.nacosConfig.loadedContent) { if (!sameAsCurrent || reloadSelected || !this.nacosConfig.loadedContent) {
@ -2090,6 +2387,8 @@ createApp({
md5: item.md5 || "", md5: item.md5 || "",
content: this.nacosConfig.loadedContent, content: this.nacosConfig.loadedContent,
}; };
this.syncConfigExplorerState(configKey);
this.scrollSelectedConfigItemIntoView();
this.recordConfigHistory("nacos", "打开配置", `${item.group}/${item.dataId}`, "neutral"); this.recordConfigHistory("nacos", "打开配置", `${item.group}/${item.dataId}`, "neutral");
} catch (error) { } catch (error) {
this.nacosConfig.detailError = error instanceof Error ? error.message : String(error); this.nacosConfig.detailError = error instanceof Error ? error.message : String(error);

50
static/haiyi.html Normal file
View File

@ -0,0 +1,50 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HY App Monitor HaiYi</title>
<link rel="stylesheet" href="app.css" />
<script defer src="haiyi.js"></script>
</head>
<body class="auth-page">
<div id="boot-indicator" class="boot-indicator">加载中...</div>
<noscript>
<div class="boot-indicator">请启用 JavaScript</div>
</noscript>
<main id="haiyi-root" class="auth-shell" hidden>
<section class="auth-panel entry-panel">
<div class="auth-panel-head">
<div class="brand-row">
<p class="brand-mark">HY APP MONITOR</p>
<span class="env-pill">HAIYI</span>
</div>
<h1 class="auth-title">HaiYi</h1>
<div class="entry-meta">
<span id="haiyi-username" class="badge neutral mono" hidden></span>
<span id="haiyi-health" class="badge neutral">检查中</span>
</div>
</div>
<div class="entry-stack">
<div class="entry-card entry-card-static">
<strong>入口已预留</strong>
<span>一期仅接统一登录、选择页和健康检查。</span>
</div>
</div>
<div class="entry-actions">
<a class="refresh ghost entry-link" href="/select">返回入口</a>
<button id="haiyi-refresh" class="refresh" type="button">刷新</button>
<button id="haiyi-logout" class="refresh ghost" type="button">退出</button>
</div>
<p id="haiyi-error" class="auth-error" hidden aria-live="polite"></p>
</section>
</main>
</body>
</html>

129
static/haiyi.js Normal file
View File

@ -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");
}
});
}
});

View File

@ -5,9 +5,9 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HY App Monitor</title> <title>HY App Monitor</title>
<link rel="stylesheet" href="/app.css" /> <link rel="stylesheet" href="app.css" />
<script defer src="/vue.js"></script> <script defer src="vue.js"></script>
<script defer src="/app.js"></script> <script defer src="app.js"></script>
</head> </head>
<body> <body>
@ -190,11 +190,11 @@
<span>构建参数 / 更新目标 / 执行日志 / 滚动重启</span> <span>构建参数 / 更新目标 / 执行日志 / 滚动重启</span>
</div> </div>
<div class="control-group"> <div class="control-group release-section-nav">
<button <button
v-for="section in releaseSections" v-for="section in releaseSections"
:key="section.id" :key="section.id"
class="filter-chip" class="section-link"
@click="scrollToSection(section.id)" @click="scrollToSection(section.id)"
> >
{{ section.label }} {{ section.label }}
@ -202,20 +202,16 @@
</div> </div>
<div class="control-group control-group-spread"> <div class="control-group control-group-spread">
<div class="density-switch"> <label class="interval-field">
<button :class="['filter-chip', releaseKindFilter === 'all' ? 'active' : '']" @click="releaseKindFilter = 'all'">全部</button> <span>类型</span>
<button :class="['filter-chip', releaseKindFilter === 'java' ? 'active' : '']" @click="releaseKindFilter = 'java'">Java</button> <select v-model="releaseKindFilter" class="interval-select release-kind-select">
<button :class="['filter-chip', releaseKindFilter === 'golang' ? 'active' : '']" @click="releaseKindFilter = 'golang'">Golang</button> <option value="all">全部</option>
<button :class="['filter-chip', releaseKindFilter === 'frontend' ? 'active' : '']" @click="releaseKindFilter = 'frontend'">前端</button> <option value="java">Java</option>
</div> <option value="golang">Golang</option>
<option value="frontend">前端</option>
</select>
</label>
<input v-model.trim="releaseKeyword" class="search" placeholder="搜索服务 / 主机 / 镜像" /> <input v-model.trim="releaseKeyword" class="search" placeholder="搜索服务 / 主机 / 镜像" />
<button class="refresh ghost" @click="refreshReleaseTargets" :disabled="updateOps.loading || restartOps.loading || updateOps.running || restartOps.running">
刷新目标
</button>
<button class="refresh ghost" @click="runReleasePrecheck">预检查</button>
<button class="refresh danger" @click="runServiceUpdate" :disabled="updateOps.running || updateOps.selectedServices.length === 0">
{{ updateOps.running ? '更新中...' : `更新 ${updateOps.selectedServices.length} 项服务` }}
</button>
</div> </div>
</section> </section>
</template> </template>
@ -384,7 +380,7 @@
@click="openServiceLog(instance)" @click="openServiceLog(instance)"
:disabled="serviceLog.loading && isServiceLogActive(instance)" :disabled="serviceLog.loading && isServiceLogActive(instance)"
> >
{{ serviceLog.loading && isServiceLogActive(instance) ? '读取中...' : '日志' }} {{ serviceLog.loading && isServiceLogActive(instance) ? '读取中...' : '查看日志' }}
</button> </button>
</div> </div>
</div> </div>
@ -398,32 +394,6 @@
<div v-if="densityMode === 'detail'" class="detail-note">{{ instance.detail || '无返回内容' }}</div> <div v-if="densityMode === 'detail'" class="detail-note">{{ instance.detail || '无返回内容' }}</div>
</article> </article>
</div> </div>
<section v-if="serviceLog.service && serviceLog.host" class="detail-log-panel">
<header class="detail-subhead">
<strong>{{ serviceLog.service }} · {{ serviceLog.host }}</strong>
<span>{{ formatDateTime(serviceLog.updatedAt) }}</span>
</header>
<div class="detail-log-toolbar">
<span class="badge neutral mono" v-if="serviceLog.container">{{ serviceLog.container }}</span>
<span class="badge neutral">最近 {{ serviceLog.previewLines }} 条</span>
<label class="interval-field detail-log-field">
<span>导出</span>
<input v-model.number="serviceLog.exportLines" type="number" min="1" :max="serviceLog.exportMaxLines" class="detail-log-input" />
</label>
<button class="refresh ghost" @click="downloadServiceLog" :disabled="serviceLog.exporting">
{{ serviceLog.exporting ? '导出中...' : '导出' }}
</button>
<button class="refresh ghost" @click="openServiceLog({ host: serviceLog.host })" :disabled="serviceLog.loading">
{{ serviceLog.loading ? '刷新中...' : '刷新' }}
</button>
<button class="refresh ghost" @click="clearServiceLog">关闭</button>
</div>
<p v-if="serviceLog.error" class="metric-error">{{ serviceLog.error }}</p>
<pre class="detail-log-output mono">{{ serviceLog.content || '暂无日志' }}</pre>
</section>
</template> </template>
<div v-else class="empty-panel">当前没有服务详情</div> <div v-else class="empty-panel">当前没有服务详情</div>
@ -459,7 +429,13 @@
<div class="host-sections"> <div class="host-sections">
<section class="host-section"> <section class="host-section">
<div class="section-caption-row">
<div class="section-caption">异常主机</div> <div class="section-caption">异常主机</div>
<div class="section-caption-meta">
<span>{{ overviewAlertHosts.length }} 台</span>
<span>{{ overviewAlertHosts.filter((host) => host.hasMetricIssue).length }} 采集异常</span>
</div>
</div>
<div class="host-row-list"> <div class="host-row-list">
<article <article
v-for="host in overviewAlertHosts" v-for="host in overviewAlertHosts"
@ -477,14 +453,28 @@
</div> </div>
<div class="host-inline-metrics compact-metrics"> <div class="host-inline-metrics compact-metrics">
<span>CPU {{ formatPercent(host.metrics.cpuPercent) }}</span> <div
<span>MEM {{ formatPercent(host.metrics.memoryPercent) }}</span> v-for="metric in hostMetricEntries(host)"
<span>DISK {{ formatPercent(host.metrics.diskPercent) }}</span> :key="host.host + '-' + metric.key"
<span>PROC {{ host.metrics.processCount ?? '-' }}</span> :class="['metric-ring-chip', `tone-${metric.level}`]"
>
<span class="metric-ring" :style="metricRingStyle(metric.value, metric.level)">
<span class="metric-ring-hole"></span>
</span>
<span class="metric-ring-copy">
<small>{{ metric.label }}</small>
<strong>{{ metric.display }}</strong>
</span>
</div>
<span :class="['metric-stat-chip', `tone-${host.metrics.processLevel || thresholdTone('processCount', host.metrics.processCount)}`]">
<small>PROC</small>
<strong>{{ host.metrics.processCount ?? '-' }}</strong>
</span>
</div> </div>
<div :class="['host-alert-summary', host.hasAlert ? 'is-alert' : 'is-quiet']"> <div :class="['host-alert-summary', `tone-${hostSummaryTone(host)}`]">
<span class="host-alert-path">{{ host.alertSummary }}</span> <small>{{ host.downCount > 0 ? '异常服务' : host.hasMetricIssue ? '采集状态' : '在线服务' }}</small>
<span class="host-alert-path">{{ host.downCount > 0 ? host.alertSummary : host.quietSummary }}</span>
</div> </div>
<div class="host-inline-state"> <div class="host-inline-state">
@ -541,14 +531,28 @@
</div> </div>
<div class="host-inline-metrics compact-metrics"> <div class="host-inline-metrics compact-metrics">
<span>CPU {{ formatPercent(host.metrics.cpuPercent) }}</span> <div
<span>MEM {{ formatPercent(host.metrics.memoryPercent) }}</span> v-for="metric in hostMetricEntries(host)"
<span>DISK {{ formatPercent(host.metrics.diskPercent) }}</span> :key="'quiet-' + host.host + '-' + metric.key"
<span>PROC {{ host.metrics.processCount ?? '-' }}</span> :class="['metric-ring-chip', `tone-${metric.level}`]"
>
<span class="metric-ring" :style="metricRingStyle(metric.value, metric.level)">
<span class="metric-ring-hole"></span>
</span>
<span class="metric-ring-copy">
<small>{{ metric.label }}</small>
<strong>{{ metric.display }}</strong>
</span>
</div>
<span :class="['metric-stat-chip', `tone-${host.metrics.processLevel || thresholdTone('processCount', host.metrics.processCount)}`]">
<small>PROC</small>
<strong>{{ host.metrics.processCount ?? '-' }}</strong>
</span>
</div> </div>
<div class="host-alert-summary is-quiet"> <div class="host-alert-summary tone-neutral">
<span class="host-alert-path">{{ host.alertSummary }}</span> <small>在线服务</small>
<span class="host-alert-path">{{ host.quietSummary }}</span>
</div> </div>
<div class="host-inline-state"> <div class="host-inline-state">
@ -580,13 +584,21 @@
<p>{{ filteredHostRows.length }} 台主机</p> <p>{{ filteredHostRows.length }} 台主机</p>
</div> </div>
<div class="panel-meta"> <div class="panel-meta">
<span class="badge neutral">点击主机展开服务</span> <span class="badge neutral">{{ filteredHostAlertTotal }} 台异常</span>
<span :class="['badge', filteredHostMetricIssueTotal > 0 ? 'warn' : 'neutral']">{{ filteredHostMetricIssueTotal }} 采集异常</span>
</div> </div>
</header> </header>
<div class="host-sections"> <div class="host-sections">
<section v-for="section in hostRowGroups" :key="section.group" class="host-section"> <section v-for="section in hostRowGroups" :key="section.group" class="host-section">
<div class="section-caption-row">
<div class="section-caption">{{ section.label }}</div> <div class="section-caption">{{ section.label }}</div>
<div class="section-caption-meta">
<span>{{ section.items.length }} 台</span>
<span>{{ section.serviceCount }} 服务</span>
<span>{{ section.alertCount }} 异常主机</span>
</div>
</div>
<div class="host-row-list"> <div class="host-row-list">
<article <article
v-for="host in section.items" v-for="host in section.items"
@ -604,14 +616,28 @@
</div> </div>
<div class="host-inline-metrics compact-metrics"> <div class="host-inline-metrics compact-metrics">
<span>CPU {{ formatPercent(host.metrics.cpuPercent) }}</span> <div
<span>MEM {{ formatPercent(host.metrics.memoryPercent) }}</span> v-for="metric in hostMetricEntries(host)"
<span>DISK {{ formatPercent(host.metrics.diskPercent) }}</span> :key="'host-' + host.host + '-' + metric.key"
<span>PROC {{ host.metrics.processCount ?? '-' }}</span> :class="['metric-ring-chip', `tone-${metric.level}`]"
>
<span class="metric-ring" :style="metricRingStyle(metric.value, metric.level)">
<span class="metric-ring-hole"></span>
</span>
<span class="metric-ring-copy">
<small>{{ metric.label }}</small>
<strong>{{ metric.display }}</strong>
</span>
</div>
<span :class="['metric-stat-chip', `tone-${host.metrics.processLevel || thresholdTone('processCount', host.metrics.processCount)}`]">
<small>PROC</small>
<strong>{{ host.metrics.processCount ?? '-' }}</strong>
</span>
</div> </div>
<div :class="['host-alert-summary', host.hasAlert ? 'is-alert' : 'is-quiet']"> <div :class="['host-alert-summary', `tone-${hostSummaryTone(host)}`]">
<span class="host-alert-path">{{ host.alertSummary }}</span> <small>{{ host.downCount > 0 ? '异常服务' : host.hasMetricIssue ? '采集状态' : '在线服务' }}</small>
<span class="host-alert-path">{{ host.downCount > 0 ? host.alertSummary : host.quietSummary }}</span>
</div> </div>
<div class="host-inline-state"> <div class="host-inline-state">
@ -1152,8 +1178,15 @@
<div class="config-layout"> <div class="config-layout">
<aside class="config-sidebar"> <aside class="config-sidebar">
<div class="config-sidebar-head">
<div class="config-sidebar-meta"> <div class="config-sidebar-meta">
<span class="badge neutral mono">{{ truncateMiddle(nacosConfig.baseUrl, 16, 12) || '-' }}</span> <span class="badge neutral mono">{{ truncateMiddle(nacosConfig.baseUrl, 16, 12) || '-' }}</span>
<span class="badge neutral">{{ nacosConfigGroups.length }} 组</span>
</div>
<div class="config-sidebar-path" v-if="nacosConfig.editor.dataId">
<strong>{{ nacosConfig.editor.dataId }}</strong>
<span>{{ nacosConfig.editor.group }}</span>
</div>
</div> </div>
<div class="config-list config-list-ide"> <div class="config-list config-list-ide">
@ -1168,12 +1201,17 @@
<button <button
v-for="item in group.items" v-for="item in group.items"
:key="configKey(item.group, item.dataId)" :key="configKey(item.group, item.dataId)"
:id="`config-item-${configKey(item.group, item.dataId)}`"
:class="['config-item', 'config-item-ide', configKey(item.group, item.dataId) === nacosConfig.selectedKey ? 'active' : '']" :class="['config-item', 'config-item-ide', configKey(item.group, item.dataId) === nacosConfig.selectedKey ? 'active' : '']"
@click="openNacosConfig(item)" @click="openNacosConfig(item)"
> >
<div class="config-item-main">
<strong>{{ item.dataId }}</strong> <strong>{{ item.dataId }}</strong>
<span>{{ item.type || 'text' }}</span>
<small v-if="item.appName">{{ item.appName }}</small> <small v-if="item.appName">{{ item.appName }}</small>
</div>
<div class="config-item-side">
<span>{{ item.type || 'text' }}</span>
</div>
</button> </button>
</div> </div>
</section> </section>
@ -1198,6 +1236,11 @@
<button :class="['filter-chip', configEditorMode === 'diff' ? 'active' : '']" @click="configEditorMode = 'diff'">Diff</button> <button :class="['filter-chip', configEditorMode === 'diff' ? 'active' : '']" @click="configEditorMode = 'diff'">Diff</button>
<button :class="['filter-chip', configEditorMode === 'source' ? 'active' : '']" @click="configEditorMode = 'source'">源码</button> <button :class="['filter-chip', configEditorMode === 'source' ? 'active' : '']" @click="configEditorMode = 'source'">源码</button>
</div> </div>
<div class="config-jump-group">
<button class="refresh ghost" @click="openPrevNacosConfig" :disabled="!selectedPrevNacosConfigKey || nacosConfig.detailLoading || nacosConfig.saving">上一项</button>
<button class="refresh ghost" @click="openNextNacosConfig" :disabled="!selectedNextNacosConfigKey || nacosConfig.detailLoading || nacosConfig.saving">下一项</button>
</div>
</div> </div>
<div class="action-group"> <div class="action-group">
@ -1362,6 +1405,28 @@
<span class="badge neutral">已选 {{ restartOps.selectedServices.length }} 项重启</span> <span class="badge neutral">已选 {{ restartOps.selectedServices.length }} 项重启</span>
</div> </div>
</header> </header>
<div class="release-action-bar">
<div class="release-action-summary">
<span class="badge neutral">更新目标 {{ releaseTargetRows.length }}</span>
<span class="badge neutral">重启目标 {{ restartTargetRows.length }}</span>
<span :class="['badge', updateOps.error || restartOps.error ? 'bad' : releasePrecheck.ok ? 'ok' : 'neutral']">
{{ releasePrecheck.ranAt ? releasePrecheck.title : '未预检查' }}
</span>
</div>
<div class="action-group">
<button class="refresh ghost" @click="refreshReleaseTargets" :disabled="updateOps.loading || restartOps.loading || updateOps.running || restartOps.running">
{{ updateOps.loading || restartOps.loading ? '刷新中...' : '刷新目标' }}
</button>
<button class="refresh ghost" @click="runReleasePrecheck" :disabled="updateOps.running || restartOps.running">预检查</button>
<button class="refresh ghost" @click="openUpdateLogViewer" :disabled="!updateOps.operationId && !updateOps.logs.length">
查看日志
</button>
<button class="refresh danger" @click="runServiceUpdate" :disabled="updateOps.running || updateOps.selectedServices.length === 0">
{{ updateOps.running ? '更新中...' : `执行更新 ${updateOps.selectedServices.length}` }}
</button>
</div>
</div>
</section> </section>
<section class="panel release-status-strip sticky-release-strip"> <section class="panel release-status-strip sticky-release-strip">
@ -1433,13 +1498,11 @@
<header class="panel-header"> <header class="panel-header">
<div class="panel-title"> <div class="panel-title">
<h2>更新目标</h2> <h2>更新目标</h2>
<p>紧凑表格,长镜像悬停查看</p> <p>紧凑表格</p>
</div> </div>
<div class="panel-meta"> <div class="panel-meta">
<span class="badge neutral">{{ releaseTargetRows.length }} 项</span> <span class="badge neutral">{{ releaseTargetRows.length }} 项</span>
<button class="refresh ghost" @click="refreshUpdateTargets" :disabled="updateOps.loading || updateOps.running"> <span class="badge neutral">已选 {{ updateOps.selectedServices.length }} 项</span>
{{ updateOps.loading ? '加载中...' : '仅刷新更新目标' }}
</button>
</div> </div>
</header> </header>
@ -1501,22 +1564,12 @@
<header class="panel-header"> <header class="panel-header">
<div class="panel-title"> <div class="panel-title">
<h2>执行日志</h2> <h2>执行日志</h2>
<p>任务、阶段、结果闭环</p> <p>页内只看摘要,完整日志弹出</p>
</div> </div>
<div class="panel-meta"> <div class="panel-meta">
<span v-if="updateOps.operationId" class="badge neutral mono">任务 {{ updateOps.operationId }}</span> <span v-if="updateOps.operationId" class="badge neutral mono">任务 {{ updateOps.operationId }}</span>
<span class="badge neutral mono">阶段 {{ updateOps.stage || '-' }}</span> <span class="badge neutral mono">阶段 {{ updateOps.stage || '-' }}</span>
<button class="refresh ghost" @click="fetchUpdateOperationStatus(updateOps.operationId)" :disabled="!updateOps.operationId || updateOps.running">刷新日志</button> <button class="refresh ghost" @click="openUpdateLogViewer" :disabled="!updateOps.operationId && !updateOps.logs.length">查看日志</button>
<a
v-if="updateOps.operationId"
class="refresh ghost"
:href="updateLogDownloadUrl()"
target="_blank"
rel="noopener"
download
>
下载日志
</a>
</div> </div>
</header> </header>
@ -1527,8 +1580,19 @@
<span>影响服务 {{ updateAffectedServicesCount }}</span> <span>影响服务 {{ updateAffectedServicesCount }}</span>
</div> </div>
<section class="update-log-panel"> <section class="release-log-preview">
<pre id="service-update-log-output" class="update-log-output mono">{{ updateLogText || '等待更新日志...' }}</pre> <div class="release-log-preview-head">
<strong>{{ updateOps.logs.length }} 条日志</strong>
<span v-if="updateOps.logDroppedCount > 0" class="badge warn">截断 {{ updateOps.logDroppedCount }}</span>
</div>
<div v-if="recentUpdateLogs.length > 0" class="release-log-snippets">
<article v-for="entry in recentUpdateLogs" :key="entry.key" :class="['release-log-snippet', entry.level === 'error' ? 'tone-bad' : 'tone-neutral']">
<strong>{{ formatDateTime(entry.time) }}</strong>
<span>{{ entry.stage || '-' }}</span>
<p>{{ entry.message || '-' }}</p>
</article>
</div>
<div v-else class="empty-panel compact">当前还没有可展示的执行日志</div>
</section> </section>
<div class="activity-list compact" v-if="releaseHistoryRows.length > 0"> <div class="activity-list compact" v-if="releaseHistoryRows.length > 0">
@ -1632,17 +1696,14 @@
<header class="panel-header"> <header class="panel-header">
<div class="panel-title"> <div class="panel-title">
<h2>滚动重启</h2> <h2>滚动重启</h2>
<p>紧凑选择表</p> <p>按服务勾选执行</p>
</div> </div>
<div class="panel-meta"> <div class="panel-meta">
<span :class="['badge', restartOps.error ? 'bad' : restartOps.running ? 'warn' : 'ok']"> <span :class="['badge', restartOps.error ? 'bad' : restartOps.running ? 'warn' : 'ok']">
{{ restartOps.error ? '存在错误' : restartOps.running ? '执行中' : '可操作' }} {{ restartOps.error ? '存在错误' : restartOps.running ? '执行中' : '可操作' }}
</span> </span>
<button class="refresh ghost" @click="refreshRestartTargets" :disabled="restartOps.loading || restartOps.running">
{{ restartOps.loading ? '加载中...' : '仅刷新重启目标' }}
</button>
<button class="refresh danger" @click="runRollingRestart" :disabled="restartOps.running || restartOps.selectedServices.length === 0"> <button class="refresh danger" @click="runRollingRestart" :disabled="restartOps.running || restartOps.selectedServices.length === 0">
{{ restartOps.running ? '重启中...' : `滚动重启 ${restartOps.selectedServices.length} 项服务` }} {{ restartOps.running ? '重启中...' : `执行重启 ${restartOps.selectedServices.length}` }}
</button> </button>
</div> </div>
</header> </header>
@ -1722,6 +1783,38 @@
</details> </details>
</section> </section>
</template> </template>
<div v-if="logViewer.open" class="log-viewer-overlay" @click.self="closeLogViewer">
<section class="log-viewer" role="dialog" aria-modal="true" :aria-labelledby="'log-viewer-title'">
<header class="log-viewer-head">
<div class="panel-title">
<h2 id="log-viewer-title">{{ activeLogViewerTitle }}</h2>
<p>{{ activeLogViewerSubtitle }}</p>
</div>
<div class="action-group">
<button class="refresh ghost" @click="refreshActiveLogViewer" :disabled="activeLogViewerRefreshDisabled">
{{ activeLogViewerLoading ? '刷新中...' : '刷新' }}
</button>
<button class="refresh ghost" @click="downloadActiveLogViewer" :disabled="activeLogViewerDownloadDisabled">
{{ activeLogViewerExporting ? '导出中...' : '导出' }}
</button>
<button class="refresh ghost" @click="closeLogViewer">关闭</button>
</div>
</header>
<div class="log-viewer-toolbar">
<span v-if="activeLogViewerBadge" class="badge neutral mono">{{ activeLogViewerBadge }}</span>
<span v-if="activeLogViewerSecondaryBadge" class="badge neutral">{{ activeLogViewerSecondaryBadge }}</span>
<label v-if="logViewer.kind === 'service'" class="interval-field detail-log-field">
<span>导出</span>
<input v-model.number="serviceLog.exportLines" type="number" min="1" :max="serviceLog.exportMaxLines" class="detail-log-input" />
</label>
</div>
<p v-if="activeLogViewerError" class="metric-error">{{ activeLogViewerError }}</p>
<pre id="service-update-log-output" class="log-viewer-output mono">{{ activeLogViewerContent || '暂无日志' }}</pre>
</section>
</div>
</div> </div>
</body> </body>

View File

@ -5,8 +5,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HY App Monitor Login</title> <title>HY App Monitor Login</title>
<link rel="stylesheet" href="/app.css" /> <link rel="stylesheet" href="app.css" />
<script defer src="/login.js"></script> <script defer src="login.js"></script>
</head> </head>
<body class="auth-page"> <body class="auth-page">

View File

@ -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 () => { window.addEventListener("DOMContentLoaded", async () => {
const bootIndicator = document.getElementById("boot-indicator"); const bootIndicator = document.getElementById("boot-indicator");
const root = document.getElementById("login-root"); const root = document.getElementById("login-root");
@ -42,7 +51,7 @@ window.addEventListener("DOMContentLoaded", async () => {
credentials: "same-origin", credentials: "same-origin",
}); });
if (response.ok) { if (response.ok) {
window.location.replace("/"); window.location.replace(nextLocation());
return; return;
} }
@ -83,7 +92,7 @@ window.addEventListener("DOMContentLoaded", async () => {
if (!response.ok) { if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`); throw new Error(payload.error || `HTTP ${response.status}`);
} }
window.location.replace("/"); window.location.replace(nextLocation());
} catch (error) { } catch (error) {
setError(error instanceof Error ? error.message : String(error)); setError(error instanceof Error ? error.message : String(error));
} finally { } finally {

51
static/select.html Normal file
View File

@ -0,0 +1,51 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HY App Monitor Entry</title>
<link rel="stylesheet" href="app.css" />
<script defer src="select.js"></script>
</head>
<body class="auth-page">
<div id="boot-indicator" class="boot-indicator">检查登录态...</div>
<noscript>
<div class="boot-indicator">请启用 JavaScript</div>
</noscript>
<main id="select-root" class="auth-shell" hidden>
<section class="auth-panel entry-panel">
<div class="auth-panel-head">
<div class="brand-row">
<p class="brand-mark">HY APP MONITOR</p>
<span class="env-pill">DEPLOY</span>
</div>
<h1 class="auth-title">选择入口</h1>
<div class="entry-meta">
<span id="select-username" class="badge neutral mono" hidden></span>
</div>
</div>
<div class="entry-grid">
<a class="entry-card" href="/yumi/">
<strong>Yumi</strong>
<span>监控 / 配置 / 发布</span>
</a>
<a class="entry-card" href="/haiyi/">
<strong>HaiYi</strong>
<span>入口已预留</span>
</a>
</div>
<div class="entry-actions">
<button id="select-logout" class="refresh ghost" type="button">退出</button>
</div>
<p id="select-error" class="auth-error" hidden aria-live="polite"></p>
</section>
</main>
</body>
</html>

82
static/select.js Normal file
View File

@ -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");
}
});
});

View File

@ -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

View File

@ -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

View File

@ -1,7 +1,7 @@
[Unit] [Unit]
Description=HY App Monitor Description=HY App Monitor Gateway
After=network-online.target After=network-online.target hy-app-monitor-yumi.service hy-app-monitor-haiyi.service
Wants=network-online.target Wants=network-online.target hy-app-monitor-yumi.service hy-app-monitor-haiyi.service
[Service] [Service]
Type=simple Type=simple

18
yumi_server.py Normal file
View File

@ -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()