From 47ba1c8a7633f242c61cbfba75c5476c0d55091c Mon Sep 17 00:00:00 2001 From: ZuoZuo <68836346+Mrz-sakura@users.noreply.github.com> Date: Thu, 23 Apr 2026 04:19:55 +0800 Subject: [PATCH] refactor monitor structure and polish control console --- .env.example | 12 + .impeccable.md | 22 + AGENTS.md | 21 + README.md | 30 +- core/__init__.py | 1 + {monitor => core}/auth.py | 0 {monitor => core}/cache.py | 0 {monitor => core}/config.py | 25 + {monitor => core}/env.py | 0 {monitor => core}/http_paths.py | 0 entry/__init__.py | 1 + {gateway => entry}/http_app.py | 6 +- gateway/__init__.py | 1 - haiyi_monitor/__init__.py | 1 - haiyi_server.py | 2 +- monitor/__init__.py | 2 - monitors/__init__.py | 1 + monitors/haiyi/__init__.py | 1 + {haiyi_monitor => monitors/haiyi}/http_app.py | 4 +- monitors/yumi/__init__.py | 1 + .../yumi}/compose_templates.py | 2 +- .../yumi}/config_validation.py | 0 {monitor => monitors/yumi}/dashboard.py | 9 +- {monitor => monitors/yumi}/host_metrics.py | 4 +- {monitor => monitors/yumi}/http_app.py | 263 +- {monitor => monitors/yumi}/http_probe.py | 4 +- monitors/yumi/mongo_admin.py | 879 +++++ {monitor => monitors/yumi}/mongo_metrics.py | 4 +- monitors/yumi/mysql_admin.py | 719 +++++ monitors/yumi/mysql_common.py | 421 +++ monitors/yumi/mysql_metrics.py | 249 ++ {monitor => monitors/yumi}/nacos.py | 4 +- {monitor => monitors/yumi}/runtime_config.py | 2 +- {monitor => monitors/yumi}/service_logs.py | 2 +- {monitor => monitors/yumi}/service_ops.py | 2 +- {monitor => monitors/yumi}/service_release.py | 2 +- .../yumi}/service_update_jobs.py | 2 +- {monitor => monitors/yumi}/ssh_remote.py | 2 +- {monitor => monitors/yumi}/tencent_tat.py | 2 +- ops/sync_runtime_compose.py | 2 +- requirements-ops.txt | 1 + server.py | 2 +- static/app.css | 2184 ++++++++++++- static/app.js | 2827 ++++++++++++++++- static/index.html | 2136 +++++++++++-- yumi_server.py | 2 +- 46 files changed, 9399 insertions(+), 458 deletions(-) create mode 100644 core/__init__.py rename {monitor => core}/auth.py (100%) rename {monitor => core}/cache.py (100%) rename {monitor => core}/config.py (94%) rename {monitor => core}/env.py (100%) rename {monitor => core}/http_paths.py (100%) create mode 100644 entry/__init__.py rename {gateway => entry}/http_app.py (99%) delete mode 100644 gateway/__init__.py delete mode 100644 haiyi_monitor/__init__.py delete mode 100644 monitor/__init__.py create mode 100644 monitors/__init__.py create mode 100644 monitors/haiyi/__init__.py rename {haiyi_monitor => monitors/haiyi}/http_app.py (97%) create mode 100644 monitors/yumi/__init__.py rename {monitor => monitors/yumi}/compose_templates.py (99%) rename {monitor => monitors/yumi}/config_validation.py (100%) rename {monitor => monitors/yumi}/dashboard.py (91%) rename {monitor => monitors/yumi}/host_metrics.py (97%) rename {monitor => monitors/yumi}/http_app.py (68%) rename {monitor => monitors/yumi}/http_probe.py (97%) create mode 100644 monitors/yumi/mongo_admin.py rename {monitor => monitors/yumi}/mongo_metrics.py (99%) create mode 100644 monitors/yumi/mysql_admin.py create mode 100644 monitors/yumi/mysql_common.py create mode 100644 monitors/yumi/mysql_metrics.py rename {monitor => monitors/yumi}/nacos.py (99%) rename {monitor => monitors/yumi}/runtime_config.py (99%) rename {monitor => monitors/yumi}/service_logs.py (96%) rename {monitor => monitors/yumi}/service_ops.py (99%) rename {monitor => monitors/yumi}/service_release.py (99%) rename {monitor => monitors/yumi}/service_update_jobs.py (99%) rename {monitor => monitors/yumi}/ssh_remote.py (99%) rename {monitor => monitors/yumi}/tencent_tat.py (98%) diff --git a/.env.example b/.env.example index 381bc40..fe79bfc 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,18 @@ MONGO_URI= MONGO_DB_NAME=tarab_all MONGO_INSTANCE_ID=ins-o9mmob66 MONGO_CACHE_TTL_SECONDS=20 +MYSQL_HOST= +MYSQL_PORT=3306 +MYSQL_USER= +MYSQL_PASSWORD= +MYSQL_DEFAULT_DATABASE= +MYSQL_CACHE_TTL_SECONDS=10 +MYSQL_CONNECT_TIMEOUT_SECONDS=5 +MYSQL_READ_TIMEOUT_SECONDS=15 +MYSQL_WRITE_TIMEOUT_SECONDS=15 +MYSQL_DEFAULT_PAGE_SIZE=100 +MYSQL_MAX_PAGE_SIZE=200 +MYSQL_QUERY_MAX_ROWS=500 CPU_WARN_PERCENT=60 CPU_BAD_PERCENT=85 MEMORY_WARN_PERCENT=70 diff --git a/.impeccable.md b/.impeccable.md index 24466fc..94b2abc 100644 --- a/.impeccable.md +++ b/.impeccable.md @@ -52,3 +52,25 @@ - `配置` 页必须拆成页内 tabs:`Nacos / Go Env / 历史版本`;编辑区使用 sticky 工具条、metadata strip、`源码 / Diff` 切换。 - `基础设施` 页必须有二级导航:`状态总览 / Nacos 服务 / Mongo 指标 / TAT`;Nacos 服务默认折叠实例,Mongo 次要指标默认折叠。 - 长文本默认缩写,完整值只通过悬停、复制或展开明细承接,不在主列表直接铺满一整行。 +- `发布` 页主动作必须集中在一条 action bar;区块内部不重复摆放刷新、预检查、执行类按钮。 +- 高权重动作必须明显少于次动作;`保存 / 执行 / 立即刷新` 才能用高权重按钮,其余默认降为 ghost 或轻量控件。 +- `执行日志` 和 `服务日志` 默认不直接铺在主页面里;页面只保留摘要,完整日志通过统一弹层查看和导出。 +- `配置` 页左侧必须保持 explorer 式树状浏览,不回退到一张张大卡片;组折叠、当前项高亮、上下项切换都要直接可用。 +- `配置` 页左侧 explorer 不重复展示已经在 workbench head 里出现的 baseUrl、组数、环境信息。 +- `配置` 页不要重复出现“页面标题 / 分区标题 / 当前文件卡片”三层同义信息;当前文件、group、type 合并进工具条和 metadata strip。 +- `配置` 页左侧文件树优先高密度列表,压缩单项高度、内边距和顶部无效空白。 +- `任务型页面` 优先使用紧凑任务头,不再额外堆一张信息价值低的说明卡。 +- 主机视图里的百分比指标优先使用小圆环 + 数值标签,不回退到纯文本堆叠。 +- 主机行中部优先展示“在线服务 / 异常服务摘要”,避免重复出现“全部正常”这类低信息密度文案。 +- 主机分组标题必须带主机数、服务数、异常主机数,保证高密度时仍能快速扫读分区状态。 +- 总览页在全正常状态下不继续渲染空异常区;异常摘要、异常主机、异常基础设施为空时直接隐藏,正常项只保留紧凑摘要。 +- 首页异常摘要优先使用分段状态带;正常健康态保持紧凑卡组,不回退到多张孤立大卡。 +- 首页服务矩阵的每个业务分组应作为独立分组卡展示,分组头直接包含服务数、节点数、异常数。 +- 首页的网关 / 应用 / 支付分组允许使用轻微色温差异和导向线区分,但必须克制,不把分组色做成高饱和装饰。 +- 首页正常主机默认使用紧凑卡栅格;没有异常主机时不渲染大空态占位。 +- 主机指标拿不到时显示采集状态芯片,不用空圆环或占位短横伪装成正常指标。 +- `发布` 页更新目标和滚动重启优先使用多列紧凑服务卡,完整宽表只留给执行结果和明细。 +- 可选择条目优先使用胶囊式勾选控件,已选态要明显强于未选态,不能只靠原生 checkbox 本体提示选择结果。 +- 首页侧栏卡、基础设施小卡、任务页工作区尽量复用同一套顶部导向线和轻表面语言,避免不同页面像不同产品拼接。 +- `数据库` 视图使用统一的工作台入口,`MySQL / MongoDB` 通过同一层引擎切换承接,不再把数据库操作散落在不同页面角落。 +- `MongoDB / MySQL` 操作页都采用 `左侧 explorer + 右侧 workbench` 结构;概览指标保持紧凑,危险操作集中到单独分区,不把删除类动作和普通查询动作混在一起。 diff --git a/AGENTS.md b/AGENTS.md index ca9dbae..d5f6acd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,10 +52,31 @@ - 首页首屏默认收敛为顶部导航、工具条、紧凑摘要条、服务矩阵,不额外插入大块最近异常横幅。 - 最近异常进入可折叠详情侧栏顶部,不独立占据首页主宽度。 - 服务矩阵按业务分组切成子矩阵,异常排序前置,表头和第一列保持 sticky。 +- 首页异常摘要优先使用分段状态带,不回退到零散大卡。 +- 服务矩阵的业务分组要做成独立分组卡,分组头直接给服务数、节点数、异常数。 +- 首页网关 / 应用 / 支付分组可以用轻微色温差异和导向线区分,但保持低饱和,不做装饰性大色块。 - 首页主机区只展示业务主机;基础设施主机放到基础设施或主机视图承接。 - 主机指标使用单行短文本,正常态降饱和,红橙色保留给异常和告警。 +- 首页没有异常主机时不渲染大空态,正常主机直接用紧凑卡栅格展示。 +- 主机指标缺失时直接显示采集状态芯片,不渲染空圆环和占位短横。 - 监控筛选条只属于 `总览 / 服务 / 主机`;`发布 / 配置 / 基础设施` 必须使用各自专用工具条和页内导航。 - `发布` 页按“构建参数 -> 更新目标 -> 执行日志 -> 滚动重启”组织,更新与重启目标都优先用紧凑表格,不再回退到大卡片流。 - `配置` 页按 `Nacos / Go Env / 历史版本` tabs 组织,编辑器必须提供 sticky 工具条、metadata strip 和 `源码 / Diff` 切换。 - `基础设施` 页按 `状态总览 / Nacos 服务 / Mongo 指标 / TAT` 二级导航组织,Nacos 服务默认折叠实例,Mongo 次要指标默认折叠。 - 长文本默认缩写并提供复制或展开,不直接在主列表铺完整 digest、路径、长 ID 或长连接串。 +- `发布` 页主动作集中到单独 action bar,区块内部避免重复放刷新、预检查、执行类按钮。 +- 高权重动作必须少于次动作;只有 `保存 / 执行 / 立即刷新` 这类核心操作保留高权重按钮,其余动作降为 ghost 或轻量控件。 +- `执行日志` 和 `服务日志` 默认通过统一弹层查看;主页面只保留摘要和上下文,不铺大块日志窗口。 +- `配置` 页左侧使用 explorer 式树状导航,避免大卡片式配置清单;当前项高亮、组折叠、上下项跳转必须可用。 +- `配置` 页左侧 explorer 不重复展示已经在 workbench head 里出现的 baseUrl、组数、环境信息。 +- `配置` 页不要重复堆叠大标题和当前文件卡片;当前文件信息应并入工具条和 metadata strip,左侧文件树保持高密度。 +- `任务型页面` 优先使用紧凑任务头,不再额外堆一张信息价值低的说明卡。 +- 主机视图中的百分比指标优先使用小圆环 + 数值标签,不回退到纯文本百分比堆叠。 +- 主机行中部优先展示服务摘要,避免重复出现“全部正常”这类低信息密度文案。 +- 主机分组标题需要同时给出主机数、服务数、异常主机数,确保高密度下仍然易扫读。 +- 总览页全正常时不继续渲染空异常区;正常主机和 `Nacos / Mongo / TAT` 统一收成紧凑摘要。 +- `发布` 页更新目标和滚动重启默认使用多列紧凑卡片,避免一项一整行的浪费布局。 +- 可选择条目优先使用胶囊式勾选控件,已选态必须明显强于未选态,不能只靠原生 checkbox 提示。 +- 首页侧栏卡、基础设施小卡、任务工作区尽量复用同一套顶部导向线和轻表面语言,避免跨页设计语言漂移。 +- `数据库` 视图使用统一的工作台入口,`MySQL / MongoDB` 通过同一层引擎切换承接,不再把数据库操作散落在不同页面角落。 +- `MongoDB / MySQL` 操作页统一采用 `左侧 explorer + 右侧 workbench` 结构;概览指标保持紧凑,危险操作集中在单独分区,不把删除类动作和普通查询动作混在一起。 diff --git a/README.md b/README.md index 0eb2359..7f468e9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ - 服务健康:直接探测各业务服务健康接口 - 主机资源:通过 `SSH` 采集 `CPU / 内存 / 磁盘 / 进程数` - Nacos:直连 Nacos OpenAPI,列出命名空间、服务和注册实例 -- MongoDB:直连 Mongo,采集 `serverStatus / listDatabases / replSetGetStatus` +- MongoDB:直连 Mongo,采集 `serverStatus / listDatabases / replSetGetStatus`,并提供库表浏览、分页查询、CRUD、聚合、Distinct、索引管理 +- MySQL:直连云 MySQL,提供运行状态、库表浏览、100 条分页查询、CRUD、列/索引变更和 SQL 工作台 - 宿主机运维:日志、滚动重启、compose 同步、服务更新统一走 `SSH` - 带外引导:只在打通 `deploy -> target:22` 时使用 `TAT` @@ -20,9 +21,10 @@ - `server.py`:gateway 入口 - `yumi_server.py`:Yumi 内部实例入口 - `haiyi_server.py`:HaiYi 内部实例入口 -- `gateway/`:统一登录、入口选择页和反代 -- `monitor/`:Yumi 监控与运维实现 -- `haiyi_monitor/`:HaiYi 占位实例 +- `entry/`:统一登录、入口选择页和反代 +- `core/`:共享配置、鉴权、缓存和路径工具 +- `monitors/yumi/`:Yumi 监控与运维实现 +- `monitors/haiyi/`:HaiYi 占位实例 - `config/targets.json`:监控目标和实例 ID - `static/`:前端页面 - `scripts/`:启停、自启安装、前台运行脚本 @@ -51,6 +53,11 @@ REFRESH_INTERVAL_OPTIONS=5,10,15,30,60 NACOS_BASE_URL=http://10.2.1.17:8848 NACOS_NAMESPACE_ID=... MONGO_URI=mongodb://... +MYSQL_HOST=10.x.x.x +MYSQL_PORT=3306 +MYSQL_USER=readonly_or_admin +MYSQL_PASSWORD=*** +MYSQL_DEFAULT_DATABASE= DEPLOY_REGION=me-saudi-arabia DEPLOY_INSTANCE_ID= TENCENT_SECRET_ID=*** @@ -115,6 +122,18 @@ cp .env.example .env AUTH_COOKIE_SECURE=1 ``` +MySQL 工作台可选参数: + +```bash +MYSQL_CACHE_TTL_SECONDS=10 +MYSQL_CONNECT_TIMEOUT_SECONDS=5 +MYSQL_READ_TIMEOUT_SECONDS=15 +MYSQL_WRITE_TIMEOUT_SECONDS=15 +MYSQL_DEFAULT_PAGE_SIZE=100 +MYSQL_MAX_PAGE_SIZE=200 +MYSQL_QUERY_MAX_ROWS=500 +``` + ## 本地运行 ```bash @@ -263,4 +282,7 @@ python3 ops/sync_runtime_compose.py --sync-remote - 服务更新:本地构建选中服务、推送 Harbor、更新完整 compose,并只重建选中的服务 - 滚动重启:可按服务选择 Java / Golang 节点,逐台重启并等待健康恢复 - MongoDB:连接、内存、网络、操作计数、复制集、数据库统计 +- Mongo 工作页:列出所有数据库和集合,文档默认每页 `100` 条,支持 `find / insertOne / insertMany / replaceOne / updateOne / updateMany / deleteOne / deleteMany / aggregate / distinct / createIndex / dropIndex / dropIndexes / createCollection / renameCollection / dropCollection / dropDatabase` +- MySQL:连接状态、版本、当前/最大连接数、QPS/TPS、数据库大小、主从/只读状态 +- MySQL 工作页:列出所有数据库和表,默认每页 `100` 条,支持筛选排序、行级增删改、增加/删除列、增加/删除索引、常用 SQL 快捷操作和单语句 SQL 执行 - 自动刷新:前端可切换刷新间隔,并保存在浏览器本地 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..28b2c0e --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +"""Shared runtime layer for gateway and monitors.""" diff --git a/monitor/auth.py b/core/auth.py similarity index 100% rename from monitor/auth.py rename to core/auth.py diff --git a/monitor/cache.py b/core/cache.py similarity index 100% rename from monitor/cache.py rename to core/cache.py diff --git a/monitor/config.py b/core/config.py similarity index 94% rename from monitor/config.py rename to core/config.py index 8a8a020..c3ec0ad 100644 --- a/monitor/config.py +++ b/core/config.py @@ -280,6 +280,31 @@ MONGO_SERVER_SELECTION_TIMEOUT_MS = env_int("MONGO_SERVER_SELECTION_TIMEOUT_MS", # Mongo socket 超时。 MONGO_SOCKET_TIMEOUT_MS = env_int("MONGO_SOCKET_TIMEOUT_MS", 5000) +# MySQL 主机地址。 +MYSQL_HOST = env_str("MYSQL_HOST", "") +# MySQL 端口。 +MYSQL_PORT = env_int("MYSQL_PORT", 3306) +# MySQL 登录账号。 +MYSQL_USER = env_str("MYSQL_USER", "") +# MySQL 登录密码;允许空串,兼容特殊场景。 +MYSQL_PASSWORD = os.environ.get("MYSQL_PASSWORD", "") +# MySQL 默认库;为空时由页面显式选择。 +MYSQL_DEFAULT_DATABASE = env_str("MYSQL_DEFAULT_DATABASE", "") +# MySQL 指标与元数据缓存秒数。 +MYSQL_CACHE_TTL_SECONDS = env_float("MYSQL_CACHE_TTL_SECONDS", 8.0) +# MySQL 建连超时秒数。 +MYSQL_CONNECT_TIMEOUT_SECONDS = env_int("MYSQL_CONNECT_TIMEOUT_SECONDS", 5) +# MySQL 读超时秒数。 +MYSQL_READ_TIMEOUT_SECONDS = env_int("MYSQL_READ_TIMEOUT_SECONDS", 8) +# MySQL 写超时秒数。 +MYSQL_WRITE_TIMEOUT_SECONDS = env_int("MYSQL_WRITE_TIMEOUT_SECONDS", 15) +# MySQL 表浏览默认分页大小。 +MYSQL_DEFAULT_PAGE_SIZE = env_int("MYSQL_DEFAULT_PAGE_SIZE", 100) +# MySQL 表浏览最大分页大小。 +MYSQL_MAX_PAGE_SIZE = env_int("MYSQL_MAX_PAGE_SIZE", 200) +# SQL 工作台单次最多返回多少行。 +MYSQL_QUERY_MAX_ROWS = env_int("MYSQL_QUERY_MAX_ROWS", 500) + # CPU 告警阈值。 CPU_WARN_PERCENT = env_float("CPU_WARN_PERCENT", 60.0) # CPU 严重阈值。 diff --git a/monitor/env.py b/core/env.py similarity index 100% rename from monitor/env.py rename to core/env.py diff --git a/monitor/http_paths.py b/core/http_paths.py similarity index 100% rename from monitor/http_paths.py rename to core/http_paths.py diff --git a/entry/__init__.py b/entry/__init__.py new file mode 100644 index 0000000..644989b --- /dev/null +++ b/entry/__init__.py @@ -0,0 +1 @@ +"""Unified gateway entry package.""" diff --git a/gateway/http_app.py b/entry/http_app.py similarity index 99% rename from gateway/http_app.py rename to entry/http_app.py index 6cb196b..ee3c52b 100644 --- a/gateway/http_app.py +++ b/entry/http_app.py @@ -10,7 +10,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import parse_qs, urlparse -from monitor.auth import ( +from core.auth import ( build_clear_session_cookie, build_session_cookie, create_session, @@ -20,7 +20,7 @@ from monitor.auth import ( parse_session_token, authenticate_user, ) -from monitor.config import ( +from core.config import ( APP_BIND, APP_PORT, GATEWAY_PROXY_TIMEOUT_SECONDS, @@ -32,7 +32,7 @@ from monitor.config import ( YUMI_INTERNAL_PORT, utc_now, ) -from monitor.http_paths import login_redirect_target +from core.http_paths import login_redirect_target def response_json(payload: dict[str, Any] | list[Any]) -> bytes: diff --git a/gateway/__init__.py b/gateway/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/gateway/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/haiyi_monitor/__init__.py b/haiyi_monitor/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/haiyi_monitor/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/haiyi_server.py b/haiyi_server.py index cb32db5..f615575 100644 --- a/haiyi_server.py +++ b/haiyi_server.py @@ -10,7 +10,7 @@ os.environ.setdefault("APP_BIND", os.environ.get("HAIYI_INTERNAL_HOST", "127.0.0 os.environ.setdefault("APP_PORT", os.environ.get("HAIYI_INTERNAL_PORT", "2028")) os.environ["APP_BASE_PATH"] = "/haiyi" -from haiyi_monitor.http_app import main +from monitors.haiyi.http_app import main if __name__ == "__main__": diff --git a/monitor/__init__.py b/monitor/__init__.py deleted file mode 100644 index e791fb5..0000000 --- a/monitor/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""hy-app-monitor package.""" - diff --git a/monitors/__init__.py b/monitors/__init__.py new file mode 100644 index 0000000..a12bce3 --- /dev/null +++ b/monitors/__init__.py @@ -0,0 +1 @@ +"""Monitor application packages.""" diff --git a/monitors/haiyi/__init__.py b/monitors/haiyi/__init__.py new file mode 100644 index 0000000..dd8ba71 --- /dev/null +++ b/monitors/haiyi/__init__.py @@ -0,0 +1 @@ +"""HaiYi monitor package.""" diff --git a/haiyi_monitor/http_app.py b/monitors/haiyi/http_app.py similarity index 97% rename from haiyi_monitor/http_app.py rename to monitors/haiyi/http_app.py index f44bc8f..9f98976 100644 --- a/haiyi_monitor/http_app.py +++ b/monitors/haiyi/http_app.py @@ -7,8 +7,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import urlparse -from monitor.config import APP_BASE_PATH, APP_BIND, APP_PORT, INTERNAL_PROXY_SHARED_SECRET, STATIC_DIR, utc_now -from monitor.http_paths import join_base_path, strip_base_path +from core.config import APP_BASE_PATH, APP_BIND, APP_PORT, INTERNAL_PROXY_SHARED_SECRET, STATIC_DIR, utc_now +from core.http_paths import join_base_path, strip_base_path def response_json(payload: dict[str, Any] | list[Any]) -> bytes: diff --git a/monitors/yumi/__init__.py b/monitors/yumi/__init__.py new file mode 100644 index 0000000..fce7da7 --- /dev/null +++ b/monitors/yumi/__init__.py @@ -0,0 +1 @@ +"""Yumi monitor package.""" diff --git a/monitor/compose_templates.py b/monitors/yumi/compose_templates.py similarity index 99% rename from monitor/compose_templates.py rename to monitors/yumi/compose_templates.py index cf9b1d0..e5c1921 100644 --- a/monitor/compose_templates.py +++ b/monitors/yumi/compose_templates.py @@ -10,7 +10,7 @@ import shlex import re from typing import Any -from .config import ROOT, RUNTIME_BASE_DIR, load_hosts, utc_now +from core.config import ROOT, RUNTIME_BASE_DIR, load_hosts, utc_now from .runtime_config import read_remote_text, write_remote_text from .ssh_remote import copy_remote_file, run_host_script_checked diff --git a/monitor/config_validation.py b/monitors/yumi/config_validation.py similarity index 100% rename from monitor/config_validation.py rename to monitors/yumi/config_validation.py diff --git a/monitor/dashboard.py b/monitors/yumi/dashboard.py similarity index 91% rename from monitor/dashboard.py rename to monitors/yumi/dashboard.py index 18eaf5b..d3387af 100644 --- a/monitor/dashboard.py +++ b/monitors/yumi/dashboard.py @@ -2,10 +2,11 @@ from __future__ import annotations from typing import Any -from .config import THRESHOLDS, load_hosts, settings_payload, utc_now +from core.config import THRESHOLDS, load_hosts, settings_payload, utc_now from .host_metrics import get_host_metrics from .http_probe import get_service_payload from .mongo_metrics import get_mongo_payload +from .mysql_metrics import get_mysql_overview_payload from .nacos import get_nacos_payload @@ -116,6 +117,8 @@ def build_monitor_payload() -> dict[str, Any]: nacos_payload = get_nacos_payload() # 获取 Mongo 指标。 mongo_payload = get_mongo_payload() + # 获取 MySQL 指标。 + mysql_payload = get_mysql_overview_payload() # 组装主机维度视图。 grouped_hosts = build_host_payload(hosts, service_payload, metrics_by_host) # 统计主机指标成功数。 @@ -137,9 +140,13 @@ def build_monitor_payload() -> dict[str, Any]: "mongoDatabaseTotal": int(mongo_payload.get("summary", {}).get("databaseTotal", 0)), "mongoCollectionTotal": int(mongo_payload.get("summary", {}).get("collectionTotal", 0)), "mongoOk": 1 if mongo_payload.get("ok") else 0, + "mysqlDatabaseTotal": int(mysql_payload.get("summary", {}).get("databaseTotal", 0)), + "mysqlTableTotal": int(mysql_payload.get("summary", {}).get("tableTotal", 0)), + "mysqlOk": 1 if mysql_payload.get("ok") else 0, }, "hosts": grouped_hosts, "items": service_payload["items"], "nacos": nacos_payload, "mongo": mongo_payload, + "mysql": mysql_payload, } diff --git a/monitor/host_metrics.py b/monitors/yumi/host_metrics.py similarity index 97% rename from monitor/host_metrics.py rename to monitors/yumi/host_metrics.py index 6c5ea11..a537cab 100644 --- a/monitor/host_metrics.py +++ b/monitors/yumi/host_metrics.py @@ -5,8 +5,8 @@ import concurrent.futures import json from typing import Any -from .cache import TTLCache -from .config import HOST_METRIC_CACHE_TTL_SECONDS, HOST_METRIC_CPU_SAMPLE_SECONDS, HOST_METRIC_TIMEOUT_SECONDS, utc_now +from core.cache import TTLCache +from core.config import HOST_METRIC_CACHE_TTL_SECONDS, HOST_METRIC_CPU_SAMPLE_SECONDS, HOST_METRIC_TIMEOUT_SECONDS, utc_now from .ssh_remote import run_host_script diff --git a/monitor/http_app.py b/monitors/yumi/http_app.py similarity index 68% rename from monitor/http_app.py rename to monitors/yumi/http_app.py index b0961f8..b6a3e1b 100644 --- a/monitor/http_app.py +++ b/monitors/yumi/http_app.py @@ -7,9 +7,42 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import parse_qs, urlparse -from .config import APP_BASE_PATH, APP_BIND, APP_PORT, INTERNAL_PROXY_SHARED_SECRET, STATIC_DIR, utc_now +from core.config import APP_BASE_PATH, APP_BIND, APP_PORT, INTERNAL_PROXY_SHARED_SECRET, STATIC_DIR, utc_now from .dashboard import build_monitor_payload -from .http_paths import join_base_path, strip_base_path +from core.http_paths import join_base_path, strip_base_path +from .mongo_admin import ( + aggregate_mongo_documents_payload, + create_mongo_collection_payload, + create_mongo_index_payload, + delete_mongo_documents_payload, + distinct_mongo_values_payload, + drop_mongo_collection_payload, + drop_mongo_database_payload, + drop_mongo_index_payload, + drop_mongo_secondary_indexes_payload, + get_mongo_admin_overview_payload, + get_mongo_collection_detail_payload, + insert_mongo_document_payload, + query_mongo_documents_payload, + rename_mongo_collection_payload, + replace_mongo_document_payload, + update_mongo_documents_payload, +) +from .mysql_admin import ( + add_mysql_column_payload, + add_mysql_index_payload, + delete_mysql_row_payload, + drop_mysql_column_payload, + drop_mysql_index_payload, + execute_mysql_sql_payload, + get_mysql_databases_payload, + get_mysql_table_schema_payload, + get_mysql_tables_payload, + insert_mysql_row_payload, + query_mysql_table_rows_payload, + update_mysql_row_payload, +) +from .mysql_metrics import get_mysql_overview_payload from .nacos import ( get_nacos_config_list_payload, get_nacos_config_payload, @@ -178,6 +211,120 @@ class MonitorHandler(BaseHTTPRequestHandler): send_body=send_body, ) + # 返回 MySQL 概览指标。 + if local_path == "/api/mysql/overview": + try: + payload = get_mysql_overview_payload() + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body) + + return self.send_payload( + HTTPStatus.OK, + response_json(payload), + "application/json; charset=utf-8", + send_body=send_body, + ) + + # 返回 MySQL 库列表。 + if local_path == "/api/mysql/databases": + try: + payload = get_mysql_databases_payload() + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body) + + return self.send_payload( + HTTPStatus.OK, + response_json(payload), + "application/json; charset=utf-8", + send_body=send_body, + ) + + # 返回某个库下的表列表。 + if local_path == "/api/mysql/tables": + database_name = self.query_value(query, "database") + if not database_name: + return self.send_error_payload( + HTTPStatus.BAD_REQUEST, + "database is required", + send_body=send_body, + ) + + try: + payload = get_mysql_tables_payload(database_name) + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body) + + return self.send_payload( + HTTPStatus.OK, + response_json(payload), + "application/json; charset=utf-8", + send_body=send_body, + ) + + # 返回 MySQL 表结构、索引和建表语句。 + if local_path == "/api/mysql/table-schema": + database_name = self.query_value(query, "database") + table_name = self.query_value(query, "table") + if not database_name or not table_name: + return self.send_error_payload( + HTTPStatus.BAD_REQUEST, + "database and table are required", + send_body=send_body, + ) + + try: + payload = get_mysql_table_schema_payload(database_name, table_name) + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body) + + return self.send_payload( + HTTPStatus.OK, + response_json(payload), + "application/json; charset=utf-8", + send_body=send_body, + ) + + # 返回 Mongo 管理页的数据库与集合树。 + if local_path == "/api/mongo-admin/overview": + try: + payload = get_mongo_admin_overview_payload() + except ValueError as exc: + return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=send_body) + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body) + + return self.send_payload( + HTTPStatus.OK, + response_json(payload), + "application/json; charset=utf-8", + send_body=send_body, + ) + + # 返回当前集合的 stats 和索引列表。 + if local_path == "/api/mongo-admin/collection-detail": + database_name = self.query_value(query, "database") + collection_name = self.query_value(query, "collection") + if not database_name or not collection_name: + return self.send_error_payload( + HTTPStatus.BAD_REQUEST, + "database and collection are required", + send_body=send_body, + ) + + try: + payload = get_mongo_collection_detail_payload(database_name, collection_name) + except ValueError as exc: + return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=send_body) + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body) + + return self.send_payload( + HTTPStatus.OK, + response_json(payload), + "application/json; charset=utf-8", + send_body=send_body, + ) + # 返回可滚动重启的业务服务。 if local_path == "/api/ops/restart-targets": try: @@ -341,12 +488,81 @@ class MonitorHandler(BaseHTTPRequestHandler): if local_path == "/api/runtime/golang-config": return self.handle_go_save(payload) + if local_path == "/api/mysql/rows/query": + return self.handle_mysql_action(query_mysql_table_rows_payload, payload) + + if local_path == "/api/mysql/rows/insert": + return self.handle_mysql_action(insert_mysql_row_payload, payload, status=HTTPStatus.CREATED) + + if local_path == "/api/mysql/rows/update": + return self.handle_mysql_action(update_mysql_row_payload, payload) + + if local_path == "/api/mysql/rows/delete": + return self.handle_mysql_action(delete_mysql_row_payload, payload) + + if local_path == "/api/mysql/columns/add": + return self.handle_mysql_action(add_mysql_column_payload, payload) + + if local_path == "/api/mysql/columns/drop": + return self.handle_mysql_action(drop_mysql_column_payload, payload) + + if local_path == "/api/mysql/indexes/add": + return self.handle_mysql_action(add_mysql_index_payload, payload, status=HTTPStatus.CREATED) + + if local_path == "/api/mysql/indexes/drop": + return self.handle_mysql_action(drop_mysql_index_payload, payload) + + if local_path == "/api/mysql/sql/execute": + return self.handle_mysql_action(execute_mysql_sql_payload, payload) + if local_path == "/api/ops/rolling-restart": return self.handle_rolling_restart(payload) if local_path == "/api/ops/update-services": return self.handle_update_services(payload) + if local_path == "/api/mongo-admin/query": + return self.handle_mongo_admin_action(query_mongo_documents_payload, payload) + + if local_path == "/api/mongo-admin/documents/insert": + return self.handle_mongo_admin_action(insert_mongo_document_payload, payload, status=HTTPStatus.CREATED) + + if local_path == "/api/mongo-admin/documents/replace": + return self.handle_mongo_admin_action(replace_mongo_document_payload, payload) + + if local_path == "/api/mongo-admin/documents/update": + return self.handle_mongo_admin_action(update_mongo_documents_payload, payload) + + if local_path == "/api/mongo-admin/documents/delete": + return self.handle_mongo_admin_action(delete_mongo_documents_payload, payload) + + if local_path == "/api/mongo-admin/aggregate": + return self.handle_mongo_admin_action(aggregate_mongo_documents_payload, payload) + + if local_path == "/api/mongo-admin/distinct": + return self.handle_mongo_admin_action(distinct_mongo_values_payload, payload) + + if local_path == "/api/mongo-admin/indexes/create": + return self.handle_mongo_admin_action(create_mongo_index_payload, payload, status=HTTPStatus.CREATED) + + if local_path == "/api/mongo-admin/indexes/drop": + return self.handle_mongo_admin_action(drop_mongo_index_payload, payload) + + if local_path == "/api/mongo-admin/indexes/drop-secondary": + return self.handle_mongo_admin_action(drop_mongo_secondary_indexes_payload, payload) + + if local_path == "/api/mongo-admin/collections/create": + return self.handle_mongo_admin_action(create_mongo_collection_payload, payload, status=HTTPStatus.CREATED) + + if local_path == "/api/mongo-admin/collections/rename": + return self.handle_mongo_admin_action(rename_mongo_collection_payload, payload) + + if local_path == "/api/mongo-admin/collections/drop": + return self.handle_mongo_admin_action(drop_mongo_collection_payload, payload) + + if local_path == "/api/mongo-admin/databases/drop": + return self.handle_mongo_admin_action(drop_mongo_database_payload, payload) + return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True) def redirect(self, location: str, *, send_body: bool) -> None: @@ -444,6 +660,27 @@ class MonitorHandler(BaseHTTPRequestHandler): send_body=True, ) + def handle_mysql_action( + self, + action: Any, + payload: dict[str, Any], + *, + status: HTTPStatus = HTTPStatus.OK, + ) -> None: + try: + result = action(payload) + except ValueError as exc: + return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True) + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True) + + return self.send_payload( + status, + response_json(result), + "application/json; charset=utf-8", + send_body=True, + ) + def handle_rolling_restart(self, payload: dict[str, Any]) -> None: services = payload.get("services") if not isinstance(services, list): @@ -491,6 +728,28 @@ class MonitorHandler(BaseHTTPRequestHandler): send_body=True, ) + def handle_mongo_admin_action( + self, + builder: Any, + payload: dict[str, Any], + *, + status: HTTPStatus = HTTPStatus.OK, + ) -> None: + try: + # Mongo 管理页所有写操作和查询操作都统一走这里收敛异常。 + result = builder(payload) + except ValueError as exc: + return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True) + except Exception as exc: # noqa: BLE001 + return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True) + + return self.send_payload( + status, + response_json(result), + "application/json; charset=utf-8", + send_body=True, + ) + def query_value(self, query: dict[str, list[str]], key: str) -> str: # 统一读取单值 query 参数。 return str((query.get(key) or [""])[0]).strip() diff --git a/monitor/http_probe.py b/monitors/yumi/http_probe.py similarity index 97% rename from monitor/http_probe.py rename to monitors/yumi/http_probe.py index 0b4fc1d..a2d1871 100644 --- a/monitor/http_probe.py +++ b/monitors/yumi/http_probe.py @@ -6,8 +6,8 @@ import urllib.error import urllib.request import time -from .cache import TTLCache -from .config import PROBE_CACHE_TTL_SECONDS, PROBE_MAX_WORKERS, PROBE_TIMEOUT_SECONDS, flatten_services, utc_now +from core.cache import TTLCache +from core.config import PROBE_CACHE_TTL_SECONDS, PROBE_MAX_WORKERS, PROBE_TIMEOUT_SECONDS, flatten_services, utc_now # 服务探测使用独立缓存,避免页面轮询直接把内网接口打爆。 diff --git a/monitors/yumi/mongo_admin.py b/monitors/yumi/mongo_admin.py new file mode 100644 index 0000000..53f1645 --- /dev/null +++ b/monitors/yumi/mongo_admin.py @@ -0,0 +1,879 @@ +from __future__ import annotations + +import json +from typing import Any + +from core.config import MONGO_DB_NAME, utc_now +from .mongo_metrics import build_mongo_client + + +# 文档浏览默认每页 100 条,和页面默认值保持一致。 +DEFAULT_PAGE_SIZE = 100 +# 单次最多放开到 200 条,避免把浏览器一次撑爆。 +MAX_PAGE_SIZE = 200 +# 浏览页默认按 _id 倒序,和大多数运维排查习惯一致。 +DEFAULT_SORT_SPEC = {"_id": -1} +# 列表里的 JSON 预览文本做一个上限,避免整行被超长文档拉坏。 +PREVIEW_TEXT_LIMIT = 280 +# Mongo 常见索引类型统一在这里收口,页面允许直接传这些值。 +ALLOWED_INDEX_DIRECTIONS = {"text", "hashed", "2dsphere", "2d"} + + +def require_bson_json_util() -> Any: + try: + # PyMongo 安装后会自带 bson 包,这里延迟导入避免缺依赖时整个服务起不来。 + from bson import json_util + except Exception as exc: # noqa: BLE001 + # 管理页不可用时直接把依赖错误返回给上层。 + raise RuntimeError(f"bson unavailable: {exc}") from exc + + # 返回 bson 的扩展 JSON 工具。 + return json_util + + +def get_mongo_client() -> Any: + # 统一复用现有 Mongo 建连逻辑,避免管理页和监控页配置口径分叉。 + client, error = build_mongo_client() + + # 建连失败时直接抛统一错误。 + if client is None: + raise RuntimeError(error) + + # 先做一次 ping,尽早把连通性错误暴露出来。 + client.admin.command("ping") + return client + + +def clamp_page_size(value: Any, default: int = DEFAULT_PAGE_SIZE) -> int: + try: + # 页面传来的值可能是字符串,这里统一转整数。 + numeric = int(value) + except Exception: # noqa: BLE001 + numeric = default + + # 最终把页大小限制在一个安全区间内。 + return max(1, min(numeric, MAX_PAGE_SIZE)) + + +def clamp_page(value: Any, default: int = 1) -> int: + try: + # 页码同样允许前端传字符串。 + numeric = int(value) + except Exception: # noqa: BLE001 + numeric = default + + # 页码至少从 1 开始。 + return max(1, numeric) + + +def require_name(value: Any, field_name: str) -> str: + # 数据库名、集合名、索引名都按最基础的字符串校验处理。 + name = str(value or "").strip() + + # 名称缺失时直接报错。 + if not name: + raise ValueError(f"{field_name} is required") + + # Mongo 名称里不允许带空字节。 + if "\x00" in name: + raise ValueError(f"{field_name} contains invalid character") + + return name + + +def ensure_mutable_collection_name(name: str) -> str: + collection_name = require_name(name, "collection") + + # 系统集合默认不开放结构变更,避免误删系统元数据。 + if collection_name.startswith("system."): + raise ValueError("system collections are read only in this page") + + return collection_name + + +def parse_extended_json(value: Any, field_name: str, *, default: Any = None) -> Any: + # 空值时直接回退默认值,方便页面把空输入视作 {} 或 []。 + if value is None: + return default + + # 字符串输入按原文解析,避免前端先 stringify 再导致转义双重嵌套。 + if isinstance(value, str): + text = value.strip() + if not text: + return default + else: + # 对象输入统一先转成标准 JSON 文本,再交给 bson 扩展解析。 + text = json.dumps(value, ensure_ascii=False) + + try: + return require_bson_json_util().loads(text) + except Exception as exc: # noqa: BLE001 + raise ValueError(f"invalid {field_name}: {exc}") from exc + + +def dump_extended_json(value: Any, *, pretty: bool = False) -> str: + helper = require_bson_json_util() + options = getattr(helper, "RELAXED_JSON_OPTIONS", None) + + # 列表页和编辑器都统一输出 relaxed extended JSON,前后端回写更稳定。 + return helper.dumps( + value, + ensure_ascii=False, + indent=2 if pretty else None, + json_options=options, + ) + + +def to_builtin_json(value: Any) -> Any: + # 先转扩展 JSON,再回到纯 Python 结构,这样 http_app 可以继续用标准 json.dumps。 + return json.loads(dump_extended_json(value, pretty=False)) + + +def compact_json_preview(value: Any, limit: int = PREVIEW_TEXT_LIMIT) -> str: + # 预览统一压成单行,避免表格高度失控。 + compact = dump_extended_json(value, pretty=False).replace("\n", " ").strip() + if len(compact) <= limit: + return compact + return f"{compact[:limit]}..." + + +def mongo_document_row(document: dict[str, Any], *, page: int, position: int) -> dict[str, Any]: + # 单条文档既返回结构化内容,也返回编辑器直接可用的 JSON 文本。 + document_json = dump_extended_json(document, pretty=True) + document_id = document.get("_id") + + return { + "key": f"{page}-{position}-{compact_json_preview(document_id, limit=64)}", + "idText": compact_json_preview(document_id, limit=72), + "idFilterText": dump_extended_json({"_id": document_id}, pretty=True), + "previewText": compact_json_preview(document), + "jsonText": document_json, + "document": to_builtin_json(document), + } + + +def normalize_sort_spec(sort_value: Any) -> list[tuple[str, int]]: + parsed = parse_extended_json(sort_value, "sort", default=DEFAULT_SORT_SPEC) + + # 浏览页主路径只支持对象排序,界面也按这个格式产出。 + if not isinstance(parsed, dict): + raise ValueError("sort must be a json object") + + items: list[tuple[str, int]] = [] + for field_name, direction in parsed.items(): + name = str(field_name or "").strip() + if not name: + continue + + try: + numeric_direction = int(direction) + except Exception as exc: # noqa: BLE001 + raise ValueError(f"invalid sort direction for {name}") from exc + + if numeric_direction not in {-1, 1}: + raise ValueError(f"sort direction for {name} must be 1 or -1") + + items.append((name, numeric_direction)) + + # 排序对象全空时回退默认值。 + return items or list(DEFAULT_SORT_SPEC.items()) + + +def normalize_index_keys(keys_value: Any) -> list[tuple[str, Any]]: + parsed = parse_extended_json(keys_value, "keys", default={}) + + # 索引创建页同样只接受对象格式。 + if not isinstance(parsed, dict): + raise ValueError("keys must be a json object") + + keys: list[tuple[str, Any]] = [] + for field_name, direction in parsed.items(): + name = str(field_name or "").strip() + if not name: + continue + + if isinstance(direction, str): + normalized_direction = direction.strip().lower() + if normalized_direction in ALLOWED_INDEX_DIRECTIONS: + keys.append((name, normalized_direction)) + continue + + try: + numeric_direction = int(normalized_direction) + except Exception as exc: # noqa: BLE001 + raise ValueError( + f"index direction for {name} must be 1, -1, text, hashed, 2dsphere or 2d", + ) from exc + else: + try: + numeric_direction = int(direction) + except Exception as exc: # noqa: BLE001 + raise ValueError( + f"index direction for {name} must be 1, -1, text, hashed, 2dsphere or 2d", + ) from exc + + if numeric_direction not in {-1, 1}: + raise ValueError(f"index direction for {name} must be 1, -1, text, hashed, 2dsphere or 2d") + + keys.append((name, numeric_direction)) + + if not keys: + raise ValueError("keys must not be empty") + + return keys + + +def require_json_object(value: Any, field_name: str, *, default: dict[str, Any] | None = None) -> dict[str, Any]: + parsed = parse_extended_json(value, field_name, default=default) + if parsed is None: + return {} + + # filter / projection / options / replacement 都要求对象。 + if not isinstance(parsed, dict): + raise ValueError(f"{field_name} must be a json object") + + return parsed + + +def require_json_array(value: Any, field_name: str, *, default: list[Any] | None = None) -> list[Any]: + parsed = parse_extended_json(value, field_name, default=default) + if parsed is None: + return [] + + # aggregation pipeline 明确要求数组。 + if not isinstance(parsed, list): + raise ValueError(f"{field_name} must be a json array") + + return parsed + + +def require_insert_documents(value: Any) -> tuple[list[dict[str, Any]], bool]: + # Mongo 写入既支持单文档,也支持批量数组。 + parsed = parse_extended_json(value, "document", default={}) + + if isinstance(parsed, dict): + return [parsed], False + + if not isinstance(parsed, list): + raise ValueError("document must be a json object or json array") + + if not parsed: + raise ValueError("document array must not be empty") + + documents: list[dict[str, Any]] = [] + for index, item in enumerate(parsed, start=1): + if not isinstance(item, dict): + raise ValueError(f"document[{index}] must be a json object") + documents.append(item) + + return documents, True + + +def ensure_safe_aggregate_pipeline(pipeline: list[Any]) -> list[Any]: + for stage in pipeline: + # 每个 stage 都必须是对象,否则驱动会直接报错,这里提前统一提示。 + if not isinstance(stage, dict): + raise ValueError("aggregate pipeline stage must be a json object") + + # 当前页面不开放会落地写数据的聚合阶段。 + stage_names = {str(key or "") for key in stage.keys()} + if "$out" in stage_names or "$merge" in stage_names: + raise ValueError("aggregate pipeline does not allow $out or $merge") + + return pipeline + + +def collection_handle(client: Any, database_name: str, collection_name: str) -> Any: + # 集合句柄统一从这里拿,保证数据库名和集合名都先校验。 + return client[require_name(database_name, "database")][require_name(collection_name, "collection")] + + +def collection_stats_payload(client: Any, database_name: str, collection_name: str) -> dict[str, Any]: + database = client[require_name(database_name, "database")] + collection = require_name(collection_name, "collection") + + # collStats 能拿到集合大小、文档数、索引数等管理页最常用信息。 + stats = database.command("collStats", collection) + index_list = list(database[collection].list_indexes()) + + indexes = [] + for item in index_list: + key_spec = dict(item.get("key") or {}) + indexes.append( + { + "name": str(item.get("name") or "").strip(), + "keys": to_builtin_json(key_spec), + "keysText": dump_extended_json(key_spec, pretty=True), + "unique": bool(item.get("unique")), + "sparse": bool(item.get("sparse")), + "hidden": bool(item.get("hidden")), + "expireAfterSeconds": item.get("expireAfterSeconds"), + "partialFilterExpression": to_builtin_json(item.get("partialFilterExpression")) + if item.get("partialFilterExpression") is not None + else None, + "partialFilterExpressionText": dump_extended_json(item.get("partialFilterExpression"), pretty=True) + if item.get("partialFilterExpression") is not None + else "", + } + ) + + # 把 _id_ 放在第一行,其他索引按名称排序。 + indexes.sort(key=lambda item: (0 if item["name"] == "_id_" else 1, item["name"])) + + return { + "database": database_name, + "collection": collection_name, + "updatedAt": utc_now(), + "stats": { + "count": int(stats.get("count") or 0), + "sizeMb": round(float(stats.get("size") or 0) / 1024 / 1024, 2), + "storageSizeMb": round(float(stats.get("storageSize") or 0) / 1024 / 1024, 2), + "avgObjSizeBytes": round(float(stats.get("avgObjSize") or 0), 2), + "indexCount": int(stats.get("nindexes") or 0), + "totalIndexSizeMb": round(float(stats.get("totalIndexSize") or 0) / 1024 / 1024, 2), + "capped": bool(stats.get("capped")), + "wiredTigerUri": str((stats.get("wiredTiger") or {}).get("uri") or "").strip(), + }, + "indexes": indexes, + } + + +def get_mongo_admin_overview_payload() -> dict[str, Any]: + client = get_mongo_client() + + try: + # 概览页既要列数据库,也要列集合,所以这里一次性把树形结构拼完。 + databases_raw = list((client.admin.command("listDatabases", 1).get("databases") or [])) + overview_items: list[dict[str, Any]] = [] + + for database_item in databases_raw: + database_name = str(database_item.get("name") or "").strip() + if not database_name: + continue + + database = client[database_name] + collection_items: list[dict[str, Any]] = [] + + # listCollections 先拿到集合名和类型,再按需补充 stats。 + for collection_info in database.list_collections(): + collection_name = str(collection_info.get("name") or "").strip() + if not collection_name: + continue + + try: + collection_stats = database.command("collStats", collection_name) + collection_items.append( + { + "name": collection_name, + "type": str(collection_info.get("type") or "collection").strip() or "collection", + "documentCount": int(collection_stats.get("count") or 0), + "sizeMb": round(float(collection_stats.get("size") or 0) / 1024 / 1024, 2), + "storageSizeMb": round( + float(collection_stats.get("storageSize") or 0) / 1024 / 1024, + 2, + ), + "indexCount": int(collection_stats.get("nindexes") or 0), + "capped": bool(collection_stats.get("capped")), + "error": "", + } + ) + except Exception as exc: # noqa: BLE001 + # 某个集合取 stats 失败时仍保留在树里,方便继续点进去排查。 + collection_items.append( + { + "name": collection_name, + "type": str(collection_info.get("type") or "collection").strip() or "collection", + "documentCount": 0, + "sizeMb": 0.0, + "storageSizeMb": 0.0, + "indexCount": 0, + "capped": False, + "error": str(exc), + } + ) + + # 集合列表默认按名字排序,侧边 explorer 更稳定。 + collection_items.sort(key=lambda item: item["name"].lower()) + + overview_items.append( + { + "name": database_name, + "empty": bool(database_item.get("empty")), + "sizeOnDiskMb": round(float(database_item.get("sizeOnDisk") or 0) / 1024 / 1024, 2), + "collectionCount": len(collection_items), + "collections": collection_items, + } + ) + + # 聚焦库优先,其余按磁盘占用倒序,和现有 Mongo 指标页一致。 + overview_items.sort( + key=lambda item: ( + 0 if item["name"] == MONGO_DB_NAME else 1, + -float(item.get("sizeOnDiskMb") or 0.0), + item["name"].lower(), + ) + ) + + default_database = next((item["name"] for item in overview_items if item["name"] == MONGO_DB_NAME), "") + if not default_database and overview_items: + default_database = overview_items[0]["name"] + + default_collection = "" + if default_database: + default_database_item = next((item for item in overview_items if item["name"] == default_database), None) + if default_database_item and default_database_item["collections"]: + default_collection = default_database_item["collections"][0]["name"] + + return { + "ok": True, + "updatedAt": utc_now(), + "focusedDatabase": MONGO_DB_NAME, + "selectedDatabase": default_database, + "selectedCollection": default_collection, + "items": overview_items, + } + finally: + # 管理页操作完成后立即关连接,避免长期挂着无用游标。 + client.close() + + +def get_mongo_collection_detail_payload(database_name: str, collection_name: str) -> dict[str, Any]: + client = get_mongo_client() + + try: + return collection_stats_payload(client, database_name, collection_name) + finally: + client.close() + + +def query_mongo_documents_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = require_name(payload.get("collection"), "collection") + page = clamp_page(payload.get("page"), default=1) + page_size = clamp_page_size(payload.get("pageSize"), default=DEFAULT_PAGE_SIZE) + filter_spec = require_json_object(payload.get("filter"), "filter", default={}) + projection_spec = require_json_object(payload.get("projection"), "projection", default={}) + sort_spec = normalize_sort_spec(payload.get("sort")) + + # 页面上的编辑和删除都依赖 _id,所以这里强制保留。 + if projection_spec.get("_id") == 0: + projection_spec = dict(projection_spec) + projection_spec.pop("_id", None) + + client = get_mongo_client() + + try: + collection = collection_handle(client, database_name, collection_name) + total = collection.count_documents(filter_spec) + skip = max(page - 1, 0) * page_size + + cursor = collection.find(filter_spec, projection_spec or None).sort(sort_spec).skip(skip).limit(page_size) + documents = list(cursor) + rows = [ + mongo_document_row(document, page=page, position=skip + index) + for index, document in enumerate(documents, start=1) + ] + + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": collection_name, + "page": page, + "pageSize": page_size, + "total": total, + "pageCount": max((total + page_size - 1) // page_size, 1), + "filterText": dump_extended_json(filter_spec, pretty=True), + "projectionText": dump_extended_json(projection_spec, pretty=True), + "sortText": dump_extended_json(dict(sort_spec), pretty=True), + "items": rows, + } + finally: + client.close() + + +def insert_mongo_document_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = ensure_mutable_collection_name(payload.get("collection")) + documents, many = require_insert_documents(payload.get("document")) + ordered = bool(payload.get("ordered", True)) + + client = get_mongo_client() + + try: + collection = collection_handle(client, database_name, collection_name) + if many: + result = collection.insert_many(documents, ordered=ordered) + inserted_ids = [to_builtin_json(inserted_id) for inserted_id in result.inserted_ids] + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": collection_name, + "insertedCount": len(inserted_ids), + "insertedIds": inserted_ids, + "message": "insertMany completed", + } + + result = collection.insert_one(documents[0]) + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": collection_name, + "insertedCount": 1, + "insertedId": to_builtin_json(result.inserted_id), + "insertedIdText": dump_extended_json(result.inserted_id, pretty=False), + "message": "insertOne completed", + } + finally: + client.close() + + +def replace_mongo_document_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = ensure_mutable_collection_name(payload.get("collection")) + filter_spec = require_json_object(payload.get("filter"), "filter", default={}) + replacement = require_json_object(payload.get("document"), "document") + upsert = bool(payload.get("upsert")) + + # replaceOne 只接受完整文档,不能混进 $set 这类更新操作符。 + if any(str(key).startswith("$") for key in replacement.keys()): + raise ValueError("replace document must not contain update operators") + + client = get_mongo_client() + + try: + result = collection_handle(client, database_name, collection_name).replace_one( + filter_spec, + replacement, + upsert=upsert, + ) + return { + "ok": True, + "updatedAt": utc_now(), + "matchedCount": int(result.matched_count), + "modifiedCount": int(result.modified_count), + "upsertedId": to_builtin_json(result.upserted_id) if result.upserted_id is not None else None, + "message": "replaceOne completed", + } + finally: + client.close() + + +def update_mongo_documents_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = ensure_mutable_collection_name(payload.get("collection")) + filter_spec = require_json_object(payload.get("filter"), "filter", default={}) + update_spec = parse_extended_json(payload.get("update"), "update", default={}) + many = bool(payload.get("many")) + upsert = bool(payload.get("upsert")) + allow_empty_filter = bool(payload.get("allowEmptyFilter")) + + # 空 filter + 多文档更新风险过高,前端必须显式确认。 + if many and not filter_spec and not allow_empty_filter: + raise ValueError("empty filter requires explicit confirmation") + + # update 支持操作符对象或更新管道数组。 + if isinstance(update_spec, dict): + if not update_spec: + raise ValueError("update must not be empty") + if not any(str(key).startswith("$") for key in update_spec.keys()): + raise ValueError("update must contain update operators or use replace") + elif isinstance(update_spec, list): + ensure_safe_aggregate_pipeline(update_spec) + else: + raise ValueError("update must be a json object or json array") + + client = get_mongo_client() + + try: + collection = collection_handle(client, database_name, collection_name) + result = ( + collection.update_many(filter_spec, update_spec, upsert=upsert) + if many + else collection.update_one(filter_spec, update_spec, upsert=upsert) + ) + return { + "ok": True, + "updatedAt": utc_now(), + "matchedCount": int(result.matched_count), + "modifiedCount": int(result.modified_count), + "upsertedId": to_builtin_json(result.upserted_id) if result.upserted_id is not None else None, + "message": "updateMany completed" if many else "updateOne completed", + } + finally: + client.close() + + +def delete_mongo_documents_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = ensure_mutable_collection_name(payload.get("collection")) + filter_spec = require_json_object(payload.get("filter"), "filter", default={}) + many = bool(payload.get("many")) + allow_empty_filter = bool(payload.get("allowEmptyFilter")) + + # deleteMany 空 filter 同样要求显式确认。 + if many and not filter_spec and not allow_empty_filter: + raise ValueError("empty filter requires explicit confirmation") + + client = get_mongo_client() + + try: + collection = collection_handle(client, database_name, collection_name) + result = collection.delete_many(filter_spec) if many else collection.delete_one(filter_spec) + return { + "ok": True, + "updatedAt": utc_now(), + "deletedCount": int(result.deleted_count), + "message": "deleteMany completed" if many else "deleteOne completed", + } + finally: + client.close() + + +def aggregate_mongo_documents_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = require_name(payload.get("collection"), "collection") + page = clamp_page(payload.get("page"), default=1) + page_size = clamp_page_size(payload.get("pageSize"), default=DEFAULT_PAGE_SIZE) + pipeline = ensure_safe_aggregate_pipeline(require_json_array(payload.get("pipeline"), "pipeline", default=[])) + + client = get_mongo_client() + + try: + collection = collection_handle(client, database_name, collection_name) + skip = max(page - 1, 0) * page_size + + # 先跑计数管道,再跑当前页数据。 + total_result = list(collection.aggregate([*pipeline, {"$count": "total"}])) + total = int((total_result[0] or {}).get("total") or 0) if total_result else 0 + documents = list(collection.aggregate([*pipeline, {"$skip": skip}, {"$limit": page_size}])) + rows = [ + { + "key": f"agg-{page}-{skip + index}", + "idText": compact_json_preview(document.get("_id"), limit=72) if "_id" in document else "-", + "idFilterText": dump_extended_json({"_id": document.get("_id")}, pretty=True) + if "_id" in document + else "{}", + "previewText": compact_json_preview(document), + "jsonText": dump_extended_json(document, pretty=True), + "document": to_builtin_json(document), + } + for index, document in enumerate(documents, start=1) + ] + + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": collection_name, + "page": page, + "pageSize": page_size, + "total": total, + "pageCount": max((total + page_size - 1) // page_size, 1), + "pipelineText": dump_extended_json(pipeline, pretty=True), + "items": rows, + } + finally: + client.close() + + +def distinct_mongo_values_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = require_name(payload.get("collection"), "collection") + field_name = require_name(payload.get("field"), "field") + filter_spec = require_json_object(payload.get("filter"), "filter", default={}) + + client = get_mongo_client() + + try: + values = collection_handle(client, database_name, collection_name).distinct(field_name, filter_spec) + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": collection_name, + "field": field_name, + "filterText": dump_extended_json(filter_spec, pretty=True), + "count": len(values), + "values": to_builtin_json(values), + "valuesText": dump_extended_json(values, pretty=True), + } + finally: + client.close() + + +def create_mongo_index_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = ensure_mutable_collection_name(payload.get("collection")) + index_keys = normalize_index_keys(payload.get("keys")) + index_options = require_json_object(payload.get("options"), "options", default={}) + + # name 允许前端留空,驱动会自动生成。 + if not str(index_options.get("name") or "").strip(): + index_options.pop("name", None) + + client = get_mongo_client() + + try: + index_name = collection_handle(client, database_name, collection_name).create_index(index_keys, **index_options) + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": collection_name, + "indexName": str(index_name), + "message": "createIndex completed", + } + finally: + client.close() + + +def drop_mongo_index_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = ensure_mutable_collection_name(payload.get("collection")) + index_name = require_name(payload.get("indexName"), "indexName") + + client = get_mongo_client() + + try: + collection_handle(client, database_name, collection_name).drop_index(index_name) + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": collection_name, + "indexName": index_name, + "message": "dropIndex completed", + } + finally: + client.close() + + +def drop_mongo_secondary_indexes_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = ensure_mutable_collection_name(payload.get("collection")) + + client = get_mongo_client() + + try: + collection = collection_handle(client, database_name, collection_name) + index_names = [ + str(item.get("name") or "").strip() + for item in collection.list_indexes() + if str(item.get("name") or "").strip() and str(item.get("name") or "").strip() != "_id_" + ] + + # Mongo 的 drop_indexes 会保留 _id_ 索引,这里先把删除名单回给前端方便确认结果。 + if not index_names: + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": collection_name, + "droppedCount": 0, + "droppedNames": [], + "message": "no secondary indexes", + } + + collection.drop_indexes() + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": collection_name, + "droppedCount": len(index_names), + "droppedNames": index_names, + "message": "dropIndexes completed", + } + finally: + client.close() + + +def create_mongo_collection_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = ensure_mutable_collection_name(payload.get("collection")) + options = require_json_object(payload.get("options"), "options", default={}) + + client = get_mongo_client() + + try: + client[database_name].create_collection(collection_name, **options) + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": collection_name, + "message": "createCollection completed", + } + finally: + client.close() + + +def rename_mongo_collection_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + source_name = ensure_mutable_collection_name(payload.get("collection")) + target_name = ensure_mutable_collection_name(payload.get("targetCollection")) + drop_target = bool(payload.get("dropTarget")) + + client = get_mongo_client() + + try: + collection_handle(client, database_name, source_name).rename(target_name, dropTarget=drop_target) + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": source_name, + "targetCollection": target_name, + "message": "renameCollection completed", + } + finally: + client.close() + + +def drop_mongo_collection_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + collection_name = ensure_mutable_collection_name(payload.get("collection")) + confirm_name = require_name(payload.get("confirmName"), "confirmName") + + # 删除集合前要求前端把目标名再传一遍,减少误触。 + if confirm_name != collection_name: + raise ValueError("confirmName does not match collection") + + client = get_mongo_client() + + try: + collection_handle(client, database_name, collection_name).drop() + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "collection": collection_name, + "message": "dropCollection completed", + } + finally: + client.close() + + +def drop_mongo_database_payload(payload: dict[str, Any]) -> dict[str, Any]: + database_name = require_name(payload.get("database"), "database") + confirm_name = require_name(payload.get("confirmName"), "confirmName") + + # 删除数据库同样要求二次校验目标名。 + if confirm_name != database_name: + raise ValueError("confirmName does not match database") + + client = get_mongo_client() + + try: + client.drop_database(database_name) + return { + "ok": True, + "updatedAt": utc_now(), + "database": database_name, + "message": "dropDatabase completed", + } + finally: + client.close() diff --git a/monitor/mongo_metrics.py b/monitors/yumi/mongo_metrics.py similarity index 99% rename from monitor/mongo_metrics.py rename to monitors/yumi/mongo_metrics.py index b391506..37503b0 100644 --- a/monitor/mongo_metrics.py +++ b/monitors/yumi/mongo_metrics.py @@ -3,8 +3,8 @@ from __future__ import annotations from datetime import datetime, timezone from typing import Any -from .cache import TTLCache -from .config import ( +from core.cache import TTLCache +from core.config import ( MONGO_CACHE_TTL_SECONDS, MONGO_CONNECT_TIMEOUT_MS, MONGO_DB_NAME, diff --git a/monitors/yumi/mysql_admin.py b/monitors/yumi/mysql_admin.py new file mode 100644 index 0000000..18d7239 --- /dev/null +++ b/monitors/yumi/mysql_admin.py @@ -0,0 +1,719 @@ +from __future__ import annotations + +import json +from typing import Any + +from core.config import MYSQL_DEFAULT_DATABASE, MYSQL_DEFAULT_PAGE_SIZE, MYSQL_MAX_PAGE_SIZE, MYSQL_QUERY_MAX_ROWS, utc_now +from .mysql_common import ( + build_mysql_connection, + build_row_identity, + database_exists, + get_preferred_identity_columns, + list_database_summaries, + list_table_columns, + list_table_indexes, + list_table_summaries, + normalize_column_name, + normalize_database_name, + normalize_mysql_row, + normalize_sql_text, + normalize_table_name, + quote_identifier, + sql_statement_kind, + table_exists, +) +from .mysql_metrics import clear_mysql_metrics_cache, get_mysql_overview_payload + + +def browser_error_payload(error: str) -> dict[str, Any]: + # 浏览器和 SQL 工作台都统一返回这套最小错误结构。 + return {"ok": False, "updatedAt": utc_now(), "error": error} + + +def clamp_page_size(value: Any) -> int: + try: + # 前端没传时回到默认 100;过大时强制截断,避免一次拖太多数据。 + return max(1, min(int(value or MYSQL_DEFAULT_PAGE_SIZE), MYSQL_MAX_PAGE_SIZE)) + except Exception: # noqa: BLE001 + return MYSQL_DEFAULT_PAGE_SIZE + + +def clamp_page_number(value: Any) -> int: + try: + # 页码最小固定为 1。 + return max(1, int(value or 1)) + except Exception: # noqa: BLE001 + return 1 + + +def normalize_sql_fragment(value: Any, *, field_name: str) -> str: + # DDL 片段允许空格和括号,但不允许多语句。 + actual = str(value or "").strip() + if not actual: + raise ValueError(f"{field_name} is required") + if ";" in actual: + raise ValueError(f"{field_name} must be a single fragment") + return actual + + +def get_mysql_databases_payload() -> dict[str, Any]: + # 概览里已经做了缓存,这里直接复用,避免重复查 information_schema。 + overview = get_mysql_overview_payload() + if not overview.get("ok"): + return { + "ok": False, + "updatedAt": overview.get("updatedAt"), + "selectedDatabase": "", + "items": [], + "error": overview.get("error") or "mysql unavailable", + } + + items = list(overview.get("databases") or []) + business_items = [item for item in items if not item.get("system")] + preferred = str(MYSQL_DEFAULT_DATABASE or "").strip() + + # 默认优先选 .env 指定库;没有时选第一个业务库,再退到第一个可见库。 + selected_database = ( + preferred + if preferred and any(item.get("name") == preferred for item in items) + else (business_items[0]["name"] if business_items else (items[0]["name"] if items else "")) + ) + + return { + "ok": True, + "updatedAt": overview.get("updatedAt"), + "selectedDatabase": selected_database, + "items": items, + "error": "", + } + + +def get_mysql_tables_payload(database: str) -> dict[str, Any]: + target_database = normalize_database_name(database) + + try: + with build_mysql_connection(autocommit=True) as connection: + if not database_exists(connection, target_database): + raise ValueError(f"database not found: {target_database}") + items = list_table_summaries(connection, target_database) + except Exception as exc: # noqa: BLE001 + return { + "ok": False, + "updatedAt": utc_now(), + "database": target_database, + "items": [], + "error": str(exc), + } + + return { + "ok": True, + "updatedAt": utc_now(), + "database": target_database, + "selectedTable": items[0]["name"] if items else "", + "items": items, + "error": "", + } + + +def get_mysql_table_schema_payload(database: str, table: str) -> dict[str, Any]: + target_database = normalize_database_name(database) + target_table = normalize_table_name(table) + + try: + with build_mysql_connection(target_database, autocommit=True) as connection: + if not table_exists(connection, target_database, target_table): + raise ValueError(f"table not found: {target_database}.{target_table}") + + tables = list_table_summaries(connection, target_database) + table_summary = next((item for item in tables if item.get("name") == target_table), None) + columns = list_table_columns(connection, target_database, target_table) + indexes = list_table_indexes(connection, target_database, target_table) + + with connection.cursor() as cursor: + # SHOW CREATE TABLE 能拿到最完整的 DDL,方便页面直接展示和复制。 + cursor.execute(f"SHOW CREATE TABLE {quote_identifier(target_table)}") + create_row = cursor.fetchone() or {} + except Exception as exc: # noqa: BLE001 + return { + "ok": False, + "updatedAt": utc_now(), + "database": target_database, + "table": target_table, + "columns": [], + "indexes": [], + "createTable": "", + "error": str(exc), + } + + return { + "ok": True, + "updatedAt": utc_now(), + "database": target_database, + "table": target_table, + "tableSummary": table_summary or {}, + "columns": columns, + "indexes": indexes, + "identity": get_preferred_identity_columns(columns, indexes), + "createTable": str(create_row.get("Create Table") or "").strip(), + "error": "", + } + + +def build_filter_clause( + columns: list[dict[str, Any]], + keyword: str, + filters: list[dict[str, Any]], +) -> tuple[str, list[Any]]: + column_names = {column["name"] for column in columns} + clauses: list[str] = [] + params: list[Any] = [] + normalized_keyword = str(keyword or "").strip() + + # 关键字搜索默认对当前表全部列做模糊匹配,适合值班快速扫数据。 + if normalized_keyword: + search_clauses: list[str] = [] + for column in columns: + search_clauses.append(f"CAST({quote_identifier(column['name'])} AS CHAR) LIKE %s") + params.append(f"%{normalized_keyword}%") + if search_clauses: + clauses.append(f"({' OR '.join(search_clauses)})") + + # 附加过滤器支持前端后续扩展成多条件。 + for item in filters: + column_name = normalize_column_name(item.get("column")) + if column_name not in column_names: + raise ValueError(f"unknown filter column: {column_name}") + + operator = str(item.get("operator") or "eq").strip().lower() + sql_column = quote_identifier(column_name) + value = item.get("value") + + if operator == "eq": + if value is None: + clauses.append(f"{sql_column} IS NULL") + else: + clauses.append(f"{sql_column} = %s") + params.append(value) + continue + if operator == "ne": + if value is None: + clauses.append(f"{sql_column} IS NOT NULL") + else: + clauses.append(f"{sql_column} <> %s") + params.append(value) + continue + if operator == "gt": + clauses.append(f"{sql_column} > %s") + params.append(value) + continue + if operator == "ge": + clauses.append(f"{sql_column} >= %s") + params.append(value) + continue + if operator == "lt": + clauses.append(f"{sql_column} < %s") + params.append(value) + continue + if operator == "le": + clauses.append(f"{sql_column} <= %s") + params.append(value) + continue + if operator == "in": + if not isinstance(value, list) or not value: + raise ValueError("in filter requires a non-empty array value") + placeholders = ", ".join(["%s"] * len(value)) + clauses.append(f"{sql_column} IN ({placeholders})") + params.extend(value) + continue + if operator == "not_in": + if not isinstance(value, list) or not value: + raise ValueError("not_in filter requires a non-empty array value") + placeholders = ", ".join(["%s"] * len(value)) + clauses.append(f"{sql_column} NOT IN ({placeholders})") + params.extend(value) + continue + if operator == "between": + if not isinstance(value, list) or len(value) != 2: + raise ValueError("between filter requires a two-item array value") + clauses.append(f"{sql_column} BETWEEN %s AND %s") + params.extend(value) + continue + if operator == "not_between": + if not isinstance(value, list) or len(value) != 2: + raise ValueError("not_between filter requires a two-item array value") + clauses.append(f"{sql_column} NOT BETWEEN %s AND %s") + params.extend(value) + continue + if operator == "like": + clauses.append(f"CAST({sql_column} AS CHAR) LIKE %s") + params.append(f"%{value}%") + continue + if operator == "not_like": + clauses.append(f"CAST({sql_column} AS CHAR) NOT LIKE %s") + params.append(f"%{value}%") + continue + if operator == "is_null": + clauses.append(f"{sql_column} IS NULL") + continue + if operator == "is_not_null": + clauses.append(f"{sql_column} IS NOT NULL") + continue + + raise ValueError(f"unsupported filter operator: {operator}") + + if not clauses: + return "", params + return f"WHERE {' AND '.join(clauses)}", params + + +def query_mysql_table_rows_payload(payload: dict[str, Any]) -> dict[str, Any]: + target_database = normalize_database_name(payload.get("database")) + target_table = normalize_table_name(payload.get("table")) + page = clamp_page_number(payload.get("page")) + page_size = clamp_page_size(payload.get("pageSize")) + keyword = str(payload.get("keyword") or "").strip() + filters = payload.get("filters") if isinstance(payload.get("filters"), list) else [] + + try: + with build_mysql_connection(target_database, autocommit=True) as connection: + if not table_exists(connection, target_database, target_table): + raise ValueError(f"table not found: {target_database}.{target_table}") + + columns = list_table_columns(connection, target_database, target_table) + indexes = list_table_indexes(connection, target_database, target_table) + identity_spec = get_preferred_identity_columns(columns, indexes) + column_names = {column["name"] for column in columns} + + requested_sort = str(payload.get("orderBy") or "").strip() + order_by = requested_sort if requested_sort in column_names else (identity_spec["columns"][0] if identity_spec["columns"] else columns[0]["name"]) + order_direction = "DESC" if str(payload.get("orderDirection") or "").strip().lower() == "desc" else "ASC" + + where_clause, params = build_filter_clause(columns, keyword, filters) + offset = (page - 1) * page_size + quoted_table = quote_identifier(target_table) + select_columns = ", ".join(quote_identifier(column["name"]) for column in columns) + + with connection.cursor() as cursor: + # 先统计总量,分页器和页数都依赖这个值。 + cursor.execute( + f"SELECT COUNT(*) AS totalRows FROM {quoted_table} {where_clause}", + tuple(params), + ) + total_rows = int((cursor.fetchone() or {}).get("totalRows") or 0) + + # 行浏览默认按指定列排序,避免翻页时顺序漂移。 + cursor.execute( + f""" + SELECT {select_columns} + FROM {quoted_table} + {where_clause} + ORDER BY {quote_identifier(order_by)} {order_direction} + LIMIT %s OFFSET %s + """, + tuple([*params, page_size, offset]), + ) + rows = cursor.fetchall() or [] + except Exception as exc: # noqa: BLE001 + return { + "ok": False, + "updatedAt": utc_now(), + "database": target_database, + "table": target_table, + "rows": [], + "totalRows": 0, + "error": str(exc), + } + + # 每一行都带 identity,后续 update / delete 不需要前端自己猜 where 条件。 + normalized_rows = [] + for row in rows: + normalized_rows.append( + { + "values": normalize_mysql_row(row), + "identity": build_row_identity(row, identity_spec), + } + ) + + return { + "ok": True, + "updatedAt": utc_now(), + "database": target_database, + "table": target_table, + "page": page, + "pageSize": page_size, + "totalRows": total_rows, + "totalPages": max(1, (total_rows + page_size - 1) // page_size) if total_rows > 0 else 1, + "keyword": keyword, + "orderBy": order_by, + "orderDirection": order_direction.lower(), + "columns": columns, + "indexes": indexes, + "identity": identity_spec, + "rows": normalized_rows, + "error": "", + } + + +def normalize_write_values(values: Any, columns: list[dict[str, Any]]) -> list[tuple[str, Any]]: + if not isinstance(values, dict): + raise ValueError("values must be an object") + + writable_columns = { + column["name"]: column + for column in columns + if "generated" not in str(column.get("extra") or "").strip().lower() + } + normalized: list[tuple[str, Any]] = [] + + # 只允许写入真实存在的列,避免页面或调用方传进来脏字段。 + for raw_name, raw_value in values.items(): + column_name = normalize_column_name(raw_name) + if column_name not in writable_columns: + raise ValueError(f"unknown writable column: {column_name}") + + # JSON 对象和数组转成字符串后再交给 MySQL,自定义 SQL 控制台仍可手写更复杂格式。 + if isinstance(raw_value, (dict, list)): + normalized.append((column_name, json.dumps(raw_value, ensure_ascii=False))) + else: + normalized.append((column_name, raw_value)) + + return normalized + + +def build_identity_clause(identity: dict[str, Any], columns: list[dict[str, Any]]) -> tuple[str, list[Any]]: + if not isinstance(identity, dict): + raise ValueError("identity is required") + + allowed_columns = {column["name"] for column in columns} + identity_columns = [normalize_column_name(item) for item in identity.get("columns") or []] + identity_values = identity.get("values") if isinstance(identity.get("values"), dict) else {} + + if not identity_columns: + raise ValueError("identity columns are required") + + clauses: list[str] = [] + params: list[Any] = [] + + # where 条件只按 identity 指定列构造,避免把整行都塞进 where。 + for column_name in identity_columns: + if column_name not in allowed_columns: + raise ValueError(f"unknown identity column: {column_name}") + if column_name not in identity_values: + raise ValueError(f"missing identity value: {column_name}") + + value = identity_values.get(column_name) + quoted = quote_identifier(column_name) + if value is None: + clauses.append(f"{quoted} IS NULL") + else: + clauses.append(f"{quoted} = %s") + params.append(value) + + return " AND ".join(clauses), params + + +def insert_mysql_row_payload(payload: dict[str, Any]) -> dict[str, Any]: + target_database = normalize_database_name(payload.get("database")) + target_table = normalize_table_name(payload.get("table")) + + try: + with build_mysql_connection(target_database, autocommit=False) as connection: + columns = list_table_columns(connection, target_database, target_table) + pairs = normalize_write_values(payload.get("values"), columns) + if not pairs: + raise ValueError("at least one column value is required") + + quoted_table = quote_identifier(target_table) + column_sql = ", ".join(quote_identifier(name) for name, _ in pairs) + placeholders = ", ".join(["%s"] * len(pairs)) + params = [value for _, value in pairs] + + with connection.cursor() as cursor: + # 插入时只拼接列名,值全部走参数化。 + cursor.execute( + f"INSERT INTO {quoted_table} ({column_sql}) VALUES ({placeholders})", + tuple(params), + ) + affected_rows = int(cursor.rowcount or 0) + last_insert_id = cursor.lastrowid + connection.commit() + except Exception as exc: # noqa: BLE001 + return browser_error_payload(str(exc)) + + clear_mysql_metrics_cache() + return { + "ok": True, + "updatedAt": utc_now(), + "message": f"已插入 {target_database}.{target_table}", + "affectedRows": affected_rows, + "lastInsertId": last_insert_id, + "error": "", + } + + +def update_mysql_row_payload(payload: dict[str, Any]) -> dict[str, Any]: + target_database = normalize_database_name(payload.get("database")) + target_table = normalize_table_name(payload.get("table")) + + try: + with build_mysql_connection(target_database, autocommit=False) as connection: + columns = list_table_columns(connection, target_database, target_table) + pairs = normalize_write_values(payload.get("values"), columns) + if not pairs: + raise ValueError("at least one column value is required") + + where_clause, where_params = build_identity_clause(payload.get("identity"), columns) + set_clause = ", ".join(f"{quote_identifier(name)} = %s" for name, _ in pairs) + params = [value for _, value in pairs] + where_params + + with connection.cursor() as cursor: + # 更新固定只影响一行;identity 使用主键或唯一索引时语义最稳定。 + cursor.execute( + f"UPDATE {quote_identifier(target_table)} SET {set_clause} WHERE {where_clause} LIMIT 1", + tuple(params), + ) + affected_rows = int(cursor.rowcount or 0) + connection.commit() + except Exception as exc: # noqa: BLE001 + return browser_error_payload(str(exc)) + + clear_mysql_metrics_cache() + return { + "ok": True, + "updatedAt": utc_now(), + "message": f"已更新 {target_database}.{target_table}", + "affectedRows": affected_rows, + "error": "", + } + + +def delete_mysql_row_payload(payload: dict[str, Any]) -> dict[str, Any]: + target_database = normalize_database_name(payload.get("database")) + target_table = normalize_table_name(payload.get("table")) + + try: + with build_mysql_connection(target_database, autocommit=False) as connection: + columns = list_table_columns(connection, target_database, target_table) + where_clause, where_params = build_identity_clause(payload.get("identity"), columns) + + with connection.cursor() as cursor: + # 删除同样只允许命中一行,避免前端误带了过宽的 where 条件。 + cursor.execute( + f"DELETE FROM {quote_identifier(target_table)} WHERE {where_clause} LIMIT 1", + tuple(where_params), + ) + affected_rows = int(cursor.rowcount or 0) + connection.commit() + except Exception as exc: # noqa: BLE001 + return browser_error_payload(str(exc)) + + clear_mysql_metrics_cache() + return { + "ok": True, + "updatedAt": utc_now(), + "message": f"已删除 {target_database}.{target_table} 中的 1 行", + "affectedRows": affected_rows, + "error": "", + } + + +def add_mysql_column_payload(payload: dict[str, Any]) -> dict[str, Any]: + target_database = normalize_database_name(payload.get("database")) + target_table = normalize_table_name(payload.get("table")) + column_name = normalize_column_name(payload.get("columnName")) + column_type = normalize_sql_fragment(payload.get("columnType"), field_name="columnType") + nullable = bool(payload.get("nullable", True)) + comment = str(payload.get("comment") or "").strip() + position = str(payload.get("position") or "last").strip().lower() + default_mode = str(payload.get("defaultMode") or "none").strip().lower() + default_value = payload.get("defaultValue") + + clauses = [ + f"ALTER TABLE {quote_identifier(target_table)} ADD COLUMN {quote_identifier(column_name)} {column_type}", + "NULL" if nullable else "NOT NULL", + ] + params: list[Any] = [] + + # 默认值支持最常用的三种模式;更复杂表达式留给 SQL 工作台。 + if default_mode == "literal": + clauses.append("DEFAULT %s") + params.append(default_value) + elif default_mode == "null": + clauses.append("DEFAULT NULL") + elif default_mode != "none": + raise ValueError(f"unsupported default mode: {default_mode}") + + if comment: + clauses.append("COMMENT %s") + params.append(comment) + + if position == "first": + clauses.append("FIRST") + elif position == "after": + after_column = normalize_column_name(payload.get("afterColumn")) + clauses.append(f"AFTER {quote_identifier(after_column)}") + elif position != "last": + raise ValueError(f"unsupported position: {position}") + + try: + with build_mysql_connection(target_database, autocommit=False) as connection: + with connection.cursor() as cursor: + # 列类型本身是 SQL 语法片段,只做单语句校验,不做过度限制。 + cursor.execute(" ".join(clauses), tuple(params)) + connection.commit() + except Exception as exc: # noqa: BLE001 + return browser_error_payload(str(exc)) + + clear_mysql_metrics_cache() + return { + "ok": True, + "updatedAt": utc_now(), + "message": f"已新增列 {target_table}.{column_name}", + "error": "", + } + + +def drop_mysql_column_payload(payload: dict[str, Any]) -> dict[str, Any]: + target_database = normalize_database_name(payload.get("database")) + target_table = normalize_table_name(payload.get("table")) + column_name = normalize_column_name(payload.get("columnName")) + + try: + with build_mysql_connection(target_database, autocommit=False) as connection: + with connection.cursor() as cursor: + # 删除列是高影响操作,但用户明确要求支持,这里保持单列单次执行。 + cursor.execute( + f"ALTER TABLE {quote_identifier(target_table)} DROP COLUMN {quote_identifier(column_name)}", + ) + connection.commit() + except Exception as exc: # noqa: BLE001 + return browser_error_payload(str(exc)) + + clear_mysql_metrics_cache() + return { + "ok": True, + "updatedAt": utc_now(), + "message": f"已删除列 {target_table}.{column_name}", + "error": "", + } + + +def add_mysql_index_payload(payload: dict[str, Any]) -> dict[str, Any]: + target_database = normalize_database_name(payload.get("database")) + target_table = normalize_table_name(payload.get("table")) + index_name = normalize_column_name(payload.get("indexName")) + index_mode = str(payload.get("indexMode") or "normal").strip().lower() + columns = payload.get("columns") if isinstance(payload.get("columns"), list) else [] + normalized_columns = [normalize_column_name(item) for item in columns] + + if not normalized_columns: + raise ValueError("at least one index column is required") + + if index_mode == "unique": + prefix = f"ADD UNIQUE INDEX {quote_identifier(index_name)}" + elif index_mode == "fulltext": + prefix = f"ADD FULLTEXT INDEX {quote_identifier(index_name)}" + elif index_mode == "normal": + prefix = f"ADD INDEX {quote_identifier(index_name)}" + else: + raise ValueError(f"unsupported index mode: {index_mode}") + + column_sql = ", ".join(quote_identifier(item) for item in normalized_columns) + + try: + with build_mysql_connection(target_database, autocommit=False) as connection: + with connection.cursor() as cursor: + # 索引列名都走标识符转义,避免前端选择值被直接拼进 SQL。 + cursor.execute( + f"ALTER TABLE {quote_identifier(target_table)} {prefix} ({column_sql})", + ) + connection.commit() + except Exception as exc: # noqa: BLE001 + return browser_error_payload(str(exc)) + + clear_mysql_metrics_cache() + return { + "ok": True, + "updatedAt": utc_now(), + "message": f"已新增索引 {target_table}.{index_name}", + "error": "", + } + + +def drop_mysql_index_payload(payload: dict[str, Any]) -> dict[str, Any]: + target_database = normalize_database_name(payload.get("database")) + target_table = normalize_table_name(payload.get("table")) + index_name = normalize_column_name(payload.get("indexName")) + + try: + with build_mysql_connection(target_database, autocommit=False) as connection: + with connection.cursor() as cursor: + # PRIMARY KEY 的删除语法和普通索引不同,这里单独分支。 + if index_name == "PRIMARY": + cursor.execute( + f"ALTER TABLE {quote_identifier(target_table)} DROP PRIMARY KEY", + ) + else: + cursor.execute( + f"ALTER TABLE {quote_identifier(target_table)} DROP INDEX {quote_identifier(index_name)}", + ) + connection.commit() + except Exception as exc: # noqa: BLE001 + return browser_error_payload(str(exc)) + + clear_mysql_metrics_cache() + return { + "ok": True, + "updatedAt": utc_now(), + "message": f"已删除索引 {target_table}.{index_name}", + "error": "", + } + + +def execute_mysql_sql_payload(payload: dict[str, Any]) -> dict[str, Any]: + target_database = str(payload.get("database") or MYSQL_DEFAULT_DATABASE or "").strip() or None + sql = normalize_sql_text(payload.get("sql")) + kind = sql_statement_kind(sql) + mutating_kinds = {"insert", "update", "delete", "replace", "alter", "create", "drop", "truncate", "rename"} + + try: + with build_mysql_connection(target_database, autocommit=False) as connection: + with connection.cursor() as cursor: + # SQL 工作台当前只执行单条语句,结果集最多保留固定行数。 + cursor.execute(sql) + if cursor.description: + columns = [str(item[0] or "").strip() for item in cursor.description] + fetched_rows = cursor.fetchmany(MYSQL_QUERY_MAX_ROWS + 1) + truncated = len(fetched_rows) > MYSQL_QUERY_MAX_ROWS + rows = [normalize_mysql_row(item) for item in fetched_rows[:MYSQL_QUERY_MAX_ROWS]] + return { + "ok": True, + "updatedAt": utc_now(), + "database": target_database or "", + "kind": kind, + "columns": columns, + "rows": rows, + "rowCount": len(rows), + "truncated": truncated, + "error": "", + } + + connection.commit() + affected_rows = int(cursor.rowcount or 0) + last_insert_id = cursor.lastrowid + except Exception as exc: # noqa: BLE001 + return browser_error_payload(str(exc)) + + if kind in mutating_kinds: + clear_mysql_metrics_cache() + + return { + "ok": True, + "updatedAt": utc_now(), + "database": target_database or "", + "kind": kind, + "affectedRows": affected_rows, + "lastInsertId": last_insert_id, + "message": f"{kind.upper()} 执行完成", + "error": "", + } diff --git a/monitors/yumi/mysql_common.py b/monitors/yumi/mysql_common.py new file mode 100644 index 0000000..ce873a9 --- /dev/null +++ b/monitors/yumi/mysql_common.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from decimal import Decimal +import json +from typing import Any + +from core.config import ( + MYSQL_CONNECT_TIMEOUT_SECONDS, + MYSQL_DEFAULT_DATABASE, + MYSQL_HOST, + MYSQL_PASSWORD, + MYSQL_PORT, + MYSQL_READ_TIMEOUT_SECONDS, + MYSQL_USER, + MYSQL_WRITE_TIMEOUT_SECONDS, + utc_now, +) + + +# 系统库默认排在业务库后面,避免主列表被系统库占满。 +SYSTEM_DATABASES = {"information_schema", "performance_schema", "mysql", "sys"} + + +def mysql_error_payload(error: str) -> dict[str, Any]: + # MySQL 基础设施页统一返回同一套失败结构,前端不需要额外分支。 + return { + "ok": False, + "updatedAt": utc_now(), + "info": {}, + "connections": {}, + "throughput": {}, + "replication": {"configured": False, "role": "", "readOnly": False, "members": []}, + "summary": { + "databaseTotal": 0, + "tableTotal": 0, + "dataSizeMb": 0.0, + "indexSizeMb": 0.0, + "totalSizeMb": 0.0, + }, + "databases": [], + "error": error, + } + + +def require_pymysql() -> Any: + try: + # 运行时再导入驱动,避免缺少依赖时整个服务无法启动。 + import pymysql + except Exception as exc: # noqa: BLE001 + raise RuntimeError(f"pymysql unavailable: {exc}") from exc + + # 导入成功后直接返回模块对象,调用方继续拿 DictCursor。 + return pymysql + + +def build_mysql_connection(database: str | None = None, *, autocommit: bool = True) -> Any: + # 主机和账号缺失时直接给出明确错误,避免后面连库报一串底层异常。 + missing = [name for name, value in {"MYSQL_HOST": MYSQL_HOST, "MYSQL_USER": MYSQL_USER}.items() if not str(value).strip()] + if missing: + raise RuntimeError(f"missing mysql config in .env: {', '.join(missing)}") + + # 默认库允许为空;表浏览场景会显式选择数据库。 + target_database = str(database or MYSQL_DEFAULT_DATABASE or "").strip() or None + pymysql = require_pymysql() + + # 统一走 utf8mb4,避免表结构和数据浏览时乱码。 + return pymysql.connect( + host=MYSQL_HOST, + port=MYSQL_PORT, + user=MYSQL_USER, + password=MYSQL_PASSWORD, + database=target_database, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=autocommit, + connect_timeout=MYSQL_CONNECT_TIMEOUT_SECONDS, + read_timeout=MYSQL_READ_TIMEOUT_SECONDS, + write_timeout=MYSQL_WRITE_TIMEOUT_SECONDS, + ) + + +def quote_identifier(name: str) -> str: + # 库、表、列名都统一走反引号包裹,并对内层反引号做转义。 + actual = str(name or "").strip() + if not actual: + raise ValueError("identifier is required") + return f"`{actual.replace('`', '``')}`" + + +def normalize_database_name(name: str) -> str: + # 数据库名作为路由参数时必须非空,统一在这里兜底校验。 + actual = str(name or "").strip() + if not actual: + raise ValueError("database is required") + return actual + + +def normalize_table_name(name: str) -> str: + # 表名同样必须非空。 + actual = str(name or "").strip() + if not actual: + raise ValueError("table is required") + return actual + + +def normalize_column_name(name: str) -> str: + # 列名校验单独抽出来,便于 schema 变更和排序共用。 + actual = str(name or "").strip() + if not actual: + raise ValueError("column is required") + return actual + + +def is_system_database(name: str) -> bool: + # 系统库统一小写比较,避免大小写混淆。 + return str(name or "").strip().lower() in SYSTEM_DATABASES + + +def normalize_mysql_value(value: Any) -> Any: + # Decimal 保留成字符串,避免金额精度被前端浮点化。 + if isinstance(value, Decimal): + return str(value) + + # 日期和时间都压成易读字符串。 + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(value, date): + return value.strftime("%Y-%m-%d") + if isinstance(value, time): + return value.strftime("%H:%M:%S") + if isinstance(value, timedelta): + return str(value) + + # 字节内容尽量按 UTF-8 展示;失败时回退成十六进制。 + if isinstance(value, (bytes, bytearray, memoryview)): + raw = bytes(value) + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return f"0x{raw.hex()}" + + # dict / list 理论上常见于 JSON 列,这里直接转成 JSON 文本方便展示和编辑。 + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + + # 其余标量直接原样返回。 + return value + + +def normalize_mysql_row(row: dict[str, Any]) -> dict[str, Any]: + # 一行记录中的每个字段都做 JSON 安全转换。 + return {key: normalize_mysql_value(value) for key, value in row.items()} + + +def list_database_summaries(connection: Any) -> list[dict[str, Any]]: + # 数据库级别汇总统一从 information_schema.tables 读取,能同时拿到表数和容量。 + query = """ + SELECT + s.SCHEMA_NAME AS schemaName, + COALESCE(t.tableTotal, 0) AS tableTotal, + COALESCE(t.dataBytes, 0) AS dataBytes, + COALESCE(t.indexBytes, 0) AS indexBytes, + COALESCE(t.rowEstimate, 0) AS rowEstimate + FROM information_schema.SCHEMATA AS s + LEFT JOIN ( + SELECT + table_schema AS schemaName, + COUNT(*) AS tableTotal, + SUM(COALESCE(data_length, 0)) AS dataBytes, + SUM(COALESCE(index_length, 0)) AS indexBytes, + SUM(COALESCE(table_rows, 0)) AS rowEstimate + FROM information_schema.TABLES + GROUP BY table_schema + ) AS t + ON t.schemaName = s.SCHEMA_NAME + ORDER BY + CASE + WHEN s.SCHEMA_NAME IN ('information_schema', 'performance_schema', 'mysql', 'sys') THEN 1 + ELSE 0 + END, + COALESCE(t.dataBytes, 0) + COALESCE(t.indexBytes, 0) DESC, + s.SCHEMA_NAME ASC + """ + + with connection.cursor() as cursor: + # 直接读取全部库级摘要,前端 sidebar 需要完整列表。 + cursor.execute(query) + rows = cursor.fetchall() or [] + + # 把字节数转换成 MiB,并附带是否系统库的标记。 + return [ + { + "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), + "totalSizeMb": round( + (float(item.get("dataBytes") or 0) + float(item.get("indexBytes") or 0)) / 1024 / 1024, + 2, + ), + } + for item in rows + ] + + +def list_table_summaries(connection: Any, database: str) -> list[dict[str, Any]]: + target_database = normalize_database_name(database) + + # 表级摘要统一从 information_schema.tables 读取,便于在浏览器里快速切表。 + query = """ + SELECT + TABLE_NAME AS tableName, + ENGINE AS engine, + TABLE_ROWS AS rowEstimate, + DATA_LENGTH AS dataBytes, + INDEX_LENGTH AS indexBytes, + AUTO_INCREMENT AS autoIncrementValue, + CREATE_TIME AS createTime, + UPDATE_TIME AS updateTime, + TABLE_COLLATION AS tableCollation + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = %s + 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 [] + + 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), + "totalSizeMb": round( + (float(item.get("dataBytes") or 0) + float(item.get("indexBytes") or 0)) / 1024 / 1024, + 2, + ), + "autoIncrement": item.get("autoIncrementValue"), + "createTime": normalize_mysql_value(item.get("createTime")), + "updateTime": normalize_mysql_value(item.get("updateTime")), + "collation": str(item.get("tableCollation") or "").strip(), + } + for item in rows + ] + + +def list_table_columns(connection: Any, database: str, table: str) -> list[dict[str, Any]]: + target_database = normalize_database_name(database) + target_table = normalize_table_name(table) + + # 列元数据以 ordinal_position 排序,保证浏览器和真实表结构一致。 + query = """ + SELECT + COLUMN_NAME AS columnName, + DATA_TYPE AS dataType, + COLUMN_TYPE AS columnType, + IS_NULLABLE AS isNullable, + COLUMN_DEFAULT AS columnDefault, + COLUMN_KEY AS columnKey, + EXTRA AS extraValue, + COLUMN_COMMENT AS columnComment, + ORDINAL_POSITION AS ordinalPosition + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s + ORDER BY ORDINAL_POSITION ASC + """ + + with connection.cursor() as cursor: + # 表结构操作都依赖列元数据,这里统一一次性拿齐。 + cursor.execute(query, (target_database, target_table)) + rows = cursor.fetchall() or [] + + return [ + { + "name": str(item.get("columnName") or "").strip(), + "dataType": str(item.get("dataType") or "").strip(), + "columnType": str(item.get("columnType") or "").strip(), + "nullable": str(item.get("isNullable") or "").strip().upper() == "YES", + "default": normalize_mysql_value(item.get("columnDefault")), + "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), + } + for item in rows + ] + + +def list_table_indexes(connection: Any, database: str, table: str) -> list[dict[str, Any]]: + target_database = normalize_database_name(database) + target_table = normalize_table_name(table) + + # 索引明细先按序号取出,再在 Python 侧聚合成前端更容易消费的结构。 + query = """ + SELECT + INDEX_NAME AS indexName, + COLUMN_NAME AS columnName, + NON_UNIQUE AS nonUnique, + INDEX_TYPE AS indexType, + SEQ_IN_INDEX AS seqInIndex, + SUB_PART AS subPart + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s + 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 [] + + grouped: dict[str, dict[str, Any]] = {} + for item in rows: + index_name = str(item.get("indexName") 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(), + "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), + } + ) + + # 输出时保证 primary 优先,再按名字排序。 + return sorted( + grouped.values(), + key=lambda item: (0 if item["primary"] else 1, item["name"].lower()), + ) + + +def get_preferred_identity_columns(columns: list[dict[str, Any]], indexes: list[dict[str, Any]]) -> dict[str, Any]: + # 行级更新优先使用主键;没有主键时退到第一个唯一索引;再不行才退到全列比对。 + primary = next((item for item in indexes if item.get("primary")), None) + if primary: + return {"mode": "primary", "columns": [column["name"] for column in primary["columns"]]} + + unique = next((item for item in indexes if item.get("unique")), None) + if unique: + return {"mode": "unique", "columns": [column["name"] for column in unique["columns"]]} + + return {"mode": "all", "columns": [column["name"] for column in columns]} + + +def build_row_identity(row: dict[str, Any], identity_spec: dict[str, Any]) -> dict[str, Any]: + # identity 只保留用于匹配的字段,避免前端把整行作为 where 条件带回。 + columns = [str(item or "").strip() for item in identity_spec.get("columns") or [] if str(item or "").strip()] + return { + "mode": str(identity_spec.get("mode") or "").strip(), + "columns": columns, + "values": {column: normalize_mysql_value(row.get(column)) for column in columns}, + } + + +def table_exists(connection: Any, database: str, table: str) -> bool: + target_database = normalize_database_name(database) + 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 + + +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 + + +def normalize_sql_text(sql: str) -> str: + # SQL 工作台当前只允许单条语句,避免一键提交多段脚本。 + text = str(sql or "").strip() + if not text: + raise ValueError("sql is required") + + # 只允许尾部分号,正文里再出现分号就拒绝执行。 + stripped = text.rstrip().rstrip(";").rstrip() + if ";" in stripped: + raise ValueError("only single sql statement is allowed") + return stripped + + +def sql_statement_kind(sql: str) -> str: + # 读取首个关键字即可分辨查询类还是变更类语句。 + parts = normalize_sql_text(sql).split(None, 1) + return parts[0].strip().lower() if parts else "" diff --git a/monitors/yumi/mysql_metrics.py b/monitors/yumi/mysql_metrics.py new file mode 100644 index 0000000..32c6024 --- /dev/null +++ b/monitors/yumi/mysql_metrics.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import threading +import time +from typing import Any + +from core.cache import TTLCache +from core.config import MYSQL_CACHE_TTL_SECONDS, MYSQL_HOST, MYSQL_PORT, utc_now +from .mysql_common import build_mysql_connection, list_database_summaries, mysql_error_payload, normalize_mysql_value + + +# MySQL 概览卡片和基础设施页都复用同一份汇总数据。 +MYSQL_OVERVIEW_CACHE = TTLCache[dict[str, Any]](MYSQL_CACHE_TTL_SECONDS) +# QPS / TPS 需要用两次采样求差值,这里单独保存最近一次原始计数器。 +MYSQL_RATE_LOCK = threading.Lock() +MYSQL_RATE_SNAPSHOT: dict[str, Any] | None = None + + +def clear_mysql_metrics_cache() -> None: + # 数据写入、DDL 之后清理缓存,避免概览继续显示旧指标。 + MYSQL_OVERVIEW_CACHE.clear() + + +def coerce_int(mapping: dict[str, Any], key: str) -> int: + try: + # MySQL status / variables 大多返回字符串,这里统一转整数。 + return int(mapping.get(key) or 0) + except Exception: # noqa: BLE001 + return 0 + + +def coerce_bool(mapping: dict[str, Any], key: str) -> bool: + value = str(mapping.get(key) or "").strip().lower() + return value in {"1", "on", "yes", "true"} + + +def collect_global_status(connection: Any) -> dict[str, Any]: + names = ( + "Threads_connected", + "Threads_running", + "Questions", + "Com_commit", + "Com_rollback", + "Uptime", + ) + + # 这里选最小必要状态量,避免每次都拉全量 GLOBAL STATUS。 + with connection.cursor() as cursor: + cursor.execute( + """ + SHOW GLOBAL STATUS + WHERE Variable_name IN (%s, %s, %s, %s, %s, %s) + """, + names, + ) + rows = cursor.fetchall() or [] + + return {str(item.get("Variable_name") or ""): item.get("Value") for item in rows} + + +def collect_global_variables(connection: Any) -> dict[str, Any]: + names = ( + "version", + "version_comment", + "server_uuid", + "server_id", + "max_connections", + "read_only", + "super_read_only", + ) + + # 版本、只读状态和最大连接数都在 GLOBAL VARIABLES 里。 + with connection.cursor() as cursor: + cursor.execute( + """ + SHOW GLOBAL VARIABLES + WHERE Variable_name IN (%s, %s, %s, %s, %s, %s, %s) + """, + names, + ) + rows = cursor.fetchall() or [] + + return {str(item.get("Variable_name") or ""): item.get("Value") for item in rows} + + +def collect_replication_payload(connection: Any, variables: dict[str, Any]) -> dict[str, Any]: + # 先把只读状态打平,这一层即使复制信息拿不到也能稳定展示。 + payload = { + "configured": False, + "role": "unknown", + "readOnly": coerce_bool(variables, "read_only"), + "superReadOnly": coerce_bool(variables, "super_read_only"), + "sourceHost": "", + "secondsBehindSource": None, + "ioRunning": False, + "sqlRunning": False, + "state": "", + "error": "", + } + + # 云 MySQL 版本不同,复制状态命令可能叫 SHOW SLAVE STATUS 或 SHOW REPLICA STATUS。 + commands = ["SHOW REPLICA STATUS", "SHOW SLAVE STATUS"] + for command in commands: + try: + with connection.cursor() as cursor: + cursor.execute(command) + row = cursor.fetchone() + except Exception: # noqa: BLE001 + # 云库版本和权限口径不一致时,这两条命令经常会报语法或权限错误。 + # 这里不把它直接当成实例异常,避免主节点或单节点 MySQL 被误标成故障。 + continue + + # 没有行时说明当前节点没有暴露复制状态,继续尝试下一条命令。 + if not row: + continue + + payload["configured"] = True + payload["sourceHost"] = str( + row.get("Source_Host") + or row.get("Master_Host") + or "" + ).strip() + payload["secondsBehindSource"] = ( + int(row.get("Seconds_Behind_Source") or row.get("Seconds_Behind_Master") or 0) + if row.get("Seconds_Behind_Source") is not None or row.get("Seconds_Behind_Master") is not None + else None + ) + payload["ioRunning"] = str( + row.get("Replica_IO_Running") + or row.get("Slave_IO_Running") + or "" + ).strip().lower() == "yes" + payload["sqlRunning"] = str( + row.get("Replica_SQL_Running") + or row.get("Slave_SQL_Running") + or "" + ).strip().lower() == "yes" + payload["state"] = str( + row.get("Replica_SQL_Running_State") + or row.get("Slave_SQL_Running_State") + or "" + ).strip() + payload["error"] = str( + row.get("Last_SQL_Error") + or row.get("Last_IO_Error") + or "" + ).strip() + payload["role"] = "replica" + return payload + + # 没拿到复制详情时,退化成基于只读状态的粗略判断。 + payload["role"] = "replica" if payload["readOnly"] or payload["superReadOnly"] else "primary" + return payload + + +def collect_throughput_payload(status: dict[str, Any]) -> dict[str, Any]: + current = { + "capturedAt": time.time(), + "questions": coerce_int(status, "Questions"), + "commits": coerce_int(status, "Com_commit"), + "rollbacks": coerce_int(status, "Com_rollback"), + } + + # 首次采样时没有可对比的上一帧,这里返回 0 但仍记住快照。 + with MYSQL_RATE_LOCK: + global MYSQL_RATE_SNAPSHOT + previous = MYSQL_RATE_SNAPSHOT + MYSQL_RATE_SNAPSHOT = current + + if previous is None: + return { + "qps": 0.0, + "tps": 0.0, + "sampleSeconds": 0.0, + "questionsDelta": 0, + "transactionsDelta": 0, + } + + elapsed = max(current["capturedAt"] - float(previous.get("capturedAt") or 0.0), 0.001) + questions_delta = max(current["questions"] - int(previous.get("questions") or 0), 0) + transactions_delta = max( + (current["commits"] + current["rollbacks"]) - (int(previous.get("commits") or 0) + int(previous.get("rollbacks") or 0)), + 0, + ) + + return { + "qps": round(questions_delta / elapsed, 2), + "tps": round(transactions_delta / elapsed, 2), + "sampleSeconds": round(elapsed, 2), + "questionsDelta": questions_delta, + "transactionsDelta": transactions_delta, + } + + +def build_mysql_overview_payload() -> dict[str, Any]: + try: + # 指标和元数据统一走一条连接,减少直连云 MySQL 的并发压力。 + with build_mysql_connection(autocommit=True) as connection: + with connection.cursor() as cursor: + # ping 能尽早暴露账号、网络和权限问题。 + cursor.execute("SELECT 1 AS ok") + cursor.fetchone() + + variables = collect_global_variables(connection) + status = collect_global_status(connection) + replication = collect_replication_payload(connection, variables) + throughput = collect_throughput_payload(status) + databases = list_database_summaries(connection) + except Exception as exc: # noqa: BLE001 + return mysql_error_payload(str(exc)) + + # 库级汇总同时作为基础设施概览卡片的数据源。 + business_databases = [item for item in databases if not item.get("system")] + summary = { + "databaseTotal": len(business_databases), + "tableTotal": sum(int(item.get("tableTotal") or 0) for item in business_databases), + "dataSizeMb": round(sum(float(item.get("dataSizeMb") or 0.0) for item in business_databases), 2), + "indexSizeMb": round(sum(float(item.get("indexSizeMb") or 0.0) for item in business_databases), 2), + "totalSizeMb": round(sum(float(item.get("totalSizeMb") or 0.0) for item in business_databases), 2), + } + + # 当前连接数、最大连接数和版本信息都单独拉平,前端展示更直接。 + return { + "ok": True, + "updatedAt": utc_now(), + "info": { + "host": f"{MYSQL_HOST}:{MYSQL_PORT}", + "version": str(variables.get("version") or "").strip(), + "versionComment": str(variables.get("version_comment") or "").strip(), + "serverUuid": str(variables.get("server_uuid") or "").strip(), + "serverId": str(variables.get("server_id") or "").strip(), + "uptimeSeconds": coerce_int(status, "Uptime"), + }, + "connections": { + "current": coerce_int(status, "Threads_connected"), + "running": coerce_int(status, "Threads_running"), + "max": coerce_int(variables, "max_connections"), + }, + "throughput": throughput, + "replication": replication, + "summary": summary, + "databases": databases, + "error": "", + } + + +def get_mysql_overview_payload() -> dict[str, Any]: + # 概览卡片和表浏览 sidebar 共用缓存,避免每次点 tab 都重查 information_schema。 + return MYSQL_OVERVIEW_CACHE.get_or_build(build_mysql_overview_payload) diff --git a/monitor/nacos.py b/monitors/yumi/nacos.py similarity index 99% rename from monitor/nacos.py rename to monitors/yumi/nacos.py index a4d3d91..c9b049c 100644 --- a/monitor/nacos.py +++ b/monitors/yumi/nacos.py @@ -8,9 +8,9 @@ import urllib.parse import urllib.request from typing import Any -from .cache import TTLCache +from core.cache import TTLCache from .config_validation import validate_config_content -from .config import ( +from core.config import ( NACOS_BASE_URL, NACOS_CACHE_TTL_SECONDS, NACOS_DISCOVERY_CLUSTER_NAME, diff --git a/monitor/runtime_config.py b/monitors/yumi/runtime_config.py similarity index 99% rename from monitor/runtime_config.py rename to monitors/yumi/runtime_config.py index 5853609..c4fa957 100644 --- a/monitor/runtime_config.py +++ b/monitors/yumi/runtime_config.py @@ -6,7 +6,7 @@ import hashlib import json from typing import Any -from .config import RUNTIME_BASE_DIR, load_hosts, utc_now +from core.config import RUNTIME_BASE_DIR, load_hosts, utc_now from .ssh_remote import read_remote_text as ssh_read_remote_text from .ssh_remote import write_remote_text as ssh_write_remote_text diff --git a/monitor/service_logs.py b/monitors/yumi/service_logs.py similarity index 96% rename from monitor/service_logs.py rename to monitors/yumi/service_logs.py index 31f1ecb..03a37ff 100644 --- a/monitor/service_logs.py +++ b/monitors/yumi/service_logs.py @@ -5,7 +5,7 @@ import shlex from typing import Any from .compose_templates import container_name -from .config import SERVICE_LOG_EXPORT_MAX_LINES, SERVICE_LOG_PREVIEW_LINES, flatten_services, load_hosts, utc_now +from core.config import SERVICE_LOG_EXPORT_MAX_LINES, SERVICE_LOG_PREVIEW_LINES, flatten_services, load_hosts, utc_now from .ssh_remote import run_host_script diff --git a/monitor/service_ops.py b/monitors/yumi/service_ops.py similarity index 99% rename from monitor/service_ops.py rename to monitors/yumi/service_ops.py index b39552a..507fec5 100644 --- a/monitor/service_ops.py +++ b/monitors/yumi/service_ops.py @@ -7,7 +7,7 @@ import time from typing import Any from .compose_templates import sync_remote_host_compose_template -from .config import ( +from core.config import ( ROLLING_RESTART_POLL_SECONDS, ROLLING_RESTART_STABILIZE_SECONDS, ROLLING_RESTART_TIMEOUT_SECONDS, diff --git a/monitor/service_release.py b/monitors/yumi/service_release.py similarity index 99% rename from monitor/service_release.py rename to monitors/yumi/service_release.py index b7b4809..365a60a 100644 --- a/monitor/service_release.py +++ b/monitors/yumi/service_release.py @@ -21,7 +21,7 @@ from .compose_templates import ( sync_remote_host_compose_template, update_local_service_image, ) -from .config import ( +from core.config import ( ADMIN_FRONTEND_BUILD_SCRIPT, ADMIN_FRONTEND_COMPOSE_FILE, ADMIN_FRONTEND_COMPOSE_SERVICE, diff --git a/monitor/service_update_jobs.py b/monitors/yumi/service_update_jobs.py similarity index 99% rename from monitor/service_update_jobs.py rename to monitors/yumi/service_update_jobs.py index 76b275b..6a25ebf 100644 --- a/monitor/service_update_jobs.py +++ b/monitors/yumi/service_update_jobs.py @@ -13,7 +13,7 @@ import re import uuid from typing import Any -from .config import ROOT, utc_now +from core.config import ROOT, utc_now from .service_release import deploy_updated_services_payload diff --git a/monitor/ssh_remote.py b/monitors/yumi/ssh_remote.py similarity index 99% rename from monitor/ssh_remote.py rename to monitors/yumi/ssh_remote.py index 24e9ea3..c187115 100644 --- a/monitor/ssh_remote.py +++ b/monitors/yumi/ssh_remote.py @@ -9,7 +9,7 @@ from pathlib import Path import subprocess from typing import Any -from .config import ( +from core.config import ( SSH_CONNECT_TIMEOUT_SECONDS, SSH_KNOWN_HOSTS_PATH, SSH_PORT, diff --git a/monitor/tencent_tat.py b/monitors/yumi/tencent_tat.py similarity index 98% rename from monitor/tencent_tat.py rename to monitors/yumi/tencent_tat.py index 4e9788e..4a075d5 100644 --- a/monitor/tencent_tat.py +++ b/monitors/yumi/tencent_tat.py @@ -5,7 +5,7 @@ import json import time from typing import Any -from .config import DEPLOY_REGION, TAT_METRIC_TIMEOUT_SECONDS, TENCENT_SECRET_ID, TENCENT_SECRET_KEY, utc_now +from core.config import DEPLOY_REGION, TAT_METRIC_TIMEOUT_SECONDS, TENCENT_SECRET_ID, TENCENT_SECRET_KEY, utc_now def decode_task_output(output: str) -> str: diff --git a/ops/sync_runtime_compose.py b/ops/sync_runtime_compose.py index 69014ca..7c8506d 100644 --- a/ops/sync_runtime_compose.py +++ b/ops/sync_runtime_compose.py @@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from monitor.compose_templates import managed_compose_host_names, save_local_host_compose_template, sync_remote_compose_templates +from monitors.yumi.compose_templates import managed_compose_host_names, save_local_host_compose_template, sync_remote_compose_templates def parse_args() -> argparse.Namespace: diff --git a/requirements-ops.txt b/requirements-ops.txt index 83f353d..92e7aeb 100644 --- a/requirements-ops.txt +++ b/requirements-ops.txt @@ -1,4 +1,5 @@ tencentcloud-sdk-python>=3.0.0 pymongo>=4.8.0 +PyMySQL>=1.1.1 PyYAML>=6.0.2 javaproperties>=0.8.2 diff --git a/server.py b/server.py index 4a87e18..4a4c65b 100644 --- a/server.py +++ b/server.py @@ -2,7 +2,7 @@ from __future__ import annotations -from gateway.http_app import main +from entry.http_app import main if __name__ == "__main__": diff --git a/static/app.css b/static/app.css index 7eac192..4306fd1 100644 --- a/static/app.css +++ b/static/app.css @@ -4,6 +4,7 @@ --panel: oklch(99.7% 0.002 228 / 0.97); --panel-muted: oklch(98.1% 0.005 228 / 0.98); --panel-strong: oklch(100% 0 0 / 0.99); + --panel-tint: color-mix(in oklab, var(--panel-strong) 94%, var(--accent-soft)); --line: oklch(87% 0.014 232 / 0.96); --line-strong: oklch(78% 0.022 234 / 0.98); --text: oklch(29% 0.018 234); @@ -16,7 +17,13 @@ --warn-soft: oklch(96% 0.034 82); --bad: oklch(58% 0.15 32); --bad-soft: oklch(95% 0.034 32); - --shadow: 0 16px 32px rgba(33, 46, 76, 0.07); + --shadow: 0 14px 30px rgba(33, 46, 76, 0.06); + --shadow-soft: 0 10px 22px rgba(33, 46, 76, 0.05); + --shadow-lift: 0 18px 36px rgba(33, 46, 76, 0.1); + --group-gateway: oklch(54% 0.075 238); + --group-app: oklch(55% 0.085 176); + --group-pay: oklch(67% 0.13 82); + --group-infra: oklch(55% 0.05 204); } * { @@ -51,21 +58,187 @@ body::before { linear-gradient(color-mix(in oklab, var(--accent) 9%, transparent) 1px, transparent 1px), linear-gradient(90deg, color-mix(in oklab, var(--accent) 9%, transparent) 1px, transparent 1px); background-size: 32px 32px; - opacity: 0.16; + opacity: 0.11; } .shell { position: relative; z-index: 1; - max-width: 1560px; + max-width: 1940px; margin: 0 auto; - padding: 14px 16px 32px; + padding: 16px 20px 36px; } .boot-indicator { - padding: 20px 24px 0; + position: fixed; + left: 50%; + top: 22px; + z-index: 60; + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 0 14px; + transform: translateX(-50%); + border: 1px solid color-mix(in oklab, var(--line) 90%, var(--panel-strong)); + border-radius: 14px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--accent-soft)) 0%, var(--panel) 100%); + box-shadow: 0 16px 32px rgba(33, 46, 76, 0.1); color: var(--muted); - font-size: 14px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.boot-indicator::before { + content: ""; + width: 10px; + height: 10px; + border-radius: 999px; + background: color-mix(in oklab, var(--accent) 66%, var(--ok)); + box-shadow: 0 0 0 5px color-mix(in oklab, var(--accent-soft) 72%, transparent); + animation: boot-pulse 1.35s ease-in-out infinite; +} + +@keyframes boot-pulse { + 0%, + 100% { + transform: scale(0.9); + opacity: 0.82; + } + 50% { + transform: scale(1.08); + opacity: 1; + } +} + +.loading-stage { + display: grid; + gap: 14px; + margin-top: 10px; + min-height: 420px; + padding: 16px; +} + +.loading-stage-head, +.loading-panels, +.loading-stack { + display: grid; + gap: 12px; +} + +.loading-chip-row { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.loading-chip, +.loading-pill, +.loading-band, +.loading-line, +.loading-table-row, +.loading-block { + position: relative; + overflow: hidden; + border-radius: 12px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)) 0%, color-mix(in oklab, var(--panel-muted) 90%, var(--panel-strong)) 100%); +} + +.loading-chip::after, +.loading-pill::after, +.loading-band::after, +.loading-line::after, +.loading-table-row::after, +.loading-block::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent 0%, color-mix(in oklab, var(--panel-strong) 54%, transparent) 50%, transparent 100%); + transform: translateX(-100%); + animation: skeleton-slide 1.8s ease-in-out infinite; +} + +.loading-chip { + width: 82px; + height: 32px; +} + +.loading-line { + height: 12px; +} + +.loading-line-short { + width: min(100%, 220px); +} + +.loading-line-medium { + width: min(100%, 160px); +} + +.loading-band-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} + +.loading-band { + height: 64px; + border-radius: 16px; +} + +.loading-panels { + grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.65fr); + align-items: start; +} + +.loading-card { + display: grid; + gap: 12px; + min-height: 240px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 18px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--accent-soft)) 0%, var(--panel) 100%); +} + +.loading-card-wide { + min-height: 340px; +} + +.loading-card-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.loading-pill { + width: 88px; + height: 30px; +} + +.loading-table { + display: grid; + gap: 8px; +} + +.loading-table-row { + height: 42px; + border-radius: 14px; +} + +.loading-block { + height: 56px; +} + +@keyframes skeleton-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } } body.auth-page { @@ -218,7 +391,7 @@ body.auth-page { .panel { border: 1px solid var(--line); border-radius: 18px; - background: var(--panel); + background: linear-gradient(180deg, var(--panel-tint) 0%, var(--panel) 100%); box-shadow: var(--shadow); } @@ -228,11 +401,27 @@ body.auth-page { z-index: 20; display: grid; grid-template-columns: minmax(200px, 260px) minmax(0, 1fr) auto; - gap: 12px; + gap: 14px; align-items: center; - min-height: 54px; - padding: 8px 12px; + min-height: 56px; + padding: 9px 14px; backdrop-filter: blur(10px); + box-shadow: + 0 16px 32px rgba(33, 46, 76, 0.08), + inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 76%, transparent); +} + +.topbar::before, +.control-bar::before { + content: ""; + position: absolute; + left: 14px; + right: 14px; + top: 0; + height: 3px; + border-radius: 999px; + background: linear-gradient(90deg, color-mix(in oklab, var(--accent) 40%, var(--group-app)) 0%, transparent 76%); + opacity: 0.8; } .brand-stack { @@ -251,7 +440,7 @@ body.auth-page { .brand-mark { margin: 0; color: var(--muted); - font-size: 12px; + font-size: 13px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; @@ -282,6 +471,11 @@ body.auth-page { gap: 10px; align-items: center; flex-wrap: wrap; + padding: 4px; + border: 1px solid color-mix(in oklab, var(--line) 90%, var(--panel-strong)); + border-radius: 16px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)) 0%, color-mix(in oklab, var(--panel-strong) 96%, var(--panel-muted)) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); } .nav-chip, @@ -301,10 +495,10 @@ body.auth-page { display: inline-flex; align-items: center; justify-content: center; - min-height: 32px; - padding: 0 11px; - border-radius: 12px; - background: var(--panel-strong); + min-height: 34px; + padding: 0 12px; + border-radius: 11px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--panel-muted)) 0%, var(--panel-strong) 100%); cursor: pointer; font-size: 11px; font-weight: 600; @@ -320,8 +514,9 @@ body.auth-page { .nav-chip.active, .filter-chip.active { border-color: color-mix(in oklab, var(--accent) 46%, var(--line-strong)); - background: var(--accent-soft); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 78%, var(--panel-strong)) 0%, color-mix(in oklab, var(--accent-soft) 56%, var(--panel-strong)) 100%); color: var(--accent); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 14%, transparent); } .topbar-status, @@ -340,6 +535,11 @@ body.auth-page { .topbar-status { justify-content: flex-end; + padding: 4px; + border: 1px solid color-mix(in oklab, var(--line) 90%, var(--panel-strong)); + border-radius: 16px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)) 0%, color-mix(in oklab, var(--panel-strong) 96%, var(--panel-muted)) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); } .control-bar { @@ -350,7 +550,11 @@ body.auth-page { justify-content: space-between; gap: 10px; margin-top: 8px; - padding: 7px 12px; + padding: 8px 14px; + backdrop-filter: blur(8px); + box-shadow: + 0 12px 26px rgba(33, 46, 76, 0.06), + inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); } .task-control-bar { @@ -371,15 +575,17 @@ body.auth-page { } .taskbar-context strong { - font-size: 12px; + color: color-mix(in oklab, var(--text) 88%, var(--accent)); + font-size: 11px; font-weight: 700; - letter-spacing: 0.02em; + letter-spacing: 0.1em; + text-transform: uppercase; } .taskbar-context span { color: var(--muted); font-size: 10px; - letter-spacing: 0.06em; + letter-spacing: 0.09em; text-transform: uppercase; } @@ -394,9 +600,9 @@ body.auth-page { display: inline-flex; align-items: center; min-height: 28px; - padding: 0 2px; - border: 0; - border-radius: 0; + padding: 0 10px; + border: 1px solid transparent; + border-radius: 10px; background: transparent; color: var(--muted); font: inherit; @@ -404,15 +610,17 @@ body.auth-page { font-weight: 700; letter-spacing: 0.06em; cursor: pointer; - transition: color 180ms ease, opacity 180ms ease; + transition: color 180ms ease, opacity 180ms ease, border-color 180ms ease, background 180ms ease; } .section-link:hover { color: var(--text); + border-color: color-mix(in oklab, var(--accent) 18%, var(--line)); + background: color-mix(in oklab, var(--accent-soft) 22%, var(--panel-strong)); } .section-link + .section-link { - margin-left: 12px; + margin-left: 0; } .release-kind-select { @@ -434,6 +642,7 @@ body.auth-page { .search::placeholder { color: var(--muted); + opacity: 0.88; } .interval-field { @@ -453,17 +662,35 @@ body.auth-page { } .refresh { - background: color-mix(in oklab, var(--accent-soft) 38%, var(--panel-strong)); + border-color: color-mix(in oklab, var(--accent) 30%, var(--line-strong)); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 62%, var(--panel-strong)) 0%, color-mix(in oklab, var(--accent-soft) 34%, var(--panel-strong)) 100%); + color: color-mix(in oklab, var(--accent) 54%, var(--text)); + box-shadow: + inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 76%, transparent), + 0 8px 18px rgba(49, 82, 130, 0.08); } .refresh.ghost { - background: var(--panel-muted); + border-color: color-mix(in oklab, var(--line) 94%, var(--panel-strong)); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 98%, var(--panel-strong)) 0%, var(--panel-muted) 100%); + color: var(--muted); + box-shadow: none; +} + +.refresh:not(:disabled):hover, +.filter-chip:not(:disabled):hover, +.nav-chip:not(:disabled):hover { + border-color: color-mix(in oklab, var(--accent) 28%, var(--line-strong)); + box-shadow: 0 6px 16px rgba(35, 57, 92, 0.08); } .refresh.danger { color: var(--bad); border-color: color-mix(in oklab, var(--bad) 38%, var(--line-strong)); - background: color-mix(in oklab, var(--bad-soft) 48%, var(--panel-strong)); + background: linear-gradient(180deg, color-mix(in oklab, var(--bad-soft) 58%, var(--panel-strong)) 0%, color-mix(in oklab, var(--bad-soft) 40%, var(--panel-strong)) 100%); + box-shadow: + inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent), + 0 8px 18px rgba(166, 76, 55, 0.1); } .refresh:disabled, @@ -495,13 +722,144 @@ body.auth-page { } .panel { - padding: 12px; + position: relative; + padding: 14px; overflow: hidden; + box-shadow: + var(--shadow), + inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); +} + +.matrix-panel, +.hosts-panel, +.bridge-panel, +.summary-strip, +.overview-calm-strip { + position: relative; +} + +.matrix-panel { + background: + linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 22%, var(--panel-strong)) 0%, var(--panel) 100%); +} + +.hosts-panel { + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 95%, var(--ok-soft)) 0%, var(--panel) 100%); +} + +.matrix-panel::before, +.hosts-panel::before { + content: ""; + position: absolute; + left: 14px; + right: 14px; + top: 0; + height: 3px; + border-radius: 999px; + background: linear-gradient(90deg, color-mix(in oklab, var(--accent) 40%, var(--ok)) 0%, transparent 65%); + opacity: 0.7; } .summary-strip { margin-top: 8px; - padding: 0 10px; + padding: 8px 10px; + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 95%, var(--accent-soft)) 0%, var(--panel) 100%); +} + +.overview-calm-strip { + display: grid; + gap: 10px; + margin-top: 8px; + padding: 10px 12px; +} + +.overview-calm-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.overview-calm-meta { + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.overview-calm-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 10px; +} + +.overview-calm-card, +.overview-side-card { + display: grid; + gap: 5px; + min-height: 70px; + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: 14px; + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 95%, var(--panel-strong)) 0%, var(--panel-muted) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 74%, transparent); + transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +.overview-side-card { + position: relative; + overflow: hidden; +} + +.overview-side-card::before, +.infra-mini-card::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 3px; + background: linear-gradient(90deg, color-mix(in oklab, var(--accent) 44%, var(--group-app)) 0%, transparent 78%); + opacity: 0.76; +} + +.overview-side-card:hover, +.infra-mini-card:hover { + border-color: color-mix(in oklab, var(--accent) 20%, var(--line)); + box-shadow: var(--shadow-soft), inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 74%, transparent); +} + +.overview-calm-card small, +.overview-side-card small, +.release-service-metric small { + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.overview-calm-card strong, +.overview-side-card strong { + font-size: 15px; + line-height: 1.2; +} + +.overview-calm-card span, +.overview-side-card span { + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + +.overview-calm-card.tone-ok, +.overview-side-card.tone-ok { + border-color: color-mix(in oklab, var(--ok) 22%, var(--line)); + background: linear-gradient(180deg, color-mix(in oklab, var(--ok-soft) 40%, var(--panel-strong)) 0%, color-mix(in oklab, var(--ok-soft) 68%, var(--panel-muted)) 100%); } .summary-cards { @@ -537,7 +895,7 @@ body.auth-page { .summary-tile strong, .summary-card strong { font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; - font-size: clamp(0.94rem, 1.25vw, 1.12rem); + font-size: clamp(0.98rem, 1.3vw, 1.18rem); line-height: 1; letter-spacing: -0.04em; } @@ -548,27 +906,62 @@ body.auth-page { } .status-band-list { - align-items: center; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(176px, 1fr)); + gap: 1px; + align-items: stretch; + border: 1px solid var(--line); + border-radius: 14px; + overflow: hidden; + background: color-mix(in oklab, var(--line) 84%, var(--panel)); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 74%, transparent), var(--shadow-soft); } .status-band-item { - flex: 1 1 180px; - min-height: 44px; - padding: 0 12px; + position: relative; + display: grid; + align-content: center; + justify-items: start; + gap: 3px; + min-height: 50px; + padding: 10px 14px; border: 0; - border-right: 1px solid var(--line); border-radius: 0; - background: transparent; + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 94%, var(--panel-muted)) 0%, var(--panel-strong) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); } -.status-band-item:last-child { - border-right: 0; +.status-band-item::before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 2px; + background: color-mix(in oklab, currentColor 46%, transparent); + opacity: 0.72; } .status-band-item.tone-ok, .status-band-item.tone-warn, .status-band-item.tone-bad { - background: transparent; + border: 0; +} + +.status-band-item.tone-ok { + background: + linear-gradient(180deg, color-mix(in oklab, var(--ok-soft) 52%, var(--panel-strong)) 0%, color-mix(in oklab, var(--ok-soft) 74%, var(--panel-muted)) 100%); +} + +.status-band-item.tone-warn { + background: + linear-gradient(180deg, color-mix(in oklab, var(--warn-soft) 54%, var(--panel-strong)) 0%, color-mix(in oklab, var(--warn-soft) 72%, var(--panel-muted)) 100%); +} + +.status-band-item.tone-bad { + background: + linear-gradient(180deg, color-mix(in oklab, var(--bad-soft) 54%, var(--panel-strong)) 0%, color-mix(in oklab, var(--bad-soft) 74%, var(--panel-muted)) 100%); } .status-band-item.tone-ok strong { @@ -674,12 +1067,12 @@ body.auth-page { .content-grid { display: grid; - gap: 12px; + gap: 14px; margin-top: 8px; } .service-layout-open { - grid-template-columns: minmax(0, 1fr) 288px; + grid-template-columns: minmax(0, 1fr) 316px; } .service-layout-solo { @@ -691,21 +1084,53 @@ body.auth-page { justify-content: space-between; align-items: flex-start; gap: 12px; - margin-bottom: 8px; + margin-bottom: 10px; +} + +.panel-title { + display: grid; + gap: 3px; + min-width: 0; +} + +.matrix-panel > .panel-header, +.hosts-panel > .panel-header { + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px solid color-mix(in oklab, var(--line) 90%, var(--panel-strong)); } .panel-title h2 { margin: 0; - font-size: 0.98rem; + font-size: clamp(1.02rem, 1vw, 1.15rem); font-weight: 700; letter-spacing: -0.02em; + font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; } .panel-title p { margin: 4px 0 0; color: var(--muted); - font-size: 11px; + max-width: 50ch; + font-size: 10.5px; line-height: 1.45; + letter-spacing: 0.02em; +} + +.panel-meta { + justify-content: flex-end; +} + +.panel-header .panel-meta .badge { + min-height: 25px; + padding: 0 8px; + font-size: 9.5px; + font-weight: 700; +} + +.panel-header .panel-meta .refresh.ghost { + min-height: 30px; + padding: 0 10px; } .matrix-scroll, @@ -713,35 +1138,88 @@ body.auth-page { overflow: auto; border: 1px solid var(--line); border-radius: 14px; - background: var(--panel-muted); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%); scrollbar-gutter: stable; } .matrix-stacks { display: grid; - gap: 10px; + gap: 12px; } .matrix-stack { + --group-color: var(--accent); display: grid; - gap: 6px; + gap: 10px; + padding: 10px; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); + border-radius: 16px; + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, color-mix(in oklab, var(--panel-strong) 96%, var(--panel-muted)) 100%); + box-shadow: + var(--shadow-soft), + inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 76%, transparent); +} + +.matrix-stack::before { + content: ""; + display: block; + width: 64px; + height: 3px; + border-radius: 999px; + background: linear-gradient(90deg, var(--group-color) 0%, color-mix(in oklab, var(--group-color) 50%, transparent) 100%); } .matrix-stack-head { display: flex; - gap: 8px; + justify-content: space-between; + gap: 10px; align-items: center; + padding: 0 2px; +} + +.matrix-stack-headline { + display: grid; + gap: 2px; +} + +.matrix-stack-headline strong { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text); + font-size: 12px; + letter-spacing: -0.02em; +} + +.matrix-stack-headline strong::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--group-color); + box-shadow: 0 0 0 4px color-mix(in oklab, var(--group-color) 18%, transparent); +} + +.matrix-stack-headline span { color: var(--muted); - font-size: 10px; + font-size: 9.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } -.matrix-stack-head strong { - color: var(--text); - font-size: 11px; - letter-spacing: 0.02em; +.matrix-stack-badges { + display: inline-flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.matrix-stack-badges .badge { + min-height: 24px; + padding: 0 8px; + font-size: 9.5px; } .matrix-table, @@ -758,7 +1236,7 @@ body.auth-page { .matrix-table td, .table th, .table td { - padding: 5px 8px; + padding: 6px 9px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; @@ -767,14 +1245,23 @@ body.auth-page { .matrix-table th, .table th { - background: color-mix(in oklab, var(--accent-soft) 28%, var(--panel-muted)); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 28%, var(--panel-muted)) 0%, color-mix(in oklab, var(--accent-soft) 18%, var(--panel-strong)) 100%); color: var(--muted); font-weight: 700; + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; white-space: nowrap; } +.table tbody tr:nth-child(even), +.matrix-table tbody tr:nth-child(even) { + background: color-mix(in oklab, var(--accent-soft) 10%, var(--panel-strong)); +} + .matrix-scroll { max-height: 520px; + background: color-mix(in oklab, var(--panel-strong) 96%, var(--panel-muted)); } .matrix-row { @@ -787,6 +1274,7 @@ body.auth-page { .matrix-row.selected { background: color-mix(in oklab, var(--accent-soft) 34%, var(--panel-strong)); + box-shadow: inset 2px 0 0 color-mix(in oklab, var(--accent) 42%, transparent); } .matrix-row.focused { @@ -814,12 +1302,12 @@ body.auth-page { .matrix-state-marker { display: block; - width: 8px; - height: 8px; + width: 9px; + height: 9px; margin: 0 auto; border-radius: 999px; background: currentColor; - box-shadow: 0 0 0 2px color-mix(in oklab, currentColor 14%, transparent); + box-shadow: 0 0 0 3px color-mix(in oklab, currentColor 12%, transparent); } .matrix-state-code { @@ -886,8 +1374,26 @@ body.auth-page { display: flex; flex-direction: column; position: sticky; - top: 118px; - max-height: calc(100vh - 132px); + top: 112px; + max-height: calc(100vh - 124px); + border-color: color-mix(in oklab, var(--accent) 12%, var(--line)); + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 90%, var(--accent-soft)) 0%, color-mix(in oklab, var(--panel) 96%, var(--panel-muted)) 100%); + box-shadow: + var(--shadow-lift), + inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 78%, transparent); +} + +.detail-panel::before { + content: ""; + position: absolute; + left: 14px; + right: 14px; + top: 0; + height: 3px; + border-radius: 999px; + background: linear-gradient(90deg, color-mix(in oklab, var(--accent) 52%, var(--group-app)) 0%, transparent 72%); + opacity: 0.85; } .service-meta-grid, @@ -914,10 +1420,31 @@ body.auth-page { } .detail-instance { + position: relative; + overflow: hidden; padding: 9px; border: 1px solid var(--line); border-radius: 12px; - background: var(--panel-muted); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)) 0%, var(--panel-muted) 100%); + transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease, transform 180ms ease; +} + +.detail-instance::before, +.detail-alert-panel::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 2px; + background: linear-gradient(90deg, color-mix(in oklab, var(--accent) 42%, var(--group-app)) 0%, transparent 76%); + opacity: 0.78; +} + +.detail-instance:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent) 22%, var(--line)); + box-shadow: var(--shadow-soft), inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); } .detail-instance-top { @@ -974,6 +1501,80 @@ body.auth-page { padding: 0 9px; } +.overview-side-stack { + display: grid; + gap: 10px; +} + +.overview-side-grid, +.overview-side-host-list { + display: grid; + gap: 9px; +} + +.overview-side-hosts { + display: grid; + gap: 10px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 16px; + background: color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)); +} + +.overview-side-host-chip { + --group-color: var(--accent); + position: relative; + display: grid; + gap: 4px; + padding: 9px 10px 9px 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)) 0%, var(--panel-muted) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 68%, transparent); + transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease, transform 180ms ease; +} + +.overview-side-host-chip::before { + content: ""; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 3px; + border-radius: 999px; + background: var(--group-color); +} + +.overview-side-host-chip:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent) 18%, var(--line)); + box-shadow: var(--shadow-soft), inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 68%, transparent); +} + +.overview-side-host-chip-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.overview-side-host-chip strong { + font-size: 11px; +} + +.overview-side-host-chip em { + color: var(--muted); + font-size: 10px; + font-style: normal; + font-weight: 700; +} + +.overview-side-host-chip span { + color: var(--muted); + font-size: 10px; + line-height: 1.35; +} + .detail-log-panel { margin-top: 10px; padding: 10px; @@ -1022,11 +1623,14 @@ body.auth-page { } .detail-alert-panel { + position: relative; + overflow: hidden; margin-bottom: 8px; - padding: 8px; + padding: 10px; border: 1px solid var(--line); border-radius: 12px; - background: var(--panel-muted); + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)) 0%, var(--panel-muted) 100%); } .detail-alert-feed { @@ -1052,7 +1656,7 @@ body.auth-page { .host-sections { display: grid; - gap: 10px; + gap: 12px; } .host-section { @@ -1066,6 +1670,7 @@ body.auth-page { gap: 12px; align-items: center; flex-wrap: wrap; + padding: 0 2px; } .section-caption { @@ -1081,22 +1686,141 @@ body.auth-page { gap: 10px; align-items: center; flex-wrap: wrap; +} + +.section-caption-meta span { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border: 1px solid color-mix(in oklab, var(--line) 90%, var(--panel-strong)); + border-radius: 999px; + background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)); color: var(--muted); font-size: 10px; - font-weight: 600; + font-weight: 700; +} + +.section-caption-row-quiet { + margin-bottom: -2px; } .host-row-list { display: grid; - gap: 6px; + gap: 8px; +} + +.quiet-host-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +.quiet-host-card { + --group-color: color-mix(in oklab, var(--accent) 42%, var(--ok)); + position: relative; + display: grid; + gap: 9px; + min-height: 108px; + padding: 10px 11px; + border: 1px solid var(--line); + border-radius: 16px; + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 97%, var(--panel-strong)) 0%, color-mix(in oklab, var(--panel-muted) 92%, var(--panel-strong)) 100%); + color: var(--text); + text-align: left; + font: inherit; + cursor: pointer; + overflow: hidden; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); +} + +.quiet-host-card::before { + content: ""; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 3px; + background: linear-gradient(90deg, var(--group-color) 0%, transparent 72%); + opacity: 0.9; +} + +.quiet-host-card:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent) 28%, var(--line)); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 18%, var(--panel-strong)) 0%, var(--panel-muted) 100%); +} + +.quiet-host-card.active { + border-color: color-mix(in oklab, var(--group-color) 36%, var(--line)); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--group-color) 18%, transparent); +} + +.quiet-host-head { + display: grid; + gap: 3px; +} + +.quiet-host-head strong { + font-size: 12px; +} + +.quiet-host-head span { + color: var(--muted); + font-size: 10px; +} + +.quiet-host-metrics, +.quiet-host-foot { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.quiet-host-foot { + justify-content: space-between; +} + +.quiet-host-foot .badge:last-child { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +.quiet-host-metrics-fallback { + min-height: 32px; +} + +.metric-ring-chip-quiet { + min-height: 32px; + padding: 0 8px 0 6px; +} + +.metric-ring-chip-quiet .metric-ring { + width: 20px; + height: 20px; +} + +.metric-ring-chip-quiet .metric-ring-hole { + inset: 3px; +} + +.metric-ring-chip-quiet .metric-ring-copy strong { + font-size: 10px; } .host-strip { + --group-color: color-mix(in oklab, var(--accent) 42%, var(--ok)); position: relative; border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); border-radius: 14px; - background: var(--panel-strong); + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--panel-muted)) 0%, var(--panel-strong) 100%); overflow: hidden; + transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease, background 180ms ease; + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 70%, transparent); } .host-strip-alert { @@ -1115,16 +1839,22 @@ body.auth-page { top: 0; bottom: 0; width: 3px; - background: linear-gradient(180deg, var(--accent) 0%, color-mix(in oklab, var(--ok) 42%, var(--accent)) 100%); + background: linear-gradient(180deg, var(--group-color) 0%, color-mix(in oklab, var(--group-color) 56%, var(--panel-strong)) 100%); } .host-strip-alert::before { background: linear-gradient(180deg, color-mix(in oklab, var(--warn) 72%, var(--accent)) 0%, var(--bad) 100%); } +.host-strip:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent) 24%, var(--line)); + box-shadow: 0 10px 22px rgba(33, 46, 76, 0.08); +} + .host-strip-main { display: grid; - grid-template-columns: minmax(180px, 220px) minmax(320px, 1fr) minmax(180px, 0.9fr) auto; + grid-template-columns: minmax(176px, 214px) minmax(280px, 1fr) minmax(220px, 0.92fr) auto; gap: 10px; align-items: center; min-height: 56px; @@ -1163,6 +1893,10 @@ body.auth-page { font-weight: 600; } +.compact-metrics-fallback { + justify-content: flex-start; +} + .metric-ring-chip, .metric-stat-chip { display: inline-flex; @@ -1237,10 +1971,48 @@ body.auth-page { white-space: nowrap; } +.metric-state-chip { + display: inline-grid; + gap: 3px; + min-height: 36px; + padding: 6px 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)); +} + +.metric-state-chip small, +.metric-state-chip strong { + display: block; +} + +.metric-state-chip small { + color: var(--muted); + font-size: 9px; + line-height: 1; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.metric-state-chip strong { + font-size: 11px; + line-height: 1.2; +} + +.metric-state-chip.tone-warn { + border-color: color-mix(in oklab, var(--warn) 34%, var(--line)); + background: color-mix(in oklab, var(--warn-soft) 62%, var(--panel-strong)); + color: var(--warn); +} + .host-alert-summary { display: grid; gap: 4px; min-width: 0; + padding: 8px 10px; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); + border-radius: 12px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%); } .host-alert-summary small { @@ -1266,14 +2038,28 @@ body.auth-page { color: color-mix(in oklab, var(--bad) 82%, var(--text)); } +.host-alert-summary.tone-bad { + border-color: color-mix(in oklab, var(--bad) 24%, var(--line)); + background: color-mix(in oklab, var(--bad-soft) 44%, var(--panel-strong)); +} + .host-alert-summary.tone-warn .host-alert-path { color: color-mix(in oklab, var(--warn) 72%, var(--text)); } +.host-alert-summary.tone-warn { + border-color: color-mix(in oklab, var(--warn) 24%, var(--line)); + background: color-mix(in oklab, var(--warn-soft) 40%, var(--panel-strong)); +} + .host-alert-summary.tone-neutral .host-alert-path { color: color-mix(in oklab, var(--muted) 76%, var(--text)); } +.host-inline-state { + justify-content: flex-end; +} + .metric-chip, .host-service-pill { border: 1px solid var(--line); @@ -1373,15 +2159,11 @@ body.auth-page { .overview-infra-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-top: 12px; } -.task-page-head { - margin-top: 8px; -} - .metadata-strip { display: flex; gap: 10px; @@ -1394,12 +2176,55 @@ body.auth-page { background: var(--panel-muted); color: var(--muted); font-size: 11px; + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 60%, transparent); } .metadata-strip span { white-space: nowrap; } +.metadata-strip-tight { + gap: 8px; + margin-bottom: 8px; + padding: 6px 8px; + font-size: 10px; +} + +.compact-workbench-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; + margin-bottom: 10px; + padding: 8px 10px; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); + border-radius: 14px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 98%, var(--panel-strong)) 0%, var(--panel-muted) 100%); +} + +.compact-workbench-head .metadata-strip { + flex: 1 1 auto; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.compact-workbench-head .metadata-strip span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +.config-workbench-panel { + position: relative; + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--accent-soft)) 0%, color-mix(in oklab, var(--panel) 98%, var(--panel-muted)) 100%); + box-shadow: var(--shadow-soft), inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 74%, transparent); +} + .infra-overview-panel { margin-top: 12px; } @@ -1407,18 +2232,40 @@ body.auth-page { .infra-overview-grid, .mongo-group-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; } .infra-focus-card, .mongo-stat-group { + position: relative; display: grid; gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--panel-muted); + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 70%, transparent); +} + +.infra-focus-card::before, +.mongo-stat-group::before, +.nacos-service-card::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 3px; + background: linear-gradient(90deg, color-mix(in oklab, var(--accent) 48%, var(--ok)) 0%, transparent 76%); +} + +.infra-focus-card:hover, +.mongo-stat-group:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent) 22%, var(--line)); + box-shadow: var(--shadow-soft), inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 70%, transparent); } .infra-focus-head { @@ -1480,22 +2327,89 @@ body.auth-page { gap: 12px; } -.config-sidebar { +.mongo-workbench-panel .panel-header { + margin-bottom: 12px; +} + +.mongo-workbench-strip { + margin-bottom: 12px; +} + +.mongo-secondary-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; +} + +.mongo-mini-card { + min-height: 0; + padding: 9px 10px; + border-radius: 14px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--accent-soft)) 0%, var(--panel-muted) 100%); +} + +.mongo-mini-card small { + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.mongo-mini-card strong { + margin-top: 5px; +} + +.mongo-detail-stack { + gap: 10px; +} + +.mongo-detail-block { display: grid; gap: 10px; - align-self: start; - position: sticky; - top: 124px; - max-height: calc(100vh - 140px); - padding: 10px; + padding: 12px; border: 1px solid var(--line); border-radius: 16px; - background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 95%, var(--panel-strong)) 0%, var(--panel-strong) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); +} + +.mongo-detail-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.mongo-detail-head .panel-title h2 { + font-size: 13px; +} + +.mongo-detail-head .panel-title p { + margin-top: 3px; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.mongo-detail-block .table-wrap { + margin-top: 0; +} + +.config-sidebar { + display: grid; + gap: 4px; + align-self: start; + position: sticky; + top: 116px; + max-height: calc(100vh - 128px); + padding: 6px; + border: 1px solid var(--line); + border-radius: 14px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); } .config-sidebar-head { display: grid; - gap: 8px; + gap: 6px; } .config-sidebar-meta { @@ -1504,49 +2418,42 @@ body.auth-page { align-items: center; } -.config-sidebar-path { - display: grid; - gap: 3px; - padding: 8px 10px; - border: 1px solid var(--line); - border-radius: 12px; - background: var(--panel-strong); -} - -.config-sidebar-path strong { - font-size: 12px; -} - -.config-sidebar-path span { - color: var(--muted); - font-size: 10px; +.config-sidebar-head.compact { + padding-bottom: 2px; } .config-list-ide { - gap: 10px; - padding-right: 4px; + gap: 4px; + padding-right: 2px; } .config-group { display: grid; - gap: 6px; + gap: 3px; } .config-group-toggle { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; - gap: 10px; + gap: 8px; align-items: center; width: 100%; - min-height: 32px; - padding: 0 10px; + min-height: 26px; + padding: 0 8px; border: 1px solid var(--line); - border-radius: 12px; - background: var(--panel-strong); + border-radius: 10px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 98%, var(--panel-strong)) 0%, var(--panel-strong) 100%); color: var(--text); font: inherit; cursor: pointer; text-align: left; + transition: border-color 180ms ease, background 180ms ease; + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 68%, transparent); +} + +.config-group-toggle:hover { + border-color: color-mix(in oklab, var(--accent) 22%, var(--line)); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 18%, var(--panel-strong)) 0%, var(--panel-strong) 100%); } .config-group-toggle strong { @@ -1556,13 +2463,15 @@ body.auth-page { .config-group-toggle span, .config-group-toggle small { color: var(--muted); - font-size: 10px; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.06em; } .config-group-items { display: grid; gap: 2px; - padding-left: 10px; + padding-left: 6px; border-left: 1px solid color-mix(in oklab, var(--line) 88%, transparent); } @@ -1571,12 +2480,12 @@ body.auth-page { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; - padding: 8px 9px 8px 12px; + padding: 6px 7px 6px 9px; border: 0; border-radius: 10px; background: transparent; position: relative; - transition: background 180ms ease, box-shadow 180ms ease; + transition: background 180ms ease, box-shadow 180ms ease, border-color 180ms ease, transform 180ms ease; } .config-item-ide::before { @@ -1591,29 +2500,32 @@ body.auth-page { } .config-item-ide strong { - font-size: 11px; + font-size: 10.5px; word-break: break-word; + line-height: 1.3; } .config-item-ide span { margin-top: 0; - font-size: 10px; + font-size: 9.5px; text-transform: uppercase; + letter-spacing: 0.06em; } .config-item-ide small { - font-size: 10px; + font-size: 9.5px; color: var(--muted); } .config-item-ide.active { - background: color-mix(in oklab, var(--accent-soft) 64%, var(--panel-strong)); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 64%, var(--panel-strong)) 0%, color-mix(in oklab, var(--accent-soft) 42%, var(--panel-strong)) 100%); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 26%, var(--line)); } .config-item-ide:hover { transform: none; - background: color-mix(in oklab, var(--accent-soft) 30%, var(--panel-strong)); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 22%, var(--panel-strong)) 0%, color-mix(in oklab, var(--accent-soft) 12%, var(--panel-strong)) 100%); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 10%, var(--line)); } .config-item-ide.active::before { @@ -1636,15 +2548,43 @@ body.auth-page { align-items: center; } +.editor-doc-pill { + display: grid; + gap: 2px; + min-width: 0; + padding: 4px 7px; + border: 1px solid var(--line); + border-radius: 11px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--accent-soft)) 0%, var(--panel-strong) 100%); +} + +.editor-doc-pill strong, +.editor-doc-pill small { + display: block; +} + +.editor-doc-pill strong { + font-size: 11px; + line-height: 1.3; + word-break: break-word; +} + +.editor-doc-pill small { + color: var(--muted); + font-size: 10px; +} + .editor-toolbar { + position: relative; display: flex; justify-content: space-between; gap: 12px; align-items: center; - padding: 10px; - border: 1px solid var(--line); + padding: 6px; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); border-radius: 14px; - background: color-mix(in oklab, var(--panel-muted) 92%, var(--panel-strong)); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); } .sticky-toolbar { @@ -1655,7 +2595,7 @@ body.auth-page { .editor-toolbar-main { display: flex; - gap: 10px; + gap: 8px; align-items: center; flex-wrap: wrap; } @@ -1665,6 +2605,7 @@ body.auth-page { border-radius: 16px; background: oklch(100% 0 0 / 0.86); overflow: auto; + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 76%, transparent); } .diff-list { @@ -1716,6 +2657,10 @@ body.auth-page { margin-top: 12px; } +.activity-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + .activity-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -1724,7 +2669,13 @@ body.auth-page { padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px; - background: var(--panel-muted); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%); + transition: border-color 180ms ease, background 180ms ease, transform 180ms ease; +} + +.activity-item:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent) 20%, var(--line)); } .activity-item.compact { @@ -1779,15 +2730,17 @@ body.auth-page { } .release-action-bar { + position: relative; display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-top: 10px; padding: 10px 12px; - border: 1px solid var(--line); + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); border-radius: 14px; - background: color-mix(in oklab, var(--panel-muted) 92%, var(--panel-strong)); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%); + box-shadow: var(--shadow-soft), inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); } .release-action-summary { @@ -1795,6 +2748,11 @@ body.auth-page { gap: 8px; flex-wrap: wrap; align-items: center; + padding: 4px; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); + border-radius: 12px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--panel-muted)) 0%, var(--panel-strong) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); } .release-status-grid { @@ -1804,11 +2762,25 @@ body.auth-page { } .release-status-item { + position: relative; + overflow: hidden; min-height: 56px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel-muted); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 68%, transparent); +} + +.release-status-item::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 2px; + background: linear-gradient(90deg, color-mix(in oklab, currentColor 46%, transparent) 0%, transparent 78%); + opacity: 0.82; } .release-status-item.tone-ok { @@ -1840,11 +2812,134 @@ body.auth-page { .release-status-item strong { margin-top: 6px; - font-size: 12px; + font-size: 12.5px; line-height: 1.45; word-break: break-word; } +.release-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(228px, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.release-service-card { + --release-kind-color: var(--accent); + position: relative; + display: grid; + gap: 8px; + padding: 11px 11px 10px; + border: 1px solid var(--line); + border-radius: 15px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%); + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +.release-service-card::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 3px; + background: linear-gradient(90deg, var(--release-kind-color) 0%, transparent 78%); +} + +.release-service-card:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent) 28%, var(--line)); +} + +.release-service-card.selected { + border-color: color-mix(in oklab, var(--accent) 38%, var(--line)); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 38%, var(--panel-strong)) 0%, color-mix(in oklab, var(--accent-soft) 20%, var(--panel-muted)) 100%); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 18%, transparent); +} + +.release-service-card.selected::before { + background: linear-gradient(90deg, color-mix(in oklab, var(--accent) 78%, var(--release-kind-color)) 0%, transparent 84%); +} + +.release-service-card.selected .release-service-title strong { + color: var(--accent); +} + +.release-service-card[data-kind="java"] { + --release-kind-color: var(--group-app); +} + +.release-service-card[data-kind="golang"] { + --release-kind-color: var(--group-gateway); +} + +.release-service-card[data-kind="frontend"] { + --release-kind-color: var(--group-pay); +} + +.release-service-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; +} + +.release-service-title { + min-width: 0; +} + +.release-service-title strong, +.release-service-title small { + display: block; +} + +.release-service-title strong { + font-size: 13px; + line-height: 1.3; +} + +.release-service-tags { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.release-service-tags .badge { + min-height: 24px; + padding: 0 8px; + font-size: 10px; +} + +.release-service-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.release-service-metric { + display: grid; + gap: 4px; + min-width: 0; + padding: 8px 9px; + border: 1px solid var(--line); + border-radius: 12px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--accent-soft)) 0%, var(--panel-strong) 100%); +} + +.release-service-metric strong, +.release-service-metric span { + display: block; + min-width: 0; + font-size: 11px; + line-height: 1.4; + word-break: break-word; +} + +.release-service-metric-wide { + grid-column: 1 / -1; +} + .precheck-strip { display: flex; gap: 8px; @@ -1860,7 +2955,7 @@ body.auth-page { padding: 12px; border: 1px solid var(--line); border-radius: 16px; - background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%); } .release-log-preview-head { @@ -1877,16 +2972,23 @@ body.auth-page { .release-log-snippets { display: grid; gap: 8px; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } .release-log-snippet { display: grid; gap: 4px; + min-height: 86px; padding: 10px 11px; border: 1px solid var(--line); border-radius: 12px; - background: var(--panel-strong); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--accent-soft)) 0%, var(--panel-strong) 100%); + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.release-log-snippet:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent) 20%, var(--line)); } .release-log-snippet strong, @@ -1946,13 +3048,38 @@ body.auth-page { display: inline-flex; gap: 6px; align-items: center; + min-height: 26px; + padding: 0 8px; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); + border-radius: 999px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--panel-muted)) 0%, var(--panel-strong) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 68%, transparent); color: var(--muted); font-size: 11px; + cursor: pointer; + white-space: nowrap; + transition: border-color 180ms ease, background 180ms ease, color 180ms ease, box-shadow 180ms ease; +} + +.table-check:hover { + border-color: color-mix(in oklab, var(--accent) 18%, var(--line)); +} + +.table-check.checked { + color: var(--accent); + border-color: color-mix(in oklab, var(--accent) 28%, var(--line)); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 62%, var(--panel-strong)) 0%, color-mix(in oklab, var(--accent-soft) 38%, var(--panel-strong)) 100%); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 14%, transparent); +} + +.table-check span { + font-weight: 700; } .table-check input { - width: 15px; - height: 15px; + width: 14px; + height: 14px; + accent-color: var(--accent); } .table-empty-cell { @@ -1973,16 +3100,43 @@ body.auth-page { } .bridge-panel { - padding: 8px 10px; + display: grid; + gap: 10px; + padding: 10px 12px 12px; + background: + linear-gradient(180deg, color-mix(in oklab, var(--bad-soft) 14%, var(--panel-strong)) 0%, var(--panel) 100%); +} + +.bridge-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; + padding: 0 2px; +} + +.bridge-head strong { + font-size: 12px; + letter-spacing: -0.02em; +} + +.bridge-head span { + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } .bridge-list { display: grid; - gap: 6px; - grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } .bridge-item { + position: relative; + overflow: hidden; display: grid; grid-template-columns: minmax(92px, 110px) minmax(0, 1fr) auto; gap: 12px; @@ -1991,13 +3145,39 @@ body.auth-page { padding: 7px 10px; border: 1px solid color-mix(in oklab, var(--bad) 24%, var(--line)); border-radius: 10px; - background: color-mix(in oklab, var(--bad-soft) 44%, var(--panel-strong)); + background: + linear-gradient(180deg, color-mix(in oklab, var(--bad-soft) 46%, var(--panel-strong)) 0%, color-mix(in oklab, var(--bad-soft) 62%, var(--panel-muted)) 100%); color: var(--text); font: inherit; text-align: left; cursor: pointer; } +.bridge-item::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: color-mix(in oklab, var(--bad) 72%, var(--warn)); +} + +.bridge-item:hover { + border-color: color-mix(in oklab, var(--accent) 22%, var(--line)); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 18%, var(--panel-strong)) 0%, var(--panel-strong) 100%); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } +} + .bridge-item strong { font-size: 11px; color: var(--bad); @@ -2027,10 +3207,26 @@ body.auth-page { padding: 0 12px; border: 1px solid var(--line); border-radius: 999px; - background: var(--panel-muted); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 98%, var(--panel-strong)) 0%, var(--panel-muted) 100%); color: var(--text); font: inherit; cursor: pointer; + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 70%, transparent); + transition: border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; +} + +.host-fold-toggle::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 999px; + background: color-mix(in oklab, var(--accent) 54%, var(--group-app)); + box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent-soft) 66%, transparent); +} + +.host-fold-toggle:hover { + border-color: color-mix(in oklab, var(--accent) 20%, var(--line)); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 16%, var(--panel-muted)) 0%, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 100%); } .host-fold-toggle strong { @@ -2044,8 +3240,11 @@ body.auth-page { } .infra-mini-card { + position: relative; + overflow: hidden; display: flex; flex-direction: column; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 94%, var(--accent-soft)) 0%, color-mix(in oklab, var(--panel) 98%, var(--panel-muted)) 100%); } .infra-mini-metrics { @@ -2067,10 +3266,11 @@ body.auth-page { } .nacos-service-card { + position: relative; padding: 14px; border: 1px solid var(--line); border-radius: 16px; - background: var(--panel-muted); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-muted) 100%); } .table-wrap { @@ -2115,14 +3315,20 @@ body.auth-page { .config-layout { display: grid; - grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); - gap: 16px; + grid-template-columns: minmax(208px, 236px) minmax(0, 1fr); + gap: 10px; +} + +.infra-panel > .panel-header { + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px solid color-mix(in oklab, var(--line) 90%, var(--panel-strong)); } .config-list { display: grid; - gap: 10px; - max-height: 760px; + gap: 6px; + max-height: calc(100vh - 196px); overflow: auto; padding-right: 4px; } @@ -2190,29 +3396,636 @@ body.auth-page { display: flex; align-items: center; justify-content: center; - min-height: 160px; - padding: 18px; - border: 1px dashed var(--line-strong); + min-height: 88px; + padding: 14px; + border: 1px solid color-mix(in oklab, var(--line) 88%, var(--panel-strong)); border-radius: 16px; - background: color-mix(in oklab, var(--panel-muted) 88%, var(--bg-soft)); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--bg-soft)) 0%, color-mix(in oklab, var(--panel-muted) 88%, var(--panel-strong)) 100%); color: var(--muted); text-align: center; + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 68%, transparent); } .empty-panel.compact { - min-height: 88px; + min-height: 60px; + padding: 12px; +} + +[data-group="gateway"] { + --group-color: var(--group-gateway); +} + +[data-group="app"] { + --group-color: var(--group-app); +} + +[data-group="pay"] { + --group-color: var(--group-pay); +} + +[data-group="nacos"], +[data-group="mongo"], +[data-group="infra"] { + --group-color: var(--group-infra); } .config-textarea { width: 100%; - min-height: 500px; - padding: 16px; + min-height: clamp(320px, 60vh, 680px); + padding: 12px 14px; resize: vertical; - border-radius: 16px; + border-radius: 14px; background: oklch(100% 0 0 / 0.86); box-shadow: inset 0 1px 2px rgba(52, 71, 103, 0.08); - font-size: 13px; - line-height: 1.55; + font-size: 12px; + line-height: 1.5; +} + +.task-page-head { + margin-top: 12px; + padding: 12px 14px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--accent-soft)) 0%, color-mix(in oklab, var(--panel) 98%, var(--panel-muted)) 100%); + box-shadow: var(--shadow-soft), inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 76%, transparent); +} + +.task-page-head::before, +.config-workbench-panel::before, +.release-action-bar::before, +.editor-toolbar::before { + content: ""; + position: absolute; + left: 14px; + right: 14px; + top: 0; + height: 3px; + border-radius: 999px; + background: linear-gradient(90deg, color-mix(in oklab, var(--accent) 44%, var(--group-app)) 0%, transparent 78%); + opacity: 0.82; +} + +.task-page-head .panel-header { + margin: 0; + align-items: center; +} + +.task-page-head .panel-title h2 { + font-size: clamp(1.08rem, 1.12vw, 1.2rem); +} + +.task-page-head .panel-title p { + margin-top: 5px; + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.task-page-head .panel-meta .badge.neutral { + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 98%, var(--panel-strong)) 0%, var(--panel-muted) 100%); +} + +.release-section-nav { + display: flex; + gap: 6px; + align-items: center; + padding: 4px; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); + border-radius: 14px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 0%, var(--panel-strong) 100%); + overflow: auto; +} + +.mongo-admin-layout { + align-items: start; +} + +.database-control-bar { + align-items: center; +} + +.database-engine-switch { + position: relative; + padding-right: 10px; +} + +.database-engine-switch::after { + content: ""; + position: absolute; + top: 6px; + right: 0; + bottom: 6px; + width: 1px; + background: color-mix(in oklab, var(--line) 88%, transparent); +} + +.database-workbench-panel { + position: relative; + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 97%, var(--accent-soft)) 0%, color-mix(in oklab, var(--panel) 98%, var(--panel-muted)) 100%); + box-shadow: var(--shadow-soft), inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 74%, transparent); +} + +.database-workbench-panel::before { + content: ""; + position: absolute; + left: 16px; + right: 16px; + top: 0; + height: 3px; + border-radius: 999px; + background: linear-gradient(90deg, color-mix(in oklab, var(--accent) 44%, var(--group-app)) 0%, transparent 78%); + opacity: 0.84; +} + +.mongo-taskbar-context { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + min-width: 0; + gap: 8px; +} + +.mongo-taskbar-context span { + color: inherit; + font-size: 11px; + letter-spacing: 0; + text-transform: none; +} + +.mongo-sidebar { + top: 112px; +} + +.mongo-admin-editor { + min-width: 0; +} + +.mysql-workbench-panel { + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--group-pay)) 0%, color-mix(in oklab, var(--panel) 98%, var(--panel-muted)) 100%); +} + +.mongo-workbench-panel { + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--group-infra)) 0%, color-mix(in oklab, var(--panel) 98%, var(--panel-muted)) 100%); +} + +.mysql-workbench-head { + margin-bottom: 8px; +} + +.mongo-workbench-head { + margin-bottom: 8px; +} + +.mongo-workbench-head .metadata-strip, +.mysql-workbench-head .metadata-strip { + gap: 8px; +} + +.mongo-workbench-head .metadata-strip span, +.mysql-workbench-head .metadata-strip span { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); + border-radius: 999px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--panel-muted)) 0%, var(--panel-strong) 100%); + color: color-mix(in oklab, var(--muted) 92%, var(--text)); +} + +.mongo-admin-subpanel, +.mongo-action-panel { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 18px; + background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)); +} + +.mongo-panel-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.mongo-panel-grid.narrow { + grid-template-columns: minmax(0, 220px) minmax(0, 1fr); +} + +.mongo-query-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.mongo-json-textarea { + min-height: 180px; +} + +.mongo-editor-textarea { + min-height: 320px; +} + +.mongo-subtoolbar { + margin-top: 0; +} + +.mongo-document-table td { + vertical-align: top; +} + +.mongo-doc-preview, +.mongo-index-text { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-size: 11px; + line-height: 1.45; +} + +.mongo-doc-preview { + color: var(--muted); +} + +.mongo-index-text { + color: var(--text); +} + +.mongo-pager { + display: flex; + justify-content: flex-end; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.mongo-check-grid { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.mongo-tag-list { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.mongo-mini-empty { + padding: 8px 10px; + border: 1px dashed var(--line); + border-radius: 10px; + color: var(--muted); + font-size: 11px; + text-align: left; +} + +.mysql-overview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.mongo-admin-overview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.mongo-admin-overview-card, +.mysql-overview-card { + position: relative; + overflow: hidden; + display: grid; + gap: 4px; + min-height: 66px; + padding: 9px 10px; + border-radius: 14px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 97%, var(--panel-muted)) 0%, var(--panel-muted) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 74%, transparent); +} + +.mongo-admin-overview-card::before, +.mysql-overview-card::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 3px; + background: linear-gradient(90deg, color-mix(in oklab, currentColor 42%, transparent) 0%, transparent 78%); + opacity: 0.8; +} + +.mysql-overview-card small { + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.mongo-admin-overview-card small, +.mongo-admin-overview-card span, +.mysql-overview-card span { + color: var(--muted); + font-size: 10px; + line-height: 1.45; +} + +.mongo-admin-overview-card small { + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.database-admin-layout { + grid-template-columns: minmax(216px, 236px) minmax(0, 1fr); + gap: 12px; +} + +.mysql-sidebar-section { + display: grid; + gap: 8px; + padding: 6px 6px 8px; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); + border-radius: 12px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--panel-muted)) 0%, var(--panel-strong) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 70%, transparent); +} + +.mongo-sidebar-section { + display: grid; + gap: 6px; + padding: 6px 6px 8px; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); + border-radius: 12px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--panel-muted)) 0%, var(--panel-strong) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 70%, transparent); +} + +.mongo-sidebar-strip, +.mysql-sidebar-strip { + margin-bottom: 4px; +} + +.mysql-admin-layout { + grid-template-columns: minmax(216px, 236px) minmax(0, 1fr); + gap: 10px; +} + +.mongo-sidebar-list { + max-height: min(46vh, 520px); +} + +.mysql-sidebar-list { + max-height: min(35vh, 360px); +} + +.mysql-sidebar-section .section-caption-row { + padding: 0 2px; +} + +.table-scroll-x { + overflow-x: auto; +} + +.mysql-data-table { + min-width: max-content; +} + +.mysql-row-action-cell { + min-width: 188px; +} + +.mysql-sort-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + border: none; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.mysql-sort-indicator { + color: var(--accent); + font-size: 10px; + letter-spacing: 0.06em; +} + +.mysql-cell { + max-width: 240px; + white-space: pre-wrap; + word-break: break-word; + vertical-align: top; +} + +.mysql-cell.is-null { + color: var(--muted); + font-style: italic; +} + +.mysql-data-table tbody tr.selected { + background: color-mix(in oklab, var(--accent-soft) 28%, var(--panel-strong)); +} + +.mysql-filter-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.mysql-filter-list { + display: grid; + gap: 8px; +} + +.mysql-filter-row { + display: grid; + grid-template-columns: minmax(0, 168px) minmax(0, 128px) minmax(0, 1fr) auto; + gap: 10px; + align-items: end; + padding: 8px; + border: 1px dashed color-mix(in oklab, var(--line) 90%, var(--panel-strong)); + border-radius: 14px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--panel-muted)) 0%, var(--panel-strong) 100%); +} + +.mysql-filter-static { + display: flex; + align-items: center; + min-height: 36px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel-muted); + color: var(--muted); + font-size: 12px; +} + +.mysql-query-stack { + display: grid; + gap: 6px; + padding: 8px; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); + border-radius: 16px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--panel-muted)) 0%, color-mix(in oklab, var(--panel-muted) 96%, var(--panel-strong)) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); +} + +.mongo-context-strip, +.mongo-result-strip { + margin-top: 0; +} + +.mongo-cell-preview { + max-width: 420px; + white-space: pre-wrap; + word-break: break-word; +} + +.mongo-aggregate-side { + align-content: start; +} + +.mongo-danger-panel { + border-color: color-mix(in oklab, var(--bad) 22%, var(--line)); + background: linear-gradient(180deg, color-mix(in oklab, var(--bad-soft) 20%, var(--panel-strong)) 0%, color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)) 100%); +} + +.mongo-danger-panel::before { + background: linear-gradient(90deg, color-mix(in oklab, var(--bad) 74%, var(--warn)) 0%, transparent 78%); +} + +.action-group-stack { + display: grid; + gap: 8px; +} + +.config-group-toggle.active { + border-color: color-mix(in oklab, var(--accent) 20%, var(--line)); + background: linear-gradient(180deg, color-mix(in oklab, var(--accent-soft) 28%, var(--panel-strong)) 0%, var(--panel-strong) 100%); +} + +.mysql-checkbox-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.mysql-checkbox { + display: flex; + align-items: center; + gap: 8px; + min-height: 40px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel-muted); + font-size: 12px; +} + +.mysql-checkbox input { + width: 16px; + height: 16px; +} + +.mysql-shortcut-grid, +.mysql-tag-list, +.mysql-result-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.mysql-result-toolbar { + margin-top: 10px; +} + +.mysql-write-grid, +.mysql-schema-grid { + align-items: start; +} + +.mysql-action-panel { + position: relative; + overflow: hidden; + border-radius: 16px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 98%, var(--panel-muted)) 0%, color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)) 100%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 72%, transparent); +} + +.mysql-action-panel::before, +.mysql-console-panel::before, +.mysql-console-result::before, +.mysql-ddl-panel::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 3px; + background: linear-gradient(90deg, color-mix(in oklab, var(--accent) 48%, var(--group-pay)) 0%, transparent 78%); + opacity: 0.82; +} + +.mysql-action-panel.tone-write::before { + background: linear-gradient(90deg, color-mix(in oklab, var(--ok) 72%, var(--accent)) 0%, transparent 78%); +} + +.mysql-action-panel.tone-edit::before { + background: linear-gradient(90deg, color-mix(in oklab, var(--warn) 74%, var(--accent)) 0%, transparent 78%); +} + +.mysql-action-panel.tone-structure::before { + background: linear-gradient(90deg, color-mix(in oklab, var(--group-app) 64%, var(--accent)) 0%, transparent 78%); +} + +.mysql-console-panel, +.mysql-console-result, +.mysql-ddl-panel { + position: relative; + overflow: hidden; +} + +.mysql-console-panel { + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)) 0%, color-mix(in oklab, var(--panel-strong) 98%, var(--panel-muted)) 100%); +} + +.mysql-shortcut-grid { + padding-bottom: 2px; +} + +.mysql-shortcut-grid .refresh { + min-height: 28px; +} + +.mysql-console-textarea { + min-height: 300px; + border-color: color-mix(in oklab, var(--line-strong) 76%, transparent); + background: oklch(22% 0.01 240 / 0.98); + color: oklch(91% 0.01 230); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.mysql-ddl-textarea { + min-height: 240px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--panel-muted)) 0%, color-mix(in oklab, var(--panel-muted) 92%, var(--panel-strong)) 100%); +} + +.mysql-result-strip { + background: linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 96%, var(--accent-soft)) 0%, var(--panel-muted) 100%); +} + +.action-group-tight { + gap: 6px; } .config-actions { @@ -2352,6 +4165,8 @@ body.auth-page { background: var(--panel-muted); font-size: 11px; font-weight: 600; + letter-spacing: 0.01em; + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--panel-strong) 66%, transparent); } .badge.neutral { @@ -2409,6 +4224,14 @@ body.auth-page { } @media (max-width: 1320px) { + .loading-band-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .loading-panels { + grid-template-columns: 1fr; + } + .topbar { grid-template-columns: 1fr; } @@ -2436,13 +4259,26 @@ body.auth-page { .infra-overview-grid, .mongo-group-grid, .release-status-grid, - .release-log-snippets { + .release-log-snippets, + .overview-calm-grid, + .release-service-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .update-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .mongo-panel-grid, + .mongo-query-grid, + .mysql-filter-grid { + grid-template-columns: 1fr; + } + + .mysql-filter-row, + .mysql-checkbox-grid { + grid-template-columns: 1fr; + } } @media (max-width: 1040px) { @@ -2450,6 +4286,12 @@ body.auth-page { position: static; } + .topbar-nav, + .topbar-status { + width: 100%; + justify-content: flex-start; + } + .detail-panel { position: static; max-height: none; @@ -2466,6 +4308,10 @@ body.auth-page { gap: 6px; } + .loading-band-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .host-strip-main { grid-template-columns: 1fr; } @@ -2474,10 +4320,18 @@ body.auth-page { grid-template-columns: 1fr; } + .mongo-panel-grid.narrow { + grid-template-columns: 1fr; + } + .config-list { max-height: 280px; } + .mysql-sidebar-list { + max-height: 280px; + } + .sticky-toolbar, .sticky-release-strip { position: static; @@ -2486,7 +4340,9 @@ body.auth-page { .release-status-grid, .infra-overview-grid, .mongo-group-grid, - .release-log-snippets { + .release-log-snippets, + .overview-calm-grid, + .release-service-grid { grid-template-columns: 1fr; } } @@ -2509,6 +4365,12 @@ body.auth-page { padding: 14px 12px 28px; } + .boot-indicator { + top: 14px; + width: calc(100% - 24px); + justify-content: center; + } + .topbar { position: static; padding: 14px; @@ -2544,9 +4406,15 @@ body.auth-page { .infra-mini-metrics, .config-meta-grid, .update-form-grid, + .mongo-panel-grid, + .mongo-query-grid, + .mysql-filter-grid, + .mysql-checkbox-grid, .release-status-grid, .infra-overview-grid, - .mongo-group-grid { + .mongo-group-grid, + .overview-calm-grid, + .release-service-grid { grid-template-columns: 1fr; } @@ -2585,7 +4453,9 @@ body.auth-page { .taskbar-context, .taskbar-inline, .metadata-strip, + .mongo-pager, .precheck-strip, + .compact-workbench-head, .activity-item-side, .release-action-bar, .release-action-summary, @@ -2601,6 +4471,10 @@ body.auth-page { grid-template-columns: 1fr; } + .mysql-filter-row { + grid-template-columns: 1fr; + } + .log-viewer-overlay { padding: 14px 12px; } @@ -2611,7 +4485,9 @@ body.auth-page { } .release-action-bar, - .log-viewer-head { + .log-viewer-head, + .compact-workbench-head, + .release-service-head { flex-direction: column; align-items: stretch; } diff --git a/static/app.js b/static/app.js index 3645d1c..bf81bd2 100644 --- a/static/app.js +++ b/static/app.js @@ -23,9 +23,14 @@ const VIEW_TABS = [ { id: "services", label: "服务" }, { id: "hosts", label: "主机" }, { id: "infra", label: "基础设施" }, + { id: "database", label: "数据库" }, { id: "config", label: "配置" }, { id: "release", label: "发布" }, ]; +const DATABASE_ENGINES = [ + { id: "mysql", label: "MySQL" }, + { id: "mongo", label: "MongoDB" }, +]; const PRIMARY_FILTERS = [ { id: "all", label: "全部" }, { id: "down", label: "异常" }, @@ -52,6 +57,44 @@ const INFRA_TABS = [ { id: "mongo", label: "Mongo 指标" }, { id: "tat", label: "TAT" }, ]; +const MONGO_ADMIN_TABS = [ + { id: "browse", label: "浏览" }, + { id: "write", label: "写入" }, + { id: "aggregate", label: "聚合" }, + { id: "indexes", label: "索引" }, +]; +const MYSQL_ADMIN_TABS = [ + { id: "browse", label: "数据" }, + { id: "write", label: "写入" }, + { id: "schema", label: "结构" }, + { id: "sql", label: "SQL" }, +]; +const MYSQL_FILTER_OPERATORS = [ + { id: "eq", label: "=" }, + { id: "ne", label: "!=" }, + { id: "gt", label: ">" }, + { id: "ge", label: ">=" }, + { id: "lt", label: "<" }, + { id: "le", label: "<=" }, + { id: "in", label: "IN" }, + { id: "not_in", label: "NOT IN" }, + { id: "between", label: "BETWEEN" }, + { id: "not_between", label: "NOT BETWEEN" }, + { id: "like", label: "LIKE" }, + { id: "not_like", label: "NOT LIKE" }, + { id: "is_null", label: "IS NULL" }, + { id: "is_not_null", label: "IS NOT NULL" }, +]; +const MYSQL_INDEX_MODES = [ + { id: "normal", label: "普通" }, + { id: "unique", label: "唯一" }, + { id: "fulltext", label: "全文" }, +]; +const MYSQL_DEFAULT_MODES = [ + { id: "none", label: "无默认值" }, + { id: "literal", label: "字面量" }, + { id: "null", label: "NULL" }, +]; function rootPath(path) { const normalized = typeof path === "string" && path.startsWith("/") ? path : `/${path || ""}`; @@ -67,21 +110,251 @@ function loginRedirectPath() { return rootPath(`/login?next=${encodeURIComponent(`${window.location.pathname}${window.location.search}`)}`); } +function createMongoAdminState() { + return { + loaded: false, + loading: false, + detailLoading: false, + querying: false, + error: "", + message: "", + messageTone: "neutral", + keyword: "", + activeTab: "browse", + pageSizeOptions: [50, 100, 200], + tabs: MONGO_ADMIN_TABS, + databases: [], + selectedDatabase: "", + selectedCollection: "", + collectionDetail: { + updatedAt: "", + stats: { + count: 0, + sizeMb: 0, + storageSizeMb: 0, + avgObjSizeBytes: 0, + indexCount: 0, + totalIndexSizeMb: 0, + capped: false, + wiredTigerUri: "", + }, + indexes: [], + }, + query: { + filterText: "{}", + sortText: JSON.stringify({ _id: -1 }, null, 2), + projectionText: "{}", + page: 1, + pageSize: 100, + total: 0, + pageCount: 1, + items: [], + }, + editor: { + selectedKey: "", + idText: "", + filterText: "{}", + documentText: "{}", + upsert: false, + saving: false, + }, + createDocument: { + documentText: "{}", + ordered: true, + saving: false, + }, + updateOp: { + filterText: "{}", + updateText: JSON.stringify({ $set: {} }, null, 2), + many: false, + upsert: false, + running: false, + }, + deleteOp: { + filterText: "{}", + many: false, + running: false, + }, + aggregate: { + pipelineText: "[]", + page: 1, + pageSize: 100, + total: 0, + pageCount: 1, + items: [], + running: false, + }, + distinct: { + field: "", + filterText: "{}", + values: [], + valuesText: "[]", + running: false, + count: 0, + }, + indexForm: { + keysText: JSON.stringify({ field: 1 }, null, 2), + optionsText: "{}", + saving: false, + dropAllSaving: false, + }, + collectionOps: { + createDatabaseName: "", + createName: "", + createOptionsText: "{}", + renameName: "", + renameDropTarget: false, + running: false, + dropRunning: false, + databaseDropRunning: false, + }, + }; +} + +function createMySqlOverviewState() { + return { + ok: false, + updatedAt: "", + info: {}, + connections: {}, + throughput: {}, + replication: { + configured: false, + role: "", + readOnly: false, + superReadOnly: false, + sourceHost: "", + secondsBehindSource: null, + ioRunning: false, + sqlRunning: false, + state: "", + error: "", + }, + summary: { + databaseTotal: 0, + tableTotal: 0, + dataSizeMb: 0, + indexSizeMb: 0, + totalSizeMb: 0, + }, + databases: [], + error: "", + }; +} + +function createMySqlFilterRow() { + return { + column: "", + operator: "eq", + value: "", + }; +} + +function createMySqlAdminState() { + return { + loaded: false, + loading: false, + tablesLoading: false, + schemaLoading: false, + rowsLoading: false, + error: "", + message: "", + messageTone: "neutral", + keyword: "", + activeTab: "browse", + pageSizeOptions: [50, 100, 200], + tabs: MYSQL_ADMIN_TABS, + filterOperators: MYSQL_FILTER_OPERATORS, + indexModes: MYSQL_INDEX_MODES, + defaultModes: MYSQL_DEFAULT_MODES, + overview: createMySqlOverviewState(), + databases: [], + tables: [], + selectedDatabase: "", + selectedTable: "", + schema: { + updatedAt: "", + tableSummary: {}, + columns: [], + indexes: [], + identity: { + mode: "all", + columns: [], + }, + createTable: "", + }, + rows: { + page: 1, + pageSize: 100, + totalRows: 0, + totalPages: 1, + keyword: "", + orderBy: "", + orderDirection: "desc", + filters: [createMySqlFilterRow()], + columns: [], + indexes: [], + identity: { + mode: "all", + columns: [], + }, + items: [], + }, + rowEditor: { + identityKey: "", + identity: null, + valuesText: "{}", + saving: false, + }, + insertForm: { + valuesText: "{}", + saving: false, + }, + columnForm: { + columnName: "", + columnType: "varchar(255)", + nullable: true, + defaultMode: "none", + defaultValue: "", + comment: "", + position: "last", + afterColumn: "", + saving: false, + }, + indexForm: { + indexName: "", + indexMode: "normal", + columns: [], + saving: false, + }, + sqlConsole: { + text: "", + running: false, + result: null, + }, + }; +} + createApp({ template: APP_TEMPLATE, data() { return { activeView: "overview", + databaseEngine: "mysql", densityMode: "compact", detailPanelOpen: false, overviewHealthyHostsOpen: false, viewTabs: VIEW_TABS, + databaseEngines: DATABASE_ENGINES, primaryFilters: PRIMARY_FILTERS, releaseSections: RELEASE_SECTIONS, configTabs: CONFIG_TABS, infraTabs: INFRA_TABS, + mongoAdminTabs: MONGO_ADMIN_TABS, + mySqlAdminTabs: MYSQL_ADMIN_TABS, expandedHosts: [], expandedNacosServices: [], + collapsedMongoDatabases: [], focusedHostKey: "", selectedServiceKey: "", loading: false, @@ -138,6 +411,9 @@ createApp({ mongoDatabaseTotal: 0, mongoCollectionTotal: 0, mongoOk: 0, + mysqlDatabaseTotal: 0, + mysqlTableTotal: 0, + mysqlOk: 0, }, hosts: [], nacos: { @@ -166,6 +442,7 @@ createApp({ members: [], }, }, + mysql: createMySqlOverviewState(), }, nacosConfig: { loading: false, @@ -206,6 +483,8 @@ createApp({ diffKeys: [], hosts: [], }, + mongoAdmin: createMongoAdminState(), + mySqlAdmin: createMySqlAdminState(), updateOps: { loading: false, error: "", @@ -286,6 +565,22 @@ createApp({ formattedUpdatedAt() { return this.formatDateTime(this.payload.updatedAt); }, + hasRenderableData() { + return ( + Boolean(this.payload.updatedAt) + || this.allServiceRows.length > 0 + || this.allHostRows.length > 0 + || (this.payload.nacos?.services || []).length > 0 + || (this.payload.mongo?.databases || []).length > 0 + || (this.nacosConfig.items || []).length > 0 + || (this.goConfig.hosts || []).length > 0 + || (this.updateOps.items || []).length > 0 + || (this.restartOps.items || []).length > 0 + ); + }, + showLoadingSkeleton() { + return this.loading && !this.hasRenderableData && !this.error; + }, normalizedKeyword() { return this.keyword.trim().toLowerCase(); }, @@ -534,6 +829,537 @@ createApp({ .includes(keyword) )); }, + mongoAdminKeyword() { + return this.mongoAdmin.keyword.trim().toLowerCase(); + }, + mongoAdminFilteredDatabases() { + return (this.mongoAdmin.databases || []) + .map((database) => { + const collections = Array.isArray(database.collections) ? database.collections : []; + if (!this.mongoAdminKeyword) { + return { + ...database, + visibleCollections: collections, + }; + } + + const databaseMatched = database.name.toLowerCase().includes(this.mongoAdminKeyword); + const visibleCollections = databaseMatched + ? collections + : collections.filter((collection) => ( + [collection.name, collection.type, collection.documentCount, collection.indexCount] + .join(" ") + .toLowerCase() + .includes(this.mongoAdminKeyword) + )); + + if (!databaseMatched && visibleCollections.length === 0) { + return null; + } + + return { + ...database, + visibleCollections, + }; + }) + .filter(Boolean); + }, + mongoAdminTotalCollectionCount() { + return (this.mongoAdmin.databases || []).reduce( + (total, database) => total + ((database.collections || []).length || 0), + 0, + ); + }, + selectedMongoDatabaseItem() { + return (this.mongoAdmin.databases || []).find((database) => database.name === this.mongoAdmin.selectedDatabase) || null; + }, + selectedMongoCollectionItem() { + return (this.selectedMongoDatabaseItem?.collections || []).find( + (collection) => collection.name === this.mongoAdmin.selectedCollection, + ) || null; + }, + activeMongoAdminTabLabel() { + return this.mongoAdminTabs.find((item) => item.id === this.mongoAdmin.activeTab)?.label || ""; + }, + mongoAdminCollectionStats() { + return this.mongoAdmin.collectionDetail?.stats || { + count: 0, + sizeMb: 0, + storageSizeMb: 0, + avgObjSizeBytes: 0, + indexCount: 0, + totalIndexSizeMb: 0, + capped: false, + wiredTigerUri: "", + }; + }, + mongoAdminOverviewCards() { + const selectedDatabase = this.selectedMongoDatabaseItem; + const selectedCollection = this.selectedMongoCollectionItem; + const stats = this.mongoAdminCollectionStats; + const totalDatabaseCount = this.mongoAdmin.databases.length; + const totalCollectionCount = this.mongoAdminTotalCollectionCount; + const selectedDatabaseDocumentTotal = (selectedDatabase?.collections || []).reduce( + (total, collection) => total + Number(collection.documentCount || 0), + 0, + ); + const selectedDatabaseIndexTotal = (selectedDatabase?.collections || []).reduce( + (total, collection) => total + Number(collection.indexCount || 0), + 0, + ); + + return [ + { + key: "database", + label: "数据库", + value: selectedDatabase ? this.truncateMiddle(selectedDatabase.name, 14, 8) : `${this.formatNumber(totalDatabaseCount)} 个`, + meta: `${this.formatNumber(totalDatabaseCount)} 库`, + tone: this.mongoAdmin.error ? "bad" : "neutral", + }, + { + key: "collection", + label: "集合", + value: selectedCollection ? this.truncateMiddle(selectedCollection.name, 16, 8) : `${this.formatNumber(selectedDatabase?.collectionCount || totalCollectionCount)} 个`, + meta: selectedCollection ? (selectedCollection.type || "collection") : (selectedDatabase ? selectedDatabase.name : "全部集合"), + tone: selectedCollection?.error ? "bad" : "neutral", + }, + { + key: "documents", + label: "文档", + value: selectedCollection ? this.formatNumber(stats.count) : this.formatNumber(selectedDatabaseDocumentTotal), + meta: selectedCollection ? `平均 ${this.formatBytes(stats.avgObjSizeBytes || 0)}` : "当前库", + tone: this.mongoAdmin.error ? "bad" : "neutral", + }, + { + key: "storage", + label: "容量", + value: selectedCollection ? this.formatMb(stats.storageSizeMb || 0) : this.formatMb(selectedDatabase?.sizeOnDiskMb || 0), + meta: selectedCollection ? `data ${this.formatMb(stats.sizeMb || 0)}` : "磁盘占用", + tone: this.mongoAdmin.error ? "bad" : "neutral", + }, + { + key: "indexes", + label: "索引", + value: selectedCollection ? this.formatNumber(stats.indexCount) : this.formatNumber(selectedDatabaseIndexTotal), + meta: selectedCollection ? `size ${this.formatMb(stats.totalIndexSizeMb || 0)}` : `${this.formatNumber(totalCollectionCount)} 集合`, + tone: this.mongoAdmin.error ? "bad" : "neutral", + }, + ]; + }, + mongoQueryRangeText() { + if (!this.mongoAdmin.query.total || this.mongoAdmin.query.items.length === 0) { + return "0 / 0"; + } + const start = (this.mongoAdmin.query.page - 1) * this.mongoAdmin.query.pageSize + 1; + const end = start + this.mongoAdmin.query.items.length - 1; + return `${start}-${end} / ${this.mongoAdmin.query.total}`; + }, + mongoAggregateRangeText() { + if (!this.mongoAdmin.aggregate.total || this.mongoAdmin.aggregate.items.length === 0) { + return "0 / 0"; + } + const start = (this.mongoAdmin.aggregate.page - 1) * this.mongoAdmin.aggregate.pageSize + 1; + const end = start + this.mongoAdmin.aggregate.items.length - 1; + return `${start}-${end} / ${this.mongoAdmin.aggregate.total}`; + }, + mySqlAdminKeyword() { + return this.mySqlAdmin.keyword.trim().toLowerCase(); + }, + mySqlFilteredDatabases() { + return (this.mySqlAdmin.databases || []).filter((database) => { + if (!this.mySqlAdminKeyword) { + return true; + } + + if ( + [ + database.name, + database.tableTotal, + database.rowEstimate, + database.totalSizeMb, + ] + .join(" ") + .toLowerCase() + .includes(this.mySqlAdminKeyword) + ) { + return true; + } + + if (database.name !== this.mySqlAdmin.selectedDatabase) { + return false; + } + + return (this.mySqlAdmin.tables || []).some((table) => ( + [ + table.name, + table.engine, + table.rowEstimate, + table.totalSizeMb, + ] + .join(" ") + .toLowerCase() + .includes(this.mySqlAdminKeyword) + )); + }); + }, + selectedMySqlDatabaseItem() { + return (this.mySqlAdmin.databases || []).find((database) => database.name === this.mySqlAdmin.selectedDatabase) || null; + }, + mySqlVisibleTables() { + const tables = this.mySqlAdmin.tables || []; + if (!this.mySqlAdminKeyword) { + return tables; + } + + const selectedMatched = this.selectedMySqlDatabaseItem + && [ + this.selectedMySqlDatabaseItem.name, + this.selectedMySqlDatabaseItem.tableTotal, + this.selectedMySqlDatabaseItem.rowEstimate, + this.selectedMySqlDatabaseItem.totalSizeMb, + ] + .join(" ") + .toLowerCase() + .includes(this.mySqlAdminKeyword); + + if (selectedMatched) { + return tables; + } + + return tables.filter((table) => ( + [ + table.name, + table.engine, + table.rowEstimate, + table.totalSizeMb, + table.collation, + ] + .join(" ") + .toLowerCase() + .includes(this.mySqlAdminKeyword) + )); + }, + selectedMySqlTableItem() { + return (this.mySqlAdmin.tables || []).find((table) => table.name === this.mySqlAdmin.selectedTable) || null; + }, + activeMySqlAdminTabLabel() { + return this.mySqlAdminTabs.find((item) => item.id === this.mySqlAdmin.activeTab)?.label || ""; + }, + activeDatabaseTabs() { + return this.databaseEngine === "mongo" ? this.mongoAdminTabs : this.mySqlAdminTabs; + }, + activeDatabaseTabId: { + get() { + return this.databaseEngine === "mongo" ? this.mongoAdmin.activeTab : this.mySqlAdmin.activeTab; + }, + set(value) { + if (this.databaseEngine === "mongo") { + this.mongoAdmin.activeTab = value; + } else { + this.mySqlAdmin.activeTab = value; + } + }, + }, + activeDatabaseTabLabel() { + return this.databaseEngine === "mongo" ? this.activeMongoAdminTabLabel : this.activeMySqlAdminTabLabel; + }, + databaseKeywordModel: { + get() { + return this.databaseEngine === "mongo" ? this.mongoAdmin.keyword : this.mySqlAdmin.keyword; + }, + set(value) { + if (this.databaseEngine === "mongo") { + this.mongoAdmin.keyword = value; + } else { + this.mySqlAdmin.keyword = value; + } + }, + }, + databaseContextLabel() { + return this.databaseEngine === "mongo" + ? "MongoDB / 浏览 / 写入 / 聚合 / 索引" + : "MySQL / 数据 / 写入 / 结构 / SQL"; + }, + databaseSearchPlaceholder() { + return this.databaseEngine === "mongo" ? "搜索数据库 / 集合" : "搜索数据库 / 表"; + }, + databaseTreeLoading() { + return this.databaseEngine === "mongo" ? this.mongoAdmin.loading : this.mySqlAdmin.loading; + }, + databaseRefreshBusy() { + if (this.databaseEngine === "mongo") { + return this.mongoAdmin.loading + || this.mongoAdmin.detailLoading + || this.mongoAdmin.querying + || this.mongoAdmin.aggregate.running + || this.mongoAdmin.distinct.running + || this.mongoAdmin.editor.saving + || this.mongoAdmin.createDocument.saving + || this.mongoAdmin.updateOp.running + || this.mongoAdmin.deleteOp.running + || this.mongoAdmin.indexForm.saving + || this.mongoAdmin.indexForm.dropAllSaving + || this.mongoAdmin.collectionOps.running + || this.mongoAdmin.collectionOps.dropRunning + || this.mongoAdmin.collectionOps.databaseDropRunning; + } + return this.mySqlAdmin.loading + || this.mySqlAdmin.tablesLoading + || this.mySqlAdmin.schemaLoading + || this.mySqlAdmin.rowsLoading + || this.mySqlAdmin.sqlConsole.running + || this.mySqlAdmin.rowEditor.saving + || this.mySqlAdmin.insertForm.saving + || this.mySqlAdmin.columnForm.saving + || this.mySqlAdmin.indexForm.saving; + }, + databaseHasSelection() { + return this.databaseEngine === "mongo" ? Boolean(this.mongoAdmin.selectedDatabase) : Boolean(this.mySqlAdmin.selectedDatabase); + }, + databaseRefreshLabel() { + if (this.databaseEngine === "mongo") { + if (!this.selectedMongoDatabaseItem) { + return "刷新库"; + } + if (!this.selectedMongoCollectionItem) { + return "刷新集合"; + } + return this.mongoAdmin.activeTab === "aggregate" ? "刷新聚合" : "刷新结果"; + } + return this.selectedMySqlTableItem ? "刷新结果" : "刷新库"; + }, + mySqlRowsRangeText() { + if (!this.mySqlAdmin.rows.totalRows || this.mySqlAdmin.rows.items.length === 0) { + return "0 / 0"; + } + const start = (this.mySqlAdmin.rows.page - 1) * this.mySqlAdmin.rows.pageSize + 1; + const end = start + this.mySqlAdmin.rows.items.length - 1; + return `${start}-${end} / ${this.mySqlAdmin.rows.totalRows}`; + }, + mySqlOverviewCards() { + const overview = this.mySqlAdmin.overview || {}; + const info = overview.info || {}; + const connections = overview.connections || {}; + const throughput = overview.throughput || {}; + const summary = overview.summary || {}; + const replication = overview.replication || {}; + + return [ + { + key: "version", + label: "版本", + value: info.version || "-", + meta: info.versionComment || "-", + tone: overview.ok ? "ok" : "bad", + }, + { + key: "connections", + label: "连接", + value: `${this.formatNumber(connections.current)} / ${this.formatNumber(connections.max)}`, + meta: `运行 ${this.formatNumber(connections.running)}`, + tone: overview.ok ? "neutral" : "bad", + }, + { + key: "throughput", + label: "QPS / TPS", + value: `${throughput.qps ?? 0} / ${throughput.tps ?? 0}`, + meta: `采样 ${throughput.sampleSeconds ?? 0}s`, + tone: overview.ok ? "neutral" : "bad", + }, + { + key: "storage", + label: "容量", + value: `${this.formatMb(summary.totalSizeMb || 0)}`, + meta: `${this.formatNumber(summary.databaseTotal || 0)} 库 / ${this.formatNumber(summary.tableTotal || 0)} 表`, + tone: overview.ok ? "neutral" : "bad", + }, + { + key: "replication", + label: "主从", + value: this.mySqlReplicationLabel(replication), + meta: this.mySqlReplicationMeta(replication), + tone: this.mySqlReplicationTone(replication), + }, + { + key: "uptime", + label: "运行时长", + value: this.formatDuration(info.uptimeSeconds), + meta: info.serverId ? `serverId ${info.serverId}` : "-", + tone: overview.ok ? "neutral" : "bad", + }, + ]; + }, + mySqlSqlShortcuts() { + const tableName = this.selectedMySqlTableItem?.name || ""; + const quotedTableName = tableName ? this.quoteMySqlIdentifier(tableName) : ""; + const quotedNewTableName = this.quoteMySqlIdentifier(tableName ? `${tableName}_new` : "new_table"); + const selectedDatabase = this.mySqlAdmin.selectedDatabase || ""; + + return [ + { + id: "show-databases", + label: "SHOW DATABASES", + sql: "SHOW DATABASES", + disabled: false, + }, + { + id: "create-database-template", + label: "CREATE DB", + sql: "CREATE DATABASE `new_database` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci", + disabled: false, + }, + { + id: "drop-database-template", + label: "DROP DB", + sql: selectedDatabase ? `DROP DATABASE ${this.quoteMySqlIdentifier(selectedDatabase)}` : "", + disabled: !selectedDatabase, + }, + { + id: "show-tables", + label: "SHOW TABLES", + sql: "SHOW TABLES", + disabled: !this.mySqlAdmin.selectedDatabase, + }, + { + id: "show-create-database", + label: "SHOW CREATE DB", + sql: selectedDatabase ? `SHOW CREATE DATABASE ${this.quoteMySqlIdentifier(selectedDatabase)}` : "", + disabled: !selectedDatabase, + }, + { + id: "describe", + label: "DESC", + sql: quotedTableName ? `DESC ${quotedTableName}` : "", + disabled: !quotedTableName, + }, + { + id: "count", + label: "COUNT", + sql: quotedTableName ? `SELECT COUNT(*) AS total FROM ${quotedTableName}` : "", + disabled: !quotedTableName, + }, + { + id: "sample", + label: "SELECT 100", + sql: quotedTableName ? `SELECT * FROM ${quotedTableName} LIMIT 100` : "", + disabled: !quotedTableName, + }, + { + id: "create", + label: "SHOW CREATE", + sql: quotedTableName ? `SHOW CREATE TABLE ${quotedTableName}` : "", + disabled: !quotedTableName, + }, + { + id: "status", + label: "TABLE STATUS", + sql: tableName ? `SHOW TABLE STATUS LIKE '${this.escapeMySqlString(tableName)}'` : "", + disabled: !tableName, + }, + { + id: "indexes", + label: "SHOW INDEX", + sql: quotedTableName ? `SHOW INDEX FROM ${quotedTableName}` : "", + disabled: !quotedTableName, + }, + { + id: "show-columns", + label: "SHOW COLUMNS", + sql: quotedTableName ? `SHOW FULL COLUMNS FROM ${quotedTableName}` : "", + disabled: !quotedTableName, + }, + { + id: "explain", + label: "EXPLAIN", + sql: quotedTableName ? `EXPLAIN SELECT * FROM ${quotedTableName} LIMIT 100` : "", + disabled: !quotedTableName, + }, + { + id: "processlist", + label: "PROCESSLIST", + sql: "SHOW FULL PROCESSLIST", + disabled: false, + }, + { + id: "replication", + label: "READ ONLY", + sql: "SHOW GLOBAL VARIABLES LIKE 'read_only'", + disabled: false, + }, + { + id: "insert-template", + label: "INSERT 模板", + sql: quotedTableName ? `INSERT INTO ${quotedTableName} () VALUES ()` : "", + disabled: !quotedTableName, + }, + { + id: "update-template", + label: "UPDATE 模板", + sql: quotedTableName ? `UPDATE ${quotedTableName} SET WHERE LIMIT 1` : "", + disabled: !quotedTableName, + }, + { + id: "delete-template", + label: "DELETE 模板", + sql: quotedTableName ? `DELETE FROM ${quotedTableName} WHERE LIMIT 1` : "", + disabled: !quotedTableName, + }, + { + id: "add-column-template", + label: "ADD COLUMN", + sql: quotedTableName ? `ALTER TABLE ${quotedTableName} ADD COLUMN \`new_column\` VARCHAR(255) NULL` : "", + disabled: !quotedTableName, + }, + { + id: "add-index-template", + label: "CREATE INDEX", + sql: quotedTableName ? `CREATE INDEX \`idx_${tableName || "table"}_column\` ON ${quotedTableName} (\`column\`)` : "", + disabled: !quotedTableName, + }, + { + id: "create-table-template", + label: "CREATE TABLE", + sql: `CREATE TABLE ${quotedNewTableName} (\n \`id\` BIGINT NOT NULL AUTO_INCREMENT,\n PRIMARY KEY (\`id\`)\n)`, + disabled: false, + }, + { + id: "rename-table-template", + label: "RENAME TABLE", + sql: quotedTableName ? `RENAME TABLE ${quotedTableName} TO ${quotedNewTableName}` : "", + disabled: !quotedTableName, + }, + { + id: "truncate-template", + label: "TRUNCATE", + sql: quotedTableName ? `TRUNCATE TABLE ${quotedTableName}` : "", + disabled: !quotedTableName, + }, + { + id: "drop-table-template", + label: "DROP TABLE", + sql: quotedTableName ? `DROP TABLE ${quotedTableName}` : "", + disabled: !quotedTableName, + }, + { + id: "analyze-table-template", + label: "ANALYZE TABLE", + sql: quotedTableName ? `ANALYZE TABLE ${quotedTableName}` : "", + disabled: !quotedTableName, + }, + { + id: "optimize-table-template", + label: "OPTIMIZE TABLE", + sql: quotedTableName ? `OPTIMIZE TABLE ${quotedTableName}` : "", + disabled: !quotedTableName, + }, + { + id: "use-database", + label: "USE", + sql: selectedDatabase ? `USE ${this.quoteMySqlIdentifier(selectedDatabase)}` : "", + disabled: !selectedDatabase, + }, + ]; + }, tatAlertHosts() { return this.allHostRows.filter((host) => ( host.hasMetricIssue @@ -688,6 +1514,15 @@ createApp({ activeConfigTabLabel() { return this.configTabs.find((item) => item.id === this.configTab)?.label || ""; }, + configContextLabel() { + if (this.configTab === "nacos") { + return `Nacos / ${this.nacosConfig.namespaceName || "default"} / ${this.filteredNacosConfigs.length} 项`; + } + if (this.configTab === "go") { + return `Go Env / ${this.goConfig.hosts.length} 台 / ${this.goConfig.diffKeys.length} 差异`; + } + return `历史版本 / ${this.configHistoryRows.length} 条`; + }, updateAffectedServicesCount() { return this.updateOps.result?.services?.length || this.updateOps.selectedServices.length || 0; }, @@ -696,6 +1531,7 @@ createApp({ const hostAlertTotal = this.allHostRows.filter((host) => host.hasAlert).length; const nacosUnavailable = !(this.payload.nacos && this.payload.nacos.ok); const mongoUnavailable = !(this.payload.mongo && this.payload.mongo.ok); + const mysqlUnavailable = !(this.payload.mysql && this.payload.mysql.ok); const tatOk = this.summary.hostMetricOk || 0; const tatTotal = this.summary.hostTotal || 0; @@ -706,6 +1542,7 @@ createApp({ value: `${serviceDownTotal}/${this.allServiceRows.length}`, suffix: "异常", tone: serviceDownTotal > 0 ? "bad" : "ok", + visible: serviceDownTotal > 0, }, { key: "instance-down", @@ -713,6 +1550,7 @@ createApp({ value: `${this.summary.down || 0}/${this.summary.total || 0}`, suffix: "异常", tone: (this.summary.down || 0) > 0 ? "bad" : "ok", + visible: (this.summary.down || 0) > 0, }, { key: "host-alert", @@ -720,6 +1558,7 @@ createApp({ value: `${hostAlertTotal}/${this.summary.hostTotal || 0}`, suffix: "异常", tone: hostAlertTotal > 0 ? "warn" : "ok", + visible: hostAlertTotal > 0, }, { key: "nacos", @@ -727,6 +1566,7 @@ createApp({ value: nacosUnavailable ? "ERR" : String(this.summary.nacosUnhealthyInstanceTotal || 0), suffix: nacosUnavailable ? "" : "异常", tone: nacosUnavailable || (this.summary.nacosUnhealthyInstanceTotal || 0) > 0 ? "bad" : "ok", + visible: nacosUnavailable || (this.summary.nacosUnhealthyInstanceTotal || 0) > 0, }, { key: "mongo", @@ -734,6 +1574,15 @@ createApp({ value: mongoUnavailable ? "ERR" : String(this.summary.mongoDatabaseTotal || 0), suffix: mongoUnavailable ? "" : "库", tone: mongoUnavailable ? "bad" : "ok", + visible: mongoUnavailable, + }, + { + key: "mysql", + label: "MySQL", + value: mysqlUnavailable ? "ERR" : String(this.summary.mysqlDatabaseTotal || 0), + suffix: mysqlUnavailable ? "" : "库", + tone: mysqlUnavailable ? "bad" : "ok", + visible: mysqlUnavailable, }, { key: "tat", @@ -741,8 +1590,122 @@ createApp({ value: `${tatOk}/${tatTotal}`, suffix: "正常", tone: (this.summary.hostMetricDown || 0) > 0 ? "warn" : "ok", + visible: (this.summary.hostMetricDown || 0) > 0, }, - ]; + ].filter((card) => card.visible); + }, + overviewHealthyCards() { + const cards = []; + if (this.overviewQuietHosts.length > 0) { + cards.push({ + key: "hosts", + label: "业务主机", + value: `${this.overviewQuietHosts.length} 台`, + meta: "正常", + tone: "ok", + }); + } + if (this.payload.nacos && this.payload.nacos.ok && (this.summary.nacosUnhealthyInstanceTotal || 0) === 0) { + cards.push({ + key: "nacos", + label: "Nacos", + value: "连接正常", + meta: `${this.payload.nacos.summary.serviceTotal || 0} 服务`, + tone: "ok", + }); + } + if (this.payload.mongo && this.payload.mongo.ok) { + cards.push({ + key: "mongo", + label: "Mongo", + value: `${this.summary.mongoDatabaseTotal || 0} 库`, + meta: "连接正常", + tone: "ok", + }); + } + if (this.payload.mysql && this.payload.mysql.ok) { + cards.push({ + key: "mysql", + label: "MySQL", + value: `${this.summary.mysqlDatabaseTotal || 0} 库`, + meta: "连接正常", + tone: "ok", + }); + } + if ((this.summary.hostMetricDown || 0) === 0 && (this.summary.hostTotal || 0) > 0) { + cards.push({ + key: "tat", + label: "TAT", + value: `${this.summary.hostMetricOk || 0}/${this.summary.hostTotal || 0}`, + meta: "采集正常", + tone: "ok", + }); + } + return cards; + }, + overviewHealthyInfraCards() { + return this.overviewHealthyCards.filter((card) => card.key !== "hosts"); + }, + overviewHealthyHostGroups() { + const groups = new Map(); + this.overviewQuietHosts.forEach((host) => { + if (!groups.has(host.group)) { + groups.set(host.group, { + key: host.group, + label: host.groupLabel, + hosts: [], + }); + } + groups.get(host.group).hosts.push(host.host); + }); + return [...groups.values()] + .map((group) => ({ + ...group, + count: group.hosts.length, + summary: group.hosts.join(" / "), + })) + .sort((left, right) => this.groupRank(left.key) - this.groupRank(right.key)); + }, + overviewHealthyStateCount() { + return this.overviewHealthyInfraCards.length + this.overviewHealthyHostGroups.length; + }, + overviewQuietServiceTotal() { + return this.overviewQuietHosts.reduce((total, host) => total + (host.serviceTotal || 0), 0); + }, + overviewDetailSubtitle() { + if (this.showSelectedServiceDetail) { + return ""; + } + if (this.activeView === "overview") { + if (this.alertFeed.length > 0) { + return `${this.alertFeed.length} 条异常 · ${this.overviewHealthyStateCount} 组正常状态`; + } + if (this.overviewHealthyStateCount > 0) { + return `${this.overviewHealthyStateCount} 组正常状态`; + } + return "当前没有可展示的状态"; + } + return `${this.alertFeed.length} 条异常`; + }, + overviewHostPanelSummary() { + if (this.overviewAlertHosts.length > 0) { + return `${this.overviewAlertHosts.length} 台异常业务主机 · ${this.overviewQuietHosts.length} 台正常主机`; + } + return `${this.overviewQuietHosts.length} 台业务主机稳定`; + }, + showSelectedServiceDetail() { + if (!this.selectedServiceDetail) { + return false; + } + if (this.activeView === "services") { + return true; + } + return ( + this.selectedServiceDetail.downCount > 0 + || Boolean(this.focusedHostKey) + || this.filter === "down" + || Boolean(this.normalizedKeyword) + ); }, overviewBridgeAlerts() { return this.serviceMatrixRows @@ -862,6 +1825,17 @@ createApp({ }); } + if (this.payload.mysql.error || !(this.payload.mysql && this.payload.mysql.ok)) { + alerts.push({ + key: "mysql-error", + type: "database", + view: "database", + tone: "bad", + title: "MySQL", + text: this.payload.mysql.error || "连接异常", + }); + } + return alerts.slice(0, 5); }, restartTargetMap() { @@ -981,6 +1955,9 @@ createApp({ if (nextValue === "services") { this.detailPanelOpen = true; } + if (nextValue === "database") { + this.ensureDatabaseWorkbenchLoaded(); + } if (nextValue === "config") { this.configTab = this.configTab || "nacos"; } @@ -991,8 +1968,47 @@ createApp({ selectedServiceKey() { this.syncServiceLogTarget(); }, + databaseEngine() { + if (this.activeView === "database") { + this.ensureDatabaseWorkbenchLoaded(); + } + }, }, methods: { + async ensureDatabaseWorkbenchLoaded(engine = this.databaseEngine) { + if (engine === "mongo") { + if (!this.mongoAdmin.loaded && !this.mongoAdmin.loading) { + await this.refreshMongoAdminOverview(); + } + return; + } + if (!this.mySqlAdmin.loaded && !this.mySqlAdmin.loading) { + await this.loadMySqlWorkbench(); + } + }, + async switchDatabaseEngine(engine) { + if (!engine || this.databaseEngine === engine) { + return; + } + this.databaseEngine = engine; + if (this.activeView === "database") { + await this.ensureDatabaseWorkbenchLoaded(engine); + } + }, + async refreshDatabaseTree() { + if (this.databaseEngine === "mongo") { + await this.refreshMongoAdminOverview({ preserveSelection: true }); + return; + } + await this.refreshMySqlDatabases({ preserveSelection: true }); + }, + async refreshDatabaseCurrent() { + if (this.databaseEngine === "mongo") { + await this.refreshMongoAdminCurrent(); + return; + } + await this.refreshMySqlCurrent(); + }, groupHosts(hostRows) { const groups = new Map(); hostRows.forEach((host) => { @@ -1486,6 +2502,9 @@ createApp({ } return parts.filter(Boolean).join(" · "); }, + matrixSectionDownCount(section) { + return (section?.rows || []).reduce((total, row) => total + (row.downCount || 0), 0); + }, hostMetricEntries(host) { const metrics = host?.metrics || {}; return [ @@ -1512,6 +2531,16 @@ createApp({ }, ]; }, + hostHasMetricData(host) { + const metrics = host?.metrics || {}; + return ["cpuPercent", "memoryPercent", "diskPercent"].some((key) => metrics[key] != null && !Number.isNaN(Number(metrics[key]))); + }, + hostMetricStatusLabel(host) { + if (host?.metrics?.error) { + return "采集异常"; + } + return "暂无指标"; + }, thresholdTone(metricKey, value) { if (value == null || Number.isNaN(Number(value))) { return "neutral"; @@ -2292,6 +3321,7 @@ createApp({ throw new Error(payload.error || `HTTP ${response.status}`); } this.payload = payload; + this.applyMySqlOverviewToPayload(payload.mysql || createMySqlOverviewState()); this.syncRefreshSettings(); if (!this.selectedServiceDetail && this.allServiceRows.length > 0) { @@ -2548,6 +3578,1801 @@ createApp({ this.goConfig.saving = false; } }, + applyMySqlOverviewToPayload(overview) { + const nextOverview = overview && typeof overview === "object" + ? overview + : createMySqlOverviewState(); + this.payload.mysql = nextOverview; + this.payload.summary.mysqlDatabaseTotal = Number(nextOverview.summary?.databaseTotal || 0); + this.payload.summary.mysqlTableTotal = Number(nextOverview.summary?.tableTotal || 0); + this.payload.summary.mysqlOk = nextOverview.ok ? 1 : 0; + this.mySqlAdmin.overview = nextOverview; + }, + setMySqlAdminMessage(message, tone = "neutral") { + this.mySqlAdmin.message = message || ""; + this.mySqlAdmin.messageTone = tone || "neutral"; + }, + clearMySqlSchemaState() { + this.mySqlAdmin.schema = { + updatedAt: "", + tableSummary: {}, + columns: [], + indexes: [], + identity: { + mode: "all", + columns: [], + }, + createTable: "", + }; + this.mySqlAdmin.indexForm.columns = []; + }, + clearMySqlRowsState() { + this.mySqlAdmin.rows.totalRows = 0; + this.mySqlAdmin.rows.totalPages = 1; + this.mySqlAdmin.rows.columns = []; + this.mySqlAdmin.rows.indexes = []; + this.mySqlAdmin.rows.identity = { + mode: "all", + columns: [], + }; + this.mySqlAdmin.rows.items = []; + }, + clearMySqlRowEditor() { + this.mySqlAdmin.rowEditor.identityKey = ""; + this.mySqlAdmin.rowEditor.identity = null; + this.mySqlAdmin.rowEditor.valuesText = "{}"; + }, + mySqlFilterNeedsValue(operator) { + return !["is_null", "is_not_null"].includes(String(operator || "").trim().toLowerCase()); + }, + parseMySqlScalarValue(value) { + const text = String(value ?? "").trim(); + if (!text.length) { + return ""; + } + if (text === "null") { + return null; + } + if (text === "true") { + return true; + } + if (text === "false") { + return false; + } + if (/^-?\d+(\.\d+)?$/.test(text)) { + return Number(text); + } + try { + return JSON.parse(text); + } catch (_error) { + return text; + } + }, + parseMySqlObjectText(text, label = "JSON") { + const raw = String(text || "").trim(); + if (!raw) { + return {}; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`${label} 解析失败`); + } + + if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error(`${label} 需要 JSON 对象`); + } + return parsed; + }, + stringifyMySqlJson(value) { + return JSON.stringify(value ?? {}, null, 2); + }, + quoteMySqlIdentifier(name) { + return `\`${String(name || "").replaceAll("`", "``")}\``; + }, + escapeMySqlString(value) { + return String(value || "").replaceAll("\\", "\\\\").replaceAll("'", "\\'"); + }, + mySqlRowKey(row) { + return JSON.stringify(row?.identity || {}); + }, + mySqlCellText(value) { + if (value == null) { + return "NULL"; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); + }, + mySqlCellPreview(value, limit = 88) { + const text = this.mySqlCellText(value).replace(/\s+/g, " ").trim(); + if (!text) { + return "-"; + } + return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`; + }, + mySqlReplicationLabel(replication) { + if (!replication || typeof replication !== "object") { + return "-"; + } + if (replication.configured) { + return replication.role === "replica" ? "从库" : "主库"; + } + return replication.readOnly || replication.superReadOnly ? "只读" : "单节点"; + }, + mySqlReplicationMeta(replication) { + if (!replication || typeof replication !== "object") { + return "-"; + } + const parts = []; + if (replication.configured && replication.sourceHost) { + parts.push(replication.sourceHost); + } + if (replication.secondsBehindSource != null) { + parts.push(`延迟 ${replication.secondsBehindSource}s`); + } + if (replication.readOnly || replication.superReadOnly) { + parts.push("只读"); + } + if (!parts.length && replication.state) { + parts.push(replication.state); + } + return parts.join(" / ") || "-"; + }, + mySqlReplicationTone(replication) { + if (!replication || typeof replication !== "object") { + return "neutral"; + } + if (replication.error) { + return "bad"; + } + if (replication.configured) { + return replication.ioRunning && replication.sqlRunning ? "ok" : "warn"; + } + if (replication.readOnly || replication.superReadOnly) { + return "warn"; + } + return "neutral"; + }, + mySqlRowIdentityText(identity) { + if (!identity || !Array.isArray(identity.columns) || identity.columns.length === 0) { + return "-"; + } + return identity.columns.map((column) => { + const rawValue = identity.values?.[column]; + return `${column}=${this.mySqlCellPreview(rawValue, 32)}`; + }).join(" / "); + }, + isMySqlRowSelected(row) { + return this.mySqlAdmin.rowEditor.identityKey && this.mySqlAdmin.rowEditor.identityKey === this.mySqlRowKey(row); + }, + mySqlSortLabel(columnName) { + if (!columnName || this.mySqlAdmin.rows.orderBy !== columnName) { + return ""; + } + return this.mySqlAdmin.rows.orderDirection === "asc" ? "ASC" : "DESC"; + }, + buildMySqlInsertValues(seedValues = null) { + const seeded = seedValues && typeof seedValues === "object" && !Array.isArray(seedValues) ? seedValues : {}; + const values = {}; + + (this.mySqlAdmin.schema.columns || []).forEach((column) => { + const extra = String(column.extra || "").trim().toLowerCase(); + if (extra.includes("generated") || extra.includes("auto_increment")) { + return; + } + + if (Object.prototype.hasOwnProperty.call(seeded, column.name)) { + values[column.name] = seeded[column.name]; + return; + } + + if (column.default != null && column.default !== "") { + values[column.name] = column.default; + return; + } + + values[column.name] = column.nullable ? null : ""; + }); + + return values; + }, + fillMySqlInsertTemplate() { + this.mySqlAdmin.insertForm.valuesText = this.stringifyMySqlJson(this.buildMySqlInsertValues()); + this.mySqlAdmin.activeTab = "write"; + this.setMySqlAdminMessage("已生成新增模板", "neutral"); + }, + copyMySqlRowToInsert(row = null) { + try { + const sourceValues = row?.values || (this.mySqlAdmin.rowEditor.valuesText + ? this.parseMySqlObjectText(this.mySqlAdmin.rowEditor.valuesText, "编辑行") + : {}); + this.mySqlAdmin.insertForm.valuesText = this.stringifyMySqlJson(this.buildMySqlInsertValues(sourceValues)); + this.mySqlAdmin.activeTab = "write"; + this.setMySqlAdminMessage("已复制为新增模板", "neutral"); + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } + }, + async toggleMySqlSort(columnName) { + const actualColumn = String(columnName || "").trim(); + if (!actualColumn) { + return; + } + + if (this.mySqlAdmin.rows.orderBy === actualColumn) { + this.mySqlAdmin.rows.orderDirection = this.mySqlAdmin.rows.orderDirection === "asc" ? "desc" : "asc"; + } else { + this.mySqlAdmin.rows.orderBy = actualColumn; + this.mySqlAdmin.rows.orderDirection = "asc"; + } + + await this.refreshMySqlRows({ resetPage: true, silent: true }); + }, + addMySqlFilterRow() { + this.mySqlAdmin.rows.filters.push(createMySqlFilterRow()); + }, + removeMySqlFilterRow(index) { + const next = [...this.mySqlAdmin.rows.filters]; + next.splice(index, 1); + this.mySqlAdmin.rows.filters = next.length > 0 ? next : [createMySqlFilterRow()]; + }, + buildMySqlFiltersPayload() { + return (this.mySqlAdmin.rows.filters || []) + .filter((item) => String(item.column || "").trim()) + .map((item) => { + const payload = { + column: item.column, + operator: item.operator || "eq", + }; + if (this.mySqlFilterNeedsValue(item.operator)) { + payload.value = this.parseMySqlScalarValue(item.value); + } + return payload; + }); + }, + async loadMySqlWorkbench(options = {}) { + await this.refreshMySqlDatabases(options); + }, + async refreshMySqlOverview({ silent = false } = {}) { + try { + const { response, payload } = await this.requestJson("/api/mysql/overview"); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + this.applyMySqlOverviewToPayload(payload); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.applyMySqlOverviewToPayload({ + ...createMySqlOverviewState(), + updatedAt: new Date().toISOString(), + error: message, + }); + if (!silent) { + this.mySqlAdmin.error = message; + } + } + }, + async refreshMySqlDatabases({ preserveSelection = true, silent = false } = {}) { + this.mySqlAdmin.loading = true; + if (!silent) { + this.mySqlAdmin.error = ""; + } + + const previousDatabase = preserveSelection ? this.mySqlAdmin.selectedDatabase : ""; + const previousTable = preserveSelection ? this.mySqlAdmin.selectedTable : ""; + + try { + await this.refreshMySqlOverview({ silent: true }); + + const { response, payload } = await this.requestJson("/api/mysql/databases"); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mySqlAdmin.databases = Array.isArray(payload.items) ? payload.items : []; + this.mySqlAdmin.loaded = true; + + const nextDatabase = previousDatabase + && this.mySqlAdmin.databases.some((database) => database.name === previousDatabase) + ? previousDatabase + : (payload.selectedDatabase || this.mySqlAdmin.databases[0]?.name || ""); + + if (!nextDatabase) { + this.mySqlAdmin.selectedDatabase = ""; + this.mySqlAdmin.selectedTable = ""; + this.mySqlAdmin.tables = []; + this.clearMySqlSchemaState(); + this.clearMySqlRowsState(); + this.clearMySqlRowEditor(); + if (!silent) { + this.setMySqlAdminMessage("当前没有可操作的数据库", "neutral"); + } + return; + } + + await this.selectMySqlDatabase(nextDatabase, { + preserveTable: preserveSelection, + preferredTable: previousTable, + silent: true, + }); + + if (!silent) { + this.setMySqlAdminMessage(`已加载 ${this.mySqlAdmin.databases.length} 个数据库`, "neutral"); + } + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.loading = false; + } + }, + async selectMySqlDatabase(databaseName, { preserveTable = true, preferredTable = "", silent = false } = {}) { + const actualDatabase = String(databaseName || "").trim(); + if (!actualDatabase) { + return; + } + + this.mySqlAdmin.selectedDatabase = actualDatabase; + await this.refreshMySqlTables({ + databaseName: actualDatabase, + preserveTable, + preferredTable, + silent, + }); + }, + async refreshMySqlTables({ + databaseName = this.mySqlAdmin.selectedDatabase, + preserveTable = true, + preferredTable = "", + silent = false, + loadRows = true, + } = {}) { + const actualDatabase = String(databaseName || "").trim(); + if (!actualDatabase) { + this.mySqlAdmin.selectedDatabase = ""; + this.mySqlAdmin.selectedTable = ""; + this.mySqlAdmin.tables = []; + this.clearMySqlSchemaState(); + this.clearMySqlRowsState(); + this.clearMySqlRowEditor(); + return; + } + + this.mySqlAdmin.tablesLoading = true; + if (!silent) { + this.mySqlAdmin.error = ""; + } + + try { + const query = new URLSearchParams({ database: actualDatabase }); + const { response, payload } = await this.requestJson(`/api/mysql/tables?${query.toString()}`); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mySqlAdmin.tables = Array.isArray(payload.items) ? payload.items : []; + this.mySqlAdmin.selectedDatabase = payload.database || actualDatabase; + + const currentTable = preserveTable ? (preferredTable || this.mySqlAdmin.selectedTable) : ""; + const nextTable = currentTable + && this.mySqlAdmin.tables.some((table) => table.name === currentTable) + ? currentTable + : (payload.selectedTable || this.mySqlAdmin.tables[0]?.name || ""); + + if (!nextTable) { + this.mySqlAdmin.selectedTable = ""; + this.clearMySqlSchemaState(); + this.clearMySqlRowsState(); + this.clearMySqlRowEditor(); + return; + } + + if (loadRows) { + await this.selectMySqlTable(this.mySqlAdmin.selectedDatabase, nextTable, { silent: true }); + return; + } + + this.mySqlAdmin.selectedTable = nextTable; + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.tablesLoading = false; + } + }, + async selectMySqlTable(databaseName, tableName, { silent = false } = {}) { + const actualDatabase = String(databaseName || "").trim(); + const actualTable = String(tableName || "").trim(); + if (!actualDatabase || !actualTable) { + return; + } + + this.mySqlAdmin.selectedDatabase = actualDatabase; + this.mySqlAdmin.selectedTable = actualTable; + this.clearMySqlRowEditor(); + + await this.refreshMySqlSchema({ silent: true }); + await this.refreshMySqlRows({ resetPage: true, silent: true }); + + if (!silent) { + this.setMySqlAdminMessage(`已切换到 ${actualTable}`, "neutral"); + } + }, + async refreshMySqlSchema({ silent = false } = {}) { + if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable) { + return; + } + + this.mySqlAdmin.schemaLoading = true; + if (!silent) { + this.mySqlAdmin.error = ""; + } + + try { + const query = new URLSearchParams({ + database: this.mySqlAdmin.selectedDatabase, + table: this.mySqlAdmin.selectedTable, + }); + const { response, payload } = await this.requestJson(`/api/mysql/table-schema?${query.toString()}`); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mySqlAdmin.schema = { + updatedAt: payload.updatedAt || "", + tableSummary: payload.tableSummary || {}, + columns: Array.isArray(payload.columns) ? payload.columns : [], + indexes: Array.isArray(payload.indexes) ? payload.indexes : [], + identity: payload.identity || { mode: "all", columns: [] }, + createTable: payload.createTable || "", + }; + + const availableColumns = new Set(this.mySqlAdmin.schema.columns.map((column) => column.name)); + this.mySqlAdmin.indexForm.columns = (this.mySqlAdmin.indexForm.columns || []) + .filter((columnName) => availableColumns.has(columnName)); + this.mySqlAdmin.rows.filters = (this.mySqlAdmin.rows.filters || []) + .map((item) => (availableColumns.has(item.column) || !item.column ? item : createMySqlFilterRow())); + + if (!this.mySqlAdmin.rows.filters.length) { + this.mySqlAdmin.rows.filters = [createMySqlFilterRow()]; + } + if (!availableColumns.has(this.mySqlAdmin.rows.orderBy)) { + this.mySqlAdmin.rows.orderBy = payload.identity?.columns?.[0] || this.mySqlAdmin.schema.columns[0]?.name || ""; + } + if (!availableColumns.has(this.mySqlAdmin.columnForm.afterColumn)) { + this.mySqlAdmin.columnForm.afterColumn = this.mySqlAdmin.schema.columns.slice(-1)[0]?.name || ""; + } + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.schemaLoading = false; + } + }, + async refreshMySqlRows({ resetPage = false, silent = false } = {}) { + if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable) { + return; + } + + if (resetPage) { + this.mySqlAdmin.rows.page = 1; + } + + this.mySqlAdmin.rowsLoading = true; + if (!silent) { + this.mySqlAdmin.error = ""; + } + + try { + const { response, payload } = await this.requestJson("/api/mysql/rows/query", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mySqlAdmin.selectedDatabase, + table: this.mySqlAdmin.selectedTable, + page: this.mySqlAdmin.rows.page, + pageSize: this.mySqlAdmin.rows.pageSize, + keyword: this.mySqlAdmin.rows.keyword, + orderBy: this.mySqlAdmin.rows.orderBy, + orderDirection: this.mySqlAdmin.rows.orderDirection, + filters: this.buildMySqlFiltersPayload(), + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mySqlAdmin.rows.page = Number(payload.page || this.mySqlAdmin.rows.page); + this.mySqlAdmin.rows.pageSize = Number(payload.pageSize || this.mySqlAdmin.rows.pageSize); + this.mySqlAdmin.rows.totalRows = Number(payload.totalRows || 0); + this.mySqlAdmin.rows.totalPages = Number(payload.totalPages || 1); + this.mySqlAdmin.rows.orderBy = payload.orderBy || this.mySqlAdmin.rows.orderBy; + this.mySqlAdmin.rows.orderDirection = payload.orderDirection || this.mySqlAdmin.rows.orderDirection; + this.mySqlAdmin.rows.columns = Array.isArray(payload.columns) ? payload.columns : []; + this.mySqlAdmin.rows.indexes = Array.isArray(payload.indexes) ? payload.indexes : []; + this.mySqlAdmin.rows.identity = payload.identity || { mode: "all", columns: [] }; + this.mySqlAdmin.rows.items = Array.isArray(payload.rows) ? payload.rows : []; + + if ( + this.mySqlAdmin.rowEditor.identityKey + && !this.mySqlAdmin.rows.items.some((item) => this.mySqlRowKey(item) === this.mySqlAdmin.rowEditor.identityKey) + ) { + this.clearMySqlRowEditor(); + } + + if (!silent) { + this.setMySqlAdminMessage(`已加载 ${this.mySqlAdmin.rows.items.length} 行`, "neutral"); + } + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.rowsLoading = false; + } + }, + resetMySqlRowQuery() { + this.mySqlAdmin.rows.keyword = ""; + this.mySqlAdmin.rows.page = 1; + this.mySqlAdmin.rows.pageSize = 100; + this.mySqlAdmin.rows.filters = [createMySqlFilterRow()]; + this.mySqlAdmin.rows.orderBy = this.mySqlAdmin.schema.identity?.columns?.[0] || this.mySqlAdmin.schema.columns[0]?.name || ""; + this.mySqlAdmin.rows.orderDirection = "desc"; + }, + async changeMySqlRowsPage(nextPage) { + const page = Math.max(1, Math.min(Number(nextPage || 1), this.mySqlAdmin.rows.totalPages || 1)); + if (page === this.mySqlAdmin.rows.page) { + return; + } + this.mySqlAdmin.rows.page = page; + await this.refreshMySqlRows({ silent: true }); + }, + async refreshMySqlCurrent() { + const tasks = [this.refreshMySqlOverview({ silent: true })]; + if (this.mySqlAdmin.selectedDatabase) { + tasks.push(this.refreshMySqlTables({ + databaseName: this.mySqlAdmin.selectedDatabase, + preserveTable: true, + preferredTable: this.mySqlAdmin.selectedTable, + silent: true, + loadRows: false, + })); + } + if (this.mySqlAdmin.selectedTable) { + tasks.push(this.refreshMySqlSchema({ silent: true })); + if (this.mySqlAdmin.activeTab !== "sql") { + tasks.push(this.refreshMySqlRows({ silent: true })); + } + } + await Promise.allSettled(tasks); + }, + loadMySqlRowForEdit(row) { + this.mySqlAdmin.rowEditor.identityKey = this.mySqlRowKey(row); + this.mySqlAdmin.rowEditor.identity = row.identity || null; + this.mySqlAdmin.rowEditor.valuesText = this.stringifyMySqlJson(row.values || {}); + this.mySqlAdmin.activeTab = "write"; + }, + async refreshMySqlAfterMutation({ resetPage = false } = {}) { + await this.refreshMySqlOverview({ silent: true }); + if (this.mySqlAdmin.selectedDatabase) { + await this.refreshMySqlTables({ + databaseName: this.mySqlAdmin.selectedDatabase, + preserveTable: true, + preferredTable: this.mySqlAdmin.selectedTable, + silent: true, + loadRows: false, + }); + } + if (this.mySqlAdmin.selectedTable) { + await this.refreshMySqlSchema({ silent: true }); + await this.refreshMySqlRows({ resetPage, silent: true }); + } + }, + async insertMySqlRow() { + if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable) { + return; + } + + this.mySqlAdmin.insertForm.saving = true; + this.mySqlAdmin.error = ""; + + try { + const values = this.parseMySqlObjectText(this.mySqlAdmin.insertForm.valuesText, "新增行"); + const { response, payload } = await this.requestJson("/api/mysql/rows/insert", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mySqlAdmin.selectedDatabase, + table: this.mySqlAdmin.selectedTable, + values, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mySqlAdmin.insertForm.valuesText = "{}"; + this.setMySqlAdminMessage(payload.message || "已插入 1 行", "ok"); + await this.refreshMySqlAfterMutation({ resetPage: true }); + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.insertForm.saving = false; + } + }, + async updateMySqlRow() { + if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable || !this.mySqlAdmin.rowEditor.identity) { + return; + } + + this.mySqlAdmin.rowEditor.saving = true; + this.mySqlAdmin.error = ""; + + try { + const values = this.parseMySqlObjectText(this.mySqlAdmin.rowEditor.valuesText, "编辑行"); + const { response, payload } = await this.requestJson("/api/mysql/rows/update", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mySqlAdmin.selectedDatabase, + table: this.mySqlAdmin.selectedTable, + identity: this.mySqlAdmin.rowEditor.identity, + values, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMySqlAdminMessage(payload.message || "已更新 1 行", "ok"); + await this.refreshMySqlAfterMutation(); + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.rowEditor.saving = false; + } + }, + async deleteMySqlRow(row = null) { + if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable) { + return; + } + + const identity = row?.identity || this.mySqlAdmin.rowEditor.identity; + if (!identity) { + return; + } + + if (!window.confirm(`删除当前行?\n${this.mySqlRowIdentityText(identity)}`)) { + return; + } + + this.mySqlAdmin.rowEditor.saving = true; + this.mySqlAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mysql/rows/delete", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mySqlAdmin.selectedDatabase, + table: this.mySqlAdmin.selectedTable, + identity, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + if (this.mySqlRowKey({ identity }) === this.mySqlAdmin.rowEditor.identityKey) { + this.clearMySqlRowEditor(); + } + this.setMySqlAdminMessage(payload.message || "已删除 1 行", "ok"); + await this.refreshMySqlAfterMutation({ resetPage: true }); + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.rowEditor.saving = false; + } + }, + async addMySqlColumn() { + if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable || !this.mySqlAdmin.columnForm.columnName.trim()) { + return; + } + if (this.mySqlAdmin.columnForm.position === "after" && !this.mySqlAdmin.columnForm.afterColumn.trim()) { + this.mySqlAdmin.error = "afterColumn is required"; + return; + } + + this.mySqlAdmin.columnForm.saving = true; + this.mySqlAdmin.error = ""; + + try { + const payloadBody = { + database: this.mySqlAdmin.selectedDatabase, + table: this.mySqlAdmin.selectedTable, + columnName: this.mySqlAdmin.columnForm.columnName.trim(), + columnType: this.mySqlAdmin.columnForm.columnType.trim(), + nullable: this.mySqlAdmin.columnForm.nullable, + defaultMode: this.mySqlAdmin.columnForm.defaultMode, + defaultValue: this.mySqlAdmin.columnForm.defaultMode === "literal" + ? this.parseMySqlScalarValue(this.mySqlAdmin.columnForm.defaultValue) + : "", + comment: this.mySqlAdmin.columnForm.comment, + position: this.mySqlAdmin.columnForm.position, + afterColumn: this.mySqlAdmin.columnForm.position === "after" + ? this.mySqlAdmin.columnForm.afterColumn + : "", + }; + + const { response, payload } = await this.requestJson("/api/mysql/columns/add", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payloadBody), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mySqlAdmin.columnForm.columnName = ""; + this.mySqlAdmin.columnForm.comment = ""; + this.mySqlAdmin.columnForm.defaultValue = ""; + this.mySqlAdmin.columnForm.position = "last"; + this.setMySqlAdminMessage(payload.message || "列已新增", "ok"); + await this.refreshMySqlAfterMutation({ resetPage: true }); + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.columnForm.saving = false; + } + }, + async dropMySqlColumn(columnName) { + if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable || !columnName) { + return; + } + if (!window.confirm(`删除列 ${columnName}?`)) { + return; + } + + this.mySqlAdmin.columnForm.saving = true; + this.mySqlAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mysql/columns/drop", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mySqlAdmin.selectedDatabase, + table: this.mySqlAdmin.selectedTable, + columnName, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMySqlAdminMessage(payload.message || "列已删除", "ok"); + await this.refreshMySqlAfterMutation({ resetPage: true }); + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.columnForm.saving = false; + } + }, + toggleMySqlIndexColumn(columnName) { + const next = new Set(this.mySqlAdmin.indexForm.columns); + if (next.has(columnName)) { + next.delete(columnName); + } else { + next.add(columnName); + } + this.mySqlAdmin.indexForm.columns = [...next]; + }, + async addMySqlIndex() { + if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable) { + return; + } + if (!this.mySqlAdmin.indexForm.indexName.trim() || this.mySqlAdmin.indexForm.columns.length === 0) { + return; + } + + this.mySqlAdmin.indexForm.saving = true; + this.mySqlAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mysql/indexes/add", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mySqlAdmin.selectedDatabase, + table: this.mySqlAdmin.selectedTable, + indexName: this.mySqlAdmin.indexForm.indexName.trim(), + indexMode: this.mySqlAdmin.indexForm.indexMode, + columns: this.mySqlAdmin.indexForm.columns, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mySqlAdmin.indexForm.indexName = ""; + this.mySqlAdmin.indexForm.columns = []; + this.setMySqlAdminMessage(payload.message || "索引已新增", "ok"); + await this.refreshMySqlAfterMutation(); + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.indexForm.saving = false; + } + }, + async dropMySqlIndex(indexName) { + if (!this.mySqlAdmin.selectedDatabase || !this.mySqlAdmin.selectedTable || !indexName) { + return; + } + if (!window.confirm(`删除索引 ${indexName}?`)) { + return; + } + + this.mySqlAdmin.indexForm.saving = true; + this.mySqlAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mysql/indexes/drop", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mySqlAdmin.selectedDatabase, + table: this.mySqlAdmin.selectedTable, + indexName, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMySqlAdminMessage(payload.message || "索引已删除", "ok"); + await this.refreshMySqlAfterMutation(); + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.indexForm.saving = false; + } + }, + applyMySqlSqlShortcut(shortcut) { + if (!shortcut || shortcut.disabled || !shortcut.sql) { + return; + } + this.mySqlAdmin.sqlConsole.text = `${shortcut.sql.trim()};`; + }, + async executeMySqlSql() { + const sqlText = String(this.mySqlAdmin.sqlConsole.text || "").trim(); + if (!sqlText) { + return; + } + + this.mySqlAdmin.sqlConsole.running = true; + this.mySqlAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mysql/sql/execute", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mySqlAdmin.selectedDatabase, + sql: sqlText, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mySqlAdmin.sqlConsole.result = payload; + if (Array.isArray(payload.columns) && payload.columns.length > 0) { + this.setMySqlAdminMessage(`SQL 返回 ${payload.rowCount || 0} 行`, "neutral"); + } else { + this.setMySqlAdminMessage(payload.message || `${String(payload.kind || "SQL").toUpperCase()} 完成`, "ok"); + await this.refreshMySqlAfterMutation({ resetPage: false }); + } + } catch (error) { + this.mySqlAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mySqlAdmin.sqlConsole.running = false; + } + }, + setMongoAdminMessage(message, tone = "neutral") { + this.mongoAdmin.message = message || ""; + this.mongoAdmin.messageTone = tone || "neutral"; + }, + clearMongoAdminCollectionState() { + this.mongoAdmin.collectionDetail = { + updatedAt: "", + stats: { + count: 0, + sizeMb: 0, + storageSizeMb: 0, + avgObjSizeBytes: 0, + indexCount: 0, + totalIndexSizeMb: 0, + capped: false, + wiredTigerUri: "", + }, + indexes: [], + }; + this.mongoAdmin.query.total = 0; + this.mongoAdmin.query.pageCount = 1; + this.mongoAdmin.query.items = []; + this.mongoAdmin.aggregate.total = 0; + this.mongoAdmin.aggregate.pageCount = 1; + this.mongoAdmin.aggregate.items = []; + this.mongoAdmin.distinct.count = 0; + this.mongoAdmin.distinct.values = []; + this.mongoAdmin.distinct.valuesText = "[]"; + this.mongoAdmin.createDocument.ordered = true; + this.mongoAdmin.collectionOps.renameDropTarget = false; + this.clearMongoDocumentEditor(); + }, + clearMongoDocumentEditor() { + this.mongoAdmin.editor.selectedKey = ""; + this.mongoAdmin.editor.idText = ""; + this.mongoAdmin.editor.filterText = "{}"; + this.mongoAdmin.editor.documentText = "{}"; + this.mongoAdmin.editor.upsert = false; + }, + syncMongoExplorerState(selectedDatabase = this.mongoAdmin.selectedDatabase, { force = false } = {}) { + const databaseNames = (this.mongoAdmin.databases || []).map((database) => database.name); + if (databaseNames.length === 0) { + this.collapsedMongoDatabases = []; + return; + } + + if (force || this.collapsedMongoDatabases.length === 0) { + this.collapsedMongoDatabases = databaseNames.filter((databaseName) => databaseName !== selectedDatabase); + return; + } + + this.collapsedMongoDatabases = this.collapsedMongoDatabases.filter((databaseName) => databaseNames.includes(databaseName)); + if (selectedDatabase && this.collapsedMongoDatabases.includes(selectedDatabase)) { + this.collapsedMongoDatabases = this.collapsedMongoDatabases.filter((databaseName) => databaseName !== selectedDatabase); + } + }, + toggleMongoDatabaseGroup(databaseName) { + const next = new Set(this.collapsedMongoDatabases); + if (next.has(databaseName)) { + next.delete(databaseName); + } else { + next.add(databaseName); + } + this.collapsedMongoDatabases = [...next]; + }, + isMongoDatabaseCollapsed(databaseName) { + return this.collapsedMongoDatabases.includes(databaseName); + }, + isMongoCollectionSelected(databaseName, collectionName) { + return this.mongoAdmin.selectedDatabase === databaseName && this.mongoAdmin.selectedCollection === collectionName; + }, + isMongoEmptyFilterText(value) { + const text = String(value || "").trim(); + return !text || text === "{}"; + }, + async refreshMongoAdminOverview({ preserveSelection = true, silent = false } = {}) { + this.mongoAdmin.loading = true; + if (!silent) { + this.mongoAdmin.error = ""; + } + + const previousDatabase = preserveSelection ? this.mongoAdmin.selectedDatabase : ""; + const previousCollection = preserveSelection ? this.mongoAdmin.selectedCollection : ""; + const preserveDatabaseOnlyView = preserveSelection && Boolean(previousDatabase) && !previousCollection; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/overview"); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mongoAdmin.databases = Array.isArray(payload.items) ? payload.items : []; + this.mongoAdmin.loaded = true; + + const hasPreviousDatabase = previousDatabase + && this.mongoAdmin.databases.some((database) => database.name === previousDatabase); + const nextDatabase = hasPreviousDatabase + ? previousDatabase + : (payload.selectedDatabase || this.mongoAdmin.databases[0]?.name || ""); + const databaseItem = this.mongoAdmin.databases.find((database) => database.name === nextDatabase) || null; + const hasPreviousCollection = previousCollection + && (databaseItem?.collections || []).some((collection) => collection.name === previousCollection); + const nextCollection = preserveDatabaseOnlyView + ? "" + : hasPreviousCollection + ? previousCollection + : (databaseItem?.collections || [])[0]?.name || ""; + + this.mongoAdmin.selectedDatabase = nextDatabase; + this.mongoAdmin.selectedCollection = nextCollection; + this.mongoAdmin.collectionOps.createDatabaseName = nextDatabase || payload.focusedDatabase || ""; + this.mongoAdmin.collectionOps.renameName = nextCollection || ""; + this.syncMongoExplorerState(nextDatabase, { force: !preserveSelection }); + + if (!nextCollection) { + this.clearMongoAdminCollectionState(); + if (!silent) { + this.setMongoAdminMessage(`已加载 ${this.mongoAdmin.databases.length} 个库`, "neutral"); + } + return; + } + + if (previousDatabase !== nextDatabase || previousCollection !== nextCollection) { + this.clearMongoAdminCollectionState(); + } + + await Promise.allSettled([ + this.refreshMongoCollectionDetail({ silent: true }), + this.runMongoQuery({ resetPage: true, silent: true }), + ]); + + if (!silent) { + this.setMongoAdminMessage(`已加载 ${this.mongoAdmin.databases.length} 个库`, "neutral"); + } + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.loading = false; + } + }, + async selectMongoDatabase(databaseName, { loadCollection = true } = {}) { + const database = (this.mongoAdmin.databases || []).find((item) => item.name === databaseName); + if (!database) { + return; + } + + this.mongoAdmin.selectedDatabase = database.name; + this.mongoAdmin.collectionOps.createDatabaseName = database.name; + this.syncMongoExplorerState(database.name); + + if (!loadCollection) { + this.mongoAdmin.selectedCollection = ""; + this.mongoAdmin.collectionOps.renameName = ""; + this.clearMongoAdminCollectionState(); + return; + } + + const currentCollectionStillExists = (database.collections || []).some( + (collection) => collection.name === this.mongoAdmin.selectedCollection, + ); + const nextCollection = currentCollectionStillExists + ? this.mongoAdmin.selectedCollection + : (database.collections || [])[0]?.name || ""; + + if (!nextCollection) { + this.mongoAdmin.selectedCollection = ""; + this.mongoAdmin.collectionOps.renameName = ""; + this.clearMongoAdminCollectionState(); + return; + } + + await this.selectMongoCollection(database.name, nextCollection); + }, + async selectMongoCollection(databaseName, collectionName) { + if (!databaseName || !collectionName) { + return; + } + + this.mongoAdmin.selectedDatabase = databaseName; + this.mongoAdmin.selectedCollection = collectionName; + this.mongoAdmin.collectionOps.createDatabaseName = databaseName; + this.mongoAdmin.collectionOps.renameName = collectionName; + this.syncMongoExplorerState(databaseName); + this.clearMongoAdminCollectionState(); + + await Promise.allSettled([ + this.refreshMongoCollectionDetail({ silent: true }), + this.runMongoQuery({ resetPage: true, silent: true }), + ]); + }, + async refreshMongoCollectionDetail({ silent = false } = {}) { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { + return; + } + + this.mongoAdmin.detailLoading = true; + if (!silent) { + this.mongoAdmin.error = ""; + } + + try { + const query = new URLSearchParams({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + }); + const { response, payload } = await this.requestJson(`/api/mongo-admin/collection-detail?${query.toString()}`); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mongoAdmin.collectionDetail = payload; + this.mongoAdmin.collectionOps.renameName = this.mongoAdmin.selectedCollection; + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.detailLoading = false; + } + }, + async refreshMongoAdminCurrent() { + if (!this.mongoAdmin.selectedCollection) { + await this.refreshMongoAdminOverview({ preserveSelection: true }); + return; + } + + const tasks = [this.refreshMongoCollectionDetail()]; + if (this.mongoAdmin.activeTab === "aggregate") { + tasks.push(this.runMongoAggregate({ silent: true })); + } else { + tasks.push(this.runMongoQuery({ silent: true })); + } + await Promise.allSettled(tasks); + }, + resetMongoQueryForm() { + this.mongoAdmin.query.filterText = "{}"; + this.mongoAdmin.query.sortText = JSON.stringify({ _id: -1 }, null, 2); + this.mongoAdmin.query.projectionText = "{}"; + this.mongoAdmin.query.page = 1; + this.mongoAdmin.query.pageSize = 100; + }, + async runMongoQuery({ resetPage = false, silent = false } = {}) { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { + return; + } + + if (resetPage) { + this.mongoAdmin.query.page = 1; + } + + this.mongoAdmin.querying = true; + if (!silent) { + this.mongoAdmin.error = ""; + } + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/query", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + filter: this.mongoAdmin.query.filterText, + sort: this.mongoAdmin.query.sortText, + projection: this.mongoAdmin.query.projectionText, + page: this.mongoAdmin.query.page, + pageSize: this.mongoAdmin.query.pageSize, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mongoAdmin.query.page = Number(payload.page || this.mongoAdmin.query.page); + this.mongoAdmin.query.pageSize = Number(payload.pageSize || this.mongoAdmin.query.pageSize); + this.mongoAdmin.query.total = Number(payload.total || 0); + this.mongoAdmin.query.pageCount = Number(payload.pageCount || 1); + this.mongoAdmin.query.items = Array.isArray(payload.items) ? payload.items : []; + this.mongoAdmin.query.filterText = payload.filterText || this.mongoAdmin.query.filterText; + this.mongoAdmin.query.sortText = payload.sortText || this.mongoAdmin.query.sortText; + this.mongoAdmin.query.projectionText = payload.projectionText || this.mongoAdmin.query.projectionText; + + if ( + this.mongoAdmin.editor.selectedKey + && !this.mongoAdmin.query.items.some((item) => item.key === this.mongoAdmin.editor.selectedKey) + ) { + this.clearMongoDocumentEditor(); + } + + if (!silent) { + this.setMongoAdminMessage(`已加载 ${this.mongoAdmin.query.items.length} 条文档`, "neutral"); + } + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.querying = false; + } + }, + async changeMongoQueryPage(nextPage) { + const page = Math.max(1, Math.min(Number(nextPage || 1), this.mongoAdmin.query.pageCount || 1)); + if (page === this.mongoAdmin.query.page) { + return; + } + this.mongoAdmin.query.page = page; + await this.runMongoQuery({ silent: true }); + }, + loadMongoDocumentForEdit(item) { + this.mongoAdmin.editor.selectedKey = item.key || ""; + this.mongoAdmin.editor.idText = item.idText || ""; + this.mongoAdmin.editor.filterText = item.idFilterText || "{}"; + this.mongoAdmin.editor.documentText = item.jsonText || "{}"; + this.mongoAdmin.editor.upsert = false; + this.mongoAdmin.activeTab = "browse"; + }, + async saveMongoEditedDocument() { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection || !this.mongoAdmin.editor.filterText) { + return; + } + + this.mongoAdmin.editor.saving = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/documents/replace", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + filter: this.mongoAdmin.editor.filterText, + document: this.mongoAdmin.editor.documentText, + upsert: this.mongoAdmin.editor.upsert, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMongoAdminMessage(`已保存 ${payload.modifiedCount || payload.matchedCount || 0} 条`, "ok"); + await Promise.allSettled([ + this.refreshMongoCollectionDetail({ silent: true }), + this.runMongoQuery({ silent: true }), + this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), + ]); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.editor.saving = false; + } + }, + async deleteMongoDocument(item) { + if (!item?.idFilterText || !this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { + return; + } + + if (!window.confirm(`删除 ${item.idText || "当前文档"}?`)) { + return; + } + + this.mongoAdmin.querying = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/documents/delete", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + filter: item.idFilterText, + many: false, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMongoAdminMessage("已删除文档", "ok"); + await Promise.allSettled([ + this.refreshMongoCollectionDetail({ silent: true }), + this.runMongoQuery({ silent: true }), + this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), + ]); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.querying = false; + } + }, + async insertMongoDocument() { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { + return; + } + + this.mongoAdmin.createDocument.saving = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/documents/insert", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + document: this.mongoAdmin.createDocument.documentText, + ordered: this.mongoAdmin.createDocument.ordered, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mongoAdmin.createDocument.documentText = "{}"; + this.mongoAdmin.createDocument.ordered = true; + this.setMongoAdminMessage(`已写入 ${payload.insertedCount || 0} 条`, "ok"); + await Promise.allSettled([ + this.refreshMongoCollectionDetail({ silent: true }), + this.runMongoQuery({ resetPage: true, silent: true }), + this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), + ]); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.createDocument.saving = false; + } + }, + async runMongoUpdateDocuments() { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { + return; + } + + const allowEmptyFilter = this.mongoAdmin.updateOp.many && this.isMongoEmptyFilterText(this.mongoAdmin.updateOp.filterText); + if (allowEmptyFilter && !window.confirm("当前条件为空,将更新整表文档。继续?")) { + return; + } + + this.mongoAdmin.updateOp.running = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/documents/update", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + filter: this.mongoAdmin.updateOp.filterText, + update: this.mongoAdmin.updateOp.updateText, + many: this.mongoAdmin.updateOp.many, + upsert: this.mongoAdmin.updateOp.upsert, + allowEmptyFilter, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMongoAdminMessage( + `匹配 ${payload.matchedCount || 0} / 修改 ${payload.modifiedCount || 0}`, + "ok", + ); + await Promise.allSettled([ + this.refreshMongoCollectionDetail({ silent: true }), + this.runMongoQuery({ silent: true }), + this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), + ]); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.updateOp.running = false; + } + }, + async runMongoDeleteDocuments() { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { + return; + } + + const allowEmptyFilter = this.mongoAdmin.deleteOp.many && this.isMongoEmptyFilterText(this.mongoAdmin.deleteOp.filterText); + if (!window.confirm(this.mongoAdmin.deleteOp.many ? "删除当前条件命中的全部文档?" : "删除当前条件命中的一条文档?")) { + return; + } + if (allowEmptyFilter && !window.confirm("当前条件为空,将删除整表文档。继续?")) { + return; + } + + this.mongoAdmin.deleteOp.running = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/documents/delete", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + filter: this.mongoAdmin.deleteOp.filterText, + many: this.mongoAdmin.deleteOp.many, + allowEmptyFilter, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMongoAdminMessage(`已删除 ${payload.deletedCount || 0} 条`, "ok"); + await Promise.allSettled([ + this.refreshMongoCollectionDetail({ silent: true }), + this.runMongoQuery({ resetPage: true, silent: true }), + this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), + ]); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.deleteOp.running = false; + } + }, + async runMongoAggregate({ resetPage = false, silent = false } = {}) { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { + return; + } + + if (resetPage) { + this.mongoAdmin.aggregate.page = 1; + } + + this.mongoAdmin.aggregate.running = true; + if (!silent) { + this.mongoAdmin.error = ""; + } + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/aggregate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + pipeline: this.mongoAdmin.aggregate.pipelineText, + page: this.mongoAdmin.aggregate.page, + pageSize: this.mongoAdmin.aggregate.pageSize, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mongoAdmin.aggregate.page = Number(payload.page || this.mongoAdmin.aggregate.page); + this.mongoAdmin.aggregate.pageSize = Number(payload.pageSize || this.mongoAdmin.aggregate.pageSize); + this.mongoAdmin.aggregate.total = Number(payload.total || 0); + this.mongoAdmin.aggregate.pageCount = Number(payload.pageCount || 1); + this.mongoAdmin.aggregate.items = Array.isArray(payload.items) ? payload.items : []; + this.mongoAdmin.aggregate.pipelineText = payload.pipelineText || this.mongoAdmin.aggregate.pipelineText; + + if (!silent) { + this.setMongoAdminMessage(`聚合返回 ${this.mongoAdmin.aggregate.items.length} 条`, "neutral"); + } + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.aggregate.running = false; + } + }, + async changeMongoAggregatePage(nextPage) { + const page = Math.max(1, Math.min(Number(nextPage || 1), this.mongoAdmin.aggregate.pageCount || 1)); + if (page === this.mongoAdmin.aggregate.page) { + return; + } + this.mongoAdmin.aggregate.page = page; + await this.runMongoAggregate({ silent: true }); + }, + async runMongoDistinct() { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection || !this.mongoAdmin.distinct.field.trim()) { + return; + } + + this.mongoAdmin.distinct.running = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/distinct", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + field: this.mongoAdmin.distinct.field, + filter: this.mongoAdmin.distinct.filterText, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mongoAdmin.distinct.count = Number(payload.count || 0); + this.mongoAdmin.distinct.values = Array.isArray(payload.values) ? payload.values : []; + this.mongoAdmin.distinct.valuesText = payload.valuesText || "[]"; + this.setMongoAdminMessage(`去重返回 ${this.mongoAdmin.distinct.count} 项`, "neutral"); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.distinct.running = false; + } + }, + async createMongoIndex() { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { + return; + } + + this.mongoAdmin.indexForm.saving = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/indexes/create", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + keys: this.mongoAdmin.indexForm.keysText, + options: this.mongoAdmin.indexForm.optionsText, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMongoAdminMessage(`已创建索引 ${payload.indexName || ""}`.trim(), "ok"); + await Promise.allSettled([ + this.refreshMongoCollectionDetail({ silent: true }), + this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), + ]); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.indexForm.saving = false; + } + }, + async dropMongoIndex(indexName) { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection || !indexName) { + return; + } + + if (!window.confirm(`删除索引 ${indexName}?`)) { + return; + } + + this.mongoAdmin.indexForm.saving = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/indexes/drop", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + indexName, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMongoAdminMessage(`已删除索引 ${indexName}`, "ok"); + await Promise.allSettled([ + this.refreshMongoCollectionDetail({ silent: true }), + this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), + ]); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.indexForm.saving = false; + } + }, + async dropMongoSecondaryIndexes() { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { + return; + } + + if (!window.confirm(`删除表 ${this.mongoAdmin.selectedCollection} 的全部二级索引?`)) { + return; + } + + this.mongoAdmin.indexForm.dropAllSaving = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/indexes/drop-secondary", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMongoAdminMessage(`已删除 ${payload.droppedCount || 0} 个二级索引`, "ok"); + await Promise.allSettled([ + this.refreshMongoCollectionDetail({ silent: true }), + this.refreshMongoAdminOverview({ preserveSelection: true, silent: true }), + ]); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.indexForm.dropAllSaving = false; + } + }, + async createMongoCollection() { + const databaseName = (this.mongoAdmin.collectionOps.createDatabaseName || this.mongoAdmin.selectedDatabase || "").trim(); + const collectionName = (this.mongoAdmin.collectionOps.createName || "").trim(); + if (!databaseName || !collectionName) { + return; + } + + this.mongoAdmin.collectionOps.running = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/collections/create", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: databaseName, + collection: collectionName, + options: this.mongoAdmin.collectionOps.createOptionsText, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.mongoAdmin.collectionOps.createName = ""; + this.mongoAdmin.collectionOps.createOptionsText = "{}"; + this.setMongoAdminMessage(`已创建表 ${collectionName}`, "ok"); + await this.refreshMongoAdminOverview({ preserveSelection: false, silent: true }); + await this.selectMongoCollection(databaseName, collectionName); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.collectionOps.running = false; + } + }, + async renameMongoCollection() { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection || !this.mongoAdmin.collectionOps.renameName.trim()) { + return; + } + + const targetName = this.mongoAdmin.collectionOps.renameName.trim(); + if (!window.confirm(`重命名表 ${this.mongoAdmin.selectedCollection} -> ${targetName}?`)) { + return; + } + + this.mongoAdmin.collectionOps.running = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/collections/rename", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + targetCollection: targetName, + dropTarget: this.mongoAdmin.collectionOps.renameDropTarget, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMongoAdminMessage(`已重命名为 ${targetName}`, "ok"); + await this.refreshMongoAdminOverview({ preserveSelection: false, silent: true }); + await this.selectMongoCollection(this.mongoAdmin.selectedDatabase, targetName); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.collectionOps.running = false; + this.mongoAdmin.collectionOps.renameDropTarget = false; + } + }, + async dropMongoCollection() { + if (!this.mongoAdmin.selectedDatabase || !this.mongoAdmin.selectedCollection) { + return; + } + + if (!window.confirm(`删除表 ${this.mongoAdmin.selectedCollection}?`)) { + return; + } + + this.mongoAdmin.collectionOps.dropRunning = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/collections/drop", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + collection: this.mongoAdmin.selectedCollection, + confirmName: this.mongoAdmin.selectedCollection, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMongoAdminMessage("已删除表", "ok"); + await this.refreshMongoAdminOverview({ preserveSelection: false, silent: true }); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.collectionOps.dropRunning = false; + } + }, + async dropMongoDatabase() { + if (!this.mongoAdmin.selectedDatabase) { + return; + } + + if (!window.confirm(`删除库 ${this.mongoAdmin.selectedDatabase}?`)) { + return; + } + + this.mongoAdmin.collectionOps.databaseDropRunning = true; + this.mongoAdmin.error = ""; + + try { + const { response, payload } = await this.requestJson("/api/mongo-admin/databases/drop", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + database: this.mongoAdmin.selectedDatabase, + confirmName: this.mongoAdmin.selectedDatabase, + }), + }); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.setMongoAdminMessage("已删除库", "ok"); + await this.refreshMongoAdminOverview({ preserveSelection: false, silent: true }); + } catch (error) { + this.mongoAdmin.error = error instanceof Error ? error.message : String(error); + } finally { + this.mongoAdmin.collectionOps.databaseDropRunning = false; + } + }, async refreshRestartTargets() { this.restartOps.loading = true; this.restartOps.error = ""; diff --git a/static/index.html b/static/index.html index 48546b1..8d21991 100644 --- a/static/index.html +++ b/static/index.html @@ -16,7 +16,7 @@
请启用 JavaScript
-
+
@@ -76,6 +76,44 @@ +
+
+
+ + + + +
+
+
+ +
+ +
+ +
+
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+
+
+ + +