405 lines
17 KiB
Python
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()
|