2248 lines
92 KiB
Python
2248 lines
92 KiB
Python
#!/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, '"')}}')">${{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()
|
||
|