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/output.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from .checks import CheckResult, Status, ping_status_for, probe_target, run_pings
|
|
8
|
+
from .config import Target, TargetKind
|
|
9
|
+
from .history import HistoryStore
|
|
10
|
+
from .render import fmt_ms, overall_status, service_label
|
|
11
|
+
|
|
12
|
+
FIELD_WIDTHS = {"status": 8, "target": 30, "ping": 7, "service": 12}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_checks(
|
|
16
|
+
targets: list[Target], ping_enabled: bool, ping_timeout: float, http_timeout: float
|
|
17
|
+
) -> list[CheckResult]:
|
|
18
|
+
"""Run checks for all targets — without any progress UI, used for
|
|
19
|
+
--plain/--json output where nothing should be printed except results.
|
|
20
|
+
|
|
21
|
+
Ping each unique host once (targets sharing a host share a ping), then
|
|
22
|
+
run each target's own HTTP/TCP probe concurrently.
|
|
23
|
+
"""
|
|
24
|
+
ping_results = run_pings(targets, ping_enabled, ping_timeout)
|
|
25
|
+
|
|
26
|
+
with ThreadPoolExecutor(max_workers=min(len(targets), 16) or 1) as pool:
|
|
27
|
+
futures = {}
|
|
28
|
+
for target in targets:
|
|
29
|
+
ping_status, ping_latency_ms = ping_status_for(target, ping_enabled, ping_results)
|
|
30
|
+
futures[pool.submit(probe_target, target, ping_status, ping_latency_ms, http_timeout)] = target
|
|
31
|
+
results = [future.result() for future in futures]
|
|
32
|
+
|
|
33
|
+
order = {target.raw: i for i, target in enumerate(targets)}
|
|
34
|
+
results.sort(key=lambda r: order[r.target.raw])
|
|
35
|
+
return results
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compute_exit_code(results: list[CheckResult]) -> int:
|
|
39
|
+
return 1 if any(overall_status(r)[0] == Status.DOWN for r in results) else 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _iso(value: float | None) -> str | None:
|
|
43
|
+
return datetime.fromtimestamp(value).isoformat() if value is not None else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def result_to_dict(result: CheckResult, history: HistoryStore) -> dict:
|
|
47
|
+
_, status_label = overall_status(result)
|
|
48
|
+
target_history = history.get(result.target.raw)
|
|
49
|
+
return {
|
|
50
|
+
"target": result.target.display_name,
|
|
51
|
+
"url": result.target.url,
|
|
52
|
+
"hostname": result.target.hostname,
|
|
53
|
+
"status": status_label,
|
|
54
|
+
"ping": {
|
|
55
|
+
"status": result.ping_status.value,
|
|
56
|
+
"latency_ms": result.ping_latency_ms,
|
|
57
|
+
},
|
|
58
|
+
"http": (
|
|
59
|
+
{
|
|
60
|
+
"status": result.http_status.value if result.http_status else None,
|
|
61
|
+
"code": result.http_code,
|
|
62
|
+
"latency_ms": result.http_latency_ms,
|
|
63
|
+
"error_kind": result.error_kind,
|
|
64
|
+
}
|
|
65
|
+
if result.target.kind == TargetKind.HTTP
|
|
66
|
+
else None
|
|
67
|
+
),
|
|
68
|
+
"tcp": (
|
|
69
|
+
{
|
|
70
|
+
"status": result.tcp_status.value if result.tcp_status else None,
|
|
71
|
+
"port": result.target.port,
|
|
72
|
+
"latency_ms": result.tcp_latency_ms,
|
|
73
|
+
"error_kind": result.error_kind,
|
|
74
|
+
}
|
|
75
|
+
if result.target.kind == TargetKind.TCP_PORT
|
|
76
|
+
else None
|
|
77
|
+
),
|
|
78
|
+
"min_latency_ms": target_history.min_latency,
|
|
79
|
+
"max_latency_ms": target_history.max_latency,
|
|
80
|
+
"last_online": _iso(target_history.last_online),
|
|
81
|
+
"last_offline": _iso(target_history.last_offline),
|
|
82
|
+
"detail": result.detail,
|
|
83
|
+
"checked_at": _iso(result.checked_at),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_json_line(result: CheckResult, history: HistoryStore) -> str:
|
|
88
|
+
return json.dumps(result_to_dict(result, history))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def format_plain_line(result: CheckResult, history: HistoryStore) -> str:
|
|
92
|
+
_, status_label = overall_status(result)
|
|
93
|
+
target_history = history.get(result.target.raw)
|
|
94
|
+
checked = datetime.fromtimestamp(result.checked_at).strftime("%Y-%m-%d %H:%M:%S")
|
|
95
|
+
|
|
96
|
+
service_field = ""
|
|
97
|
+
if result.target.kind == TargetKind.HTTP:
|
|
98
|
+
service_field = f"http={service_label(result):<{FIELD_WIDTHS['service']}} "
|
|
99
|
+
elif result.target.kind == TargetKind.TCP_PORT:
|
|
100
|
+
service_field = f"tcp={service_label(result):<{FIELD_WIDTHS['service']}} "
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
f"{checked} "
|
|
104
|
+
f"{status_label:<{FIELD_WIDTHS['status']}} "
|
|
105
|
+
f"{result.target.display_name:<{FIELD_WIDTHS['target']}} "
|
|
106
|
+
f"ping={result.ping_status.value:<{FIELD_WIDTHS['ping']}} "
|
|
107
|
+
f"{service_field}"
|
|
108
|
+
f"latency={fmt_ms(result.latency_ms):>8} "
|
|
109
|
+
f"min={fmt_ms(target_history.min_latency):>8} "
|
|
110
|
+
f"max={fmt_ms(target_history.max_latency):>8} "
|
|
111
|
+
f'detail="{result.detail}"'
|
|
112
|
+
)
|
isitup/render.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from textwrap import dedent
|
|
7
|
+
|
|
8
|
+
from rich.align import Align
|
|
9
|
+
from rich.console import Console, Group, RenderableType
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TextColumn
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from .checks import CheckResult, Status, ping_status_for, probe_target, run_pings
|
|
16
|
+
from .config import Target, TargetKind
|
|
17
|
+
from .history import HistoryStore
|
|
18
|
+
|
|
19
|
+
WORDMARK_MARKUP = (
|
|
20
|
+
"[#DBDBDA] ██ ██ ██ [/] [#00E359] [/]\n"
|
|
21
|
+
"[#DBDBDA] ██ [/] [#00E359] [/]\n"
|
|
22
|
+
"[#DBDBDA] ▀██ ▄█▀▀ ▀██ ▀▀██▀▀ [/] [#00E359]██ ██ ██▀█▄ [/]\n"
|
|
23
|
+
"[#DBDBDA] ██ ▀█▄▄ ██ ██ [/] [#00E359]██ ██ ██ ██ [/]\n"
|
|
24
|
+
"[#DBDBDA] ██ ██ ██ ██▄ [/] [#00E359]██ ██ ██▀▀ [/]\n"
|
|
25
|
+
"[#DBDBDA] ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀ [/] [#00E359] ▀▀▀▀ ██ [/]\n"
|
|
26
|
+
"[#00E359] ⬤───────────────────────╱╲╱────────────⬤ [/]\n"
|
|
27
|
+
)
|
|
28
|
+
WORDMARK = Text.from_markup(WORDMARK_MARKUP)
|
|
29
|
+
WORDMARK.no_wrap = True
|
|
30
|
+
|
|
31
|
+
MASCOT_MARKUP = dedent(r"""
|
|
32
|
+
[#A8AAA8]╭────╮[/]
|
|
33
|
+
[#A8AAA8]╭───╯ ╰──╮[/]
|
|
34
|
+
[#A8AAA8]╭╯[/] [#00E359]↑[/] [#FF4D3D]↓[/] [#A8AAA8]╰╮[/]
|
|
35
|
+
[#A8AAA8]╰─────┬────────╯[/]
|
|
36
|
+
[#00E359]│[/]
|
|
37
|
+
[#A8AAA8]╭─┬─╮ ╭────┴──────────╮ ╭───────╮[/]
|
|
38
|
+
[#A8AAA8]╭─┼─┼─┼─╮─────│[/] [#A8AAA8]│[/][#00E359]───╱╲╱───[/][#A8AAA8]┤[/] [#00E359]●[/] [#FFD43B]●[/] [#A8AAA8]│[/]
|
|
39
|
+
[#A8AAA8]├─┼─┼─┼─┤ │[/] [#00E359]⌃ ⌃[/] [#A8AAA8]│ ├───────┤[/]
|
|
40
|
+
[#A8AAA8]╰─┼─┼─┼─╯ │[/] [#00E359]• • •[/] [#A8AAA8]│ │[/] [#00E359]●[/] [#FFD43B]●[/] [#A8AAA8]│[/]
|
|
41
|
+
[#A8AAA8]╰─┴─╯ ╰───────────────╯ ├───────┤[/]
|
|
42
|
+
[#A8AAA8]╱ ╲ │[/] [#FFD43B]●[/] [#FF4D3D]●[/] [#A8AAA8]│[/]
|
|
43
|
+
[#A8AAA8]╭─╯ ╰─╮ ╰───────╯[/]
|
|
44
|
+
[#A8AAA8]╰────┐ ┌────╯[/]
|
|
45
|
+
[#A8AAA8]╰───╯[/]
|
|
46
|
+
""").strip("\n")
|
|
47
|
+
MASCOT = Text.from_markup(MASCOT_MARKUP)
|
|
48
|
+
MASCOT.no_wrap = True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _header(show_mascot: bool) -> RenderableType:
|
|
52
|
+
"""Mascot on the left, wordmark vertically centered against it, the
|
|
53
|
+
pair centered as one lockup in the available width. Once real results
|
|
54
|
+
have rendered once, drop the mascot and keep just the wordmark."""
|
|
55
|
+
if not show_mascot:
|
|
56
|
+
return Align.center(WORDMARK)
|
|
57
|
+
grid = Table.grid(padding=(0, 2, 0, 0))
|
|
58
|
+
grid.add_column()
|
|
59
|
+
grid.add_column(vertical="middle")
|
|
60
|
+
grid.add_row(MASCOT, WORDMARK)
|
|
61
|
+
return Align.center(grid)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def framed(base: RenderableType, *rest: RenderableType) -> Group:
|
|
65
|
+
"""Prefix `base` (plus any extra renderables, e.g. a progress bar) with
|
|
66
|
+
a blank line, the header, and another blank line. `base` is also what's
|
|
67
|
+
being framed, so it doubles as the signal for whether a results table
|
|
68
|
+
has ever been shown yet: while it's still the waiting placeholder, the
|
|
69
|
+
header includes the mascot; once it's an actual results table, later
|
|
70
|
+
calls (even mid-round, with a stale table as `base`) show wordmark only.
|
|
71
|
+
"""
|
|
72
|
+
show_mascot = not isinstance(base, Table)
|
|
73
|
+
return Group("", _header(show_mascot), "", base, *rest)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
STATUS_STYLE = {
|
|
77
|
+
Status.OK: "bold green",
|
|
78
|
+
Status.WARN: "bold yellow",
|
|
79
|
+
Status.DOWN: "bold red",
|
|
80
|
+
Status.UNKNOWN: "dim",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
STATUS_LABEL = {
|
|
84
|
+
Status.OK: "UP",
|
|
85
|
+
Status.WARN: "WARN",
|
|
86
|
+
Status.DOWN: "DOWN",
|
|
87
|
+
Status.UNKNOWN: "-",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
ERROR_KIND_LABEL = {
|
|
91
|
+
"dns": "DNS",
|
|
92
|
+
"tls": "TLS",
|
|
93
|
+
"redirect": "REDIRECT",
|
|
94
|
+
"timeout": "TIMEOUT",
|
|
95
|
+
"connection": "CONN",
|
|
96
|
+
"request_error": "ERROR",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
PHASE_LABEL = {
|
|
100
|
+
"queued": "queued",
|
|
101
|
+
"ping": "pinging…",
|
|
102
|
+
"http": "http check…",
|
|
103
|
+
"tcp": "checking port…",
|
|
104
|
+
"done": "done",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def overall_status(result: CheckResult) -> tuple[Status, str]:
|
|
109
|
+
"""A host that blocks ICMP but serves its actual service fine is still
|
|
110
|
+
up — the probe (HTTP/TCP) takes priority over ping, which is just extra
|
|
111
|
+
diagnostic info. Ping-only targets have no probe, so ping *is* the signal."""
|
|
112
|
+
target = result.target
|
|
113
|
+
|
|
114
|
+
if target.kind == TargetKind.PING_ONLY:
|
|
115
|
+
if result.ping_status == Status.OK:
|
|
116
|
+
return Status.OK, "UP"
|
|
117
|
+
if result.ping_status == Status.DOWN:
|
|
118
|
+
return Status.DOWN, "OFFLINE"
|
|
119
|
+
return Status.UNKNOWN, "NO CHECK"
|
|
120
|
+
|
|
121
|
+
if target.kind == TargetKind.TCP_PORT:
|
|
122
|
+
if result.tcp_status == Status.OK:
|
|
123
|
+
return Status.OK, "UP"
|
|
124
|
+
if result.ping_status == Status.DOWN:
|
|
125
|
+
return Status.DOWN, "OFFLINE"
|
|
126
|
+
return Status.DOWN, "PORT DOWN"
|
|
127
|
+
|
|
128
|
+
if result.http_status == Status.OK:
|
|
129
|
+
return Status.OK, "UP"
|
|
130
|
+
if result.http_status == Status.WARN:
|
|
131
|
+
return Status.WARN, "WARN"
|
|
132
|
+
if result.ping_status == Status.DOWN:
|
|
133
|
+
return Status.DOWN, "OFFLINE"
|
|
134
|
+
return Status.DOWN, "HTTP DOWN"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def is_online(result: CheckResult) -> bool:
|
|
138
|
+
return overall_status(result)[0] != Status.DOWN
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def fmt_ms(value: float | None) -> str:
|
|
142
|
+
return f"{value:.0f} ms" if value is not None else "-"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def fmt_time(value: float | None) -> str:
|
|
146
|
+
return datetime.fromtimestamp(value).strftime("%H:%M:%S") if value is not None else "never"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _error_label(error_kind: str) -> str:
|
|
150
|
+
return ERROR_KIND_LABEL.get(error_kind, error_kind.upper())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def service_label(result: CheckResult) -> str:
|
|
154
|
+
"""Label for whichever probe applies to this target (HTTP or TCP) — the
|
|
155
|
+
interactive UI shows both in the same column since a target only ever
|
|
156
|
+
has one or the other."""
|
|
157
|
+
target = result.target
|
|
158
|
+
|
|
159
|
+
if target.kind == TargetKind.HTTP:
|
|
160
|
+
label = STATUS_LABEL[result.http_status] if result.http_status else STATUS_LABEL[Status.UNKNOWN]
|
|
161
|
+
if result.http_code is not None:
|
|
162
|
+
return f"{label} ({result.http_code})"
|
|
163
|
+
if result.error_kind:
|
|
164
|
+
return f"{label} ({_error_label(result.error_kind)})"
|
|
165
|
+
return label
|
|
166
|
+
|
|
167
|
+
if target.kind == TargetKind.TCP_PORT:
|
|
168
|
+
label = STATUS_LABEL[result.tcp_status] if result.tcp_status else STATUS_LABEL[Status.UNKNOWN]
|
|
169
|
+
if result.tcp_status == Status.DOWN and result.error_kind:
|
|
170
|
+
return f"{label} ({_error_label(result.error_kind)})"
|
|
171
|
+
return f"{label} ({target.port})"
|
|
172
|
+
|
|
173
|
+
return "-"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def service_style(result: CheckResult) -> str:
|
|
177
|
+
target = result.target
|
|
178
|
+
if target.kind == TargetKind.HTTP:
|
|
179
|
+
return STATUS_STYLE[result.http_status or Status.UNKNOWN]
|
|
180
|
+
if target.kind == TargetKind.TCP_PORT:
|
|
181
|
+
return STATUS_STYLE[result.tcp_status or Status.UNKNOWN]
|
|
182
|
+
return STATUS_STYLE[Status.UNKNOWN]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def build_table(results: list[CheckResult], history: HistoryStore) -> Table:
|
|
186
|
+
table = Table(expand=True)
|
|
187
|
+
table.add_column("Target")
|
|
188
|
+
table.add_column("Status", justify="center")
|
|
189
|
+
table.add_column("Ping", justify="center")
|
|
190
|
+
table.add_column("Service", justify="center")
|
|
191
|
+
table.add_column("Latency", justify="right")
|
|
192
|
+
table.add_column("Min", justify="right")
|
|
193
|
+
table.add_column("Max", justify="right")
|
|
194
|
+
table.add_column("Trend", justify="left")
|
|
195
|
+
table.add_column("Last Online", justify="right")
|
|
196
|
+
table.add_column("Last Offline", justify="right")
|
|
197
|
+
table.add_column("Detail")
|
|
198
|
+
table.add_column("Checked", justify="right")
|
|
199
|
+
|
|
200
|
+
for result in results:
|
|
201
|
+
status, status_label = overall_status(result)
|
|
202
|
+
status_style = STATUS_STYLE[status]
|
|
203
|
+
|
|
204
|
+
ping_label = STATUS_LABEL[result.ping_status]
|
|
205
|
+
if result.ping_status == Status.OK and result.ping_latency_ms is not None:
|
|
206
|
+
ping_label += f" {result.ping_latency_ms:.0f}ms"
|
|
207
|
+
ping_style = STATUS_STYLE[result.ping_status]
|
|
208
|
+
|
|
209
|
+
target_history = history.get(result.target.raw)
|
|
210
|
+
|
|
211
|
+
table.add_row(
|
|
212
|
+
result.target.display_name,
|
|
213
|
+
f"[{status_style}]{status_label}[/{status_style}]",
|
|
214
|
+
f"[{ping_style}]{ping_label}[/{ping_style}]",
|
|
215
|
+
f"[{service_style(result)}]{service_label(result)}[/{service_style(result)}]",
|
|
216
|
+
fmt_ms(result.latency_ms),
|
|
217
|
+
fmt_ms(target_history.min_latency),
|
|
218
|
+
fmt_ms(target_history.max_latency),
|
|
219
|
+
target_history.sparkline or "-",
|
|
220
|
+
fmt_time(target_history.last_online),
|
|
221
|
+
fmt_time(target_history.last_offline),
|
|
222
|
+
result.detail,
|
|
223
|
+
fmt_time(result.checked_at),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return table
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def waiting_placeholder() -> Text:
|
|
230
|
+
return Text("waiting for first check…", style="dim")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _probe_phase(target: Target) -> str:
|
|
234
|
+
if target.kind == TargetKind.HTTP:
|
|
235
|
+
return "http"
|
|
236
|
+
if target.kind == TargetKind.TCP_PORT:
|
|
237
|
+
return "tcp"
|
|
238
|
+
return "done"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def run_round(
|
|
242
|
+
console: Console,
|
|
243
|
+
targets: list[Target],
|
|
244
|
+
ping_enabled: bool,
|
|
245
|
+
ping_timeout: float,
|
|
246
|
+
http_timeout: float,
|
|
247
|
+
live: Live,
|
|
248
|
+
base: RenderableType,
|
|
249
|
+
) -> list[CheckResult]:
|
|
250
|
+
"""Run checks for all targets in two phases: first ping every unique
|
|
251
|
+
host once (targets pointing at the same host share a ping), then run
|
|
252
|
+
each target's own follow-up probe (HTTP/TCP) concurrently. A live
|
|
253
|
+
spinner and progress bar per target track both phases, rendered above
|
|
254
|
+
whatever `base` (the previous results table) currently is."""
|
|
255
|
+
progress = Progress(
|
|
256
|
+
SpinnerColumn(finished_text="[bold green]✓[/bold green]"),
|
|
257
|
+
TextColumn("[bold]{task.fields[name]}[/bold]", justify="left"),
|
|
258
|
+
BarColumn(bar_width=24),
|
|
259
|
+
TextColumn("{task.fields[phase]}"),
|
|
260
|
+
console=console,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
task_ids: dict[str, TaskID] = {
|
|
264
|
+
target.raw: progress.add_task("", total=100, name=target.display_name, phase=PHASE_LABEL["queued"])
|
|
265
|
+
for target in targets
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
def wants_ping(target: Target) -> bool:
|
|
269
|
+
return target.ping and ping_enabled
|
|
270
|
+
|
|
271
|
+
# Phase 1: ping each unique host once.
|
|
272
|
+
pinging = [t for t in targets if wants_ping(t)]
|
|
273
|
+
for target in pinging:
|
|
274
|
+
progress.update(task_ids[target.raw], phase=PHASE_LABEL["ping"])
|
|
275
|
+
|
|
276
|
+
ping_results: dict[str, tuple[Status, float | None]] = {}
|
|
277
|
+
if pinging:
|
|
278
|
+
# run_pings already dedups by host and pings concurrently internally;
|
|
279
|
+
# wrap it in one future purely so we can poll for progress animation.
|
|
280
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
281
|
+
future = pool.submit(run_pings, pinging, True, ping_timeout)
|
|
282
|
+
start = time.monotonic()
|
|
283
|
+
while not future.done():
|
|
284
|
+
pct = min((time.monotonic() - start) / ping_timeout, 1.0) * 100 if ping_timeout else 100
|
|
285
|
+
for target in pinging:
|
|
286
|
+
progress.update(task_ids[target.raw], completed=pct)
|
|
287
|
+
live.update(framed(base, progress))
|
|
288
|
+
time.sleep(0.05)
|
|
289
|
+
ping_results = future.result()
|
|
290
|
+
|
|
291
|
+
for target in pinging:
|
|
292
|
+
progress.update(task_ids[target.raw], completed=100)
|
|
293
|
+
|
|
294
|
+
# Phase 2: each target's own HTTP/TCP probe (or nothing, for ping-only).
|
|
295
|
+
for target in targets:
|
|
296
|
+
progress.update(task_ids[target.raw], completed=0, phase=PHASE_LABEL[_probe_phase(target)])
|
|
297
|
+
live.update(framed(base, progress))
|
|
298
|
+
|
|
299
|
+
results: list[CheckResult] = []
|
|
300
|
+
with ThreadPoolExecutor(max_workers=min(len(targets), 16) or 1) as pool:
|
|
301
|
+
futures = {}
|
|
302
|
+
for target in targets:
|
|
303
|
+
ping_status, ping_latency_ms = ping_status_for(target, ping_enabled, ping_results)
|
|
304
|
+
futures[pool.submit(probe_target, target, ping_status, ping_latency_ms, http_timeout)] = target
|
|
305
|
+
|
|
306
|
+
start = time.monotonic()
|
|
307
|
+
probing = [t for t in targets if t.kind != TargetKind.PING_ONLY]
|
|
308
|
+
while not all(f.done() for f in futures):
|
|
309
|
+
pct = min((time.monotonic() - start) / http_timeout, 1.0) * 100 if http_timeout else 100
|
|
310
|
+
for target in probing:
|
|
311
|
+
progress.update(task_ids[target.raw], completed=pct)
|
|
312
|
+
live.update(framed(base, progress))
|
|
313
|
+
time.sleep(0.05)
|
|
314
|
+
|
|
315
|
+
for task_id in task_ids.values():
|
|
316
|
+
progress.update(task_id, completed=100, phase=PHASE_LABEL["done"])
|
|
317
|
+
live.update(framed(base, progress))
|
|
318
|
+
|
|
319
|
+
for probe_future in futures:
|
|
320
|
+
results.append(probe_future.result())
|
|
321
|
+
|
|
322
|
+
order = {target.raw: i for i, target in enumerate(targets)}
|
|
323
|
+
results.sort(key=lambda r: order[r.target.raw])
|
|
324
|
+
return results
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def countdown(console: Console, live: Live, table: RenderableType, seconds: float) -> None:
|
|
328
|
+
progress = Progress(
|
|
329
|
+
TextColumn("[dim]next check in[/dim]"),
|
|
330
|
+
BarColumn(bar_width=24),
|
|
331
|
+
TextColumn("{task.fields[remaining]}"),
|
|
332
|
+
console=console,
|
|
333
|
+
)
|
|
334
|
+
task_id = progress.add_task("", total=seconds, remaining=f"{seconds:.0f}s")
|
|
335
|
+
start = time.monotonic()
|
|
336
|
+
while True:
|
|
337
|
+
elapsed = time.monotonic() - start
|
|
338
|
+
if elapsed >= seconds:
|
|
339
|
+
break
|
|
340
|
+
progress.update(task_id, completed=elapsed, remaining=f"{seconds - elapsed:.0f}s")
|
|
341
|
+
live.update(framed(table, progress))
|
|
342
|
+
time.sleep(0.2)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: isitup-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small CLI that periodically checks whether servers respond to ping, HTTP, and TCP ports
|
|
5
|
+
Author: stefan.insam
|
|
6
|
+
Author-email: stefan.insam <stefan.insam@netgo.de>
|
|
7
|
+
License-Expression: GPL-3.0-only
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: System Administrators
|
|
11
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
12
|
+
Classifier: Topic :: Utilities
|
|
13
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
14
|
+
Requires-Dist: requests>=2.34.2
|
|
15
|
+
Requires-Dist: rich>=15.0.0
|
|
16
|
+
Requires-Python: >=3.14
|
|
17
|
+
Project-URL: Repository, https://github.com/ramsesoriginal/isitup
|
|
18
|
+
Project-URL: Issues, https://github.com/ramsesoriginal/isitup/issues
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
<div align="center">
|
|
22
|
+
<img src="logo/main_hero.svg" alt="isitup" width="720">
|
|
23
|
+
|
|
24
|
+
<p><strong>Is it up? Ping, HTTP, and TCP checks in one small, fast CLI.</strong></p>
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/isitup-cli/)
|
|
27
|
+
[](https://github.com/ramsesoriginal/isitup/actions/workflows/ci.yml)
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
[](pyproject.toml)
|
|
30
|
+
[](https://github.com/astral-sh/uv)
|
|
31
|
+
[](https://github.com/astral-sh/ruff)
|
|
32
|
+
[](https://mypy-lang.org/)
|
|
33
|
+
[](https://github.com/ramsesoriginal/isitup)
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
**isitup** is a small CLI that periodically checks whether servers respond to ping and,
|
|
39
|
+
depending on how the target is written, HTTP or a TCP port:
|
|
40
|
+
|
|
41
|
+
| Target | Checks |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `example.com` | ping only |
|
|
44
|
+
| `example.com:1337` | ping + TCP connect to port 1337 |
|
|
45
|
+
| `http://example.com` / `https://example.com` | ping + HTTP (catching a 500, a DNS failure, an expired TLS certificate, a redirect loop, etc.) |
|
|
46
|
+
| `https://example.com:1337` | ping + HTTP, against that port |
|
|
47
|
+
|
|
48
|
+
Ping is checked independently from the HTTP/TCP probe: many hosts (behind
|
|
49
|
+
load balancers, CDNs, or firewalls) drop ICMP but serve their actual service
|
|
50
|
+
just fine, so a blocked ping alone doesn't mark a target down: the "Status"
|
|
51
|
+
column is based on the probe (HTTP or TCP), with ping shown alongside as
|
|
52
|
+
extra diagnostic info. A target only shows as `OFFLINE` when both ping and
|
|
53
|
+
the probe fail; a ping-only target's status is based on ping alone, since
|
|
54
|
+
there's nothing else to check.
|
|
55
|
+
|
|
56
|
+
If the same host appears more than once (e.g. `example.com` and
|
|
57
|
+
`https://example.com/health`), it's only pinged once per round. The ping
|
|
58
|
+
result is shared across all targets pointing at that host.
|
|
59
|
+
|
|
60
|
+
While checks are running you get a live spinner and a progress bar per
|
|
61
|
+
target counting down its timeout; results are then shown in a table that
|
|
62
|
+
updates in place (via [`rich`](https://github.com/Textualize/rich)) rather
|
|
63
|
+
than reprinting on every round.
|
|
64
|
+
|
|
65
|
+
Each target also keeps a rolling window of its last 20 response times for
|
|
66
|
+
the session (HTTP response time or TCP connect time, whichever applies),
|
|
67
|
+
shown as Min/Max latency columns and a tiny sparkline ("Trend"), along with
|
|
68
|
+
the last time it was seen online and the last time it was seen offline.
|
|
69
|
+
This history is in-memory only and resets each time you start the tool.
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
The package is published on PyPI as `isitup-cli`, but it installs a plain
|
|
74
|
+
`isitup` command:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uv tool install isitup-cli
|
|
78
|
+
# or: pipx install isitup-cli
|
|
79
|
+
# or: pip install isitup-cli
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
To try unreleased changes straight from `main` instead (no PyPI release
|
|
83
|
+
needed):
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uv tool install git+https://github.com/ramsesoriginal/isitup
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Either way, remove it later with `uv tool uninstall isitup`.
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
isitup --url example.com --url https://example.org --url db.internal:5432
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Or with a config file:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cp config.example.yaml config.yaml
|
|
101
|
+
# edit config.yaml
|
|
102
|
+
isitup --config config.yaml
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Config files and `--url` flags can be combined; the tool watches the union of
|
|
106
|
+
both. The config file is watched for changes and reloaded automatically:
|
|
107
|
+
add, remove, or edit targets without restarting the tool (`--url` targets
|
|
108
|
+
can't change at runtime, only what's in the file).
|
|
109
|
+
|
|
110
|
+
### Options
|
|
111
|
+
|
|
112
|
+
| Flag | Description |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| `-c`, `--config PATH` | YAML file listing targets; reloaded automatically on change |
|
|
115
|
+
| `-u`, `--url TARGET` | a target to monitor (repeatable): hostname, `hostname:port`, or a URL |
|
|
116
|
+
| `-i`, `--interval SECONDS` | seconds between check rounds (default: 30) |
|
|
117
|
+
| `--ping-timeout SECONDS` | ping reply timeout (default: 2) |
|
|
118
|
+
| `--http-timeout SECONDS` | timeout for the HTTP request or TCP connect (default: 5) |
|
|
119
|
+
| `--no-ping` | skip ICMP entirely, HTTP/TCP-only checks |
|
|
120
|
+
| `--once` | run a single round and exit; exit code is `1` if any target is down, `0` otherwise. Useful as a cron/CI health gate |
|
|
121
|
+
| `--plain` | print one plain-text line per target per round instead of the live UI (for logging/piping) |
|
|
122
|
+
| `--json` | print one JSON object per target per round instead of the live UI (for machine consumption) |
|
|
123
|
+
|
|
124
|
+
`--plain` and `--json` are mutually exclusive with each other, and both skip
|
|
125
|
+
the interactive spinner/progress UI entirely.
|
|
126
|
+
|
|
127
|
+
### Note on ping
|
|
128
|
+
|
|
129
|
+
Some servers/networks (common on cloud providers, behind load balancers or
|
|
130
|
+
CDNs) block ICMP echo requests entirely even though the service itself is
|
|
131
|
+
perfectly reachable. The "Status" column already accounts for this and
|
|
132
|
+
won't flag such a target as down (unless it's ping-only, in which case ping
|
|
133
|
+
*is* the only signal there is). If you'd rather not run ping checks against
|
|
134
|
+
a target at all (e.g. to skip the ~2s ping timeout), either set `ping: false`
|
|
135
|
+
for it in the config file, or pass `--no-ping` to disable ICMP checks
|
|
136
|
+
globally. Note that a ping-only target (bare hostname, no port) can't have
|
|
137
|
+
`ping: false`, there would be nothing left to check.
|
|
138
|
+
|
|
139
|
+
### Failure classification
|
|
140
|
+
|
|
141
|
+
When the HTTP or TCP probe fails, the specific reason is captured as
|
|
142
|
+
`error_kind` (visible in `--json` output, and shown as a short tag like
|
|
143
|
+
`DOWN (DNS)` in the Service column otherwise):
|
|
144
|
+
|
|
145
|
+
| `error_kind` | Meaning |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `dns` | the hostname failed to resolve |
|
|
148
|
+
| `tls` | a TLS/certificate error (HTTP only, e.g. expired or self-signed cert) |
|
|
149
|
+
| `redirect` | too many redirects (HTTP only, possible redirect loop) |
|
|
150
|
+
| `timeout` | didn't complete within `--http-timeout` |
|
|
151
|
+
| `connection` | connection refused or otherwise unreachable |
|
|
152
|
+
| `client_error` / `server_error` | got a 4xx / 5xx HTTP response |
|
|
153
|
+
| `request_error` | anything else `requests` raised |
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
uv sync
|
|
159
|
+
uv run isitup --once --url https://example.com
|
|
160
|
+
|
|
161
|
+
uv run pytest # tests
|
|
162
|
+
uv run ruff check . # lint
|
|
163
|
+
uv run ruff format . # format
|
|
164
|
+
uv run mypy src tests # type check
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Every push and pull request runs this same lint/type-check/test suite via
|
|
168
|
+
[GitHub Actions](.github/workflows/ci.yml), across Linux and Windows.
|
|
169
|
+
|
|
170
|
+
### Releasing
|
|
171
|
+
|
|
172
|
+
Pushing a tag matching `vX.Y.Z` builds the package and publishes it to PyPI
|
|
173
|
+
via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (see
|
|
174
|
+
[`.github/workflows/publish.yml`](.github/workflows/publish.yml)), no API
|
|
175
|
+
token stored anywhere:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
# bump `version` in pyproject.toml first (skip this for the very first
|
|
179
|
+
# release: it's already at 0.1.0), then:
|
|
180
|
+
git tag v0.1.0
|
|
181
|
+
git push origin v0.1.0
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
[GPL-3.0](LICENSE): see [LICENSE](LICENSE) for the full text.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
isitup/__init__.py,sha256=oKsCpoiQSSXx57DGs2VfYzeO1ukJQxdGnV-2hC3BI0M,42
|
|
2
|
+
isitup/checks.py,sha256=SDkPinxtx0tYXofZ_CcFxFXBuHJnXbTpMH1nVS8-ny8,7348
|
|
3
|
+
isitup/cli.py,sha256=NCzqug5G8CtQKxFBBLK6WQEbCUaOeyBl9BY4glXfe1c,6248
|
|
4
|
+
isitup/config.py,sha256=WO14EjLVo5huKOPGdw9enjcCHvQUhPLdOz3b3oBDIQc,4941
|
|
5
|
+
isitup/history.py,sha256=BT-j6pK1AUYbMIz2xrTNCz5dzs9KMsMSFgjTPKXccfE,1912
|
|
6
|
+
isitup/output.py,sha256=UhhwKYXDI3Z0J-djBDC9V0ZWEbTFlsbLrqp4M5YCKto,4327
|
|
7
|
+
isitup/render.py,sha256=dM4FxJrYeNanfR-NNNSbgyA127ujVLrIFZlwMNIwNpI,13734
|
|
8
|
+
isitup_cli-0.1.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
9
|
+
isitup_cli-0.1.0.dist-info/WHEEL,sha256=y6e-a5KI2W-qDAJKfh9Xr81bin8NgUdfWgz3VSXXpe4,80
|
|
10
|
+
isitup_cli-0.1.0.dist-info/entry_points.txt,sha256=qtY-K4y-86S5h3ABNK9FAH9ABDZflPqoTS1VE9F98y8,40
|
|
11
|
+
isitup_cli-0.1.0.dist-info/METADATA,sha256=0WWmEqMQFfpWWlz33vdjJEkrktv7JRRLr-cjBjiPI3I,7800
|
|
12
|
+
isitup_cli-0.1.0.dist-info/RECORD,,
|