Dodaj monitor dostępności hostów

This commit is contained in:
Wojtek
2026-10-14 11:26:53 +02:00
commit 6ffbce804e
5 changed files with 455 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
hosts.json
.venv/
venv/
+3
View File
@@ -0,0 +1,3 @@
{
"python-envs.defaultEnvManager": "ms-python.python:system"
}
+44
View File
@@ -0,0 +1,44 @@
# Host checker
Prosty monitor dostępności hostów przez HTTP albo ICMP. Wymaga Python 3.10+ i nie korzysta z dodatkowych pakietów.
## Aplikacja graficzna
Uruchom:
```bash
python3 app.py
```
Aplikacja pozwala dodawać, aktualizować i usuwać hosty, wybierać metodę `HTTP`/`ICMP`, ustawić interwał oraz timeout. Konfiguracja jest automatycznie zapisywana w pliku `hosts.json` obok aplikacji. Tabela pokazuje bieżący status, liczbę testów, liczbę odpowiedzi OK/FAIL, procent dostępności i średni czas odpowiedzi.
Przycisk „Uruchom monitoring” rozpoczyna cykliczne sprawdzanie wszystkich zapisanych hostów. Wyniki są zbierane równolegle, więc wolny host nie blokuje pozostałych.
## Przykłady
Jeden test HTTP:
```bash
python3 host_checker.py https://example.com --once
```
Monitorowanie hostów przez ICMP co 5 sekund:
```bash
python3 host_checker.py 192.168.1.1 router.local -m icmp -i 5
```
Lista hostów z pliku:
```text
# komentarze są ignorowane
https://example.com
https://example.org/health
192.168.1.1
```
```bash
python3 host_checker.py --file hosts.txt -m http --timeout 2
```
Kod wyjścia przy `--once` wynosi `0`, jeśli wszystkie testy się powiodły, albo `1`, gdy przynajmniej jeden host jest niedostępny. W przypadku ICMP system musi mieć dostępny program `ping`; na niektórych systemach wymagane są dodatkowe uprawnienia.
+229
View File
@@ -0,0 +1,229 @@
#!/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()
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Monitor dostępności hostów przez HTTP lub ICMP."""
from __future__ import annotations
import argparse
import concurrent.futures
import platform
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
@dataclass(frozen=True)
class CheckResult:
target: str
method: str
available: bool
detail: str
elapsed_ms: float
def normalize_url(target: str) -> str:
if "://" not in target:
return f"http://{target}"
return target
def host_from_target(target: str) -> str:
parsed = urlparse(target if "://" in target else f"//{target}")
return parsed.hostname or target.split("/")[0]
def check_http(target: str, timeout: float) -> CheckResult:
started = time.perf_counter()
url = normalize_url(target)
request = Request(url, headers={"User-Agent": "host-checker/1.0"})
try:
with urlopen(request, timeout=timeout) as response:
status = response.status
available = status < 400
detail = f"HTTP {status}"
except HTTPError as error:
# Odpowiedź HTTP, także 4xx/5xx, oznacza że host jest osiągalny.
available = True
detail = f"HTTP {error.code}"
except (URLError, TimeoutError, OSError) as error:
available = False
reason = getattr(error, "reason", error)
detail = str(reason)
return CheckResult(target, "http", available, detail, elapsed_ms(started))
def check_icmp(target: str, timeout: float) -> CheckResult:
started = time.perf_counter()
host = host_from_target(target)
system = platform.system().lower()
if system == "windows":
command = ["ping", "-n", "1", "-w", str(max(1, int(timeout * 1000))), host]
else:
command = ["ping", "-c", "1", "-W", str(max(1, int(timeout))), host]
try:
completed = subprocess.run(
command,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=timeout + 1,
check=False,
)
available = completed.returncode == 0
detail = "odpowiedź ICMP" if available else "brak odpowiedzi"
except FileNotFoundError:
available = False
detail = "nie znaleziono programu ping"
except subprocess.TimeoutExpired:
available = False
detail = "przekroczono timeout"
except OSError as error:
available = False
detail = str(error)
return CheckResult(target, "icmp", available, detail, elapsed_ms(started))
def elapsed_ms(started: float) -> float:
return (time.perf_counter() - started) * 1000
def check(target: str, method: str, timeout: float) -> CheckResult:
if method == "http":
return check_http(target, timeout)
return check_icmp(target, timeout)
def load_targets(cli_targets: list[str], filename: str | None) -> list[str]:
targets = list(cli_targets)
if filename:
for line in Path(filename).read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
targets.append(line)
# Zachowaj kolejność i usuń powtórzenia.
return list(dict.fromkeys(targets))
def print_results(results: Iterable[CheckResult]) -> None:
timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S%z")
for result in results:
state = "OK " if result.available else "FAIL"
print(
f"{timestamp} | {state} | {result.method.upper():5} | "
f"{result.target} | {result.detail} | {result.elapsed_ms:.0f} ms",
flush=True,
)
def run(args: argparse.Namespace, targets: list[str]) -> int:
while True:
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = [pool.submit(check, target, args.method, args.timeout) for target in targets]
results = [future.result() for future in futures]
print_results(results)
if args.once:
return 0 if all(result.available for result in results) else 1
try:
time.sleep(args.interval)
except KeyboardInterrupt:
print("\nZatrzymano.")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Sprawdzanie dostępności hostów przez HTTP lub ICMP.")
parser.add_argument("targets", nargs="*", help="Hosty, adresy IP lub URL-e")
parser.add_argument("-f", "--file", help="Plik z hostami: jeden wpis w linii")
parser.add_argument("-m", "--method", choices=("http", "icmp"), default="http", help="Metoda testu (domyślnie: http)")
parser.add_argument("-t", "--timeout", type=float, default=3.0, help="Timeout w sekundach (domyślnie: 3)")
parser.add_argument("-i", "--interval", type=float, default=10.0, help="Odstęp między testami (domyślnie: 10 s)")
parser.add_argument("-w", "--workers", type=int, default=10, help="Liczba równoległych testów (domyślnie: 10)")
parser.add_argument("--once", action="store_true", help="Wykonaj tylko jeden test i zakończ")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.timeout <= 0 or args.interval < 0 or args.workers < 1:
parser.error("timeout musi być > 0, interval >= 0, a workers >= 1")
try:
targets = load_targets(args.targets, args.file)
except OSError as error:
parser.error(f"nie można odczytać pliku: {error}")
if not targets:
parser.error("podaj co najmniej jeden host albo użyj --file")
try:
return run(args, targets)
except KeyboardInterrupt:
print("\nZatrzymano.")
return 0
if __name__ == "__main__":
sys.exit(main())