From 94232f5851df1e2559fa9d3aebaedcf623cfcda5 Mon Sep 17 00:00:00 2001 From: nate Date: Fri, 10 Apr 2026 03:05:01 +0400 Subject: [PATCH] clean up --- apps/api/src/routes/status_pages.ts | 13 ++-- apps/shared/db.ts | 2 - apps/status/src/data.ts | 88 +++---------------------- apps/status/src/index.ts | 14 ++-- apps/status/src/static/app.css | 23 +------ apps/status/src/views/page.ejs | 73 ++------------------ apps/web/src/routes/dashboard.ts | 12 ++-- apps/web/src/views/docs.ejs | 3 +- apps/web/src/views/status-page-edit.ejs | 17 ----- 9 files changed, 31 insertions(+), 214 deletions(-) diff --git a/apps/api/src/routes/status_pages.ts b/apps/api/src/routes/status_pages.ts index caa0318..b7fb905 100644 --- a/apps/api/src/routes/status_pages.ts +++ b/apps/api/src/routes/status_pages.ts @@ -3,7 +3,6 @@ import { requireAuth } from "./auth"; import sql from "../db"; const Theme = t.Union([t.Literal("auto"), t.Literal("light"), t.Literal("dark")]); -const DisplayMode = t.Union([t.Literal("compact"), t.Literal("expanded")]); const BarFrequency = t.Union([t.Literal("hourly"), t.Literal("daily")]); const StatusPageBody = t.Object({ @@ -16,7 +15,6 @@ const StatusPageBody = t.Object({ show_powered_by: t.Optional(t.Boolean()), show_response_time: t.Optional(t.Boolean()), show_cert_expiry: t.Optional(t.Boolean()), - display_mode: t.Optional(DisplayMode), bar_frequency: t.Optional(BarFrequency), bar_count: t.Optional(t.Number({ minimum: 1, maximum: 180 })), custom_css: t.Optional(t.Nullable(t.String({ maxLength: 50_000 }))), @@ -32,7 +30,6 @@ const StatusPageBody = t.Object({ monitor_id: t.String(), group_index: t.Optional(t.Nullable(t.Number())), display_name: t.Optional(t.Nullable(t.String({ maxLength: 200 }))), - display_mode: t.Optional(t.Nullable(DisplayMode)), position: t.Optional(t.Number()), }))), }); @@ -55,7 +52,7 @@ async function replaceGroupsAndMonitors( pageId: string, accountId: string, groups: { name: string; position?: number }[] | undefined, - monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; display_mode?: "compact" | "expanded" | null; position?: number }[] | undefined, + monitorsList: { monitor_id: string; group_index?: number | null; display_name?: string | null; position?: number }[] | undefined, ) { if (groups !== undefined) { await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`; @@ -99,13 +96,12 @@ async function replaceGroupsAndMonitors( monitor_id: m.monitor_id, group_id: groupId, display_name: m.display_name ?? null, - display_mode: m.display_mode ?? null, position: m.position ?? i, }); } if (rows.length > 0) { await sql` - INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "display_mode", "position")} + INSERT INTO status_page_monitors ${sql(rows, "status_page_id", "monitor_id", "group_id", "display_name", "position")} `; } } @@ -131,7 +127,7 @@ export const statusPages = new Elysia({ prefix: "/pages" }) [row] = await sql` INSERT INTO status_pages ( account_id, slug, title, description, theme, password_hash, index_search, - show_powered_by, show_response_time, show_cert_expiry, display_mode, + show_powered_by, show_response_time, show_cert_expiry, bar_frequency, bar_count, custom_css, footer_text, og_image_url, analytics_html, auto_refresh_s ) @@ -139,7 +135,7 @@ export const statusPages = new Elysia({ prefix: "/pages" }) ${accountId}, ${body.slug}, ${body.title}, ${body.description ?? null}, ${body.theme ?? 'auto'}, ${password_hash}, ${body.index_search ?? true}, ${body.show_powered_by ?? true}, ${body.show_response_time ?? true}, - ${body.show_cert_expiry ?? false}, ${body.display_mode ?? 'expanded'}, + ${body.show_cert_expiry ?? false}, ${body.bar_frequency ?? 'daily'}, ${body.bar_count ?? 90}, ${css}, ${body.footer_text ?? null}, ${body.og_image_url ?? null}, ${body.analytics_html ?? null}, ${body.auto_refresh_s ?? 60} @@ -195,7 +191,6 @@ export const statusPages = new Elysia({ prefix: "/pages" }) show_powered_by = COALESCE(${body.show_powered_by ?? null}, show_powered_by), show_response_time = COALESCE(${body.show_response_time ?? null}, show_response_time), show_cert_expiry = COALESCE(${body.show_cert_expiry ?? null}, show_cert_expiry), - display_mode = COALESCE(${body.display_mode ?? null}, display_mode), bar_frequency = COALESCE(${body.bar_frequency ?? null}, bar_frequency), bar_count = COALESCE(${body.bar_count ?? null}, bar_count), custom_css = CASE WHEN ${body.custom_css !== undefined} THEN ${css} ELSE custom_css END, diff --git a/apps/shared/db.ts b/apps/shared/db.ts index a174da8..5f73341 100644 --- a/apps/shared/db.ts +++ b/apps/shared/db.ts @@ -130,7 +130,6 @@ export async function migrate(sql: any) { show_powered_by BOOLEAN NOT NULL DEFAULT true, show_response_time BOOLEAN NOT NULL DEFAULT true, show_cert_expiry BOOLEAN NOT NULL DEFAULT false, - display_mode TEXT NOT NULL DEFAULT 'expanded', bar_frequency TEXT NOT NULL DEFAULT 'daily', bar_count INTEGER NOT NULL DEFAULT 90, custom_css TEXT, @@ -162,7 +161,6 @@ export async function migrate(sql: any) { monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, group_id UUID REFERENCES status_page_groups(id) ON DELETE SET NULL, display_name TEXT, - display_mode TEXT, position INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (status_page_id, monitor_id) ) diff --git a/apps/status/src/data.ts b/apps/status/src/data.ts index 4388dcb..087afbc 100644 --- a/apps/status/src/data.ts +++ b/apps/status/src/data.ts @@ -4,7 +4,6 @@ import sql from "./db"; -export type Window = "24h" | "7d" | "30d" | "90d"; export type BucketType = "hourly" | "daily"; export interface StatusPageRow { @@ -19,7 +18,6 @@ export interface StatusPageRow { show_powered_by: boolean; show_response_time:boolean; show_cert_expiry: boolean; - display_mode: "compact" | "expanded"; bar_frequency: BucketType; bar_count: number; custom_css: string | null; @@ -29,13 +27,6 @@ export interface StatusPageRow { auto_refresh_s: number; } -export interface MultiWindowUptime { - d24: number | null; - d7: number | null; - d30: number | null; - d90: number | null; -} - export interface MonitorRow { id: string; display_name: string; @@ -49,14 +40,12 @@ export interface MonitorRow { // looks up groups by this token. group_id: string | null; position: number; - display_mode: "compact" | "expanded"; // resolved (per-monitor override → page default → 'expanded') // 'paused' means the monitor was disabled in the dashboard - the runner has // stopped checking it, and the public page should treat it as planned // maintenance rather than an outage. current_state: "up" | "down" | "unknown" | "paused"; region_states: Array<{ region: string; state: "up" | "down" | "unknown"; updated_at: string | null }>; uptime_pct: number | null; - uptime: MultiWindowUptime; // 24h / 7d / 30d / 90d row buckets: Array<{ start: string; total: number; up: number; avg_latency: number | null }>; // bar chart input avg_latency: number | null; latency_history: Array<{ region: string; latency_ms: number | null; ts: string }>; @@ -107,8 +96,6 @@ export async function loadGroups(pageId: string): Promise { export async function loadMonitors( pageId: string, - window: Window, - pageDisplayMode: "compact" | "expanded" = "expanded", barFrequency: BucketType = "daily", barCount: number = 90, ): Promise { @@ -126,7 +113,6 @@ export async function loadMonitors( m.enabled AS enabled, spm.group_id, spm.position, - spm.display_mode AS spm_display_mode FROM status_page_monitors spm JOIN monitors m ON m.id = spm.monitor_id WHERE spm.status_page_id = ${pageId} @@ -230,33 +216,14 @@ export async function loadMonitors( // barIndexed[mid][isoStart] → cross-region {total, up} for bar coloring (only rows of barFrequency) // barRegionLat[mid][region] → weighted latency over the bar window for picking fastest region // barRegionBucketLat[mid][region][iso] → per-bucket latency in the fastest region (only rows of barFrequency) - // windowTotals[mid][windowKey] → {up, total} per uptime window (24h/7d/30d/90d) // latByMonitor[mid][] → 30h hourly latency sparkline rows const barIndexed: Record> = {}; const barRegionLat: Record> = {}; const barRegionBucketLat: Record>> = {}; - type WindowKey = "d24" | "d7" | "d30" | "d90"; - const windowTotals: Record> = {}; - const initWindowTotals = (mid: string) => { - if (!windowTotals[mid]) { - windowTotals[mid] = { - d24: { up: 0, total: 0 }, - d7: { up: 0, total: 0 }, - d30: { up: 0, total: 0 }, - d90: { up: 0, total: 0 }, - }; - } - return windowTotals[mid]!; - }; - const latByMonitor: Record = {}; const nowMs = Date.now(); - const ms24h = 24 * 3600_000; - const ms7d = 7 * 86_400_000; - const ms30d = 30 * 86_400_000; - const ms90d = 90 * 86_400_000; const ms30h = 30 * 3600_000; for (const r of rollupRows) { @@ -290,20 +257,6 @@ export async function loadMonitors( } } - // Multi-window uptime accumulators. 24h uses hourly buckets; 7d/30d/90d - // use daily buckets - same as the old loadMultiWindowUptime SQL did. - // Strict `<` to match the old SQL's `bucket_start > now() - interval`. - const wt = initWindowTotals(mid); - if (bt === "hourly" && nowMs - startMs < ms24h) { - wt.d24.up += up; wt.d24.total += total; - } - if (bt === "daily") { - const age = nowMs - startMs; - if (age < ms7d) { wt.d7.up += up; wt.d7.total += total; } - if (age < ms30d) { wt.d30.up += up; wt.d30.total += total; } - if (age < ms90d) { wt.d90.up += up; wt.d90.total += total; } - } - // 30h hourly latency sparkline. if (bt === "hourly" && nowMs - startMs < ms30h) { if (!latByMonitor[mid]) latByMonitor[mid] = []; @@ -370,21 +323,6 @@ export async function loadMonitors( }); } - // Multi-window uptime is a straight read from the windowTotals accumulator. - // We deliberately do NOT round to 2 decimals here - the formatter on the - // public page truncates (not rounds) to 2 decimals so a value like 99.9999% - // doesn't visually round up to "100.00%". Pre-rounding here would erase that - // information before the formatter ever sees it. - const multiWindow: Record = {}; - const toPct = (up: number, total: number): number | null => - total > 0 ? (100 * up / total) : null; - for (const id of ids) { - const wt = windowTotals[id]; - multiWindow[id] = wt - ? { d24: toPct(wt.d24.up, wt.d24.total), d7: toPct(wt.d7.up, wt.d7.total), d30: toPct(wt.d30.up, wt.d30.total), d90: toPct(wt.d90.up, wt.d90.total) } - : { d24: null, d7: null, d30: null, d90: null }; - } - const latencyByMonitorList = latByMonitor; return monitorRows.map((m) => { @@ -409,20 +347,14 @@ export async function loadMonitors( uptime_pct = tot > 0 ? (100 * upT / tot) : null; } const avg_latency = fastestLatency[m.id] ?? null; - // Per-monitor display mode override → page default → 'expanded'. - const display_mode = (m.spm_display_mode === 'compact' || m.spm_display_mode === 'expanded') - ? m.spm_display_mode - : pageDisplayMode; return { id: m.id, display_name: m.display_name, group_id: m.group_id, position: m.position, - display_mode, current_state, region_states, uptime_pct, - uptime: multiWindow[m.id] ?? { d24: null, d7: null, d30: null, d90: null }, buckets, avg_latency, latency_history: latencyByMonitorList[m.id] ?? [], @@ -483,7 +415,7 @@ export interface MonitorDetailPayload { generated_at: string; } -export async function loadMonitorDetail(slug: string, monitorId: string, window?: Window): Promise { +export async function loadMonitorDetail(slug: string, monitorId: string): Promise { const page = await loadStatusPage(slug); if (!page) return null; // Existence check only - confirm the monitor is actually attached to this @@ -497,14 +429,13 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window? `; if (!link) return null; - const win = (window ?? '24h') as Window; // Reuse the bulk loader with a single-monitor list - keeps the bucket/state // logic in one place. Cheap because we're querying for one ID. We also need // the page's groups so we can redact the monitor's group_id (UUID → public // position-as-string token), matching what /:slug.json emits. const [allGroups, allMonitors] = await Promise.all([ loadGroups(page.id), - loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count), + loadMonitors(page.id, page.bar_frequency, page.bar_count), ]); const { monitors } = redactGroupsAndMonitors(allGroups, allMonitors); const m = monitors.find((x) => x.id === monitorId); @@ -570,7 +501,6 @@ export interface PublicPageView { show_powered_by: boolean; show_response_time: boolean; show_cert_expiry: boolean; - display_mode: "compact" | "expanded"; bar_frequency: BucketType; bar_count: number; custom_css: string | null; @@ -609,7 +539,6 @@ function redactPageForPublic(p: StatusPageRow): PublicPageView { show_powered_by: p.show_powered_by, show_response_time: p.show_response_time, show_cert_expiry: p.show_cert_expiry, - display_mode: p.display_mode, bar_frequency: p.bar_frequency, bar_count: p.bar_count, custom_css: p.custom_css, @@ -635,20 +564,19 @@ function redactGroupsAndMonitors( name: g.name, position: g.position, })); - const publicMonitors = monitors.map((m) => { - const { uptime, ...rest } = m; - return { ...rest, group_id: m.group_id ? (idMap.get(m.group_id) ?? null) : null }; - }); + const publicMonitors = monitors.map((m) => ({ + ...m, + group_id: m.group_id ? (idMap.get(m.group_id) ?? null) : null, + })); return { groups: publicGroups, monitors: publicMonitors }; } -export async function loadPagePayload(slug: string, window?: Window): Promise { +export async function loadPagePayload(slug: string): Promise { const page = await loadStatusPage(slug); if (!page) return null; - const win = (window ?? '24h') as Window; const [rawGroups, rawMonitors, incidents] = await Promise.all([ loadGroups(page.id), - loadMonitors(page.id, win, page.display_mode, page.bar_frequency, page.bar_count), + loadMonitors(page.id, page.bar_frequency, page.bar_count), loadIncidents(page.id), ]); const { groups, monitors } = redactGroupsAndMonitors(rawGroups, rawMonitors); diff --git a/apps/status/src/index.ts b/apps/status/src/index.ts index 748718c..c9156c2 100644 --- a/apps/status/src/index.ts +++ b/apps/status/src/index.ts @@ -3,7 +3,7 @@ import { Eta } from "eta"; import { resolve } from "path"; import { createHash } from "crypto"; import sql from "./db"; -import { loadStatusPage, loadPagePayload, loadMonitorDetail, type Window } from "./data"; +import { loadStatusPage, loadPagePayload, loadMonitorDetail } from "./data"; import { renderRss } from "./render/rss"; import { renderBadge, badgeFromState } from "./render/badge"; import { cached } from "./cache"; @@ -132,7 +132,7 @@ async function renderHtml(slug: string, request: Request): Promise { return new Response(html, { headers }); } -async function renderJson(slug: string, request: Request, win?: Window): Promise { +async function renderJson(slug: string, request: Request): Promise { // Same as renderHtml: never cache the page row. The auth check has to see // the live password_hash, otherwise rotating a password leaves a 15s // window where old cookies still validate against the stale row. @@ -143,8 +143,7 @@ async function renderJson(slug: string, request: Request, win?: Window): Promise // fall back to HTML scraping (which gets the password form, also a 401-ish // signal but more expensive to parse and less stable). if (!page || !isAuthorised(page, request)) return jsonNotFound(); - const cacheKey = `payload:${slug}:${win ?? '24h'}`; - const payload = await cached(cacheKey, 15, () => loadPagePayload(slug, win)); + const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug)); if (!payload) return jsonNotFound(); // Password-protected JSON must be private - same reasoning as renderHtml. const cacheControl = page.password_hash @@ -208,7 +207,7 @@ const app = new Elysia() .get("/:slug", async ({ params, request, query }) => { const { slug, format } = splitSlugAndFormat(params.slug); if (!allow(slug, clientIp(request))) return rateLimited(); - if (format === "json") return renderJson(slug, request, (query as any)?.window as Window | undefined); + if (format === "json") return renderJson(slug, request); if (format === "rss") return renderRssResp(slug, request); return renderHtml(slug, request); }) @@ -253,9 +252,8 @@ const app = new Elysia() if (!page || !isAuthorised(page, request)) return jsonNotFound(); const idWithExt = params.idWithExt; const monitorId = idWithExt.endsWith(".json") ? idWithExt.slice(0, -5) : idWithExt; - const win = (query as any)?.window as Window | undefined; - const cacheKey = `monitor:${params.slug}:${monitorId}:${win ?? ''}`; - const payload = await cached(cacheKey, 15, () => loadMonitorDetail(params.slug, monitorId, win)); + const cacheKey = `monitor:${params.slug}:${monitorId}`; + const payload = await cached(cacheKey, 15, () => loadMonitorDetail(params.slug, monitorId)); if (!payload) return jsonNotFound(); const cacheControl = page.password_hash ? "private, no-store, must-revalidate" diff --git a/apps/status/src/static/app.css b/apps/status/src/static/app.css index ba39967..5c4ae74 100644 --- a/apps/status/src/static/app.css +++ b/apps/status/src/static/app.css @@ -55,10 +55,6 @@ h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; } .group-body { display: none; padding: 0 0.75rem 0.75rem; } .group-toggle:checked ~ .group-body { display: block; } .group-body .monitors { gap: 0.35rem; } -/* Compact monitor inside a group - no collapse, no uptime cells */ -.monitor-compact { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; } -.monitor-compact-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; } -.monitor-compact .bars { height: 24px; } .page-components { display: flex; flex-direction: column; gap: 0.5rem; } .monitors { display: flex; flex-direction: column; gap: 0.5rem; } /* All monitors share one structure: a clickable header row + a collapsible @@ -94,22 +90,8 @@ h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; } #bar-tooltip .pct.warn { color: var(--bar-partial); } #bar-tooltip .pct.bad { color: var(--bar-down); } /* Multi-window uptime row: four labelled cells side by side. */ -.uptime-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.5rem; margin-top: 0.75rem; } -.uptime-cell { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.65rem; } -.uptime-cell .label { font-size: 0.65rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.2rem; } -.uptime-cell .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; } -.uptime-cell.good .value { color: var(--bar-up); } -.uptime-cell.warn .value { color: var(--bar-partial); } -.uptime-cell.bad .value { color: var(--bar-down); } -.uptime-cell.empty .value { color: var(--muted); } -/* Header row is always clickable; detail panel collapses/expands via hidden checkbox. */ -.monitor-toggle { display: none; } -.monitor-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; cursor: pointer; background: transparent; border: none; color: inherit; width: 100%; text-align: left; font-family: inherit; font-size: inherit; } -.monitor-row:hover { background: rgba(255,255,255,0.02); } -.monitor-row .chev { color: var(--muted); transition: transform 0.15s; flex-shrink: 0; } -.monitor-toggle:checked ~ .monitor-row .chev { transform: rotate(90deg); } -.monitor-detail { padding: 0 1.25rem 1rem; border-top: 1px solid var(--border); display: none; } -.monitor-toggle:checked ~ .monitor-detail { display: block; padding-top: 1rem; } +.monitor-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem 0; } +.monitor .bars { padding: 0.5rem 1.25rem 1rem; } .regions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; font-size: 0.75rem; } .region { padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--border); } .region.up { color: var(--green); border-color: rgba(16,185,129,0.3); } @@ -160,7 +142,6 @@ h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; } .past-update-body p { display: inline; margin: 0; } .past-update-body code { background: var(--bg); padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em; } .past-update-time { color: var(--muted); font-size: 0.7rem; margin-top: 0.15rem; } -.page-uptime { margin-top: 2rem; } footer { padding-top: 2rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } diff --git a/apps/status/src/views/page.ejs b/apps/status/src/views/page.ejs index 1988458..b0e4879 100644 --- a/apps/status/src/views/page.ejs +++ b/apps/status/src/views/page.ejs @@ -95,19 +95,6 @@ : overall === 'degraded' ? 'rgba(245,158,11,0.4)' : 'rgba(239,68,68,0.4)'; - // Page-wide aggregate uptime - average across active monitors - const pageUptimeAcc = { d24: { sum: 0, n: 0 }, d7: { sum: 0, n: 0 }, d30: { sum: 0, n: 0 }, d90: { sum: 0, n: 0 } }; - for (const m of monitors) { - if (m.current_state === 'paused') continue; - const u = m.uptime || {}; - for (const key of ['d24', 'd7', 'd30', 'd90']) { - if (u[key] != null) { pageUptimeAcc[key].sum += u[key]; pageUptimeAcc[key].n++; } - } - } - function pageUptimePct(key) { - const a = pageUptimeAcc[key]; - return a.n > 0 ? (a.sum / a.n) : null; - } %> @@ -182,11 +169,6 @@ <% } %> <% - // Per-monitor mode now lives on each MonitorRow.display_mode (already - // resolved against the page-level fallback by the data layer). The - // page-level isCompact is kept only as a hint for whether to emit the - // expand JS at all. - const anyCompact = monitors.some(m => m.display_mode === 'compact'); const windowLabel = page.bar_frequency === 'hourly' ? ('Last ' + page.bar_count + ' hour' + (page.bar_count === 1 ? '' : 's')) : ('Last ' + page.bar_count + ' day' + (page.bar_count === 1 ? '' : 's')); @@ -253,35 +235,9 @@
<% list.forEach(function(m) { const buckets = m.buckets || []; - const hasData = buckets.some(b => b.total > 0); - const startsOpen = m.display_mode !== 'compact'; %> - <% if (inGroup) { %> -
-
-
- - <%= m.display_name %> -
-
- <% if (m.current_state === 'paused') { %> - Maintenance - <% } else { %> - <% if (page.show_response_time && m.avg_latency != null) { %><%= m.avg_latency %>ms<% } %> - <%= fmtUptime(m.uptime_pct) %> - <% } %> -
-
-
- <% buckets.forEach(function(b) { %> -
data-latency="<%= b.avg_latency %>"<% } %>>
- <% }); %> -
-
- <% } else { %>
- > -
<% if (groupName) { %> @@ -362,15 +310,6 @@
<% } %> -
-
-
24h
<%= fmtUptime(pageUptimePct('d24')) %>
-
7d
<%= fmtUptime(pageUptimePct('d7')) %>
-
30d
<%= fmtUptime(pageUptimePct('d30')) %>
-
90d
<%= fmtUptime(pageUptimePct('d90')) %>
-
-
-