diff --git a/.env.example b/.env.example index 797f3e1..96111df 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,27 @@ APP_BIND=0.0.0.0 APP_PORT=2026 +AUTH_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3 +AUTH_SESSION_TTL_HOURS=24 +AUTH_COOKIE_SECURE=0 DEFAULT_REFRESH_INTERVAL_SECONDS=10 REFRESH_INTERVAL_OPTIONS=5,10,15,30,60 PROBE_TIMEOUT_SECONDS=3 PROBE_CACHE_TTL_SECONDS=5 PROBE_MAX_WORKERS=12 HOST_METRIC_CACHE_TTL_SECONDS=25 +HOST_METRIC_TIMEOUT_SECONDS=30 +HOST_METRIC_CPU_SAMPLE_SECONDS=1 TAT_METRIC_TIMEOUT_SECONDS=30 TAT_CPU_SAMPLE_SECONDS=1 +SSH_USER=root +SSH_USE_SUDO=0 +SSH_PORT=22 +SSH_CONNECT_TIMEOUT_SECONDS=10 +SSH_STRICT_HOST_KEY_CHECKING=accept-new +SSH_PRIVATE_KEY_PATH=/opt/hy-app-monitor/run/ssh/id_ed25519 +SSH_KNOWN_HOSTS_PATH=/opt/hy-app-monitor/run/ssh/known_hosts +SERVICE_LOG_PREVIEW_LINES=1000 +SERVICE_LOG_EXPORT_MAX_LINES=10000 ROLLING_RESTART_TIMEOUT_SECONDS=180 ROLLING_RESTART_POLL_SECONDS=2 ROLLING_RESTART_STABILIZE_SECONDS=2 @@ -29,8 +43,28 @@ HARBOR_PASSWORD= BUILD_DOCKER_PLATFORM=linux/amd64 UPDATE_DEFAULT_JAVA_GIT_REF=main UPDATE_DEFAULT_GO_GIT_REF=main +UPDATE_DEFAULT_ADMIN_GIT_REF=main JAVA_REPO_ROOT=/opt/deploy/sources/chatapp3-java GOLANG_REPO_ROOT=/opt/deploy/sources/chatapp3-golang +ADMIN_FRONTEND_ENABLED=0 +ADMIN_FRONTEND_SERVICE_NAME=admin +ADMIN_FRONTEND_REPO_URL=git@gitea.haiyihy.com:hy/chatapp3-admin.git +ADMIN_FRONTEND_REPO_ROOT=/opt/deploy/sources/chatapp3-admin +ADMIN_FRONTEND_HOST= +ADMIN_FRONTEND_IP= +ADMIN_FRONTEND_INSTANCE_ID= +ADMIN_FRONTEND_SSH_HOST= +ADMIN_FRONTEND_SSH_USER=root +ADMIN_FRONTEND_SSH_PORT=22 +ADMIN_FRONTEND_SSH_USE_SUDO=0 +ADMIN_FRONTEND_REMOTE_DIR=/opt/yumi-admin/frontend-admin +ADMIN_FRONTEND_SITE_DIR=/opt/yumi-admin/frontend-admin/site +ADMIN_FRONTEND_COMPOSE_FILE=/opt/yumi-admin/frontend-admin/docker-compose.yml +ADMIN_FRONTEND_COMPOSE_SERVICE=caddy +ADMIN_FRONTEND_RELEASE_META_PATH=/opt/yumi-admin/frontend-admin/.hy-app-monitor-admin-release.json +ADMIN_FRONTEND_BUILD_SCRIPT=build:antdv-next +ADMIN_FRONTEND_DIST_DIR=apps/dist +ADMIN_FRONTEND_PUBLIC_URL= MONGO_URI= MONGO_DB_NAME=tarab_all MONGO_INSTANCE_ID=ins-o9mmob66 @@ -46,5 +80,11 @@ PROCESS_BAD_COUNT=260 SERVICE_LATENCY_WARN_MS=1000 SERVICE_LATENCY_BAD_MS=3000 DEPLOY_REGION=me-saudi-arabia +DEPLOY_INSTANCE_ID= TENCENT_SECRET_ID= TENCENT_SECRET_KEY= +DEPLOY_SSH_HOST= +DEPLOY_SSH_USER=root +DEPLOY_SSH_PORT=22 +DEPLOY_SSH_IDENTITY_FILE= +MONITOR_REMOTE_DIR=/opt/hy-app-monitor diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000..24466fc --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,54 @@ +## Design Context + +### Users +- 主要使用者是 deploy 机上的运维、后端开发和值班人员。 +- 他们会在同一页面里完成巡检、异常定位、配置核对、服务更新和滚动重启。 +- 这个面板的核心目标是 3 秒扫状态、10 秒定位异常、30 秒进入操作。 + +### Brand Personality +- 气质是冷静、可靠、克制。 +- 界面要传递“可控、可信、响应快”,不是展示型大屏,也不是炫技 demo。 +- 页面文案必须短,不写解释性口号,不加无意义描述。 + +### Aesthetic Direction +- 视觉方向固定为浅色工业控制台。 +- 主题采用浅底、钢蓝主强调、绿橙红只用于状态,不做装饰。 +- 结构采用硬质面板、清晰边线、小圆角、机架式主机条带。 +- 禁止继续使用玻璃拟态、霓虹渐变、厚重阴影、大 Hero、紫蓝科技感 demo 风。 + +### Design Principles +- 先扫读,再操作:总览在顶部,异常优先暴露,操作区保持直接。 +- 状态色只表达状态:正常、警告、异常必须一眼可分,但不要泛滥。 +- 同类控件同一语言:按钮、筛选、输入框、列表项统一边线和圆角系统。 +- 面板比卡片重要:减少漂浮卡片感,增强工作台和控制台感。 +- 文案最短化:标题、标签、动作名称直接,不补说明性废话。 + +### Information Architecture +- 首页默认视图必须先给“状态总览”,不能先给配置编辑器或发布表单。 +- 首页第一屏核心固定为“服务状态矩阵”,行是服务,列是部署主机/实例。 +- 主机区必须使用紧凑条带,一台主机一行;主机指标不能继续做四个大卡片。 +- `Nacos / Mongo / TAT` 在首页只做基础设施摘要,详细表格放到基础设施视图。 +- `配置` 和 `发布` 必须独立成单独视图,不再默认堆在首页长滚动流里。 +- 筛选优先按业务分组:`全部 / 异常 / 网关 / 应用 / 支付 / 基础设施`。 + +### Density Rules +- 首屏要尽量在不滚动的情况下同时看到异常摘要和大部分服务状态。 +- 首页首屏结构固定收敛为:顶部导航、工具条、紧凑摘要条、服务矩阵;不再插入宽而矮利用率低的大提示块。 +- 服务状态单元格只保留状态码或极简状态,不在矩阵里铺长 URL 或返回正文。 +- 最近异常并入可折叠右侧详情面板顶部,不单独占首页整行宽度。 +- 详情信息通过可折叠右侧详情面板或展开行承载,不通过继续增高卡片解决。 +- 异常项排序必须前置,正常项后置,保证值班扫屏优先看到问题。 +- 紧凑模式下隐藏分组、均值、URL、返回详情等次要信息,优先保留状态判断信息。 +- 正常态默认降饱和处理,灰绿或中性灰优先;红橙只服务于告警和异常。 + +### Layout Rules +- 服务矩阵按业务分组切成多个子矩阵,减少未部署空白格,不再把所有主机列硬拼成一张大表。 +- 服务矩阵只保留一层分组表达,禁止在行内和表头重复写相同分组信息。 +- 服务矩阵表头和第一列默认 sticky,横向或纵向滚动时不能丢服务名和主机名。 +- 首页主机区只展示业务主机条带,`Nacos / Mongo` 等基础设施主机放到基础设施或主机视图承接。 +- 主机指标必须收成一行短文本,不回退到四个大指标块或一排高饱和胶囊。 +- `总览 / 发布 / 配置 / 基础设施` 不共享同一套工具条;监控筛选只属于总览、服务、主机视图。 +- `发布` 页必须是专用工作台:顶部 sticky 操作栏、单独状态条、构建参数在上、更新目标和滚动重启都用紧凑表格。 +- `配置` 页必须拆成页内 tabs:`Nacos / Go Env / 历史版本`;编辑区使用 sticky 工具条、metadata strip、`源码 / Diff` 切换。 +- `基础设施` 页必须有二级导航:`状态总览 / Nacos 服务 / Mongo 指标 / TAT`;Nacos 服务默认折叠实例,Mongo 次要指标默认折叠。 +- 长文本默认缩写,完整值只通过悬停、复制或展开明细承接,不在主列表直接铺满一整行。 diff --git a/AGENTS.md b/AGENTS.md index 4bc0e9b..ca9dbae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,3 +40,22 @@ - 不要删除页面里的主机指标区块。 - Python 文件保持高注释密度,优先写清楚每个步骤在做什么。 - Python 文件顶部的 `import` 区域不写包用途注释。 + +## 视觉约定 + +- 前端默认视觉风格固定为“浅色工业控制台”。 +- 设计上下文以项目根目录 `.impeccable.md` 为准,后续前端对话和改动先读取它。 +- 页面保持冷静、可靠、可操作,不回退到玻璃拟态、霓虹渐变、大 Hero 的 demo 风。 +- 页面文案只保留必要标签和动作,不添加无意义描述。 +- 首页默认是状态总览,不把配置编辑和发布表单继续堆在同一条长页面里。 +- 服务视图优先使用状态矩阵,主机视图优先使用紧凑条带,基础设施视图承接 Nacos / Mongo 详情。 +- 首页首屏默认收敛为顶部导航、工具条、紧凑摘要条、服务矩阵,不额外插入大块最近异常横幅。 +- 最近异常进入可折叠详情侧栏顶部,不独立占据首页主宽度。 +- 服务矩阵按业务分组切成子矩阵,异常排序前置,表头和第一列保持 sticky。 +- 首页主机区只展示业务主机;基础设施主机放到基础设施或主机视图承接。 +- 主机指标使用单行短文本,正常态降饱和,红橙色保留给异常和告警。 +- 监控筛选条只属于 `总览 / 服务 / 主机`;`发布 / 配置 / 基础设施` 必须使用各自专用工具条和页内导航。 +- `发布` 页按“构建参数 -> 更新目标 -> 执行日志 -> 滚动重启”组织,更新与重启目标都优先用紧凑表格,不再回退到大卡片流。 +- `配置` 页按 `Nacos / Go Env / 历史版本` tabs 组织,编辑器必须提供 sticky 工具条、metadata strip 和 `源码 / Diff` 切换。 +- `基础设施` 页按 `状态总览 / Nacos 服务 / Mongo 指标 / TAT` 二级导航组织,Nacos 服务默认折叠实例,Mongo 次要指标默认折叠。 +- 长文本默认缩写并提供复制或展开,不直接在主列表铺完整 digest、路径、长 ID 或长连接串。 diff --git a/README.md b/README.md index 3b75364..39118c4 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,13 @@ - 页面端口:`2026` - 页面入口:`http://43.164.75.199:2026/` +- 登录入口:`http://43.164.75.199:2026/login` - 服务健康:直接探测各业务服务健康接口 -- 主机资源:通过 `TAT` 批量采集 `CPU / 内存 / 磁盘 / 进程数` +- 主机资源:通过 `SSH` 采集 `CPU / 内存 / 磁盘 / 进程数` - Nacos:直连 Nacos OpenAPI,列出命名空间、服务和注册实例 - MongoDB:直连 Mongo,采集 `serverStatus / listDatabases / replSetGetStatus` +- 宿主机运维:日志、滚动重启、compose 同步、服务更新统一走 `SSH` +- 带外引导:只在打通 `deploy -> target:22` 时使用 `TAT` ## 目录 @@ -17,7 +20,7 @@ - `static/`:前端页面 - `scripts/`:启停、自启安装、前台运行脚本 - `systemd/`:systemd unit -- `ops/`:TAT 放行和远端部署脚本 +- `ops/`:SSH 引导、放行和部署脚本 - `.env.example`:运行配置模板 ## 运行配置 @@ -29,14 +32,20 @@ ```bash APP_BIND=0.0.0.0 APP_PORT=2026 +AUTH_DB_PATH=/opt/hy-app-monitor/run/auth.sqlite3 +AUTH_SESSION_TTL_HOURS=24 DEFAULT_REFRESH_INTERVAL_SECONDS=10 REFRESH_INTERVAL_OPTIONS=5,10,15,30,60 NACOS_BASE_URL=http://10.2.1.17:8848 NACOS_NAMESPACE_ID=... MONGO_URI=mongodb://... DEPLOY_REGION=me-saudi-arabia +DEPLOY_INSTANCE_ID= TENCENT_SECRET_ID=*** TENCENT_SECRET_KEY=*** +SSH_USER=root +SSH_PORT=22 +SSH_PRIVATE_KEY_PATH=/opt/hy-app-monitor/run/ssh/id_ed25519 ``` 如果你要直接在页面里做“构建镜像 -> 推 Harbor -> 更新服务”,还需要补这几项: @@ -53,6 +62,33 @@ JAVA_MAVEN_THREADS=1C JAVA_IMAGE_BUILD_MAX_WORKERS=3 UPDATE_DEFAULT_JAVA_GIT_REF=main UPDATE_DEFAULT_GO_GIT_REF=main +UPDATE_DEFAULT_ADMIN_GIT_REF=main +DEPLOY_SSH_HOST=10.2.x.x +DEPLOY_SSH_USER=root +DEPLOY_SSH_PORT=22 +DEPLOY_SSH_IDENTITY_FILE=~/.ssh/xxx +``` + +如果你还要直接发布 admin 前端静态站点,再补这几项: + +```bash +ADMIN_FRONTEND_ENABLED=1 +ADMIN_FRONTEND_REPO_URL=git@gitea.haiyihy.com:hy/chatapp3-admin.git +ADMIN_FRONTEND_REPO_ROOT=/opt/deploy/sources/chatapp3-admin +ADMIN_FRONTEND_HOST=admin-platform +ADMIN_FRONTEND_IP=10.2.1.2 +ADMIN_FRONTEND_INSTANCE_ID=ins-p11begwe +ADMIN_FRONTEND_SSH_HOST=10.2.1.2 +ADMIN_FRONTEND_SSH_USER=root +ADMIN_FRONTEND_SSH_PORT=22 +ADMIN_FRONTEND_REMOTE_DIR=/opt/yumi-admin/frontend-admin +ADMIN_FRONTEND_SITE_DIR=/opt/yumi-admin/frontend-admin/site +ADMIN_FRONTEND_COMPOSE_FILE=/opt/yumi-admin/frontend-admin/docker-compose.yml +ADMIN_FRONTEND_COMPOSE_SERVICE=caddy +ADMIN_FRONTEND_RELEASE_META_PATH=/opt/yumi-admin/frontend-admin/.hy-app-monitor-admin-release.json +ADMIN_FRONTEND_BUILD_SCRIPT=build:antdv-next +ADMIN_FRONTEND_DIST_DIR=apps/dist +ADMIN_FRONTEND_PUBLIC_URL=https://yumi-admin.haiyihy.com ``` 可以直接复制模板: @@ -61,6 +97,12 @@ UPDATE_DEFAULT_GO_GIT_REF=main cp .env.example .env ``` +如需放到 HTTPS 反代后再启用 `Secure Cookie`,可在 `.env` 追加: + +```bash +AUTH_COOKIE_SECURE=1 +``` + ## 本地运行 ```bash @@ -74,6 +116,12 @@ bash scripts/status.sh http://127.0.0.1:2026/ ``` +未登录会自动跳到: + +```bash +http://127.0.0.1:2026/login +``` + ## systemd 开机自启 在远端项目目录执行: @@ -90,33 +138,44 @@ bash scripts/status.sh bash scripts/uninstall_systemd.sh ``` -## 通过 TAT 放行端口 +## 通过 TAT 引导 SSH + +```bash +python3 ops/bootstrap_ssh_via_tat.py +``` + +默认会: + +1. 在 `deploy` 机生成专用 SSH 密钥对 +2. 通过 `TAT` 给目标机写入公钥并准备 `sshd` +3. 只允许 `deploy -> target:22` +4. 把目标机本机防火墙和安全组都限制到 `deploy` 私网 IP + +如果 `.env` 里开启了 `ADMIN_FRONTEND_ENABLED=1`,脚本也会把 admin 前端机器一起纳入这轮 SSH 引导,即使它不在 `config/targets.json` 里。 + +如果你只需要放开监控面板端口,仍然可以使用: ```bash python3 ops/open_port_via_tat.py ``` -默认会: - -1. 解析 `deploy` 机器安全组 -2. 通过 `TAT` 放开机器内防火墙 -3. 通过腾讯云 API 放开安全组 TCP `2026` - -## 通过 TAT 远端部署 +## 通过 SSH 远端部署 ```bash -python3 ops/deploy_via_tat.py +python3 ops/deploy_via_ssh.py ``` 默认会: -1. 拉取或更新 `/opt/hy-app-monitor` +1. 把当前工作区同步到远端 `/opt/hy-app-monitor` 2. 把本地项目 `.env` 同步到远端项目 `.env` 3. 创建远端 `.venv` 4. 安装 `requirements-ops.txt` 5. 安装并启用 `systemd` 6. 重启服务并检查 `2026` 监听 +`ops/deploy_via_tat.py` 仍可保留成带外兜底,不建议继续作为日常主链路。 + ## 维护完整 Compose 默认只刷新本地完整模板: @@ -134,6 +193,15 @@ python3 ops/sync_runtime_compose.py --sync-remote 模板文件会写到本地 `run/compose_templates/`。 滚动重启在真正执行 `docker compose up -d --force-recreate --no-deps ` 之前,也会先把对应宿主机的完整 `docker-compose.yml` 同步到远端,避免线上 compose 被裁剪成子集。 +## 服务日志 + +页面里的服务详情支持按实例查看容器日志: + +1. 预览固定最近 `1000` 条 +2. 导出支持自定义条数 +3. 导出条数会按 `SERVICE_LOG_EXPORT_MAX_LINES` 限制 +4. 日志命令走目标机 `docker logs --tail N` + ## 服务更新 页面里的“服务更新”会直接在当前机器上执行这条链路: @@ -142,15 +210,26 @@ python3 ops/sync_runtime_compose.py --sync-remote 2. 本地构建镜像并推送到 Harbor 3. 取回 Harbor `digest` 4. 用新 `digest` 更新本地完整 compose 模板 -5. 把完整 compose 下发到目标宿主机 -6. 先在目标机 `docker pull` 新镜像 +5. 通过 `SSH` 把完整 compose 下发到目标宿主机 +6. 通过 `SSH` 先在目标机 `docker pull` 新镜像 7. 最后只对你选中的服务执行 `docker compose up -d --force-recreate --no-deps ` +如果选中 `admin` 前端目标,则会改走: + +1. 在 deploy 机拉取 / 更新 `chatapp3-admin` +2. 执行 `corepack pnpm install --frozen-lockfile` +3. 执行 `pnpm run build:antdv-next` +4. 通过 `SSH + SCP` 把 `apps/dist` 同步到 admin 机 `site` 目录 +5. 备份旧站点文件并原地替换内容 +6. 执行 `docker compose up -d caddy` +7. 把本次发布的 `gitRef / commit / 时间` 写到远端元数据文件,页面后续可直接看到当前已部署版本 + 说明: - `other` 现在也按 Harbor 业务镜像更新,不再继续依赖本地 `./other/service.jar` 挂载 - Java Maven 默认走增量 `package`,并支持通过 `JAVA_MAVEN_THREADS` 开启 reactor 并行 - 多个 Java 服务会按 `JAVA_IMAGE_BUILD_MAX_WORKERS` 受限并行执行 `docker build + push` +- `admin` 前端不会走 Harbor 镜像链路,而是直接发布静态文件到 `site` - 页面里的“滚动重启”仍然保留,适合只想让配置生效、不涉及代码构建的场景 - 这条链路默认假设 `hy-app-monitor` 就运行在打包机 / deploy 机上,并且本机已经能访问业务仓、Docker 和 Harbor @@ -161,6 +240,7 @@ python3 ops/sync_runtime_compose.py --sync-remote - Nacos:命名空间、服务、注册实例、健康状态 - Nacos 配置:读取当前 namespace 下的配置列表,支持格式校验、在线编辑保存 - Golang 配置:读取 app 节点当前运行中的 `service.env`,展示 `chatapp3-golang` 实际消费的环境变量并支持保存 +- 服务日志:按实例查看最近日志并导出 - 服务更新:本地构建选中服务、推送 Harbor、更新完整 compose,并只重建选中的服务 - 滚动重启:可按服务选择 Java / Golang 节点,逐台重启并等待健康恢复 - MongoDB:连接、内存、网络、操作计数、复制集、数据库统计 diff --git a/monitor/compose_templates.py b/monitor/compose_templates.py index 2d91443..cf9b1d0 100644 --- a/monitor/compose_templates.py +++ b/monitor/compose_templates.py @@ -12,7 +12,7 @@ from typing import Any from .config import ROOT, RUNTIME_BASE_DIR, load_hosts, utc_now from .runtime_config import read_remote_text, write_remote_text -from .tencent_tat import run_shell_command +from .ssh_remote import copy_remote_file, run_host_script_checked # 本地完整 compose 模板统一放到 run 目录,避免把运行期产物写进 Git。 @@ -49,7 +49,7 @@ def container_name(host_name: str, service_name: str) -> str: def docker_inspect_payload(host: dict[str, Any], service_names: list[str]) -> list[dict[str, Any]]: - # 这里只抓 compose 需要的关键字段,避免完整 inspect JSON 被 TAT 输出截断。 + # 这里只抓 compose 需要的关键字段,避免远端 inspect 输出塞进一大坨无关 JSON。 container_names = [container_name(host["host"], service_name) for service_name in service_names] if not container_names: raise RuntimeError(f"no compose services configured for host: {host['host']}") @@ -89,15 +89,12 @@ for item in payload: script = f"""set -eu docker inspect {quoted_names} | python3 -c {shlex.quote(python_code)} """ - result_map = run_shell_command( - [host["instanceId"]], + result = run_host_script_checked( + host, script, command_name=f"inspect-compose-{host['host']}", timeout_seconds=90, ) - result = result_map[host["instanceId"]] - if result.get("status") != "SUCCESS": - raise RuntimeError(f"docker inspect failed on {host['host']}: {result.get('status')}") payload: list[dict[str, Any]] = [] for raw_line in str(result.get("output") or "").splitlines(): @@ -466,15 +463,12 @@ def verify_remote_compose(host: dict[str, Any], remote_path: str) -> list[str]: FILE={shlex.quote(remote_path)} docker compose -f "$FILE" config --services """ - result_map = run_shell_command( - [host["instanceId"]], + result = run_host_script_checked( + host, script, command_name=f"verify-compose-{host['host']}", timeout_seconds=60, ) - result = result_map[host["instanceId"]] - if result.get("status") != "SUCCESS": - raise RuntimeError(f"compose verify failed on {host['host']}: {result.get('status')}") return [line.strip() for line in str(result.get("output") or "").splitlines() if line.strip()] @@ -506,7 +500,7 @@ def sync_remote_host_compose_template(host_name: str, *, refresh_from_remote: bo backup_path = "" if remote_current and remote_current != template_payload["content"]: backup_path = f"{remote_path}.hyapp.{utc_now().replace(':', '').replace('+', '_')}.bak" - write_remote_text(host, backup_path, remote_current) + copy_remote_file(host, remote_path, backup_path) changed = remote_current != template_payload["content"] if changed: diff --git a/monitor/host_metrics.py b/monitor/host_metrics.py index 456b86e..6c5ea11 100644 --- a/monitor/host_metrics.py +++ b/monitor/host_metrics.py @@ -1,14 +1,16 @@ from __future__ import annotations +# SSH 主机指标改成并发采集,避免逐台串行拖慢首页。 +import concurrent.futures import json from typing import Any from .cache import TTLCache -from .config import HOST_METRIC_CACHE_TTL_SECONDS, TAT_CPU_SAMPLE_SECONDS, TAT_METRIC_TIMEOUT_SECONDS, utc_now -from .tencent_tat import run_shell_command +from .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 -# 主机指标单独缓存,避免页面频繁刷新时不断调用 TAT。 +# 主机指标单独缓存,避免页面频繁刷新时不断压业务主机。 HOST_METRIC_CACHE = TTLCache[dict[str, dict[str, Any]]](HOST_METRIC_CACHE_TTL_SECONDS) @@ -33,7 +35,7 @@ def read_cpu(): # 第一次采样。 idle_1, total_1 = read_cpu() # 等待一个很短的采样窗口。 -time.sleep({TAT_CPU_SAMPLE_SECONDS}) +time.sleep({HOST_METRIC_CPU_SAMPLE_SECONDS}) # 第二次采样。 idle_2, total_2 = read_cpu() @@ -91,57 +93,63 @@ def metrics_error_payload(error: str, instance_id: str = "") -> dict[str, Any]: } -def collect_host_metrics(hosts: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: - # 建立实例 ID 到 host 的映射。 - host_by_instance = {host["instanceId"]: host for host in hosts if host.get("instanceId")} - # 去重后的实例 ID 列表。 - instance_ids = list(host_by_instance.keys()) - - # 没有实例 ID 时直接返回错误。 - if not instance_ids: - return { - host["host"]: metrics_error_payload("missing instanceId in config/targets.json") - for host in hosts - } - - # 通过 TAT 批量执行主机指标采集脚本。 - task_results = run_shell_command( - instance_ids, +def collect_single_host_metrics(host: dict[str, Any]) -> tuple[str, dict[str, Any]]: + # SSH 远端执行失败时也按统一结构回传,避免单机问题拖垮整个汇总接口。 + instance_id = str(host.get("instanceId") or "").strip() + result = run_host_script( + host, build_metrics_script(), - command_name="hy-app-monitor-host-metrics", - timeout_seconds=TAT_METRIC_TIMEOUT_SECONDS, + command_name=f"host-metrics-{host['host']}", + timeout_seconds=HOST_METRIC_TIMEOUT_SECONDS, ) + status = str(result.get("status") or "").strip() + output = str(result.get("output") or "") - # 这里按 host name 回填最终结果。 + if status == "SUCCESS": + try: + payload = json.loads(output or "{}") + payload["updatedAt"] = utc_now() + payload["instanceId"] = instance_id + return host["host"], payload + except Exception as exc: # noqa: BLE001 + return host["host"], metrics_error_payload(f"metrics parse failed: {exc}", instance_id) + + return host["host"], metrics_error_payload(output.strip() or status or "metrics failed", instance_id) + + +def collect_host_metrics(hosts: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + # 没有主机时直接返回空结构。 + if not hosts: + return {} + + # 主机没有基本 SSH 信息时先记错误,剩余主机继续采集。 payload_by_host: dict[str, dict[str, Any]] = {} - - # 逐台主机解析结果。 + available_hosts: list[dict[str, Any]] = [] for host in hosts: - # 读取实例 ID。 - instance_id = host.get("instanceId", "") - # 读取该实例的远端执行结果。 - task = task_results.get(instance_id, {}) - # 读取执行状态。 - status = task.get("status", "") - # 读取远端输出。 - output = task.get("output", "") + if str(host.get("ip") or host.get("sshHost") or "").strip(): + available_hosts.append(host) + continue + payload_by_host[host["host"]] = metrics_error_payload( + "missing ip/sshHost in config/targets.json", + host.get("instanceId", ""), + ) - # 成功时尝试解析 JSON。 - if status == "SUCCESS": + # SSH 无法像 TAT 那样一次多机下发,这里按主机并发执行。 + workers = max(1, min(len(available_hosts) or 1, 8)) + + with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: + future_map = {executor.submit(collect_single_host_metrics, host): host for host in available_hosts} + for future in concurrent.futures.as_completed(future_map): + host = future_map[future] try: - # 把 JSON 输出反序列化成 Python 结构。 - payload = json.loads(output or "{}") - # 补齐统一字段。 - payload["updatedAt"] = utc_now() - payload["instanceId"] = instance_id - payload_by_host[host["host"]] = payload - continue + host_name, payload = future.result() except Exception as exc: # noqa: BLE001 - payload_by_host[host["host"]] = metrics_error_payload(f"metrics parse failed: {exc}", instance_id) + payload_by_host[host["host"]] = metrics_error_payload( + f"metrics collection failed: {exc}", + host.get("instanceId", ""), + ) continue - - # 失败时保留错误文本或状态码。 - payload_by_host[host["host"]] = metrics_error_payload(output.strip() or status or "metrics failed", instance_id) + payload_by_host[host_name] = payload # 返回按 host 索引的主机指标。 return payload_by_host @@ -160,5 +168,5 @@ def get_host_metrics(hosts: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: for host in hosts } - # 通过缓存保护 TAT 调用。 + # 通过缓存保护 SSH 调用。 return HOST_METRIC_CACHE.get_or_build(builder) diff --git a/monitor/http_app.py b/monitor/http_app.py index 268d7f4..654c0fa 100644 --- a/monitor/http_app.py +++ b/monitor/http_app.py @@ -572,6 +572,7 @@ class MonitorHandler(BaseHTTPRequestHandler): java_git_ref = str(payload.get("javaGitRef") or "").strip() go_git_ref = str(payload.get("goGitRef") or "").strip() + admin_git_ref = str(payload.get("adminGitRef") or "").strip() image_tag = str(payload.get("imageTag") or "").strip() skip_maven_build = bool(payload.get("skipMavenBuild")) @@ -580,6 +581,7 @@ class MonitorHandler(BaseHTTPRequestHandler): services, java_git_ref=java_git_ref, go_git_ref=go_git_ref, + admin_git_ref=admin_git_ref, image_tag=image_tag, skip_maven_build=skip_maven_build, ) diff --git a/monitor/runtime_config.py b/monitor/runtime_config.py index 1c3c7c9..5853609 100644 --- a/monitor/runtime_config.py +++ b/monitor/runtime_config.py @@ -1,17 +1,14 @@ from __future__ import annotations -# 远端写文件时需要 base64 传输,避免引号和换行转义问题。 -import base64 # 返回给前端的摘要信息会带 sha256。 import hashlib # env 值里有 JSON 字符串时直接复用标准库解析。 import json -# 远端路径和 shell 参数需要安全转义。 -import shlex from typing import Any -from .config import RUNTIME_BASE_DIR, TAT_METRIC_TIMEOUT_SECONDS, load_hosts, utc_now -from .tencent_tat import run_shell_command +from .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 # Go 配置按源码里的运行分段排序,页面上也按这个顺序展示。 @@ -304,12 +301,6 @@ GO_ENV_PRIMARY_BY_KEY = { # 同时保留按主键索引的 spec,后续查类型更直接。 GO_ENV_SPECS_BY_PRIMARY = {spec["primary"]: spec for spec in GO_ENV_SPECS} -# 远端文件不存在时,用固定标记避免和 shell 错误混淆。 -REMOTE_FILE_MISSING_MARKER = "__HY_APP_MONITOR_FILE_MISSING__" -# TAT 单次输出有长度上限,读取较大的 env / compose 文件时需要分块。 -REMOTE_TEXT_CHUNK_BYTES = 4096 - - def go_service_hosts() -> list[dict[str, Any]]: # 只挑出当前监控目标里真正承载 golang 服务的主机。 hosts = [ @@ -379,90 +370,14 @@ def parse_env_text(raw: str) -> dict[str, str]: return values -def remote_host_result(host: dict[str, Any], script: str, command_name: str, *, timeout_seconds: int = TAT_METRIC_TIMEOUT_SECONDS) -> dict[str, Any]: - # 统一执行单机 TAT 命令,并把错误折叠成 RuntimeError。 - result_map = run_shell_command( - [host["instanceId"]], - script, - command_name=command_name, - timeout_seconds=timeout_seconds, - ) - result = result_map[host["instanceId"]] - if result.get("status") != "SUCCESS": - raise RuntimeError(f"{host['host']} {command_name} failed: {result.get('status')}") - return result - - -def remote_file_size(host: dict[str, Any], path: str) -> int: - quoted_path = shlex.quote(path) - script = f"""set -eu -FILE={quoted_path} -if [ ! -f "$FILE" ]; then - printf '%s' {shlex.quote(REMOTE_FILE_MISSING_MARKER)} - exit 0 -fi -python3 - "$FILE" <<'PY' -from pathlib import Path -import sys - -print(Path(sys.argv[1]).stat().st_size, end="") -PY -""" - result = remote_host_result(host, script, f"stat-{host['host']}-remote-file") - output = str(result.get("output") or "") - if output.strip() == REMOTE_FILE_MISSING_MARKER: - raise RuntimeError(f"{host['host']} missing file: {path}") - return int(output.strip() or "0") - - def read_remote_text(host: dict[str, Any], path: str) -> str: - quoted_path = shlex.quote(path) - file_size = remote_file_size(host, path) - if file_size == 0: - return "" - - # 通过多次小输出读取完整文件,避免 TAT 把大文本截断。 - chunks: list[bytes] = [] - for offset in range(0, file_size, REMOTE_TEXT_CHUNK_BYTES): - script = f"""set -eu -FILE={quoted_path} -if [ ! -f "$FILE" ]; then - printf '%s' {shlex.quote(REMOTE_FILE_MISSING_MARKER)} - exit 0 -fi -python3 - "$FILE" {offset} {REMOTE_TEXT_CHUNK_BYTES} <<'PY' -from pathlib import Path -import base64 -import sys - -path = Path(sys.argv[1]) -offset = int(sys.argv[2]) -chunk_size = int(sys.argv[3]) -data = path.read_bytes()[offset : offset + chunk_size] -print(base64.b64encode(data).decode("ascii"), end="") -PY -""" - result = remote_host_result(host, script, f"read-{host['host']}-remote-file-chunk-{offset}") - output = str(result.get("output") or "") - if output.strip() == REMOTE_FILE_MISSING_MARKER: - raise RuntimeError(f"{host['host']} missing file: {path}") - chunks.append(base64.b64decode(output.strip() or "")) - - return b"".join(chunks).decode("utf-8") + # 远端 env / compose 文本现在统一走 SSH 直连读取。 + return ssh_read_remote_text(host, path) def write_remote_text(host: dict[str, Any], path: str, content: str) -> None: - # 把文本整体 base64 编码后再下发,避免 shell here-doc 被内容污染。 - encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") - quoted_path = shlex.quote(path) - script = f"""set -eu -FILE={quoted_path} -TMP="${{FILE}}.hyapp.$$" -mkdir -p "$(dirname "$FILE")" -printf '%s' {shlex.quote(encoded)} | base64 -d > "$TMP" -mv "$TMP" "$FILE" -""" - remote_host_result(host, script, f"write-{host['host']}-service-env") + # 远端文本覆盖同样走 SSH,避免继续依赖 TAT 输出长度和轮询结果。 + ssh_write_remote_text(host, path, content) def host_sha256(content: str) -> str: diff --git a/monitor/service_ops.py b/monitor/service_ops.py index 8875845..b39552a 100644 --- a/monitor/service_ops.py +++ b/monitor/service_ops.py @@ -27,7 +27,7 @@ from .nacos import ( list_instances, nacos_truthy, ) -from .tencent_tat import run_shell_command +from .ssh_remote import run_host_script def restartable_service_records() -> list[dict[str, Any]]: @@ -74,14 +74,13 @@ def service_records_by_name() -> dict[str, list[dict[str, Any]]]: def remote_command_result(service: dict[str, Any], script: str, command_name: str, timeout_seconds: int) -> dict[str, Any]: - # 单实例远端执行统一复用 TAT shell。 - result_map = run_shell_command( - [service["instanceId"]], + # 单实例远端执行现在统一复用 SSH shell。 + return run_host_script( + service, script, command_name=command_name, timeout_seconds=timeout_seconds, ) - return result_map[service["instanceId"]] def restart_service_instance(service: dict[str, Any]) -> dict[str, Any]: @@ -225,8 +224,8 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]: } restart_result = restart_service_instance(service) - step["tatStatus"] = restart_result.get("status") - step["tatOutput"] = str(restart_result.get("output") or "").strip()[:500] + step["remoteStatus"] = restart_result.get("status") + step["remoteOutput"] = str(restart_result.get("output") or "").strip()[:500] http_result = wait_http_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS) step["http"] = { diff --git a/monitor/service_release.py b/monitor/service_release.py index 08f3aef..d125d48 100644 --- a/monitor/service_release.py +++ b/monitor/service_release.py @@ -4,6 +4,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed # 镜像 tag 需要拼时间戳。 from datetime import datetime # 本地构建和 git worktree 都依赖标准库 subprocess / tempfile。 +import json import os from pathlib import Path import re @@ -21,6 +22,25 @@ from .compose_templates import ( update_local_service_image, ) from .config import ( + ADMIN_FRONTEND_BUILD_SCRIPT, + ADMIN_FRONTEND_COMPOSE_FILE, + ADMIN_FRONTEND_COMPOSE_SERVICE, + ADMIN_FRONTEND_DIST_DIR, + ADMIN_FRONTEND_ENABLED, + ADMIN_FRONTEND_HOST, + ADMIN_FRONTEND_INSTANCE_ID, + ADMIN_FRONTEND_IP, + ADMIN_FRONTEND_PUBLIC_URL, + ADMIN_FRONTEND_RELEASE_META_PATH, + ADMIN_FRONTEND_REMOTE_DIR, + ADMIN_FRONTEND_REPO_ROOT, + ADMIN_FRONTEND_REPO_URL, + ADMIN_FRONTEND_SERVICE_NAME, + ADMIN_FRONTEND_SITE_DIR, + ADMIN_FRONTEND_SSH_HOST, + ADMIN_FRONTEND_SSH_PORT, + ADMIN_FRONTEND_SSH_USE_SUDO, + ADMIN_FRONTEND_SSH_USER, BUILD_DOCKER_PLATFORM, GOLANG_REPO_ROOT, HARBOR_PASSWORD, @@ -36,6 +56,7 @@ from .config import ( SERVICE_BUILD_CACHE_ROOT, SERVICE_BUILD_TIMEOUT_SECONDS, SERVICE_PULL_TIMEOUT_SECONDS, + UPDATE_DEFAULT_ADMIN_GIT_REF, UPDATE_DEFAULT_GO_GIT_REF, UPDATE_DEFAULT_JAVA_GIT_REF, utc_now, @@ -47,13 +68,15 @@ from .service_ops import ( wait_http_ready, wait_java_registration_ready, ) -from .tencent_tat import run_shell_command +from .ssh_remote import copy_local_path_to_host, read_remote_text, run_host_script_checked, target_display, write_remote_text # Java 仓里当前支持 Harbor 更新的服务集合。 JAVA_BUILDABLE_SERVICES = ("auth", "gateway", "external", "console", "other", "live", "wallet", "order") # Go 仓目前只有主业务服务。 GOLANG_BUILDABLE_SERVICES = ("golang",) +# Admin 前端当前只接入一个发布目标。 +FRONTEND_BUILDABLE_SERVICES = (ADMIN_FRONTEND_SERVICE_NAME,) # Java 批量 Maven 构建时,需要映射回对应的启动模块。 JAVA_SERVICE_MODULES = { "auth": "rc-auth", @@ -82,6 +105,8 @@ SERVICE_IMAGE_REPOSITORY_SUFFIXES = { ProgressLogger = Callable[[str, str, str], None] # 私有 Maven 安装缓存统一放到 run 目录,避免混进 Git 工作区。 PRIVATE_MAVEN_CACHE_DIR = ROOT / "run" / "maven_private_cache" +# Admin 前端站点备份最多保留最近若干份。 +FRONTEND_SITE_BACKUP_KEEP = 3 def emit_progress(progress: ProgressLogger | None, stage: str, message: str, *, level: str = "info") -> None: @@ -137,7 +162,10 @@ def stream_log_file_lines( def buildable_service_names() -> list[str]: # 页面和接口统一按这里判定哪些服务允许“构建并更新”。 - return [*JAVA_BUILDABLE_SERVICES, *GOLANG_BUILDABLE_SERVICES] + names = [*JAVA_BUILDABLE_SERVICES, *GOLANG_BUILDABLE_SERVICES] + if frontend_release_enabled(): + names.extend(service_name for service_name in FRONTEND_BUILDABLE_SERVICES if str(service_name or "").strip()) + return names def normalize_service_names(service_names: list[str]) -> list[str]: @@ -147,11 +175,13 @@ def normalize_service_names(service_names: list[str]) -> list[str]: def service_kind(service_name: str) -> str: - # 更新链路目前只分 Java / Golang 两类仓库。 + # 更新链路目前分 Java / Golang / Frontend 三类仓库。 if service_name in JAVA_BUILDABLE_SERVICES: return "java" if service_name in GOLANG_BUILDABLE_SERVICES: return "golang" + if frontend_release_enabled() and service_name in FRONTEND_BUILDABLE_SERVICES: + return "frontend" raise RuntimeError(f"unsupported update service: {service_name}") @@ -165,6 +195,27 @@ def ensure_repo_root(repo_root: Path, label: str) -> Path: return resolved +def ensure_frontend_repo_root(*, progress: ProgressLogger | None = None) -> Path: + # Admin 前端允许第一次使用时按配置的 repo URL 自动 clone 到 deploy 机。 + repo_root = Path(ADMIN_FRONTEND_REPO_ROOT).expanduser().resolve() + if (repo_root / ".git").exists(): + return repo_root + + repo_url = str(ADMIN_FRONTEND_REPO_URL or "").strip() + if not repo_url: + raise RuntimeError("missing ADMIN_FRONTEND_REPO_URL in .env") + + repo_root.parent.mkdir(parents=True, exist_ok=True) + emit_progress(progress, "build.frontend.repo", f"初始化前端仓库:{repo_url} -> {repo_root}") + local_command_output( + ["git", "clone", repo_url, str(repo_root)], + cwd=repo_root.parent, + label="frontend clone repo", + timeout_seconds=900, + ) + return ensure_repo_root(repo_root, "frontend") + + def local_command_output( command: list[str], *, @@ -207,6 +258,34 @@ def tail_text(path: Path, max_bytes: int = 8192) -> str: return handle.read().decode("utf-8", errors="replace").strip() +def frontend_release_enabled() -> bool: + # Admin 前端需要仓库、主机和发布目录都齐备后才开放到页面。 + return bool( + ADMIN_FRONTEND_ENABLED + and str(ADMIN_FRONTEND_SERVICE_NAME or "").strip() + and str(ADMIN_FRONTEND_HOST or "").strip() + and str(ADMIN_FRONTEND_SITE_DIR or "").strip() + and (str(ADMIN_FRONTEND_SSH_HOST or "").strip() or str(ADMIN_FRONTEND_IP or "").strip()) + and (str(ADMIN_FRONTEND_REPO_URL or "").strip() or str(ADMIN_FRONTEND_REPO_ROOT or "").strip()) + ) + + +def frontend_host_record(service_name: str) -> dict[str, Any]: + # 当前只有 admin 前端走这条链路,后续再扩展时继续在这里分流。 + if not frontend_release_enabled() or service_name != ADMIN_FRONTEND_SERVICE_NAME: + raise RuntimeError(f"unsupported frontend service: {service_name}") + + return { + "host": ADMIN_FRONTEND_HOST, + "ip": ADMIN_FRONTEND_IP, + "instanceId": ADMIN_FRONTEND_INSTANCE_ID, + "sshHost": ADMIN_FRONTEND_SSH_HOST or ADMIN_FRONTEND_IP, + "sshUser": ADMIN_FRONTEND_SSH_USER, + "sshUseSudo": ADMIN_FRONTEND_SSH_USE_SUDO, + "sshPort": ADMIN_FRONTEND_SSH_PORT, + } + + def run_logged_command( command: list[str], *, @@ -527,6 +606,105 @@ def derive_image_tag(git_ref: str, commit: str, image_tag: str) -> str: return f"{sanitized_ref_fragment(git_ref)}-{commit[:12]}-{timestamp}" +def frontend_version_label(git_ref: str, commit: str) -> str: + # 前端发布结果统一显示成 ref@commit12,便于和镜像 tag 区分。 + resolved_ref = str(git_ref or "").strip() or UPDATE_DEFAULT_ADMIN_GIT_REF + return f"{resolved_ref}@{commit[:12]}" + + +def frontend_release_metadata(service_name: str) -> dict[str, Any]: + # 当前前端站点的已部署版本由远端元数据文件提供;缺失时允许返回空结构。 + host = frontend_host_record(service_name) + try: + raw = read_remote_text(host, ADMIN_FRONTEND_RELEASE_META_PATH, timeout_seconds=30).strip() + except Exception as exc: # noqa: BLE001 + return {"error": str(exc)} + + if not raw: + return {} + + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return {"error": f"invalid frontend release metadata on {target_display(host)}"} + + if not isinstance(payload, dict): + return {"error": f"invalid frontend release metadata on {target_display(host)}"} + return payload + + +def image_release_display(current_images: list[str]) -> dict[str, str]: + # 镜像型服务在表格里固定展示 tag / digest / 可复制的主镜像。 + primary_image = str((current_images or [""])[0] or "").strip() + if not primary_image: + return { + "currentLabel": "-", + "currentTitle": "-", + "detailLabel": "-", + "detailTitle": "-", + "copyValue": "", + } + + repository = image_repository_ref(primary_image) + meta = { + "repository": repository, + "tag": "", + "digest": "", + } + without_digest, _, digest_text = primary_image.partition("@") + meta["digest"] = digest_text or "" + last_slash = without_digest.rfind("/") + last_colon = without_digest.rfind(":") + if last_colon > last_slash: + meta["tag"] = without_digest[last_colon + 1 :] + + return { + "currentLabel": meta["tag"] or "-", + "currentTitle": "\n".join(current_images) if len(current_images) > 1 else primary_image, + "detailLabel": (meta["digest"][:12] + "...") if len(meta["digest"]) > 15 else (meta["digest"] or "-"), + "detailTitle": primary_image, + "copyValue": primary_image, + } + + +def frontend_release_display(service_name: str) -> dict[str, str]: + # 前端目标优先显示最近一次由监控面板写入的 gitRef / commit。 + metadata = frontend_release_metadata(service_name) + version = str(metadata.get("version") or "").strip() + commit = str(metadata.get("commit") or "").strip() + git_ref = str(metadata.get("gitRef") or "").strip() + deployed_at = str(metadata.get("deployedAt") or "").strip() + error = str(metadata.get("error") or "").strip() + + if version or commit or git_ref: + label = version or git_ref or commit[:12] + title_parts = [part for part in [version, git_ref, commit, deployed_at, ADMIN_FRONTEND_PUBLIC_URL] if part] + return { + "currentLabel": label, + "currentTitle": " | ".join(title_parts) if title_parts else label, + "detailLabel": commit[:12] if commit else "-", + "detailTitle": commit or deployed_at or label, + "copyValue": commit or git_ref, + } + + if error: + return { + "currentLabel": "未记录", + "currentTitle": error, + "detailLabel": "SSH", + "detailTitle": error, + "copyValue": "", + } + + return { + "currentLabel": "未记录", + "currentTitle": "当前站点还没有由 hy-app-monitor 写入过版本元数据", + "detailLabel": "-", + "detailTitle": "-", + "copyValue": "", + } + + def docker_digest(image_ref: str, *, cwd: Path) -> str: # push 完要拿 digest,后续 compose 一律按 digest 部署。 return local_command_output( @@ -850,6 +1028,81 @@ def build_golang_images( } +def build_frontend_bundle( + service_name: str, + git_ref: str, + *, + progress: ProgressLogger | None = None, +) -> dict[str, Any]: + # Admin 前端在 deploy 机做 pnpm install + build,最后产出静态 dist 目录。 + repo_root = ensure_frontend_repo_root(progress=progress) + resolved_ref = str(git_ref or "").strip() or UPDATE_DEFAULT_ADMIN_GIT_REF + emit_progress(progress, "build.frontend", f"刷新前端仓库 refs:{resolved_ref}") + fetch_repo_refs(repo_root, "frontend") + commit = resolve_repo_commit(repo_root, resolved_ref, "frontend") + emit_progress(progress, "build.frontend", f"Frontend ref 已解析:{resolved_ref} -> {commit[:12]}") + worktree_root = prepare_persistent_build_workspace( + repo_root, + resolved_ref, + commit, + "frontend", + progress=progress, + ) + + frontend_env = { + "CI": "true", + "NODE_OPTIONS": "--max-old-space-size=8192", + } + emit_progress(progress, "build.frontend", "准备 corepack / pnpm") + local_command_output( + ["corepack", "enable"], + cwd=worktree_root, + label="frontend corepack enable", + env=frontend_env, + timeout_seconds=120, + ) + emit_progress(progress, "build.frontend.install", "开始安装前端依赖") + run_logged_command( + ["corepack", "pnpm", "install", "--frozen-lockfile"], + cwd=worktree_root, + label="frontend pnpm install", + env=frontend_env, + timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS, + progress_stage="build.frontend.install", + progress=progress, + ) + emit_progress(progress, "build.frontend.build", f"开始构建静态站点:{ADMIN_FRONTEND_BUILD_SCRIPT}") + run_logged_command( + ["corepack", "pnpm", "run", ADMIN_FRONTEND_BUILD_SCRIPT], + cwd=worktree_root, + label="frontend pnpm build", + env=frontend_env, + timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS, + progress_stage="build.frontend.build", + progress=progress, + ) + + artifact_root = (worktree_root / ADMIN_FRONTEND_DIST_DIR).resolve() + if not artifact_root.exists(): + raise RuntimeError(f"frontend dist missing after build: {artifact_root}") + if not artifact_root.is_dir(): + raise RuntimeError(f"frontend dist is not a directory: {artifact_root}") + + version = frontend_version_label(resolved_ref, commit) + emit_progress(progress, "build.frontend", f"前端静态产物已生成:{artifact_root}") + return { + "kind": "frontend", + "repoRoot": str(repo_root), + "repoUrl": str(ADMIN_FRONTEND_REPO_URL or "").strip(), + "gitRef": resolved_ref, + "commit": commit, + "imageTag": version, + "services": [service_name], + "artifactPath": str(artifact_root), + "serviceArtifacts": {service_name: str(artifact_root)}, + } + + def build_service_images( service_names: list[str], *, @@ -937,16 +1190,12 @@ def render_remote_login_lines() -> list[str]: def remote_host_result(host_entry: dict[str, Any], script: str, command_name: str, timeout_seconds: int) -> dict[str, Any]: # 统一执行单宿主机命令,便于预拉镜像和其他操作复用。 - result_map = run_shell_command( - [host_entry["instanceId"]], + return run_host_script_checked( + host_entry, script, command_name=command_name, timeout_seconds=timeout_seconds, ) - result = result_map[host_entry["instanceId"]] - if result.get("status") != "SUCCESS": - raise RuntimeError(f"{host_entry['host']} {command_name} failed: {result.get('status')}") - return result def prepull_host_images(host_entry: dict[str, Any]) -> dict[str, Any]: @@ -967,6 +1216,188 @@ def prepull_host_images(host_entry: dict[str, Any]) -> dict[str, Any]: } +def prepare_frontend_remote_tmp(host: dict[str, Any]) -> str: + # 先在远端站点目录旁边建一份临时上传目录,避免直接覆盖线上内容。 + site_dir = str(ADMIN_FRONTEND_SITE_DIR or "").rstrip("/") + parent_dir = str(Path(site_dir).parent) + script = f"""set -eu +PARENT_DIR={shlex.quote(parent_dir)} +mkdir -p "$PARENT_DIR" +mktemp -d "$PARENT_DIR/.hy-app-monitor-site.XXXXXX" +""" + result = run_host_script_checked( + host, + script, + command_name=f"frontend-tmp-{host['host']}", + timeout_seconds=30, + ) + tmp_dir = str(result.get("output") or "").strip().splitlines()[-1].strip() + if not tmp_dir: + raise RuntimeError(f"failed to create frontend temp dir on {target_display(host)}") + return tmp_dir + + +def activate_frontend_site( + host: dict[str, Any], + *, + tmp_dir: str, + backup_stamp: str, +) -> dict[str, str]: + # 站点目录保留原 inode,只在目录内替换文件,避免 bind mount 指向失效。 + site_dir = str(ADMIN_FRONTEND_SITE_DIR or "").rstrip("/") + remote_dir = str(ADMIN_FRONTEND_REMOTE_DIR or "").rstrip("/") + compose_file = str(ADMIN_FRONTEND_COMPOSE_FILE or "").rstrip("/") + compose_service = str(ADMIN_FRONTEND_COMPOSE_SERVICE or "").strip() or "caddy" + backup_root = str(Path(site_dir).parent / ".hy-app-monitor-site-backups") + keep_count = max(1, int(FRONTEND_SITE_BACKUP_KEEP)) + script = f"""set -eu +SITE_DIR={shlex.quote(site_dir)} +TMP_DIR={shlex.quote(tmp_dir)} +BACKUP_ROOT={shlex.quote(backup_root)} +BACKUP_DIR="$BACKUP_ROOT/{shlex.quote(backup_stamp)}" +REMOTE_DIR={shlex.quote(remote_dir)} +COMPOSE_FILE={shlex.quote(compose_file)} +COMPOSE_SERVICE={shlex.quote(compose_service)} +KEEP_COUNT={keep_count} + +mkdir -p "$SITE_DIR" "$BACKUP_ROOT" +if [ -d "$SITE_DIR" ]; then + mkdir -p "$BACKUP_DIR" + cp -a "$SITE_DIR"/. "$BACKUP_DIR"/ 2>/dev/null || true +fi +find "$SITE_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {{}} + +cp -a "$TMP_DIR"/. "$SITE_DIR"/ +rm -rf "$TMP_DIR" + +if [ -d "$BACKUP_ROOT" ]; then + ls -1dt "$BACKUP_ROOT"/* 2>/dev/null | awk "NR>$KEEP_COUNT" | xargs -r rm -rf +fi + +cd "$REMOTE_DIR" +docker compose -f "$COMPOSE_FILE" up -d "$COMPOSE_SERVICE" >/dev/null +docker compose -f "$COMPOSE_FILE" ps "$COMPOSE_SERVICE" || true +""" + result = run_host_script_checked( + host, + script, + command_name=f"frontend-activate-{host['host']}", + timeout_seconds=max(SERVICE_BUILD_TIMEOUT_SECONDS, 180), + ) + return { + "backupPath": f"{backup_root}/{backup_stamp}", + "output": str(result.get("output") or "").strip()[-1200:], + } + + +def deploy_frontend_site( + service_name: str, + *, + git_ref: str, + progress: ProgressLogger | None = None, +) -> dict[str, Any]: + # 前端发布链路:构建 dist -> SCP 到目标机 -> 原地切换站点内容 -> 写版本元数据。 + host = frontend_host_record(service_name) + build_result = build_frontend_bundle(service_name, git_ref, progress=progress) + artifact_path = Path(str(build_result.get("artifactPath") or "")).expanduser().resolve() + version = str(build_result.get("imageTag") or "").strip() + backup_stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + step_stage = f"frontend.{host['host']}.{service_name}" + started_at = time.time() + step: dict[str, Any] = { + "service": service_name, + "host": host["host"], + "ip": host["ip"], + "kind": "frontend", + "image": version, + "status": "running", + } + tmp_dir = "" + + try: + tmp_dir = prepare_frontend_remote_tmp(host) + emit_progress(progress, step_stage, f"上传静态产物:{artifact_path} -> {target_display(host)}:{tmp_dir}") + copy_local_path_to_host( + host, + f"{artifact_path}/.", + f"{tmp_dir}/", + recursive=True, + timeout_seconds=max(SERVICE_BUILD_TIMEOUT_SECONDS, 180), + ) + emit_progress(progress, step_stage, "上传完成,开始切换站点目录") + activate_result = activate_frontend_site(host, tmp_dir=tmp_dir, backup_stamp=backup_stamp) + except Exception as exc: # noqa: BLE001 + # 上传或切换失败时尽量收尾远端临时目录,避免后续发布被历史垃圾影响。 + cleanup_script = f"""set -eu +TMP_DIR={shlex.quote(tmp_dir)} +if [ -d "$TMP_DIR" ]; then + rm -rf "$TMP_DIR" +fi +""" + if tmp_dir: + try: + run_host_script_checked( + host, + cleanup_script, + command_name=f"frontend-cleanup-{host['host']}", + timeout_seconds=30, + ) + except Exception: # noqa: BLE001 + pass + step["status"] = "failed" + step["error"] = str(exc) + step["durationSeconds"] = round(time.time() - started_at, 2) + emit_progress(progress, step_stage, f"前端发布失败:{step['error']}", level="error") + return { + "ok": False, + "updatedAt": utc_now(), + "services": [service_name], + "build": [build_result], + "templates": [], + "preparedHosts": [], + "steps": [step], + "error": step["error"], + } + + deployed_at = utc_now() + metadata = { + "service": service_name, + "kind": "frontend", + "repoUrl": str(ADMIN_FRONTEND_REPO_URL or "").strip(), + "repoRoot": str(ADMIN_FRONTEND_REPO_ROOT), + "gitRef": str(build_result.get("gitRef") or "").strip(), + "commit": str(build_result.get("commit") or "").strip(), + "version": version, + "publicUrl": ADMIN_FRONTEND_PUBLIC_URL, + "siteDir": ADMIN_FRONTEND_SITE_DIR, + "composeFile": ADMIN_FRONTEND_COMPOSE_FILE, + "deployedAt": deployed_at, + "backupPath": activate_result["backupPath"], + "deployedBy": "hy-app-monitor", + } + write_remote_text( + host, + ADMIN_FRONTEND_RELEASE_META_PATH, + json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", + timeout_seconds=30, + ) + emit_progress(progress, step_stage, f"版本元数据已写入:{ADMIN_FRONTEND_RELEASE_META_PATH}") + step["status"] = "success" + step["remoteStatus"] = "SUCCESS" + step["remoteOutput"] = activate_result["output"] + step["durationSeconds"] = round(time.time() - started_at, 2) + + return { + "ok": True, + "updatedAt": deployed_at, + "services": [service_name], + "build": [build_result], + "templates": [], + "preparedHosts": [], + "steps": [step], + "message": "service update complete", + } + + def build_update_targets_payload() -> dict[str, Any]: # 页面需要展示当前可更新服务、宿主机,以及本地构建所依赖的仓路径。 records_map = service_records_by_name() @@ -982,12 +1413,18 @@ def build_update_targets_payload() -> dict[str, Any]: image_ref = str(((model.get("services") or {}).get(service_name) or {}).get("image") or "").strip() if image_ref and image_ref not in current_images: current_images.append(image_ref) + display = image_release_display(current_images) items.append( { "service": service_name, "kind": service_kind(service_name), "repository": service_repository_ref(service_name), + "currentLabel": display["currentLabel"], + "currentTitle": display["currentTitle"], + "detailLabel": display["detailLabel"], + "detailTitle": display["detailTitle"], + "copyValue": display["copyValue"], "currentImages": current_images, "hosts": [ { @@ -1002,12 +1439,39 @@ def build_update_targets_payload() -> dict[str, Any]: } ) + if frontend_release_enabled(): + frontend_service = ADMIN_FRONTEND_SERVICE_NAME + display = frontend_release_display(frontend_service) + items.append( + { + "service": frontend_service, + "kind": "frontend", + "repository": str(ADMIN_FRONTEND_REPO_URL or ADMIN_FRONTEND_REPO_ROOT), + "currentLabel": display["currentLabel"], + "currentTitle": display["currentTitle"], + "detailLabel": display["detailLabel"], + "detailTitle": display["detailTitle"], + "copyValue": display["copyValue"], + "currentImages": [], + "hosts": [ + { + "host": ADMIN_FRONTEND_HOST, + "ip": ADMIN_FRONTEND_IP, + "instanceId": ADMIN_FRONTEND_INSTANCE_ID, + "port": 0, + "path": ADMIN_FRONTEND_SITE_DIR, + } + ], + } + ) + return { "ok": True, "updatedAt": utc_now(), "defaults": { "javaGitRef": UPDATE_DEFAULT_JAVA_GIT_REF, "goGitRef": UPDATE_DEFAULT_GO_GIT_REF, + "adminGitRef": UPDATE_DEFAULT_ADMIN_GIT_REF, "dockerPlatform": BUILD_DOCKER_PLATFORM, }, "builder": { @@ -1015,12 +1479,15 @@ def build_update_targets_payload() -> dict[str, Any]: "harborProject": HARBOR_PROJECT, "javaRepoRoot": str(Path(JAVA_REPO_ROOT).expanduser()), "golangRepoRoot": str(Path(GOLANG_REPO_ROOT).expanduser()), + "frontendRepoRoot": str(Path(ADMIN_FRONTEND_REPO_ROOT).expanduser()), + "frontendRepoUrl": str(ADMIN_FRONTEND_REPO_URL or "").strip(), + "frontendPublicUrl": ADMIN_FRONTEND_PUBLIC_URL, }, "items": items, } -def deploy_updated_services_payload( +def deploy_runtime_services_payload( service_names: list[str], *, java_git_ref: str, @@ -1029,7 +1496,7 @@ def deploy_updated_services_payload( skip_maven_build: bool, progress: ProgressLogger | None = None, ) -> dict[str, Any]: - # 这是“服务更新”主链路:本地 build -> Harbor digest -> 完整 compose -> 预拉 -> 滚动重建。 + # Java / Go 服务沿用镜像构建、完整 compose、预拉、滚动重建这条链路。 normalized = normalize_service_names(service_names) if not normalized: raise RuntimeError("services are required") @@ -1115,12 +1582,12 @@ def deploy_updated_services_payload( try: emit_progress(progress, step_stage, f"开始滚动更新:{service['host']} / {service_name}") restart_result = restart_service_instance(service) - step["tatStatus"] = restart_result.get("status") - step["tatOutput"] = str(restart_result.get("output") or "").strip()[:500] + step["remoteStatus"] = restart_result.get("status") + step["remoteOutput"] = str(restart_result.get("output") or "").strip()[:500] emit_progress( progress, step_stage, - f"TAT 重建完成:{restart_result.get('status')} {short_text(step['tatOutput'])}", + f"远端重建完成:{restart_result.get('status')} {short_text(step['remoteOutput'])}", ) http_result = wait_http_ready(service, timeout_seconds=ROLLING_RESTART_TIMEOUT_SECONDS) @@ -1184,7 +1651,6 @@ def deploy_updated_services_payload( clear_operation_caches() emit_progress(progress, "finish", "已清理监控缓存") - emit_progress(progress, "finish", "全部服务更新完成") return { "ok": True, "updatedAt": utc_now(), @@ -1195,3 +1661,87 @@ def deploy_updated_services_payload( "steps": steps, "message": "service update complete", } + + +def merge_update_results(service_names: list[str], results: list[dict[str, Any]]) -> dict[str, Any]: + # 一个任务里允许同时带镜像服务和前端服务,这里统一把结果拼回页面结构。 + merged = { + "ok": True, + "updatedAt": utc_now(), + "services": list(service_names), + "build": [], + "templates": [], + "preparedHosts": [], + "steps": [], + "message": "service update complete", + } + + for result in results: + if not result: + continue + merged["ok"] = merged["ok"] and bool(result.get("ok")) + merged["build"].extend(list(result.get("build") or result.get("builds") or [])) + merged["templates"].extend(list(result.get("templates") or [])) + merged["preparedHosts"].extend(list(result.get("preparedHosts") or [])) + merged["steps"].extend(list(result.get("steps") or [])) + if not result.get("ok"): + merged["error"] = str(result.get("error") or "service update failed").strip() + merged["message"] = str(result.get("message") or merged["error"] or "service update failed").strip() + return merged + if str(result.get("message") or "").strip(): + merged["message"] = str(result.get("message") or "").strip() + + return merged + + +def deploy_updated_services_payload( + service_names: list[str], + *, + java_git_ref: str, + go_git_ref: str, + admin_git_ref: str, + image_tag: str, + skip_maven_build: bool, + progress: ProgressLogger | None = None, +) -> dict[str, Any]: + # 顶层按服务类型拆分:容器服务走 Harbor/compose,Admin 前端走 pnpm build + SSH 发布。 + normalized = normalize_service_names(service_names) + if not normalized: + raise RuntimeError("services are required") + + unsupported = [service_name for service_name in normalized if service_name not in buildable_service_names()] + if unsupported: + raise RuntimeError(f"unsupported update services: {', '.join(unsupported)}") + + runtime_services = [service_name for service_name in normalized if service_kind(service_name) in {"java", "golang"}] + frontend_services = [service_name for service_name in normalized if service_kind(service_name) == "frontend"] + records_map = service_records_by_name() + + for service_name in runtime_services: + if service_name not in records_map: + raise RuntimeError(f"service not found in runtime topology: {service_name}") + + emit_progress(progress, "prepare", f"准备更新服务:{', '.join(normalized)}") + results: list[dict[str, Any]] = [] + + if runtime_services: + runtime_result = deploy_runtime_services_payload( + runtime_services, + java_git_ref=java_git_ref, + go_git_ref=go_git_ref, + image_tag=image_tag, + skip_maven_build=skip_maven_build, + progress=progress, + ) + results.append(runtime_result) + if not runtime_result.get("ok"): + return merge_update_results(normalized, results) + + for service_name in frontend_services: + frontend_result = deploy_frontend_site(service_name, git_ref=admin_git_ref, progress=progress) + results.append(frontend_result) + if not frontend_result.get("ok"): + return merge_update_results(normalized, results) + + emit_progress(progress, "finish", "全部服务更新完成") + return merge_update_results(normalized, results) diff --git a/monitor/service_update_jobs.py b/monitor/service_update_jobs.py index d543e13..76b275b 100644 --- a/monitor/service_update_jobs.py +++ b/monitor/service_update_jobs.py @@ -156,6 +156,7 @@ def create_job_payload( *, java_git_ref: str, go_git_ref: str, + admin_git_ref: str, image_tag: str, skip_maven_build: bool, ) -> dict[str, Any]: @@ -180,6 +181,7 @@ def create_job_payload( "services": normalized, "javaGitRef": str(java_git_ref or "").strip(), "goGitRef": str(go_git_ref or "").strip(), + "adminGitRef": str(admin_git_ref or "").strip(), "imageTag": str(image_tag or "").strip(), "skipMavenBuild": bool(skip_maven_build), }, @@ -284,6 +286,7 @@ def run_update_job( *, java_git_ref: str, go_git_ref: str, + admin_git_ref: str, image_tag: str, skip_maven_build: bool, ) -> None: @@ -296,6 +299,7 @@ def run_update_job( service_names, java_git_ref=java_git_ref, go_git_ref=go_git_ref, + admin_git_ref=admin_git_ref, image_tag=image_tag, skip_maven_build=skip_maven_build, progress=job_progress_logger(operation_id), @@ -324,6 +328,7 @@ def start_update_job( *, java_git_ref: str, go_git_ref: str, + admin_git_ref: str, image_tag: str, skip_maven_build: bool, ) -> dict[str, Any]: @@ -332,6 +337,7 @@ def start_update_job( service_names, java_git_ref=java_git_ref, go_git_ref=go_git_ref, + admin_git_ref=admin_git_ref, image_tag=image_tag, skip_maven_build=skip_maven_build, ) @@ -344,6 +350,7 @@ def start_update_job( "service_names": list(service_names), "java_git_ref": java_git_ref, "go_git_ref": go_git_ref, + "admin_git_ref": admin_git_ref, "image_tag": image_tag, "skip_maven_build": skip_maven_build, }, diff --git a/ops/bootstrap_ssh_via_tat.py b/ops/bootstrap_ssh_via_tat.py new file mode 100644 index 0000000..fdabe2a --- /dev/null +++ b/ops/bootstrap_ssh_via_tat.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import shlex +import site +import sys +import time +from pathlib import Path +from typing import Callable, TypeVar + + +# 项目根目录,后面需要从这里读取 .env 和 targets.json。 +ROOT = Path(__file__).resolve().parents[1] +# 默认 .env 路径。 +ENV_FILE = Path(os.environ.get("APP_ENV_FILE", ROOT / ".env")).expanduser() +# 泛型只用于 invoke 的返回值标注。 +T = TypeVar("T") + + +def load_project_venv() -> None: + # 如果当前本来就在项目虚拟环境里运行,就不需要重复处理。 + if str(ROOT / ".venv") in sys.prefix: + return + + # 遍历项目 .venv 下可能存在的 site-packages 目录。 + for candidate in (ROOT / ".venv" / "lib").glob("python*/site-packages"): + # 把依赖目录加入解释器搜索路径。 + site.addsitedir(str(candidate)) + + +# 先注入项目虚拟环境,再导入腾讯云 SDK。 +load_project_venv() + +# 腾讯云凭据对象。 +from tencentcloud.common import credential +# 腾讯云 SDK 统一异常类型。 +from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException +# 客户端公共配置。 +from tencentcloud.common.profile.client_profile import ClientProfile +# HTTP endpoint 配置。 +from tencentcloud.common.profile.http_profile import HttpProfile +# CVM 用来解析实例私网 IP 和安全组。 +from tencentcloud.cvm.v20170312 import cvm_client, models as cvm_models +# TAT 用来做一次性带外引导。 +from tencentcloud.tat.v20201028 import models as tat_models, tat_client +# VPC 用来写安全组规则。 +from tencentcloud.vpc.v20170312 import models as vpc_models, vpc_client + + +def load_env_file(path: Path) -> None: + # .env 文件不存在时允许继续使用外部环境变量。 + if not path.exists(): + return + + # 逐行解析 KEY=VALUE。 + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + if value.startswith("'") and value.endswith("'"): + value = value[1:-1] + os.environ.setdefault(key, value) + + +def env_truthy(name: str, default: bool = False) -> bool: + # 兼容 .env 里常见的布尔字符串写法。 + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +# 先加载项目根目录的 .env。 +load_env_file(ENV_FILE) + +# 默认 deploy 实例 ID。 +DEFAULT_DEPLOY_INSTANCE_ID = os.environ.get("DEPLOY_INSTANCE_ID", "").strip() +# 默认区域。 +DEFAULT_REGION = os.environ.get("DEPLOY_REGION", "").strip() +# 默认监控目标文件。 +TARGETS_FILE = Path(os.environ.get("TARGETS_FILE", ROOT / "config" / "targets.json")).expanduser() +# deploy 机上生成的专用 SSH 私钥路径。 +DEFAULT_REMOTE_SSH_KEY_PATH = os.environ.get("SSH_PRIVATE_KEY_PATH", "/opt/hy-app-monitor/run/ssh/id_ed25519").strip() +# 默认远端 SSH 端口。 +DEFAULT_SSH_PORT = int(os.environ.get("SSH_PORT", "22")) +# 默认远端 SSH 用户。 +DEFAULT_SSH_USER = os.environ.get("SSH_USER", "root").strip() or "root" +# 独立 admin 前端目标机配置;配置齐备时会一并加入 SSH 引导。 +ADMIN_FRONTEND_ENABLED = env_truthy("ADMIN_FRONTEND_ENABLED", False) +ADMIN_FRONTEND_HOST = os.environ.get("ADMIN_FRONTEND_HOST", "").strip() +ADMIN_FRONTEND_IP = os.environ.get("ADMIN_FRONTEND_IP", "").strip() +ADMIN_FRONTEND_INSTANCE_ID = os.environ.get("ADMIN_FRONTEND_INSTANCE_ID", "").strip() + + +def parse_args() -> argparse.Namespace: + # 默认对 targets.json 里的所有实例做 SSH 引导。 + parser = argparse.ArgumentParser(description="bootstrap deploy->target ssh connectivity via TAT") + parser.add_argument("--hosts", nargs="*", default=[], help="target host names, default all hosts from targets.json") + parser.add_argument("--deploy-instance-id", default=DEFAULT_DEPLOY_INSTANCE_ID, help="deploy machine instance id") + parser.add_argument("--region", default=DEFAULT_REGION, help="tencent cloud region") + parser.add_argument("--ssh-user", default=DEFAULT_SSH_USER, help="remote ssh login user, default root") + parser.add_argument("--ssh-port", type=int, default=DEFAULT_SSH_PORT, help="remote ssh port") + parser.add_argument( + "--remote-key-path", + default=DEFAULT_REMOTE_SSH_KEY_PATH, + help="private key path generated on deploy machine", + ) + parser.add_argument( + "--deploy-ip", + default="", + help="override deploy private ip used in security group and firewall rules", + ) + return parser.parse_args() + + +def env_required(name: str) -> str: + # 读取环境变量并去掉两端空白。 + value = os.environ.get(name, "").strip() + if not value: + raise SystemExit(f"missing env: {name}") + return value + + +def build_cvm(secret_id: str, secret_key: str, region: str) -> cvm_client.CvmClient: + # 构造腾讯云凭据对象。 + cred = credential.Credential(secret_id, secret_key) + return cvm_client.CvmClient( + cred, + region, + ClientProfile(httpProfile=HttpProfile(endpoint="cvm.tencentcloudapi.com")), + ) + + +def build_tat(secret_id: str, secret_key: str, region: str) -> tat_client.TatClient: + # 构造腾讯云凭据对象。 + cred = credential.Credential(secret_id, secret_key) + return tat_client.TatClient( + cred, + region, + ClientProfile(httpProfile=HttpProfile(endpoint="tat.tencentcloudapi.com")), + ) + + +def build_vpc(secret_id: str, secret_key: str, region: str) -> vpc_client.VpcClient: + # 构造腾讯云凭据对象。 + cred = credential.Credential(secret_id, secret_key) + return vpc_client.VpcClient( + cred, + region, + ClientProfile(httpProfile=HttpProfile(endpoint="vpc.tencentcloudapi.com")), + ) + + +def invoke(action: Callable[[], T], label: str, retries: int = 4, delay_seconds: int = 2) -> T: + # 保存最后一次异常,方便重试结束后抛出。 + last_error: Exception | None = None + for attempt in range(1, retries + 1): + try: + return action() + except TencentCloudSDKException as exc: + last_error = exc + if attempt == retries: + break + time.sleep(delay_seconds * attempt) + + if last_error is not None: + raise last_error + raise RuntimeError(f"{label} failed without error") + + +def describe_instances(client: cvm_client.CvmClient, instance_ids: list[str]) -> dict[str, dict[str, object]]: + # 一次性把 deploy 和目标机实例详情取回来,减少 API 往返。 + req = cvm_models.DescribeInstancesRequest() + req.from_json_string(json.dumps({"InstanceIds": instance_ids})) + resp = json.loads(invoke(lambda: client.DescribeInstances(req), "DescribeInstances").to_json_string()) + items = resp.get("InstanceSet") or [] + return { + str(item.get("InstanceId") or ""): { + "privateIp": str((item.get("PrivateIpAddresses") or [""])[0] or "").strip(), + "securityGroupIds": [str(group_id) for group_id in (item.get("SecurityGroupIds") or []) if str(group_id)], + "instanceName": str(item.get("InstanceName") or "").strip(), + } + for item in items + } + + +def load_target_hosts(host_names: list[str]) -> list[dict[str, str]]: + # 读取 targets.json,按 host 名过滤后返回唯一实例列表。 + if not TARGETS_FILE.exists(): + raise SystemExit(f"targets file not found: {TARGETS_FILE}") + payload = json.loads(TARGETS_FILE.read_text(encoding="utf-8")) + configured = payload.get("hosts") or [] + selected = {str(name).strip() for name in host_names if str(name).strip()} + items: list[dict[str, str]] = [] + seen_instance_ids: set[str] = set() + + for host in configured: + host_name = str(host.get("name") or "").strip() + instance_id = str(host.get("instanceId") or "").strip() + if not host_name or not instance_id: + continue + if selected and host_name not in selected: + continue + if instance_id in seen_instance_ids: + continue + seen_instance_ids.add(instance_id) + items.append( + { + "host": host_name, + "ip": str(host.get("ip") or "").strip(), + "instanceId": instance_id, + } + ) + + # Admin 前端机器不在 targets.json 时,也允许通过 .env 把它并进同一轮 SSH 引导。 + if ADMIN_FRONTEND_ENABLED and ADMIN_FRONTEND_HOST and ADMIN_FRONTEND_INSTANCE_ID: + if (not selected) or ADMIN_FRONTEND_HOST in selected: + if ADMIN_FRONTEND_INSTANCE_ID not in seen_instance_ids: + seen_instance_ids.add(ADMIN_FRONTEND_INSTANCE_ID) + items.append( + { + "host": ADMIN_FRONTEND_HOST, + "ip": ADMIN_FRONTEND_IP, + "instanceId": ADMIN_FRONTEND_INSTANCE_ID, + } + ) + return items + + +def render_keygen_script(remote_key_path: str) -> str: + # deploy 机上只生成一次专用 SSH key,后续运行链路直接复用。 + return f"""set -euo pipefail +KEY_PATH={json.dumps(remote_key_path)} +KEY_DIR="$(dirname "$KEY_PATH")" +mkdir -p "$KEY_DIR" +chmod 700 "$KEY_DIR" +if [ ! -f "$KEY_PATH" ]; then + ssh-keygen -t ed25519 -N '' -C 'hy-app-monitor-deploy' -f "$KEY_PATH" >/dev/null +fi +touch "$KEY_DIR/known_hosts" +chmod 600 "$KEY_PATH" "$KEY_PATH.pub" "$KEY_DIR/known_hosts" +cat "$KEY_PATH.pub" +""" + + +def render_target_bootstrap_script(public_key: str, deploy_ip: str, ssh_user: str, ssh_port: int) -> str: + # 目标机侧只做 SSH 基础设施准备,不夹带业务运维逻辑。 + quoted_key = shlex.quote(public_key) + quoted_ip = shlex.quote(deploy_ip) + quoted_user = shlex.quote(ssh_user) + root_login = "yes" if ssh_user == "root" else "no" + + if ssh_user == "root": + principal_block = """ +HOME_DIR=/root +install -d -m 700 "$HOME_DIR/.ssh" +touch "$HOME_DIR/.ssh/authorized_keys" +chmod 600 "$HOME_DIR/.ssh/authorized_keys" +grep -qxF "$PUBKEY" "$HOME_DIR/.ssh/authorized_keys" || printf '%s\\n' "$PUBKEY" >> "$HOME_DIR/.ssh/authorized_keys" +""" + else: + principal_block = """ +if ! id -u "$SSH_USER" >/dev/null 2>&1; then + useradd -m -s /bin/bash "$SSH_USER" +fi +if getent group docker >/dev/null 2>&1; then + usermod -aG docker "$SSH_USER" >/dev/null 2>&1 || true +fi +HOME_DIR="$(getent passwd "$SSH_USER" | cut -d: -f6)" +install -d -o "$SSH_USER" -g "$SSH_USER" -m 700 "$HOME_DIR/.ssh" +touch "$HOME_DIR/.ssh/authorized_keys" +chown "$SSH_USER":"$SSH_USER" "$HOME_DIR/.ssh/authorized_keys" +chmod 600 "$HOME_DIR/.ssh/authorized_keys" +grep -qxF "$PUBKEY" "$HOME_DIR/.ssh/authorized_keys" || printf '%s\\n' "$PUBKEY" >> "$HOME_DIR/.ssh/authorized_keys" +chown "$SSH_USER":"$SSH_USER" "$HOME_DIR/.ssh/authorized_keys" +printf '%s\\n' "$SSH_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/hy-app-monitor-ssh-user +chmod 440 /etc/sudoers.d/hy-app-monitor-ssh-user +""" + + return f"""set -euo pipefail +PUBKEY={quoted_key} +DEPLOY_IP={quoted_ip} +SSH_USER={quoted_user} +SSH_PORT={int(ssh_port)} +DEPLOY_CIDR="${{DEPLOY_IP}}/32" + +if ! command -v sshd >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -y >/dev/null 2>&1 || true + apt-get install -y openssh-server >/dev/null + elif command -v dnf >/dev/null 2>&1; then + dnf install -y openssh-server >/dev/null + elif command -v yum >/dev/null 2>&1; then + yum install -y openssh-server >/dev/null + else + echo "openssh-server unavailable" >&2 + exit 1 + fi +fi + +{principal_block} + +mkdir -p /etc/ssh/sshd_config.d +if ! grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\\.d/\\*\\.conf' /etc/ssh/sshd_config; then + printf '\\nInclude /etc/ssh/sshd_config.d/*.conf\\n' >> /etc/ssh/sshd_config +fi + +cat > /etc/ssh/sshd_config.d/hy-app-monitor.conf </dev/null 2>&1; then + firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=${{DEPLOY_CIDR}} port protocol=tcp port=${{SSH_PORT}} accept" >/dev/null 2>&1 || true + firewall-cmd --reload >/dev/null 2>&1 || true +fi + +if command -v ufw >/dev/null 2>&1; then + ufw allow from "${{DEPLOY_IP}}" to any port "${{SSH_PORT}}" proto tcp >/dev/null 2>&1 || true +fi + +if command -v iptables >/dev/null 2>&1; then + iptables -C INPUT -s "${{DEPLOY_CIDR}}" -p tcp --dport "${{SSH_PORT}}" -j ACCEPT >/dev/null 2>&1 || iptables -I INPUT -s "${{DEPLOY_CIDR}}" -p tcp --dport "${{SSH_PORT}}" -j ACCEPT || true +fi + +if command -v sshd >/dev/null 2>&1; then + sshd -t +fi + +if systemctl list-unit-files sshd.service >/dev/null 2>&1; then + systemctl enable sshd >/dev/null 2>&1 || true + systemctl restart sshd +elif systemctl list-unit-files ssh.service >/dev/null 2>&1; then + systemctl enable ssh >/dev/null 2>&1 || true + systemctl restart ssh +else + echo "ssh systemd service not found" >&2 + exit 1 +fi + +echo "ssh_bootstrap_ok user=${{SSH_USER}} port=${{SSH_PORT}}" +""" + + +def run_tat_script(client: tat_client.TatClient, instance_id: str, script: str, timeout_seconds: int = 600) -> str: + # 创建执行命令请求。 + req = tat_models.RunCommandRequest() + req.from_json_string( + json.dumps( + { + "CommandName": "hy-app-monitor-bootstrap-ssh", + "CommandType": "SHELL", + "Content": base64.b64encode(script.encode("utf-8")).decode("ascii"), + "InstanceIds": [instance_id], + "Timeout": timeout_seconds, + "WorkingDirectory": "/root", + } + ) + ) + + # 发起命令执行。 + resp = json.loads(invoke(lambda: client.RunCommand(req), "RunCommand").to_json_string()) + invocation_id = resp["InvocationId"] + deadline = time.time() + timeout_seconds + 120 + + # 轮询远端执行结果。 + while time.time() < deadline: + time.sleep(3) + query = tat_models.DescribeInvocationTasksRequest() + query.from_json_string( + json.dumps( + { + "HideOutput": False, + "Limit": 20, + "Filters": [{"Name": "invocation-id", "Values": [invocation_id]}], + } + ) + ) + data = json.loads(invoke(lambda: client.DescribeInvocationTasks(query), "DescribeInvocationTasks").to_json_string()) + tasks = data.get("InvocationTaskSet") or [] + if not tasks: + continue + task = tasks[0] + status = task.get("TaskStatus") or "" + if status in {"SUCCESS", "FAILED", "TIMEOUT", "CANCELLED", "START_FAILED"}: + output = base64.b64decode(task.get("TaskResult", {}).get("Output", "")).decode("utf-8", errors="replace") + if status != "SUCCESS": + raise SystemExit(output or f"TAT task failed: {status}") + return output + + raise SystemExit("TAT task timed out") + + +def ensure_security_group_rule( + client: vpc_client.VpcClient, + security_group_id: str, + *, + deploy_ip: str, + ssh_port: int, +) -> str: + # 安全组只允许 deploy 私网 IP 访问 22,避免直接对整段网开放 SSH。 + req = vpc_models.CreateSecurityGroupPoliciesRequest() + req.from_json_string( + json.dumps( + { + "SecurityGroupId": security_group_id, + "SecurityGroupPolicySet": { + "Ingress": [ + { + "Protocol": "TCP", + "Port": str(ssh_port), + "CidrBlock": f"{deploy_ip}/32", + "Action": "ACCEPT", + "PolicyDescription": f"hy-app-monitor ssh {deploy_ip}", + } + ] + }, + } + ) + ) + + try: + invoke(lambda: client.CreateSecurityGroupPolicies(req), "CreateSecurityGroupPolicies") + return "created" + except TencentCloudSDKException as exc: + if "Duplicate" in str(exc) or "already exists" in str(exc): + return "exists" + raise + + +def prepare_deploy_public_key( + client: tat_client.TatClient, + deploy_instance_id: str, + remote_key_path: str, +) -> str: + # deploy 机上生成完专用 key 后,把公钥文本拿回来继续给目标机铺设 authorized_keys。 + output = run_tat_script(client, deploy_instance_id, render_keygen_script(remote_key_path), timeout_seconds=300) + public_key = output.strip().splitlines()[-1].strip() + if not public_key.startswith("ssh-"): + raise SystemExit(f"invalid public key output: {public_key or output.strip()}") + return public_key + + +def main() -> None: + args = parse_args() + if not args.deploy_instance_id: + raise SystemExit("deploy instance id is required; set DEPLOY_INSTANCE_ID or pass --deploy-instance-id") + if not args.region: + raise SystemExit("deploy region is required; set DEPLOY_REGION or pass --region") + + secret_id = env_required("TENCENT_SECRET_ID") + secret_key = env_required("TENCENT_SECRET_KEY") + targets = load_target_hosts(args.hosts) + if not targets: + raise SystemExit("no target hosts found in config/targets.json") + + cvm = build_cvm(secret_id, secret_key, args.region) + tat = build_tat(secret_id, secret_key, args.region) + vpc = build_vpc(secret_id, secret_key, args.region) + + instance_ids = [args.deploy_instance_id, *[target["instanceId"] for target in targets]] + instance_map = describe_instances(cvm, instance_ids) + deploy_detail = instance_map.get(args.deploy_instance_id) + if not deploy_detail: + raise SystemExit(f"deploy instance not found: {args.deploy_instance_id}") + + deploy_ip = str(args.deploy_ip or deploy_detail.get("privateIp") or "").strip() + if not deploy_ip: + raise SystemExit(f"missing deploy private ip: {args.deploy_instance_id}") + + public_key = prepare_deploy_public_key(tat, args.deploy_instance_id, args.remote_key_path) + results: list[dict[str, object]] = [] + + for target in targets: + detail = instance_map.get(target["instanceId"]) + if not detail: + raise SystemExit(f"instance not found: {target['instanceId']}") + + output = run_tat_script( + tat, + target["instanceId"], + render_target_bootstrap_script(public_key, deploy_ip, args.ssh_user, args.ssh_port), + timeout_seconds=600, + ).strip() + + security_groups = list(detail.get("securityGroupIds") or []) + sg_results = [ + { + "securityGroupId": security_group_id, + "status": ensure_security_group_rule( + vpc, + security_group_id, + deploy_ip=deploy_ip, + ssh_port=args.ssh_port, + ), + } + for security_group_id in security_groups + ] + + results.append( + { + "host": target["host"], + "instanceId": target["instanceId"], + "ip": target["ip"] or detail.get("privateIp") or "", + "sshUser": args.ssh_user, + "sshPort": args.ssh_port, + "bootstrap": output, + "securityGroups": sg_results, + } + ) + + print( + json.dumps( + { + "ok": True, + "deployInstanceId": args.deploy_instance_id, + "deployIp": deploy_ip, + "remoteKeyPath": args.remote_key_path, + "sshUser": args.ssh_user, + "sshPort": args.ssh_port, + "targets": results, + }, + ensure_ascii=False, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/ops/deploy_via_ssh.py b/ops/deploy_via_ssh.py new file mode 100644 index 0000000..bcb2313 --- /dev/null +++ b/ops/deploy_via_ssh.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shlex +import subprocess + + +# 项目根目录,后面会从这里读取 .env 并打包代码。 +ROOT = Path(__file__).resolve().parents[1] +# 默认 .env 路径。 +ENV_FILE = Path(os.environ.get("APP_ENV_FILE", ROOT / ".env")).expanduser() + + +def load_env_file(path: Path) -> None: + # .env 文件不存在时允许继续使用外部环境变量。 + if not path.exists(): + return + + # 逐行读取 .env 内容。 + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + if value.startswith("'") and value.endswith("'"): + value = value[1:-1] + os.environ.setdefault(key, value) + + +# 先加载项目根目录 .env,保证默认 SSH 目标也能从这里读取。 +load_env_file(ENV_FILE) +# 默认远端部署目录。 +DEFAULT_REMOTE_DIR = os.environ.get("MONITOR_REMOTE_DIR", "/opt/hy-app-monitor") +# 默认远端监听端口。 +DEFAULT_APP_PORT = os.environ.get("APP_PORT", "2026") +# 默认 SSH 主机、用户和端口。 +DEFAULT_DEPLOY_SSH_HOST = os.environ.get("DEPLOY_SSH_HOST", "").strip() +DEFAULT_DEPLOY_SSH_USER = os.environ.get("DEPLOY_SSH_USER", "root").strip() or "root" +DEFAULT_DEPLOY_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22")) +DEFAULT_DEPLOY_SSH_IDENTITY_FILE = os.environ.get("DEPLOY_SSH_IDENTITY_FILE", "").strip() + + +def parse_args() -> argparse.Namespace: + # 这里默认按当前项目目录直接同步到远端,不再依赖远端 git clone。 + parser = argparse.ArgumentParser(description="deploy hy-app-monitor to deploy host via ssh/scp") + parser.add_argument("--host", default=DEFAULT_DEPLOY_SSH_HOST, help="deploy ssh host") + parser.add_argument("--user", default=DEFAULT_DEPLOY_SSH_USER, help="deploy ssh user") + parser.add_argument("--port", type=int, default=DEFAULT_DEPLOY_SSH_PORT, help="deploy ssh port") + parser.add_argument("--identity-file", default=DEFAULT_DEPLOY_SSH_IDENTITY_FILE, help="optional ssh identity file") + parser.add_argument("--remote-dir", default=DEFAULT_REMOTE_DIR, help="remote project directory") + parser.add_argument("--app-port", default=DEFAULT_APP_PORT, help="remote app port") + return parser.parse_args() + + +def require_env_file() -> Path: + # 当前项目约定 .env 是唯一运行时配置源,部署时必须显式同步。 + if not ENV_FILE.exists(): + raise SystemExit(f"missing local env file: {ENV_FILE}") + return ENV_FILE + + +def ssh_base_args(host: str, *, user: str, port: int, identity_file: str) -> list[str]: + # 所有 SSH / SCP 命令统一复用同一组参数,避免行为不一致。 + args = [ + "-p", + str(port), + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=3", + "-o", + "StrictHostKeyChecking=accept-new", + ] + if identity_file: + args.extend(["-i", identity_file]) + args.append(f"{user}@{host}") + return args + + +def scp_base_args(*, port: int, identity_file: str) -> list[str]: + # SCP 的端口参数和 SSH 不同,这里单独组装一份。 + args = [ + "-P", + str(port), + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "StrictHostKeyChecking=accept-new", + ] + if identity_file: + args.extend(["-i", identity_file]) + return args + + +def run_command(command: list[str], *, input_bytes: bytes | None = None, label: str) -> bytes: + # 小工具统一把 stdout/stderr 合并,失败时直接抛完整错误文本。 + try: + result = subprocess.run( + command, + input=input_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + except FileNotFoundError as exc: + raise SystemExit(f"missing command for {label}: {command[0]}") from exc + + if result.returncode != 0: + raise SystemExit((result.stdout or b"").decode("utf-8", errors="replace").strip() or f"{label} failed") + return result.stdout or b"" + + +def sync_project_tree(*, remote_dir: str, ssh_args: list[str]) -> None: + # 远端保留 .env / .venv / run,其他项目内容全部按当前工作区覆盖。 + remote_prepare_script = f"""set -euo pipefail +REMOTE_DIR={shlex.quote(remote_dir)} +mkdir -p "$REMOTE_DIR" +find "$REMOTE_DIR" -mindepth 1 -maxdepth 1 \\ + ! -name '.env' \\ + ! -name '.venv' \\ + ! -name 'run' \\ + -exec rm -rf {{}} + +""" + run_command(["ssh", *ssh_args, "bash", "-se"], input_bytes=remote_prepare_script.encode("utf-8"), label="remote prepare") + + # 本地 tar 流直接送到远端解压,避免要求远端额外安装 rsync。 + tar_command = [ + "tar", + "-czf", + "-", + "--exclude=.git", + "--exclude=.env", + "--exclude=.venv", + "--exclude=run", + "--exclude=__pycache__", + "--exclude=.pytest_cache", + "--exclude=.mypy_cache", + "--exclude=.DS_Store", + ".", + ] + ssh_command = ["ssh", *ssh_args, "tar", "-xzf", "-", "-C", remote_dir] + + try: + tar_process = subprocess.Popen(tar_command, cwd=str(ROOT), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except FileNotFoundError as exc: + raise SystemExit("missing command for tar sync: tar") from exc + + try: + ssh_process = subprocess.run( + ssh_command, + stdin=tar_process.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + if tar_process.stdout is not None: + tar_process.stdout.close() + tar_stderr = (tar_process.stderr.read() if tar_process.stderr is not None else b"").decode("utf-8", errors="replace") + tar_return = tar_process.wait() + finally: + if tar_process.poll() is None: + tar_process.kill() + tar_process.wait() + + if tar_return != 0: + raise SystemExit(tar_stderr.strip() or "local tar packaging failed") + if ssh_process.returncode != 0: + raise SystemExit((ssh_process.stdout or b"").decode("utf-8", errors="replace").strip() or "remote extract failed") + + +def copy_env_file( + target: str, + *, + remote_dir: str, + env_path: Path, + port: int, + identity_file: str, +) -> None: + # 运行配置单独走 scp,和代码同步解耦,避免误把远端 .env 覆盖成空文件。 + run_command( + ["scp", *scp_base_args(port=port, identity_file=identity_file), str(env_path), f"{target}:{remote_dir}/.env"], + label="copy env", + ) + + +def run_remote_install(*, remote_dir: str, app_port: str, ssh_args: list[str]) -> str: + # 远端只负责安装依赖、重启 systemd 并校验监听。 + script = f"""set -euo pipefail +REMOTE_DIR={shlex.quote(remote_dir)} +APP_PORT={shlex.quote(app_port)} +cd "$REMOTE_DIR" +chmod +x scripts/*.sh +python3 -m venv .venv +.venv/bin/pip install -r requirements-ops.txt +bash scripts/install_systemd.sh +systemctl restart hy-app-monitor.service +systemctl is-active hy-app-monitor.service +ss -lntp | grep ":$APP_PORT" || true +""" + output = run_command( + ["ssh", *ssh_args, "bash", "-se"], + input_bytes=script.encode("utf-8"), + label="remote install", + ) + return output.decode("utf-8", errors="replace").strip() + + +def main() -> None: + args = parse_args() + if not args.host: + raise SystemExit("deploy ssh host is required; set DEPLOY_SSH_HOST or pass --host") + + env_path = require_env_file() + target = f"{args.user}@{args.host}" + ssh_args = ssh_base_args(args.host, user=args.user, port=args.port, identity_file=args.identity_file) + + sync_project_tree(remote_dir=args.remote_dir, ssh_args=ssh_args) + copy_env_file( + target, + remote_dir=args.remote_dir, + env_path=env_path, + port=args.port, + identity_file=args.identity_file, + ) + output = run_remote_install(remote_dir=args.remote_dir, app_port=args.app_port, ssh_args=ssh_args) + print(output) + + +if __name__ == "__main__": + main() diff --git a/scripts/common.sh b/scripts/common.sh index 04078bf..3bc280a 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -31,17 +31,31 @@ mkdir -p "$RUN_DIR" read_pid() { if [ -f "$PID_FILE" ]; then - cat "$PID_FILE" + local pid + pid="$(cat "$PID_FILE")" + if pid_matches_project "$pid"; then + echo "$pid" + return 0 + fi fi + + discover_project_pid } is_running() { local pid pid="$(read_pid || true)" if [ -z "${pid:-}" ]; then + rm -f "$PID_FILE" return 1 fi - kill -0 "$pid" >/dev/null 2>&1 + if ! kill -0 "$pid" >/dev/null 2>&1; then + rm -f "$PID_FILE" + return 1 + fi + + echo "$pid" >"$PID_FILE" + return 0 } has_systemd_unit() { @@ -68,3 +82,42 @@ stop_legacy_process() { rm -f "$PID_FILE" } + +discover_project_pid() { + if ! command -v lsof >/dev/null 2>&1; then + return 1 + fi + + local pid + while IFS= read -r pid; do + if pid_matches_project "$pid"; then + echo "$pid" + return 0 + fi + done < <(lsof -tiTCP:"$APP_PORT" -sTCP:LISTEN 2>/dev/null || true) + + return 1 +} + +pid_matches_project() { + local pid="$1" + if [ -z "${pid:-}" ] || ! kill -0 "$pid" >/dev/null 2>&1; then + return 1 + fi + + local command + command="$(ps -p "$pid" -o command= 2>/dev/null || true)" + if [[ "$command" != *"$ROOT_DIR/server.py"* ]] && [[ "$command" != *" server.py"* ]]; then + return 1 + fi + + if command -v lsof >/dev/null 2>&1; then + local cwd + cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)" + if [ -n "$cwd" ] && [ "$cwd" != "$ROOT_DIR" ]; then + return 1 + fi + fi + + return 0 +} diff --git a/static/app.css b/static/app.css index 7d41c78..b2bcfda 100644 --- a/static/app.css +++ b/static/app.css @@ -1,16 +1,22 @@ :root { - --bg: oklch(19% 0.02 248); - --bg-soft: oklch(23% 0.02 248); - --panel: oklch(27% 0.024 248 / 0.9); - --panel-strong: oklch(31% 0.028 248 / 0.95); - --line: oklch(46% 0.03 248 / 0.34); - --text: oklch(94% 0.01 248); - --muted: oklch(76% 0.02 248); - --accent: oklch(70% 0.13 206); - --ok: oklch(76% 0.16 151); - --warn: oklch(80% 0.14 84); - --bad: oklch(68% 0.18 24); - --shadow: 0 24px 70px rgba(5, 10, 24, 0.35); + --bg: oklch(97.2% 0.004 228); + --bg-soft: oklch(99.1% 0.004 228); + --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); + --line: oklch(87% 0.014 232 / 0.96); + --line-strong: oklch(78% 0.022 234 / 0.98); + --text: oklch(29% 0.018 234); + --muted: oklch(53% 0.016 234); + --accent: oklch(51% 0.078 244); + --accent-soft: oklch(94% 0.018 244); + --ok: oklch(55% 0.12 154); + --ok-soft: oklch(95% 0.028 154); + --warn: oklch(68% 0.14 82); + --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); } * { @@ -28,18 +34,32 @@ body { } body { + position: relative; color: var(--text); font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif; background: - radial-gradient(circle at top left, rgba(84, 165, 255, 0.14), transparent 24%), - radial-gradient(circle at bottom right, rgba(33, 211, 146, 0.14), transparent 22%), + radial-gradient(circle at top right, color-mix(in oklab, var(--accent) 8%, transparent), transparent 30%), linear-gradient(180deg, var(--bg-soft) 0%, var(--bg) 100%); } +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + 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; +} + .shell { - max-width: 1440px; + position: relative; + z-index: 1; + max-width: 1560px; margin: 0 auto; - padding: 28px 18px 48px; + padding: 14px 16px 32px; } .boot-indicator { @@ -48,146 +68,272 @@ body { font-size: 14px; } -.hero { - display: grid; - grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr); - gap: 18px; - margin-bottom: 18px; +body.auth-page { + display: block; + padding: 28px 16px; } -.hero-copy, -.hero-side, -.toolbar, -.host-panel, -.infra-panel { +.auth-shell { + position: relative; + z-index: 1; + width: min(100%, 460px); + margin: min(12vh, 120px) auto 0; +} + +.auth-panel { border: 1px solid var(--line); - border-radius: 24px; + border-radius: 22px; + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-strong) 92%, var(--accent-soft)) 0%, var(--panel) 100%); + box-shadow: var(--shadow); + padding: 22px; +} + +.auth-panel-head { + display: flex; + flex-direction: column; + gap: 12px; +} + +.auth-title { + margin: 0; + font-size: clamp(24px, 4vw, 30px); + font-weight: 700; + letter-spacing: 0.02em; +} + +.auth-form { + display: grid; + gap: 14px; + margin-top: 18px; +} + +.auth-field { + display: grid; + gap: 7px; + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +.auth-input { + min-height: 42px; + padding: 0 12px; + border: 1px solid var(--line-strong); + border-radius: 14px; + background: color-mix(in oklab, var(--panel-muted) 88%, var(--bg-soft)); + color: var(--text); + font: inherit; +} + +.auth-input:focus-visible { + outline: 0; + border-color: color-mix(in oklab, var(--accent) 56%, var(--line-strong)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent-soft) 84%, transparent); +} + +.auth-submit { + width: 100%; + min-height: 40px; +} + +.auth-error { + margin: 0; + padding: 10px 12px; + border: 1px solid color-mix(in oklab, var(--bad) 32%, var(--line)); + border-radius: 14px; + background: color-mix(in oklab, var(--bad-soft) 56%, var(--panel-strong)); + color: var(--bad); + font-size: 12px; +} + +.topbar, +.control-bar, +.panel { + border: 1px solid var(--line); + border-radius: 18px; background: var(--panel); box-shadow: var(--shadow); - backdrop-filter: blur(14px); } -.hero-copy { - padding: 26px 28px; +.topbar { + position: sticky; + top: 10px; + z-index: 20; + display: grid; + grid-template-columns: minmax(200px, 260px) minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + min-height: 54px; + padding: 8px 12px; + backdrop-filter: blur(10px); } -.eyebrow { - margin: 0 0 12px; - color: var(--accent); - font-size: 13px; +.brand-stack { + display: flex; + flex-direction: column; + gap: 4px; +} + +.brand-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.brand-mark { + margin: 0; + color: var(--muted); + font-size: 12px; + font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; } -.hero-copy h1 { - margin: 0; - font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; - font-size: clamp(2rem, 4vw, 3.4rem); - line-height: 0.96; - letter-spacing: -0.05em; -} - -.hero-side { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - padding: 18px; -} - -.stat-block, -.summary-card { - padding: 16px 18px; - border-radius: 18px; - background: var(--panel-strong); - border: 1px solid var(--line); -} - -.stat-block span, -.summary-card small { - display: block; - color: var(--muted); - font-size: 13px; -} - -.stat-block strong, -.summary-card strong { - display: block; - margin-top: 8px; - font-size: 1.55rem; - letter-spacing: -0.04em; -} - -.stat-block.ok strong, -.ok-text { - color: var(--ok); -} - -.stat-block.bad strong, -.bad-text { - color: var(--bad); -} - -.stat-block.neutral strong { +.env-pill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border: 1px solid color-mix(in oklab, var(--accent) 34%, var(--line-strong)); + border-radius: 999px; + background: var(--accent-soft); color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; } -.toolbar { - display: flex; - justify-content: space-between; - gap: 14px; - padding: 14px; - margin-bottom: 12px; +.sync-note { + margin: 0; + color: var(--muted); + font-size: 11px; } -.toolbar-left, -.toolbar-right { +.topbar-nav { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.nav-chip, .filter-chip, .refresh, .interval-select, -.search { - border: 1px solid var(--line); - background: var(--panel-strong); +.search, +.config-textarea { + border: 1px solid var(--line-strong); color: var(--text); font: inherit; } +.nav-chip, .filter-chip, .refresh { - border-radius: 999px; - padding: 10px 14px; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 11px; + border-radius: 12px; + background: var(--panel-strong); cursor: pointer; - transition: transform 180ms ease, background 180ms ease, border-color 180ms ease; + font-size: 11px; + font-weight: 600; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease, box-shadow 180ms ease; } +.nav-chip:hover, .filter-chip:hover, .refresh:hover { transform: translateY(-1px); } -.refresh.ghost { - background: color-mix(in oklab, var(--panel-strong) 82%, transparent); -} - -.refresh.danger { - color: var(--bad); - border-color: color-mix(in oklab, var(--bad) 55%, var(--line)); -} - +.nav-chip.active, .filter-chip.active { - border-color: color-mix(in oklab, var(--accent) 65%, white 15%); - background: color-mix(in oklab, var(--accent) 18%, var(--panel-strong)); + border-color: color-mix(in oklab, var(--accent) 46%, var(--line-strong)); + background: var(--accent-soft); + color: var(--accent); +} + +.topbar-status, +.control-group, +.control-group-spread, +.density-switch, +.panel-meta, +.action-group, +.host-tags, +.restart-pills { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.topbar-status { + justify-content: flex-end; +} + +.control-bar { + position: sticky; + top: 72px; + z-index: 18; + display: flex; + justify-content: space-between; + gap: 10px; + margin-top: 8px; + padding: 7px 12px; +} + +.task-control-bar { + gap: 12px; + align-items: center; +} + +.taskbar-context { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 160px; +} + +.taskbar-context strong { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.taskbar-context span { + color: var(--muted); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.taskbar-inline { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.control-group-spread { + justify-content: flex-end; + flex: 1 1 auto; } .search { - min-width: 260px; - border-radius: 999px; - padding: 10px 14px; + min-width: 320px; + min-height: 34px; + padding: 0 11px; + border-radius: 12px; + background: color-mix(in oklab, var(--panel-muted) 88%, var(--bg-soft)); +} + +.search::placeholder { + color: var(--muted); } .interval-field { @@ -195,281 +341,1385 @@ body { align-items: center; gap: 8px; color: var(--muted); - font-size: 13px; + font-size: 12px; } .interval-select { - min-width: 88px; - padding: 10px 12px; - border-radius: 999px; -} - -.meta-strip { - display: flex; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - padding: 2px 4px 16px; - color: var(--muted); - font-size: 13px; -} - -.section-head { - margin: 10px 0 12px; -} - -.section-head h2 { - margin: 0; - font-size: 1rem; - letter-spacing: 0.03em; - text-transform: uppercase; - color: var(--muted); -} - -.hosts, -.nacos-service-grid { - display: grid; - gap: 16px; -} - -.host-panel, -.infra-panel { - padding: 18px; -} - -.host-header, -.panel-head, -.nacos-service-head { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 14px; - margin-bottom: 16px; -} - -.host-header h2, -.panel-head h3, -.nacos-service-head h4 { - margin: 0; - font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; - letter-spacing: -0.03em; -} - -.host-header h2 { - font-size: 1.45rem; -} - -.panel-head h3, -.nacos-service-head h4 { - font-size: 1.1rem; -} - -.host-header p, -.panel-head p, -.nacos-service-head p { - margin: 6px 0 0; - color: var(--muted); - font-size: 13px; -} - -.host-badges { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.host-metrics, -.summary-grid, -.metric-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; - margin-bottom: 16px; -} - -.metric-card { - padding: 14px 16px; - border-radius: 18px; - border: 1px solid var(--line); + min-width: 82px; + min-height: 32px; + padding: 0 11px; + border-radius: 12px; background: var(--panel-strong); } -.metric-card small, -.metric-card span { - color: var(--muted); +.refresh { + background: color-mix(in oklab, var(--accent-soft) 38%, var(--panel-strong)); } -.metric-card small { - display: block; - font-size: 12px; +.refresh.ghost { + background: var(--panel-muted); } -.metric-card strong { - display: block; - margin-top: 6px; - font-size: 1.1rem; - letter-spacing: -0.02em; +.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)); } -.metric-card span { - display: block; - margin-top: 6px; - font-size: 12px; +.refresh:disabled, +.filter-chip:disabled, +.nav-chip:disabled { + opacity: 0.56; + cursor: not-allowed; + transform: none; } -.metric-error { - margin: -4px 0 16px; +.nav-chip:focus-visible, +.filter-chip:focus-visible, +.refresh:focus-visible, +.search:focus-visible, +.interval-select:focus-visible, +.detail-log-input:focus-visible, +.config-item:focus-visible, +.config-textarea:focus-visible { + outline: 0; + border-color: color-mix(in oklab, var(--accent) 56%, var(--line-strong)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent-soft) 84%, transparent); +} + +.toolbar-error { + margin: 12px 0 0; color: var(--bad); font-size: 12px; - word-break: break-word; } -.badge { +.panel { + padding: 12px; + overflow: hidden; +} + +.summary-strip { + margin-top: 8px; + padding: 0 10px; +} + +.summary-cards { + display: flex; + flex-wrap: wrap; + gap: 0; +} + +.summary-tile, +.summary-card { + min-height: 54px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-strong); +} + +.summary-tile { display: inline-flex; align-items: center; - min-height: 32px; - padding: 0 12px; - border-radius: 999px; - font-size: 12px; - border: 1px solid var(--line); + gap: 8px; + min-height: 44px; } -.badge.neutral { +.summary-strip-label { color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } -.badge.ok { +.summary-tile strong, +.summary-card strong { + font-family: "Space Grotesk", "IBM Plex Sans", sans-serif; + font-size: clamp(0.94rem, 1.25vw, 1.12rem); + line-height: 1; + letter-spacing: -0.04em; +} + +.summary-tile small { + color: var(--muted); + font-size: 10px; +} + +.status-band-list { + align-items: center; +} + +.status-band-item { + flex: 1 1 180px; + min-height: 44px; + padding: 0 12px; + border: 0; + border-right: 1px solid var(--line); + border-radius: 0; + background: transparent; +} + +.status-band-item:last-child { + border-right: 0; +} + +.status-band-item.tone-ok, +.status-band-item.tone-warn, +.status-band-item.tone-bad { + background: transparent; +} + +.status-band-item.tone-ok strong { color: var(--ok); - border-color: color-mix(in oklab, var(--ok) 50%, var(--line)); } -.badge.warn { +.status-band-item.tone-warn strong { color: var(--warn); - border-color: color-mix(in oklab, var(--warn) 50%, var(--line)); } -.badge.bad { +.status-band-item.tone-bad strong { color: var(--bad); - border-color: color-mix(in oklab, var(--bad) 50%, var(--line)); } -.service-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 14px; +.status-band-suffix { + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } -.service-card, -.nacos-service-card { - padding: 16px; - border-radius: 20px; +.summary-card small { + display: block; + color: var(--muted); + font-size: 11px; +} + +.summary-card strong { + display: block; + margin-top: 6px; +} + +.summary-tile.tone-ok, +.summary-card.tone-ok, +.matrix-state.tone-ok, +.metric-chip.tone-ok, +.host-service-pill.tone-ok, +.host-detail-line.tone-ok, +.alert-item.tone-ok { + border-color: color-mix(in oklab, var(--ok) 32%, var(--line)); + background: color-mix(in oklab, var(--ok-soft) 70%, var(--panel-strong)); +} + +.summary-tile.tone-warn, +.matrix-state.tone-warn, +.metric-chip.tone-warn, +.host-service-pill.tone-warn, +.host-detail-line.tone-warn, +.alert-item.tone-warn { + border-color: color-mix(in oklab, var(--warn) 32%, var(--line)); + background: color-mix(in oklab, var(--warn-soft) 72%, var(--panel-strong)); +} + +.summary-tile.tone-bad, +.summary-card.tone-bad, +.matrix-state.tone-bad, +.metric-chip.tone-bad, +.host-service-pill.tone-bad, +.host-detail-line.tone-bad, +.alert-item.tone-bad { + border-color: color-mix(in oklab, var(--bad) 32%, var(--line)); + background: color-mix(in oklab, var(--bad-soft) 72%, var(--panel-strong)); +} + +.matrix-state.tone-ok, +.matrix-state.tone-warn, +.matrix-state.tone-bad { + background: transparent; +} + +.alert-item { + width: 100%; + padding: 9px 10px; border: 1px solid var(--line); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)), - var(--panel-strong); + border-radius: 12px; + background: var(--panel-strong); + color: var(--text); + text-align: left; + cursor: pointer; } -.service-top { +.alert-item.compact { + padding: 7px 9px; +} + +.alert-item strong, +.alert-item span { + display: block; +} + +.alert-item strong { + font-size: 12px; + line-height: 1.35; +} + +.alert-item span { + margin-top: 4px; + color: var(--muted); + font-size: 11px; + line-height: 1.4; +} + +.content-grid { + display: grid; + gap: 12px; + margin-top: 8px; +} + +.service-layout-open { + grid-template-columns: minmax(0, 1fr) 288px; +} + +.service-layout-solo { + grid-template-columns: 1fr; +} + +.panel-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; + margin-bottom: 8px; } -.service-name { - font-size: 1.08rem; +.panel-title h2 { + margin: 0; + font-size: 0.98rem; font-weight: 700; letter-spacing: -0.02em; } -.service-kind { - margin-top: 4px; +.panel-title p { + margin: 4px 0 0; + color: var(--muted); + font-size: 11px; + line-height: 1.45; +} + +.matrix-scroll, +.table-wrap { + overflow: auto; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel-muted); + scrollbar-gutter: stable; +} + +.matrix-stacks { + display: grid; + gap: 10px; +} + +.matrix-stack { + display: grid; + gap: 6px; +} + +.matrix-stack-head { + display: flex; + gap: 8px; + align-items: center; + color: var(--muted); + font-size: 10px; + 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-table, +.table { + width: 100%; + border-collapse: collapse; +} + +.matrix-table { + min-width: 520px; +} + +.matrix-table th, +.matrix-table td, +.table th, +.table td { + padding: 5px 8px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: middle; + font-size: 11px; +} + +.matrix-table th, +.table th { + background: color-mix(in oklab, var(--accent-soft) 28%, var(--panel-muted)); + color: var(--muted); + font-weight: 700; + white-space: nowrap; +} + +.matrix-scroll { + max-height: 520px; +} + +.matrix-row { + cursor: pointer; +} + +.matrix-row:hover { + background: color-mix(in oklab, var(--accent-soft) 18%, var(--panel-strong)); +} + +.matrix-row.selected { + background: color-mix(in oklab, var(--accent-soft) 34%, var(--panel-strong)); +} + +.matrix-row.focused { + background: color-mix(in oklab, var(--warn-soft) 28%, var(--panel-strong)); +} + +.matrix-service strong { + font-size: 12px; + line-height: 1.3; +} + +.matrix-kind { + color: var(--muted); + font-size: 11px; +} + +.matrix-state { + min-width: 34px; + height: 24px; + padding: 0 4px; + border-left: 1px solid var(--line); + text-align: center; + vertical-align: middle; +} + +.matrix-state-marker { + display: block; + width: 8px; + height: 8px; + margin: 0 auto; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 0 2px color-mix(in oklab, currentColor 14%, transparent); +} + +.matrix-state-code { + display: none; + margin-top: 2px; + font-size: 9px; + font-weight: 700; + line-height: 1; +} + +.matrix-state-latency { + display: none; + margin-top: 2px; + color: var(--muted); + font-size: 9px; + line-height: 1; +} + +.matrix-table.is-detail .matrix-state { + height: 40px; + padding: 4px 5px; +} + +.matrix-table.is-detail .matrix-state-code, +.matrix-table.is-detail .matrix-state-latency { + display: block; +} + +.matrix-state-empty { + background: + linear-gradient(180deg, color-mix(in oklab, var(--panel-muted) 92%, var(--bg-soft)) 0%, color-mix(in oklab, var(--panel-muted) 82%, var(--bg-soft)) 100%), + repeating-linear-gradient(135deg, transparent 0 6px, color-mix(in oklab, var(--line) 30%, transparent) 6px 7px); + color: color-mix(in oklab, var(--muted) 58%, transparent); +} + +.matrix-state-empty-mark { + display: block; + font-size: 9px; + line-height: 1; + opacity: 0.32; +} + +.matrix-table thead th { + position: sticky; + top: 0; + z-index: 4; +} + +.matrix-sticky-col { + position: sticky; + left: 0; + z-index: 3; + background: color-mix(in oklab, var(--panel-strong) 94%, var(--panel-muted)); + box-shadow: 1px 0 0 var(--line); + min-width: 118px; +} + +.matrix-table thead .matrix-sticky-col { + z-index: 5; + background: color-mix(in oklab, var(--accent-soft) 28%, var(--panel-muted)); +} + +.detail-panel { + display: flex; + flex-direction: column; + position: sticky; + top: 118px; + max-height: calc(100vh - 132px); +} + +.service-meta-grid, +.summary-grid, +.metric-grid, +.infra-mini-metrics, +.config-meta-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.service-meta-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: 10px; +} + +.detail-instance-list { + display: grid; + gap: 8px; + max-height: 480px; + overflow: auto; + padding-right: 4px; +} + +.detail-instance { + padding: 9px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-muted); +} + +.detail-instance-top { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; +} + +.detail-instance-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.detail-instance-top strong { + display: block; + font-size: 13px; +} + +.detail-instance-top p { + margin: 4px 0 0; color: var(--muted); font-size: 12px; +} + +.detail-inline { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 6px; + color: var(--muted); + font-size: 11px; +} + +.detail-secondary { + margin-top: 8px; + color: var(--muted); + font-size: 10px; + word-break: break-all; +} + +.detail-note { + margin-top: 6px; + color: var(--text); + font-size: 11px; + line-height: 1.45; + word-break: break-word; +} + +.detail-log-trigger { + min-height: 26px; + padding: 0 9px; +} + +.detail-log-panel { + margin-top: 10px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-muted); +} + +.detail-log-toolbar { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.detail-log-field { + gap: 6px; +} + +.detail-log-input { + width: 96px; + min-height: 30px; + padding: 0 9px; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: var(--panel-strong); + color: var(--text); + font: inherit; +} + +.detail-log-output { + margin: 0; + min-height: 144px; + max-height: 320px; + overflow: auto; + padding: 12px; + border: 1px solid color-mix(in oklab, var(--line-strong) 74%, transparent); + border-radius: 12px; + background: oklch(22% 0.01 240 / 0.98); + color: oklch(91% 0.01 230); + font-size: 11px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +.detail-alert-panel { + margin-bottom: 8px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-muted); +} + +.detail-alert-feed { + display: grid; + gap: 6px; + max-height: 112px; + overflow: auto; + padding-right: 2px; +} + +.detail-subhead { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + margin-bottom: 6px; + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.host-sections { + display: grid; + gap: 10px; +} + +.host-section { + display: grid; + gap: 8px; +} + +.section-caption { + color: var(--muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.host-row-list { + display: grid; + gap: 6px; +} + +.host-strip { + position: relative; + border: 1px solid color-mix(in oklab, var(--line) 92%, var(--panel-strong)); + border-radius: 14px; + background: var(--panel-strong); + overflow: hidden; +} + +.host-strip-alert { + border-color: color-mix(in oklab, var(--bad) 28%, var(--line)); +} + +.host-strip-focused { + border-color: color-mix(in oklab, var(--accent) 42%, var(--line)); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-soft) 72%, transparent); +} + +.host-strip::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: linear-gradient(180deg, var(--accent) 0%, color-mix(in oklab, var(--ok) 42%, var(--accent)) 100%); +} + +.host-strip-alert::before { + background: linear-gradient(180deg, color-mix(in oklab, var(--warn) 72%, var(--accent)) 0%, var(--bad) 100%); +} + +.host-strip-main { + display: grid; + grid-template-columns: minmax(164px, 208px) minmax(210px, 0.9fr) minmax(220px, 1.2fr) auto; + gap: 8px; + align-items: center; + min-height: 48px; + padding: 7px 11px 7px 16px; + cursor: pointer; +} + +.host-strip-id strong, +.host-strip-id span { + display: block; +} + +.host-strip-id strong { + font-size: 13px; + line-height: 1.3; +} + +.host-strip-id span { + margin-top: 4px; + color: var(--muted); + font-size: 11px; +} + +.host-inline-metrics, +.host-inline-state { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.compact-metrics { + gap: 10px; + color: var(--muted); + font-size: 10px; + font-weight: 600; +} + +.compact-metrics span { + white-space: nowrap; +} + +.host-alert-summary { + display: flex; + align-items: center; + min-width: 0; +} + +.host-alert-path { + display: block; + width: 100%; + overflow: hidden; + color: var(--muted); + font-size: 10px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.host-alert-summary.is-alert .host-alert-path { + color: color-mix(in oklab, var(--bad) 82%, var(--text)); +} + +.host-alert-summary.is-quiet .host-alert-path { + color: color-mix(in oklab, var(--muted) 88%, transparent); +} + +.metric-chip, +.host-service-pill { + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel-muted); + color: var(--text); +} + +.metric-chip { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + font-size: 11px; + font-weight: 700; +} + +.host-strip-detail { + display: flex; + gap: 6px; + flex-wrap: wrap; + padding: 0 11px 10px 16px; +} + +.host-strip-detail-wide { + padding-top: 2px; +} + +.host-strip-detail-list { + display: grid; + gap: 6px; +} + +.host-detail-line { + display: grid; + grid-template-columns: minmax(92px, 110px) minmax(0, 1fr); + gap: 12px; + align-items: center; + width: 100%; + padding: 7px 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel-muted); + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.host-detail-line strong, +.host-detail-line span, +.host-detail-line small { + display: block; +} + +.host-detail-line strong { + font-size: 11px; +} + +.host-detail-line span, +.host-detail-line small { + color: var(--muted); + font-size: 10px; + line-height: 1.35; +} + +.host-detail-line-warn { + cursor: default; + border-color: color-mix(in oklab, var(--warn) 36%, var(--line)); + background: color-mix(in oklab, var(--warn-soft) 50%, var(--panel-muted)); +} + +.host-service-pill { + min-width: 138px; + padding: 7px 9px; + text-align: left; + cursor: pointer; +} + +.host-service-pill strong, +.host-service-pill span, +.host-service-pill small { + display: block; +} + +.host-service-pill strong { + font-size: 11px; +} + +.host-service-pill span, +.host-service-pill small { + margin-top: 4px; + color: var(--muted); + font-size: 10px; + line-height: 1.35; +} + +.overview-infra-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.task-page-head { + margin-top: 8px; +} + +.metadata-strip { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + margin-bottom: 10px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-muted); + color: var(--muted); + font-size: 11px; +} + +.metadata-strip span { + white-space: nowrap; +} + +.infra-overview-panel { + margin-top: 12px; +} + +.infra-overview-grid, +.mongo-group-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.infra-focus-card, +.mongo-stat-group { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel-muted); +} + +.infra-focus-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.infra-focus-head strong { + font-size: 13px; +} + +.infra-focus-metrics { + display: flex; + gap: 8px; + flex-wrap: wrap; + color: var(--muted); + font-size: 11px; +} + +.infra-inline-list { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.infra-inline-link { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid color-mix(in oklab, var(--bad) 24%, var(--line)); + border-radius: 999px; + background: color-mix(in oklab, var(--bad-soft) 38%, var(--panel-strong)); + color: var(--bad); + font: inherit; + font-size: 11px; + cursor: pointer; +} + +.nacos-summary-row.is-alert { + background: color-mix(in oklab, var(--bad-soft) 24%, var(--panel-strong)); +} + +.nacos-service-detail-cell { + padding: 8px; + background: color-mix(in oklab, var(--panel-muted) 92%, var(--panel-strong)); +} + +.fold-group { + display: grid; + gap: 10px; + margin-top: 12px; +} + +.infra-detail-stack { + display: grid; + gap: 12px; +} + +.config-sidebar { + display: grid; + gap: 10px; +} + +.config-sidebar-meta { + display: flex; + gap: 8px; + align-items: center; +} + +.config-list-ide { + gap: 8px; +} + +.config-group { + display: grid; + gap: 6px; +} + +.config-group-toggle { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 10px; + align-items: center; + width: 100%; + min-height: 32px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-strong); + color: var(--text); + font: inherit; + cursor: pointer; + text-align: left; +} + +.config-group-toggle strong { + font-size: 11px; +} + +.config-group-toggle span, +.config-group-toggle small { + color: var(--muted); + font-size: 10px; +} + +.config-group-items { + display: grid; + gap: 4px; +} + +.config-item-ide { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 9px 10px; + border-radius: 12px; +} + +.config-item-ide strong { + font-size: 12px; + word-break: break-word; +} + +.config-item-ide span { + margin-top: 0; + font-size: 10px; + text-transform: uppercase; +} + +.config-item-ide small { + grid-column: 1 / -1; + font-size: 10px; +} + +.editor-toolbar { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + padding: 10px; + border: 1px solid var(--line); + border-radius: 14px; + background: color-mix(in oklab, var(--panel-muted) 92%, var(--panel-strong)); +} + +.sticky-toolbar { + position: sticky; + top: 124px; + z-index: 8; +} + +.editor-toolbar-main { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.diff-panel { + border: 1px solid var(--line); + border-radius: 16px; + background: oklch(100% 0 0 / 0.86); + overflow: auto; +} + +.diff-list { + display: grid; +} + +.diff-row { + display: grid; + grid-template-columns: 48px 48px 28px minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 6px 12px; + border-bottom: 1px solid var(--line); + font-size: 12px; + line-height: 1.5; +} + +.diff-row:last-child { + border-bottom: 0; +} + +.diff-row.is-add { + background: color-mix(in oklab, var(--ok-soft) 58%, var(--panel-strong)); +} + +.diff-row.is-remove { + background: color-mix(in oklab, var(--bad-soft) 52%, var(--panel-strong)); +} + +.diff-gutter, +.diff-mark { + color: var(--muted); + font-size: 10px; + text-align: right; +} + +.diff-row code { + font: inherit; + word-break: break-word; + white-space: pre-wrap; +} + +.activity-list { + display: grid; + gap: 8px; +} + +.activity-list.compact { + margin-top: 12px; +} + +.activity-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-muted); +} + +.activity-item.compact { + padding: 8px 10px; +} + +.activity-item.tone-ok { + border-color: color-mix(in oklab, var(--ok) 28%, var(--line)); + background: color-mix(in oklab, var(--ok-soft) 42%, var(--panel-strong)); +} + +.activity-item.tone-warn { + border-color: color-mix(in oklab, var(--warn) 28%, var(--line)); + background: color-mix(in oklab, var(--warn-soft) 44%, var(--panel-strong)); +} + +.activity-item.tone-bad { + border-color: color-mix(in oklab, var(--bad) 28%, var(--line)); + background: color-mix(in oklab, var(--bad-soft) 44%, var(--panel-strong)); +} + +.activity-item-main strong, +.activity-item-main span, +.activity-item-side small { + display: block; +} + +.activity-item-main strong { + font-size: 12px; +} + +.activity-item-main span, +.activity-item-side small { + color: var(--muted); + font-size: 11px; + line-height: 1.45; +} + +.activity-item-side { + text-align: right; +} + +.release-status-strip { + margin-top: 8px; + padding: 10px 12px; +} + +.sticky-release-strip { + position: sticky; + top: 126px; + z-index: 12; +} + +.release-status-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} + +.release-status-item { + min-height: 56px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-muted); +} + +.release-status-item.tone-ok { + border-color: color-mix(in oklab, var(--ok) 26%, var(--line)); + background: color-mix(in oklab, var(--ok-soft) 42%, var(--panel-strong)); +} + +.release-status-item.tone-warn { + border-color: color-mix(in oklab, var(--warn) 26%, var(--line)); + background: color-mix(in oklab, var(--warn-soft) 44%, var(--panel-strong)); +} + +.release-status-item.tone-bad { + border-color: color-mix(in oklab, var(--bad) 26%, var(--line)); + background: color-mix(in oklab, var(--bad-soft) 42%, var(--panel-strong)); +} + +.release-status-item small, +.release-status-item strong { + display: block; +} + +.release-status-item small { + color: var(--muted); + font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; } -.status-pill { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - border-radius: 999px; - border: 1px solid var(--line); +.release-status-item strong { + margin-top: 6px; font-size: 12px; -} - -.dot { - width: 8px; - height: 8px; - border-radius: 999px; - background: currentColor; -} - -.service-data { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; - margin: 16px 0 14px; -} - -.service-data small { - display: block; - color: var(--muted); - font-size: 12px; -} - -.service-data strong { - display: block; - margin-top: 5px; - font-size: 1rem; -} - -.service-url, -.service-detail, -.mono { - font-family: "IBM Plex Mono", "SFMono-Regular", monospace; -} - -.service-url, -.service-detail { - color: var(--muted); - font-size: 12px; - word-break: break-all; -} - -.service-detail { - margin-top: 10px; line-height: 1.45; + word-break: break-word; } -.table-wrap { - overflow-x: auto; +.precheck-strip { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + margin-top: 10px; +} + +.release-table td strong { + display: block; + font-size: 12px; +} + +.table-subtext { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 10px; +} + +.inline-copy { + display: inline-flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} + +.inline-button { + min-height: 24px; + padding: 0 8px; +} + +.table-check { + display: inline-flex; + gap: 6px; + align-items: center; + color: var(--muted); + font-size: 11px; +} + +.table-check input { + width: 15px; + height: 15px; +} + +.table-empty-cell { + padding: 24px 10px; + color: var(--muted); + text-align: center; +} + +.result-disclosure { margin-top: 12px; } -.table-wrap.compact { - margin-top: 8px; +.result-disclosure summary { + cursor: pointer; + color: var(--text); + font-size: 12px; + font-weight: 700; } -.table { +.bridge-panel { + padding: 8px 10px; +} + +.bridge-list { + display: grid; + gap: 6px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.bridge-item { + display: grid; + grid-template-columns: minmax(92px, 110px) minmax(0, 1fr) auto; + gap: 12px; + align-items: center; width: 100%; - border-collapse: collapse; - font-size: 13px; -} - -.table th, -.table td { - padding: 10px 12px; - border-bottom: 1px solid var(--line); + 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)); + color: var(--text); + font: inherit; text-align: left; - vertical-align: top; + cursor: pointer; } -.table th { +.bridge-item strong { + font-size: 11px; + color: var(--bad); +} + +.bridge-item span { color: var(--muted); - font-weight: 600; + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bridge-item em { + color: var(--bad); + font-size: 10px; + font-style: normal; + font-weight: 700; +} + +.host-fold-toggle { + display: inline-flex; + align-items: center; + gap: 10px; + width: fit-content; + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel-muted); + color: var(--text); + font: inherit; + cursor: pointer; +} + +.host-fold-toggle strong { + color: var(--text); + font-size: 11px; +} + +.host-fold-toggle small { + color: var(--muted); + font-size: 10px; +} + +.infra-mini-card { + display: flex; + flex-direction: column; +} + +.infra-mini-metrics { + margin-bottom: 12px; +} + +.infra-card-action { + align-self: flex-start; +} + +.infra-panel { + margin-top: 12px; +} + +.nacos-service-grid { + display: grid; + gap: 12px; + margin-top: 14px; +} + +.nacos-service-card { + padding: 14px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--panel-muted); +} + +.table-wrap { + margin-top: 10px; +} + +.table tbody tr:hover { + background: color-mix(in oklab, var(--accent-soft) 22%, var(--panel-strong)); +} + +.table tbody tr:last-child td, +.matrix-table tbody tr:last-child td { + border-bottom: 0; } .table.compact th, @@ -478,25 +1728,6 @@ body { font-size: 12px; } -.tone-ok { - color: var(--ok); - border-color: color-mix(in oklab, var(--ok) 55%, var(--line)); -} - -.tone-warn { - color: var(--warn); - border-color: color-mix(in oklab, var(--warn) 55%, var(--line)); -} - -.tone-bad { - color: var(--bad); - border-color: color-mix(in oklab, var(--bad) 55%, var(--line)); -} - -.tone-neutral { - color: var(--text); -} - .config-toolbar { display: flex; justify-content: space-between; @@ -509,15 +1740,6 @@ body { min-width: 320px; } -.host-tags, -.restart-pills, -.action-group { - display: flex; - gap: 10px; - align-items: center; - flex-wrap: wrap; -} - .restart-pills { flex: 1 1 auto; } @@ -527,6 +1749,116 @@ body { min-width: 220px; } +.config-layout { + display: grid; + grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); + gap: 16px; +} + +.config-list { + display: grid; + gap: 10px; + max-height: 760px; + overflow: auto; + padding-right: 4px; +} + +.config-item { + display: block; + width: 100%; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel-muted); + color: var(--text); + text-align: left; + cursor: pointer; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.config-item:hover { + transform: translateY(-1px); +} + +.config-item.active { + border-color: color-mix(in oklab, var(--accent) 44%, var(--line-strong)); + background: var(--accent-soft); +} + +.config-item strong, +.config-item span, +.config-item small { + display: block; +} + +.config-item strong { + font-size: 13px; + line-height: 1.35; + word-break: break-all; +} + +.config-item span, +.config-item small, +.config-description { + color: var(--muted); + font-size: 12px; +} + +.config-item span, +.config-description { + margin-top: 6px; +} + +.config-description { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.config-editor { + display: flex; + flex-direction: column; + gap: 12px; +} + +.config-empty, +.empty-panel { + display: flex; + align-items: center; + justify-content: center; + min-height: 160px; + padding: 18px; + border: 1px dashed var(--line-strong); + border-radius: 16px; + background: color-mix(in oklab, var(--panel-muted) 88%, var(--bg-soft)); + color: var(--muted); + text-align: center; +} + +.empty-panel.compact { + min-height: 88px; +} + +.config-textarea { + width: 100%; + min-height: 500px; + padding: 16px; + resize: vertical; + border-radius: 16px; + 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; +} + +.config-actions { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + .update-form-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -534,38 +1866,6 @@ body { margin-top: 14px; } -.operation-meta { - margin-top: 14px; -} - -.update-log-panel { - margin-top: 14px; - padding: 16px; - border: 1px solid var(--line); - border-radius: 22px; - background: color-mix(in oklab, var(--panel-strong) 94%, transparent); -} - -.update-log-head { - margin-bottom: 12px; -} - -.update-log-output { - margin: 0; - min-height: 180px; - max-height: 420px; - overflow: auto; - padding: 16px; - border: 1px solid color-mix(in oklab, var(--line) 88%, transparent); - border-radius: 18px; - background: oklch(19% 0.02 248 / 0.96); - color: oklch(92% 0.01 250); - font-size: 12px; - line-height: 1.55; - white-space: pre-wrap; - word-break: break-word; -} - .update-field, .update-check { display: flex; @@ -583,8 +1883,8 @@ body { justify-content: center; padding: 12px 14px; border: 1px solid var(--line); - border-radius: 18px; - background: color-mix(in oklab, var(--panel-strong) 92%, transparent); + border-radius: 14px; + background: var(--panel-muted); } .update-check input { @@ -592,132 +1892,160 @@ body { height: 18px; } -.config-layout { - display: grid; - grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); - gap: 16px; +.operation-meta { + margin-top: 14px; } -.config-list { - display: grid; - gap: 10px; - max-height: 760px; - overflow: auto; - padding-right: 4px; -} - -.config-item { - display: block; - width: 100%; - padding: 14px 16px; - border: 1px solid var(--line); - border-radius: 18px; - background: var(--panel-strong); - color: var(--text); - text-align: left; - cursor: pointer; - transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; -} - -.config-item:hover { - transform: translateY(-1px); -} - -.config-item.active { - border-color: color-mix(in oklab, var(--accent) 60%, white 15%); - background: color-mix(in oklab, var(--accent) 14%, var(--panel-strong)); -} - -.config-item strong, -.config-item span, -.config-item small { - display: block; -} - -.config-item strong { - font-size: 0.98rem; - line-height: 1.35; - word-break: break-all; -} - -.config-item span, -.config-item small, -.config-description { - color: var(--muted); - font-size: 12px; -} - -.config-item span, -.config-description { - margin-top: 6px; -} - -.config-editor { - display: flex; - flex-direction: column; - gap: 12px; -} - -.config-meta-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; -} - -.config-meta-grid .summary-card strong { - font-size: 0.95rem; - word-break: break-all; -} - -.config-empty { - min-height: 160px; - display: flex; - align-items: center; - justify-content: center; - border: 1px dashed var(--line); - border-radius: 18px; - color: var(--muted); - background: color-mix(in oklab, var(--panel-strong) 88%, transparent); - text-align: center; - padding: 18px; -} - -.config-textarea { - width: 100%; - min-height: 520px; +.update-log-panel { + margin-top: 14px; padding: 16px; - resize: vertical; border: 1px solid var(--line); - border-radius: 20px; - background: oklch(21% 0.02 248 / 0.95); - color: var(--text); - font-size: 13px; + border-radius: 18px; + background: var(--panel-muted); +} + +.update-log-head { + margin-bottom: 12px; +} + +.update-log-output { + margin: 0; + min-height: 180px; + max-height: 420px; + overflow: auto; + padding: 16px; + border: 1px solid color-mix(in oklab, var(--line-strong) 78%, transparent); + border-radius: 14px; + background: oklch(22% 0.01 240 / 0.98); + color: oklch(91% 0.01 230); + font-size: 12px; line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; } -.config-actions { - display: flex; - justify-content: space-between; - gap: 12px; +.badge { + display: inline-flex; align-items: center; - flex-wrap: wrap; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel-muted); + font-size: 11px; + font-weight: 600; } -@media (max-width: 1180px) { - .hero { +.badge.neutral { + color: color-mix(in oklab, var(--muted) 92%, var(--text)); + border-color: color-mix(in oklab, var(--line) 90%, var(--panel-strong)); + background: color-mix(in oklab, var(--panel-muted) 94%, var(--panel-strong)); +} + +.badge.ok, +.ok-text, +.tone-ok { + color: var(--ok); +} + +.badge.warn, +.tone-warn { + color: var(--warn); +} + +.badge.bad, +.bad-text, +.tone-bad { + color: var(--bad); +} + +.badge.ok { + border-color: color-mix(in oklab, var(--ok) 26%, var(--line)); + background: color-mix(in oklab, var(--ok-soft) 32%, var(--panel-muted)); +} + +.badge.warn { + border-color: color-mix(in oklab, var(--warn) 42%, var(--line)); + background: color-mix(in oklab, var(--warn-soft) 70%, var(--panel-muted)); +} + +.badge.bad { + border-color: color-mix(in oklab, var(--bad) 42%, var(--line)); + background: color-mix(in oklab, var(--bad-soft) 70%, var(--panel-muted)); +} + +.tone-neutral { + color: var(--text); +} + +.metric-error { + margin: 0; + color: var(--bad); + font-size: 12px; + word-break: break-word; +} + +.mono, +.detail-secondary { + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; +} + +@media (max-width: 1320px) { + .topbar { grid-template-columns: 1fr; } - .service-grid { + .service-layout-open { + grid-template-columns: 1fr; + } + + .bridge-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .host-metrics, + .overview-infra-grid { + grid-template-columns: 1fr; + } + + .service-meta-grid, .summary-grid, - .metric-grid { + .metric-grid, + .infra-mini-metrics, + .config-meta-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .infra-overview-grid, + .mongo-group-grid, + .release-status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .update-form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1040px) { + .control-bar { + position: static; + } + + .detail-panel { + position: static; + max-height: none; + } + + .summary-cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + + .host-strip-main { + grid-template-columns: 1fr; + } + .config-layout { grid-template-columns: 1fr; } @@ -726,35 +2054,56 @@ body { max-height: 280px; } - .config-meta-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); + .sticky-toolbar, + .sticky-release-strip { + position: static; } - .update-form-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); + .release-status-grid, + .infra-overview-grid, + .mongo-group-grid { + grid-template-columns: 1fr; } } @media (max-width: 720px) { + body.auth-page { + padding: 20px 12px; + } + + .auth-shell { + width: 100%; + margin-top: 72px; + } + + .auth-panel { + padding: 18px; + } + .shell { - padding: 18px 12px 32px; + padding: 14px 12px 28px; } - .toolbar, - .host-header, - .panel-head, - .nacos-service-head { + .topbar { + position: static; + padding: 14px; + } + + .control-bar, + .config-toolbar, + .config-actions, + .panel-header { flex-direction: column; + align-items: stretch; } - .hero-side, - .service-grid, - .host-metrics, - .summary-grid, - .metric-grid, - .config-meta-grid, - .update-form-grid { - grid-template-columns: 1fr; + .control-group, + .control-group-spread, + .density-switch, + .action-group, + .host-tags, + .restart-pills { + width: 100%; } .search, @@ -762,19 +2111,61 @@ body { min-width: 100%; } - .config-toolbar, - .config-actions { - flex-direction: column; - align-items: stretch; + .summary-cards, + .bridge-list, + .service-meta-grid, + .summary-grid, + .metric-grid, + .infra-mini-metrics, + .config-meta-grid, + .update-form-grid, + .release-status-grid, + .infra-overview-grid, + .mongo-group-grid { + grid-template-columns: 1fr; } - .host-tags, - .restart-pills, - .action-group { + .status-band-item { + border-right: 0; + border-bottom: 1px solid var(--line); + justify-content: flex-start; + } + + .status-band-item:last-child { + border-bottom: 0; + } + + .service-layout-open { + grid-template-columns: 1fr; + } + + .host-inline-metrics, + .host-inline-state { width: 100%; } + .host-service-pill, .restart-pills .config-item { width: 100%; } + + .taskbar-context, + .taskbar-inline, + .metadata-strip, + .precheck-strip, + .activity-item-side { + width: 100%; + text-align: left; + } + + .editor-toolbar, + .activity-item { + grid-template-columns: 1fr; + } + + .diff-row { + grid-template-columns: 34px 34px 22px minmax(0, 1fr); + gap: 8px; + padding: 6px 8px; + } } diff --git a/static/app.js b/static/app.js index 8484c39..5f81c0c 100644 --- a/static/app.js +++ b/static/app.js @@ -1,17 +1,108 @@ const { createApp } = Vue; +const APP_TEMPLATE = document.getElementById("app")?.innerHTML || ""; const STORAGE_KEY = "hy-app-monitor-refresh-interval"; const UPDATE_OPERATION_STORAGE_KEY = "hy-app-monitor-service-update-operation"; +const GROUP_LABELS = { + app: "应用节点", + pay: "支付节点", + gateway: "网关节点", + nacos: "Nacos 节点", + mongo: "MongoDB 节点", +}; +const GROUP_ORDER = { + gateway: 10, + app: 20, + pay: 30, + nacos: 40, + mongo: 50, +}; +const VIEW_TABS = [ + { id: "overview", label: "总览" }, + { id: "services", label: "服务" }, + { id: "hosts", label: "主机" }, + { id: "infra", label: "基础设施" }, + { id: "config", label: "配置" }, + { id: "release", label: "发布" }, +]; +const PRIMARY_FILTERS = [ + { id: "all", label: "全部" }, + { id: "down", label: "异常" }, + { id: "gateway", label: "网关" }, + { id: "app", label: "应用" }, + { id: "pay", label: "支付" }, + { id: "infra", label: "基础设施" }, +]; +const BUSINESS_GROUPS = new Set(["gateway", "app", "pay"]); +const RELEASE_SECTIONS = [ + { id: "release-build", label: "构建参数" }, + { id: "release-targets", label: "更新目标" }, + { id: "release-log", label: "执行日志" }, + { id: "release-restart", label: "滚动重启" }, +]; +const CONFIG_TABS = [ + { id: "nacos", label: "Nacos" }, + { id: "go", label: "Go Env" }, + { id: "history", label: "历史版本" }, +]; +const INFRA_TABS = [ + { id: "overview", label: "状态总览" }, + { id: "nacos", label: "Nacos 服务" }, + { id: "mongo", label: "Mongo 指标" }, + { id: "tat", label: "TAT" }, +]; createApp({ + template: APP_TEMPLATE, data() { return { + activeView: "overview", + densityMode: "compact", + detailPanelOpen: false, + overviewHealthyHostsOpen: false, + viewTabs: VIEW_TABS, + primaryFilters: PRIMARY_FILTERS, + releaseSections: RELEASE_SECTIONS, + configTabs: CONFIG_TABS, + infraTabs: INFRA_TABS, + expandedHosts: [], + expandedNacosServices: [], + focusedHostKey: "", + selectedServiceKey: "", loading: false, error: "", filter: "all", keyword: "", + infraKeyword: "", + releaseKeyword: "", + releaseKindFilter: "all", + configTab: "nacos", + configEditorMode: "diff", + goEditorMode: "diff", + infraTab: "overview", + infraHealthyNacosOpen: false, + infraHealthyTatOpen: false, + infraMongoDetailsOpen: false, + collapsedConfigGroups: [], + copiedToken: "", + copiedTokenTimer: null, refreshIntervalSeconds: 10, refreshIntervalOptions: [5, 10, 15, 30, 60], + auth: { + username: "", + checking: false, + redirecting: false, + loggingOut: false, + }, + configHistory: [], + releaseHistory: [], + releasePrecheck: { + ok: false, + ranAt: "", + tone: "neutral", + title: "", + checks: [], + }, payload: { updatedAt: "", settings: { @@ -36,13 +127,24 @@ createApp({ hosts: [], nacos: { ok: false, + error: "", + baseUrl: "", + namespaceName: "", + namespaceId: "", summary: {}, services: [], namespaces: [], }, mongo: { ok: false, + error: "", + info: {}, summary: {}, + connections: {}, + memory: {}, + network: {}, + opcounters: {}, + wiredTigerCache: {}, databases: [], replicaSet: { configured: false, @@ -62,6 +164,9 @@ createApp({ validationMessage: "", saving: false, saveMessage: "", + baseUrl: "", + namespaceId: "", + namespaceName: "", loadedContent: "", editor: { group: "", @@ -102,11 +207,13 @@ createApp({ selectedServices: [], javaGitRef: "main", goGitRef: "main", + adminGitRef: "main", imageTag: "", skipMavenBuild: false, defaults: { javaGitRef: "main", goGitRef: "main", + adminGitRef: "main", dockerPlatform: "linux/amd64", }, builder: { @@ -114,6 +221,9 @@ createApp({ harborProject: "", javaRepoRoot: "", golangRepoRoot: "", + frontendRepoRoot: "", + frontendRepoUrl: "", + frontendPublicUrl: "", }, result: null, pollTimer: null, @@ -131,6 +241,19 @@ createApp({ selectedServices: [], result: null, }, + serviceLog: { + loading: false, + error: "", + service: "", + host: "", + container: "", + content: "", + updatedAt: "", + previewLines: 1000, + exportLines: 1000, + exportMaxLines: 10000, + exporting: false, + }, timer: null, }; }, @@ -144,53 +267,185 @@ createApp({ formattedUpdatedAt() { return this.formatDateTime(this.payload.updatedAt); }, - groupedHosts() { - const keyword = this.keyword.trim().toLowerCase(); + normalizedKeyword() { + return this.keyword.trim().toLowerCase(); + }, + normalizedInfraKeyword() { + return this.infraKeyword.trim().toLowerCase(); + }, + normalizedReleaseKeyword() { + return this.releaseKeyword.trim().toLowerCase(); + }, + allHostRows() { + return this.buildHostRows({ businessOnly: false, applyFilter: false, applyKeyword: false }); + }, + hostServiceAlertTotal() { + return this.allHostRows.filter((host) => host.downCount > 0).length; + }, + businessHosts() { + return this.buildHostRows({ businessOnly: true, applyFilter: false, applyKeyword: false }); + }, + overviewHostRows() { + return this.buildHostRows({ businessOnly: true, applyFilter: true, applyKeyword: true }); + }, + overviewAlertHosts() { + return this.overviewHostRows.filter((host) => host.hasAlert); + }, + overviewQuietHosts() { + return this.overviewHostRows.filter((host) => !host.hasAlert); + }, + filteredHostRows() { + return this.buildHostRows({ businessOnly: false, applyFilter: true, applyKeyword: true }); + }, + overviewHostRowGroups() { + return this.groupHosts(this.overviewHostRows); + }, + hostRowGroups() { + return this.groupHosts(this.filteredHostRows); + }, + detailPanelVisible() { + return this.detailPanelOpen; + }, + serviceMatrixColumns() { + if (this.filter === "infra") { + return []; + } - return (this.payload.hosts || []) - .map((host) => { - const hostMatched = [host.host, host.ip, host.group, host.instanceId] - .join(" ") - .toLowerCase() - .includes(keyword); + let columns = this.businessHosts; + if (["gateway", "app", "pay"].includes(this.filter)) { + columns = columns.filter((host) => host.group === this.filter); + } + if (this.focusedHostKey) { + columns = columns.filter((host) => host.host === this.focusedHostKey); + } - const filteredItems = (host.items || []).filter((item) => { - if (this.filter === "down" && item.ok) { - return false; - } - if (this.filter === "java" && item.kind !== "java") { - return false; - } - if (this.filter === "golang" && item.kind !== "golang") { - return false; - } - if (!keyword) { - return true; - } - return [item.host, item.ip, item.service, item.kind, item.group, item.url, item.detail] - .join(" ") - .toLowerCase() - .includes(keyword); + if (!this.normalizedKeyword) { + return columns; + } + + const keywordMatched = columns.filter((host) => host.searchText.includes(this.normalizedKeyword)); + return keywordMatched.length > 0 ? keywordMatched : columns; + }, + serviceMatrixColumnGroups() { + const groups = []; + this.serviceMatrixColumns.forEach((host) => { + const last = groups[groups.length - 1]; + if (last && last.group === host.group) { + last.span += 1; + return; + } + groups.push({ + group: host.group, + label: this.groupLabel(host.group), + span: 1, + }); + }); + return groups; + }, + allServiceRows() { + return this.buildServiceRows({ + applyFilter: false, + applyKeyword: false, + columns: this.businessHosts, + }); + }, + serviceMatrixRows() { + return this.buildServiceRows({ + applyFilter: true, + applyKeyword: true, + columns: this.serviceMatrixColumns, + }); + }, + serviceMatrixSections() { + const groups = new Map(); + this.serviceMatrixRows.forEach((row) => { + if (!groups.has(row.group)) { + groups.set(row.group, { + group: row.group, + label: this.groupLabel(row.group), + rows: [], + columns: this.serviceMatrixColumns.filter((host) => host.group === row.group), }); - - const shouldShowBecauseDown = this.filter === "down" && !(host.metrics && host.metrics.ok); - const shouldShowBecauseKeyword = keyword ? hostMatched || filteredItems.length > 0 : true; - const shouldShow = - shouldShowBecauseKeyword - && (this.filter === "all" || filteredItems.length > 0 || shouldShowBecauseDown || hostMatched); - - return { - ...host, - groupLabel: this.groupLabel(host.group), - items: filteredItems, - downCount: (host.items || []).filter((item) => !item.ok).length, - shouldShow, - }; - }) - .filter((host) => host.shouldShow); + } + groups.get(row.group).rows.push(row); + }); + return [...groups.values()] + .filter((section) => section.rows.length > 0 && section.columns.length > 0) + .map((section) => ({ + ...section, + rows: section.rows.map((row) => ({ + ...row, + sectionCells: section.columns.map((host) => row.instanceMap[host.host] || { + host: host.host, + ip: host.ip, + group: host.group, + groupLabel: host.groupLabel, + present: false, + ok: false, + level: "neutral", + statusCode: null, + latencyMs: null, + port: null, + detail: "", + url: "", + }), + })), + })) + .sort((left, right) => this.groupRank(left.group) - this.groupRank(right.group)); + }, + selectedServiceDetail() { + return this.serviceMatrixRows.find((row) => row.key === this.selectedServiceKey) || this.serviceMatrixRows[0] || null; + }, + selectedServiceHosts() { + return new Set((this.selectedServiceDetail?.instances || []).map((instance) => instance.host)); + }, + nacosConfigGroups() { + const groups = new Map(); + this.filteredNacosConfigs.forEach((item) => { + const groupKey = item.group || "default"; + if (!groups.has(groupKey)) { + groups.set(groupKey, { + key: groupKey, + label: groupKey, + items: [], + }); + } + groups.get(groupKey).items.push(item); + }); + return [...groups.values()].map((group) => ({ + ...group, + items: group.items.sort((left, right) => left.dataId.localeCompare(right.dataId, "zh-CN")), + })); + }, + selectedNacosConfigGroup() { + return this.nacosConfigGroups.find((group) => + group.items.some((item) => this.configKey(item.group, item.dataId) === this.nacosConfig.selectedKey) + )?.key || ""; + }, + configHistoryRows() { + const keyword = this.nacosConfig.keyword.trim().toLowerCase(); + return this.configHistory.filter((item) => ( + !keyword + || [item.scope, item.title, item.detail, item.time] + .join(" ") + .toLowerCase() + .includes(keyword) + )); + }, + currentNacosDiffRows() { + return this.buildLineDiffRows(this.nacosConfig.loadedContent, this.nacosConfig.editor.content); + }, + currentGoDiffRows() { + return this.buildLineDiffRows(this.goConfig.loadedContent, this.goConfig.editorContent); + }, + showNacosDiffEmpty() { + return this.configEditorMode === "diff" && this.currentNacosDiffRows.length === 0; + }, + showGoDiffEmpty() { + return this.goEditorMode === "diff" && this.currentGoDiffRows.length === 0; }, filteredNacosServices() { - const keyword = this.keyword.trim().toLowerCase(); + const keyword = this.normalizedInfraKeyword; return (this.payload.nacos?.services || []).filter((service) => { if (!keyword) { return true; @@ -210,6 +465,53 @@ createApp({ ); }); }, + filteredNacosServiceRows() { + return this.filteredNacosServices.sort((left, right) => ( + Number((right.unhealthyTotal || 0) > 0) - Number((left.unhealthyTotal || 0) > 0) + || (right.unhealthyTotal || 0) - (left.unhealthyTotal || 0) + || left.serviceName.localeCompare(right.serviceName, "zh-CN") + )); + }, + infraAlertNacosServices() { + return this.filteredNacosServiceRows.filter((service) => (service.unhealthyTotal || 0) > 0); + }, + infraHealthyNacosServices() { + return this.filteredNacosServiceRows.filter((service) => (service.unhealthyTotal || 0) === 0); + }, + infraVisibleHealthyNacosServices() { + return this.infraHealthyNacosOpen ? this.infraHealthyNacosServices : []; + }, + filteredMongoDatabases() { + const keyword = this.normalizedInfraKeyword; + return (this.payload.mongo.databases || []).filter((db) => ( + !keyword + || [db.name, db.collections, db.objects, db.dataSizeMb, db.storageSizeMb] + .join(" ") + .toLowerCase() + .includes(keyword) + )); + }, + tatAlertHosts() { + return this.allHostRows.filter((host) => ( + host.hasMetricIssue + && ( + !this.normalizedInfraKeyword + || host.searchText.includes(this.normalizedInfraKeyword) + ) + )); + }, + tatHealthyHosts() { + return this.allHostRows.filter((host) => ( + !host.hasMetricIssue + && ( + !this.normalizedInfraKeyword + || host.searchText.includes(this.normalizedInfraKeyword) + ) + )); + }, + visibleTatHealthyHosts() { + return this.infraHealthyTatOpen ? this.tatHealthyHosts : []; + }, filteredNacosConfigs() { const keyword = this.nacosConfig.keyword.trim().toLowerCase(); return (this.nacosConfig.items || []).filter((item) => { @@ -228,6 +530,297 @@ createApp({ goConfigDirty() { return this.goConfig.editorContent !== this.goConfig.loadedContent; }, + releaseTargetRows() { + const keyword = this.normalizedReleaseKeyword; + return (this.updateOps.items || []) + .map((item) => { + const images = Array.isArray(item.currentImages) ? item.currentImages : []; + const primaryImage = images[0] || ""; + const imageMeta = this.imageMeta(primaryImage); + const imageSummary = images.length > 1 + ? `${images.length} 个版本` + : primaryImage || "-"; + const currentLabel = item.currentLabel || (images.length > 1 ? "多版本" : (imageMeta.tag || "-")); + const currentTitle = item.currentTitle || imageSummary; + const detailLabel = item.detailLabel || (images.length > 1 ? `${images.length} 项` : imageMeta.digestShort); + const detailTitle = item.detailTitle || primaryImage || "-"; + const copyValue = item.copyValue || primaryImage || ""; + return { + ...item, + hostCount: (item.hosts || []).length, + hostsLabel: (item.hosts || []).map((host) => host.host).join(" / "), + imageTitle: images.join("\n"), + imageSummary, + currentLabel, + currentTitle, + detailLabel, + detailTitle, + copyValue, + digestFull: imageMeta.digestFull, + repositoryShort: item.repository ? this.truncateMiddle(item.repository, 18, 10) : imageMeta.repositoryShort, + currentImagePrimary: primaryImage, + currentImageCount: images.length, + searchText: [ + item.service, + item.kind, + item.repository, + currentLabel, + detailLabel, + ...(item.hosts || []).flatMap((host) => [host.host, host.ip, host.path, host.port]), + ...images, + ].join(" ").toLowerCase(), + }; + }) + .filter((item) => { + if (this.releaseKindFilter !== "all" && item.kind !== this.releaseKindFilter) { + return false; + } + if (keyword && !item.searchText.includes(keyword)) { + return false; + } + return true; + }) + .sort((left, right) => left.service.localeCompare(right.service, "zh-CN")); + }, + restartTargetRows() { + const keyword = this.normalizedReleaseKeyword; + return (this.restartOps.items || []) + .map((item) => ({ + ...item, + hostCount: (item.hosts || []).length, + hostsLabel: (item.hosts || []).map((host) => host.host).join(" / "), + searchText: [ + item.service, + item.kind, + ...(item.hosts || []).flatMap((host) => [host.host, host.ip, host.path, host.port]), + ].join(" ").toLowerCase(), + })) + .filter((item) => { + if (this.releaseKindFilter !== "all" && item.kind !== this.releaseKindFilter) { + return false; + } + if (keyword && !item.searchText.includes(keyword)) { + return false; + } + return true; + }) + .sort((left, right) => left.service.localeCompare(right.service, "zh-CN")); + }, + releaseStatusSummary() { + const affectedServices = this.updateOps.result?.services?.length + || this.updateOps.selectedServices.length + || 0; + const lastLog = (this.updateOps.logs || []).slice(-1)[0]; + return [ + { + label: "上次执行", + value: this.formatDateTime(this.updateOps.finishedAt || this.updateOps.startedAt), + tone: "neutral", + }, + { + label: "最近结果", + value: this.updateStatusLabel(), + tone: this.updateStatusBadgeClass(), + }, + { + label: "失败阶段", + value: this.updateOps.error ? (this.updateOps.stage || "failed") : "-", + tone: this.updateOps.error ? "bad" : "neutral", + }, + { + label: "影响服务", + value: String(affectedServices), + tone: affectedServices > 0 ? "warn" : "neutral", + }, + { + label: "最近日志", + value: lastLog?.message || "-", + tone: lastLog?.level === "error" ? "bad" : "neutral", + }, + ]; + }, + releaseHistoryRows() { + return this.releaseHistory.slice(0, 8); + }, + activeConfigTabLabel() { + return this.configTabs.find((item) => item.id === this.configTab)?.label || ""; + }, + updateAffectedServicesCount() { + return this.updateOps.result?.services?.length || this.updateOps.selectedServices.length || 0; + }, + summaryStripCards() { + const serviceDownTotal = this.allServiceRows.filter((row) => row.downCount > 0).length; + 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 tatOk = this.summary.hostMetricOk || 0; + const tatTotal = this.summary.hostTotal || 0; + + return [ + { + key: "service-down", + label: "服务", + value: `${serviceDownTotal}/${this.allServiceRows.length}`, + suffix: "异常", + tone: serviceDownTotal > 0 ? "bad" : "ok", + }, + { + key: "instance-down", + label: "实例", + value: `${this.summary.down || 0}/${this.summary.total || 0}`, + suffix: "异常", + tone: (this.summary.down || 0) > 0 ? "bad" : "ok", + }, + { + key: "host-alert", + label: "主机", + value: `${hostAlertTotal}/${this.summary.hostTotal || 0}`, + suffix: "异常", + tone: hostAlertTotal > 0 ? "warn" : "ok", + }, + { + key: "nacos", + label: "Nacos", + value: nacosUnavailable ? "ERR" : String(this.summary.nacosUnhealthyInstanceTotal || 0), + suffix: nacosUnavailable ? "" : "异常", + tone: nacosUnavailable || (this.summary.nacosUnhealthyInstanceTotal || 0) > 0 ? "bad" : "ok", + }, + { + key: "mongo", + label: "Mongo", + value: mongoUnavailable ? "ERR" : String(this.summary.mongoDatabaseTotal || 0), + suffix: mongoUnavailable ? "" : "库", + tone: mongoUnavailable ? "bad" : "ok", + }, + { + key: "tat", + label: "TAT", + value: `${tatOk}/${tatTotal}`, + suffix: "正常", + tone: (this.summary.hostMetricDown || 0) > 0 ? "warn" : "ok", + }, + ]; + }, + overviewBridgeAlerts() { + return this.serviceMatrixRows + .filter((row) => row.downCount > 0) + .map((row) => ({ + key: row.key, + service: row.service, + groupLabel: row.groupLabel, + downCount: row.downCount, + hosts: row.instances + .filter((instance) => !instance.ok) + .map((instance) => instance.host), + })) + .slice(0, 12); + }, + overviewInfraCards() { + const nacosTone = !(this.payload.nacos && this.payload.nacos.ok) + ? "bad" + : (this.summary.nacosUnhealthyInstanceTotal || 0) > 0 + ? "warn" + : "ok"; + const mongoTone = this.payload.mongo && this.payload.mongo.ok ? "ok" : "bad"; + const tatTone = (this.summary.hostMetricDown || 0) > 0 ? "warn" : "ok"; + + return [ + { + key: "nacos", + view: "infra", + label: "Nacos", + badge: this.payload.nacos && this.payload.nacos.ok ? "连接正常" : "连接异常", + tone: nacosTone, + meta: this.payload.nacos.baseUrl || "deploy 直连", + metrics: [ + { label: "服务", value: this.payload.nacos.summary.serviceTotal || 0 }, + { label: "实例", value: this.payload.nacos.summary.instanceTotal || 0 }, + { label: "异常", value: this.payload.nacos.summary.unhealthyInstanceTotal || 0 }, + ], + }, + { + key: "mongo", + view: "infra", + label: "MongoDB", + badge: this.payload.mongo && this.payload.mongo.ok ? "连接正常" : "连接异常", + tone: mongoTone, + meta: this.payload.mongo.info.host || "deploy 直连", + metrics: [ + { label: "数据库", value: this.payload.mongo.summary.databaseTotal || 0 }, + { label: "集合", value: this.payload.mongo.summary.collectionTotal || 0 }, + { label: "连接", value: this.payload.mongo.connections.current ?? 0 }, + ], + }, + { + key: "tat", + view: "hosts", + label: "TAT", + badge: (this.summary.hostMetricDown || 0) > 0 ? "采集异常" : "采集正常", + tone: tatTone, + meta: "主机资源采集", + metrics: [ + { label: "主机", value: this.summary.hostTotal || 0 }, + { label: "正常", value: this.summary.hostMetricOk || 0 }, + { label: "异常", value: this.summary.hostMetricDown || 0 }, + ], + }, + ]; + }, + alertFeed() { + const alerts = []; + + this.allServiceRows.forEach((row) => { + row.instances + .filter((instance) => !instance.ok) + .forEach((instance) => { + alerts.push({ + key: `service-${row.service}-${instance.host}`, + type: "service", + service: row.service, + tone: "bad", + title: row.service, + text: `${instance.host} · ${instance.detail || "HTTP 异常"}`, + }); + }); + }); + + this.allHostRows + .filter((host) => host.hasMetricIssue) + .forEach((host) => { + alerts.push({ + key: `host-${host.host}`, + type: "host", + host: host.host, + tone: "warn", + title: host.host, + text: host.metrics.error || "主机指标异常", + }); + }); + + if (this.payload.nacos.error || !(this.payload.nacos && this.payload.nacos.ok)) { + alerts.push({ + key: "nacos-error", + type: "infra", + view: "infra", + tone: "bad", + title: "Nacos", + text: this.payload.nacos.error || "连接异常", + }); + } + + if (this.payload.mongo.error || !(this.payload.mongo && this.payload.mongo.ok)) { + alerts.push({ + key: "mongo-error", + type: "infra", + view: "infra", + tone: "bad", + title: "MongoDB", + text: this.payload.mongo.error || "连接异常", + }); + } + + return alerts.slice(0, 5); + }, restartTargetMap() { return Object.fromEntries((this.restartOps.items || []).map((item) => [item.service, item])); }, @@ -244,26 +837,656 @@ createApp({ }) .join("\n"); }, + serviceLogDownloadUrl() { + if (!this.serviceLog.service || !this.serviceLog.host) { + return ""; + } + const requested = Number(this.serviceLog.exportLines || this.serviceLog.previewLines || 1000); + const fallback = Number(this.serviceLog.previewLines || 1000); + const lines = Math.min( + Math.max(1, Number.isNaN(requested) ? fallback : requested), + Number(this.serviceLog.exportMaxLines || 10000), + ); + const query = new URLSearchParams({ + service: this.serviceLog.service, + host: this.serviceLog.host, + lines: String(lines), + }); + return `/api/ops/service-log/export?${query.toString()}`; + }, }, watch: { refreshIntervalSeconds() { window.localStorage.setItem(STORAGE_KEY, String(this.refreshIntervalSeconds)); this.applyRefreshTimer(); }, + activeView(nextValue) { + if (nextValue === "services") { + this.detailPanelOpen = true; + } + if (nextValue === "config") { + this.configTab = this.configTab || "nacos"; + } + if (nextValue === "infra") { + this.infraTab = this.infraTab || "overview"; + } + }, + selectedServiceKey() { + this.syncServiceLogTarget(); + }, }, methods: { + groupHosts(hostRows) { + const groups = new Map(); + hostRows.forEach((host) => { + if (!groups.has(host.group)) { + groups.set(host.group, { + group: host.group, + label: this.groupLabel(host.group), + items: [], + }); + } + groups.get(host.group).items.push(host); + }); + return [...groups.values()].sort((left, right) => this.groupRank(left.group) - this.groupRank(right.group)); + }, + groupRank(group) { + return GROUP_ORDER[group] || 999; + }, groupLabel(group) { - return { - app: "应用节点", - pay: "支付节点", - gateway: "网关节点", - nacos: "Nacos 节点", - mongo: "MongoDB 节点", - }[group] || group; + return GROUP_LABELS[group] || group; }, toneClass(level) { return `tone-${level || "neutral"}`; }, + recordConfigHistory(scope, title, detail, tone = "neutral") { + this.configHistory = [ + { + id: `config-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + scope, + title, + detail, + tone, + time: this.formatDateTime(new Date().toISOString()), + }, + ...this.configHistory, + ].slice(0, 40); + }, + recordReleaseHistory(scope, title, detail, tone = "neutral") { + this.releaseHistory = [ + { + id: `release-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + scope, + title, + detail, + tone, + time: this.formatDateTime(new Date().toISOString()), + }, + ...this.releaseHistory, + ].slice(0, 40); + }, + explainError(message) { + const text = String(message || "").trim(); + if (!text) { + return ""; + } + if (text.includes("yaml renderer unavailable") || text.includes("No module named 'yaml'")) { + return "缺少 PyYAML,当前无法渲染 compose 模板"; + } + return text; + }, + scrollToSection(sectionId) { + this.$nextTick(() => { + const node = document.getElementById(sectionId); + if (node) { + node.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }); + }, + imageMeta(imageRef) { + const raw = String(imageRef || "").trim(); + if (!raw) { + return { + repository: "", + repositoryShort: "-", + tag: "", + digestFull: "", + digestShort: "-", + }; + } + const [withoutDigest, digestRaw = ""] = raw.split("@"); + const lastSlash = withoutDigest.lastIndexOf("/"); + const colon = withoutDigest.lastIndexOf(":"); + const hasTag = colon > lastSlash; + const tag = hasTag ? withoutDigest.slice(colon + 1) : ""; + const repository = hasTag ? withoutDigest.slice(0, colon) : withoutDigest; + const digestFull = digestRaw ? (digestRaw.startsWith("sha256:") ? digestRaw : `sha256:${digestRaw}`) : ""; + return { + repository, + repositoryShort: this.truncateMiddle(repository, 18, 10), + tag, + digestFull, + digestShort: digestFull ? this.truncateMiddle(digestFull, 10, 6) : "-", + }; + }, + truncateMiddle(value, start = 12, end = 10) { + const text = String(value || ""); + if (!text) { + return ""; + } + if (text.length <= start + end + 3) { + return text; + } + return `${text.slice(0, start)}...${text.slice(-end)}`; + }, + async copyText(text, token = "") { + const value = String(text || ""); + if (!value) { + return; + } + try { + await navigator.clipboard.writeText(value); + this.copiedToken = token || value; + if (this.copiedTokenTimer) { + window.clearTimeout(this.copiedTokenTimer); + } + this.copiedTokenTimer = window.setTimeout(() => { + this.copiedToken = ""; + this.copiedTokenTimer = null; + }, 1600); + } catch (error) { + this.recordReleaseHistory("copy", "复制失败", error instanceof Error ? error.message : String(error), "bad"); + } + }, + isCopied(token) { + return this.copiedToken === token; + }, + runReleasePrecheck() { + const checks = [ + { + label: "更新目标", + ok: this.updateOps.items.length > 0, + detail: this.updateOps.items.length > 0 ? `${this.updateOps.items.length} 项可更新服务` : this.explainError(this.updateOps.error) || "暂无更新目标", + }, + { + label: "更新选择", + ok: this.updateOps.selectedServices.length > 0, + detail: this.updateOps.selectedServices.length > 0 ? this.updateOps.selectedServices.join(", ") : "未选择更新服务", + }, + { + label: "滚动重启目标", + ok: this.restartOps.items.length > 0, + detail: this.restartOps.items.length > 0 ? `${this.restartOps.items.length} 项可重启服务` : (this.restartOps.error || "暂无重启目标"), + }, + { + label: "构建参数", + ok: (() => { + const selectedKinds = new Set( + (this.updateOps.items || []) + .filter((item) => this.updateOps.selectedServices.includes(item.service)) + .map((item) => item.kind) + ); + return ( + (!selectedKinds.has("java") || Boolean(this.updateOps.javaGitRef.trim())) + && (!selectedKinds.has("golang") || Boolean(this.updateOps.goGitRef.trim())) + && (!selectedKinds.has("frontend") || Boolean(this.updateOps.adminGitRef.trim())) + ); + })(), + detail: `Java ${this.updateOps.javaGitRef || "-"} / Go ${this.updateOps.goGitRef || "-"} / Admin ${this.updateOps.adminGitRef || "-"}`, + }, + ]; + const ok = checks.every((item) => item.ok); + this.releasePrecheck = { + ok, + ranAt: new Date().toISOString(), + tone: ok ? "ok" : "warn", + title: ok ? "预检查通过" : "预检查未通过", + checks, + }; + this.recordReleaseHistory("precheck", this.releasePrecheck.title, checks.map((item) => `${item.label}: ${item.detail}`).join(" | "), ok ? "ok" : "warn"); + }, + async refreshReleaseTargets() { + await this.refreshUpdateTargets(); + await this.refreshRestartTargets(); + this.runReleasePrecheck(); + }, + toggleConfigGroup(groupKey) { + const next = new Set(this.collapsedConfigGroups); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + this.collapsedConfigGroups = [...next]; + }, + isConfigGroupCollapsed(groupKey) { + return this.collapsedConfigGroups.includes(groupKey); + }, + toggleNacosService(serviceName) { + const next = new Set(this.expandedNacosServices); + if (next.has(serviceName)) { + next.delete(serviceName); + } else { + next.add(serviceName); + } + this.expandedNacosServices = [...next]; + }, + isNacosServiceExpanded(serviceName) { + return this.expandedNacosServices.includes(serviceName); + }, + restoreNacosContent() { + this.nacosConfig.editor.content = this.nacosConfig.loadedContent; + this.recordConfigHistory("nacos", "恢复编辑内容", this.nacosConfig.editor.dataId || "-", "warn"); + }, + restoreGoContent() { + this.goConfig.editorContent = this.goConfig.loadedContent; + this.recordConfigHistory("go", "恢复 Go 配置", "已回退到当前线上版本", "warn"); + }, + splitLines(text) { + return String(text || "").replace(/\r\n/g, "\n").split("\n"); + }, + buildLineDiffRows(beforeText, afterText) { + const before = this.splitLines(beforeText); + const after = this.splitLines(afterText); + const rows = []; + const max = Math.max(before.length, after.length); + let leftNo = 1; + let rightNo = 1; + for (let index = 0; index < max; index += 1) { + const left = before[index]; + const right = after[index]; + if (left === right) { + if (left !== undefined) { + leftNo += 1; + rightNo += 1; + } + continue; + } + if (left !== undefined) { + rows.push({ + key: `remove-${index}-${leftNo}`, + type: "remove", + leftNo, + rightNo: "", + text: left, + }); + leftNo += 1; + } + if (right !== undefined) { + rows.push({ + key: `add-${index}-${rightNo}`, + type: "add", + leftNo: "", + rightNo, + text: right, + }); + rightNo += 1; + } + } + return rows; + }, + toggleOverviewHealthyHosts() { + this.overviewHealthyHostsOpen = !this.overviewHealthyHostsOpen; + }, + toggleDetailPanel() { + this.detailPanelOpen = !this.detailPanelOpen; + }, + isSelectedService(serviceKey) { + return Boolean(this.selectedServiceDetail && this.selectedServiceDetail.key === serviceKey); + }, + selectService(serviceKey) { + this.selectedServiceKey = serviceKey; + this.detailPanelOpen = true; + }, + clearServiceLog() { + this.serviceLog.loading = false; + this.serviceLog.exporting = false; + this.serviceLog.error = ""; + this.serviceLog.service = ""; + this.serviceLog.host = ""; + this.serviceLog.container = ""; + this.serviceLog.content = ""; + this.serviceLog.updatedAt = ""; + this.serviceLog.previewLines = 1000; + this.serviceLog.exportLines = 1000; + this.serviceLog.exportMaxLines = 10000; + }, + syncServiceLogTarget() { + if (!this.serviceLog.service || !this.serviceLog.host) { + return; + } + if (!this.selectedServiceDetail || this.selectedServiceDetail.service !== this.serviceLog.service) { + this.clearServiceLog(); + return; + } + const exists = (this.selectedServiceDetail.instances || []).some((instance) => instance.host === this.serviceLog.host); + if (!exists) { + this.clearServiceLog(); + } + }, + isServiceLogActive(instance) { + return this.serviceLog.service === (this.selectedServiceDetail?.service || "") && this.serviceLog.host === instance.host; + }, + async openServiceLog(instance) { + const serviceName = this.selectedServiceDetail?.service || ""; + const hostName = instance?.host || ""; + if (!serviceName || !hostName) { + return; + } + + this.serviceLog.loading = true; + this.serviceLog.error = ""; + this.serviceLog.service = serviceName; + this.serviceLog.host = hostName; + this.serviceLog.container = ""; + this.serviceLog.content = ""; + + try { + const query = new URLSearchParams({ service: serviceName, host: hostName }); + const { response, payload } = await this.requestJson(`/api/ops/service-log?${query.toString()}`); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.serviceLog.service = payload.service || serviceName; + this.serviceLog.host = payload.host || hostName; + this.serviceLog.container = payload.container || ""; + this.serviceLog.content = typeof payload.content === "string" ? payload.content : ""; + this.serviceLog.updatedAt = payload.updatedAt || ""; + this.serviceLog.previewLines = Number(payload.previewLines || payload.lines || 1000); + this.serviceLog.exportMaxLines = Number(payload.exportMaxLines || 10000); + this.serviceLog.exportLines = Math.min( + Math.max(1, Number(this.serviceLog.exportLines || this.serviceLog.previewLines)), + this.serviceLog.exportMaxLines, + ); + } catch (error) { + this.serviceLog.error = error instanceof Error ? error.message : String(error); + } finally { + this.serviceLog.loading = false; + } + }, + clearFocusedHost() { + this.focusedHostKey = ""; + }, + selectHost(hostName) { + const next = new Set(this.expandedHosts); + if (next.has(hostName)) { + next.delete(hostName); + } else { + next.add(hostName); + } + this.expandedHosts = [...next]; + this.focusedHostKey = this.focusedHostKey === hostName ? "" : hostName; + }, + toggleHost(hostName) { + const next = new Set(this.expandedHosts); + if (next.has(hostName)) { + next.delete(hostName); + } else { + next.add(hostName); + } + this.expandedHosts = [...next]; + }, + isHostExpanded(hostName) { + return this.expandedHosts.includes(hostName); + }, + isHostHighlighted(host) { + return this.focusedHostKey === host.host || this.selectedServiceHosts.has(host.host); + }, + jumpToAlert(alert) { + if (alert.type === "service" && alert.service) { + this.selectService(alert.service); + this.activeView = "services"; + return; + } + if (alert.type === "host" && alert.host) { + this.activeView = "hosts"; + this.focusedHostKey = alert.host; + if (!this.expandedHosts.includes(alert.host)) { + this.expandedHosts = [...this.expandedHosts, alert.host]; + } + return; + } + this.activeView = alert.view || "infra"; + }, + matrixCellTitle(cell) { + if (!cell.present) { + return "未部署"; + } + const parts = [ + cell.host || "", + `HTTP ${cell.statusCode || (cell.ok ? "UP" : "ERR")}`, + ]; + if (cell.latencyMs != null) { + parts.push(this.formatLatency(cell.latencyMs)); + } + if (cell.detail) { + parts.push(cell.detail); + } + return parts.filter(Boolean).join(" · "); + }, + buildHostRows({ businessOnly = false, applyFilter = false, applyKeyword = false } = {}) { + const keyword = applyKeyword ? this.normalizedKeyword : ""; + + return (this.payload.hosts || []) + .filter((host) => !businessOnly || ((host.items || []).length > 0)) + .map((host) => { + const metrics = host.metrics || {}; + const items = (host.items || []) + .map((item) => ({ + ...item, + level: item.level || (item.ok ? "ok" : "bad"), + })) + .sort((left, right) => ( + Number(right.ok === false) - Number(left.ok === false) + || left.service.localeCompare(right.service, "zh-CN") + )); + const alertItems = items.filter((item) => !item.ok); + const downCount = alertItems.length; + const hasMetricIssue = metrics.ok === false || Boolean(metrics.error); + const alertServices = [...new Set(alertItems.map((item) => item.service))]; + const alertSummary = alertServices.length > 0 + ? alertServices.join(" / ") + : hasMetricIssue + ? "指标采集异常" + : "全部正常"; + const searchText = [ + host.host, + host.ip, + host.group, + host.instanceId, + ...items.flatMap((item) => [ + item.service, + item.kind, + item.detail, + item.url, + item.port, + item.statusCode, + ]), + ] + .join(" ") + .toLowerCase(); + + return { + ...host, + metrics, + items, + alertItems, + alertSummary, + groupLabel: this.groupLabel(host.group), + serviceTotal: items.length, + downCount, + hasMetricIssue, + hasAlert: downCount > 0 || hasMetricIssue, + searchText, + }; + }) + .filter((host) => { + if (applyFilter) { + if (this.filter === "down" && !host.hasAlert) { + return false; + } + if (this.filter === "infra" && BUSINESS_GROUPS.has(host.group)) { + return false; + } + if (["gateway", "app", "pay"].includes(this.filter) && host.group !== this.filter) { + return false; + } + } + + if (keyword && !host.searchText.includes(keyword)) { + return false; + } + + return true; + }) + .sort((left, right) => ( + Number(right.hasAlert) - Number(left.hasAlert) + || this.groupRank(left.group) - this.groupRank(right.group) + || left.host.localeCompare(right.host, "zh-CN") + )); + }, + buildServiceRows({ applyFilter = false, applyKeyword = false, columns = null } = {}) { + const columnHosts = Array.isArray(columns) ? columns : this.businessHosts; + const keyword = applyKeyword ? this.normalizedKeyword : ""; + const rowMap = new Map(); + + this.businessHosts.forEach((host) => { + (host.items || []).forEach((item) => { + const rowKey = item.service; + if (!rowMap.has(rowKey)) { + rowMap.set(rowKey, { + key: rowKey, + service: item.service, + kind: item.kind || "-", + group: item.group || host.group, + groupLabel: this.groupLabel(item.group || host.group), + deployedCount: 0, + downCount: 0, + okCount: 0, + latencyValues: [], + instanceMap: {}, + instances: [], + }); + } + + const row = rowMap.get(rowKey); + const latencyMs = item.latencyMs == null || Number.isNaN(Number(item.latencyMs)) + ? null + : Number(item.latencyMs); + const instance = { + host: host.host, + ip: host.ip, + group: host.group, + groupLabel: host.groupLabel, + present: true, + ok: Boolean(item.ok), + level: item.level || (item.ok ? "ok" : "bad"), + statusCode: item.statusCode, + latencyMs, + port: item.port, + detail: item.detail || "", + url: item.url || "", + kind: item.kind || row.kind, + }; + + row.instanceMap[host.host] = instance; + row.instances.push(instance); + row.deployedCount += 1; + row.kind = item.kind || row.kind; + + if (instance.ok) { + row.okCount += 1; + } else { + row.downCount += 1; + } + + if (latencyMs != null) { + row.latencyValues.push(latencyMs); + } + }); + }); + + return [...rowMap.values()] + .map((row) => { + const cells = columnHosts.map((host) => row.instanceMap[host.host] || { + host: host.host, + ip: host.ip, + group: host.group, + groupLabel: host.groupLabel, + present: false, + ok: false, + level: "neutral", + statusCode: null, + latencyMs: null, + port: null, + detail: "", + url: "", + }); + + const avgLatencyMs = row.latencyValues.length > 0 + ? Math.round(row.latencyValues.reduce((sum, value) => sum + value, 0) / row.latencyValues.length) + : null; + const searchText = [ + row.service, + row.kind, + row.group, + ...row.instances.flatMap((instance) => [ + instance.host, + instance.ip, + instance.detail, + instance.url, + instance.port, + instance.statusCode, + ]), + ] + .join(" ") + .toLowerCase(); + + return { + ...row, + cells, + avgLatencyMs, + searchText, + }; + }) + .filter((row) => { + if (this.focusedHostKey && !row.instanceMap[this.focusedHostKey]) { + return false; + } + if (applyFilter) { + if (this.filter === "down" && row.downCount === 0) { + return false; + } + if (this.filter === "infra") { + return false; + } + if (["gateway", "app", "pay"].includes(this.filter) && row.group !== this.filter) { + return false; + } + } + + if (keyword && !row.searchText.includes(keyword)) { + return false; + } + + return true; + }) + .sort((left, right) => ( + Number(right.downCount > 0) - Number(left.downCount > 0) + || right.downCount - left.downCount + || left.service.localeCompare(right.service, "zh-CN") + )); + }, + statusCellLabel(cell) { + if (!cell.present) { + return ""; + } + if (cell.statusCode && Number(cell.statusCode) > 0) { + return String(cell.statusCode); + } + return cell.ok ? "UP" : "ERR"; + }, configKey(group, dataId) { return `${group}@@${dataId}`; }, @@ -304,6 +1527,12 @@ createApp({ } return Number(value).toLocaleString(); }, + formatLatency(value) { + if (value == null || Number.isNaN(Number(value))) { + return "-"; + } + return `${Math.round(Number(value))}ms`; + }, formatDateTime(value) { if (!value) { return "-"; @@ -442,6 +1671,22 @@ createApp({ this.updateOps.message = operation.message || ""; } + if (operation.status === "success") { + this.recordReleaseHistory( + "update", + "服务更新完成", + `${(operation.result?.services || []).join(", ") || "-"} · ${this.updateOps.finishedAt || this.updateOps.updatedAt || "-"}`, + "ok", + ); + } else if (operation.status === "failed") { + this.recordReleaseHistory( + "update", + "服务更新失败", + this.explainError(this.updateOps.error || operation.message || "服务更新失败"), + "bad", + ); + } + this.persistUpdateOperationId(); this.scrollUpdateLogToBottom(); }, @@ -459,7 +1704,6 @@ createApp({ return; } - // 轮询只保留一个在途请求,避免旧响应晚到后把新日志覆盖掉。 if (this.updateOps.statusRequestInFlight) { return; } @@ -472,13 +1716,11 @@ createApp({ try { const query = new URLSearchParams({ id: operationId }); - const response = await fetch(`/api/ops/update-status?${query.toString()}`, { cache: "no-store" }); - const payload = await this.readJsonResponse(response); + const { response, payload } = await this.requestJson(`/api/ops/update-status?${query.toString()}`); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } - // 新任务已经开始时,直接丢掉旧任务轮询结果。 if (requestEpoch !== this.updateOps.requestEpoch) { return; } @@ -487,7 +1729,6 @@ createApp({ return; } - // 保护日志顺序,旧轮询结果不能回退页面状态。 if (requestSeq < this.updateOps.statusAppliedSeq) { return; } @@ -576,17 +1817,195 @@ createApp({ throw new Error(text); } }, + handleUnauthorized() { + if (this.auth.redirecting) { + return; + } + + this.auth.redirecting = true; + this.stopUpdatePolling(); + + if (this.timer) { + window.clearInterval(this.timer); + this.timer = null; + } + + window.sessionStorage.removeItem(UPDATE_OPERATION_STORAGE_KEY); + window.location.replace("/login"); + }, + async authFetch(url, options = {}) { + const requestOptions = { + credentials: "same-origin", + ...options, + }; + + if (typeof requestOptions.cache === "undefined") { + requestOptions.cache = "no-store"; + } + + return fetch(url, requestOptions); + }, + async requestJson(url, options = {}) { + const response = await this.authFetch(url, options); + const payload = await this.readJsonResponse(response); + + if (response.status === 401) { + this.handleUnauthorized(); + throw new Error(payload.error || "authentication required"); + } + + return { response, payload }; + }, + async refreshSession() { + this.auth.checking = true; + + try { + const response = await fetch("/api/auth/session", { + cache: "no-store", + credentials: "same-origin", + }); + const payload = await this.readJsonResponse(response); + + if (response.status === 401) { + this.handleUnauthorized(); + return false; + } + + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + + this.auth.username = payload.username || ""; + return true; + } finally { + this.auth.checking = false; + } + }, + async bootstrap() { + const root = document.getElementById("app"); + const bootIndicator = document.getElementById("boot-indicator"); + + try { + const authenticated = await this.refreshSession(); + if (!authenticated) { + return; + } + } catch (error) { + this.error = error instanceof Error ? error.message : String(error); + } finally { + if (root) { + root.removeAttribute("v-cloak"); + } + if (bootIndicator) { + bootIndicator.hidden = true; + } + } + + if (this.error) { + return; + } + + await Promise.allSettled([ + this.refresh(), + this.refreshNacosConfigs({ preserveSelection: false, reloadSelected: true }), + this.refreshGoConfig(), + this.refreshUpdateTargets(), + this.refreshRestartTargets(), + ]); + + const lastOperationId = window.sessionStorage.getItem(UPDATE_OPERATION_STORAGE_KEY) || ""; + if (!lastOperationId) { + return; + } + + await this.fetchUpdateOperationStatus(lastOperationId, { silent: true }); + if (this.updateOps.operationId) { + this.startUpdatePolling(); + } + }, + async logout() { + if (this.auth.loggingOut) { + return; + } + + this.auth.loggingOut = true; + + try { + await this.authFetch("/api/auth/logout", { + method: "POST", + }); + } finally { + this.handleUnauthorized(); + } + }, + async downloadFile(url, fallbackName) { + const response = await this.authFetch(url); + + if (response.status === 401) { + this.handleUnauthorized(); + return; + } + + if (!response.ok) { + const contentType = response.headers.get("Content-Type") || ""; + if (contentType.includes("application/json")) { + const payload = await this.readJsonResponse(response); + throw new Error(payload.error || `HTTP ${response.status}`); + } + throw new Error((await response.text()) || `HTTP ${response.status}`); + } + + const blob = await response.blob(); + const contentDisposition = response.headers.get("Content-Disposition") || ""; + const matched = contentDisposition.match(/filename="?([^";]+)"?/i); + const fileName = matched?.[1] || fallbackName; + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = objectUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(objectUrl); + }, + async downloadServiceLog() { + if (!this.serviceLogDownloadUrl) { + return; + } + + this.serviceLog.exporting = true; + this.serviceLog.error = ""; + + try { + await this.downloadFile( + this.serviceLogDownloadUrl, + `${this.serviceLog.service}-${this.serviceLog.host}-${this.serviceLog.exportLines}.log`, + ); + } catch (error) { + this.serviceLog.error = error instanceof Error ? error.message : String(error); + } finally { + this.serviceLog.exporting = false; + } + }, async refresh() { this.loading = true; this.error = ""; try { - const response = await fetch("/api/monitor/services", { cache: "no-store" }); - const payload = await this.readJsonResponse(response); + const { response, payload } = await this.requestJson("/api/monitor/services"); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.payload = payload; this.syncRefreshSettings(); + + if (!this.selectedServiceDetail && this.allServiceRows.length > 0) { + this.selectedServiceKey = this.allServiceRows[0].key; + } + + if (this.selectedServiceKey && !this.allServiceRows.some((row) => row.key === this.selectedServiceKey)) { + this.selectedServiceKey = this.allServiceRows[0]?.key || ""; + } + this.syncServiceLogTarget(); } catch (error) { this.error = error instanceof Error ? error.message : String(error); } finally { @@ -600,13 +2019,15 @@ createApp({ this.nacosConfig.saveMessage = ""; try { - const response = await fetch("/api/nacos/configs", { cache: "no-store" }); - const payload = await this.readJsonResponse(response); + const { response, payload } = await this.requestJson("/api/nacos/configs"); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.nacosConfig.items = Array.isArray(payload.items) ? payload.items : []; + this.nacosConfig.baseUrl = payload.baseUrl || ""; + this.nacosConfig.namespaceId = payload.namespaceId || ""; + this.nacosConfig.namespaceName = payload.namespaceName || ""; const hasPrevious = previousKey && this.nacosConfig.items.some((item) => this.configKey(item.group, item.dataId) === previousKey); @@ -627,8 +2048,10 @@ createApp({ if (!sameAsCurrent || reloadSelected || !this.nacosConfig.loadedContent) { await this.openNacosConfigByKey(nextKey); } + this.recordConfigHistory("nacos", "刷新 Nacos 配置", `${this.nacosConfig.items.length} 项`, "neutral"); } catch (error) { this.nacosConfig.error = error instanceof Error ? error.message : String(error); + this.recordConfigHistory("nacos", "刷新 Nacos 配置失败", this.explainError(this.nacosConfig.error), "bad"); } finally { this.nacosConfig.loading = false; } @@ -651,8 +2074,7 @@ createApp({ try { const query = new URLSearchParams({ group: item.group, dataId: item.dataId }); - const response = await fetch(`/api/nacos/config?${query.toString()}`, { cache: "no-store" }); - const payload = await this.readJsonResponse(response); + const { response, payload } = await this.requestJson(`/api/nacos/config?${query.toString()}`); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } @@ -668,8 +2090,10 @@ createApp({ md5: item.md5 || "", content: this.nacosConfig.loadedContent, }; + this.recordConfigHistory("nacos", "打开配置", `${item.group}/${item.dataId}`, "neutral"); } catch (error) { this.nacosConfig.detailError = error instanceof Error ? error.message : String(error); + this.recordConfigHistory("nacos", "打开配置失败", this.explainError(this.nacosConfig.detailError), "bad"); } finally { this.nacosConfig.detailLoading = false; } @@ -684,7 +2108,7 @@ createApp({ this.nacosConfig.validationMessage = ""; try { - const response = await fetch("/api/nacos/config/validate", { + const { response, payload } = await this.requestJson("/api/nacos/config/validate", { method: "POST", headers: { "Content-Type": "application/json", @@ -696,7 +2120,6 @@ createApp({ content: this.nacosConfig.editor.content, }), }); - const payload = await this.readJsonResponse(response); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } @@ -704,8 +2127,10 @@ createApp({ throw new Error(payload.message || "config validation failed"); } this.nacosConfig.validationMessage = payload.message || "格式校验通过"; + this.recordConfigHistory("nacos", "校验通过", `${this.nacosConfig.editor.group}/${this.nacosConfig.editor.dataId}`, "ok"); } catch (error) { this.nacosConfig.detailError = error instanceof Error ? error.message : String(error); + this.recordConfigHistory("nacos", "校验失败", this.explainError(this.nacosConfig.detailError), "bad"); } finally { this.nacosConfig.validating = false; } @@ -720,7 +2145,7 @@ createApp({ this.nacosConfig.saveMessage = ""; try { - const response = await fetch("/api/nacos/config", { + const { response, payload } = await this.requestJson("/api/nacos/config", { method: "POST", headers: { "Content-Type": "application/json", @@ -732,16 +2157,17 @@ createApp({ content: this.nacosConfig.editor.content, }), }); - const payload = await this.readJsonResponse(response); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.nacosConfig.loadedContent = this.nacosConfig.editor.content; this.nacosConfig.saveMessage = `已保存 ${this.nacosConfig.editor.group}/${this.nacosConfig.editor.dataId}`; + this.recordConfigHistory("nacos", "保存成功", `${this.nacosConfig.editor.group}/${this.nacosConfig.editor.dataId}`, "ok"); await this.refreshNacosConfigs({ preserveSelection: true, reloadSelected: true }); } catch (error) { this.nacosConfig.detailError = error instanceof Error ? error.message : String(error); + this.recordConfigHistory("nacos", "保存失败", this.explainError(this.nacosConfig.detailError), "bad"); } finally { this.nacosConfig.saving = false; } @@ -752,8 +2178,7 @@ createApp({ this.goConfig.saveMessage = ""; try { - const response = await fetch("/api/runtime/golang-config", { cache: "no-store" }); - const payload = await this.readJsonResponse(response); + const { response, payload } = await this.requestJson("/api/runtime/golang-config"); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } @@ -761,8 +2186,10 @@ createApp({ this.goConfig.diffKeys = Array.isArray(payload.diffKeys) ? payload.diffKeys : []; this.goConfig.loadedContent = typeof payload.editorContent === "string" ? payload.editorContent : ""; this.goConfig.editorContent = this.goConfig.loadedContent; + this.recordConfigHistory("go", "刷新 Go 配置", `${this.goConfig.hosts.length} 台主机`, "neutral"); } catch (error) { this.goConfig.error = error instanceof Error ? error.message : String(error); + this.recordConfigHistory("go", "刷新 Go 配置失败", this.explainError(this.goConfig.error), "bad"); } finally { this.goConfig.loading = false; } @@ -773,14 +2200,13 @@ createApp({ this.goConfig.validationMessage = ""; try { - const response = await fetch("/api/runtime/golang-config/validate", { + const { response, payload } = await this.requestJson("/api/runtime/golang-config/validate", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content: this.goConfig.editorContent }), }); - const payload = await this.readJsonResponse(response); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } @@ -788,8 +2214,10 @@ createApp({ throw new Error(payload.message || "golang config validation failed"); } this.goConfig.validationMessage = payload.message || "格式校验通过"; + this.recordConfigHistory("go", "校验通过", `${this.goConfig.diffKeys.length} 个差异 key`, "ok"); } catch (error) { this.goConfig.error = error instanceof Error ? error.message : String(error); + this.recordConfigHistory("go", "校验失败", this.explainError(this.goConfig.error), "bad"); } finally { this.goConfig.validating = false; } @@ -800,22 +2228,23 @@ createApp({ this.goConfig.saveMessage = ""; try { - const response = await fetch("/api/runtime/golang-config", { + const { response, payload } = await this.requestJson("/api/runtime/golang-config", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content: this.goConfig.editorContent }), }); - const payload = await this.readJsonResponse(response); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.goConfig.loadedContent = this.goConfig.editorContent; this.goConfig.saveMessage = payload.message || "golang 配置已保存"; + this.recordConfigHistory("go", "保存成功", payload.message || "Go 配置已下发", "ok"); await this.refreshGoConfig(); } catch (error) { this.goConfig.error = error instanceof Error ? error.message : String(error); + this.recordConfigHistory("go", "保存失败", this.explainError(this.goConfig.error), "bad"); } finally { this.goConfig.saving = false; } @@ -825,16 +2254,17 @@ createApp({ this.restartOps.error = ""; try { - const response = await fetch("/api/ops/restart-targets", { cache: "no-store" }); - const payload = await this.readJsonResponse(response); + const { response, payload } = await this.requestJson("/api/ops/restart-targets"); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } this.restartOps.items = Array.isArray(payload.items) ? payload.items : []; const available = new Set(this.restartOps.items.map((item) => item.service)); this.restartOps.selectedServices = this.restartOps.selectedServices.filter((service) => available.has(service)); + this.recordReleaseHistory("restart", "刷新滚动重启目标", `${this.restartOps.items.length} 项`, "neutral"); } catch (error) { this.restartOps.error = error instanceof Error ? error.message : String(error); + this.recordReleaseHistory("restart", "刷新滚动重启目标失败", this.explainError(this.restartOps.error), "bad"); } finally { this.restartOps.loading = false; } @@ -844,8 +2274,7 @@ createApp({ this.updateOps.error = ""; try { - const response = await fetch("/api/ops/update-targets", { cache: "no-store" }); - const payload = await this.readJsonResponse(response); + const { response, payload } = await this.requestJson("/api/ops/update-targets"); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } @@ -866,10 +2295,18 @@ createApp({ this.updateOps.goGitRef = this.updateOps.defaults.goGitRef; } + if (!this.updateOps.adminGitRef) { + this.updateOps.adminGitRef = this.updateOps.defaults.adminGitRef || "main"; + } else if (this.updateOps.adminGitRef === "main" && this.updateOps.defaults.adminGitRef) { + this.updateOps.adminGitRef = this.updateOps.defaults.adminGitRef; + } + const available = new Set(this.updateOps.items.map((item) => item.service)); this.updateOps.selectedServices = this.updateOps.selectedServices.filter((service) => available.has(service)); + this.recordReleaseHistory("update", "刷新更新目标", `${this.updateOps.items.length} 项`, "neutral"); } catch (error) { this.updateOps.error = error instanceof Error ? error.message : String(error); + this.recordReleaseHistory("update", "刷新更新目标失败", this.explainError(this.updateOps.error), "bad"); } finally { this.updateOps.loading = false; } @@ -904,9 +2341,10 @@ createApp({ this.updateOps.logs = []; this.updateOps.result = null; this.persistUpdateOperationId(); + this.recordReleaseHistory("update", "提交服务更新", this.updateOps.selectedServices.join(", "), "warn"); try { - const response = await fetch("/api/ops/update-services", { + const { response, payload } = await this.requestJson("/api/ops/update-services", { method: "POST", headers: { "Content-Type": "application/json", @@ -915,11 +2353,11 @@ createApp({ services: this.updateOps.selectedServices, javaGitRef: this.updateOps.javaGitRef, goGitRef: this.updateOps.goGitRef, + adminGitRef: this.updateOps.adminGitRef, imageTag: this.updateOps.imageTag, skipMavenBuild: this.updateOps.skipMavenBuild, }), }); - const payload = await this.readJsonResponse(response); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } @@ -930,6 +2368,7 @@ createApp({ } catch (error) { this.updateOps.error = error instanceof Error ? error.message : String(error); this.updateOps.running = false; + this.recordReleaseHistory("update", "提交服务更新失败", this.explainError(this.updateOps.error), "bad"); } finally { this.scrollUpdateLogToBottom(); } @@ -953,14 +2392,13 @@ createApp({ this.restartOps.message = ""; try { - const response = await fetch("/api/ops/rolling-restart", { + const { response, payload } = await this.requestJson("/api/ops/rolling-restart", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ services: this.restartOps.selectedServices }), }); - const payload = await this.readJsonResponse(response); if (!response.ok) { throw new Error(payload.error || `HTTP ${response.status}`); } @@ -969,40 +2407,33 @@ createApp({ this.restartOps.message = payload.ok ? (payload.message || "滚动重启完成") : (payload.error || "滚动重启失败"); + this.recordReleaseHistory( + "restart", + payload.ok ? "滚动重启完成" : "滚动重启失败", + (payload.steps || []).map((step) => `${step.service}@${step.host}:${step.status}`).join(" | "), + payload.ok ? "ok" : "bad", + ); await this.refresh(); await this.refreshRestartTargets(); } catch (error) { this.restartOps.error = error instanceof Error ? error.message : String(error); + this.recordReleaseHistory("restart", "滚动重启失败", this.explainError(this.restartOps.error), "bad"); } finally { this.restartOps.running = false; } }, }, mounted() { - const root = document.getElementById("app"); - const bootIndicator = document.getElementById("boot-indicator"); - if (root) { - root.removeAttribute("v-cloak"); - } - if (bootIndicator) { - bootIndicator.hidden = true; - } - this.refresh(); - this.refreshNacosConfigs({ preserveSelection: false, reloadSelected: true }); - this.refreshGoConfig(); - this.refreshUpdateTargets(); - this.refreshRestartTargets(); - const lastOperationId = window.sessionStorage.getItem(UPDATE_OPERATION_STORAGE_KEY) || ""; - if (lastOperationId) { - this.fetchUpdateOperationStatus(lastOperationId, { silent: true }); - this.startUpdatePolling(); - } + this.bootstrap(); }, beforeUnmount() { if (this.timer) { window.clearInterval(this.timer); } + if (this.copiedTokenTimer) { + window.clearTimeout(this.copiedTokenTimer); + } this.stopUpdatePolling(); }, }).mount("#app"); diff --git a/static/index.html b/static/index.html index b0a3dbb..bed3345 100644 --- a/static/index.html +++ b/static/index.html @@ -17,495 +17,1496 @@
-
-
-

