Uptime Kumaで複数のWebサイトを死活監視する手順|「200なのに落ちている」も見逃さない設定と月報【2026年版】
2026-10-03
※内容は2026年9月27日時点の情報(Uptime Kuma 2.5.5)をもとにしています。
管理するWebサイトやWebサービスが増えてくると、「どれかが止まっていても、気づけないのでは」という不安が出てきます。この記事では、オープンソースの死活監視ツール「Uptime Kuma(アップタイム・クマ)」を使って、複数のサイトをまとめて監視する手順を紹介します。
当サイトOSSMatchの運営チームが、自社の5つのサイトを実際に監視して、わざと異常を起こしてスマホに通知が届くか、お客様向けの月次レポートを作れるかまで確かめた結果をもとにまとめています。
管理するサイトが増えて、止まったことに気づけるか不安ではありませんか
私たちはWeb制作とWebサービスの開発をしていて、保守を請け負うサイトが少しずつ増えています。
レンタルサーバーで動いているサイトなら、事業者側でサーバーの監視や障害情報の公開を行っていることが多く、ある程度は任せられます。一方で、AWSやVercelなどで構築したサイトやWebサービスは、自分で監視の仕組みを用意しなければなりません。こうしたサイトの保守も増えてきたため、まとめて見張れる仕組みが必要になりました。
監視がないと、次のようなことが起こります。
- サイトが止まっていることに、お客様からの連絡で初めて気づく
- SSL証明書の更新に失敗して、ブラウザに警告が出たまま放置される
- ドメインの更新を忘れて、サイトごと表示されなくなる
なぜ「ページが開けるか」だけの監視では足りないのか
死活監視というと、「URLにアクセスして、応答があるか」を確かめるものを思い浮かべるかもしれません。ところが、サイトが壊れていても「正常(ステータスコード200)」を返すことがよくあります。
- WordPressなどで、データベースにつながらないときのエラー画面
- アプリの不具合で、中身が空の画面や「エラーが発生しました」の画面が表示される
- ホスティングの設定ミスで、別のページ(初期画面など)が表示される
どれも「ページは返ってきている」ので、応答だけを見る監視では「正常」と判定されてしまいます。そこで大事になるのが、正常なページに必ずある文字(キーワード)が表示されているかまで確かめる監視です。
Uptime Kumaを選んだ理由
Uptime Kumaは、ブラウザの画面から監視を登録できるオープンソースの死活監視ツールです。GitHubのスターは9万以上と、この分野でいちばん使われています。私たちが比べた候補は次のとおりです。
| ツール | 特徴 | 向いている使い方 |
|---|---|---|
| Uptime Kuma | 画面から登録できる。通知先の種類が多い。キーワード監視・SSL証明書とドメインの期限の確認・ステータスページがある | 複数のサイトを手軽に見張りたい |
| Gatus | 監視の設定をYAMLファイルに書く。軽い | 設定をGitで管理したい、数十件以上の監視 |
| Checkmate | 死活監視に加えてサーバーの負荷も見られる | サーバーの状態もあわせて見たい |
| UptimeRobotなどの有料サービス | サーバーの用意がいらない | 監視の数が少ない、自分でサーバーを持ちたくない |
自前で動かせば、監視するサイトの数が増えても料金は変わりません。実際に5つのサイトを監視しているときのメモリの使用量は約180MB、記録のデータは約2MBで、とても軽く動きます。
画面は日本語で表示されますが、バージョン2で増えた項目(ドメインの期限の通知など)は、一部が英語のままです。この翻訳は、私たちが進めて公式に提出する予定です。

