From e5c54597c52f0344f696ec24c618710d2fa250ba Mon Sep 17 00:00:00 2001 From: mizuki Date: Tue, 18 Aug 2026 07:22:42 +0900 Subject: [PATCH] Build Cloudflare status D-Day dashboard --- README.md | 20 +-- bun.lock | 61 +++++++++ package.json | 1 + src/index.ts | 350 ++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 420 insertions(+), 12 deletions(-) create mode 100644 bun.lock diff --git a/README.md b/README.md index 688c87e..8297965 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,19 @@ -# Elysia with Bun runtime +# Cloudflare Status D-Day -## Getting Started -To get started with this template, simply paste this command into your terminal: -```bash -bun create elysia ./elysia-example -``` +Cloudflare 공식 Status API를 주기적으로 확인해 마지막 인시던트 이후 경과 일수를 보여주는 Elysia + Bun 대시보드입니다. + +## Features + +- 서버 시작 시 즉시 Cloudflare 상태 동기화 +- Elysia cron으로 5분마다 자동 갱신 +- 장애 없는 날 카운터, 전체 상태, 서비스 구성요소, 최근 인시던트, 유지보수 표시 +- `GET /api/status` JSON API와 `GET /healthz` 헬스체크 제공 ## Development -To start the development server run: + ```bash +bun install bun run dev ``` -Open http://localhost:3000/ with your browser to see the result. \ No newline at end of file +Open http://localhost:3000/ in your browser. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..1060bd8 --- /dev/null +++ b/bun.lock @@ -0,0 +1,61 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "cf-status-dday", + "dependencies": { + "@elysiajs/cron": "^1.4.2", + "elysia": "latest", + }, + "devDependencies": { + "bun-types": "latest", + }, + }, + }, + "packages": { + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + + "@elysiajs/cron": ["@elysiajs/cron@1.4.2", "", { "dependencies": { "croner": "^6.0.3" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-osQrgInKlW0m0/NUuBUX7+O9CFnx5NnG3NszeCaokTChaCVYciv/S4mmXzZZ8+Zg9sI8BmEQbV+L19wKStXYZw=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.52", "", {}, "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "croner": ["croner@6.0.7", "", {}, "sha512-k3Xx3Rcclfr60Yx4TmvsF3Yscuiql8LSvYLaphTsaq5Hk8La4Z/udmUANMOTKpgGGroI2F6/XOr9cU9OFkYluQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "elysia": ["elysia@1.4.29", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-GwMRGGwSdjfPt+w3LA0fqTuYJtS8uVRJicvoar98/HrO5qdFKDc9CwjIb6Kja+v39lkY+58hr2JvdR9jQzlUuA=="], + + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "file-type": ["file-type@22.0.2", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-0H8TsCUGBLx+V5adH3EY52hTAcyLKbV1D4gq5cIOJ6DnQAHeV9Z2Hhuc5CoBX4YmvB2oL+JIC84z0qO7JsCoNw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/package.json b/package.json index 5c8ca68..5934853 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "bun run --watch src/index.ts" }, "dependencies": { + "@elysiajs/cron": "^1.4.2", "elysia": "latest" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts index 9c1f7a1..e7c7db1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,349 @@ import { Elysia } from "elysia"; +import { cron } from "@elysiajs/cron"; -const app = new Elysia().get("/", () => "Hello Elysia").listen(3000); +const CLOUDFLARE_STATUS_URL = "https://www.cloudflarestatus.com"; +const CLOUDFLARE_API_URL = `${CLOUDFLARE_STATUS_URL}/api/v2`; +const REFRESH_INTERVAL = "*/5 * * * *"; -console.log( - `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` -); +type PageIndicator = "none" | "minor" | "major" | "critical" | "unknown"; +type ComponentStatus = + | "operational" + | "degraded_performance" + | "partial_outage" + | "major_outage" + | "unknown"; + +interface CloudflareComponent { + id: string; + name: string; + status: ComponentStatus; + position?: number; + description?: string | null; + group?: boolean; +} + +interface CloudflareIncident { + id: string; + name: string; + status: string; + impact?: string; + shortlink?: string; + started_at?: string; + created_at?: string; + updated_at?: string; + resolved_at?: string | null; +} + +interface CloudflareMaintenance { + id: string; + name: string; + status: string; + impact?: string; + shortlink?: string; + started_at?: string; + scheduled_for?: string; + scheduled_until?: string; +} + +interface CloudflareSummaryResponse { + page?: { name?: string; updated_at?: string }; + status?: { indicator?: string; description?: string }; + components?: CloudflareComponent[]; + incidents?: CloudflareIncident[]; + scheduled_maintenances?: CloudflareMaintenance[]; +} + +interface CloudflareIncidentsResponse { + incidents?: CloudflareIncident[]; +} + +interface StatusSnapshot { + ok: boolean; + fetchedAt: string | null; + pageUpdatedAt: string | null; + status: { indicator: PageIndicator; description: string }; + streak: { days: number | null; since: string | null; activeIncident: boolean }; + lastIncident: { + id: string; + name: string; + startedAt: string | null; + resolvedAt: string | null; + impact: string; + shortlink: string | null; + } | null; + incidents: Array<{ + id: string; + name: string; + status: string; + impact: string; + startedAt: string | null; + resolvedAt: string | null; + shortlink: string | null; + }>; + components: Array<{ + id: string; + name: string; + status: ComponentStatus; + description: string | null; + }>; + componentCounts: { total: number; operational: number; affected: number }; + maintenances: Array<{ + id: string; + name: string; + status: string; + impact: string; + startsAt: string | null; + endsAt: string | null; + shortlink: string | null; + }>; + error: string | null; +} + +const initialSnapshot: StatusSnapshot = { + ok: false, + fetchedAt: null, + pageUpdatedAt: null, + status: { indicator: "unknown", description: "Cloudflare 상태를 확인하는 중입니다" }, + streak: { days: null, since: null, activeIncident: false }, + lastIncident: null, + incidents: [], + components: [], + componentCounts: { total: 0, operational: 0, affected: 0 }, + maintenances: [], + error: null, +}; + +let snapshot = initialSnapshot; + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + +function isIncidentActive(incident: CloudflareIncident): boolean { + return !["resolved", "postmortem"].includes(incident.status); +} + +function toTime(value?: string | null): number { + if (!value) return 0; + const time = Date.parse(value); + return Number.isNaN(time) ? 0 : time; +} + +function daysSince(value: string | null, now = Date.now()): number | null { + if (!value) return null; + const since = toTime(value); + if (!since) return null; + return Math.max(0, Math.floor((now - since) / (1000 * 60 * 60 * 24))); +} + +function indicator(value?: string): PageIndicator { + if (value === "none" || value === "minor" || value === "major" || value === "critical") return value; + return "unknown"; +} + +function componentStatus(value?: string): ComponentStatus { + if ( + value === "operational" || + value === "degraded_performance" || + value === "partial_outage" || + value === "major_outage" + ) return value; + return "unknown"; +} + +function normalizeIncident(incident: CloudflareIncident) { + return { + id: incident.id, + name: incident.name, + status: incident.status, + impact: incident.impact ?? "none", + startedAt: incident.started_at ?? incident.created_at ?? null, + resolvedAt: incident.resolved_at ?? null, + shortlink: incident.shortlink ?? null, + }; +} + +function normalizeMaintenance(maintenance: CloudflareMaintenance) { + return { + id: maintenance.id, + name: maintenance.name, + status: maintenance.status, + impact: maintenance.impact ?? "none", + startsAt: maintenance.scheduled_for ?? maintenance.started_at ?? null, + endsAt: maintenance.scheduled_until ?? null, + shortlink: maintenance.shortlink ?? null, + }; +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { accept: "application/json", "user-agent": "cloudflare-status-dday/1.0" }, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) throw new Error(`Cloudflare API returned ${response.status}`); + return (await response.json()) as T; +} + +async function refreshStatus(source: "startup" | "cron") { + try { + const [summary, incidentsResponse] = await Promise.all([ + fetchJson(`${CLOUDFLARE_API_URL}/summary.json`), + fetchJson(`${CLOUDFLARE_API_URL}/incidents.json`), + ]); + + const incidentsById = new Map(); + for (const incident of [ + ...asArray(summary.incidents), + ...asArray(incidentsResponse.incidents), + ]) { + if (incident.id) incidentsById.set(incident.id, incident); + } + + const incidents = [...incidentsById.values()].sort( + (a, b) => toTime(b.started_at ?? b.created_at) - toTime(a.started_at ?? a.created_at), + ); + const activeIncidents = incidents.filter(isIncidentActive); + const latestIncident = incidents[0]; + const components = asArray(summary.components) + .filter((component) => !component.group) + .sort((a, b) => { + const aAffected = componentStatus(a.status) === "operational" ? 1 : 0; + const bAffected = componentStatus(b.status) === "operational" ? 1 : 0; + return aAffected - bAffected || (a.position ?? 0) - (b.position ?? 0); + }) + .map((component) => ({ + id: component.id, + name: component.name, + status: componentStatus(component.status), + description: component.description ?? null, + })); + const maintenances = asArray(summary.scheduled_maintenances) + .sort((a, b) => toTime(a.scheduled_for ?? a.started_at) - toTime(b.scheduled_for ?? b.started_at)) + .map(normalizeMaintenance); + const now = new Date().toISOString(); + + snapshot = { + ok: true, + fetchedAt: now, + pageUpdatedAt: summary.page?.updated_at ?? null, + status: { + indicator: indicator(summary.status?.indicator), + description: summary.status?.description ?? "상태 정보가 없습니다", + }, + streak: { + days: daysSince(latestIncident?.started_at ?? latestIncident?.created_at ?? null), + since: latestIncident?.started_at ?? latestIncident?.created_at ?? null, + activeIncident: activeIncidents.length > 0, + }, + lastIncident: latestIncident + ? { + id: latestIncident.id, + name: latestIncident.name, + startedAt: latestIncident.started_at ?? latestIncident.created_at ?? null, + resolvedAt: latestIncident.resolved_at ?? null, + impact: latestIncident.impact ?? "none", + shortlink: latestIncident.shortlink ?? null, + } + : null, + incidents: incidents.slice(0, 6).map(normalizeIncident), + components, + componentCounts: { + total: components.length, + operational: components.filter((component) => component.status === "operational").length, + affected: components.filter((component) => component.status !== "operational").length, + }, + maintenances: maintenances.slice(0, 5), + error: null, + }; + + console.log(`[${source}] Cloudflare status updated at ${now}`); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + snapshot = { ...snapshot, ok: false, error: message }; + console.error(`[${source}] Failed to update Cloudflare status: ${message}`); + } +} + +const INDEX_HTML = String.raw` + + + + + + + Cloudflare · Days Without Incident + + + +
+ +
a quiet day on the edge

