Add chatapp cron deployment
This commit is contained in:
parent
9c8faf2896
commit
05f0ecbfbc
20
.env.example
20
.env.example
@ -31,6 +31,9 @@ SERVICE_LOG_EXPORT_MAX_LINES=10000
|
||||
ROLLING_RESTART_TIMEOUT_SECONDS=180
|
||||
ROLLING_RESTART_POLL_SECONDS=2
|
||||
ROLLING_RESTART_STABILIZE_SECONDS=2
|
||||
NACOS_RELEASE_DRAIN_SERVICES=other
|
||||
NACOS_RELEASE_DRAIN_SECONDS=8
|
||||
NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS=30
|
||||
SERVICE_BUILD_TIMEOUT_SECONDS=3600
|
||||
SERVICE_PULL_TIMEOUT_SECONDS=600
|
||||
SERVICE_BUILD_CACHE_ROOT=/opt/deploy/build-cache
|
||||
@ -71,6 +74,23 @@ ADMIN_FRONTEND_RELEASE_META_PATH=/opt/yumi-admin/frontend-admin/.hy-app-monitor-
|
||||
ADMIN_FRONTEND_BUILD_SCRIPT=build:antdv-next
|
||||
ADMIN_FRONTEND_DIST_DIR=apps/dist
|
||||
ADMIN_FRONTEND_PUBLIC_URL=https://yumi-admin.haiyihy.com
|
||||
CHATAPP_CRON_ENABLED=1
|
||||
CHATAPP_CRON_REPO_URL=git@gitea.haiyihy.com:hy/chatapp-cron.git
|
||||
CHATAPP_CRON_REPO_ROOT=/opt/deploy/sources/chatapp-cron
|
||||
CHATAPP_CRON_DEFAULT_GIT_REF=main
|
||||
CHATAPP_CRON_SERVICE_NAME=chatapp-cron
|
||||
CHATAPP_CRON_HOST=cron
|
||||
CHATAPP_CRON_IP=10.3.1.14
|
||||
CHATAPP_CRON_INSTANCE_ID=ins-nt6bsay0
|
||||
CHATAPP_CRON_SSH_HOST=10.3.1.14
|
||||
CHATAPP_CRON_SSH_USER=root
|
||||
CHATAPP_CRON_SSH_PORT=22
|
||||
CHATAPP_CRON_SSH_USE_SUDO=0
|
||||
CHATAPP_CRON_REMOTE_DIR=/opt/deploy/runtime/current
|
||||
CHATAPP_CRON_COMPOSE_FILE=/opt/deploy/runtime/current/docker-compose.yml
|
||||
CHATAPP_CRON_COMPOSE_SERVICE=week-star-job
|
||||
CHATAPP_CRON_BINARY_PATH=/opt/deploy/runtime/current/week-star-job/service
|
||||
CHATAPP_CRON_RELEASE_META_PATH=/opt/deploy/runtime/current/.hy-app-monitor-chatapp-cron-release.json
|
||||
MONGO_URI=
|
||||
MONGO_DB_NAME=tarab_all
|
||||
MONGO_INSTANCE_ID=ins-o9mmob66
|
||||
|
||||
24
README.md
24
README.md
@ -120,6 +120,28 @@ ADMIN_FRONTEND_DIST_DIR=apps/dist
|
||||
ADMIN_FRONTEND_PUBLIC_URL=https://yumi-admin.haiyihy.com
|
||||
```
|
||||
|
||||
通用模块里的 `Cron 部署` 用于发布 `chatapp-cron`,需要目标机已有 `docker-compose.yml` 和 `service.env`。部署流程会构建 `week-star-job` 二进制,上传到目标机,再执行 compose 重建:
|
||||
|
||||
```bash
|
||||
CHATAPP_CRON_ENABLED=1
|
||||
CHATAPP_CRON_REPO_URL=git@gitea.haiyihy.com:hy/chatapp-cron.git
|
||||
CHATAPP_CRON_REPO_ROOT=/opt/deploy/sources/chatapp-cron
|
||||
CHATAPP_CRON_DEFAULT_GIT_REF=main
|
||||
CHATAPP_CRON_SERVICE_NAME=chatapp-cron
|
||||
CHATAPP_CRON_HOST=cron
|
||||
CHATAPP_CRON_IP=10.3.1.14
|
||||
CHATAPP_CRON_INSTANCE_ID=ins-nt6bsay0
|
||||
CHATAPP_CRON_SSH_HOST=10.3.1.14
|
||||
CHATAPP_CRON_SSH_USER=root
|
||||
CHATAPP_CRON_SSH_PORT=22
|
||||
CHATAPP_CRON_SSH_USE_SUDO=0
|
||||
CHATAPP_CRON_REMOTE_DIR=/opt/deploy/runtime/current
|
||||
CHATAPP_CRON_COMPOSE_FILE=/opt/deploy/runtime/current/docker-compose.yml
|
||||
CHATAPP_CRON_COMPOSE_SERVICE=week-star-job
|
||||
CHATAPP_CRON_BINARY_PATH=/opt/deploy/runtime/current/week-star-job/service
|
||||
CHATAPP_CRON_RELEASE_META_PATH=/opt/deploy/runtime/current/.hy-app-monitor-chatapp-cron-release.json
|
||||
```
|
||||
|
||||
可以直接复制模板:
|
||||
|
||||
```bash
|
||||
@ -205,7 +227,7 @@ python3 ops/bootstrap_ssh_via_tat.py
|
||||
3. 只允许 `deploy -> target:22`
|
||||
4. 把目标机本机防火墙和安全组都限制到 `deploy` 私网 IP
|
||||
|
||||
如果 `.env` 里开启了 `ADMIN_FRONTEND_ENABLED=1`,脚本也会把 admin 前端机器一起纳入这轮 SSH 引导,即使它不在 `config/targets.json` 里。
|
||||
如果 `.env` 里开启了 `ADMIN_FRONTEND_ENABLED=1` 或 `CHATAPP_CRON_ENABLED=1`,脚本也会把 admin 前端机器和定时任务服务器纳入这轮 SSH 引导,即使它们不在 `config/targets.json` 里。
|
||||
|
||||
如果你只需要放开监控面板端口,仍然可以使用:
|
||||
|
||||
|
||||
@ -93,6 +93,12 @@ ROLLING_RESTART_TIMEOUT_SECONDS = env_int("ROLLING_RESTART_TIMEOUT_SECONDS", 180
|
||||
ROLLING_RESTART_POLL_SECONDS = env_float("ROLLING_RESTART_POLL_SECONDS", 2.0)
|
||||
# 单实例恢复后额外稳定等待秒数。
|
||||
ROLLING_RESTART_STABILIZE_SECONDS = env_float("ROLLING_RESTART_STABILIZE_SECONDS", 2.0)
|
||||
# 依赖方较多的服务发布前先在 Nacos 摘掉当前实例,避免调用方继续打到正在重启的节点。
|
||||
NACOS_RELEASE_DRAIN_SERVICES = tuple(env_str_list("NACOS_RELEASE_DRAIN_SERVICES", ["other"]))
|
||||
# Nacos 摘实例后额外等待调用方本地缓存刷新。
|
||||
NACOS_RELEASE_DRAIN_SECONDS = env_float("NACOS_RELEASE_DRAIN_SECONDS", 8.0)
|
||||
# 等待 Nacos enabled 状态变化的最长秒数。
|
||||
NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS = env_int("NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS", 30)
|
||||
# 日志查看固定读取最近多少条。
|
||||
SERVICE_LOG_PREVIEW_LINES = env_int("SERVICE_LOG_PREVIEW_LINES", 1000)
|
||||
# 日志导出最大允许条数,避免单次下载打爆页面进程。
|
||||
@ -281,6 +287,70 @@ ADMIN_FRONTEND_ENABLED = truthy(
|
||||
),
|
||||
)
|
||||
|
||||
# chatapp-cron 仓库地址;仓库缺失时允许按这里自动 clone。
|
||||
CHATAPP_CRON_REPO_URL = env_str("CHATAPP_CRON_REPO_URL", "git@gitea.haiyihy.com:hy/chatapp-cron.git")
|
||||
# chatapp-cron 仓库路径,默认兼容本地工作区和远端 deploy 机。
|
||||
CHATAPP_CRON_REPO_ROOT = env_or_candidate_path(
|
||||
"CHATAPP_CRON_REPO_ROOT",
|
||||
[
|
||||
WORKSPACE_ROOT / "chatapp3" / "chatapp-cron",
|
||||
WORKSPACE_ROOT / "chatapp-cron",
|
||||
Path("/opt/deploy/sources/chatapp-cron"),
|
||||
],
|
||||
)
|
||||
# chatapp-cron 默认拉取的 Git ref。
|
||||
CHATAPP_CRON_DEFAULT_GIT_REF = env_str("CHATAPP_CRON_DEFAULT_GIT_REF", "main")
|
||||
# chatapp-cron 在通用部署页里的服务名。
|
||||
CHATAPP_CRON_SERVICE_NAME = env_str("CHATAPP_CRON_SERVICE_NAME", "chatapp-cron").strip() or "chatapp-cron"
|
||||
# chatapp-cron 远端主机名称。
|
||||
CHATAPP_CRON_HOST = env_str("CHATAPP_CRON_HOST", "cron").strip() or "cron"
|
||||
# chatapp-cron 远端主机私网 IP。
|
||||
# 定时任务服务器:ins-nt6bsay0 / cron / 10.3.1.14。
|
||||
CHATAPP_CRON_IP = env_str("CHATAPP_CRON_IP", "10.3.1.14")
|
||||
# chatapp-cron 云服务器实例 ID,主要供 TAT SSH 引导使用。
|
||||
CHATAPP_CRON_INSTANCE_ID = env_str("CHATAPP_CRON_INSTANCE_ID", "ins-nt6bsay0")
|
||||
# chatapp-cron SSH 主机,默认沿用私网 IP。
|
||||
CHATAPP_CRON_SSH_HOST = env_str("CHATAPP_CRON_SSH_HOST", CHATAPP_CRON_IP).strip() or CHATAPP_CRON_IP
|
||||
# chatapp-cron SSH 用户。
|
||||
CHATAPP_CRON_SSH_USER = env_str("CHATAPP_CRON_SSH_USER", SSH_USER).strip() or SSH_USER
|
||||
# chatapp-cron SSH 端口。
|
||||
CHATAPP_CRON_SSH_PORT = env_int("CHATAPP_CRON_SSH_PORT", SSH_PORT)
|
||||
# chatapp-cron 是否需要 sudo。
|
||||
CHATAPP_CRON_SSH_USE_SUDO = env_str("CHATAPP_CRON_SSH_USE_SUDO", "0").lower() in {"1", "true", "yes", "on"}
|
||||
# chatapp-cron 远端运行目录。
|
||||
CHATAPP_CRON_REMOTE_DIR = env_str("CHATAPP_CRON_REMOTE_DIR", "/opt/deploy/runtime/current").strip() or "/opt/deploy/runtime/current"
|
||||
# chatapp-cron compose 文件路径。
|
||||
CHATAPP_CRON_COMPOSE_FILE = (
|
||||
env_str("CHATAPP_CRON_COMPOSE_FILE", f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/docker-compose.yml").strip()
|
||||
or f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/docker-compose.yml"
|
||||
)
|
||||
# chatapp-cron compose 服务名。
|
||||
CHATAPP_CRON_COMPOSE_SERVICE = env_str("CHATAPP_CRON_COMPOSE_SERVICE", "week-star-job").strip() or "week-star-job"
|
||||
# chatapp-cron 远端二进制路径。
|
||||
CHATAPP_CRON_BINARY_PATH = (
|
||||
env_str("CHATAPP_CRON_BINARY_PATH", f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/week-star-job/service").strip()
|
||||
or f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/week-star-job/service"
|
||||
)
|
||||
# chatapp-cron 当前发布元数据文件路径。
|
||||
CHATAPP_CRON_RELEASE_META_PATH = (
|
||||
env_str(
|
||||
"CHATAPP_CRON_RELEASE_META_PATH",
|
||||
f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/.hy-app-monitor-chatapp-cron-release.json",
|
||||
).strip()
|
||||
or f"{CHATAPP_CRON_REMOTE_DIR.rstrip('/')}/.hy-app-monitor-chatapp-cron-release.json"
|
||||
)
|
||||
# chatapp-cron 是否启用;未显式指定时,只要目标机和仓库信息齐备就自动开启。
|
||||
CHATAPP_CRON_ENABLED = truthy(
|
||||
os.environ.get("CHATAPP_CRON_ENABLED"),
|
||||
bool(
|
||||
CHATAPP_CRON_SERVICE_NAME
|
||||
and CHATAPP_CRON_HOST
|
||||
and (CHATAPP_CRON_SSH_HOST or CHATAPP_CRON_IP)
|
||||
and CHATAPP_CRON_REMOTE_DIR
|
||||
and (CHATAPP_CRON_REPO_URL or str(CHATAPP_CRON_REPO_ROOT).strip())
|
||||
),
|
||||
)
|
||||
|
||||
# Mongo 连接串。
|
||||
MONGO_URI = env_str("MONGO_URI", "")
|
||||
# Mongo 默认关注的业务库名。
|
||||
|
||||
648
monitors/yumi/chatapp_cron_deploy.py
Normal file
648
monitors/yumi/chatapp_cron_deploy.py
Normal file
@ -0,0 +1,648 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import copy
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
import uuid
|
||||
|
||||
from core.config import (
|
||||
BUILD_DOCKER_PLATFORM,
|
||||
CHATAPP_CRON_BINARY_PATH,
|
||||
CHATAPP_CRON_COMPOSE_FILE,
|
||||
CHATAPP_CRON_COMPOSE_SERVICE,
|
||||
CHATAPP_CRON_DEFAULT_GIT_REF,
|
||||
CHATAPP_CRON_ENABLED,
|
||||
CHATAPP_CRON_HOST,
|
||||
CHATAPP_CRON_IP,
|
||||
CHATAPP_CRON_INSTANCE_ID,
|
||||
CHATAPP_CRON_RELEASE_META_PATH,
|
||||
CHATAPP_CRON_REMOTE_DIR,
|
||||
CHATAPP_CRON_REPO_ROOT,
|
||||
CHATAPP_CRON_REPO_URL,
|
||||
CHATAPP_CRON_SERVICE_NAME,
|
||||
CHATAPP_CRON_SSH_HOST,
|
||||
CHATAPP_CRON_SSH_PORT,
|
||||
CHATAPP_CRON_SSH_USE_SUDO,
|
||||
CHATAPP_CRON_SSH_USER,
|
||||
ROOT,
|
||||
SERVICE_BUILD_TIMEOUT_SECONDS,
|
||||
SERVICE_PULL_TIMEOUT_SECONDS,
|
||||
utc_now,
|
||||
)
|
||||
from .service_release import (
|
||||
create_temp_worktree,
|
||||
emit_progress,
|
||||
ensure_repo_root,
|
||||
fetch_repo_refs,
|
||||
local_command_output,
|
||||
remove_temp_worktree,
|
||||
resolve_repo_commit,
|
||||
run_logged_command,
|
||||
sanitized_ref_fragment,
|
||||
short_text,
|
||||
)
|
||||
from .ssh_remote import copy_local_path_to_host, read_remote_text, run_host_script_checked, target_display
|
||||
|
||||
|
||||
# 后台任务的日志回调统一走这个签名。
|
||||
ProgressLogger = Callable[[str, str, str], None]
|
||||
# 内存里最多保留最近若干次 cron 部署记录。
|
||||
MAX_CRON_DEPLOY_OPERATIONS = 10
|
||||
# 任务状态和完整日志统一落到 run 目录。
|
||||
CRON_DEPLOY_JOB_DIR = ROOT / "run" / "chatapp_cron_deploy_jobs"
|
||||
# 统一保护任务状态的锁。
|
||||
CRON_DEPLOY_JOB_LOCK = threading.Lock()
|
||||
# 当前进程内的 cron 部署任务表。
|
||||
CRON_DEPLOY_JOBS: dict[str, dict[str, Any]] = {}
|
||||
# 记录任务插入顺序,便于淘汰最旧项。
|
||||
CRON_DEPLOY_JOB_ORDER: list[str] = []
|
||||
|
||||
|
||||
def chatapp_cron_release_enabled() -> bool:
|
||||
# 只有目标机、运行目录和仓库信息齐备时才开放部署按钮。
|
||||
return bool(
|
||||
CHATAPP_CRON_ENABLED
|
||||
and str(CHATAPP_CRON_SERVICE_NAME or "").strip()
|
||||
and str(CHATAPP_CRON_HOST or "").strip()
|
||||
and str(CHATAPP_CRON_REMOTE_DIR or "").strip()
|
||||
and str(CHATAPP_CRON_BINARY_PATH or "").strip()
|
||||
and str(CHATAPP_CRON_COMPOSE_FILE or "").strip()
|
||||
and str(CHATAPP_CRON_COMPOSE_SERVICE or "").strip()
|
||||
and (str(CHATAPP_CRON_SSH_HOST or "").strip() or str(CHATAPP_CRON_IP or "").strip())
|
||||
and (str(CHATAPP_CRON_REPO_URL or "").strip() or str(CHATAPP_CRON_REPO_ROOT or "").strip())
|
||||
)
|
||||
|
||||
|
||||
def chatapp_cron_host_record() -> dict[str, Any]:
|
||||
# chatapp-cron 不在 Yumi 业务服务 targets 里,部署主机单独从 .env 读取。
|
||||
if not chatapp_cron_release_enabled():
|
||||
raise RuntimeError("chatapp-cron deploy is not configured")
|
||||
|
||||
return {
|
||||
"host": CHATAPP_CRON_HOST,
|
||||
"ip": CHATAPP_CRON_IP,
|
||||
"instanceId": CHATAPP_CRON_INSTANCE_ID,
|
||||
"sshHost": CHATAPP_CRON_SSH_HOST or CHATAPP_CRON_IP,
|
||||
"sshUser": CHATAPP_CRON_SSH_USER,
|
||||
"sshUseSudo": CHATAPP_CRON_SSH_USE_SUDO,
|
||||
"sshPort": CHATAPP_CRON_SSH_PORT,
|
||||
}
|
||||
|
||||
|
||||
def ensure_chatapp_cron_repo_root(*, progress: ProgressLogger | None = None) -> Path:
|
||||
# 第一次使用时允许按配置仓库地址自动 clone 到 deploy 机。
|
||||
repo_root = Path(CHATAPP_CRON_REPO_ROOT).expanduser().resolve()
|
||||
if (repo_root / ".git").exists():
|
||||
return repo_root
|
||||
|
||||
repo_url = str(CHATAPP_CRON_REPO_URL or "").strip()
|
||||
if not repo_url:
|
||||
raise RuntimeError("missing CHATAPP_CRON_REPO_URL in .env")
|
||||
|
||||
repo_root.parent.mkdir(parents=True, exist_ok=True)
|
||||
emit_progress(progress, "cron.repo", f"初始化 chatapp-cron 仓库:{repo_url} -> {repo_root}")
|
||||
local_command_output(
|
||||
["git", "clone", repo_url, str(repo_root)],
|
||||
cwd=repo_root.parent,
|
||||
label="chatapp-cron clone repo",
|
||||
timeout_seconds=900,
|
||||
)
|
||||
return ensure_repo_root(repo_root, "chatapp-cron")
|
||||
|
||||
|
||||
def read_chatapp_cron_metadata() -> dict[str, Any]:
|
||||
# 元数据只用于展示最近部署版本,读取失败不影响页面打开。
|
||||
if not chatapp_cron_release_enabled():
|
||||
return {}
|
||||
|
||||
try:
|
||||
text = read_remote_text(chatapp_cron_host_record(), CHATAPP_CRON_RELEASE_META_PATH, timeout_seconds=30)
|
||||
return json.loads(text or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def build_chatapp_cron_overview_payload() -> dict[str, Any]:
|
||||
# 通用页加载时返回配置摘要和最近部署版本。
|
||||
enabled = chatapp_cron_release_enabled()
|
||||
settings = {
|
||||
"enabled": enabled,
|
||||
"serviceName": CHATAPP_CRON_SERVICE_NAME,
|
||||
"host": CHATAPP_CRON_HOST,
|
||||
"sshHost": CHATAPP_CRON_SSH_HOST or CHATAPP_CRON_IP,
|
||||
"instanceId": CHATAPP_CRON_INSTANCE_ID,
|
||||
"repoRoot": str(Path(CHATAPP_CRON_REPO_ROOT).expanduser()),
|
||||
"repoUrl": CHATAPP_CRON_REPO_URL,
|
||||
"defaultGitRef": CHATAPP_CRON_DEFAULT_GIT_REF,
|
||||
"remoteDir": CHATAPP_CRON_REMOTE_DIR,
|
||||
"composeFile": CHATAPP_CRON_COMPOSE_FILE,
|
||||
"composeService": CHATAPP_CRON_COMPOSE_SERVICE,
|
||||
"binaryPath": CHATAPP_CRON_BINARY_PATH,
|
||||
"dockerPlatform": BUILD_DOCKER_PLATFORM,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"enabled": enabled,
|
||||
"configured": enabled,
|
||||
"updatedAt": utc_now(),
|
||||
"settings": settings,
|
||||
"metadata": read_chatapp_cron_metadata() if enabled else {},
|
||||
}
|
||||
|
||||
|
||||
def docker_builder_image_name(commit: str) -> str:
|
||||
# 本地临时 builder 镜像只用于 docker cp 导出二进制。
|
||||
stamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
||||
tag = sanitized_ref_fragment(f"{commit[:12]}-{stamp}").lower()
|
||||
return f"hy-app-monitor/chatapp-cron-builder:{tag}"
|
||||
|
||||
|
||||
def build_chatapp_cron_binary(git_ref: str, *, progress: ProgressLogger | None = None) -> dict[str, Any]:
|
||||
# 使用仓库 Dockerfile 的 builder stage 产出 linux/amd64 二进制,避免依赖宿主机 Go 版本。
|
||||
repo_root = ensure_chatapp_cron_repo_root(progress=progress)
|
||||
resolved_ref = str(git_ref or "").strip() or CHATAPP_CRON_DEFAULT_GIT_REF
|
||||
emit_progress(progress, "cron.build", f"刷新 chatapp-cron refs:{resolved_ref}")
|
||||
fetch_repo_refs(repo_root, "chatapp-cron")
|
||||
commit = resolve_repo_commit(repo_root, resolved_ref, "chatapp-cron")
|
||||
emit_progress(progress, "cron.build", f"chatapp-cron ref 已解析:{resolved_ref} -> {commit[:12]}")
|
||||
|
||||
worktree_root = create_temp_worktree(repo_root, commit, "chatapp-cron")
|
||||
artifact_root = Path(tempfile.mkdtemp(prefix="hy-app-monitor-chatapp-cron-artifact-"))
|
||||
builder_image = docker_builder_image_name(commit)
|
||||
container_id = ""
|
||||
binary_path = artifact_root / "week-star-job"
|
||||
|
||||
try:
|
||||
emit_progress(progress, "cron.build", f"开始 Docker builder 构建:{BUILD_DOCKER_PLATFORM}")
|
||||
run_logged_command(
|
||||
["docker", "build", "--platform", BUILD_DOCKER_PLATFORM, "--target", "builder", "-t", builder_image, "."],
|
||||
cwd=worktree_root,
|
||||
label="chatapp-cron docker builder",
|
||||
timeout_seconds=SERVICE_BUILD_TIMEOUT_SECONDS,
|
||||
progress_stage="cron.build.docker",
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
container_id = local_command_output(
|
||||
["docker", "create", builder_image],
|
||||
cwd=worktree_root,
|
||||
label="chatapp-cron create builder container",
|
||||
timeout_seconds=120,
|
||||
)
|
||||
local_command_output(
|
||||
["docker", "cp", f"{container_id}:/out/week-star-job", str(binary_path)],
|
||||
cwd=worktree_root,
|
||||
label="chatapp-cron export binary",
|
||||
timeout_seconds=180,
|
||||
)
|
||||
binary_path.chmod(0o755)
|
||||
emit_progress(progress, "cron.build", f"二进制已生成:{binary_path}")
|
||||
return {
|
||||
"kind": "chatapp-cron",
|
||||
"gitRef": resolved_ref,
|
||||
"commit": commit,
|
||||
"binaryPath": str(binary_path),
|
||||
"builderImage": builder_image,
|
||||
}
|
||||
finally:
|
||||
if container_id:
|
||||
try:
|
||||
local_command_output(
|
||||
["docker", "rm", "-f", container_id],
|
||||
cwd=worktree_root,
|
||||
label="chatapp-cron remove builder container",
|
||||
timeout_seconds=60,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
local_command_output(
|
||||
["docker", "rmi", "-f", builder_image],
|
||||
cwd=worktree_root,
|
||||
label="chatapp-cron remove builder image",
|
||||
timeout_seconds=120,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
remove_temp_worktree(repo_root, worktree_root)
|
||||
|
||||
|
||||
def activate_chatapp_cron_binary(build_result: dict[str, Any], *, progress: ProgressLogger | None = None) -> dict[str, Any]:
|
||||
# 把新二进制传到 cron 机器,再用 docker compose 重建 week-star-job。
|
||||
host = chatapp_cron_host_record()
|
||||
binary_path = Path(str(build_result["binaryPath"]))
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(f"chatapp-cron binary missing: {binary_path}")
|
||||
|
||||
remote_tmp = f"/tmp/hy-app-monitor-chatapp-cron-{build_result['commit'][:12]}-{int(time.time())}"
|
||||
emit_progress(progress, "cron.deploy.copy", f"上传二进制到 {target_display(host)}:{remote_tmp}")
|
||||
copy_local_path_to_host(
|
||||
host,
|
||||
binary_path,
|
||||
remote_tmp,
|
||||
timeout_seconds=SERVICE_PULL_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
metadata = {
|
||||
"service": CHATAPP_CRON_SERVICE_NAME,
|
||||
"component": CHATAPP_CRON_COMPOSE_SERVICE,
|
||||
"host": CHATAPP_CRON_HOST,
|
||||
"gitRef": build_result["gitRef"],
|
||||
"commit": build_result["commit"],
|
||||
"deployedAt": utc_now(),
|
||||
"binaryPath": CHATAPP_CRON_BINARY_PATH,
|
||||
"composeFile": CHATAPP_CRON_COMPOSE_FILE,
|
||||
"remoteDir": CHATAPP_CRON_REMOTE_DIR,
|
||||
}
|
||||
metadata_blob = base64.b64encode(json.dumps(metadata, ensure_ascii=False, indent=2).encode("utf-8")).decode("ascii")
|
||||
|
||||
script = f"""set -euo pipefail
|
||||
REMOTE_DIR={shlex.quote(CHATAPP_CRON_REMOTE_DIR)}
|
||||
COMPOSE_FILE={shlex.quote(CHATAPP_CRON_COMPOSE_FILE)}
|
||||
COMPOSE_SERVICE={shlex.quote(CHATAPP_CRON_COMPOSE_SERVICE)}
|
||||
BINARY_PATH={shlex.quote(CHATAPP_CRON_BINARY_PATH)}
|
||||
REMOTE_TMP={shlex.quote(remote_tmp)}
|
||||
META_PATH={shlex.quote(CHATAPP_CRON_RELEASE_META_PATH)}
|
||||
META_BLOB={shlex.quote(metadata_blob)}
|
||||
|
||||
compose() {{
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
docker compose "$@"
|
||||
else
|
||||
docker-compose "$@"
|
||||
fi
|
||||
}}
|
||||
|
||||
mkdir -p "$(dirname "$BINARY_PATH")" "$REMOTE_DIR/.backups"
|
||||
if [ -f "$BINARY_PATH" ]; then
|
||||
cp "$BINARY_PATH" "$REMOTE_DIR/.backups/week-star-job.$(date +%Y%m%d-%H%M%S)"
|
||||
fi
|
||||
install -m 0755 "$REMOTE_TMP" "$BINARY_PATH"
|
||||
rm -f "$REMOTE_TMP"
|
||||
|
||||
cd "$REMOTE_DIR"
|
||||
compose -f "$COMPOSE_FILE" up -d --no-deps --force-recreate "$COMPOSE_SERVICE"
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
if compose -f "$COMPOSE_FILE" ps "$COMPOSE_SERVICE" | grep -Eiq 'running|up'; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
STATUS_OUTPUT="$(compose -f "$COMPOSE_FILE" ps "$COMPOSE_SERVICE")"
|
||||
printf '%s\\n' "$STATUS_OUTPUT"
|
||||
if ! printf '%s\\n' "$STATUS_OUTPUT" | grep -Eiq 'running|up'; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$META_PATH")"
|
||||
printf '%s' "$META_BLOB" | base64 -d > "$META_PATH"
|
||||
"""
|
||||
|
||||
emit_progress(progress, "cron.deploy.activate", f"重建远端服务:{CHATAPP_CRON_COMPOSE_SERVICE}")
|
||||
result = run_host_script_checked(
|
||||
host,
|
||||
script,
|
||||
command_name="chatapp-cron-activate",
|
||||
timeout_seconds=SERVICE_PULL_TIMEOUT_SECONDS,
|
||||
)
|
||||
emit_progress(progress, "cron.deploy.activate", "chatapp-cron 重启完成")
|
||||
return {
|
||||
"host": CHATAPP_CRON_HOST,
|
||||
"target": result.get("target"),
|
||||
"status": result.get("status"),
|
||||
"output": short_text(str(result.get("output") or ""), 1000),
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
def deploy_chatapp_cron_payload(git_ref: str, *, progress: ProgressLogger | None = None) -> dict[str, Any]:
|
||||
# 对外的部署主流程:构建二进制 -> 上传目标机 -> compose 重建。
|
||||
if not chatapp_cron_release_enabled():
|
||||
raise RuntimeError("chatapp-cron deploy is not configured")
|
||||
|
||||
started_at = time.time()
|
||||
build_result = build_chatapp_cron_binary(git_ref, progress=progress)
|
||||
try:
|
||||
deploy_result = activate_chatapp_cron_binary(build_result, progress=progress)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "chatapp-cron deploy complete",
|
||||
"updatedAt": utc_now(),
|
||||
"durationSeconds": round(time.time() - started_at, 2),
|
||||
"build": {
|
||||
"kind": build_result["kind"],
|
||||
"gitRef": build_result["gitRef"],
|
||||
"commit": build_result["commit"],
|
||||
"services": [CHATAPP_CRON_SERVICE_NAME],
|
||||
},
|
||||
"deploy": deploy_result,
|
||||
}
|
||||
finally:
|
||||
artifact_root = Path(str(build_result.get("binaryPath") or "")).parent
|
||||
if str(artifact_root).startswith(tempfile.gettempdir()):
|
||||
shutil.rmtree(artifact_root, ignore_errors=True)
|
||||
|
||||
|
||||
def ensure_cron_deploy_job_dir() -> None:
|
||||
# 持久化目录不存在时先创建。
|
||||
CRON_DEPLOY_JOB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def cron_deploy_job_state_path(operation_id: str) -> Path:
|
||||
# 每个任务一份状态 JSON。
|
||||
return CRON_DEPLOY_JOB_DIR / f"{operation_id}.json"
|
||||
|
||||
|
||||
def cron_deploy_job_log_path(operation_id: str) -> Path:
|
||||
# 每个任务一份完整文本日志。
|
||||
return CRON_DEPLOY_JOB_DIR / f"{operation_id}.log"
|
||||
|
||||
|
||||
def valid_operation_id(operation_id: str) -> bool:
|
||||
# 只接受简单安全字符,避免路径拼接被滥用。
|
||||
return re.fullmatch(r"[0-9A-Za-z_-]{6,64}", str(operation_id or "").strip()) is not None
|
||||
|
||||
|
||||
def cron_operation_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
# 对外返回和落盘都统一走这个结构。
|
||||
return {
|
||||
"operationId": payload["operationId"],
|
||||
"kind": payload["kind"],
|
||||
"status": payload["status"],
|
||||
"stage": payload["stage"],
|
||||
"message": payload["message"],
|
||||
"error": payload["error"],
|
||||
"startedAt": payload["startedAt"],
|
||||
"updatedAt": payload["updatedAt"],
|
||||
"finishedAt": payload["finishedAt"],
|
||||
"logs": copy.deepcopy(payload.get("logs") or []),
|
||||
"result": copy.deepcopy(payload.get("result")),
|
||||
"request": copy.deepcopy(payload.get("request") or {}),
|
||||
"logUrl": f"/api/chatapp-cron/deploy-log?id={payload['operationId']}",
|
||||
}
|
||||
|
||||
|
||||
def persist_cron_job_unlocked(payload: dict[str, Any]) -> None:
|
||||
# 每次状态变化都落盘,服务重启后还能恢复最近任务。
|
||||
ensure_cron_deploy_job_dir()
|
||||
cron_deploy_job_state_path(payload["operationId"]).write_text(
|
||||
json.dumps({"ok": True, "operation": cron_operation_payload(payload)}, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def append_cron_log_file(operation_id: str, line: str) -> None:
|
||||
# 完整日志单独写入文本文件,供页面查看。
|
||||
ensure_cron_deploy_job_dir()
|
||||
with cron_deploy_job_log_path(operation_id).open("a", encoding="utf-8") as handle:
|
||||
handle.write(line)
|
||||
|
||||
|
||||
def trim_cron_jobs_unlocked() -> None:
|
||||
# 只保留最近若干次任务,老数据直接淘汰内存引用。
|
||||
while len(CRON_DEPLOY_JOB_ORDER) > MAX_CRON_DEPLOY_OPERATIONS:
|
||||
operation_id = CRON_DEPLOY_JOB_ORDER.pop(0)
|
||||
CRON_DEPLOY_JOBS.pop(operation_id, None)
|
||||
|
||||
|
||||
def create_cron_deploy_job_payload(git_ref: str) -> dict[str, Any]:
|
||||
# 后台任务一创建就落一份初始状态,前端可以马上开始轮询。
|
||||
resolved_ref = str(git_ref or "").strip() or CHATAPP_CRON_DEFAULT_GIT_REF
|
||||
operation_id = uuid.uuid4().hex[:12]
|
||||
now = utc_now()
|
||||
payload = {
|
||||
"operationId": operation_id,
|
||||
"kind": "chatapp-cron-deploy",
|
||||
"status": "queued",
|
||||
"stage": "queued",
|
||||
"message": "chatapp-cron deploy queued",
|
||||
"error": "",
|
||||
"startedAt": now,
|
||||
"updatedAt": now,
|
||||
"finishedAt": "",
|
||||
"logs": [],
|
||||
"result": None,
|
||||
"request": {
|
||||
"gitRef": resolved_ref,
|
||||
"service": CHATAPP_CRON_SERVICE_NAME,
|
||||
},
|
||||
}
|
||||
with CRON_DEPLOY_JOB_LOCK:
|
||||
ensure_cron_deploy_job_dir()
|
||||
CRON_DEPLOY_JOBS[operation_id] = payload
|
||||
CRON_DEPLOY_JOB_ORDER.append(operation_id)
|
||||
cron_deploy_job_log_path(operation_id).write_text("", encoding="utf-8")
|
||||
persist_cron_job_unlocked(payload)
|
||||
trim_cron_jobs_unlocked()
|
||||
return snapshot_cron_deploy_job(operation_id)
|
||||
|
||||
|
||||
def append_cron_job_log(operation_id: str, stage: str, message: str, *, level: str = "info") -> None:
|
||||
# 每条日志都带时间和阶段,页面和状态文件都保留日志。
|
||||
text = str(message or "").rstrip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
with CRON_DEPLOY_JOB_LOCK:
|
||||
payload = CRON_DEPLOY_JOBS.get(operation_id)
|
||||
if payload is None:
|
||||
return
|
||||
|
||||
payload["updatedAt"] = utc_now()
|
||||
payload["stage"] = str(stage or payload.get("stage") or "running")
|
||||
payload["logs"].append(
|
||||
{
|
||||
"time": payload["updatedAt"],
|
||||
"stage": str(stage or "").strip(),
|
||||
"level": str(level or "info").strip() or "info",
|
||||
"message": text,
|
||||
}
|
||||
)
|
||||
append_cron_log_file(
|
||||
operation_id,
|
||||
f"[{payload['updatedAt']}] [{str(stage or '').strip() or payload['stage']}] [{str(level or 'info').strip() or 'info'}] {text}\n",
|
||||
)
|
||||
persist_cron_job_unlocked(payload)
|
||||
|
||||
|
||||
def patch_cron_job(operation_id: str, **fields: Any) -> None:
|
||||
# 任务元信息更新统一走这里,避免漏掉 updatedAt。
|
||||
with CRON_DEPLOY_JOB_LOCK:
|
||||
payload = CRON_DEPLOY_JOBS.get(operation_id)
|
||||
if payload is None:
|
||||
return
|
||||
payload.update(fields)
|
||||
payload["updatedAt"] = utc_now()
|
||||
persist_cron_job_unlocked(payload)
|
||||
|
||||
|
||||
def finish_cron_job(operation_id: str, *, result: dict[str, Any], status: str, error: str = "") -> None:
|
||||
# 结束态统一收口,前端轮询时不用自己猜。
|
||||
now = utc_now()
|
||||
with CRON_DEPLOY_JOB_LOCK:
|
||||
payload = CRON_DEPLOY_JOBS.get(operation_id)
|
||||
if payload is None:
|
||||
return
|
||||
payload["status"] = status
|
||||
payload["stage"] = status
|
||||
payload["message"] = (
|
||||
str(result.get("message") or "").strip()
|
||||
or ("chatapp-cron deploy complete" if status == "success" else "chatapp-cron deploy failed")
|
||||
)
|
||||
payload["error"] = str(error or "").strip()
|
||||
payload["finishedAt"] = now
|
||||
payload["updatedAt"] = now
|
||||
payload["result"] = result
|
||||
persist_cron_job_unlocked(payload)
|
||||
|
||||
|
||||
def snapshot_cron_deploy_job(operation_id: str) -> dict[str, Any]:
|
||||
# HTTP 返回前复制一份,避免把锁外的可变对象直接暴露出去。
|
||||
with CRON_DEPLOY_JOB_LOCK:
|
||||
payload = CRON_DEPLOY_JOBS.get(operation_id)
|
||||
if payload is None:
|
||||
raise KeyError(operation_id)
|
||||
return {"ok": True, "operation": cron_operation_payload(payload)}
|
||||
|
||||
|
||||
def cron_job_progress_logger(operation_id: str) -> ProgressLogger:
|
||||
# 部署主流程只关心 stage / level / message,落盘细节都交给这里。
|
||||
def _logger(stage: str, level: str, message: str) -> None:
|
||||
append_cron_job_log(operation_id, stage, message, level=level)
|
||||
|
||||
return _logger
|
||||
|
||||
|
||||
def run_chatapp_cron_deploy_job(operation_id: str, git_ref: str) -> None:
|
||||
# 真正的部署逻辑放后台线程里执行。
|
||||
patch_cron_job(operation_id, status="running", stage="prepare", message="chatapp-cron deploy running")
|
||||
append_cron_job_log(operation_id, "prepare", f"开始部署 chatapp-cron:{git_ref}")
|
||||
|
||||
try:
|
||||
result = deploy_chatapp_cron_payload(git_ref, progress=cron_job_progress_logger(operation_id))
|
||||
append_cron_job_log(operation_id, "success", "chatapp-cron 部署完成")
|
||||
finish_cron_job(operation_id, result=result, status="success")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
error = str(exc)
|
||||
append_cron_job_log(operation_id, "failed", error, level="error")
|
||||
finish_cron_job(
|
||||
operation_id,
|
||||
result={"ok": False, "updatedAt": utc_now(), "error": error},
|
||||
status="failed",
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def start_chatapp_cron_deploy_job(git_ref: str) -> dict[str, Any]:
|
||||
# 创建任务后立刻拉起后台线程,并把 operationId 返回给前端。
|
||||
if not chatapp_cron_release_enabled():
|
||||
raise RuntimeError("chatapp-cron deploy is not configured")
|
||||
|
||||
payload = create_cron_deploy_job_payload(git_ref)
|
||||
operation_id = payload["operation"]["operationId"]
|
||||
resolved_ref = payload["operation"]["request"]["gitRef"]
|
||||
|
||||
worker = threading.Thread(
|
||||
target=run_chatapp_cron_deploy_job,
|
||||
kwargs={"operation_id": operation_id, "git_ref": resolved_ref},
|
||||
name=f"chatapp-cron-deploy-{operation_id}",
|
||||
daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
return snapshot_cron_deploy_job(operation_id)
|
||||
|
||||
|
||||
def recover_lost_cron_job_unlocked(payload: dict[str, Any]) -> None:
|
||||
# 进程重启后,原后台线程已经不存在;把悬挂中的任务收口成失败态。
|
||||
if payload.get("status") not in {"queued", "running"}:
|
||||
return
|
||||
|
||||
now = utc_now()
|
||||
message = "monitor process restarted before chatapp-cron deploy status could be finalized"
|
||||
payload["status"] = "failed"
|
||||
payload["stage"] = "failed"
|
||||
payload["message"] = "chatapp-cron deploy interrupted"
|
||||
payload["error"] = message
|
||||
payload["updatedAt"] = now
|
||||
payload["finishedAt"] = now
|
||||
payload["logs"].append(
|
||||
{
|
||||
"time": now,
|
||||
"stage": "failed",
|
||||
"level": "error",
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
append_cron_log_file(payload["operationId"], f"[{now}] [failed] [error] {message}\n")
|
||||
persist_cron_job_unlocked(payload)
|
||||
|
||||
|
||||
def load_cron_job_from_disk(operation_id: str) -> dict[str, Any] | None:
|
||||
# 任务不在内存时,从持久化状态恢复。
|
||||
if not valid_operation_id(operation_id):
|
||||
return None
|
||||
|
||||
state_path = cron_deploy_job_state_path(operation_id)
|
||||
if not state_path.exists():
|
||||
return None
|
||||
|
||||
payload = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
operation = dict(payload.get("operation") or {})
|
||||
if not operation:
|
||||
return None
|
||||
|
||||
restored = {
|
||||
"operationId": str(operation.get("operationId") or operation_id).strip(),
|
||||
"kind": str(operation.get("kind") or "chatapp-cron-deploy").strip() or "chatapp-cron-deploy",
|
||||
"status": str(operation.get("status") or "").strip(),
|
||||
"stage": str(operation.get("stage") or "").strip(),
|
||||
"message": str(operation.get("message") or "").strip(),
|
||||
"error": str(operation.get("error") or "").strip(),
|
||||
"startedAt": str(operation.get("startedAt") or "").strip(),
|
||||
"updatedAt": str(operation.get("updatedAt") or "").strip(),
|
||||
"finishedAt": str(operation.get("finishedAt") or "").strip(),
|
||||
"logs": list(operation.get("logs") or []),
|
||||
"result": operation.get("result"),
|
||||
"request": dict(operation.get("request") or {}),
|
||||
}
|
||||
recover_lost_cron_job_unlocked(restored)
|
||||
return restored
|
||||
|
||||
|
||||
def get_chatapp_cron_deploy_job_payload(operation_id: str) -> dict[str, Any]:
|
||||
# 供 HTTP 轮询接口直接读取任务状态。
|
||||
try:
|
||||
return snapshot_cron_deploy_job(operation_id)
|
||||
except KeyError:
|
||||
restored = load_cron_job_from_disk(operation_id)
|
||||
if restored is None:
|
||||
raise
|
||||
|
||||
with CRON_DEPLOY_JOB_LOCK:
|
||||
CRON_DEPLOY_JOBS[operation_id] = restored
|
||||
if operation_id not in CRON_DEPLOY_JOB_ORDER:
|
||||
CRON_DEPLOY_JOB_ORDER.append(operation_id)
|
||||
trim_cron_jobs_unlocked()
|
||||
return snapshot_cron_deploy_job(operation_id)
|
||||
|
||||
|
||||
def get_chatapp_cron_deploy_log_text(operation_id: str) -> str:
|
||||
# 下载或弹层展示完整任务日志。
|
||||
if not valid_operation_id(operation_id):
|
||||
raise KeyError(operation_id)
|
||||
log_path = cron_deploy_job_log_path(operation_id)
|
||||
if not log_path.exists():
|
||||
if load_cron_job_from_disk(operation_id) is None:
|
||||
raise KeyError(operation_id)
|
||||
return log_path.read_text(encoding="utf-8") if log_path.exists() else ""
|
||||
@ -265,6 +265,19 @@ def build_service_definition(
|
||||
if restart_policy:
|
||||
service_definition["restart"] = restart_policy
|
||||
|
||||
log_config = host_config.get("LogConfig") or {}
|
||||
log_driver = str(log_config.get("Type") or "").strip()
|
||||
log_options = {
|
||||
str(key): str(value)
|
||||
for key, value in (log_config.get("Config") or {}).items()
|
||||
if str(key).strip() and value is not None
|
||||
}
|
||||
if log_driver and (log_driver != "json-file" or log_options):
|
||||
logging_definition: dict[str, Any] = {"driver": log_driver}
|
||||
if log_options:
|
||||
logging_definition["options"] = log_options
|
||||
service_definition["logging"] = logging_definition
|
||||
|
||||
stop_signal = str(config.get("StopSignal") or "").strip()
|
||||
if stop_signal:
|
||||
service_definition["stop_signal"] = stop_signal
|
||||
|
||||
@ -55,6 +55,12 @@ from .service_release import build_update_targets_payload
|
||||
from .service_update_jobs import get_update_job_log_text, get_update_job_payload, start_update_job
|
||||
from .service_ops import build_restart_targets_payload, rolling_restart_payload
|
||||
from .tencent_cdn import get_cdn_overview_payload, submit_cdn_purge_payload
|
||||
from .chatapp_cron_deploy import (
|
||||
build_chatapp_cron_overview_payload,
|
||||
get_chatapp_cron_deploy_job_payload,
|
||||
get_chatapp_cron_deploy_log_text,
|
||||
start_chatapp_cron_deploy_job,
|
||||
)
|
||||
|
||||
|
||||
def response_json(payload: dict[str, Any] | list[Any]) -> bytes:
|
||||
@ -254,6 +260,20 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
# 返回 chatapp-cron 部署配置和最近版本。
|
||||
if local_path == "/api/chatapp-cron/overview":
|
||||
try:
|
||||
payload = build_chatapp_cron_overview_payload()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
|
||||
|
||||
return self.send_payload(
|
||||
HTTPStatus.OK,
|
||||
response_json(payload),
|
||||
"application/json; charset=utf-8",
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
# 返回某个库下的表列表。
|
||||
if local_path == "/api/mysql/tables":
|
||||
database_name = self.query_value(query, "database")
|
||||
@ -416,6 +436,58 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
# 返回 chatapp-cron 部署任务状态。
|
||||
if local_path == "/api/chatapp-cron/deploy-status":
|
||||
operation_id = self.query_value(query, "id")
|
||||
if not operation_id:
|
||||
return self.send_error_payload(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"id is required",
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = get_chatapp_cron_deploy_job_payload(operation_id)
|
||||
except KeyError:
|
||||
return self.send_error_payload(
|
||||
HTTPStatus.NOT_FOUND,
|
||||
"chatapp-cron deploy operation not found",
|
||||
send_body=send_body,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
|
||||
|
||||
return self.send_payload(
|
||||
HTTPStatus.OK,
|
||||
response_json(payload),
|
||||
"application/json; charset=utf-8",
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
# 返回 chatapp-cron 部署任务完整日志。
|
||||
if local_path == "/api/chatapp-cron/deploy-log":
|
||||
operation_id = self.query_value(query, "id")
|
||||
if not operation_id:
|
||||
return self.send_error_payload(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"id is required",
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
try:
|
||||
content = get_chatapp_cron_deploy_log_text(operation_id)
|
||||
except KeyError:
|
||||
return self.send_error_payload(HTTPStatus.NOT_FOUND, "chatapp-cron deploy log not found", send_body=send_body)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
|
||||
|
||||
return self.send_payload(
|
||||
HTTPStatus.OK,
|
||||
content.encode("utf-8"),
|
||||
"text/plain; charset=utf-8",
|
||||
send_body=send_body,
|
||||
)
|
||||
|
||||
# 返回单个服务实例最近 1000 条容器日志。
|
||||
if local_path == "/api/ops/service-log":
|
||||
service_name = self.query_value(query, "service")
|
||||
@ -533,6 +605,9 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
if local_path == "/api/cdn/purge":
|
||||
return self.handle_mysql_action(submit_cdn_purge_payload, payload, status=HTTPStatus.ACCEPTED)
|
||||
|
||||
if local_path == "/api/chatapp-cron/deploy":
|
||||
return self.handle_chatapp_cron_deploy(payload)
|
||||
|
||||
if local_path == "/api/ops/rolling-restart":
|
||||
return self.handle_rolling_restart(payload)
|
||||
|
||||
@ -746,6 +821,23 @@ class MonitorHandler(BaseHTTPRequestHandler):
|
||||
send_body=True,
|
||||
)
|
||||
|
||||
def handle_chatapp_cron_deploy(self, payload: dict[str, Any]) -> None:
|
||||
git_ref = str(payload.get("gitRef") or "").strip()
|
||||
|
||||
try:
|
||||
result = start_chatapp_cron_deploy_job(git_ref)
|
||||
except ValueError as exc:
|
||||
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=True)
|
||||
|
||||
return self.send_payload(
|
||||
HTTPStatus.ACCEPTED,
|
||||
response_json(result),
|
||||
"application/json; charset=utf-8",
|
||||
send_body=True,
|
||||
)
|
||||
|
||||
def handle_mongo_admin_action(
|
||||
self,
|
||||
builder: Any,
|
||||
|
||||
@ -602,6 +602,32 @@ def catalog_instances(namespace_id: str, service_name: str, group_name: str) ->
|
||||
return list(payload.get("list") or [])
|
||||
|
||||
|
||||
def set_instance_enabled(
|
||||
namespace_id: str,
|
||||
service_name: str,
|
||||
group_name: str,
|
||||
*,
|
||||
ip: str,
|
||||
port: int,
|
||||
enabled: bool,
|
||||
ephemeral: bool | None = None,
|
||||
) -> str:
|
||||
# 发布链路用 Nacos enabled 做摘流;这里只改当前 ip:port 这个实例。
|
||||
params: dict[str, Any] = {
|
||||
"serviceName": service_name,
|
||||
"groupName": group_name,
|
||||
"namespaceId": namespace_id or None,
|
||||
"clusterName": NACOS_DISCOVERY_CLUSTER_NAME or None,
|
||||
"ip": ip,
|
||||
"port": str(int(port)),
|
||||
"enabled": "true" if enabled else "false",
|
||||
}
|
||||
if ephemeral is not None:
|
||||
params["ephemeral"] = "true" if bool(ephemeral) else "false"
|
||||
|
||||
return nacos_request_text("PUT", "/nacos/v1/ns/instance", params=params).strip()
|
||||
|
||||
|
||||
def build_service_level(instance_total: int, healthy_total: int, enabled_total: int) -> str:
|
||||
# 没有实例时直接标 bad。
|
||||
if instance_total == 0:
|
||||
|
||||
@ -8,6 +8,9 @@ from typing import Any
|
||||
|
||||
from .compose_templates import sync_remote_host_compose_template
|
||||
from core.config import (
|
||||
NACOS_RELEASE_DRAIN_SECONDS,
|
||||
NACOS_RELEASE_DRAIN_SERVICES,
|
||||
NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS,
|
||||
ROLLING_RESTART_POLL_SECONDS,
|
||||
ROLLING_RESTART_STABILIZE_SECONDS,
|
||||
ROLLING_RESTART_TIMEOUT_SECONDS,
|
||||
@ -26,6 +29,7 @@ from .nacos import (
|
||||
current_namespace,
|
||||
list_instances,
|
||||
nacos_truthy,
|
||||
set_instance_enabled,
|
||||
)
|
||||
from .ssh_remote import run_host_script
|
||||
|
||||
@ -179,6 +183,100 @@ def wait_java_registration_ready(service: dict[str, Any], timeout_seconds: int)
|
||||
raise RuntimeError(f"nacos registration not ready on {service['host']} {service['service']}")
|
||||
|
||||
|
||||
def should_drain_java_registration(service: dict[str, Any]) -> bool:
|
||||
# 默认只对 other 做发布前摘流;其他服务需要时可通过 .env 扩展。
|
||||
return service.get("kind") == "java" and str(service.get("service") or "").strip() in NACOS_RELEASE_DRAIN_SERVICES
|
||||
|
||||
|
||||
def current_java_registration_record(service: dict[str, Any]) -> dict[str, Any] | None:
|
||||
# 返回当前 ip:port 对应的 Nacos 实例,供摘流和恢复复用。
|
||||
registry_name = NACOS_SERVICE_NAME_MAP.get(service["service"])
|
||||
if not registry_name:
|
||||
return None
|
||||
|
||||
namespace_id, _ = current_namespace()
|
||||
return matched_nacos_record(list_instances(namespace_id, registry_name, NACOS_DISCOVERY_GROUP), service)
|
||||
|
||||
|
||||
def update_java_registration_enabled(service: dict[str, Any], enabled: bool) -> dict[str, Any]:
|
||||
# 只更新当前服务实例的 enabled 状态,不影响同服务其他宿主机。
|
||||
registry_name = NACOS_SERVICE_NAME_MAP.get(service["service"])
|
||||
if not registry_name:
|
||||
return {"checked": False, "reason": "service not managed by nacos registry map"}
|
||||
|
||||
namespace_id, _ = current_namespace()
|
||||
current_record = current_java_registration_record(service)
|
||||
ephemeral = current_record.get("ephemeral") if current_record and "ephemeral" in current_record else True
|
||||
response_text = set_instance_enabled(
|
||||
namespace_id,
|
||||
registry_name,
|
||||
NACOS_DISCOVERY_GROUP,
|
||||
ip=str(service["ip"]),
|
||||
port=int(service["port"]),
|
||||
enabled=enabled,
|
||||
ephemeral=bool(ephemeral),
|
||||
)
|
||||
NACOS_CACHE.clear()
|
||||
return {
|
||||
"checked": True,
|
||||
"serviceName": registry_name,
|
||||
"enabled": enabled,
|
||||
"response": response_text,
|
||||
"ephemeral": bool(ephemeral),
|
||||
}
|
||||
|
||||
|
||||
def wait_java_registration_enabled_state(service: dict[str, Any], enabled: bool, timeout_seconds: int) -> dict[str, Any]:
|
||||
# 等到 Nacos 读回当前实例 enabled 状态,避免刚写完就立刻重启。
|
||||
registry_name = NACOS_SERVICE_NAME_MAP.get(service["service"])
|
||||
if not registry_name:
|
||||
return {"checked": False, "reason": "service not managed by nacos registry map"}
|
||||
|
||||
namespace_id, _ = current_namespace()
|
||||
deadline = time.time() + timeout_seconds
|
||||
last_record: dict[str, Any] | None = None
|
||||
|
||||
while time.time() < deadline:
|
||||
last_record = matched_nacos_record(list_instances(namespace_id, registry_name, NACOS_DISCOVERY_GROUP), service)
|
||||
if last_record is not None and nacos_truthy(last_record.get("enabled", True)) == enabled:
|
||||
return {
|
||||
"checked": True,
|
||||
"enabled": enabled,
|
||||
"healthy": nacos_truthy(last_record.get("healthy", False)),
|
||||
"source": "instance/list",
|
||||
}
|
||||
time.sleep(ROLLING_RESTART_POLL_SECONDS)
|
||||
|
||||
raise RuntimeError(f"nacos enabled={enabled} not ready on {service['host']} {service['service']}")
|
||||
|
||||
|
||||
def drain_java_registration(service: dict[str, Any]) -> dict[str, Any]:
|
||||
# 摘掉当前实例后给调用方一点缓存刷新时间,避免 live 继续打到正在重启的 other。
|
||||
if not should_drain_java_registration(service):
|
||||
return {"checked": False, "reason": "service not configured for nacos release drain"}
|
||||
|
||||
update_result = update_java_registration_enabled(service, False)
|
||||
state_result = wait_java_registration_enabled_state(service, False, NACOS_RELEASE_DRAIN_TIMEOUT_SECONDS)
|
||||
if NACOS_RELEASE_DRAIN_SECONDS > 0:
|
||||
time.sleep(NACOS_RELEASE_DRAIN_SECONDS)
|
||||
return {
|
||||
**state_result,
|
||||
"drainSeconds": NACOS_RELEASE_DRAIN_SECONDS,
|
||||
"update": update_result,
|
||||
}
|
||||
|
||||
|
||||
def restore_java_registration(service: dict[str, Any]) -> dict[str, Any]:
|
||||
# 容器 HTTP 恢复后再打开 Nacos enabled,并读回健康状态。
|
||||
update_result = update_java_registration_enabled(service, True)
|
||||
ready_result = wait_java_registration_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS)
|
||||
return {
|
||||
**ready_result,
|
||||
"restored": True,
|
||||
"update": update_result,
|
||||
}
|
||||
|
||||
|
||||
def clear_operation_caches() -> None:
|
||||
# 重启会直接影响服务探测和 Nacos 面板,完成后把缓存清掉。
|
||||
SERVICE_CACHE.clear()
|
||||
@ -209,6 +307,7 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]:
|
||||
"kind": service["kind"],
|
||||
"status": "running",
|
||||
}
|
||||
nacos_drained = False
|
||||
|
||||
try:
|
||||
# 单机第一次进入重启前,先把完整 compose 模板同步到远端。
|
||||
@ -223,6 +322,12 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]:
|
||||
"source": str(prepared_hosts[service["host"]].get("source") or ""),
|
||||
}
|
||||
|
||||
if service["kind"] == "java":
|
||||
drain_result = drain_java_registration(service)
|
||||
if drain_result.get("checked"):
|
||||
nacos_drained = True
|
||||
step["nacosDrain"] = drain_result
|
||||
|
||||
restart_result = restart_service_instance(service)
|
||||
step["remoteStatus"] = restart_result.get("status")
|
||||
step["remoteOutput"] = str(restart_result.get("output") or "").strip()[:500]
|
||||
@ -236,7 +341,11 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
if service["kind"] == "java":
|
||||
step["nacos"] = wait_java_registration_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS)
|
||||
step["nacos"] = (
|
||||
restore_java_registration(service)
|
||||
if nacos_drained
|
||||
else wait_java_registration_ready(service, ROLLING_RESTART_TIMEOUT_SECONDS)
|
||||
)
|
||||
|
||||
if ROLLING_RESTART_STABILIZE_SECONDS > 0:
|
||||
time.sleep(ROLLING_RESTART_STABILIZE_SECONDS)
|
||||
@ -245,6 +354,11 @@ def rolling_restart_payload(service_names: list[str]) -> dict[str, Any]:
|
||||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||||
steps.append(step)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if nacos_drained:
|
||||
try:
|
||||
step["nacosRestoreRecovered"] = update_java_registration_enabled(service, True)
|
||||
except Exception as restore_exc: # noqa: BLE001
|
||||
step["nacosRestoreError"] = str(restore_exc)
|
||||
step["status"] = "failed"
|
||||
step["error"] = str(exc)
|
||||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||||
|
||||
@ -64,8 +64,11 @@ from core.config import (
|
||||
)
|
||||
from .service_ops import (
|
||||
clear_operation_caches,
|
||||
drain_java_registration,
|
||||
restart_service_instance,
|
||||
restore_java_registration,
|
||||
service_records_by_name,
|
||||
update_java_registration_enabled,
|
||||
wait_http_ready,
|
||||
wait_java_registration_ready,
|
||||
)
|
||||
@ -1626,6 +1629,7 @@ def deploy_runtime_services_payload(
|
||||
gateway_clb_snapshot: dict[str, Any] | None = None
|
||||
gateway_clb_health_ready = False
|
||||
gateway_clb_restored = False
|
||||
nacos_drained = False
|
||||
try:
|
||||
emit_progress(progress, step_stage, f"开始滚动更新:{service['host']} / {service_name}")
|
||||
|
||||
@ -1655,6 +1659,16 @@ def deploy_runtime_services_payload(
|
||||
if GATEWAY_CLB_DRAIN_SECONDS > 0:
|
||||
emit_progress(progress, step_stage, f"CLB 排空等待 {GATEWAY_CLB_DRAIN_SECONDS}s")
|
||||
time.sleep(GATEWAY_CLB_DRAIN_SECONDS)
|
||||
elif service["kind"] == "java":
|
||||
drain_result = drain_java_registration(service)
|
||||
if drain_result.get("checked"):
|
||||
nacos_drained = True
|
||||
step["nacosDrain"] = drain_result
|
||||
emit_progress(
|
||||
progress,
|
||||
step_stage,
|
||||
f"Nacos 摘流完成:enabled=false drain={drain_result.get('drainSeconds')}s",
|
||||
)
|
||||
|
||||
restart_result = restart_service_instance(service)
|
||||
step["remoteStatus"] = restart_result.get("status")
|
||||
@ -1679,10 +1693,14 @@ def deploy_runtime_services_payload(
|
||||
)
|
||||
|
||||
if service["kind"] == "java":
|
||||
step["nacos"] = wait_java_registration_ready(
|
||||
step["nacos"] = (
|
||||
restore_java_registration(service)
|
||||
if nacos_drained
|
||||
else wait_java_registration_ready(
|
||||
service,
|
||||
timeout_seconds=ROLLING_RESTART_TIMEOUT_SECONDS,
|
||||
)
|
||||
)
|
||||
emit_progress(
|
||||
progress,
|
||||
step_stage,
|
||||
@ -1730,6 +1748,13 @@ def deploy_runtime_services_payload(
|
||||
emit_progress(progress, step_stage, f"失败收尾时已恢复 CLB 权重:{restore_result['summary']}")
|
||||
except Exception as restore_exc: # noqa: BLE001
|
||||
step.setdefault("clb", {})["restoreError"] = str(restore_exc)
|
||||
if nacos_drained:
|
||||
try:
|
||||
restore_result = update_java_registration_enabled(service, True)
|
||||
step["nacosRestoreRecovered"] = restore_result
|
||||
emit_progress(progress, step_stage, "失败收尾时已恢复 Nacos enabled=true")
|
||||
except Exception as restore_exc: # noqa: BLE001
|
||||
step["nacosRestoreError"] = str(restore_exc)
|
||||
step["status"] = "failed"
|
||||
step["error"] = str(exc)
|
||||
step["durationSeconds"] = round(time.time() - started_at, 2)
|
||||
|
||||
@ -100,6 +100,11 @@ 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()
|
||||
# 独立 chatapp-cron 目标机配置;配置齐备时也会加入 SSH 引导。
|
||||
CHATAPP_CRON_ENABLED = env_truthy("CHATAPP_CRON_ENABLED", False)
|
||||
CHATAPP_CRON_HOST = os.environ.get("CHATAPP_CRON_HOST", "").strip()
|
||||
CHATAPP_CRON_IP = os.environ.get("CHATAPP_CRON_IP", "").strip()
|
||||
CHATAPP_CRON_INSTANCE_ID = os.environ.get("CHATAPP_CRON_INSTANCE_ID", "").strip()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
@ -234,6 +239,18 @@ def load_target_hosts(host_names: list[str]) -> list[dict[str, str]]:
|
||||
"instanceId": ADMIN_FRONTEND_INSTANCE_ID,
|
||||
}
|
||||
)
|
||||
# 定时任务机器也不在 targets.json 时,通过 .env 并入同一轮 SSH 引导。
|
||||
if CHATAPP_CRON_ENABLED and CHATAPP_CRON_HOST and CHATAPP_CRON_INSTANCE_ID:
|
||||
if (not selected) or CHATAPP_CRON_HOST in selected:
|
||||
if CHATAPP_CRON_INSTANCE_ID not in seen_instance_ids:
|
||||
seen_instance_ids.add(CHATAPP_CRON_INSTANCE_ID)
|
||||
items.append(
|
||||
{
|
||||
"host": CHATAPP_CRON_HOST,
|
||||
"ip": CHATAPP_CRON_IP,
|
||||
"instanceId": CHATAPP_CRON_INSTANCE_ID,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
|
||||
@ -4146,6 +4146,35 @@ body.auth-page {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.common-cron-grid {
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(0, 420px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.common-cron-summary {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.common-cron-target-strip,
|
||||
.common-cron-status-strip {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.common-cron-log-preview {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.common-cron-log-panel .release-log-list {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.compact-button {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mongo-panel-grid.narrow {
|
||||
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
|
||||
}
|
||||
@ -5108,6 +5137,7 @@ body.auth-page {
|
||||
|
||||
.mongo-panel-grid,
|
||||
.common-cdn-grid,
|
||||
.common-cron-grid,
|
||||
.mongo-query-grid,
|
||||
.mysql-filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@ -5274,6 +5304,7 @@ body.auth-page {
|
||||
.update-form-grid,
|
||||
.mongo-panel-grid,
|
||||
.common-cdn-grid,
|
||||
.common-cron-grid,
|
||||
.mongo-query-grid,
|
||||
.mysql-filter-grid,
|
||||
.mysql-checkbox-grid,
|
||||
|
||||
405
static/app.js
405
static/app.js
@ -46,6 +46,7 @@ const COMMON_MODULE_VIEW_TABS = [
|
||||
const COMMON_TOOL_TABS = [
|
||||
{ id: "mysql", label: "MySQL" },
|
||||
{ id: "cdn", label: "CDN 刷新" },
|
||||
{ id: "cron", label: "Cron 部署" },
|
||||
];
|
||||
const PRIMARY_FILTERS = [
|
||||
{ id: "all", label: "全部" },
|
||||
@ -395,6 +396,51 @@ function createCdnAdminState() {
|
||||
};
|
||||
}
|
||||
|
||||
function createChatappCronDeployState() {
|
||||
return {
|
||||
loaded: false,
|
||||
loading: false,
|
||||
deploying: false,
|
||||
error: "",
|
||||
message: "",
|
||||
messageTone: "neutral",
|
||||
form: {
|
||||
gitRef: "main",
|
||||
},
|
||||
overview: {
|
||||
ok: false,
|
||||
enabled: false,
|
||||
configured: false,
|
||||
updatedAt: "",
|
||||
settings: {
|
||||
enabled: false,
|
||||
serviceName: "chatapp-cron",
|
||||
host: "",
|
||||
instanceId: "",
|
||||
sshHost: "",
|
||||
repoRoot: "",
|
||||
repoUrl: "",
|
||||
defaultGitRef: "main",
|
||||
remoteDir: "",
|
||||
composeFile: "",
|
||||
composeService: "week-star-job",
|
||||
binaryPath: "",
|
||||
dockerPlatform: "linux/amd64",
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
operationId: "",
|
||||
status: "",
|
||||
stage: "",
|
||||
startedAt: "",
|
||||
finishedAt: "",
|
||||
logs: [],
|
||||
result: null,
|
||||
pollTimer: null,
|
||||
statusRequestInFlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
createApp({
|
||||
template: APP_TEMPLATE,
|
||||
data() {
|
||||
@ -552,6 +598,7 @@ createApp({
|
||||
mongoAdmin: createMongoAdminState(),
|
||||
mySqlAdmin: createMySqlAdminState(),
|
||||
cdnAdmin: createCdnAdminState(),
|
||||
chatappCron: createChatappCronDeployState(),
|
||||
updateOps: {
|
||||
loading: false,
|
||||
loaded: false,
|
||||
@ -634,7 +681,7 @@ createApp({
|
||||
formattedUpdatedAt() {
|
||||
if (CURRENT_APP_CONTEXT.mode === "common") {
|
||||
return this.formatDateTime(
|
||||
this.cdnAdmin.overview.updatedAt || this.mySqlAdmin.overview.updatedAt || "",
|
||||
this.chatappCron.overview.updatedAt || this.cdnAdmin.overview.updatedAt || this.mySqlAdmin.overview.updatedAt || "",
|
||||
);
|
||||
}
|
||||
return this.formatDateTime(this.payload.updatedAt);
|
||||
@ -653,6 +700,9 @@ createApp({
|
||||
if (this.commonTool === "cdn" && this.cdnAdmin.error) {
|
||||
return "bad";
|
||||
}
|
||||
if (this.commonTool === "cron" && this.chatappCron.error) {
|
||||
return "bad";
|
||||
}
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
@ -706,7 +756,13 @@ createApp({
|
||||
return this.releaseKeyword;
|
||||
}
|
||||
if (this.activeView === "common") {
|
||||
return this.commonTool === "mysql" ? this.mySqlAdmin.keyword : this.cdnAdmin.keyword;
|
||||
if (this.commonTool === "mysql") {
|
||||
return this.mySqlAdmin.keyword;
|
||||
}
|
||||
if (this.commonTool === "cdn") {
|
||||
return this.cdnAdmin.keyword;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
@ -737,7 +793,7 @@ createApp({
|
||||
if (this.activeView === "common") {
|
||||
if (this.commonTool === "mysql") {
|
||||
this.mySqlAdmin.keyword = nextValue;
|
||||
} else {
|
||||
} else if (this.commonTool === "cdn") {
|
||||
this.cdnAdmin.keyword = nextValue;
|
||||
}
|
||||
}
|
||||
@ -760,12 +816,19 @@ createApp({
|
||||
return "搜索服务 / 主机 / 镜像";
|
||||
}
|
||||
if (this.activeView === "common") {
|
||||
return this.commonTool === "mysql" ? "搜索数据库 / 表" : "搜索 taskId / URL / 状态";
|
||||
if (this.commonTool === "mysql") {
|
||||
return "搜索数据库 / 表";
|
||||
}
|
||||
if (this.commonTool === "cdn") {
|
||||
return "搜索 taskId / URL / 状态";
|
||||
}
|
||||
return "chatapp-cron 部署无搜索";
|
||||
}
|
||||
return "全局搜索";
|
||||
},
|
||||
globalSearchDisabled() {
|
||||
return this.activeView === "config" && this.configTab === "go";
|
||||
return (this.activeView === "config" && this.configTab === "go")
|
||||
|| (this.activeView === "common" && this.commonTool === "cron");
|
||||
},
|
||||
dataFreshnessLabel() {
|
||||
const updatedAt = Date.parse(this.payload.updatedAt || "");
|
||||
@ -806,6 +869,7 @@ createApp({
|
||||
|| (this.restartOps.items || []).length > 0
|
||||
|| this.mySqlAdmin.loaded
|
||||
|| this.cdnAdmin.loaded
|
||||
|| this.chatappCron.loaded
|
||||
);
|
||||
},
|
||||
showLoadingSkeleton() {
|
||||
@ -1512,10 +1576,89 @@ createApp({
|
||||
.includes(keyword)
|
||||
));
|
||||
},
|
||||
chatappCronSettings() {
|
||||
return this.chatappCron.overview.settings || createChatappCronDeployState().overview.settings;
|
||||
},
|
||||
chatappCronMetadata() {
|
||||
return this.chatappCron.overview.metadata || {};
|
||||
},
|
||||
chatappCronIsRunning() {
|
||||
return Boolean(
|
||||
this.chatappCron.deploying
|
||||
|| this.chatappCron.status === "queued"
|
||||
|| this.chatappCron.status === "running",
|
||||
);
|
||||
},
|
||||
chatappCronStatusTone() {
|
||||
if (this.chatappCron.error || this.chatappCron.status === "failed" || this.chatappCronMetadata.error) {
|
||||
return "bad";
|
||||
}
|
||||
if (this.chatappCronIsRunning) {
|
||||
return "warn";
|
||||
}
|
||||
if (this.chatappCron.status === "success" || this.chatappCron.overview.enabled) {
|
||||
return "ok";
|
||||
}
|
||||
if (this.chatappCron.loaded && !this.chatappCron.overview.enabled) {
|
||||
return "warn";
|
||||
}
|
||||
return "neutral";
|
||||
},
|
||||
chatappCronStatusText() {
|
||||
if (this.chatappCron.error || this.chatappCron.status === "failed") {
|
||||
return "部署异常";
|
||||
}
|
||||
if (this.chatappCron.status === "queued") {
|
||||
return "排队中";
|
||||
}
|
||||
if (this.chatappCron.status === "running" || this.chatappCron.deploying) {
|
||||
return "部署中";
|
||||
}
|
||||
if (this.chatappCron.status === "success") {
|
||||
return "已完成";
|
||||
}
|
||||
if (this.chatappCron.loaded && !this.chatappCron.overview.enabled) {
|
||||
return "未配置";
|
||||
}
|
||||
return this.chatappCron.loaded ? "可部署" : "待加载";
|
||||
},
|
||||
chatappCronCurrentCommit() {
|
||||
return this.chatappCronMetadata.commit || this.chatappCron.result?.build?.commit || "";
|
||||
},
|
||||
chatappCronLogText() {
|
||||
return (this.chatappCron.logs || [])
|
||||
.map((entry) => {
|
||||
const time = this.formatDateTime(entry.time);
|
||||
const stage = entry.stage ? ` [${entry.stage}]` : "";
|
||||
const level = entry.level && entry.level !== "info" ? ` (${entry.level})` : "";
|
||||
return `${time}${stage}${level} ${entry.message || ""}`.trim();
|
||||
})
|
||||
.join("\n");
|
||||
},
|
||||
recentChatappCronLogs() {
|
||||
return (this.chatappCron.logs || [])
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
.map((entry, index) => ({
|
||||
...entry,
|
||||
key: `${entry.time || "log"}-${entry.stage || "stage"}-${index}`,
|
||||
}));
|
||||
},
|
||||
chatappCronLogDownloadUrl() {
|
||||
if (!this.chatappCron.operationId) {
|
||||
return "";
|
||||
}
|
||||
const query = new URLSearchParams({ id: this.chatappCron.operationId });
|
||||
return `/api/chatapp-cron/deploy-log?${query.toString()}`;
|
||||
},
|
||||
commonToolContextLabel() {
|
||||
return this.commonTool === "mysql"
|
||||
? "MySQL / 数据 / 写入 / 结构 / SQL"
|
||||
: "CDN / URL 刷新 / 目录刷新 / 最近记录";
|
||||
if (this.commonTool === "mysql") {
|
||||
return "MySQL / 数据 / 写入 / 结构 / SQL";
|
||||
}
|
||||
if (this.commonTool === "cdn") {
|
||||
return "CDN / URL 刷新 / 目录刷新 / 最近记录";
|
||||
}
|
||||
return "chatapp-cron / 构建 / 上传 / 重启";
|
||||
},
|
||||
mySqlSqlShortcuts() {
|
||||
const tableName = this.selectedMySqlTableItem?.name || "";
|
||||
@ -2123,6 +2266,9 @@ createApp({
|
||||
if (this.logViewer.kind === "service") {
|
||||
return `${this.serviceLog.service || "-"} · ${this.serviceLog.host || "-"}`;
|
||||
}
|
||||
if (this.logViewer.kind === "chatapp-cron") {
|
||||
return "chatapp-cron 部署日志";
|
||||
}
|
||||
return "执行日志";
|
||||
},
|
||||
activeLogViewerSubtitle() {
|
||||
@ -2134,36 +2280,54 @@ createApp({
|
||||
subtitleParts.push(`更新于 ${this.formatDateTime(this.serviceLog.updatedAt)}`);
|
||||
return subtitleParts.join(" · ");
|
||||
}
|
||||
if (this.logViewer.kind === "chatapp-cron") {
|
||||
return `任务 ${this.chatappCron.operationId || "-"} · ${this.chatappCronStatusText} · ${this.formatDateTime(this.chatappCron.finishedAt || this.chatappCron.startedAt)}`;
|
||||
}
|
||||
return `任务 ${this.updateOps.operationId || "-"} · ${this.updateStatusLabel()} · ${this.formatDateTime(this.updateOps.finishedAt || this.updateOps.startedAt)}`;
|
||||
},
|
||||
activeLogViewerBadge() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return this.serviceLog.container || "";
|
||||
}
|
||||
if (this.logViewer.kind === "chatapp-cron") {
|
||||
return this.chatappCron.operationId || "";
|
||||
}
|
||||
return this.updateOps.operationId || "";
|
||||
},
|
||||
activeLogViewerSecondaryBadge() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return `最近 ${this.serviceLog.previewLines} 条`;
|
||||
}
|
||||
if (this.logViewer.kind === "chatapp-cron") {
|
||||
return this.chatappCron.stage ? `阶段 ${this.chatappCron.stage}` : "";
|
||||
}
|
||||
return this.updateOps.stage ? `阶段 ${this.updateOps.stage}` : "";
|
||||
},
|
||||
activeLogViewerContent() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return this.serviceLog.content;
|
||||
}
|
||||
if (this.logViewer.kind === "chatapp-cron") {
|
||||
return this.chatappCronLogText;
|
||||
}
|
||||
return this.updateLogText;
|
||||
},
|
||||
activeLogViewerError() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return this.serviceLog.error;
|
||||
}
|
||||
if (this.logViewer.kind === "chatapp-cron") {
|
||||
return this.chatappCron.error ? this.explainError(this.chatappCron.error) : "";
|
||||
}
|
||||
return this.updateOps.error ? this.explainError(this.updateOps.error) : "";
|
||||
},
|
||||
activeLogViewerLoading() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return this.serviceLog.loading;
|
||||
}
|
||||
if (this.logViewer.kind === "chatapp-cron") {
|
||||
return this.chatappCron.statusRequestInFlight;
|
||||
}
|
||||
return this.updateOps.statusRequestInFlight;
|
||||
},
|
||||
activeLogViewerExporting() {
|
||||
@ -2176,12 +2340,18 @@ createApp({
|
||||
if (this.logViewer.kind === "service") {
|
||||
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.loading;
|
||||
}
|
||||
if (this.logViewer.kind === "chatapp-cron") {
|
||||
return !this.chatappCron.operationId || this.chatappCron.statusRequestInFlight;
|
||||
}
|
||||
return !this.updateOps.operationId || this.updateOps.statusRequestInFlight;
|
||||
},
|
||||
activeLogViewerDownloadDisabled() {
|
||||
if (this.logViewer.kind === "service") {
|
||||
return !this.serviceLog.service || !this.serviceLog.host || this.serviceLog.exporting;
|
||||
}
|
||||
if (this.logViewer.kind === "chatapp-cron") {
|
||||
return !this.chatappCron.operationId;
|
||||
}
|
||||
return !this.updateOps.operationId;
|
||||
},
|
||||
},
|
||||
@ -2233,9 +2403,16 @@ createApp({
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === "cdn") {
|
||||
if (!this.cdnAdmin.loaded && !this.cdnAdmin.loading) {
|
||||
await this.refreshCdnOverview({ silent: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === "cron" && !this.chatappCron.loaded && !this.chatappCron.loading) {
|
||||
await this.refreshChatappCronOverview({ silent: true });
|
||||
}
|
||||
},
|
||||
async ensureReleaseWorkbenchLoaded({ force = false } = {}) {
|
||||
const shouldRefreshUpdate = force
|
||||
@ -2643,6 +2820,11 @@ createApp({
|
||||
this.logViewer.kind = "update";
|
||||
this.$nextTick(() => this.scrollUpdateLogToBottom());
|
||||
},
|
||||
openChatappCronLogViewer() {
|
||||
this.logViewer.open = true;
|
||||
this.logViewer.kind = "chatapp-cron";
|
||||
this.$nextTick(() => this.scrollUpdateLogToBottom());
|
||||
},
|
||||
closeLogViewer() {
|
||||
const previousKind = this.logViewer.kind;
|
||||
this.logViewer.open = false;
|
||||
@ -2659,6 +2841,14 @@ createApp({
|
||||
await this.openServiceLog({ host: this.serviceLog.host });
|
||||
return;
|
||||
}
|
||||
if (this.logViewer.kind === "chatapp-cron") {
|
||||
if (!this.chatappCron.operationId) {
|
||||
return;
|
||||
}
|
||||
await this.fetchChatappCronDeployStatus(this.chatappCron.operationId);
|
||||
this.scrollUpdateLogToBottom();
|
||||
return;
|
||||
}
|
||||
if (!this.updateOps.operationId) {
|
||||
return;
|
||||
}
|
||||
@ -2670,6 +2860,17 @@ createApp({
|
||||
await this.downloadServiceLog();
|
||||
return;
|
||||
}
|
||||
if (this.logViewer.kind === "chatapp-cron") {
|
||||
if (!this.chatappCron.operationId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.downloadFile(this.chatappCronLogDownloadUrl, `chatapp-cron-${this.chatappCron.operationId}.log`);
|
||||
} catch (error) {
|
||||
this.chatappCron.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.updateOps.operationId) {
|
||||
return;
|
||||
}
|
||||
@ -3516,6 +3717,7 @@ createApp({
|
||||
|
||||
this.auth.redirecting = true;
|
||||
this.stopUpdatePolling();
|
||||
this.stopChatappCronPolling();
|
||||
|
||||
if (this.timer) {
|
||||
window.clearInterval(this.timer);
|
||||
@ -3700,13 +3902,15 @@ createApp({
|
||||
this.error = "";
|
||||
this.mySqlAdmin.error = "";
|
||||
this.cdnAdmin.error = "";
|
||||
this.chatappCron.error = "";
|
||||
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
this.refreshMySqlDatabases({ preserveSelection: true, silent: true }),
|
||||
this.refreshCdnOverview({ silent: true }),
|
||||
this.refreshChatappCronOverview({ silent: true }),
|
||||
]);
|
||||
this.error = this.mySqlAdmin.error || this.cdnAdmin.error || "";
|
||||
this.error = this.mySqlAdmin.error || this.cdnAdmin.error || this.chatappCron.error || "";
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@ -4000,6 +4204,69 @@ createApp({
|
||||
this.cdnAdmin.message = message || "";
|
||||
this.cdnAdmin.messageTone = tone || "neutral";
|
||||
},
|
||||
setChatappCronMessage(message, tone = "neutral") {
|
||||
this.chatappCron.message = message || "";
|
||||
this.chatappCron.messageTone = tone || "neutral";
|
||||
},
|
||||
applyChatappCronOverview(payload) {
|
||||
const defaults = createChatappCronDeployState().overview;
|
||||
const nextSettings = {
|
||||
...defaults.settings,
|
||||
...(payload?.settings || {}),
|
||||
};
|
||||
this.chatappCron.overview = {
|
||||
...defaults,
|
||||
...(payload || {}),
|
||||
settings: nextSettings,
|
||||
metadata: payload?.metadata && typeof payload.metadata === "object" ? payload.metadata : {},
|
||||
};
|
||||
this.chatappCron.loaded = true;
|
||||
this.chatappCron.error = payload?.error || "";
|
||||
if (!this.chatappCron.form.gitRef || this.chatappCron.form.gitRef === defaults.settings.defaultGitRef) {
|
||||
this.chatappCron.form.gitRef = nextSettings.defaultGitRef || defaults.settings.defaultGitRef;
|
||||
}
|
||||
},
|
||||
applyChatappCronOperation(operation) {
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chatappCron.operationId = operation.operationId || "";
|
||||
this.chatappCron.status = operation.status || "";
|
||||
this.chatappCron.stage = operation.stage || "";
|
||||
this.chatappCron.startedAt = operation.startedAt || "";
|
||||
this.chatappCron.finishedAt = operation.finishedAt || "";
|
||||
this.chatappCron.logs = Array.isArray(operation.logs) ? operation.logs : [];
|
||||
this.chatappCron.result = operation.result || null;
|
||||
this.chatappCron.deploying = operation.status === "queued" || operation.status === "running";
|
||||
|
||||
if (operation.status === "success") {
|
||||
this.chatappCron.error = "";
|
||||
this.setChatappCronMessage(operation.message || "chatapp-cron 部署完成", "ok");
|
||||
} else if (operation.status === "failed") {
|
||||
this.chatappCron.error = operation.error || operation.result?.error || "chatapp-cron 部署失败";
|
||||
this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn");
|
||||
} else {
|
||||
this.setChatappCronMessage(operation.message || "chatapp-cron 部署执行中", "neutral");
|
||||
}
|
||||
|
||||
this.scrollUpdateLogToBottom();
|
||||
},
|
||||
stopChatappCronPolling() {
|
||||
if (this.chatappCron.pollTimer) {
|
||||
window.clearInterval(this.chatappCron.pollTimer);
|
||||
this.chatappCron.pollTimer = null;
|
||||
}
|
||||
},
|
||||
startChatappCronPolling() {
|
||||
this.stopChatappCronPolling();
|
||||
if (!this.chatappCron.operationId) {
|
||||
return;
|
||||
}
|
||||
this.chatappCron.pollTimer = window.setInterval(() => {
|
||||
this.fetchChatappCronDeployStatus(this.chatappCron.operationId, { silent: true });
|
||||
}, 2000);
|
||||
},
|
||||
parseMultilineItems(text) {
|
||||
return String(text || "")
|
||||
.split(/\r?\n/)
|
||||
@ -4148,6 +4415,125 @@ createApp({
|
||||
this.cdnAdmin.submitting = false;
|
||||
}
|
||||
},
|
||||
async refreshChatappCronOverview({ silent = false } = {}) {
|
||||
this.chatappCron.loading = true;
|
||||
if (!silent) {
|
||||
this.chatappCron.error = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, payload } = await this.requestJson("/api/chatapp-cron/overview");
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.applyChatappCronOverview(payload);
|
||||
if (!silent) {
|
||||
if (!payload.enabled) {
|
||||
this.setChatappCronMessage("chatapp-cron 部署未配置", "warn");
|
||||
} else if (payload.metadata?.error) {
|
||||
this.setChatappCronMessage("已加载配置,最近版本读取失败", "warn");
|
||||
} else {
|
||||
this.setChatappCronMessage("chatapp-cron 配置已刷新", "neutral");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.chatappCron.error = error instanceof Error ? error.message : String(error);
|
||||
if (!silent) {
|
||||
this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn");
|
||||
}
|
||||
} finally {
|
||||
this.chatappCron.loading = false;
|
||||
}
|
||||
},
|
||||
async fetchChatappCronDeployStatus(operationId = this.chatappCron.operationId, { silent = false } = {}) {
|
||||
if (!operationId || this.chatappCron.statusRequestInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasRunning = this.chatappCron.deploying;
|
||||
this.chatappCron.statusRequestInFlight = true;
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({ id: operationId });
|
||||
const { response, payload } = await this.requestJson(`/api/chatapp-cron/deploy-status?${query.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (this.chatappCron.operationId && this.chatappCron.operationId !== operationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyChatappCronOperation(payload.operation || {});
|
||||
if (!this.chatappCron.deploying) {
|
||||
this.stopChatappCronPolling();
|
||||
if (wasRunning || this.chatappCron.status === "success") {
|
||||
await this.refreshChatappCronOverview({ silent: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const notFound = errorMessage.includes("chatapp-cron deploy operation not found") || errorMessage.includes("HTTP 404");
|
||||
if (silent && !notFound) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopChatappCronPolling();
|
||||
this.chatappCron.deploying = false;
|
||||
this.chatappCron.status = "failed";
|
||||
this.chatappCron.error = notFound
|
||||
? "chatapp-cron 部署任务状态不存在,可能因为服务重启或旧任务已清理"
|
||||
: errorMessage;
|
||||
this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn");
|
||||
} finally {
|
||||
this.chatappCron.statusRequestInFlight = false;
|
||||
}
|
||||
},
|
||||
async runChatappCronDeploy() {
|
||||
if (this.chatappCronIsRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.chatappCron.overview.enabled) {
|
||||
this.setChatappCronMessage("chatapp-cron 部署未配置", "warn");
|
||||
return;
|
||||
}
|
||||
|
||||
const gitRef = String(this.chatappCron.form.gitRef || "").trim()
|
||||
|| this.chatappCronSettings.defaultGitRef
|
||||
|| "main";
|
||||
|
||||
this.stopChatappCronPolling();
|
||||
this.chatappCron.deploying = true;
|
||||
this.chatappCron.error = "";
|
||||
this.chatappCron.status = "queued";
|
||||
this.chatappCron.stage = "queued";
|
||||
this.chatappCron.logs = [];
|
||||
this.chatappCron.result = null;
|
||||
this.setChatappCronMessage("chatapp-cron 部署已提交", "neutral");
|
||||
|
||||
try {
|
||||
const { response, payload } = await this.requestJson("/api/chatapp-cron/deploy", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ gitRef }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.applyChatappCronOperation(payload.operation || {});
|
||||
this.startChatappCronPolling();
|
||||
} catch (error) {
|
||||
this.chatappCron.deploying = false;
|
||||
this.chatappCron.status = "failed";
|
||||
this.chatappCron.error = error instanceof Error ? error.message : String(error);
|
||||
this.setChatappCronMessage(this.explainError(this.chatappCron.error), "warn");
|
||||
}
|
||||
},
|
||||
clearMySqlSchemaState() {
|
||||
this.mySqlAdmin.schema = {
|
||||
updatedAt: "",
|
||||
@ -6126,5 +6512,6 @@ createApp({
|
||||
window.clearTimeout(this.copiedTokenTimer);
|
||||
}
|
||||
this.stopUpdatePolling();
|
||||
this.stopChatappCronPolling();
|
||||
},
|
||||
}).mount("#app");
|
||||
|
||||
@ -241,7 +241,7 @@
|
||||
{{ databaseRefreshBusy ? '加载中...' : databaseRefreshLabel }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-else-if="commonTool === 'cdn'">
|
||||
<button class="refresh ghost" @click="refreshCdnOverview()" :disabled="cdnAdmin.loading">
|
||||
{{ cdnAdmin.loading ? '刷新中...' : '刷新记录' }}
|
||||
</button>
|
||||
@ -249,6 +249,17 @@
|
||||
{{ cdnAdmin.submitting ? '提交中...' : '执行刷新' }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="refresh ghost" @click="refreshChatappCronOverview()" :disabled="chatappCron.loading">
|
||||
{{ chatappCron.loading ? '刷新中...' : '刷新配置' }}
|
||||
</button>
|
||||
<button class="refresh ghost" @click="openChatappCronLogViewer" :disabled="!chatappCron.operationId">
|
||||
查看日志
|
||||
</button>
|
||||
<button class="refresh" @click="runChatappCronDeploy" :disabled="chatappCronIsRunning || !chatappCron.overview.enabled">
|
||||
{{ chatappCronIsRunning ? '部署中...' : '执行部署' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@ -2868,6 +2879,125 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="activeView === 'common' && commonTool === 'cron'">
|
||||
<section class="panel infra-panel common-cron-panel">
|
||||
<div class="compact-workbench-head common-workbench-head">
|
||||
<div class="metadata-strip metadata-strip-tight">
|
||||
<span class="mono">{{ chatappCronSettings.serviceName || 'chatapp-cron' }}</span>
|
||||
<span>{{ chatappCronSettings.host || '未配置目标机' }}</span>
|
||||
<span class="mono">{{ chatappCronSettings.composeService || '-' }}</span>
|
||||
<span>更新时间 {{ formatDateTime(chatappCron.overview.updatedAt) }}</span>
|
||||
</div>
|
||||
<span :class="['badge', chatappCronStatusTone]">{{ chatappCronStatusText }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="chatappCron.error || chatappCronMetadata.error" class="metric-error">
|
||||
{{ explainError(chatappCron.error || chatappCronMetadata.error) }}
|
||||
</p>
|
||||
|
||||
<div class="summary-cards common-cron-summary">
|
||||
<article class="summary-card tone-neutral">
|
||||
<small>当前版本</small>
|
||||
<strong class="mono">{{ truncateMiddle(chatappCronCurrentCommit, 14, 8) || '-' }}</strong>
|
||||
<span>{{ formatDateTime(chatappCronMetadata.deployedAt) || '暂无部署记录' }}</span>
|
||||
</article>
|
||||
<article class="summary-card tone-neutral">
|
||||
<small>Git Ref</small>
|
||||
<strong class="mono">{{ chatappCronMetadata.gitRef || chatappCron.form.gitRef || '-' }}</strong>
|
||||
<span>{{ chatappCronSettings.repoUrl ? truncateMiddle(chatappCronSettings.repoUrl, 18, 12) : '仓库未配置' }}</span>
|
||||
</article>
|
||||
<article class="summary-card tone-neutral">
|
||||
<small>目标机</small>
|
||||
<strong>{{ chatappCronSettings.host || '-' }}</strong>
|
||||
<span class="mono">{{ chatappCronSettings.instanceId || chatappCronSettings.sshHost || '-' }}</span>
|
||||
</article>
|
||||
<article class="summary-card tone-neutral">
|
||||
<small>运行目录</small>
|
||||
<strong class="mono">{{ truncateMiddle(chatappCronSettings.remoteDir, 16, 12) || '-' }}</strong>
|
||||
<span class="mono">{{ truncateMiddle(chatappCronSettings.binaryPath, 16, 12) || '-' }}</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="mongo-panel-grid common-cron-grid">
|
||||
<article class="mongo-action-panel common-cron-form-panel tone-write">
|
||||
<header class="panel-header">
|
||||
<div class="panel-title">
|
||||
<h2>部署参数</h2>
|
||||
<p>{{ chatappCronSettings.dockerPlatform || 'linux/amd64' }}</p>
|
||||
</div>
|
||||
<span :class="['badge', chatappCron.overview.enabled ? 'ok' : 'warn']">
|
||||
{{ chatappCron.overview.enabled ? '已配置' : '未配置' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<label class="update-field">
|
||||
<span>Git Ref</span>
|
||||
<input v-model.trim="chatappCron.form.gitRef" class="search mono" :placeholder="chatappCronSettings.defaultGitRef || 'main'" />
|
||||
</label>
|
||||
|
||||
<div class="metadata-strip metadata-strip-tight common-cron-target-strip">
|
||||
<span class="mono">{{ truncateMiddle(chatappCronSettings.repoRoot, 16, 12) || '-' }}</span>
|
||||
<span class="mono">{{ truncateMiddle(chatappCronSettings.composeFile, 16, 12) || '-' }}</span>
|
||||
<span>{{ chatappCronSettings.composeService || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="action-group">
|
||||
<button class="refresh ghost" @click="refreshChatappCronOverview()" :disabled="chatappCron.loading">
|
||||
{{ chatappCron.loading ? '刷新中...' : '刷新配置' }}
|
||||
</button>
|
||||
<button class="refresh" @click="runChatappCronDeploy" :disabled="chatappCronIsRunning || !chatappCron.overview.enabled">
|
||||
{{ chatappCronIsRunning ? '部署中...' : '部署 chatapp-cron' }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="mongo-action-panel common-cron-log-panel tone-edit">
|
||||
<header class="panel-header">
|
||||
<div class="panel-title">
|
||||
<h2>部署状态</h2>
|
||||
<p>{{ chatappCron.operationId ? '任务 ' + chatappCron.operationId : '未开始' }}</p>
|
||||
</div>
|
||||
<span v-if="chatappCron.message" :class="['badge', chatappCron.messageTone === 'ok' ? 'ok' : chatappCron.messageTone === 'warn' ? 'warn' : 'neutral']">
|
||||
{{ chatappCron.message }}
|
||||
</span>
|
||||
<span v-else :class="['badge', chatappCronStatusTone]">{{ chatappCronStatusText }}</span>
|
||||
</header>
|
||||
|
||||
<div class="metadata-strip metadata-strip-tight common-cron-status-strip">
|
||||
<span>{{ chatappCron.stage || '-' }}</span>
|
||||
<span>{{ formatDateTime(chatappCron.startedAt) || '未开始' }}</span>
|
||||
<span>{{ formatDateTime(chatappCron.finishedAt) || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="release-log-preview common-cron-log-preview">
|
||||
<div class="release-log-preview-head">
|
||||
<strong>最近日志</strong>
|
||||
<button class="refresh ghost compact-button" @click="openChatappCronLogViewer" :disabled="!chatappCron.operationId">
|
||||
完整日志
|
||||
</button>
|
||||
</div>
|
||||
<div class="release-log-list">
|
||||
<div
|
||||
v-for="entry in recentChatappCronLogs"
|
||||
:key="entry.key"
|
||||
:class="['release-log-entry', entry.level === 'error' ? 'tone-bad' : '']"
|
||||
>
|
||||
<strong>{{ formatDateTime(entry.time) }}</strong>
|
||||
<span>{{ entry.stage || '-' }}</span>
|
||||
<small>{{ entry.message }}</small>
|
||||
</div>
|
||||
<div v-if="recentChatappCronLogs.length === 0" class="release-log-entry">
|
||||
<strong>-</strong>
|
||||
<span>idle</span>
|
||||
<small>暂无部署日志</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-if="!showLoadingSkeleton && activeView === 'config'">
|
||||
<section v-if="configTab === 'nacos'" class="panel infra-panel config-workbench-panel">
|
||||
<div class="compact-workbench-head config-head-strip">
|
||||
|
||||
84
tests/test_chatapp_cron_deploy.py
Normal file
84
tests/test_chatapp_cron_deploy.py
Normal file
@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from monitors.yumi import chatapp_cron_deploy as cron
|
||||
|
||||
|
||||
class ChatappCronDeployTests(unittest.TestCase):
|
||||
def test_overview_does_not_read_remote_metadata_when_disabled(self) -> None:
|
||||
with (
|
||||
patch.object(cron, "CHATAPP_CRON_ENABLED", False),
|
||||
patch.object(cron, "read_chatapp_cron_metadata") as read_metadata_mock,
|
||||
):
|
||||
payload = cron.build_chatapp_cron_overview_payload()
|
||||
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertFalse(payload["enabled"])
|
||||
self.assertEqual(payload["metadata"], {})
|
||||
read_metadata_mock.assert_not_called()
|
||||
|
||||
def test_activate_uploads_binary_and_recreates_compose_service(self) -> None:
|
||||
host = {
|
||||
"host": "cron",
|
||||
"ip": "10.3.1.14",
|
||||
"instanceId": "ins-nt6bsay0",
|
||||
"sshHost": "10.3.1.14",
|
||||
"sshUser": "root",
|
||||
"sshUseSudo": False,
|
||||
"sshPort": 22,
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
binary_path = Path(tmp_dir) / "week-star-job"
|
||||
binary_path.write_text("binary", encoding="utf-8")
|
||||
binary_path.chmod(0o755)
|
||||
|
||||
with (
|
||||
patch.object(cron, "CHATAPP_CRON_SERVICE_NAME", "chatapp-cron"),
|
||||
patch.object(cron, "CHATAPP_CRON_HOST", "cron"),
|
||||
patch.object(cron, "CHATAPP_CRON_REMOTE_DIR", "/opt/deploy/runtime/current"),
|
||||
patch.object(cron, "CHATAPP_CRON_COMPOSE_FILE", "/opt/deploy/runtime/current/docker-compose.yml"),
|
||||
patch.object(cron, "CHATAPP_CRON_COMPOSE_SERVICE", "week-star-job"),
|
||||
patch.object(cron, "CHATAPP_CRON_BINARY_PATH", "/opt/deploy/runtime/current/week-star-job/service"),
|
||||
patch.object(
|
||||
cron,
|
||||
"CHATAPP_CRON_RELEASE_META_PATH",
|
||||
"/opt/deploy/runtime/current/.hy-app-monitor-chatapp-cron-release.json",
|
||||
),
|
||||
patch.object(cron, "chatapp_cron_host_record", return_value=host),
|
||||
patch.object(cron, "copy_local_path_to_host") as copy_mock,
|
||||
patch.object(
|
||||
cron,
|
||||
"run_host_script_checked",
|
||||
return_value={"target": "cron", "status": "ok", "output": "week-star-job running"},
|
||||
) as run_remote_mock,
|
||||
):
|
||||
result = cron.activate_chatapp_cron_binary(
|
||||
{
|
||||
"binaryPath": str(binary_path),
|
||||
"gitRef": "main",
|
||||
"commit": "f0f6205abc1234567890",
|
||||
}
|
||||
)
|
||||
|
||||
copy_mock.assert_called_once()
|
||||
self.assertEqual(copy_mock.call_args.args[0], host)
|
||||
self.assertEqual(copy_mock.call_args.args[1], binary_path)
|
||||
|
||||
run_remote_mock.assert_called_once()
|
||||
remote_script = run_remote_mock.call_args.args[1]
|
||||
self.assertIn("COMPOSE_SERVICE=week-star-job", remote_script)
|
||||
self.assertIn("BINARY_PATH=/opt/deploy/runtime/current/week-star-job/service", remote_script)
|
||||
self.assertIn('up -d --no-deps --force-recreate "$COMPOSE_SERVICE"', remote_script)
|
||||
self.assertIn("META_BLOB=", remote_script)
|
||||
|
||||
self.assertEqual(result["metadata"]["commit"], "f0f6205abc1234567890")
|
||||
self.assertEqual(result["metadata"]["component"], "week-star-job")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -492,6 +492,115 @@ class GatewayReleaseFlowTests(unittest.TestCase):
|
||||
self.assertTrue(result["ok"])
|
||||
prepare_gateway_release_clb_snapshot_mock.assert_not_called()
|
||||
|
||||
@patch("monitors.yumi.service_release.build_service_images")
|
||||
@patch("monitors.yumi.service_release.service_records_by_name")
|
||||
@patch("monitors.yumi.service_release.buildable_service_names", return_value=["other"])
|
||||
@patch("monitors.yumi.service_release.host_image_plan")
|
||||
@patch("monitors.yumi.service_release.update_local_service_image")
|
||||
@patch("monitors.yumi.service_release.sync_remote_host_compose_template")
|
||||
@patch("monitors.yumi.service_release.prepull_host_images")
|
||||
@patch("monitors.yumi.service_release.drain_java_registration")
|
||||
@patch("monitors.yumi.service_release.restart_service_instance")
|
||||
@patch("monitors.yumi.service_release.wait_http_ready")
|
||||
@patch("monitors.yumi.service_release.restore_java_registration")
|
||||
def test_other_update_drains_nacos_before_restart(
|
||||
self,
|
||||
restore_java_registration_mock,
|
||||
wait_http_ready_mock,
|
||||
restart_service_instance_mock,
|
||||
drain_java_registration_mock,
|
||||
prepull_host_images_mock,
|
||||
sync_remote_host_compose_template_mock,
|
||||
update_local_service_image_mock,
|
||||
host_image_plan_mock,
|
||||
buildable_service_names_mock,
|
||||
service_records_by_name_mock,
|
||||
build_service_images_mock,
|
||||
) -> None:
|
||||
del buildable_service_names_mock
|
||||
other_record = {
|
||||
"host": "app-1",
|
||||
"ip": "10.0.0.3",
|
||||
"instanceId": "ins-app-1",
|
||||
"port": 2400,
|
||||
"path": "/actuator/health",
|
||||
"service": "other",
|
||||
"kind": "java",
|
||||
}
|
||||
service_records_by_name_mock.return_value = {"other": [other_record]}
|
||||
build_service_images_mock.return_value = {
|
||||
"serviceImages": {"other": "registry/other:new"},
|
||||
"builds": [],
|
||||
}
|
||||
host_image_plan_mock.return_value = [
|
||||
{
|
||||
"host": "app-1",
|
||||
"ip": "10.0.0.3",
|
||||
"services": ["other"],
|
||||
"images": ["registry/other:new"],
|
||||
}
|
||||
]
|
||||
update_local_service_image_mock.return_value = {"host": "app-1", "service": "other"}
|
||||
sync_remote_host_compose_template_mock.return_value = {
|
||||
"path": "/tmp/docker-compose.yml",
|
||||
"changed": False,
|
||||
"source": "local",
|
||||
"backupPath": "",
|
||||
}
|
||||
prepull_host_images_mock.return_value = {"status": "SUCCESS", "output": "ok"}
|
||||
wait_http_ready_mock.return_value = {"ok": True, "statusCode": 200, "latencyMs": 11, "detail": "ok"}
|
||||
drain_java_registration_mock.return_value = {
|
||||
"checked": True,
|
||||
"enabled": False,
|
||||
"source": "instance/list",
|
||||
"drainSeconds": 8.0,
|
||||
}
|
||||
restore_java_registration_mock.return_value = {
|
||||
"checked": True,
|
||||
"healthy": True,
|
||||
"enabled": True,
|
||||
"source": "instance/list",
|
||||
"restored": True,
|
||||
}
|
||||
|
||||
call_order: list[str] = []
|
||||
drain_java_registration_mock.side_effect = lambda service: call_order.append("drain") or {
|
||||
"checked": True,
|
||||
"enabled": False,
|
||||
"source": "instance/list",
|
||||
"drainSeconds": 8.0,
|
||||
}
|
||||
restart_service_instance_mock.side_effect = lambda service: call_order.append("restart") or {
|
||||
"status": "SUCCESS",
|
||||
"output": "restarted",
|
||||
}
|
||||
wait_http_ready_mock.side_effect = lambda service, timeout_seconds: call_order.append("http") or {
|
||||
"ok": True,
|
||||
"statusCode": 200,
|
||||
"latencyMs": 11,
|
||||
"detail": "ok",
|
||||
}
|
||||
restore_java_registration_mock.side_effect = lambda service: call_order.append("restore") or {
|
||||
"checked": True,
|
||||
"healthy": True,
|
||||
"enabled": True,
|
||||
"source": "instance/list",
|
||||
"restored": True,
|
||||
}
|
||||
|
||||
result = deploy_runtime_services_payload(
|
||||
["other"],
|
||||
java_git_ref="main",
|
||||
go_git_ref="main",
|
||||
image_tag="20260423",
|
||||
skip_maven_build=True,
|
||||
)
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(call_order, ["drain", "restart", "http", "restore"])
|
||||
self.assertEqual(result["steps"][0]["nacosDrain"]["enabled"], False)
|
||||
self.assertEqual(result["steps"][0]["nacos"]["restored"], True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user