Uptime KumaをVPSに構築する手順
Docker ComposeでUptime Kumaを動かし、Caddyで自動的にHTTPS化します。前提は次のとおりです。
- Ubuntu 24.04のVPSに、Dockerがインストール済み
- 監視画面用のサブドメイン(例:status.example.com)を用意できる
- ファイアウォールで80番・443番を開けている
DockerのインストールやVPSの初期設定は、Umamiの構築記事の手順1・手順2と同じです。
監視サーバーは、監視されるサイトとは別の場所に置いてください。同じサーバーに置くと、サーバーごと止まったときに通知も止まってしまいます。
手順1:DNSを設定する
サブドメインのAレコードをVPSのIPアドレスに向け、反映されたことを確認します。
dig +short status.example.com # 自分のPCで実行。VPSのIPアドレスが返ればOK
手順2:設定ファイルを作って起動する
nano docker-compose.ymlで次の内容を保存します。
services:
uptime-kuma:
image: louislam/uptime-kuma:2.5.5 # 版を固定する(最新は GitHub のリリースで確認)
restart: unless-stopped
environment:
TZ: Asia/Tokyo
volumes:
- kuma_data:/app/data
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- uptime-kuma
volumes:
kuma_data:
caddy_data:
caddy_config:
続けて、nano Caddyfileで次の3行を保存します。
status.example.com {
reverse_proxy uptime-kuma:3001
}
mkdir -p ~/uptime-kuma && cd ~/uptime-kuma
# docker-compose.yml と Caddyfile を保存してから起動する
ls -l docker-compose.yml Caddyfile
sudo docker compose up -d
sudo docker compose ps
私たちの環境は、ほかのサービスと同じVPSでCoolify(Traefik)の後ろに置いていますが、Uptime Kuma側の設定は同じです。
手順3:データベースを選び、管理者を作る
ブラウザでhttps://status.example.comを開くと、最初にデータベースの選択画面が出ます。監視するサイトが数十件程度までなら、SQLiteで十分です。バックアップもファイル1つで済みます。

続けて管理者のユーザー名とパスワードを決めれば、ダッシュボードが表示されます。起動したらすぐに管理者を作ってください。URLを他人に先に開かれると、そのまま管理者にされてしまいます。
サイトを「キーワード監視」で登録する
左上の「監視の追加」から、サイトを1つずつ登録します。設定のポイントは次のとおりです。
| 項目 | 設定 | 理由 |
|---|---|---|
| 監視タイプ | HTTP(s) – キーワード | 応答があるかに加えて、ページの中身まで確かめる |
| キーワード | 正常なページに必ずある文字(例:サイト名) | エラー画面や空の画面では消えるので、異常として検知できる |
| 監視間隔 | 60秒 | 初期値のまま |
| 再試行回数 | 2回 | 一瞬の遅れで通知が来るのを防ぐ |
| 証明書有効期限通知・ドメインの期限の通知 | オン | SSL証明書の更新失敗やドメインの更新忘れに気づける |

キーワードは大文字と小文字を区別します。日本語もそのまま使えます。私たちは、各サイトのサイト名をキーワードにしました。2件目からは、登録済みの監視を「複製」して、名前・URL・キーワードだけ変えると早く登録できます。
注意点として、画面をブラウザ側で組み立てるタイプのWebサービス(Next.jsなどで作ったSPA)では、サーバーが返すHTMLに本文が入っていないことがあります。その場合は、アプリの「ヘルスチェック用のURL」を監視するか、JSONの値を確かめる監視タイプを使いましょう。

5つのサイトを登録すると、1つの画面で稼働状況を見渡せます。応答時間の平均は、私たちの環境で約60msでした。
スマホに通知して、わざと異常を起こして確かめる
通知先には、以前紹介した自分のサーバーで動かすプッシュ通知「ntfy」を使いました。メール・Slack・Discord・Microsoft Teamsなど、ほかにも多くの通知先に対応しています。
設定画面の「通知」で、ntfyのサーバーURL・トピック・アクセストークンを入れ、「既存のモニターすべてに適用」をオンにすると、登録済みのサイトにまとめて通知が付きます。

通知の優先度は、落ちたときを最大、復旧は通常にしておくと、夜中に復旧の通知で起こされずに済みます。
設定したら、必ずわざと異常を起こして確かめましょう。サイトには手を加えず、監視のキーワードを存在しない文字(例:「サイト名TEST」)に変えるだけで試せます。

私たちの環境では、キーワードを変えてから約1〜2分で「Down(異常)」、元に戻してから約30秒で「Up(復旧)」の通知が届きました。通知には「200 – OK, but keyword is not in(応答はあるがキーワードがない)」と、異常の理由も入ります。
小さな注意点として、ページのタイトルに「–」などの記号があると、通知の本文に–のような記号の番号がそのまま表示されることがあります。
お客様に見せるステータスページを作る
保守の案件では、お客様から「今、サイトは正常ですか?」と聞かれることがあります。Uptime Kumaのステータスページを使うと、サイトの稼働状況を公開のページで見せられます。
- 画面上部の「ステータスページ」から「新しいステータスページ」を選び、名前とスラッグ(URLの一部。例:
edel)を入れる - 編集画面の「監視の追加」の欄から、見せたいサイトを選ぶ(自動で「サービス」というグループに入る)
- 必要に応じて「証明書の有効期限を表示」などをオンにする
- 左下の「保存」を押す
「保存」を押す前に画面を移ると、追加したサイトが消えてしまいます。私たちも一度これで、公開のページが空のままになっていました。

