diff --git a/src/db.ts b/src/db.ts index 2d1262c..33ee48c 100644 --- a/src/db.ts +++ b/src/db.ts @@ -86,6 +86,57 @@ export async function getUptimeBuckets( return result; } +export async function getOverallUptimeDays( + db: D1Database, + days: number, +): Promise<{ date: string; status: "up" | "degraded" | "down" | "none" }[]> { + const since = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60; + const rows = await db + .prepare( + `SELECT + (timestamp / 86400) AS day_bucket, + status, + COUNT(*) AS cnt + FROM pings + WHERE timestamp >= ? + GROUP BY day_bucket, status + ORDER BY day_bucket ASC`, + ) + .bind(since) + .all(); + + const bucketMap = new Map>(); + for (const row of rows.results) { + const b = row.day_bucket as number; + if (!bucketMap.has(b)) bucketMap.set(b, new Map()); + bucketMap.get(b)!.set(row.status as string, row.cnt as number); + } + + const now = Math.floor(Date.now() / 1000); + const todayBucket = Math.floor(now / 86400); + const result: { date: string; status: "up" | "degraded" | "down" | "none" }[] = []; + + for (let i = days - 1; i >= 0; i--) { + const bucket = todayBucket - i; + const d = new Date(bucket * 86400 * 1000); + const date = d.toISOString().slice(0, 10); + const counts = bucketMap.get(bucket); + + if (!counts) { + result.push({ date, status: "none" }); + continue; + } + + let status: "up" | "degraded" | "down" = "up"; + if (counts.has("down") || counts.has("timeout")) status = "down"; + else if (counts.has("degraded") || counts.has("misconfigured")) status = "degraded"; + + result.push({ date, status }); + } + + return result; +} + export async function getLastCheckTime( db: D1Database, ): Promise { diff --git a/src/routes/index.ts b/src/routes/index.ts index 87aa9b7..0f85a21 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,6 +1,6 @@ import type { Env } from "../types"; import { getManifest } from "../manifest"; -import { getLatestPing, getUptime7d, getLastCheckTime } from "../db"; +import { getLatestPing, getUptime7d, getOverallUptimeDays, getLastCheckTime } from "../db"; import { getDeviceStatus } from "../tailscale"; import { getOverallStatus } from "../overall"; import { COMMIT_SHA } from "../version"; @@ -39,6 +39,7 @@ export async function handleIndex(env: Env): Promise { const clients = machines.filter((m) => m.type === "client"); const { grade: overallClass, label: overallText } = await getOverallStatus(env); + const uptimeDays = await getOverallUptimeDays(env.DB, 90); const html = ` @@ -90,12 +91,20 @@ export async function handleIndex(env: Env): Promise { .clients-header { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: #8b949e; margin-bottom: 0.5rem; } .clients-list { display: flex; flex-wrap: wrap; gap: 0.5rem; } .client { display: flex; align-items: center; gap: 0.25rem; font-size: 0.8rem; color: #8b949e; } - footer { margin-top: auto; padding-top: 1rem; border-top: 1px solid #21262d; font-size: 0.7rem; color: #8b949e; display: flex; justify-content: space-between; } + .uptime-bar { display: flex; position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 10; } + .uptime-bar .day { flex: 1; } + .uptime-bar .day.up { background: #2ecc71; } + .uptime-bar .day.degraded { background: #f39c12; } + .uptime-bar .day.down { background: #e74c3c; } + .uptime-bar .day.none { background: #21262d; } + footer { margin-top: auto; padding-top: 1rem; border-top: 1px solid #21262d; font-size: 0.7rem; color: #8b949e; } + .footer-meta { display: flex; justify-content: space-between; } footer a { color: #8b949e; text-decoration: none; } footer a:hover { text-decoration: underline; } +
${uptimeDays.map((d) => `
`).join("")}

infra.dunkirk.sh

${overallText}

${servers @@ -124,7 +133,9 @@ ${clients.length > 0 ? `
${clients.map((m) => `${esc(m.name)}`).join("\n")}
` : ""} -
${lastCheckISO ? `updated loading` : "no checks yet"}${COMMIT_SHA}
+
+ +