修复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/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`
- 服务健康:直接探测各业务服务健康接口

View File

@ -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)

View File

@ -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:

View File

@ -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": "",
}

View File

@ -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;

View File

@ -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 {

View File

@ -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}`]">

View File

@ -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>

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