公開のページはhttps://status.example.com/status/スラッグで表示されます。閲覧する人の設定に合わせて、明るい表示と暗い表示が自動で切り替わります。障害のお知らせを掲示する「インシデント」の機能や、独自のドメインで表示する設定もあります。
私たちの環境では、管理画面は運営者のIPアドレスからだけ開けるように制限し、ステータスページだけを誰でも見られるようにしています。リバースプロキシ(Caddyなど)で、/status/と、ステータスページの表示に必要なパス(/api/status-page/・/assets/など)だけを公開する形です。お客様に見せるのは稼働状況だけで、監視の設定には触れられません。
お客様向けの月次レポートを作る
保守の案件では、「今月も問題なく動いていました」とお客様に報告したい場面があります。ところが、Uptime Kumaには月次レポートを作る機能がありません。
そこで、Uptime Kumaが記録しているデータベースから、月ごとの稼働状況をまとめるスクリプトを作りました。Uptime Kumaは、日ごとの集計(正常と異常の回数、平均の応答時間)と、状態が変わったとき(異常と復旧)の記録を残しています。これを次のように読み出します。
-- 月の稼働率と平均応答時間(stat_daily:日ごとの集計)
select sum(up) as up, sum(down) as down, sum(ping * up) / sum(up) as avg_ping
from stat_daily
where monitor_id = ? and timestamp >= ? and timestamp < ?;
-- 障害の記録(状態が変わったときだけ important = 1 で残る)
select time, status, msg
from heartbeat
where monitor_id = ? and important = 1
order by time;
この集計をもとに、サイトごとの稼働率・障害の回数と停止時間・平均の応答時間・SSL証明書とドメインの期限を、A4で1ページのレポートにしています。

