solorpower_crawler/crawler_gui.py

405 lines
17 KiB
Python

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("<ButtonRelease-1>", self.on_tree_click)
self.tree.bind("<Button-3>", self.show_context_menu)
self.tree.bind("<Double-1>", 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()