HY APP MONITOR

-

服务 / 主机 / Nacos / MongoDB

+
+
+
+

HY APP MONITOR

+ DEPLOY +
+

最后刷新 {{ formattedUpdatedAt }}

-
-
- 服务总数 - {{ summary.total }} -
-
- 服务正常 - {{ summary.ok }} -
-
- 服务异常 - {{ summary.down }} -
-
- 主机数 - {{ summary.hostTotal }} -
-
- Nacos 服务 - {{ summary.nacosServiceTotal }} -
-
- Nacos 异常实例 - {{ summary.nacosUnhealthyInstanceTotal }} -
-
- Mongo 状态 - {{ payload.mongo && payload.mongo.ok ? 'OK' : 'ERR' }} -
-
- Mongo 集合 - {{ summary.mongoCollectionTotal }} -
-
-
-
-
- - - - -
-
- -
+
-
- 最后刷新:{{ formattedUpdatedAt }} - 主机指标正常:{{ summary.hostMetricOk }} / {{ summary.hostTotal }} - Nacos 实例:{{ summary.nacosInstanceTotal }} - Mongo 数据库:{{ summary.mongoDatabaseTotal }} - 加载失败:{{ error }} -
- -
-

主机与服务

-
-
-
-
-
-

{{ group.host }}

