diff --git a/README.md b/README.md index 7f468e9..9d46524 100644 --- a/README.md +++ b/README.md @@ -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` - 服务健康:直接探测各业务服务健康接口 diff --git a/entry/http_app.py b/entry/http_app.py index ee3c52b..5d74278 100644 --- a/entry/http_app.py +++ b/entry/http_app.py @@ -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) diff --git a/monitors/yumi/mysql_common.py b/monitors/yumi/mysql_common.py index ce873a9..a4a2256 100644 --- a/monitors/yumi/mysql_common.py +++ b/monitors/yumi/mysql_common.py @@ -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: diff --git a/monitors/yumi/mysql_metrics.py b/monitors/yumi/mysql_metrics.py index 32c6024..d811e4a 100644 --- a/monitors/yumi/mysql_metrics.py +++ b/monitors/yumi/mysql_metrics.py @@ -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": "", } diff --git a/static/app.css b/static/app.css index 4306fd1..b1674dc 100644 --- a/static/app.css +++ b/static/app.css @@ -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; diff --git a/static/app.js b/static/app.js index bf81bd2..d669f4b 100644 --- a/static/app.js +++ b/static/app.js @@ -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 { diff --git a/static/index.html b/static/index.html index 8d21991..6de83ec 100644 --- a/static/index.html +++ b/static/index.html @@ -26,7 +26,7 @@

最后刷新 {{ formattedUpdatedAt }}

-