使い方は次のとおりです。動いている監視サーバーのデータベースは直接触らず、コピーを取ってから集計します。
./fetch_db.sh # 監視サーバーの記録を安全にコピーして手元に置く
python3 report.py --month 2026-10 --client "株式会社サンプル" --monitors 3,4 --author "合同会社サンプル" --out out/2026-10-sample.html
# PDF にする(Chrome のヘッドレスモード)
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless --no-pdf-header-footer \
--print-to-pdf=out/2026-10-sample.pdf "file://$PWD/out/2026-10-sample.html"
スクリプトの全体は、記事の最後に載せています。Python 3.9以上で動き、追加のライブラリは必要ありません。
運用で気をつけること
- 監視サーバー自身も見張る:Uptime Kumaが止まると、すべての監視が止まります。外部の無料の監視サービスなどで、監視サーバーが動いているかを確かめておくと安心です
- 記録のバックアップ:SQLiteのファイル1つなので、毎日コピーしておきます
- キーワードの見直し:サイトのデザインを変えてキーワードが消えると、誤って「異常」になります。リニューアルのときは監視の設定もあわせて確認します
- アップデート:docker-compose.ymlの版を書き換えて、
sudo docker compose pullとsudo docker compose up -dを実行します。更新の前にはバックアップを取ります
バックアップは、次のようなスクリプトを毎日動かしています。
#!/bin/bash
# Uptime Kuma の記録(SQLite)を毎日保存し、14日分残す(root で実行)
set -euo pipefail
cd "$(dirname "$0")"
DIR="/root/backup/uptime-kuma"
STAMP="$(date +%F)"
mkdir -p "$DIR" && chmod 700 "$DIR"
# 動いている DB は直接コピーせず、SQLite の .backup で一貫した状態を書き出す
docker compose exec -T uptime-kuma sqlite3 /app/data/kuma.db ".backup /app/data/backup.db"
docker compose cp uptime-kuma:/app/data/backup.db "$DIR/kuma-$STAMP.db"
docker compose exec -T uptime-kuma rm -f /app/data/backup.db
gzip -f "$DIR/kuma-$STAMP.db"
find "$DIR" -name 'kuma-*.db.gz' -mtime +14 -delete
まとめ:Uptime Kumaなら、複数のサイトの監視を自前で持てる
Uptime Kumaは、軽くて、画面から登録でき、監視の数が増えても費用が変わらない死活監視ツールです。
- 監視サーバーは、監視されるサイトとは別の場所に置く
- 「HTTP(s) – キーワード」で、ページの中身まで確かめる
- SSL証明書とドメインの期限の通知もオンにする
- 通知は、わざと異常を起こして届くことを確かめる
- お客様への報告は、データベースから月次レポートを作る
Uptime Kumaはメモリの使用量が少ないので、小さなVPSで十分動きます。すでにVPSを持っていれば、監視されるサイトとは別のサーバーに置くのがおすすめです。
Uptime Kumaの必要スペックや、ほかの監視ツールはUptime Kumaの詳細ページで確認できます。
月次レポートのスクリプト(全体)
fetch_db.sh(監視サーバーから記録を安全にコピーする):
#!/bin/zsh
# Uptime Kuma の DB を安全にコピーして手元に持ってくる(稼働中の DB は直接触らない。sqlite の .backup を使う)
# HOST・KEY・CONTAINER は自分の環境に合わせて書き換える
set -e
cd "$(dirname "$0")"
HOST=root@203.0.113.10 # 監視サーバー(例)
KEY=~/.ssh/id_ed25519 # SSH の鍵
CONTAINER=uptime-kuma-uptime-kuma-1 # docker compose ps で確認
ssh -i $KEY $HOST "docker exec $CONTAINER sqlite3 /app/data/kuma.db '.backup /app/data/report-copy.db' && docker cp $CONTAINER:/app/data/report-copy.db /tmp/kuma-report-copy.db && docker exec $CONTAINER rm -f /app/data/report-copy.db"
scp -q -i $KEY $HOST:/tmp/kuma-report-copy.db ./kuma.db
ssh -i $KEY $HOST 'rm -f /tmp/kuma-report-copy.db'
echo "kuma.db を取得しました"
report.py(月次レポートのHTMLを作る):
#!/usr/bin/env python3
"""Uptime Kuma の記録から、お客様向けの月次稼働レポート(HTML)を作る。
使い方:
./fetch_db.sh # DB を安全にコピーして kuma.db に置く
python3 report.py --month 2026-09 --client "株式会社サンプル" --monitors 1,2 --author "合同会社サンプル" --out out/2026-09-sample.html
(--monitors を省くと全監視。PDF は Chrome ヘッドレスで HTML から作る)
集計の考え方:
- 稼働率 = 正常だった確認の回数 ÷(正常+異常の回数)。stat_daily(日ごとの集計)を使う
- 障害 = 状態が「異常」に変わった記録(heartbeat の important=1・status=0)から、次に「正常」に戻るまで
- 時刻はすべて日本時間(Asia/Tokyo)で表示
"""
import argparse
import html
import json
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
JST = timezone(timedelta(hours=9))
def month_range(ym):
y, m = map(int, ym.split("-"))
start = datetime(y, m, 1, tzinfo=JST)
end = datetime(y + (m == 12), m % 12 + 1, 1, tzinfo=JST)
return start, end
def parse_utc(s):
# heartbeat.time は UTC の文字列(例:2026-09-26 23:51:23.111)
return datetime.fromisoformat(s.replace(" ", "T")).replace(tzinfo=timezone.utc)
def fmt_dur(sec):
sec = int(round(sec))
if sec < 60:
return f"{sec}秒"
m, s = divmod(sec, 60)
if m < 60:
return f"{m}分{s:02d}秒"
h, m = divmod(m, 60)
return f"{h}時間{m:02d}分"
def collect(db, month, monitor_ids):
start, end = month_range(month)
now = datetime.now(JST)
until = min(end, now)
con = sqlite3.connect(db)
con.row_factory = sqlite3.Row
mons = con.execute("select id, name, url, type, keyword, interval from monitor where active = 1 order by id").fetchall()
if monitor_ids:
mons = [m for m in mons if m["id"] in monitor_ids]
rows = []
for m in mons:
st = con.execute(
"select sum(up) up, sum(down) down, sum(ping * up) pw from stat_daily where monitor_id = ? and timestamp >= ? and timestamp < ?",
(m["id"], int(start.timestamp()), int(end.timestamp())),
).fetchone()
up, down = st["up"] or 0, st["down"] or 0
total = up + down
uptime = (up / total * 100) if total else None
avg_ping = (st["pw"] / up) if up else None
# 障害:状態が変わった記録を時系列に並べ、異常 → 次の正常 までを1件とする
evs = con.execute(
"select time, status, msg from heartbeat where monitor_id = ? and important = 1 order by time",
(m["id"],),
).fetchall()
incidents, open_ev = [], None
for e in evs:
t = parse_utc(e["time"]).astimezone(JST)
if e["status"] == 0 and open_ev is None:
open_ev = (t, e["msg"])
elif e["status"] == 1 and open_ev is not None:
incidents.append((open_ev[0], t, open_ev[1]))
open_ev = None
if open_ev is not None:
incidents.append((open_ev[0], None, open_ev[1]))
incidents = [i for i in incidents if i[0] < end and (i[1] or until) >= start]
down_sec = sum(((i[1] or until) - max(i[0], start)).total_seconds() for i in incidents)
# 証明書・ドメインの期限
cert = None
tls = con.execute("select info_json from monitor_tls_info where monitor_id = ?", (m["id"],)).fetchone()
if tls:
info = json.loads(tls["info_json"] or "{}").get("certInfo") or {}
if info.get("validTo"):
cert = (info["validTo"], info.get("daysRemaining"))
host = (m["url"] or "").split("//")[-1].split("/")[0]
dom = con.execute("select expiry from domain_expiry where domain = ?", (host.removeprefix("www."),)).fetchone()
rows.append({
"name": m["name"], "url": m["url"], "type": m["type"], "keyword": m["keyword"], "interval": m["interval"],
"checks": total, "uptime": uptime, "avg_ping": avg_ping, "incidents": incidents, "down_sec": down_sec,
"cert": cert, "domain_expiry": dom["expiry"][:10] if dom and dom["expiry"] else None,
})
first = con.execute("select min(time) t from heartbeat").fetchone()["t"]
since = max(start, parse_utc(first).astimezone(JST)) if first else start
return rows, start, end, since, until
def render(client, month, rows, start, end, since, until, author=""):
e = html.escape
y, m = month.split("-")
period = f"{since:%Y年%-m月%-d日 %H:%M} 〜 {(until if until < end else end - timedelta(seconds=1)):%Y年%-m月%-d日 %H:%M}"
partial = since > start or until < end
tr = []
for r in rows:
up = "—" if r["uptime"] is None else f"{r['uptime']:.2f}%"
ping = "—" if r["avg_ping"] is None else "%.0fms" % r["avg_ping"]
cls = "ok" if (r["uptime"] or 0) >= 99.9 else ("warn" if (r["uptime"] or 0) >= 99 else "bad")
tr.append(
f"<tr><td><b>{e(r['name'])}</b><br><small>{e(r['url'])}</small></td>"
f"<td class='num {cls}'>{up}</td><td class='num'>{len(r['incidents'])}回</td>"
f"<td class='num'>{fmt_dur(r['down_sec']) if r['incidents'] else '0'}</td>"
f"<td class='num'>{ping}</td>"
f"<td class='num'>{r['checks']:,}回</td></tr>"
)
inc = []
for r in rows:
for s, t, msg in r["incidents"]:
reason = "ページは表示されたが、正常時の文言(キーワード)が見つからない" if "keyword is not" in (msg or "") else e((msg or "")[:80])
inc.append(
f"<tr><td>{e(r['name'])}</td><td>{s:%-m/%-d %H:%M:%S}</td>"
f"<td>{t.strftime('%-m/%-d %H:%M:%S') if t else '継続中'}</td>"
f"<td class='num'>{fmt_dur(((t or until) - s).total_seconds())}</td><td>{reason}</td></tr>"
)
exp = []
for r in rows:
c = f"{r['cert'][0][:10]}(あと{r['cert'][1]}日)" if r["cert"] else "—"
exp.append(f"<tr><td>{e(r['name'])}</td><td>{c}</td><td>{r['domain_expiry'] or '—'}</td></tr>")
kinds = {"keyword": "ページの表示+正常時の文言の確認", "http": "ページの表示の確認"}
method = "、".join(sorted({kinds.get(r["type"], r["type"]) for r in rows}))
interval = sorted({r["interval"] for r in rows})
return f"""<!doctype html><html lang="ja"><head><meta charset="utf-8"><title>サイト稼働レポート {y}年{int(m)}月 {e(client)}</title>
<style>
@page {{ size: 210mm 297mm; margin: 0; }}
* {{ box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }}
body {{ margin: 0; font-family: "Hiragino Sans", "Noto Sans JP", sans-serif; color: #1a1d23; font-size: 10.5pt; line-height: 1.6; }}
.page {{ width: 210mm; height: 297mm; overflow: hidden; padding: 14mm 16mm 12mm; }}
header {{ border-bottom: 3px solid #1f5eff; padding-bottom: 8px; margin-bottom: 14px; display: flex; justify-content: space-between; align-items: flex-end; }}
h1 {{ font-size: 18pt; margin: 0; white-space: nowrap; }} .client {{ font-size: 11pt; color: #444; }}
.meta {{ text-align: right; font-size: 9pt; color: #667085; }}
h2 {{ font-size: 12pt; margin: 14px 0 6px; padding-left: 8px; border-left: 4px solid #1f5eff; }}
table {{ width: 100%; border-collapse: collapse; font-size: 9.5pt; }}
th {{ background: #eef3ff; color: #1a2b5f; text-align: left; padding: 6px 8px; border-bottom: 1px solid #c7d7ff; }}
td {{ padding: 6px 8px; border-bottom: 1px solid #e4e7ec; vertical-align: top; }}
td small {{ color: #667085; }} .num {{ text-align: right; white-space: nowrap; }}
.ok {{ color: #0a7a48; font-weight: 700; }} .warn {{ color: #b54708; font-weight: 700; }} .bad {{ color: #c01048; font-weight: 700; }}
.note {{ font-size: 8.5pt; color: #667085; margin-top: 6px; }}
.summary {{ display: flex; gap: 10px; margin-bottom: 4px; }}
.card {{ flex: 1; border: 1px solid #e4e7ec; border-radius: 8px; padding: 8px 10px; }}
.card b {{ display: block; font-size: 16pt; }} .card span {{ font-size: 8.5pt; color: #667085; }}
footer {{ margin-top: 18px; font-size: 8.5pt; color: #667085; border-top: 1px solid #e4e7ec; padding-top: 6px; }}
</style></head><body><div class="page">
<header><div><h1>サイト稼働レポート {y}年{int(m)}月</h1><div class="client">{e(client)} 様</div></div>
<div class="meta">集計期間:{period}{'<br>※ 監視開始日からの集計です' if partial else ''}<br>作成:{datetime.now(JST):%Y年%-m月%-d日}</div></header>
<div class="summary">
<div class="card"><span>監視しているサイト</span><b>{len(rows)}</b></div>
<div class="card"><span>障害の件数(合計)</span><b>{sum(len(r['incidents']) for r in rows)}回</b></div>
<div class="card"><span>停止時間(合計)</span><b>{fmt_dur(sum(r['down_sec'] for r in rows)) if any(r['incidents'] for r in rows) else '0'}</b></div>
</div>
<h2>サイトごとの稼働状況</h2>
<table><thead><tr><th>サイト</th><th class="num">稼働率</th><th class="num">障害</th><th class="num">停止時間</th><th class="num">平均応答</th><th class="num">確認回数</th></tr></thead><tbody>{''.join(tr)}</tbody></table>
<p class="note">稼働率=正常だった確認の回数 ÷ すべての確認の回数。平均応答=サーバーがページを返すまでの平均時間。</p>
<h2>障害の記録</h2>
{('<table><thead><tr><th>サイト</th><th>発生</th><th>復旧</th><th class="num">時間</th><th>内容</th></tr></thead><tbody>' + ''.join(inc) + '</tbody></table>') if inc else '<p>この期間に障害はありませんでした。</p>'}
<h2>SSL証明書・ドメインの期限</h2>
<table><thead><tr><th>サイト</th><th>SSL証明書の期限</th><th>ドメインの期限</th></tr></thead><tbody>{''.join(exp)}</tbody></table>
<p class="note">期限が近づくと、担当者に自動で通知が届く設定です。</p>
<footer>監視の方法:{method}を、{'・'.join(f'{i}秒' for i in interval)}ごとに行っています。異常を検知した場合は再確認のうえ、担当者に通知しています。<br>監視ツール:Uptime Kuma(オープンソース){("/作成:" + e(author)) if author else ""}</footer>
</div></body></html>"""
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--db", default=str(Path(__file__).with_name("kuma.db")))
ap.add_argument("--month", required=True, help="YYYY-MM")
ap.add_argument("--client", default="(お客様名)")
ap.add_argument("--monitors", default="", help="監視IDをカンマ区切り。省略で全部")
ap.add_argument("--author", default="", help="レポートの作成者名(脚注に表示)")
ap.add_argument("--out", required=True)
a = ap.parse_args()
ids = {int(x) for x in a.monitors.split(",") if x.strip()}
rows, start, end, since, until = collect(a.db, a.month, ids)
out = Path(a.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(render(a.client, a.month, rows, start, end, since, until, a.author), encoding="utf-8")
print(out, f"{len(rows)}サイト")
if __name__ == "__main__":
main()