-

{{ group.ip }} · {{ group.groupLabel }}

-
-
- {{ group.serviceSummary.total }} 项服务 - - {{ group.downCount > 0 ? `${group.downCount} 异常` : '服务正常' }} - - - {{ group.metrics && group.metrics.ok ? '主机指标正常' : '主机指标异常' }} - -
-
- -
-
- CPU - {{ formatPercent(group.metrics.cpuPercent) }} -
-
- 内存 - {{ formatPercent(group.metrics.memoryPercent) }} - {{ formatGb(group.metrics.memoryUsedGb, group.metrics.memoryTotalGb) }} -
-
- 磁盘 - {{ formatPercent(group.metrics.diskPercent) }} - {{ formatGb(group.metrics.diskUsedGb, group.metrics.diskTotalGb) }} -
-
- 进程数 - {{ group.metrics.processCount ?? '-' }} - {{ group.metrics.hostname || group.instanceId }} -
-
- -

{{ group.metrics.error }}

- -
-
-
-
-
{{ item.service }}
-
{{ item.kind }}
-
-
- - {{ item.ok ? 'UP' : 'DOWN' }} -
-
- -
-
- HTTP - {{ item.statusCode || '-' }} -
-
- 耗时 - {{ item.latencyMs }}ms -
-
- 端口 - {{ item.port }} -
-
- -
{{ item.url }}
-
{{ item.detail || '无返回内容' }}
-
-
-
-
- -
-

