修复mysql

This commit is contained in:
hy 2026-04-23 11:59:58 +08:00
parent 47ba1c8a76
commit 4f3b55468f
10 changed files with 661 additions and 90 deletions

View File

@ -6,6 +6,7 @@
- 登录入口:`http://43.164.75.199:2026/login` - 登录入口:`http://43.164.75.199:2026/login`
- 入口选择页:`http://43.164.75.199:2026/select` - 入口选择页:`http://43.164.75.199:2026/select`
- Yumi 入口:`http://43.164.75.199:2026/yumi/` - Yumi 入口:`http://43.164.75.199:2026/yumi/`
- MySQL 入口:`http://43.164.75.199:2026/mysql/`
- HaiYi 入口:`http://43.164.75.199:2026/haiyi/` - HaiYi 入口:`http://43.164.75.199:2026/haiyi/`
- 内部实例:`gateway:2026``yumi:2027``haiyi:2028` - 内部实例:`gateway:2026``yumi:2027``haiyi:2028`
- 服务健康:直接探测各业务服务健康接口 - 服务健康:直接探测各业务服务健康接口

View File

@ -8,7 +8,7 @@ import urllib.request
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any from typing import Any
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse, urlunparse
from core.auth import ( from core.auth import (
build_clear_session_cookie, build_clear_session_cookie,
@ -32,7 +32,7 @@ from core.config import (
YUMI_INTERNAL_PORT, YUMI_INTERNAL_PORT,
utc_now, 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: 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") 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): class GatewayHandler(BaseHTTPRequestHandler):
# 单独标出这是统一入口网关。 # 单独标出这是统一入口网关。
server_version = "HyAppMonitorGateway/2.0" 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) return self.handle_auth_session(send_body=send_body)
# favicon 直接返回空。 # favicon 直接返回空。
@ -129,8 +174,11 @@ class GatewayHandler(BaseHTTPRequestHandler):
# 子应用其余请求统一走透明反代。 # 子应用其余请求统一走透明反代。
target = self.proxy_target(path) target = self.proxy_target(path)
if target is not None: if target is not None:
if path in {target["basePath"], f"{target['basePath']}/"} and not self.require_page_authentication(send_body=send_body): if path in {target["basePath"], f"{target['basePath']}/"}:
return 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): if not self.ensure_prefixed_authentication(path, send_body=send_body):
return return
@ -144,7 +192,7 @@ class GatewayHandler(BaseHTTPRequestHandler):
path = parsed.path 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() return self.handle_logout()
# 登录接口只在根路径开放。 # 登录接口只在根路径开放。
@ -170,21 +218,7 @@ class GatewayHandler(BaseHTTPRequestHandler):
def proxy_target(self, path: str) -> dict[str, Any] | None: def proxy_target(self, path: str) -> dict[str, Any] | None:
# 按固定前缀把请求路由到对应内部实例。 # 按固定前缀把请求路由到对应内部实例。
if path == "/yumi" or path.startswith("/yumi/"): return match_proxy_target(path)
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
def ensure_prefixed_authentication(self, path: str, *, send_body: bool) -> bool: def ensure_prefixed_authentication(self, path: str, *, send_body: bool) -> bool:
# 前缀下的业务页面和 API 都统一要求先登录。 # 前缀下的业务页面和 API 都统一要求先登录。
@ -299,8 +333,13 @@ class GatewayHandler(BaseHTTPRequestHandler):
return "/select" return "/select"
def proxy_request(self, target: dict[str, Any], *, send_body: bool, body: bytes | None = None) -> None: 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"]) headers = self.proxy_headers(target["basePath"])
request = urllib.request.Request(url, data=body, method=self.command, headers=headers) request = urllib.request.Request(url, data=body, method=self.command, headers=headers)

View File

@ -39,6 +39,7 @@ def mysql_error_payload(error: str) -> dict[str, Any]:
"totalSizeMb": 0.0, "totalSizeMb": 0.0,
}, },
"databases": [], "databases": [],
"warnings": [],
"error": error, "error": error,
} }
@ -117,6 +118,22 @@ def is_system_database(name: str) -> bool:
return str(name or "").strip().lower() in SYSTEM_DATABASES 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: def normalize_mysql_value(value: Any) -> Any:
# Decimal 保留成字符串,避免金额精度被前端浮点化。 # Decimal 保留成字符串,避免金额精度被前端浮点化。
if isinstance(value, 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()} 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]]: def list_database_summaries(connection: Any) -> list[dict[str, Any]]:
# 数据库级别汇总统一从 information_schema.tables 读取,能同时拿到表数和容量。 # 数据库级别汇总统一从 information_schema.tables 读取,能同时拿到表数和容量。
query = """ query = """
@ -183,27 +314,31 @@ def list_database_summaries(connection: Any) -> list[dict[str, Any]]:
s.SCHEMA_NAME ASC s.SCHEMA_NAME ASC
""" """
with connection.cursor() as cursor: try:
# 直接读取全部库级摘要,前端 sidebar 需要完整列表。 with connection.cursor() as cursor:
cursor.execute(query) # 直接读取全部库级摘要,前端 sidebar 需要完整列表。
rows = cursor.fetchall() or [] cursor.execute(query)
rows = cursor.fetchall() or []
except Exception: # noqa: BLE001
# 只读账号常见是 information_schema 可见但全局视图受限,这里退到 SHOW 系列命令。
return list_database_summaries_via_show(connection)
# 把字节数转换成 MiB并附带是否系统库的标记。 # 把字节数转换成 MiB并附带是否系统库的标记。
return [ return sort_database_summaries([
{ {
"name": str(item.get("schemaName") or "").strip(), "name": str(item.get("schemaName") or "").strip(),
"system": is_system_database(item.get("schemaName")), "system": is_system_database(item.get("schemaName")),
"tableTotal": int(item.get("tableTotal") or 0), "tableTotal": safe_int(item.get("tableTotal")),
"rowEstimate": int(item.get("rowEstimate") or 0), "rowEstimate": safe_int(item.get("rowEstimate")),
"dataSizeMb": round(float(item.get("dataBytes") or 0) / 1024 / 1024, 2), "dataSizeMb": round(safe_float(item.get("dataBytes")) / 1024 / 1024, 2),
"indexSizeMb": round(float(item.get("indexBytes") or 0) / 1024 / 1024, 2), "indexSizeMb": round(safe_float(item.get("indexBytes")) / 1024 / 1024, 2),
"totalSizeMb": round( "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, 2,
), ),
} }
for item in rows for item in rows
] ])
def list_table_summaries(connection: Any, database: str) -> list[dict[str, Any]]: 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 ORDER BY COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0) DESC, TABLE_NAME ASC
""" """
with connection.cursor() as cursor: try:
# 同一库内表数量通常有限,这里直接全量读取。 with connection.cursor() as cursor:
cursor.execute(query, (target_database,)) # 同一库内表数量通常有限,这里直接全量读取。
rows = cursor.fetchall() or [] 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 [ return [
{ {
"name": str(item.get("tableName") or "").strip(), "name": str(item.get("tableName") or "").strip(),
"engine": str(item.get("engine") or "").strip(), "engine": str(item.get("engine") or "").strip(),
"rowEstimate": int(item.get("rowEstimate") or 0), "rowEstimate": safe_int(item.get("rowEstimate")),
"dataSizeMb": round(float(item.get("dataBytes") or 0) / 1024 / 1024, 2), "dataSizeMb": round(safe_float(item.get("dataBytes")) / 1024 / 1024, 2),
"indexSizeMb": round(float(item.get("indexBytes") or 0) / 1024 / 1024, 2), "indexSizeMb": round(safe_float(item.get("indexBytes")) / 1024 / 1024, 2),
"totalSizeMb": round( "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, 2,
), ),
"autoIncrement": item.get("autoIncrementValue"), "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 ORDER BY ORDINAL_POSITION ASC
""" """
with connection.cursor() as cursor: try:
# 表结构操作都依赖列元数据,这里统一一次性拿齐。 with connection.cursor() as cursor:
cursor.execute(query, (target_database, target_table)) # 表结构操作都依赖列元数据,这里统一一次性拿齐。
rows = cursor.fetchall() or [] 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 [ return [
{ {
@ -287,7 +465,7 @@ def list_table_columns(connection: Any, database: str, table: str) -> list[dict[
"key": str(item.get("columnKey") or "").strip(), "key": str(item.get("columnKey") or "").strip(),
"extra": str(item.get("extraValue") or "").strip(), "extra": str(item.get("extraValue") or "").strip(),
"comment": str(item.get("columnComment") 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 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 ORDER BY INDEX_NAME ASC, SEQ_IN_INDEX ASC
""" """
with connection.cursor() as cursor: try:
# information_schema.STATISTICS 能完整覆盖 primary / unique / 普通索引。 with connection.cursor() as cursor:
cursor.execute(query, (target_database, target_table)) # information_schema.STATISTICS 能完整覆盖 primary / unique / 普通索引。
rows = cursor.fetchall() or [] 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]] = {} grouped: dict[str, dict[str, Any]] = {}
for item in rows: 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( target = grouped.setdefault(
index_name, index_name,
{ {
"name": index_name, "name": index_name,
"primary": index_name == "PRIMARY", "primary": index_name == "PRIMARY",
"unique": int(item.get("nonUnique") or 0) == 0, "unique": safe_int(item.get("nonUnique") if "nonUnique" in item else item.get("Non_unique")) == 0,
"type": str(item.get("indexType") or "").strip(), "type": str(item.get("indexType") or item.get("Index_type") or "").strip(),
"columns": [], "columns": [],
}, },
) )
target["columns"].append( target["columns"].append(
{ {
"name": str(item.get("columnName") or "").strip(), "name": str(item.get("columnName") or item.get("Column_name") or "").strip(),
"subPart": int(item.get("subPart") or 0) or None, "subPart": safe_int(item.get("subPart") if "subPart" in item else item.get("Sub_part")) or None,
"position": int(item.get("seqInIndex") or 0), "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) target_table = normalize_table_name(table)
# DDL 和浏览接口都需要一个轻量存在性校验,避免后面拼 SQL 才报错。 # DDL 和浏览接口都需要一个轻量存在性校验,避免后面拼 SQL 才报错。
with connection.cursor() as cursor: try:
cursor.execute( with connection.cursor() as cursor:
""" cursor.execute(
SELECT 1 """
FROM information_schema.TABLES SELECT 1
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s FROM information_schema.TABLES
LIMIT 1 WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
""", LIMIT 1
(target_database, target_table), """,
) (target_database, target_table),
return cursor.fetchone() is not None )
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: def database_exists(connection: Any, database: str) -> bool:
target_database = normalize_database_name(database) target_database = normalize_database_name(database)
# 库级存在性校验单独抽出来,浏览器切库时可以复用。 # 库级存在性校验单独抽出来,浏览器切库时可以复用。
with connection.cursor() as cursor: try:
cursor.execute( with connection.cursor() as cursor:
""" cursor.execute(
SELECT 1 """
FROM information_schema.SCHEMATA SELECT 1
WHERE SCHEMA_NAME = %s FROM information_schema.SCHEMATA
LIMIT 1 WHERE SCHEMA_NAME = %s
""", LIMIT 1
(target_database,), """,
) (target_database,),
return cursor.fetchone() is not None )
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: def normalize_sql_text(sql: str) -> str:

View File

@ -34,6 +34,38 @@ def coerce_bool(mapping: dict[str, Any], key: str) -> bool:
return value in {"1", "on", "yes", "true"} 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]: def collect_global_status(connection: Any) -> dict[str, Any]:
names = ( names = (
"Threads_connected", "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]: def build_mysql_overview_payload() -> dict[str, Any]:
warnings: list[str] = []
try: try:
# 指标和元数据统一走一条连接,减少直连云 MySQL 的并发压力。 # 指标和元数据统一走一条连接,减少直连云 MySQL 的并发压力。
with build_mysql_connection(autocommit=True) as connection: 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.execute("SELECT 1 AS ok")
cursor.fetchone() cursor.fetchone()
variables = collect_global_variables(connection) variables = collect_with_warning(
status = collect_global_status(connection) "全局变量读取受限",
replication = collect_replication_payload(connection, variables) 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) 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 except Exception as exc: # noqa: BLE001
return mysql_error_payload(str(exc)) return mysql_error_payload(str(exc))
@ -240,6 +294,7 @@ def build_mysql_overview_payload() -> dict[str, Any]:
"replication": replication, "replication": replication,
"summary": summary, "summary": summary,
"databases": databases, "databases": databases,
"warnings": warnings,
"error": "", "error": "",
} }

View File

@ -4218,6 +4218,10 @@ body.auth-page {
word-break: break-word; word-break: break-word;
} }
.metric-error.warn {
color: var(--warn);
}
.mono, .mono,
.detail-secondary { .detail-secondary {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace; font-family: "IBM Plex Mono", "SFMono-Regular", monospace;

View File

@ -1,7 +1,10 @@
const { createApp } = Vue; const { createApp } = Vue;
const APP_TEMPLATE = document.getElementById("app")?.innerHTML || ""; 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 STORAGE_KEY = "hy-app-monitor-refresh-interval";
const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation"; const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation";
const GROUP_LABELS = { const GROUP_LABELS = {
@ -31,6 +34,9 @@ const DATABASE_ENGINES = [
{ id: "mysql", label: "MySQL" }, { id: "mysql", label: "MySQL" },
{ id: "mongo", label: "MongoDB" }, { id: "mongo", label: "MongoDB" },
]; ];
const MYSQL_MODULE_VIEW_TABS = [
{ id: "database", label: "MySQL" },
];
const PRIMARY_FILTERS = [ const PRIMARY_FILTERS = [
{ id: "all", label: "全部" }, { id: "all", label: "全部" },
{ id: "down", label: "异常" }, { id: "down", label: "异常" },
@ -96,6 +102,18 @@ const MYSQL_DEFAULT_MODES = [
{ id: "null", label: "NULL" }, { 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) { function rootPath(path) {
const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`; const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`;
return normalized; return normalized;
@ -238,6 +256,7 @@ function createMySqlOverviewState() {
totalSizeMb: 0, totalSizeMb: 0,
}, },
databases: [], databases: [],
warnings: [],
error: "", error: "",
}; };
} }
@ -339,13 +358,15 @@ createApp({
template: APP_TEMPLATE, template: APP_TEMPLATE,
data() { data() {
return { return {
activeView: "overview", activeView: CURRENT_APP_CONTEXT.mode === "mysql" ? "database" : "overview",
databaseEngine: "mysql", databaseEngine: "mysql",
densityMode: "compact", densityMode: "compact",
detailPanelOpen: false, detailPanelOpen: false,
overviewHealthyHostsOpen: false, overviewHealthyHostsOpen: false,
viewTabs: VIEW_TABS, viewTabs: CURRENT_APP_CONTEXT.mode === "mysql" ? MYSQL_MODULE_VIEW_TABS : VIEW_TABS,
databaseEngines: DATABASE_ENGINES, databaseEngines: CURRENT_APP_CONTEXT.mode === "mysql"
? DATABASE_ENGINES.filter((engine) => engine.id === "mysql")
: DATABASE_ENGINES,
primaryFilters: PRIMARY_FILTERS, primaryFilters: PRIMARY_FILTERS,
releaseSections: RELEASE_SECTIONS, releaseSections: RELEASE_SECTIONS,
configTabs: CONFIG_TABS, configTabs: CONFIG_TABS,
@ -1136,6 +1157,28 @@ createApp({
const end = start + this.mySqlAdmin.rows.items.length - 1; const end = start + this.mySqlAdmin.rows.items.length - 1;
return `${start}-${end} / ${this.mySqlAdmin.rows.totalRows}`; 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() { mySqlOverviewCards() {
const overview = this.mySqlAdmin.overview || {}; const overview = this.mySqlAdmin.overview || {};
const info = overview.info || {}; const info = overview.info || {};
@ -3230,6 +3273,11 @@ createApp({
return; return;
} }
if (CURRENT_APP_CONTEXT.mode === "mysql") {
await this.refresh();
return;
}
await Promise.allSettled([ await Promise.allSettled([
this.refresh(), this.refresh(),
this.refreshNacosConfigs({ preserveSelection: false, reloadSelected: true }), this.refreshNacosConfigs({ preserveSelection: false, reloadSelected: true }),
@ -3312,7 +3360,24 @@ createApp({
this.serviceLog.exporting = false; 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() { async refresh() {
if (CURRENT_APP_CONTEXT.mode === "mysql") {
await this.refreshMySqlModule();
return;
}
this.loading = true; this.loading = true;
this.error = ""; this.error = "";
try { try {

View File

@ -26,7 +26,7 @@
<p class="sync-note">最后刷新 {{ formattedUpdatedAt }}</p> <p class="sync-note">最后刷新 {{ formattedUpdatedAt }}</p>
</div> </div>
<nav class="topbar-nav" aria-label="主导航"> <nav v-if="viewTabs.length > 1" class="topbar-nav" aria-label="主导航">
<button <button
v-for="tab in viewTabs" v-for="tab in viewTabs"
:key="tab.id" :key="tab.id"
@ -170,7 +170,7 @@
<span>{{ databaseContextLabel }}</span> <span>{{ databaseContextLabel }}</span>
</div> </div>
<div class="control-group database-engine-switch"> <div v-if="databaseEngines.length > 1" class="control-group database-engine-switch">
<button <button
v-for="engine in databaseEngines" v-for="engine in databaseEngines"
:key="engine.id" :key="engine.id"
@ -1334,10 +1334,11 @@
<span>复制 {{ mySqlReplicationLabel(mySqlAdmin.overview.replication) }}</span> <span>复制 {{ mySqlReplicationLabel(mySqlAdmin.overview.replication) }}</span>
<span>只读 {{ (mySqlAdmin.overview.replication.readOnly || mySqlAdmin.overview.replication.superReadOnly) ? 'Y' : 'N' }}</span> <span>只读 {{ (mySqlAdmin.overview.replication.readOnly || mySqlAdmin.overview.replication.superReadOnly) ? 'Y' : 'N' }}</span>
</div> </div>
<span :class="['badge', mySqlAdmin.error || !mySqlAdmin.overview.ok ? 'bad' : 'ok']">{{ mySqlAdmin.error || !mySqlAdmin.overview.ok ? '存在错误' : '已连接' }}</span> <span :class="['badge', mySqlOverviewStatusTone]">{{ mySqlOverviewStatusText }}</span>
</div> </div>
<p v-if="mySqlAdmin.error" class="metric-error">{{ explainError(mySqlAdmin.error) }}</p> <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"> <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}`]"> <article v-for="card in mySqlOverviewCards" :key="'mysql-overview-' + card.key" :class="['summary-card', 'mysql-overview-card', `tone-${card.tone}`]">

View File

@ -33,6 +33,10 @@
<strong>Yumi</strong> <strong>Yumi</strong>
<span>监控 / 配置 / 发布</span> <span>监控 / 配置 / 发布</span>
</a> </a>
<a class="entry-card" href="/mysql/">
<strong>MySQL</strong>
<span>数据库工作台</span>
</a>
<a class="entry-card" href="/haiyi/"> <a class="entry-card" href="/haiyi/">
<strong>HaiYi</strong> <strong>HaiYi</strong>
<span>入口已预留</span> <span>入口已预留</span>

View 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()

View 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()