#!/usr/bin/env python3 """Graficzna aplikacja do monitorowania dostępności hostów.""" from __future__ import annotations import concurrent.futures import json import queue import threading import tkinter as tk from dataclasses import dataclass from datetime import datetime from pathlib import Path from tkinter import messagebox, ttk from host_checker import CheckResult, check CONFIG_FILE = Path(__file__).with_name("hosts.json") @dataclass class Host: target: str method: str = "http" class HostMonitorApp(tk.Tk): def __init__(self) -> None: super().__init__() self.title("Monitor dostępności hostów") self.geometry("1120x620") self.minsize(850, 450) self.hosts: list[Host] = [] self.stats: dict[str, dict[str, float | int | bool | str]] = {} self.results: queue.Queue[tuple[str, CheckResult]] = queue.Queue() self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=10) self.running = False self.after_id: str | None = None self.worker_lock = threading.Lock() self._build_ui() self._load_config() self.protocol("WM_DELETE_WINDOW", self._close) self.after(200, self._consume_results) def _build_ui(self) -> None: controls = ttk.Frame(self, padding=10) controls.pack(fill="x") ttk.Label(controls, text="Host / URL:").grid(row=0, column=0, sticky="w") self.target_var = tk.StringVar() ttk.Entry(controls, textvariable=self.target_var, width=38).grid(row=0, column=1, padx=5) ttk.Label(controls, text="Metoda:").grid(row=0, column=2, sticky="e") self.method_var = tk.StringVar(value="http") ttk.Combobox(controls, textvariable=self.method_var, values=("http", "icmp"), state="readonly", width=8).grid(row=0, column=3, padx=5) ttk.Button(controls, text="Dodaj / aktualizuj", command=self._add_host).grid(row=0, column=4, padx=5) ttk.Button(controls, text="Usuń zaznaczone", command=self._remove_selected).grid(row=0, column=5, padx=5) ttk.Label(controls, text="Interwał [s]:").grid(row=1, column=0, sticky="w", pady=(8, 0)) self.interval_var = tk.StringVar(value="10") ttk.Spinbox(controls, from_=1, to=86400, textvariable=self.interval_var, width=8).grid(row=1, column=1, sticky="w", padx=5, pady=(8, 0)) ttk.Label(controls, text="Timeout [s]:").grid(row=1, column=2, sticky="e", pady=(8, 0)) self.timeout_var = tk.StringVar(value="3") ttk.Spinbox(controls, from_=0.1, to=300, increment=0.5, textvariable=self.timeout_var, width=8).grid(row=1, column=3, sticky="w", padx=5, pady=(8, 0)) self.start_button = ttk.Button(controls, text="Uruchom monitoring", command=self._toggle_monitoring) self.start_button.grid(row=1, column=4, padx=5, pady=(8, 0)) ttk.Button(controls, text="Zapisz konfigurację", command=self._save_config).grid(row=1, column=5, padx=5, pady=(8, 0)) table_frame = ttk.Frame(self, padding=(10, 0, 10, 10)) table_frame.pack(fill="both", expand=True) columns = ("target", "method", "status", "tests", "ok", "fail", "availability", "avg", "last") self.table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended") headings = {"target": "Host / URL", "method": "Metoda", "status": "Status", "tests": "Testy", "ok": "OK", "fail": "FAIL", "availability": "Dostępność", "avg": "Śr. czas", "last": "Ostatni test"} widths = {"target": 260, "method": 70, "status": 75, "tests": 55, "ok": 45, "fail": 50, "availability": 90, "avg": 80, "last": 180} for column in columns: self.table.heading(column, text=headings[column]) self.table.column(column, width=widths[column], anchor="center") self.table.column("target", anchor="w") scrollbar = ttk.Scrollbar(table_frame, orient="vertical", command=self.table.yview) self.table.configure(yscrollcommand=scrollbar.set) self.table.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") self.table.tag_configure("up", foreground="#16803c") self.table.tag_configure("down", foreground="#b42318") self.table.tag_configure("unknown", foreground="#666666") self.status_var = tk.StringVar(value="Gotowy. Dodaj hosty i uruchom monitoring.") ttk.Label(self, textvariable=self.status_var, relief="sunken", anchor="w", padding=5).pack(fill="x", side="bottom") def _add_host(self) -> None: target = self.target_var.get().strip() method = self.method_var.get() if not target: messagebox.showwarning("Brak hosta", "Wpisz adres hosta lub URL.") return existing = next((host for host in self.hosts if host.target == target), None) if existing: existing.method = method else: self.hosts.append(Host(target, method)) self.target_var.set("") self._refresh_table() self._save_config(silent=True) def _remove_selected(self) -> None: selected = set(self.table.selection()) if not selected: return self.hosts = [host for host in self.hosts if host.target not in selected] for target in selected: self.stats.pop(target, None) self._refresh_table() self._save_config(silent=True) def _toggle_monitoring(self) -> None: if self.running: self.running = False if self.after_id: self.after_cancel(self.after_id) self.start_button.configure(text="Uruchom monitoring") self.status_var.set("Monitoring zatrzymany.") return if not self.hosts: messagebox.showwarning("Brak hostów", "Dodaj co najmniej jeden host.") return try: float(self.interval_var.get()) float(self.timeout_var.get()) except ValueError: messagebox.showerror("Błędne ustawienia", "Interwał i timeout muszą być liczbami.") return self.running = True self.start_button.configure(text="Zatrzymaj monitoring") self._schedule_check(0) def _schedule_check(self, delay_ms: int | None = None) -> None: if not self.running: return if delay_ms is None: delay_ms = max(1, int(float(self.interval_var.get()) * 1000)) self.after_id = self.after(delay_ms, self._start_check) def _start_check(self) -> None: if not self.running: return timeout = float(self.timeout_var.get()) with self.worker_lock: for host in self.hosts: future = self.executor.submit(check, host.target, host.method, timeout) future.add_done_callback(lambda item, target=host.target: self._put_result(target, item)) self._schedule_check() def _put_result(self, target: str, future: concurrent.futures.Future[CheckResult]) -> None: try: self.results.put((target, future.result())) except Exception as error: # nie przerywaj monitorowania przez pojedynczy host self.results.put((target, CheckResult(target, "?", False, str(error), 0))) def _consume_results(self) -> None: changed = False while True: try: target, result = self.results.get_nowait() except queue.Empty: break stats = self.stats.setdefault(target, {"tests": 0, "ok": 0, "fail": 0, "total_ms": 0.0, "available": False, "detail": "", "last": ""}) stats["tests"] = int(stats["tests"]) + 1 stats["ok"] = int(stats["ok"]) + int(result.available) stats["fail"] = int(stats["fail"]) + int(not result.available) stats["total_ms"] = float(stats["total_ms"]) + result.elapsed_ms stats["available"] = result.available stats["detail"] = result.detail stats["last"] = datetime.now().strftime("%H:%M:%S") changed = True if changed: self._refresh_table() self.after(200, self._consume_results) def _refresh_table(self) -> None: existing = set(self.table.get_children()) current = {host.target for host in self.hosts} for target in existing - current: self.table.delete(target) for host in self.hosts: stats = self.stats.get(host.target, {}) tests = int(stats.get("tests", 0)) ok = int(stats.get("ok", 0)) fail = int(stats.get("fail", 0)) availability = f"{ok / tests * 100:.1f}%" if tests else "-" avg = f"{float(stats.get('total_ms', 0)) / tests:.0f} ms" if tests else "-" last = f"{stats.get('last', '-')} ({stats.get('detail', '-')})" if tests else "-" values = (host.target, host.method.upper(), "OK" if stats.get("available") else ("FAIL" if tests else "-"), tests, ok, fail, availability, avg, last) tag = "up" if stats.get("available") else ("down" if tests else "unknown") if host.target in existing: self.table.item(host.target, values=values, tags=(tag,)) else: self.table.insert("", "end", iid=host.target, values=values, tags=(tag,)) def _load_config(self) -> None: if CONFIG_FILE.exists(): try: data = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) self.hosts = [Host(str(item["target"]), str(item.get("method", "http"))) for item in data.get("hosts", []) if item.get("target")] self.interval_var.set(str(data.get("interval", 10))) self.timeout_var.set(str(data.get("timeout", 3))) self._refresh_table() except (OSError, ValueError, KeyError, TypeError) as error: messagebox.showwarning("Błąd konfiguracji", f"Nie udało się odczytać hosts.json: {error}") def _save_config(self, silent: bool = False) -> None: try: data = {"interval": float(self.interval_var.get()), "timeout": float(self.timeout_var.get()), "hosts": [{"target": host.target, "method": host.method} for host in self.hosts]} CONFIG_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") if not silent: self.status_var.set(f"Zapisano konfigurację: {CONFIG_FILE.name}") except (OSError, ValueError) as error: messagebox.showerror("Błąd zapisu", str(error)) def _close(self) -> None: self.running = False if self.after_id: self.after_cancel(self.after_id) self._save_config(silent=True) self.executor.shutdown(wait=False, cancel_futures=True) self.destroy() if __name__ == "__main__": HostMonitorApp().mainloop()