diff --git a/AGENTS.md b/AGENTS.md index c5782c4..4bc0e9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,3 +39,4 @@ - 不要把密钥写进版本库。 - 不要删除页面里的主机指标区块。 - Python 文件保持高注释密度,优先写清楚每个步骤在做什么。 +- Python 文件顶部的 `import` 区域不写包用途注释。 diff --git a/README.md b/README.md index 9549384..f6022b9 100644 --- a/README.md +++ b/README.md @@ -106,5 +106,6 @@ python3 ops/deploy_via_tat.py - 主机:`CPU / 内存 / 磁盘 / 进程数`,带阈值颜色分级 - 服务:Java / Golang 健康检查和耗时 - Nacos:命名空间、服务、注册实例、健康状态 +- Nacos 配置:读取当前 namespace 下的配置列表,支持在线编辑保存 - MongoDB:连接、内存、网络、操作计数、复制集、数据库统计 - 自动刷新:前端可切换刷新间隔,并保存在浏览器本地 diff --git a/monitor/cache.py b/monitor/cache.py index c36d482..d1d54ff 100644 --- a/monitor/cache.py +++ b/monitor/cache.py @@ -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 - diff --git a/monitor/config.py b/monitor/config.py index 60a8063..fbd3b93 100644 --- a/monitor/config.py +++ b/monitor/config.py @@ -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 - diff --git a/monitor/dashboard.py b/monitor/dashboard.py index 6e208b1..18eaf5b 100644 --- a/monitor/dashboard.py +++ b/monitor/dashboard.py @@ -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, } - diff --git a/monitor/env.py b/monitor/env.py index 48478d9..b2a4f50 100644 --- a/monitor/env.py +++ b/monitor/env.py @@ -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) - diff --git a/monitor/host_metrics.py b/monitor/host_metrics.py index f7502e9..456b86e 100644 --- a/monitor/host_metrics.py +++ b/monitor/host_metrics.py @@ -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 diff --git a/monitor/http_app.py b/monitor/http_app.py index 5af483f..04d12a2 100644 --- a/monitor/http_app.py +++ b/monitor/http_app.py @@ -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 diff --git a/monitor/http_probe.py b/monitor/http_probe.py index 633e64c..0b4fc1d 100644 --- a/monitor/http_probe.py +++ b/monitor/http_probe.py @@ -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 diff --git a/monitor/mongo_metrics.py b/monitor/mongo_metrics.py index 2d6edba..b391506 100644 --- a/monitor/mongo_metrics.py +++ b/monitor/mongo_metrics.py @@ -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) - diff --git a/monitor/nacos.py b/monitor/nacos.py index 6b430c0..eb1a9ad 100644 --- a/monitor/nacos.py +++ b/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") diff --git a/monitor/tencent_tat.py b/monitor/tencent_tat.py index 2fc1a38..4e9788e 100644 --- a/monitor/tencent_tat.py +++ b/monitor/tencent_tat.py @@ -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 - diff --git a/ops/deploy_via_tat.py b/ops/deploy_via_tat.py index c91f039..8366901 100755 --- a/ops/deploy_via_tat.py +++ b/ops/deploy_via_tat.py @@ -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 diff --git a/ops/open_port_via_tat.py b/ops/open_port_via_tat.py index c3ab81a..239b614 100755 --- a/ops/open_port_via_tat.py +++ b/ops/open_port_via_tat.py @@ -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 diff --git a/server.py b/server.py index 1e0bfc1..84eaaa6 100644 --- a/server.py +++ b/server.py @@ -2,7 +2,6 @@ from __future__ import annotations -# 入口文件只保留启动逻辑,具体实现放进 monitor/ 模块里。 from monitor.http_app import main diff --git a/static/app.css b/static/app.css index 561108b..980c1a3 100644 --- a/static/app.css +++ b/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; + } } diff --git a/static/app.js b/static/app.js index 489772c..12bc268 100644 --- a/static/app.js +++ b/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) { diff --git a/static/index.html b/static/index.html index 1e8c891..14b6a22 100644 --- a/static/index.html +++ b/static/index.html @@ -260,6 +260,102 @@ + + Nacos 配置 + + + + + Nacos Config + 读取当前命名空间下的配置并直接编辑保存 + + + {{ nacosConfig.error || nacosConfig.detailError ? '存在错误' : '可操作' }} + + + + + + + {{ nacosConfig.loading ? '加载中...' : '刷新配置列表' }} + + + + {{ nacosConfig.error }} + {{ nacosConfig.detailError }} + + + + + + + + + Data ID + {{ nacosConfig.editor.dataId }} + + + Group + {{ nacosConfig.editor.group }} + + + Type + {{ nacosConfig.editor.type || 'text' }} + + + 最后修改 + {{ formatDateTime(nacosConfig.editor.lastModifiedTime) }} + + + + {{ nacosConfig.editor.description }} + appName:{{ nacosConfig.editor.appName }} + md5:{{ nacosConfig.editor.md5 }} + + + + + {{ nacosConfig.saveMessage }} + 配置加载中 + 有未保存修改 + 内容已同步 + + + {{ nacosConfig.saving ? '保存中...' : '保存配置' }} + + + + + + 请选择左侧配置项 + + + + + MongoDB 指标
读取当前命名空间下的配置并直接编辑保存
{{ nacosConfig.error }}
{{ nacosConfig.detailError }}
{{ nacosConfig.editor.description }}
appName:{{ nacosConfig.editor.appName }}
md5:{{ nacosConfig.editor.md5 }}