增加nacos
This commit is contained in:
parent
ebbadb2ea8
commit
492c9db93b
@ -39,3 +39,4 @@
|
||||
- 不要把密钥写进版本库。
|
||||
- 不要删除页面里的主机指标区块。
|
||||
- Python 文件保持高注释密度,优先写清楚每个步骤在做什么。
|
||||
- Python 文件顶部的 `import` 区域不写包用途注释。
|
||||
|
||||
@ -106,5 +106,6 @@ python3 ops/deploy_via_tat.py
|
||||
- 主机:`CPU / 内存 / 磁盘 / 进程数`,带阈值颜色分级
|
||||
- 服务:Java / Golang 健康检查和耗时
|
||||
- Nacos:命名空间、服务、注册实例、健康状态
|
||||
- Nacos 配置:读取当前 namespace 下的配置列表,支持在线编辑保存
|
||||
- MongoDB:连接、内存、网络、操作计数、复制集、数据库统计
|
||||
- 自动刷新:前端可切换刷新间隔,并保存在浏览器本地
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# 线程锁用于保护缓存读写。
|
||||
import threading
|
||||
# 时间模块用于判断缓存是否过期。
|
||||
import time
|
||||
# 类型提示用于表达构建函数和返回值。
|
||||
from typing import Callable, Generic, TypeVar
|
||||
|
||||
|
||||
@ -52,4 +49,3 @@ class TTLCache(Generic[T]):
|
||||
with self._lock:
|
||||
self._payload = None
|
||||
self._built_at = 0.0
|
||||
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# 读取 targets.json 需要 JSON 模块。
|
||||
import json
|
||||
# Path 用来稳定定位项目目录和配置文件。
|
||||
from pathlib import Path
|
||||
# 类型提示只用于提升可读性。
|
||||
from typing import Any
|
||||
|
||||
# 统一从这里读取 .env 和环境变量。
|
||||
from .env import env_float, env_int, env_int_list, env_str, load_env_file
|
||||
|
||||
|
||||
@ -184,4 +180,3 @@ def flatten_services(hosts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
# 返回扁平服务列表。
|
||||
return services
|
||||
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# 类型提示只用于表达返回结构。
|
||||
from typing import Any
|
||||
|
||||
# 聚合各模块监控结果。
|
||||
from .config import THRESHOLDS, load_hosts, settings_payload, utc_now
|
||||
from .host_metrics import get_host_metrics
|
||||
from .http_probe import get_service_payload
|
||||
@ -145,4 +143,3 @@ def build_monitor_payload() -> dict[str, Any]:
|
||||
"nacos": nacos_payload,
|
||||
"mongo": mongo_payload,
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# 读取环境变量时要处理项目根目录的 .env。
|
||||
import os
|
||||
# Path 用来稳定处理 .env 路径。
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@ -84,4 +82,3 @@ def env_int_list(name: str, default: list[int]) -> tuple[int, ...]:
|
||||
|
||||
# 转成整数元组返回。
|
||||
return tuple(int(item) for item in values)
|
||||
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# JSON 用来解析远端脚本输出。
|
||||
import json
|
||||
# 类型提示提升可读性。
|
||||
from typing import Any
|
||||
|
||||
# 读取主机指标相关配置。
|
||||
from .cache import TTLCache
|
||||
from .config import HOST_METRIC_CACHE_TTL_SECONDS, TAT_CPU_SAMPLE_SECONDS, TAT_METRIC_TIMEOUT_SECONDS, utc_now
|
||||
from .tencent_tat import run_shell_command
|
||||
|
||||
@ -1,20 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# JSON 用于构造统一接口响应。
|
||||
import json
|
||||
# HTTP 状态码常量。
|
||||
from http import HTTPStatus
|
||||
# 标准库 HTTP Server 足够承载这个轻量页面。
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
# 类型提示用于方法签名。
|
||||
from typing import Any
|
||||
# urlparse 只解析 path。
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# 读取应用配置和静态目录。
|
||||
from .config import APP_BIND, APP_PORT, STATIC_DIR, utc_now
|
||||
# 统一构造监控页 API 载荷。
|
||||
from .dashboard import build_monitor_payload
|
||||
from .nacos import get_nacos_config_list_payload, get_nacos_config_payload, save_nacos_config_payload
|
||||
|
||||
|
||||
def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
|
||||
@ -38,9 +32,15 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
# HEAD 请求只返回响应头。
|
||||
self.dispatch(send_body=False)
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
# POST 请求主要用于配置保存。
|
||||
self.dispatch_post()
|
||||
|
||||
def dispatch(self, send_body: bool) -> None:
|
||||
# 只解析 URL 的 path 段。
|
||||
parsed = urlparse(self.path)
|
||||
# 顺手解析 query 参数,后面接口直接取。
|
||||
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||
|
||||
# 首页返回静态 HTML。
|
||||
if parsed.path in {"/", "/index.html"}:
|
||||
@ -76,6 +76,44 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
# 返回当前 namespace 下全部 Nacos 配置元数据。
|
||||
if parsed.path == "/api/nacos/configs":
|
||||
try:
|
||||
payload = get_nacos_config_list_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/nacos/config":
|
||||
group_name = self.query_value(query, "group")
|
||||
data_id = self.query_value(query, "dataId")
|
||||
|
||||
if not group_name or not data_id:
|
||||
return self.send_error_payload(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"group and dataId are required",
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = get_nacos_config_payload(group_name, data_id)
|
||||
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)
|
||||
@ -83,6 +121,75 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
# 其他路径统一 404。
|
||||
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=send_body)
|
||||
|
||||
def dispatch_post(self) -> None:
|
||||
# 只解析 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)
|
||||
|
||||
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
|
||||
|
||||
# group / dataId 是保存现有配置的唯一键。
|
||||
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)
|
||||
|
||||
try:
|
||||
result = save_nacos_config_payload(group_name, data_id, content, 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 query_value(self, query: dict[str, list[str]], key: str) -> str:
|
||||
# 统一读取单值 query 参数。
|
||||
return str((query.get(key) or [""])[0]).strip()
|
||||
|
||||
def read_json_body(self) -> dict[str, Any]:
|
||||
# Content-Length 缺失时按空请求体处理。
|
||||
raw_length = self.headers.get("Content-Length", "0").strip() or "0"
|
||||
|
||||
try:
|
||||
content_length = int(raw_length)
|
||||
except ValueError as exc:
|
||||
raise ValueError("invalid Content-Length") from exc
|
||||
|
||||
# 空请求体不符合当前 JSON 接口要求。
|
||||
if content_length <= 0:
|
||||
raise ValueError("request body is required")
|
||||
|
||||
# 读取原始请求体。
|
||||
body = self.rfile.read(content_length).decode("utf-8", errors="replace")
|
||||
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("request body must be valid json") from exc
|
||||
|
||||
# 顶层必须是对象。
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request body must be a json object")
|
||||
|
||||
return payload
|
||||
|
||||
def serve_static(self, name: str, content_type: str, send_body: bool) -> None:
|
||||
# 计算静态文件路径。
|
||||
path = STATIC_DIR / name
|
||||
|
||||
@ -1,16 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# 并发探测服务使用线程池即可。
|
||||
import concurrent.futures
|
||||
# JSON 只用于类型结构兼容和调试。
|
||||
from typing import Any
|
||||
# urllib 直接探测内网 HTTP 健康接口。
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
# 计时用于计算耗时。
|
||||
import time
|
||||
|
||||
# 读取服务配置和运行参数。
|
||||
from .cache import TTLCache
|
||||
from .config import PROBE_CACHE_TTL_SECONDS, PROBE_MAX_WORKERS, PROBE_TIMEOUT_SECONDS, flatten_services, utc_now
|
||||
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# datetime 用于复制集 optime 时间格式化。
|
||||
from datetime import datetime, timezone
|
||||
# 类型提示只用于表达返回结构。
|
||||
from typing import Any
|
||||
|
||||
# 读取 Mongo 配置。
|
||||
from .cache import TTLCache
|
||||
from .config import (
|
||||
MONGO_CACHE_TTL_SECONDS,
|
||||
@ -354,4 +351,3 @@ def build_mongo_payload() -> dict[str, Any]:
|
||||
def get_mongo_payload() -> dict[str, Any]:
|
||||
# 使用缓存保护 Mongo 采集。
|
||||
return MONGO_CACHE.get_or_build(build_mongo_payload)
|
||||
|
||||
|
||||
235
monitor/nacos.py
235
monitor/nacos.py
@ -1,19 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# JSON 用来解析 Nacos 响应。
|
||||
import json
|
||||
# 线程锁用于保护登录 token 缓存。
|
||||
import threading
|
||||
# token 过期和缓存使用时间模块。
|
||||
import time
|
||||
# urllib 负责直接请求 Nacos OpenAPI。
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
# 类型提示只用于表达返回结构。
|
||||
from typing import Any
|
||||
|
||||
# 读取 Nacos 配置。
|
||||
from .cache import TTLCache
|
||||
from .config import (
|
||||
NACOS_BASE_URL,
|
||||
@ -112,10 +106,16 @@ def request_text(
|
||||
|
||||
# 有请求体时按 form-urlencoded 提交。
|
||||
if data is not None:
|
||||
body = urllib.parse.urlencode(data, quote_via=urllib.parse.quote).encode("utf-8")
|
||||
cleaned_data = {key: value for key, value in data.items() if value is not None}
|
||||
body = urllib.parse.urlencode(cleaned_data, quote_via=urllib.parse.quote).encode("utf-8")
|
||||
|
||||
# 表单提交时显式声明 content type,避免不同 Python 版本行为不一致。
|
||||
headers = {"User-Agent": "hy-app-monitor/1.0"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8"
|
||||
|
||||
# 构造 HTTP 请求对象。
|
||||
request = urllib.request.Request(url, data=body, method=method, headers={"User-Agent": "hy-app-monitor/1.0"})
|
||||
request = urllib.request.Request(url, data=body, method=method, headers=headers)
|
||||
|
||||
try:
|
||||
# 发起请求并读取文本响应。
|
||||
@ -199,6 +199,225 @@ def nacos_request(method: str, path: str, params: dict[str, Any] | None = None)
|
||||
return request_json(method, path, params=merged_params)
|
||||
|
||||
|
||||
def nacos_request_json(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# 复制一份参数,避免污染外部调用方。
|
||||
merged_params = dict(params or {})
|
||||
# 如果登录拿到了 token,就把 accessToken 带上。
|
||||
access_token = fetch_access_token()
|
||||
if access_token:
|
||||
merged_params["accessToken"] = access_token
|
||||
|
||||
# 发起请求并解析 JSON。
|
||||
return request_json(method, path, params=merged_params, data=data)
|
||||
|
||||
|
||||
def nacos_request_text(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
# 复制一份参数,避免污染外部调用方。
|
||||
merged_params = dict(params or {})
|
||||
# 如果登录拿到了 token,就把 accessToken 带上。
|
||||
access_token = fetch_access_token()
|
||||
if access_token:
|
||||
merged_params["accessToken"] = access_token
|
||||
|
||||
# 发起请求并直接返回原始文本。
|
||||
return request_text(method, path, params=merged_params, data=data)
|
||||
|
||||
|
||||
def current_namespace() -> tuple[str, str]:
|
||||
# 优先使用显式配置的 namespace,未配置时回退 public。
|
||||
namespace_id = NACOS_NAMESPACE_ID or "public"
|
||||
namespace_name = NACOS_NAMESPACE_NAME or namespace_id
|
||||
return namespace_id, namespace_name
|
||||
|
||||
|
||||
def tenant_param(namespace_id: str) -> str | None:
|
||||
# public 命名空间不需要显式传 tenant。
|
||||
if not namespace_id or namespace_id == "public":
|
||||
return None
|
||||
return namespace_id
|
||||
|
||||
|
||||
def infer_config_type(data_id: str, fallback: str = "text") -> str:
|
||||
# 按文件后缀猜一个最接近的 Nacos type。
|
||||
lowered = data_id.strip().lower()
|
||||
if lowered.endswith((".yml", ".yaml")):
|
||||
return "yaml"
|
||||
if lowered.endswith(".json"):
|
||||
return "json"
|
||||
if lowered.endswith(".properties"):
|
||||
return "properties"
|
||||
if lowered.endswith(".xml"):
|
||||
return "xml"
|
||||
if lowered.endswith(".html"):
|
||||
return "html"
|
||||
if lowered.endswith(".txt"):
|
||||
return "text"
|
||||
return fallback
|
||||
|
||||
|
||||
def normalize_config_entry(item: dict[str, Any], namespace_id: str) -> dict[str, Any]:
|
||||
# 从列表接口里抽取前端真正需要的元数据。
|
||||
data_id = str(item.get("dataId") or "").strip()
|
||||
group_name = str(item.get("group") or item.get("groupName") or "").strip()
|
||||
config_type = str(item.get("type") or "").strip() or infer_config_type(data_id)
|
||||
return {
|
||||
"dataId": data_id,
|
||||
"group": group_name,
|
||||
"appName": str(item.get("appName") or "").strip(),
|
||||
"type": config_type,
|
||||
"description": str(item.get("description") or item.get("desc") or "").strip(),
|
||||
"namespaceId": str(item.get("tenant") or item.get("namespaceId") or namespace_id).strip() or namespace_id,
|
||||
"md5": str(item.get("md5") or "").strip(),
|
||||
"lastModifiedTime": str(item.get("lastModifiedTime") or item.get("gmtModified") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def list_config_entries(namespace_id: str) -> list[dict[str, Any]]:
|
||||
# 逐页拉取当前 namespace 下全部配置项。
|
||||
page_no = 1
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
while True:
|
||||
# Nacos v1 配置列表要求显式带 search 参数。
|
||||
payload = nacos_request_json(
|
||||
"GET",
|
||||
"/nacos/v1/cs/configs",
|
||||
params={
|
||||
"search": "blur",
|
||||
"pageNo": str(page_no),
|
||||
"pageSize": str(NACOS_PAGE_SIZE),
|
||||
"tenant": tenant_param(namespace_id),
|
||||
"dataId": "",
|
||||
"group": "",
|
||||
"appName": "",
|
||||
"config_tags": "",
|
||||
},
|
||||
)
|
||||
|
||||
# 兼容常见字段名。
|
||||
page_items = list(payload.get("pageItems") or payload.get("data") or payload.get("items") or [])
|
||||
if not page_items:
|
||||
break
|
||||
|
||||
items.extend(normalize_config_entry(item, namespace_id) for item in page_items)
|
||||
|
||||
# totalCount 是配置列表接口的标准分页字段。
|
||||
total_count = int(payload.get("totalCount") or payload.get("count") or len(items))
|
||||
if len(items) >= total_count:
|
||||
break
|
||||
|
||||
page_no += 1
|
||||
|
||||
# 去重并按 group/dataId 排序,避免翻页结果不稳定。
|
||||
deduped: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for item in items:
|
||||
deduped[(item["group"], item["dataId"])] = item
|
||||
return sorted(deduped.values(), key=lambda item: (item["group"], item["dataId"]))
|
||||
|
||||
|
||||
def get_config_content(namespace_id: str, group_name: str, data_id: str) -> str:
|
||||
# 读取指定配置的原始文本内容。
|
||||
return nacos_request_text(
|
||||
"GET",
|
||||
"/nacos/v1/cs/configs",
|
||||
params={
|
||||
"tenant": tenant_param(namespace_id),
|
||||
"group": group_name,
|
||||
"dataId": data_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_nacos_config_list_payload() -> dict[str, Any]:
|
||||
# 使用当前监控配置里的 namespace。
|
||||
namespace_id, namespace_name = current_namespace()
|
||||
# 读取全部配置项。
|
||||
items = list_config_entries(namespace_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"updatedAt": utc_now(),
|
||||
"baseUrl": NACOS_BASE_URL,
|
||||
"namespaceId": namespace_id,
|
||||
"namespaceName": namespace_name,
|
||||
"total": len(items),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def get_nacos_config_payload(group_name: str, data_id: str) -> dict[str, Any]:
|
||||
# 使用当前监控配置里的 namespace。
|
||||
namespace_id, namespace_name = current_namespace()
|
||||
# 读取配置原文。
|
||||
content = get_config_content(namespace_id, group_name, data_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"updatedAt": utc_now(),
|
||||
"baseUrl": NACOS_BASE_URL,
|
||||
"namespaceId": namespace_id,
|
||||
"namespaceName": namespace_name,
|
||||
"item": {
|
||||
"dataId": data_id,
|
||||
"group": group_name,
|
||||
"type": infer_config_type(data_id),
|
||||
},
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
# public 命名空间不显式传 tenant。
|
||||
save_data: dict[str, Any] = {
|
||||
"group": group_name,
|
||||
"dataId": data_id,
|
||||
"type": (config_type or infer_config_type(data_id)).strip(),
|
||||
"content": content,
|
||||
}
|
||||
if tenant_param(namespace_id):
|
||||
save_data["tenant"] = tenant_param(namespace_id)
|
||||
|
||||
# Nacos v1 配置保存统一走 POST /v1/cs/configs。
|
||||
response_text = nacos_request_text(
|
||||
"POST",
|
||||
"/nacos/v1/cs/configs",
|
||||
data=save_data,
|
||||
).strip()
|
||||
|
||||
# 2xx 下如果返回 false,也按失败处理。
|
||||
if response_text and response_text.lower() == "false":
|
||||
raise RuntimeError(f"nacos save config failed: {group_name}/{data_id}")
|
||||
|
||||
# 配置数量等汇总依赖 Nacos 面板缓存,保存后主动清掉。
|
||||
NACOS_CACHE.clear()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"updatedAt": utc_now(),
|
||||
"baseUrl": NACOS_BASE_URL,
|
||||
"namespaceId": namespace_id,
|
||||
"namespaceName": namespace_name,
|
||||
"item": {
|
||||
"dataId": data_id,
|
||||
"group": group_name,
|
||||
"type": (config_type or infer_config_type(data_id)).strip(),
|
||||
},
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
def list_namespaces() -> list[dict[str, Any]]:
|
||||
# 读取 Nacos 命名空间列表。
|
||||
payload = nacos_request("GET", "/nacos/v1/console/namespaces")
|
||||
|
||||
@ -1,15 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# base64 用来传输 shell 脚本和结果。
|
||||
import base64
|
||||
# JSON 用来构造和解析腾讯云 SDK 请求。
|
||||
import json
|
||||
# 轮询结果需要 sleep 和超时判断。
|
||||
import time
|
||||
# 类型提示只用于表达结构。
|
||||
from typing import Any
|
||||
|
||||
# 读取 TAT 连接配置。
|
||||
from .config import DEPLOY_REGION, TAT_METRIC_TIMEOUT_SECONDS, TENCENT_SECRET_ID, TENCENT_SECRET_KEY, utc_now
|
||||
|
||||
|
||||
@ -196,4 +191,3 @@ def run_shell_command(
|
||||
|
||||
# 返回按实例 ID 索引的结果。
|
||||
return results
|
||||
|
||||
|
||||
@ -2,21 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# base64 用来安全传输远端脚本和 .env 内容。
|
||||
import base64
|
||||
# json 用来构造腾讯云 SDK 请求。
|
||||
import json
|
||||
# 读取本地 .env 和运行环境变量。
|
||||
import os
|
||||
# site 用来把项目 .venv 的依赖注入当前解释器。
|
||||
import site
|
||||
# sys 只用来判断当前解释器前缀。
|
||||
import sys
|
||||
# TAT 轮询需要 sleep 和超时控制。
|
||||
import time
|
||||
# Path 用于稳定定位项目根目录。
|
||||
from pathlib import Path
|
||||
# 类型提示只用于提高可读性。
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
|
||||
|
||||
@ -2,21 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# base64 用来把远端脚本和 JSON 安全塞进 TAT 请求。
|
||||
import base64
|
||||
# json 用来构造腾讯云 SDK 请求和解析响应。
|
||||
import json
|
||||
# 环境变量读取来自项目根目录的 .env。
|
||||
import os
|
||||
# site 用来把项目 .venv 的依赖注入当前解释器。
|
||||
import site
|
||||
# sys 只用来判断当前解释器前缀。
|
||||
import sys
|
||||
# 轮询 TAT 执行结果时需要 sleep 和超时控制。
|
||||
import time
|
||||
# Path 用于稳定定位项目根目录和 .env 文件。
|
||||
from pathlib import Path
|
||||
# 类型提示只用于提升可读性。
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# 入口文件只保留启动逻辑,具体实现放进 monitor/ 模块里。
|
||||
from monitor.http_app import main
|
||||
|
||||
|
||||
|
||||
147
static/app.css
147
static/app.css
@ -488,6 +488,129 @@ body {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.config-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.config-search {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.config-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 760px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel-strong);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.config-item:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.config-item.active {
|
||||
border-color: color-mix(in oklab, var(--accent) 60%, white 15%);
|
||||
background: color-mix(in oklab, var(--accent) 14%, var(--panel-strong));
|
||||
}
|
||||
|
||||
.config-item strong,
|
||||
.config-item span,
|
||||
.config-item small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.config-item strong {
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.config-item span,
|
||||
.config-item small,
|
||||
.config-description {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-item span,
|
||||
.config-description {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.config-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-meta-grid .summary-card strong {
|
||||
font-size: 0.95rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.config-empty {
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 18px;
|
||||
color: var(--muted);
|
||||
background: color-mix(in oklab, var(--panel-strong) 88%, transparent);
|
||||
text-align: center;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.config-textarea {
|
||||
width: 100%;
|
||||
min-height: 520px;
|
||||
padding: 16px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: oklch(21% 0.02 248 / 0.95);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
@ -502,6 +625,18 @@ body {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.config-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-list {
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.config-meta-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@ -520,11 +655,19 @@ body {
|
||||
.service-grid,
|
||||
.host-metrics,
|
||||
.summary-grid,
|
||||
.metric-grid {
|
||||
.metric-grid,
|
||||
.config-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search {
|
||||
.search,
|
||||
.config-search {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.config-toolbar,
|
||||
.config-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
192
static/app.js
192
static/app.js
@ -49,6 +49,28 @@ createApp({
|
||||
},
|
||||
},
|
||||
},
|
||||
nacosConfig: {
|
||||
loading: false,
|
||||
error: "",
|
||||
keyword: "",
|
||||
items: [],
|
||||
selectedKey: "",
|
||||
detailLoading: false,
|
||||
detailError: "",
|
||||
saving: false,
|
||||
saveMessage: "",
|
||||
loadedContent: "",
|
||||
editor: {
|
||||
group: "",
|
||||
dataId: "",
|
||||
type: "",
|
||||
appName: "",
|
||||
description: "",
|
||||
lastModifiedTime: "",
|
||||
md5: "",
|
||||
content: "",
|
||||
},
|
||||
},
|
||||
timer: null,
|
||||
};
|
||||
},
|
||||
@ -128,6 +150,24 @@ createApp({
|
||||
);
|
||||
});
|
||||
},
|
||||
filteredNacosConfigs() {
|
||||
const keyword = this.nacosConfig.keyword.trim().toLowerCase();
|
||||
return (this.nacosConfig.items || []).filter((item) => {
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
return [item.group, item.dataId, item.type, item.appName, item.description]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.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;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
refreshIntervalSeconds() {
|
||||
@ -151,6 +191,9 @@ createApp({
|
||||
boolTone(ok) {
|
||||
return ok ? "tone-ok" : "tone-bad";
|
||||
},
|
||||
configKey(group, dataId) {
|
||||
return `${group}@@${dataId}`;
|
||||
},
|
||||
formatPercent(value) {
|
||||
if (value == null || Number.isNaN(Number(value))) {
|
||||
return "-";
|
||||
@ -246,15 +289,159 @@ createApp({
|
||||
}
|
||||
this.timer = window.setInterval(() => this.refresh(), this.refreshIntervalSeconds * 1000);
|
||||
},
|
||||
resetNacosEditor() {
|
||||
this.nacosConfig.selectedKey = "";
|
||||
this.nacosConfig.detailError = "";
|
||||
this.nacosConfig.saveMessage = "";
|
||||
this.nacosConfig.loadedContent = "";
|
||||
this.nacosConfig.editor = {
|
||||
group: "",
|
||||
dataId: "",
|
||||
type: "",
|
||||
appName: "",
|
||||
description: "",
|
||||
lastModifiedTime: "",
|
||||
md5: "",
|
||||
content: "",
|
||||
};
|
||||
},
|
||||
async readJsonResponse(response) {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new Error(text);
|
||||
}
|
||||
},
|
||||
async refreshNacosConfigs({ preserveSelection = true, reloadSelected = false } = {}) {
|
||||
const previousKey = preserveSelection ? this.nacosConfig.selectedKey : "";
|
||||
this.nacosConfig.loading = true;
|
||||
this.nacosConfig.error = "";
|
||||
this.nacosConfig.saveMessage = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/nacos/configs", { cache: "no-store" });
|
||||
const payload = await this.readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.nacosConfig.items = Array.isArray(payload.items) ? payload.items : [];
|
||||
|
||||
const hasPrevious = previousKey
|
||||
&& this.nacosConfig.items.some((item) => this.configKey(item.group, item.dataId) === previousKey);
|
||||
const nextKey = hasPrevious
|
||||
? previousKey
|
||||
: (this.nacosConfig.items[0]
|
||||
? this.configKey(this.nacosConfig.items[0].group, this.nacosConfig.items[0].dataId)
|
||||
: "");
|
||||
|
||||
if (!nextKey) {
|
||||
this.resetNacosEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
this.nacosConfig.selectedKey = nextKey;
|
||||
|
||||
const sameAsCurrent = this.configKey(this.nacosConfig.editor.group, this.nacosConfig.editor.dataId) === nextKey;
|
||||
if (!sameAsCurrent || reloadSelected || !this.nacosConfig.loadedContent) {
|
||||
await this.openNacosConfigByKey(nextKey);
|
||||
}
|
||||
} catch (error) {
|
||||
this.nacosConfig.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.nacosConfig.loading = false;
|
||||
}
|
||||
},
|
||||
async openNacosConfig(item) {
|
||||
const nextKey = this.configKey(item.group, item.dataId);
|
||||
this.nacosConfig.selectedKey = nextKey;
|
||||
await this.openNacosConfigByKey(nextKey);
|
||||
},
|
||||
async openNacosConfigByKey(configKey) {
|
||||
const item = (this.nacosConfig.items || []).find((entry) => this.configKey(entry.group, entry.dataId) === configKey);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.nacosConfig.detailLoading = true;
|
||||
this.nacosConfig.detailError = "";
|
||||
this.nacosConfig.saveMessage = "";
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({ group: item.group, dataId: item.dataId });
|
||||
const response = await fetch(`/api/nacos/config?${query.toString()}`, { cache: "no-store" });
|
||||
const payload = await this.readJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.nacosConfig.loadedContent = typeof payload.content === "string" ? payload.content : "";
|
||||
this.nacosConfig.editor = {
|
||||
group: item.group,
|
||||
dataId: item.dataId,
|
||||
type: item.type || payload.item?.type || "",
|
||||
appName: item.appName || "",
|
||||
description: item.description || "",
|
||||
lastModifiedTime: item.lastModifiedTime || "",
|
||||
md5: item.md5 || "",
|
||||
content: this.nacosConfig.loadedContent,
|
||||
};
|
||||
} catch (error) {
|
||||
this.nacosConfig.detailError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.nacosConfig.detailLoading = false;
|
||||
}
|
||||
},
|
||||
async saveNacosConfig() {
|
||||
if (!this.nacosConfig.editor.group || !this.nacosConfig.editor.dataId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.nacosConfig.saving = true;
|
||||
this.nacosConfig.detailError = "";
|
||||
this.nacosConfig.saveMessage = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/nacos/config", {
|
||||
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}`);
|
||||
}
|
||||
|
||||
this.nacosConfig.loadedContent = this.nacosConfig.editor.content;
|
||||
this.nacosConfig.saveMessage = `已保存 ${this.nacosConfig.editor.group}/${this.nacosConfig.editor.dataId}`;
|
||||
await this.refreshNacosConfigs({ preserveSelection: true, reloadSelected: true });
|
||||
} catch (error) {
|
||||
this.nacosConfig.detailError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
this.nacosConfig.saving = false;
|
||||
}
|
||||
},
|
||||
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(`HTTP ${response.status}`);
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
this.payload = await response.json();
|
||||
this.payload = payload;
|
||||
this.syncRefreshSettings();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
@ -273,6 +460,7 @@ createApp({
|
||||
bootIndicator.hidden = true;
|
||||
}
|
||||
this.refresh();
|
||||
this.refreshNacosConfigs({ preserveSelection: false, reloadSelected: true });
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.timer) {
|
||||
|
||||
@ -260,6 +260,102 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-head">
|
||||
<h2>Nacos 配置</h2>
|
||||
</section>
|
||||
<section class="infra-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h3>Nacos Config</h3>
|
||||
<p>读取当前命名空间下的配置并直接编辑保存</p>
|
||||
</div>
|
||||
<span :class="['badge', nacosConfig.error || nacosConfig.detailError ? 'bad' : 'ok']">
|
||||
{{ nacosConfig.error || nacosConfig.detailError ? '存在错误' : '可操作' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="config-toolbar">
|
||||
<input
|
||||
v-model.trim="nacosConfig.keyword"
|
||||
class="search config-search"
|
||||
placeholder="搜索 dataId / group / appName"
|
||||
/>
|
||||
<button class="refresh" @click="refreshNacosConfigs({ preserveSelection: true, reloadSelected: false })" :disabled="nacosConfig.loading">
|
||||
{{ nacosConfig.loading ? '加载中...' : '刷新配置列表' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="nacosConfig.error" class="metric-error">{{ nacosConfig.error }}</p>
|
||||
<p v-if="nacosConfig.detailError" class="metric-error">{{ nacosConfig.detailError }}</p>
|
||||
|
||||
<div class="config-layout">
|
||||
<aside class="config-list">
|
||||
<button
|
||||
v-for="item in filteredNacosConfigs"
|
||||
:key="configKey(item.group, item.dataId)"
|
||||
:class="['config-item', configKey(item.group, item.dataId) === nacosConfig.selectedKey ? 'active' : '']"
|
||||
@click="openNacosConfig(item)"
|
||||
>
|
||||
<strong>{{ item.dataId }}</strong>
|
||||
<span>{{ item.group }} · {{ item.type || 'text' }}</span>
|
||||
<small v-if="item.appName">{{ item.appName }}</small>
|
||||
</button>
|
||||
<div v-if="!nacosConfig.loading && filteredNacosConfigs.length === 0" class="config-empty">
|
||||
当前没有可展示的配置项
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="config-editor">
|
||||
<template v-if="nacosConfig.editor.dataId">
|
||||
<div class="config-meta-grid">
|
||||
<div class="summary-card">
|
||||
<small>Data ID</small>
|
||||
<strong class="mono">{{ nacosConfig.editor.dataId }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<small>Group</small>
|
||||
<strong class="mono">{{ nacosConfig.editor.group }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<small>Type</small>
|
||||
<strong>{{ nacosConfig.editor.type || 'text' }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<small>最后修改</small>
|
||||
<strong>{{ formatDateTime(nacosConfig.editor.lastModifiedTime) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="nacosConfig.editor.description" class="config-description">{{ nacosConfig.editor.description }}</p>
|
||||
<p v-if="nacosConfig.editor.appName" class="config-description">appName:{{ nacosConfig.editor.appName }}</p>
|
||||
<p v-if="nacosConfig.editor.md5" class="config-description mono">md5:{{ nacosConfig.editor.md5 }}</p>
|
||||
|
||||
<textarea
|
||||
v-model="nacosConfig.editor.content"
|
||||
class="config-textarea mono"
|
||||
spellcheck="false"
|
||||
:disabled="nacosConfig.detailLoading || nacosConfig.saving"
|
||||
></textarea>
|
||||
|
||||
<div class="config-actions">
|
||||
<span v-if="nacosConfig.saveMessage" class="ok-text">{{ nacosConfig.saveMessage }}</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>
|
||||
</template>
|
||||
|
||||
<div v-else class="config-empty">
|
||||
请选择左侧配置项
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-head">
|
||||
<h2>MongoDB 指标</h2>
|
||||
</section>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user