a-cloud-all/.devops/monitor.py

2248 lines
92 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Git 仓库监听器
监听多个 Git 仓库的指定分支,检测到新提交时触发部署
"""
import os
import sys
import time
import yaml
import subprocess
import socket
import threading
import json
from datetime import datetime
from pathlib import Path
from flask import Flask, request, jsonify, render_template_string
# 数据库相关导入
try:
import pymysql
pymysql.install_as_MySQLdb()
PYMYSQL_AVAILABLE = True
except ImportError:
PYMYSQL_AVAILABLE = False
print("警告: pymysql 未安装,数据库管理功能将不可用。请运行: pip install pymysql")
# 添加当前目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# 导入自定义模块
from scripts.log import Logger
from scripts import docker, maven, npm
from scripts.init import mysql, redis, nacos
from scripts.dingtalk import DingTalkNotifier
class GitMonitor:
"""Git 仓库监听器"""
def __init__(self, config_path='.devops/config.yaml'):
"""初始化监听器"""
self.config_path = config_path
self.config = None
self.last_commits = {}
self.last_tags = {} # 记录每个仓库的最新 tag
self.global_branch = 'main'
self.project_root = None
self.runtime_path = None
self.dingtalk_notifier = None
self.watch_tags = False
self.tag_pattern = "v*"
# 初始化
self._print_startup_banner()
self._load_config()
self._init_paths()
self._init_dingtalk()
def _print_startup_banner(self):
"""打印启动横幅"""
print("\n")
Logger.separator()
print(" RuoYi Cloud DevOps 自动化部署系统")
Logger.separator()
print(f"启动时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
Logger.separator()
print("\n")
def _load_config(self):
"""加载配置文件"""
Logger.info(f"[步骤 1/3] 读取配置文件: {self.config_path}")
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
self.config = yaml.safe_load(f)
self.global_branch = self.config.get('global_branch', 'main')
# 加载 tag 监听配置
monitor_config = self.config.get('monitor', {})
self.watch_tags = monitor_config.get('watch_tags', False)
self.tag_pattern = monitor_config.get('tag_pattern', 'v*')
# 初始化日志配置
log_config = self.config.get('logging', {})
log_file = log_config.get('file', '.devops/logs/devops.log')
max_size = log_config.get('max_size', 10485760)
Logger.init(log_file=log_file, max_size=max_size)
Logger.info(f"✓ 配置加载成功 - 全局分支: {self.global_branch}")
Logger.info(f"✓ Tag 监听: {'已启用' if self.watch_tags else '未启用'} (模式: {self.tag_pattern})")
Logger.info(f"✓ 日志配置 - 文件: {log_file}, 最大大小: {max_size} 字节")
except Exception as e:
Logger.error(f"配置加载失败: {e}")
sys.exit(1)
def _init_paths(self):
"""初始化路径"""
Logger.info("[步骤 2/3] 初始化路径")
try:
self.project_root = Path(__file__).parent.parent.resolve()
runtime_path = self.config['main_repository']['runtime_path']
if not Path(runtime_path).is_absolute():
self.runtime_path = self.project_root / runtime_path
else:
self.runtime_path = Path(runtime_path)
Logger.info(f"✓ 路径初始化成功")
Logger.info(f" 项目根目录: {self.project_root}")
Logger.info(f" Runtime 目录: {self.runtime_path}")
except Exception as e:
Logger.error(f"路径初始化失败: {e}")
sys.exit(1)
def _init_dingtalk(self):
"""初始化钉钉通知器"""
try:
dingtalk_config = self.config.get('dingtalk', {})
if dingtalk_config.get('enabled', False):
access_token = dingtalk_config.get('access_token')
secret = dingtalk_config.get('secret')
if access_token:
self.dingtalk_notifier = DingTalkNotifier(access_token, secret)
Logger.info("✓ 钉钉通知已启用")
else:
Logger.warning("钉钉通知已启用但未配置 access_token")
else:
Logger.info("钉钉通知未启用")
except Exception as e:
Logger.warning(f"钉钉通知初始化失败: {e}")
def get_server_ip(self):
"""获取服务器IP地址"""
try:
# 创建一个UDP socket连接到外部地址来获取本机IP
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
try:
# 备用方案获取主机名对应的IP
return socket.gethostbyname(socket.gethostname())
except Exception:
return "unknown"
def get_remote_commit(self, repo_url, branch):
"""获取远程仓库的最新提交 hash"""
try:
cmd = f"git ls-remote {repo_url} refs/heads/{branch}"
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=30
)
if result.returncode == 0 and result.stdout:
return result.stdout.split()[0]
return None
except Exception as e:
Logger.error(f"获取远程提交失败 {repo_url}: {e}")
return None
def get_commit_message(self, repo_url, commit_hash):
"""获取指定 commit 的提交消息"""
try:
cmd = f"git ls-remote --heads {repo_url} | grep {commit_hash[:8]}"
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=10
)
# 由于 ls-remote 无法获取 commit message我们返回简短的 hash
return f"提交 {commit_hash[:8]}"
except Exception as e:
Logger.error(f"获取提交消息失败: {e}")
return f"提交 {commit_hash[:8]}"
def get_remote_tags(self, repo_url):
"""获取远程仓库的所有 tags"""
try:
cmd = f"git ls-remote --tags {repo_url}"
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=30
)
if result.returncode == 0 and result.stdout:
tags = {}
for line in result.stdout.strip().split('\n'):
if line:
parts = line.split()
if len(parts) >= 2:
commit_hash = parts[0]
ref = parts[1]
# 提取 tag 名称,去掉 refs/tags/ 前缀和 ^{} 后缀
if ref.startswith('refs/tags/'):
tag_name = ref.replace('refs/tags/', '')
if not tag_name.endswith('^{}'):
tags[tag_name] = commit_hash
return tags
return {}
except Exception as e:
Logger.error(f"获取远程 tags 失败 {repo_url}: {e}")
return {}
def check_repository(self, repo_config):
"""检查单个仓库是否有新提交"""
repo_name = repo_config['name']
repo_url = repo_config['url']
current_commit = self.get_remote_commit(repo_url, self.global_branch)
if not current_commit:
return False
last_commit = self.last_commits.get(repo_name)
if last_commit is None:
self.last_commits[repo_name] = current_commit
Logger.info(f"初始化 {repo_name} 提交记录: {current_commit[:8]}")
return False
if current_commit != last_commit:
Logger.info(f"检测到 {repo_name} 新提交: {last_commit[:8]} -> {current_commit[:8]}")
self.last_commits[repo_name] = current_commit
return True
return False
def check_repository_tags(self, repo_config):
"""检查单个仓库是否有新 tag"""
repo_name = repo_config['name']
repo_url = repo_config['url']
# 获取远程所有 tags
current_tags = self.get_remote_tags(repo_url)
if current_tags is None or (not current_tags and repo_name not in self.last_tags):
# 首次获取失败或获取失败时发送通知
error_msg = f"获取 {repo_name} 远程 tags 失败"
Logger.error(error_msg)
if self.dingtalk_notifier and repo_name in self.last_tags:
# 只有在之前成功获取过 tags 的情况下才发送通知,避免首次初始化时发送
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash='unknown',
error_msg=error_msg
)
return False, None
# 获取上次记录的 tags
last_tags = self.last_tags.get(repo_name, {})
# 找出新增的 tags
new_tags = []
for tag_name, commit_hash in current_tags.items():
# 检查 tag 是否匹配模式
import fnmatch
if fnmatch.fnmatch(tag_name, self.tag_pattern):
if tag_name not in last_tags:
new_tags.append((tag_name, commit_hash))
# 更新记录
self.last_tags[repo_name] = current_tags
if new_tags:
# 返回最新的 tag
new_tags.sort(reverse=True) # 按名称排序,最新的在前
latest_tag = new_tags[0]
Logger.info(f"检测到 {repo_name} 新 tag: {latest_tag[0]} ({latest_tag[1][:8]})")
return True, latest_tag
return False, None
def update_main_repo(self):
"""更新主仓库和所有子模块"""
repo_path = self.runtime_path / 'a-cloud-all'
main_repo_url = self.config['main_repository']['url']
Logger.separator()
Logger.info("更新主仓库和子模块")
Logger.separator()
# 检查主仓库是否存在
if not (repo_path / '.git').exists():
Logger.info("主仓库不存在,开始克隆...")
self.runtime_path.mkdir(parents=True, exist_ok=True)
cmd = f"git clone --recurse-submodules {main_repo_url} a-cloud-all"
result = subprocess.run(cmd, shell=True, cwd=self.runtime_path, capture_output=True, text=True)
if result.returncode != 0:
Logger.error("克隆主仓库失败")
return False
Logger.info("主仓库克隆成功")
else:
Logger.info("主仓库已存在,更新代码...")
# 检查主仓库是否有未提交的修改
cmd = "git status --porcelain"
result = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True)
if result.stdout.strip():
Logger.warning("主仓库有未提交的修改,先清理工作区...")
cmd = "git reset --hard HEAD"
subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True)
# 切换到主分支
cmd = f"git checkout {self.global_branch}"
result = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True)
if result.returncode != 0:
Logger.error(f"切换到分支 {self.global_branch} 失败: {result.stderr}")
return False
# 拉取最新代码
cmd = "git pull"
result = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True)
if result.returncode != 0:
Logger.error(f"拉取主仓库失败: {result.stderr}")
return False
# 清理所有子模块的本地修改(避免文件被意外清空的问题)
Logger.info("清理子模块本地修改...")
cmd = "git submodule foreach 'git reset --hard HEAD && git clean -fd'"
result = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True)
if result.returncode != 0:
Logger.warning(f"清理子模块失败,继续执行: {result.stderr}")
# 拉取所有子模块的最新代码
Logger.info("拉取子模块最新代码...")
cmd = "git submodule foreach 'git fetch origin && git checkout {branch} && git pull origin {branch}'".format(branch=self.global_branch)
result = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True)
if result.returncode != 0:
Logger.warning(f"拉取子模块失败,继续执行: {result.stderr}")
# 初始化和更新所有子模块到主仓库记录的版本
Logger.info("更新子模块到主仓库记录的版本...")
cmd = "git submodule update --init --recursive --force"
result = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True)
if result.returncode != 0:
Logger.error(f"更新子模块失败: {result.stderr}")
return False
Logger.info("主仓库和子模块更新成功")
return True
def init_infrastructure(self):
"""初始化基础设施服务(动态读取配置)"""
repo_path = self.runtime_path / 'a-cloud-all'
# 从配置文件读取基础设施列表
infra_config = self.config.get('infrastructure', [])
for infra in infra_config:
service_name = infra['name']
docker_service = infra['docker_service']
wait_time = infra.get('wait_time', 10)
# 检查是否已初始化
flag_file = repo_path / '.devops' / f'.deployed_{service_name}'
if not flag_file.exists():
Logger.info(f"初始化 {service_name}...")
# 执行预部署命令(如果有)
pre_deploy_commands = infra.get('pre_deploy_commands', [])
if pre_deploy_commands:
Logger.info(f"执行 {service_name} 预部署命令...")
for cmd in pre_deploy_commands:
Logger.info(f"执行命令: {cmd}")
result = subprocess.run(
cmd,
shell=True,
cwd=repo_path,
capture_output=True,
text=True
)
if result.returncode != 0:
Logger.error(f"预部署命令执行失败: {result.stderr}")
return False
# 构建并启动服务
docker_dir = repo_path / 'docker'
# 构建镜像
Logger.info(f"构建 {service_name} 镜像...")
build_cmd = f"docker-compose build --no-cache {docker_service}"
result = subprocess.run(
build_cmd,
shell=True,
cwd=docker_dir,
capture_output=True,
text=True
)
if result.returncode != 0:
Logger.error(f"{service_name} 镜像构建失败: {result.stderr}")
return False
Logger.info(f"{service_name} 镜像构建成功")
# 启动容器
Logger.info(f"启动 {service_name} 容器...")
up_cmd = f"docker-compose up -d {docker_service}"
result = subprocess.run(
up_cmd,
shell=True,
cwd=docker_dir,
capture_output=True,
text=True
)
if result.returncode != 0:
Logger.error(f"{service_name} 容器启动失败: {result.stderr}")
return False
Logger.info(f"{service_name} 容器启动成功")
# 创建标记文件
flag_file.parent.mkdir(parents=True, exist_ok=True)
flag_file.touch()
# 等待服务启动
Logger.info(f"等待 {service_name} 启动({wait_time}秒)...")
time.sleep(wait_time)
return True
def deploy(self, repo_config, tag_name=None):
"""执行部署流程
参数:
repo_config: 仓库配置
tag_name: 可选的 tag 名称,如果提供则表示这是由 tag 触发的部署
"""
repo_path = self.runtime_path / 'a-cloud-all'
repo_name = repo_config['name']
commit_hash = self.last_commits.get(repo_name, 'unknown')
start_time = time.time()
Logger.separator()
Logger.info(f"开始部署: {repo_name}")
if tag_name:
Logger.info(f"触发方式: Tag ({tag_name})")
else:
Logger.info(f"触发方式: 分支提交")
Logger.separator()
try:
# 1. 更新主仓库和子模块
if not self.update_main_repo():
# 发送 Git 更新失败通知
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg="Git 仓库更新失败(主仓库或子模块)"
)
return False
# 获取子仓库的 commit message
commit_message = None
submodule_path = repo_path / repo_config['path']
if submodule_path.exists():
try:
cmd = f"git log -1 --pretty=format:'%s' {commit_hash}"
result = subprocess.run(
cmd, shell=True, cwd=submodule_path,
capture_output=True, text=True, timeout=10
)
if result.returncode == 0 and result.stdout:
commit_message = result.stdout.strip()
Logger.info(f"提交消息: {commit_message}")
except Exception as e:
Logger.warning(f"获取提交消息失败: {e}")
# 获取服务器 IP
server_ip = self.get_server_ip()
# 发送构建开始通知(包含 commit message 和服务器 IP
if self.dingtalk_notifier:
# 如果是 tag 触发,在 commit_message 中添加 tag 信息
display_message = commit_message
if tag_name:
display_message = f"Tag: {tag_name}" + (f" - {commit_message}" if commit_message else "")
self.dingtalk_notifier.send_build_start(
repo_name=repo_name,
branch=self.global_branch if not tag_name else f"tag/{tag_name}",
commit_hash=commit_hash,
commit_message=display_message,
server_ip=server_ip
)
# 2. 初始化基础设施
if not self.init_infrastructure():
# 发送基础设施初始化失败通知
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg="基础设施初始化失败MySQL/Redis/Nacos等"
)
return False
# 3. 根据项目类型执行打包
if repo_config['type'] == 'java':
# Maven 打包
work_dir = repo_path
commands = ' && '.join(repo_config['build_commands'])
source_path = repo_config['path'] + '/' + repo_config['artifact_path']
target_dir = repo_path / repo_config['docker_path']
success, error_msg = maven.run_maven(work_dir, commands, source_path, target_dir)
if not success:
# 发送 Maven 构建失败通知
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg=f"Maven 打包失败: {error_msg}"
)
return False
elif repo_config['type'] == 'nodejs':
# NPM 打包
work_dir = repo_path / repo_config['path']
commands = ' && '.join(repo_config['build_commands'])
source_dir = repo_config['artifact_path']
target_dir = repo_path / repo_config['docker_path']
success, error_msg = npm.run_npm(work_dir, commands, source_dir, target_dir)
if not success:
# 发送 NPM/PNPM 构建失败通知
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg=f"NPM/PNPM 打包失败: {error_msg}"
)
return False
elif repo_config['type'] == 'python':
# Python 项目 - 直接复制源码到 docker 目录
Logger.separator()
Logger.info("开始 Python 项目部署")
Logger.separator()
source_path = repo_path / repo_config['path']
target_dir = repo_path / repo_config['docker_path']
Logger.info(f"源码目录: {source_path}")
Logger.info(f"目标目录: {target_dir}")
try:
# 清空目标目录(保留 .gitkeep 等隐藏文件)
if target_dir.exists():
import shutil
for item in target_dir.iterdir():
if not item.name.startswith('.'):
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
Logger.info("清空目标目录完成")
else:
target_dir.mkdir(parents=True, exist_ok=True)
Logger.info("创建目标目录完成")
# 复制源码到目标目录
import shutil
for item in source_path.iterdir():
if item.name in ['.git', '__pycache__', '.pytest_cache', '.venv', 'venv']:
continue # 跳过不需要的目录
target_item = target_dir / item.name
if item.is_dir():
if target_item.exists():
shutil.rmtree(target_item)
shutil.copytree(item, target_item)
else:
shutil.copy2(item, target_item)
Logger.info("源码复制完成")
except Exception as e:
error_msg = f"Python 项目源码复制失败: {str(e)}"
Logger.error(error_msg)
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg=error_msg
)
return False
# 4. Docker 部署
compose_dir = repo_path / 'docker'
service_name = repo_config['docker_service']
if not docker.run_docker_compose(compose_dir, service_name):
# 发送构建失败通知
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg="Docker 部署失败"
)
return False
# 计算构建耗时
duration = time.time() - start_time
# 发送构建成功通知
if self.dingtalk_notifier:
self.dingtalk_notifier.send_build_success(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
duration=duration
)
Logger.info(f"部署完成: {repo_config['name']}")
return True
except Exception as e:
# 计算构建耗时
duration = time.time() - start_time
# 发送构建失败通知
if self.dingtalk_notifier:
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg=str(e),
at_all=True
)
Logger.error(f"部署异常: {e}")
return False
def run_once(self):
"""执行一次检查"""
Logger.info("[步骤 3/3] 开始监听分支变化")
repos = self.config.get('repositories', [])
for repo_config in repos:
try:
# 检查分支提交
if self.check_repository(repo_config):
Logger.info(f"触发部署: {repo_config['name']} (分支提交)")
if self.deploy(repo_config):
Logger.info(f"✓ 部署成功: {repo_config['name']}")
else:
Logger.error(f"✗ 部署失败: {repo_config['name']}")
continue # 已经部署,跳过 tag 检查
# 检查 tag如果启用
if self.watch_tags:
has_new_tag, tag_info = self.check_repository_tags(repo_config)
if has_new_tag and tag_info:
tag_name, commit_hash = tag_info
Logger.info(f"触发部署: {repo_config['name']} (新 tag: {tag_name})")
# 更新 last_commits 以便 deploy 方法使用
self.last_commits[repo_config['name']] = commit_hash
if self.deploy(repo_config, tag_name=tag_name):
Logger.info(f"✓ 部署成功: {repo_config['name']}")
else:
Logger.error(f"✗ 部署失败: {repo_config['name']}")
except Exception as e:
Logger.error(f"处理仓库异常 {repo_config['name']}: {e}")
# 发送异常通知
if self.dingtalk_notifier:
commit_hash = self.last_commits.get(repo_config['name'], 'unknown')
self.dingtalk_notifier.send_build_failure(
repo_name=repo_config['name'],
branch=self.global_branch,
commit_hash=commit_hash,
error_msg=f"处理仓库时发生异常: {str(e)}",
at_all=True
)
def deploy_by_name(self, repo_name):
"""通过项目名称触发部署
参数:
repo_name: 项目名称
返回:
(success, message): 成功标志和消息
"""
repos = self.config.get('repositories', [])
# 查找匹配的仓库配置
repo_config = None
for repo in repos:
if repo['name'] == repo_name:
repo_config = repo
break
if not repo_config:
return False, f"未找到项目: {repo_name}"
Logger.info(f"HTTP触发部署: {repo_name}")
# 获取最新的commit hash
repo_url = repo_config['url']
current_commit = self.get_remote_commit(repo_url, self.global_branch)
if current_commit:
self.last_commits[repo_name] = current_commit
# 执行部署
success = self.deploy(repo_config)
if success:
return True, f"项目 {repo_name} 部署成功"
else:
return False, f"项目 {repo_name} 部署失败"
def get_all_projects(self):
"""获取所有可部署的项目列表
返回:
项目列表,每个项目包含 name, type, docker_service
"""
repos = self.config.get('repositories', [])
projects = []
for repo in repos:
projects.append({
'name': repo['name'],
'type': repo['type'],
'docker_service': repo['docker_service']
})
return projects
def run(self):
"""持续监听运行"""
poll_interval = self.config['monitor']['poll_interval']
Logger.info(f"开始持续监听,轮询间隔: {poll_interval}")
Logger.info("按 Ctrl+C 停止监听\n")
try:
while True:
self.run_once()
time.sleep(poll_interval)
except KeyboardInterrupt:
Logger.info("\n收到停止信号,退出监听")
except Exception as e:
Logger.error(f"监听异常: {e}")
class DeploymentServer:
"""HTTP部署服务器"""
def __init__(self, monitor, port=9999):
"""初始化HTTP服务器
参数:
monitor: GitMonitor实例
port: HTTP服务器端口默认9999
"""
self.monitor = monitor
self.port = port
self.app = Flask(__name__)
self._setup_routes()
def get_all_containers(self):
"""获取所有Docker容器列表"""
try:
cmd = "docker ps --format '{{.ID}}|{{.Names}}|{{.Status}}|{{.Image}}'"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return []
containers = []
for line in result.stdout.strip().split('\n'):
if line:
parts = line.split('|')
if len(parts) >= 4:
containers.append({
'id': parts[0],
'name': parts[1],
'status': parts[2],
'image': parts[3]
})
return containers
except Exception as e:
Logger.error(f"获取容器列表失败: {e}")
return []
def _setup_routes(self):
"""设置路由"""
@self.app.route('/')
def index():
"""根路径 - 显示操作说明"""
projects = self.monitor.get_all_projects()
# 获取当前访问的主机IP从请求头中获取
host_ip = request.host.split(':')[0] # 去掉端口号只保留IP
html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>DevOps 部署系统</title>
<style>
body {{
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}}
h1 {{
color: #333;
text-align: center;
}}
.info {{
background-color: #e3f2fd;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}}
table {{
width: 100%;
border-collapse: collapse;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
th, td {{
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}}
th {{
background-color: #1976d2;
color: white;
}}
tr:hover {{
background-color: #f5f5f5;
}}
.deploy-link {{
color: #1976d2;
text-decoration: none;
font-weight: bold;
}}
.deploy-link:hover {{
text-decoration: underline;
}}
.type-badge {{
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
}}
.type-java {{
background-color: #ff9800;
color: white;
}}
.type-nodejs {{
background-color: #4caf50;
color: white;
}}
.type-python {{
background-color: #2196f3;
color: white;
}}
</style>
</head>
<body>
<h1>🚀 RuoYi Cloud DevOps 部署系统</h1>
<div class="info">
<h3>📖 使用说明</h3>
<p><strong>触发部署:</strong>点击下方表格中的"部署链接",或直接访问 <code>http://IP:9999/项目名称</code></p>
<p><strong>示例:</strong><code>http://IP:9999/tuoheng-device</code> 触发 tuoheng-device 项目部署</p>
<p><strong>查看日志:</strong><a href="/logs" style="color: #1976d2; font-weight: bold;">点击这里查看部署日志</a> | <a href="/containers" style="color: #1976d2; font-weight: bold;">查看容器日志</a> | <a href="/database" style="color: #1976d2; font-weight: bold;">数据库管理</a></p>
</div>
<h2>🌐 服务访问入口</h2>
<table>
<thead>
<tr>
<th>服务名称</th>
<th>访问地址</th>
<th>默认用户名</th>
<th>默认密码</th>
</tr>
</thead>
<tbody>
<tr>
<td>航运服务 (HYF)</td>
<td><a href="http://{host_ip}:9988" target="_blank" class="deploy-link">http://{host_ip}:9988</a></td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>核心服务 (HXF)</td>
<td><a href="http://{host_ip}:9898" target="_blank" class="deploy-link">http://{host_ip}:9898</a></td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>视频监控 (WVP)</td>
<td><a href="http://{host_ip}:28181" target="_blank" class="deploy-link">http://{host_ip}:28181</a></td>
<td><code>admin</code></td>
<td><code>admin</code></td>
</tr>
<tr>
<td>管理后台 (Nginx)</td>
<td><a href="http://{host_ip}:8899" target="_blank" class="deploy-link">http://{host_ip}:8899</a></td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>对象存储 (MinIO)</td>
<td><a href="http://{host_ip}:9001" target="_blank" class="deploy-link">http://{host_ip}:9001</a></td>
<td><code>minioadmin</code></td>
<td><code>minioadmin</code></td>
</tr>
<tr>
<td>物联网平台 (ThingsBoard)</td>
<td><a href="http://{host_ip}:28080" target="_blank" class="deploy-link">http://{host_ip}:28080</a></td>
<td><code>tenant@thingsboard.org</code></td>
<td><code>tenant</code></td>
</tr>
<tr>
<td>Nacos</td>
<td><a href="http://{host_ip}:8848/nacos/#/configurationManagement?dataId=&group=&appName=&namespace=&pageSize=&pageNo=" target="_blank" class="deploy-link">http://{host_ip}:8848/nacos</a></td>
<td><code></code></td>
<td><code></code></td>
</tr>
</tbody>
</table>
<h2>📦 可部署项目列表</h2>
<table>
<thead>
<tr>
<th>项目名称</th>
<th>类型</th>
<th>Docker服务</th>
<th>部署链接</th>
</tr>
</thead>
<tbody>
'''
for project in projects:
type_class = f"type-{project['type']}"
html += f'''
<tr>
<td>{project['name']}</td>
<td><span class="{type_class} type-badge">{project['type'].upper()}</span></td>
<td>{project['docker_service']}</td>
<td><a href="/{project['name']}" class="deploy-link">/{project['name']}</a></td>
</tr>
'''
html += '''
</tbody>
</table>
</body>
</html>
'''
return html
@self.app.route('/logs')
def view_logs():
"""查看日志页面"""
html = '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>DevOps 日志查看</title>
<style>
body {
font-family: 'Courier New', monospace;
margin: 0;
padding: 20px;
background-color: #1e1e1e;
color: #d4d4d4;
}
.header {
background-color: #2d2d30;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
margin: 0;
color: #4ec9b0;
font-size: 24px;
}
.controls {
display: flex;
gap: 10px;
}
button {
background-color: #0e639c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background-color: #1177bb;
}
.log-container {
background-color: #1e1e1e;
border: 1px solid #3e3e42;
border-radius: 5px;
padding: 15px;
height: calc(100vh - 180px);
overflow-y: auto;
font-size: 13px;
line-height: 1.6;
}
.log-line {
margin: 2px 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.log-error {
color: #f48771;
}
.log-warning {
color: #dcdcaa;
}
.log-info {
color: #4ec9b0;
}
.log-success {
color: #4ec9b0;
}
.loading {
text-align: center;
padding: 20px;
color: #858585;
}
.back-link {
color: #4ec9b0;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>📋 DevOps 部署日志</h1>
<a href="/" class="back-link">← 返回首页</a>
</div>
<div class="controls">
<button onclick="refreshLogs()">🔄 刷新</button>
<button onclick="clearDisplay()">🗑️ 清空显示</button>
<button onclick="toggleAutoRefresh()">⏱️ <span id="autoRefreshText">开启自动刷新</span></button>
</div>
</div>
<div class="log-container" id="logContainer">
<div class="loading">正在加载日志...</div>
</div>
<script>
let autoRefreshInterval = null;
let isAutoRefresh = false;
function formatLogLine(line) {
if (line.includes('ERROR') || line.includes('失败') || line.includes('异常')) {
return '<div class="log-line log-error">' + escapeHtml(line) + '</div>';
} else if (line.includes('WARNING') || line.includes('警告')) {
return '<div class="log-line log-warning">' + escapeHtml(line) + '</div>';
} else if (line.includes('INFO') || line.includes('')) {
return '<div class="log-line log-info">' + escapeHtml(line) + '</div>';
} else if (line.includes('成功') || line.includes('完成')) {
return '<div class="log-line log-success">' + escapeHtml(line) + '</div>';
} else {
return '<div class="log-line">' + escapeHtml(line) + '</div>';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function refreshLogs() {
fetch('/api/logs')
.then(response => response.json())
.then(data => {
const container = document.getElementById('logContainer');
if (data.logs && data.logs.length > 0) {
container.innerHTML = data.logs.map(formatLogLine).join('');
container.scrollTop = container.scrollHeight;
} else {
container.innerHTML = '<div class="loading">暂无日志</div>';
}
})
.catch(error => {
console.error('获取日志失败:', error);
document.getElementById('logContainer').innerHTML =
'<div class="log-line log-error">获取日志失败: ' + error.message + '</div>';
});
}
function clearDisplay() {
document.getElementById('logContainer').innerHTML = '<div class="loading">显示已清空,点击刷新重新加载</div>';
}
function toggleAutoRefresh() {
isAutoRefresh = !isAutoRefresh;
const text = document.getElementById('autoRefreshText');
if (isAutoRefresh) {
text.textContent = '关闭自动刷新';
autoRefreshInterval = setInterval(refreshLogs, 3000);
refreshLogs();
} else {
text.textContent = '开启自动刷新';
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
}
}
// 页面加载时获取日志
refreshLogs();
</script>
</body>
</html>
'''
return html
@self.app.route('/api/logs')
def get_logs():
"""获取日志内容API"""
try:
log_file = self.monitor.config.get('logging', {}).get('file', '.devops/logs/devops.log')
log_path = Path(log_file)
if not log_path.exists():
return jsonify({'logs': [], 'message': '日志文件不存在'})
# 读取最后1000行日志
with open(log_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 只返回最后1000行
recent_lines = lines[-1000:] if len(lines) > 1000 else lines
return jsonify({'logs': [line.rstrip() for line in recent_lines]})
except Exception as e:
return jsonify({'logs': [], 'error': str(e)})
@self.app.route('/containers')
def view_containers():
"""容器列表页面"""
containers = self.get_all_containers()
html = '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Docker 容器管理</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1400px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}
h1 {
color: #333;
text-align: center;
}
.info {
background-color: #e3f2fd;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.back-link {
color: #1976d2;
text-decoration: none;
font-weight: bold;
}
.back-link:hover {
text-decoration: underline;
}
table {
width: 100%;
border-collapse: collapse;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #1976d2;
color: white;
}
tr:hover {
background-color: #f5f5f5;
}
.container-link {
color: #1976d2;
text-decoration: none;
font-weight: bold;
}
.container-link:hover {
text-decoration: underline;
}
.status-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
background-color: #4caf50;
color: white;
}
</style>
</head>
<body>
<h1>🐳 Docker 容器管理</h1>
<div class="info">
<a href="/" class="back-link">← 返回首页</a>
<p><strong>说明:</strong>点击容器名称查看该容器的实时日志</p>
</div>
<h2>📋 运行中的容器列表</h2>
<table>
<thead>
<tr>
<th>容器ID</th>
<th>容器名称</th>
<th>状态</th>
<th>镜像</th>
<th>操作</th>
</tr>
</thead>
<tbody>
'''
for container in containers:
html += f'''
<tr>
<td>{container['id'][:12]}</td>
<td>{container['name']}</td>
<td><span class="status-badge">{container['status']}</span></td>
<td>{container['image']}</td>
<td><a href="/container-logs/{container['name']}" class="container-link">查看日志</a></td>
</tr>
'''
if not containers:
html += '''
<tr>
<td colspan="5" style="text-align: center; padding: 20px; color: #999;">
暂无运行中的容器
</td>
</tr>
'''
html += '''
</tbody>
</table>
</body>
</html>
'''
return html
@self.app.route('/container-logs/<container_name>')
def view_container_logs(container_name):
"""容器日志查看页面"""
html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>容器日志 - {container_name}</title>
<style>
body {{
font-family: 'Courier New', monospace;
margin: 0;
padding: 20px;
background-color: #1e1e1e;
color: #d4d4d4;
}}
.header {{
background-color: #2d2d30;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}}
h1 {{
margin: 0;
color: #4ec9b0;
font-size: 24px;
}}
.controls {{
display: flex;
gap: 10px;
}}
button {{
background-color: #0e639c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
}}
button:hover {{
background-color: #1177bb;
}}
.log-container {{
background-color: #1e1e1e;
border: 1px solid #3e3e42;
border-radius: 5px;
padding: 15px;
height: calc(100vh - 180px);
overflow-y: auto;
font-size: 13px;
line-height: 1.6;
}}
.log-line {{
margin: 2px 0;
white-space: pre-wrap;
word-wrap: break-word;
}}
.log-error {{
color: #f48771;
}}
.log-warning {{
color: #dcdcaa;
}}
.log-info {{
color: #4ec9b0;
}}
.loading {{
text-align: center;
padding: 20px;
color: #858585;
}}
.back-link {{
color: #4ec9b0;
text-decoration: none;
font-size: 14px;
}}
.back-link:hover {{
text-decoration: underline;
}}
</style>
</head>
<body>
<div class="header">
<div>
<h1>📋 容器日志: {container_name}</h1>
<a href="/containers" class="back-link">← 返回容器列表</a>
</div>
<div class="controls">
<button onclick="refreshLogs()">🔄 刷新</button>
<button onclick="clearDisplay()">🗑️ 清空显示</button>
<button onclick="toggleAutoRefresh()">⏱️ <span id="autoRefreshText">开启自动刷新</span></button>
</div>
</div>
<div class="log-container" id="logContainer">
<div class="loading">正在加载日志...</div>
</div>
<script>
let autoRefreshInterval = null;
let isAutoRefresh = false;
const containerName = '{container_name}';
function formatLogLine(line) {{
if (line.includes('ERROR') || line.includes('error') || line.includes('Exception') || line.includes('Failed')) {{
return '<div class="log-line log-error">' + escapeHtml(line) + '</div>';
}} else if (line.includes('WARN') || line.includes('warn') || line.includes('WARNING')) {{
return '<div class="log-line log-warning">' + escapeHtml(line) + '</div>';
}} else if (line.includes('INFO') || line.includes('info')) {{
return '<div class="log-line log-info">' + escapeHtml(line) + '</div>';
}} else {{
return '<div class="log-line">' + escapeHtml(line) + '</div>';
}}
}}
function escapeHtml(text) {{
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}}
function refreshLogs() {{
fetch('/api/container-logs/' + containerName)
.then(response => response.json())
.then(data => {{
const container = document.getElementById('logContainer');
if (data.logs && data.logs.length > 0) {{
container.innerHTML = data.logs.map(formatLogLine).join('');
container.scrollTop = container.scrollHeight;
}} else if (data.error) {{
container.innerHTML = '<div class="log-line log-error">获取日志失败: ' + data.error + '</div>';
}} else {{
container.innerHTML = '<div class="loading">暂无日志</div>';
}}
}})
.catch(error => {{
console.error('获取日志失败:', error);
document.getElementById('logContainer').innerHTML =
'<div class="log-line log-error">获取日志失败: ' + error.message + '</div>';
}});
}}
function clearDisplay() {{
document.getElementById('logContainer').innerHTML = '<div class="loading">显示已清空,点击刷新重新加载</div>';
}}
function toggleAutoRefresh() {{
isAutoRefresh = !isAutoRefresh;
const text = document.getElementById('autoRefreshText');
if (isAutoRefresh) {{
text.textContent = '关闭自动刷新';
autoRefreshInterval = setInterval(refreshLogs, 3000);
refreshLogs();
}} else {{
text.textContent = '开启自动刷新';
if (autoRefreshInterval) {{
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}}
}}
}}
// 页面加载时获取日志
refreshLogs();
</script>
</body>
</html>
'''
return html
@self.app.route('/api/container-logs/<container_name>')
def get_container_logs(container_name):
"""获取容器日志API"""
try:
cmd = f"docker logs --tail 500 {container_name}"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return jsonify({'logs': [], 'error': f'获取容器日志失败: {result.stderr}'})
# 合并 stdout 和 stderr
logs = []
if result.stdout:
logs.extend(result.stdout.strip().split('\n'))
if result.stderr:
logs.extend(result.stderr.strip().split('\n'))
return jsonify({'logs': logs})
except Exception as e:
return jsonify({'logs': [], 'error': str(e)})
@self.app.route('/database')
def view_database():
"""数据库管理页面"""
if not PYMYSQL_AVAILABLE:
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>数据库管理 - 错误</title>
</head>
<body>
<h1>数据库管理功能不可用</h1>
<p>pymysql 模块未安装,请运行: pip install pymysql</p>
<a href="/">返回首页</a>
</body>
</html>
'''
# 获取数据库配置
db_config = self.monitor.config.get('database', {})
container_name = db_config.get('container_name', 'ruoyi-mysql')
html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>数据库管理</title>
<style>
body {{
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}}
.header {{
background-color: #1976d2;
color: white;
padding: 20px;
border-radius: 5px;
margin-bottom: 20px;
}}
.header h1 {{
margin: 0;
font-size: 24px;
}}
.back-link {{
color: white;
text-decoration: none;
font-size: 14px;
display: inline-block;
margin-top: 10px;
}}
.back-link:hover {{
text-decoration: underline;
}}
.container {{
max-width: 1400px;
margin: 0 auto;
}}
.panel {{
background-color: white;
border-radius: 5px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
.panel h2 {{
margin-top: 0;
color: #333;
border-bottom: 2px solid #1976d2;
padding-bottom: 10px;
}}
.db-list {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
margin-bottom: 20px;
}}
.db-item {{
padding: 15px;
background-color: #e3f2fd;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s;
text-align: center;
font-weight: bold;
}}
.db-item:hover {{
background-color: #1976d2;
color: white;
transform: translateY(-2px);
}}
.db-item.active {{
background-color: #1976d2;
color: white;
}}
.table-list {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
margin-bottom: 20px;
}}
.table-item {{
padding: 10px;
background-color: #f5f5f5;
border-radius: 3px;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
}}
.table-item:hover {{
background-color: #4caf50;
color: white;
}}
.table-item.active {{
background-color: #4caf50;
color: white;
}}
.sql-editor {{
width: 100%;
min-height: 150px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
resize: vertical;
}}
.btn {{
background-color: #1976d2;
color: white;
border: none;
padding: 10px 20px;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
margin-right: 10px;
}}
.btn:hover {{
background-color: #1565c0;
}}
.btn-success {{
background-color: #4caf50;
}}
.btn-success:hover {{
background-color: #45a049;
}}
.result-container {{
margin-top: 20px;
overflow-x: auto;
}}
table {{
width: 100%;
border-collapse: collapse;
background-color: white;
}}
th, td {{
padding: 12px;
text-align: left;
border: 1px solid #ddd;
}}
th {{
background-color: #1976d2;
color: white;
font-weight: bold;
}}
tr:hover {{
background-color: #f5f5f5;
}}
.loading {{
text-align: center;
padding: 20px;
color: #999;
}}
.error {{
background-color: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 3px;
margin-top: 10px;
}}
.success {{
background-color: #e8f5e9;
color: #2e7d32;
padding: 15px;
border-radius: 3px;
margin-top: 10px;
}}
.info {{
background-color: #e3f2fd;
padding: 15px;
border-radius: 3px;
margin-bottom: 20px;
}}
.history-container {{
margin-top: 15px;
max-height: 200px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 3px;
background-color: #fafafa;
}}
.history-item {{
padding: 10px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
font-family: 'Courier New', monospace;
font-size: 13px;
}}
.history-item:hover {{
background-color: #e3f2fd;
}}
.history-item:last-child {{
border-bottom: none;
}}
.history-empty {{
padding: 20px;
text-align: center;
color: #999;
font-size: 14px;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🗄️ 数据库管理 - {container_name}</h1>
<a href="/" class="back-link">← 返回首页</a>
</div>
<div class="info">
<strong>说明:</strong>
<ul style="margin: 10px 0;">
<li>点击数据库名称查看该数据库的所有表</li>
<li>点击表名查看表数据默认显示前100条</li>
<li>可以在SQL编辑器中执行自定义SQL查询</li>
<li>⚠️ 请谨慎执行修改数据的SQL语句</li>
</ul>
</div>
<div class="panel">
<h2>📚 数据库列表</h2>
<div id="dbList" class="db-list">
<div class="loading">正在加载数据库列表...</div>
</div>
</div>
<div class="panel" id="tablePanel" style="display: none;">
<h2>📋 表列表 - <span id="currentDb"></span></h2>
<div id="tableList" class="table-list">
<div class="loading">正在加载表列表...</div>
</div>
</div>
<div class="panel">
<h2>💻 SQL 查询</h2>
<div>
<label for="sqlEditor"><strong>SQL 语句:</strong></label>
<textarea id="sqlEditor" class="sql-editor" placeholder="输入 SQL 查询语句例如SELECT * FROM table_name LIMIT 10"></textarea>
</div>
<!-- SQL 历史记录 -->
<div id="historyPanel" style="display: none;">
<div style="margin-top: 10px; margin-bottom: 5px;">
<strong>📜 历史查询最近10条</strong>
</div>
<div id="historyContainer" class="history-container">
<div class="history-empty">暂无历史记录</div>
</div>
</div>
<div style="margin-top: 10px;">
<button class="btn btn-success" onclick="executeSQL()">▶ 执行查询</button>
<button class="btn" onclick="clearSQL()">🗑️ 清空</button>
</div>
<div id="sqlMessage"></div>
</div>
<div class="panel" id="resultPanel" style="display: none;">
<h2>📊 查询结果</h2>
<div id="resultContainer" class="result-container"></div>
</div>
</div>
<script>
let currentDatabase = null;
// 加载数据库列表
function loadDatabases() {{
fetch('/api/database/list')
.then(response => response.json())
.then(data => {{
const dbList = document.getElementById('dbList');
if (data.success && data.databases) {{
dbList.innerHTML = data.databases.map(db =>
`<div class="db-item" onclick="selectDatabase('${{db}}')">${{db}}</div>`
).join('');
// 自动选择 ry-cloud 数据库
if (data.databases.includes('ry-cloud')) {{
selectDatabase('ry-cloud');
}}
}} else {{
dbList.innerHTML = '<div class="error">加载失败: ' + (data.error || '未知错误') + '</div>';
}}
}})
.catch(error => {{
document.getElementById('dbList').innerHTML =
'<div class="error">加载失败: ' + error.message + '</div>';
}});
}}
// 选择数据库
function selectDatabase(dbName) {{
currentDatabase = dbName;
// 更新UI
document.querySelectorAll('.db-item').forEach(item => {{
item.classList.remove('active');
if (item.textContent === dbName) {{
item.classList.add('active');
}}
}});
document.getElementById('currentDb').textContent = dbName;
document.getElementById('tablePanel').style.display = 'block';
// 加载表列表
loadTables(dbName);
// 加载SQL历史记录
loadSQLHistory(dbName);
}}
// 加载SQL历史记录
function loadSQLHistory(dbName) {{
fetch(`/api/database/history?db=${{dbName}}`)
.then(response => response.json())
.then(data => {{
const historyPanel = document.getElementById('historyPanel');
const historyContainer = document.getElementById('historyContainer');
if (data.success && data.history && data.history.length > 0) {{
historyPanel.style.display = 'block';
historyContainer.innerHTML = data.history.map(sql =>
`<div class="history-item" onclick="loadHistorySQL('${{sql.replace(/'/g, "\\\\'").replace(/"/g, '&quot;')}}')">${{escapeHtml(sql)}}</div>`
).join('');
}} else {{
historyPanel.style.display = 'none';
}}
}})
.catch(error => {{
console.error('加载历史记录失败:', error);
}});
}}
// 加载历史SQL到编辑器
function loadHistorySQL(sql) {{
document.getElementById('sqlEditor').value = sql;
}}
// HTML转义函数
function escapeHtml(text) {{
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}}
// 加载表列表
function loadTables(dbName) {{
const tableList = document.getElementById('tableList');
tableList.innerHTML = '<div class="loading">正在加载表列表...</div>';
fetch(`/api/database/tables?db=${{dbName}}`)
.then(response => response.json())
.then(data => {{
if (data.success && data.tables) {{
tableList.innerHTML = data.tables.map(table =>
`<div class="table-item" onclick="viewTable('${{dbName}}', '${{table}}')">${{table}}</div>`
).join('');
}} else {{
tableList.innerHTML = '<div class="error">加载失败: ' + (data.error || '未知错误') + '</div>';
}}
}})
.catch(error => {{
tableList.innerHTML = '<div class="error">加载失败: ' + error.message + '</div>';
}});
}}
// 查看表数据
function viewTable(dbName, tableName) {{
// 更新表格选中状态
document.querySelectorAll('.table-item').forEach(item => {{
item.classList.remove('active');
if (item.textContent === tableName) {{
item.classList.add('active');
}}
}});
const sql = `SELECT * FROM \`${{tableName}}\` LIMIT 100`;
document.getElementById('sqlEditor').value = sql;
executeSQL(dbName);
}}
// 执行SQL
function executeSQL(dbName) {{
const sql = document.getElementById('sqlEditor').value.trim();
const db = dbName || currentDatabase;
if (!sql) {{
showMessage('请输入SQL语句', 'error');
return;
}}
if (!db) {{
showMessage('请先选择数据库', 'error');
return;
}}
showMessage('正在执行查询...', 'info');
fetch('/api/database/query', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json'
}},
body: JSON.stringify({{
database: db,
sql: sql
}})
}})
.then(response => response.json())
.then(data => {{
if (data.success) {{
showMessage(`查询成功!返回 ${{data.rows || 0}} 行数据`, 'success');
displayResults(data);
// 重新加载历史记录
loadSQLHistory(db);
}} else {{
showMessage('查询失败: ' + (data.error || '未知错误'), 'error');
document.getElementById('resultPanel').style.display = 'none';
}}
}})
.catch(error => {{
showMessage('查询失败: ' + error.message, 'error');
document.getElementById('resultPanel').style.display = 'none';
}});
}}
// 显示查询结果
function displayResults(data) {{
const resultPanel = document.getElementById('resultPanel');
const resultContainer = document.getElementById('resultContainer');
if (!data.data || data.data.length === 0) {{
resultContainer.innerHTML = '<div class="loading">查询成功,但没有返回数据</div>';
resultPanel.style.display = 'block';
return;
}}
// 获取列名
const columns = data.columns || Object.keys(data.data[0]);
// 生成表格
let html = '<table><thead><tr>';
columns.forEach(col => {{
html += `<th>${{col}}</th>`;
}});
html += '</tr></thead><tbody>';
data.data.forEach(row => {{
html += '<tr>';
columns.forEach(col => {{
const value = row[col];
const displayValue = value === null ? '<em style="color: #999;">NULL</em>' :
(typeof value === 'object' ? JSON.stringify(value) : value);
html += `<td>${{displayValue}}</td>`;
}});
html += '</tr>';
}});
html += '</tbody></table>';
resultContainer.innerHTML = html;
resultPanel.style.display = 'block';
// 滚动到结果区域
resultPanel.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
}}
// 显示消息
function showMessage(message, type) {{
const messageDiv = document.getElementById('sqlMessage');
messageDiv.innerHTML = `<div class="${{type}}">${{message}}</div>`;
if (type === 'success' || type === 'info') {{
setTimeout(() => {{
messageDiv.innerHTML = '';
}}, 3000);
}}
}}
// 清空SQL
function clearSQL() {{
document.getElementById('sqlEditor').value = '';
document.getElementById('sqlMessage').innerHTML = '';
}}
// 页面加载时加载数据库列表
loadDatabases();
</script>
</body>
</html>
'''
return html
@self.app.route('/api/database/list')
def get_database_list():
"""获取数据库列表API"""
if not PYMYSQL_AVAILABLE:
return jsonify({'success': False, 'error': 'pymysql 未安装'})
try:
db_config = self.monitor.config.get('database', {})
container_name = db_config.get('container_name', 'ruoyi-mysql')
# 通过 docker exec 连接到容器内的 MySQL
cmd = f"docker exec {container_name} mysql -uroot -ppassword --default-character-set=utf8mb4 -e 'SHOW DATABASES;'"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return jsonify({'success': False, 'error': f'连接数据库失败: {result.stderr}'})
# 解析数据库列表
databases = []
for line in result.stdout.strip().split('\n')[1:]: # 跳过第一行标题
db_name = line.strip()
if db_name and db_name not in ['information_schema', 'performance_schema', 'mysql', 'sys']:
databases.append(db_name)
return jsonify({'success': True, 'databases': databases})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@self.app.route('/api/database/tables')
def get_table_list():
"""获取表列表API"""
if not PYMYSQL_AVAILABLE:
return jsonify({'success': False, 'error': 'pymysql 未安装'})
try:
db_name = request.args.get('db')
if not db_name:
return jsonify({'success': False, 'error': '缺少数据库名称'})
db_config = self.monitor.config.get('database', {})
container_name = db_config.get('container_name', 'ruoyi-mysql')
# 通过 docker exec 连接到容器内的 MySQL
cmd = f"docker exec {container_name} mysql -uroot -ppassword --default-character-set=utf8mb4 -e 'USE {db_name}; SHOW TABLES;'"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return jsonify({'success': False, 'error': f'查询表列表失败: {result.stderr}'})
# 解析表列表
tables = []
for line in result.stdout.strip().split('\n')[1:]: # 跳过第一行标题
table_name = line.strip()
if table_name:
tables.append(table_name)
return jsonify({'success': True, 'tables': tables})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@self.app.route('/api/database/query', methods=['POST'])
def execute_query():
"""执行SQL查询API"""
if not PYMYSQL_AVAILABLE:
return jsonify({'success': False, 'error': 'pymysql 未安装'})
try:
data = request.get_json()
database = data.get('database')
sql = data.get('sql', '').strip()
if not database or not sql:
return jsonify({'success': False, 'error': '缺少必要参数'})
db_config = self.monitor.config.get('database', {})
container_name = db_config.get('container_name', 'ruoyi-mysql')
# 转义SQL中的单引号
sql_escaped = sql.replace("'", "'\\''")
# 通过 docker exec 执行 SQL
cmd = f"docker exec {container_name} mysql -uroot -ppassword --default-character-set=utf8mb4 {database} -e '{sql_escaped}' --batch"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
return jsonify({'success': False, 'error': f'SQL执行失败: {result.stderr}'})
# 解析查询结果
lines = result.stdout.strip().split('\n')
if not lines or not lines[0]:
return jsonify({'success': True, 'data': [], 'columns': [], 'rows': 0})
# 第一行是列名
columns = lines[0].split('\t')
# 解析数据行
data_rows = []
for line in lines[1:]:
if line:
values = line.split('\t')
row_dict = {}
for i, col in enumerate(columns):
row_dict[col] = values[i] if i < len(values) else None
data_rows.append(row_dict)
# 保存SQL历史记录
save_sql_history(database, sql)
return jsonify({
'success': True,
'data': data_rows,
'columns': columns,
'rows': len(data_rows)
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@self.app.route('/api/database/history')
def get_sql_history():
"""获取SQL历史记录API"""
try:
db_name = request.args.get('db')
if not db_name:
return jsonify({'success': False, 'error': '缺少数据库名称'})
history = load_sql_history(db_name)
return jsonify({'success': True, 'history': history})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@self.app.route('/<project_name>')
def deploy_project(project_name):
"""触发项目部署"""
# 避免与其他路由冲突
if project_name in ['logs', 'api', 'containers', 'container-logs', 'database']:
return jsonify({'code': 404, 'message': '路径不存在'})
Logger.info(f"收到HTTP部署请求: {project_name}")
# 在后台线程中执行部署
def deploy_task():
success, message = self.monitor.deploy_by_name(project_name)
Logger.info(f"部署结果: {message}")
thread = threading.Thread(target=deploy_task)
thread.daemon = True
thread.start()
return jsonify({
'code': 200,
'message': f'已触发 {project_name} 部署,请查看日志了解部署进度',
'project': project_name
})
def run(self):
"""启动HTTP服务器"""
Logger.info(f"HTTP部署服务器启动在端口 {self.port}")
self.app.run(host='0.0.0.0', port=self.port, debug=False, use_reloader=False)
def save_sql_history(database, sql):
"""保存SQL历史记录到文件"""
try:
history_dir = Path('.devops/sql_history')
history_dir.mkdir(parents=True, exist_ok=True)
history_file = history_dir / f'{database}.json'
# 读取现有历史记录
history = []
if history_file.exists():
with open(history_file, 'r', encoding='utf-8') as f:
history = json.load(f)
# 添加新记录(去重)
if sql not in history:
history.insert(0, sql) # 插入到开头
# 只保留最近10条
history = history[:10]
# 保存到文件
with open(history_file, 'w', encoding='utf-8') as f:
json.dump(history, f, ensure_ascii=False, indent=2)
except Exception as e:
Logger.warning(f"保存SQL历史记录失败: {e}")
def load_sql_history(database):
"""从文件加载SQL历史记录"""
try:
history_file = Path('.devops/sql_history') / f'{database}.json'
if history_file.exists():
with open(history_file, 'r', encoding='utf-8') as f:
return json.load(f)
return []
except Exception as e:
Logger.warning(f"加载SQL历史记录失败: {e}")
return []
def main():
"""主函数"""
import argparse
parser = argparse.ArgumentParser(description='Git 仓库监听器')
parser.add_argument('--config', default='.devops/config.yaml', help='配置文件路径')
parser.add_argument('--once', action='store_true', help='只执行一次检查')
parser.add_argument('--http-port', type=int, default=9999, help='HTTP服务器端口默认9999')
parser.add_argument('--no-http', action='store_true', help='禁用HTTP服务器')
args = parser.parse_args()
monitor = GitMonitor(args.config)
if args.once:
monitor.run_once()
else:
# 启动HTTP服务器在单独的线程中
if not args.no_http:
server = DeploymentServer(monitor, port=args.http_port)
http_thread = threading.Thread(target=server.run)
http_thread.daemon = True
http_thread.start()
Logger.info(f"✓ HTTP部署服务器已启动: http://0.0.0.0:{args.http_port}")
# 启动Git监听
monitor.run()
if __name__ == '__main__':
main()