#!/usr/bin/env bash # watchpup — command-line client for the Watchpup uptime-monitoring API. # # Install (or re-run any time to update): # curl -fsSL https://watchpup.watchpup.workers.dev/cli -o ~/.local/bin/watchpup && chmod +x ~/.local/bin/watchpup # # Start: # watchpup signup you@example.com # or: watchpup login you@example.com # watchpup add https://example.com # watchpup ls # # Everything the CLI does is plain HTTP against https://watchpup.watchpup.workers.dev/docs — # use `watchpup api GET /api/monitors` for anything not wrapped here. # Needs: bash, curl, and python3 (for pretty output; falls back to raw JSON). # Watchpup is built and operated by an AI agent (Cassian Wei). set -euo pipefail WP_URL="${WATCHPUP_URL:-https://watchpup.watchpup.workers.dev}" CFG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/watchpup" CFG="$CFG_DIR/config" UA="watchpup-cli/1.0" die() { echo "watchpup: $*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } load_key() { WP_KEY="${WATCHPUP_KEY:-}" if [ -z "$WP_KEY" ] && [ -f "$CFG" ]; then WP_KEY="$(sed -n 's/^key=//p' "$CFG" | head -1)" u="$(sed -n 's/^url=//p' "$CFG" | head -1)" if [ -n "$u" ] && [ -z "${WATCHPUP_URL:-}" ]; then WP_URL="$u"; fi fi } save_key() { # $1=api_key mkdir -p "$CFG_DIR" printf 'url=%s\nkey=%s\n' "$WP_URL" "$1" > "$CFG" chmod 600 "$CFG" echo "saved API key to $CFG" } need_key() { load_key [ -n "$WP_KEY" ] || die "no API key. Run: watchpup login you@example.com (or signup)" } # api METHOD PATH [JSON_BODY] → body on stdout; nonzero + error line on HTTP >= 400 api() { local method="$1" path="$2" body="${3:-}" out code local args=(-sS -X "$method" -A "$UA" -H "accept: application/json" -w $'\n%{http_code}') [ -n "${WP_KEY:-}" ] && args+=(-H "authorization: Bearer $WP_KEY") [ -n "$body" ] && args+=(-H "content-type: application/json" -d "$body") out="$(curl "${args[@]}" "$WP_URL$path")" || die "network error talking to $WP_URL" code="${out##*$'\n'}" out="${out%$'\n'*}" if [ "$code" -ge 400 ] 2>/dev/null; then echo "$out" | jget error "HTTP $code" >&2 return 1 fi printf '%s\n' "$out" } # jget FIELD [FALLBACK] — print one top-level field from JSON on stdin jget() { if have python3; then python3 -c 'import sys,json try: d=json.load(sys.stdin) except Exception: d={} v=d.get(sys.argv[1]) if isinstance(d,dict) else None print(v if v not in (None,"") else (sys.argv[2] if len(sys.argv)>2 else ""))' "$@" else cat; [ $# -gt 1 ] && echo "$2" fi } # render PYCODE — pretty-print JSON from stdin via python3, else raw JSON render() { if have python3; then python3 -c " import sys, json, time, os def maintws(): # active/upcoming maintenance windows from WP_MAINT env (set by status/watch) try: ws = json.loads(os.environ.get('WP_MAINT','') or '{}').get('maintenance_windows', []) except Exception: ws = [] return [w for w in ws if w.get('state') in ('active', 'upcoming')] def wfmt(ts): # short UTC time; adds the date when >24h out f = '%H:%M' if -86400 < ts - time.time() < 86400 else '%b %d %H:%M' return time.strftime(f, time.gmtime(ts)) + 'Z' def ago(ts): if not ts: return '-' s = int(time.time() - ts) if s < 0: s = 0 for div, unit in ((86400,'d'), (3600,'h'), (60,'m')): if s >= div: return f'{s//div}{unit} ago' return f'{s}s ago' def dur(s): s = int(s); out = [] for div, unit in ((86400,'d'), (3600,'h'), (60,'m')): if s >= div: out.append(f'{s//div}{unit}'); s %= div if not out or s: out.append(f'{s}s') return ' '.join(out[:2]) try: d = json.load(sys.stdin) except Exception: sys.exit(1) $1" else cat; fi } read_password() { # prompt on stderr, echo password local pw printf 'password (min 8 chars, hidden): ' >&2 read -rs pw; echo >&2 [ ${#pw} -ge 8 ] || die "password too short" printf '%s' "$pw" } dur_secs() { # "90", "45m", "2h", "1d" → seconds on stdout; rc=1 if unparseable local d="$1" u=1 case "$d" in *m) u=60; d="${d%m}" ;; *h) u=3600; d="${d%h}" ;; *d) u=86400; d="${d%d}" ;; *s) d="${d%s}" ;; esac case "$d" in ''|*[!0-9]*) return 1 ;; esac echo $(( d * u )) } json_escape() { # safe JSON string of $1 if have python3; then python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1" else printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"; fi } cmd_signup() { [ $# -ge 1 ] || die "usage: watchpup signup you@example.com" local pw; pw="$(read_password)" local body; body="{\"email\":$(json_escape "$1"),\"password\":$(json_escape "$pw")}" local out; out="$(api POST /api/signup "$body")" || exit 1 local key; key="$(printf '%s' "$out" | jget api_key)" [ -n "$key" ] || die "signup failed: $out" save_key "$key" echo "account created — dashboard: $WP_URL/dash" } cmd_login() { [ $# -ge 1 ] || die "usage: watchpup login you@example.com" local pw; pw="$(read_password)" local body out code body="{\"email\":$(json_escape "$1"),\"password\":$(json_escape "$pw")}" out="$(curl -sS -X POST -A "$UA" -H "accept: application/json" -H "content-type: application/json" \ -d "$body" -w $'\n%{http_code}' "$WP_URL/api/login")" || die "network error talking to $WP_URL" code="${out##*$'\n'}"; out="${out%$'\n'*}" if [ "$code" = 401 ] && printf '%s' "$out" | grep -q needs_totp; then local tf printf 'two-factor code (authenticator app, or a recovery code): ' >&2 IFS= read -r tf body="{\"email\":$(json_escape "$1"),\"password\":$(json_escape "$pw"),\"code\":$(json_escape "$tf")}" out="$(api POST /api/login "$body")" || exit 1 elif [ "$code" -ge 400 ] 2>/dev/null; then printf '%s' "$out" | jget error "HTTP $code" >&2 exit 1 fi local key; key="$(printf '%s' "$out" | jget api_key)" [ -n "$key" ] || die "login failed: $out" local note; note="$(printf '%s' "$out" | jget note)" [ -n "$note" ] && echo "$note" >&2 save_key "$key" } cmd_key() { [ $# -ge 1 ] || die "usage: watchpup key wp_yourapikey # save a key on this machine (wp_ro_… read-only keys work too) watchpup key rotate # issue a new key (the old one dies instantly) watchpup key ro # show the account's read-only key watchpup key ro rotate # issue a new read-only key" if [ "$1" = ro ]; then need_key if [ "${2:-}" = rotate ]; then printf 'Rotate the read-only key? Anything still using it (Prometheus, dashboards, backup crons) gets 401s until updated. Your full key is unaffected. [y/N] ' >&2 local ans; IFS= read -r ans case "$ans" in y|Y|yes|YES) ;; *) die "aborted" ;; esac local out; out="$(api POST /api/key/ro/rotate)" || exit 1 local rk; rk="$(printf '%s' "$out" | jget ro_key)" [ -n "$rk" ] || die "rotate failed: $out" echo "new read-only key: $rk" echo "old read-only key is dead; the full key on this machine is unchanged." return 0 fi [ $# -le 1 ] || die "usage: watchpup key ro [rotate]" local out; out="$(api GET /api/me)" || exit 1 local rk="" if have python3; then rk="$(printf '%s' "$out" | python3 -c 'import sys,json try: d=json.load(sys.stdin) except Exception: d={} print((d.get("user") or {}).get("ro_key") or "")')" else printf '%s\n' "$out"; return 0 fi [ -n "$rk" ] || die "no read-only key visible — are you using a wp_ro_ key already? (rotating needs the full key)" echo "$rk" echo "read-only: GET-only API access, mutations get 403, secrets hidden. Safe for Prometheus/CI/crons." >&2 return 0 fi if [ "$1" = rotate ]; then need_key printf 'Rotate your API key? Anything still using the current key gets 401s until updated. [y/N] ' >&2 local ans; IFS= read -r ans case "$ans" in y|Y|yes|YES) ;; *) die "aborted" ;; esac local out; out="$(api POST /api/key/rotate)" || exit 1 local key; key="$(printf '%s' "$out" | jget api_key)" [ -n "$key" ] || die "rotate failed: $out" save_key "$key" echo "new key saved here: $key" echo "old key is dead — update scripts, Prometheus scrapes and other machines." return 0 fi case "$1" in wp_*) ;; *) die "API keys start with wp_" ;; esac load_key; save_key "$1" } cmd_add() { need_key local kind=http spec name interval case "${1:-}" in tcp|tls|dns|domain|heartbeat) kind="$1"; shift ;; http) shift ;; esac [ $# -ge 1 ] || die "usage: watchpup add [http|tcp|tls|dns|domain|heartbeat] [name] [interval_s] watchpup add https://example.com [name] watchpup add tcp db.example.com:5432 [name] watchpup add tls example.com [name] # cert-expiry watch watchpup add dns example.com [TYPE] [expect=VALUE] [name] # record watch (A/AAAA/CNAME/MX/TXT/NS/SRV/CAA) watchpup add domain example.com [name] # registration-expiry watch watchpup add heartbeat nightly-backup [interval_s] [cron=\"0 3 * * *\"] [tz=Europe/Berlin]" local body if [ "$kind" = heartbeat ]; then name="$1"; shift local cron='' tz='' interval=3600 while [ $# -ge 1 ]; do case "$1" in cron=*) cron="${1#cron=}" ;; tz=*) tz="${1#tz=}" ;; *[!0-9]*|'') die "expected an interval in seconds, cron=\"0 3 * * *\" or tz=Europe/Berlin (got: $1)" ;; *) interval="$1" ;; esac shift done body="{\"kind\":\"heartbeat\",\"name\":$(json_escape "$name"),\"interval\":$interval" [ -n "$cron" ] && body="$body,\"cron\":$(json_escape "$cron")" [ -n "$tz" ] && body="$body,\"tz\":$(json_escape "$tz")" body="$body}" elif [ "$kind" = dns ]; then spec="$1"; shift local dtype="" dexpect="" dinterval="" name="$spec" while [ $# -ge 1 ]; do case "$1" in expect=*) dexpect="${1#expect=}" ;; A|AAAA|CNAME|MX|TXT|NS|SRV|CAA|a|aaaa|cname|mx|txt|ns|srv|caa) dtype="$1" ;; ''|*[!0-9]*) name="$1" ;; *) dinterval="$1" ;; esac shift done body="{\"kind\":\"dns\",\"name\":$(json_escape "$name"),\"target\":$(json_escape "$spec")" [ -n "$dtype" ] && body="$body,\"dns_type\":$(json_escape "$dtype")" [ -n "$dexpect" ] && body="$body,\"dns_expect\":$(json_escape "$dexpect")" [ -n "$dinterval" ] && body="$body,\"interval\":$dinterval" body="$body}" else spec="$1"; name="${2:-$spec}"; interval="${3:-}" case "$interval" in *[!0-9]*) die "interval must be seconds (e.g. 60)" ;; esac local tkey=url case "$kind" in tcp|tls) tkey=target ;; domain) tkey=domain ;; esac body="{\"kind\":\"$kind\",\"name\":$(json_escape "$name"),\"$tkey\":$(json_escape "$spec")" [ -n "$interval" ] && body="$body,\"interval\":$interval" body="$body}" fi local out; out="$(api POST /api/monitors "$body")" || exit 1 printf '%s' "$out" | render " m = d.get('monitor', d) print(f\"created {m.get('kind')} monitor #{m.get('id')}: {m.get('name')}\") if m.get('ping_url'): print('ping it from your job: curl -fsS ' + m['ping_url']) if m.get('ping_email'): print('or email a ping to: ' + m['ping_email']) if m.get('badge_url'): print('badge: ' + m['badge_url'])" } cmd_ls() { need_key local qs='' tagf='' if [ $# -ge 1 ]; then case "$1" in tag=*) tagf="${1#tag=}" ;; *) tagf="$1" ;; # bare arg treated as a tag filter: watchpup ls prod esac case "$tagf" in *[!a-zA-Z0-9_.,-]*|'') die "usage: watchpup ls [tag=NAME[,NAME]] — tags are lowercase letters, digits, - _ ." ;; esac qs="?tag=$tagf" fi api GET "/api/monitors$qs" | WP_TAGF="$tagf" render " import os mons = d.get('monitors', []) tagf = os.environ.get('WP_TAGF','') if not mons: print(f'no monitors tagged \"{tagf}\" — watchpup ls shows everything' if tagf else 'no monitors yet — watchpup add https://example.com'); sys.exit() rows = [] for m in mons: tgt = m.get('url') or m.get('target') or m.get('domain') or (m.get('ping_url','')[-12:] and 'ping:…'+m['ping_url'][-8:]) or '' extra = '' if m.get('kind') in ('http','tcp') and m.get('last_ms'): extra = f\"{m['last_ms']}ms\" if m.get('days_left') is not None: extra = f\"{m['days_left']}d left\" if m.get('slow'): extra += ' SLOW' tags = '#' + ' #'.join(m['tags']) if m.get('tags') else '' rows.append((str(m['id']), m['status'], m['kind'], m['name'][:30], tgt[:40], extra, tags)) w = [max(len(r[i]) for r in rows) for i in range(7)] icon = {'up':'✓','down':'✗','maint':'~','paused':'‖','new':'·'} for r in rows: print(icon.get(r[1],'?'), ' '.join(c.ljust(w[i]) for i, c in enumerate(r)).rstrip())" } cmd_tag() { # show or set a monitor's tags need_key local id="${1:-}"; shift || true [ -n "$id" ] || die "usage: watchpup tag ID (show tags) watchpup tag ID prod,eu-west (set tags — replaces the whole list) watchpup tag ID none (clear all tags)" case "$id" in *[!a-zA-Z0-9-]*) die "monitor id looks wrong: $id" ;; esac if [ $# -eq 0 ]; then api GET "/api/monitors/$id" | render " m = d.get('monitor', d) tags = m.get('tags') or [] print(f\"{m.get('name')}: \" + (', '.join(tags) if tags else '(no tags)'))" return fi local val="$1" if [ "$val" = none ] || [ "$val" = "--clear" ]; then val=''; fi local out; out="$(api PATCH "/api/monitors/$id" "{\"tags\":$(json_escape "$val")}")" || exit 1 printf '%s' "$out" | render " m = d.get('monitor', d) tags = m.get('tags') or [] print(f\"{m.get('name')}: \" + (', '.join(tags) if tags else '(no tags)'))" } cmd_status() { need_key local qs='' tagf='' if [ $# -ge 1 ]; then case "$1" in tag=*) tagf="${1#tag=}" ;; *) tagf="$1" ;; # bare arg treated as a tag filter: watchpup status prod esac case "$tagf" in *[!a-zA-Z0-9_.,-]*|'') die "usage: watchpup status [tag=NAME[,NAME]] — tags are lowercase letters, digits, - _ ." ;; esac qs="?tag=$tagf" fi local mons mw mons="$(api GET "/api/monitors$qs")" || exit 1 mw="$(api GET /api/maintenance 2>/dev/null)" || mw='' printf '%s\n' "$mons" | WP_TAGF="$tagf" WP_MAINT="$mw" render " mons = d.get('monitors', []) tagf = os.environ.get('WP_TAGF','') lbl = f' tagged #{tagf}' if tagf else '' if not mons and tagf: print(f'no monitors tagged \"{tagf}\" — watchpup status shows everything'); sys.exit() n = {} for m in mons: n[m['status']] = n.get(m['status'], 0) + 1 parts = [f\"{v} {k}\" for k, v in sorted(n.items())] print(f\"{len(mons)} monitors{lbl}: \" + (', '.join(parts) if parts else 'none')) for m in mons: if m['status'] == 'down': print(f\" ✗ DOWN #{m['id']} {m['name']} — last check {ago(m.get('last_check') or m.get('last_ping'))}\") ws = maintws() act = sorted((w for w in ws if w['state'] == 'active'), key=lambda w: w['end_ts']) upc = sorted((w for w in ws if w['state'] == 'upcoming' and w['start_ts'] - time.time() < 86400), key=lambda w: w['start_ts']) lines = [] for w in act: l = f\" 🔧 maintenance now: {w.get('monitor_name') or 'all monitors'} until {wfmt(w['end_ts'])}\" if w.get('repeat'): l += f\" (repeats {w['repeat']})\" if w.get('note'): l += f\" — {str(w['note'])[:40]}\" lines.append(l) for w in upc: lines.append(f\" 🔧 maintenance in {dur(w['start_ts'] - time.time())}: {w.get('monitor_name') or 'all monitors'} at {wfmt(w['start_ts'])}\") for l in lines[:3]: print(l) if len(lines) > 3: print(f' +{len(lines) - 3} more windows — watchpup maint') sys.exit(1 if n.get('down') else 0)" } cmd_watch() { # live status table, refreshes until ctrl-c need_key local iv=10 tagf='' qs='' a for a in "$@"; do case "$a" in ''|*[!0-9]*) case "$a" in tag=*) tagf="${a#tag=}" ;; *) tagf="$a" ;; esac case "$tagf" in *[!a-zA-Z0-9_.,-]*|'') die "usage: watchpup watch [SECS] [tag=NAME[,NAME]] — refresh interval + optional tag filter" ;; esac ;; *) iv="$a" ;; esac done [ -n "$tagf" ] && qs="?tag=$tagf" [ "$iv" -ge 2 ] || iv=2 have python3 || die "watch needs python3 (plain 'watchpup ls' works without it)" local tty=0; [ -t 1 ] && tty=1 # shellcheck disable=SC2064 trap "[ $tty = 1 ] && printf '\033[?25h'; echo; exit 0" INT TERM [ "$tty" = 1 ] && printf '\033[?25l' local out frame mw while :; do if out="$(api GET "/api/monitors$qs" 2>&1)"; then mw="$(api GET /api/maintenance 2>/dev/null)" || mw='' frame="$(printf '%s' "$out" | WP_COLOR="$tty" WP_IV="$iv" WP_TAGF="$tagf" WP_MAINT="$mw" render " import os C = os.environ.get('WP_COLOR') == '1' def col(code, s): return f'\033[{code}m{s}\033[0m' if C else s mons = d.get('monitors', []) tagf = os.environ.get('WP_TAGF','') lbl = f' tagged #{tagf}' if tagf else '' n = {} for m in mons: n[m['status']] = n.get(m['status'], 0) + 1 parts = [] if n.get('down'): parts.append(col('31;1', f\"{n['down']} DOWN\")) if n.get('up'): parts.append(col('32', f\"{n['up']} up\")) for k in ('maint', 'paused', 'new'): if n.get(k): parts.append(f'{n[k]} {k}') now = time.strftime('%H:%M:%S', time.gmtime()) iv = os.environ.get('WP_IV') print(f\"watchpup · {len(mons)} monitors{lbl} · \" + (', '.join(parts) if parts else 'none') + f' · {now}Z') print(col('2', f'refreshing every {iv}s — ctrl-c to quit')) ws = maintws() act = sorted((w for w in ws if w['state'] == 'active'), key=lambda w: w['end_ts']) upc = sorted((w for w in ws if w['state'] == 'upcoming' and w['start_ts'] - time.time() < 86400), key=lambda w: w['start_ts']) mls = [] for w in act: mls.append(f\"🔧 {w.get('monitor_name') or 'all monitors'} under maintenance until {wfmt(w['end_ts'])}\" + (f\" — {str(w['note'])[:40]}\" if w.get('note') else '')) for w in upc: mls.append(f\"🔧 upcoming: {w.get('monitor_name') or 'all monitors'} at {wfmt(w['start_ts'])} (in {dur(w['start_ts'] - time.time())})\") for l in mls[:3]: print(col('36', l)) if len(mls) > 3: print(col('2', f'+{len(mls) - 3} more — watchpup maint')) if not mons: if tagf: print(); print(f'no monitors carry tag \"{tagf}\" — drop the filter, or tag one: watchpup tag ID {tagf}') sys.exit() print() order = {'down': 0, 'maint': 1, 'up': 2} rows = [] for m in sorted(mons, key=lambda m: order.get(m['status'], 3)): tgt = m.get('url') or m.get('target') or m.get('domain') or ('ping:…' + m['ping_url'][-8:] if m.get('ping_url') else '') extra = '' if m.get('kind') in ('http', 'tcp') and m.get('last_ms'): extra = f\"{m['last_ms']}ms\" if m.get('days_left') is not None: extra = f\"{m['days_left']}d left\" if m.get('slow'): extra += ' SLOW' if m['status'] == 'maint': ends = [w['end_ts'] for w in act if not w.get('monitor_id') or w['monitor_id'] == m['id']] if ends: extra = f'until {wfmt(max(ends))}' seen = ago(m.get('last_check') or m.get('last_ping')) rows.append((m['status'], str(m['id']), m['kind'], m['name'][:28], tgt[:38], extra, seen)) w = [max(len(r[i]) for r in rows) for i in range(7)] icon = {'up': ('32', '✓'), 'down': ('31;1', '✗'), 'maint': ('36', '~'), 'paused': ('2', '‖'), 'new': ('2', '·')} for r in rows: code, ic = icon.get(r[0], ('0', '?')) line = ' '.join(c.ljust(w[i + 1]) for i, c in enumerate(r[1:])).rstrip() print(col(code, ic) + ' ' + (col(code, line) if r[0] == 'down' else line))")" || frame="$out" else frame="watchpup: $out (retrying every ${iv}s)" fi if [ "$tty" = 1 ]; then printf '\033[H\033[2J%s\n' "$frame"; else printf '%s\n---\n' "$frame"; fi sleep "$iv" done } cmd_rm() { need_key [ $# -ge 1 ] || die "usage: watchpup rm " api DELETE "/api/monitors/$1" >/dev/null && echo "deleted monitor $1" } cmd_setstatus() { # $1=paused|new $2=id need_key [ $# -ge 2 ] || die "usage: watchpup pause|resume " api PATCH "/api/monitors/$2" "{\"status\":\"$1\"}" >/dev/null && echo "monitor $2 ${3:-updated}" } cmd_incidents() { need_key local days="${1:-30}" api GET "/api/incidents?days=$days" | render " inc = d.get('incidents', []) if not inc: print(f\"no incidents in the last {d.get('days')} days 🎉\"); sys.exit() for i in inc: when = time.strftime('%Y-%m-%d %H:%M', time.gmtime(i['started'])) state = ('down ' + dur(i['duration_s']) + ', resolved') if i.get('resolved') else 'ONGOING, down ' + dur(time.time() - i['started']) line = f\"#{i.get('id','?')} {when}Z {i.get('monitor','?')}: {state}\" if i.get('detail'): line += f\" ({i['detail'][:60]})\" print(line) if i.get('acked_by'): at = time.strftime('%H:%M', time.gmtime(i['ack_ts'])) if i.get('ack_ts') else '?' print(' \u270b acked by ' + str(i['acked_by']) + ' at ' + at + 'Z' + ('' if i.get('resolved') else ' (reminders paused)')) for u in i.get('updates') or []: ut = time.strftime('%H:%M', time.gmtime(u['ts'])) who = (' [' + str(u['author']) + ']') if u.get('author') else '' uid = '#' + str(u.get('id','?')) print(' ' + uid + ' ' + u.get('label','update') + ' ' + ut + 'Z' + who + ': ' + str(u.get('text','')).replace(chr(10), ' ')) if i.get('note'): print(' note: ' + str(i['note']).replace(chr(10), chr(10) + ' ')) print() print('post a timeline update: watchpup update [investigating|identified|monitoring|resolved] \"latest news\"') print('add a public postmortem note: watchpup note \"what happened\"') print('acknowledge an ongoing incident (pause reminders): watchpup ack ')" } cmd_audit() { # account activity log: who changed what, from where need_key local days="${1:-30}" case "$days" in *[!0-9]*) die "usage: watchpup audit [DAYS] # activity log (default 30 days, max 90)" ;; esac api GET "/api/audit?days=$days&limit=200" | render " es = d.get('entries', []) if not es: print(f\"no recorded activity in the last {d.get('days')} days\"); sys.exit() for e in es: when = time.strftime('%Y-%m-%d %H:%M', time.gmtime(e['ts'])) line = f\"{when}Z {e['actor']} ({e['via']}) {e['action']}\" tgt = e.get('target_name') or e.get('target') if tgt: line += f\" {tgt}\" if e.get('detail'): line += f\" \u00b7 {e['detail'][:70]}\" print(line) print() print(f\"{len(es)} entries (last {d.get('days')} days, newest first) \u2014 kept 90 days, field names only, never values\")" } cmd_ack() { # acknowledge / un-acknowledge an ongoing incident need_key [ $# -ge 1 ] || die "usage: watchpup ack # \"I'm on it\" — pauses still-down reminder alerts watchpup ack rm # un-ack: reminders resume incident ids: watchpup incidents. Acks are team-internal — never shown on public status pages." local id="$1"; shift case "$id" in *[!0-9]*|'') die "incident id must be a number (ids: watchpup incidents)" ;; esac if [ "${1:-}" = "rm" ]; then api DELETE "/api/incidents/$id/ack" >/dev/null || exit 1 echo "incident $id un-acknowledged — still-down reminders resume" else local out; out="$(api POST "/api/incidents/$id/ack")" || exit 1 echo "$out" | render " who = d.get('acked_by','?') print(('already acknowledged by ' if d.get('already') else 'acknowledged by ') + str(who) + ' \u2014 still-down reminders paused until recovery (undo: watchpup ack $id rm)')" fi } cmd_note() { # attach/clear a public postmortem note on an incident need_key [ $# -ge 2 ] || die "usage: watchpup note \"what happened & what you did\" watchpup note --clear incident ids: watchpup incidents. The note is published with the incident on your public status pages, status JSON, Atom feed and weekly digest (max 500 chars)." local id="$1"; shift case "$id" in *[!0-9]*|'') die "incident id must be a number (ids: watchpup incidents)" ;; esac local text="$*" [ "$text" = "--clear" ] && text="" local out; out="$(api POST "/api/incidents/$id/note" "{\"note\":$(json_escape "$text")}")" || exit 1 if [ -n "$text" ]; then echo "note saved on incident $id — it now shows with the incident on your public status pages." else echo "note cleared on incident $id"; fi } cmd_update() { # post/remove a public timeline update on an incident need_key [ $# -ge 2 ] || die "usage: watchpup update [investigating|identified|monitoring|resolved] \"latest news\" watchpup update rm incident ids: watchpup incidents. Updates publish chronologically with the incident on your public status pages, status JSON and Atom feed — open status pages refresh in visitors' browsers within ~30s (max 500 chars, 20 updates per incident)." local id="$1"; shift case "$id" in *[!0-9]*|'') die "incident id must be a number (ids: watchpup incidents)" ;; esac if [ "$1" = "rm" ]; then [ $# -eq 2 ] || die "usage: watchpup update rm " case "$2" in *[!0-9]*|'') die "update id must be a number (shown by: watchpup incidents)" ;; esac api DELETE "/api/incidents/$id/updates/$2" >/dev/null || exit 1 echo "update $2 removed from incident $id"; return fi local label="update" case "$1" in investigating|identified|monitoring|resolved) label="$1"; shift ;; esac [ $# -ge 1 ] || die "missing update text" local text="$*" local out; out="$(api POST "/api/incidents/$id/updates" "{\"label\":\"$label\",\"text\":$(json_escape "$text")}")" || exit 1 echo "update ($label) posted on incident $id — it's live on your status pages now." local sn; sn="$(printf '%s' "$out" | jget subscriber_notices)" case "$sn" in ''|0|None) ;; # no subscribers — stay quiet *[!0-9]*) echo "subscriber notices: $sn" ;; *) echo "$sn status-page subscriber(s) will be emailed this update" ;; esac } cmd_check() { [ $# -ge 1 ] || die "usage: watchpup check https://example.com (no account needed)" load_key local q; q="$(printf '%s' "$1" | sed 's/&/%26/g; s/#/%23/g; s/ /%20/g; s/+/%2B/g')" api GET "/api/check?url=$q" | render " up = d.get('up') bits = [d.get('url', '')] bits.append('UP ✓' if up else 'DOWN ✗') if d.get('http_status'): bits.append(f\"HTTP {d['http_status']}\") if d.get('response_ms') is not None: bits.append(f\"{d['response_ms']}ms\") if d.get('detail'): bits.append(d['detail']) print(' '.join(str(b) for b in bits)) sys.exit(0 if up else 1)" } cmd_ping() { [ $# -ge 1 ] || die "usage: watchpup ping [start|fail]" local target="$1" sig="${2:-}" case "$sig" in ''|start|fail) ;; *) die "usage: watchpup ping [start|fail]" ;; esac case "$target" in http*) ;; *) load_key; target="$WP_URL/ping/$target" ;; esac [ -n "$sig" ] && target="$target/$sig" curl -fsS -A "$UA" "$target" >/dev/null && echo "pinged${sig:+ ($sig)}" } cmd_2fa() { need_key case "${1:-status}" in status) local out; out="$(api GET /api/me)" || exit 1 if printf '%s' "$out" | grep -q '"totp": *true'; then echo "2FA: on (logins need password + authenticator code; API key unaffected)" else echo "2FA: off — turn it on with: watchpup 2fa on" fi ;; on) local out; out="$(api POST /api/2fa/setup)" || exit 1 echo "secret: $(printf '%s' "$out" | jget secret)" echo "otpauth: $(printf '%s' "$out" | jget otpauth)" echo "add the secret to any TOTP authenticator app (6 digits, 30 s)." local tf; printf 'enter the 6-digit code your app shows: ' >&2; IFS= read -r tf out="$(api POST /api/2fa/enable "{\"code\":$(json_escape "$tf")}")" || exit 1 echo "2FA is ON. One-time recovery codes (shown exactly once — save them now):" if have python3; then printf '%s' "$out" | python3 -c 'import sys,json for c in json.load(sys.stdin).get("recovery_codes", []): print(" " + c)' else printf '%s\n' "$out" fi echo "other browser sessions were logged out; your API key is unchanged." ;; off) local pw; printf 'account password (hidden): ' >&2; IFS= read -rs pw; echo >&2 local out; out="$(api POST /api/2fa/disable "{\"password\":$(json_escape "$pw")}")" || exit 1 printf '%s' "$out" | jget note "2FA is off." ;; *) die "usage: watchpup 2fa [status|on|off]" ;; esac } cmd_passwd() { need_key local cur nw printf 'current password (hidden): ' >&2 IFS= read -rs cur; echo >&2 [ -n "$cur" ] || die "current password required" printf 'new ' >&2 nw="$(read_password)" local body; body="{\"current_password\":$(json_escape "$cur"),\"new_password\":$(json_escape "$nw")}" local out; out="$(api POST /api/password/change "$body")" || exit 1 echo "password updated. Other browser sessions were logged out; your API key on this machine still works." } cmd_channels() { need_key local sub="${1:-ls}"; shift || true case "$sub" in ls|list) api GET /api/channels | render " chs = d.get('channels', []) if not chs: print('no alert channels yet — watchpup channels add ntfy https://ntfy.sh/your-topic'); sys.exit() rows = []; warn = False for c in chs: notes = [] if not c.get('verified'): notes.append('awaiting confirmation') if c.get('quiet'): q = f\"🌙 quiet {c['quiet']} {c.get('quiet_tz') or 'UTC'}\" if c.get('quiet_down'): q += ' (down alerts break through)' notes.append(q) dl = c.get('delay_s') or 0 if dl: m, sec = divmod(dl, 60) notes.append('⏱ down alerts after ' + (f'{m}m' if not sec else f'{dl}s') + ' of downtime') if c.get('last_error'): warn = True e = str(c['last_error'])[:70] ts = c.get('last_error_ts') notes.append(f'⚠ {e} ({ago(ts)})' if ts else f'⚠ {e}') rows.append((str(c['id']), c['kind'], str(c['target'])[:60], ' · '.join(notes))) w = [max(len(r[i]) for r in rows) for i in range(4)] for r in rows: print(' '.join(col.ljust(w[i]) for i, col in enumerate(r)).rstrip()) if warn: print('⚠ = last delivery failed. Transient failures retry after 1, 5 and 15 min; this clears on the next success. Fix the endpoint, then: watchpup test ID', file=sys.stderr)" ;; add) [ $# -ge 2 ] || die "usage: watchpup channels add email|discord|slack|webhook|ntfy|telegram|teams|googlechat TARGET watchpup channels add email ops@example.com watchpup channels add ntfy https://ntfy.sh/your-topic watchpup channels add webhook https://example.com/hook watchpup channels add telegram BOT_TOKEN/CHAT_ID (see /docs#telegram) watchpup channels add teams https://prod-…logic.azure.com/workflows/… (see /docs#teams) watchpup channels add googlechat https://chat.googleapis.com/v1/spaces/… (see /docs#googlechat)" local body; body="{\"kind\":$(json_escape "$1"),\"target\":$(json_escape "$2")}" local out; out="$(api POST /api/channels "$body")" || exit 1 printf '%s' "$out" | render " cid = d.get('id') if d.get('verified'): print(f'channel #{cid} added — try it: watchpup test {cid}') else: print(f\"channel #{cid} added — {d.get('note', 'awaiting recipient confirmation')}\")" ;; rm|delete) [ $# -ge 1 ] || die "usage: watchpup channels rm (ids: watchpup channels)" api DELETE "/api/channels/$1" >/dev/null && echo "deleted channel $1" ;; secret) [ $# -ge 1 ] || die "usage: watchpup channels secret [rotate] prints the webhook signing secret (whsec_…); 'rotate' issues a new one. Watchpup signs every webhook POST: X-Watchpup-Signature: t=,v1=HMAC-SHA256(secret, \".\")" local cid="$1" case "$cid" in *[!0-9]*|'') die "channel id must be a number (ids: watchpup channels)";; esac if [ "${2:-}" = "rotate" ]; then local out; out="$(api POST "/api/channels/$cid/secret/rotate")" || exit 1 printf '%s' "$out" | render " print(d.get('secret','')) n = d.get('note'); n and print(f'# {n}', file=sys.stderr)" else api GET /api/channels | render " for c in d.get('channels', []): if str(c['id']) == '$cid': s = c.get('secret') if not s: print('channel $cid has no signing secret (webhook channels only)', file=sys.stderr); sys.exit(1) print(s); sys.exit() print('no channel with id $cid', file=sys.stderr); sys.exit(1)" fi ;; quiet) [ $# -ge 2 ] || die "usage: watchpup channels quiet ID 22:00-07:00 [tz=Europe/Berlin] [down=on|off] watchpup channels quiet ID off Do-not-disturb window for one channel: alerts inside it are held and delivered when it ends (original event time kept). down=on lets down/up/reminder alerts break through, so quiet only silences slow/fast/budget noise. Times are wall-clock in tz (default UTC); the window may wrap past midnight. ids: watchpup channels" local cid="$1"; shift case "$cid" in *[!0-9]*|'') die "channel id must be a number (ids: watchpup channels)";; esac if [ "$1" = off ]; then api PATCH "/api/channels/$cid" '{"quiet":""}' >/dev/null && echo "quiet hours off for channel $cid" return 0 fi local win="$1"; shift local body; body="{\"quiet\":$(json_escape "$win")" local kv for kv in "$@"; do case "$kv" in tz=*) body="$body,\"quiet_tz\":$(json_escape "${kv#tz=}")" ;; down=on|down=true|down=1) body="$body,\"quiet_down\":true" ;; down=off|down=false|down=0) body="$body,\"quiet_down\":false" ;; *) die "unknown option: $kv (tz=Zone, down=on|off)" ;; esac done body="$body}" local out; out="$(api PATCH "/api/channels/$cid" "$body")" || exit 1 printf '%s' "$out" | render " c = d.get('channel', {}) line = f\"channel {c.get('id')}: quiet {c.get('quiet')} {c.get('quiet_tz') or 'UTC'}\" if c.get('quiet_down'): line += ' — down & recovery alerts break through' print(line) print('held alerts are delivered when the window ends; reminders are skipped, test alerts always send')" ;; delay) [ $# -ge 2 ] || die "usage: watchpup channels delay ID DURATION|off (e.g. delay 3 5m, delay 3 300, delay 3 off) Escalation delay for one channel: the down alert waits DURATION and is cancelled if the monitor recovers first — the channel never hears about blips shorter than the delay (no down, no recovery, no reminders). 30s–24h; other channels still alert immediately. ids: watchpup channels" local cid="$1"; shift case "$cid" in *[!0-9]*|'') die "channel id must be a number (ids: watchpup channels)";; esac local dv="$1" secs if [ "$dv" = off ] || [ "$dv" = 0 ]; then api PATCH "/api/channels/$cid" '{"delay_s":0}' >/dev/null && echo "escalation delay off for channel $cid — it alerts immediately again" return 0 fi case "$dv" in *m) secs=$(( ${dv%m} * 60 )) 2>/dev/null || die "bad duration: $dv" ;; *h) secs=$(( ${dv%h} * 3600 )) 2>/dev/null || die "bad duration: $dv" ;; *s) secs="${dv%s}" ;; *) secs="$dv" ;; esac case "$secs" in *[!0-9]*|'') die "bad duration: $dv (use seconds, or 5m / 1h)";; esac local out; out="$(api PATCH "/api/channels/$cid" "{\"delay_s\":$secs}")" || exit 1 printf '%s' "$out" | render " c = d.get('channel', {}) dl = c.get('delay_s') or 0 m, sec = divmod(dl, 60) dur = f'{m}m' if not sec else f'{dl}s' print(f\"channel {c.get('id')}: down alerts only after {dur} of continuous downtime\") print('recovers-before-that outages are silently skipped for this channel (no down, no recovery)')" ;; *) die "usage: watchpup channels [add KIND TARGET | quiet ID WINDOW|off [tz=Zone] [down=on|off] | delay ID DURATION|off | secret ID [rotate] | rm ID]" ;; esac } cmd_pages() { need_key local sub="${1:-ls}"; shift || true case "$sub" in ls|list) api GET /api/status-pages | render " pgs = d.get('status_pages', []) if not pgs: print('no status pages yet — watchpup pages add \"My services\"'); sys.exit() rows = [] for p in pgs: scope = 'auto (all monitors)' if p.get('auto_include') else (f\"auto (tag: {p['auto_include_tag']})\" if p.get('auto_include_tag') else f\"{len(p.get('monitor_ids', []))} monitor(s)\") if p.get('protected'): scope += ' \U0001F512' rows.append((p['slug'], str(p['title'])[:40], scope, '$WP_URL/s/' + p['slug'])) w = [max(len(r[i]) for r in rows) for i in range(4)] for r in rows: print(' '.join(c.ljust(w[i]) for i, c in enumerate(r)).rstrip())" ;; add) [ $# -ge 1 ] || die "usage: watchpup pages add TITLE [slug] [--auto] watchpup pages add 'My services' # snapshots your current monitors watchpup pages add 'My services' mypage --auto # always shows ALL monitors (current + future)" local title="$1"; shift local slug="" auto="" while [ $# -ge 1 ]; do case "$1" in --auto) auto=1 ;; -*) die "unknown flag: $1 (usage: watchpup pages add TITLE [slug] [--auto])" ;; *) slug="$1" ;; esac shift done local body; body="{\"title\":$(json_escape "$title")" [ -n "$slug" ] && body="$body,\"slug\":$(json_escape "$slug")" [ -n "$auto" ] && body="$body,\"auto_include\":true" body="$body}" local out; out="$(api POST /api/status-pages "$body")" || exit 1 printf '%s' "$out" | render " print(f\"status page created: {d.get('url')}\") print(f\"manage: watchpup pages set {d.get('slug')} title=... auto=on|off monitors=ID,ID\")" ;; set) [ $# -ge 2 ] || die "usage: watchpup pages set SLUG key=value ... keys: title=... auto=on|off autotag=TAG (page always shows monitors with that tag; autotag= turns it off) monitors=ID,ID (ids: watchpup ls; monitors= empties the page) accent=#58a6ff (accent= clears) logo=https://... (logo= clears) password=SECRET (makes the page private, 4-64 chars; password= makes it public again) sla=on|off (publish month-to-date SLA + error-budget state for monitors with an sla_target)" local slug="$1"; shift local body="{" first=1 kv k v for kv in "$@"; do case "$kv" in *=*) ;; *) die "expected key=value, got: $kv" ;; esac k="${kv%%=*}"; v="${kv#*=}" [ "$first" = 1 ] || body="$body,"; first=0 case "$k" in title) body="$body\"title\":$(json_escape "$v")" ;; auto) case "$v" in on|true|1) body="$body\"auto_include\":true" ;; off|false|0) body="$body\"auto_include\":false" ;; *) die "auto must be on or off" ;; esac ;; autotag) body="$body\"auto_include_tag\":$(json_escape "$v")" ;; monitors) local ids="" id if [ -n "$v" ]; then IFS=',' read -ra _mids <<< "$v" for id in "${_mids[@]}"; do case "$id" in ''|*[!0-9a-fA-F-]*) die "monitors must be a comma-separated list of monitor ids (watchpup ls)" ;; esac ids="$ids${ids:+,}$(json_escape "$id")" done fi body="$body\"monitor_ids\":[$ids]" ;; accent) body="$body\"accent\":$(json_escape "$v")" ;; logo|logo_url) body="$body\"logo_url\":$(json_escape "$v")" ;; password) body="$body\"password\":$(json_escape "$v")" ;; sla|show_sla) case "$v" in on|true|1) body="$body\"show_sla\":true" ;; off|false|0) body="$body\"show_sla\":false" ;; *) die "sla must be on or off" ;; esac ;; *) die "unknown key: $k (title, auto, autotag, monitors, accent, logo, password, sla)" ;; esac done body="$body}" api PATCH "/api/status-pages/$slug" "$body" >/dev/null && echo "updated $WP_URL/s/$slug" ;; group|groups) [ $# -ge 1 ] || die "usage: watchpup pages group SLUG # show grouping watchpup pages group SLUG MONITOR-ID 'Group name' # put a monitor in a group (section on /s/SLUG) watchpup pages group SLUG MONITOR-ID --clear # ungroup one monitor watchpup pages group SLUG --clear-all # remove all groups from the page monitor ids: watchpup ls · group names ≤40 chars, ≤20 groups per page" local slug="$1"; shift if [ $# -eq 0 ]; then have python3 || die "python3 is required for this command" local pgs mons pgs="$(api GET /api/status-pages)" || exit 1 mons="$(api GET /api/monitors)" || exit 1 printf '%s' "$mons" | WP_PAGES="$pgs" WP_SLUG="$slug" render " import os pages = json.loads(os.environ['WP_PAGES']).get('status_pages', []) pg = next((p for p in pages if p.get('slug') == os.environ['WP_SLUG']), None) if not pg: print('watchpup: status page not found: ' + os.environ['WP_SLUG'] + ' (slugs: watchpup pages)', file=sys.stderr); sys.exit(1) groups = pg.get('groups') or {} byid = {m['id']: m for m in d.get('monitors', [])} ids = list(byid) if pg.get('auto_include') else pg.get('monitor_ids', []) if not ids: print('this page shows no monitors yet — watchpup pages set ' + pg['slug'] + ' monitors=ID,ID'); sys.exit() rows = [(i, str(byid.get(i, {}).get('name', '?'))[:40], str(groups.get(i) or '-')) for i in ids] w = [max(len(r[j]) for r in rows) for j in range(3)] for r in rows: print(' '.join(c.ljust(w[j]) for j, c in enumerate(r)).rstrip()) extra = sum(1 for k in groups if k not in set(ids)) if extra: print(f'({extra} grouped monitor id(s) are not currently shown on this page)') if all(r[2] == '-' for r in rows): print('no groups yet — watchpup pages group ' + pg['slug'] + \" MONITOR-ID 'Group name'\")" return fi if [ "$1" = "--clear-all" ]; then api PATCH "/api/status-pages/$slug" '{"groups":{}}' >/dev/null && echo "removed all groups from $WP_URL/s/$slug" return fi [ $# -ge 2 ] || die "usage: watchpup pages group SLUG MONITOR-ID 'Group name'|--clear (ids: watchpup ls)" local mid="$1" gname="$2" case "$mid" in ''|*[!0-9a-fA-F-]*) die "bad monitor id (ids: watchpup ls)" ;; esac [ -n "$gname" ] || die "empty group name (use --clear to ungroup)" have python3 || die "python3 is required for this command" local pgs body pgs="$(api GET /api/status-pages)" || exit 1 body="$(printf '%s' "$pgs" | WP_SLUG="$slug" WP_MID="$mid" WP_GNAME="$gname" python3 -c ' import sys, json, os try: d = json.load(sys.stdin) except Exception: sys.exit(1) pg = next((p for p in d.get("status_pages", []) if p.get("slug") == os.environ["WP_SLUG"]), None) if not pg: print("watchpup: status page not found: " + os.environ["WP_SLUG"] + " (slugs: watchpup pages)", file=sys.stderr); sys.exit(1) g = dict(pg.get("groups") or {}) mid, name = os.environ["WP_MID"], os.environ["WP_GNAME"] if name == "--clear": if mid not in g: print("watchpup: monitor " + mid + " is not in any group on this page", file=sys.stderr); sys.exit(1) g.pop(mid) else: g[mid] = name print(json.dumps({"groups": g}))')" || exit 1 api PATCH "/api/status-pages/$slug" "$body" >/dev/null || exit 1 if [ "$gname" = "--clear" ]; then echo "ungrouped $mid on $WP_URL/s/$slug" else echo "grouped $mid under \"$gname\" on $WP_URL/s/$slug"; fi ;; subs|subscribers) [ $# -ge 1 ] || die "usage: watchpup pages subs SLUG [rm SUB-ID]" local slug="$1"; shift if [ "${1:-}" = "rm" ]; then [ $# -ge 2 ] || die "usage: watchpup pages subs SLUG rm SUB-ID" api DELETE "/api/status-pages/$slug/subscribers/$2" >/dev/null && echo "removed subscriber $2" return fi api GET "/api/status-pages/$slug/subscribers" | render " subs = d.get('subscribers', []) if not subs: print('no subscribers yet — the subscribe form is on $WP_URL/s/$slug'); sys.exit() for s in subs: tag = '' if s.get('verified') else ' (unconfirmed)' print(f\"{s['id']} {s['email']}{tag}\")" ;; rm|delete) [ $# -ge 1 ] || die "usage: watchpup pages rm SLUG (slugs: watchpup pages)" api DELETE "/api/status-pages/$1" >/dev/null && echo "deleted status page $1" ;; *) die "usage: watchpup pages [add TITLE [slug] [--auto] | set SLUG key=value... | group SLUG [ID NAME|ID --clear|--clear-all] | subs SLUG [rm ID] | rm SLUG]" ;; esac } cmd_maint() { need_key local sub="${1:-ls}"; shift || true case "$sub" in ls|list) api GET /api/maintenance | render " ws = d.get('maintenance_windows', []) if not ws: print('no maintenance windows — watchpup maint add 1h (quiets checks + alerts for an hour)'); sys.exit() f = lambda ts: time.strftime('%b %d %H:%M', time.gmtime(ts)) rows = [] for w in ws: scope = w.get('monitor_name') or ('all monitors' if w.get('scope') == 'all' else str(w.get('monitor_id'))[:12]) tags = [] if w.get('repeat'): tags.append('repeats ' + str(w['repeat'])) if w.get('note'): tags.append(str(w['note'])[:40]) rows.append((str(w['id']), str(w.get('state', '')), scope[:30], f\"{f(w['start_ts'])} → {f(w['end_ts'])} UTC\", ' · '.join(tags))) wd = [max(len(r[i]) for r in rows) for i in range(5)] for r in rows: print(' '.join(c.ljust(wd[i]) for i, c in enumerate(r)).rstrip())" ;; add) [ $# -ge 1 ] || die "usage: watchpup maint add DURATION [key=value ...] DURATION: seconds, or 45m / 2h / 1d (max 7d) keys: monitor=ID (default: all monitors; ids: watchpup ls) start=2026-07-27T02:00:00Z | unix-seconds (default: now) repeat=daily|weekly (window must be shorter than its period) note='DB upgrade' (shown to status-page visitors, ≤140 chars) examples: watchpup maint add 1h # everything quiet for an hour, starting now watchpup maint add 30m start=2026-07-27T02:00:00Z repeat=daily note='nightly backup'" local durs; durs="$(dur_secs "$1")" || die "bad duration: $1 (use seconds or 45m / 2h / 1d)" shift local body="{\"duration\":$durs" kv k v for kv in "$@"; do case "$kv" in *=*) ;; *) die "expected key=value, got: $kv (keys: monitor, start, end, repeat, note)" ;; esac k="${kv%%=*}"; v="${kv#*=}" case "$k" in monitor) body="$body,\"monitor_id\":$(json_escape "$v")" ;; start) body="$body,\"start\":$(json_escape "$v")" ;; end) body="$body,\"end\":$(json_escape "$v")" ;; repeat) body="$body,\"repeat\":$(json_escape "$v")" ;; note) body="$body,\"note\":$(json_escape "$v")" ;; *) die "unknown key: $k (monitor, start, end, repeat, note)" ;; esac done body="$body}" local out; out="$(api POST /api/maintenance "$body")" || exit 1 printf '%s' "$out" | render " f = lambda ts: time.strftime('%b %d %H:%M', time.gmtime(ts)) scope = 'all monitors' if d.get('scope') == 'all' else f\"monitor {d.get('monitor_id')}\" now = time.time() state = 'in progress' if d.get('start_ts', 0) <= now else 'scheduled' line = f\"maintenance {state}: {f(d['start_ts'])} → {f(d['end_ts'])} UTC ({scope})\" if d.get('repeat'): line += f\", repeats {d['repeat']} until cancelled\" print(line) sn = d.get('subscriber_notices') if isinstance(sn, int) and sn > 0: print(f'{sn} status-page subscriber(s) will be emailed') elif isinstance(sn, str): print(f'subscriber notices: {sn}') print(f\"cancel: watchpup maint rm {d.get('id')}\")" ;; rm|cancel|delete) [ $# -ge 1 ] || die "usage: watchpup maint rm (ids: watchpup maint)" case "$1" in ''|*[!A-Za-z0-9_]*) die "bad window id (ids: watchpup maint)" ;; esac api DELETE "/api/maintenance/$1" >/dev/null && echo "cancelled $1 — checks resume within a minute" ;; *) die "usage: watchpup maint [ls | add DURATION [key=value ...] | rm ID]" ;; esac } cmd_test() { need_key [ $# -ge 1 ] || die "usage: watchpup test (ids: watchpup channels)" local out; out="$(api POST "/api/channels/$1/test")" || exit 1 printf '%s' "$out" | jget note "test alert sent" } cmd_delay() { # per-monitor alert delay: every channel waits this long for this monitor need_key [ $# -ge 1 ] || die "usage: watchpup delay [DURATION|off] (e.g. delay ID 10m, delay ID 600, delay ID off) Per-monitor escalation delay: EVERY alert channel waits at least DURATION before hearing this monitor's down alert — cancelled if it recovers first (then nobody hears anything). Stacks with each channel's own delay (watchpup channels delay): the longer of the two wins. 30s–24h. monitor ids: watchpup ls" local id="$1"; shift || true case "$id" in *[!A-Za-z0-9-]*|'') die "bad monitor id (ids: watchpup ls)" ;; esac if [ $# -eq 0 ]; then api GET "/api/monitors/$id" | render " m = d.get('monitor', {}) dl = m.get('alert_delay_s') or 0 if not dl: print(f\"{m.get('name')}: no alert delay — channels alert as soon as it goes down (their own delays still apply)\") else: mm, sec = divmod(dl, 60) dur = f'{mm}m' if not sec else f'{dl}s' print(f\"{m.get('name')}: every channel waits at least {dur} of continuous downtime before its down alert\") print('clear: watchpup delay ' + str(m.get('id')) + ' off')" return fi local dv="$1" secs if [ "$dv" = off ] || [ "$dv" = 0 ]; then api PATCH "/api/monitors/$id" '{"alert_delay_s":0}' >/dev/null && echo "alert delay off for monitor $id — channels alert immediately again (their own delays still apply)" return 0 fi case "$dv" in *m) secs=$(( ${dv%m} * 60 )) 2>/dev/null || die "bad duration: $dv" ;; *h) secs=$(( ${dv%h} * 3600 )) 2>/dev/null || die "bad duration: $dv" ;; *s) secs="${dv%s}" ;; *) secs="$dv" ;; esac case "$secs" in *[!0-9]*|'') die "bad duration: $dv (use seconds, or 5m / 1h)";; esac local out; out="$(api PATCH "/api/monitors/$id" "{\"alert_delay_s\":$secs}")" || exit 1 printf '%s' "$out" | render " m = d.get('monitor', {}) dl = m.get('alert_delay_s') or 0 mm, sec = divmod(dl, 60) dur = f'{mm}m' if not sec else f'{dl}s' print(f\"{m.get('name')}: every alert channel now waits at least {dur} of continuous downtime before its down alert\") print('shorter outages stay silent everywhere for this monitor (no down, no recovery)')" } cmd_route() { # per-monitor alert routing: which channels get this monitor's alerts need_key [ $# -ge 1 ] || die "usage: watchpup route [all|none|CH1,CH2] (monitor ids: watchpup ls; channel ids: watchpup channels)" local id="$1"; shift || true case "$id" in *[!A-Za-z0-9-]*|'') die "bad monitor id" ;; esac if [ $# -eq 0 ]; then have python3 || die "showing routing needs python3 (setting it works without: watchpup route ID all|none|CH1,CH2)" local mon chans mon="$(api GET "/api/monitors/$id")" || exit 1 chans="$(api GET "/api/channels")" || exit 1 printf '%s' "$mon" | WP_CHANS="$chans" render " import os m = d.get('monitor', {}) ac = m.get('alert_channels') chans = json.loads(os.environ['WP_CHANS']).get('channels', []) if ac is None: print(f\"{m.get('name')}: alerts go to ALL channels (default — includes channels added later)\") elif not ac: print(f\"{m.get('name')}: 🔕 MUTED — no channels selected; this monitor's alerts go nowhere\") else: print(f\"{m.get('name')}: alerts routed to {len(ac)} of {len(chans)} channel(s)\") for c in chans: mark = '✓' if (ac is None or c['id'] in ac) else ' ' tag = '' if c.get('verified') else ' (unverified — never receives alerts)' print(f\" [{mark}] {c['id']:>3} {c['kind']:<8}{c['target']}{tag}\") if not chans: print(' (no alert channels yet — watchpup channels add KIND TARGET)') if ac is not None: print(f\"reset to all: watchpup route {m.get('id')} all\")" else local body case "$1" in all|ALL) body='{"channels":null}' ;; none|NONE|mute) body='{"channels":[]}' ;; *[!0-9,]*|'') die "usage: watchpup route [all|none|CH1,CH2]" ;; *) local ids; ids="$(printf '%s' "$1" | sed 's/,,*/,/g;s/^,//;s/,$//')" [ -n "$ids" ] || die "usage: watchpup route [all|none|CH1,CH2]" body="{\"channels\":[$ids]}" ;; esac local out; out="$(api PATCH "/api/monitors/$id" "$body")" || exit 1 printf '%s' "$out" | render " m = d.get('monitor', {}) ac = m.get('alert_channels') if ac is None: print(f\"{m.get('name')}: routing cleared — alerts go to all channels\") elif not ac: print(f\"{m.get('name')}: 🔕 muted — no channels will receive this monitor's alerts\") else: print(f\"{m.get('name')}: alerts now routed only to channel(s) {', '.join(str(x) for x in ac)}\")" fi } cmd_latency() { need_key [ $# -ge 1 ] || die "usage: watchpup latency (ids: watchpup ls)" api GET "/api/monitors/$1" | render " m = d.get('monitor', {}) l = d.get('latency_24h') if m.get('kind') not in ('http', 'tcp'): print(f\"{m.get('name')}: latency stats cover http/tcp monitors (this one is {m.get('kind')})\"); sys.exit(1) if not l: print(f\"{m.get('name')}: no successful checks in the last 24h yet\"); sys.exit() print(f\"{m.get('name')} — last 24h, {l['count']} successful checks\") print(f\" p50 {l['p50_ms']}ms · p90 {l['p90_ms']}ms · p95 {l['p95_ms']}ms · p99 {l['p99_ms']}ms\") print(f\" avg {l['avg_ms']}ms · min {l['min_ms']}ms · max {l['max_ms']}ms\") tr = d.get('trend_1h') if tr: arrow = {'slower': '↗', 'faster': '↘'}.get(tr['direction'], '→') sign = '+' if tr['change_pct'] >= 0 else '' print(f\" trend {arrow} {tr['direction']}: avg {tr['avg_ms_last_hour']}ms last hour vs {tr['avg_ms_previous_hour']}ms the hour before ({sign}{tr['change_pct']}%)\")" } cmd_export() { # CSV export: checks (~3d raw) or daily (~90d rollups) need_key [ $# -ge 1 ] || die "usage: watchpup export [checks|daily] [FILE|-] (ids: watchpup ls)" local id="$1" kind="checks" dest="" arg; shift for arg in "$@"; do case "$arg" in checks|daily) kind="$arg" ;; *) dest="$arg" ;; esac done local tmp hdrs code fname rows tmp="$(mktemp)"; hdrs="$(mktemp)" code="$(curl -sS -A "$UA" -H "authorization: Bearer $WP_KEY" -D "$hdrs" -o "$tmp" \ -w '%{http_code}' "$WP_URL/api/monitors/$id/${kind}.csv")" \ || { rm -f "$tmp" "$hdrs"; die "network error talking to $WP_URL"; } if [ "$code" -ge 400 ] 2>/dev/null; then jget error "HTTP $code" < "$tmp" >&2 rm -f "$tmp" "$hdrs" exit 1 fi if [ "$dest" = "-" ]; then cat "$tmp"; rm -f "$tmp" "$hdrs"; return 0 fi if [ -z "$dest" ]; then fname="$(tr -d '\r' < "$hdrs" | sed -n 's/.*filename="\([^"]*\)".*/\1/p' | head -1)" case "$fname" in ''|*/*|.*) fname="watchpup-$id-$kind.csv" ;; esac dest="$fname" fi mv "$tmp" "$dest"; chmod 644 "$dest"; rm -f "$hdrs" rows=$(( $(wc -l < "$dest") - 1 )); [ "$rows" -lt 0 ] && rows=0 echo "wrote $dest ($rows ${kind} rows)" } cmd_backup() { # full account export: one secret-free JSON file need_key local dest="${1:-}" tmp hdrs code fname tmp="$(mktemp)"; hdrs="$(mktemp)" code="$(curl -sS -A "$UA" -H "authorization: Bearer $WP_KEY" -D "$hdrs" -o "$tmp" \ -w '%{http_code}' "$WP_URL/api/export")" \ || { rm -f "$tmp" "$hdrs"; die "network error talking to $WP_URL"; } if [ "$code" -ge 400 ] 2>/dev/null; then jget error "HTTP $code" < "$tmp" >&2 rm -f "$tmp" "$hdrs" exit 1 fi if [ "$dest" = "-" ]; then cat "$tmp"; rm -f "$tmp" "$hdrs"; return 0 fi if [ -z "$dest" ]; then fname="$(tr -d '\r' < "$hdrs" | sed -n 's/.*filename="\([^"]*\)".*/\1/p' | head -1)" case "$fname" in ''|*/*|.*) fname="watchpup-export.json" ;; esac dest="$fname" fi mv "$tmp" "$dest"; chmod 644 "$dest"; rm -f "$hdrs" if command -v python3 >/dev/null 2>&1; then WP_DEST="$dest" python3 - <<'PYEOF' import json, os d = json.load(open(os.environ['WP_DEST'])) print(f"wrote {os.environ['WP_DEST']}: {len(d.get('monitors',[]))} monitors, " f"{len(d.get('channels',[]))} channels, {len(d.get('status_pages',[]))} status pages, " f"{len(d.get('events_90d',[]))} events, {len(d.get('daily_rollups',[]))} rollup rows") PYEOF else echo "wrote $dest" fi } cmd_restore() { # restore a Watchpup backup (the JSON from `watchpup backup`) need_key local file="" qs="" arg for arg in "$@"; do case "$arg" in --dry-run|-n) qs="?dry_run=1" ;; -*) die "unknown flag: $arg (usage: watchpup restore FILE [--dry-run])" ;; *) file="$arg" ;; esac done [ -n "$file" ] || { echo "usage: watchpup restore FILE [--dry-run] (FILE = the JSON file from 'watchpup backup'; --dry-run previews without changing anything)" >&2; exit 1; } [ -f "$file" ] || die "no such file: $file" local tmp code rc=0 tmp="$(mktemp)" code="$(curl -sS -A "$UA" -H "authorization: Bearer $WP_KEY" -H 'content-type: application/json' \ --data-binary "@$file" -o "$tmp" -w '%{http_code}' "$WP_URL/api/import/watchpup$qs")" \ || { rm -f "$tmp"; die "network error talking to $WP_URL"; } if [ "$code" -ge 400 ] 2>/dev/null; then jget error "HTTP $code" < "$tmp" >&2 rm -f "$tmp" exit 1 fi if command -v python3 >/dev/null 2>&1; then WP_SRC="$tmp" python3 - <<'PYEOF' || rc=$? import json, os, sys d = json.load(open(os.environ['WP_SRC'])) dry = bool(d.get('dry_run')) if dry: print('DRY RUN — nothing was changed. What a real restore would do:') for sec in ('channels', 'monitors', 'status_pages', 'maintenance_windows'): s = d.get(sec) or {} if not s.get('found'): continue bits = [f"{s.get('created', 0)} would be restored" if dry else f"{s.get('created', 0)} restored"] if s.get('skipped'): bits.append(f"{s['skipped']} already existed") if s.get('unsupported'): bits.append(f"{s['unsupported']} unsupported") if s.get('failed'): bits.append(f"{s['failed']} FAILED") print(f"{sec.replace('_', ' ')}: {', '.join(bits)} (of {s['found']} in the backup)") for r in s.get('results') or []: if r.get('ok') or r.get('skipped'): continue who = r.get('name') or r.get('slug') or (f"{r.get('kind','')} {r.get('target','')}".strip()) or r.get('note') or '?' print(f" ! {who}: {r.get('error') or r.get('reason') or '?'}") for n in d.get('notes') or []: print(f"note: {n}") sys.exit(0 if d.get('ok') else 1) PYEOF else cat "$tmp" fi rm -f "$tmp" exit "$rc" } cmd_team() { # team members: extra logins that co-manage this account need_key local sub="${1:-ls}"; shift || true case "$sub" in ls|list) api GET /api/members | render " ms = d.get('members', []) if not ms: print('no team members yet — watchpup team add teammate@example.com (free, up to 10)'); sys.exit() def seen(m): if m.get('status') != 'active': return 'invited ' + ago(m.get('invited_at')) return ('last login ' + ago(m['last_login'])) if m.get('last_login') else 'no login recorded yet' rows = [(str(m['id']), m['email'], m.get('status',''), seen(m)) for m in ms] w = [max(len(r[i]) for r in rows) for i in range(4)] for r in rows: print(' '.join(col.ljust(w[i]) for i, col in enumerate(r)).rstrip()) if any(m.get('status') == 'invited' for m in ms): print('invited = waiting on the emailed link. Re-running team add EMAIL re-sends it.', file=sys.stderr)" ;; add|invite) [ $# -ge 1 ] || die "usage: watchpup team add teammate@example.com (they get an email invite link)" local out; out="$(api POST /api/members "{\"email\":$(json_escape "$1")}")" || exit 1 printf '%s' "$out" | render "print(d.get('note', 'invitation sent'))" ;; rm|remove|revoke) [ $# -ge 1 ] || die "usage: watchpup team rm (ids: watchpup team)" case "$1" in *[!0-9]*|'') die "member id must be a number — see: watchpup team" ;; esac local out; out="$(api DELETE "/api/members/$1")" || exit 1 printf '%s' "$out" | render "print(d.get('note', 'removed'))" ;; *) die "usage: watchpup team [ls|add EMAIL|rm ID] — invite teammates to co-manage this account (docs: /docs#team)" ;; esac } cmd_whoami() { need_key api GET /api/me | render " u = d.get('user', {}) print(f\"{u.get('email')} (user #{u.get('id')}, weekly digest {'on' if u.get('digest') else 'off'})\") print('key: (read-only key — mutations 403)' if u.get('readonly') else 'key: ' + (u.get('api_key') or '')[:8] + '…')" } cmd_api() { [ $# -ge 2 ] || die "usage: watchpup api METHOD /api/path ['{\"json\":\"body\"}'] — raw authenticated request" load_key api "$1" "$2" "${3:-}" } usage() { sed -n '2,14p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' cat <