Cloudflare 장애 없이
계산 중 지났습니다

공식 Cloudflare Status API를 주기적으로 확인해
마지막 인시던트 이후의 시간을 기록합니다.

days without incident
상태 확인 중

Cloudflare status 데이터를 불러오고 있습니다.

+
+

현재 전체 상태

Cloudflare 공식 페이지의 롤업 상태

LIVE
상태 확인 중마지막 동기화: —
전체 서비스
정상 운영
확인 필요
+

서비스 구성요소

영향이 있는 항목을 먼저 표시합니다

불러오는 중…
+

최근 인시던트

Cloudflare Status에 기록된 최근 장애

불러오는 중…
+

예정된 유지보수

예정 또는 진행 중인 작업

예정된 유지보수가 없습니다.
+
+ +
+ + +`; + +const port = Number.parseInt(Bun.env.PORT ?? "3000", 10); +const hostname = Bun.env.HOST ?? "0.0.0.0"; + +const app = new Elysia() + .use(cron({ name: "cloudflare-status-refresh", pattern: REFRESH_INTERVAL, catch: true, run: () => refreshStatus("cron") })) + .get("/", () => new Response(INDEX_HTML, { headers: { "content-type": "text/html; charset=utf-8" } })) + .get("/api/status", () => new Response(JSON.stringify(snapshot), { headers: { "cache-control": "no-store", "content-type": "application/json; charset=utf-8" } })) + .get("/healthz", () => ({ ok: true, fetchedAt: snapshot.fetchedAt, cloudflareOk: snapshot.ok })) + .listen({ port, hostname }); + +await refreshStatus("startup"); + +console.log(`🦊 Cloudflare D-Day is running at ${app.server?.hostname}:${app.server?.port}`);