811 lines
27 KiB
Python
811 lines
27 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from typing import Any
|
||
|
||
from .cache import TTLCache
|
||
from .config import (
|
||
NACOS_BASE_URL,
|
||
NACOS_CACHE_TTL_SECONDS,
|
||
NACOS_DISCOVERY_CLUSTER_NAME,
|
||
NACOS_DISCOVERY_GROUP,
|
||
NACOS_NAMESPACE_ID,
|
||
NACOS_NAMESPACE_NAME,
|
||
NACOS_PAGE_SIZE,
|
||
NACOS_PASSWORD,
|
||
NACOS_TIMEOUT_SECONDS,
|
||
NACOS_USERNAME,
|
||
load_hosts,
|
||
utc_now,
|
||
)
|
||
|
||
|
||
# Nacos 监控结果缓存。
|
||
NACOS_CACHE = TTLCache[dict[str, Any]](NACOS_CACHE_TTL_SECONDS)
|
||
# Nacos access token 缓存锁。
|
||
TOKEN_LOCK = threading.Lock()
|
||
# Nacos access token 文本。
|
||
TOKEN_VALUE = ""
|
||
# Nacos access token 失效时间。
|
||
TOKEN_EXPIRES_AT = 0.0
|
||
|
||
# 复用现有部署链路里的 Nacos 注册名映射。
|
||
NACOS_SERVICE_NAME_MAP = {
|
||
"auth": "rc-auth",
|
||
"gateway": "rc-gateway",
|
||
"external": "rc-service-external",
|
||
"console": "rc-service-console",
|
||
"other": "rc-service-other",
|
||
"live": "rc-service-live",
|
||
"wallet": "rc-service-wallet",
|
||
"order": "rc-service-order",
|
||
}
|
||
# 反向映射,便于把注册名转回页面上的业务名。
|
||
FRIENDLY_SERVICE_NAME_MAP = {value: key for key, value in NACOS_SERVICE_NAME_MAP.items()}
|
||
|
||
|
||
def nacos_truthy(value: Any) -> bool:
|
||
# 布尔值直接返回。
|
||
if isinstance(value, bool):
|
||
return value
|
||
|
||
# 字符串则按 true/false 判断。
|
||
if isinstance(value, str):
|
||
return value.strip().lower() == "true"
|
||
|
||
# 其余值按 Python truthy 规则处理。
|
||
return bool(value)
|
||
|
||
|
||
def parse_json_payload(payload: str) -> dict[str, Any]:
|
||
# 空响应视为空对象。
|
||
if not payload.strip():
|
||
return {}
|
||
|
||
# 尝试按 JSON 解析。
|
||
try:
|
||
return json.loads(payload)
|
||
except json.JSONDecodeError:
|
||
# 非 JSON 文本也保留 raw 字段,方便排查。
|
||
return {"raw": payload.strip()}
|
||
|
||
|
||
def request_url(path: str, params: dict[str, Any] | None = None) -> str:
|
||
# 先拼基础 URL。
|
||
base = f"{NACOS_BASE_URL}{path}"
|
||
|
||
# 没 query 参数时直接返回。
|
||
if not params:
|
||
return base
|
||
|
||
# 去掉值为 None 的参数。
|
||
cleaned = {key: value for key, value in params.items() if value is not None}
|
||
# 编码 query string。
|
||
query = urllib.parse.urlencode(cleaned, quote_via=urllib.parse.quote)
|
||
|
||
# 拼回完整 URL。
|
||
return f"{base}?{query}" if query else base
|
||
|
||
|
||
def request_text(
|
||
method: str,
|
||
path: str,
|
||
*,
|
||
params: dict[str, Any] | None = None,
|
||
data: dict[str, Any] | None = None,
|
||
) -> str:
|
||
# 先构造 URL。
|
||
url = request_url(path, params)
|
||
# 默认没有请求体。
|
||
body: bytes | None = None
|
||
|
||
# 有请求体时按 form-urlencoded 提交。
|
||
if data is not None:
|
||
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=headers)
|
||
|
||
try:
|
||
# 发起请求并读取文本响应。
|
||
with urllib.request.urlopen(request, timeout=NACOS_TIMEOUT_SECONDS) as response:
|
||
return response.read().decode("utf-8", errors="replace")
|
||
except urllib.error.HTTPError as exc:
|
||
# HTTP 错误时尽量把响应体保留下来。
|
||
detail = exc.read().decode("utf-8", errors="replace")
|
||
raise RuntimeError(f"nacos {method} {path} failed: {exc.code} {detail}") from exc
|
||
except urllib.error.URLError as exc:
|
||
# 网络错误也转成统一异常。
|
||
raise RuntimeError(f"nacos {method} {path} failed: {exc}") from exc
|
||
|
||
|
||
def request_json(
|
||
method: str,
|
||
path: str,
|
||
*,
|
||
params: dict[str, Any] | None = None,
|
||
data: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
# 读取文本响应。
|
||
payload = request_text(method, path, params=params, data=data)
|
||
# 解析成 JSON 结构。
|
||
return parse_json_payload(payload)
|
||
|
||
|
||
def fetch_access_token() -> str:
|
||
# 没配置用户名密码时直接不登录。
|
||
if not NACOS_USERNAME or not NACOS_PASSWORD:
|
||
return ""
|
||
|
||
# 优先复用缓存 token。
|
||
global TOKEN_VALUE, TOKEN_EXPIRES_AT
|
||
|
||
# 先在锁内检查 token 是否还有效。
|
||
with TOKEN_LOCK:
|
||
if TOKEN_VALUE and time.time() < TOKEN_EXPIRES_AT:
|
||
return TOKEN_VALUE
|
||
|
||
# 依次尝试常见的两个登录端点。
|
||
for path in ("/nacos/v1/auth/users/login", "/nacos/v1/auth/login"):
|
||
try:
|
||
# 发起登录请求。
|
||
payload = request_json("POST", path, data={"username": NACOS_USERNAME, "password": NACOS_PASSWORD})
|
||
except RuntimeError:
|
||
# 这个端点失败时继续尝试下一个。
|
||
continue
|
||
|
||
# 读取 accessToken。
|
||
token = str(payload.get("accessToken") or "").strip()
|
||
|
||
# 没拿到 token 时继续尝试。
|
||
if not token:
|
||
continue
|
||
|
||
# 读取 token TTL,单位秒。
|
||
token_ttl = int(payload.get("tokenTtl") or 18000)
|
||
|
||
# 在锁内写回缓存。
|
||
with TOKEN_LOCK:
|
||
TOKEN_VALUE = token
|
||
TOKEN_EXPIRES_AT = time.time() + max(token_ttl - 30, 30)
|
||
|
||
# 返回有效 token。
|
||
return token
|
||
|
||
# 所有端点都没拿到 token 时返回空串。
|
||
return ""
|
||
|
||
|
||
def nacos_request(method: str, path: str, params: 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)
|
||
|
||
|
||
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")
|
||
# 兼容 data 字段和空列表。
|
||
return list(payload.get("data") or [])
|
||
|
||
|
||
def resolve_namespace(namespaces: list[dict[str, Any]]) -> tuple[str, str]:
|
||
# 优先按显式 namespace id 匹配。
|
||
if NACOS_NAMESPACE_ID:
|
||
for item in namespaces:
|
||
if str(item.get("namespace") or item.get("namespaceId") or "").strip() == NACOS_NAMESPACE_ID:
|
||
return NACOS_NAMESPACE_ID, str(item.get("namespaceShowName") or item.get("namespace") or "").strip()
|
||
|
||
# 其次按命名空间名称匹配。
|
||
if NACOS_NAMESPACE_NAME:
|
||
for item in namespaces:
|
||
show_name = str(item.get("namespaceShowName") or "").strip()
|
||
if show_name == NACOS_NAMESPACE_NAME:
|
||
return str(item.get("namespace") or item.get("namespaceId") or "").strip(), show_name
|
||
|
||
# 再退回配置值。
|
||
return NACOS_NAMESPACE_ID, NACOS_NAMESPACE_NAME
|
||
|
||
|
||
def split_grouped_service_name(raw_name: str) -> tuple[str, str]:
|
||
# service list 可能返回 DEFAULT_GROUP@@serviceName。
|
||
if "@@" in raw_name:
|
||
group_name, service_name = raw_name.split("@@", 1)
|
||
return group_name, service_name
|
||
|
||
# 没有分组前缀时使用默认分组。
|
||
return NACOS_DISCOVERY_GROUP, raw_name
|
||
|
||
|
||
def monitored_service_queries() -> list[dict[str, str]]:
|
||
# 读取当前 targets.json 里实际存在的服务名。
|
||
service_names = sorted(
|
||
{
|
||
str(service.get("name") or "").strip()
|
||
for host in load_hosts()
|
||
for service in host.get("services") or []
|
||
if str(service.get("name") or "").strip()
|
||
}
|
||
)
|
||
|
||
# 这里构造要查的注册目标。
|
||
queries: list[dict[str, str]] = []
|
||
|
||
# 逐个业务服务转成 Nacos 注册名。
|
||
for display_name in service_names:
|
||
registry_name = NACOS_SERVICE_NAME_MAP.get(display_name)
|
||
|
||
# 没映射的服务先跳过,比如当前 golang 不走 Nacos 注册。
|
||
if not registry_name:
|
||
continue
|
||
|
||
# 追加查询目标。
|
||
queries.append(
|
||
{
|
||
"displayName": display_name,
|
||
"registryName": registry_name,
|
||
"groupName": NACOS_DISCOVERY_GROUP,
|
||
"rawServiceName": f"{NACOS_DISCOVERY_GROUP}@@{registry_name}",
|
||
}
|
||
)
|
||
|
||
# 返回需要主动查询的服务清单。
|
||
return queries
|
||
|
||
|
||
def list_services(namespace_id: str) -> list[str]:
|
||
# 这里逐页拉取 service list。
|
||
doms: list[str] = []
|
||
# 初始页码从 1 开始。
|
||
page_no = 1
|
||
|
||
while True:
|
||
# 请求当前页服务列表。
|
||
payload = nacos_request(
|
||
"GET",
|
||
"/nacos/v1/ns/service/list",
|
||
{
|
||
"pageNo": str(page_no),
|
||
"pageSize": str(NACOS_PAGE_SIZE),
|
||
"namespaceId": namespace_id or None,
|
||
},
|
||
)
|
||
|
||
# 读取当前页服务名列表。
|
||
page_items = list(payload.get("doms") or [])
|
||
|
||
# 没有数据时结束。
|
||
if not page_items:
|
||
break
|
||
|
||
# 追加本页数据。
|
||
doms.extend(str(item).strip() for item in page_items if str(item).strip())
|
||
|
||
# 读取总数。
|
||
total = int(payload.get("count") or len(doms))
|
||
|
||
# 收齐所有服务后结束。
|
||
if len(doms) >= total:
|
||
break
|
||
|
||
# 继续下一页。
|
||
page_no += 1
|
||
|
||
# 去重并排序后返回。
|
||
return sorted(dict.fromkeys(doms))
|
||
|
||
|
||
def list_instances(namespace_id: str, service_name: str, group_name: str) -> list[dict[str, Any]]:
|
||
# 调用 instance list 拉取服务实例。
|
||
payload = nacos_request(
|
||
"GET",
|
||
"/nacos/v1/ns/instance/list",
|
||
{
|
||
"serviceName": service_name,
|
||
"groupName": group_name,
|
||
"namespaceId": namespace_id or None,
|
||
"healthyOnly": "false",
|
||
},
|
||
)
|
||
|
||
# 返回实例数组。
|
||
return list(payload.get("hosts") or [])
|
||
|
||
|
||
def catalog_instances(namespace_id: str, service_name: str, group_name: str) -> list[dict[str, Any]]:
|
||
try:
|
||
# 调用 catalog/instances 获取注册表视图。
|
||
payload = nacos_request(
|
||
"GET",
|
||
"/nacos/v1/ns/catalog/instances",
|
||
{
|
||
"serviceName": service_name,
|
||
"groupName": group_name,
|
||
"namespaceId": namespace_id or None,
|
||
"clusterName": NACOS_DISCOVERY_CLUSTER_NAME,
|
||
"pageNo": "1",
|
||
"pageSize": str(max(NACOS_PAGE_SIZE, 200)),
|
||
},
|
||
)
|
||
except RuntimeError as exc:
|
||
# 服务不存在时按空列表处理,不让单服务失败拖垮整块 Nacos 面板。
|
||
message = str(exc).lower()
|
||
if "is not found" in message or "service " in message and "not found" in message:
|
||
return []
|
||
raise
|
||
|
||
# 返回 list 字段。
|
||
return list(payload.get("list") or [])
|
||
|
||
|
||
def build_service_level(instance_total: int, healthy_total: int, enabled_total: int) -> str:
|
||
# 没有实例时直接标 bad。
|
||
if instance_total == 0:
|
||
return "bad"
|
||
|
||
# 没有可用健康实例时也标 bad。
|
||
if healthy_total == 0 or enabled_total == 0:
|
||
return "bad"
|
||
|
||
# 不是全健康时标 warn。
|
||
if healthy_total < instance_total or enabled_total < instance_total:
|
||
return "warn"
|
||
|
||
# 其余标 ok。
|
||
return "ok"
|
||
|
||
|
||
def build_nacos_payload() -> dict[str, Any]:
|
||
# 先取命名空间列表。
|
||
namespaces = list_namespaces()
|
||
# 解析目标 namespace。
|
||
namespace_id, namespace_name = resolve_namespace(namespaces)
|
||
|
||
# 先把命名空间归一化。
|
||
normalized_namespaces = [
|
||
{
|
||
"namespaceId": str(item.get("namespace") or item.get("namespaceId") or "").strip(),
|
||
"name": str(item.get("namespaceShowName") or item.get("namespace") or "").strip(),
|
||
"configCount": int(item.get("configCount") or 0),
|
||
"type": int(item.get("type") or 0),
|
||
"visible": True,
|
||
}
|
||
for item in namespaces
|
||
]
|
||
|
||
# 建立当前可见 namespace id 集合。
|
||
visible_ids = {item["namespaceId"] for item in normalized_namespaces}
|
||
# 建立当前可见 namespace 名称集合。
|
||
visible_names = {item["name"] for item in normalized_namespaces}
|
||
|
||
# 如果配置里的 namespace 不在可见列表里,就补一个兜底项。
|
||
if (namespace_id and namespace_id not in visible_ids) or (namespace_name and namespace_name not in visible_names):
|
||
normalized_namespaces.append(
|
||
{
|
||
"namespaceId": namespace_id,
|
||
"name": namespace_name,
|
||
"configCount": 0,
|
||
"type": 0,
|
||
"visible": False,
|
||
}
|
||
)
|
||
|
||
# 归一化后的服务列表。
|
||
services: list[dict[str, Any]] = []
|
||
# 统计实例总量。
|
||
instance_total = 0
|
||
# 统计健康实例总量。
|
||
healthy_instance_total = 0
|
||
# 统计启用实例总量。
|
||
enabled_instance_total = 0
|
||
# 统计全健康服务数量。
|
||
healthy_service_total = 0
|
||
|
||
# 逐个 namespace 读取服务和实例。
|
||
for namespace in normalized_namespaces:
|
||
# 当前 namespace 的自动发现结果。
|
||
raw_services = list_services(namespace["namespaceId"])
|
||
# 这里保存当前 namespace 下所有待查询服务。
|
||
queries_by_key: dict[tuple[str, str], dict[str, str]] = {}
|
||
# 只在可见 namespace,或者根本没有可见 namespace 时,才补 fallback 查询。
|
||
allow_fallback_queries = namespace["visible"] or not any(item["visible"] for item in normalized_namespaces)
|
||
|
||
# 先收 service/list 返回的服务。
|
||
for raw_name in raw_services:
|
||
# 解析 group 和真正的 serviceName。
|
||
group_name, registry_name = split_grouped_service_name(raw_name)
|
||
# 保存查询项。
|
||
queries_by_key[(group_name, registry_name)] = {
|
||
"displayName": FRIENDLY_SERVICE_NAME_MAP.get(registry_name, registry_name),
|
||
"registryName": registry_name,
|
||
"groupName": group_name,
|
||
"rawServiceName": raw_name,
|
||
}
|
||
|
||
# 再补上 targets.json 里已知的注册服务,避免 service/list 返回空时整页丢数据。
|
||
if allow_fallback_queries:
|
||
for item in monitored_service_queries():
|
||
queries_by_key.setdefault(
|
||
(item["groupName"], item["registryName"]),
|
||
item,
|
||
)
|
||
|
||
# 逐个服务拉实例详情。
|
||
for query in queries_by_key.values():
|
||
# 拉取当前服务实例。
|
||
instance_items = list_instances(namespace["namespaceId"], query["registryName"], query["groupName"])
|
||
|
||
# 常规实例接口为空时,再走 catalog 视图兜底。
|
||
if not instance_items:
|
||
instance_items = catalog_instances(
|
||
namespace["namespaceId"],
|
||
query["registryName"],
|
||
query["groupName"],
|
||
)
|
||
# 归一化实例结构。
|
||
normalized_instances: list[dict[str, Any]] = []
|
||
|
||
# 当前服务健康实例数。
|
||
healthy_total = 0
|
||
# 当前服务启用实例数。
|
||
enabled_total = 0
|
||
|
||
# 逐个实例整理字段。
|
||
for item in instance_items:
|
||
# 读取 healthy 标记。
|
||
healthy = nacos_truthy(item.get("healthy", False))
|
||
# 读取 enabled 标记。
|
||
enabled = nacos_truthy(item.get("enabled", True))
|
||
|
||
# 统计健康实例数。
|
||
if healthy:
|
||
healthy_total += 1
|
||
|
||
# 统计启用实例数。
|
||
if enabled:
|
||
enabled_total += 1
|
||
|
||
# 追加归一化实例结构。
|
||
normalized_instances.append(
|
||
{
|
||
"ip": str(item.get("ip") or "").strip(),
|
||
"port": int(item.get("port") or 0),
|
||
"healthy": healthy,
|
||
"enabled": enabled,
|
||
"ephemeral": nacos_truthy(item.get("ephemeral", True)),
|
||
"weight": float(item.get("weight") or 0.0),
|
||
"clusterName": str(item.get("clusterName") or NACOS_DISCOVERY_CLUSTER_NAME).strip(),
|
||
"metadata": item.get("metadata") or {},
|
||
}
|
||
)
|
||
|
||
# 当前服务实例总数。
|
||
current_total = len(normalized_instances)
|
||
# 汇总到全局实例总数。
|
||
instance_total += current_total
|
||
# 汇总到全局健康实例总数。
|
||
healthy_instance_total += healthy_total
|
||
# 汇总到全局启用实例总数。
|
||
enabled_instance_total += enabled_total
|
||
|
||
# 计算当前服务级别。
|
||
level = build_service_level(current_total, healthy_total, enabled_total)
|
||
|
||
# 全健康服务才记入 healthy_service_total。
|
||
if level == "ok":
|
||
healthy_service_total += 1
|
||
|
||
# 追加当前服务结构。
|
||
services.append(
|
||
{
|
||
"namespaceId": namespace["namespaceId"],
|
||
"namespaceName": namespace["name"],
|
||
"rawServiceName": query["rawServiceName"],
|
||
"serviceName": query["displayName"],
|
||
"registryServiceName": query["registryName"],
|
||
"groupName": query["groupName"],
|
||
"level": level,
|
||
"instanceTotal": current_total,
|
||
"healthyTotal": healthy_total,
|
||
"unhealthyTotal": current_total - healthy_total,
|
||
"enabledTotal": enabled_total,
|
||
"disabledTotal": current_total - enabled_total,
|
||
"instances": normalized_instances,
|
||
}
|
||
)
|
||
|
||
# 返回完整 Nacos 监控结构。
|
||
return {
|
||
"ok": True,
|
||
"updatedAt": utc_now(),
|
||
"baseUrl": NACOS_BASE_URL,
|
||
"namespaceId": namespace_id,
|
||
"namespaceName": namespace_name,
|
||
"summary": {
|
||
"namespaceTotal": len(normalized_namespaces),
|
||
"serviceTotal": len(services),
|
||
"healthyServiceTotal": healthy_service_total,
|
||
"degradedServiceTotal": len(services) - healthy_service_total,
|
||
"instanceTotal": instance_total,
|
||
"healthyInstanceTotal": healthy_instance_total,
|
||
"unhealthyInstanceTotal": instance_total - healthy_instance_total,
|
||
"enabledInstanceTotal": enabled_instance_total,
|
||
"disabledInstanceTotal": instance_total - enabled_instance_total,
|
||
},
|
||
"namespaces": normalized_namespaces,
|
||
"services": services,
|
||
}
|
||
|
||
|
||
def nacos_error_payload(error: str) -> dict[str, Any]:
|
||
# 返回统一错误结构,前端可以直接消费。
|
||
return {
|
||
"ok": False,
|
||
"updatedAt": utc_now(),
|
||
"baseUrl": NACOS_BASE_URL,
|
||
"namespaceId": NACOS_NAMESPACE_ID,
|
||
"namespaceName": NACOS_NAMESPACE_NAME,
|
||
"summary": {
|
||
"namespaceTotal": 0,
|
||
"serviceTotal": 0,
|
||
"healthyServiceTotal": 0,
|
||
"degradedServiceTotal": 0,
|
||
"instanceTotal": 0,
|
||
"healthyInstanceTotal": 0,
|
||
"unhealthyInstanceTotal": 0,
|
||
"enabledInstanceTotal": 0,
|
||
"disabledInstanceTotal": 0,
|
||
},
|
||
"namespaces": [],
|
||
"services": [],
|
||
"error": error,
|
||
}
|
||
|
||
|
||
def get_nacos_payload() -> dict[str, Any]:
|
||
# 使用缓存保护 Nacos API。
|
||
def builder() -> dict[str, Any]:
|
||
try:
|
||
return build_nacos_payload()
|
||
except Exception as exc: # noqa: BLE001
|
||
return nacos_error_payload(str(exc))
|
||
|
||
# 返回缓存或最新构建结果。
|
||
return NACOS_CACHE.get_or_build(builder)
|