isitup-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
isitup/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
isitup/checks.py ADDED
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import socket
5
+ import subprocess
6
+ import time
7
+ from collections.abc import Callable
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum
11
+
12
+ import requests
13
+
14
+ from .config import Target, TargetKind
15
+
16
+ PhaseCallback = Callable[[str], None]
17
+
18
+ # Distinguishes *why* a probe (HTTP or TCP) failed. None means it didn't fail.
19
+ ErrorKind = str | None
20
+
21
+
22
+ class Status(StrEnum):
23
+ OK = "ok"
24
+ WARN = "warn"
25
+ DOWN = "down"
26
+ UNKNOWN = "unknown"
27
+
28
+
29
+ @dataclass
30
+ class CheckResult:
31
+ target: Target
32
+ ping_status: Status
33
+ ping_latency_ms: float | None
34
+ http_status: Status | None # set only when target.kind is HTTP
35
+ http_code: int | None
36
+ http_latency_ms: float | None
37
+ tcp_status: Status | None # set only when target.kind is TCP_PORT
38
+ tcp_latency_ms: float | None
39
+ error_kind: ErrorKind
40
+ detail: str
41
+ checked_at: float
42
+
43
+ @property
44
+ def latency_ms(self) -> float | None:
45
+ """Whichever probe latency applies to this target's kind (HTTP
46
+ response time, TCP connect time, or None for a ping-only target)."""
47
+ if self.target.kind == TargetKind.HTTP:
48
+ return self.http_latency_ms
49
+ if self.target.kind == TargetKind.TCP_PORT:
50
+ return self.tcp_latency_ms
51
+ return None
52
+
53
+
54
+ def ping_once(host: str, timeout_s: float = 2.0) -> tuple[bool, float | None]:
55
+ """Send a single ICMP echo request using the OS ping binary. Returns (reachable, latency_ms)."""
56
+ is_windows = platform.system().lower() == "windows"
57
+ if is_windows:
58
+ cmd = ["ping", "-n", "1", "-w", str(int(timeout_s * 1000)), host]
59
+ else:
60
+ cmd = ["ping", "-c", "1", "-W", str(int(timeout_s)), host]
61
+
62
+ start = time.monotonic()
63
+ try:
64
+ proc = subprocess.run(
65
+ cmd,
66
+ capture_output=True,
67
+ timeout=timeout_s + 2,
68
+ )
69
+ except subprocess.TimeoutExpired, OSError:
70
+ return False, None
71
+ elapsed_ms = (time.monotonic() - start) * 1000
72
+
73
+ return proc.returncode == 0, elapsed_ms
74
+
75
+
76
+ def run_pings(
77
+ targets: list[Target], ping_enabled: bool, ping_timeout: float
78
+ ) -> dict[str, tuple[Status, float | None]]:
79
+ """Ping each unique ping-host once — targets that point at the same host
80
+ (e.g. "example.com" and "https://example.com/health") share one ping."""
81
+ if not ping_enabled:
82
+ return {}
83
+ hosts = sorted({t.ping_host for t in targets if t.ping})
84
+ if not hosts:
85
+ return {}
86
+ with ThreadPoolExecutor(max_workers=min(len(hosts), 16) or 1) as pool:
87
+ futures = {pool.submit(ping_once, host, ping_timeout): host for host in hosts}
88
+ results: dict[str, tuple[Status, float | None]] = {}
89
+ for future, host in futures.items():
90
+ reachable, latency_ms = future.result()
91
+ results[host] = (Status.OK if reachable else Status.DOWN, latency_ms)
92
+ return results
93
+
94
+
95
+ def ping_status_for(
96
+ target: Target, ping_enabled: bool, ping_results: dict[str, tuple[Status, float | None]]
97
+ ) -> tuple[Status, float | None]:
98
+ if target.ping and ping_enabled:
99
+ return ping_results.get(target.ping_host, (Status.DOWN, None))
100
+ return Status.UNKNOWN, None
101
+
102
+
103
+ def _caused_by(exc: BaseException, cls: type[BaseException]) -> bool:
104
+ """Walk an exception's __cause__/__context__ chain looking for `cls`."""
105
+ seen: set[int] = set()
106
+ current: BaseException | None = exc
107
+ while current is not None and id(current) not in seen:
108
+ if isinstance(current, cls):
109
+ return True
110
+ seen.add(id(current))
111
+ current = current.__cause__ or current.__context__
112
+ return False
113
+
114
+
115
+ def http_check(url: str, timeout_s: float = 5.0) -> tuple[Status, int | None, float | None, ErrorKind, str]:
116
+ try:
117
+ response = requests.get(url, timeout=timeout_s, allow_redirects=True)
118
+ except requests.exceptions.SSLError as exc:
119
+ return Status.DOWN, None, None, "tls", f"TLS/certificate error: {exc}"
120
+ except requests.exceptions.TooManyRedirects:
121
+ return Status.DOWN, None, None, "redirect", "too many redirects (possible redirect loop)"
122
+ except requests.exceptions.Timeout:
123
+ return Status.DOWN, None, None, "timeout", "request timed out"
124
+ except requests.exceptions.ConnectionError as exc:
125
+ if _caused_by(exc, socket.gaierror):
126
+ return Status.DOWN, None, None, "dns", "DNS resolution failed"
127
+ return Status.DOWN, None, None, "connection", "connection failed"
128
+ except requests.exceptions.RequestException as exc:
129
+ return Status.DOWN, None, None, "request_error", str(exc)
130
+
131
+ latency_ms = response.elapsed.total_seconds() * 1000
132
+ code = response.status_code
133
+ if code >= 500:
134
+ return Status.DOWN, code, latency_ms, "server_error", f"HTTP {code} server error"
135
+ if code >= 400:
136
+ return Status.WARN, code, latency_ms, "client_error", f"HTTP {code} client error"
137
+ return Status.OK, code, latency_ms, None, f"HTTP {code}"
138
+
139
+
140
+ def tcp_check(
141
+ hostname: str, port: int, timeout_s: float = 5.0
142
+ ) -> tuple[Status, float | None, ErrorKind, str]:
143
+ start = time.monotonic()
144
+ try:
145
+ with socket.create_connection((hostname, port), timeout=timeout_s):
146
+ pass
147
+ except TimeoutError:
148
+ return Status.DOWN, None, "timeout", f"connection to port {port} timed out"
149
+ except socket.gaierror:
150
+ return Status.DOWN, None, "dns", "DNS resolution failed"
151
+ except OSError as exc:
152
+ return Status.DOWN, None, "connection", f"connection to port {port} failed: {exc.strerror or exc}"
153
+
154
+ latency_ms = (time.monotonic() - start) * 1000
155
+ return Status.OK, latency_ms, None, f"port {port} open"
156
+
157
+
158
+ def probe_target(
159
+ target: Target,
160
+ ping_status: Status,
161
+ ping_latency_ms: float | None,
162
+ timeout_s: float,
163
+ on_phase: PhaseCallback | None = None,
164
+ ) -> CheckResult:
165
+ """Run whatever follow-up check `target.kind` calls for (HTTP, a TCP
166
+ connect, or nothing for a ping-only target). Ping is assumed to have
167
+ already been done — see `run_pings` for why it's handled separately."""
168
+ checked_at = time.time()
169
+
170
+ http_status = http_code = http_latency_ms = None
171
+ tcp_status = tcp_latency_ms = None
172
+ error_kind: ErrorKind = None
173
+
174
+ if target.kind == TargetKind.HTTP:
175
+ assert target.url is not None
176
+ if on_phase:
177
+ on_phase("http")
178
+ http_status, http_code, http_latency_ms, error_kind, detail = http_check(target.url, timeout_s)
179
+ elif target.kind == TargetKind.TCP_PORT:
180
+ assert target.port is not None
181
+ if on_phase:
182
+ on_phase("tcp")
183
+ tcp_status, tcp_latency_ms, error_kind, detail = tcp_check(target.hostname, target.port, timeout_s)
184
+ else:
185
+ detail = "ping-only target (no scheme or port given)"
186
+
187
+ if ping_status == Status.DOWN and (http_status == Status.DOWN or tcp_status == Status.DOWN):
188
+ detail = "host did not respond to ping, and " + detail
189
+
190
+ return CheckResult(
191
+ target=target,
192
+ ping_status=ping_status,
193
+ ping_latency_ms=ping_latency_ms,
194
+ http_status=http_status,
195
+ http_code=http_code,
196
+ http_latency_ms=http_latency_ms,
197
+ tcp_status=tcp_status,
198
+ tcp_latency_ms=tcp_latency_ms,
199
+ error_kind=error_kind,
200
+ detail=detail,
201
+ checked_at=checked_at,
202
+ )
isitup/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console, RenderableType
8
+ from rich.live import Live
9
+
10
+ from .config import Target, config_mtime, load_targets, reload_if_changed
11
+ from .history import HistoryStore
12
+ from .output import compute_exit_code, format_json_line, format_plain_line, run_checks
13
+ from .render import build_table, countdown, framed, is_online, run_round, waiting_placeholder
14
+
15
+
16
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser(
18
+ prog="isitup",
19
+ description="Periodically check whether servers respond to ping, HTTP, and TCP ports.",
20
+ )
21
+ parser.add_argument(
22
+ "-c",
23
+ "--config",
24
+ type=Path,
25
+ default=None,
26
+ help="path to a YAML config file listing targets (see config.example.yaml); "
27
+ "reloaded automatically whenever it changes",
28
+ )
29
+ parser.add_argument(
30
+ "-u",
31
+ "--url",
32
+ action="append",
33
+ default=[],
34
+ dest="urls",
35
+ help="a target to monitor; can be passed multiple times. A bare hostname "
36
+ "('example.com') is ping-only, 'hostname:port' also does a TCP connect check, "
37
+ "and a URL with a scheme ('https://example.com') also does an HTTP check",
38
+ )
39
+ parser.add_argument(
40
+ "-i",
41
+ "--interval",
42
+ type=float,
43
+ default=30.0,
44
+ help="seconds between check rounds (default: 30)",
45
+ )
46
+ parser.add_argument(
47
+ "--ping-timeout",
48
+ type=float,
49
+ default=2.0,
50
+ help="seconds to wait for a ping reply (default: 2)",
51
+ )
52
+ parser.add_argument(
53
+ "--http-timeout",
54
+ type=float,
55
+ default=5.0,
56
+ help="seconds to wait for an HTTP response or TCP connection (default: 5)",
57
+ )
58
+ parser.add_argument(
59
+ "--no-ping",
60
+ action="store_true",
61
+ help="skip ICMP ping entirely and only perform HTTP/TCP checks "
62
+ "(useful when targets sit behind firewalls that drop ICMP)",
63
+ )
64
+ parser.add_argument(
65
+ "--once",
66
+ action="store_true",
67
+ help="run a single check round and exit instead of looping — "
68
+ "exit code is 1 if any target is down, 0 otherwise",
69
+ )
70
+ output_group = parser.add_mutually_exclusive_group()
71
+ output_group.add_argument(
72
+ "--plain",
73
+ action="store_true",
74
+ help="print one plain-text line per target per round instead of the live UI (for logging/piping)",
75
+ )
76
+ output_group.add_argument(
77
+ "--json",
78
+ action="store_true",
79
+ dest="json_output",
80
+ help="print one JSON object per target per round instead of the live UI (for machine consumption)",
81
+ )
82
+ return parser.parse_args(argv)
83
+
84
+
85
+ def run_headless(
86
+ console: Console,
87
+ args: argparse.Namespace,
88
+ targets: list[Target],
89
+ ping_enabled: bool,
90
+ history: HistoryStore,
91
+ ) -> int:
92
+ emit = format_json_line if args.json_output else format_plain_line
93
+ config_path = args.config
94
+ mtime = config_mtime(config_path)
95
+
96
+ while True:
97
+ results = run_checks(targets, ping_enabled, args.ping_timeout, args.http_timeout)
98
+ history.update(results, {r.target.raw: is_online(r) for r in results})
99
+ for result in results:
100
+ print(emit(result, history), flush=True)
101
+
102
+ if args.once:
103
+ return compute_exit_code(results)
104
+
105
+ reload = reload_if_changed(config_path, args.urls, mtime)
106
+ mtime = reload.mtime
107
+ if reload.error:
108
+ console.print(f"[yellow]warning:[/yellow] config reload failed: {reload.error}")
109
+ if reload.targets is not None:
110
+ targets = reload.targets
111
+
112
+ time.sleep(args.interval)
113
+
114
+
115
+ def run_interactive(
116
+ console: Console,
117
+ args: argparse.Namespace,
118
+ targets: list[Target],
119
+ ping_enabled: bool,
120
+ history: HistoryStore,
121
+ ) -> int:
122
+ if args.once:
123
+ with Live(console=console, refresh_per_second=12, transient=True) as live:
124
+ results = run_round(
125
+ console,
126
+ targets,
127
+ ping_enabled,
128
+ args.ping_timeout,
129
+ args.http_timeout,
130
+ live,
131
+ waiting_placeholder(),
132
+ )
133
+ history.update(results, {r.target.raw: is_online(r) for r in results})
134
+ console.print(framed(build_table(results, history)))
135
+ return compute_exit_code(results)
136
+
137
+ config_path = args.config
138
+ mtime = config_mtime(config_path)
139
+ base: RenderableType = waiting_placeholder()
140
+ try:
141
+ with Live(console=console, refresh_per_second=12) as live:
142
+ live.update(framed(base))
143
+ while True:
144
+ results = run_round(
145
+ console,
146
+ targets,
147
+ ping_enabled,
148
+ args.ping_timeout,
149
+ args.http_timeout,
150
+ live,
151
+ base,
152
+ )
153
+ history.update(results, {r.target.raw: is_online(r) for r in results})
154
+ base = build_table(results, history)
155
+ live.update(framed(base))
156
+
157
+ reload = reload_if_changed(config_path, args.urls, mtime)
158
+ mtime = reload.mtime
159
+ if reload.error:
160
+ console.print(f"[yellow]warning:[/yellow] config reload failed: {reload.error}")
161
+ if reload.targets is not None:
162
+ targets = reload.targets
163
+
164
+ countdown(console, live, base, args.interval)
165
+ except KeyboardInterrupt:
166
+ console.print("\n[dim]stopped[/dim]")
167
+
168
+ return 0
169
+
170
+
171
+ def main(argv: list[str] | None = None) -> int:
172
+ args = parse_args(argv)
173
+ console = Console()
174
+
175
+ try:
176
+ targets = load_targets(args.config, args.urls)
177
+ except (FileNotFoundError, ValueError) as exc:
178
+ console.print(f"[bold red]error:[/bold red] {exc}")
179
+ return 1
180
+
181
+ ping_enabled = not args.no_ping
182
+ history = HistoryStore()
183
+
184
+ if args.plain or args.json_output:
185
+ return run_headless(console, args, targets, ping_enabled, history)
186
+ return run_interactive(console, args, targets, ping_enabled, history)
187
+
188
+
189
+ if __name__ == "__main__":
190
+ raise SystemExit(main())
isitup/config.py ADDED
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+ from urllib.parse import urlparse
7
+
8
+ import yaml
9
+
10
+
11
+ class TargetKind(StrEnum):
12
+ PING_ONLY = "ping_only" # bare hostname, e.g. "example.com"
13
+ HTTP = "http" # has a scheme, e.g. "https://example.com"
14
+ TCP_PORT = "tcp_port" # bare hostname:port, e.g. "example.com:1337"
15
+
16
+
17
+ @dataclass
18
+ class Target:
19
+ """A single server to monitor.
20
+
21
+ What gets checked besides ping is derived from how it was written:
22
+ a bare hostname is ping-only, a URL with a scheme also gets an HTTP
23
+ check, and a bare "hostname:port" also gets a TCP connect check.
24
+ """
25
+
26
+ raw: str
27
+ hostname: str
28
+ kind: TargetKind
29
+ url: str | None = None # set when kind is HTTP
30
+ port: int | None = None # set when kind is TCP_PORT
31
+ name: str | None = None
32
+ host: str | None = None
33
+ ping: bool = True
34
+
35
+ @property
36
+ def display_name(self) -> str:
37
+ return self.name or self.raw
38
+
39
+ @property
40
+ def ping_host(self) -> str:
41
+ return self.host or self.hostname
42
+
43
+
44
+ def parse_target_string(
45
+ raw: str,
46
+ *,
47
+ name: str | None = None,
48
+ host: str | None = None,
49
+ ping: bool = True,
50
+ ) -> Target:
51
+ if "://" in raw:
52
+ hostname = urlparse(raw).hostname
53
+ if not hostname:
54
+ raise ValueError(f"could not determine a hostname from '{raw}'")
55
+ return Target(
56
+ raw=raw, hostname=hostname, kind=TargetKind.HTTP, url=raw, name=name, host=host, ping=ping
57
+ )
58
+
59
+ parsed = urlparse(f"//{raw}")
60
+ hostname = parsed.hostname
61
+ if not hostname:
62
+ raise ValueError(f"could not determine a hostname from '{raw}'")
63
+ if parsed.path or parsed.query or parsed.fragment:
64
+ raise ValueError(
65
+ f"'{raw}' has a path/query but no scheme — did you mean http://{raw} or https://{raw}?"
66
+ )
67
+
68
+ if parsed.port is not None:
69
+ target = Target(
70
+ raw=raw,
71
+ hostname=hostname,
72
+ kind=TargetKind.TCP_PORT,
73
+ port=parsed.port,
74
+ name=name,
75
+ host=host,
76
+ ping=ping,
77
+ )
78
+ else:
79
+ target = Target(
80
+ raw=raw, hostname=hostname, kind=TargetKind.PING_ONLY, name=name, host=host, ping=ping
81
+ )
82
+
83
+ if not target.ping and target.kind == TargetKind.PING_ONLY:
84
+ raise ValueError(
85
+ f"target '{raw}' has ping disabled but is a bare hostname with no scheme or port — "
86
+ "there would be nothing left to check"
87
+ )
88
+
89
+ return target
90
+
91
+
92
+ def _target_from_dict(raw: dict) -> Target:
93
+ if "url" not in raw:
94
+ raise ValueError(f"target entry is missing required 'url' field: {raw!r}")
95
+ return parse_target_string(
96
+ raw["url"],
97
+ name=raw.get("name"),
98
+ host=raw.get("host"),
99
+ ping=raw.get("ping", True),
100
+ )
101
+
102
+
103
+ def load_targets(config_path: Path | None, cli_urls: list[str]) -> list[Target]:
104
+ """Combine targets from a YAML config file and/or CLI-supplied URLs."""
105
+ targets: list[Target] = []
106
+
107
+ if config_path is not None:
108
+ if not config_path.exists():
109
+ raise FileNotFoundError(f"config file not found: {config_path}")
110
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
111
+ raw_targets = data.get("targets", [])
112
+ if not isinstance(raw_targets, list):
113
+ raise ValueError("config 'targets' must be a list")
114
+ targets.extend(_target_from_dict(item) for item in raw_targets)
115
+
116
+ targets.extend(parse_target_string(url) for url in cli_urls)
117
+
118
+ if not targets:
119
+ raise ValueError("no targets configured — pass --url one or more times, or provide a --config file")
120
+
121
+ return targets
122
+
123
+
124
+ def config_mtime(config_path: Path | None) -> float | None:
125
+ if config_path is None:
126
+ return None
127
+ try:
128
+ return config_path.stat().st_mtime
129
+ except OSError:
130
+ return None
131
+
132
+
133
+ @dataclass
134
+ class ReloadResult:
135
+ """Result of checking whether the config file changed since `last_mtime`.
136
+
137
+ `targets` is None when nothing changed (or reload failed) — callers
138
+ should keep using whatever target list they already have.
139
+ """
140
+
141
+ targets: list[Target] | None
142
+ mtime: float | None
143
+ error: str | None = None
144
+
145
+
146
+ def reload_if_changed(
147
+ config_path: Path | None, cli_urls: list[str], last_mtime: float | None
148
+ ) -> ReloadResult:
149
+ current = config_mtime(config_path)
150
+ if config_path is None or current == last_mtime:
151
+ return ReloadResult(targets=None, mtime=last_mtime)
152
+
153
+ try:
154
+ targets = load_targets(config_path, cli_urls)
155
+ except (FileNotFoundError, ValueError) as exc:
156
+ # Update mtime even on failure so a still-broken file isn't retried
157
+ # every round — only a further edit triggers another attempt.
158
+ return ReloadResult(targets=None, mtime=current, error=str(exc))
159
+
160
+ return ReloadResult(targets=targets, mtime=current)
isitup/history.py ADDED
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from dataclasses import dataclass, field
5
+
6
+ from .checks import CheckResult
7
+
8
+ MAX_SAMPLES = 20
9
+
10
+ SPARK_BLOCKS = "▁▂▃▄▅▆▇█"
11
+
12
+
13
+ @dataclass
14
+ class TargetHistory:
15
+ latencies: deque[float] = field(default_factory=lambda: deque(maxlen=MAX_SAMPLES))
16
+ last_online: float | None = None
17
+ last_offline: float | None = None
18
+
19
+ def record(self, result: CheckResult, is_online: bool) -> None:
20
+ if result.latency_ms is not None:
21
+ self.latencies.append(result.latency_ms)
22
+ if is_online:
23
+ self.last_online = result.checked_at
24
+ else:
25
+ self.last_offline = result.checked_at
26
+
27
+ @property
28
+ def min_latency(self) -> float | None:
29
+ return min(self.latencies) if self.latencies else None
30
+
31
+ @property
32
+ def max_latency(self) -> float | None:
33
+ return max(self.latencies) if self.latencies else None
34
+
35
+ @property
36
+ def sparkline(self) -> str:
37
+ return sparkline(self.latencies)
38
+
39
+
40
+ def sparkline(values: deque[float] | list[float]) -> str:
41
+ """A tiny one-line chart of recent values using unicode block characters."""
42
+ if not values:
43
+ return ""
44
+ lo, hi = min(values), max(values)
45
+ if hi == lo:
46
+ mid = SPARK_BLOCKS[len(SPARK_BLOCKS) // 2]
47
+ return mid * len(values)
48
+ span = hi - lo
49
+ scale = len(SPARK_BLOCKS) - 1
50
+ return "".join(SPARK_BLOCKS[int((v - lo) / span * scale)] for v in values)
51
+
52
+
53
+ class HistoryStore:
54
+ def __init__(self) -> None:
55
+ self._by_target: dict[str, TargetHistory] = {}
56
+
57
+ def get(self, target_key: str) -> TargetHistory:
58
+ return self._by_target.setdefault(target_key, TargetHistory())
59
+
60
+ def update(self, results: list[CheckResult], is_online: dict[str, bool]) -> None:
61
+ for result in results:
62
+ self.get(result.target.raw).record(result, is_online[result.target.raw])