提交 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
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
......@@ -43,3 +43,15 @@ OFFLINE_MODE=true
# 不设则派生自 SECRET_KEY;生产建议单独设置一个 44 字节 base64 密钥
# 生成方法:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
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():
# 启动新容器
run_cmd = f'''sudo docker run -d --name troubleshoot --restart unless-stopped \\
--add-host=host.docker.internal:host-gateway \\
-p 8088:80 \\
-v {REMOTE_BASE}/monitor-data:/app/web/service_monitor/data \\
-v {REMOTE_BASE}/data/users.json:/app/web/users.json \\
......
......@@ -13,6 +13,7 @@
# ./docker_deploy.sh --build-only # 仅构建镜像
# ./docker_deploy.sh --stop # 停止容器
# ./docker_deploy.sh --status # 查看状态
# ./docker_deploy.sh --update-fe # 仅更新前端(替换 dist,不重建镜像)
# ============================================================
set -euo pipefail
......@@ -22,6 +23,7 @@ CONTAINER_NAME="troubleshoot"
IMAGE_NAME="troubleshoot:latest"
DATA_BASE="/opt/troubleshoot"
ENV_FILE="${DATA_BASE}/.env"
DIST_DIR="${DATA_BASE}/dist"
# 颜色输出
RED='\033[0;31m'
......@@ -90,6 +92,9 @@ prepare_dirs() {
# 日志目录
mkdir -p "${DATA_BASE}/logs"
# 前端 dist 目录(首次部署时由镜像构建填充,volume 挂载后覆盖)
mkdir -p "${DIST_DIR}"
# 搜索索引(如果存在旧部署的索引,复制过来)
if [ -f "${DATA_BASE}/搜索索引.json" ] && [ ! -f "${DATA_BASE}/data/搜索索引.json" ]; then
cp "${DATA_BASE}/搜索索引.json" "${DATA_BASE}/data/搜索索引.json"
......@@ -107,7 +112,7 @@ prepare_dirs() {
# ---------- 构建镜像 ----------
build_image() {
info "构建 Docker 镜像..."
info "构建 Docker 镜像(多阶段:Node 构建 Vue + Python Flask)..."
docker build -t "${IMAGE_NAME}" .
info "镜像构建完成: ${IMAGE_NAME}"
}
......@@ -125,11 +130,12 @@ start_container() {
docker run -d \
--name "${CONTAINER_NAME}" \
--restart unless-stopped \
-p 8088:8088 \
-p 8088:80 \
-v "${DATA_BASE}/monitor-data:/app/web/service_monitor/data" \
-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 "${DIST_DIR}:/data/dist" \
-v "${DATA_BASE}/logs:/app/web/logs" \
--env-file "${ENV_FILE}" \
-e FLASK_DEBUG=0 \
......@@ -142,6 +148,20 @@ start_container() {
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() {
info "等待服务启动..."
......@@ -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
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
fi
count=$((count + 1))
......@@ -200,6 +220,9 @@ main() {
--status)
show_status
;;
--update-fe)
update_frontend
;;
deploy|"")
check_prerequisites
prepare_dirs
......@@ -208,7 +231,7 @@ main() {
health_check
;;
*)
echo "用法: $0 [--build-only|--stop|--status]"
echo "用法: $0 [--build-only|--stop|--status|--update-fe]"
exit 1
;;
esac
......
......@@ -29,6 +29,9 @@ services:
ports:
- "8088:80" # nginx 对外端口(映射到 8088,保持与旧服务一致)
# 8088 仅容器内访问,不对外暴露
# 本级巡检 SSH 到宿主机(需要 Docker 20.10+)
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# 服务监测数据(targets/schedules/reports/notifications)
- monitor-data:/app/web/service_monitor/data
......@@ -52,6 +55,10 @@ services:
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
- OFFLINE_MODE=${OFFLINE_MODE:-true}
- 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 有其他服务容器,防止抢占资源)
deploy:
resources:
......
......@@ -17,6 +17,7 @@ export interface Target {
built_in: boolean
container_overrides: Record<string, string>
thresholds: Record<string, number | string>
warning?: string // 本级目标警告信息
created_at?: string
updated_at?: string
}
......
......@@ -176,6 +176,21 @@ onMounted(loadTargets)
</el-tag>
</template>
</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">
<template #default="{ row }">
{{ (row as Target).username || '-' }}
......@@ -279,6 +294,11 @@ onMounted(loadTargets)
color: $gray-900;
}
.status-ok {
color: $gray-500;
font-size: 13px;
}
.form-hint {
font-size: 12px;
color: $gray-500;
......
......@@ -95,10 +95,11 @@ class TestIndex:
"""平台首页(/ 由 platform Blueprint 接管)"""
def test_index_not_logged_in_redirect(self, client):
"""未登录访问 / 重定向到 /login"""
"""未登录访问 / 重定向到 /login(带 next 参数)"""
r = client.get("/")
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):
"""已登录访问 / 返回平台首页(模块卡片)"""
......
# -*- coding: utf-8 -*-
"""
platform.py — 平台首页路由
platform.py — 平台首页路由(已迁移到 Vue SPA)
提供平台级首页(模块列表),作为统一入口
各模块独立 Blueprint,首页通过 modules.py 动态渲染模块卡片
页面路由已移除,仅保留 API 端点(如有)
原有 / 路由由 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
logger = get_logger(__name__)
bp = Blueprint('platform', __name__)
@bp.route('/')
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="运行维护平台",
)
# 页面路由已迁移到 Vue SPA
# 原有 / 路由由 server.py 的 spa_fallback 处理
......@@ -2,90 +2,17 @@
"""
service_manage.py — 服务管理模块路由
页面路由:/service-manage/*
- /service-manage → 重定向到服务授权
- /service-manage/authorization → 服务授权
- /service-manage/upgrade → 服务升级
- /service-manage/info → 服务信息
权限:仅管理员可访问
页面路由已迁移到 Vue SPA,本文件仅保留 API 端点(如有)。
原有页面路由由 server.py 的 SPA 回退处理。
"""
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
logger = get_logger(__name__)
bp = Blueprint('service-manage', __name__)
# ============================================================
# 内部辅助
# ============================================================
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',
)
# 页面路由已迁移到 Vue SPA
# 原有 /service-manage/* 路由由 server.py 的 spa_fallback 处理
......@@ -109,6 +109,10 @@ def list_targets(role: str) -> list:
"created_at": t.get("created_at"),
"updated_at": t.get("updated_at"),
}
# 本级目标(本地类型)添加警告
if t["type"] == "local":
item["warning"] = "容器化部署下本级巡检数据可能不准确,建议配置宿主机 SSH(见文档)"
# 普通用户只看本地目标
if role != "admin" and t["type"] != "local":
continue
......@@ -136,7 +140,7 @@ def get_target_view(target_id: str, role: str) -> Optional[dict]:
def _build_view(t: dict) -> dict:
return {
view = {
"id": t["id"],
"name": t["name"],
"type": t["type"],
......@@ -148,10 +152,17 @@ def _build_view(t: dict) -> dict:
"container_overrides": t.get("container_overrides", {}),
"thresholds": t.get("thresholds", {}),
}
# 本级目标添加警告
if t["type"] == "local":
view["warning"] = "容器化部署下本级巡检数据可能不准确,建议配置宿主机 SSH(见文档)"
return view
def create_target(data: dict) -> dict:
"""新增远程目标(管理员)。"""
"""新增远程目标(管理员)。
如果 host 为 localhost/127.0.0.1/host.docker.internal,自动标记为本级目标。
"""
name = (data.get("name") or "").strip()
host = (data.get("host") or "").strip()
port = data.get("port", 22)
......@@ -160,6 +171,15 @@ def create_target(data: dict) -> dict:
if not name:
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):
raise ValueError("主机地址非法")
if not username or not _USER_RE.match(username):
......
......@@ -48,6 +48,44 @@ except ImportError:
_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):
"""Python 依赖缺失异常。"""
pass
......@@ -264,22 +302,75 @@ class BaseExecutor(abc.ABC):
# ============================================================
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 只建目录结构,
_write_config 写渲染后的 config.sh,run_module 用 subprocess 调 bash。
如果环境变量未配置,回退到本地 subprocess 执行(非 Docker 模式)。
"""
def __init__(self, run_id: str):
super().__init__(run_id)
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
def is_local(self) -> bool:
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:
# 本地用 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}_"))
# 建 lib 子目录
lib_dir = self._local_workdir / "lib"
......@@ -297,14 +388,26 @@ class LocalExecutor(BaseExecutor):
return self._workdir
def upload_assets(self) -> None:
# setup 时已完成复制,无需额外操作
pass
if self._use_ssh:
# SSH 模式:上传资产到宿主机
self._get_ssh_executor().upload_assets()
# 本地模式:setup 时已完成复制,无需额外操作
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.write_text(config_text, encoding="utf-8")
def _exec(self, command: str, timeout: int) -> str:
if self._use_ssh:
# SSH 模式:远程执行
return self._get_ssh_executor()._exec(command, timeout)
else:
# 本地模式:subprocess 执行
try:
r = subprocess.run(
["bash", "-c", command],
......@@ -327,11 +430,18 @@ class LocalExecutor(BaseExecutor):
return ""
def _upload_file(self, local_path: Path, remote_path: str):
# 本地模式,文件已在 setup 时复制
pass
if self._use_ssh:
# SSH 模式:通过 SFTP 上传
self._get_ssh_executor()._upload_file(local_path, remote_path)
# 本地模式:文件已在 setup 时复制
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)
logger.info("清理本地工作目录: %s", self._local_workdir)
self._workdir = None
......
......@@ -6,8 +6,8 @@
"password_hash": "scrypt:32768:8:1$evVCVFTTv2ZVvjto$10300880338aae565f2a1874ace20fd30470e6970132da309399fb7ccf6a1982732b715da4a569f168e8f517a8b07d267cccd16f09c9352c7abb61d21383a03f",
"role": "admin",
"created_at": "2026-07-12T16:00:00",
"last_login": "2026-07-12T16:06:00",
"login_count": 5,
"last_login": "2026-07-28T17:59:41.466778",
"login_count": 14,
"active": true
},
{
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论