Initial commit: Dabit Time Manager project
Python-based time management application with UDP discovery, TCP protocol communication, time sync, and drift monitoring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
241
gui/monitor_panel.py
Normal file
241
gui/monitor_panel.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""탭3: 시간 모니터링/오차 추적 패널"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog, messagebox
|
||||
from datetime import datetime
|
||||
|
||||
from models.controller import Controller
|
||||
from models.reading import TimeReading
|
||||
from services.drift_monitor import DriftMonitor
|
||||
from services.export_service import export_readings_csv, export_summary_csv
|
||||
from config import DEFAULT_MONITOR_INTERVAL
|
||||
|
||||
|
||||
class MonitorPanel(ttk.Frame):
|
||||
"""시간 모니터링 및 오차 추적 패널."""
|
||||
|
||||
def __init__(self, parent, shared_controllers: list[Controller]):
|
||||
super().__init__(parent)
|
||||
self._controllers = shared_controllers
|
||||
self._monitor = DriftMonitor()
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
# --- 상단: 설정 및 제어 ---
|
||||
top = ttk.LabelFrame(self, text="모니터링 설정", padding=5)
|
||||
top.pack(fill=tk.X, padx=5, pady=5)
|
||||
|
||||
ttk.Label(top, text="읽기 주기(초):").pack(side=tk.LEFT, padx=(0, 5))
|
||||
self._interval_var = tk.StringVar(value=str(DEFAULT_MONITOR_INTERVAL))
|
||||
ttk.Entry(top, textvariable=self._interval_var, width=8).pack(side=tk.LEFT, padx=(0, 10))
|
||||
|
||||
self._btn_start = ttk.Button(top, text="시작", command=self._on_start)
|
||||
self._btn_start.pack(side=tk.LEFT, padx=2)
|
||||
self._btn_stop = ttk.Button(top, text="중지", command=self._on_stop, state=tk.DISABLED)
|
||||
self._btn_stop.pack(side=tk.LEFT, padx=2)
|
||||
self._btn_read_now = ttk.Button(top, text="즉시 읽기", command=self._on_read_now)
|
||||
self._btn_read_now.pack(side=tk.LEFT, padx=2)
|
||||
self._btn_clear = ttk.Button(top, text="기록 초기화", command=self._on_clear)
|
||||
self._btn_clear.pack(side=tk.LEFT, padx=2)
|
||||
|
||||
self._status_label = ttk.Label(top, text="정지됨")
|
||||
self._status_label.pack(side=tk.RIGHT, padx=5)
|
||||
|
||||
# --- 중간: 오차 읽기 기록 테이블 ---
|
||||
mid = ttk.LabelFrame(self, text="오차 읽기 기록", padding=5)
|
||||
mid.pack(fill=tk.BOTH, expand=True, padx=5, pady=(5, 2))
|
||||
|
||||
cols_read = ("controller", "pc_time", "ctrl_time", "drift")
|
||||
self._read_tree = ttk.Treeview(mid, columns=cols_read, show="headings", height=8)
|
||||
self._read_tree.heading("controller", text="컨트롤러")
|
||||
self._read_tree.heading("pc_time", text="PC 시간")
|
||||
self._read_tree.heading("ctrl_time", text="컨트롤러 시간")
|
||||
self._read_tree.heading("drift", text="오차")
|
||||
self._read_tree.column("controller", width=180)
|
||||
self._read_tree.column("pc_time", width=160)
|
||||
self._read_tree.column("ctrl_time", width=160)
|
||||
self._read_tree.column("drift", width=100)
|
||||
|
||||
scroll_r = ttk.Scrollbar(mid, orient=tk.VERTICAL, command=self._read_tree.yview)
|
||||
self._read_tree.configure(yscrollcommand=scroll_r.set)
|
||||
self._read_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
scroll_r.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
# --- 하단: 요약 테이블 + CSV 내보내기 ---
|
||||
bot = ttk.LabelFrame(self, text="오차 요약", padding=5)
|
||||
bot.pack(fill=tk.BOTH, expand=True, padx=5, pady=(2, 5))
|
||||
|
||||
cols_sum = ("controller", "count", "avg", "max_d", "rate")
|
||||
self._sum_tree = ttk.Treeview(bot, columns=cols_sum, show="headings", height=5)
|
||||
self._sum_tree.heading("controller", text="컨트롤러")
|
||||
self._sum_tree.heading("count", text="읽기 횟수")
|
||||
self._sum_tree.heading("avg", text="평균 오차(초)")
|
||||
self._sum_tree.heading("max_d", text="최대 오차(초)")
|
||||
self._sum_tree.heading("rate", text="시간당 오차율(초/h)")
|
||||
self._sum_tree.column("controller", width=180)
|
||||
self._sum_tree.column("count", width=80)
|
||||
self._sum_tree.column("avg", width=100)
|
||||
self._sum_tree.column("max_d", width=100)
|
||||
self._sum_tree.column("rate", width=120)
|
||||
|
||||
scroll_s = ttk.Scrollbar(bot, orient=tk.VERTICAL, command=self._sum_tree.yview)
|
||||
self._sum_tree.configure(yscrollcommand=scroll_s.set)
|
||||
self._sum_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
scroll_s.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
# CSV 내보내기
|
||||
export_frame = ttk.Frame(self)
|
||||
export_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Button(export_frame, text="기록 CSV 내보내기", command=self._export_readings).pack(side=tk.LEFT, padx=5)
|
||||
ttk.Button(export_frame, text="요약 CSV 내보내기", command=self._export_summary).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
def _on_start(self):
|
||||
selected = [c for c in self._controllers if c.selected]
|
||||
if not selected:
|
||||
messagebox.showwarning("알림", "모니터링할 컨트롤러가 없습니다.\n검색 후 선택하세요.")
|
||||
return
|
||||
|
||||
try:
|
||||
interval = int(self._interval_var.get().strip())
|
||||
if interval < 1:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
messagebox.showerror("오류", "읽기 주기는 1 이상의 정수여야 합니다.")
|
||||
return
|
||||
|
||||
self._monitor.configure(
|
||||
controllers=self._controllers,
|
||||
interval=interval,
|
||||
on_readings=lambda readings: self.after(0, self._on_new_readings, readings),
|
||||
on_error=lambda msg: self.after(0, self._on_monitor_error, msg),
|
||||
)
|
||||
self._monitor.start()
|
||||
|
||||
self._btn_start.config(state=tk.DISABLED)
|
||||
self._btn_stop.config(state=tk.NORMAL)
|
||||
self._status_label.config(text=f"모니터링 중 (주기: {interval}초)")
|
||||
|
||||
def _on_stop(self):
|
||||
self._monitor.stop()
|
||||
self._btn_start.config(state=tk.NORMAL)
|
||||
self._btn_stop.config(state=tk.DISABLED)
|
||||
self._status_label.config(text="정지됨")
|
||||
|
||||
def _on_read_now(self):
|
||||
if not self._controllers:
|
||||
messagebox.showwarning("알림", "컨트롤러가 없습니다.")
|
||||
return
|
||||
|
||||
# 모니터가 구성되지 않은 경우 현재 설정으로 구성
|
||||
if not self._monitor.is_running:
|
||||
try:
|
||||
interval = int(self._interval_var.get().strip())
|
||||
except ValueError:
|
||||
interval = DEFAULT_MONITOR_INTERVAL
|
||||
|
||||
self._monitor.configure(
|
||||
controllers=self._controllers,
|
||||
interval=interval,
|
||||
on_readings=lambda readings: self.after(0, self._on_new_readings, readings),
|
||||
on_error=lambda msg: self.after(0, self._on_monitor_error, msg),
|
||||
)
|
||||
|
||||
self._monitor.read_once()
|
||||
self._status_label.config(text="읽기 중...")
|
||||
|
||||
def _on_clear(self):
|
||||
self._monitor.clear_readings()
|
||||
self._read_tree.delete(*self._read_tree.get_children())
|
||||
self._sum_tree.delete(*self._sum_tree.get_children())
|
||||
|
||||
def _on_new_readings(self, readings: list[TimeReading]):
|
||||
"""새 읽기 결과가 도착했을 때 GUI 업데이트."""
|
||||
for r in readings:
|
||||
self._read_tree.insert("", tk.END, values=(
|
||||
r.controller_label,
|
||||
r.pc_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
r.controller_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
r.drift_display,
|
||||
))
|
||||
# 스크롤 끝으로
|
||||
children = self._read_tree.get_children()
|
||||
if children:
|
||||
self._read_tree.see(children[-1])
|
||||
|
||||
# 요약 갱신
|
||||
self._refresh_summary()
|
||||
|
||||
if self._monitor.is_running:
|
||||
self._status_label.config(
|
||||
text=f"모니터링 중 (주기: {self._monitor.interval}초) - "
|
||||
f"마지막 읽기: {datetime.now().strftime('%H:%M:%S')}"
|
||||
)
|
||||
else:
|
||||
self._status_label.config(
|
||||
text=f"읽기 완료: {datetime.now().strftime('%H:%M:%S')}"
|
||||
)
|
||||
|
||||
def _on_monitor_error(self, msg: str):
|
||||
# 에러를 읽기 테이블에 표시
|
||||
self._read_tree.insert("", tk.END, values=(msg, "", "", "오류"))
|
||||
children = self._read_tree.get_children()
|
||||
if children:
|
||||
self._read_tree.see(children[-1])
|
||||
|
||||
def _refresh_summary(self):
|
||||
self._sum_tree.delete(*self._sum_tree.get_children())
|
||||
summary = self._monitor.get_summary()
|
||||
for mac, s in summary.items():
|
||||
self._sum_tree.insert("", tk.END, values=(
|
||||
s["label"],
|
||||
s["count"],
|
||||
f"{s['avg_drift']:.2f}",
|
||||
f"{s['max_drift']:.2f}",
|
||||
f"{s['drift_per_hour']:.3f}",
|
||||
))
|
||||
|
||||
def _export_readings(self):
|
||||
readings = self._monitor.readings
|
||||
if not readings:
|
||||
messagebox.showinfo("알림", "내보낼 기록이 없습니다.")
|
||||
return
|
||||
|
||||
filepath = filedialog.asksaveasfilename(
|
||||
title="기록 CSV 저장",
|
||||
defaultextension=".csv",
|
||||
filetypes=[("CSV 파일", "*.csv")],
|
||||
initialfile=f"drift_readings_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
||||
)
|
||||
if not filepath:
|
||||
return
|
||||
|
||||
try:
|
||||
saved = export_readings_csv(readings, filepath)
|
||||
messagebox.showinfo("성공", f"저장 완료: {saved}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("오류", f"저장 실패: {e}")
|
||||
|
||||
def _export_summary(self):
|
||||
summary = self._monitor.get_summary()
|
||||
if not summary:
|
||||
messagebox.showinfo("알림", "내보낼 요약이 없습니다.")
|
||||
return
|
||||
|
||||
filepath = filedialog.asksaveasfilename(
|
||||
title="요약 CSV 저장",
|
||||
defaultextension=".csv",
|
||||
filetypes=[("CSV 파일", "*.csv")],
|
||||
initialfile=f"drift_summary_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
||||
)
|
||||
if not filepath:
|
||||
return
|
||||
|
||||
try:
|
||||
saved = export_summary_csv(summary, filepath)
|
||||
messagebox.showinfo("성공", f"저장 완료: {saved}")
|
||||
except Exception as e:
|
||||
messagebox.showerror("오류", f"저장 실패: {e}")
|
||||
|
||||
def stop_monitor(self):
|
||||
"""앱 종료 시 호출."""
|
||||
self._monitor.stop()
|
||||
Reference in New Issue
Block a user