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_PORT=2026
YUMI_INTERNAL_HOST=127.0.0.1
YUMI_INTERNAL_PORT=2027
HAIYI_INTERNAL_HOST=127.0.0.1
HAIYI_INTERNAL_PORT=2028
INTERNAL_PROXY_SHARED_SECRET=change-me
GATEWAY_PROXY_TIMEOUT_SECONDS=600
AUTH_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3
AUTH_SESSION_TTL_HOURS=24
AUTH_COOKIE_SECURE=0

View File

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

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"
# HTTP 服务监听端口。
APP_PORT = env_int("APP_PORT", 2026)
# 当前 HTTP 应用挂载前缀原始值。
RAW_APP_BASE_PATH = env_str("APP_BASE_PATH", "").strip()
# 当前 HTTP 应用挂载前缀;空串表示直接挂在根路径。
APP_BASE_PATH = "/" + RAW_APP_BASE_PATH.strip("/") if RAW_APP_BASE_PATH else ""
# Yumi 内部实例固定走本机回环地址。
YUMI_INTERNAL_HOST = env_str("YUMI_INTERNAL_HOST", "127.0.0.1") or "127.0.0.1"
# Yumi 内部实例监听端口。
YUMI_INTERNAL_PORT = env_int("YUMI_INTERNAL_PORT", 2027)
# HaiYi 内部实例固定走本机回环地址。
HAIYI_INTERNAL_HOST = env_str("HAIYI_INTERNAL_HOST", "127.0.0.1") or "127.0.0.1"
# HaiYi 内部实例监听端口。
HAIYI_INTERNAL_PORT = env_int("HAIYI_INTERNAL_PORT", 2028)
# 网关反代到内部实例时统一带这一段共享密钥。
INTERNAL_PROXY_SHARED_SECRET = env_str("INTERNAL_PROXY_SHARED_SECRET", "hy-app-monitor-internal")
# 网关反代内部实例时的统一超时秒数。
GATEWAY_PROXY_TIMEOUT_SECONDS = env_int("GATEWAY_PROXY_TIMEOUT_SECONDS", 600)
# 本地账号库统一落到 run 目录,避免写入版本库。
AUTH_DB_PATH = Path(env_str("AUTH_DB_PATH", str(RUNTIME_DIR / "auth.sqlite3"))).expanduser()
# 会话 Cookie 名称固定成单值,前后端都按这个名字取。

View File

