Files
projektX/app.py
T

313 lines
14 KiB
Python

#!/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
__version__ = "0.0.1"
CONFIG_FILE = Path(__file__).with_name("hosts.json")
@dataclass
class Host:
target: str
method: str = "http"
interval: float = 10.0
class HostMonitorApp(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title(f"Monitor dostępności hostów v{__version__}")
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.host_after_ids: dict[str, str] = {}
self.selected_target: 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", "interval", "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", "interval": "Interwał [s]", "status": "Status", "tests": "Testy", "ok": "OK", "fail": "FAIL", "availability": "Dostępność", "avg": "Śr. czas", "last": "Ostatni test"}
widths = {"target": 240, "method": 70, "interval": 85, "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.bind("<<TreeviewSelect>>", self._select_host)
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 _select_host(self, _event: tk.Event[tk.Misc] | None = None) -> None:
selected = self.table.selection()
if len(selected) != 1:
self.selected_target = None
return
target = selected[0]
host = next((item for item in self.hosts if item.target == target), None)
if host is None:
self.selected_target = None
return
self.selected_target = target
self.target_var.set(host.target)
self.method_var.set(host.method)
self.interval_var.set(str(host.interval).rstrip("0").rstrip("."))
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
try:
interval = float(self.interval_var.get())
if interval <= 0:
raise ValueError
except ValueError:
messagebox.showerror("Błędny interwał", "Interwał musi być liczbą większą od zera.")
return
selected_host = next((host for host in self.hosts if host.target == self.selected_target), None)
duplicate = next((host for host in self.hosts if host.target == target and host is not selected_host), None)
if duplicate:
messagebox.showwarning("Host już istnieje", "Host o tym adresie jest już na liście.")
return
was_update = selected_host is not None
if selected_host:
old_target = selected_host.target
if self.running:
old_after_id = self.host_after_ids.pop(old_target, None)
if old_after_id:
self.after_cancel(old_after_id)
selected_host.target = target
selected_host.method = method
selected_host.interval = interval
if old_target != target:
if old_target in self.stats:
self.stats[target] = self.stats.pop(old_target)
self.selected_target = target
else:
existing = next((host for host in self.hosts if host.target == target), None)
if existing:
existing.method = method
existing.interval = interval
self.selected_target = target
else:
self.hosts.append(Host(target, method, interval))
self.selected_target = target
if not was_update:
self.target_var.set("")
self._refresh_table()
if was_update and self.running:
self._schedule_check(self.selected_target or target)
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)
if self.selected_target in selected:
self.selected_target = None
self._refresh_table()
self._save_config(silent=True)
def _toggle_monitoring(self) -> None:
if self.running:
self.running = False
for after_id in self.host_after_ids.values():
self.after_cancel(after_id)
self.host_after_ids.clear()
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.timeout_var.get())
if any(host.interval <= 0 for host in self.hosts):
raise ValueError
except ValueError:
messagebox.showerror("Błędne ustawienia", "Interwały hostów i timeout muszą być poprawnymi liczbami.")
return
self.running = True
self.start_button.configure(text="Zatrzymaj monitoring")
for host in self.hosts:
self._schedule_check(host.target, 0)
def _schedule_check(self, target: str, delay_ms: int | None = None) -> None:
if not self.running:
return
if delay_ms is None:
host = next((item for item in self.hosts if item.target == target), None)
if host is None:
return
delay_ms = max(1, int(host.interval * 1000))
self.host_after_ids[target] = self.after(delay_ms, lambda: self._start_check(target))
def _start_check(self, target: str) -> None:
if not self.running:
return
timeout = float(self.timeout_var.get())
host = next((item for item in self.hosts if item.target == target), None)
if host is None:
self.host_after_ids.pop(target, None)
return
with self.worker_lock:
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(target)
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(), f"{host.interval:g}", "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,))
if self.selected_target in current:
self.table.selection_set(self.selected_target)
self.table.focus(self.selected_target)
def _load_config(self) -> None:
if CONFIG_FILE.exists():
try:
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
default_interval = float(data.get("interval", 10))
self.hosts = [
Host(
str(item["target"]),
str(item.get("method", "http")),
float(item.get("interval", default_interval)),
)
for item in data.get("hosts", [])
if item.get("target")
]
self.interval_var.set(str(default_interval))
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, "interval": host.interval} 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
for after_id in self.host_after_ids.values():
self.after_cancel(after_id)
self.host_after_ids.clear()
self._save_config(silent=True)
self.executor.shutdown(wait=False, cancel_futures=True)
self.destroy()
if __name__ == "__main__":
HostMonitorApp().mainloop()