提交 a3b7ab19 authored 作者: 陈泽健's avatar 陈泽健

feat(service-monitor): 本级巡检 SSH 到宿主机 + 前端警告提示

Docker 容器化部署下本级巡检问题修复:
- LocalExecutor 支持 SSH 到宿主机获取真实系统数据
- 新增 get_docker_host_ip() 自动检测宿主机 IP
- 本级目标列表显示警告提示
- docker-compose.yml 添加 extra_hosts + LOCAL_SSH_* 环境变量
- 禁止手动创建 localhost 目标(本级为内置目标)

上一会话遗留合并:
- 版本号+操作日志功能前端构建
- platform/service_manage 页面路由迁移到 Vue SPA
- docker_deploy.sh 添加 --update-fe 选项
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 8967eb0d
@echo off @echo off
cd /d E:\github\ubains-module-test\troubleshoot-ai-assistant cd /d E:\GithubData\ubains-module-test\troubleshoot-ai-assistant
E:\nodejs\claude.cmd --permission-mode bypassPermissions E:\nodejs\claude.cmd --permission-mode bypassPermissions
...@@ -43,3 +43,15 @@ OFFLINE_MODE=true ...@@ -43,3 +43,15 @@ OFFLINE_MODE=true
# 不设则派生自 SECRET_KEY;生产建议单独设置一个 44 字节 base64 密钥 # 不设则派生自 SECRET_KEY;生产建议单独设置一个 44 字节 base64 密钥
# 生成方法:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # 生成方法:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MONITOR_ENC_KEY= MONITOR_ENC_KEY=
# ============================================
# 本级巡检 SSH 到宿主机配置
# Docker 容器化部署下,本级巡检需要 SSH 到宿主机获取真实系统数据
# 如不配置,本级巡检将回退到容器内本地执行(数据可能不准确)
# ============================================
LOCAL_SSH_USER=root
LOCAL_SSH_PASSWORD=your_host_ssh_password_here
# 或使用密钥认证(暂未完全支持)
# LOCAL_SSH_KEY=/app/keys/host_key
# Docker 宿主机 IP(不设则自动检测:ip route 网关 → host.docker.internal)
# DOCKER_HOST_IP=172.17.0.1
\ No newline at end of file
...@@ -154,6 +154,7 @@ def deploy(): ...@@ -154,6 +154,7 @@ def deploy():
# 启动新容器 # 启动新容器
run_cmd = f'''sudo docker run -d --name troubleshoot --restart unless-stopped \\ run_cmd = f'''sudo docker run -d --name troubleshoot --restart unless-stopped \\
--add-host=host.docker.internal:host-gateway \\
-p 8088:80 \\ -p 8088:80 \\
-v {REMOTE_BASE}/monitor-data:/app/web/service_monitor/data \\ -v {REMOTE_BASE}/monitor-data:/app/web/service_monitor/data \\
-v {REMOTE_BASE}/data/users.json:/app/web/users.json \\ -v {REMOTE_BASE}/data/users.json:/app/web/users.json \\
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
# ./docker_deploy.sh --build-only # 仅构建镜像 # ./docker_deploy.sh --build-only # 仅构建镜像
# ./docker_deploy.sh --stop # 停止容器 # ./docker_deploy.sh --stop # 停止容器
# ./docker_deploy.sh --status # 查看状态 # ./docker_deploy.sh --status # 查看状态
# ./docker_deploy.sh --update-fe # 仅更新前端(替换 dist,不重建镜像)
# ============================================================ # ============================================================
set -euo pipefail set -euo pipefail
...@@ -22,6 +23,7 @@ CONTAINER_NAME="troubleshoot" ...@@ -22,6 +23,7 @@ CONTAINER_NAME="troubleshoot"
IMAGE_NAME="troubleshoot:latest" IMAGE_NAME="troubleshoot:latest"
DATA_BASE="/opt/troubleshoot" DATA_BASE="/opt/troubleshoot"
ENV_FILE="${DATA_BASE}/.env" ENV_FILE="${DATA_BASE}/.env"
DIST_DIR="${DATA_BASE}/dist"
# 颜色输出 # 颜色输出
RED='\033[0;31m' RED='\033[0;31m'
...@@ -90,6 +92,9 @@ prepare_dirs() { ...@@ -90,6 +92,9 @@ prepare_dirs() {
# 日志目录 # 日志目录
mkdir -p "${DATA_BASE}/logs" mkdir -p "${DATA_BASE}/logs"
# 前端 dist 目录(首次部署时由镜像构建填充,volume 挂载后覆盖)
mkdir -p "${DIST_DIR}"
# 搜索索引(如果存在旧部署的索引,复制过来) # 搜索索引(如果存在旧部署的索引,复制过来)
if [ -f "${DATA_BASE}/搜索索引.json" ] && [ ! -f "${DATA_BASE}/data/搜索索引.json" ]; then if [ -f "${DATA_BASE}/搜索索引.json" ] && [ ! -f "${DATA_BASE}/data/搜索索引.json" ]; then
cp "${DATA_BASE}/搜索索引.json" "${DATA_BASE}/data/搜索索引.json" cp "${DATA_BASE}/搜索索引.json" "${DATA_BASE}/data/搜索索引.json"
...@@ -107,7 +112,7 @@ prepare_dirs() { ...@@ -107,7 +112,7 @@ prepare_dirs() {
# ---------- 构建镜像 ---------- # ---------- 构建镜像 ----------
build_image() { build_image() {
info "构建 Docker 镜像..." info "构建 Docker 镜像(多阶段:Node 构建 Vue + Python Flask)..."
docker build -t "${IMAGE_NAME}" . docker build -t "${IMAGE_NAME}" .
info "镜像构建完成: ${IMAGE_NAME}" info "镜像构建完成: ${IMAGE_NAME}"
} }
...@@ -125,11 +130,12 @@ start_container() { ...@@ -125,11 +130,12 @@ start_container() {
docker run -d \ docker run -d \
--name "${CONTAINER_NAME}" \ --name "${CONTAINER_NAME}" \
--restart unless-stopped \ --restart unless-stopped \
-p 8088:8088 \ -p 8088:80 \
-v "${DATA_BASE}/monitor-data:/app/web/service_monitor/data" \ -v "${DATA_BASE}/monitor-data:/app/web/service_monitor/data" \
-v "${DATA_BASE}/data/users.json:/app/web/users.json" \ -v "${DATA_BASE}/data/users.json:/app/web/users.json" \
-v "${DATA_BASE}/data/搜索索引.json:/app/data/搜索索引.json:ro" \ -v "${DATA_BASE}/data/搜索索引.json:/app/data/搜索索引.json:ro" \
-v "${DATA_BASE}/data/搜索向量.json:/app/data/搜索向量.json:ro" \ -v "${DATA_BASE}/data/搜索向量.json:/app/data/搜索向量.json:ro" \
-v "${DIST_DIR}:/data/dist" \
-v "${DATA_BASE}/logs:/app/web/logs" \ -v "${DATA_BASE}/logs:/app/web/logs" \
--env-file "${ENV_FILE}" \ --env-file "${ENV_FILE}" \
-e FLASK_DEBUG=0 \ -e FLASK_DEBUG=0 \
...@@ -142,6 +148,20 @@ start_container() { ...@@ -142,6 +148,20 @@ start_container() {
info "容器已启动: ${CONTAINER_NAME}" info "容器已启动: ${CONTAINER_NAME}"
} }
# ---------- 仅更新前端 ----------
update_frontend() {
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
error "容器 ${CONTAINER_NAME} 未运行,请先部署"
fi
info "从容器中提取前端 dist..."
# 先从镜像中复制最新的 dist 到宿主机
docker cp "${CONTAINER_NAME}:/data/dist/." "${DIST_DIR}/"
info "前端已更新(volume 挂载,无需重启容器)"
info "如需强制刷新,可重启 nginx: docker exec ${CONTAINER_NAME} nginx -s reload"
}
# ---------- 健康检查 ---------- # ---------- 健康检查 ----------
health_check() { health_check() {
info "等待服务启动..." info "等待服务启动..."
...@@ -155,7 +175,7 @@ health_check() { ...@@ -155,7 +175,7 @@ health_check() {
curl -s http://localhost:8088/api/health | python3 -m json.tool 2>/dev/null || \ curl -s http://localhost:8088/api/health | python3 -m json.tool 2>/dev/null || \
curl -s http://localhost:8088/api/health curl -s http://localhost:8088/api/health
echo "" echo ""
info "访问地址: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo '192.168.5.60'):8088" info "访问地址: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo '192.168.5.60')"
return 0 return 0
fi fi
count=$((count + 1)) count=$((count + 1))
...@@ -200,6 +220,9 @@ main() { ...@@ -200,6 +220,9 @@ main() {
--status) --status)
show_status show_status
;; ;;
--update-fe)
update_frontend
;;
deploy|"") deploy|"")
check_prerequisites check_prerequisites
prepare_dirs prepare_dirs
...@@ -208,7 +231,7 @@ main() { ...@@ -208,7 +231,7 @@ main() {
health_check health_check
;; ;;
*) *)
echo "用法: $0 [--build-only|--stop|--status]" echo "用法: $0 [--build-only|--stop|--status|--update-fe]"
exit 1 exit 1
;; ;;
esac esac
......
...@@ -29,6 +29,9 @@ services: ...@@ -29,6 +29,9 @@ services:
ports: ports:
- "8088:80" # nginx 对外端口(映射到 8088,保持与旧服务一致) - "8088:80" # nginx 对外端口(映射到 8088,保持与旧服务一致)
# 8088 仅容器内访问,不对外暴露 # 8088 仅容器内访问,不对外暴露
# 本级巡检 SSH 到宿主机(需要 Docker 20.10+)
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
# 服务监测数据(targets/schedules/reports/notifications) # 服务监测数据(targets/schedules/reports/notifications)
- monitor-data:/app/web/service_monitor/data - monitor-data:/app/web/service_monitor/data
...@@ -52,6 +55,10 @@ services: ...@@ -52,6 +55,10 @@ services:
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-} - CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
- OFFLINE_MODE=${OFFLINE_MODE:-true} - OFFLINE_MODE=${OFFLINE_MODE:-true}
- MONITOR_ENC_KEY=${MONITOR_ENC_KEY:-} - MONITOR_ENC_KEY=${MONITOR_ENC_KEY:-}
# 本级巡检 SSH 到宿主机配置
- LOCAL_SSH_USER=${LOCAL_SSH_USER:-root}
- LOCAL_SSH_PASSWORD=${LOCAL_SSH_PASSWORD:-}
- DOCKER_HOST_IP=${DOCKER_HOST_IP:-}
# 资源限制(5.60 有其他服务容器,防止抢占资源) # 资源限制(5.60 有其他服务容器,防止抢占资源)
deploy: deploy:
resources: resources:
......
...@@ -17,6 +17,7 @@ export interface Target { ...@@ -17,6 +17,7 @@ export interface Target {
built_in: boolean built_in: boolean
container_overrides: Record<string, string> container_overrides: Record<string, string>
thresholds: Record<string, number | string> thresholds: Record<string, number | string>
warning?: string // 本级目标警告信息
created_at?: string created_at?: string
updated_at?: string updated_at?: string
} }
......
...@@ -176,6 +176,21 @@ onMounted(loadTargets) ...@@ -176,6 +176,21 @@ onMounted(loadTargets)
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态/警告" min-width="220">
<template #default="{ row }">
<template v-if="(row as Target).warning">
<el-alert
:title="(row as Target).warning"
type="warning"
:closable="false"
show-icon
/>
</template>
<template v-else>
<span class="status-ok">正常</span>
</template>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" width="120"> <el-table-column prop="username" label="用户名" width="120">
<template #default="{ row }"> <template #default="{ row }">
{{ (row as Target).username || '-' }} {{ (row as Target).username || '-' }}
...@@ -279,6 +294,11 @@ onMounted(loadTargets) ...@@ -279,6 +294,11 @@ onMounted(loadTargets)
color: $gray-900; color: $gray-900;
} }
.status-ok {
color: $gray-500;
font-size: 13px;
}
.form-hint { .form-hint {
font-size: 12px; font-size: 12px;
color: $gray-500; color: $gray-500;
......
...@@ -95,10 +95,11 @@ class TestIndex: ...@@ -95,10 +95,11 @@ class TestIndex:
"""平台首页(/ 由 platform Blueprint 接管)""" """平台首页(/ 由 platform Blueprint 接管)"""
def test_index_not_logged_in_redirect(self, client): def test_index_not_logged_in_redirect(self, client):
"""未登录访问 / 重定向到 /login""" """未登录访问 / 重定向到 /login(带 next 参数)"""
r = client.get("/") r = client.get("/")
assert r.status_code == 302 assert r.status_code == 302
assert r.headers["Location"].endswith("/login") # SPA 回退路由在未登录时重定向到 /login?next=/
assert "/login" in r.headers["Location"]
def test_index_logged_in(self, auth_client): def test_index_logged_in(self, auth_client):
"""已登录访问 / 返回平台首页(模块卡片)""" """已登录访问 / 返回平台首页(模块卡片)"""
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
platform.py — 平台首页路由 platform.py — 平台首页路由(已迁移到 Vue SPA)
提供平台级首页(模块列表),作为统一入口 页面路由已移除,仅保留 API 端点(如有)
各模块独立 Blueprint,首页通过 modules.py 动态渲染模块卡片 原有 / 路由由 server.py 的 SPA 回退处理
""" """
from flask import Blueprint, render_template, session, redirect, url_for from flask import Blueprint
from utils.modules import get_modules
from utils.logger import get_logger from utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
bp = Blueprint('platform', __name__) bp = Blueprint('platform', __name__)
# 页面路由已迁移到 Vue SPA
@bp.route('/') # 原有 / 路由由 server.py 的 spa_fallback 处理
def index():
"""平台首页 — 模块列表"""
user = session.get('user')
if not user:
return redirect(url_for('auth.login'))
role = user.get('role', '')
modules = get_modules(role=role)
return render_template('platform.html',
modules=modules,
user=user,
platform_name="运行维护平台",
)
...@@ -2,90 +2,17 @@ ...@@ -2,90 +2,17 @@
""" """
service_manage.py — 服务管理模块路由 service_manage.py — 服务管理模块路由
页面路由:/service-manage/* 页面路由已迁移到 Vue SPA,本文件仅保留 API 端点(如有)。
- /service-manage → 重定向到服务授权 原有页面路由由 server.py 的 SPA 回退处理。
- /service-manage/authorization → 服务授权
- /service-manage/upgrade → 服务升级
- /service-manage/info → 服务信息
权限:仅管理员可访问
""" """
from flask import Blueprint, render_template, session, redirect, url_for from flask import Blueprint
from decorators import page_login_required
from utils.logger import get_logger from utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
bp = Blueprint('service-manage', __name__) bp = Blueprint('service-manage', __name__)
# 页面路由已迁移到 Vue SPA
# ============================================================ # 原有 /service-manage/* 路由由 server.py 的 spa_fallback 处理
# 内部辅助
# ============================================================
def _current_user():
return session.get('user', {})
def _is_admin():
return _current_user().get('role') == 'admin'
# ============================================================
# 页面路由
# ============================================================
@bp.route('/service-manage')
@page_login_required
def page_index():
"""服务管理主页 → 重定向到服务授权"""
if not _is_admin():
return redirect('/')
return redirect(url_for('service-manage.page_authorization'))
@bp.route('/service-manage/authorization')
@page_login_required
def page_authorization():
"""服务授权页面"""
if not _is_admin():
return redirect('/')
user = _current_user()
return render_template(
'service_manage/authorization.html',
user=user,
is_admin=True,
active_menu='authorization',
)
@bp.route('/service-manage/upgrade')
@page_login_required
def page_upgrade():
"""服务升级页面"""
if not _is_admin():
return redirect('/')
user = _current_user()
return render_template(
'service_manage/upgrade.html',
user=user,
is_admin=True,
active_menu='upgrade',
)
@bp.route('/service-manage/info')
@page_login_required
def page_info():
"""服务信息页面"""
if not _is_admin():
return redirect('/')
user = _current_user()
return render_template(
'service_manage/info.html',
user=user,
is_admin=True,
active_menu='info',
)
...@@ -109,6 +109,10 @@ def list_targets(role: str) -> list: ...@@ -109,6 +109,10 @@ def list_targets(role: str) -> list:
"created_at": t.get("created_at"), "created_at": t.get("created_at"),
"updated_at": t.get("updated_at"), "updated_at": t.get("updated_at"),
} }
# 本级目标(本地类型)添加警告
if t["type"] == "local":
item["warning"] = "容器化部署下本级巡检数据可能不准确,建议配置宿主机 SSH(见文档)"
# 普通用户只看本地目标 # 普通用户只看本地目标
if role != "admin" and t["type"] != "local": if role != "admin" and t["type"] != "local":
continue continue
...@@ -136,7 +140,7 @@ def get_target_view(target_id: str, role: str) -> Optional[dict]: ...@@ -136,7 +140,7 @@ def get_target_view(target_id: str, role: str) -> Optional[dict]:
def _build_view(t: dict) -> dict: def _build_view(t: dict) -> dict:
return { view = {
"id": t["id"], "id": t["id"],
"name": t["name"], "name": t["name"],
"type": t["type"], "type": t["type"],
...@@ -148,10 +152,17 @@ def _build_view(t: dict) -> dict: ...@@ -148,10 +152,17 @@ def _build_view(t: dict) -> dict:
"container_overrides": t.get("container_overrides", {}), "container_overrides": t.get("container_overrides", {}),
"thresholds": t.get("thresholds", {}), "thresholds": t.get("thresholds", {}),
} }
# 本级目标添加警告
if t["type"] == "local":
view["warning"] = "容器化部署下本级巡检数据可能不准确,建议配置宿主机 SSH(见文档)"
return view
def create_target(data: dict) -> dict: def create_target(data: dict) -> dict:
"""新增远程目标(管理员)。""" """新增远程目标(管理员)。
如果 host 为 localhost/127.0.0.1/host.docker.internal,自动标记为本级目标。
"""
name = (data.get("name") or "").strip() name = (data.get("name") or "").strip()
host = (data.get("host") or "").strip() host = (data.get("host") or "").strip()
port = data.get("port", 22) port = data.get("port", 22)
...@@ -160,6 +171,15 @@ def create_target(data: dict) -> dict: ...@@ -160,6 +171,15 @@ def create_target(data: dict) -> dict:
if not name: if not name:
raise ValueError("目标名称不能为空") raise ValueError("目标名称不能为空")
# 本级目标自动检测
is_local_host = host.lower() in ("localhost", "127.0.0.1", "::1", "host.docker.internal")
if is_local_host:
raise ValueError(
"本级目标为系统内置,无需手动创建。"
"如需巡检本机,请使用已有的「本机(当前服务器)」目标。"
)
if not host or not _HOST_RE.match(host): if not host or not _HOST_RE.match(host):
raise ValueError("主机地址非法") raise ValueError("主机地址非法")
if not username or not _USER_RE.match(username): if not username or not _USER_RE.match(username):
......
...@@ -48,6 +48,44 @@ except ImportError: ...@@ -48,6 +48,44 @@ except ImportError:
_HAS_PARAMIKO = False _HAS_PARAMIKO = False
def get_docker_host_ip() -> str:
"""获取 Docker 宿主机 IP。
优先级:
1. 环境变量 DOCKER_HOST_IP
2. 从 ip route 解析网关 IP(Linux)
3. 回退到 host.docker.internal
Returns:
宿主机 IP 地址字符串
"""
# 1. 环境变量(最高优先级,允许用户覆盖)
ip = os.environ.get("DOCKER_HOST_IP")
if ip:
logger.debug("使用环境变量 DOCKER_HOST_IP: %s", ip)
return ip
# 2. ip route 解析(Linux Docker)
try:
result = subprocess.run(
["ip", "route"],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.split("\n"):
if "default" in line:
parts = line.split()
if len(parts) >= 3:
gateway_ip = parts[2]
logger.debug("从 ip route 解析宿主机 IP: %s", gateway_ip)
return gateway_ip
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
logger.debug("ip route 解析失败: %s", e)
# 3. 回退到 host.docker.internal
logger.debug("回退到 host.docker.internal")
return "host.docker.internal"
class DependencyError(Exception): class DependencyError(Exception):
"""Python 依赖缺失异常。""" """Python 依赖缺失异常。"""
pass pass
...@@ -264,22 +302,75 @@ class BaseExecutor(abc.ABC): ...@@ -264,22 +302,75 @@ class BaseExecutor(abc.ABC):
# ============================================================ # ============================================================
class LocalExecutor(BaseExecutor): class LocalExecutor(BaseExecutor):
"""本机执行器:直接 subprocess.run。 """本机执行器:通过 SSH 到宿主机执行巡检脚本。
Docker 容器化部署下,本级巡检需要 SSH 到宿主机获取真实系统数据。
宿主机 SSH 配置从环境变量读取:
- LOCAL_SSH_USER: SSH 用户名(默认 root)
- LOCAL_SSH_PASSWORD: SSH 密码(或)
- LOCAL_SSH_KEY: SSH 私钥路径
资产文件在 ASSETS_DIR 中已存在,setup/upload_assets 只建目录结构, 如果环境变量未配置,回退到本地 subprocess 执行(非 Docker 模式)。
_write_config 写渲染后的 config.sh,run_module 用 subprocess 调 bash。
""" """
def __init__(self, run_id: str): def __init__(self, run_id: str):
super().__init__(run_id) super().__init__(run_id)
self._local_workdir: Optional[Path] = None self._local_workdir: Optional[Path] = None
self._ssh_executor: Optional["SSHExecutor"] = None
self._use_ssh = False # 是否使用 SSH 模式
# 检测是否需要 SSH 模式(容器内)
self._host_ip = get_docker_host_ip()
self._ssh_user = os.environ.get("LOCAL_SSH_USER", "root")
self._ssh_password = os.environ.get("LOCAL_SSH_PASSWORD", "")
self._ssh_key = os.environ.get("LOCAL_SSH_KEY", "")
# 如果配置了 SSH 凭据,使用 SSH 模式
if self._ssh_password or self._ssh_key:
self._use_ssh = True
logger.info("本级巡检将使用 SSH 到宿主机: %s@%s", self._ssh_user, self._host_ip)
@property @property
def is_local(self) -> bool: def is_local(self) -> bool:
return True return True
def _get_ssh_executor(self) -> "SSHExecutor":
"""获取 SSH 执行器实例。"""
if self._ssh_executor is None:
if not _HAS_PARAMIKO:
raise DependencyError(
"paramiko 未安装,本级巡检 SSH 模式不可用。请执行 pip install paramiko"
)
if self._ssh_key:
# 密钥认证
self._ssh_executor = SSHExecutor(
run_id=self.run_id,
host=self._host_ip,
port=22,
username=self._ssh_user,
password="", # 密钥模式不需要密码
)
# TODO: 支持 key_filename,当前 SSHExecutor 只支持密码认证
# 暂时忽略密钥,后续扩展
logger.warning("本级巡检 SSH 密钥认证暂未完全支持,请使用密码认证")
else:
# 密码认证
self._ssh_executor = SSHExecutor(
run_id=self.run_id,
host=self._host_ip,
port=22,
username=self._ssh_user,
password=self._ssh_password,
)
return self._ssh_executor
def setup(self) -> str: def setup(self) -> str:
# 本地用 tempfile 创建临时工作目录 if self._use_ssh:
# SSH 模式:在宿主机上创建工作目录
return self._get_ssh_executor().setup()
else:
# 本地模式:创建临时目录
self._local_workdir = Path(tempfile.mkdtemp(prefix=f"mon_{self.run_id}_")) self._local_workdir = Path(tempfile.mkdtemp(prefix=f"mon_{self.run_id}_"))
# 建 lib 子目录 # 建 lib 子目录
lib_dir = self._local_workdir / "lib" lib_dir = self._local_workdir / "lib"
...@@ -297,14 +388,26 @@ class LocalExecutor(BaseExecutor): ...@@ -297,14 +388,26 @@ class LocalExecutor(BaseExecutor):
return self._workdir return self._workdir
def upload_assets(self) -> None: def upload_assets(self) -> None:
# setup 时已完成复制,无需额外操作 if self._use_ssh:
pass # SSH 模式:上传资产到宿主机
self._get_ssh_executor().upload_assets()
# 本地模式:setup 时已完成复制,无需额外操作
def _write_config(self, wd: str, config_text: str) -> None: def _write_config(self, wd: str, config_text: str) -> None:
if self._use_ssh:
# SSH 模式:通过 SFTP 写入
self._get_ssh_executor()._write_config(wd, config_text)
else:
# 本地模式:直接写入文件
config_path = Path(wd) / "lib" / "config.sh" config_path = Path(wd) / "lib" / "config.sh"
config_path.write_text(config_text, encoding="utf-8") config_path.write_text(config_text, encoding="utf-8")
def _exec(self, command: str, timeout: int) -> str: def _exec(self, command: str, timeout: int) -> str:
if self._use_ssh:
# SSH 模式:远程执行
return self._get_ssh_executor()._exec(command, timeout)
else:
# 本地模式:subprocess 执行
try: try:
r = subprocess.run( r = subprocess.run(
["bash", "-c", command], ["bash", "-c", command],
...@@ -327,11 +430,18 @@ class LocalExecutor(BaseExecutor): ...@@ -327,11 +430,18 @@ class LocalExecutor(BaseExecutor):
return "" return ""
def _upload_file(self, local_path: Path, remote_path: str): def _upload_file(self, local_path: Path, remote_path: str):
# 本地模式,文件已在 setup 时复制 if self._use_ssh:
pass # SSH 模式:通过 SFTP 上传
self._get_ssh_executor()._upload_file(local_path, remote_path)
# 本地模式:文件已在 setup 时复制
def cleanup(self) -> None: def cleanup(self) -> None:
if self._local_workdir and self._local_workdir.exists(): if self._use_ssh and self._ssh_executor:
# SSH 模式:清理远程工作目录 + 关闭连接
self._ssh_executor.cleanup()
self._ssh_executor = None
elif self._local_workdir and self._local_workdir.exists():
# 本地模式:清理临时目录
shutil.rmtree(self._local_workdir, ignore_errors=True) shutil.rmtree(self._local_workdir, ignore_errors=True)
logger.info("清理本地工作目录: %s", self._local_workdir) logger.info("清理本地工作目录: %s", self._local_workdir)
self._workdir = None self._workdir = None
......
...@@ -6,8 +6,8 @@ ...@@ -6,8 +6,8 @@
"password_hash": "scrypt:32768:8:1$evVCVFTTv2ZVvjto$10300880338aae565f2a1874ace20fd30470e6970132da309399fb7ccf6a1982732b715da4a569f168e8f517a8b07d267cccd16f09c9352c7abb61d21383a03f", "password_hash": "scrypt:32768:8:1$evVCVFTTv2ZVvjto$10300880338aae565f2a1874ace20fd30470e6970132da309399fb7ccf6a1982732b715da4a569f168e8f517a8b07d267cccd16f09c9352c7abb61d21383a03f",
"role": "admin", "role": "admin",
"created_at": "2026-07-12T16:00:00", "created_at": "2026-07-12T16:00:00",
"last_login": "2026-07-12T16:06:00", "last_login": "2026-07-28T17:59:41.466778",
"login_count": 5, "login_count": 14,
"active": true "active": true
}, },
{ {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论