from __future__ import annotations from datetime import datetime, timezone from typing import Any from core.cache import TTLCache from core.config import ( MONGO_CACHE_TTL_SECONDS, MONGO_CONNECT_TIMEOUT_MS, MONGO_DB_NAME, MONGO_SERVER_SELECTION_TIMEOUT_MS, MONGO_SOCKET_TIMEOUT_MS, MONGO_URI, utc_now, ) # Mongo 结果缓存。 MONGO_CACHE = TTLCache[dict[str, Any]](MONGO_CACHE_TTL_SECONDS) def mongo_error_payload(error: str) -> dict[str, Any]: # 统一返回 Mongo 错误结构。 return { "ok": False, "updatedAt": utc_now(), "summary": { "databaseTotal": 0, "collectionTotal": 0, "objectTotal": 0, "storageSizeMb": 0.0, "dataSizeMb": 0.0, "connectionCurrent": 0, "connectionAvailable": 0, }, "info": {}, "connections": {}, "memory": {}, "network": {}, "opcounters": {}, "wiredTigerCache": {}, "replicaSet": {"configured": False, "members": []}, "databases": [], "error": error, } def iso_datetime(value: Any) -> str: # datetime 对象转成 UTC ISO 字符串。 if isinstance(value, datetime): return value.astimezone(timezone.utc).replace(microsecond=0).isoformat() # 其余情况直接转字符串。 return str(value or "") def build_mongo_client() -> tuple[Any | None, str]: # 没配置 URI 时直接报错。 if not MONGO_URI: return None, "missing MONGO_URI in .env" try: # 运行时导入 PyMongo,避免缺依赖时整个服务起不来。 from pymongo import MongoClient except Exception as exc: # noqa: BLE001 # 依赖不存在时返回错误文本。 return None, f"pymongo unavailable: {exc}" # 创建 MongoClient。 client = MongoClient( MONGO_URI, appname="hy-app-monitor", connectTimeoutMS=MONGO_CONNECT_TIMEOUT_MS, serverSelectionTimeoutMS=MONGO_SERVER_SELECTION_TIMEOUT_MS, socketTimeoutMS=MONGO_SOCKET_TIMEOUT_MS, ) # 返回连接对象。 return client, "" def safe_get(mapping: dict[str, Any], key: str, default: float = 0.0) -> float: # 读取数值型字段。 value = mapping.get(key, default) try: # 尽量转成 float 返回。 return float(value) except Exception: # noqa: BLE001 # 转换失败时回退默认值。 return default def build_replica_payload(client: Any) -> dict[str, Any]: try: # 读取复制集状态。 payload = client.admin.command("replSetGetStatus") except Exception as exc: # noqa: BLE001 # 未配置复制集或权限不足时给出降级信息。 return { "configured": False, "error": str(exc), "name": "", "members": [], "summary": {"memberTotal": 0, "healthyMemberTotal": 0, "primary": ""}, } # 原始成员列表。 members = payload.get("members") or [] # 尝试定位 primary 成员。 primary_member = next((item for item in members if str(item.get("stateStr") or "") == "PRIMARY"), None) # 拿到 primary optime 时间。 primary_optime = primary_member.get("optimeDate") if primary_member else None # 归一化后的成员列表。 normalized_members: list[dict[str, Any]] = [] # 健康成员数量。 healthy_member_total = 0 # 逐个成员整理结构。 for item in members: # 当前成员 optime 时间。 optime_date = item.get("optimeDate") # 默认 lag 为空。 lag_seconds: int | None = None # 主节点和当前成员都有 optime 时间时计算 lag。 if isinstance(primary_optime, datetime) and isinstance(optime_date, datetime): lag_seconds = max(int((primary_optime - optime_date).total_seconds()), 0) # 成员 health 为 1 视为健康。 healthy = float(item.get("health") or 0) >= 1.0 # 统计健康成员数。 if healthy: healthy_member_total += 1 # 追加成员结构。 normalized_members.append( { "name": str(item.get("name") or "").strip(), "state": int(item.get("state") or 0), "stateStr": str(item.get("stateStr") or "").strip(), "health": float(item.get("health") or 0.0), "uptime": int(item.get("uptime") or 0), "optimeDate": iso_datetime(optime_date), "lagSeconds": lag_seconds, "self": bool(item.get("self")), } ) # 返回复制集结构。 return { "configured": True, "error": "", "name": str(payload.get("set") or "").strip(), "members": normalized_members, "summary": { "memberTotal": len(normalized_members), "healthyMemberTotal": healthy_member_total, "primary": str(primary_member.get("name") or "").strip() if primary_member else "", }, } def build_database_payloads(client: Any, databases: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any]]: # 所有数据库的详情列表。 payloads: list[dict[str, Any]] = [] # 汇总统计。 summary = { "databaseTotal": 0, "collectionTotal": 0, "objectTotal": 0, "storageSizeMb": 0.0, "dataSizeMb": 0.0, } # 逐个数据库补充 dbStats。 for item in databases: # 读取数据库名。 name = str(item.get("name") or "").strip() # 没名字时跳过。 if not name: continue try: # 获取 dbStats,并把 scale 设成 1,方便后端自己换算。 stats = client[name].command("dbStats", 1, scale=1) except Exception as exc: # noqa: BLE001 # 获取失败时仍保留基础信息。 payloads.append( { "name": name, "empty": bool(item.get("empty")), "sizeOnDiskMb": round(safe_get(item, "sizeOnDisk") / 1024 / 1024, 2), "error": str(exc), } ) continue # 读取 collection 总数。 collections = int(stats.get("collections") or 0) # 读取 object 总数。 objects = int(stats.get("objects") or 0) # 读取 dataSize。 data_size_mb = round(safe_get(stats, "dataSize") / 1024 / 1024, 2) # 读取 storageSize。 storage_size_mb = round(safe_get(stats, "storageSize") / 1024 / 1024, 2) # 读取 index 数。 indexes = int(stats.get("indexes") or 0) # 读取 indexSize。 index_size_mb = round(safe_get(stats, "indexSize") / 1024 / 1024, 2) # 读取 avgObjSize。 avg_obj_size_b = round(safe_get(stats, "avgObjSize"), 2) # 汇总数据库级统计。 summary["databaseTotal"] += 1 summary["collectionTotal"] += collections summary["objectTotal"] += objects summary["storageSizeMb"] += storage_size_mb summary["dataSizeMb"] += data_size_mb # 追加当前数据库详情。 payloads.append( { "name": name, "empty": bool(item.get("empty")), "collections": collections, "views": int(stats.get("views") or 0), "objects": objects, "dataSizeMb": data_size_mb, "storageSizeMb": storage_size_mb, "indexCount": indexes, "indexSizeMb": index_size_mb, "avgObjSizeBytes": avg_obj_size_b, "sizeOnDiskMb": round(safe_get(item, "sizeOnDisk") / 1024 / 1024, 2), } ) # 数据库列表按 storageSize 倒序显示。 payloads.sort(key=lambda item: float(item.get("storageSizeMb") or 0.0), reverse=True) # 汇总值保留两位小数。 summary["storageSizeMb"] = round(summary["storageSizeMb"], 2) summary["dataSizeMb"] = round(summary["dataSizeMb"], 2) # 返回数据库列表和汇总信息。 return payloads, summary def build_mongo_payload() -> dict[str, Any]: # 先构造客户端。 client, error = build_mongo_client() # 客户端不可用时直接返回错误结构。 if client is None: return mongo_error_payload(error) try: # 先做一次 ping,保证连接可用。 client.admin.command("ping") # 读取 serverStatus,全局指标都在这里。 server_status = client.admin.command("serverStatus", recordStats=0) # 读取数据库列表。 database_list = client.admin.command("listDatabases", 1) # 读取复制集状态。 replica_payload = build_replica_payload(client) # 拿到数据库项数组。 database_items = list(database_list.get("databases") or []) # 构建数据库详情和汇总。 database_payloads, database_summary = build_database_payloads(client, database_items) # 读取连接数信息。 connections = server_status.get("connections") or {} # 读取内存信息。 memory = server_status.get("mem") or {} # 读取网络信息。 network = server_status.get("network") or {} # 读取操作计数器。 opcounters = server_status.get("opcounters") or {} # 读取 WiredTiger 指标。 wired_tiger = server_status.get("wiredTiger") or {} # 读取 cache 指标。 wired_tiger_cache = wired_tiger.get("cache") or {} # 组装最终返回结构。 return { "ok": True, "updatedAt": utc_now(), "summary": { **database_summary, "connectionCurrent": int(connections.get("current") or 0), "connectionAvailable": int(connections.get("available") or 0), }, "info": { "host": str(server_status.get("host") or "").strip(), "version": str(server_status.get("version") or "").strip(), "process": str(server_status.get("process") or "").strip(), "uptimeSeconds": int(server_status.get("uptime") or 0), "localTime": iso_datetime(server_status.get("localTime")), "focusedDatabase": MONGO_DB_NAME, }, "connections": { "current": int(connections.get("current") or 0), "available": int(connections.get("available") or 0), "totalCreated": int(connections.get("totalCreated") or 0), "active": int(connections.get("active") or 0), "threaded": int(connections.get("threaded") or 0), }, "memory": { "residentMb": safe_get(memory, "resident"), "virtualMb": safe_get(memory, "virtual"), "mappedMb": safe_get(memory, "mapped"), "mappedWithJournalMb": safe_get(memory, "mappedWithJournal"), "bits": int(memory.get("bits") or 0), }, "network": { "bytesIn": int(network.get("bytesIn") or 0), "bytesOut": int(network.get("bytesOut") or 0), "numRequests": int(network.get("numRequests") or 0), }, "opcounters": { "insert": int(opcounters.get("insert") or 0), "query": int(opcounters.get("query") or 0), "update": int(opcounters.get("update") or 0), "delete": int(opcounters.get("delete") or 0), "getmore": int(opcounters.get("getmore") or 0), "command": int(opcounters.get("command") or 0), }, "wiredTigerCache": { "bytesCurrentlyInCache": int(wired_tiger_cache.get("bytes currently in the cache") or 0), "trackedDirtyBytesInCache": int( wired_tiger_cache.get("tracked dirty bytes in the cache") or 0 ), "maximumBytesConfigured": int(wired_tiger_cache.get("maximum bytes configured") or 0), "pagesReadIntoCache": int(wired_tiger_cache.get("pages read into cache") or 0), "pagesWrittenFromCache": int(wired_tiger_cache.get("pages written from cache") or 0), }, "replicaSet": replica_payload, "databases": database_payloads, } except Exception as exc: # noqa: BLE001 # 任意异常都降级成统一错误结构。 return mongo_error_payload(str(exc)) finally: # 连接用完就关闭。 client.close() def get_mongo_payload() -> dict[str, Any]: # 使用缓存保护 Mongo 采集。 return MONGO_CACHE.get_or_build(build_mongo_payload)