增加nacos

This commit is contained in:
hy 2026-04-22 14:39:40 +08:00
parent ebbadb2ea8
commit 492c9db93b
18 changed files with 775 additions and 70 deletions

View File

@ -39,3 +39,4 @@
- 不要把密钥写进版本库。
- 不要删除页面里的主机指标区块。
- Python 文件保持高注释密度,优先写清楚每个步骤在做什么。
- Python 文件顶部的 `import` 区域不写包用途注释。

View File

@ -106,5 +106,6 @@ python3 ops/deploy_via_tat.py
- 主机:`CPU / 内存 / 磁盘 / 进程数`,带阈值颜色分级
- 服务Java / Golang 健康检查和耗时
- Nacos命名空间、服务、注册实例、健康状态
- Nacos 配置:读取当前 namespace 下的配置列表,支持在线编辑保存
- MongoDB连接、内存、网络、操作计数、复制集、数据库统计
- 自动刷新:前端可切换刷新间隔,并保存在浏览器本地

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@
from __future__ import annotations
# 入口文件只保留启动逻辑,具体实现放进 monitor/ 模块里。
from monitor.http_app import main

View File

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

View File

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

View File

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