Split gateway and internal monitor services
This commit is contained in:
parent
c1f528a72e
commit
e01ca63ad8
@ -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
|
||||
|
||||
33
README.md
33
README.md
@ -1,10 +1,13 @@
|
||||
# hy-app-monitor
|
||||
|
||||
部署在 `deploy` 机器上的轻量监控面板。
|
||||
部署在 `deploy` 机器上的统一入口监控面板。
|
||||
|
||||
- 页面端口:`2026`
|
||||
- 页面入口:`http://43.164.75.199:2026/`
|
||||
- 对外入口端口:`2026`
|
||||
- 登录入口:`http://43.164.75.199:2026/login`
|
||||
- 入口选择页:`http://43.164.75.199:2026/select`
|
||||
- Yumi 入口:`http://43.164.75.199:2026/yumi/`
|
||||
- HaiYi 入口:`http://43.164.75.199:2026/haiyi/`
|
||||
- 内部实例:`gateway:2026`、`yumi:2027`、`haiyi:2028`
|
||||
- 服务健康:直接探测各业务服务健康接口
|
||||
- 主机资源:通过 `SSH` 采集 `CPU / 内存 / 磁盘 / 进程数`
|
||||
- Nacos:直连 Nacos OpenAPI,列出命名空间、服务和注册实例
|
||||
@ -14,8 +17,12 @@
|
||||
|
||||
## 目录
|
||||
|
||||
- `server.py`:后端入口
|
||||
- `monitor/`:Python 模块实现
|
||||
- `server.py`:gateway 入口
|
||||
- `yumi_server.py`:Yumi 内部实例入口
|
||||
- `haiyi_server.py`:HaiYi 内部实例入口
|
||||
- `gateway/`:统一登录、入口选择页和反代
|
||||
- `monitor/`:Yumi 监控与运维实现
|
||||
- `haiyi_monitor/`:HaiYi 占位实例
|
||||
- `config/targets.json`:监控目标和实例 ID
|
||||
- `static/`:前端页面
|
||||
- `scripts/`:启停、自启安装、前台运行脚本
|
||||
@ -32,6 +39,11 @@
|
||||
```bash
|
||||
APP_BIND=0.0.0.0
|
||||
APP_PORT=2026
|
||||
YUMI_INTERNAL_HOST=127.0.0.1
|
||||
YUMI_INTERNAL_PORT=2027
|
||||
HAIYI_INTERNAL_HOST=127.0.0.1
|
||||
HAIYI_INTERNAL_PORT=2028
|
||||
INTERNAL_PROXY_SHARED_SECRET=***
|
||||
AUTH_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3
|
||||
AUTH_SESSION_TTL_HOURS=24
|
||||
DEFAULT_REFRESH_INTERVAL_SECONDS=10
|
||||
@ -122,6 +134,12 @@ http://127.0.0.1:2026/
|
||||
http://127.0.0.1:2026/login
|
||||
```
|
||||
|
||||
登录后会进入:
|
||||
|
||||
```bash
|
||||
http://127.0.0.1:2026/select
|
||||
```
|
||||
|
||||
## systemd 开机自启
|
||||
|
||||
在远端项目目录执行:
|
||||
@ -171,8 +189,9 @@ python3 ops/deploy_via_ssh.py
|
||||
2. 把本地项目 `.env` 同步到远端项目 `.env`
|
||||
3. 创建远端 `.venv`
|
||||
4. 安装 `requirements-ops.txt`
|
||||
5. 安装并启用 `systemd`
|
||||
6. 重启服务并检查 `2026` 监听
|
||||
5. 安装并启用三套 `systemd` unit
|
||||
6. 重启 `gateway / yumi / haiyi`
|
||||
7. 检查 `2026` 监听
|
||||
|
||||
`ops/deploy_via_tat.py` 仍可保留成带外兜底,不建议继续作为日常主链路。
|
||||
|
||||
|
||||
1
gateway/__init__.py
Normal file
1
gateway/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
460
gateway/http_app.py
Normal file
460
gateway/http_app.py
Normal 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()
|
||||
1
haiyi_monitor/__init__.py
Normal file
1
haiyi_monitor/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
179
haiyi_monitor/http_app.py
Normal file
179
haiyi_monitor/http_app.py
Normal 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
18
haiyi_server.py
Normal 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()
|
||||
@ -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 名称固定成单值,前后端都按这个名字取。
|
||||
|
||||
@ -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
42
monitor/http_paths.py
Normal 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='')}"
|
||||
@ -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
|
||||
"""
|
||||
|
||||
@ -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
|
||||
"""
|
||||
|
||||
383
scripts/common.sh
Executable file → Normal file
383
scripts/common.sh
Executable file → Normal 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"
|
||||
service_display_name() {
|
||||
case "$1" in
|
||||
gateway) echo "gateway" ;;
|
||||
yumi) echo "yumi" ;;
|
||||
haiyi) echo "haiyi" ;;
|
||||
*)
|
||||
echo "unknown service: $1" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
||||
rm -f "$PID_FILE"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
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
|
||||
has_full_systemd_suite() {
|
||||
local name
|
||||
for name in $(suite_service_names); do
|
||||
if ! has_systemd_unit "$name"; then
|
||||
return 1
|
||||
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
|
||||
fi
|
||||
done < <(lsof -tiTCP:"$APP_PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
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
40
scripts/install_systemd.sh
Executable file → Normal 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
5
scripts/restart.sh
Executable file → Normal 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
7
scripts/run_foreground.sh
Executable file → Normal 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"
|
||||
|
||||
9
scripts/run_haiyi_foreground.sh
Normal file
9
scripts/run_haiyi_foreground.sh
Normal 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"
|
||||
14
scripts/run_yumi_foreground.sh
Normal file
14
scripts/run_yumi_foreground.sh
Normal 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
33
scripts/start.sh
Executable file → Normal 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
26
scripts/status.sh
Executable file → Normal 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
32
scripts/stop.sh
Executable file → Normal 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
16
scripts/uninstall_systemd.sh
Executable file → Normal 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"
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from monitor.http_app import main
|
||||
from gateway.http_app import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
497
static/app.css
497
static/app.css
@ -147,6 +147,72 @@ body.auth-page {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.entry-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.entry-grid,
|
||||
.entry-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.entry-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.entry-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 132px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 18px;
|
||||
background: color-mix(in oklab, var(--panel-strong) 88%, var(--accent-soft));
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.entry-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in oklab, var(--accent) 42%, var(--line-strong));
|
||||
background: color-mix(in oklab, var(--accent-soft) 74%, var(--panel-strong));
|
||||
}
|
||||
|
||||
.entry-card strong {
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.entry-card span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.entry-card-static {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.entry-actions,
|
||||
.entry-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.entry-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.entry-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.control-bar,
|
||||
.panel {
|
||||
@ -292,6 +358,11 @@ body.auth-page {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.release-section-nav {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.taskbar-context {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -319,6 +390,35 @@ body.auth-page {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
transition: color 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.section-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.section-link + .section-link {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.release-kind-select {
|
||||
min-width: 98px;
|
||||
}
|
||||
|
||||
.control-group-spread {
|
||||
justify-content: flex-end;
|
||||
flex: 1 1 auto;
|
||||
@ -379,6 +479,7 @@ body.auth-page {
|
||||
.refresh:focus-visible,
|
||||
.search:focus-visible,
|
||||
.interval-select:focus-visible,
|
||||
.section-link:focus-visible,
|
||||
.detail-log-input:focus-visible,
|
||||
.config-item:focus-visible,
|
||||
.config-textarea:focus-visible {
|
||||
@ -959,6 +1060,14 @@ body.auth-page {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-caption-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-caption {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
@ -967,6 +1076,16 @@ body.auth-page {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-caption-meta {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.host-row-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@ -1005,11 +1124,11 @@ body.auth-page {
|
||||
|
||||
.host-strip-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(164px, 208px) minmax(210px, 0.9fr) minmax(220px, 1.2fr) auto;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(180px, 220px) minmax(320px, 1fr) minmax(180px, 0.9fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 7px 11px 7px 16px;
|
||||
min-height: 56px;
|
||||
padding: 8px 11px 8px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@ -1038,39 +1157,121 @@ body.auth-page {
|
||||
}
|
||||
|
||||
.compact-metrics {
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compact-metrics span {
|
||||
.metric-ring-chip,
|
||||
.metric-stat-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 0 9px 0 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong));
|
||||
}
|
||||
|
||||
.metric-ring-chip.tone-ok,
|
||||
.metric-stat-chip.tone-ok {
|
||||
border-color: color-mix(in oklab, var(--ok) 28%, var(--line));
|
||||
}
|
||||
|
||||
.metric-ring-chip.tone-warn,
|
||||
.metric-stat-chip.tone-warn {
|
||||
border-color: color-mix(in oklab, var(--warn) 28%, var(--line));
|
||||
}
|
||||
|
||||
.metric-ring-chip.tone-bad,
|
||||
.metric-stat-chip.tone-bad {
|
||||
border-color: color-mix(in oklab, var(--bad) 28%, var(--line));
|
||||
}
|
||||
|
||||
.metric-ring {
|
||||
--metric-color: var(--line-strong);
|
||||
--metric-value: 0%;
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
conic-gradient(var(--metric-color) 0 var(--metric-value), color-mix(in oklab, var(--line) 88%, transparent) var(--metric-value) 100%);
|
||||
}
|
||||
|
||||
.metric-ring-hole {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.metric-ring-copy,
|
||||
.metric-stat-chip {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-ring-copy small,
|
||||
.metric-ring-copy strong,
|
||||
.metric-stat-chip small,
|
||||
.metric-stat-chip strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.metric-ring-copy small,
|
||||
.metric-stat-chip small {
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-ring-copy strong,
|
||||
.metric-stat-chip strong {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.host-alert-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.host-alert-summary small {
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.host-alert-path {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.host-alert-summary.is-alert .host-alert-path {
|
||||
.host-alert-summary.tone-bad .host-alert-path {
|
||||
color: color-mix(in oklab, var(--bad) 82%, var(--text));
|
||||
}
|
||||
|
||||
.host-alert-summary.is-quiet .host-alert-path {
|
||||
color: color-mix(in oklab, var(--muted) 88%, transparent);
|
||||
.host-alert-summary.tone-warn .host-alert-path {
|
||||
color: color-mix(in oklab, var(--warn) 72%, var(--text));
|
||||
}
|
||||
|
||||
.host-alert-summary.tone-neutral .host-alert-path {
|
||||
color: color-mix(in oklab, var(--muted) 76%, var(--text));
|
||||
}
|
||||
|
||||
.metric-chip,
|
||||
@ -1282,6 +1483,19 @@ body.auth-page {
|
||||
.config-sidebar {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 124px;
|
||||
max-height: calc(100vh - 140px);
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong));
|
||||
}
|
||||
|
||||
.config-sidebar-head {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-sidebar-meta {
|
||||
@ -1290,8 +1504,27 @@ body.auth-page {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-sidebar-path {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.config-sidebar-path strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-sidebar-path span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.config-list-ide {
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.config-group {
|
||||
@ -1328,7 +1561,9 @@ body.auth-page {
|
||||
|
||||
.config-group-items {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid color-mix(in oklab, var(--line) 88%, transparent);
|
||||
}
|
||||
|
||||
.config-item-ide {
|
||||
@ -1336,12 +1571,27 @@ body.auth-page {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 9px 10px;
|
||||
border-radius: 12px;
|
||||
padding: 8px 9px 8px 12px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
transition: background 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.config-item-ide::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.config-item-ide strong {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@ -1352,8 +1602,38 @@ body.auth-page {
|
||||
}
|
||||
|
||||
.config-item-ide small {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.config-item-ide.active {
|
||||
background: color-mix(in oklab, var(--accent-soft) 64%, var(--panel-strong));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 26%, var(--line));
|
||||
}
|
||||
|
||||
.config-item-ide:hover {
|
||||
transform: none;
|
||||
background: color-mix(in oklab, var(--accent-soft) 30%, var(--panel-strong));
|
||||
}
|
||||
|
||||
.config-item-ide.active::before {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.config-item-main,
|
||||
.config-item-side {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.config-item-side {
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.config-jump-group {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
@ -1498,6 +1778,25 @@ body.auth-page {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.release-action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: color-mix(in oklab, var(--panel-muted) 92%, var(--panel-strong));
|
||||
}
|
||||
|
||||
.release-action-summary {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.release-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
@ -1554,6 +1853,71 @@ body.auth-page {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.release-log-preview {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong));
|
||||
}
|
||||
|
||||
.release-log-preview-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.release-log-preview-head strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.release-log-snippets {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.release-log-snippet {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.release-log-snippet strong,
|
||||
.release-log-snippet span,
|
||||
.release-log-snippet p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.release-log-snippet strong {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.release-log-snippet span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.release-log-snippet p {
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.release-log-snippet.tone-bad {
|
||||
border-color: color-mix(in oklab, var(--bad) 28%, var(--line));
|
||||
background: color-mix(in oklab, var(--bad-soft) 44%, var(--panel-strong));
|
||||
}
|
||||
|
||||
.release-table td strong {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
@ -1924,6 +2288,60 @@ body.auth-page {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-viewer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 48;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 28px 20px;
|
||||
background: color-mix(in oklab, var(--text) 14%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.log-viewer {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
width: min(1120px, 100%);
|
||||
max-height: min(88vh, 920px);
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: color-mix(in oklab, var(--panel-strong) 96%, var(--panel-muted));
|
||||
box-shadow: 0 24px 64px rgba(29, 42, 68, 0.24);
|
||||
}
|
||||
|
||||
.log-viewer-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.log-viewer-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-viewer-output {
|
||||
margin: 0;
|
||||
min-height: 280px;
|
||||
max-height: calc(88vh - 176px);
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
border: 1px solid color-mix(in oklab, var(--line-strong) 78%, transparent);
|
||||
border-radius: 16px;
|
||||
background: oklch(22% 0.01 240 / 0.98);
|
||||
color: oklch(91% 0.01 230);
|
||||
font-size: 12px;
|
||||
line-height: 1.58;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -2017,7 +2435,8 @@ body.auth-page {
|
||||
|
||||
.infra-overview-grid,
|
||||
.mongo-group-grid,
|
||||
.release-status-grid {
|
||||
.release-status-grid,
|
||||
.release-log-snippets {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@ -2036,6 +2455,11 @@ body.auth-page {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.config-sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@ -2061,7 +2485,8 @@ body.auth-page {
|
||||
|
||||
.release-status-grid,
|
||||
.infra-overview-grid,
|
||||
.mongo-group-grid {
|
||||
.mongo-group-grid,
|
||||
.release-log-snippets {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@ -2144,6 +2569,14 @@ body.auth-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-caption-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.entry-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.host-service-pill,
|
||||
.restart-pills .config-item {
|
||||
width: 100%;
|
||||
@ -2153,7 +2586,12 @@ body.auth-page {
|
||||
.taskbar-inline,
|
||||
.metadata-strip,
|
||||
.precheck-strip,
|
||||
.activity-item-side {
|
||||
.activity-item-side,
|
||||
.release-action-bar,
|
||||
.release-action-summary,
|
||||
.log-viewer-head,
|
||||
.log-viewer-toolbar,
|
||||
.entry-actions {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
@ -2163,6 +2601,21 @@ body.auth-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.log-viewer-overlay {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.log-viewer {
|
||||
max-height: calc(100vh - 28px);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.release-action-bar,
|
||||
.log-viewer-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.diff-row {
|
||||
grid-template-columns: 34px 34px 22px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
|
||||
319
static/app.js
319
static/app.js
@ -1,6 +1,7 @@
|
||||
const { createApp } = Vue;
|
||||
|
||||
const APP_TEMPLATE = document.getElementById("app")?.innerHTML || "";
|
||||
const APP_BASE_PATH = window.location.pathname === "/yumi" || window.location.pathname.startsWith("/yumi/") ? "/yumi" : "";
|
||||
const STORAGE_KEY = "hy-app-monitor-refresh-interval";
|
||||
const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation";
|
||||
const GROUP_LABELS = {
|
||||
@ -52,6 +53,20 @@ const INFRA_TABS = [
|
||||
{ id: "tat", label: "TAT" },
|
||||
];
|
||||
|
||||
function rootPath(path) {
|
||||
const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function appPath(path) {
|
||||
const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`;
|
||||
return `${APP_BASE_PATH}${normalized}` || "/";
|
||||
}
|
||||
|
||||
function loginRedirectPath() {
|
||||
return rootPath(`/login?next=${encodeURIComponent(`${window.location.pathname}${window.location.search}`)}`);
|
||||
}
|
||||
|
||||
createApp({
|
||||
template: APP_TEMPLATE,
|
||||
data() {
|
||||
@ -254,6 +269,10 @@ createApp({
|
||||
exportMaxLines: 10000,
|
||||
exporting: false,
|
||||
},
|
||||
logViewer: {
|
||||
open: false,
|
||||
kind: "",
|
||||
},
|
||||
timer: null,
|
||||
};
|
||||
},
|
||||
@ -297,6 +316,12 @@ createApp({
|
||||
filteredHostRows() {
|
||||
return this.buildHostRows({ businessOnly: false, applyFilter: true, applyKeyword: true });
|
||||
},
|
||||
filteredHostAlertTotal() {
|
||||
return this.filteredHostRows.filter((host) => host.hasAlert).length;
|
||||
},
|
||||
filteredHostMetricIssueTotal() {
|
||||
return this.filteredHostRows.filter((host) => host.hasMetricIssue).length;
|
||||
},
|
||||
overviewHostRowGroups() {
|
||||
return this.groupHosts(this.overviewHostRows);
|
||||
},
|
||||
@ -422,6 +447,24 @@ createApp({
|
||||
group.items.some((item) => this.configKey(item.group, item.dataId) === this.nacosConfig.selectedKey)
|
||||
)?.key || "";
|
||||
},
|
||||
flatNacosConfigKeys() {
|
||||
return this.nacosConfigGroups.flatMap((group) =>
|
||||
group.items.map((item) => this.configKey(item.group, item.dataId))
|
||||
);
|
||||
},
|
||||
selectedNacosConfigIndex() {
|
||||
return this.flatNacosConfigKeys.indexOf(this.nacosConfig.selectedKey);
|
||||
},
|
||||
selectedPrevNacosConfigKey() {
|
||||
return this.selectedNacosConfigIndex > 0
|
||||
? this.flatNacosConfigKeys[this.selectedNacosConfigIndex - 1]
|
||||
: "";
|
||||
},
|
||||
selectedNextNacosConfigKey() {
|
||||
return this.selectedNacosConfigIndex >= 0 && this.selectedNacosConfigIndex < this.flatNacosConfigKeys.length - 1
|
||||
? this.flatNacosConfigKeys[this.selectedNacosConfigIndex + 1]
|
||||
: "";
|
||||
},
|
||||
configHistoryRows() {
|
||||
const keyword = this.nacosConfig.keyword.trim().toLowerCase();
|
||||
return this.configHistory.filter((item) => (
|
||||
@ -634,7 +677,7 @@ createApp({
|
||||
},
|
||||
{
|
||||
label: "最近日志",
|
||||
value: lastLog?.message || "-",
|
||||
value: lastLog?.message ? this.truncateMiddle(lastLog.message, 24, 14) : "-",
|
||||
tone: lastLog?.level === "error" ? "bad" : "neutral",
|
||||
},
|
||||
];
|
||||
@ -837,6 +880,15 @@ createApp({
|
||||
})
|
||||
.join("\n");
|
||||
},
|
||||
recentUpdateLogs() {
|
||||
return (this.updateOps.logs || [])
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
.map((entry, index) => ({
|
||||
...entry,
|
||||
key: `${entry.time || "log"}-${entry.stage || "stage"}-${index}`,
|
||||
}));
|
||||
},
|
||||
serviceLogDownloadUrl() {
|
||||
if (!this.serviceLog.service || !this.serviceLog.host) {
|
||||
return "";
|
||||
@ -854,6 +906,71 @@ createApp({
|
||||
});
|
||||
return `/api/ops/service-log/export?${query.toString()}`;
|
||||
},
|
||||
activeLogViewerTitle() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return `${this.serviceLog.service || "-"} · ${this.serviceLog.host || "-"}`;
|
||||
}
|
||||
return "执行日志";
|
||||
},
|
||||
activeLogViewerSubtitle() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
const subtitleParts = [];
|
||||
if (this.serviceLog.container) {
|
||||
subtitleParts.push(this.serviceLog.container);
|
||||
}
|
||||
subtitleParts.push(`更新于 ${this.formatDateTime(this.serviceLog.updatedAt)}`);
|
||||
return subtitleParts.join(" · ");
|
||||
}
|
||||
return `任务 ${this.updateOps.operationId || "-"} · ${this.updateStatusLabel()} · ${this.formatDateTime(this.updateOps.finishedAt || this.updateOps.startedAt)}`;
|
||||
},
|
||||
activeLogViewerBadge() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return this.serviceLog.container || "";
|
||||
}
|
||||
return this.updateOps.operationId || "";
|
||||
},
|
||||
activeLogViewerSecondaryBadge() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return `最近 ${this.serviceLog.previewLines} 条`;
|
||||
}
|
||||
return this.updateOps.stage ? `阶段 ${this.updateOps.stage}` : "";
|
||||
},
|
||||
activeLogViewerContent() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return this.serviceLog.content;
|
||||
}
|
||||
return this.updateLogText;
|
||||
},
|
||||
activeLogViewerError() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return this.serviceLog.error;
|
||||
}
|
||||
return this.updateOps.error ? this.explainError(this.updateOps.error) : "";
|
||||
},
|
||||
activeLogViewerLoading() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return this.serviceLog.loading;
|
||||
}
|
||||
return this.updateOps.statusRequestInFlight;
|
||||
},
|
||||
activeLogViewerExporting() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return this.serviceLog.exporting;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
activeLogViewerRefreshDisabled() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.loading;
|
||||
}
|
||||
return !this.updateOps.operationId || this.updateOps.statusRequestInFlight;
|
||||
},
|
||||
activeLogViewerDownloadDisabled() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.exporting;
|
||||
}
|
||||
return !this.updateOps.operationId;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
refreshIntervalSeconds() {
|
||||
@ -884,9 +1001,20 @@ createApp({
|
||||
group: host.group,
|
||||
label: this.groupLabel(host.group),
|
||||
items: [],
|
||||
alertCount: 0,
|
||||
metricIssueCount: 0,
|
||||
serviceCount: 0,
|
||||
});
|
||||
}
|
||||
groups.get(host.group).items.push(host);
|
||||
const target = groups.get(host.group);
|
||||
target.items.push(host);
|
||||
target.serviceCount += host.serviceTotal || 0;
|
||||
if (host.hasAlert) {
|
||||
target.alertCount += 1;
|
||||
}
|
||||
if (host.hasMetricIssue) {
|
||||
target.metricIssueCount += 1;
|
||||
}
|
||||
});
|
||||
return [...groups.values()].sort((left, right) => this.groupRank(left.group) - this.groupRank(right.group));
|
||||
},
|
||||
@ -1059,9 +1187,50 @@ createApp({
|
||||
}
|
||||
this.collapsedConfigGroups = [...next];
|
||||
},
|
||||
syncConfigExplorerState(selectedKey = this.nacosConfig.selectedKey, { force = false } = {}) {
|
||||
const groupKeys = [...new Set((this.nacosConfig.items || []).map((item) => item.group || "default"))];
|
||||
if (groupKeys.length === 0) {
|
||||
this.collapsedConfigGroups = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedItem = (this.nacosConfig.items || []).find((item) => this.configKey(item.group, item.dataId) === selectedKey);
|
||||
const selectedGroup = selectedItem?.group || groupKeys[0];
|
||||
|
||||
if (force || this.collapsedConfigGroups.length === 0) {
|
||||
this.collapsedConfigGroups = groupKeys.filter((groupKey) => groupKey !== selectedGroup);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.collapsedConfigGroups.includes(selectedGroup)) {
|
||||
this.collapsedConfigGroups = this.collapsedConfigGroups.filter((groupKey) => groupKey !== selectedGroup);
|
||||
}
|
||||
},
|
||||
isConfigGroupCollapsed(groupKey) {
|
||||
return this.collapsedConfigGroups.includes(groupKey);
|
||||
},
|
||||
scrollSelectedConfigItemIntoView() {
|
||||
this.$nextTick(() => {
|
||||
const node = document.getElementById(`config-item-${this.nacosConfig.selectedKey}`);
|
||||
if (node) {
|
||||
node.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
});
|
||||
},
|
||||
openPrevNacosConfig() {
|
||||
if (!this.selectedPrevNacosConfigKey) {
|
||||
return;
|
||||
}
|
||||
this.nacosConfig.selectedKey = this.selectedPrevNacosConfigKey;
|
||||
this.openNacosConfigByKey(this.selectedPrevNacosConfigKey);
|
||||
},
|
||||
openNextNacosConfig() {
|
||||
if (!this.selectedNextNacosConfigKey) {
|
||||
return;
|
||||
}
|
||||
this.nacosConfig.selectedKey = this.selectedNextNacosConfigKey;
|
||||
this.openNacosConfigByKey(this.selectedNextNacosConfigKey);
|
||||
},
|
||||
toggleNacosService(serviceName) {
|
||||
const next = new Set(this.expandedNacosServices);
|
||||
if (next.has(serviceName)) {
|
||||
@ -1138,6 +1307,47 @@ createApp({
|
||||
this.selectedServiceKey = serviceKey;
|
||||
this.detailPanelOpen = true;
|
||||
},
|
||||
openUpdateLogViewer() {
|
||||
this.logViewer.open = true;
|
||||
this.logViewer.kind = "update";
|
||||
this.$nextTick(() => this.scrollUpdateLogToBottom());
|
||||
},
|
||||
closeLogViewer() {
|
||||
const previousKind = this.logViewer.kind;
|
||||
this.logViewer.open = false;
|
||||
this.logViewer.kind = "";
|
||||
if (previousKind === "service") {
|
||||
this.clearServiceLog();
|
||||
}
|
||||
},
|
||||
async refreshActiveLogViewer() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
if (!this.serviceLog.host) {
|
||||
return;
|
||||
}
|
||||
await this.openServiceLog({ host: this.serviceLog.host });
|
||||
return;
|
||||
}
|
||||
if (!this.updateOps.operationId) {
|
||||
return;
|
||||
}
|
||||
await this.fetchUpdateOperationStatus(this.updateOps.operationId);
|
||||
this.scrollUpdateLogToBottom();
|
||||
},
|
||||
async downloadActiveLogViewer() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
await this.downloadServiceLog();
|
||||
return;
|
||||
}
|
||||
if (!this.updateOps.operationId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.downloadFile(this.updateLogDownloadUrl(), `service-update-${this.updateOps.operationId}.log`);
|
||||
} catch (error) {
|
||||
this.updateOps.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
clearServiceLog() {
|
||||
this.serviceLog.loading = false;
|
||||
this.serviceLog.exporting = false;
|
||||
@ -1156,13 +1366,21 @@ createApp({
|
||||
return;
|
||||
}
|
||||
if (!this.selectedServiceDetail || this.selectedServiceDetail.service !== this.serviceLog.service) {
|
||||
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) {
|
||||
if (this.logViewer.kind === "service" && this.logViewer.open) {
|
||||
this.closeLogViewer();
|
||||
} else {
|
||||
this.clearServiceLog();
|
||||
}
|
||||
}
|
||||
},
|
||||
isServiceLogActive(instance) {
|
||||
return this.serviceLog.service === (this.selectedServiceDetail?.service || "") && this.serviceLog.host === instance.host;
|
||||
@ -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
50
static/haiyi.html
Normal 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
129
static/haiyi.js
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -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>
|
||||
@ -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-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-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-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)"
|
||||
>
|
||||
<div class="config-item-main">
|
||||
<strong>{{ item.dataId }}</strong>
|
||||
<span>{{ item.type || 'text' }}</span>
|
||||
<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>
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
51
static/select.html
Normal 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
82
static/select.js
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
16
systemd/hy-app-monitor-haiyi.service
Normal file
16
systemd/hy-app-monitor-haiyi.service
Normal 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
|
||||
16
systemd/hy-app-monitor-yumi.service
Normal file
16
systemd/hy-app-monitor-yumi.service
Normal 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
|
||||
@ -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
18
yumi_server.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user