修复mysql
This commit is contained in:
parent
47ba1c8a76
commit
4f3b55468f
@ -6,6 +6,7 @@
|
||||
- 登录入口:`http://43.164.75.199:2026/login`
|
||||
- 入口选择页:`http://43.164.75.199:2026/select`
|
||||
- Yumi 入口:`http://43.164.75.199:2026/yumi/`
|
||||
- MySQL 入口:`http://43.164.75.199:2026/mysql/`
|
||||
- HaiYi 入口:`http://43.164.75.199:2026/haiyi/`
|
||||
- 内部实例:`gateway:2026`、`yumi:2027`、`haiyi:2028`
|
||||
- 服务健康:直接探测各业务服务健康接口
|
||||
|
||||
@ -8,7 +8,7 @@ import urllib.request
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
|
||||
from core.auth import (
|
||||
build_clear_session_cookie,
|
||||
@ -32,7 +32,7 @@ from core.config import (
|
||||
YUMI_INTERNAL_PORT,
|
||||
utc_now,
|
||||
)
|
||||
from core.http_paths import login_redirect_target
|
||||
from core.http_paths import join_base_path, login_redirect_target, strip_base_path
|
||||
|
||||
|
||||
def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
|
||||
@ -40,6 +40,51 @@ def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
|
||||
return (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
APP_PROXY_TARGETS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"app": "yumi",
|
||||
"basePath": "/yumi",
|
||||
"upstreamBasePath": "/yumi",
|
||||
"host": YUMI_INTERNAL_HOST,
|
||||
"port": YUMI_INTERNAL_PORT,
|
||||
},
|
||||
{
|
||||
"app": "mysql",
|
||||
"basePath": "/mysql",
|
||||
"upstreamBasePath": "/yumi",
|
||||
"host": YUMI_INTERNAL_HOST,
|
||||
"port": YUMI_INTERNAL_PORT,
|
||||
},
|
||||
{
|
||||
"app": "haiyi",
|
||||
"basePath": "/haiyi",
|
||||
"upstreamBasePath": "/haiyi",
|
||||
"host": HAIYI_INTERNAL_HOST,
|
||||
"port": HAIYI_INTERNAL_PORT,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def match_proxy_target(path: str) -> dict[str, Any] | None:
|
||||
# 网关入口固定收敛成少量前缀,MySQL 复用 Yumi 内部实例但暴露独立入口。
|
||||
for target in APP_PROXY_TARGETS:
|
||||
base_path = str(target["basePath"])
|
||||
if path == base_path or path.startswith(f"{base_path}/"):
|
||||
return dict(target)
|
||||
return None
|
||||
|
||||
|
||||
def rewrite_target_path(request_path: str, base_path: str, upstream_base_path: str) -> str:
|
||||
# 别名入口需要把外部前缀重写成内部实例真实挂载前缀。
|
||||
parsed = urlparse(request_path)
|
||||
local_path = strip_base_path(base_path, parsed.path)
|
||||
if local_path is None:
|
||||
return request_path
|
||||
|
||||
rewritten_path = join_base_path(upstream_base_path, local_path)
|
||||
return urlunparse(parsed._replace(path=rewritten_path))
|
||||
|
||||
|
||||
class GatewayHandler(BaseHTTPRequestHandler):
|
||||
# 单独标出这是统一入口网关。
|
||||
server_version = "HyAppMonitorGateway/2.0"
|
||||
@ -119,7 +164,7 @@ class GatewayHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
|
||||
# 子应用前缀下的认证状态统一由网关代答。
|
||||
if path in {"/yumi/api/auth/session", "/haiyi/api/auth/session"}:
|
||||
if path in {"/yumi/api/auth/session", "/haiyi/api/auth/session", "/mysql/api/auth/session"}:
|
||||
return self.handle_auth_session(send_body=send_body)
|
||||
|
||||
# favicon 直接返回空。
|
||||
@ -129,8 +174,11 @@ class GatewayHandler(BaseHTTPRequestHandler):
|
||||
# 子应用其余请求统一走透明反代。
|
||||
target = self.proxy_target(path)
|
||||
if target is not None:
|
||||
if path in {target["basePath"], f"{target['basePath']}/"} and not self.require_page_authentication(send_body=send_body):
|
||||
return
|
||||
if path in {target["basePath"], f"{target['basePath']}/"}:
|
||||
if not self.require_page_authentication(send_body=send_body):
|
||||
return
|
||||
if path == target["basePath"]:
|
||||
return self.redirect(f"{target['basePath']}/", send_body=send_body)
|
||||
|
||||
if not self.ensure_prefixed_authentication(path, send_body=send_body):
|
||||
return
|
||||
@ -144,7 +192,7 @@ class GatewayHandler(BaseHTTPRequestHandler):
|
||||
path = parsed.path
|
||||
|
||||
# 退出接口允许空请求体,先单独处理。
|
||||
if path in {"/api/auth/logout", "/yumi/api/auth/logout", "/haiyi/api/auth/logout"}:
|
||||
if path in {"/api/auth/logout", "/yumi/api/auth/logout", "/haiyi/api/auth/logout", "/mysql/api/auth/logout"}:
|
||||
return self.handle_logout()
|
||||
|
||||
# 登录接口只在根路径开放。
|
||||
@ -170,21 +218,7 @@ class GatewayHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def proxy_target(self, path: str) -> dict[str, Any] | None:
|
||||
# 按固定前缀把请求路由到对应内部实例。
|
||||
if path == "/yumi" or path.startswith("/yumi/"):
|
||||
return {
|
||||
"app": "yumi",
|
||||
"basePath": "/yumi",
|
||||
"host": YUMI_INTERNAL_HOST,
|
||||
"port": YUMI_INTERNAL_PORT,
|
||||
}
|
||||
if path == "/haiyi" or path.startswith("/haiyi/"):
|
||||
return {
|
||||
"app": "haiyi",
|
||||
"basePath": "/haiyi",
|
||||
"host": HAIYI_INTERNAL_HOST,
|
||||
"port": HAIYI_INTERNAL_PORT,
|
||||
}
|
||||
return None
|
||||
return match_proxy_target(path)
|
||||
|
||||
def ensure_prefixed_authentication(self, path: str, *, send_body: bool) -> bool:
|
||||
# 前缀下的业务页面和 API 都统一要求先登录。
|
||||
@ -299,8 +333,13 @@ class GatewayHandler(BaseHTTPRequestHandler):
|
||||
return "/select"
|
||||
|
||||
def proxy_request(self, target: dict[str, Any], *, send_body: bool, body: bytes | None = None) -> None:
|
||||
# 透传完整 path + query,保持前缀路径和下载地址都不变。
|
||||
url = f"http://{target['host']}:{target['port']}{self.path}"
|
||||
# 别名入口会先重写前缀,再转发到内部实例真实挂载路径。
|
||||
upstream_path = rewrite_target_path(
|
||||
self.path,
|
||||
str(target["basePath"]),
|
||||
str(target.get("upstreamBasePath") or target["basePath"]),
|
||||
)
|
||||
url = f"http://{target['host']}:{target['port']}{upstream_path}"
|
||||
headers = self.proxy_headers(target["basePath"])
|
||||
request = urllib.request.Request(url, data=body, method=self.command, headers=headers)
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ def mysql_error_payload(error: str) -> dict[str, Any]:
|
||||
"totalSizeMb": 0.0,
|
||||
},
|
||||
"databases": [],
|
||||
"warnings": [],
|
||||
"error": error,
|
||||
}
|
||||
|
||||
@ -117,6 +118,22 @@ def is_system_database(name: str) -> bool:
|
||||
return str(name or "").strip().lower() in SYSTEM_DATABASES
|
||||
|
||||
|
||||
def safe_int(value: Any) -> int:
|
||||
try:
|
||||
# SHOW STATUS / SHOW TABLE STATUS 这类命令常把数字返回成字符串。
|
||||
return int(value or 0)
|
||||
except Exception: # noqa: BLE001
|
||||
return 0
|
||||
|
||||
|
||||
def safe_float(value: Any) -> float:
|
||||
try:
|
||||
# 容量类字段统一按浮点数归一,便于后续换算 MiB。
|
||||
return float(value or 0.0)
|
||||
except Exception: # noqa: BLE001
|
||||
return 0.0
|
||||
|
||||
|
||||
def normalize_mysql_value(value: Any) -> Any:
|
||||
# Decimal 保留成字符串,避免金额精度被前端浮点化。
|
||||
if isinstance(value, Decimal):
|
||||
@ -153,6 +170,120 @@ def normalize_mysql_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: normalize_mysql_value(value) for key, value in row.items()}
|
||||
|
||||
|
||||
def row_first_value(row: Any) -> Any:
|
||||
# SHOW DATABASES / SHOW FULL TABLES 的列名会随库名变化,这里统一取第一列。
|
||||
if isinstance(row, dict):
|
||||
return next(iter(row.values()), None)
|
||||
return row
|
||||
|
||||
|
||||
def infer_connected_database(connection: Any) -> str:
|
||||
# 当账号拿不到 SHOW DATABASES 时,尽量退到显式配置或当前连接库名。
|
||||
for attribute_name in ("db", "database"):
|
||||
raw_value = getattr(connection, attribute_name, None)
|
||||
if isinstance(raw_value, (bytes, bytearray, memoryview)):
|
||||
raw_value = bytes(raw_value).decode("utf-8", errors="ignore")
|
||||
actual = str(raw_value or "").strip()
|
||||
if actual:
|
||||
return actual
|
||||
return ""
|
||||
|
||||
|
||||
def list_accessible_database_names(connection: Any) -> list[str]:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# 优先使用 SHOW DATABASES,让受限账号也能拿到自己可见的库列表。
|
||||
cursor.execute("SHOW DATABASES")
|
||||
rows = cursor.fetchall() or []
|
||||
except Exception: # noqa: BLE001
|
||||
fallback_names = [name for name in {str(MYSQL_DEFAULT_DATABASE or "").strip(), infer_connected_database(connection)} if name]
|
||||
if not fallback_names:
|
||||
raise
|
||||
return sorted(
|
||||
fallback_names,
|
||||
key=lambda item: (1 if is_system_database(item) else 0, item.lower()),
|
||||
)
|
||||
|
||||
names: list[str] = []
|
||||
for item in rows:
|
||||
name = str(row_first_value(item) or "").strip()
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def list_accessible_table_names(connection: Any, database: str) -> list[str]:
|
||||
target_database = normalize_database_name(database)
|
||||
with connection.cursor() as cursor:
|
||||
# SHOW FULL TABLES 不依赖 information_schema,可兼容权限更窄的只读账号。
|
||||
cursor.execute(f"SHOW FULL TABLES FROM {quote_identifier(target_database)}")
|
||||
rows = cursor.fetchall() or []
|
||||
|
||||
names: list[str] = []
|
||||
for item in rows:
|
||||
table_name = str(row_first_value(item) or "").strip()
|
||||
if table_name and table_name not in names:
|
||||
names.append(table_name)
|
||||
return names
|
||||
|
||||
|
||||
def show_table_status_rows(connection: Any, database: str) -> list[dict[str, Any]]:
|
||||
target_database = normalize_database_name(database)
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# SHOW TABLE STATUS 能在不查 information_schema 的情况下返回行数和容量估算。
|
||||
cursor.execute(f"SHOW TABLE STATUS FROM {quote_identifier(target_database)}")
|
||||
return cursor.fetchall() or []
|
||||
|
||||
|
||||
def infer_mysql_data_type(column_type: Any) -> str:
|
||||
actual = str(column_type or "").strip().lower()
|
||||
if not actual:
|
||||
return ""
|
||||
return actual.split("(", 1)[0].split()[0]
|
||||
|
||||
|
||||
def sort_database_summaries(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
# 排序口径保持和 information_schema 查询一致:业务库优先,再按容量倒序。
|
||||
return sorted(
|
||||
items,
|
||||
key=lambda item: (
|
||||
1 if item.get("system") else 0,
|
||||
-safe_float(item.get("totalSizeMb")),
|
||||
str(item.get("name") or "").lower(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_database_summary_item(name: str, table_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
data_bytes = sum(safe_float(item.get("Data_length")) for item in table_rows)
|
||||
index_bytes = sum(safe_float(item.get("Index_length")) for item in table_rows)
|
||||
|
||||
return {
|
||||
"name": str(name or "").strip(),
|
||||
"system": is_system_database(name),
|
||||
"tableTotal": len(table_rows),
|
||||
"rowEstimate": sum(safe_int(item.get("Rows")) for item in table_rows),
|
||||
"dataSizeMb": round(data_bytes / 1024 / 1024, 2),
|
||||
"indexSizeMb": round(index_bytes / 1024 / 1024, 2),
|
||||
"totalSizeMb": round((data_bytes + index_bytes) / 1024 / 1024, 2),
|
||||
}
|
||||
|
||||
|
||||
def list_database_summaries_via_show(connection: Any) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
# 逐个库读取 SHOW TABLE STATUS,即使单个库失败也不影响其余库展示。
|
||||
for database_name in list_accessible_database_names(connection):
|
||||
try:
|
||||
table_rows = show_table_status_rows(connection, database_name)
|
||||
except Exception: # noqa: BLE001
|
||||
table_rows = []
|
||||
items.append(build_database_summary_item(database_name, table_rows))
|
||||
|
||||
return sort_database_summaries(items)
|
||||
|
||||
|
||||
def list_database_summaries(connection: Any) -> list[dict[str, Any]]:
|
||||
# 数据库级别汇总统一从 information_schema.tables 读取,能同时拿到表数和容量。
|
||||
query = """
|
||||
@ -183,27 +314,31 @@ def list_database_summaries(connection: Any) -> list[dict[str, Any]]:
|
||||
s.SCHEMA_NAME ASC
|
||||
"""
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# 直接读取全部库级摘要,前端 sidebar 需要完整列表。
|
||||
cursor.execute(query)
|
||||
rows = cursor.fetchall() or []
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# 直接读取全部库级摘要,前端 sidebar 需要完整列表。
|
||||
cursor.execute(query)
|
||||
rows = cursor.fetchall() or []
|
||||
except Exception: # noqa: BLE001
|
||||
# 只读账号常见是 information_schema 可见但全局视图受限,这里退到 SHOW 系列命令。
|
||||
return list_database_summaries_via_show(connection)
|
||||
|
||||
# 把字节数转换成 MiB,并附带是否系统库的标记。
|
||||
return [
|
||||
return sort_database_summaries([
|
||||
{
|
||||
"name": str(item.get("schemaName") or "").strip(),
|
||||
"system": is_system_database(item.get("schemaName")),
|
||||
"tableTotal": int(item.get("tableTotal") or 0),
|
||||
"rowEstimate": int(item.get("rowEstimate") or 0),
|
||||
"dataSizeMb": round(float(item.get("dataBytes") or 0) / 1024 / 1024, 2),
|
||||
"indexSizeMb": round(float(item.get("indexBytes") or 0) / 1024 / 1024, 2),
|
||||
"tableTotal": safe_int(item.get("tableTotal")),
|
||||
"rowEstimate": safe_int(item.get("rowEstimate")),
|
||||
"dataSizeMb": round(safe_float(item.get("dataBytes")) / 1024 / 1024, 2),
|
||||
"indexSizeMb": round(safe_float(item.get("indexBytes")) / 1024 / 1024, 2),
|
||||
"totalSizeMb": round(
|
||||
(float(item.get("dataBytes") or 0) + float(item.get("indexBytes") or 0)) / 1024 / 1024,
|
||||
(safe_float(item.get("dataBytes")) + safe_float(item.get("indexBytes"))) / 1024 / 1024,
|
||||
2,
|
||||
),
|
||||
}
|
||||
for item in rows
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def list_table_summaries(connection: Any, database: str) -> list[dict[str, Any]]:
|
||||
@ -226,20 +361,41 @@ def list_table_summaries(connection: Any, database: str) -> list[dict[str, Any]]
|
||||
ORDER BY COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0) DESC, TABLE_NAME ASC
|
||||
"""
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# 同一库内表数量通常有限,这里直接全量读取。
|
||||
cursor.execute(query, (target_database,))
|
||||
rows = cursor.fetchall() or []
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# 同一库内表数量通常有限,这里直接全量读取。
|
||||
cursor.execute(query, (target_database,))
|
||||
rows = cursor.fetchall() or []
|
||||
except Exception: # noqa: BLE001
|
||||
rows = show_table_status_rows(connection, target_database)
|
||||
return [
|
||||
{
|
||||
"name": str(item.get("Name") or "").strip(),
|
||||
"engine": str(item.get("Engine") or "").strip(),
|
||||
"rowEstimate": safe_int(item.get("Rows")),
|
||||
"dataSizeMb": round(safe_float(item.get("Data_length")) / 1024 / 1024, 2),
|
||||
"indexSizeMb": round(safe_float(item.get("Index_length")) / 1024 / 1024, 2),
|
||||
"totalSizeMb": round(
|
||||
(safe_float(item.get("Data_length")) + safe_float(item.get("Index_length"))) / 1024 / 1024,
|
||||
2,
|
||||
),
|
||||
"autoIncrement": item.get("Auto_increment"),
|
||||
"createTime": normalize_mysql_value(item.get("Create_time")),
|
||||
"updateTime": normalize_mysql_value(item.get("Update_time")),
|
||||
"collation": str(item.get("Collation") or "").strip(),
|
||||
}
|
||||
for item in rows
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
"name": str(item.get("tableName") or "").strip(),
|
||||
"engine": str(item.get("engine") or "").strip(),
|
||||
"rowEstimate": int(item.get("rowEstimate") or 0),
|
||||
"dataSizeMb": round(float(item.get("dataBytes") or 0) / 1024 / 1024, 2),
|
||||
"indexSizeMb": round(float(item.get("indexBytes") or 0) / 1024 / 1024, 2),
|
||||
"rowEstimate": safe_int(item.get("rowEstimate")),
|
||||
"dataSizeMb": round(safe_float(item.get("dataBytes")) / 1024 / 1024, 2),
|
||||
"indexSizeMb": round(safe_float(item.get("indexBytes")) / 1024 / 1024, 2),
|
||||
"totalSizeMb": round(
|
||||
(float(item.get("dataBytes") or 0) + float(item.get("indexBytes") or 0)) / 1024 / 1024,
|
||||
(safe_float(item.get("dataBytes")) + safe_float(item.get("indexBytes"))) / 1024 / 1024,
|
||||
2,
|
||||
),
|
||||
"autoIncrement": item.get("autoIncrementValue"),
|
||||
@ -272,10 +428,32 @@ def list_table_columns(connection: Any, database: str, table: str) -> list[dict[
|
||||
ORDER BY ORDINAL_POSITION ASC
|
||||
"""
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# 表结构操作都依赖列元数据,这里统一一次性拿齐。
|
||||
cursor.execute(query, (target_database, target_table))
|
||||
rows = cursor.fetchall() or []
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# 表结构操作都依赖列元数据,这里统一一次性拿齐。
|
||||
cursor.execute(query, (target_database, target_table))
|
||||
rows = cursor.fetchall() or []
|
||||
except Exception: # noqa: BLE001
|
||||
with connection.cursor() as cursor:
|
||||
# SHOW FULL COLUMNS 在受限账号下更稳定,并且字段足够支撑浏览和 DDL。
|
||||
cursor.execute(
|
||||
f"SHOW FULL COLUMNS FROM {quote_identifier(target_table)} FROM {quote_identifier(target_database)}"
|
||||
)
|
||||
rows = cursor.fetchall() or []
|
||||
return [
|
||||
{
|
||||
"name": str(item.get("Field") or "").strip(),
|
||||
"dataType": infer_mysql_data_type(item.get("Type")),
|
||||
"columnType": str(item.get("Type") or "").strip(),
|
||||
"nullable": str(item.get("Null") or "").strip().upper() == "YES",
|
||||
"default": normalize_mysql_value(item.get("Default")),
|
||||
"key": str(item.get("Key") or "").strip(),
|
||||
"extra": str(item.get("Extra") or "").strip(),
|
||||
"comment": str(item.get("Comment") or "").strip(),
|
||||
"ordinalPosition": index,
|
||||
}
|
||||
for index, item in enumerate(rows, start=1)
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
@ -287,7 +465,7 @@ def list_table_columns(connection: Any, database: str, table: str) -> list[dict[
|
||||
"key": str(item.get("columnKey") or "").strip(),
|
||||
"extra": str(item.get("extraValue") or "").strip(),
|
||||
"comment": str(item.get("columnComment") or "").strip(),
|
||||
"ordinalPosition": int(item.get("ordinalPosition") or 0),
|
||||
"ordinalPosition": safe_int(item.get("ordinalPosition")),
|
||||
}
|
||||
for item in rows
|
||||
]
|
||||
@ -311,29 +489,35 @@ def list_table_indexes(connection: Any, database: str, table: str) -> list[dict[
|
||||
ORDER BY INDEX_NAME ASC, SEQ_IN_INDEX ASC
|
||||
"""
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# information_schema.STATISTICS 能完整覆盖 primary / unique / 普通索引。
|
||||
cursor.execute(query, (target_database, target_table))
|
||||
rows = cursor.fetchall() or []
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# information_schema.STATISTICS 能完整覆盖 primary / unique / 普通索引。
|
||||
cursor.execute(query, (target_database, target_table))
|
||||
rows = cursor.fetchall() or []
|
||||
except Exception: # noqa: BLE001
|
||||
with connection.cursor() as cursor:
|
||||
# SHOW INDEX 同样可以完整覆盖索引顺序和类型,并且权限要求更宽松。
|
||||
cursor.execute(f"SHOW INDEX FROM {quote_identifier(target_table)} FROM {quote_identifier(target_database)}")
|
||||
rows = cursor.fetchall() or []
|
||||
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for item in rows:
|
||||
index_name = str(item.get("indexName") or "").strip()
|
||||
index_name = str(item.get("indexName") or item.get("Key_name") or "").strip()
|
||||
target = grouped.setdefault(
|
||||
index_name,
|
||||
{
|
||||
"name": index_name,
|
||||
"primary": index_name == "PRIMARY",
|
||||
"unique": int(item.get("nonUnique") or 0) == 0,
|
||||
"type": str(item.get("indexType") or "").strip(),
|
||||
"unique": safe_int(item.get("nonUnique") if "nonUnique" in item else item.get("Non_unique")) == 0,
|
||||
"type": str(item.get("indexType") or item.get("Index_type") or "").strip(),
|
||||
"columns": [],
|
||||
},
|
||||
)
|
||||
target["columns"].append(
|
||||
{
|
||||
"name": str(item.get("columnName") or "").strip(),
|
||||
"subPart": int(item.get("subPart") or 0) or None,
|
||||
"position": int(item.get("seqInIndex") or 0),
|
||||
"name": str(item.get("columnName") or item.get("Column_name") or "").strip(),
|
||||
"subPart": safe_int(item.get("subPart") if "subPart" in item else item.get("Sub_part")) or None,
|
||||
"position": safe_int(item.get("seqInIndex") if "seqInIndex" in item else item.get("Seq_in_index")),
|
||||
}
|
||||
)
|
||||
|
||||
@ -372,34 +556,46 @@ def table_exists(connection: Any, database: str, table: str) -> bool:
|
||||
target_table = normalize_table_name(table)
|
||||
|
||||
# DDL 和浏览接口都需要一个轻量存在性校验,避免后面拼 SQL 才报错。
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(target_database, target_table),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(target_database, target_table),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
return target_table in list_accessible_table_names(connection, target_database)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def database_exists(connection: Any, database: str) -> bool:
|
||||
target_database = normalize_database_name(database)
|
||||
|
||||
# 库级存在性校验单独抽出来,浏览器切库时可以复用。
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.SCHEMATA
|
||||
WHERE SCHEMA_NAME = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(target_database,),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.SCHEMATA
|
||||
WHERE SCHEMA_NAME = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(target_database,),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
return target_database in list_accessible_database_names(connection)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def normalize_sql_text(sql: str) -> str:
|
||||
|
||||
@ -34,6 +34,38 @@ def coerce_bool(mapping: dict[str, Any], key: str) -> bool:
|
||||
return value in {"1", "on", "yes", "true"}
|
||||
|
||||
|
||||
def default_replication_payload(variables: dict[str, Any]) -> dict[str, Any]:
|
||||
# 复制详情拿不到时,至少保留只读状态和基础占位,避免整块直接失败。
|
||||
read_only = coerce_bool(variables, "read_only")
|
||||
super_read_only = coerce_bool(variables, "super_read_only")
|
||||
return {
|
||||
"configured": False,
|
||||
"role": "replica" if read_only or super_read_only else "primary",
|
||||
"readOnly": read_only,
|
||||
"superReadOnly": super_read_only,
|
||||
"sourceHost": "",
|
||||
"secondsBehindSource": None,
|
||||
"ioRunning": False,
|
||||
"sqlRunning": False,
|
||||
"state": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def collect_with_warning(
|
||||
label: str,
|
||||
builder: Any,
|
||||
fallback: Any,
|
||||
warnings: list[str],
|
||||
) -> Any:
|
||||
try:
|
||||
# 账号权限不足时保留可用信息,不让整个 MySQL 模块直接退成 ERR。
|
||||
return builder()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warnings.append(f"{label}: {exc}")
|
||||
return fallback
|
||||
|
||||
|
||||
def collect_global_status(connection: Any) -> dict[str, Any]:
|
||||
names = (
|
||||
"Threads_connected",
|
||||
@ -193,6 +225,8 @@ def collect_throughput_payload(status: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def build_mysql_overview_payload() -> dict[str, Any]:
|
||||
warnings: list[str] = []
|
||||
|
||||
try:
|
||||
# 指标和元数据统一走一条连接,减少直连云 MySQL 的并发压力。
|
||||
with build_mysql_connection(autocommit=True) as connection:
|
||||
@ -201,11 +235,31 @@ def build_mysql_overview_payload() -> dict[str, Any]:
|
||||
cursor.execute("SELECT 1 AS ok")
|
||||
cursor.fetchone()
|
||||
|
||||
variables = collect_global_variables(connection)
|
||||
status = collect_global_status(connection)
|
||||
replication = collect_replication_payload(connection, variables)
|
||||
variables = collect_with_warning(
|
||||
"全局变量读取受限",
|
||||
lambda: collect_global_variables(connection),
|
||||
{},
|
||||
warnings,
|
||||
)
|
||||
status = collect_with_warning(
|
||||
"全局状态读取受限",
|
||||
lambda: collect_global_status(connection),
|
||||
{},
|
||||
warnings,
|
||||
)
|
||||
replication = collect_with_warning(
|
||||
"复制状态读取受限",
|
||||
lambda: collect_replication_payload(connection, variables),
|
||||
default_replication_payload(variables),
|
||||
warnings,
|
||||
)
|
||||
throughput = collect_throughput_payload(status)
|
||||
databases = list_database_summaries(connection)
|
||||
databases = collect_with_warning(
|
||||
"数据库元数据读取受限",
|
||||
lambda: list_database_summaries(connection),
|
||||
[],
|
||||
warnings,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return mysql_error_payload(str(exc))
|
||||
|
||||
@ -240,6 +294,7 @@ def build_mysql_overview_payload() -> dict[str, Any]:
|
||||
"replication": replication,
|
||||
"summary": summary,
|
||||
"databases": databases,
|
||||
"warnings": warnings,
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
@ -4218,6 +4218,10 @@ body.auth-page {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.metric-error.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.mono,
|
||||
.detail-secondary {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
const { createApp } = Vue;
|
||||
|
||||
const APP_TEMPLATE = document.getElementById("app")?.innerHTML || "";
|
||||
const APP_BASE_PATH = window.location.pathname === "/yumi" || window.location.pathname.startsWith("/yumi/") ? "/yumi" : "";
|
||||
const APP_CONTEXTS = [
|
||||
{ id: "yumi", basePath: "/yumi", mode: "suite", title: "HY App Monitor" },
|
||||
{ id: "mysql", basePath: "/mysql", mode: "mysql", title: "HY App Monitor MySQL" },
|
||||
];
|
||||
const STORAGE_KEY = "hy-app-monitor-refresh-interval";
|
||||
const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation";
|
||||
const GROUP_LABELS = {
|
||||
@ -31,6 +34,9 @@ const DATABASE_ENGINES = [
|
||||
{ id: "mysql", label: "MySQL" },
|
||||
{ id: "mongo", label: "MongoDB" },
|
||||
];
|
||||
const MYSQL_MODULE_VIEW_TABS = [
|
||||
{ id: "database", label: "MySQL" },
|
||||
];
|
||||
const PRIMARY_FILTERS = [
|
||||
{ id: "all", label: "全部" },
|
||||
{ id: "down", label: "异常" },
|
||||
@ -96,6 +102,18 @@ const MYSQL_DEFAULT_MODES = [
|
||||
{ id: "null", label: "NULL" },
|
||||
];
|
||||
|
||||
function detectAppContext() {
|
||||
const pathname = window.location.pathname || "/";
|
||||
return APP_CONTEXTS.find((item) => (
|
||||
pathname === item.basePath || pathname.startsWith(`${item.basePath}/`)
|
||||
)) || { id: "root", basePath: "", mode: "suite", title: "HY App Monitor" };
|
||||
}
|
||||
|
||||
const CURRENT_APP_CONTEXT = detectAppContext();
|
||||
const APP_BASE_PATH = CURRENT_APP_CONTEXT.basePath;
|
||||
|
||||
document.title = CURRENT_APP_CONTEXT.title;
|
||||
|
||||
function rootPath(path) {
|
||||
const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`;
|
||||
return normalized;
|
||||
@ -238,6 +256,7 @@ function createMySqlOverviewState() {
|
||||
totalSizeMb: 0,
|
||||
},
|
||||
databases: [],
|
||||
warnings: [],
|
||||
error: "",
|
||||
};
|
||||
}
|
||||
@ -339,13 +358,15 @@ createApp({
|
||||
template: APP_TEMPLATE,
|
||||
data() {
|
||||
return {
|
||||
activeView: "overview",
|
||||
activeView: CURRENT_APP_CONTEXT.mode === "mysql" ? "database" : "overview",
|
||||
databaseEngine: "mysql",
|
||||
densityMode: "compact",
|
||||
detailPanelOpen: false,
|
||||
overviewHealthyHostsOpen: false,
|
||||
viewTabs: VIEW_TABS,
|
||||
databaseEngines: DATABASE_ENGINES,
|
||||
viewTabs: CURRENT_APP_CONTEXT.mode === "mysql" ? MYSQL_MODULE_VIEW_TABS : VIEW_TABS,
|
||||
databaseEngines: CURRENT_APP_CONTEXT.mode === "mysql"
|
||||
? DATABASE_ENGINES.filter((engine) => engine.id === "mysql")
|
||||
: DATABASE_ENGINES,
|
||||
primaryFilters: PRIMARY_FILTERS,
|
||||
releaseSections: RELEASE_SECTIONS,
|
||||
configTabs: CONFIG_TABS,
|
||||
@ -1136,6 +1157,28 @@ createApp({
|
||||
const end = start + this.mySqlAdmin.rows.items.length - 1;
|
||||
return `${start}-${end} / ${this.mySqlAdmin.rows.totalRows}`;
|
||||
},
|
||||
mySqlOverviewStatusTone() {
|
||||
if (this.mySqlAdmin.error || !this.mySqlAdmin.overview.ok) {
|
||||
return "bad";
|
||||
}
|
||||
return (this.mySqlAdmin.overview.warnings || []).length > 0 ? "warn" : "ok";
|
||||
},
|
||||
mySqlOverviewStatusText() {
|
||||
if (this.mySqlAdmin.error || !this.mySqlAdmin.overview.ok) {
|
||||
return "存在错误";
|
||||
}
|
||||
return (this.mySqlAdmin.overview.warnings || []).length > 0 ? "权限受限" : "已连接";
|
||||
},
|
||||
mySqlOverviewWarningText() {
|
||||
const warnings = this.mySqlAdmin.overview.warnings || [];
|
||||
if (!warnings.length) {
|
||||
return "";
|
||||
}
|
||||
if (warnings.length === 1) {
|
||||
return warnings[0];
|
||||
}
|
||||
return `${warnings[0]}(另 ${warnings.length - 1} 项)`;
|
||||
},
|
||||
mySqlOverviewCards() {
|
||||
const overview = this.mySqlAdmin.overview || {};
|
||||
const info = overview.info || {};
|
||||
@ -3230,6 +3273,11 @@ createApp({
|
||||
return;
|
||||
}
|
||||
|
||||
if (CURRENT_APP_CONTEXT.mode === "mysql") {
|
||||
await this.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.allSettled([
|
||||
this.refresh(),
|
||||
this.refreshNacosConfigs({ preserveSelection: false, reloadSelected: true }),
|
||||
@ -3312,7 +3360,24 @@ createApp({
|
||||
this.serviceLog.exporting = false;
|
||||
}
|
||||
},
|
||||
async refreshMySqlModule() {
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
this.mySqlAdmin.error = "";
|
||||
|
||||
try {
|
||||
await this.refreshMySqlDatabases({ preserveSelection: true, silent: true });
|
||||
this.error = this.mySqlAdmin.error || "";
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async refresh() {
|
||||
if (CURRENT_APP_CONTEXT.mode === "mysql") {
|
||||
await this.refreshMySqlModule();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
try {
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
<p class="sync-note">最后刷新 {{ formattedUpdatedAt }}</p>
|
||||
</div>
|
||||
|
||||
<nav class="topbar-nav" aria-label="主导航">
|
||||
<nav v-if="viewTabs.length > 1" class="topbar-nav" aria-label="主导航">
|
||||
<button
|
||||
v-for="tab in viewTabs"
|
||||
:key="tab.id"
|
||||
@ -170,7 +170,7 @@
|
||||
<span>{{ databaseContextLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group database-engine-switch">
|
||||
<div v-if="databaseEngines.length > 1" class="control-group database-engine-switch">
|
||||
<button
|
||||
v-for="engine in databaseEngines"
|
||||
:key="engine.id"
|
||||
@ -1334,10 +1334,11 @@
|
||||
<span>复制 {{ mySqlReplicationLabel(mySqlAdmin.overview.replication) }}</span>
|
||||
<span>只读 {{ (mySqlAdmin.overview.replication.readOnly || mySqlAdmin.overview.replication.superReadOnly) ? 'Y' : 'N' }}</span>
|
||||
</div>
|
||||
<span :class="['badge', mySqlAdmin.error || !mySqlAdmin.overview.ok ? 'bad' : 'ok']">{{ mySqlAdmin.error || !mySqlAdmin.overview.ok ? '存在错误' : '已连接' }}</span>
|
||||
<span :class="['badge', mySqlOverviewStatusTone]">{{ mySqlOverviewStatusText }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="mySqlAdmin.error" class="metric-error">{{ explainError(mySqlAdmin.error) }}</p>
|
||||
<p v-else-if="mySqlOverviewWarningText" class="metric-error warn">{{ mySqlOverviewWarningText }}</p>
|
||||
|
||||
<div class="summary-cards mysql-overview-grid">
|
||||
<article v-for="card in mySqlOverviewCards" :key="'mysql-overview-' + card.key" :class="['summary-card', 'mysql-overview-card', `tone-${card.tone}`]">
|
||||
|
||||
@ -33,6 +33,10 @@
|
||||
<strong>Yumi</strong>
|
||||
<span>监控 / 配置 / 发布</span>
|
||||
</a>
|
||||
<a class="entry-card" href="/mysql/">
|
||||
<strong>MySQL</strong>
|
||||
<span>数据库工作台</span>
|
||||
</a>
|
||||
<a class="entry-card" href="/haiyi/">
|
||||
<strong>HaiYi</strong>
|
||||
<span>入口已预留</span>
|
||||
|
||||
24
tests/test_gateway_mysql_alias.py
Normal file
24
tests/test_gateway_mysql_alias.py
Normal file
@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from entry.http_app import match_proxy_target, rewrite_target_path
|
||||
|
||||
|
||||
class GatewayMysqlAliasTests(unittest.TestCase):
|
||||
def test_match_proxy_target_returns_mysql_alias(self) -> None:
|
||||
target = match_proxy_target("/mysql/api/mysql/overview")
|
||||
|
||||
self.assertIsNotNone(target)
|
||||
self.assertEqual(target["app"], "mysql")
|
||||
self.assertEqual(target["basePath"], "/mysql")
|
||||
self.assertEqual(target["upstreamBasePath"], "/yumi")
|
||||
|
||||
def test_rewrite_target_path_preserves_query(self) -> None:
|
||||
rewritten = rewrite_target_path("/mysql/api/mysql/overview?refresh=1", "/mysql", "/yumi")
|
||||
|
||||
self.assertEqual(rewritten, "/yumi/api/mysql/overview?refresh=1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
182
tests/test_mysql_permissions.py
Normal file
182
tests/test_mysql_permissions.py
Normal file
@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from monitors.yumi.mysql_common import (
|
||||
database_exists,
|
||||
list_database_summaries,
|
||||
list_table_columns,
|
||||
list_table_indexes,
|
||||
table_exists,
|
||||
)
|
||||
from monitors.yumi.mysql_metrics import build_mysql_overview_payload
|
||||
|
||||
|
||||
def normalize_sql(sql: str) -> str:
|
||||
return " ".join(str(sql).split()).strip().upper()
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, handler) -> None:
|
||||
self._handler = handler
|
||||
self._result = None
|
||||
|
||||
def __enter__(self) -> "FakeCursor":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
def execute(self, query: str, params=None) -> None:
|
||||
response = self._handler(normalize_sql(query), params)
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
self._result = response
|
||||
|
||||
def fetchall(self):
|
||||
if self._result is None:
|
||||
return []
|
||||
if isinstance(self._result, list):
|
||||
return self._result
|
||||
return [self._result]
|
||||
|
||||
def fetchone(self):
|
||||
if isinstance(self._result, list):
|
||||
return self._result[0] if self._result else None
|
||||
return self._result
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, handler, db: str = "") -> None:
|
||||
self._handler = handler
|
||||
self.db = db
|
||||
|
||||
def cursor(self) -> FakeCursor:
|
||||
return FakeCursor(self._handler)
|
||||
|
||||
|
||||
class DummyOverviewConnection:
|
||||
def cursor(self) -> FakeCursor:
|
||||
def handler(query: str, params=None):
|
||||
if query == "SELECT 1 AS OK":
|
||||
return {"ok": 1}
|
||||
raise AssertionError(f"unexpected query: {query} {params!r}")
|
||||
|
||||
return FakeCursor(handler)
|
||||
|
||||
def __enter__(self) -> "DummyOverviewConnection":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class MysqlPermissionFallbackTests(unittest.TestCase):
|
||||
def test_list_database_summaries_falls_back_to_show_commands(self) -> None:
|
||||
def handler(query: str, params=None):
|
||||
if query.startswith("SELECT S.SCHEMA_NAME AS SCHEMANAME"):
|
||||
return RuntimeError("Access denied for information_schema")
|
||||
if query == "SHOW DATABASES":
|
||||
return [{"Database": "biz"}, {"Database": "mysql"}]
|
||||
if query == "SHOW TABLE STATUS FROM `BIZ`":
|
||||
return [{"Name": "orders", "Rows": "8", "Data_length": "1024", "Index_length": "256"}]
|
||||
if query == "SHOW TABLE STATUS FROM `MYSQL`":
|
||||
return []
|
||||
raise AssertionError(f"unexpected query: {query} {params!r}")
|
||||
|
||||
items = list_database_summaries(FakeConnection(handler))
|
||||
|
||||
self.assertEqual(items[0]["name"], "biz")
|
||||
self.assertEqual(items[0]["tableTotal"], 1)
|
||||
self.assertEqual(items[0]["rowEstimate"], 8)
|
||||
self.assertFalse(items[0]["system"])
|
||||
self.assertEqual(items[1]["name"], "mysql")
|
||||
self.assertTrue(items[1]["system"])
|
||||
|
||||
def test_table_metadata_falls_back_to_show_commands(self) -> None:
|
||||
def handler(query: str, params=None):
|
||||
if query.startswith("SELECT COLUMN_NAME AS COLUMNNAME"):
|
||||
return RuntimeError("Access denied for columns")
|
||||
if query == "SHOW FULL COLUMNS FROM `ORDERS` FROM `BIZ`":
|
||||
return [
|
||||
{
|
||||
"Field": "id",
|
||||
"Type": "bigint unsigned",
|
||||
"Null": "NO",
|
||||
"Default": None,
|
||||
"Key": "PRI",
|
||||
"Extra": "auto_increment",
|
||||
"Comment": "primary key",
|
||||
}
|
||||
]
|
||||
if query.startswith("SELECT INDEX_NAME AS INDEXNAME"):
|
||||
return RuntimeError("Access denied for statistics")
|
||||
if query == "SHOW INDEX FROM `ORDERS` FROM `BIZ`":
|
||||
return [
|
||||
{
|
||||
"Key_name": "PRIMARY",
|
||||
"Column_name": "id",
|
||||
"Non_unique": 0,
|
||||
"Index_type": "BTREE",
|
||||
"Seq_in_index": 1,
|
||||
"Sub_part": None,
|
||||
}
|
||||
]
|
||||
raise AssertionError(f"unexpected query: {query} {params!r}")
|
||||
|
||||
columns = list_table_columns(FakeConnection(handler), "biz", "orders")
|
||||
indexes = list_table_indexes(FakeConnection(handler), "biz", "orders")
|
||||
|
||||
self.assertEqual(columns[0]["name"], "id")
|
||||
self.assertEqual(columns[0]["dataType"], "bigint")
|
||||
self.assertEqual(columns[0]["ordinalPosition"], 1)
|
||||
self.assertEqual(indexes[0]["name"], "PRIMARY")
|
||||
self.assertTrue(indexes[0]["primary"])
|
||||
self.assertTrue(indexes[0]["unique"])
|
||||
|
||||
def test_exists_checks_fall_back_to_show_commands(self) -> None:
|
||||
def handler(query: str, params=None):
|
||||
if "FROM INFORMATION_SCHEMA.SCHEMATA" in query:
|
||||
return RuntimeError("Access denied for schemata")
|
||||
if "FROM INFORMATION_SCHEMA.TABLES" in query:
|
||||
return RuntimeError("Access denied for tables")
|
||||
if query == "SHOW DATABASES":
|
||||
return [{"Database": "biz"}]
|
||||
if query == "SHOW FULL TABLES FROM `BIZ`":
|
||||
return [{"Tables_in_biz": "orders", "Table_type": "BASE TABLE"}]
|
||||
raise AssertionError(f"unexpected query: {query} {params!r}")
|
||||
|
||||
connection = FakeConnection(handler)
|
||||
|
||||
self.assertTrue(database_exists(connection, "biz"))
|
||||
self.assertTrue(table_exists(connection, "biz", "orders"))
|
||||
self.assertFalse(table_exists(connection, "biz", "missing"))
|
||||
|
||||
@patch("monitors.yumi.mysql_metrics.list_database_summaries", return_value=[{"name": "biz", "system": False, "tableTotal": 3, "dataSizeMb": 1.0, "indexSizeMb": 0.5, "totalSizeMb": 1.5}])
|
||||
@patch("monitors.yumi.mysql_metrics.collect_replication_payload", side_effect=RuntimeError("need REPLICATION CLIENT"))
|
||||
@patch("monitors.yumi.mysql_metrics.collect_global_status", side_effect=RuntimeError("need PROCESS"))
|
||||
@patch("monitors.yumi.mysql_metrics.collect_global_variables", side_effect=RuntimeError("need PROCESS"))
|
||||
@patch("monitors.yumi.mysql_metrics.build_mysql_connection")
|
||||
def test_overview_keeps_ok_when_partial_permissions_missing(
|
||||
self,
|
||||
build_connection,
|
||||
collect_global_variables,
|
||||
collect_global_status,
|
||||
collect_replication_payload,
|
||||
list_database_summaries_mock,
|
||||
) -> None:
|
||||
del collect_global_variables, collect_global_status, collect_replication_payload, list_database_summaries_mock
|
||||
build_connection.return_value = DummyOverviewConnection()
|
||||
|
||||
payload = build_mysql_overview_payload()
|
||||
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual(payload["summary"]["databaseTotal"], 1)
|
||||
self.assertEqual(payload["connections"]["current"], 0)
|
||||
self.assertEqual(payload["replication"]["role"], "primary")
|
||||
self.assertEqual(len(payload["warnings"]), 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user