import tkinter as tk from tkinter import ttk, messagebox, scrolledtext import threading import subprocess import sys import os import json import sqlite3 from datetime import datetime import time # 프로젝트 루트 경로 추가 current_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(current_dir) sys.path.append(project_root) # 모듈 import 시도 (실패 시 예외처리) try: from config import get_all_plants from crawler_manager import CrawlerManager except ImportError: # GUI 단독 실행 시 더미 데이터 사용 가능하도록 pass class CrawlerControlPanel: def __init__(self, root): self.root = root self.root.title("☀️ 태양광 발전 통합 관제 시스템 [관리자 모드]") self.root.geometry("1100x750") self.root.configure(bg="#f0f2f5") # 스타일 설정 self.setup_styles() # 데이터 매니저 초기화 try: self.manager = CrawlerManager(os.path.join(project_root, "crawler_manager.db")) self.plants = get_all_plants() except: self.manager = None self.plants = [] # 메인 레이아웃 self.create_layout() # 초기 데이터 로드 self.refresh_monitor() def setup_styles(self): style = ttk.Style() style.theme_use('clam') # 프리미엄 색상 팔레트 colors = { 'primary': '#2563eb', 'secondary': '#64748b', 'success': '#16a34a', 'danger': '#dc2626', 'bg': '#f8fafc', 'card': '#ffffff' } style.configure("Header.TLabel", font=("Malgun Gothic", 16, "bold"), background="#f0f2f5", foreground="#1e293b") style.configure("Section.TLabel", font=("Malgun Gothic", 12, "bold"), background="#f0f2f5", foreground="#334155") style.configure("Card.TFrame", background="#ffffff", relief="flat") # 트리뷰 스타일 (표) style.configure("Treeview", background="#ffffff", fieldbackground="#ffffff", font=("Malgun Gothic", 10), rowheight=30 ) style.configure("Treeview.Heading", font=("Malgun Gothic", 10, "bold"), background="#e2e8f0", foreground="#1e293b" ) # 버튼 스타일 style.configure("Action.TButton", font=("Malgun Gothic", 10), padding=6) style.map("Action.TButton", background=[("active", "#dbeafe")]) def create_layout(self): # 상단 헤더 header_frame = ttk.Frame(self.root, padding="20 20 20 10") header_frame.pack(fill="x") ttk.Label(header_frame, text="⚡ SolorPower Crawler Control", style="Header.TLabel").pack(side="left") status_frame = ttk.Frame(header_frame) status_frame.pack(side="right") self.status_label = ttk.Label(status_frame, text="🟢 시스템 대기중", font=("Malgun Gothic", 10), foreground="green") self.status_label.pack() # 메인 컨텐츠 (좌우 분할) main_paned = ttk.PanedWindow(self.root, orient="horizontal") main_paned.pack(fill="both", expand=True, padx=20, pady=10) # 좌측 패널: 발전소 목록 및 제어 left_frame = ttk.Frame(main_paned) main_paned.add(left_frame, weight=2) # 우측 패널: 로그 및 상세 정보 right_frame = ttk.Frame(main_paned) main_paned.add(right_frame, weight=1) # --- 좌측 패널 구성 --- # 1. 제어 버튼 그룹 control_frame = ttk.LabelFrame(left_frame, text="통합 제어", padding=15) control_frame.pack(fill="x", pady=(0, 15)) btn_grid = ttk.Frame(control_frame) btn_grid.pack(fill="x") ttk.Button(btn_grid, text="▶ 전체 수집 시작", command=self.run_all_crawlers, style="Action.TButton").pack(side="left", padx=5) ttk.Button(btn_grid, text="🔄 새로고침", command=self.refresh_monitor, style="Action.TButton").pack(side="left", padx=5) ttk.Button(btn_grid, text="📊 통계 요약 실행", command=self.run_daily_summary, style="Action.TButton").pack(side="left", padx=5) # 2. 발전소 모니터링 테이블 table_frame = ttk.LabelFrame(left_frame, text="발전소 모니터링 현황", padding=10) table_frame.pack(fill="both", expand=True) columns = ("site_id", "name", "type", "status", "schedule", "last_run", "action", "history") self.tree = ttk.Treeview(table_frame, columns=columns, show="tree headings", selectmode="browse") self.tree.heading("site_id", text="ID") self.tree.heading("name", text="발전소명") self.tree.heading("type", text="타입") self.tree.heading("status", text="상태") self.tree.heading("schedule", text="스케줄") self.tree.heading("last_run", text="최근 실행") self.tree.heading("action", text="개별 제어") self.tree.heading("history", text="과거 데이터") self.tree.column("site_id", width=80) self.tree.column("name", width=150) self.tree.column("type", width=80) self.tree.column("status", width=80) self.tree.column("schedule", width=100) self.tree.column("last_run", width=140) self.tree.column("action", width=80) self.tree.column("history", width=80) scrollbar = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview) self.tree.configure(yscroll=scrollbar.set) self.tree.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") # 우클릭 메뉴 (복구) self.context_menu = tk.Menu(self.root, tearoff=0) self.context_menu.add_command(label="▶ 이 사이트만 즉시 실행", command=self.run_selected_crawler) self.context_menu.add_command(label="📑 상세 로그 보기", command=self.show_site_logs) self.context_menu.add_separator() self.context_menu.add_command(label="🔄 학습 모드로 리셋", command=self.reset_learning_mode) # 이벤트 바인딩 self.tree.bind("", self.on_tree_click) self.tree.bind("", self.show_context_menu) self.tree.bind("", lambda e: self.run_selected_crawler()) # --- 우측 패널 구성 --- # 실시간 로그 뷰어 log_frame = ttk.LabelFrame(right_frame, text="실시간 시스템 로그", padding=10) log_frame.pack(fill="both", expand=True) self.log_text = scrolledtext.ScrolledText(log_frame, state='disabled', font=("Consolas", 9), bg="#1e293b", fg="#e2e8f0") self.log_text.pack(fill="both", expand=True) # 태그 설정 (로그 색상) self.log_text.tag_config("INFO", foreground="#60a5fa") self.log_text.tag_config("SUCCESS", foreground="#4ade80") self.log_text.tag_config("ERROR", foreground="#f87171") self.log_text.tag_config("WARNING", foreground="#fbbf24") def log(self, message, level="INFO"): """로그 창에 메시지 출력""" timestamp = datetime.now().strftime("%H:%M:%S") full_msg = f"[{timestamp}] {message}\n" self.log_text.configure(state='normal') self.log_text.insert("end", full_msg, level) self.log_text.see("end") self.log_text.configure(state='disabled') def refresh_monitor(self): """테이블 데이터 새로고침""" # 기존 항목 제거 for i in self.tree.get_children(): self.tree.delete(i) if not self.manager: self.log("DB 매니저 로드 실패", "ERROR") return # DB에서 최신 상태 조회 site_stats = {s['site_id']: s for s in self.manager.get_all_sites()} # 중복 회사 노드 방지용 added_companies = set() for plant in self.plants: # 1,2호기 분리 로직 반영 is_split = plant.get('options', {}).get('is_split', False) company_name = plant.get('company_name', '') plant_name = plant.get('name', '') sub_units = [] if is_split: sub_units.append({'id': 'nrems-01', 'name': f'{company_name} 1호기', 'type': plant['type']}) sub_units.append({'id': 'nrems-02', 'name': f'{company_name} 2호기', 'type': plant['type']}) else: plant_id = plant.get('id', '') if plant_id: sub_units.append({'id': plant_id, 'name': f'{company_name} {plant_name}', 'type': plant['type']}) for unit in sub_units: site_id = unit['id'] stat = site_stats.get(site_id, {}) status_text = stat.get('status', 'UNREGISTERED') schedule_text = f"매시 {stat.get('target_minute', -1)}분" if stat.get('target_minute', -1) >= 0 else "학습중" last_run = stat.get('last_run', '-') or '-' if last_run != '-': try: last_run = last_run.split('.')[0].replace('T', ' ') # 포맷팅 except: pass # 태그 설정 (색상) row_tag = "normal" if status_text == 'OPTIMIZED': row_tag = "optimized" # 회사 노드 확인 및 생성 company_id = plant.get('company_id', 'unknown') if company_id not in added_companies: self.tree.insert("", "end", iid=company_id, text=company_name, values=( "", company_name, "GROUP", "", "", "", "", "" ), open=True) added_companies.add(company_id) # 발전소 노드 추가 (회사 노드 하위) self.tree.insert(company_id, "end", iid=site_id, values=( site_id, unit['name'], unit['type'].upper(), status_text, schedule_text, last_run, "▶ 실행", "📥 수집" ), tags=(row_tag,)) self.tree.tag_configure("optimized", foreground="#059669") # 진한 녹색 self.log("모니터링 상태 갱신 완료 (계층형)", "INFO") def on_tree_click(self, event): """트리뷰 클릭 이벤트 처리""" try: region = self.tree.identify_region(event.x, event.y) if region != "cell": return col = self.tree.identify_column(event.x) item_id = self.tree.identify_row(event.y) if not item_id: return # 컬럼 인덱스 확인 (columns 배열 기준 1-based, #1=site_id, ... #7=action, #8=history) # Treeview columns: ("site_id", "name", "type", "status", "schedule", "last_run", "action", "history") # Display columns include transparent tree column if show="tree headings" # identify_column returns '#N'. # #1: site_id, #7: action, #8: history if col == '#7': # Action (실행) self.log(f"'{item_id}' 실행 요청", "INFO") # TODO: 개별 실행 self.run_process_thread(["main.py", "--site", item_id], f"{item_id} 수집") elif col == '#8': # History (과거 데이터) # 그룹 노드는 제외 if self.tree.parent(item_id) == "": return if messagebox.askyesno("과거 데이터 수집", f"'{item_id}'의 과거 내역을 수집하시겠습니까?\n(시간별/일별/월별 전체)"): self.run_process_thread(["fetch_history.py", item_id], f"{item_id} 히스토리 수집") except Exception as e: self.log(f"클릭 처리 중 오류: {e}", "ERROR") def show_context_menu(self, event): item = self.tree.identify_row(event.y) if item: self.tree.selection_set(item) self.context_menu.post(event.x_root, event.y_root) def run_process_thread(self, cmd_list, description): """백그라운드 스레드에서 서브프로세스 실행""" def task(): self.status_label.config(text=f"⏳ {description} 중...", foreground="orange") self.log(f"{description} 시작...", "INFO") try: # python 실행 경로 확보 python_exe = sys.executable # 가상환경 venv/temp_env 사용 시 경로 조정 venv_python = os.path.join(project_root, "venv", "Scripts", "python.exe") temp_env_python = os.path.join(current_dir, "temp_env", "Scripts", "python.exe") if os.path.exists(temp_env_python): python_exe = temp_env_python elif os.path.exists(venv_python): python_exe = venv_python full_cmd = [python_exe] + cmd_list # 서브프로세스 실행 process = subprocess.Popen( full_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=current_dir, text=True, encoding='utf-8', errors='replace' # 인코딩 에러 방지 ) stdout, stderr = process.communicate() if stdout: for line in stdout.splitlines(): if "Error" in line or "fail" in line.lower(): self.log(line, "ERROR") else: self.log(line, "INFO") if stderr: self.log(f"STDERR: {stderr}", "WARNING") if process.returncode == 0: self.log(f"{description} 완료 ✅", "SUCCESS") else: self.log(f"{description} 실패 (Exit Code: {process.returncode})", "ERROR") except Exception as e: self.log(f"실행 오류: {e}", "ERROR") finally: self.root.after(0, self.refresh_monitor) self.root.after(0, lambda: self.status_label.config(text="🟢 시스템 대기중", foreground="green")) thread = threading.Thread(target=task) thread.daemon = True thread.start() def run_all_crawlers(self): """전체 통합 크롤링 실행 (강제 모드)""" if messagebox.askyesno("확인", "모든 발전소 데이터를 강제로 수집하시겠습니까?"): self.run_process_thread(["main.py", "--force"], "전체 데이터 수집") def run_selected_crawler(self): """선택된 단일 사이트 크롤링 (현재 main.py는 단일 실행 옵션이 없어서 전체를 돌리되, 추후 개선 필요)""" # 임시로 단일 실행 기능이 없으므로 알림만 띄움 (추후 main.py에 --site 옵션 추가 필요) selected = self.tree.selection() if not selected: return site_id = selected[0] # main.py 수정 없이 특정 사이트만 돌리기 어려우므로, 안내 메시지 # 실제로는 main.py에 인자 처리를 추가해야 함. # 여기서는 전체 실행으로 대체하거나, 추후 main.py 업데이트 후 구현 # 임시 구현: main.py를 호출하되 필터링은 구현 안 되어있음. # 이번 단계에서는 GUI 틀을 만드는 것이므로 전체 실행으로 트리거 self.log(f"'{site_id}' 단일 실행 요청 (현재는 전체 실행으로 동작)", "WARNING") self.run_process_thread(["main.py", "--force"], f"'{site_id}' 데이터 수집") def run_daily_summary(self): """일일 통계 집계 실행""" self.run_process_thread(["daily_summary.py"], "일일 통계 집계") def show_site_logs(self): selected = self.tree.selection() if selected: site_id = selected[0] self.log(f"'{site_id}' 로그 조회 기능은 아직 구현되지 않았습니다.", "INFO") def reset_learning_mode(self): selected = self.tree.selection() if selected: site_id = selected[0] if self.manager.reset_to_learning(site_id): self.log(f"'{site_id}' 학습 모드로 리셋 완료", "SUCCESS") self.refresh_monitor() if __name__ == "__main__": root = tk.Tk() # 아이콘 설정 (옵션) # try: root.iconbitmap("icon.ico") # except: pass app = CrawlerControlPanel(root) root.mainloop()