Nacos 注册

-
-
-
-
-

Nacos

-

{{ payload.nacos.baseUrl }} · {{ payload.nacos.namespaceName }} · {{ payload.nacos.namespaceId }}

-
- - {{ payload.nacos && payload.nacos.ok ? '连接正常' : '连接异常' }} - -
- -
-
- 命名空间 - {{ payload.nacos.summary.namespaceTotal || 0 }} -
-
- 服务数 - {{ payload.nacos.summary.serviceTotal || 0 }} -
-
- 实例数 - {{ payload.nacos.summary.instanceTotal || 0 }} -
-
- 异常实例 - {{ payload.nacos.summary.unhealthyInstanceTotal || 0 }} -
-
- -

{{ payload.nacos.error }}

- -
- - - - - - - - - - - - - - - - - -
命名空间ID配置数类型
{{ item.name }}{{ item.namespaceId }}{{ item.configCount }}{{ item.type }}
-
- -
-
-
-
-

{{ service.serviceName }}

-

{{ service.namespaceName || 'public' }} · {{ service.groupName }}

-
- - {{ service.instanceTotal }} / {{ service.healthyTotal }} - -
-
- - - - - - - - - - - - - - - - - - - - - -
IP端口集群健康启用权重
{{ instance.ip }}{{ instance.port }}{{ instance.clusterName }}{{ instance.healthy ? 'Y' : 'N' }}{{ instance.enabled ? 'Y' : 'N' }}{{ instance.weight }}
-
-
-
-
- -
-

Nacos 配置

-
-
-
-
-

Nacos Config

-

读取当前命名空间下的配置并直接编辑保存

-
- - {{ nacosConfig.error || nacosConfig.detailError ? '存在错误' : '可操作' }} - -
- -
- - -
- -

{{ nacosConfig.error }}

-

{{ nacosConfig.detailError }}

- -
-