增加滚动重启

This commit is contained in:
hy 2026-04-22 15:52:52 +08:00
parent 492c9db93b
commit e700149a99
11 changed files with 1679 additions and 26 deletions

View File

@ -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连接、内存、网络、操作计数、复制集、数据库统计
- 自动刷新:前端可切换刷新间隔,并保存在浏览器本地

View File

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

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

View File

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

View File

@ -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
View 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
View 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",
}

View File

@ -1,2 +1,4 @@
tencentcloud-sdk-python>=3.0.0
pymongo>=4.8.0
PyYAML>=6.0.2
javaproperties>=0.8.2

View File

@ -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%;
}
}

View File

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

View File

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