Build Cloudflare status D-Day dashboard

This commit is contained in:
Akiyama Mizuki 2026-08-18 07:22:42 +09:00
commit e5c54597c5
4 changed files with 420 additions and 12 deletions

View file

@ -1,15 +1,19 @@
# Elysia with Bun runtime # Cloudflare Status D-Day
## Getting Started Cloudflare 공식 Status API를 주기적으로 확인해 마지막 인시던트 이후 경과 일수를 보여주는 Elysia + Bun 대시보드입니다.
To get started with this template, simply paste this command into your terminal:
```bash ## Features
bun create elysia ./elysia-example
``` - 서버 시작 시 즉시 Cloudflare 상태 동기화
- Elysia cron으로 5분마다 자동 갱신
- 장애 없는 날 카운터, 전체 상태, 서비스 구성요소, 최근 인시던트, 유지보수 표시
- `GET /api/status` JSON API와 `GET /healthz` 헬스체크 제공
## Development ## Development
To start the development server run:
```bash ```bash
bun install
bun run dev bun run dev
``` ```
Open http://localhost:3000/ with your browser to see the result. Open http://localhost:3000/ in your browser.

61
bun.lock Normal file
View file

@ -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=="],
}
}

View file

@ -6,6 +6,7 @@
"dev": "bun run --watch src/index.ts" "dev": "bun run --watch src/index.ts"
}, },
"dependencies": { "dependencies": {
"@elysiajs/cron": "^1.4.2",
"elysia": "latest" "elysia": "latest"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,7 +1,349 @@
import { Elysia } from "elysia"; 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( type PageIndicator = "none" | "minor" | "major" | "critical" | "unknown";
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` 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<T>(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<T>(url: string): Promise<T> {
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<CloudflareSummaryResponse>(`${CLOUDFLARE_API_URL}/summary.json`),
fetchJson<CloudflareIncidentsResponse>(`${CLOUDFLARE_API_URL}/incidents.json`),
]);
const incidentsById = new Map<string, CloudflareIncident>();
for (const incident of [
...asArray<CloudflareIncident>(summary.incidents),
...asArray<CloudflareIncident>(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<CloudflareComponent>(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<CloudflareMaintenance>(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`<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#07111f" />
<meta name="description" content="Cloudflare 장애 없는 날을 확인하는 상태 대시보드" />
<title>Cloudflare · Days Without Incident</title>
<style>
:root { color-scheme: dark; --bg: #06101e; --card: rgba(13, 31, 52, .7); --line: rgba(141, 196, 255, .16); --text: #eef7ff; --muted: #8da8c0; --blue: #69b7ff; --cyan: #59e3f4; --green: #58e39a; --amber: #ffc76b; --red: #ff7d9b; }
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--bg); }
body { margin: 0; min-height: 100vh; overflow-x: hidden; color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: radial-gradient(circle at 50% -8%, rgba(70, 168, 255, .24), transparent 36rem), radial-gradient(circle at 10% 58%, rgba(20, 103, 170, .11), transparent 26rem), linear-gradient(150deg, #071527 0%, #06101e 48%, #030912 100%); }
body::before { position: fixed; inset: 0; z-index: -1; pointer-events: none; content: ""; opacity: .22; background-image: linear-gradient(rgba(127, 182, 235, .07) 1px, transparent 1px), linear-gradient(90deg, rgba(127, 182, 235, .07) 1px, transparent 1px); background-size: 72px 72px; mask-image: linear-gradient(to bottom, black, transparent 78%); }
a { color: inherit; } button { font: inherit; }
.shell { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding: 28px 0 64px; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.brand { display: inline-flex; align-items: center; gap: 12px; text-decoration: none; }
.brand-mark { display: grid; width: 38px; height: 38px; place-items: center; border: 1px solid rgba(124, 203, 255, .48); border-radius: 12px; color: #c7efff; background: linear-gradient(145deg, rgba(65, 176, 255, .38), rgba(19, 72, 126, .45)); box-shadow: 0 0 28px rgba(45, 171, 255, .2); }
.brand-mark svg { width: 22px; height: 22px; } .brand-copy { display: grid; gap: 2px; } .brand-copy strong { font-size: 14px; letter-spacing: .04em; } .brand-copy span, .eyebrow { color: var(--muted); font-size: 11px; letter-spacing: .16em; text-transform: uppercase; }
.source-link { color: var(--muted); font-size: 12px; text-decoration: none; transition: color .2s ease; } .source-link:hover { color: var(--text); }
.hero { padding: 84px 0 68px; text-align: center; } .eyebrow { display: inline-flex; align-items: center; gap: 8px; color: #9dcbeb; } .eyebrow::before { width: 5px; height: 5px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 14px var(--cyan); content: ""; }
.hero h1 { max-width: 780px; margin: 18px auto 16px; font-size: clamp(35px, 7vw, 72px); line-height: .98; letter-spacing: -.065em; text-wrap: balance; } .hero h1 em { color: var(--blue); font-style: normal; text-shadow: 0 0 30px rgba(105, 183, 255, .28); }
.hero-subtitle { max-width: 560px; margin: 0 auto; color: var(--muted); font-size: 15px; line-height: 1.7; } .counter-wrap { position: relative; width: fit-content; margin: 42px auto 0; } .counter-wrap::before { position: absolute; inset: 12% -12%; z-index: -1; border-radius: 50%; background: rgba(49, 159, 255, .18); filter: blur(42px); content: ""; }
.counter { font-size: clamp(108px, 24vw, 246px); font-weight: 750; line-height: .8; letter-spacing: -.1em; color: #f5fbff; text-shadow: 0 8px 48px rgba(54, 165, 255, .2); } .counter-label { margin-top: 22px; color: #a9c7de; font-size: clamp(13px, 2vw, 16px); letter-spacing: .2em; text-transform: uppercase; } .hero-note { margin: 18px 0 0; color: #6f8ca6; font-size: 12px; }
.status-pill { display: inline-flex; align-items: center; gap: 8px; margin-top: 26px; padding: 9px 14px; border: 1px solid rgba(88, 227, 154, .25); border-radius: 999px; color: #b7f6d2; background: rgba(53, 162, 105, .11); font-size: 12px; } .status-pill::before { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 13px var(--green); content: ""; } .status-pill[data-indicator="minor"] { border-color: rgba(255, 199, 107, .32); color: #ffe3ad; background: rgba(193, 123, 30, .12); } .status-pill[data-indicator="minor"]::before { background: var(--amber); box-shadow: 0 0 13px var(--amber); } .status-pill[data-indicator="major"], .status-pill[data-indicator="critical"] { border-color: rgba(255, 125, 155, .32); color: #ffc0d0; background: rgba(173, 48, 83, .12); } .status-pill[data-indicator="major"]::before, .status-pill[data-indicator="critical"]::before { background: var(--red); box-shadow: 0 0 13px var(--red); } .status-pill[data-indicator="unknown"] { border-color: var(--line); color: var(--muted); background: rgba(120, 150, 180, .08); } .status-pill[data-indicator="unknown"]::before { background: var(--muted); box-shadow: none; }
.grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; } .card { min-width: 0; border: 1px solid var(--line); border-radius: 20px; background: linear-gradient(145deg, rgba(19, 48, 79, .72), rgba(8, 23, 41, .76)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .035), 0 18px 45px rgba(0, 0, 0, .14); } .card-pad { padding: 24px; }
.health-card { grid-column: span 7; } .components-card { grid-column: span 5; } .incidents-card, .maintenance-card { grid-column: span 6; } .card-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; } .card-title { margin: 0; font-size: 15px; letter-spacing: -.02em; } .card-kicker { margin: 5px 0 0; color: var(--muted); font-size: 12px; } .live-dot { display: inline-flex; align-items: center; gap: 6px; color: #8fdcb1; font-size: 11px; white-space: nowrap; } .live-dot::before { width: 6px; height: 6px; border-radius: 50%; background: var(--green); box-shadow: 0 0 10px var(--green); content: ""; }
.health-summary { display: flex; align-items: center; gap: 17px; padding: 17px; border: 1px solid rgba(111, 192, 255, .13); border-radius: 14px; background: rgba(2, 14, 28, .25); } .health-icon { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; border-radius: 12px; color: var(--cyan); background: rgba(64, 193, 224, .11); } .health-icon svg { width: 21px; height: 21px; } .health-summary strong { display: block; font-size: 15px; } .health-summary span { display: block; margin-top: 5px; color: var(--muted); font-size: 12px; }
.metric-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; } .metric { padding: 14px; border-radius: 14px; background: rgba(4, 17, 31, .32); } .metric strong { display: block; font-size: 20px; letter-spacing: -.04em; } .metric span { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; } .component-count { color: var(--muted); font-size: 12px; }
.component-list { display: grid; gap: 8px; max-height: 265px; overflow-y: auto; padding-right: 3px; } .component-list::-webkit-scrollbar { width: 4px; } .component-list::-webkit-scrollbar-thumb { border-radius: 4px; background: rgba(141, 196, 255, .22); } .component { display: flex; align-items: center; gap: 9px; min-width: 0; padding: 10px 11px; border-radius: 10px; background: rgba(4, 17, 31, .27); } .component .dot, .incident-status { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 9px rgba(88, 227, 154, .7); } .component[data-status="degraded_performance"] .dot { background: var(--amber); box-shadow: 0 0 9px rgba(255, 199, 107, .7); } .component[data-status="partial_outage"] .dot, .component[data-status="major_outage"] .dot { background: var(--red); box-shadow: 0 0 9px rgba(255, 125, 155, .7); } .component-name { overflow: hidden; color: #c7dced; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .component-state { margin-left: auto; color: var(--muted); font-size: 10px; white-space: nowrap; }
.incident-list, .maintenance-list { display: grid; gap: 8px; } .incident, .maintenance { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 12px; border-radius: 12px; background: rgba(4, 17, 31, .3); text-decoration: none; transition: background .2s ease, transform .2s ease; } .incident:hover, .maintenance:hover { transform: translateY(-1px); background: rgba(26, 67, 106, .38); } .incident-status { background: var(--muted); box-shadow: none; } .incident[data-active="true"] .incident-status { background: var(--amber); box-shadow: 0 0 9px rgba(255, 199, 107, .7); } .incident-copy, .maintenance-copy { min-width: 0; flex: 1; } .incident-copy strong, .maintenance-copy strong { display: block; overflow: hidden; font-size: 12px; font-weight: 550; text-overflow: ellipsis; white-space: nowrap; } .incident-copy span, .maintenance-copy span { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; } .incident-impact { color: #a4bfd4; font-size: 10px; text-transform: capitalize; } .empty { padding: 22px 12px; color: var(--muted); font-size: 12px; text-align: center; }
.footer { display: flex; justify-content: space-between; gap: 20px; margin-top: 22px; color: #648099; font-size: 11px; } .footer a { color: #82b8df; text-decoration: none; } .error-banner { display: none; margin: 18px 0 0; padding: 11px 14px; border: 1px solid rgba(255, 125, 155, .22); border-radius: 10px; color: #ffc0d0; background: rgba(173, 48, 83, .1); font-size: 12px; } .error-banner[data-visible="true"] { display: block; }
@media (max-width: 760px) { .shell { width: min(100% - 28px, 600px); padding-top: 20px; } .source-link { display: none; } .hero { padding: 68px 0 52px; } .hero h1 { font-size: clamp(34px, 12vw, 62px); } .counter { font-size: clamp(104px, 34vw, 190px); } .health-card, .components-card, .incidents-card, .maintenance-card { grid-column: span 12; } .footer { flex-direction: column; gap: 8px; } }
@media (max-width: 430px) { .card-pad { padding: 18px; } .metric strong { font-size: 17px; } .component-state { display: none; } }
</style>
</head>
<body>
<main class="shell">
<nav class="topbar"><a class="brand" href="/"><span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 3.5 19 6v5.4c0 4.3-2.9 7.9-7 9.1-4.1-1.2-7-4.8-7-9.1V6l7-2.5Z"/><path d="m8.7 12.2 2.1 2.1 4.6-4.6"/></svg></span><span class="brand-copy"><strong>CF STATUS D-DAY</strong><span>Cloudflare reliability</span></span></a><a class="source-link" href="https://www.cloudflarestatus.com" target="_blank" rel="noreferrer">cloudflarestatus.com </a></nav>
<section class="hero"><span class="eyebrow">a quiet day on the edge</span><h1>Cloudflare <br /><em id="hero-days"> </em> </h1><p class="hero-subtitle"> Cloudflare Status API를 <br /> .</p><div class="counter-wrap"><div id="days" class="counter" aria-live="polite"></div><div class="counter-label">days without incident</div></div><div id="status-pill" class="status-pill" data-indicator="unknown"> </div><p id="hero-note" class="hero-note">Cloudflare status .</p><div id="error-banner" class="error-banner" data-visible="false"></div></section>
<section class="grid" aria-label="Cloudflare status overview">
<article class="card health-card"><div class="card-pad"><div class="card-heading"><div><h2 class="card-title"> </h2><p class="card-kicker">Cloudflare </p></div><span class="live-dot">LIVE</span></div><div class="health-summary"><div class="health-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 12h3l2-6 3 12 2-6h6"/><path d="M4 19h16" opacity=".45"/></svg></div><div><strong id="status-description"> </strong><span id="last-updated"> : </span></div></div><div class="metric-row"><div class="metric"><strong id="total-components"></strong><span> </span></div><div class="metric"><strong id="operational-components"></strong><span> </span></div><div class="metric"><strong id="affected-components"></strong><span> </span></div></div></div></article>
<article class="card components-card"><div class="card-pad"><div class="card-heading"><div><h2 class="card-title"> </h2><p class="card-kicker"> </p></div><span id="component-count" class="component-count"></span></div><div id="component-list" class="component-list"><div class="empty"> </div></div></div></article>
<article class="card incidents-card"><div class="card-pad"><div class="card-heading"><div><h2 class="card-title"> </h2><p class="card-kicker">Cloudflare Status에 </p></div></div><div id="incident-list" class="incident-list"><div class="empty"> </div></div></div></article>
<article class="card maintenance-card"><div class="card-pad"><div class="card-heading"><div><h2 class="card-title"> </h2><p class="card-kicker"> </p></div></div><div id="maintenance-list" class="maintenance-list"><div class="empty"> .</div></div></div></article>
</section>
<footer class="footer"><span id="footer-status">5 .</span><span>Data from <a href="https://www.cloudflarestatus.com/api" target="_blank" rel="noreferrer">Cloudflare Status API</a></span></footer>
</main>
<script>
const labels = { operational: "정상", degraded_performance: "성능 저하", partial_outage: "부분 장애", major_outage: "주요 장애", unknown: "확인 중" };
const statusLabels = { none: "모든 시스템 정상", minor: "일부 성능 저하", major: "부분 시스템 장애", critical: "주요 시스템 장애", unknown: "상태 확인 중" };
const impactLabels = { none: "영향 없음", minor: "경미한 영향", major: "주요 영향", critical: "치명적 영향" };
const incidentStatusLabels = { investigating: "조사 중", identified: "원인 확인", monitoring: "모니터링", resolved: "해결됨", postmortem: "사후 분석" };
const dateFormatter = new Intl.DateTimeFormat("ko-KR", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
const fullDateFormatter = new Intl.DateTimeFormat("ko-KR", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" });
const byId = (id) => document.getElementById(id);
const formatDate = (value, full) => { if (!value) return "—"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "—" : (full ? fullDateFormatter : dateFormatter).format(date); };
const escapeHtml = (value) => String(value || "").replace(/[&<>\"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#039;" }[character]));
const setText = (id, value) => { byId(id).textContent = value; };
function renderComponents(data) { const list = byId("component-list"); setText("component-count", data.componentCounts.total + "개"); if (!data.components.length) { list.innerHTML = '<div class="empty">구성요소 정보가 없습니다.</div>'; return; } list.innerHTML = data.components.slice(0, 14).map((component) => '<div class="component" data-status="' + component.status + '"><span class="dot"></span><span class="component-name" title="' + escapeHtml(component.description || component.name) + '">' + escapeHtml(component.name) + '</span><span class="component-state">' + (labels[component.status] || labels.unknown) + '</span></div>').join(""); if (data.components.length > 14) list.insertAdjacentHTML("beforeend", '<div class="empty">외 ' + (data.components.length - 14) + '개 구성요소</div>'); }
function renderIncidents(data) { const list = byId("incident-list"); if (!data.incidents.length) { list.innerHTML = '<div class="empty">기록된 인시던트가 없습니다.</div>'; return; } list.innerHTML = data.incidents.map((incident) => { const active = incident.status !== "resolved" && incident.status !== "postmortem"; const content = '<span class="incident-status"></span><span class="incident-copy"><strong>' + escapeHtml(incident.name) + '</strong><span>' + (incidentStatusLabels[incident.status] || incident.status) + ' · ' + formatDate(incident.startedAt) + '</span></span><span class="incident-impact">' + (impactLabels[incident.impact] || incident.impact) + '</span>'; return incident.shortlink ? '<a class="incident" data-active="' + active + '" href="' + escapeHtml(incident.shortlink) + '" target="_blank" rel="noreferrer">' + content + '</a>' : '<div class="incident" data-active="' + active + '">' + content + '</div>'; }).join(""); }
function renderMaintenances(data) { const list = byId("maintenance-list"); if (!data.maintenances.length) { list.innerHTML = '<div class="empty">예정된 유지보수가 없습니다.</div>'; return; } list.innerHTML = data.maintenances.map((maintenance) => { const content = '<span class="incident-status"></span><span class="maintenance-copy"><strong>' + escapeHtml(maintenance.name) + '</strong><span>' + escapeHtml(maintenance.status) + ' · ' + formatDate(maintenance.startsAt) + '</span></span><span class="incident-impact">' + (impactLabels[maintenance.impact] || maintenance.impact) + '</span>'; return maintenance.shortlink ? '<a class="maintenance" href="' + escapeHtml(maintenance.shortlink) + '" target="_blank" rel="noreferrer">' + content + '</a>' : '<div class="maintenance">' + content + '</div>'; }).join(""); }
function render(data) { const indicator = data.status.indicator; const active = data.streak.activeIncident; byId("status-pill").dataset.indicator = indicator; setText("status-pill", statusLabels[indicator] || statusLabels.unknown); setText("status-description", data.status.description); setText("hero-days", data.streak.days === null ? "아직" : data.streak.days.toLocaleString("ko-KR")); setText("days", data.streak.days === null ? "—" : data.streak.days.toLocaleString("ko-KR")); setText("hero-note", active ? "현재 진행 중인 인시던트가 있어 카운터가 멈춰 있습니다." : (data.lastIncident ? "마지막 인시던트: " + data.lastIncident.name + " · " + formatDate(data.lastIncident.startedAt, true) : "최근 인시던트 데이터를 기다리는 중입니다.")); setText("last-updated", "마지막 동기화: " + formatDate(data.fetchedAt, true)); setText("total-components", data.componentCounts.total.toLocaleString("ko-KR")); setText("operational-components", data.componentCounts.operational.toLocaleString("ko-KR")); setText("affected-components", data.componentCounts.affected.toLocaleString("ko-KR")); setText("footer-status", data.error ? "마지막 정상 데이터 이후 업데이트에 실패했습니다." : "5분마다 자동으로 업데이트됩니다."); byId("error-banner").dataset.visible = data.error ? "true" : "false"; setText("error-banner", data.error ? "업데이트 오류: " + data.error : ""); renderComponents(data); renderIncidents(data); renderMaintenances(data); }
async function load() { try { const response = await fetch("/api/status", { cache: "no-store" }); render(await response.json()); } catch (error) { setText("hero-note", "상태 데이터를 불러오지 못했습니다. 잠시 후 다시 시도합니다."); } }
load(); setInterval(load, 60 * 1000);
</script>
</body>
</html>`;
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}`);