isitup-cli 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -0,0 +1,530 @@
1
+ Metadata-Version: 2.4
2
+ Name: isitup-cli
3
+ Version: 0.3.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: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Topic :: System :: Networking :: Monitoring
13
+ Classifier: Topic :: Utilities
14
+ Requires-Dist: pyyaml>=6.0.3
15
+ Requires-Dist: requests>=2.34.2
16
+ Requires-Dist: rich>=15.0.0
17
+ Requires-Python: >=3.14
18
+ Project-URL: Repository, https://github.com/ramsesoriginal/isitup
19
+ Project-URL: Issues, https://github.com/ramsesoriginal/isitup/issues
20
+ Description-Content-Type: text/markdown
21
+
22
+ <div align="center">
23
+ <img src="https://raw.githubusercontent.com/ramsesoriginal/isitup/main/logo/main_hero.svg" alt="isitup" width="720">
24
+
25
+ <p><strong>Is it up? Ping, HTTP, and TCP checks in one small, fast CLI.</strong></p>
26
+
27
+ [![PyPI](https://img.shields.io/pypi/v/isitup-cli?color=blue)](https://pypi.org/project/isitup-cli/)
28
+ [![CI](https://github.com/ramsesoriginal/isitup/actions/workflows/ci.yml/badge.svg)](https://github.com/ramsesoriginal/isitup/actions/workflows/ci.yml)
29
+ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
30
+ [![Python 3.14+](https://img.shields.io/badge/python-3.14%2B-3776AB?logo=python&logoColor=white)](pyproject.toml)
31
+ [![uv](https://img.shields.io/badge/uv-managed-DE5FE9?logo=uv&logoColor=white)](https://github.com/astral-sh/uv)
32
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
33
+ [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
34
+ [![GitHub stars](https://img.shields.io/github/stars/ramsesoriginal/isitup?style=social)](https://github.com/ramsesoriginal/isitup)
35
+ </div>
36
+
37
+ ---
38
+
39
+ **isitup** is a small CLI that periodically checks whether servers respond to ping and,
40
+ depending on how the target is written, HTTP, a TCP port, or SSH:
41
+
42
+ | Target | Checks |
43
+ | --- | --- |
44
+ | `example.com` | ping only |
45
+ | `example.com:1337` | ping + TCP connect to port 1337 |
46
+ | `http://example.com` / `https://example.com` | ping + HTTP (catching a 500, a DNS failure, an expired TLS certificate, a redirect loop, etc.) |
47
+ | `https://example.com:1337` | ping + HTTP, against that port |
48
+ | `ssh://example.com` / `ssh://example.com:1337` | ping + SSH (connects and verifies the server's SSH identification banner; defaults to port 22) |
49
+
50
+ Ping is checked independently from the HTTP/TCP/SSH probe: many hosts (behind
51
+ load balancers, CDNs, or firewalls) drop ICMP but serve their actual service
52
+ just fine, so a blocked ping alone doesn't mark a target down: the "Status"
53
+ column is based on the probe, with ping shown alongside as extra diagnostic
54
+ info. A target only shows as `OFFLINE` when both ping and the probe fail; a
55
+ ping-only target's status is based on ping alone, since there's nothing else
56
+ to check.
57
+
58
+ If the same host appears more than once (e.g. `example.com` and
59
+ `https://example.com/health`), it's only pinged once per round. The ping
60
+ result is shared across all targets pointing at that host.
61
+
62
+ While checks are running you get a live spinner and a progress bar per
63
+ target counting down its timeout; results are then shown in a table that
64
+ updates in place (via [`rich`](https://github.com/Textualize/rich)) rather
65
+ than reprinting on every round.
66
+
67
+ Each target also keeps a rolling window of its last 20 response times for
68
+ the session (HTTP response time, TCP connect time, or SSH connect+banner
69
+ time, whichever applies), shown as Min/Max latency columns and a tiny
70
+ sparkline ("Trend"), along with
71
+ the last time it was seen online and the last time it was seen offline.
72
+ This history is in-memory only and resets each time you start the tool.
73
+
74
+ ## Installation
75
+
76
+ The package is published on PyPI as `isitup-cli`, but it installs a plain
77
+ `isitup` command:
78
+
79
+ ```bash
80
+ uv tool install isitup-cli
81
+ # or: pipx install isitup-cli
82
+ # or: pip install isitup-cli
83
+ ```
84
+
85
+ To try unreleased changes straight from `main` instead (no PyPI release
86
+ needed):
87
+
88
+ ```bash
89
+ uv tool install git+https://github.com/ramsesoriginal/isitup
90
+ ```
91
+
92
+ Either way, remove it later with `uv tool uninstall isitup`.
93
+
94
+ ## Usage
95
+
96
+ ```bash
97
+ isitup example.com https://example.org db.internal:5432
98
+ ```
99
+
100
+ `--url`/`-u` work the same way and can be freely mixed with positional
101
+ targets: `isitup example.com -u https://example.org` is the same as
102
+ `isitup example.com https://example.org`:
103
+
104
+ ```bash
105
+ isitup --url example.com --url https://example.org --url db.internal:5432
106
+ ```
107
+
108
+ Or with a config file:
109
+
110
+ ```bash
111
+ cp config.example.yaml config.yaml
112
+ # edit config.yaml
113
+ isitup --config config.yaml
114
+ ```
115
+
116
+ Config files and CLI-supplied targets (positional or `--url`) can be
117
+ combined; the tool watches the union of both. The config file is watched for
118
+ changes and reloaded automatically: add, remove, or edit targets without
119
+ restarting the tool (CLI-supplied targets can't change at runtime, only
120
+ what's in the file).
121
+
122
+ ### Options
123
+
124
+ | Flag | Description |
125
+ | --- | --- |
126
+ | `-c`, `--config PATH` | YAML file listing targets; reloaded automatically on change |
127
+ | `TARGET` (positional) | a target to monitor: same as `-u`/`--url`, just without the flag (repeatable) |
128
+ | `-u`, `--url TARGET` | a target to monitor (repeatable): hostname, `hostname:port`, or a URL |
129
+ | `-i`, `--interval SECONDS` | seconds between check rounds (default: 30) |
130
+ | `--ping-timeout SECONDS` | ping reply timeout (default: 2) |
131
+ | `--http-timeout SECONDS` | timeout for the HTTP request or TCP connect (default: 5) |
132
+ | `--no-ping` | skip ICMP entirely, HTTP/TCP-only checks |
133
+ | `--columns LIST` | comma-separated list of table columns to show, in order (interactive UI only; `--plain`/`--json` always include every field). see below |
134
+ | `--basic-auth USER:PASS` | HTTP Basic Auth for CLI-supplied HTTP targets that don't set their own `basic_auth:` in the config file. see below |
135
+ | `--basic-auth-env USER_VAR:PASS_VAR` | same as `--basic-auth`, but reads the username/password from the named environment variables |
136
+ | `--tag TAG` | tag applied to CLI-supplied targets (repeatable). see below |
137
+ | `--filter-tag TAG` | only monitor targets carrying at least one of the given tags (repeatable). see below |
138
+ | `--expected-status SPEC` | comma-separated status codes/ranges (e.g. `200,204,301-302`) that count as OK, for CLI-supplied HTTP targets that don't set their own `expected_status:` in the config file. see below |
139
+ | `--warn-status SPEC` | same as `--expected-status`, but for codes that count as WARN instead of OK. see below |
140
+ | `--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 |
141
+ | `--plain` | print one plain-text line per target per round instead of the live UI (for logging/piping) |
142
+ | `--json` | print one JSON object per target per round instead of the live UI (for machine consumption) |
143
+
144
+ `--plain` and `--json` are mutually exclusive with each other, and both skip
145
+ the interactive spinner/progress UI entirely.
146
+
147
+ ### Config file reference
148
+
149
+ Two top-level keys:
150
+
151
+ | Key | Description |
152
+ | --- | --- |
153
+ | `targets` | list of target entries (required. see below) |
154
+ | `columns` | list of table column keys to show, in order (optional; interactive UI only). See "Customizing table columns" below |
155
+
156
+ Each entry under `targets:` supports:
157
+
158
+ | Key | Type | Default | Applies to | Description |
159
+ | --- | --- | --- | --- | --- |
160
+ | `url` | string | *(required)* | all | the target itself: a bare hostname, `hostname:port`, or a URL (`http://`, `https://`, `ssh://`) What gets checked besides ping depends on how it's written (see the table at the top of this README) |
161
+ | `name` | string | the `url` value | all | display name shown in place of the raw target string |
162
+ | `host` | string | hostname from `url` | all | ping a different host than the one being checked, e.g. when `url` goes through a load balancer or CDN |
163
+ | `ping` | bool | `true` | all except ping-only targets | set `false` to skip ICMP for this target (a ping-only target can't disable it, there'd be nothing left to check) |
164
+ | `id` | string | auto-generated | all | an explicit, stable identity for history/state; see "Target id" below |
165
+ | `tags` | list of strings | `[]` | all | tags for grouping and `--filter-tag`; see "Tagging and grouping" below |
166
+ | `basic_auth` | mapping | *(none)* | `http://`/`https://` only | `username`/`password`, or `username_env`/`password_env` to read from an environment variable; see "HTTP Basic Auth" below |
167
+ | `expected_status` | list of ints and/or `"NNN-NNN"` ranges | *(none)* | `http://`/`https://` only | status codes that count as OK; see "Custom HTTP status classification" below |
168
+ | `warn_status` | list of ints and/or `"NNN-NNN"` ranges | *(none)* | `http://`/`https://` only | status codes that count as WARN; see "Custom HTTP status classification" below |
169
+ | `retries` | int, `>= 0` | `0` | HTTP/TCP/SSH (not ping-only) | extra attempts within the same round if the probe comes back DOWN; see "Retries and flap-debouncing" below |
170
+ | `failure_threshold` | int, `>= 1` | `1` | HTTP/TCP/SSH (not ping-only) | consecutive DOWN rounds required before the displayed status flips to down; see "Retries and flap-debouncing" below |
171
+ | `recovery_threshold` | int, `>= 1` | `1` | HTTP/TCP/SSH (not ping-only) | consecutive non-DOWN rounds required before the status flips back; see "Retries and flap-debouncing" below |
172
+
173
+ See [config.example.yaml](config.example.yaml) for a fully-annotated example using
174
+ every one of these.
175
+
176
+ ### Note on ping
177
+
178
+ Some servers/networks (common on cloud providers, behind load balancers or
179
+ CDNs) block ICMP echo requests entirely even though the service itself is
180
+ perfectly reachable. The "Status" column already accounts for this and
181
+ won't flag such a target as down (unless it's ping-only, in which case ping
182
+ *is* the only signal there is). If you'd rather not run ping checks against
183
+ a target at all (e.g. to skip the ~2s ping timeout), either set `ping: false`
184
+ for it in the config file, or pass `--no-ping` to disable ICMP checks
185
+ globally. Note that a ping-only target (bare hostname, no port) can't have
186
+ `ping: false`, there would be nothing left to check.
187
+
188
+ ### SSH availability check
189
+
190
+ `ssh://example.com` (or `ssh://example.com:1234` for a non-standard port,
191
+ default 22) connects over TCP and reads the server's SSH identification
192
+ banner (the string like `SSH-2.0-OpenSSH_9.6` that a compliant server sends
193
+ before any client input, per RFC 4253) rather than just checking whether
194
+ the port is open. If the port is open but the banner doesn't start with
195
+ `SSH-`, the target is reported down with `error_kind: protocol`. This
196
+ catches something else listening on the port. No SSH handshake, key
197
+ exchange, or authentication is attempted, so no credentials are needed or
198
+ used.
199
+
200
+ ### Customizing table columns
201
+
202
+ Every available column, in the order they appear by default:
203
+
204
+ | Column | Header shown | Default? | Notes |
205
+ | --- | --- | --- | --- |
206
+ | `target` | Target | Yes | display name (or the raw target string) |
207
+ | `status` | Status | Yes | overall UP/WARN/OFFLINE status (debounced. see "Retries and flap-debouncing") |
208
+ | `ping` | Ping | Yes | ping status and RTT |
209
+ | `service` | Service | Yes | the HTTP/TCP/SSH probe's own status, with code/port/error |
210
+ | `latency` | Latency | Yes | this round's latency (falls back to ping RTT when the probe has none) |
211
+ | `min` | Min | Yes | rolling-window minimum of the Latency column |
212
+ | `max` | Max | Yes | rolling-window maximum of the Latency column |
213
+ | `trend` | Trend | Yes | sparkline of the rolling window |
214
+ | `last_online` | Last Online | Yes | when this target was last confirmed online |
215
+ | `last_offline` | Last Offline | Yes | when this target was last confirmed offline |
216
+ | `detail` | Detail | Yes | free-text detail message for the current round |
217
+ | `checked` | Checked | Yes | when this round's check ran |
218
+ | `id` | Id | No | resolved id; see "Target id" below |
219
+ | `tags` | Tags | No | comma-joined tags, or `-`; see "Tagging and grouping" below |
220
+ | `content_length` | Size | No | HTTP `Content-Length` header; see "HTTP header/metadata tracking" below |
221
+ | `last_modified` | Last Modified | No | HTTP `Last-Modified` header |
222
+ | `etag` | ETag | No | HTTP `ETag` header |
223
+ | `content_type` | Content-Type | No | HTTP `Content-Type` header |
224
+ | `server` | Server | No | HTTP `Server` header |
225
+ | `header_change` | Headers Changed | No | when any tracked HTTP header last changed |
226
+ | `dns` | Resolved IPs | No | resolved A/AAAA addresses; see "Hostname resolution tracking (DNS)" below |
227
+ | `dns_change` | DNS Changed | No | when the resolved IP set last changed |
228
+
229
+ To show a different subset (or reorder them), pass `--columns` with a
230
+ comma-separated list:
231
+
232
+ ```bash
233
+ isitup --columns target,status,detail --url https://example.com
234
+ ```
235
+
236
+ Or set it once in the config file:
237
+
238
+ ```yaml
239
+ columns: [target, status, service, latency, detail]
240
+ ```
241
+
242
+ `--columns` takes precedence over a config file's `columns:` list, which
243
+ takes precedence over the default. This only affects the interactive UI:
244
+ `--plain` and `--json` always include every field, since they're meant for
245
+ machine consumption.
246
+
247
+ ### HTTP Basic Auth
248
+
249
+ Set `basic_auth` on a config file target to send an `Authorization` header
250
+ with its HTTP requests. Each of `username`/`password` can be given either
251
+ literally or via a `*_env` reference to an environment variable (but not
252
+ both):
253
+
254
+ ```yaml
255
+ targets:
256
+ - name: Protected API
257
+ url: https://api.internal.example.com
258
+ basic_auth:
259
+ username: admin
260
+ password_env: API_PASSWORD # read from $API_PASSWORD at startup
261
+ ```
262
+
263
+ `basic_auth` only applies to `http://`/`https://` targets. It's rejected
264
+ on a bare hostname or `hostname:port` target.
265
+
266
+ For targets passed directly on the command line (positional or `--url`),
267
+ use `--basic-auth USER:PASS` or `--basic-auth-env USER_VAR:PASS_VAR`
268
+ instead. Either flag applies to every CLI-supplied HTTP target in that
269
+ invocation, but never to config-file targets. Those always use their own
270
+ `basic_auth:` (or none), so the two never mix unexpectedly:
271
+
272
+ ```bash
273
+ isitup --basic-auth-env API_USER:API_PASSWORD --url https://api.internal.example.com
274
+ ```
275
+
276
+ ### HTTP header/metadata tracking
277
+
278
+ For HTTP(S) targets, a handful of response headers are captured every
279
+ round: `Content-Length` (response size), `Last-Modified`, `ETag`,
280
+ `Content-Type`, and `Server` without ever touching the response body
281
+ itself. `--plain`/`--json` always include them (nested under `http` in
282
+ JSON, as `content_length=`/`last_modified=`/etc. fields in `--plain`),
283
+ alongside a `last_header_change`/`header_change` timestamp: the last time
284
+ any of those five values actually differed from the previous round (not
285
+ set on the very first check, since there's nothing yet to compare against).
286
+ This is handy for noticing "something on this page changed" without
287
+ diffing the body yourself.
288
+
289
+ In the interactive table these show up as the `content_length`,
290
+ `last_modified`, `etag`, `content_type`, `server`, and `header_change`
291
+ columns: available via `--columns`/config `columns:`, but not part of the
292
+ default set (see above), so the table's default look is unaffected.
293
+
294
+ HTTP checks never download the response body, only the status line and
295
+ headers are read off the connection before it's closed. That makes checking
296
+ a large file, a big API payload, or a slow streaming endpoint just as cheap
297
+ as checking a tiny page.
298
+
299
+ ### Hostname resolution tracking (DNS)
300
+
301
+ Every target's ping-host (`host` override if set, otherwise its hostname.
302
+ see "Note on ping" above) is resolved via DNS once per round, regardless of
303
+ target kind (ping-only, TCP, HTTP, or SSH) and independent of whether ping
304
+ itself is enabled (`--no-ping`/`ping: false`). Resolution is deduped the
305
+ same way ping is, so targets sharing a host are only resolved once.
306
+ `--plain`/`--json` always include the result as a top-level `resolved_ips`
307
+ field (a list of every A/AAAA address returned, or `null`/`-` if resolution
308
+ failed), alongside a `last_dns_change` (JSON) / `dns_change` (`--plain`)
309
+ timestamp: the last time the resolved IP set actually changed from the
310
+ previous round (not set on the very first check, since there's nothing yet
311
+ to compare against). This is handy for noticing a target silently moved to
312
+ a new IP (e.g. after a DNS failover or a misconfigured record).
313
+
314
+ In the interactive table these show up as the opt-in `dns` (resolved IPs)
315
+ and `dns_change` columns, available via `--columns`/config `columns:`,
316
+ but not part of the default set (see above).
317
+
318
+ ### Tagging and grouping
319
+
320
+ Any target (ping-only, TCP, or HTTP) can carry `tags` in the config file:
321
+
322
+ ```yaml
323
+ targets:
324
+ - name: Prod Website
325
+ url: https://example.com
326
+ tags: [prod, web]
327
+ - name: Staging Website
328
+ url: https://staging.example.com
329
+ tags: [staging, web]
330
+ ```
331
+
332
+ Tags show up as their own `Tags` table column. Add it with `--columns
333
+ target,tags,status,...` (or a config `columns:` list), since it isn't in
334
+ the default set, and always as a `tags` field in `--plain`/`--json`
335
+ output.
336
+
337
+ To only monitor a subset, pass `--filter-tag` (repeatable, a target
338
+ matches if it has *any* of the given tags):
339
+
340
+ ```bash
341
+ isitup --config config.yaml --filter-tag prod
342
+ ```
343
+
344
+ With no `--filter-tag` given, every target is monitored, same as today.
345
+ For targets passed directly on the command line, `--tag TAG` (repeatable)
346
+ tags every CLI-supplied target in that invocation, like `--basic-auth`,
347
+ it never touches config-file targets, which set their own `tags:` instead.
348
+
349
+ ### Target id
350
+
351
+ Every target has an `id` it's tracked/keyed by internally (history,
352
+ last online/offline, header-change tracking). By default this is
353
+ auto-generated deterministically from the target's URL, its `host`
354
+ override, and its `basic_auth` username (never the password), so
355
+ renaming a target or changing its `tags` doesn't reset its history, and
356
+ rotating a password doesn't either.
357
+
358
+ Give a config file target an explicit `id:` when you need to guarantee
359
+ its identity, or to disambiguate two targets that would otherwise
360
+ auto-generate the same id. Most commonly, the same URL monitored twice
361
+ with different credentials:
362
+
363
+ ```yaml
364
+ targets:
365
+ - name: Protected API (as Alice)
366
+ url: https://api.internal.example.com
367
+ id: api-as-alice
368
+ basic_auth:
369
+ username: alice
370
+ password_env: ALICE_PASSWORD
371
+ - name: Protected API (as Bob)
372
+ url: https://api.internal.example.com
373
+ basic_auth:
374
+ username: bob
375
+ password_env: BOB_PASSWORD
376
+ ```
377
+
378
+ `load_targets` rejects the whole config at startup if any two targets
379
+ resolve to the same id (explicit or auto-generated), naming which
380
+ targets collide. The resolved `id` is always included as a field in
381
+ `--plain`/`--json` output, and available as an opt-in `id` table column
382
+ via `--columns`/config `columns:` (not shown by default).
383
+
384
+ ### Failure classification
385
+
386
+ When the HTTP, TCP, or SSH probe fails, the specific reason is captured as
387
+ `error_kind` (visible in `--json` output, and shown as a short tag like
388
+ `DOWN (DNS)` in the Service column otherwise):
389
+
390
+ | `error_kind` | Meaning |
391
+ | --- | --- |
392
+ | `dns` | the hostname failed to resolve |
393
+ | `tls` | a TLS/certificate error (HTTP only, e.g. expired or self-signed cert) |
394
+ | `redirect` | too many redirects (HTTP only, possible redirect loop) |
395
+ | `timeout` | didn't complete within `--http-timeout` |
396
+ | `connection` | connection refused or otherwise unreachable |
397
+ | `protocol` | connected, but the server's response wasn't a valid SSH banner (SSH only) |
398
+ | `client_error` / `server_error` | got a 4xx / 5xx HTTP response |
399
+ | `request_error` | anything else `requests` raised |
400
+
401
+ Note that `error_kind` above always reflects the raw HTTP status code, even
402
+ when a custom `expected_status`/`warn_status` classification (below) decides
403
+ a different overall status for the same response: a 404 you've configured
404
+ as OK still shows `error_kind: client_error` for diagnostics.
405
+
406
+ ### Custom HTTP status classification
407
+
408
+ By default, any HTTP response is classified as OK below 400, WARN for
409
+ 4xx, and DOWN for 5xx. This is what "Failure classification" above
410
+ describes. For an HTTP target where a specific 4xx/5xx (or a redirect)
411
+ should be treated differently, e.g. a `401`/`403` on an endpoint you
412
+ expect to require auth, or a `404` that should count as fully down instead
413
+ of a mere warning, set `expected_status` and/or `warn_status` on it in
414
+ the config file:
415
+
416
+ ```yaml
417
+ targets:
418
+ - name: Authenticated API (401 without valid creds should be DOWN)
419
+ url: https://protected.internal.example.com/health
420
+ expected_status: [200]
421
+ ```
422
+
423
+ Each is a list of bare status codes and/or inclusive `"NNN-NNN"` range
424
+ strings, e.g. `expected_status: [200, 204, "300-399"]`. Setting *either*
425
+ field on a target **fully replaces** the default classification for it,
426
+ it does not blend with or extend the 4xx=warn/5xx=down rule. A code
427
+ matching `expected_status` is OK, a code matching `warn_status` is WARN,
428
+ and anything matching neither is DOWN. In particular, this means **3xx
429
+ redirects are not automatically OK** once you opt in. If your target
430
+ redirects and that's fine, include the range explicitly (e.g.
431
+ `expected_status: [200, "300-399"]`).
432
+
433
+ For targets passed directly on the command line, use `--expected-status`
434
+ and/or `--warn-status` with a comma-separated value instead (same syntax,
435
+ e.g. `--expected-status 200,204,301-302`). Like `--basic-auth`/`--tag`,
436
+ these apply only to CLI-supplied HTTP targets that don't already set their
437
+ own `expected_status:`/`warn_status:` in the config file, never to
438
+ config-file targets:
439
+
440
+ ```bash
441
+ isitup --expected-status 200 --url https://protected.internal.example.com/health
442
+ ```
443
+
444
+ This is also what makes `--once`'s exit code meaningful for endpoints
445
+ where "reachable" isn't good enough: without this, a `401`/`403`/`404` on
446
+ a target you actually care about being properly authorized/found only ever
447
+ shows as WARN, so `--once` still exits `0`.
448
+
449
+ ### Retries and flap-debouncing
450
+
451
+ Two related but independent knobs make isitup less trigger-happy about
452
+ transient blips, for HTTP/TCP/SSH targets (ping-only targets have nothing
453
+ to configure here, there's no separate probe to retry or debounce):
454
+
455
+ - `retries`: extra attempts of the HTTP/TCP/SSH probe *within the same
456
+ round* if it comes back DOWN, stopping as soon as a non-DOWN result is
457
+ seen. `retries: 2` means up to 3 total attempts. Only the last attempt's
458
+ result is kept; if more than one attempt was made, a short note like
459
+ `(down after 3 attempts)` or `(recovered after 2 attempts)` is appended
460
+ to the detail message.
461
+ - `failure_threshold` / `recovery_threshold`: how many *consecutive
462
+ rounds* a target must report DOWN before its displayed status flips to
463
+ reflect that, and how many consecutive non-DOWN rounds it must report
464
+ before flipping back. A single blip that doesn't reach the threshold
465
+ doesn't move the needle at all: The target keeps showing whatever it
466
+ showed before, and a recovery in the middle of an accumulating failure
467
+ streak (or vice versa) resets that streak back to zero rather than
468
+ partially counting toward the threshold.
469
+
470
+ The **defaults reproduce today's exact behavior**: `retries: 0` (a single
471
+ attempt, no retry) and `failure_threshold: 1` / `recovery_threshold: 1`
472
+ (the status flips immediately on the very first differing result, exactly
473
+ like before this feature existed).
474
+
475
+ ```yaml
476
+ targets:
477
+ - name: Flaky But Usually Fine Internal Service
478
+ url: https://flaky.internal.example.com/health
479
+ retries: 2
480
+ failure_threshold: 3
481
+ recovery_threshold: 2
482
+ # Up to 3 attempts per round if the probe comes back DOWN. Even then,
483
+ # the displayed status only flips to down after 3 consecutive rounds
484
+ # report DOWN, and only flips back to up after 2 consecutive rounds
485
+ # report OK/WARN.
486
+ ```
487
+
488
+ For targets passed directly on the command line, use `--retries`,
489
+ `--failure-threshold`, and `--recovery-threshold` instead. Like
490
+ `--basic-auth`/`--tag`/`--expected-status`, these apply only to
491
+ CLI-supplied HTTP/TCP/SSH targets that don't already set their own
492
+ `retries:`/`failure_threshold:`/`recovery_threshold:` in the config file,
493
+ never to config-file targets:
494
+
495
+ ```bash
496
+ isitup --retries 2 --failure-threshold 3 --url https://flaky.internal.example.com/health
497
+ ```
498
+
499
+ Internally, this is why the online/offline decision now lives entirely in
500
+ `TargetHistory`, computed once per round from the raw per-round result:
501
+ the debounced status (what's shown as the Status column, the top-level
502
+ `status` field in `--json`/`--plain`, and what `--once`'s exit code
503
+ reflects) and the `last_online`/`last_offline` timestamps are derived from
504
+ the exact same value, so they can never disagree with each other. The
505
+ Service column / `http=`/`tcp=`/`ssh=` fields, latency, HTTP headers, DNS
506
+ fields, and ping fields are unaffected, they always reflect the raw,
507
+ current-round result.
508
+
509
+ ## Development
510
+
511
+ ```bash
512
+ uv sync
513
+ uv run isitup --once --url https://example.com
514
+
515
+ uv run pytest # tests
516
+ uv run ruff check . # lint
517
+ uv run ruff format . # format
518
+ uv run mypy src tests # type check
519
+ ```
520
+
521
+ Every push and pull request runs this same lint/type-check/test suite via
522
+ [GitHub Actions](.github/workflows/ci.yml), across Linux and Windows.
523
+
524
+ ## Changelog
525
+
526
+ See [CHANGELOG.md](CHANGELOG.md).
527
+
528
+ ## License
529
+
530
+ [GPL-3.0](LICENSE): see [LICENSE](LICENSE) for the full text.