@ -1,24 +1,15 @@
from __future__ import annotations
import ipaddress
import json
import time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
from .auth import (
build_clear_session_cookie,
build_session_cookie,
create_session,
delete_session,
ensure_auth_store,
get_session,
parse_session_token,
authenticate_user,
)
from .config import APP_BIND, APP_PORT, STATIC_DIR, utc_now
from .config import APP_BASE_PATH, APP_BIND, APP_PORT, INTERNAL_PROXY_SHARED_SECRET, STATIC_DIR, utc_now
from .dashboard import build_monitor_payload
from .http_paths import join_base_path, strip_base_path
from .nacos import (
get_nacos_config_list_payload,
get_nacos_config_payload,
@ -38,90 +29,96 @@ def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
class MonitorHandler(BaseHTTPRequestHandler):
# 自定义服务名,便于 curl 时识别来源
server_version = "HyAppMonitor/2.0"
# 单独标出这是 Yumi 内部实例,排查反代链路更直接
server_version = "HyAppMonitorYumi/2.0"
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
# 关闭默认访问日志,避免轮询刷屏
# 内部轮询很多,关闭默认访问日志。
return
def current_session_token(self) -> str:
# 同一个请求对象里只解析一次 Cookie。
if not hasattr(self, "_cached_session_token"):
self._cached_session_token = parse_session_token(self.headers.get("Cookie"))
return str(self._cached_session_token)
def current_session(self) -> Any:
# 同一个请求对象里也只查一次会话库。
if not hasattr(self, "_cached_session"):
self._cached_session = get_session(self.current_session_token())
return self._cached_session
def do_GET(self) -> None: # noqa: N802
# GET 请求需要响应头和响应体。
# 只接受来自统一网关的代理请求。
if not self.require_internal_proxy(send_body=True):
return
self.dispatch(send_body=True)
def do_HEAD(self) -> None: # noqa: N802
# HEAD 请求只返回响应头。
# HEAD 请求同样只允许内部网关进入。
if not self.require_internal_proxy(send_body=False):
return
self.dispatch(send_body=False)
def do_POST(self) -> None: # noqa: N802
# POST 请求主要用于配置保存。
# POST 接口也必须经过统一入口。
if not self.require_internal_proxy(send_body=True):
return
self.dispatch_post()
def require_internal_proxy(self, send_body: bool) -> bool:
# 内部实例除了共享密钥,还要求请求确实来自本机回环地址。
# 这样就算运维误把端口绑到了 0.0.0.0,外部流量也不能直接绕过网关。
actual_secret = str(self.headers.get("X-HY-Internal-Proxy-Secret") or "").strip()
if self.is_loopback_client() and actual_secret == INTERNAL_PROXY_SHARED_SECRET:
return True
self.send_error_payload(
HTTPStatus.FORBIDDEN,
"internal proxy authorization required",
send_body=send_body,
)
return False
def is_loopback_client(self) -> bool:
# BaseHTTPRequestHandler 会把来源地址放在 client_address 第一个槽位。
remote_host = str((self.client_address or ("", 0))[0]).strip()
if not remote_host:
return False
try:
# IPv6 链路本地格式可能带 zone index这里先切掉再判断。
return ipaddress.ip_address(remote_host.split("%", 1)[0]).is_loopback
except ValueError:
return False
def dispatch(self, send_body: bool) -> None:
# 只解析 URL 的 path 段。
# 统一先拆 URL后面都只操作 path 和 query
parsed = urlparse(self.path)
# 顺手解析 query 参数,后面接口直接取。
query = parse_qs(parsed.query, keep_blank_values=True)
local_path = strip_base_path(APP_BASE_PATH, parsed.path)
# 登录页对未登录用户开放,已登录时直接回总览。
if parsed.path in {"/login", "/login.html"}:
if self.current_session():
return self.redirect("/", send_body=send_body)
return self.serve_static("login.html", "text/html; charset=utf-8", send_body=send_body)
# 不属于当前应用 base path 的请求直接 404。
if local_path is None:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
# 总览页需要先过登录校验,未登录统一跳到登录页。
if parsed.path in {"/", "/index.html"}:
if not self.current_session():
return self.redirect("/login", send_body=send_body)
# /yumi 必须补成 /yumi/,这样相对静态资源路径才稳定。
if APP_BASE_PATH and parsed.path == APP_BASE_PATH:
return self.redirect(join_base_path(APP_BASE_PATH, "/"), send_body=send_body)
# Yumi 首页直接返回当前监控面板。
if local_path in {"/", "/index.html"}:
return self.serve_static("index.html", "text/html; charset=utf-8", send_body=send_body)
# CSS 文件。
if parsed.path == "/app.css":
# 前端静态资源统一挂在应用前缀下
if local_path == "/app.css":
return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body)
# 登录页脚本。
if parsed.path == "/login.js":
return self.serve_static("login.js", "application/javascript; charset=utf-8", send_body=send_body)
# 前端业务 JS。
if parsed.path == "/app.js":
if local_path == "/app.js":
return self.serve_static("app.js", "application/javascript; charset=utf-8", send_body=send_body)
# 本地 Vue 运行时。
if parsed.path == "/vue.js":
if local_path == "/vue.js":
return self.serve_static("vue.js", "application/javascript; charset=utf-8", send_body=send_body)
# 会话查询接口单独开放给登录页和主页面做状态判断。
if parsed.path == "/api/auth/session":
return self.handle_auth_session(send_body=send_body)
# 除鉴权接口外,其余 API 一律要求带有效会话。
if parsed.path.startswith("/api/") and not self.require_authentication(send_body=send_body):
return
# 轻量健康检查。
if parsed.path == "/api/health":
# 内部实例自身的健康检查。
if local_path == "/api/health":
return self.send_payload(
HTTPStatus.OK,
response_json({"status": "ok", "time": utc_now()}),
response_json({"status": "ok", "time": utc_now(), "app": "yumi"}),
"application/json; charset=utf-8",
send_body=send_body,
)
# 主监控接口。
if parsed.path == "/api/monitor/services":
if local_path == "/api/monitor/services":
return self.send_payload(
HTTPStatus.OK,
response_json(build_monitor_payload()),
@ -130,7 +127,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
)
# 返回当前 namespace 下全部 Nacos 配置元数据。
if parsed.path == "/api/nacos/configs":
if local_path == "/api/nacos/configs":
try:
payload = get_nacos_config_list_payload()
except Exception as exc: # noqa: BLE001
@ -144,7 +141,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
)
# 返回单个配置的原始文本内容。
if parsed.path == "/api/nacos/config":
if local_path == "/api/nacos/config":
group_name = self.query_value(query, "group")
data_id = self.query_value(query, "dataId")
@ -168,7 +165,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
)
# 返回当前线上 golang 的运行配置。
if parsed.path == "/api/runtime/golang-config":
if local_path == "/api/runtime/golang-config":
try:
payload = get_go_config_payload()
except Exception as exc: # noqa: BLE001
@ -182,7 +179,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
)
# 返回可滚动重启的业务服务。
if parsed.path == "/api/ops/restart-targets":
if local_path == "/api/ops/restart-targets":
try:
payload = build_restart_targets_payload()
except Exception as exc: # noqa: BLE001
@ -196,7 +193,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
)
# 返回可构建并发布的业务服务。
if parsed.path == "/api/ops/update-targets":
if local_path == "/api/ops/update-targets":
try:
payload = build_update_targets_payload()
except Exception as exc: # noqa: BLE001
@ -210,7 +207,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
)
# 返回当前服务更新任务的阶段和日志。
if parsed.path == "/api/ops/update-status":
if local_path == "/api/ops/update-status":
operation_id = self.query_value(query, "id")
if not operation_id:
return self.send_error_payload(
@ -234,7 +231,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
)
# 返回单个服务更新任务的完整文本日志。
if parsed.path == "/api/ops/update-log":
if local_path == "/api/ops/update-log":
operation_id = self.query_value(query, "id")
if not operation_id:
return self.send_error_payload(
@ -258,7 +255,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
)
# 返回单个服务实例最近 1000 条容器日志。
if parsed.path == "/api/ops/service-log":
if local_path == "/api/ops/service-log":
service_name = self.query_value(query, "service")
host_name = self.query_value(query, "host")
if not service_name or not host_name:
@ -283,7 +280,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
)
# 导出单个服务实例指定条数的容器日志。
if parsed.path == "/api/ops/service-log/export":
if local_path == "/api/ops/service-log/export":
service_name = self.query_value(query, "service")
host_name = self.query_value(query, "host")
lines = self.query_value(query, "lines")
@ -313,7 +310,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
)
# favicon 直接返回空。
if parsed.path == "/favicon.ico":
if local_path == "/favicon.ico":
return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body)
# 其他路径统一 404。
@ -322,137 +319,36 @@ class MonitorHandler(BaseHTTPRequestHandler):
def dispatch_post(self) -> None:
# 只解析 path 段POST 不处理复杂 query。
parsed = urlparse(self.path)
local_path = strip_base_path(APP_BASE_PATH, parsed.path)
# 退出接口允许空请求体,先单独处理。
if parsed.path == "/api/auth/logout":
return self.handle_logout()
# 登录接口对未登录用户开放,但要求标准 JSON 请求体。
if parsed.path == "/api/auth/login":
try:
payload = self.read_json_body()
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
return self.handle_login(payload)
# 其余 POST 接口都要求先通过会话校验。
if not self.require_authentication(send_body=True):
return
if local_path is None:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
try:
payload = self.read_json_body()
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
if parsed.path == "/api/nacos/config/validate":
if local_path == "/api/nacos/config/validate":
return self.handle_nacos_validate(payload)
if parsed.path == "/api/nacos/config":
if local_path == "/api/nacos/config":
return self.handle_nacos_save(payload)
if parsed.path == "/api/runtime/golang-config/validate":
if local_path == "/api/runtime/golang-config/validate":
return self.handle_go_validate(payload)
if parsed.path == "/api/runtime/golang-config":
if local_path == "/api/runtime/golang-config":
return self.handle_go_save(payload)
if parsed.path == "/api/ops/rolling-restart":
if local_path == "/api/ops/rolling-restart":
return self.handle_rolling_restart(payload)
if parsed.path == "/api/ops/update-services":
if local_path == "/api/ops/update-services":
return self.handle_update_services(payload)
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
def handle_auth_session(self, send_body: bool) -> None:
session = self.current_session()
# 未登录时返回 401前端据此回到登录页。
if session is None:
return self.send_error_payload(
HTTPStatus.UNAUTHORIZED,
"authentication required",
send_body=send_body,
extra_headers=self.auth_cookie_cleanup_headers(),
)
return self.send_payload(
HTTPStatus.OK,
response_json(self.session_payload(session)),
"application/json; charset=utf-8",
send_body=send_body,
)
def handle_login(self, payload: dict[str, Any]) -> None:
username = str(payload.get("username") or "").strip()
password = payload.get("password")
# 登录接口要求账号密码都以字符串传入。
if not isinstance(password, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "password must be a string", send_body=True)
canonical_username = authenticate_user(username, password)
if not canonical_username:
return self.send_error_payload(HTTPStatus.UNAUTHORIZED, "invalid username or password", send_body=True)
# 同一浏览器重复登录时,先清掉旧 token 对应会话。
delete_session(self.current_session_token())
session = create_session(
canonical_username,
remote_addr=self.client_address[0] if self.client_address else "",
user_agent=self.headers.get("User-Agent", ""),
)
cookie_header = build_session_cookie(
session.token,
max_age=max(1, session.expires_at_ts - int(time.time())),
)
return self.send_payload(
HTTPStatus.OK,
response_json(self.session_payload(session)),
"application/json; charset=utf-8",
send_body=True,
extra_headers={"Set-Cookie": cookie_header},
)
def handle_logout(self) -> None:
# 退出时带不带当前 Cookie 都按成功处理,方便前端幂等调用。
delete_session(self.current_session_token())
return self.send_payload(
HTTPStatus.OK,
response_json({"ok": True}),
"application/json; charset=utf-8",
send_body=True,
extra_headers={"Set-Cookie": build_clear_session_cookie()},
)
def session_payload(self, session: Any) -> dict[str, Any]:
# 当前只给前端暴露最小必要字段。
return {
"authenticated": True,
"username": session.username,
"expiresAt": session.expires_at,
}
def require_authentication(self, send_body: bool) -> bool:
# 查到有效会话时,当前请求可以继续执行。
if self.current_session() is not None:
return True
# token 缺失或失效时统一打回 401。
self.send_error_payload(
HTTPStatus.UNAUTHORIZED,
"authentication required",
send_body=send_body,
extra_headers=self.auth_cookie_cleanup_headers(),
)
return False
def auth_cookie_cleanup_headers(self) -> dict[str, str] | None:
# 只有请求里真的带了旧 token才下发清 Cookie 头。
if not self.current_session_token():
return None
return {"Set-Cookie": build_clear_session_cookie()}
def redirect(self, location: str, *, send_body: bool) -> None:
# 页面跳转只依赖 Location响应体保持空即可。
self.send_payload(
@ -683,11 +579,9 @@ class MonitorHandler(BaseHTTPRequestHandler):
def main() -> None:
# 启动前先把账号库和默认账号准备好。
ensure_auth_store()
# 创建线程型 HTTP 服务。
server = ThreadingHTTPServer((APP_BIND, APP_PORT), MonitorHandler)
# 打印监听地址便于排查
print(f"hy-app-monitor listening on http://{APP_BIND}:{APP_PORT}", flush=True)
# 打印监听地址和挂载路径,部署排查时更直观
print(f"hy-app-monitor yumi listening on http://{APP_BIND}:{APP_PORT}{APP_BASE_PATH or '/'}", flush=True)
# 进入永久监听。
server.serve_forever()

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
.venv/bin/pip install -r requirements-ops.txt
bash scripts/install_systemd.sh
systemctl restart hy-app-monitor.service
systemctl restart hy-app-monitor-yumi.service hy-app-monitor-haiyi.service hy-app-monitor.service
systemctl is-active hy-app-monitor-yumi.service
systemctl is-active hy-app-monitor-haiyi.service
systemctl is-active hy-app-monitor.service
ss -lntp | grep ":$APP_PORT" || true
"""

View File

@ -156,6 +156,12 @@ def load_runtime_env() -> str:
lines = [
f"APP_BIND={os.environ.get('APP_BIND', '0.0.0.0')}",
f"APP_PORT={os.environ.get('APP_PORT', DEFAULT_APP_PORT)}",
f"YUMI_INTERNAL_HOST={os.environ.get('YUMI_INTERNAL_HOST', '127.0.0.1')}",
f"YUMI_INTERNAL_PORT={os.environ.get('YUMI_INTERNAL_PORT', '2027')}",
f"HAIYI_INTERNAL_HOST={os.environ.get('HAIYI_INTERNAL_HOST', '127.0.0.1')}",
f"HAIYI_INTERNAL_PORT={os.environ.get('HAIYI_INTERNAL_PORT', '2028')}",
f"INTERNAL_PROXY_SHARED_SECRET={os.environ.get('INTERNAL_PROXY_SHARED_SECRET', 'hy-app-monitor-internal')}",
f"GATEWAY_PROXY_TIMEOUT_SECONDS={os.environ.get('GATEWAY_PROXY_TIMEOUT_SECONDS', '600')}",
f"PROBE_TIMEOUT_SECONDS={os.environ.get('PROBE_TIMEOUT_SECONDS', '3')}",
f"PROBE_CACHE_TTL_SECONDS={os.environ.get('PROBE_CACHE_TTL_SECONDS', '5')}",
f"PROBE_MAX_WORKERS={os.environ.get('PROBE_MAX_WORKERS', '12')}",
@ -214,7 +220,9 @@ python3 -m venv .venv
.venv/bin/pip install --quiet --disable-pip-version-check -r requirements-ops.txt
bash scripts/install_systemd.sh
systemctl restart hy-app-monitor.service
systemctl restart hy-app-monitor-yumi.service hy-app-monitor-haiyi.service hy-app-monitor.service
systemctl is-active hy-app-monitor-yumi.service
systemctl is-active hy-app-monitor-haiyi.service
systemctl is-active hy-app-monitor.service
ss -lntp | grep ":$APP_PORT" || true
"""

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

@ -3,10 +3,7 @@ set -euo pipefail
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
RUN_DIR="$ROOT_DIR/run"
PID_FILE="$RUN_DIR/hy-app-monitor.pid"
LOG_FILE="$RUN_DIR/hy-app-monitor.log"
ENV_FILE="${APP_ENV_FILE:-$ROOT_DIR/.env}"
SYSTEMD_SERVICE_NAME="hy-app-monitor.service"
if [ -f "$ENV_FILE" ]; then
set -a
@ -18,6 +15,16 @@ fi
APP_BIND="${APP_BIND:-0.0.0.0}"
APP_PORT="${APP_PORT:-2026}"
TARGETS_FILE="${TARGETS_FILE:-$ROOT_DIR/config/targets.json}"
YUMI_INTERNAL_HOST="${YUMI_INTERNAL_HOST:-127.0.0.1}"
YUMI_INTERNAL_PORT="${YUMI_INTERNAL_PORT:-2027}"
HAIYI_INTERNAL_HOST="${HAIYI_INTERNAL_HOST:-127.0.0.1}"
HAIYI_INTERNAL_PORT="${HAIYI_INTERNAL_PORT:-2028}"
GATEWAY_BIND="$APP_BIND"
GATEWAY_PORT="$APP_PORT"
GATEWAY_SYSTEMD_SERVICE_NAME="hy-app-monitor.service"
YUMI_SYSTEMD_SERVICE_NAME="hy-app-monitor-yumi.service"
HAIYI_SYSTEMD_SERVICE_NAME="hy-app-monitor-haiyi.service"
if [ -x "$ROOT_DIR/.venv/bin/python" ]; then
PYTHON_BIN_DEFAULT="$ROOT_DIR/.venv/bin/python"
@ -29,88 +36,133 @@ PYTHON_BIN="${PYTHON_BIN:-$PYTHON_BIN_DEFAULT}"
mkdir -p "$RUN_DIR"
read_pid() {
if [ -f "$PID_FILE" ]; then
local pid
pid="$(cat "$PID_FILE")"
if pid_matches_project "$pid"; then
echo "$pid"
return 0
fi
fi
discover_project_pid
suite_service_names() {
printf '%s\n' gateway yumi haiyi
}
is_running() {
local pid
pid="$(read_pid || true)"
if [ -z "${pid:-}" ]; then
rm -f "$PID_FILE"
return 1
fi
if ! kill -0 "$pid" >/dev/null 2>&1; then
rm -f "$PID_FILE"
return 1
fi
service_display_name() {
case "$1" in
gateway) echo "gateway" ;;
yumi) echo "yumi" ;;
haiyi) echo "haiyi" ;;
*)
echo "unknown service: $1" >&2
return 1
;;
esac
}
echo "$pid" >"$PID_FILE"
return 0
service_unit_name() {
case "$1" in
gateway) echo "$GATEWAY_SYSTEMD_SERVICE_NAME" ;;
yumi) echo "$YUMI_SYSTEMD_SERVICE_NAME" ;;
haiyi) echo "$HAIYI_SYSTEMD_SERVICE_NAME" ;;
*)
echo "unknown service: $1" >&2
return 1
;;
esac
}
service_entry_file() {
case "$1" in
gateway) echo "$ROOT_DIR/server.py" ;;
yumi) echo "$ROOT_DIR/yumi_server.py" ;;
haiyi) echo "$ROOT_DIR/haiyi_server.py" ;;
*)
echo "unknown service: $1" >&2
return 1
;;
esac
}
service_bind() {
case "$1" in
gateway) echo "$GATEWAY_BIND" ;;
yumi) echo "$YUMI_INTERNAL_HOST" ;;
haiyi) echo "$HAIYI_INTERNAL_HOST" ;;
*)
echo "unknown service: $1" >&2
return 1
;;
esac
}
service_port() {
case "$1" in
gateway) echo "$GATEWAY_PORT" ;;
yumi) echo "$YUMI_INTERNAL_PORT" ;;
haiyi) echo "$HAIYI_INTERNAL_PORT" ;;
*)
echo "unknown service: $1" >&2
return 1
;;
esac
}
service_base_path() {
case "$1" in
gateway) echo "" ;;
yumi) echo "/yumi" ;;
haiyi) echo "/haiyi" ;;
*)
echo "unknown service: $1" >&2
return 1
;;
esac
}
service_pid_file() {
echo "$RUN_DIR/hy-app-monitor-$1.pid"
}
service_log_file() {
echo "$RUN_DIR/hy-app-monitor-$1.log"
}
service_command_pattern() {
basename "$(service_entry_file "$1")"
}
has_systemd_unit() {
local name="$1"
if ! command -v systemctl >/dev/null 2>&1; then
return 1
fi
systemctl cat "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1
systemctl cat "$(service_unit_name "$name")" >/dev/null 2>&1
}
stop_legacy_process() {
local pid
pid="$(read_pid || true)"
if [ -n "${pid:-}" ] && kill -0 "$pid" >/dev/null 2>&1; then
kill "$pid" >/dev/null 2>&1 || true
for _ in $(seq 1 10); do
if ! kill -0 "$pid" >/dev/null 2>&1; then
break
fi
sleep 1
done
kill -9 "$pid" >/dev/null 2>&1 || true
fi
rm -f "$PID_FILE"
}
discover_project_pid() {
if ! command -v lsof >/dev/null 2>&1; then
return 1
fi
local pid
while IFS= read -r pid; do
if pid_matches_project "$pid"; then
echo "$pid"
return 0
has_full_systemd_suite() {
local name
for name in $(suite_service_names); do
if ! has_systemd_unit "$name"; then
return 1
fi
done < <(lsof -tiTCP:"$APP_PORT" -sTCP:LISTEN 2>/dev/null || true)
return 1
done
return 0
}
pid_matches_project() {
local pid="$1"
local name="$1"
local pid="$2"
if [ -z "${pid:-}" ] || ! kill -0 "$pid" >/dev/null 2>&1; then
return 1
fi
local command
command="$(ps -p "$pid" -o command= 2>/dev/null || true)"
if [[ "$command" != *"$ROOT_DIR/server.py"* ]] && [[ "$command" != *" server.py"* ]]; then
if [[ "$command" != *"$(service_command_pattern "$name")"* ]]; then
return 1
fi
if command -v lsof >/dev/null 2>&1; then
local expected_port
expected_port="$(service_port "$name")"
if ! lsof -a -p "$pid" -iTCP:"$expected_port" -sTCP:LISTEN >/dev/null 2>&1; then
return 1
fi
fi
if command -v lsof >/dev/null 2>&1; then
local cwd
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)"
@ -121,3 +173,218 @@ pid_matches_project() {
return 0
}
discover_project_pid() {
local name="$1"
if ! command -v lsof >/dev/null 2>&1; then
return 1
fi
local pid
while IFS= read -r pid; do
if pid_matches_project "$name" "$pid"; then
echo "$pid"
return 0
fi
done < <(lsof -tiTCP:"$(service_port "$name")" -sTCP:LISTEN 2>/dev/null || true)
return 1
}
read_pid() {
local name="$1"
local pid_file
pid_file="$(service_pid_file "$name")"
if [ -f "$pid_file" ]; then
local pid
pid="$(cat "$pid_file")"
if pid_matches_project "$name" "$pid"; then
echo "$pid"
return 0
fi
fi
discover_project_pid "$name"
}
is_running() {
local name="$1"
local pid
local pid_file
pid_file="$(service_pid_file "$name")"
pid="$(read_pid "$name" || true)"
if [ -z "${pid:-}" ]; then
rm -f "$pid_file"
return 1
fi
if ! kill -0 "$pid" >/dev/null 2>&1; then
rm -f "$pid_file"
return 1
fi
echo "$pid" >"$pid_file"
return 0
}
stop_legacy_process() {
local name="$1"
local pid
local pid_file
pid_file="$(service_pid_file "$name")"
pid="$(read_pid "$name" || true)"
if [ -n "${pid:-}" ] && kill -0 "$pid" >/dev/null 2>&1; then
kill "$pid" >/dev/null 2>&1 || true
local _i
for _i in $(seq 1 10); do
if ! kill -0 "$pid" >/dev/null 2>&1; then
break
fi
sleep 1
done
kill -9 "$pid" >/dev/null 2>&1 || true
fi
rm -f "$pid_file"
}
stop_all_legacy_processes() {
local name
for name in $(suite_service_names); do
stop_legacy_process "$name"
done
}
export_service_env() {
local name="$1"
export APP_BIND
export APP_PORT
export APP_BASE_PATH
export TARGETS_FILE
export PYTHONUNBUFFERED=1
APP_BIND="$(service_bind "$name")"
APP_PORT="$(service_port "$name")"
APP_BASE_PATH="$(service_base_path "$name")"
export APP_BIND APP_PORT APP_BASE_PATH TARGETS_FILE PYTHONUNBUFFERED
}
start_local_service() {
local name="$1"
local pid_file
local log_file
local entry_file
if is_running "$name"; then
echo "$(service_display_name "$name") already running pid=$(read_pid "$name")"
return 0
fi
if [ "$name" = "yumi" ] && [ ! -f "$TARGETS_FILE" ]; then
echo "targets file not found: $TARGETS_FILE" >&2
return 1
fi
pid_file="$(service_pid_file "$name")"
log_file="$(service_log_file "$name")"
entry_file="$(service_entry_file "$name")"
export_service_env "$name"
nohup "$PYTHON_BIN" "$entry_file" >>"$log_file" 2>&1 &
local pid=$!
echo "$pid" >"$pid_file"
sleep 1
if kill -0 "$pid" >/dev/null 2>&1; then
echo "$(service_display_name "$name") started pid=$pid port=$(service_port "$name")"
return 0
fi
echo "$(service_display_name "$name") failed to start" >&2
tail -n 40 "$log_file" >&2 || true
return 1
}
stop_local_service() {
local name="$1"
local pid_file
pid_file="$(service_pid_file "$name")"
if ! is_running "$name"; then
echo "$(service_display_name "$name") not running"
rm -f "$pid_file"
return 0
fi
local pid
pid="$(read_pid "$name")"
kill "$pid" >/dev/null 2>&1 || true
local _i
for _i in $(seq 1 10); do
if ! kill -0 "$pid" >/dev/null 2>&1; then
rm -f "$pid_file"
echo "$(service_display_name "$name") stopped"
return 0
fi
sleep 1
done
kill -9 "$pid" >/dev/null 2>&1 || true
rm -f "$pid_file"
echo "$(service_display_name "$name") force stopped"
}
status_local_service() {
local name="$1"
if is_running "$name"; then
echo "$(service_display_name "$name") running pid=$(read_pid "$name") port=$(service_port "$name")"
return 0
fi
echo "$(service_display_name "$name") stopped"
return 1
}
start_systemd_suite() {
local name
for name in yumi haiyi gateway; do
systemctl start "$(service_unit_name "$name")"
done
for name in $(suite_service_names); do
systemctl is-active "$(service_unit_name "$name")"
done
}
stop_systemd_suite() {
local name
for name in gateway yumi haiyi; do
systemctl stop "$(service_unit_name "$name")" >/dev/null 2>&1 || true
done
}
restart_systemd_suite() {
local name
for name in yumi haiyi gateway; do
systemctl restart "$(service_unit_name "$name")"
done
for name in $(suite_service_names); do
systemctl is-active "$(service_unit_name "$name")"
done
}
status_systemd_suite() {
local all_active=0
local name
for name in $(suite_service_names); do
if systemctl is-active --quiet "$(service_unit_name "$name")"; then
echo "$(service_display_name "$name") running under systemd port=$(service_port "$name")"
else
echo "$(service_display_name "$name") stopped"
all_active=1
fi
done
return "$all_active"
}

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
source "$SCRIPT_DIR/common.sh"
UNIT_SRC="$ROOT_DIR/systemd/hy-app-monitor.service"
UNIT_DST="/etc/systemd/system/$SYSTEMD_SERVICE_NAME"
if [ ! -f "$UNIT_SRC" ]; then
echo "systemd unit missing: $UNIT_SRC" >&2
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
echo "env file missing: $ENV_FILE" >&2
exit 1
fi
install -m 0644 "$UNIT_SRC" "$UNIT_DST"
install_unit() {
local name="$1"
local src
local dst
src="$ROOT_DIR/systemd/$(service_unit_name "$name")"
dst="/etc/systemd/system/$(service_unit_name "$name")"
if [ ! -f "$src" ]; then
echo "systemd unit missing: $src" >&2
exit 1
fi
install -m 0644 "$src" "$dst"
}
name=""
for name in $(suite_service_names); do
install_unit "$name"
done
chmod +x "$ROOT_DIR"/scripts/*.sh
stop_legacy_process
stop_all_legacy_processes
systemctl daemon-reload
systemctl enable "$SYSTEMD_SERVICE_NAME"
systemctl restart "$SYSTEMD_SERVICE_NAME"
systemctl is-active "$SYSTEMD_SERVICE_NAME"
for name in $(suite_service_names); do
systemctl enable "$(service_unit_name "$name")"
done
restart_systemd_suite

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

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

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
source "$SCRIPT_DIR/common.sh"
if [ ! -f "$TARGETS_FILE" ]; then
echo "targets file not found: $TARGETS_FILE" >&2
exit 1
fi
export APP_BIND APP_PORT TARGETS_FILE PYTHONUNBUFFERED=1
export_service_env gateway
exec "$PYTHON_BIN" "$ROOT_DIR/server.py"

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
source "$SCRIPT_DIR/common.sh"
if has_systemd_unit; then
systemctl start "$SYSTEMD_SERVICE_NAME"
systemctl is-active "$SYSTEMD_SERVICE_NAME"
if has_full_systemd_suite; then
start_systemd_suite
exit 0
fi
if is_running; then
echo "hy-app-monitor already running pid=$(read_pid)"
exit 0
fi
if [ ! -f "$TARGETS_FILE" ]; then
echo "targets file not found: $TARGETS_FILE" >&2
exit 1
fi
export APP_BIND APP_PORT TARGETS_FILE PYTHONUNBUFFERED=1
nohup "$PYTHON_BIN" "$ROOT_DIR/server.py" >>"$LOG_FILE" 2>&1 &
pid=$!
echo "$pid" >"$PID_FILE"
sleep 1
if kill -0 "$pid" >/dev/null 2>&1; then
echo "hy-app-monitor started pid=$pid port=$APP_PORT"
exit 0
fi
echo "hy-app-monitor failed to start" >&2
tail -n 40 "$LOG_FILE" >&2 || true
exit 1
start_local_service yumi
start_local_service haiyi
start_local_service gateway

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

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

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

@ -5,31 +5,13 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/common.sh"
if has_systemd_unit; then
systemctl stop "$SYSTEMD_SERVICE_NAME"
stop_legacy_process
echo "hy-app-monitor stopped"
if has_full_systemd_suite; then
stop_systemd_suite
stop_all_legacy_processes
echo "hy-app-monitor suite stopped"
exit 0
fi
if ! is_running; then
echo "hy-app-monitor not running"
rm -f "$PID_FILE"
exit 0
fi
pid="$(read_pid)"
kill "$pid" >/dev/null 2>&1 || true
for _ in $(seq 1 10); do
if ! kill -0 "$pid" >/dev/null 2>&1; then
rm -f "$PID_FILE"
echo "hy-app-monitor stopped"
exit 0
fi
sleep 1
done
kill -9 "$pid" >/dev/null 2>&1 || true
rm -f "$PID_FILE"
echo "hy-app-monitor force stopped"
stop_local_service gateway
stop_local_service yumi
stop_local_service haiyi

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
source "$SCRIPT_DIR/common.sh"
UNIT_DST="/etc/systemd/system/$SYSTEMD_SERVICE_NAME"
if command -v systemctl >/dev/null 2>&1; then
name=""
for name in $(suite_service_names); do
systemctl disable --now "$(service_unit_name "$name")" >/dev/null 2>&1 || true
done
fi
name=""
for name in $(suite_service_names); do
rm -f "/etc/systemd/system/$(service_unit_name "$name")"
done
if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 || true
systemctl daemon-reload || true
fi
rm -f "$UNIT_DST"
echo "systemd unit removed: $UNIT_DST"
echo "systemd units removed"

View File

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

View File

@ -147,6 +147,72 @@ body.auth-page {
font-size: 12px;
}
.entry-panel {
display: grid;
gap: 18px;
}
.entry-grid,
.entry-stack {
display: grid;
gap: 12px;
}
.entry-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.entry-card {
display: grid;
gap: 8px;
min-height: 132px;
padding: 16px;
border: 1px solid var(--line-strong);
border-radius: 18px;
background: color-mix(in oklab, var(--panel-strong) 88%, var(--accent-soft));
color: inherit;
text-decoration: none;
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease;
}
.entry-card:hover {
transform: translateY(-1px);
border-color: color-mix(in oklab, var(--accent) 42%, var(--line-strong));
background: color-mix(in oklab, var(--accent-soft) 74%, var(--panel-strong));
}
.entry-card strong {
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
font-size: 1.1rem;
letter-spacing: -0.03em;
}
.entry-card span {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.entry-card-static {
min-height: 0;
}
.entry-actions,
.entry-meta {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.entry-actions {
justify-content: flex-end;
}
.entry-link {
text-decoration: none;
}
.topbar,
.control-bar,
.panel {
@ -292,6 +358,11 @@ body.auth-page {
align-items: center;
}
.release-section-nav {
flex: 1 1 auto;
min-width: 0;
}
.taskbar-context {
display: flex;
flex-direction: column;
@ -319,6 +390,35 @@ body.auth-page {
flex-wrap: wrap;
}
.section-link {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 2px;
border: 0;
border-radius: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
cursor: pointer;
transition: color 180ms ease, opacity 180ms ease;
}
.section-link:hover {
color: var(--text);
}
.section-link + .section-link {
margin-left: 12px;
}
.release-kind-select {
min-width: 98px;
}
.control-group-spread {
justify-content: flex-end;
flex: 1 1 auto;
@ -379,6 +479,7 @@ body.auth-page {
.refresh:focus-visible,
.search:focus-visible,
.interval-select:focus-visible,
.section-link:focus-visible,
.detail-log-input:focus-visible,
.config-item:focus-visible,
.config-textarea:focus-visible {
@ -959,6 +1060,14 @@ body.auth-page {
gap: 8px;
}
.section-caption-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.section-caption {
color: var(--muted);
font-size: 10px;
@ -967,6 +1076,16 @@ body.auth-page {
text-transform: uppercase;
}
.section-caption-meta {
display: inline-flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
color: var(--muted);
font-size: 10px;
font-weight: 600;
}
.host-row-list {
display: grid;
gap: 6px;
@ -1005,11 +1124,11 @@ body.auth-page {
.host-strip-main {
display: grid;
grid-template-columns: minmax(164px, 208px) minmax(210px, 0.9fr) minmax(220px, 1.2fr) auto;
gap: 8px;
grid-template-columns: minmax(180px, 220px) minmax(320px, 1fr) minmax(180px, 0.9fr) auto;
gap: 10px;
align-items: center;
min-height: 48px;
padding: 7px 11px 7px 16px;
min-height: 56px;
padding: 8px 11px 8px 16px;
cursor: pointer;
}
@ -1038,39 +1157,121 @@ body.auth-page {
}
.compact-metrics {
gap: 10px;
gap: 8px;
color: var(--muted);
font-size: 10px;
font-weight: 600;
}
.compact-metrics span {
.metric-ring-chip,
.metric-stat-chip {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 9px 0 7px;
border: 1px solid var(--line);
border-radius: 999px;
background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong));
}
.metric-ring-chip.tone-ok,
.metric-stat-chip.tone-ok {
border-color: color-mix(in oklab, var(--ok) 28%, var(--line));
}
.metric-ring-chip.tone-warn,
.metric-stat-chip.tone-warn {
border-color: color-mix(in oklab, var(--warn) 28%, var(--line));
}
.metric-ring-chip.tone-bad,
.metric-stat-chip.tone-bad {
border-color: color-mix(in oklab, var(--bad) 28%, var(--line));
}
.metric-ring {
--metric-color: var(--line-strong);
--metric-value: 0%;
position: relative;
width: 24px;
height: 24px;
border-radius: 999px;
background:
conic-gradient(var(--metric-color) 0 var(--metric-value), color-mix(in oklab, var(--line) 88%, transparent) var(--metric-value) 100%);
}
.metric-ring-hole {
position: absolute;
inset: 4px;
border-radius: 999px;
background: var(--panel-strong);
}
.metric-ring-copy,
.metric-stat-chip {
min-width: 0;
}
.metric-ring-copy small,
.metric-ring-copy strong,
.metric-stat-chip small,
.metric-stat-chip strong {
display: block;
}
.metric-ring-copy small,
.metric-stat-chip small {
color: var(--muted);
font-size: 9px;
line-height: 1;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.metric-ring-copy strong,
.metric-stat-chip strong {
margin-top: 3px;
font-size: 11px;
line-height: 1.1;
white-space: nowrap;
}
.host-alert-summary {
display: flex;
align-items: center;
display: grid;
gap: 4px;
min-width: 0;
}
.host-alert-summary small {
color: var(--muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.host-alert-path {
display: block;
width: 100%;
overflow: hidden;
color: var(--muted);
font-size: 10px;
color: var(--text);
font-size: 11px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-alert-summary.is-alert .host-alert-path {
.host-alert-summary.tone-bad .host-alert-path {
color: color-mix(in oklab, var(--bad) 82%, var(--text));
}
.host-alert-summary.is-quiet .host-alert-path {
color: color-mix(in oklab, var(--muted) 88%, transparent);
.host-alert-summary.tone-warn .host-alert-path {
color: color-mix(in oklab, var(--warn) 72%, var(--text));
}
.host-alert-summary.tone-neutral .host-alert-path {
color: color-mix(in oklab, var(--muted) 76%, var(--text));
}
.metric-chip,
@ -1282,6 +1483,19 @@ body.auth-page {
.config-sidebar {
display: grid;
gap: 10px;
align-self: start;
position: sticky;
top: 124px;
max-height: calc(100vh - 140px);
padding: 10px;
border: 1px solid var(--line);
border-radius: 16px;
background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong));
}
.config-sidebar-head {
display: grid;
gap: 8px;
}
.config-sidebar-meta {
@ -1290,8 +1504,27 @@ body.auth-page {
align-items: center;
}
.config-sidebar-path {
display: grid;
gap: 3px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-strong);
}
.config-sidebar-path strong {
font-size: 12px;
}
.config-sidebar-path span {
color: var(--muted);
font-size: 10px;
}
.config-list-ide {
gap: 8px;
gap: 10px;
padding-right: 4px;
}
.config-group {
@ -1328,7 +1561,9 @@ body.auth-page {
.config-group-items {
display: grid;
gap: 4px;
gap: 2px;
padding-left: 10px;
border-left: 1px solid color-mix(in oklab, var(--line) 88%, transparent);
}
.config-item-ide {
@ -1336,12 +1571,27 @@ body.auth-page {
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 9px 10px;
border-radius: 12px;
padding: 8px 9px 8px 12px;
border: 0;
border-radius: 10px;
background: transparent;
position: relative;
transition: background 180ms ease, box-shadow 180ms ease;
}
.config-item-ide::before {
content: "";
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 2px;
border-radius: 999px;
background: transparent;
}
.config-item-ide strong {
font-size: 12px;
font-size: 11px;
word-break: break-word;
}
@ -1352,8 +1602,38 @@ body.auth-page {
}
.config-item-ide small {
grid-column: 1 / -1;
font-size: 10px;
color: var(--muted);
}
.config-item-ide.active {
background: color-mix(in oklab, var(--accent-soft) 64%, var(--panel-strong));
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 26%, var(--line));
}
.config-item-ide:hover {
transform: none;
background: color-mix(in oklab, var(--accent-soft) 30%, var(--panel-strong));
}
.config-item-ide.active::before {
background: var(--accent);
}
.config-item-main,
.config-item-side {
display: grid;
gap: 3px;
}
.config-item-side {
justify-items: end;
}
.config-jump-group {
display: inline-flex;
gap: 8px;
align-items: center;
}
.editor-toolbar {
@ -1498,6 +1778,25 @@ body.auth-page {
z-index: 12;
}
.release-action-bar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-top: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: color-mix(in oklab, var(--panel-muted) 92%, var(--panel-strong));
}
.release-action-summary {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.release-status-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@ -1554,6 +1853,71 @@ body.auth-page {
margin-top: 10px;
}
.release-log-preview {
display: grid;
gap: 10px;
margin-top: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong));
}
.release-log-preview-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.release-log-preview-head strong {
font-size: 12px;
}
.release-log-snippets {
display: grid;
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.release-log-snippet {
display: grid;
gap: 4px;
padding: 10px 11px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-strong);
}
.release-log-snippet strong,
.release-log-snippet span,
.release-log-snippet p {
margin: 0;
}
.release-log-snippet strong {
font-size: 11px;
}
.release-log-snippet span {
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.release-log-snippet p {
color: var(--text);
font-size: 11px;
line-height: 1.45;
word-break: break-word;
}
.release-log-snippet.tone-bad {
border-color: color-mix(in oklab, var(--bad) 28%, var(--line));
background: color-mix(in oklab, var(--bad-soft) 44%, var(--panel-strong));
}
.release-table td strong {
display: block;
font-size: 12px;
@ -1924,6 +2288,60 @@ body.auth-page {
word-break: break-word;
}
.log-viewer-overlay {
position: fixed;
inset: 0;
z-index: 48;
display: grid;
place-items: center;
padding: 28px 20px;
background: color-mix(in oklab, var(--text) 14%, transparent);
backdrop-filter: blur(8px);
}
.log-viewer {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
width: min(1120px, 100%);
max-height: min(88vh, 920px);
padding: 16px;
border: 1px solid var(--line);
border-radius: 22px;
background: color-mix(in oklab, var(--panel-strong) 96%, var(--panel-muted));
box-shadow: 0 24px 64px rgba(29, 42, 68, 0.24);
}
.log-viewer-head {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-start;
}
.log-viewer-toolbar {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.log-viewer-output {
margin: 0;
min-height: 280px;
max-height: calc(88vh - 176px);
overflow: auto;
padding: 16px;
border: 1px solid color-mix(in oklab, var(--line-strong) 78%, transparent);
border-radius: 16px;
background: oklch(22% 0.01 240 / 0.98);
color: oklch(91% 0.01 230);
font-size: 12px;
line-height: 1.58;
white-space: pre-wrap;
word-break: break-word;
}
.badge {
display: inline-flex;
align-items: center;
@ -2017,7 +2435,8 @@ body.auth-page {
.infra-overview-grid,
.mongo-group-grid,
.release-status-grid {
.release-status-grid,
.release-log-snippets {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@ -2036,6 +2455,11 @@ body.auth-page {
max-height: none;
}
.config-sidebar {
position: static;
max-height: none;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -2061,7 +2485,8 @@ body.auth-page {
.release-status-grid,
.infra-overview-grid,
.mongo-group-grid {
.mongo-group-grid,
.release-log-snippets {
grid-template-columns: 1fr;
}
}
@ -2144,6 +2569,14 @@ body.auth-page {
width: 100%;
}
.section-caption-row {
align-items: flex-start;
}
.entry-grid {
grid-template-columns: 1fr;
}
.host-service-pill,
.restart-pills .config-item {
width: 100%;
@ -2153,7 +2586,12 @@ body.auth-page {
.taskbar-inline,
.metadata-strip,
.precheck-strip,
.activity-item-side {
.activity-item-side,
.release-action-bar,
.release-action-summary,
.log-viewer-head,
.log-viewer-toolbar,
.entry-actions {
width: 100%;
text-align: left;
}
@ -2163,6 +2601,21 @@ body.auth-page {
grid-template-columns: 1fr;
}
.log-viewer-overlay {
padding: 14px 12px;
}
.log-viewer {
max-height: calc(100vh - 28px);
padding: 14px;
}
.release-action-bar,
.log-viewer-head {
flex-direction: column;
align-items: stretch;
}
.diff-row {
grid-template-columns: 34px 34px 22px minmax(0, 1fr);
gap: 8px;

View File

@ -1,6 +1,7 @@
const { createApp } = Vue;
const APP_TEMPLATE = document.getElementById("app")?.innerHTML || "";
const APP_BASE_PATH = window.location.pathname === "/yumi" || window.location.pathname.startsWith("/yumi/") ? "/yumi" : "";
const STORAGE_KEY = "hy-app-monitor-refresh-interval";
const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation";
const GROUP_LABELS = {
@ -52,6 +53,20 @@ const INFRA_TABS = [
{ id: "tat", label: "TAT" },
];
function rootPath(path) {
const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`;
return normalized;
}
function appPath(path) {
const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`;
return `${APP_BASE_PATH}${normalized}` || "/";
}
function loginRedirectPath() {
return rootPath(`/login?next=${encodeURIComponent(`${window.location.pathname}${window.location.search}`)}`);
}
createApp({
template: APP_TEMPLATE,
data() {
@ -254,6 +269,10 @@ createApp({
exportMaxLines: 10000,
exporting: false,
},
logViewer: {
open: false,
kind: "",
},
timer: null,
};
},
@ -297,6 +316,12 @@ createApp({
filteredHostRows() {
return this.buildHostRows({ businessOnly: false, applyFilter: true, applyKeyword: true });
},
filteredHostAlertTotal() {
return this.filteredHostRows.filter((host) => host.hasAlert).length;
},
filteredHostMetricIssueTotal() {
return this.filteredHostRows.filter((host) => host.hasMetricIssue).length;
},
overviewHostRowGroups() {
return this.groupHosts(this.overviewHostRows);
},
@ -422,6 +447,24 @@ createApp({
group.items.some((item) => this.configKey(item.group, item.dataId) === this.nacosConfig.selectedKey)
)?.key || "";
},
flatNacosConfigKeys() {
return this.nacosConfigGroups.flatMap((group) =>
group.items.map((item) => this.configKey(item.group, item.dataId))
);
},
selectedNacosConfigIndex() {
return this.flatNacosConfigKeys.indexOf(this.nacosConfig.selectedKey);
},
selectedPrevNacosConfigKey() {
return this.selectedNacosConfigIndex > 0
? this.flatNacosConfigKeys[this.selectedNacosConfigIndex - 1]
: "";
},
selectedNextNacosConfigKey() {
return this.selectedNacosConfigIndex >= 0 && this.selectedNacosConfigIndex < this.flatNacosConfigKeys.length - 1
? this.flatNacosConfigKeys[this.selectedNacosConfigIndex + 1]
: "";
},
configHistoryRows() {
const keyword = this.nacosConfig.keyword.trim().toLowerCase();
return this.configHistory.filter((item) => (
@ -634,7 +677,7 @@ createApp({
},
{
label: "最近日志",
value: lastLog?.message || "-",
value: lastLog?.message ? this.truncateMiddle(lastLog.message, 24, 14) : "-",
tone: lastLog?.level === "error" ? "bad" : "neutral",
},
];
@ -837,6 +880,15 @@ createApp({
})
.join("\n");
},
recentUpdateLogs() {
return (this.updateOps.logs || [])
.slice(-5)
.reverse()
.map((entry, index) => ({
...entry,
key: `${entry.time || "log"}-${entry.stage || "stage"}-${index}`,
}));
},
serviceLogDownloadUrl() {
if (!this.serviceLog.service || !this.serviceLog.host) {
return "";
@ -854,6 +906,71 @@ createApp({
});
return `/api/ops/service-log/export?${query.toString()}`;
},
activeLogViewerTitle() {
if (this.logViewer.kind === "service") {
return `${this.serviceLog.service || "-"} · ${this.serviceLog.host || "-"}`;
}
return "执行日志";
},
activeLogViewerSubtitle() {
if (this.logViewer.kind === "service") {
const subtitleParts = [];
if (this.serviceLog.container) {
subtitleParts.push(this.serviceLog.container);
}
subtitleParts.push(`更新于 ${this.formatDateTime(this.serviceLog.updatedAt)}`);
return subtitleParts.join(" · ");
}
return `任务 ${this.updateOps.operationId || "-"} · ${this.updateStatusLabel()} · ${this.formatDateTime(this.updateOps.finishedAt || this.updateOps.startedAt)}`;
},
activeLogViewerBadge() {
if (this.logViewer.kind === "service") {
return this.serviceLog.container || "";
}
return this.updateOps.operationId || "";
},
activeLogViewerSecondaryBadge() {
if (this.logViewer.kind === "service") {
return `最近 ${this.serviceLog.previewLines}`;
}
return this.updateOps.stage ? `阶段 ${this.updateOps.stage}` : "";
},
activeLogViewerContent() {
if (this.logViewer.kind === "service") {
return this.serviceLog.content;
}
return this.updateLogText;
},
activeLogViewerError() {
if (this.logViewer.kind === "service") {
return this.serviceLog.error;
}
return this.updateOps.error ? this.explainError(this.updateOps.error) : "";
},
activeLogViewerLoading() {
if (this.logViewer.kind === "service") {
return this.serviceLog.loading;
}
return this.updateOps.statusRequestInFlight;
},
activeLogViewerExporting() {
if (this.logViewer.kind === "service") {
return this.serviceLog.exporting;
}
return false;
},
activeLogViewerRefreshDisabled() {
if (this.logViewer.kind === "service") {
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.loading;
}
return !this.updateOps.operationId || this.updateOps.statusRequestInFlight;
},
activeLogViewerDownloadDisabled() {
if (this.logViewer.kind === "service") {
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.exporting;
}
return !this.updateOps.operationId;
},
},
watch: {
refreshIntervalSeconds() {
@ -884,9 +1001,20 @@ createApp({
group: host.group,
label: this.groupLabel(host.group),
items: [],
alertCount: 0,
metricIssueCount: 0,
serviceCount: 0,
});
}
groups.get(host.group).items.push(host);
const target = groups.get(host.group);
target.items.push(host);
target.serviceCount += host.serviceTotal || 0;
if (host.hasAlert) {
target.alertCount += 1;
}
if (host.hasMetricIssue) {
target.metricIssueCount += 1;
}
});
return [...groups.values()].sort((left, right) => this.groupRank(left.group) - this.groupRank(right.group));
},
@ -1059,9 +1187,50 @@ createApp({
}
this.collapsedConfigGroups = [...next];
},
syncConfigExplorerState(selectedKey = this.nacosConfig.selectedKey, { force = false } = {}) {
const groupKeys = [...new Set((this.nacosConfig.items || []).map((item) => item.group || "default"))];
if (groupKeys.length === 0) {
this.collapsedConfigGroups = [];
return;
}
const selectedItem = (this.nacosConfig.items || []).find((item) => this.configKey(item.group, item.dataId) === selectedKey);
const selectedGroup = selectedItem?.group || groupKeys[0];
if (force || this.collapsedConfigGroups.length === 0) {
this.collapsedConfigGroups = groupKeys.filter((groupKey) => groupKey !== selectedGroup);
return;
}
if (this.collapsedConfigGroups.includes(selectedGroup)) {
this.collapsedConfigGroups = this.collapsedConfigGroups.filter((groupKey) => groupKey !== selectedGroup);
}
},
isConfigGroupCollapsed(groupKey) {
return this.collapsedConfigGroups.includes(groupKey);
},
scrollSelectedConfigItemIntoView() {
this.$nextTick(() => {
const node = document.getElementById(`config-item-${this.nacosConfig.selectedKey}`);
if (node) {
node.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
});
},
openPrevNacosConfig() {
if (!this.selectedPrevNacosConfigKey) {
return;
}
this.nacosConfig.selectedKey = this.selectedPrevNacosConfigKey;
this.openNacosConfigByKey(this.selectedPrevNacosConfigKey);
},
openNextNacosConfig() {
if (!this.selectedNextNacosConfigKey) {
return;
}
this.nacosConfig.selectedKey = this.selectedNextNacosConfigKey;
this.openNacosConfigByKey(this.selectedNextNacosConfigKey);
},
toggleNacosService(serviceName) {
const next = new Set(this.expandedNacosServices);
if (next.has(serviceName)) {
@ -1138,6 +1307,47 @@ createApp({
this.selectedServiceKey = serviceKey;
this.detailPanelOpen = true;
},
openUpdateLogViewer() {
this.logViewer.open = true;
this.logViewer.kind = "update";
this.$nextTick(() => this.scrollUpdateLogToBottom());
},
closeLogViewer() {
const previousKind = this.logViewer.kind;
this.logViewer.open = false;
this.logViewer.kind = "";
if (previousKind === "service") {
this.clearServiceLog();
}
},
async refreshActiveLogViewer() {
if (this.logViewer.kind === "service") {
if (!this.serviceLog.host) {
return;
}
await this.openServiceLog({ host: this.serviceLog.host });
return;
}
if (!this.updateOps.operationId) {
return;
}
await this.fetchUpdateOperationStatus(this.updateOps.operationId);
this.scrollUpdateLogToBottom();
},
async downloadActiveLogViewer() {
if (this.logViewer.kind === "service") {
await this.downloadServiceLog();
return;
}
if (!this.updateOps.operationId) {
return;
}
try {
await this.downloadFile(this.updateLogDownloadUrl(), `service-update-${this.updateOps.operationId}.log`);
} catch (error) {
this.updateOps.error = error instanceof Error ? error.message : String(error);
}
},
clearServiceLog() {
this.serviceLog.loading = false;
this.serviceLog.exporting = false;
@ -1156,12 +1366,20 @@ createApp({
return;
}
if (!this.selectedServiceDetail || this.selectedServiceDetail.service !== this.serviceLog.service) {
this.clearServiceLog();
if (this.logViewer.kind === "service" && this.logViewer.open) {
this.closeLogViewer();
} else {
this.clearServiceLog();
}
return;
}
const exists = (this.selectedServiceDetail.instances || []).some((instance) => instance.host === this.serviceLog.host);
if (!exists) {
this.clearServiceLog();
if (this.logViewer.kind === "service" && this.logViewer.open) {
this.closeLogViewer();
} else {
this.clearServiceLog();
}
}
},
isServiceLogActive(instance) {
@ -1174,6 +1392,8 @@ createApp({
return;
}
this.logViewer.open = true;
this.logViewer.kind = "service";
this.serviceLog.loading = true;
this.serviceLog.error = "";
this.serviceLog.service = serviceName;
@ -1199,6 +1419,7 @@ createApp({
Math.max(1, Number(this.serviceLog.exportLines || this.serviceLog.previewLines)),
this.serviceLog.exportMaxLines,
);
this.$nextTick(() => this.scrollUpdateLogToBottom());
} catch (error) {
this.serviceLog.error = error instanceof Error ? error.message : String(error);
} finally {
@ -1265,6 +1486,81 @@ createApp({
}
return parts.filter(Boolean).join(" · ");
},
hostMetricEntries(host) {
const metrics = host?.metrics || {};
return [
{
key: "cpu",
label: "CPU",
value: metrics.cpuPercent,
display: this.formatPercent(metrics.cpuPercent),
level: metrics.cpuLevel || this.thresholdTone("cpuPercent", metrics.cpuPercent),
},
{
key: "memory",
label: "MEM",
value: metrics.memoryPercent,
display: this.formatPercent(metrics.memoryPercent),
level: metrics.memoryLevel || this.thresholdTone("memoryPercent", metrics.memoryPercent),
},
{
key: "disk",
label: "DISK",
value: metrics.diskPercent,
display: this.formatPercent(metrics.diskPercent),
level: metrics.diskLevel || this.thresholdTone("diskPercent", metrics.diskPercent),
},
];
},
thresholdTone(metricKey, value) {
if (value == null || Number.isNaN(Number(value))) {
return "neutral";
}
const rule = this.thresholds?.[metricKey];
if (!rule) {
return "neutral";
}
const amount = Number(value);
const bad = Number(rule.bad);
const warn = Number(rule.warn);
if (!Number.isNaN(bad) && amount >= bad) {
return "bad";
}
if (!Number.isNaN(warn) && amount >= warn) {
return "warn";
}
return "ok";
},
metricRingStyle(value, level = "neutral") {
const amount = value == null || Number.isNaN(Number(value))
? 0
: Math.max(0, Math.min(100, Number(value)));
const tone = level || "neutral";
return {
"--metric-value": `${amount}%`,
"--metric-color": `var(--${tone === "bad" ? "bad" : tone === "warn" ? "warn" : tone === "ok" ? "ok" : "line-strong"})`,
};
},
hostServiceSummary(host, { quiet = false } = {}) {
const source = quiet ? host.items : (host.alertItems.length > 0 ? host.alertItems : host.items);
const services = [...new Set((source || []).map((item) => item.service).filter(Boolean))];
if (services.length === 0) {
return host.hasMetricIssue ? "指标采集异常" : "无服务";
}
if (services.length <= 4) {
return services.join(" / ");
}
return `${services.slice(0, 4).join(" / ")} +${services.length - 4}`;
},
hostSummaryTone(host) {
if (host.downCount > 0) {
return "bad";
}
if (host.hasMetricIssue) {
return "warn";
}
return "neutral";
},
buildHostRows({ businessOnly = false, applyFilter = false, applyKeyword = false } = {}) {
const keyword = applyKeyword ? this.normalizedKeyword : "";
@ -1286,10 +1582,11 @@ createApp({
const hasMetricIssue = metrics.ok === false || Boolean(metrics.error);
const alertServices = [...new Set(alertItems.map((item) => item.service))];
const alertSummary = alertServices.length > 0
? alertServices.join(" / ")
? this.hostServiceSummary({ items, alertItems, hasMetricIssue }, { quiet: false })
: hasMetricIssue
? "指标采集异常"
: "全部正常";
: "无服务异常";
const quietSummary = this.hostServiceSummary({ items, alertItems, hasMetricIssue }, { quiet: true });
const searchText = [
host.host,
host.ip,
@ -1313,6 +1610,7 @@ createApp({
items,
alertItems,
alertSummary,
quietSummary,
groupLabel: this.groupLabel(host.group),
serviceTotal: items.length,
downCount,
@ -1831,7 +2129,7 @@ createApp({
}
window.sessionStorage.removeItem(UPDATE_OPERATION_STORAGE_KEY);
window.location.replace("/login");
window.location.replace(loginRedirectPath());
},
async authFetch(url, options = {}) {
const requestOptions = {
@ -1843,7 +2141,8 @@ createApp({
requestOptions.cache = "no-store";
}
return fetch(url, requestOptions);
const actualUrl = typeof url === "string" && url.startsWith("/") ? appPath(url) : url;
return fetch(actualUrl, requestOptions);
},
async requestJson(url, options = {}) {
const response = await this.authFetch(url, options);
@ -1860,10 +2159,7 @@ createApp({
this.auth.checking = true;
try {
const response = await fetch("/api/auth/session", {
cache: "no-store",
credentials: "same-origin",
});
const response = await this.authFetch("/api/auth/session");
const payload = await this.readJsonResponse(response);
if (response.status === 401) {
@ -2043,6 +2339,7 @@ createApp({
}
this.nacosConfig.selectedKey = nextKey;
this.syncConfigExplorerState(nextKey, { force: !preserveSelection || this.collapsedConfigGroups.length === 0 });
const sameAsCurrent = this.configKey(this.nacosConfig.editor.group, this.nacosConfig.editor.dataId) === nextKey;
if (!sameAsCurrent || reloadSelected || !this.nacosConfig.loadedContent) {
@ -2090,6 +2387,8 @@ createApp({
md5: item.md5 || "",
content: this.nacosConfig.loadedContent,
};
this.syncConfigExplorerState(configKey);
this.scrollSelectedConfigItemIntoView();
this.recordConfigHistory("nacos", "打开配置", `${item.group}/${item.dataId}`, "neutral");
} catch (error) {
this.nacosConfig.detailError = error instanceof Error ? error.message : String(error);

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 name="viewport" content="width=device-width, initial-scale=1" />
<title>HY App Monitor</title>
<link rel="stylesheet" href="/app.css" />
<script defer src="/vue.js"></script>
<script defer src="/app.js"></script>
<link rel="stylesheet" href="app.css" />
<script defer src="vue.js"></script>
<script defer src="app.js"></script>
</head>
<body>
@ -190,11 +190,11 @@
<span>构建参数 / 更新目标 / 执行日志 / 滚动重启</span>
</div>
<div class="control-group">
<div class="control-group release-section-nav">
<button
v-for="section in releaseSections"
:key="section.id"
class="filter-chip"
class="section-link"
@click="scrollToSection(section.id)"
>
{{ section.label }}
@ -202,20 +202,16 @@
</div>
<div class="control-group control-group-spread">
<div class="density-switch">
<button :class="['filter-chip', releaseKindFilter === 'all' ? 'active' : '']" @click="releaseKindFilter = 'all'">全部</button>
<button :class="['filter-chip', releaseKindFilter === 'java' ? 'active' : '']" @click="releaseKindFilter = 'java'">Java</button>
<button :class="['filter-chip', releaseKindFilter === 'golang' ? 'active' : '']" @click="releaseKindFilter = 'golang'">Golang</button>
<button :class="['filter-chip', releaseKindFilter === 'frontend' ? 'active' : '']" @click="releaseKindFilter = 'frontend'">前端</button>
</div>
<label class="interval-field">
<span>类型</span>
<select v-model="releaseKindFilter" class="interval-select release-kind-select">
<option value="all">全部</option>
<option value="java">Java</option>
<option value="golang">Golang</option>
<option value="frontend">前端</option>
</select>
</label>
<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>
</section>
</template>
@ -318,11 +314,11 @@
</section>
<aside v-if="detailPanelVisible" class="panel detail-panel">
<header class="panel-header">
<div class="panel-title">
<h2>{{ selectedServiceDetail ? selectedServiceDetail.service : '服务详情' }}</h2>
<p v-if="selectedServiceDetail">{{ selectedServiceDetail.groupLabel }} · {{ selectedServiceDetail.kind }}</p>
<p v-else>{{ alertFeed.length }} 条异常</p>
<header class="panel-header">
<div class="panel-title">
<h2>{{ selectedServiceDetail ? selectedServiceDetail.service : '服务详情' }}</h2>
<p v-if="selectedServiceDetail">{{ selectedServiceDetail.groupLabel }} · {{ selectedServiceDetail.kind }}</p>
<p v-else>{{ alertFeed.length }} 条异常</p>
</div>
<div class="action-group">
@ -384,7 +380,7 @@
@click="openServiceLog(instance)"
:disabled="serviceLog.loading && isServiceLogActive(instance)"
>
{{ serviceLog.loading && isServiceLogActive(instance) ? '读取中...' : '日志' }}
{{ serviceLog.loading && isServiceLogActive(instance) ? '读取中...' : '查看日志' }}
</button>
</div>
</div>
@ -398,32 +394,6 @@
<div v-if="densityMode === 'detail'" class="detail-note">{{ instance.detail || '无返回内容' }}</div>
</article>
</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>
<div v-else class="empty-panel">当前没有服务详情</div>
@ -459,7 +429,13 @@
<div class="host-sections">
<section class="host-section">
<div class="section-caption">异常主机</div>
<div class="section-caption-row">
<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">
<article
v-for="host in overviewAlertHosts"
@ -477,14 +453,28 @@
</div>
<div class="host-inline-metrics compact-metrics">
<span>CPU {{ formatPercent(host.metrics.cpuPercent) }}</span>
<span>MEM {{ formatPercent(host.metrics.memoryPercent) }}</span>
<span>DISK {{ formatPercent(host.metrics.diskPercent) }}</span>
<span>PROC {{ host.metrics.processCount ?? '-' }}</span>
<div
v-for="metric in hostMetricEntries(host)"
:key="host.host + '-' + metric.key"
: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 :class="['host-alert-summary', host.hasAlert ? 'is-alert' : 'is-quiet']">
<span class="host-alert-path">{{ host.alertSummary }}</span>
<div :class="['host-alert-summary', `tone-${hostSummaryTone(host)}`]">
<small>{{ host.downCount > 0 ? '异常服务' : host.hasMetricIssue ? '采集状态' : '在线服务' }}</small>
<span class="host-alert-path">{{ host.downCount > 0 ? host.alertSummary : host.quietSummary }}</span>
</div>
<div class="host-inline-state">
@ -541,14 +531,28 @@
</div>
<div class="host-inline-metrics compact-metrics">
<span>CPU {{ formatPercent(host.metrics.cpuPercent) }}</span>
<span>MEM {{ formatPercent(host.metrics.memoryPercent) }}</span>
<span>DISK {{ formatPercent(host.metrics.diskPercent) }}</span>
<span>PROC {{ host.metrics.processCount ?? '-' }}</span>
<div
v-for="metric in hostMetricEntries(host)"
:key="'quiet-' + host.host + '-' + metric.key"
: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 class="host-alert-summary is-quiet">
<span class="host-alert-path">{{ host.alertSummary }}</span>
<div class="host-alert-summary tone-neutral">
<small>在线服务</small>
<span class="host-alert-path">{{ host.quietSummary }}</span>
</div>
<div class="host-inline-state">
@ -580,13 +584,21 @@
<p>{{ filteredHostRows.length }} 台主机</p>
</div>
<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>
</header>
<div class="host-sections">
<section v-for="section in hostRowGroups" :key="section.group" class="host-section">
<div class="section-caption">{{ section.label }}</div>
<div class="section-caption-row">
<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">
<article
v-for="host in section.items"
@ -604,14 +616,28 @@
</div>
<div class="host-inline-metrics compact-metrics">
<span>CPU {{ formatPercent(host.metrics.cpuPercent) }}</span>
<span>MEM {{ formatPercent(host.metrics.memoryPercent) }}</span>
<span>DISK {{ formatPercent(host.metrics.diskPercent) }}</span>
<span>PROC {{ host.metrics.processCount ?? '-' }}</span>
<div
v-for="metric in hostMetricEntries(host)"
:key="'host-' + host.host + '-' + metric.key"
: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 :class="['host-alert-summary', host.hasAlert ? 'is-alert' : 'is-quiet']">
<span class="host-alert-path">{{ host.alertSummary }}</span>
<div :class="['host-alert-summary', `tone-${hostSummaryTone(host)}`]">
<small>{{ host.downCount > 0 ? '异常服务' : host.hasMetricIssue ? '采集状态' : '在线服务' }}</small>
<span class="host-alert-path">{{ host.downCount > 0 ? host.alertSummary : host.quietSummary }}</span>
</div>
<div class="host-inline-state">
@ -1152,8 +1178,15 @@
<div class="config-layout">
<aside class="config-sidebar">
<div class="config-sidebar-meta">
<span class="badge neutral mono">{{ truncateMiddle(nacosConfig.baseUrl, 16, 12) || '-' }}</span>
<div class="config-sidebar-head">
<div class="config-sidebar-meta">
<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 class="config-list config-list-ide">
@ -1168,12 +1201,17 @@
<button
v-for="item in group.items"
: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' : '']"
@click="openNacosConfig(item)"
>
<strong>{{ item.dataId }}</strong>
<span>{{ item.type || 'text' }}</span>
<small v-if="item.appName">{{ item.appName }}</small>
<div class="config-item-main">
<strong>{{ item.dataId }}</strong>
<small v-if="item.appName">{{ item.appName }}</small>
</div>
<div class="config-item-side">
<span>{{ item.type || 'text' }}</span>
</div>
</button>
</div>
</section>
@ -1198,6 +1236,11 @@
<button :class="['filter-chip', configEditorMode === 'diff' ? 'active' : '']" @click="configEditorMode = 'diff'">Diff</button>
<button :class="['filter-chip', configEditorMode === 'source' ? 'active' : '']" @click="configEditorMode = 'source'">源码</button>
</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 class="action-group">
@ -1362,6 +1405,28 @@
<span class="badge neutral">已选 {{ restartOps.selectedServices.length }} 项重启</span>
</div>
</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 class="panel release-status-strip sticky-release-strip">
@ -1433,13 +1498,11 @@
<header class="panel-header">
<div class="panel-title">
<h2>更新目标</h2>
<p>紧凑表格,长镜像悬停查看</p>
<p>紧凑表格</p>
</div>
<div class="panel-meta">
<span class="badge neutral">{{ releaseTargetRows.length }} 项</span>
<button class="refresh ghost" @click="refreshUpdateTargets" :disabled="updateOps.loading || updateOps.running">
{{ updateOps.loading ? '加载中...' : '仅刷新更新目标' }}
</button>
<span class="badge neutral">已选 {{ updateOps.selectedServices.length }} 项</span>
</div>
</header>
@ -1501,22 +1564,12 @@
<header class="panel-header">
<div class="panel-title">
<h2>执行日志</h2>
<p>任务、阶段、结果闭环</p>
<p>页内只看摘要,完整日志弹出</p>
</div>
<div class="panel-meta">
<span v-if="updateOps.operationId" class="badge neutral mono">任务 {{ updateOps.operationId }}</span>
<span class="badge neutral mono">阶段 {{ updateOps.stage || '-' }}</span>
<button class="refresh ghost" @click="fetchUpdateOperationStatus(updateOps.operationId)" :disabled="!updateOps.operationId || updateOps.running">刷新日志</button>
<a
v-if="updateOps.operationId"
class="refresh ghost"
:href="updateLogDownloadUrl()"
target="_blank"
rel="noopener"
download
>
下载日志
</a>
<button class="refresh ghost" @click="openUpdateLogViewer" :disabled="!updateOps.operationId && !updateOps.logs.length">查看日志</button>
</div>
</header>
@ -1527,8 +1580,19 @@
<span>影响服务 {{ updateAffectedServicesCount }}</span>
</div>
<section class="update-log-panel">
<pre id="service-update-log-output" class="update-log-output mono">{{ updateLogText || '等待更新日志...' }}</pre>
<section class="release-log-preview">
<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>
<div class="activity-list compact" v-if="releaseHistoryRows.length > 0">
@ -1632,17 +1696,14 @@
<header class="panel-header">
<div class="panel-title">
<h2>滚动重启</h2>
<p>紧凑选择表</p>
<p>按服务勾选执行</p>
</div>
<div class="panel-meta">
<span :class="['badge', restartOps.error ? 'bad' : restartOps.running ? 'warn' : 'ok']">
{{ restartOps.error ? '存在错误' : restartOps.running ? '执行中' : '可操作' }}
</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">
{{ restartOps.running ? '重启中...' : `滚动重启 ${restartOps.selectedServices.length} 项服务` }}
{{ restartOps.running ? '重启中...' : `执行重启 ${restartOps.selectedServices.length}` }}
</button>
</div>
</header>
@ -1722,6 +1783,38 @@
</details>
</section>
</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>
</body>

View File

@ -5,8 +5,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HY App Monitor Login</title>
<link rel="stylesheet" href="/app.css" />
<script defer src="/login.js"></script>
<link rel="stylesheet" href="app.css" />
<script defer src="login.js"></script>
</head>
<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 () => {
const bootIndicator = document.getElementById("boot-indicator");
const root = document.getElementById("login-root");
@ -42,7 +51,7 @@ window.addEventListener("DOMContentLoaded", async () => {
credentials: "same-origin",
});
if (response.ok) {
window.location.replace("/");
window.location.replace(nextLocation());
return;
}
@ -83,7 +92,7 @@ window.addEventListener("DOMContentLoaded", async () => {
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
window.location.replace("/");
window.location.replace(nextLocation());
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
} finally {

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]
Description=HY App Monitor
After=network-online.target
Wants=network-online.target
Description=HY App Monitor Gateway
After=network-online.target hy-app-monitor-yumi.service hy-app-monitor-haiyi.service
Wants=network-online.target hy-app-monitor-yumi.service hy-app-monitor-haiyi.service
[Service]
Type=simple

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