Files
watchdogs/watchdog_gui.py
T
2026-03-18 11:07:16 +09:00

629 lines
21 KiB
Python

# -*- coding: utf-8 -*-
import os
import sys
import json
import time
import traceback
import subprocess
from pathlib import Path
from logging.handlers import RotatingFileHandler
import logging
import psutil
from PyQt5 import QtCore, QtGui, QtWidgets
APP_NAME = "Watchdog GUI"
BASE_DIR = Path(__file__).resolve().parent
CONFIG_PATH = BASE_DIR / "watchdog_config.json"
LOG_DIR = BASE_DIR / "watchdog_logs"
LOG_FILE = LOG_DIR / "watchdog.log"
DEFAULT_CONFIG = {
"target_exe_path": "",
"check_interval_sec": 5,
"restart_cooldown_sec": 10,
"auto_start_monitoring": False,
"start_minimized_to_tray": False,
"hide_on_close": True,
"show_log_panel": False
}
# =========================
# 로거
# =========================
def setup_logger():
LOG_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("watchdog_gui")
logger.setLevel(logging.INFO)
if logger.handlers:
return logger
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
file_handler = RotatingFileHandler(
str(LOG_FILE),
maxBytes=2 * 1024 * 1024,
backupCount=5,
encoding="utf-8"
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
LOGGER = setup_logger()
# =========================
# UI 로그 핸들러
# =========================
class QTextEditLogger(logging.Handler, QtCore.QObject):
log_signal = QtCore.pyqtSignal(str)
def __init__(self, text_edit: QtWidgets.QPlainTextEdit):
logging.Handler.__init__(self)
QtCore.QObject.__init__(self)
self.text_edit = text_edit
self.log_signal.connect(self.append_log)
def emit(self, record):
try:
msg = self.format(record)
self.log_signal.emit(msg)
except Exception:
pass
@QtCore.pyqtSlot(str)
def append_log(self, msg: str):
self.text_edit.appendPlainText(msg)
sb = self.text_edit.verticalScrollBar()
sb.setValue(sb.maximum())
# =========================
# 설정
# =========================
def load_config():
if not CONFIG_PATH.exists():
save_config(DEFAULT_CONFIG)
return DEFAULT_CONFIG.copy()
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
cfg = DEFAULT_CONFIG.copy()
cfg.update(data)
return cfg
except Exception:
LOGGER.exception("설정 파일 로드 실패, 기본값 사용")
return DEFAULT_CONFIG.copy()
def save_config(cfg: dict):
try:
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
except Exception:
LOGGER.exception("설정 파일 저장 실패")
# =========================
# 감시 유틸
# =========================
def normalize_path(path: str) -> str:
try:
return os.path.normcase(os.path.abspath(path))
except Exception:
return path
def get_target_process_name(target_exe_path: str) -> str:
return os.path.basename(target_exe_path).lower().strip()
def is_process_running_by_path(target_exe_path: str) -> bool:
if not target_exe_path:
return False
target_exe_path_n = normalize_path(target_exe_path)
target_name = get_target_process_name(target_exe_path)
for proc in psutil.process_iter(["pid", "name", "exe"]):
try:
proc_name = (proc.info.get("name") or "").lower().strip()
proc_exe = proc.info.get("exe") or ""
if proc_exe:
proc_exe_n = normalize_path(proc_exe)
if proc_exe_n == target_exe_path_n:
return True
if proc_name == target_name:
return True
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
except Exception:
LOGGER.exception("프로세스 확인 중 오류")
return False
def start_target_program(target_exe_path: str) -> bool:
if not target_exe_path:
LOGGER.error("실행할 대상 프로그램 경로가 비어 있습니다.")
return False
if not os.path.exists(target_exe_path):
LOGGER.error(f"대상 프로그램이 존재하지 않습니다: {target_exe_path}")
return False
try:
work_dir = os.path.dirname(target_exe_path) or None
subprocess.Popen(
[target_exe_path],
cwd=work_dir,
shell=False
)
LOGGER.info(f"프로그램 재실행 성공: {target_exe_path}")
return True
except Exception:
LOGGER.exception(f"프로그램 재실행 실패: {target_exe_path}")
return False
# =========================
# 메인 윈도우
# =========================
class WatchdogWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(APP_NAME)
self.resize(520, 250)
self.setMinimumSize(500, 230)
self.cfg = load_config()
self.monitoring = False
self.last_restart_time = 0.0
self._really_quit = False
self._tray_shown_once = False
self._build_ui()
self._connect_signals()
self._setup_log_to_ui()
self._load_config_to_ui()
self._create_tray_icon()
self.timer = QtCore.QTimer(self)
self.timer.timeout.connect(self.check_target_once)
LOGGER.info("=" * 60)
LOGGER.info("Watchdog GUI 시작")
LOGGER.info(f"로그 파일: {LOG_FILE}")
LOGGER.info("=" * 60)
if self.cfg.get("auto_start_monitoring", False):
self.start_monitoring()
self.update_target_status_label()
if self.cfg.get("start_minimized_to_tray", False):
QtCore.QTimer.singleShot(300, self.hide_to_tray)
# ---------------------
# UI 구성
# ---------------------
def _build_ui(self):
cw = QtWidgets.QWidget()
self.setCentralWidget(cw)
main_layout = QtWidgets.QVBoxLayout(cw)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(8)
# 경로 행
path_layout = QtWidgets.QHBoxLayout()
path_layout.setSpacing(6)
self.edit_exe_path = QtWidgets.QLineEdit()
self.edit_exe_path.setPlaceholderText("감시할 EXE 경로 선택")
self.btn_browse = QtWidgets.QPushButton("찾기")
self.btn_browse.setFixedWidth(70)
path_layout.addWidget(QtWidgets.QLabel("대상"))
path_layout.addWidget(self.edit_exe_path, 1)
path_layout.addWidget(self.btn_browse)
# 옵션 행
option_layout = QtWidgets.QGridLayout()
option_layout.setHorizontalSpacing(8)
option_layout.setVerticalSpacing(6)
self.spin_interval = QtWidgets.QSpinBox()
self.spin_interval.setRange(1, 3600)
self.spin_interval.setSuffix(" 초")
self.spin_interval.setFixedWidth(90)
self.spin_cooldown = QtWidgets.QSpinBox()
self.spin_cooldown.setRange(1, 3600)
self.spin_cooldown.setSuffix(" 초")
self.spin_cooldown.setFixedWidth(90)
self.chk_auto_start = QtWidgets.QCheckBox("실행 시 감시 자동 시작")
self.chk_start_minimized = QtWidgets.QCheckBox("실행 시 트레이로 숨김")
self.chk_hide_on_close = QtWidgets.QCheckBox("닫기(X) 시 종료하지 않고 트레이로 숨김")
option_layout.addWidget(QtWidgets.QLabel("체크 주기"), 0, 0)
option_layout.addWidget(self.spin_interval, 0, 1)
option_layout.addWidget(QtWidgets.QLabel("재실행 대기"), 0, 2)
option_layout.addWidget(self.spin_cooldown, 0, 3)
option_layout.addWidget(self.chk_auto_start, 1, 0, 1, 2)
option_layout.addWidget(self.chk_start_minimized, 1, 2, 1, 2)
option_layout.addWidget(self.chk_hide_on_close, 2, 0, 1, 4)
# 버튼/상태 행
top_btn_layout = QtWidgets.QHBoxLayout()
top_btn_layout.setSpacing(6)
self.btn_start = QtWidgets.QPushButton("감시 시작")
self.btn_stop = QtWidgets.QPushButton("감시 중지")
self.btn_stop.setEnabled(False)
self.btn_toggle_log = QtWidgets.QPushButton("로그 보기")
self.btn_open_log_file = QtWidgets.QPushButton("로그 파일 열기")
self.btn_start.setFixedHeight(30)
self.btn_stop.setFixedHeight(30)
self.btn_toggle_log.setFixedHeight(30)
self.btn_open_log_file.setFixedHeight(30)
top_btn_layout.addWidget(self.btn_start)
top_btn_layout.addWidget(self.btn_stop)
top_btn_layout.addWidget(self.btn_toggle_log)
top_btn_layout.addWidget(self.btn_open_log_file)
# 상태 표시
status_layout = QtWidgets.QVBoxLayout()
status_layout.setSpacing(4)
self.lbl_monitor = QtWidgets.QLabel("감시 상태: 중지")
self.lbl_monitor.setStyleSheet("font-weight: bold; color: #b00020;")
self.lbl_target = QtWidgets.QLabel("대상 상태: 미확인")
self.lbl_target.setStyleSheet("font-weight: bold; color: #444;")
self.lbl_hint = QtWidgets.QLabel("안내: 창을 닫아도 트레이로 숨길 수 있습니다.")
self.lbl_hint.setStyleSheet("color: #666; font-size: 11px;")
status_layout.addWidget(self.lbl_monitor)
status_layout.addWidget(self.lbl_target)
status_layout.addWidget(self.lbl_hint)
# 로그 패널
self.log_group = QtWidgets.QGroupBox("로그")
log_layout = QtWidgets.QVBoxLayout(self.log_group)
log_layout.setContentsMargins(8, 8, 8, 8)
self.text_log = QtWidgets.QPlainTextEdit()
self.text_log.setReadOnly(True)
self.text_log.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
self.text_log.setMinimumHeight(180)
self.btn_clear_log = QtWidgets.QPushButton("로그창 비우기")
self.btn_clear_log.setFixedHeight(28)
log_layout.addWidget(self.text_log)
log_layout.addWidget(self.btn_clear_log)
main_layout.addLayout(path_layout)
main_layout.addLayout(option_layout)
main_layout.addLayout(top_btn_layout)
main_layout.addLayout(status_layout)
main_layout.addWidget(self.log_group)
def _connect_signals(self):
self.btn_browse.clicked.connect(self.browse_exe)
self.btn_start.clicked.connect(self.start_monitoring)
self.btn_stop.clicked.connect(self.stop_monitoring)
self.btn_toggle_log.clicked.connect(self.toggle_log_panel)
self.btn_open_log_file.clicked.connect(self.open_log_file)
self.btn_clear_log.clicked.connect(self.text_log.clear)
self.edit_exe_path.editingFinished.connect(self.save_ui_to_config)
self.spin_interval.valueChanged.connect(self.save_ui_to_config)
self.spin_cooldown.valueChanged.connect(self.save_ui_to_config)
self.chk_auto_start.stateChanged.connect(self.save_ui_to_config)
self.chk_start_minimized.stateChanged.connect(self.save_ui_to_config)
self.chk_hide_on_close.stateChanged.connect(self.save_ui_to_config)
def _setup_log_to_ui(self):
self.ui_log_handler = QTextEditLogger(self.text_log)
self.ui_log_handler.setFormatter(
logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
)
LOGGER.addHandler(self.ui_log_handler)
def _load_config_to_ui(self):
self.edit_exe_path.setText(self.cfg.get("target_exe_path", ""))
self.spin_interval.setValue(int(self.cfg.get("check_interval_sec", 5)))
self.spin_cooldown.setValue(int(self.cfg.get("restart_cooldown_sec", 10)))
self.chk_auto_start.setChecked(bool(self.cfg.get("auto_start_monitoring", False)))
self.chk_start_minimized.setChecked(bool(self.cfg.get("start_minimized_to_tray", False)))
self.chk_hide_on_close.setChecked(bool(self.cfg.get("hide_on_close", True)))
show_log = bool(self.cfg.get("show_log_panel", False))
self.log_group.setVisible(show_log)
self.btn_toggle_log.setText("로그 숨기기" if show_log else "로그 보기")
if show_log:
self.resize(560, 500)
else:
self.resize(520, 250)
def save_ui_to_config(self):
self.cfg["target_exe_path"] = self.edit_exe_path.text().strip()
self.cfg["check_interval_sec"] = int(self.spin_interval.value())
self.cfg["restart_cooldown_sec"] = int(self.spin_cooldown.value())
self.cfg["auto_start_monitoring"] = bool(self.chk_auto_start.isChecked())
self.cfg["start_minimized_to_tray"] = bool(self.chk_start_minimized.isChecked())
self.cfg["hide_on_close"] = bool(self.chk_hide_on_close.isChecked())
self.cfg["show_log_panel"] = bool(self.log_group.isVisible())
save_config(self.cfg)
# ---------------------
# 트레이
# ---------------------
def _create_tray_icon(self):
self.tray_icon = QtWidgets.QSystemTrayIcon(self)
icon = self.style().standardIcon(QtWidgets.QStyle.SP_ComputerIcon)
self.setWindowIcon(icon)
self.tray_icon.setIcon(icon)
menu = QtWidgets.QMenu()
self.action_show = menu.addAction("창 열기")
self.action_hide = menu.addAction("트레이로 숨기기")
menu.addSeparator()
self.action_start = menu.addAction("감시 시작")
self.action_stop = menu.addAction("감시 중지")
menu.addSeparator()
self.action_quit = menu.addAction("종료")
self.action_show.triggered.connect(self.show_normal_window)
self.action_hide.triggered.connect(self.hide_to_tray)
self.action_start.triggered.connect(self.start_monitoring)
self.action_stop.triggered.connect(self.stop_monitoring)
self.action_quit.triggered.connect(self.quit_application)
self.tray_icon.setContextMenu(menu)
self.tray_icon.activated.connect(self.on_tray_icon_activated)
self.tray_icon.show()
def on_tray_icon_activated(self, reason):
if reason == QtWidgets.QSystemTrayIcon.DoubleClick:
self.show_normal_window()
def show_normal_window(self):
self.show()
self.raise_()
self.activateWindow()
def hide_to_tray(self):
self.hide()
if not self._tray_shown_once:
self.tray_icon.showMessage(
APP_NAME,
"프로그램이 트레이로 숨겨졌습니다. 트레이 아이콘에서 다시 열 수 있습니다.",
QtWidgets.QSystemTrayIcon.Information,
3000
)
self._tray_shown_once = True
def quit_application(self):
self._really_quit = True
self.save_ui_to_config()
LOGGER.info("사용자 요청으로 Watchdog GUI 종료")
QtWidgets.QApplication.quit()
# ---------------------
# 기능
# ---------------------
def browse_exe(self):
path, _ = QtWidgets.QFileDialog.getOpenFileName(
self,
"감시할 EXE 선택",
self.edit_exe_path.text().strip() or str(BASE_DIR),
"Executable Files (*.exe);;All Files (*.*)"
)
if path:
self.edit_exe_path.setText(path)
self.save_ui_to_config()
LOGGER.info(f"감시 대상 선택: {path}")
self.update_target_status_label()
def toggle_log_panel(self):
visible = not self.log_group.isVisible()
self.log_group.setVisible(visible)
self.btn_toggle_log.setText("로그 숨기기" if visible else "로그 보기")
if visible:
self.resize(max(self.width(), 560), 500)
else:
self.resize(max(520, self.width()), 250)
self.save_ui_to_config()
def open_log_file(self):
try:
if not LOG_FILE.exists():
LOGGER.warning("열 로그 파일이 아직 없습니다.")
return
os.startfile(str(LOG_FILE))
except Exception:
LOGGER.exception("로그 파일 열기 실패")
def start_monitoring(self):
self.save_ui_to_config()
exe_path = self.edit_exe_path.text().strip()
if not exe_path:
QtWidgets.QMessageBox.warning(self, "경고", "감시할 프로그램을 먼저 선택하세요.")
return
if not os.path.exists(exe_path):
QtWidgets.QMessageBox.warning(self, "경고", "선택한 프로그램 경로가 존재하지 않습니다.")
return
interval_ms = int(self.spin_interval.value()) * 1000
self.timer.start(interval_ms)
self.monitoring = True
self.btn_start.setEnabled(False)
self.btn_stop.setEnabled(True)
self.action_start.setEnabled(False)
self.action_stop.setEnabled(True)
self.lbl_monitor.setText("감시 상태: 실행 중")
self.lbl_monitor.setStyleSheet("font-weight: bold; color: #007a1f;")
LOGGER.info(f"감시 시작: {exe_path}")
LOGGER.info(f"체크 주기: {self.spin_interval.value()}초 / 쿨다운: {self.spin_cooldown.value()}초")
self.check_target_once()
def stop_monitoring(self):
self.timer.stop()
self.monitoring = False
self.btn_start.setEnabled(True)
self.btn_stop.setEnabled(False)
self.action_start.setEnabled(True)
self.action_stop.setEnabled(False)
self.lbl_monitor.setText("감시 상태: 중지")
self.lbl_monitor.setStyleSheet("font-weight: bold; color: #b00020;")
LOGGER.info("감시 중지")
def update_target_status_label(self, running=None):
if running is None:
exe_path = self.edit_exe_path.text().strip()
running = is_process_running_by_path(exe_path) if exe_path else False
if running:
self.lbl_target.setText("대상 상태: 실행 중")
self.lbl_target.setStyleSheet("font-weight: bold; color: #007a1f;")
self.tray_icon.setToolTip(f"{APP_NAME} - 대상 실행 중")
else:
self.lbl_target.setText("대상 상태: 종료됨")
self.lbl_target.setStyleSheet("font-weight: bold; color: #b00020;")
self.tray_icon.setToolTip(f"{APP_NAME} - 대상 종료됨")
def check_target_once(self):
try:
exe_path = self.edit_exe_path.text().strip()
if not exe_path:
self.update_target_status_label(False)
LOGGER.warning("감시 대상 경로가 비어 있습니다.")
return
if not os.path.exists(exe_path):
self.update_target_status_label(False)
LOGGER.error(f"감시 대상 파일이 존재하지 않습니다: {exe_path}")
return
running = is_process_running_by_path(exe_path)
self.update_target_status_label(running)
if running:
return
now = time.time()
cooldown = int(self.spin_cooldown.value())
elapsed = now - self.last_restart_time
LOGGER.warning("대상 프로그램이 종료된 것으로 감지됨")
if elapsed < cooldown:
remain = int(cooldown - elapsed)
LOGGER.warning(f"재실행 쿨다운 중: {remain}초 남음")
return
ok = start_target_program(exe_path)
if ok:
self.last_restart_time = time.time()
self.tray_icon.showMessage(
APP_NAME,
"대상 프로그램이 종료되어 자동 재실행했습니다.",
QtWidgets.QSystemTrayIcon.Warning,
2500
)
QtCore.QTimer.singleShot(1500, self.update_target_status_label)
else:
self.update_target_status_label(False)
except Exception:
LOGGER.error("감시 루프 오류 발생")
LOGGER.error(traceback.format_exc())
# ---------------------
# 종료 / 최소화
# ---------------------
def changeEvent(self, event):
if event.type() == QtCore.QEvent.WindowStateChange:
if self.isMinimized():
QtCore.QTimer.singleShot(0, self._handle_minimize)
super().changeEvent(event)
def _handle_minimize(self):
if self.chk_hide_on_close.isChecked():
self.hide_to_tray()
def closeEvent(self, event: QtGui.QCloseEvent):
try:
self.save_ui_to_config()
except Exception:
pass
if self._really_quit:
LOGGER.info("Watchdog GUI 종료")
event.accept()
return
if self.chk_hide_on_close.isChecked():
event.ignore()
self.hide_to_tray()
return
LOGGER.info("Watchdog GUI 종료")
event.accept()
# =========================
# 메인
# =========================
def main():
app = QtWidgets.QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
app.setApplicationName(APP_NAME)
font = QtGui.QFont("맑은 고딕", 9)
app.setFont(font)
win = WatchdogWindow()
if not win.cfg.get("start_minimized_to_tray", False):
win.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()