Build Cloudflare status D-Day dashboard
This commit is contained in:
parent
413b1ac7c0
commit
e5c54597c5
4 changed files with 420 additions and 12 deletions
20
README.md
20
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.
|
||||
Open http://localhost:3000/ in your browser.
|
||||
|
|
|
|||
61
bun.lock
Normal file
61
bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
"dev": "bun run --watch src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cron": "^1.4.2",
|
||||
"elysia": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
348
src/index.ts
348
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<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) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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}`);
|
||||
|
|
|
|||
Loading…
Reference in a new issue