增加滚动重启
This commit is contained in:
parent
492c9db93b
commit
e700149a99
@ -106,6 +106,8 @@ python3 ops/deploy_via_tat.py
|
||||
- 主机:`CPU / 内存 / 磁盘 / 进程数`,带阈值颜色分级
|
||||
- 服务:Java / Golang 健康检查和耗时
|
||||
- Nacos:命名空间、服务、注册实例、健康状态
|
||||
- Nacos 配置:读取当前 namespace 下的配置列表,支持在线编辑保存
|
||||
- Nacos 配置:读取当前 namespace 下的配置列表,支持格式校验、在线编辑保存
|
||||
- Golang 配置:读取 app 节点当前运行中的 `service.env`,展示 `chatapp3-golang` 实际消费的环境变量并支持保存
|
||||
- 滚动重启:可按服务选择 Java / Golang 节点,逐台重启并等待健康恢复
|
||||
- MongoDB:连接、内存、网络、操作计数、复制集、数据库统计
|
||||
- 自动刷新:前端可切换刷新间隔,并保存在浏览器本地
|
||||
|
||||
@ -37,6 +37,14 @@ HOST_METRIC_CACHE_TTL_SECONDS = env_float("HOST_METRIC_CACHE_TTL_SECONDS", 25.0)
|
||||
TAT_METRIC_TIMEOUT_SECONDS = env_int("TAT_METRIC_TIMEOUT_SECONDS", 30)
|
||||
# TAT CPU 采样间隔。
|
||||
TAT_CPU_SAMPLE_SECONDS = env_float("TAT_CPU_SAMPLE_SECONDS", 1.0)
|
||||
# 业务运行目录根路径。
|
||||
RUNTIME_BASE_DIR = env_str("RUNTIME_BASE_DIR", "/opt/likei")
|
||||
# 滚动重启单实例最大等待秒数。
|
||||
ROLLING_RESTART_TIMEOUT_SECONDS = env_int("ROLLING_RESTART_TIMEOUT_SECONDS", 180)
|
||||
# 滚动重启探测轮询间隔。
|
||||
ROLLING_RESTART_POLL_SECONDS = env_float("ROLLING_RESTART_POLL_SECONDS", 2.0)
|
||||
# 单实例恢复后额外稳定等待秒数。
|
||||
ROLLING_RESTART_STABILIZE_SECONDS = env_float("ROLLING_RESTART_STABILIZE_SECONDS", 2.0)
|
||||
|
||||
# TAT 所在区域。
|
||||
DEPLOY_REGION = env_str("DEPLOY_REGION", "me-saudi-arabia")
|
||||
|
||||
161
monitor/config_validation.py
Normal file
161
monitor/config_validation.py
Normal file
@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# JSON 校验直接走标准库。
|
||||
import json
|
||||
# XML 校验直接走标准库。
|
||||
import xml.etree.ElementTree as element_tree
|
||||
from typing import Any
|
||||
|
||||
|
||||
def normalized_config_type(config_type: str, data_id: str = "") -> str:
|
||||
# 统一把类型名压平,避免前后端大小写不一致。
|
||||
lowered = (config_type or "").strip().lower()
|
||||
if lowered in {"yml", "yaml"}:
|
||||
return "yaml"
|
||||
if lowered in {"json", "properties", "xml", "html", "text"}:
|
||||
return lowered
|
||||
|
||||
# 没显式类型时,按常见后缀兜底。
|
||||
suffix = data_id.strip().lower()
|
||||
if suffix.endswith((".yml", ".yaml")):
|
||||
return "yaml"
|
||||
if suffix.endswith(".json"):
|
||||
return "json"
|
||||
if suffix.endswith(".properties"):
|
||||
return "properties"
|
||||
if suffix.endswith(".xml"):
|
||||
return "xml"
|
||||
if suffix.endswith(".html"):
|
||||
return "html"
|
||||
return "text"
|
||||
|
||||
|
||||
def dependency_error(message: str) -> dict[str, Any]:
|
||||
# 缺少解析依赖时也返回统一结构,前端方便直接展示。
|
||||
return {
|
||||
"ok": False,
|
||||
"strict": False,
|
||||
"type": "",
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
def validation_success(config_type: str, message: str, *, strict: bool = True, details: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
# 成功结果也统一带上 strict 标记,告诉前端是不是强校验。
|
||||
return {
|
||||
"ok": True,
|
||||
"strict": strict,
|
||||
"type": config_type,
|
||||
"message": message,
|
||||
"details": details or {},
|
||||
}
|
||||
|
||||
|
||||
def validation_error(config_type: str, message: str, *, details: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
# 失败结构保持和成功结构一致。
|
||||
return {
|
||||
"ok": False,
|
||||
"strict": True,
|
||||
"type": config_type,
|
||||
"message": message,
|
||||
"details": details or {},
|
||||
}
|
||||
|
||||
|
||||
def validate_yaml(content: str) -> dict[str, Any]:
|
||||
try:
|
||||
# 按需导入 PyYAML,避免依赖缺失时影响其他功能启动。
|
||||
import yaml
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return dependency_error(f"yaml validator unavailable: {exc}")
|
||||
|
||||
try:
|
||||
# 空 YAML 允许解析成 null。
|
||||
parsed = yaml.safe_load(content)
|
||||
except yaml.YAMLError as exc:
|
||||
# PyYAML 异常里通常会带问题行列。
|
||||
problem_mark = getattr(exc, "problem_mark", None)
|
||||
details = {}
|
||||
if problem_mark is not None:
|
||||
details = {"line": int(problem_mark.line) + 1, "column": int(problem_mark.column) + 1}
|
||||
return validation_error("yaml", str(exc), details=details)
|
||||
|
||||
parsed_kind = type(parsed).__name__ if parsed is not None else "null"
|
||||
return validation_success("yaml", f"yaml format ok ({parsed_kind})", details={"parsedKind": parsed_kind})
|
||||
|
||||
|
||||
def validate_json_text(content: str) -> dict[str, Any]:
|
||||
try:
|
||||
# JSON 必须是合法文本,空串直接视为错误。
|
||||
parsed = json.loads(content)
|
||||
except json.JSONDecodeError as exc:
|
||||
return validation_error(
|
||||
"json",
|
||||
exc.msg,
|
||||
details={"line": int(exc.lineno), "column": int(exc.colno)},
|
||||
)
|
||||
|
||||
parsed_kind = type(parsed).__name__
|
||||
return validation_success("json", f"json format ok ({parsed_kind})", details={"parsedKind": parsed_kind})
|
||||
|
||||
|
||||
def validate_properties_text(content: str) -> dict[str, Any]:
|
||||
try:
|
||||
# Java properties 校验直接走专用解析器,避免手写规则误判。
|
||||
import javaproperties
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return dependency_error(f"properties validator unavailable: {exc}")
|
||||
|
||||
try:
|
||||
# loads 会在格式异常时抛出 ParseError。
|
||||
parsed = javaproperties.loads(content)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
line = getattr(exc, "lineno", None)
|
||||
details = {"line": int(line)} if isinstance(line, int) else {}
|
||||
return validation_error("properties", str(exc), details=details)
|
||||
|
||||
return validation_success("properties", f"properties format ok ({len(parsed)} keys)", details={"keysCount": len(parsed)})
|
||||
|
||||
|
||||
def validate_xml_text(content: str) -> dict[str, Any]:
|
||||
try:
|
||||
# XML 用标准库直接做语法解析。
|
||||
root = element_tree.fromstring(content)
|
||||
except element_tree.ParseError as exc:
|
||||
line, column = getattr(exc, "position", (0, 0))
|
||||
return validation_error(
|
||||
"xml",
|
||||
str(exc),
|
||||
details={"line": int(line), "column": int(column) + 1},
|
||||
)
|
||||
|
||||
return validation_success("xml", f"xml format ok (<{root.tag}>)", details={"rootTag": root.tag})
|
||||
|
||||
|
||||
def validate_text_like(config_type: str) -> dict[str, Any]:
|
||||
# text/html 先不做严格语法校验,只给出明确提示。
|
||||
return validation_success(
|
||||
config_type,
|
||||
f"{config_type} saved as plain text; strict syntax validation is not enabled",
|
||||
strict=False,
|
||||
)
|
||||
|
||||
|
||||
def validate_config_content(content: str, *, config_type: str = "", data_id: str = "") -> dict[str, Any]:
|
||||
# 统一解析类型。
|
||||
normalized_type = normalized_config_type(config_type, data_id)
|
||||
|
||||
# 按类型路由到对应校验器。
|
||||
if normalized_type == "yaml":
|
||||
return validate_yaml(content)
|
||||
if normalized_type == "json":
|
||||
return validate_json_text(content)
|
||||
if normalized_type == "properties":
|
||||
return validate_properties_text(content)
|
||||
if normalized_type == "xml":
|
||||
return validate_xml_text(content)
|
||||
if normalized_type in {"html", "text"}:
|
||||
return validate_text_like(normalized_type)
|
||||
|
||||
# 理论上不会走到这里,这里只是兜底。
|
||||
return validation_success(normalized_type, "no validator configured", strict=False)
|
||||
@ -8,7 +8,14 @@ from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from .config import APP_BIND, APP_PORT, STATIC_DIR, utc_now
|
||||
from .dashboard import build_monitor_payload
|
||||
from .nacos import get_nacos_config_list_payload, get_nacos_config_payload, save_nacos_config_payload
|
||||
from .nacos import (
|
||||
get_nacos_config_list_payload,
|
||||
get_nacos_config_payload,
|
||||
save_nacos_config_payload,
|
||||
validate_nacos_config_payload,
|
||||
)
|
||||
from .runtime_config import get_go_config_payload, save_go_config_payload, validate_go_config_payload
|
||||
from .service_ops import build_restart_targets_payload, rolling_restart_payload
|
||||
|
||||
|
||||
def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
|
||||
@ -114,6 +121,34 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
# 返回当前线上 golang 的运行配置。
|
||||
if parsed.path == "/api/runtime/golang-config":
|
||||
try:
|
||||
payload = get_go_config_payload()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
|
||||
|
||||
return self.send_payload(
|
||||
HTTPStatus.OK,
|
||||
response_json(payload),
|
||||
"application/json; charset=utf-8",
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
# 返回可滚动重启的业务服务。
|
||||
if parsed.path == "/api/ops/restart-targets":
|
||||
try:
|
||||
payload = build_restart_targets_payload()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
|
||||
|
||||
return self.send_payload(
|
||||
HTTPStatus.OK,
|
||||
response_json(payload),
|
||||
"application/json; charset=utf-8",
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
# favicon 直接返回空。
|
||||
if parsed.path == "/favicon.ico":
|
||||
return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body)
|
||||
@ -125,15 +160,55 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
# 只解析 path 段,POST 不处理复杂 query。
|
||||
parsed = urlparse(self.path)
|
||||
|
||||
# 目前只开放 Nacos 配置保存接口。
|
||||
if parsed.path != "/api/nacos/config":
|
||||
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
|
||||
|
||||
try:
|
||||
payload = self.read_json_body()
|
||||
except ValueError as exc:
|
||||
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
|
||||
|
||||
if parsed.path == "/api/nacos/config/validate":
|
||||
return self.handle_nacos_validate(payload)
|
||||
|
||||
if parsed.path == "/api/nacos/config":
|
||||
return self.handle_nacos_save(payload)
|
||||
|
||||
if parsed.path == "/api/runtime/golang-config/validate":
|
||||
return self.handle_go_validate(payload)
|
||||
|
||||
if parsed.path == "/api/runtime/golang-config":
|
||||
return self.handle_go_save(payload)
|
||||
|
||||
if parsed.path == "/api/ops/rolling-restart":
|
||||
return self.handle_rolling_restart(payload)
|
||||
|
||||
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
|
||||
|
||||
def handle_nacos_validate(self, payload: dict[str, Any]) -> None:
|
||||
group_name = str(payload.get("group") or "").strip()
|
||||
data_id = str(payload.get("dataId") or "").strip()
|
||||
content = payload.get("content")
|
||||
config_type = str(payload.get("type") or "").strip() or None
|
||||
|
||||
if not isinstance(content, str):
|
||||
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
|
||||
|
||||
try:
|
||||
result = validate_nacos_config_payload(
|
||||
content,
|
||||
group_name=group_name,
|
||||
data_id=data_id,
|
||||
config_type=config_type,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
|
||||
|
||||
return self.send_payload(
|
||||
HTTPStatus.OK,
|
||||
response_json(result),
|
||||
"application/json; charset=utf-8",
|
||||
send_body=True,
|
||||
)
|
||||
|
||||
def handle_nacos_save(self, payload: dict[str, Any]) -> None:
|
||||
group_name = str(payload.get("group") or "").strip()
|
||||
data_id = str(payload.get("dataId") or "").strip()
|
||||
content = payload.get("content")
|
||||
@ -143,7 +218,6 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
if not group_name or not data_id:
|
||||
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "group and dataId are required", send_body=True)
|
||||
|
||||
# content 必须是字符串;允许保存空串。
|
||||
if not isinstance(content, str):
|
||||
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
|
||||
|
||||
@ -159,6 +233,57 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
send_body=True,
|
||||
)
|
||||
|
||||
def handle_go_validate(self, payload: dict[str, Any]) -> None:
|
||||
content = payload.get("content")
|
||||
if not isinstance(content, str):
|
||||
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
|
||||
|
||||
try:
|
||||
result = validate_go_config_payload(content)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
|
||||
|
||||
return self.send_payload(
|
||||
HTTPStatus.OK,
|
||||
response_json(result),
|
||||
"application/json; charset=utf-8",
|
||||
send_body=True,
|
||||
)
|
||||
|
||||
def handle_go_save(self, payload: dict[str, Any]) -> None:
|
||||
content = payload.get("content")
|
||||
if not isinstance(content, str):
|
||||
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "content must be a string", send_body=True)
|
||||
|
||||
try:
|
||||
result = save_go_config_payload(content)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
|
||||
|
||||
return self.send_payload(
|
||||
HTTPStatus.OK,
|
||||
response_json(result),
|
||||
"application/json; charset=utf-8",
|
||||
send_body=True,
|
||||
)
|
||||
|
||||
def handle_rolling_restart(self, payload: dict[str, Any]) -> None:
|
||||
services = payload.get("services")
|
||||
if not isinstance(services, list):
|
||||
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "services must be a list", send_body=True)
|
||||
|
||||
try:
|
||||
result = rolling_restart_payload([str(item or "").strip() for item in services])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
|
||||
|
||||
return self.send_payload(
|
||||
HTTPStatus.OK,
|
||||
response_json(result),
|
||||
"application/json; charset=utf-8",
|
||||
send_body=True,
|
||||
)
|
||||
|
||||
def query_value(self, query: dict[str, list[str]], key: str) -> str:
|
||||
# 统一读取单值 query 参数。
|
||||
return str((query.get(key) or [""])[0]).strip()
|
||||
|
||||
@ -9,6 +9,7 @@ import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from .cache import TTLCache
|
||||
from .config_validation import validate_config_content
|
||||
from .config import (
|
||||
NACOS_BASE_URL,
|
||||
NACOS_CACHE_TTL_SECONDS,
|
||||
@ -376,9 +377,36 @@ def get_nacos_config_payload(group_name: str, data_id: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def validate_nacos_config_payload(content: str, *, group_name: str = "", data_id: str = "", config_type: str | None = None) -> dict[str, Any]:
|
||||
# 给页面单独暴露一个“先校验再保存”的能力。
|
||||
result = validate_config_content(
|
||||
content,
|
||||
config_type=(config_type or "").strip(),
|
||||
data_id=data_id,
|
||||
)
|
||||
return {
|
||||
**result,
|
||||
"updatedAt": utc_now(),
|
||||
"item": {
|
||||
"group": group_name,
|
||||
"dataId": data_id,
|
||||
"type": (config_type or infer_config_type(data_id)).strip(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def save_nacos_config_payload(group_name: str, data_id: str, content: str, config_type: str | None = None) -> dict[str, Any]:
|
||||
# 使用当前监控配置里的 namespace。
|
||||
namespace_id, namespace_name = current_namespace()
|
||||
# 保存前先做一次强校验,避免误把错误格式写进 Nacos。
|
||||
validation = validate_config_content(
|
||||
content,
|
||||
config_type=(config_type or "").strip(),
|
||||
data_id=data_id,
|
||||
)
|
||||
if not validation.get("ok"):
|
||||
raise RuntimeError(str(validation.get("message") or f"invalid config content: {group_name}/{data_id}"))
|
||||
|
||||
# public 命名空间不显式传 tenant。
|
||||
save_data: dict[str, Any] = {
|
||||
"group": group_name,
|
||||
|
||||
697
monitor/runtime_config.py
Normal file
697
monitor/runtime_config.py
Normal file
@ -0,0 +1,697 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# 远端写文件时需要 base64 传输,避免引号和换行转义问题。
|
||||
import base64
|
||||
# 返回给前端的摘要信息会带 sha256。
|
||||
import hashlib
|
||||
# env 值里有 JSON 字符串时直接复用标准库解析。
|
||||
import json
|
||||
# 远端路径和 shell 参数需要安全转义。
|
||||
import shlex
|
||||
from typing import Any
|
||||
|
||||
from .config import RUNTIME_BASE_DIR, TAT_METRIC_TIMEOUT_SECONDS, load_hosts, utc_now
|
||||
from .tencent_tat import run_shell_command
|
||||
|
||||
|
||||
# Go 配置按源码里的运行分段排序,页面上也按这个顺序展示。
|
||||
GO_ENV_SPECS: list[dict[str, Any]] = [
|
||||
{"section": "HTTP", "primary": "CHATAPP_HTTP_LISTEN_ADDR", "aliases": ("INVITE_HTTP_ADDR",), "kind": "string"},
|
||||
{
|
||||
"section": "HTTP",
|
||||
"primary": "CHATAPP_HTTP_TIMEOUT_SECONDS",
|
||||
"aliases": ("INVITE_HTTP_TIMEOUT_SECONDS",),
|
||||
"kind": "int",
|
||||
},
|
||||
{
|
||||
"section": "HTTP",
|
||||
"primary": "CHATAPP_HTTP_SHUTDOWN_TIMEOUT_SECONDS",
|
||||
"aliases": ("INVITE_HTTP_SHUTDOWN_TIMEOUT_SECONDS",),
|
||||
"kind": "int",
|
||||
},
|
||||
{
|
||||
"section": "HTTP",
|
||||
"primary": "CHATAPP_HTTP_INTERNAL_CALLBACK_SECRET",
|
||||
"aliases": ("GAME_INTERNAL_CALLBACK_SECRET",),
|
||||
"kind": "string",
|
||||
},
|
||||
{"section": "Store", "primary": "CHATAPP_STORE_MYSQL_DSN", "aliases": ("INVITE_MYSQL_DSN",), "kind": "string"},
|
||||
{"section": "Store", "primary": "CHATAPP_STORE_REDIS_ADDR", "aliases": ("INVITE_REDIS_ADDR",), "kind": "string"},
|
||||
{
|
||||
"section": "Store",
|
||||
"primary": "CHATAPP_STORE_REDIS_PASSWORD",
|
||||
"aliases": ("INVITE_REDIS_PASSWORD",),
|
||||
"kind": "string",
|
||||
},
|
||||
{"section": "Store", "primary": "CHATAPP_STORE_REDIS_DB", "aliases": ("INVITE_REDIS_DB",), "kind": "int"},
|
||||
{
|
||||
"section": "Java",
|
||||
"primary": "CHATAPP_JAVA_AUTH_BASE_URL",
|
||||
"aliases": ("INVITE_JAVA_AUTH_BASE_URL",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Java",
|
||||
"primary": "CHATAPP_JAVA_CONSOLE_BASE_URL",
|
||||
"aliases": ("JAVA_CONSOLE_BASE_URL",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Java",
|
||||
"primary": "CHATAPP_JAVA_DEVICE_BASE_URL",
|
||||
"aliases": ("INVITE_JAVA_DEVICE_BASE_URL",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Java",
|
||||
"primary": "CHATAPP_JAVA_APP_BASE_URL",
|
||||
"aliases": ("INVITE_JAVA_APP_BASE_URL",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Java",
|
||||
"primary": "CHATAPP_JAVA_BRIDGE_BASE_URL",
|
||||
"aliases": ("INVITE_JAVA_BRIDGE_BASE_URL",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Java",
|
||||
"primary": "CHATAPP_JAVA_EXTERNAL_BASE_URL",
|
||||
"aliases": ("INVITE_JAVA_EXTERNAL_BASE_URL",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Java",
|
||||
"primary": "CHATAPP_JAVA_OTHER_BASE_URL",
|
||||
"aliases": ("GAME_JAVA_OTHER_BASE_URL", "INVITE_JAVA_APP_BASE_URL"),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Java",
|
||||
"primary": "CHATAPP_JAVA_WALLET_BASE_URL",
|
||||
"aliases": ("GAME_JAVA_WALLET_BASE_URL",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Worker",
|
||||
"primary": "CHATAPP_REGISTER_REWARD_STREAM_KEY",
|
||||
"aliases": ("REGISTER_REWARD_STREAM_KEY",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Worker",
|
||||
"primary": "CHATAPP_REGISTER_REWARD_CONSUMER_GROUP",
|
||||
"aliases": ("REGISTER_REWARD_CONSUMER_GROUP",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Worker",
|
||||
"primary": "CHATAPP_REGISTER_REWARD_CONSUMER_NAME",
|
||||
"aliases": ("REGISTER_REWARD_CONSUMER_NAME",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Worker",
|
||||
"primary": "CHATAPP_REGISTER_REWARD_BATCH_SIZE",
|
||||
"aliases": ("REGISTER_REWARD_BATCH_SIZE",),
|
||||
"kind": "int",
|
||||
},
|
||||
{
|
||||
"section": "Worker",
|
||||
"primary": "CHATAPP_REGISTER_REWARD_BLOCK_MILLIS",
|
||||
"aliases": ("REGISTER_REWARD_BLOCK_MILLIS",),
|
||||
"kind": "int",
|
||||
},
|
||||
{
|
||||
"section": "Worker",
|
||||
"primary": "CHATAPP_REGISTER_REWARD_MIN_IDLE_MILLIS",
|
||||
"aliases": ("REGISTER_REWARD_MIN_IDLE_MILLIS",),
|
||||
"kind": "int",
|
||||
},
|
||||
{
|
||||
"section": "Invite",
|
||||
"primary": "CHATAPP_INVITE_PUBLIC_BASE_URL",
|
||||
"aliases": ("INVITE_PUBLIC_BASE_URL",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN",
|
||||
"aliases": ("WEEK_STAR_DEFAULT_SYS_ORIGIN",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_TIMEZONE",
|
||||
"aliases": ("WEEK_STAR_TIMEZONE",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_RETRY_STREAM_KEY",
|
||||
"aliases": ("WEEK_STAR_RETRY_STREAM_KEY",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_ENABLED",
|
||||
"aliases": ("WEEK_STAR_ROCKETMQ_ENABLED",),
|
||||
"kind": "bool",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_ENDPOINT",
|
||||
"aliases": ("WEEK_STAR_ROCKETMQ_ENDPOINT",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_NAMESPACE",
|
||||
"aliases": ("WEEK_STAR_ROCKETMQ_NAMESPACE",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_KEY",
|
||||
"aliases": ("WEEK_STAR_ROCKETMQ_ACCESS_KEY",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_ACCESS_SECRET",
|
||||
"aliases": ("WEEK_STAR_ROCKETMQ_ACCESS_SECRET",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_SECURITY_TOKEN",
|
||||
"aliases": ("WEEK_STAR_ROCKETMQ_SECURITY_TOKEN",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_CONSUMER_GROUP",
|
||||
"aliases": ("WEEK_STAR_ROCKETMQ_CONSUMER_GROUP",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_TOPIC",
|
||||
"aliases": ("WEEK_STAR_ROCKETMQ_TOPIC",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "WeekStar",
|
||||
"primary": "CHATAPP_WEEK_STAR_ROCKETMQ_TAG",
|
||||
"aliases": ("WEEK_STAR_ROCKETMQ_TAG",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Baishun",
|
||||
"primary": "CHATAPP_BAISHUN_PLATFORM_BASE_URL",
|
||||
"aliases": ("BAISHUN_PLATFORM_BASE_URL",),
|
||||
"kind": "string",
|
||||
},
|
||||
{"section": "Baishun", "primary": "CHATAPP_BAISHUN_APP_ID", "aliases": ("BAISHUN_APP_ID",), "kind": "int"},
|
||||
{
|
||||
"section": "Baishun",
|
||||
"primary": "CHATAPP_BAISHUN_APP_NAME",
|
||||
"aliases": ("BAISHUN_APP_NAME",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "Baishun",
|
||||
"primary": "CHATAPP_BAISHUN_APP_CHANNEL",
|
||||
"aliases": ("BAISHUN_APP_CHANNEL",),
|
||||
"kind": "string",
|
||||
},
|
||||
{"section": "Baishun", "primary": "CHATAPP_BAISHUN_APP_KEY", "aliases": ("BAISHUN_APP_KEY",), "kind": "string"},
|
||||
{"section": "Baishun", "primary": "CHATAPP_BAISHUN_GSP", "aliases": ("BAISHUN_GSP",), "kind": "int"},
|
||||
{
|
||||
"section": "Baishun",
|
||||
"primary": "CHATAPP_BAISHUN_LAUNCH_CODE_TTL_SECONDS",
|
||||
"aliases": ("BAISHUN_LAUNCH_CODE_TTL_SECONDS",),
|
||||
"kind": "int",
|
||||
},
|
||||
{
|
||||
"section": "Baishun",
|
||||
"primary": "CHATAPP_BAISHUN_SS_TOKEN_TTL_SECONDS",
|
||||
"aliases": ("BAISHUN_SS_TOKEN_TTL_SECONDS",),
|
||||
"kind": "int",
|
||||
},
|
||||
{
|
||||
"section": "LuckyGift",
|
||||
"primary": "CHATAPP_LUCKY_GIFT_ENABLED",
|
||||
"aliases": ("LUCKY_GIFT_ENABLED", "LUCKY_GIFT_GO_ENABLED"),
|
||||
"kind": "bool",
|
||||
},
|
||||
{
|
||||
"section": "LuckyGift",
|
||||
"primary": "CHATAPP_LUCKY_GIFT_TIMEOUT_MILLIS",
|
||||
"aliases": ("LUCKY_GIFT_TIMEOUT_MILLIS", "LUCKY_GIFT_GO_TIMEOUT_MILLIS"),
|
||||
"kind": "int",
|
||||
},
|
||||
{
|
||||
"section": "LuckyGift",
|
||||
"primary": "CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY",
|
||||
"aliases": ("LUCKY_GIFT_REWARD_STREAM_KEY",),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "LuckyGift",
|
||||
"primary": "CHATAPP_LUCKY_GIFT_CONFIGS_JSON",
|
||||
"aliases": ("LUCKY_GIFT_CONFIGS_JSON", "LUCKY_GIFT_CONFIGS"),
|
||||
"kind": "json",
|
||||
},
|
||||
{
|
||||
"section": "LuckyGift",
|
||||
"primary": "CHATAPP_LUCKY_GIFT_STANDARD_ID",
|
||||
"aliases": ("LUCKY_GIFT_STANDARD_ID",),
|
||||
"kind": "int",
|
||||
},
|
||||
{
|
||||
"section": "LuckyGift",
|
||||
"primary": "CHATAPP_LUCKY_GIFT_URL",
|
||||
"aliases": ("LUCKY_GIFT_URL", "LUCKY_GIFT_API_DEFAULT_URL"),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "LuckyGift",
|
||||
"primary": "CHATAPP_LUCKY_GIFT_APP_ID",
|
||||
"aliases": ("LUCKY_GIFT_APP_ID", "LUCKY_GIFT_API_DEFAULT_APP_ID"),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "LuckyGift",
|
||||
"primary": "CHATAPP_LUCKY_GIFT_APP_CHANNEL",
|
||||
"aliases": ("LUCKY_GIFT_APP_CHANNEL", "LUCKY_GIFT_API_DEFAULT_APP_CHANNEL"),
|
||||
"kind": "string",
|
||||
},
|
||||
{
|
||||
"section": "LuckyGift",
|
||||
"primary": "CHATAPP_LUCKY_GIFT_APP_KEY",
|
||||
"aliases": ("LUCKY_GIFT_APP_KEY", "LUCKY_GIFT_API_DEFAULT_APP_KEY"),
|
||||
"kind": "string",
|
||||
},
|
||||
]
|
||||
|
||||
# 把所有可接受的 key 映射回唯一主键,方便校验和保存时去重。
|
||||
GO_ENV_PRIMARY_BY_KEY = {
|
||||
key: spec["primary"]
|
||||
for spec in GO_ENV_SPECS
|
||||
for key in (spec["primary"], *spec["aliases"])
|
||||
}
|
||||
# 同时保留按主键索引的 spec,后续查类型更直接。
|
||||
GO_ENV_SPECS_BY_PRIMARY = {spec["primary"]: spec for spec in GO_ENV_SPECS}
|
||||
|
||||
# 远端文件不存在时,用固定标记避免和 shell 错误混淆。
|
||||
REMOTE_FILE_MISSING_MARKER = "__HY_APP_MONITOR_FILE_MISSING__"
|
||||
|
||||
|
||||
def go_service_hosts() -> list[dict[str, Any]]:
|
||||
# 只挑出当前监控目标里真正承载 golang 服务的主机。
|
||||
hosts = [
|
||||
host
|
||||
for host in load_hosts()
|
||||
if any(str(service.get("name") or "").strip() == "golang" for service in host.get("services") or [])
|
||||
]
|
||||
if not hosts:
|
||||
raise RuntimeError("no golang hosts found in config/targets.json")
|
||||
return hosts
|
||||
|
||||
|
||||
def service_env_path(host_name: str) -> str:
|
||||
# 线上运行目录沿用现有部署结构。
|
||||
return f"{RUNTIME_BASE_DIR.rstrip('/')}/{host_name}/service.env"
|
||||
|
||||
|
||||
def parse_env_assignment(raw_line: str) -> tuple[str, str] | None:
|
||||
# 保留原始内容用于判断注释和空行。
|
||||
stripped = raw_line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
return None
|
||||
if stripped.startswith("export "):
|
||||
stripped = stripped[7:].strip()
|
||||
if "=" not in stripped:
|
||||
return None
|
||||
|
||||
# 只按第一个等号切分,避免 URL / JSON 被截断。
|
||||
key, value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if not key:
|
||||
return None
|
||||
return key, decode_env_value(value)
|
||||
|
||||
|
||||
def decode_env_value(raw_value: str) -> str:
|
||||
# 双引号优先按 JSON 字符串解码,兼容反斜杠和 Unicode。
|
||||
if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] == '"':
|
||||
try:
|
||||
return str(json.loads(raw_value))
|
||||
except Exception: # noqa: BLE001
|
||||
return raw_value[1:-1]
|
||||
|
||||
# 单引号值按最简单规则去壳。
|
||||
if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] == "'":
|
||||
return raw_value[1:-1]
|
||||
|
||||
# 其余值原样返回。
|
||||
return raw_value
|
||||
|
||||
|
||||
def render_env_value(value: str) -> str:
|
||||
# 统一用 JSON 风格双引号输出,避免空格和特殊字符破坏 env 语法。
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def parse_env_text(raw: str) -> dict[str, str]:
|
||||
# env 文件最终按 key/value 平铺。
|
||||
values: dict[str, str] = {}
|
||||
for raw_line in raw.splitlines():
|
||||
parsed = parse_env_assignment(raw_line)
|
||||
if parsed is None:
|
||||
continue
|
||||
key, value = parsed
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def remote_host_result(host: dict[str, Any], script: str, command_name: str, *, timeout_seconds: int = TAT_METRIC_TIMEOUT_SECONDS) -> dict[str, Any]:
|
||||
# 统一执行单机 TAT 命令,并把错误折叠成 RuntimeError。
|
||||
result_map = run_shell_command(
|
||||
[host["instanceId"]],
|
||||
script,
|
||||
command_name=command_name,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
result = result_map[host["instanceId"]]
|
||||
if result.get("status") != "SUCCESS":
|
||||
raise RuntimeError(f"{host['host']} {command_name} failed: {result.get('status')}")
|
||||
return result
|
||||
|
||||
|
||||
def read_remote_text(host: dict[str, Any], path: str) -> str:
|
||||
quoted_path = shlex.quote(path)
|
||||
script = f"""set -eu
|
||||
FILE={quoted_path}
|
||||
if [ ! -f "$FILE" ]; then
|
||||
printf '%s' {shlex.quote(REMOTE_FILE_MISSING_MARKER)}
|
||||
exit 0
|
||||
fi
|
||||
cat "$FILE"
|
||||
"""
|
||||
result = remote_host_result(host, script, f"read-{host['host']}-service-env")
|
||||
output = str(result.get("output") or "")
|
||||
if output.strip() == REMOTE_FILE_MISSING_MARKER:
|
||||
raise RuntimeError(f"{host['host']} missing file: {path}")
|
||||
return output
|
||||
|
||||
|
||||
def write_remote_text(host: dict[str, Any], path: str, content: str) -> None:
|
||||
# 把文本整体 base64 编码后再下发,避免 shell here-doc 被内容污染。
|
||||
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
quoted_path = shlex.quote(path)
|
||||
script = f"""set -eu
|
||||
FILE={quoted_path}
|
||||
TMP="${{FILE}}.hyapp.$$"
|
||||
mkdir -p "$(dirname "$FILE")"
|
||||
printf '%s' {shlex.quote(encoded)} | base64 -d > "$TMP"
|
||||
mv "$TMP" "$FILE"
|
||||
"""
|
||||
remote_host_result(host, script, f"write-{host['host']}-service-env")
|
||||
|
||||
|
||||
def host_sha256(content: str) -> str:
|
||||
# 前端需要一个稳定摘要判断读写前后是否一致。
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def first_present_value(values: dict[str, str], keys: tuple[str, ...]) -> str:
|
||||
# 配置读取语义和 getEnvAny 对齐,优先取第一个非空值。
|
||||
for key in keys:
|
||||
candidate = values.get(key, "")
|
||||
if candidate != "":
|
||||
return candidate
|
||||
|
||||
# 如果全是空串,但确实存在某个 key,也保留空串语义。
|
||||
for key in keys:
|
||||
if key in values:
|
||||
return values[key]
|
||||
return ""
|
||||
|
||||
|
||||
def extract_go_values(values: dict[str, str]) -> dict[str, str]:
|
||||
# 统一抽成“主键 -> 当前值”,方便页面和保存逻辑复用。
|
||||
extracted: dict[str, str] = {}
|
||||
for spec in GO_ENV_SPECS:
|
||||
keys = (spec["primary"], *spec["aliases"])
|
||||
extracted[spec["primary"]] = first_present_value(values, keys)
|
||||
return extracted
|
||||
|
||||
|
||||
def render_go_editor_content(values: dict[str, str]) -> str:
|
||||
# 生成带分段注释的可编辑 env 文本,用户一眼能看出结构。
|
||||
lines: list[str] = []
|
||||
current_section = ""
|
||||
for spec in GO_ENV_SPECS:
|
||||
if spec["section"] != current_section:
|
||||
if lines:
|
||||
lines.append("")
|
||||
current_section = spec["section"]
|
||||
lines.append(f"# {current_section}")
|
||||
lines.append(f"{spec['primary']}={render_env_value(values.get(spec['primary'], ''))}")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def validate_scalar_kind(primary_key: str, value: str, kind: str) -> None:
|
||||
# 空串允许保存,运行时会按 fallback / 默认值处理。
|
||||
if value == "":
|
||||
return
|
||||
if kind == "string":
|
||||
return
|
||||
if kind == "int":
|
||||
int(value)
|
||||
return
|
||||
if kind == "bool":
|
||||
if value.strip().lower() not in {"1", "0", "true", "false", "yes", "no", "on", "off"}:
|
||||
raise ValueError(f"{primary_key} must be a boolean")
|
||||
return
|
||||
if kind == "json":
|
||||
json.loads(value)
|
||||
return
|
||||
|
||||
|
||||
def validate_go_editor_content(content: str) -> dict[str, Any]:
|
||||
# 这里按“可编辑文本”逐行解析,保留重复和未知 key 的校验能力。
|
||||
values: dict[str, str] = {}
|
||||
seen_primaries: set[str] = set()
|
||||
|
||||
for line_no, raw_line in enumerate(content.splitlines(), start=1):
|
||||
parsed = parse_env_assignment(raw_line)
|
||||
if parsed is None:
|
||||
continue
|
||||
|
||||
key, value = parsed
|
||||
primary_key = GO_ENV_PRIMARY_BY_KEY.get(key)
|
||||
if not primary_key:
|
||||
return {
|
||||
"ok": False,
|
||||
"strict": True,
|
||||
"type": "env",
|
||||
"message": f"unsupported golang env key: {key}",
|
||||
"details": {"line": line_no, "key": key},
|
||||
}
|
||||
|
||||
if primary_key in seen_primaries:
|
||||
return {
|
||||
"ok": False,
|
||||
"strict": True,
|
||||
"type": "env",
|
||||
"message": f"duplicated golang env key: {primary_key}",
|
||||
"details": {"line": line_no, "key": primary_key},
|
||||
}
|
||||
|
||||
spec = GO_ENV_SPECS_BY_PRIMARY[primary_key]
|
||||
try:
|
||||
validate_scalar_kind(primary_key, value, spec["kind"])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"ok": False,
|
||||
"strict": True,
|
||||
"type": "env",
|
||||
"message": str(exc),
|
||||
"details": {"line": line_no, "key": primary_key},
|
||||
}
|
||||
|
||||
seen_primaries.add(primary_key)
|
||||
values[primary_key] = value
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"strict": True,
|
||||
"type": "env",
|
||||
"message": f"golang env format ok ({len(values)} keys)",
|
||||
"details": {"keysCount": len(values)},
|
||||
"values": values,
|
||||
}
|
||||
|
||||
|
||||
def expand_host_updates(current_values: dict[str, str], editor_values: dict[str, str]) -> dict[str, str]:
|
||||
# 保存时尽量沿用宿主机现有 key 形态,只在缺失时补主键。
|
||||
updates: dict[str, str] = {}
|
||||
for primary_key, value in editor_values.items():
|
||||
spec = GO_ENV_SPECS_BY_PRIMARY[primary_key]
|
||||
host_keys = [key for key in (primary_key, *spec["aliases"]) if key in current_values]
|
||||
if not host_keys:
|
||||
host_keys = [primary_key]
|
||||
for key in host_keys:
|
||||
updates[key] = value
|
||||
return updates
|
||||
|
||||
|
||||
def merge_env_text(raw_content: str, updates: dict[str, str]) -> str:
|
||||
# 在原文件基础上按行覆盖,尽量保留注释和无关配置。
|
||||
output_lines: list[str] = []
|
||||
seen_keys: set[str] = set()
|
||||
|
||||
for raw_line in raw_content.splitlines():
|
||||
parsed = parse_env_assignment(raw_line)
|
||||
if parsed is None:
|
||||
output_lines.append(raw_line)
|
||||
continue
|
||||
|
||||
key, _ = parsed
|
||||
if key not in updates:
|
||||
output_lines.append(raw_line)
|
||||
continue
|
||||
|
||||
output_lines.append(f"{key}={render_env_value(updates[key])}")
|
||||
seen_keys.add(key)
|
||||
|
||||
# 原文件没有的 key 统一追加到末尾。
|
||||
missing_keys = [key for key in updates if key not in seen_keys]
|
||||
if missing_keys:
|
||||
if output_lines and output_lines[-1].strip():
|
||||
output_lines.append("")
|
||||
output_lines.append("# hy-app-monitor golang config")
|
||||
for key in missing_keys:
|
||||
output_lines.append(f"{key}={render_env_value(updates[key])}")
|
||||
|
||||
return "\n".join(output_lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def go_host_snapshots() -> list[dict[str, Any]]:
|
||||
# 逐台读取实际运行中的 service.env。
|
||||
snapshots: list[dict[str, Any]] = []
|
||||
for host in go_service_hosts():
|
||||
env_path = service_env_path(host["host"])
|
||||
raw_content = read_remote_text(host, env_path)
|
||||
env_values = parse_env_text(raw_content)
|
||||
go_values = extract_go_values(env_values)
|
||||
snapshots.append(
|
||||
{
|
||||
"host": host["host"],
|
||||
"ip": host["ip"],
|
||||
"instanceId": host["instanceId"],
|
||||
"path": env_path,
|
||||
"sha256": host_sha256(raw_content),
|
||||
"lineCount": len(raw_content.splitlines()) if raw_content else 0,
|
||||
"content": raw_content,
|
||||
"values": env_values,
|
||||
"goValues": go_values,
|
||||
}
|
||||
)
|
||||
return snapshots
|
||||
|
||||
|
||||
def go_diff_keys(snapshots: list[dict[str, Any]]) -> list[str]:
|
||||
# 同步看两台 app 机器上的 Go 配置是否已经一致。
|
||||
if not snapshots:
|
||||
return []
|
||||
baseline = snapshots[0]["goValues"]
|
||||
diff: list[str] = []
|
||||
for key in baseline:
|
||||
if any(snapshot["goValues"].get(key, "") != baseline.get(key, "") for snapshot in snapshots[1:]):
|
||||
diff.append(key)
|
||||
return diff
|
||||
|
||||
|
||||
def get_go_config_payload() -> dict[str, Any]:
|
||||
# 读取所有 golang 宿主机当前运行配置。
|
||||
snapshots = go_host_snapshots()
|
||||
diff_keys = go_diff_keys(snapshots)
|
||||
baseline_values = snapshots[0]["goValues"] if snapshots else {}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"updatedAt": utc_now(),
|
||||
"hosts": [
|
||||
{
|
||||
"host": snapshot["host"],
|
||||
"ip": snapshot["ip"],
|
||||
"instanceId": snapshot["instanceId"],
|
||||
"path": snapshot["path"],
|
||||
"sha256": snapshot["sha256"],
|
||||
"lineCount": snapshot["lineCount"],
|
||||
}
|
||||
for snapshot in snapshots
|
||||
],
|
||||
"diffKeys": diff_keys,
|
||||
"editorContent": render_go_editor_content(baseline_values),
|
||||
"keysCount": len([line for line in render_go_editor_content(baseline_values).splitlines() if "=" in line]),
|
||||
}
|
||||
|
||||
|
||||
def validate_go_config_payload(content: str) -> dict[str, Any]:
|
||||
# 对页面编辑器内容做显式格式校验。
|
||||
result = validate_go_editor_content(content)
|
||||
if result.get("ok"):
|
||||
result["updatedAt"] = utc_now()
|
||||
return result
|
||||
|
||||
|
||||
def save_go_config_payload(content: str) -> dict[str, Any]:
|
||||
# 保存前先做一次强校验,避免把非法配置下发到线上。
|
||||
validation = validate_go_editor_content(content)
|
||||
if not validation.get("ok"):
|
||||
raise RuntimeError(str(validation.get("message") or "invalid golang env content"))
|
||||
|
||||
editor_values = dict(validation.get("values") or {})
|
||||
if not editor_values:
|
||||
raise RuntimeError("no golang env keys found in editor content")
|
||||
|
||||
saved_hosts: list[dict[str, Any]] = []
|
||||
for snapshot in go_host_snapshots():
|
||||
host_updates = expand_host_updates(snapshot["values"], editor_values)
|
||||
merged_content = merge_env_text(snapshot["content"], host_updates)
|
||||
write_remote_text(
|
||||
{"host": snapshot["host"], "instanceId": snapshot["instanceId"]},
|
||||
snapshot["path"],
|
||||
merged_content,
|
||||
)
|
||||
|
||||
# 写完立即回读,确认关键值确实落盘。
|
||||
verified_content = read_remote_text(
|
||||
{"host": snapshot["host"], "instanceId": snapshot["instanceId"]},
|
||||
snapshot["path"],
|
||||
)
|
||||
verified_values = extract_go_values(parse_env_text(verified_content))
|
||||
for primary_key, expected_value in editor_values.items():
|
||||
if verified_values.get(primary_key, "") != expected_value:
|
||||
raise RuntimeError(f"verify golang config failed on {snapshot['host']}: {primary_key}")
|
||||
|
||||
saved_hosts.append(
|
||||
{
|
||||
"host": snapshot["host"],
|
||||
"ip": snapshot["ip"],
|
||||
"instanceId": snapshot["instanceId"],
|
||||
"path": snapshot["path"],
|
||||
"sha256": host_sha256(verified_content),
|
||||
}
|
||||
)
|
||||
|
||||
payload = get_go_config_payload()
|
||||
payload["savedHosts"] = saved_hosts
|
||||
payload["message"] = "golang service.env saved"
|
||||
return payload
|
||||
255
monitor/service_ops.py
Normal file
255
monitor/service_ops.py
Normal file
@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# 远端命令里会拼接 shell 参数,需要显式转义。
|
||||
import shlex
|
||||
# 滚动重启是串行动作,轮询恢复要用时间模块。
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .config import (
|
||||
ROLLING_RESTART_POLL_SECONDS,
|
||||
ROLLING_RESTART_STABILIZE_SECONDS,
|
||||
ROLLING_RESTART_TIMEOUT_SECONDS,
|
||||
RUNTIME_BASE_DIR,
|
||||
THRESHOLDS,
|
||||
flatten_services,
|
||||
load_hosts,
|
||||
utc_now,
|
||||
)
|
||||
from .http_probe import SERVICE_CACHE, probe_service
|
||||
from .nacos import (
|
||||
NACOS_CACHE,
|
||||
NACOS_DISCOVERY_GROUP,
|
||||
NACOS_SERVICE_NAME_MAP,
|
||||
catalog_instances,
|
||||
current_namespace,
|
||||
list_instances,
|
||||
nacos_truthy,
|
||||
)
|
||||
from .tencent_tat import run_shell_command
|
||||
|
||||
|
||||
def restartable_service_records() -> list[dict[str, Any]]:
|
||||
# 当前只开放 Java / Golang 业务服务滚动重启。
|
||||
services = flatten_services(load_hosts())
|
||||
return [service for service in services if service.get("kind") in {"java", "golang"}]
|
||||
|
||||
|
||||
def build_restart_targets_payload() -> dict[str, Any]:
|
||||
# 页面需要按“服务 -> 宿主机”展示选择项。
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for service in restartable_service_records():
|
||||
item = grouped.setdefault(
|
||||
service["service"],
|
||||
{
|
||||
"service": service["service"],
|
||||
"kind": service["kind"],
|
||||
"hosts": [],
|
||||
},
|
||||
)
|
||||
item["hosts"].append(
|
||||
{
|
||||
"host": service["host"],
|
||||
"ip": service["ip"],
|
||||
"instanceId": service["instanceId"],
|
||||
"port": service["port"],
|
||||
"path": service["path"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"updatedAt": utc_now(),
|
||||
"items": list(grouped.values()),
|
||||
}
|
||||
|
||||
|
||||
def service_records_by_name() -> dict[str, list[dict[str, Any]]]:
|
||||
# 重启顺序沿用 targets.json 的主机顺序。
|
||||
grouped: dict[str, list[dict[str, Any]]] = {}
|
||||
for service in restartable_service_records():
|
||||
grouped.setdefault(service["service"], []).append(service)
|
||||
return grouped
|
||||
|
||||
|
||||
def remote_command_result(service: dict[str, Any], script: str, command_name: str, timeout_seconds: int) -> dict[str, Any]:
|
||||
# 单实例远端执行统一复用 TAT shell。
|
||||
result_map = run_shell_command(
|
||||
[service["instanceId"]],
|
||||
script,
|
||||
command_name=command_name,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
return result_map[service["instanceId"]]
|
||||
|
||||
|
||||
def restart_service_instance(service: dict[str, Any]) -> dict[str, Any]:
|
||||
# 线上业务目录和 compose 文件复用现网部署结构。
|
||||
host_dir = f"{RUNTIME_BASE_DIR.rstrip('/')}/{service['host']}"
|
||||
container_name = f"likei-{service['host']}-{service['service']}"
|
||||
script = f"""set -eu
|
||||
HOST_DIR={shlex.quote(host_dir)}
|
||||
cd "$HOST_DIR"
|
||||
docker compose up -d --force-recreate --no-deps {shlex.quote(service['service'])}
|
||||
docker ps --filter name={shlex.quote(container_name)} --format '{{{{.Names}}}} {{{{.Status}}}}'
|
||||
"""
|
||||
result = remote_command_result(
|
||||
service,
|
||||
script,
|
||||
f"restart-{service['host']}-{service['service']}",
|
||||
timeout_seconds=max(ROLLING_RESTART_TIMEOUT_SECONDS, 60),
|
||||
)
|
||||
if result.get("status") != "SUCCESS":
|
||||
raise RuntimeError(f"restart failed on {service['host']}: {result.get('status')}")
|
||||
return result
|
||||
|
||||
|
||||
def wait_http_ready(service: dict[str, Any], timeout_seconds: int) -> dict[str, Any]:
|
||||
# 直接复用现有健康探测逻辑,不走缓存,确保拿的是最新状态。
|
||||
deadline = time.time() + timeout_seconds
|
||||
last_result: dict[str, Any] | None = None
|
||||
|
||||
while time.time() < deadline:
|
||||
last_result = probe_service(service, THRESHOLDS["latencyMs"])
|
||||
if last_result.get("ok"):
|
||||
return last_result
|
||||
time.sleep(ROLLING_RESTART_POLL_SECONDS)
|
||||
|
||||
detail = (last_result or {}).get("detail") or "service did not recover"
|
||||
raise RuntimeError(f"http health not ready on {service['host']} {service['service']}: {detail}")
|
||||
|
||||
|
||||
def matched_nacos_record(items: list[dict[str, Any]], service: dict[str, Any]) -> dict[str, Any] | None:
|
||||
# 只认当前宿主机对应的实例,避免误把另一台机器当成恢复成功。
|
||||
for item in items:
|
||||
ip = str(item.get("ip") or "").strip()
|
||||
port = int(item.get("port") or 0)
|
||||
if ip == service["ip"] and port == int(service["port"]):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def wait_java_registration_ready(service: dict[str, Any], timeout_seconds: int) -> dict[str, Any]:
|
||||
# 只有走 Nacos 注册的 Java 服务才做注册恢复校验。
|
||||
registry_name = NACOS_SERVICE_NAME_MAP.get(service["service"])
|
||||
if not registry_name:
|
||||
return {"checked": False, "reason": "service not managed by nacos registry map"}
|
||||
|
||||
namespace_id, _ = current_namespace()
|
||||
deadline = time.time() + timeout_seconds
|
||||
last_record: dict[str, Any] | None = None
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
last_record = matched_nacos_record(
|
||||
list_instances(namespace_id, registry_name, NACOS_DISCOVERY_GROUP),
|
||||
service,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
last_record = None
|
||||
|
||||
if last_record and nacos_truthy(last_record.get("healthy", False)) and nacos_truthy(last_record.get("enabled", True)):
|
||||
return {
|
||||
"checked": True,
|
||||
"healthy": True,
|
||||
"enabled": True,
|
||||
"source": "instance/list",
|
||||
}
|
||||
|
||||
try:
|
||||
catalog_record = matched_nacos_record(
|
||||
catalog_instances(namespace_id, registry_name, NACOS_DISCOVERY_GROUP),
|
||||
service,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
catalog_record = None
|
||||
|
||||
if catalog_record and nacos_truthy(catalog_record.get("healthy", False)) and nacos_truthy(
|
||||
catalog_record.get("enabled", True)
|
||||
):
|
||||
return {
|
||||
"checked": True,
|
||||
"healthy": True,
|
||||
"enabled": True,
|
||||
"source": "catalog/instances",
|
||||
}
|
||||
|
||||
time.sleep(ROLLING_RESTART_POLL_SECONDS)
|
||||
|
||||
raise RuntimeError(f"nacos registration not ready on {service['host']} {service['service']}")
|
||||
|
||||
|
||||
def clear_operation_caches() -> None:
|
||||
# 重启会直接影响服务探测和 Nacos 面板,完成后把缓存清掉。
|
||||
SERVICE_CACHE.clear()
|
||||
NACOS_CACHE.clear()
|
||||
|
||||
|
||||
def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]:
|
||||
# 用户可能重复点击同一个服务,这里先去重并保序。
|
||||
normalized_names = [str(name or "").strip() for name in service_names if str(name or "").strip()]
|
||||
normalized_names = list(dict.fromkeys(normalized_names))
|
||||
if not normalized_names:
|
||||
raise RuntimeError("services are required")
|
||||
|
||||
records_map = service_records_by_name()
|
||||
steps: list[dict[str, Any]] = []
|
||||
|
||||
for service_name in normalized_names:
|
||||
if service_name not in records_map:
|
||||
raise RuntimeError(f"unsupported restart service: {service_name}")
|
||||
|
||||
for service in records_map[service_name]:
|
||||
started_at = time.time()
|
||||
step: dict[str, Any] = {
|
||||
"service": service_name,
|
||||
"host": service["host"],
|
||||
"ip": service["ip"],
|
||||
"kind": service["kind"],
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
try:
|
||||
restart_result = restart_service_instance(service)
|
||||
step["tatStatus"] = restart_result.get("status")
|
||||
step["tatOutput"] = str(restart_result.get("output") or "").strip()[:500]
|
||||
|
||||
http_result = wait_http_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS)
|
||||
step["http"] = {
|
||||
"ok": bool(http_result.get("ok")),
|
||||
"statusCode": int(http_result.get("statusCode") or 0),
|
||||
"latencyMs": int(http_result.get("latencyMs") or 0),
|
||||
"detail": str(http_result.get("detail") or ""),
|
||||
}
|
||||
|
||||
if service["kind"] == "java":
|
||||
step["nacos"] = wait_java_registration_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS)
|
||||
|
||||
if ROLLING_RESTART_STABILIZE_SECONDS > 0:
|
||||
time.sleep(ROLLING_RESTART_STABILIZE_SECONDS)
|
||||
|
||||
step["status"] = "success"
|
||||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||||
steps.append(step)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
step["status"] = "failed"
|
||||
step["error"] = str(exc)
|
||||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||||
steps.append(step)
|
||||
clear_operation_caches()
|
||||
return {
|
||||
"ok": False,
|
||||
"updatedAt": utc_now(),
|
||||
"services": normalized_names,
|
||||
"steps": steps,
|
||||
"error": step["error"],
|
||||
}
|
||||
|
||||
clear_operation_caches()
|
||||
return {
|
||||
"ok": True,
|
||||
"updatedAt": utc_now(),
|
||||
"services": normalized_names,
|
||||
"steps": steps,
|
||||
"message": "rolling restart complete",
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
tencentcloud-sdk-python>=3.0.0
|
||||
pymongo>=4.8.0
|
||||
PyYAML>=6.0.2
|
||||
javaproperties>=0.8.2
|
||||
|
||||
@ -170,6 +170,15 @@ body {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.refresh.ghost {
|
||||
background: color-mix(in oklab, var(--panel-strong) 82%, transparent);
|
||||
}
|
||||
|
||||
.refresh.danger {
|
||||
color: var(--bad);
|
||||
border-color: color-mix(in oklab, var(--bad) 55%, var(--line));
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
border-color: color-mix(in oklab, var(--accent) 65%, white 15%);
|
||||
background: color-mix(in oklab, var(--accent) 18%, var(--panel-strong));
|
||||
@ -500,6 +509,24 @@ body {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.host-tags,
|
||||
.restart-pills,
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.restart-pills {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.restart-pills .config-item {
|
||||
width: auto;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.config-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
|
||||
@ -670,4 +697,14 @@ body {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.host-tags,
|
||||
.restart-pills,
|
||||
.action-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.restart-pills .config-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
235
static/app.js
235
static/app.js
@ -57,6 +57,8 @@ createApp({
|
||||
selectedKey: "",
|
||||
detailLoading: false,
|
||||
detailError: "",
|
||||
validating: false,
|
||||
validationMessage: "",
|
||||
saving: false,
|
||||
saveMessage: "",
|
||||
loadedContent: "",
|
||||
@ -71,6 +73,27 @@ createApp({
|
||||
content: "",
|
||||
},
|
||||
},
|
||||
goConfig: {
|
||||
loading: false,
|
||||
error: "",
|
||||
validating: false,
|
||||
validationMessage: "",
|
||||
saving: false,
|
||||
saveMessage: "",
|
||||
loadedContent: "",
|
||||
editorContent: "",
|
||||
diffKeys: [],
|
||||
hosts: [],
|
||||
},
|
||||
restartOps: {
|
||||
loading: false,
|
||||
error: "",
|
||||
running: false,
|
||||
message: "",
|
||||
items: [],
|
||||
selectedServices: [],
|
||||
result: null,
|
||||
},
|
||||
timer: null,
|
||||
};
|
||||
},
|
||||
@ -116,8 +139,8 @@ createApp({
|
||||
const shouldShowBecauseDown = this.filter === "down" && !(host.metrics && host.metrics.ok);
|
||||
const shouldShowBecauseKeyword = keyword ? hostMatched || filteredItems.length > 0 : true;
|
||||
const shouldShow =
|
||||
shouldShowBecauseKeyword &&
|
||||
(this.filter === "all" || filteredItems.length > 0 || shouldShowBecauseDown || hostMatched);
|
||||
shouldShowBecauseKeyword
|
||||
&& (this.filter === "all" || filteredItems.length > 0 || shouldShowBecauseDown || hostMatched);
|
||||
|
||||
return {
|
||||
...host,
|
||||
@ -162,12 +185,15 @@ createApp({
|
||||
.includes(keyword);
|
||||
});
|
||||
},
|
||||
selectedNacosConfig() {
|
||||
return (this.nacosConfig.items || []).find((item) => this.configKey(item.group, item.dataId) === this.nacosConfig.selectedKey) || null;
|
||||
},
|
||||
nacosConfigDirty() {
|
||||
return this.nacosConfig.editor.content !== this.nacosConfig.loadedContent;
|
||||
},
|
||||
goConfigDirty() {
|
||||
return this.goConfig.editorContent !== this.goConfig.loadedContent;
|
||||
},
|
||||
restartTargetMap() {
|
||||
return Object.fromEntries((this.restartOps.items || []).map((item) => [item.service, item]));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
refreshIntervalSeconds() {
|
||||
@ -188,9 +214,6 @@ createApp({
|
||||
toneClass(level) {
|
||||
return `tone-${level || "neutral"}`;
|
||||
},
|
||||
boolTone(ok) {
|
||||
return ok ? "tone-ok" : "tone-bad";
|
||||
},
|
||||
configKey(group, dataId) {
|
||||
return `${group}@@${dataId}`;
|
||||
},
|
||||
@ -258,6 +281,9 @@ createApp({
|
||||
}
|
||||
return `${remainSeconds}s`;
|
||||
},
|
||||
restartTargetLabel(item) {
|
||||
return `${item.service} · ${item.kind} · ${item.hosts.map((host) => host.host).join(" / ")}`;
|
||||
},
|
||||
syncRefreshSettings() {
|
||||
const settings = this.payload.settings || {};
|
||||
const options = Array.isArray(settings.refreshIntervalOptions)
|
||||
@ -292,6 +318,7 @@ createApp({
|
||||
resetNacosEditor() {
|
||||
this.nacosConfig.selectedKey = "";
|
||||
this.nacosConfig.detailError = "";
|
||||
this.nacosConfig.validationMessage = "";
|
||||
this.nacosConfig.saveMessage = "";
|
||||
this.nacosConfig.loadedContent = "";
|
||||
this.nacosConfig.editor = {
|
||||
@ -316,6 +343,23 @@ createApp({
|
||||
throw new Error(text);
|
||||
}
|
||||
},
|
||||
async refresh() {
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
const response = await fetch("/api/monitor/services", { cache: "no-store" });
|
||||
const payload = await this.readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
this.payload = payload;
|
||||
this.syncRefreshSettings();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async refreshNacosConfigs({ preserveSelection = true, reloadSelected = false } = {}) {
|
||||
const previousKey = preserveSelection ? this.nacosConfig.selectedKey : "";
|
||||
this.nacosConfig.loading = true;
|
||||
@ -369,6 +413,7 @@ createApp({
|
||||
|
||||
this.nacosConfig.detailLoading = true;
|
||||
this.nacosConfig.detailError = "";
|
||||
this.nacosConfig.validationMessage = "";
|
||||
this.nacosConfig.saveMessage = "";
|
||||
|
||||
try {
|
||||
@ -396,6 +441,42 @@ createApp({
|
||||
this.nacosConfig.detailLoading = false;
|
||||
}
|
||||
},
|
||||
async validateNacosConfig() {
|
||||
if (!this.nacosConfig.editor.dataId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.nacosConfig.validating = true;
|
||||
this.nacosConfig.detailError = "";
|
||||
this.nacosConfig.validationMessage = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/nacos/config/validate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
group: this.nacosConfig.editor.group,
|
||||
dataId: this.nacosConfig.editor.dataId,
|
||||
type: this.nacosConfig.editor.type,
|
||||
content: this.nacosConfig.editor.content,
|
||||
}),
|
||||
});
|
||||
const payload = await this.readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
if (!payload.ok) {
|
||||
throw new Error(payload.message || "config validation failed");
|
||||
}
|
||||
this.nacosConfig.validationMessage = payload.message || "格式校验通过";
|
||||
} catch (error) {
|
||||
this.nacosConfig.detailError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.nacosConfig.validating = false;
|
||||
}
|
||||
},
|
||||
async saveNacosConfig() {
|
||||
if (!this.nacosConfig.editor.group || !this.nacosConfig.editor.dataId) {
|
||||
return;
|
||||
@ -432,21 +513,141 @@ createApp({
|
||||
this.nacosConfig.saving = false;
|
||||
}
|
||||
},
|
||||
async refresh() {
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
async refreshGoConfig() {
|
||||
this.goConfig.loading = true;
|
||||
this.goConfig.error = "";
|
||||
this.goConfig.saveMessage = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/monitor/services", { cache: "no-store" });
|
||||
const response = await fetch("/api/runtime/golang-config", { cache: "no-store" });
|
||||
const payload = await this.readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
this.payload = payload;
|
||||
this.syncRefreshSettings();
|
||||
this.goConfig.hosts = Array.isArray(payload.hosts) ? payload.hosts : [];
|
||||
this.goConfig.diffKeys = Array.isArray(payload.diffKeys) ? payload.diffKeys : [];
|
||||
this.goConfig.loadedContent = typeof payload.editorContent === "string" ? payload.editorContent : "";
|
||||
this.goConfig.editorContent = this.goConfig.loadedContent;
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
this.goConfig.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.goConfig.loading = false;
|
||||
}
|
||||
},
|
||||
async validateGoConfig() {
|
||||
this.goConfig.validating = true;
|
||||
this.goConfig.error = "";
|
||||
this.goConfig.validationMessage = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/runtime/golang-config/validate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ content: this.goConfig.editorContent }),
|
||||
});
|
||||
const payload = await this.readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
if (!payload.ok) {
|
||||
throw new Error(payload.message || "golang config validation failed");
|
||||
}
|
||||
this.goConfig.validationMessage = payload.message || "格式校验通过";
|
||||
} catch (error) {
|
||||
this.goConfig.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.goConfig.validating = false;
|
||||
}
|
||||
},
|
||||
async saveGoConfig() {
|
||||
this.goConfig.saving = true;
|
||||
this.goConfig.error = "";
|
||||
this.goConfig.saveMessage = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/runtime/golang-config", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ content: this.goConfig.editorContent }),
|
||||
});
|
||||
const payload = await this.readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
this.goConfig.loadedContent = this.goConfig.editorContent;
|
||||
this.goConfig.saveMessage = payload.message || "golang 配置已保存";
|
||||
await this.refreshGoConfig();
|
||||
} catch (error) {
|
||||
this.goConfig.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.goConfig.saving = false;
|
||||
}
|
||||
},
|
||||
async refreshRestartTargets() {
|
||||
this.restartOps.loading = true;
|
||||
this.restartOps.error = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ops/restart-targets", { cache: "no-store" });
|
||||
const payload = await this.readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
this.restartOps.items = Array.isArray(payload.items) ? payload.items : [];
|
||||
const available = new Set(this.restartOps.items.map((item) => item.service));
|
||||
this.restartOps.selectedServices = this.restartOps.selectedServices.filter((service) => available.has(service));
|
||||
} catch (error) {
|
||||
this.restartOps.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.restartOps.loading = false;
|
||||
}
|
||||
},
|
||||
toggleRestartService(serviceName) {
|
||||
const next = new Set(this.restartOps.selectedServices);
|
||||
if (next.has(serviceName)) {
|
||||
next.delete(serviceName);
|
||||
} else {
|
||||
next.add(serviceName);
|
||||
}
|
||||
this.restartOps.selectedServices = [...next];
|
||||
},
|
||||
async runRollingRestart() {
|
||||
if (this.restartOps.selectedServices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.restartOps.running = true;
|
||||
this.restartOps.error = "";
|
||||
this.restartOps.message = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ops/rolling-restart", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ services: this.restartOps.selectedServices }),
|
||||
});
|
||||
const payload = await this.readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.restartOps.result = payload;
|
||||
this.restartOps.message = payload.ok
|
||||
? (payload.message || "滚动重启完成")
|
||||
: (payload.error || "滚动重启失败");
|
||||
|
||||
await this.refresh();
|
||||
await this.refreshRestartTargets();
|
||||
} catch (error) {
|
||||
this.restartOps.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.restartOps.running = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
@ -461,6 +662,8 @@ createApp({
|
||||
}
|
||||
this.refresh();
|
||||
this.refreshNacosConfigs({ preserveSelection: false, reloadSelected: true });
|
||||
this.refreshGoConfig();
|
||||
this.refreshRestartTargets();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.timer) {
|
||||
|
||||
@ -339,13 +339,19 @@
|
||||
|
||||
<div class="config-actions">
|
||||
<span v-if="nacosConfig.saveMessage" class="ok-text">{{ nacosConfig.saveMessage }}</span>
|
||||
<span v-else-if="nacosConfig.validationMessage" class="ok-text">{{ nacosConfig.validationMessage }}</span>
|
||||
<span v-else-if="nacosConfig.detailLoading" class="badge neutral">配置加载中</span>
|
||||
<span v-else-if="nacosConfigDirty" class="badge warn">有未保存修改</span>
|
||||
<span v-else class="badge neutral">内容已同步</span>
|
||||
|
||||
<button class="refresh" @click="saveNacosConfig" :disabled="nacosConfig.saving || nacosConfig.detailLoading">
|
||||
{{ nacosConfig.saving ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
<div class="action-group">
|
||||
<button class="refresh ghost" @click="validateNacosConfig" :disabled="nacosConfig.validating || nacosConfig.saving || nacosConfig.detailLoading">
|
||||
{{ nacosConfig.validating ? '校验中...' : '校验格式' }}
|
||||
</button>
|
||||
<button class="refresh" @click="saveNacosConfig" :disabled="nacosConfig.saving || nacosConfig.detailLoading">
|
||||
{{ nacosConfig.saving ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -356,6 +362,135 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-head">
|
||||
<h2>Golang 配置</h2>
|
||||
</section>
|
||||
<section class="infra-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3>Golang Runtime Env</h3>
|
||||
<p>读取 app 节点当前运行中的 service.env,只暴露 chatapp3-golang 实际消费的环境变量</p>
|
||||
</div>
|
||||
<span :class="['badge', goConfig.error ? 'bad' : goConfig.diffKeys.length > 0 ? 'warn' : 'ok']">
|
||||
{{ goConfig.error ? '存在错误' : goConfig.diffKeys.length > 0 ? '双机有差异' : '已同步' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="config-toolbar">
|
||||
<div class="host-tags">
|
||||
<span v-for="host in goConfig.hosts" :key="host.host" class="badge neutral">
|
||||
{{ host.host }} · {{ host.path }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="refresh" @click="refreshGoConfig" :disabled="goConfig.loading">
|
||||
{{ goConfig.loading ? '加载中...' : '刷新 Go 配置' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="goConfig.error" class="metric-error">{{ goConfig.error }}</p>
|
||||
<p v-if="goConfig.diffKeys.length > 0" class="metric-error">
|
||||
当前 app 节点存在差异 key:{{ goConfig.diffKeys.join(', ') }}
|
||||
</p>
|
||||
|
||||
<section class="config-editor">
|
||||
<textarea
|
||||
v-model="goConfig.editorContent"
|
||||
class="config-textarea mono"
|
||||
spellcheck="false"
|
||||
:disabled="goConfig.loading || goConfig.saving"
|
||||
></textarea>
|
||||
|
||||
<div class="config-actions">
|
||||
<span v-if="goConfig.saveMessage" class="ok-text">{{ goConfig.saveMessage }}</span>
|
||||
<span v-else-if="goConfig.validationMessage" class="ok-text">{{ goConfig.validationMessage }}</span>
|
||||
<span v-else-if="goConfigDirty" class="badge warn">有未保存修改</span>
|
||||
<span v-else class="badge neutral">内容已同步</span>
|
||||
|
||||
<div class="action-group">
|
||||
<button class="refresh ghost" @click="validateGoConfig" :disabled="goConfig.validating || goConfig.saving || goConfig.loading">
|
||||
{{ goConfig.validating ? '校验中...' : '校验格式' }}
|
||||
</button>
|
||||
<button class="refresh" @click="saveGoConfig" :disabled="goConfig.saving || goConfig.loading">
|
||||
{{ goConfig.saving ? '保存中...' : '保存 Go 配置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="section-head">
|
||||
<h2>滚动重启</h2>
|
||||
</section>
|
||||
<section class="infra-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3>Rolling Restart</h3>
|
||||
<p>选择要发布生效的 Java / Golang 服务,按宿主机顺序逐台重启并等待健康恢复</p>
|
||||
</div>
|
||||
<span :class="['badge', restartOps.error ? 'bad' : restartOps.running ? 'warn' : 'ok']">
|
||||
{{ restartOps.error ? '存在错误' : restartOps.running ? '执行中' : '可操作' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="config-toolbar">
|
||||
<div class="restart-pills">
|
||||
<button
|
||||
v-for="item in restartOps.items"
|
||||
:key="item.service"
|
||||
:class="['config-item', restartOps.selectedServices.includes(item.service) ? 'active' : '']"
|
||||
@click="toggleRestartService(item.service)"
|
||||
>
|
||||
<strong>{{ item.service }}</strong>
|
||||
<span>{{ item.kind }} · {{ item.hosts.map((host) => host.host).join(' / ') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="action-group">
|
||||
<button class="refresh ghost" @click="refreshRestartTargets" :disabled="restartOps.loading || restartOps.running">
|
||||
{{ restartOps.loading ? '加载中...' : '刷新服务列表' }}
|
||||
</button>
|
||||
<button class="refresh danger" @click="runRollingRestart" :disabled="restartOps.running || restartOps.selectedServices.length === 0">
|
||||
{{ restartOps.running ? '重启中...' : `滚动重启 ${restartOps.selectedServices.length} 项服务` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="restartOps.error" class="metric-error">{{ restartOps.error }}</p>
|
||||
<p v-if="restartOps.message" :class="restartOps.result && restartOps.result.ok ? 'ok-text' : 'metric-error'">{{ restartOps.message }}</p>
|
||||
|
||||
<div v-if="restartOps.result && restartOps.result.steps && restartOps.result.steps.length > 0" class="table-wrap">
|
||||
<table class="table compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>服务</th>
|
||||
<th>主机</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>HTTP</th>
|
||||
<th>Nacos</th>
|
||||
<th>耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="step in restartOps.result.steps" :key="step.service + '-' + step.host">
|
||||
<td>{{ step.service }}</td>
|
||||
<td>{{ step.host }}</td>
|
||||
<td>{{ step.kind }}</td>
|
||||
<td :class="step.status === 'success' ? 'ok-text' : 'bad-text'">{{ step.status }}</td>
|
||||
<td>
|
||||
<span v-if="step.http">{{ step.http.statusCode }} / {{ step.http.latencyMs }}ms</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="step.nacos">{{ step.nacos.source || 'ok' }}</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ step.durationSeconds || '-' }}s</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-head">
|
||||
<h2>MongoDB 指标</h2>
|
||||
</section>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user