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 +3 -0
- isitup/checks.py +202 -0
- isitup/cli.py +190 -0
- isitup/config.py +160 -0
- isitup/history.py +62 -0
- isitup/output.py +112 -0
- isitup/render.py +342 -0
- isitup_cli-0.1.0.dist-info/METADATA +186 -0
- isitup_cli-0.1.0.dist-info/RECORD +12 -0
- isitup_cli-0.1.0.dist-info/WHEEL +4 -0
- isitup_cli-0.1.0.dist-info/entry_points.txt +3 -0
- isitup_cli-0.1.0.dist-info/licenses/LICENSE +674 -0
isitup/__init__.py
ADDED
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])
|