hy-app-monitor/monitors/yumi/mongo_admin.py

880 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import json
from typing import Any
from core.config import MONGO_DB_NAME, utc_now
from .mongo_metrics import build_mongo_client
# 文档浏览默认每页 100 条,和页面默认值保持一致。
DEFAULT_PAGE_SIZE = 100
# 单次最多放开到 200 条,避免把浏览器一次撑爆。
MAX_PAGE_SIZE = 200
# 浏览页默认按 _id 倒序,和大多数运维排查习惯一致。
DEFAULT_SORT_SPEC = {"_id": -1}
# 列表里的 JSON 预览文本做一个上限,避免整行被超长文档拉坏。
PREVIEW_TEXT_LIMIT = 280
# Mongo 常见索引类型统一在这里收口,页面允许直接传这些值。
ALLOWED_INDEX_DIRECTIONS = {"text", "hashed", "2dsphere", "2d"}
def require_bson_json_util() -> Any:
try:
# PyMongo 安装后会自带 bson 包,这里延迟导入避免缺依赖时整个服务起不来。
from bson import json_util
except Exception as exc: # noqa: BLE001
# 管理页不可用时直接把依赖错误返回给上层。
raise RuntimeError(f"bson unavailable: {exc}") from exc
# 返回 bson 的扩展 JSON 工具。
return json_util
def get_mongo_client() -> Any:
# 统一复用现有 Mongo 建连逻辑,避免管理页和监控页配置口径分叉。
client, error = build_mongo_client()
# 建连失败时直接抛统一错误。
if client is None:
raise RuntimeError(error)
# 先做一次 ping尽早把连通性错误暴露出来。
client.admin.command("ping")
return client
def clamp_page_size(value: Any, default: int = DEFAULT_PAGE_SIZE) -> int:
try:
# 页面传来的值可能是字符串,这里统一转整数。
numeric = int(value)
except Exception: # noqa: BLE001
numeric = default
# 最终把页大小限制在一个安全区间内。
return max(1, min(numeric, MAX_PAGE_SIZE))
def clamp_page(value: Any, default: int = 1) -> int:
try:
# 页码同样允许前端传字符串。
numeric = int(value)
except Exception: # noqa: BLE001
numeric = default
# 页码至少从 1 开始。
return max(1, numeric)
def require_name(value: Any, field_name: str) -> str:
# 数据库名、集合名、索引名都按最基础的字符串校验处理。
name = str(value or "").strip()
# 名称缺失时直接报错。
if not name:
raise ValueError(f"{field_name} is required")
# Mongo 名称里不允许带空字节。
if "\x00" in name:
raise ValueError(f"{field_name} contains invalid character")
return name
def ensure_mutable_collection_name(name: str) -> str:
collection_name = require_name(name, "collection")
# 系统集合默认不开放结构变更,避免误删系统元数据。
if collection_name.startswith("system."):
raise ValueError("system collections are read only in this page")
return collection_name
def parse_extended_json(value: Any, field_name: str, *, default: Any = None) -> Any:
# 空值时直接回退默认值,方便页面把空输入视作 {} 或 []。
if value is None:
return default
# 字符串输入按原文解析,避免前端先 stringify 再导致转义双重嵌套。
if isinstance(value, str):
text = value.strip()
if not text:
return default
else:
# 对象输入统一先转成标准 JSON 文本,再交给 bson 扩展解析。
text = json.dumps(value, ensure_ascii=False)
try:
return require_bson_json_util().loads(text)
except Exception as exc: # noqa: BLE001
raise ValueError(f"invalid {field_name}: {exc}") from exc
def dump_extended_json(value: Any, *, pretty: bool = False) -> str:
helper = require_bson_json_util()
options = getattr(helper, "RELAXED_JSON_OPTIONS", None)
# 列表页和编辑器都统一输出 relaxed extended JSON前后端回写更稳定。
return helper.dumps(
value,
ensure_ascii=False,
indent=2 if pretty else None,
json_options=options,
)
def to_builtin_json(value: Any) -> Any:
# 先转扩展 JSON再回到纯 Python 结构,这样 http_app 可以继续用标准 json.dumps。
return json.loads(dump_extended_json(value, pretty=False))
def compact_json_preview(value: Any, limit: int = PREVIEW_TEXT_LIMIT) -> str:
# 预览统一压成单行,避免表格高度失控。
compact = dump_extended_json(value, pretty=False).replace("\n", " ").strip()
if len(compact) <= limit:
return compact
return f"{compact[:limit]}..."
def mongo_document_row(document: dict[str, Any], *, page: int, position: int) -> dict[str, Any]:
# 单条文档既返回结构化内容,也返回编辑器直接可用的 JSON 文本。
document_json = dump_extended_json(document, pretty=True)
document_id = document.get("_id")
return {
"key": f"{page}-{position}-{compact_json_preview(document_id, limit=64)}",
"idText": compact_json_preview(document_id, limit=72),
"idFilterText": dump_extended_json({"_id": document_id}, pretty=True),
"previewText": compact_json_preview(document),
"jsonText": document_json,
"document": to_builtin_json(document),
}
def normalize_sort_spec(sort_value: Any) -> list[tuple[str, int]]:
parsed = parse_extended_json(sort_value, "sort", default=DEFAULT_SORT_SPEC)
# 浏览页主路径只支持对象排序,界面也按这个格式产出。
if not isinstance(parsed, dict):
raise ValueError("sort must be a json object")
items: list[tuple[str, int]] = []
for field_name, direction in parsed.items():
name = str(field_name or "").strip()
if not name:
continue
try:
numeric_direction = int(direction)
except Exception as exc: # noqa: BLE001
raise ValueError(f"invalid sort direction for {name}") from exc
if numeric_direction not in {-1, 1}:
raise ValueError(f"sort direction for {name} must be 1 or -1")
items.append((name, numeric_direction))
# 排序对象全空时回退默认值。
return items or list(DEFAULT_SORT_SPEC.items())
def normalize_index_keys(keys_value: Any) -> list[tuple[str, Any]]:
parsed = parse_extended_json(keys_value, "keys", default={})
# 索引创建页同样只接受对象格式。
if not isinstance(parsed, dict):
raise ValueError("keys must be a json object")
keys: list[tuple[str, Any]] = []
for field_name, direction in parsed.items():
name = str(field_name or "").strip()
if not name:
continue
if isinstance(direction, str):
normalized_direction = direction.strip().lower()
if normalized_direction in ALLOWED_INDEX_DIRECTIONS:
keys.append((name, normalized_direction))
continue
try:
numeric_direction = int(normalized_direction)
except Exception as exc: # noqa: BLE001
raise ValueError(
f"index direction for {name} must be 1, -1, text, hashed, 2dsphere or 2d",
) from exc
else:
try:
numeric_direction = int(direction)
except Exception as exc: # noqa: BLE001
raise ValueError(
f"index direction for {name} must be 1, -1, text, hashed, 2dsphere or 2d",
) from exc
if numeric_direction not in {-1, 1}:
raise ValueError(f"index direction for {name} must be 1, -1, text, hashed, 2dsphere or 2d")
keys.append((name, numeric_direction))
if not keys:
raise ValueError("keys must not be empty")
return keys
def require_json_object(value: Any, field_name: str, *, default: dict[str, Any] | None = None) -> dict[str, Any]:
parsed = parse_extended_json(value, field_name, default=default)
if parsed is None:
return {}
# filter / projection / options / replacement 都要求对象。
if not isinstance(parsed, dict):
raise ValueError(f"{field_name} must be a json object")
return parsed
def require_json_array(value: Any, field_name: str, *, default: list[Any] | None = None) -> list[Any]:
parsed = parse_extended_json(value, field_name, default=default)
if parsed is None:
return []
# aggregation pipeline 明确要求数组。
if not isinstance(parsed, list):
raise ValueError(f"{field_name} must be a json array")
return parsed
def require_insert_documents(value: Any) -> tuple[list[dict[str, Any]], bool]:
# Mongo 写入既支持单文档,也支持批量数组。
parsed = parse_extended_json(value, "document", default={})
if isinstance(parsed, dict):
return [parsed], False
if not isinstance(parsed, list):
raise ValueError("document must be a json object or json array")
if not parsed:
raise ValueError("document array must not be empty")
documents: list[dict[str, Any]] = []
for index, item in enumerate(parsed, start=1):
if not isinstance(item, dict):
raise ValueError(f"document[{index}] must be a json object")
documents.append(item)
return documents, True
def ensure_safe_aggregate_pipeline(pipeline: list[Any]) -> list[Any]:
for stage in pipeline:
# 每个 stage 都必须是对象,否则驱动会直接报错,这里提前统一提示。
if not isinstance(stage, dict):
raise ValueError("aggregate pipeline stage must be a json object")
# 当前页面不开放会落地写数据的聚合阶段。
stage_names = {str(key or "") for key in stage.keys()}
if "$out" in stage_names or "$merge" in stage_names:
raise ValueError("aggregate pipeline does not allow $out or $merge")
return pipeline
def collection_handle(client: Any, database_name: str, collection_name: str) -> Any:
# 集合句柄统一从这里拿,保证数据库名和集合名都先校验。
return client[require_name(database_name, "database")][require_name(collection_name, "collection")]
def collection_stats_payload(client: Any, database_name: str, collection_name: str) -> dict[str, Any]:
database = client[require_name(database_name, "database")]
collection = require_name(collection_name, "collection")
# collStats 能拿到集合大小、文档数、索引数等管理页最常用信息。
stats = database.command("collStats", collection)
index_list = list(database[collection].list_indexes())
indexes = []
for item in index_list:
key_spec = dict(item.get("key") or {})
indexes.append(
{
"name": str(item.get("name") or "").strip(),
"keys": to_builtin_json(key_spec),
"keysText": dump_extended_json(key_spec, pretty=True),
"unique": bool(item.get("unique")),
"sparse": bool(item.get("sparse")),
"hidden": bool(item.get("hidden")),
"expireAfterSeconds": item.get("expireAfterSeconds"),
"partialFilterExpression": to_builtin_json(item.get("partialFilterExpression"))
if item.get("partialFilterExpression") is not None
else None,
"partialFilterExpressionText": dump_extended_json(item.get("partialFilterExpression"), pretty=True)
if item.get("partialFilterExpression") is not None
else "",
}
)
# 把 _id_ 放在第一行,其他索引按名称排序。
indexes.sort(key=lambda item: (0 if item["name"] == "_id_" else 1, item["name"]))
return {
"database": database_name,
"collection": collection_name,
"updatedAt": utc_now(),
"stats": {
"count": int(stats.get("count") or 0),
"sizeMb": round(float(stats.get("size") or 0) / 1024 / 1024, 2),
"storageSizeMb": round(float(stats.get("storageSize") or 0) / 1024 / 1024, 2),
"avgObjSizeBytes": round(float(stats.get("avgObjSize") or 0), 2),
"indexCount": int(stats.get("nindexes") or 0),
"totalIndexSizeMb": round(float(stats.get("totalIndexSize") or 0) / 1024 / 1024, 2),
"capped": bool(stats.get("capped")),
"wiredTigerUri": str((stats.get("wiredTiger") or {}).get("uri") or "").strip(),
},
"indexes": indexes,
}
def get_mongo_admin_overview_payload() -> dict[str, Any]:
client = get_mongo_client()
try:
# 概览页既要列数据库,也要列集合,所以这里一次性把树形结构拼完。
databases_raw = list((client.admin.command("listDatabases", 1).get("databases") or []))
overview_items: list[dict[str, Any]] = []
for database_item in databases_raw:
database_name = str(database_item.get("name") or "").strip()
if not database_name:
continue
database = client[database_name]
collection_items: list[dict[str, Any]] = []
# listCollections 先拿到集合名和类型,再按需补充 stats。
for collection_info in database.list_collections():
collection_name = str(collection_info.get("name") or "").strip()
if not collection_name:
continue
try:
collection_stats = database.command("collStats", collection_name)
collection_items.append(
{
"name": collection_name,
"type": str(collection_info.get("type") or "collection").strip() or "collection",
"documentCount": int(collection_stats.get("count") or 0),
"sizeMb": round(float(collection_stats.get("size") or 0) / 1024 / 1024, 2),
"storageSizeMb": round(
float(collection_stats.get("storageSize") or 0) / 1024 / 1024,
2,
),
"indexCount": int(collection_stats.get("nindexes") or 0),
"capped": bool(collection_stats.get("capped")),
"error": "",
}
)
except Exception as exc: # noqa: BLE001
# 某个集合取 stats 失败时仍保留在树里,方便继续点进去排查。
collection_items.append(
{
"name": collection_name,
"type": str(collection_info.get("type") or "collection").strip() or "collection",
"documentCount": 0,
"sizeMb": 0.0,
"storageSizeMb": 0.0,
"indexCount": 0,
"capped": False,
"error": str(exc),
}
)
# 集合列表默认按名字排序,侧边 explorer 更稳定。
collection_items.sort(key=lambda item: item["name"].lower())
overview_items.append(
{
"name": database_name,
"empty": bool(database_item.get("empty")),
"sizeOnDiskMb": round(float(database_item.get("sizeOnDisk") or 0) / 1024 / 1024, 2),
"collectionCount": len(collection_items),
"collections": collection_items,
}
)
# 聚焦库优先,其余按磁盘占用倒序,和现有 Mongo 指标页一致。
overview_items.sort(
key=lambda item: (
0 if item["name"] == MONGO_DB_NAME else 1,
-float(item.get("sizeOnDiskMb") or 0.0),
item["name"].lower(),
)
)
default_database = next((item["name"] for item in overview_items if item["name"] == MONGO_DB_NAME), "")
if not default_database and overview_items:
default_database = overview_items[0]["name"]
default_collection = ""
if default_database:
default_database_item = next((item for item in overview_items if item["name"] == default_database), None)
if default_database_item and default_database_item["collections"]:
default_collection = default_database_item["collections"][0]["name"]
return {
"ok": True,
"updatedAt": utc_now(),
"focusedDatabase": MONGO_DB_NAME,
"selectedDatabase": default_database,
"selectedCollection": default_collection,
"items": overview_items,
}
finally:
# 管理页操作完成后立即关连接,避免长期挂着无用游标。
client.close()
def get_mongo_collection_detail_payload(database_name: str, collection_name: str) -> dict[str, Any]:
client = get_mongo_client()
try:
return collection_stats_payload(client, database_name, collection_name)
finally:
client.close()
def query_mongo_documents_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = require_name(payload.get("collection"), "collection")
page = clamp_page(payload.get("page"), default=1)
page_size = clamp_page_size(payload.get("pageSize"), default=DEFAULT_PAGE_SIZE)
filter_spec = require_json_object(payload.get("filter"), "filter", default={})
projection_spec = require_json_object(payload.get("projection"), "projection", default={})
sort_spec = normalize_sort_spec(payload.get("sort"))
# 页面上的编辑和删除都依赖 _id所以这里强制保留。
if projection_spec.get("_id") == 0:
projection_spec = dict(projection_spec)
projection_spec.pop("_id", None)
client = get_mongo_client()
try:
collection = collection_handle(client, database_name, collection_name)
total = collection.count_documents(filter_spec)
skip = max(page - 1, 0) * page_size
cursor = collection.find(filter_spec, projection_spec or None).sort(sort_spec).skip(skip).limit(page_size)
documents = list(cursor)
rows = [
mongo_document_row(document, page=page, position=skip + index)
for index, document in enumerate(documents, start=1)
]
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": collection_name,
"page": page,
"pageSize": page_size,
"total": total,
"pageCount": max((total + page_size - 1) // page_size, 1),
"filterText": dump_extended_json(filter_spec, pretty=True),
"projectionText": dump_extended_json(projection_spec, pretty=True),
"sortText": dump_extended_json(dict(sort_spec), pretty=True),
"items": rows,
}
finally:
client.close()
def insert_mongo_document_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = ensure_mutable_collection_name(payload.get("collection"))
documents, many = require_insert_documents(payload.get("document"))
ordered = bool(payload.get("ordered", True))
client = get_mongo_client()
try:
collection = collection_handle(client, database_name, collection_name)
if many:
result = collection.insert_many(documents, ordered=ordered)
inserted_ids = [to_builtin_json(inserted_id) for inserted_id in result.inserted_ids]
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": collection_name,
"insertedCount": len(inserted_ids),
"insertedIds": inserted_ids,
"message": "insertMany completed",
}
result = collection.insert_one(documents[0])
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": collection_name,
"insertedCount": 1,
"insertedId": to_builtin_json(result.inserted_id),
"insertedIdText": dump_extended_json(result.inserted_id, pretty=False),
"message": "insertOne completed",
}
finally:
client.close()
def replace_mongo_document_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = ensure_mutable_collection_name(payload.get("collection"))
filter_spec = require_json_object(payload.get("filter"), "filter", default={})
replacement = require_json_object(payload.get("document"), "document")
upsert = bool(payload.get("upsert"))
# replaceOne 只接受完整文档,不能混进 $set 这类更新操作符。
if any(str(key).startswith("$") for key in replacement.keys()):
raise ValueError("replace document must not contain update operators")
client = get_mongo_client()
try:
result = collection_handle(client, database_name, collection_name).replace_one(
filter_spec,
replacement,
upsert=upsert,
)
return {
"ok": True,
"updatedAt": utc_now(),
"matchedCount": int(result.matched_count),
"modifiedCount": int(result.modified_count),
"upsertedId": to_builtin_json(result.upserted_id) if result.upserted_id is not None else None,
"message": "replaceOne completed",
}
finally:
client.close()
def update_mongo_documents_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = ensure_mutable_collection_name(payload.get("collection"))
filter_spec = require_json_object(payload.get("filter"), "filter", default={})
update_spec = parse_extended_json(payload.get("update"), "update", default={})
many = bool(payload.get("many"))
upsert = bool(payload.get("upsert"))
allow_empty_filter = bool(payload.get("allowEmptyFilter"))
# 空 filter + 多文档更新风险过高,前端必须显式确认。
if many and not filter_spec and not allow_empty_filter:
raise ValueError("empty filter requires explicit confirmation")
# update 支持操作符对象或更新管道数组。
if isinstance(update_spec, dict):
if not update_spec:
raise ValueError("update must not be empty")
if not any(str(key).startswith("$") for key in update_spec.keys()):
raise ValueError("update must contain update operators or use replace")
elif isinstance(update_spec, list):
ensure_safe_aggregate_pipeline(update_spec)
else:
raise ValueError("update must be a json object or json array")
client = get_mongo_client()
try:
collection = collection_handle(client, database_name, collection_name)
result = (
collection.update_many(filter_spec, update_spec, upsert=upsert)
if many
else collection.update_one(filter_spec, update_spec, upsert=upsert)
)
return {
"ok": True,
"updatedAt": utc_now(),
"matchedCount": int(result.matched_count),
"modifiedCount": int(result.modified_count),
"upsertedId": to_builtin_json(result.upserted_id) if result.upserted_id is not None else None,
"message": "updateMany completed" if many else "updateOne completed",
}
finally:
client.close()
def delete_mongo_documents_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = ensure_mutable_collection_name(payload.get("collection"))
filter_spec = require_json_object(payload.get("filter"), "filter", default={})
many = bool(payload.get("many"))
allow_empty_filter = bool(payload.get("allowEmptyFilter"))
# deleteMany 空 filter 同样要求显式确认。
if many and not filter_spec and not allow_empty_filter:
raise ValueError("empty filter requires explicit confirmation")
client = get_mongo_client()
try:
collection = collection_handle(client, database_name, collection_name)
result = collection.delete_many(filter_spec) if many else collection.delete_one(filter_spec)
return {
"ok": True,
"updatedAt": utc_now(),
"deletedCount": int(result.deleted_count),
"message": "deleteMany completed" if many else "deleteOne completed",
}
finally:
client.close()
def aggregate_mongo_documents_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = require_name(payload.get("collection"), "collection")
page = clamp_page(payload.get("page"), default=1)
page_size = clamp_page_size(payload.get("pageSize"), default=DEFAULT_PAGE_SIZE)
pipeline = ensure_safe_aggregate_pipeline(require_json_array(payload.get("pipeline"), "pipeline", default=[]))
client = get_mongo_client()
try:
collection = collection_handle(client, database_name, collection_name)
skip = max(page - 1, 0) * page_size
# 先跑计数管道,再跑当前页数据。
total_result = list(collection.aggregate([*pipeline, {"$count": "total"}]))
total = int((total_result[0] or {}).get("total") or 0) if total_result else 0
documents = list(collection.aggregate([*pipeline, {"$skip": skip}, {"$limit": page_size}]))
rows = [
{
"key": f"agg-{page}-{skip + index}",
"idText": compact_json_preview(document.get("_id"), limit=72) if "_id" in document else "-",
"idFilterText": dump_extended_json({"_id": document.get("_id")}, pretty=True)
if "_id" in document
else "{}",
"previewText": compact_json_preview(document),
"jsonText": dump_extended_json(document, pretty=True),
"document": to_builtin_json(document),
}
for index, document in enumerate(documents, start=1)
]
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": collection_name,
"page": page,
"pageSize": page_size,
"total": total,
"pageCount": max((total + page_size - 1) // page_size, 1),
"pipelineText": dump_extended_json(pipeline, pretty=True),
"items": rows,
}
finally:
client.close()
def distinct_mongo_values_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = require_name(payload.get("collection"), "collection")
field_name = require_name(payload.get("field"), "field")
filter_spec = require_json_object(payload.get("filter"), "filter", default={})
client = get_mongo_client()
try:
values = collection_handle(client, database_name, collection_name).distinct(field_name, filter_spec)
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": collection_name,
"field": field_name,
"filterText": dump_extended_json(filter_spec, pretty=True),
"count": len(values),
"values": to_builtin_json(values),
"valuesText": dump_extended_json(values, pretty=True),
}
finally:
client.close()
def create_mongo_index_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = ensure_mutable_collection_name(payload.get("collection"))
index_keys = normalize_index_keys(payload.get("keys"))
index_options = require_json_object(payload.get("options"), "options", default={})
# name 允许前端留空,驱动会自动生成。
if not str(index_options.get("name") or "").strip():
index_options.pop("name", None)
client = get_mongo_client()
try:
index_name = collection_handle(client, database_name, collection_name).create_index(index_keys, **index_options)
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": collection_name,
"indexName": str(index_name),
"message": "createIndex completed",
}
finally:
client.close()
def drop_mongo_index_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = ensure_mutable_collection_name(payload.get("collection"))
index_name = require_name(payload.get("indexName"), "indexName")
client = get_mongo_client()
try:
collection_handle(client, database_name, collection_name).drop_index(index_name)
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": collection_name,
"indexName": index_name,
"message": "dropIndex completed",
}
finally:
client.close()
def drop_mongo_secondary_indexes_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = ensure_mutable_collection_name(payload.get("collection"))
client = get_mongo_client()
try:
collection = collection_handle(client, database_name, collection_name)
index_names = [
str(item.get("name") or "").strip()
for item in collection.list_indexes()
if str(item.get("name") or "").strip() and str(item.get("name") or "").strip() != "_id_"
]
# Mongo 的 drop_indexes 会保留 _id_ 索引,这里先把删除名单回给前端方便确认结果。
if not index_names:
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": collection_name,
"droppedCount": 0,
"droppedNames": [],
"message": "no secondary indexes",
}
collection.drop_indexes()
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": collection_name,
"droppedCount": len(index_names),
"droppedNames": index_names,
"message": "dropIndexes completed",
}
finally:
client.close()
def create_mongo_collection_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = ensure_mutable_collection_name(payload.get("collection"))
options = require_json_object(payload.get("options"), "options", default={})
client = get_mongo_client()
try:
client[database_name].create_collection(collection_name, **options)
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": collection_name,
"message": "createCollection completed",
}
finally:
client.close()
def rename_mongo_collection_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
source_name = ensure_mutable_collection_name(payload.get("collection"))
target_name = ensure_mutable_collection_name(payload.get("targetCollection"))
drop_target = bool(payload.get("dropTarget"))
client = get_mongo_client()
try:
collection_handle(client, database_name, source_name).rename(target_name, dropTarget=drop_target)
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": source_name,
"targetCollection": target_name,
"message": "renameCollection completed",
}
finally:
client.close()
def drop_mongo_collection_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
collection_name = ensure_mutable_collection_name(payload.get("collection"))
confirm_name = require_name(payload.get("confirmName"), "confirmName")
# 删除集合前要求前端把目标名再传一遍,减少误触。
if confirm_name != collection_name:
raise ValueError("confirmName does not match collection")
client = get_mongo_client()
try:
collection_handle(client, database_name, collection_name).drop()
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"collection": collection_name,
"message": "dropCollection completed",
}
finally:
client.close()
def drop_mongo_database_payload(payload: dict[str, Any]) -> dict[str, Any]:
database_name = require_name(payload.get("database"), "database")
confirm_name = require_name(payload.get("confirmName"), "confirmName")
# 删除数据库同样要求二次校验目标名。
if confirm_name != database_name:
raise ValueError("confirmName does not match database")
client = get_mongo_client()
try:
client.drop_database(database_name)
return {
"ok": True,
"updatedAt": utc_now(),
"database": database_name,
"message": "dropDatabase completed",
}
finally:
client.close()