diff options
Diffstat (limited to 'status')
| -rw-r--r-- | status/Dockerfile | 4 | ||||
| -rw-r--r-- | status/app.py | 198 | ||||
| -rwxr-xr-x | status/deploy.sh | 5 | ||||
| -rw-r--r-- | status/docker-compose.yml | 20 | ||||
| -rw-r--r-- | status/targets.txt | 15 |
5 files changed, 242 insertions, 0 deletions
diff --git a/status/Dockerfile b/status/Dockerfile new file mode 100644 index 0000000..0073f81 --- /dev/null +++ b/status/Dockerfile @@ -0,0 +1,4 @@ +FROM python:3.12-slim +WORKDIR /app +COPY app.py . +CMD ["python", "-u", "app.py"] diff --git a/status/app.py b/status/app.py new file mode 100644 index 0000000..9cde2e9 --- /dev/null +++ b/status/app.py @@ -0,0 +1,198 @@ +"""軽量ステータスページ。targets.txt の各サービスを定期チェックし HTML / JSON で公開する。""" +import json +import os +import socket +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from collections import deque +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +from html import escape +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +TARGETS_FILE = os.environ.get("TARGETS_FILE", "/app/targets.txt") +INTERVAL = int(os.environ.get("CHECK_INTERVAL", "60")) +TIMEOUT = float(os.environ.get("CHECK_TIMEOUT", "10")) +HISTORY = int(os.environ.get("HISTORY_SIZE", "90")) +PORT = int(os.environ.get("PORT", "8080")) +NTFY_URL = os.environ.get("NTFY_URL", "") # 例: http://ntfy/status(空なら通知しない) +NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "") +FAIL_THRESHOLD = int(os.environ.get("FAIL_THRESHOLD", "2")) # 連続失敗この回数で DOWN 通知 +PUBLIC_URL = os.environ.get("PUBLIC_URL", "https://status.yyamashita.com") + +lock = threading.Lock() +state = {} # name -> {"url", "up", "detail", "ms", "checked_at", "history": deque[bool], "fails", "alerted"} + + +def load_targets(): + targets = [] + with open(TARGETS_FILE) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + name, url = line.split(None, 1) + targets.append((name, url.strip())) + return targets + + +def check(url): + start = time.monotonic() + parsed = urllib.parse.urlparse(url) + try: + if parsed.scheme == "tcp": + with socket.create_connection((parsed.hostname, parsed.port), timeout=TIMEOUT): + pass + up, detail = True, "connected" + else: + req = urllib.request.Request(url, headers={"User-Agent": "status-check/1.0"}) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + code = r.status + except urllib.error.HTTPError as e: + code = e.code + up, detail = code < 500, f"HTTP {code}" + except Exception as e: + up, detail = False, f"{type(e).__name__}: {e}" + return up, detail, int((time.monotonic() - start) * 1000) + + +def notify(title, message, down): + if not NTFY_URL: + return + headers = { + "Title": title, + "Tags": "rotating_light" if down else "white_check_mark", + "Priority": "high" if down else "default", + "Click": PUBLIC_URL, + } + if NTFY_TOKEN: + headers["Authorization"] = f"Bearer {NTFY_TOKEN}" + req = urllib.request.Request(NTFY_URL, data=message.encode(), headers=headers, method="POST") + try: + urllib.request.urlopen(req, timeout=TIMEOUT).close() + except Exception as e: + print(f"ntfy notify failed: {e}", flush=True) + + +def run_checks(): + targets = load_targets() + with ThreadPoolExecutor(max_workers=max(1, len(targets))) as pool: + results = list(pool.map(lambda t: check(t[1]), targets)) + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + alerts = [] + with lock: + for name in set(state) - {n for n, _ in targets}: + del state[name] + for (name, url), (up, detail, ms) in zip(targets, results): + entry = state.setdefault( + name, {"history": deque(maxlen=HISTORY), "fails": 0, "alerted": False} + ) + entry.update(url=url, up=up, detail=detail, ms=ms, checked_at=now) + entry["history"].append(up) + entry["fails"] = 0 if up else entry["fails"] + 1 + if not up and entry["fails"] >= FAIL_THRESHOLD and not entry["alerted"]: + entry["alerted"] = True + alerts.append((f"{name} is DOWN", f"{url}\n{detail}", True)) + elif up and entry["alerted"]: + entry["alerted"] = False + alerts.append((f"{name} recovered", f"{url}\n{detail} ({ms} ms)", False)) + for alert in alerts: + notify(*alert) + + +def checker_loop(): + while True: + try: + run_checks() + except Exception as e: + print(f"check failed: {e}", flush=True) + time.sleep(INTERVAL) + + +def snapshot(): + with lock: + services = [ + { + "name": name, + "url": e["url"], + "up": e["up"], + "detail": e["detail"], + "ms": e["ms"], + "checked_at": e["checked_at"], + "uptime": round(100 * sum(e["history"]) / len(e["history"]), 1), + "history": list(e["history"]), + } + for name, e in state.items() + ] + return {"all_up": all(s["up"] for s in services), "services": services} + + +def render_html(data): + rows = [] + for s in data["services"]: + bars = "".join( + f'<i class="{"u" if ok else "d"}"></i>' for ok in s["history"] + ) + rows.append( + f'<tr><td><span class="dot {"u" if s["up"] else "d"}"></span>{escape(s["name"])}</td>' + f'<td>{escape(s["detail"])}</td><td>{s["ms"]} ms</td><td>{s["uptime"]}%</td>' + f'<td class="bars">{bars}</td></tr>' + ) + if not data["services"]: + banner = '<div class="banner">Checking...</div>' + elif data["all_up"]: + banner = '<div class="banner u">All systems operational</div>' + else: + banner = '<div class="banner d">Some systems are down</div>' + checked = data["services"][0]["checked_at"] if data["services"] else "-" + return f"""<!DOCTYPE html> +<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> +<meta http-equiv="refresh" content="{INTERVAL}"><title>Status - yyamashita.com</title> +<style> +body{{font-family:system-ui,sans-serif;max-width:860px;margin:2em auto;padding:0 1em;color:#222}} +.banner{{padding:.8em 1em;border-radius:6px;color:#fff;background:#888;font-weight:600}} +.banner.u{{background:#2a9d4b}}.banner.d{{background:#d0393e}} +table{{width:100%;border-collapse:collapse;margin-top:1em}}td{{padding:.5em;border-bottom:1px solid #eee;white-space:nowrap}} +.dot{{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:.5em}} +.u{{background:#2a9d4b}}.d{{background:#d0393e}} +.bars{{width:100%}}.bars i{{display:inline-block;width:4px;height:18px;margin-right:1px;border-radius:1px}} +small{{color:#888}} +</style></head><body> +<h2>yyamashita.com status</h2> +{banner} +<table>{"".join(rows)}</table> +<p><small>Last checked: {checked} (every {INTERVAL}s) · <a href="/api/status">JSON</a></small></p> +</body></html>""" + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + data = snapshot() + if self.path == "/api/status": + body = json.dumps(data).encode() + ctype = "application/json" + elif self.path == "/healthz": + body, ctype = b"ok", "text/plain" + elif self.path == "/": + body = render_html(data).encode() + ctype = "text/html; charset=utf-8" + else: + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +if __name__ == "__main__": + threading.Thread(target=checker_loop, daemon=True).start() + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/status/deploy.sh b/status/deploy.sh new file mode 100755 index 0000000..946f779 --- /dev/null +++ b/status/deploy.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +touch .env # 通知未設定でも起動できるように +docker compose up -d --build diff --git a/status/docker-compose.yml b/status/docker-compose.yml new file mode 100644 index 0000000..e150751 --- /dev/null +++ b/status/docker-compose.yml @@ -0,0 +1,20 @@ +services: + status: + build: . + container_name: status-app + restart: unless-stopped + # ディレクトリごとマウント(git checkout でファイルが差し替わっても追従できる)。 + # targets.txt はチェックのたびに再読込されるので push だけで反映される。 + volumes: + - ./:/app/conf:ro + environment: + - TARGETS_FILE=/app/conf/targets.txt + # NTFY_URL / NTFY_TOKEN(ntfy/install.sh が生成) + env_file: .env + networks: + - web + +networks: + web: + external: true + name: web diff --git a/status/targets.txt b/status/targets.txt new file mode 100644 index 0000000..5ee1dcc --- /dev/null +++ b/status/targets.txt @@ -0,0 +1,15 @@ +# 監視対象 +# 形式: <name> <url> +# http(s)://... → HTTP ステータス 5xx 未満なら UP(401 等の認証エラーも UP 扱い) +# tcp://host:port → TCP 接続できれば UP(web ネットワーク上のコンテナ名が使える) +golive https://golive.yyamashita.com +whoisband https://whoisband.yyamashita.com +blog https://blog.yyamashita.com +todo https://todo.yyamashita.com +git https://git.yyamashita.com +code https://code.yyamashita.com +hetzner-admin https://hetzner-admin.yyamashita.com +uptime https://uptime.yyamashita.com +vpn https://vpn.yyamashita.com +ntfy http://ntfy/v1/health +mail tcp://mail:25 |
