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_BIND=0.0.0.0
|
||||||
APP_PORT=2026
|
APP_PORT=2026
|
||||||
|
YUMI_INTERNAL_HOST=127.0.0.1
|
||||||
|
YUMI_INTERNAL_PORT=2027
|
||||||
|
HAIYI_INTERNAL_HOST=127.0.0.1
|
||||||
|
HAIYI_INTERNAL_PORT=2028
|
||||||
|
INTERNAL_PROXY_SHARED_SECRET=change-me
|
||||||
|
GATEWAY_PROXY_TIMEOUT_SECONDS=600
|
||||||
AUTH_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3
|
AUTH_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3
|
||||||
AUTH_SESSION_TTL_HOURS=24
|
AUTH_SESSION_TTL_HOURS=24
|
||||||
AUTH_COOKIE_SECURE=0
|
AUTH_COOKIE_SECURE=0
|
||||||
|
|||||||
33
README.md
33
README.md
@ -1,10 +1,13 @@
|
|||||||
# hy-app-monitor
|
# hy-app-monitor
|
||||||
|
|
||||||
部署在 `deploy` 机器上的轻量监控面板。
|
部署在 `deploy` 机器上的统一入口监控面板。
|
||||||
|
|
||||||
- 页面端口:`2026`
|
- 对外入口端口:`2026`
|
||||||
- 页面入口:`http://43.164.75.199:2026/`
|
|
||||||
- 登录入口:`http://43.164.75.199:2026/login`
|
- 登录入口:`http://43.164.75.199:2026/login`
|
||||||
|
- 入口选择页:`http://43.164.75.199:2026/select`
|
||||||
|
- Yumi 入口:`http://43.164.75.199:2026/yumi/`
|
||||||
|
- HaiYi 入口:`http://43.164.75.199:2026/haiyi/`
|
||||||
|
- 内部实例:`gateway:2026`、`yumi:2027`、`haiyi:2028`
|
||||||
- 服务健康:直接探测各业务服务健康接口
|
- 服务健康:直接探测各业务服务健康接口
|
||||||
- 主机资源:通过 `SSH` 采集 `CPU / 内存 / 磁盘 / 进程数`
|
- 主机资源:通过 `SSH` 采集 `CPU / 内存 / 磁盘 / 进程数`
|
||||||
- Nacos:直连 Nacos OpenAPI,列出命名空间、服务和注册实例
|
- Nacos:直连 Nacos OpenAPI,列出命名空间、服务和注册实例
|
||||||
@ -14,8 +17,12 @@
|
|||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- `server.py`:后端入口
|
- `server.py`:gateway 入口
|
||||||
- `monitor/`:Python 模块实现
|
- `yumi_server.py`:Yumi 内部实例入口
|
||||||
|
- `haiyi_server.py`:HaiYi 内部实例入口
|
||||||
|
- `gateway/`:统一登录、入口选择页和反代
|
||||||
|
- `monitor/`:Yumi 监控与运维实现
|
||||||
|
- `haiyi_monitor/`:HaiYi 占位实例
|
||||||
- `config/targets.json`:监控目标和实例 ID
|
- `config/targets.json`:监控目标和实例 ID
|
||||||
- `static/`:前端页面
|
- `static/`:前端页面
|
||||||
- `scripts/`:启停、自启安装、前台运行脚本
|
- `scripts/`:启停、自启安装、前台运行脚本
|
||||||
@ -32,6 +39,11 @@
|
|||||||
```bash
|
```bash
|
||||||
APP_BIND=0.0.0.0
|
APP_BIND=0.0.0.0
|
||||||
APP_PORT=2026
|
APP_PORT=2026
|
||||||
|
YUMI_INTERNAL_HOST=127.0.0.1
|
||||||
|
YUMI_INTERNAL_PORT=2027
|
||||||
|
HAIYI_INTERNAL_HOST=127.0.0.1
|
||||||
|
HAIYI_INTERNAL_PORT=2028
|
||||||
|
INTERNAL_PROXY_SHARED_SECRET=***
|
||||||
AUTH_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3
|
AUTH_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3
|
||||||
AUTH_SESSION_TTL_HOURS=24
|
AUTH_SESSION_TTL_HOURS=24
|
||||||
DEFAULT_REFRESH_INTERVAL_SECONDS=10
|
DEFAULT_REFRESH_INTERVAL_SECONDS=10
|
||||||
@ -122,6 +134,12 @@ http://127.0.0.1:2026/
|
|||||||
http://127.0.0.1:2026/login
|
http://127.0.0.1:2026/login
|
||||||
```
|
```
|
||||||
|
|
||||||
|
登录后会进入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
http://127.0.0.1:2026/select
|
||||||
|
```
|
||||||
|
|
||||||
## systemd 开机自启
|
## systemd 开机自启
|
||||||
|
|
||||||
在远端项目目录执行:
|
在远端项目目录执行:
|
||||||
@ -171,8 +189,9 @@ python3 ops/deploy_via_ssh.py
|
|||||||
2. 把本地项目 `.env` 同步到远端项目 `.env`
|
2. 把本地项目 `.env` 同步到远端项目 `.env`
|
||||||
3. 创建远端 `.venv`
|
3. 创建远端 `.venv`
|
||||||
4. 安装 `requirements-ops.txt`
|
4. 安装 `requirements-ops.txt`
|
||||||
5. 安装并启用 `systemd`
|
5. 安装并启用三套 `systemd` unit
|
||||||
6. 重启服务并检查 `2026` 监听
|
6. 重启 `gateway / yumi / haiyi`
|
||||||
|
7. 检查 `2026` 监听
|
||||||
|
|
||||||
`ops/deploy_via_tat.py` 仍可保留成带外兜底,不建议继续作为日常主链路。
|
`ops/deploy_via_tat.py` 仍可保留成带外兜底,不建议继续作为日常主链路。
|
||||||
|
|
||||||
|
|||||||
1
gateway/__init__.py
Normal file
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"
|
APP_BIND = env_str("APP_BIND", "0.0.0.0") or "0.0.0.0"
|
||||||
# HTTP 服务监听端口。
|
# HTTP 服务监听端口。
|
||||||
APP_PORT = env_int("APP_PORT", 2026)
|
APP_PORT = env_int("APP_PORT", 2026)
|
||||||
|
# 当前 HTTP 应用挂载前缀原始值。
|
||||||
|
RAW_APP_BASE_PATH = env_str("APP_BASE_PATH", "").strip()
|
||||||
|
# 当前 HTTP 应用挂载前缀;空串表示直接挂在根路径。
|
||||||
|
APP_BASE_PATH = "/" + RAW_APP_BASE_PATH.strip("/") if RAW_APP_BASE_PATH else ""
|
||||||
|
# Yumi 内部实例固定走本机回环地址。
|
||||||
|
YUMI_INTERNAL_HOST = env_str("YUMI_INTERNAL_HOST", "127.0.0.1") or "127.0.0.1"
|
||||||
|
# Yumi 内部实例监听端口。
|
||||||
|
YUMI_INTERNAL_PORT = env_int("YUMI_INTERNAL_PORT", 2027)
|
||||||
|
# HaiYi 内部实例固定走本机回环地址。
|
||||||
|
HAIYI_INTERNAL_HOST = env_str("HAIYI_INTERNAL_HOST", "127.0.0.1") or "127.0.0.1"
|
||||||
|
# HaiYi 内部实例监听端口。
|
||||||
|
HAIYI_INTERNAL_PORT = env_int("HAIYI_INTERNAL_PORT", 2028)
|
||||||
|
# 网关反代到内部实例时统一带这一段共享密钥。
|
||||||
|
INTERNAL_PROXY_SHARED_SECRET = env_str("INTERNAL_PROXY_SHARED_SECRET", "hy-app-monitor-internal")
|
||||||
|
# 网关反代内部实例时的统一超时秒数。
|
||||||
|
GATEWAY_PROXY_TIMEOUT_SECONDS = env_int("GATEWAY_PROXY_TIMEOUT_SECONDS", 600)
|
||||||
# 本地账号库统一落到 run 目录,避免写入版本库。
|
# 本地账号库统一落到 run 目录,避免写入版本库。
|
||||||
AUTH_DB_PATH = Path(env_str("AUTH_DB_PATH", str(RUNTIME_DIR / "auth.sqlite3"))).expanduser()
|
AUTH_DB_PATH = Path(env_str("AUTH_DB_PATH", str(RUNTIME_DIR / "auth.sqlite3"))).expanduser()
|
||||||
# 会话 Cookie 名称固定成单值,前后端都按这个名字取。
|
# 会话 Cookie 名称固定成单值,前后端都按这个名字取。
|
||||||
|
|||||||
@ -1,24 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from .auth import (
|
from .config import APP_BASE_PATH, APP_BIND, APP_PORT, INTERNAL_PROXY_SHARED_SECRET, STATIC_DIR, utc_now
|
||||||
build_clear_session_cookie,
|
|
||||||
build_session_cookie,
|
|
||||||
create_session,
|
|
||||||
delete_session,
|
|
||||||
ensure_auth_store,
|
|
||||||
get_session,
|
|
||||||
parse_session_token,
|
|
||||||
authenticate_user,
|
|
||||||
)
|
|
||||||
from .config import APP_BIND, APP_PORT, STATIC_DIR, utc_now
|
|
||||||
from .dashboard import build_monitor_payload
|
from .dashboard import build_monitor_payload
|
||||||
|
from .http_paths import join_base_path, strip_base_path
|
||||||
from .nacos import (
|
from .nacos import (
|
||||||
get_nacos_config_list_payload,
|
get_nacos_config_list_payload,
|
||||||
get_nacos_config_payload,
|
get_nacos_config_payload,
|
||||||
@ -38,90 +29,96 @@ def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
class MonitorHandler(BaseHTTPRequestHandler):
|
class MonitorHandler(BaseHTTPRequestHandler):
|
||||||
# 自定义服务名,便于 curl 时识别来源。
|
# 单独标出这是 Yumi 内部实例,排查反代链路更直接。
|
||||||
server_version = "HyAppMonitor/2.0"
|
server_version = "HyAppMonitorYumi/2.0"
|
||||||
|
|
||||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
|
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
|
||||||
# 关闭默认访问日志,避免轮询刷屏。
|
# 内部轮询很多,关闭默认访问日志。
|
||||||
return
|
return
|
||||||
|
|
||||||
def current_session_token(self) -> str:
|
|
||||||
# 同一个请求对象里只解析一次 Cookie。
|
|
||||||
if not hasattr(self, "_cached_session_token"):
|
|
||||||
self._cached_session_token = parse_session_token(self.headers.get("Cookie"))
|
|
||||||
return str(self._cached_session_token)
|
|
||||||
|
|
||||||
def current_session(self) -> Any:
|
|
||||||
# 同一个请求对象里也只查一次会话库。
|
|
||||||
if not hasattr(self, "_cached_session"):
|
|
||||||
self._cached_session = get_session(self.current_session_token())
|
|
||||||
return self._cached_session
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
def do_GET(self) -> None: # noqa: N802
|
||||||
# GET 请求需要响应头和响应体。
|
# 只接受来自统一网关的代理请求。
|
||||||
|
if not self.require_internal_proxy(send_body=True):
|
||||||
|
return
|
||||||
self.dispatch(send_body=True)
|
self.dispatch(send_body=True)
|
||||||
|
|
||||||
def do_HEAD(self) -> None: # noqa: N802
|
def do_HEAD(self) -> None: # noqa: N802
|
||||||
# HEAD 请求只返回响应头。
|
# HEAD 请求同样只允许内部网关进入。
|
||||||
|
if not self.require_internal_proxy(send_body=False):
|
||||||
|
return
|
||||||
self.dispatch(send_body=False)
|
self.dispatch(send_body=False)
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802
|
def do_POST(self) -> None: # noqa: N802
|
||||||
# POST 请求主要用于配置保存。
|
# POST 接口也必须经过统一入口。
|
||||||
|
if not self.require_internal_proxy(send_body=True):
|
||||||
|
return
|
||||||
self.dispatch_post()
|
self.dispatch_post()
|
||||||
|
|
||||||
|
def require_internal_proxy(self, send_body: bool) -> bool:
|
||||||
|
# 内部实例除了共享密钥,还要求请求确实来自本机回环地址。
|
||||||
|
# 这样就算运维误把端口绑到了 0.0.0.0,外部流量也不能直接绕过网关。
|
||||||
|
actual_secret = str(self.headers.get("X-HY-Internal-Proxy-Secret") or "").strip()
|
||||||
|
if self.is_loopback_client() and actual_secret == INTERNAL_PROXY_SHARED_SECRET:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.send_error_payload(
|
||||||
|
HTTPStatus.FORBIDDEN,
|
||||||
|
"internal proxy authorization required",
|
||||||
|
send_body=send_body,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_loopback_client(self) -> bool:
|
||||||
|
# BaseHTTPRequestHandler 会把来源地址放在 client_address 第一个槽位。
|
||||||
|
remote_host = str((self.client_address or ("", 0))[0]).strip()
|
||||||
|
if not remote_host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# IPv6 链路本地格式可能带 zone index,这里先切掉再判断。
|
||||||
|
return ipaddress.ip_address(remote_host.split("%", 1)[0]).is_loopback
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
def dispatch(self, send_body: bool) -> None:
|
def dispatch(self, send_body: bool) -> None:
|
||||||
# 只解析 URL 的 path 段。
|
# 统一先拆 URL,后面都只操作 path 和 query。
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
# 顺手解析 query 参数,后面接口直接取。
|
|
||||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
local_path = strip_base_path(APP_BASE_PATH, parsed.path)
|
||||||
|
|
||||||
# 登录页对未登录用户开放,已登录时直接回总览。
|
# 不属于当前应用 base path 的请求直接 404。
|
||||||
if parsed.path in {"/login", "/login.html"}:
|
if local_path is None:
|
||||||
if self.current_session():
|
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
|
||||||
return self.redirect("/", send_body=send_body)
|
|
||||||
return self.serve_static("login.html", "text/html; charset=utf-8", send_body=send_body)
|
|
||||||
|
|
||||||
# 总览页需要先过登录校验,未登录统一跳到登录页。
|
# /yumi 必须补成 /yumi/,这样相对静态资源路径才稳定。
|
||||||
if parsed.path in {"/", "/index.html"}:
|
if APP_BASE_PATH and parsed.path == APP_BASE_PATH:
|
||||||
if not self.current_session():
|
return self.redirect(join_base_path(APP_BASE_PATH, "/"), send_body=send_body)
|
||||||
return self.redirect("/login", send_body=send_body)
|
|
||||||
|
# Yumi 首页直接返回当前监控面板。
|
||||||
|
if local_path in {"/", "/index.html"}:
|
||||||
return self.serve_static("index.html", "text/html; charset=utf-8", send_body=send_body)
|
return self.serve_static("index.html", "text/html; charset=utf-8", send_body=send_body)
|
||||||
|
|
||||||
# CSS 文件。
|
# 前端静态资源统一挂在应用前缀下。
|
||||||
if parsed.path == "/app.css":
|
if local_path == "/app.css":
|
||||||
return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body)
|
return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body)
|
||||||
|
|
||||||
# 登录页脚本。
|
if local_path == "/app.js":
|
||||||
if parsed.path == "/login.js":
|
|
||||||
return self.serve_static("login.js", "application/javascript; charset=utf-8", send_body=send_body)
|
|
||||||
|
|
||||||
# 前端业务 JS。
|
|
||||||
if parsed.path == "/app.js":
|
|
||||||
return self.serve_static("app.js", "application/javascript; charset=utf-8", send_body=send_body)
|
return self.serve_static("app.js", "application/javascript; charset=utf-8", send_body=send_body)
|
||||||
|
|
||||||
# 本地 Vue 运行时。
|
if local_path == "/vue.js":
|
||||||
if parsed.path == "/vue.js":
|
|
||||||
return self.serve_static("vue.js", "application/javascript; charset=utf-8", send_body=send_body)
|
return self.serve_static("vue.js", "application/javascript; charset=utf-8", send_body=send_body)
|
||||||
|
|
||||||
# 会话查询接口单独开放给登录页和主页面做状态判断。
|
# 内部实例自身的健康检查。
|
||||||
if parsed.path == "/api/auth/session":
|
if local_path == "/api/health":
|
||||||
return self.handle_auth_session(send_body=send_body)
|
|
||||||
|
|
||||||
# 除鉴权接口外,其余 API 一律要求带有效会话。
|
|
||||||
if parsed.path.startswith("/api/") and not self.require_authentication(send_body=send_body):
|
|
||||||
return
|
|
||||||
|
|
||||||
# 轻量健康检查。
|
|
||||||
if parsed.path == "/api/health":
|
|
||||||
return self.send_payload(
|
return self.send_payload(
|
||||||
HTTPStatus.OK,
|
HTTPStatus.OK,
|
||||||
response_json({"status": "ok", "time": utc_now()}),
|
response_json({"status": "ok", "time": utc_now(), "app": "yumi"}),
|
||||||
"application/json; charset=utf-8",
|
"application/json; charset=utf-8",
|
||||||
send_body=send_body,
|
send_body=send_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 主监控接口。
|
# 主监控接口。
|
||||||
if parsed.path == "/api/monitor/services":
|
if local_path == "/api/monitor/services":
|
||||||
return self.send_payload(
|
return self.send_payload(
|
||||||
HTTPStatus.OK,
|
HTTPStatus.OK,
|
||||||
response_json(build_monitor_payload()),
|
response_json(build_monitor_payload()),
|
||||||
@ -130,7 +127,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 返回当前 namespace 下全部 Nacos 配置元数据。
|
# 返回当前 namespace 下全部 Nacos 配置元数据。
|
||||||
if parsed.path == "/api/nacos/configs":
|
if local_path == "/api/nacos/configs":
|
||||||
try:
|
try:
|
||||||
payload = get_nacos_config_list_payload()
|
payload = get_nacos_config_list_payload()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
@ -144,7 +141,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 返回单个配置的原始文本内容。
|
# 返回单个配置的原始文本内容。
|
||||||
if parsed.path == "/api/nacos/config":
|
if local_path == "/api/nacos/config":
|
||||||
group_name = self.query_value(query, "group")
|
group_name = self.query_value(query, "group")
|
||||||
data_id = self.query_value(query, "dataId")
|
data_id = self.query_value(query, "dataId")
|
||||||
|
|
||||||
@ -168,7 +165,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 返回当前线上 golang 的运行配置。
|
# 返回当前线上 golang 的运行配置。
|
||||||
if parsed.path == "/api/runtime/golang-config":
|
if local_path == "/api/runtime/golang-config":
|
||||||
try:
|
try:
|
||||||
payload = get_go_config_payload()
|
payload = get_go_config_payload()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
@ -182,7 +179,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 返回可滚动重启的业务服务。
|
# 返回可滚动重启的业务服务。
|
||||||
if parsed.path == "/api/ops/restart-targets":
|
if local_path == "/api/ops/restart-targets":
|
||||||
try:
|
try:
|
||||||
payload = build_restart_targets_payload()
|
payload = build_restart_targets_payload()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
@ -196,7 +193,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 返回可构建并发布的业务服务。
|
# 返回可构建并发布的业务服务。
|
||||||
if parsed.path == "/api/ops/update-targets":
|
if local_path == "/api/ops/update-targets":
|
||||||
try:
|
try:
|
||||||
payload = build_update_targets_payload()
|
payload = build_update_targets_payload()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
@ -210,7 +207,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 返回当前服务更新任务的阶段和日志。
|
# 返回当前服务更新任务的阶段和日志。
|
||||||
if parsed.path == "/api/ops/update-status":
|
if local_path == "/api/ops/update-status":
|
||||||
operation_id = self.query_value(query, "id")
|
operation_id = self.query_value(query, "id")
|
||||||
if not operation_id:
|
if not operation_id:
|
||||||
return self.send_error_payload(
|
return self.send_error_payload(
|
||||||
@ -234,7 +231,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 返回单个服务更新任务的完整文本日志。
|
# 返回单个服务更新任务的完整文本日志。
|
||||||
if parsed.path == "/api/ops/update-log":
|
if local_path == "/api/ops/update-log":
|
||||||
operation_id = self.query_value(query, "id")
|
operation_id = self.query_value(query, "id")
|
||||||
if not operation_id:
|
if not operation_id:
|
||||||
return self.send_error_payload(
|
return self.send_error_payload(
|
||||||
@ -258,7 +255,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 返回单个服务实例最近 1000 条容器日志。
|
# 返回单个服务实例最近 1000 条容器日志。
|
||||||
if parsed.path == "/api/ops/service-log":
|
if local_path == "/api/ops/service-log":
|
||||||
service_name = self.query_value(query, "service")
|
service_name = self.query_value(query, "service")
|
||||||
host_name = self.query_value(query, "host")
|
host_name = self.query_value(query, "host")
|
||||||
if not service_name or not host_name:
|
if not service_name or not host_name:
|
||||||
@ -283,7 +280,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 导出单个服务实例指定条数的容器日志。
|
# 导出单个服务实例指定条数的容器日志。
|
||||||
if parsed.path == "/api/ops/service-log/export":
|
if local_path == "/api/ops/service-log/export":
|
||||||
service_name = self.query_value(query, "service")
|
service_name = self.query_value(query, "service")
|
||||||
host_name = self.query_value(query, "host")
|
host_name = self.query_value(query, "host")
|
||||||
lines = self.query_value(query, "lines")
|
lines = self.query_value(query, "lines")
|
||||||
@ -313,7 +310,7 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# favicon 直接返回空。
|
# favicon 直接返回空。
|
||||||
if parsed.path == "/favicon.ico":
|
if local_path == "/favicon.ico":
|
||||||
return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body)
|
return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body)
|
||||||
|
|
||||||
# 其他路径统一 404。
|
# 其他路径统一 404。
|
||||||
@ -322,137 +319,36 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
def dispatch_post(self) -> None:
|
def dispatch_post(self) -> None:
|
||||||
# 只解析 path 段,POST 不处理复杂 query。
|
# 只解析 path 段,POST 不处理复杂 query。
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
|
local_path = strip_base_path(APP_BASE_PATH, parsed.path)
|
||||||
|
|
||||||
# 退出接口允许空请求体,先单独处理。
|
if local_path is None:
|
||||||
if parsed.path == "/api/auth/logout":
|
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
|
||||||
return self.handle_logout()
|
|
||||||
|
|
||||||
# 登录接口对未登录用户开放,但要求标准 JSON 请求体。
|
|
||||||
if parsed.path == "/api/auth/login":
|
|
||||||
try:
|
|
||||||
payload = self.read_json_body()
|
|
||||||
except ValueError as exc:
|
|
||||||
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
|
|
||||||
return self.handle_login(payload)
|
|
||||||
|
|
||||||
# 其余 POST 接口都要求先通过会话校验。
|
|
||||||
if not self.require_authentication(send_body=True):
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = self.read_json_body()
|
payload = self.read_json_body()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
|
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
|
||||||
|
|
||||||
if parsed.path == "/api/nacos/config/validate":
|
if local_path == "/api/nacos/config/validate":
|
||||||
return self.handle_nacos_validate(payload)
|
return self.handle_nacos_validate(payload)
|
||||||
|
|
||||||
if parsed.path == "/api/nacos/config":
|
if local_path == "/api/nacos/config":
|
||||||
return self.handle_nacos_save(payload)
|
return self.handle_nacos_save(payload)
|
||||||
|
|
||||||
if parsed.path == "/api/runtime/golang-config/validate":
|
if local_path == "/api/runtime/golang-config/validate":
|
||||||
return self.handle_go_validate(payload)
|
return self.handle_go_validate(payload)
|
||||||
|
|
||||||
if parsed.path == "/api/runtime/golang-config":
|
if local_path == "/api/runtime/golang-config":
|
||||||
return self.handle_go_save(payload)
|
return self.handle_go_save(payload)
|
||||||
|
|
||||||
if parsed.path == "/api/ops/rolling-restart":
|
if local_path == "/api/ops/rolling-restart":
|
||||||
return self.handle_rolling_restart(payload)
|
return self.handle_rolling_restart(payload)
|
||||||
|
|
||||||
if parsed.path == "/api/ops/update-services":
|
if local_path == "/api/ops/update-services":
|
||||||
return self.handle_update_services(payload)
|
return self.handle_update_services(payload)
|
||||||
|
|
||||||
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
|
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
|
||||||
|
|
||||||
def handle_auth_session(self, send_body: bool) -> None:
|
|
||||||
session = self.current_session()
|
|
||||||
|
|
||||||
# 未登录时返回 401,前端据此回到登录页。
|
|
||||||
if session is None:
|
|
||||||
return self.send_error_payload(
|
|
||||||
HTTPStatus.UNAUTHORIZED,
|
|
||||||
"authentication required",
|
|
||||||
send_body=send_body,
|
|
||||||
extra_headers=self.auth_cookie_cleanup_headers(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.send_payload(
|
|
||||||
HTTPStatus.OK,
|
|
||||||
response_json(self.session_payload(session)),
|
|
||||||
"application/json; charset=utf-8",
|
|
||||||
send_body=send_body,
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle_login(self, payload: dict[str, Any]) -> None:
|
|
||||||
username = str(payload.get("username") or "").strip()
|
|
||||||
password = payload.get("password")
|
|
||||||
|
|
||||||
# 登录接口要求账号密码都以字符串传入。
|
|
||||||
if not isinstance(password, str):
|
|
||||||
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "password must be a string", send_body=True)
|
|
||||||
|
|
||||||
canonical_username = authenticate_user(username, password)
|
|
||||||
if not canonical_username:
|
|
||||||
return self.send_error_payload(HTTPStatus.UNAUTHORIZED, "invalid username or password", send_body=True)
|
|
||||||
|
|
||||||
# 同一浏览器重复登录时,先清掉旧 token 对应会话。
|
|
||||||
delete_session(self.current_session_token())
|
|
||||||
session = create_session(
|
|
||||||
canonical_username,
|
|
||||||
remote_addr=self.client_address[0] if self.client_address else "",
|
|
||||||
user_agent=self.headers.get("User-Agent", ""),
|
|
||||||
)
|
|
||||||
cookie_header = build_session_cookie(
|
|
||||||
session.token,
|
|
||||||
max_age=max(1, session.expires_at_ts - int(time.time())),
|
|
||||||
)
|
|
||||||
return self.send_payload(
|
|
||||||
HTTPStatus.OK,
|
|
||||||
response_json(self.session_payload(session)),
|
|
||||||
"application/json; charset=utf-8",
|
|
||||||
send_body=True,
|
|
||||||
extra_headers={"Set-Cookie": cookie_header},
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle_logout(self) -> None:
|
|
||||||
# 退出时带不带当前 Cookie 都按成功处理,方便前端幂等调用。
|
|
||||||
delete_session(self.current_session_token())
|
|
||||||
return self.send_payload(
|
|
||||||
HTTPStatus.OK,
|
|
||||||
response_json({"ok": True}),
|
|
||||||
"application/json; charset=utf-8",
|
|
||||||
send_body=True,
|
|
||||||
extra_headers={"Set-Cookie": build_clear_session_cookie()},
|
|
||||||
)
|
|
||||||
|
|
||||||
def session_payload(self, session: Any) -> dict[str, Any]:
|
|
||||||
# 当前只给前端暴露最小必要字段。
|
|
||||||
return {
|
|
||||||
"authenticated": True,
|
|
||||||
"username": session.username,
|
|
||||||
"expiresAt": session.expires_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
def require_authentication(self, send_body: bool) -> bool:
|
|
||||||
# 查到有效会话时,当前请求可以继续执行。
|
|
||||||
if self.current_session() is not None:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# token 缺失或失效时统一打回 401。
|
|
||||||
self.send_error_payload(
|
|
||||||
HTTPStatus.UNAUTHORIZED,
|
|
||||||
"authentication required",
|
|
||||||
send_body=send_body,
|
|
||||||
extra_headers=self.auth_cookie_cleanup_headers(),
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def auth_cookie_cleanup_headers(self) -> dict[str, str] | None:
|
|
||||||
# 只有请求里真的带了旧 token,才下发清 Cookie 头。
|
|
||||||
if not self.current_session_token():
|
|
||||||
return None
|
|
||||||
return {"Set-Cookie": build_clear_session_cookie()}
|
|
||||||
|
|
||||||
def redirect(self, location: str, *, send_body: bool) -> None:
|
def redirect(self, location: str, *, send_body: bool) -> None:
|
||||||
# 页面跳转只依赖 Location,响应体保持空即可。
|
# 页面跳转只依赖 Location,响应体保持空即可。
|
||||||
self.send_payload(
|
self.send_payload(
|
||||||
@ -683,11 +579,9 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
# 启动前先把账号库和默认账号准备好。
|
|
||||||
ensure_auth_store()
|
|
||||||
# 创建线程型 HTTP 服务。
|
# 创建线程型 HTTP 服务。
|
||||||
server = ThreadingHTTPServer((APP_BIND, APP_PORT), MonitorHandler)
|
server = ThreadingHTTPServer((APP_BIND, APP_PORT), MonitorHandler)
|
||||||
# 打印监听地址便于排查。
|
# 打印监听地址和挂载路径,部署排查时更直观。
|
||||||
print(f"hy-app-monitor listening on http://{APP_BIND}:{APP_PORT}", flush=True)
|
print(f"hy-app-monitor yumi listening on http://{APP_BIND}:{APP_PORT}{APP_BASE_PATH or '/'}", flush=True)
|
||||||
# 进入永久监听。
|
# 进入永久监听。
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
|
|||||||
42
monitor/http_paths.py
Normal file
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
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -r requirements-ops.txt
|
.venv/bin/pip install -r requirements-ops.txt
|
||||||
bash scripts/install_systemd.sh
|
bash scripts/install_systemd.sh
|
||||||
systemctl restart hy-app-monitor.service
|
systemctl restart hy-app-monitor-yumi.service hy-app-monitor-haiyi.service hy-app-monitor.service
|
||||||
|
systemctl is-active hy-app-monitor-yumi.service
|
||||||
|
systemctl is-active hy-app-monitor-haiyi.service
|
||||||
systemctl is-active hy-app-monitor.service
|
systemctl is-active hy-app-monitor.service
|
||||||
ss -lntp | grep ":$APP_PORT" || true
|
ss -lntp | grep ":$APP_PORT" || true
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -156,6 +156,12 @@ def load_runtime_env() -> str:
|
|||||||
lines = [
|
lines = [
|
||||||
f"APP_BIND={os.environ.get('APP_BIND', '0.0.0.0')}",
|
f"APP_BIND={os.environ.get('APP_BIND', '0.0.0.0')}",
|
||||||
f"APP_PORT={os.environ.get('APP_PORT', DEFAULT_APP_PORT)}",
|
f"APP_PORT={os.environ.get('APP_PORT', DEFAULT_APP_PORT)}",
|
||||||
|
f"YUMI_INTERNAL_HOST={os.environ.get('YUMI_INTERNAL_HOST', '127.0.0.1')}",
|
||||||
|
f"YUMI_INTERNAL_PORT={os.environ.get('YUMI_INTERNAL_PORT', '2027')}",
|
||||||
|
f"HAIYI_INTERNAL_HOST={os.environ.get('HAIYI_INTERNAL_HOST', '127.0.0.1')}",
|
||||||
|
f"HAIYI_INTERNAL_PORT={os.environ.get('HAIYI_INTERNAL_PORT', '2028')}",
|
||||||
|
f"INTERNAL_PROXY_SHARED_SECRET={os.environ.get('INTERNAL_PROXY_SHARED_SECRET', 'hy-app-monitor-internal')}",
|
||||||
|
f"GATEWAY_PROXY_TIMEOUT_SECONDS={os.environ.get('GATEWAY_PROXY_TIMEOUT_SECONDS', '600')}",
|
||||||
f"PROBE_TIMEOUT_SECONDS={os.environ.get('PROBE_TIMEOUT_SECONDS', '3')}",
|
f"PROBE_TIMEOUT_SECONDS={os.environ.get('PROBE_TIMEOUT_SECONDS', '3')}",
|
||||||
f"PROBE_CACHE_TTL_SECONDS={os.environ.get('PROBE_CACHE_TTL_SECONDS', '5')}",
|
f"PROBE_CACHE_TTL_SECONDS={os.environ.get('PROBE_CACHE_TTL_SECONDS', '5')}",
|
||||||
f"PROBE_MAX_WORKERS={os.environ.get('PROBE_MAX_WORKERS', '12')}",
|
f"PROBE_MAX_WORKERS={os.environ.get('PROBE_MAX_WORKERS', '12')}",
|
||||||
@ -214,7 +220,9 @@ python3 -m venv .venv
|
|||||||
.venv/bin/pip install --quiet --disable-pip-version-check -r requirements-ops.txt
|
.venv/bin/pip install --quiet --disable-pip-version-check -r requirements-ops.txt
|
||||||
|
|
||||||
bash scripts/install_systemd.sh
|
bash scripts/install_systemd.sh
|
||||||
systemctl restart hy-app-monitor.service
|
systemctl restart hy-app-monitor-yumi.service hy-app-monitor-haiyi.service hy-app-monitor.service
|
||||||
|
systemctl is-active hy-app-monitor-yumi.service
|
||||||
|
systemctl is-active hy-app-monitor-haiyi.service
|
||||||
systemctl is-active hy-app-monitor.service
|
systemctl is-active hy-app-monitor.service
|
||||||
ss -lntp | grep ":$APP_PORT" || true
|
ss -lntp | grep ":$APP_PORT" || true
|
||||||
"""
|
"""
|
||||||
|
|||||||
383
scripts/common.sh
Executable file → Normal file
383
scripts/common.sh
Executable file → Normal file
@ -3,10 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
RUN_DIR="$ROOT_DIR/run"
|
RUN_DIR="$ROOT_DIR/run"
|
||||||
PID_FILE="$RUN_DIR/hy-app-monitor.pid"
|
|
||||||
LOG_FILE="$RUN_DIR/hy-app-monitor.log"
|
|
||||||
ENV_FILE="${APP_ENV_FILE:-$ROOT_DIR/.env}"
|
ENV_FILE="${APP_ENV_FILE:-$ROOT_DIR/.env}"
|
||||||
SYSTEMD_SERVICE_NAME="hy-app-monitor.service"
|
|
||||||
|
|
||||||
if [ -f "$ENV_FILE" ]; then
|
if [ -f "$ENV_FILE" ]; then
|
||||||
set -a
|
set -a
|
||||||
@ -18,6 +15,16 @@ fi
|
|||||||
APP_BIND="${APP_BIND:-0.0.0.0}"
|
APP_BIND="${APP_BIND:-0.0.0.0}"
|
||||||
APP_PORT="${APP_PORT:-2026}"
|
APP_PORT="${APP_PORT:-2026}"
|
||||||
TARGETS_FILE="${TARGETS_FILE:-$ROOT_DIR/config/targets.json}"
|
TARGETS_FILE="${TARGETS_FILE:-$ROOT_DIR/config/targets.json}"
|
||||||
|
YUMI_INTERNAL_HOST="${YUMI_INTERNAL_HOST:-127.0.0.1}"
|
||||||
|
YUMI_INTERNAL_PORT="${YUMI_INTERNAL_PORT:-2027}"
|
||||||
|
HAIYI_INTERNAL_HOST="${HAIYI_INTERNAL_HOST:-127.0.0.1}"
|
||||||
|
HAIYI_INTERNAL_PORT="${HAIYI_INTERNAL_PORT:-2028}"
|
||||||
|
GATEWAY_BIND="$APP_BIND"
|
||||||
|
GATEWAY_PORT="$APP_PORT"
|
||||||
|
|
||||||
|
GATEWAY_SYSTEMD_SERVICE_NAME="hy-app-monitor.service"
|
||||||
|
YUMI_SYSTEMD_SERVICE_NAME="hy-app-monitor-yumi.service"
|
||||||
|
HAIYI_SYSTEMD_SERVICE_NAME="hy-app-monitor-haiyi.service"
|
||||||
|
|
||||||
if [ -x "$ROOT_DIR/.venv/bin/python" ]; then
|
if [ -x "$ROOT_DIR/.venv/bin/python" ]; then
|
||||||
PYTHON_BIN_DEFAULT="$ROOT_DIR/.venv/bin/python"
|
PYTHON_BIN_DEFAULT="$ROOT_DIR/.venv/bin/python"
|
||||||
@ -29,88 +36,133 @@ PYTHON_BIN="${PYTHON_BIN:-$PYTHON_BIN_DEFAULT}"
|
|||||||
|
|
||||||
mkdir -p "$RUN_DIR"
|
mkdir -p "$RUN_DIR"
|
||||||
|
|
||||||
read_pid() {
|
suite_service_names() {
|
||||||
if [ -f "$PID_FILE" ]; then
|
printf '%s\n' gateway yumi haiyi
|
||||||
local pid
|
|
||||||
pid="$(cat "$PID_FILE")"
|
|
||||||
if pid_matches_project "$pid"; then
|
|
||||||
echo "$pid"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
discover_project_pid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is_running() {
|
service_display_name() {
|
||||||
local pid
|
case "$1" in
|
||||||
pid="$(read_pid || true)"
|
gateway) echo "gateway" ;;
|
||||||
if [ -z "${pid:-}" ]; then
|
yumi) echo "yumi" ;;
|
||||||
rm -f "$PID_FILE"
|
haiyi) echo "haiyi" ;;
|
||||||
|
*)
|
||||||
|
echo "unknown service: $1" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
;;
|
||||||
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
esac
|
||||||
rm -f "$PID_FILE"
|
}
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$pid" >"$PID_FILE"
|
service_unit_name() {
|
||||||
return 0
|
case "$1" in
|
||||||
|
gateway) echo "$GATEWAY_SYSTEMD_SERVICE_NAME" ;;
|
||||||
|
yumi) echo "$YUMI_SYSTEMD_SERVICE_NAME" ;;
|
||||||
|
haiyi) echo "$HAIYI_SYSTEMD_SERVICE_NAME" ;;
|
||||||
|
*)
|
||||||
|
echo "unknown service: $1" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
service_entry_file() {
|
||||||
|
case "$1" in
|
||||||
|
gateway) echo "$ROOT_DIR/server.py" ;;
|
||||||
|
yumi) echo "$ROOT_DIR/yumi_server.py" ;;
|
||||||
|
haiyi) echo "$ROOT_DIR/haiyi_server.py" ;;
|
||||||
|
*)
|
||||||
|
echo "unknown service: $1" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
service_bind() {
|
||||||
|
case "$1" in
|
||||||
|
gateway) echo "$GATEWAY_BIND" ;;
|
||||||
|
yumi) echo "$YUMI_INTERNAL_HOST" ;;
|
||||||
|
haiyi) echo "$HAIYI_INTERNAL_HOST" ;;
|
||||||
|
*)
|
||||||
|
echo "unknown service: $1" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
service_port() {
|
||||||
|
case "$1" in
|
||||||
|
gateway) echo "$GATEWAY_PORT" ;;
|
||||||
|
yumi) echo "$YUMI_INTERNAL_PORT" ;;
|
||||||
|
haiyi) echo "$HAIYI_INTERNAL_PORT" ;;
|
||||||
|
*)
|
||||||
|
echo "unknown service: $1" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
service_base_path() {
|
||||||
|
case "$1" in
|
||||||
|
gateway) echo "" ;;
|
||||||
|
yumi) echo "/yumi" ;;
|
||||||
|
haiyi) echo "/haiyi" ;;
|
||||||
|
*)
|
||||||
|
echo "unknown service: $1" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
service_pid_file() {
|
||||||
|
echo "$RUN_DIR/hy-app-monitor-$1.pid"
|
||||||
|
}
|
||||||
|
|
||||||
|
service_log_file() {
|
||||||
|
echo "$RUN_DIR/hy-app-monitor-$1.log"
|
||||||
|
}
|
||||||
|
|
||||||
|
service_command_pattern() {
|
||||||
|
basename "$(service_entry_file "$1")"
|
||||||
}
|
}
|
||||||
|
|
||||||
has_systemd_unit() {
|
has_systemd_unit() {
|
||||||
|
local name="$1"
|
||||||
if ! command -v systemctl >/dev/null 2>&1; then
|
if ! command -v systemctl >/dev/null 2>&1; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
systemctl cat "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1
|
systemctl cat "$(service_unit_name "$name")" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
stop_legacy_process() {
|
has_full_systemd_suite() {
|
||||||
local pid
|
local name
|
||||||
pid="$(read_pid || true)"
|
for name in $(suite_service_names); do
|
||||||
|
if ! has_systemd_unit "$name"; then
|
||||||
if [ -n "${pid:-}" ] && kill -0 "$pid" >/dev/null 2>&1; then
|
return 1
|
||||||
kill "$pid" >/dev/null 2>&1 || true
|
|
||||||
for _ in $(seq 1 10); do
|
|
||||||
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
|
||||||
break
|
|
||||||
fi
|
fi
|
||||||
sleep 1
|
|
||||||
done
|
done
|
||||||
kill -9 "$pid" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "$PID_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
discover_project_pid() {
|
|
||||||
if ! command -v lsof >/dev/null 2>&1; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local pid
|
|
||||||
while IFS= read -r pid; do
|
|
||||||
if pid_matches_project "$pid"; then
|
|
||||||
echo "$pid"
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
|
||||||
done < <(lsof -tiTCP:"$APP_PORT" -sTCP:LISTEN 2>/dev/null || true)
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pid_matches_project() {
|
pid_matches_project() {
|
||||||
local pid="$1"
|
local name="$1"
|
||||||
|
local pid="$2"
|
||||||
if [ -z "${pid:-}" ] || ! kill -0 "$pid" >/dev/null 2>&1; then
|
if [ -z "${pid:-}" ] || ! kill -0 "$pid" >/dev/null 2>&1; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local command
|
local command
|
||||||
command="$(ps -p "$pid" -o command= 2>/dev/null || true)"
|
command="$(ps -p "$pid" -o command= 2>/dev/null || true)"
|
||||||
if [[ "$command" != *"$ROOT_DIR/server.py"* ]] && [[ "$command" != *" server.py"* ]]; then
|
if [[ "$command" != *"$(service_command_pattern "$name")"* ]]; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if command -v lsof >/dev/null 2>&1; then
|
||||||
|
local expected_port
|
||||||
|
expected_port="$(service_port "$name")"
|
||||||
|
if ! lsof -a -p "$pid" -iTCP:"$expected_port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v lsof >/dev/null 2>&1; then
|
if command -v lsof >/dev/null 2>&1; then
|
||||||
local cwd
|
local cwd
|
||||||
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)"
|
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)"
|
||||||
@ -121,3 +173,218 @@ pid_matches_project() {
|
|||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
discover_project_pid() {
|
||||||
|
local name="$1"
|
||||||
|
if ! command -v lsof >/dev/null 2>&1; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pid
|
||||||
|
while IFS= read -r pid; do
|
||||||
|
if pid_matches_project "$name" "$pid"; then
|
||||||
|
echo "$pid"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done < <(lsof -tiTCP:"$(service_port "$name")" -sTCP:LISTEN 2>/dev/null || true)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
read_pid() {
|
||||||
|
local name="$1"
|
||||||
|
local pid_file
|
||||||
|
pid_file="$(service_pid_file "$name")"
|
||||||
|
|
||||||
|
if [ -f "$pid_file" ]; then
|
||||||
|
local pid
|
||||||
|
pid="$(cat "$pid_file")"
|
||||||
|
if pid_matches_project "$name" "$pid"; then
|
||||||
|
echo "$pid"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
discover_project_pid "$name"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_running() {
|
||||||
|
local name="$1"
|
||||||
|
local pid
|
||||||
|
local pid_file
|
||||||
|
pid_file="$(service_pid_file "$name")"
|
||||||
|
pid="$(read_pid "$name" || true)"
|
||||||
|
if [ -z "${pid:-}" ]; then
|
||||||
|
rm -f "$pid_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
||||||
|
rm -f "$pid_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$pid" >"$pid_file"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_legacy_process() {
|
||||||
|
local name="$1"
|
||||||
|
local pid
|
||||||
|
local pid_file
|
||||||
|
pid_file="$(service_pid_file "$name")"
|
||||||
|
pid="$(read_pid "$name" || true)"
|
||||||
|
|
||||||
|
if [ -n "${pid:-}" ] && kill -0 "$pid" >/dev/null 2>&1; then
|
||||||
|
kill "$pid" >/dev/null 2>&1 || true
|
||||||
|
local _i
|
||||||
|
for _i in $(seq 1 10); do
|
||||||
|
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
kill -9 "$pid" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$pid_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_all_legacy_processes() {
|
||||||
|
local name
|
||||||
|
for name in $(suite_service_names); do
|
||||||
|
stop_legacy_process "$name"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
export_service_env() {
|
||||||
|
local name="$1"
|
||||||
|
export APP_BIND
|
||||||
|
export APP_PORT
|
||||||
|
export APP_BASE_PATH
|
||||||
|
export TARGETS_FILE
|
||||||
|
export PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
APP_BIND="$(service_bind "$name")"
|
||||||
|
APP_PORT="$(service_port "$name")"
|
||||||
|
APP_BASE_PATH="$(service_base_path "$name")"
|
||||||
|
export APP_BIND APP_PORT APP_BASE_PATH TARGETS_FILE PYTHONUNBUFFERED
|
||||||
|
}
|
||||||
|
|
||||||
|
start_local_service() {
|
||||||
|
local name="$1"
|
||||||
|
local pid_file
|
||||||
|
local log_file
|
||||||
|
local entry_file
|
||||||
|
|
||||||
|
if is_running "$name"; then
|
||||||
|
echo "$(service_display_name "$name") already running pid=$(read_pid "$name")"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$name" = "yumi" ] && [ ! -f "$TARGETS_FILE" ]; then
|
||||||
|
echo "targets file not found: $TARGETS_FILE" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pid_file="$(service_pid_file "$name")"
|
||||||
|
log_file="$(service_log_file "$name")"
|
||||||
|
entry_file="$(service_entry_file "$name")"
|
||||||
|
|
||||||
|
export_service_env "$name"
|
||||||
|
|
||||||
|
nohup "$PYTHON_BIN" "$entry_file" >>"$log_file" 2>&1 &
|
||||||
|
local pid=$!
|
||||||
|
echo "$pid" >"$pid_file"
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
if kill -0 "$pid" >/dev/null 2>&1; then
|
||||||
|
echo "$(service_display_name "$name") started pid=$pid port=$(service_port "$name")"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$(service_display_name "$name") failed to start" >&2
|
||||||
|
tail -n 40 "$log_file" >&2 || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_local_service() {
|
||||||
|
local name="$1"
|
||||||
|
local pid_file
|
||||||
|
pid_file="$(service_pid_file "$name")"
|
||||||
|
|
||||||
|
if ! is_running "$name"; then
|
||||||
|
echo "$(service_display_name "$name") not running"
|
||||||
|
rm -f "$pid_file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pid
|
||||||
|
pid="$(read_pid "$name")"
|
||||||
|
kill "$pid" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
local _i
|
||||||
|
for _i in $(seq 1 10); do
|
||||||
|
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
||||||
|
rm -f "$pid_file"
|
||||||
|
echo "$(service_display_name "$name") stopped"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
kill -9 "$pid" >/dev/null 2>&1 || true
|
||||||
|
rm -f "$pid_file"
|
||||||
|
echo "$(service_display_name "$name") force stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_local_service() {
|
||||||
|
local name="$1"
|
||||||
|
if is_running "$name"; then
|
||||||
|
echo "$(service_display_name "$name") running pid=$(read_pid "$name") port=$(service_port "$name")"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$(service_display_name "$name") stopped"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
start_systemd_suite() {
|
||||||
|
local name
|
||||||
|
for name in yumi haiyi gateway; do
|
||||||
|
systemctl start "$(service_unit_name "$name")"
|
||||||
|
done
|
||||||
|
for name in $(suite_service_names); do
|
||||||
|
systemctl is-active "$(service_unit_name "$name")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_systemd_suite() {
|
||||||
|
local name
|
||||||
|
for name in gateway yumi haiyi; do
|
||||||
|
systemctl stop "$(service_unit_name "$name")" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_systemd_suite() {
|
||||||
|
local name
|
||||||
|
for name in yumi haiyi gateway; do
|
||||||
|
systemctl restart "$(service_unit_name "$name")"
|
||||||
|
done
|
||||||
|
for name in $(suite_service_names); do
|
||||||
|
systemctl is-active "$(service_unit_name "$name")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
status_systemd_suite() {
|
||||||
|
local all_active=0
|
||||||
|
local name
|
||||||
|
for name in $(suite_service_names); do
|
||||||
|
if systemctl is-active --quiet "$(service_unit_name "$name")"; then
|
||||||
|
echo "$(service_display_name "$name") running under systemd port=$(service_port "$name")"
|
||||||
|
else
|
||||||
|
echo "$(service_display_name "$name") stopped"
|
||||||
|
all_active=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return "$all_active"
|
||||||
|
}
|
||||||
|
|||||||
40
scripts/install_systemd.sh
Executable file → Normal file
40
scripts/install_systemd.sh
Executable file → Normal file
@ -5,23 +5,37 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$SCRIPT_DIR/common.sh"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
UNIT_SRC="$ROOT_DIR/systemd/hy-app-monitor.service"
|
|
||||||
UNIT_DST="/etc/systemd/system/$SYSTEMD_SERVICE_NAME"
|
|
||||||
|
|
||||||
if [ ! -f "$UNIT_SRC" ]; then
|
|
||||||
echo "systemd unit missing: $UNIT_SRC" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
echo "env file missing: $ENV_FILE" >&2
|
echo "env file missing: $ENV_FILE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
install -m 0644 "$UNIT_SRC" "$UNIT_DST"
|
install_unit() {
|
||||||
|
local name="$1"
|
||||||
|
local src
|
||||||
|
local dst
|
||||||
|
src="$ROOT_DIR/systemd/$(service_unit_name "$name")"
|
||||||
|
dst="/etc/systemd/system/$(service_unit_name "$name")"
|
||||||
|
|
||||||
|
if [ ! -f "$src" ]; then
|
||||||
|
echo "systemd unit missing: $src" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -m 0644 "$src" "$dst"
|
||||||
|
}
|
||||||
|
|
||||||
|
name=""
|
||||||
|
for name in $(suite_service_names); do
|
||||||
|
install_unit "$name"
|
||||||
|
done
|
||||||
|
|
||||||
chmod +x "$ROOT_DIR"/scripts/*.sh
|
chmod +x "$ROOT_DIR"/scripts/*.sh
|
||||||
stop_legacy_process
|
stop_all_legacy_processes
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable "$SYSTEMD_SERVICE_NAME"
|
|
||||||
systemctl restart "$SYSTEMD_SERVICE_NAME"
|
for name in $(suite_service_names); do
|
||||||
systemctl is-active "$SYSTEMD_SERVICE_NAME"
|
systemctl enable "$(service_unit_name "$name")"
|
||||||
|
done
|
||||||
|
|
||||||
|
restart_systemd_suite
|
||||||
|
|||||||
5
scripts/restart.sh
Executable file → Normal file
5
scripts/restart.sh
Executable file → Normal file
@ -5,9 +5,8 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$SCRIPT_DIR/common.sh"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
if has_systemd_unit; then
|
if has_full_systemd_suite; then
|
||||||
systemctl restart "$SYSTEMD_SERVICE_NAME"
|
restart_systemd_suite
|
||||||
systemctl is-active "$SYSTEMD_SERVICE_NAME"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
7
scripts/run_foreground.sh
Executable file → Normal file
7
scripts/run_foreground.sh
Executable file → Normal file
@ -5,10 +5,5 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$SCRIPT_DIR/common.sh"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
if [ ! -f "$TARGETS_FILE" ]; then
|
export_service_env gateway
|
||||||
echo "targets file not found: $TARGETS_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
export APP_BIND APP_PORT TARGETS_FILE PYTHONUNBUFFERED=1
|
|
||||||
exec "$PYTHON_BIN" "$ROOT_DIR/server.py"
|
exec "$PYTHON_BIN" "$ROOT_DIR/server.py"
|
||||||
|
|||||||
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
|
# shellcheck disable=SC1091
|
||||||
source "$SCRIPT_DIR/common.sh"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
if has_systemd_unit; then
|
if has_full_systemd_suite; then
|
||||||
systemctl start "$SYSTEMD_SERVICE_NAME"
|
start_systemd_suite
|
||||||
systemctl is-active "$SYSTEMD_SERVICE_NAME"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if is_running; then
|
start_local_service yumi
|
||||||
echo "hy-app-monitor already running pid=$(read_pid)"
|
start_local_service haiyi
|
||||||
exit 0
|
start_local_service gateway
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$TARGETS_FILE" ]; then
|
|
||||||
echo "targets file not found: $TARGETS_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
export APP_BIND APP_PORT TARGETS_FILE PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
nohup "$PYTHON_BIN" "$ROOT_DIR/server.py" >>"$LOG_FILE" 2>&1 &
|
|
||||||
pid=$!
|
|
||||||
echo "$pid" >"$PID_FILE"
|
|
||||||
|
|
||||||
sleep 1
|
|
||||||
if kill -0 "$pid" >/dev/null 2>&1; then
|
|
||||||
echo "hy-app-monitor started pid=$pid port=$APP_PORT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "hy-app-monitor failed to start" >&2
|
|
||||||
tail -n 40 "$LOG_FILE" >&2 || true
|
|
||||||
exit 1
|
|
||||||
|
|||||||
22
scripts/status.sh
Executable file → Normal file
22
scripts/status.sh
Executable file → Normal file
@ -5,19 +5,17 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$SCRIPT_DIR/common.sh"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
if has_systemd_unit; then
|
if has_full_systemd_suite; then
|
||||||
if systemctl is-active --quiet "$SYSTEMD_SERVICE_NAME"; then
|
status_systemd_suite
|
||||||
echo "hy-app-monitor running under systemd port=$APP_PORT"
|
exit $?
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "hy-app-monitor stopped"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if is_running; then
|
all_ok=0
|
||||||
echo "hy-app-monitor running pid=$(read_pid) port=$APP_PORT"
|
name=""
|
||||||
exit 0
|
for name in $(suite_service_names); do
|
||||||
|
if ! status_local_service "$name"; then
|
||||||
|
all_ok=1
|
||||||
fi
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "hy-app-monitor stopped"
|
exit "$all_ok"
|
||||||
exit 1
|
|
||||||
|
|||||||
32
scripts/stop.sh
Executable file → Normal file
32
scripts/stop.sh
Executable file → Normal file
@ -5,31 +5,13 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$SCRIPT_DIR/common.sh"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
if has_systemd_unit; then
|
if has_full_systemd_suite; then
|
||||||
systemctl stop "$SYSTEMD_SERVICE_NAME"
|
stop_systemd_suite
|
||||||
stop_legacy_process
|
stop_all_legacy_processes
|
||||||
echo "hy-app-monitor stopped"
|
echo "hy-app-monitor suite stopped"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! is_running; then
|
stop_local_service gateway
|
||||||
echo "hy-app-monitor not running"
|
stop_local_service yumi
|
||||||
rm -f "$PID_FILE"
|
stop_local_service haiyi
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
pid="$(read_pid)"
|
|
||||||
kill "$pid" >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
for _ in $(seq 1 10); do
|
|
||||||
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
|
||||||
rm -f "$PID_FILE"
|
|
||||||
echo "hy-app-monitor stopped"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
kill -9 "$pid" >/dev/null 2>&1 || true
|
|
||||||
rm -f "$PID_FILE"
|
|
||||||
echo "hy-app-monitor force stopped"
|
|
||||||
|
|||||||
16
scripts/uninstall_systemd.sh
Executable file → Normal file
16
scripts/uninstall_systemd.sh
Executable file → Normal file
@ -5,12 +5,20 @@ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source "$SCRIPT_DIR/common.sh"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
UNIT_DST="/etc/systemd/system/$SYSTEMD_SERVICE_NAME"
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
name=""
|
||||||
|
for name in $(suite_service_names); do
|
||||||
|
systemctl disable --now "$(service_unit_name "$name")" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
name=""
|
||||||
|
for name in $(suite_service_names); do
|
||||||
|
rm -f "/etc/systemd/system/$(service_unit_name "$name")"
|
||||||
|
done
|
||||||
|
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
systemctl disable --now "$SYSTEMD_SERVICE_NAME" >/dev/null 2>&1 || true
|
|
||||||
systemctl daemon-reload || true
|
systemctl daemon-reload || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -f "$UNIT_DST"
|
echo "systemd units removed"
|
||||||
echo "systemd unit removed: $UNIT_DST"
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from monitor.http_app import main
|
from gateway.http_app import main
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
497
static/app.css
497
static/app.css
@ -147,6 +147,72 @@ body.auth-page {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-grid,
|
||||||
|
.entry-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 132px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: color-mix(in oklab, var(--panel-strong) 88%, var(--accent-soft));
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in oklab, var(--accent) 42%, var(--line-strong));
|
||||||
|
background: color-mix(in oklab, var(--accent-soft) 74%, var(--panel-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card strong {
|
||||||
|
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card-static {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-actions,
|
||||||
|
.entry-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar,
|
.topbar,
|
||||||
.control-bar,
|
.control-bar,
|
||||||
.panel {
|
.panel {
|
||||||
@ -292,6 +358,11 @@ body.auth-page {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.release-section-nav {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.taskbar-context {
|
.taskbar-context {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -319,6 +390,35 @@ body.auth-page {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 2px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 180ms ease, opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link + .section-link {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-kind-select {
|
||||||
|
min-width: 98px;
|
||||||
|
}
|
||||||
|
|
||||||
.control-group-spread {
|
.control-group-spread {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@ -379,6 +479,7 @@ body.auth-page {
|
|||||||
.refresh:focus-visible,
|
.refresh:focus-visible,
|
||||||
.search:focus-visible,
|
.search:focus-visible,
|
||||||
.interval-select:focus-visible,
|
.interval-select:focus-visible,
|
||||||
|
.section-link:focus-visible,
|
||||||
.detail-log-input:focus-visible,
|
.detail-log-input:focus-visible,
|
||||||
.config-item:focus-visible,
|
.config-item:focus-visible,
|
||||||
.config-textarea:focus-visible {
|
.config-textarea:focus-visible {
|
||||||
@ -959,6 +1060,14 @@ body.auth-page {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-caption-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.section-caption {
|
.section-caption {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@ -967,6 +1076,16 @@ body.auth-page {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-caption-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.host-row-list {
|
.host-row-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@ -1005,11 +1124,11 @@ body.auth-page {
|
|||||||
|
|
||||||
.host-strip-main {
|
.host-strip-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(164px, 208px) minmax(210px, 0.9fr) minmax(220px, 1.2fr) auto;
|
grid-template-columns: minmax(180px, 220px) minmax(320px, 1fr) minmax(180px, 0.9fr) auto;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 48px;
|
min-height: 56px;
|
||||||
padding: 7px 11px 7px 16px;
|
padding: 8px 11px 8px 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1038,39 +1157,121 @@ body.auth-page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compact-metrics {
|
.compact-metrics {
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-metrics span {
|
.metric-ring-chip,
|
||||||
|
.metric-stat-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 9px 0 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-ring-chip.tone-ok,
|
||||||
|
.metric-stat-chip.tone-ok {
|
||||||
|
border-color: color-mix(in oklab, var(--ok) 28%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-ring-chip.tone-warn,
|
||||||
|
.metric-stat-chip.tone-warn {
|
||||||
|
border-color: color-mix(in oklab, var(--warn) 28%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-ring-chip.tone-bad,
|
||||||
|
.metric-stat-chip.tone-bad {
|
||||||
|
border-color: color-mix(in oklab, var(--bad) 28%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-ring {
|
||||||
|
--metric-color: var(--line-strong);
|
||||||
|
--metric-value: 0%;
|
||||||
|
position: relative;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
conic-gradient(var(--metric-color) 0 var(--metric-value), color-mix(in oklab, var(--line) 88%, transparent) var(--metric-value) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-ring-hole {
|
||||||
|
position: absolute;
|
||||||
|
inset: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-ring-copy,
|
||||||
|
.metric-stat-chip {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-ring-copy small,
|
||||||
|
.metric-ring-copy strong,
|
||||||
|
.metric-stat-chip small,
|
||||||
|
.metric-stat-chip strong {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-ring-copy small,
|
||||||
|
.metric-stat-chip small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-ring-copy strong,
|
||||||
|
.metric-stat-chip strong {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-alert-summary {
|
.host-alert-summary {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
gap: 4px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.host-alert-summary small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.host-alert-path {
|
.host-alert-path {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--muted);
|
color: var(--text);
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-alert-summary.is-alert .host-alert-path {
|
.host-alert-summary.tone-bad .host-alert-path {
|
||||||
color: color-mix(in oklab, var(--bad) 82%, var(--text));
|
color: color-mix(in oklab, var(--bad) 82%, var(--text));
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-alert-summary.is-quiet .host-alert-path {
|
.host-alert-summary.tone-warn .host-alert-path {
|
||||||
color: color-mix(in oklab, var(--muted) 88%, transparent);
|
color: color-mix(in oklab, var(--warn) 72%, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-alert-summary.tone-neutral .host-alert-path {
|
||||||
|
color: color-mix(in oklab, var(--muted) 76%, var(--text));
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-chip,
|
.metric-chip,
|
||||||
@ -1282,6 +1483,19 @@ body.auth-page {
|
|||||||
.config-sidebar {
|
.config-sidebar {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-self: start;
|
||||||
|
position: sticky;
|
||||||
|
top: 124px;
|
||||||
|
max-height: calc(100vh - 140px);
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-sidebar-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-sidebar-meta {
|
.config-sidebar-meta {
|
||||||
@ -1290,8 +1504,27 @@ body.auth-page {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-sidebar-path {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-sidebar-path strong {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-sidebar-path span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.config-list-ide {
|
.config-list-ide {
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-group {
|
.config-group {
|
||||||
@ -1328,7 +1561,9 @@ body.auth-page {
|
|||||||
|
|
||||||
.config-group-items {
|
.config-group-items {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 1px solid color-mix(in oklab, var(--line) 88%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-item-ide {
|
.config-item-ide {
|
||||||
@ -1336,12 +1571,27 @@ body.auth-page {
|
|||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 9px 10px;
|
padding: 8px 9px 8px 12px;
|
||||||
border-radius: 12px;
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
transition: background 180ms ease, box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item-ide::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
width: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-item-ide strong {
|
.config-item-ide strong {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1352,8 +1602,38 @@ body.auth-page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.config-item-ide small {
|
.config-item-ide small {
|
||||||
grid-column: 1 / -1;
|
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item-ide.active {
|
||||||
|
background: color-mix(in oklab, var(--accent-soft) 64%, var(--panel-strong));
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 26%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item-ide:hover {
|
||||||
|
transform: none;
|
||||||
|
background: color-mix(in oklab, var(--accent-soft) 30%, var(--panel-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item-ide.active::before {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item-main,
|
||||||
|
.config-item-side {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item-side {
|
||||||
|
justify-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-jump-group {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
@ -1498,6 +1778,25 @@ body.auth-page {
|
|||||||
z-index: 12;
|
z-index: 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.release-action-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in oklab, var(--panel-muted) 92%, var(--panel-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-action-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.release-status-grid {
|
.release-status-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
@ -1554,6 +1853,71 @@ body.auth-page {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.release-log-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-log-preview-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-log-preview-head strong {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-log-snippets {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-log-snippet {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-log-snippet strong,
|
||||||
|
.release-log-snippet span,
|
||||||
|
.release-log-snippet p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-log-snippet strong {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-log-snippet span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-log-snippet p {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-log-snippet.tone-bad {
|
||||||
|
border-color: color-mix(in oklab, var(--bad) 28%, var(--line));
|
||||||
|
background: color-mix(in oklab, var(--bad-soft) 44%, var(--panel-strong));
|
||||||
|
}
|
||||||
|
|
||||||
.release-table td strong {
|
.release-table td strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -1924,6 +2288,60 @@ body.auth-page {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-viewer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 48;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 28px 20px;
|
||||||
|
background: color-mix(in oklab, var(--text) 14%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
width: min(1120px, 100%);
|
||||||
|
max-height: min(88vh, 920px);
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: color-mix(in oklab, var(--panel-strong) 96%, var(--panel-muted));
|
||||||
|
box-shadow: 0 24px 64px rgba(29, 42, 68, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewer-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewer-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewer-output {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 280px;
|
||||||
|
max-height: calc(88vh - 176px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line-strong) 78%, transparent);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: oklch(22% 0.01 240 / 0.98);
|
||||||
|
color: oklch(91% 0.01 230);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.58;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -2017,7 +2435,8 @@ body.auth-page {
|
|||||||
|
|
||||||
.infra-overview-grid,
|
.infra-overview-grid,
|
||||||
.mongo-group-grid,
|
.mongo-group-grid,
|
||||||
.release-status-grid {
|
.release-status-grid,
|
||||||
|
.release-log-snippets {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2036,6 +2455,11 @@ body.auth-page {
|
|||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-sidebar {
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-cards {
|
.summary-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@ -2061,7 +2485,8 @@ body.auth-page {
|
|||||||
|
|
||||||
.release-status-grid,
|
.release-status-grid,
|
||||||
.infra-overview-grid,
|
.infra-overview-grid,
|
||||||
.mongo-group-grid {
|
.mongo-group-grid,
|
||||||
|
.release-log-snippets {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2144,6 +2569,14 @@ body.auth-page {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-caption-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.host-service-pill,
|
.host-service-pill,
|
||||||
.restart-pills .config-item {
|
.restart-pills .config-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -2153,7 +2586,12 @@ body.auth-page {
|
|||||||
.taskbar-inline,
|
.taskbar-inline,
|
||||||
.metadata-strip,
|
.metadata-strip,
|
||||||
.precheck-strip,
|
.precheck-strip,
|
||||||
.activity-item-side {
|
.activity-item-side,
|
||||||
|
.release-action-bar,
|
||||||
|
.release-action-summary,
|
||||||
|
.log-viewer-head,
|
||||||
|
.log-viewer-toolbar,
|
||||||
|
.entry-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@ -2163,6 +2601,21 @@ body.auth-page {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-viewer-overlay {
|
||||||
|
padding: 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-viewer {
|
||||||
|
max-height: calc(100vh - 28px);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-action-bar,
|
||||||
|
.log-viewer-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.diff-row {
|
.diff-row {
|
||||||
grid-template-columns: 34px 34px 22px minmax(0, 1fr);
|
grid-template-columns: 34px 34px 22px minmax(0, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
319
static/app.js
319
static/app.js
@ -1,6 +1,7 @@
|
|||||||
const { createApp } = Vue;
|
const { createApp } = Vue;
|
||||||
|
|
||||||
const APP_TEMPLATE = document.getElementById("app")?.innerHTML || "";
|
const APP_TEMPLATE = document.getElementById("app")?.innerHTML || "";
|
||||||
|
const APP_BASE_PATH = window.location.pathname === "/yumi" || window.location.pathname.startsWith("/yumi/") ? "/yumi" : "";
|
||||||
const STORAGE_KEY = "hy-app-monitor-refresh-interval";
|
const STORAGE_KEY = "hy-app-monitor-refresh-interval";
|
||||||
const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation";
|
const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation";
|
||||||
const GROUP_LABELS = {
|
const GROUP_LABELS = {
|
||||||
@ -52,6 +53,20 @@ const INFRA_TABS = [
|
|||||||
{ id: "tat", label: "TAT" },
|
{ id: "tat", label: "TAT" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function rootPath(path) {
|
||||||
|
const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appPath(path) {
|
||||||
|
const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`;
|
||||||
|
return `${APP_BASE_PATH}${normalized}` || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginRedirectPath() {
|
||||||
|
return rootPath(`/login?next=${encodeURIComponent(`${window.location.pathname}${window.location.search}`)}`);
|
||||||
|
}
|
||||||
|
|
||||||
createApp({
|
createApp({
|
||||||
template: APP_TEMPLATE,
|
template: APP_TEMPLATE,
|
||||||
data() {
|
data() {
|
||||||
@ -254,6 +269,10 @@ createApp({
|
|||||||
exportMaxLines: 10000,
|
exportMaxLines: 10000,
|
||||||
exporting: false,
|
exporting: false,
|
||||||
},
|
},
|
||||||
|
logViewer: {
|
||||||
|
open: false,
|
||||||
|
kind: "",
|
||||||
|
},
|
||||||
timer: null,
|
timer: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -297,6 +316,12 @@ createApp({
|
|||||||
filteredHostRows() {
|
filteredHostRows() {
|
||||||
return this.buildHostRows({ businessOnly: false, applyFilter: true, applyKeyword: true });
|
return this.buildHostRows({ businessOnly: false, applyFilter: true, applyKeyword: true });
|
||||||
},
|
},
|
||||||
|
filteredHostAlertTotal() {
|
||||||
|
return this.filteredHostRows.filter((host) => host.hasAlert).length;
|
||||||
|
},
|
||||||
|
filteredHostMetricIssueTotal() {
|
||||||
|
return this.filteredHostRows.filter((host) => host.hasMetricIssue).length;
|
||||||
|
},
|
||||||
overviewHostRowGroups() {
|
overviewHostRowGroups() {
|
||||||
return this.groupHosts(this.overviewHostRows);
|
return this.groupHosts(this.overviewHostRows);
|
||||||
},
|
},
|
||||||
@ -422,6 +447,24 @@ createApp({
|
|||||||
group.items.some((item) => this.configKey(item.group, item.dataId) === this.nacosConfig.selectedKey)
|
group.items.some((item) => this.configKey(item.group, item.dataId) === this.nacosConfig.selectedKey)
|
||||||
)?.key || "";
|
)?.key || "";
|
||||||
},
|
},
|
||||||
|
flatNacosConfigKeys() {
|
||||||
|
return this.nacosConfigGroups.flatMap((group) =>
|
||||||
|
group.items.map((item) => this.configKey(item.group, item.dataId))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
selectedNacosConfigIndex() {
|
||||||
|
return this.flatNacosConfigKeys.indexOf(this.nacosConfig.selectedKey);
|
||||||
|
},
|
||||||
|
selectedPrevNacosConfigKey() {
|
||||||
|
return this.selectedNacosConfigIndex > 0
|
||||||
|
? this.flatNacosConfigKeys[this.selectedNacosConfigIndex - 1]
|
||||||
|
: "";
|
||||||
|
},
|
||||||
|
selectedNextNacosConfigKey() {
|
||||||
|
return this.selectedNacosConfigIndex >= 0 && this.selectedNacosConfigIndex < this.flatNacosConfigKeys.length - 1
|
||||||
|
? this.flatNacosConfigKeys[this.selectedNacosConfigIndex + 1]
|
||||||
|
: "";
|
||||||
|
},
|
||||||
configHistoryRows() {
|
configHistoryRows() {
|
||||||
const keyword = this.nacosConfig.keyword.trim().toLowerCase();
|
const keyword = this.nacosConfig.keyword.trim().toLowerCase();
|
||||||
return this.configHistory.filter((item) => (
|
return this.configHistory.filter((item) => (
|
||||||
@ -634,7 +677,7 @@ createApp({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "最近日志",
|
label: "最近日志",
|
||||||
value: lastLog?.message || "-",
|
value: lastLog?.message ? this.truncateMiddle(lastLog.message, 24, 14) : "-",
|
||||||
tone: lastLog?.level === "error" ? "bad" : "neutral",
|
tone: lastLog?.level === "error" ? "bad" : "neutral",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -837,6 +880,15 @@ createApp({
|
|||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
},
|
},
|
||||||
|
recentUpdateLogs() {
|
||||||
|
return (this.updateOps.logs || [])
|
||||||
|
.slice(-5)
|
||||||
|
.reverse()
|
||||||
|
.map((entry, index) => ({
|
||||||
|
...entry,
|
||||||
|
key: `${entry.time || "log"}-${entry.stage || "stage"}-${index}`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
serviceLogDownloadUrl() {
|
serviceLogDownloadUrl() {
|
||||||
if (!this.serviceLog.service || !this.serviceLog.host) {
|
if (!this.serviceLog.service || !this.serviceLog.host) {
|
||||||
return "";
|
return "";
|
||||||
@ -854,6 +906,71 @@ createApp({
|
|||||||
});
|
});
|
||||||
return `/api/ops/service-log/export?${query.toString()}`;
|
return `/api/ops/service-log/export?${query.toString()}`;
|
||||||
},
|
},
|
||||||
|
activeLogViewerTitle() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
return `${this.serviceLog.service || "-"} · ${this.serviceLog.host || "-"}`;
|
||||||
|
}
|
||||||
|
return "执行日志";
|
||||||
|
},
|
||||||
|
activeLogViewerSubtitle() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
const subtitleParts = [];
|
||||||
|
if (this.serviceLog.container) {
|
||||||
|
subtitleParts.push(this.serviceLog.container);
|
||||||
|
}
|
||||||
|
subtitleParts.push(`更新于 ${this.formatDateTime(this.serviceLog.updatedAt)}`);
|
||||||
|
return subtitleParts.join(" · ");
|
||||||
|
}
|
||||||
|
return `任务 ${this.updateOps.operationId || "-"} · ${this.updateStatusLabel()} · ${this.formatDateTime(this.updateOps.finishedAt || this.updateOps.startedAt)}`;
|
||||||
|
},
|
||||||
|
activeLogViewerBadge() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
return this.serviceLog.container || "";
|
||||||
|
}
|
||||||
|
return this.updateOps.operationId || "";
|
||||||
|
},
|
||||||
|
activeLogViewerSecondaryBadge() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
return `最近 ${this.serviceLog.previewLines} 条`;
|
||||||
|
}
|
||||||
|
return this.updateOps.stage ? `阶段 ${this.updateOps.stage}` : "";
|
||||||
|
},
|
||||||
|
activeLogViewerContent() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
return this.serviceLog.content;
|
||||||
|
}
|
||||||
|
return this.updateLogText;
|
||||||
|
},
|
||||||
|
activeLogViewerError() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
return this.serviceLog.error;
|
||||||
|
}
|
||||||
|
return this.updateOps.error ? this.explainError(this.updateOps.error) : "";
|
||||||
|
},
|
||||||
|
activeLogViewerLoading() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
return this.serviceLog.loading;
|
||||||
|
}
|
||||||
|
return this.updateOps.statusRequestInFlight;
|
||||||
|
},
|
||||||
|
activeLogViewerExporting() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
return this.serviceLog.exporting;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
activeLogViewerRefreshDisabled() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.loading;
|
||||||
|
}
|
||||||
|
return !this.updateOps.operationId || this.updateOps.statusRequestInFlight;
|
||||||
|
},
|
||||||
|
activeLogViewerDownloadDisabled() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.exporting;
|
||||||
|
}
|
||||||
|
return !this.updateOps.operationId;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
refreshIntervalSeconds() {
|
refreshIntervalSeconds() {
|
||||||
@ -884,9 +1001,20 @@ createApp({
|
|||||||
group: host.group,
|
group: host.group,
|
||||||
label: this.groupLabel(host.group),
|
label: this.groupLabel(host.group),
|
||||||
items: [],
|
items: [],
|
||||||
|
alertCount: 0,
|
||||||
|
metricIssueCount: 0,
|
||||||
|
serviceCount: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
groups.get(host.group).items.push(host);
|
const target = groups.get(host.group);
|
||||||
|
target.items.push(host);
|
||||||
|
target.serviceCount += host.serviceTotal || 0;
|
||||||
|
if (host.hasAlert) {
|
||||||
|
target.alertCount += 1;
|
||||||
|
}
|
||||||
|
if (host.hasMetricIssue) {
|
||||||
|
target.metricIssueCount += 1;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return [...groups.values()].sort((left, right) => this.groupRank(left.group) - this.groupRank(right.group));
|
return [...groups.values()].sort((left, right) => this.groupRank(left.group) - this.groupRank(right.group));
|
||||||
},
|
},
|
||||||
@ -1059,9 +1187,50 @@ createApp({
|
|||||||
}
|
}
|
||||||
this.collapsedConfigGroups = [...next];
|
this.collapsedConfigGroups = [...next];
|
||||||
},
|
},
|
||||||
|
syncConfigExplorerState(selectedKey = this.nacosConfig.selectedKey, { force = false } = {}) {
|
||||||
|
const groupKeys = [...new Set((this.nacosConfig.items || []).map((item) => item.group || "default"))];
|
||||||
|
if (groupKeys.length === 0) {
|
||||||
|
this.collapsedConfigGroups = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItem = (this.nacosConfig.items || []).find((item) => this.configKey(item.group, item.dataId) === selectedKey);
|
||||||
|
const selectedGroup = selectedItem?.group || groupKeys[0];
|
||||||
|
|
||||||
|
if (force || this.collapsedConfigGroups.length === 0) {
|
||||||
|
this.collapsedConfigGroups = groupKeys.filter((groupKey) => groupKey !== selectedGroup);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.collapsedConfigGroups.includes(selectedGroup)) {
|
||||||
|
this.collapsedConfigGroups = this.collapsedConfigGroups.filter((groupKey) => groupKey !== selectedGroup);
|
||||||
|
}
|
||||||
|
},
|
||||||
isConfigGroupCollapsed(groupKey) {
|
isConfigGroupCollapsed(groupKey) {
|
||||||
return this.collapsedConfigGroups.includes(groupKey);
|
return this.collapsedConfigGroups.includes(groupKey);
|
||||||
},
|
},
|
||||||
|
scrollSelectedConfigItemIntoView() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const node = document.getElementById(`config-item-${this.nacosConfig.selectedKey}`);
|
||||||
|
if (node) {
|
||||||
|
node.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
openPrevNacosConfig() {
|
||||||
|
if (!this.selectedPrevNacosConfigKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.nacosConfig.selectedKey = this.selectedPrevNacosConfigKey;
|
||||||
|
this.openNacosConfigByKey(this.selectedPrevNacosConfigKey);
|
||||||
|
},
|
||||||
|
openNextNacosConfig() {
|
||||||
|
if (!this.selectedNextNacosConfigKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.nacosConfig.selectedKey = this.selectedNextNacosConfigKey;
|
||||||
|
this.openNacosConfigByKey(this.selectedNextNacosConfigKey);
|
||||||
|
},
|
||||||
toggleNacosService(serviceName) {
|
toggleNacosService(serviceName) {
|
||||||
const next = new Set(this.expandedNacosServices);
|
const next = new Set(this.expandedNacosServices);
|
||||||
if (next.has(serviceName)) {
|
if (next.has(serviceName)) {
|
||||||
@ -1138,6 +1307,47 @@ createApp({
|
|||||||
this.selectedServiceKey = serviceKey;
|
this.selectedServiceKey = serviceKey;
|
||||||
this.detailPanelOpen = true;
|
this.detailPanelOpen = true;
|
||||||
},
|
},
|
||||||
|
openUpdateLogViewer() {
|
||||||
|
this.logViewer.open = true;
|
||||||
|
this.logViewer.kind = "update";
|
||||||
|
this.$nextTick(() => this.scrollUpdateLogToBottom());
|
||||||
|
},
|
||||||
|
closeLogViewer() {
|
||||||
|
const previousKind = this.logViewer.kind;
|
||||||
|
this.logViewer.open = false;
|
||||||
|
this.logViewer.kind = "";
|
||||||
|
if (previousKind === "service") {
|
||||||
|
this.clearServiceLog();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async refreshActiveLogViewer() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
if (!this.serviceLog.host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.openServiceLog({ host: this.serviceLog.host });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.updateOps.operationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.fetchUpdateOperationStatus(this.updateOps.operationId);
|
||||||
|
this.scrollUpdateLogToBottom();
|
||||||
|
},
|
||||||
|
async downloadActiveLogViewer() {
|
||||||
|
if (this.logViewer.kind === "service") {
|
||||||
|
await this.downloadServiceLog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.updateOps.operationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.downloadFile(this.updateLogDownloadUrl(), `service-update-${this.updateOps.operationId}.log`);
|
||||||
|
} catch (error) {
|
||||||
|
this.updateOps.error = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
clearServiceLog() {
|
clearServiceLog() {
|
||||||
this.serviceLog.loading = false;
|
this.serviceLog.loading = false;
|
||||||
this.serviceLog.exporting = false;
|
this.serviceLog.exporting = false;
|
||||||
@ -1156,13 +1366,21 @@ createApp({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.selectedServiceDetail || this.selectedServiceDetail.service !== this.serviceLog.service) {
|
if (!this.selectedServiceDetail || this.selectedServiceDetail.service !== this.serviceLog.service) {
|
||||||
|
if (this.logViewer.kind === "service" && this.logViewer.open) {
|
||||||
|
this.closeLogViewer();
|
||||||
|
} else {
|
||||||
this.clearServiceLog();
|
this.clearServiceLog();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const exists = (this.selectedServiceDetail.instances || []).some((instance) => instance.host === this.serviceLog.host);
|
const exists = (this.selectedServiceDetail.instances || []).some((instance) => instance.host === this.serviceLog.host);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
|
if (this.logViewer.kind === "service" && this.logViewer.open) {
|
||||||
|
this.closeLogViewer();
|
||||||
|
} else {
|
||||||
this.clearServiceLog();
|
this.clearServiceLog();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
isServiceLogActive(instance) {
|
isServiceLogActive(instance) {
|
||||||
return this.serviceLog.service === (this.selectedServiceDetail?.service || "") && this.serviceLog.host === instance.host;
|
return this.serviceLog.service === (this.selectedServiceDetail?.service || "") && this.serviceLog.host === instance.host;
|
||||||
@ -1174,6 +1392,8 @@ createApp({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logViewer.open = true;
|
||||||
|
this.logViewer.kind = "service";
|
||||||
this.serviceLog.loading = true;
|
this.serviceLog.loading = true;
|
||||||
this.serviceLog.error = "";
|
this.serviceLog.error = "";
|
||||||
this.serviceLog.service = serviceName;
|
this.serviceLog.service = serviceName;
|
||||||
@ -1199,6 +1419,7 @@ createApp({
|
|||||||
Math.max(1, Number(this.serviceLog.exportLines || this.serviceLog.previewLines)),
|
Math.max(1, Number(this.serviceLog.exportLines || this.serviceLog.previewLines)),
|
||||||
this.serviceLog.exportMaxLines,
|
this.serviceLog.exportMaxLines,
|
||||||
);
|
);
|
||||||
|
this.$nextTick(() => this.scrollUpdateLogToBottom());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.serviceLog.error = error instanceof Error ? error.message : String(error);
|
this.serviceLog.error = error instanceof Error ? error.message : String(error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -1265,6 +1486,81 @@ createApp({
|
|||||||
}
|
}
|
||||||
return parts.filter(Boolean).join(" · ");
|
return parts.filter(Boolean).join(" · ");
|
||||||
},
|
},
|
||||||
|
hostMetricEntries(host) {
|
||||||
|
const metrics = host?.metrics || {};
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "cpu",
|
||||||
|
label: "CPU",
|
||||||
|
value: metrics.cpuPercent,
|
||||||
|
display: this.formatPercent(metrics.cpuPercent),
|
||||||
|
level: metrics.cpuLevel || this.thresholdTone("cpuPercent", metrics.cpuPercent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "memory",
|
||||||
|
label: "MEM",
|
||||||
|
value: metrics.memoryPercent,
|
||||||
|
display: this.formatPercent(metrics.memoryPercent),
|
||||||
|
level: metrics.memoryLevel || this.thresholdTone("memoryPercent", metrics.memoryPercent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "disk",
|
||||||
|
label: "DISK",
|
||||||
|
value: metrics.diskPercent,
|
||||||
|
display: this.formatPercent(metrics.diskPercent),
|
||||||
|
level: metrics.diskLevel || this.thresholdTone("diskPercent", metrics.diskPercent),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
thresholdTone(metricKey, value) {
|
||||||
|
if (value == null || Number.isNaN(Number(value))) {
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
const rule = this.thresholds?.[metricKey];
|
||||||
|
if (!rule) {
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
const amount = Number(value);
|
||||||
|
const bad = Number(rule.bad);
|
||||||
|
const warn = Number(rule.warn);
|
||||||
|
if (!Number.isNaN(bad) && amount >= bad) {
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
|
if (!Number.isNaN(warn) && amount >= warn) {
|
||||||
|
return "warn";
|
||||||
|
}
|
||||||
|
return "ok";
|
||||||
|
},
|
||||||
|
metricRingStyle(value, level = "neutral") {
|
||||||
|
const amount = value == null || Number.isNaN(Number(value))
|
||||||
|
? 0
|
||||||
|
: Math.max(0, Math.min(100, Number(value)));
|
||||||
|
const tone = level || "neutral";
|
||||||
|
return {
|
||||||
|
"--metric-value": `${amount}%`,
|
||||||
|
"--metric-color": `var(--${tone === "bad" ? "bad" : tone === "warn" ? "warn" : tone === "ok" ? "ok" : "line-strong"})`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hostServiceSummary(host, { quiet = false } = {}) {
|
||||||
|
const source = quiet ? host.items : (host.alertItems.length > 0 ? host.alertItems : host.items);
|
||||||
|
const services = [...new Set((source || []).map((item) => item.service).filter(Boolean))];
|
||||||
|
if (services.length === 0) {
|
||||||
|
return host.hasMetricIssue ? "指标采集异常" : "无服务";
|
||||||
|
}
|
||||||
|
if (services.length <= 4) {
|
||||||
|
return services.join(" / ");
|
||||||
|
}
|
||||||
|
return `${services.slice(0, 4).join(" / ")} +${services.length - 4}`;
|
||||||
|
},
|
||||||
|
hostSummaryTone(host) {
|
||||||
|
if (host.downCount > 0) {
|
||||||
|
return "bad";
|
||||||
|
}
|
||||||
|
if (host.hasMetricIssue) {
|
||||||
|
return "warn";
|
||||||
|
}
|
||||||
|
return "neutral";
|
||||||
|
},
|
||||||
buildHostRows({ businessOnly = false, applyFilter = false, applyKeyword = false } = {}) {
|
buildHostRows({ businessOnly = false, applyFilter = false, applyKeyword = false } = {}) {
|
||||||
const keyword = applyKeyword ? this.normalizedKeyword : "";
|
const keyword = applyKeyword ? this.normalizedKeyword : "";
|
||||||
|
|
||||||
@ -1286,10 +1582,11 @@ createApp({
|
|||||||
const hasMetricIssue = metrics.ok === false || Boolean(metrics.error);
|
const hasMetricIssue = metrics.ok === false || Boolean(metrics.error);
|
||||||
const alertServices = [...new Set(alertItems.map((item) => item.service))];
|
const alertServices = [...new Set(alertItems.map((item) => item.service))];
|
||||||
const alertSummary = alertServices.length > 0
|
const alertSummary = alertServices.length > 0
|
||||||
? alertServices.join(" / ")
|
? this.hostServiceSummary({ items, alertItems, hasMetricIssue }, { quiet: false })
|
||||||
: hasMetricIssue
|
: hasMetricIssue
|
||||||
? "指标采集异常"
|
? "指标采集异常"
|
||||||
: "全部正常";
|
: "无服务异常";
|
||||||
|
const quietSummary = this.hostServiceSummary({ items, alertItems, hasMetricIssue }, { quiet: true });
|
||||||
const searchText = [
|
const searchText = [
|
||||||
host.host,
|
host.host,
|
||||||
host.ip,
|
host.ip,
|
||||||
@ -1313,6 +1610,7 @@ createApp({
|
|||||||
items,
|
items,
|
||||||
alertItems,
|
alertItems,
|
||||||
alertSummary,
|
alertSummary,
|
||||||
|
quietSummary,
|
||||||
groupLabel: this.groupLabel(host.group),
|
groupLabel: this.groupLabel(host.group),
|
||||||
serviceTotal: items.length,
|
serviceTotal: items.length,
|
||||||
downCount,
|
downCount,
|
||||||
@ -1831,7 +2129,7 @@ createApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.sessionStorage.removeItem(UPDATE_OPERATION_STORAGE_KEY);
|
window.sessionStorage.removeItem(UPDATE_OPERATION_STORAGE_KEY);
|
||||||
window.location.replace("/login");
|
window.location.replace(loginRedirectPath());
|
||||||
},
|
},
|
||||||
async authFetch(url, options = {}) {
|
async authFetch(url, options = {}) {
|
||||||
const requestOptions = {
|
const requestOptions = {
|
||||||
@ -1843,7 +2141,8 @@ createApp({
|
|||||||
requestOptions.cache = "no-store";
|
requestOptions.cache = "no-store";
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(url, requestOptions);
|
const actualUrl = typeof url === "string" && url.startsWith("/") ? appPath(url) : url;
|
||||||
|
return fetch(actualUrl, requestOptions);
|
||||||
},
|
},
|
||||||
async requestJson(url, options = {}) {
|
async requestJson(url, options = {}) {
|
||||||
const response = await this.authFetch(url, options);
|
const response = await this.authFetch(url, options);
|
||||||
@ -1860,10 +2159,7 @@ createApp({
|
|||||||
this.auth.checking = true;
|
this.auth.checking = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/auth/session", {
|
const response = await this.authFetch("/api/auth/session");
|
||||||
cache: "no-store",
|
|
||||||
credentials: "same-origin",
|
|
||||||
});
|
|
||||||
const payload = await this.readJsonResponse(response);
|
const payload = await this.readJsonResponse(response);
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@ -2043,6 +2339,7 @@ createApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.nacosConfig.selectedKey = nextKey;
|
this.nacosConfig.selectedKey = nextKey;
|
||||||
|
this.syncConfigExplorerState(nextKey, { force: !preserveSelection || this.collapsedConfigGroups.length === 0 });
|
||||||
|
|
||||||
const sameAsCurrent = this.configKey(this.nacosConfig.editor.group, this.nacosConfig.editor.dataId) === nextKey;
|
const sameAsCurrent = this.configKey(this.nacosConfig.editor.group, this.nacosConfig.editor.dataId) === nextKey;
|
||||||
if (!sameAsCurrent || reloadSelected || !this.nacosConfig.loadedContent) {
|
if (!sameAsCurrent || reloadSelected || !this.nacosConfig.loadedContent) {
|
||||||
@ -2090,6 +2387,8 @@ createApp({
|
|||||||
md5: item.md5 || "",
|
md5: item.md5 || "",
|
||||||
content: this.nacosConfig.loadedContent,
|
content: this.nacosConfig.loadedContent,
|
||||||
};
|
};
|
||||||
|
this.syncConfigExplorerState(configKey);
|
||||||
|
this.scrollSelectedConfigItemIntoView();
|
||||||
this.recordConfigHistory("nacos", "打开配置", `${item.group}/${item.dataId}`, "neutral");
|
this.recordConfigHistory("nacos", "打开配置", `${item.group}/${item.dataId}`, "neutral");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.nacosConfig.detailError = error instanceof Error ? error.message : String(error);
|
this.nacosConfig.detailError = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
50
static/haiyi.html
Normal file
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 charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>HY App Monitor</title>
|
<title>HY App Monitor</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="app.css" />
|
||||||
<script defer src="/vue.js"></script>
|
<script defer src="vue.js"></script>
|
||||||
<script defer src="/app.js"></script>
|
<script defer src="app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@ -190,11 +190,11 @@
|
|||||||
<span>构建参数 / 更新目标 / 执行日志 / 滚动重启</span>
|
<span>构建参数 / 更新目标 / 执行日志 / 滚动重启</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group release-section-nav">
|
||||||
<button
|
<button
|
||||||
v-for="section in releaseSections"
|
v-for="section in releaseSections"
|
||||||
:key="section.id"
|
:key="section.id"
|
||||||
class="filter-chip"
|
class="section-link"
|
||||||
@click="scrollToSection(section.id)"
|
@click="scrollToSection(section.id)"
|
||||||
>
|
>
|
||||||
{{ section.label }}
|
{{ section.label }}
|
||||||
@ -202,20 +202,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-group control-group-spread">
|
<div class="control-group control-group-spread">
|
||||||
<div class="density-switch">
|
<label class="interval-field">
|
||||||
<button :class="['filter-chip', releaseKindFilter === 'all' ? 'active' : '']" @click="releaseKindFilter = 'all'">全部</button>
|
<span>类型</span>
|
||||||
<button :class="['filter-chip', releaseKindFilter === 'java' ? 'active' : '']" @click="releaseKindFilter = 'java'">Java</button>
|
<select v-model="releaseKindFilter" class="interval-select release-kind-select">
|
||||||
<button :class="['filter-chip', releaseKindFilter === 'golang' ? 'active' : '']" @click="releaseKindFilter = 'golang'">Golang</button>
|
<option value="all">全部</option>
|
||||||
<button :class="['filter-chip', releaseKindFilter === 'frontend' ? 'active' : '']" @click="releaseKindFilter = 'frontend'">前端</button>
|
<option value="java">Java</option>
|
||||||
</div>
|
<option value="golang">Golang</option>
|
||||||
|
<option value="frontend">前端</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<input v-model.trim="releaseKeyword" class="search" placeholder="搜索服务 / 主机 / 镜像" />
|
<input v-model.trim="releaseKeyword" class="search" placeholder="搜索服务 / 主机 / 镜像" />
|
||||||
<button class="refresh ghost" @click="refreshReleaseTargets" :disabled="updateOps.loading || restartOps.loading || updateOps.running || restartOps.running">
|
|
||||||
刷新目标
|
|
||||||
</button>
|
|
||||||
<button class="refresh ghost" @click="runReleasePrecheck">预检查</button>
|
|
||||||
<button class="refresh danger" @click="runServiceUpdate" :disabled="updateOps.running || updateOps.selectedServices.length === 0">
|
|
||||||
{{ updateOps.running ? '更新中...' : `更新 ${updateOps.selectedServices.length} 项服务` }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@ -384,7 +380,7 @@
|
|||||||
@click="openServiceLog(instance)"
|
@click="openServiceLog(instance)"
|
||||||
:disabled="serviceLog.loading && isServiceLogActive(instance)"
|
:disabled="serviceLog.loading && isServiceLogActive(instance)"
|
||||||
>
|
>
|
||||||
{{ serviceLog.loading && isServiceLogActive(instance) ? '读取中...' : '日志' }}
|
{{ serviceLog.loading && isServiceLogActive(instance) ? '读取中...' : '查看日志' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -398,32 +394,6 @@
|
|||||||
<div v-if="densityMode === 'detail'" class="detail-note">{{ instance.detail || '无返回内容' }}</div>
|
<div v-if="densityMode === 'detail'" class="detail-note">{{ instance.detail || '无返回内容' }}</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="serviceLog.service && serviceLog.host" class="detail-log-panel">
|
|
||||||
<header class="detail-subhead">
|
|
||||||
<strong>{{ serviceLog.service }} · {{ serviceLog.host }}</strong>
|
|
||||||
<span>{{ formatDateTime(serviceLog.updatedAt) }}</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="detail-log-toolbar">
|
|
||||||
<span class="badge neutral mono" v-if="serviceLog.container">{{ serviceLog.container }}</span>
|
|
||||||
<span class="badge neutral">最近 {{ serviceLog.previewLines }} 条</span>
|
|
||||||
<label class="interval-field detail-log-field">
|
|
||||||
<span>导出</span>
|
|
||||||
<input v-model.number="serviceLog.exportLines" type="number" min="1" :max="serviceLog.exportMaxLines" class="detail-log-input" />
|
|
||||||
</label>
|
|
||||||
<button class="refresh ghost" @click="downloadServiceLog" :disabled="serviceLog.exporting">
|
|
||||||
{{ serviceLog.exporting ? '导出中...' : '导出' }}
|
|
||||||
</button>
|
|
||||||
<button class="refresh ghost" @click="openServiceLog({ host: serviceLog.host })" :disabled="serviceLog.loading">
|
|
||||||
{{ serviceLog.loading ? '刷新中...' : '刷新' }}
|
|
||||||
</button>
|
|
||||||
<button class="refresh ghost" @click="clearServiceLog">关闭</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="serviceLog.error" class="metric-error">{{ serviceLog.error }}</p>
|
|
||||||
<pre class="detail-log-output mono">{{ serviceLog.content || '暂无日志' }}</pre>
|
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="empty-panel">当前没有服务详情</div>
|
<div v-else class="empty-panel">当前没有服务详情</div>
|
||||||
@ -459,7 +429,13 @@
|
|||||||
|
|
||||||
<div class="host-sections">
|
<div class="host-sections">
|
||||||
<section class="host-section">
|
<section class="host-section">
|
||||||
|
<div class="section-caption-row">
|
||||||
<div class="section-caption">异常主机</div>
|
<div class="section-caption">异常主机</div>
|
||||||
|
<div class="section-caption-meta">
|
||||||
|
<span>{{ overviewAlertHosts.length }} 台</span>
|
||||||
|
<span>{{ overviewAlertHosts.filter((host) => host.hasMetricIssue).length }} 采集异常</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="host-row-list">
|
<div class="host-row-list">
|
||||||
<article
|
<article
|
||||||
v-for="host in overviewAlertHosts"
|
v-for="host in overviewAlertHosts"
|
||||||
@ -477,14 +453,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="host-inline-metrics compact-metrics">
|
<div class="host-inline-metrics compact-metrics">
|
||||||
<span>CPU {{ formatPercent(host.metrics.cpuPercent) }}</span>
|
<div
|
||||||
<span>MEM {{ formatPercent(host.metrics.memoryPercent) }}</span>
|
v-for="metric in hostMetricEntries(host)"
|
||||||
<span>DISK {{ formatPercent(host.metrics.diskPercent) }}</span>
|
:key="host.host + '-' + metric.key"
|
||||||
<span>PROC {{ host.metrics.processCount ?? '-' }}</span>
|
:class="['metric-ring-chip', `tone-${metric.level}`]"
|
||||||
|
>
|
||||||
|
<span class="metric-ring" :style="metricRingStyle(metric.value, metric.level)">
|
||||||
|
<span class="metric-ring-hole"></span>
|
||||||
|
</span>
|
||||||
|
<span class="metric-ring-copy">
|
||||||
|
<small>{{ metric.label }}</small>
|
||||||
|
<strong>{{ metric.display }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span :class="['metric-stat-chip', `tone-${host.metrics.processLevel || thresholdTone('processCount', host.metrics.processCount)}`]">
|
||||||
|
<small>PROC</small>
|
||||||
|
<strong>{{ host.metrics.processCount ?? '-' }}</strong>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="['host-alert-summary', host.hasAlert ? 'is-alert' : 'is-quiet']">
|
<div :class="['host-alert-summary', `tone-${hostSummaryTone(host)}`]">
|
||||||
<span class="host-alert-path">{{ host.alertSummary }}</span>
|
<small>{{ host.downCount > 0 ? '异常服务' : host.hasMetricIssue ? '采集状态' : '在线服务' }}</small>
|
||||||
|
<span class="host-alert-path">{{ host.downCount > 0 ? host.alertSummary : host.quietSummary }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="host-inline-state">
|
<div class="host-inline-state">
|
||||||
@ -541,14 +531,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="host-inline-metrics compact-metrics">
|
<div class="host-inline-metrics compact-metrics">
|
||||||
<span>CPU {{ formatPercent(host.metrics.cpuPercent) }}</span>
|
<div
|
||||||
<span>MEM {{ formatPercent(host.metrics.memoryPercent) }}</span>
|
v-for="metric in hostMetricEntries(host)"
|
||||||
<span>DISK {{ formatPercent(host.metrics.diskPercent) }}</span>
|
:key="'quiet-' + host.host + '-' + metric.key"
|
||||||
<span>PROC {{ host.metrics.processCount ?? '-' }}</span>
|
:class="['metric-ring-chip', `tone-${metric.level}`]"
|
||||||
|
>
|
||||||
|
<span class="metric-ring" :style="metricRingStyle(metric.value, metric.level)">
|
||||||
|
<span class="metric-ring-hole"></span>
|
||||||
|
</span>
|
||||||
|
<span class="metric-ring-copy">
|
||||||
|
<small>{{ metric.label }}</small>
|
||||||
|
<strong>{{ metric.display }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span :class="['metric-stat-chip', `tone-${host.metrics.processLevel || thresholdTone('processCount', host.metrics.processCount)}`]">
|
||||||
|
<small>PROC</small>
|
||||||
|
<strong>{{ host.metrics.processCount ?? '-' }}</strong>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="host-alert-summary is-quiet">
|
<div class="host-alert-summary tone-neutral">
|
||||||
<span class="host-alert-path">{{ host.alertSummary }}</span>
|
<small>在线服务</small>
|
||||||
|
<span class="host-alert-path">{{ host.quietSummary }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="host-inline-state">
|
<div class="host-inline-state">
|
||||||
@ -580,13 +584,21 @@
|
|||||||
<p>{{ filteredHostRows.length }} 台主机</p>
|
<p>{{ filteredHostRows.length }} 台主机</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-meta">
|
<div class="panel-meta">
|
||||||
<span class="badge neutral">点击主机展开服务</span>
|
<span class="badge neutral">{{ filteredHostAlertTotal }} 台异常</span>
|
||||||
|
<span :class="['badge', filteredHostMetricIssueTotal > 0 ? 'warn' : 'neutral']">{{ filteredHostMetricIssueTotal }} 采集异常</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="host-sections">
|
<div class="host-sections">
|
||||||
<section v-for="section in hostRowGroups" :key="section.group" class="host-section">
|
<section v-for="section in hostRowGroups" :key="section.group" class="host-section">
|
||||||
|
<div class="section-caption-row">
|
||||||
<div class="section-caption">{{ section.label }}</div>
|
<div class="section-caption">{{ section.label }}</div>
|
||||||
|
<div class="section-caption-meta">
|
||||||
|
<span>{{ section.items.length }} 台</span>
|
||||||
|
<span>{{ section.serviceCount }} 服务</span>
|
||||||
|
<span>{{ section.alertCount }} 异常主机</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="host-row-list">
|
<div class="host-row-list">
|
||||||
<article
|
<article
|
||||||
v-for="host in section.items"
|
v-for="host in section.items"
|
||||||
@ -604,14 +616,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="host-inline-metrics compact-metrics">
|
<div class="host-inline-metrics compact-metrics">
|
||||||
<span>CPU {{ formatPercent(host.metrics.cpuPercent) }}</span>
|
<div
|
||||||
<span>MEM {{ formatPercent(host.metrics.memoryPercent) }}</span>
|
v-for="metric in hostMetricEntries(host)"
|
||||||
<span>DISK {{ formatPercent(host.metrics.diskPercent) }}</span>
|
:key="'host-' + host.host + '-' + metric.key"
|
||||||
<span>PROC {{ host.metrics.processCount ?? '-' }}</span>
|
:class="['metric-ring-chip', `tone-${metric.level}`]"
|
||||||
|
>
|
||||||
|
<span class="metric-ring" :style="metricRingStyle(metric.value, metric.level)">
|
||||||
|
<span class="metric-ring-hole"></span>
|
||||||
|
</span>
|
||||||
|
<span class="metric-ring-copy">
|
||||||
|
<small>{{ metric.label }}</small>
|
||||||
|
<strong>{{ metric.display }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span :class="['metric-stat-chip', `tone-${host.metrics.processLevel || thresholdTone('processCount', host.metrics.processCount)}`]">
|
||||||
|
<small>PROC</small>
|
||||||
|
<strong>{{ host.metrics.processCount ?? '-' }}</strong>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="['host-alert-summary', host.hasAlert ? 'is-alert' : 'is-quiet']">
|
<div :class="['host-alert-summary', `tone-${hostSummaryTone(host)}`]">
|
||||||
<span class="host-alert-path">{{ host.alertSummary }}</span>
|
<small>{{ host.downCount > 0 ? '异常服务' : host.hasMetricIssue ? '采集状态' : '在线服务' }}</small>
|
||||||
|
<span class="host-alert-path">{{ host.downCount > 0 ? host.alertSummary : host.quietSummary }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="host-inline-state">
|
<div class="host-inline-state">
|
||||||
@ -1152,8 +1178,15 @@
|
|||||||
|
|
||||||
<div class="config-layout">
|
<div class="config-layout">
|
||||||
<aside class="config-sidebar">
|
<aside class="config-sidebar">
|
||||||
|
<div class="config-sidebar-head">
|
||||||
<div class="config-sidebar-meta">
|
<div class="config-sidebar-meta">
|
||||||
<span class="badge neutral mono">{{ truncateMiddle(nacosConfig.baseUrl, 16, 12) || '-' }}</span>
|
<span class="badge neutral mono">{{ truncateMiddle(nacosConfig.baseUrl, 16, 12) || '-' }}</span>
|
||||||
|
<span class="badge neutral">{{ nacosConfigGroups.length }} 组</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-sidebar-path" v-if="nacosConfig.editor.dataId">
|
||||||
|
<strong>{{ nacosConfig.editor.dataId }}</strong>
|
||||||
|
<span>{{ nacosConfig.editor.group }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="config-list config-list-ide">
|
<div class="config-list config-list-ide">
|
||||||
@ -1168,12 +1201,17 @@
|
|||||||
<button
|
<button
|
||||||
v-for="item in group.items"
|
v-for="item in group.items"
|
||||||
:key="configKey(item.group, item.dataId)"
|
:key="configKey(item.group, item.dataId)"
|
||||||
|
:id="`config-item-${configKey(item.group, item.dataId)}`"
|
||||||
:class="['config-item', 'config-item-ide', configKey(item.group, item.dataId) === nacosConfig.selectedKey ? 'active' : '']"
|
:class="['config-item', 'config-item-ide', configKey(item.group, item.dataId) === nacosConfig.selectedKey ? 'active' : '']"
|
||||||
@click="openNacosConfig(item)"
|
@click="openNacosConfig(item)"
|
||||||
>
|
>
|
||||||
|
<div class="config-item-main">
|
||||||
<strong>{{ item.dataId }}</strong>
|
<strong>{{ item.dataId }}</strong>
|
||||||
<span>{{ item.type || 'text' }}</span>
|
|
||||||
<small v-if="item.appName">{{ item.appName }}</small>
|
<small v-if="item.appName">{{ item.appName }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="config-item-side">
|
||||||
|
<span>{{ item.type || 'text' }}</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -1198,6 +1236,11 @@
|
|||||||
<button :class="['filter-chip', configEditorMode === 'diff' ? 'active' : '']" @click="configEditorMode = 'diff'">Diff</button>
|
<button :class="['filter-chip', configEditorMode === 'diff' ? 'active' : '']" @click="configEditorMode = 'diff'">Diff</button>
|
||||||
<button :class="['filter-chip', configEditorMode === 'source' ? 'active' : '']" @click="configEditorMode = 'source'">源码</button>
|
<button :class="['filter-chip', configEditorMode === 'source' ? 'active' : '']" @click="configEditorMode = 'source'">源码</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="config-jump-group">
|
||||||
|
<button class="refresh ghost" @click="openPrevNacosConfig" :disabled="!selectedPrevNacosConfigKey || nacosConfig.detailLoading || nacosConfig.saving">上一项</button>
|
||||||
|
<button class="refresh ghost" @click="openNextNacosConfig" :disabled="!selectedNextNacosConfigKey || nacosConfig.detailLoading || nacosConfig.saving">下一项</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-group">
|
<div class="action-group">
|
||||||
@ -1362,6 +1405,28 @@
|
|||||||
<span class="badge neutral">已选 {{ restartOps.selectedServices.length }} 项重启</span>
|
<span class="badge neutral">已选 {{ restartOps.selectedServices.length }} 项重启</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="release-action-bar">
|
||||||
|
<div class="release-action-summary">
|
||||||
|
<span class="badge neutral">更新目标 {{ releaseTargetRows.length }}</span>
|
||||||
|
<span class="badge neutral">重启目标 {{ restartTargetRows.length }}</span>
|
||||||
|
<span :class="['badge', updateOps.error || restartOps.error ? 'bad' : releasePrecheck.ok ? 'ok' : 'neutral']">
|
||||||
|
{{ releasePrecheck.ranAt ? releasePrecheck.title : '未预检查' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-group">
|
||||||
|
<button class="refresh ghost" @click="refreshReleaseTargets" :disabled="updateOps.loading || restartOps.loading || updateOps.running || restartOps.running">
|
||||||
|
{{ updateOps.loading || restartOps.loading ? '刷新中...' : '刷新目标' }}
|
||||||
|
</button>
|
||||||
|
<button class="refresh ghost" @click="runReleasePrecheck" :disabled="updateOps.running || restartOps.running">预检查</button>
|
||||||
|
<button class="refresh ghost" @click="openUpdateLogViewer" :disabled="!updateOps.operationId && !updateOps.logs.length">
|
||||||
|
查看日志
|
||||||
|
</button>
|
||||||
|
<button class="refresh danger" @click="runServiceUpdate" :disabled="updateOps.running || updateOps.selectedServices.length === 0">
|
||||||
|
{{ updateOps.running ? '更新中...' : `执行更新 ${updateOps.selectedServices.length}` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel release-status-strip sticky-release-strip">
|
<section class="panel release-status-strip sticky-release-strip">
|
||||||
@ -1433,13 +1498,11 @@
|
|||||||
<header class="panel-header">
|
<header class="panel-header">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<h2>更新目标</h2>
|
<h2>更新目标</h2>
|
||||||
<p>紧凑表格,长镜像悬停查看</p>
|
<p>紧凑表格</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-meta">
|
<div class="panel-meta">
|
||||||
<span class="badge neutral">{{ releaseTargetRows.length }} 项</span>
|
<span class="badge neutral">{{ releaseTargetRows.length }} 项</span>
|
||||||
<button class="refresh ghost" @click="refreshUpdateTargets" :disabled="updateOps.loading || updateOps.running">
|
<span class="badge neutral">已选 {{ updateOps.selectedServices.length }} 项</span>
|
||||||
{{ updateOps.loading ? '加载中...' : '仅刷新更新目标' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -1501,22 +1564,12 @@
|
|||||||
<header class="panel-header">
|
<header class="panel-header">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<h2>执行日志</h2>
|
<h2>执行日志</h2>
|
||||||
<p>任务、阶段、结果闭环</p>
|
<p>页内只看摘要,完整日志弹出</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-meta">
|
<div class="panel-meta">
|
||||||
<span v-if="updateOps.operationId" class="badge neutral mono">任务 {{ updateOps.operationId }}</span>
|
<span v-if="updateOps.operationId" class="badge neutral mono">任务 {{ updateOps.operationId }}</span>
|
||||||
<span class="badge neutral mono">阶段 {{ updateOps.stage || '-' }}</span>
|
<span class="badge neutral mono">阶段 {{ updateOps.stage || '-' }}</span>
|
||||||
<button class="refresh ghost" @click="fetchUpdateOperationStatus(updateOps.operationId)" :disabled="!updateOps.operationId || updateOps.running">刷新日志</button>
|
<button class="refresh ghost" @click="openUpdateLogViewer" :disabled="!updateOps.operationId && !updateOps.logs.length">查看日志</button>
|
||||||
<a
|
|
||||||
v-if="updateOps.operationId"
|
|
||||||
class="refresh ghost"
|
|
||||||
:href="updateLogDownloadUrl()"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
download
|
|
||||||
>
|
|
||||||
下载日志
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -1527,8 +1580,19 @@
|
|||||||
<span>影响服务 {{ updateAffectedServicesCount }}</span>
|
<span>影响服务 {{ updateAffectedServicesCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="update-log-panel">
|
<section class="release-log-preview">
|
||||||
<pre id="service-update-log-output" class="update-log-output mono">{{ updateLogText || '等待更新日志...' }}</pre>
|
<div class="release-log-preview-head">
|
||||||
|
<strong>{{ updateOps.logs.length }} 条日志</strong>
|
||||||
|
<span v-if="updateOps.logDroppedCount > 0" class="badge warn">截断 {{ updateOps.logDroppedCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="recentUpdateLogs.length > 0" class="release-log-snippets">
|
||||||
|
<article v-for="entry in recentUpdateLogs" :key="entry.key" :class="['release-log-snippet', entry.level === 'error' ? 'tone-bad' : 'tone-neutral']">
|
||||||
|
<strong>{{ formatDateTime(entry.time) }}</strong>
|
||||||
|
<span>{{ entry.stage || '-' }}</span>
|
||||||
|
<p>{{ entry.message || '-' }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-panel compact">当前还没有可展示的执行日志</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="activity-list compact" v-if="releaseHistoryRows.length > 0">
|
<div class="activity-list compact" v-if="releaseHistoryRows.length > 0">
|
||||||
@ -1632,17 +1696,14 @@
|
|||||||
<header class="panel-header">
|
<header class="panel-header">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<h2>滚动重启</h2>
|
<h2>滚动重启</h2>
|
||||||
<p>紧凑选择表</p>
|
<p>按服务勾选执行</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-meta">
|
<div class="panel-meta">
|
||||||
<span :class="['badge', restartOps.error ? 'bad' : restartOps.running ? 'warn' : 'ok']">
|
<span :class="['badge', restartOps.error ? 'bad' : restartOps.running ? 'warn' : 'ok']">
|
||||||
{{ restartOps.error ? '存在错误' : restartOps.running ? '执行中' : '可操作' }}
|
{{ restartOps.error ? '存在错误' : restartOps.running ? '执行中' : '可操作' }}
|
||||||
</span>
|
</span>
|
||||||
<button class="refresh ghost" @click="refreshRestartTargets" :disabled="restartOps.loading || restartOps.running">
|
|
||||||
{{ restartOps.loading ? '加载中...' : '仅刷新重启目标' }}
|
|
||||||
</button>
|
|
||||||
<button class="refresh danger" @click="runRollingRestart" :disabled="restartOps.running || restartOps.selectedServices.length === 0">
|
<button class="refresh danger" @click="runRollingRestart" :disabled="restartOps.running || restartOps.selectedServices.length === 0">
|
||||||
{{ restartOps.running ? '重启中...' : `滚动重启 ${restartOps.selectedServices.length} 项服务` }}
|
{{ restartOps.running ? '重启中...' : `执行重启 ${restartOps.selectedServices.length}` }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -1722,6 +1783,38 @@
|
|||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div v-if="logViewer.open" class="log-viewer-overlay" @click.self="closeLogViewer">
|
||||||
|
<section class="log-viewer" role="dialog" aria-modal="true" :aria-labelledby="'log-viewer-title'">
|
||||||
|
<header class="log-viewer-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h2 id="log-viewer-title">{{ activeLogViewerTitle }}</h2>
|
||||||
|
<p>{{ activeLogViewerSubtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="action-group">
|
||||||
|
<button class="refresh ghost" @click="refreshActiveLogViewer" :disabled="activeLogViewerRefreshDisabled">
|
||||||
|
{{ activeLogViewerLoading ? '刷新中...' : '刷新' }}
|
||||||
|
</button>
|
||||||
|
<button class="refresh ghost" @click="downloadActiveLogViewer" :disabled="activeLogViewerDownloadDisabled">
|
||||||
|
{{ activeLogViewerExporting ? '导出中...' : '导出' }}
|
||||||
|
</button>
|
||||||
|
<button class="refresh ghost" @click="closeLogViewer">关闭</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="log-viewer-toolbar">
|
||||||
|
<span v-if="activeLogViewerBadge" class="badge neutral mono">{{ activeLogViewerBadge }}</span>
|
||||||
|
<span v-if="activeLogViewerSecondaryBadge" class="badge neutral">{{ activeLogViewerSecondaryBadge }}</span>
|
||||||
|
<label v-if="logViewer.kind === 'service'" class="interval-field detail-log-field">
|
||||||
|
<span>导出</span>
|
||||||
|
<input v-model.number="serviceLog.exportLines" type="number" min="1" :max="serviceLog.exportMaxLines" class="detail-log-input" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="activeLogViewerError" class="metric-error">{{ activeLogViewerError }}</p>
|
||||||
|
<pre id="service-update-log-output" class="log-viewer-output mono">{{ activeLogViewerContent || '暂无日志' }}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>HY App Monitor Login</title>
|
<title>HY App Monitor Login</title>
|
||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="app.css" />
|
||||||
<script defer src="/login.js"></script>
|
<script defer src="login.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="auth-page">
|
<body class="auth-page">
|
||||||
|
|||||||
@ -10,6 +10,15 @@ async function readJsonResponse(response) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nextLocation() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const candidate = (params.get("next") || "").trim();
|
||||||
|
if (candidate.startsWith("/") && !candidate.startsWith("//")) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return "/select";
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", async () => {
|
window.addEventListener("DOMContentLoaded", async () => {
|
||||||
const bootIndicator = document.getElementById("boot-indicator");
|
const bootIndicator = document.getElementById("boot-indicator");
|
||||||
const root = document.getElementById("login-root");
|
const root = document.getElementById("login-root");
|
||||||
@ -42,7 +51,7 @@ window.addEventListener("DOMContentLoaded", async () => {
|
|||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
window.location.replace("/");
|
window.location.replace(nextLocation());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,7 +92,7 @@ window.addEventListener("DOMContentLoaded", async () => {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
window.location.replace("/");
|
window.location.replace(nextLocation());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : String(error));
|
setError(error instanceof Error ? error.message : String(error));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
51
static/select.html
Normal file
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]
|
[Unit]
|
||||||
Description=HY App Monitor
|
Description=HY App Monitor Gateway
|
||||||
After=network-online.target
|
After=network-online.target hy-app-monitor-yumi.service hy-app-monitor-haiyi.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target hy-app-monitor-yumi.service hy-app-monitor-haiyi.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|||||||
18
yumi_server.py
Normal file
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