add multi-program monitoring

This commit is contained in:
2026-08-25 10:41:44 +09:00
parent 970a8a07a0
commit 61de0448a1
+221 -101
View File
@@ -16,7 +16,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets
APP_NAME = "Watchdog GUI"
APP_VERSION = "1.1.2"
APP_VERSION = "1.2.0"
BASE_DIR = Path(__file__).resolve().parent
CONFIG_PATH = BASE_DIR / "watchdog_config.json"
@@ -25,7 +25,7 @@ LOG_FILE = LOG_DIR / "watchdog.log"
DEFAULT_CONFIG = {
"target_exe_path": "",
"target_exe_paths": [],
"check_interval_sec": 5,
"restart_cooldown_sec": 10,
"auto_start_monitoring": False,
@@ -67,23 +67,24 @@ LOGGER = setup_logger()
# =========================
# UI 로그 핸들러
# =========================
class QTextEditLogger(logging.Handler, QtCore.QObject):
class LogSignalEmitter(QtCore.QObject):
log_signal = QtCore.pyqtSignal(str)
class QTextEditLogger(logging.Handler):
def __init__(self, text_edit: QtWidgets.QPlainTextEdit):
logging.Handler.__init__(self)
QtCore.QObject.__init__(self)
super().__init__()
self.text_edit = text_edit
self.log_signal.connect(self.append_log)
self.signal_emitter = LogSignalEmitter()
self.signal_emitter.log_signal.connect(self.append_log)
def emit(self, record):
try:
msg = self.format(record)
self.log_signal.emit(msg)
self.signal_emitter.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()
@@ -103,6 +104,23 @@ def load_config():
data = json.load(f)
cfg = DEFAULT_CONFIG.copy()
cfg.update(data)
# 1.1.x 이하의 단일 대상 설정을 다중 대상 목록으로 자동 변환한다.
paths = data.get("target_exe_paths")
if not isinstance(paths, list):
legacy_path = str(data.get("target_exe_path", "")).strip()
paths = [legacy_path] if legacy_path else []
unique_paths = []
seen = set()
for path in paths:
path = str(path).strip()
key = normalize_path(path) if path else ""
if path and key not in seen:
seen.add(key)
unique_paths.append(path)
cfg["target_exe_paths"] = unique_paths
cfg.pop("target_exe_path", None)
return cfg
except Exception:
LOGGER.exception("설정 파일 로드 실패, 기본값 사용")
@@ -209,8 +227,7 @@ def is_process_running_by_path(target_exe_path: str) -> bool:
proc_exe_n = normalize_path(proc_exe)
if proc_exe_n == target_exe_path_n:
return True
if proc_name == target_name:
elif proc_name == target_name:
return True
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
@@ -251,13 +268,13 @@ class WatchdogWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
self.resize(560, 300)
self.setMinimumSize(520, 270)
self.resize(700, 430)
self.setMinimumSize(620, 380)
self.cfg = load_config()
self._is_loading_config = False
self.monitoring = False
self.last_restart_time = 0.0
self.last_restart_times = {}
self._really_quit = False
self._tray_shown_once = False
@@ -303,24 +320,37 @@ class WatchdogWindow(QtWidgets.QMainWindow):
self.lbl_watchdog_version = QtWidgets.QLabel(f"Watchdog 버전: {APP_VERSION}")
self.lbl_watchdog_version.setStyleSheet("font-weight: bold; color: #1f1f1f;")
self.lbl_target_version = QtWidgets.QLabel("대상 버전: 알 수 없음")
self.lbl_target_version.setStyleSheet("font-weight: bold; color: #1f1f1f;")
self.lbl_target_count = QtWidgets.QLabel("감시 대상: 0개")
self.lbl_target_count.setStyleSheet("font-weight: bold; color: #1f1f1f;")
version_layout.addWidget(self.lbl_watchdog_version, 0, 0)
version_layout.addWidget(self.lbl_target_version, 0, 1)
version_layout.addWidget(self.lbl_target_count, 0, 1)
# 경로 행
path_layout = QtWidgets.QHBoxLayout()
path_layout.setSpacing(6)
# 감시 대상 목록
target_group = QtWidgets.QGroupBox("감시 프로그램")
target_layout = QtWidgets.QVBoxLayout(target_group)
target_layout.setContentsMargins(8, 8, 8, 8)
self.edit_exe_path = QtWidgets.QLineEdit()
self.edit_exe_path.setPlaceholderText("감시할 EXE 경로 선택")
self.btn_browse = QtWidgets.QPushButton("찾기")
self.btn_browse.setFixedWidth(70)
self.target_table = QtWidgets.QTableWidget(0, 3)
self.target_table.setHorizontalHeaderLabels(["프로그램 경로", "상태", "버전"])
self.target_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.target_table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.target_table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
self.target_table.verticalHeader().setVisible(False)
self.target_table.horizontalHeader().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
self.target_table.horizontalHeader().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents)
self.target_table.horizontalHeader().setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents)
self.target_table.setMinimumHeight(125)
path_layout.addWidget(QtWidgets.QLabel("대상"))
path_layout.addWidget(self.edit_exe_path, 1)
path_layout.addWidget(self.btn_browse)
target_btn_layout = QtWidgets.QHBoxLayout()
self.btn_add_target = QtWidgets.QPushButton("프로그램 추가")
self.btn_remove_target = QtWidgets.QPushButton("선택 항목 삭제")
target_btn_layout.addWidget(self.btn_add_target)
target_btn_layout.addWidget(self.btn_remove_target)
target_btn_layout.addStretch(1)
target_layout.addWidget(self.target_table)
target_layout.addLayout(target_btn_layout)
# 옵션 행
option_layout = QtWidgets.QGridLayout()
@@ -403,21 +433,21 @@ class WatchdogWindow(QtWidgets.QMainWindow):
log_layout.addWidget(self.btn_clear_log)
main_layout.addLayout(version_layout)
main_layout.addLayout(path_layout)
main_layout.addWidget(target_group)
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_add_target.clicked.connect(self.add_target_programs)
self.btn_remove_target.clicked.connect(self.remove_selected_targets)
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.on_path_edited)
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)
@@ -430,11 +460,21 @@ class WatchdogWindow(QtWidgets.QMainWindow):
logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
)
LOGGER.addHandler(self.ui_log_handler)
app = QtWidgets.QApplication.instance()
if app is not None:
app.aboutToQuit.connect(self._remove_ui_log_handler)
def _remove_ui_log_handler(self):
handler = getattr(self, "ui_log_handler", None)
if handler is not None and handler in LOGGER.handlers:
LOGGER.removeHandler(handler)
handler.close()
def _load_config_to_ui(self):
self._is_loading_config = True
try:
self.edit_exe_path.setText(self.cfg.get("target_exe_path", ""))
for path in self.cfg.get("target_exe_paths", []):
self._append_target_row(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)))
@@ -446,17 +486,20 @@ class WatchdogWindow(QtWidgets.QMainWindow):
self.btn_toggle_log.setText("로그 숨기기" if show_log else "로그 보기")
if show_log:
self.resize(600, 540)
self.resize(740, 650)
else:
self.resize(560, 300)
self.resize(700, 430)
finally:
self._is_loading_config = False
self._update_target_count()
def save_ui_to_config(self):
if getattr(self, "_is_loading_config", False):
return
self.cfg["target_exe_path"] = self.edit_exe_path.text().strip()
self.cfg["target_exe_paths"] = self.get_target_paths()
self.cfg.pop("target_exe_path", None)
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())
@@ -465,16 +508,27 @@ class WatchdogWindow(QtWidgets.QMainWindow):
self.cfg["show_log_panel"] = bool(self.log_group.isVisible())
save_config(self.cfg)
def on_path_edited(self):
self.save_ui_to_config()
self.update_target_status_label()
self.update_version_labels()
def update_version_labels(self):
exe_path = self.edit_exe_path.text().strip()
target_ver = get_file_version(exe_path)
self.lbl_watchdog_version.setText(f"Watchdog 버전: {APP_VERSION}")
self.lbl_target_version.setText(f"대상 버전: {target_ver}")
for row, exe_path in enumerate(self.get_target_paths()):
self.target_table.item(row, 2).setText(get_file_version(exe_path))
def get_target_paths(self):
return [
self.target_table.item(row, 0).text()
for row in range(self.target_table.rowCount())
]
def _append_target_row(self, path):
row = self.target_table.rowCount()
self.target_table.insertRow(row)
self.target_table.setItem(row, 0, QtWidgets.QTableWidgetItem(path))
self.target_table.setItem(row, 1, QtWidgets.QTableWidgetItem("미확인"))
self.target_table.setItem(row, 2, QtWidgets.QTableWidgetItem("알 수 없음"))
def _update_target_count(self):
count = self.target_table.rowCount()
self.lbl_target_count.setText(f"감시 대상: {count}개")
# ---------------------
# 트레이
@@ -535,19 +589,47 @@ class WatchdogWindow(QtWidgets.QMainWindow):
# ---------------------
# 기능
# ---------------------
def browse_exe(self):
path, _ = QtWidgets.QFileDialog.getOpenFileName(
def add_target_programs(self):
paths, _ = QtWidgets.QFileDialog.getOpenFileNames(
self,
"감시할 EXE 선택",
self.edit_exe_path.text().strip() or str(BASE_DIR),
"감시할 EXE 선택 (여러 개 선택 가능)",
str(BASE_DIR),
"Executable Files (*.exe);;All Files (*.*)"
)
if path:
self.edit_exe_path.setText(path)
if not paths:
return
existing = {normalize_path(path) for path in self.get_target_paths()}
added = 0
for path in paths:
if normalize_path(path) in existing:
continue
self._append_target_row(path)
existing.add(normalize_path(path))
LOGGER.info(f"감시 대상 추가: {path}")
added += 1
if added:
self._update_target_count()
self.save_ui_to_config()
self.update_version_labels()
LOGGER.info(f"감시 대상 선택: {path}")
self.update_target_status_label()
self.update_version_labels()
def remove_selected_targets(self):
rows = sorted({index.row() for index in self.target_table.selectedIndexes()}, reverse=True)
if not rows:
QtWidgets.QMessageBox.information(self, "안내", "삭제할 프로그램을 선택하세요.")
return
for row in rows:
path = self.target_table.item(row, 0).text()
self.target_table.removeRow(row)
self.last_restart_times.pop(normalize_path(path), None)
LOGGER.info(f"감시 대상 삭제: {path}")
self._update_target_count()
self.save_ui_to_config()
self.update_target_status_label()
def toggle_log_panel(self):
visible = not self.log_group.isVisible()
@@ -555,9 +637,9 @@ class WatchdogWindow(QtWidgets.QMainWindow):
self.btn_toggle_log.setText("로그 숨기기" if visible else "로그 보기")
if visible:
self.resize(max(self.width(), 600), 540)
self.resize(max(self.width(), 740), 650)
else:
self.resize(max(560, self.width()), 300)
self.resize(max(700, self.width()), 430)
self.save_ui_to_config()
@@ -573,13 +655,19 @@ class WatchdogWindow(QtWidgets.QMainWindow):
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, "경고", "감시할 프로그램을 먼저 선택하세요.")
exe_paths = self.get_target_paths()
if not exe_paths:
QtWidgets.QMessageBox.warning(self, "경고", "감시할 프로그램을 하나 이상 추가하세요.")
return
if not os.path.exists(exe_path):
QtWidgets.QMessageBox.warning(self, "경고", "선택한 프로그램 경로가 존재하지 않습니다.")
missing_paths = [path for path in exe_paths if not os.path.exists(path)]
if missing_paths:
names = "\n".join(os.path.basename(path) or path for path in missing_paths[:5])
QtWidgets.QMessageBox.warning(
self,
"경고",
f"다음 프로그램 경로가 존재하지 않습니다.\n\n{names}"
)
return
interval_ms = int(self.spin_interval.value()) * 1000
@@ -597,7 +685,9 @@ class WatchdogWindow(QtWidgets.QMainWindow):
self.update_version_labels()
LOGGER.info(f"감시 시작: {exe_path}")
LOGGER.info(f"감시 시작: {len(exe_paths)}개 프로그램")
for exe_path in exe_paths:
LOGGER.info(f"감시 대상: {exe_path}")
LOGGER.info(f"체크 주기: {self.spin_interval.value()}초 / 쿨다운: {self.spin_cooldown.value()}초")
self.check_target_once()
@@ -616,66 +706,96 @@ class WatchdogWindow(QtWidgets.QMainWindow):
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
def _set_target_row_status(self, row, text, color):
item = self.target_table.item(row, 1)
item.setText(text)
item.setForeground(QtGui.QColor(color))
if running:
self.lbl_target.setText("대상 상태: 실행 중")
self.lbl_target.setStyleSheet("font-weight: bold; color: #007a1f;")
self.tray_icon.setToolTip(f"{APP_NAME} - 대상 실행 중")
def update_target_status_label(self, statuses=None):
paths = self.get_target_paths()
if statuses is None:
statuses = {
normalize_path(path): is_process_running_by_path(path)
for path in paths
if os.path.exists(path)
}
running_count = 0
problem_count = 0
for row, path in enumerate(paths):
if not os.path.exists(path):
self._set_target_row_status(row, "파일 없음", "#b00020")
problem_count += 1
elif statuses.get(normalize_path(path), False):
self._set_target_row_status(row, "실행 중", "#007a1f")
running_count += 1
else:
self._set_target_row_status(row, "종료됨", "#b00020")
problem_count += 1
total = len(paths)
if not total:
summary = "대상 상태: 등록된 프로그램 없음"
color = "#444"
elif problem_count:
summary = f"대상 상태: 실행 {running_count} / 점검 필요 {problem_count}"
color = "#b00020"
else:
self.lbl_target.setText("대상 상태: 종료됨")
self.lbl_target.setStyleSheet("font-weight: bold; color: #b00020;")
self.tray_icon.setToolTip(f"{APP_NAME} - 대상 종료됨")
summary = f"대상 상태: {running_count}개 모두 실행 중"
color = "#007a1f"
self.lbl_target.setText(summary)
self.lbl_target.setStyleSheet(f"font-weight: bold; color: {color};")
self.tray_icon.setToolTip(f"{APP_NAME} - {summary.replace('대상 상태: ', '')}")
def check_target_once(self):
try:
exe_path = self.edit_exe_path.text().strip()
if not exe_path:
self.update_target_status_label(False)
self.update_version_labels()
LOGGER.warning("감시 대상 경로가 비어 있습니다.")
exe_paths = self.get_target_paths()
if not exe_paths:
self.update_target_status_label({})
LOGGER.warning("등록된 감시 대상이 없습니다.")
return
if not os.path.exists(exe_path):
self.update_target_status_label(False)
self.update_version_labels()
LOGGER.error(f"감시 대상 파일이 존재하지 않습니다: {exe_path}")
return
cooldown = int(self.spin_cooldown.value())
statuses = {}
restarted_names = []
running = is_process_running_by_path(exe_path)
self.update_target_status_label(running)
for exe_path in exe_paths:
key = normalize_path(exe_path)
if not os.path.exists(exe_path):
LOGGER.error(f"감시 대상 파일이 존재하지 않습니다: {exe_path}")
statuses[key] = False
continue
running = is_process_running_by_path(exe_path)
statuses[key] = running
if running:
continue
LOGGER.warning(f"대상 프로그램이 종료된 것으로 감지됨: {exe_path}")
now = time.time()
elapsed = now - self.last_restart_times.get(key, 0.0)
if elapsed < cooldown:
remain = max(0, int(cooldown - elapsed))
LOGGER.warning(f"재실행 쿨다운 중 ({os.path.basename(exe_path)}): {remain}초 남음")
continue
if start_target_program(exe_path):
self.last_restart_times[key] = time.time()
restarted_names.append(os.path.basename(exe_path))
self.update_target_status_label(statuses)
self.update_version_labels()
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()
if restarted_names:
names = ", ".join(restarted_names)
self.tray_icon.showMessage(
APP_NAME,
"대상 프로그램이 종료되어 자동 재실행했습니다.",
f"종료된 프로그램을 자동 재실행했습니다: {names}",
QtWidgets.QSystemTrayIcon.Warning,
2500
)
QtCore.QTimer.singleShot(1500, self._refresh_after_restart)
else:
self.update_target_status_label(False)
except Exception:
LOGGER.error("감시 루프 오류 발생")