remove em dashes
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// Password gate for protected status pages. We sign a short-lived cookie with
|
||||
// the page id + a tag derived from the current password hash + an expiry, so
|
||||
// a successful password unlock survives across page loads without us having
|
||||
// to hit Postgres on every request — and so changing the page password
|
||||
// to hit Postgres on every request - and so changing the page password
|
||||
// invalidates every cookie issued under the old password.
|
||||
//
|
||||
// Cookie format: <pageId>.<pwTag>.<exp>.<sig>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Tiny in-memory TTL cache keyed by string. Status pages serve the same payload
|
||||
// to many visitors during an outage; we don't want every page hit to fan out to
|
||||
// Postgres. The cache is per-process; behind a load balancer each replica fills
|
||||
// independently, which is fine — short TTLs converge quickly.
|
||||
// independently, which is fine - short TTLs converge quickly.
|
||||
|
||||
interface Entry<T> { value: T; expires: number }
|
||||
|
||||
|
||||
+18
-18
@@ -1,5 +1,5 @@
|
||||
// Loads the read-only data needed to render a public status page. NEVER reads
|
||||
// the raw `pings` table — uses `monitor_region_state` for current state and
|
||||
// the raw `pings` table - uses `monitor_region_state` for current state and
|
||||
// `monitor_uptime_rollup` for historical uptime windows.
|
||||
|
||||
import sql from "./db";
|
||||
@@ -46,12 +46,12 @@ export interface MonitorRow {
|
||||
// etc.) must never leak to anonymous visitors via the JSON endpoint.
|
||||
// Group correlator. Emitted as the matching group's `position` index
|
||||
// (0-based string), NOT the underlying UUID, so the JSON doesn't leak
|
||||
// internal IDs. The HTML render works the same either way — it just
|
||||
// internal IDs. The HTML render works the same either way - it just
|
||||
// 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
|
||||
// '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";
|
||||
@@ -64,7 +64,7 @@ export interface MonitorRow {
|
||||
}
|
||||
|
||||
// Multi-window uptime (24h / 7d / 30d / 90d) is now derived from the same
|
||||
// rollup row set that loadMonitors pulls for the bar chart — see the in-JS
|
||||
// rollup row set that loadMonitors pulls for the bar chart - see the in-JS
|
||||
// aggregation pass below. This used to be a second SQL round-trip running
|
||||
// four FILTER aggregates that redid arithmetic the raw bucket rows already
|
||||
// contained.
|
||||
@@ -116,9 +116,9 @@ export async function loadMonitors(
|
||||
// Step 1: page → monitors with display overrides + group + position. Pull
|
||||
// m.enabled too so we can render disabled monitors as "Maintenance" on the
|
||||
// public page (the runner stops checking them when disabled, so their
|
||||
// region_states would otherwise drift to a stale "up" — visitors should
|
||||
// region_states would otherwise drift to a stale "up" - visitors should
|
||||
// see this as planned downtime, not phantom uptime).
|
||||
// Deliberately do NOT select m.url — see the MonitorRow comment for why the
|
||||
// Deliberately do NOT select m.url - see the MonitorRow comment for why the
|
||||
// raw target URL must never reach the public payload.
|
||||
const monitorRows = await sql<any[]>`
|
||||
SELECT
|
||||
@@ -160,7 +160,7 @@ export async function loadMonitors(
|
||||
//
|
||||
// Union of all of those is "hourly back N hours OR daily back N days" with
|
||||
// N chosen to cover whichever consumer needs the widest window. The rows
|
||||
// are then partitioned by purpose entirely in JS — no second round-trip,
|
||||
// are then partitioned by purpose entirely in JS - no second round-trip,
|
||||
// no duplicate FILTER aggregates inside Postgres.
|
||||
const bucket: BucketType = barFrequency;
|
||||
const count = Math.max(1, Math.min(180, barCount));
|
||||
@@ -270,7 +270,7 @@ export async function loadMonitors(
|
||||
const mid = r.monitor_id;
|
||||
const bt: BucketType = r.bucket_type;
|
||||
|
||||
// Bar chart accumulators — only rows matching the configured bar frequency.
|
||||
// Bar chart accumulators - only rows matching the configured bar frequency.
|
||||
if (bt === bucket) {
|
||||
if (!barIndexed[mid]) barIndexed[mid] = {};
|
||||
const slot = barIndexed[mid]![startIso] ?? { total: 0, up: 0 };
|
||||
@@ -292,7 +292,7 @@ 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.
|
||||
// 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) {
|
||||
@@ -319,7 +319,7 @@ export async function loadMonitors(
|
||||
// Sort the latency sparkline rows by ts ASC per monitor (the unified query
|
||||
// sorts by bucket_type then region then bucket_start, so the per-monitor
|
||||
// hourly subset is already ordered within a region but interleaved across
|
||||
// regions — this normalises it the same way the old separate query did).
|
||||
// regions - this normalises it the same way the old separate query did).
|
||||
for (const mid of Object.keys(latByMonitor)) {
|
||||
latByMonitor[mid]!.sort((a, b) => a.ts.localeCompare(b.ts));
|
||||
}
|
||||
@@ -372,7 +372,7 @@ 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
|
||||
// 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.
|
||||
@@ -396,7 +396,7 @@ export async function loadMonitors(
|
||||
const anyUp = region_states.some((s) => s.state === "up");
|
||||
current_state = anyDown ? "down" : anyUp ? "up" : "unknown";
|
||||
}
|
||||
// A disabled monitor is in operator-declared maintenance — runner has
|
||||
// A disabled monitor is in operator-declared maintenance - runner has
|
||||
// stopped checking it. Override whatever the last region state was so the
|
||||
// public page reads "Maintenance" instead of a stale "Operational".
|
||||
if (m.enabled === false) current_state = "paused";
|
||||
@@ -405,7 +405,7 @@ export async function loadMonitors(
|
||||
if (buckets.length > 0) {
|
||||
const tot = buckets.reduce((a, b) => a + b.total, 0);
|
||||
const upT = buckets.reduce((a, b) => a + b.up, 0);
|
||||
// Full precision — the display layer truncates (not rounds) to 2 decimals
|
||||
// Full precision - the display layer truncates (not rounds) to 2 decimals
|
||||
// so any downtime, however small, never visually rounds up to 100%.
|
||||
uptime_pct = tot > 0 ? (100 * upT / tot) : null;
|
||||
}
|
||||
@@ -487,7 +487,7 @@ export interface MonitorDetailPayload {
|
||||
export async function loadMonitorDetail(slug: string, monitorId: string, window?: Window): Promise<MonitorDetailPayload | null> {
|
||||
const page = await loadStatusPage(slug);
|
||||
if (!page) return null;
|
||||
// Existence check only — confirm the monitor is actually attached to this
|
||||
// Existence check only - confirm the monitor is actually attached to this
|
||||
// page. The bulk loader below produces the full payload; this query exists
|
||||
// purely so we can return null on a wrong slug/monitor combo without firing
|
||||
// the bigger query at all.
|
||||
@@ -499,7 +499,7 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
|
||||
if (!link) return null;
|
||||
|
||||
const win = (window ?? page.default_window) as Window;
|
||||
// Reuse the bulk loader with a single-monitor list — keeps the bucket/state
|
||||
// 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.
|
||||
@@ -556,10 +556,10 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
|
||||
|
||||
// The shape we actually expose to anonymous visitors. Computed by stripping
|
||||
// internal IDs and any field a public consumer doesn't need from the row
|
||||
// types — see redactPageForPublic / redactGroupsAndMonitors below.
|
||||
// types - see redactPageForPublic / redactGroupsAndMonitors below.
|
||||
//
|
||||
// custom_css and analytics_html are kept here even though they're noisy to
|
||||
// JSON consumers, because the HTML render reads from this same object — and
|
||||
// JSON consumers, because the HTML render reads from this same object - and
|
||||
// they're already publicly visible in the rendered HTML, so dropping them
|
||||
// from JSON wouldn't actually add any privacy.
|
||||
export interface PublicPageView {
|
||||
@@ -626,7 +626,7 @@ function redactPageForPublic(p: StatusPageRow): PublicPageView {
|
||||
|
||||
// Replace each group's UUID with its position-as-string. Monitors carry the
|
||||
// same token in their group_id field, so the consumer can still join them
|
||||
// — they just see opaque "0", "1", "2" tokens instead of internal UUIDs.
|
||||
// - they just see opaque "0", "1", "2" tokens instead of internal UUIDs.
|
||||
function redactGroupsAndMonitors(
|
||||
groups: GroupRow[],
|
||||
monitors: MonitorRow[],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Read-only Postgres client. The status service does NOT run migrations —
|
||||
// Read-only Postgres client. The status service does NOT run migrations -
|
||||
// schema is owned by apps/api. This file just opens a connection.
|
||||
import postgres from "postgres";
|
||||
|
||||
|
||||
+14
-14
@@ -38,7 +38,7 @@ function clientIp(req: Request): string {
|
||||
}
|
||||
|
||||
// 404s must NOT be cached by browsers or Cloudflare. The same URL frequently
|
||||
// flips between "200 with data" and "404 not-found" — for example when an
|
||||
// flips between "200 with data" and "404 not-found" - for example when an
|
||||
// operator adds a password to a previously-public page, or when a slug
|
||||
// changes, or when a monitor is removed. If Cloudflare cached a 404 (default
|
||||
// behaviour for unspecified Cache-Control on 404 responses) the operator
|
||||
@@ -81,7 +81,7 @@ function isAuthorised(page: { id: string; password_hash: string | null }, req: R
|
||||
if (!page.password_hash) return true;
|
||||
// Pass the current password_hash so verifyAuthCookie can derive the
|
||||
// expected pwTag and reject any cookie that was issued under a previous
|
||||
// password — i.e. rotating the password evicts every existing session.
|
||||
// password - i.e. rotating the password evicts every existing session.
|
||||
return verifyAuthCookie(req.headers.get("cookie"), page.id, page.password_hash);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ function splitSlugAndFormat(raw: string): { slug: string; format: "html" | "json
|
||||
}
|
||||
|
||||
async function renderHtml(slug: string, request: Request): Promise<Response> {
|
||||
// Page row is fetched fresh on every request — never cached. The page row
|
||||
// Page row is fetched fresh on every request - never cached. The page row
|
||||
// carries the live password_hash + index_search + display config; an
|
||||
// operator changing any of those in the dashboard must take effect
|
||||
// immediately, not after a TTL window. The single PK lookup is sub-ms,
|
||||
@@ -103,7 +103,7 @@ async function renderHtml(slug: string, request: Request): Promise<Response> {
|
||||
const page = await loadStatusPage(slug);
|
||||
if (!page) return notFound();
|
||||
if (!isAuthorised(page, request)) {
|
||||
// Do NOT pass page.title to the password template — that would let any
|
||||
// Do NOT pass page.title to the password template - that would let any
|
||||
// OSINT scraper iterating slugs harvest the human-readable name of every
|
||||
// private page without ever authenticating. Slug is fine: it's already
|
||||
// in the URL the visitor typed.
|
||||
@@ -115,7 +115,7 @@ async function renderHtml(slug: string, request: Request): Promise<Response> {
|
||||
const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug));
|
||||
if (!payload) return notFound();
|
||||
const html = eta.render("page", { ...payload, expandJsHash, appCssHash });
|
||||
// Password-protected pages MUST be private — never let an edge cache or
|
||||
// Password-protected pages MUST be private - never let an edge cache or
|
||||
// shared proxy hold a copy that some other visitor could pull. Public
|
||||
// pages keep the 15s shared cache for performance under viral hits.
|
||||
const cacheControl = page.password_hash
|
||||
@@ -146,7 +146,7 @@ async function renderJson(slug: string, request: Request, win?: Window): Promise
|
||||
const cacheKey = `payload:${slug}:${win ?? page.default_window}`;
|
||||
const payload = await cached(cacheKey, 15, () => loadPagePayload(slug, win));
|
||||
if (!payload) return jsonNotFound();
|
||||
// Password-protected JSON must be private — same reasoning as renderHtml.
|
||||
// Password-protected JSON must be private - same reasoning as renderHtml.
|
||||
const cacheControl = page.password_hash
|
||||
? "private, no-store, must-revalidate"
|
||||
: "public, max-age=15, s-maxage=15";
|
||||
@@ -182,11 +182,11 @@ async function renderRssResp(slug: string, request: Request): Promise<Response>
|
||||
}
|
||||
|
||||
const app = new Elysia()
|
||||
// No status page lives at the root — show the same 404 visitors get for any
|
||||
// No status page lives at the root - show the same 404 visitors get for any
|
||||
// unknown slug, so a stray hit on the apex doesn't leak service identity.
|
||||
.get("/", () => notFound())
|
||||
|
||||
// Static expand.js — cached aggressively, hash-busted via query string.
|
||||
// Static expand.js - cached aggressively, hash-busted via query string.
|
||||
.get("/_static/expand.js", () => new Response(Bun.file(expandJsPath), {
|
||||
headers: {
|
||||
"content-type": "application/javascript; charset=utf-8",
|
||||
@@ -194,7 +194,7 @@ const app = new Elysia()
|
||||
},
|
||||
}))
|
||||
|
||||
// Static app.css — same caching contract as expand.js. The query string in
|
||||
// Static app.css - same caching contract as expand.js. The query string in
|
||||
// the <link> tag is the file's MD5 hash, so deploys propagate immediately
|
||||
// even though the asset itself is marked immutable for a year.
|
||||
.get("/_static/app.css", () => new Response(Bun.file(appCssPath), {
|
||||
@@ -204,7 +204,7 @@ const app = new Elysia()
|
||||
},
|
||||
}))
|
||||
|
||||
// Single public route — dispatches HTML / JSON / RSS by extension on the slug.
|
||||
// Single public route - dispatches HTML / JSON / RSS by extension on the slug.
|
||||
.get("/:slug", async ({ params, request, query }) => {
|
||||
const { slug, format } = splitSlugAndFormat(params.slug);
|
||||
if (!allow(slug, clientIp(request))) return rateLimited();
|
||||
@@ -214,7 +214,7 @@ const app = new Elysia()
|
||||
})
|
||||
|
||||
// Public SVG badge. Password-protected pages 404 here so an unauthenticated
|
||||
// shields-style embed can't reveal a private page's current state — and
|
||||
// shields-style embed can't reveal a private page's current state - and
|
||||
// crucially can't even confirm whether a private slug exists, since the
|
||||
// 404 is identical to a totally bogus slug.
|
||||
.get("/:slug/badge.svg", async ({ params, request }) => {
|
||||
@@ -240,7 +240,7 @@ const app = new Elysia()
|
||||
|
||||
// Per-monitor detail JSON for the click-to-expand UI in compact mode.
|
||||
// Path is /:slug/monitor/:idWithExt where idWithExt is e.g. "abc123.json".
|
||||
// We strip the .json suffix in the handler — same trick as the slug route to
|
||||
// We strip the .json suffix in the handler - same trick as the slug route to
|
||||
// dodge memoirist's "two params at the same position" rule.
|
||||
.get("/:slug/monitor/:idWithExt", async ({ params, request, query }) => {
|
||||
if (!allow(params.slug, clientIp(request))) return rateLimited();
|
||||
@@ -320,8 +320,8 @@ const app = new Elysia()
|
||||
const port = Number(process.env.STATUS_PORT ?? 3003);
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
// Wrap app.handle in a try/catch so any unexpected throw — Postgres
|
||||
// connection blip, template render error, missing env var, etc. — turns
|
||||
// Wrap app.handle in a try/catch so any unexpected throw - Postgres
|
||||
// connection blip, template render error, missing env var, etc. - turns
|
||||
// into a generic 500 with an opaque body. Without this wrapper Bun's
|
||||
// default error path may include framework details, file paths, or stack
|
||||
// traces in the response, which would leak internal layout to anyone who
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Per-(slug, IP) token bucket. 30 requests in a 10s window. Cheap, in-memory,
|
||||
// resets on process restart. Behind a load balancer each replica enforces its
|
||||
// own bucket — that's fine, the goal is "stop a hostile script from melting one
|
||||
// own bucket - that's fine, the goal is "stop a hostile script from melting one
|
||||
// box", not perfect distributed accounting.
|
||||
|
||||
interface Bucket { tokens: number; refillAt: number }
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function renderRss(page: StatusPageRow, baseUrl: string): Promise<s
|
||||
body_html: u.body_html,
|
||||
}));
|
||||
|
||||
const channelTitle = escapeXml(`${page.title} — Incidents`);
|
||||
const channelTitle = escapeXml(`${page.title} - Incidents`);
|
||||
const channelDescription = escapeXml(page.description || `${page.title} status updates`);
|
||||
const channelLink = `${baseUrl}/${page.slug}`;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
visitors as soon as the new bundle is deployed.
|
||||
|
||||
Per-page custom CSS is still inlined in page.ejs (it's per-request data,
|
||||
not a build artifact) — kept in a separate <style> block AFTER this
|
||||
not a build artifact) - kept in a separate <style> block AFTER this
|
||||
file is loaded so it always wins on specificity ties. */
|
||||
|
||||
:root {
|
||||
@@ -126,7 +126,7 @@ h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem; }
|
||||
.incident-update .body code { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
||||
.past-incidents { margin-top: 3rem; }
|
||||
.past-incidents h2 { font-size: 1.1rem; margin-bottom: 1rem; }
|
||||
/* Past incidents — Atlassian-style: grouped by date with light typography. */
|
||||
/* Past incidents - Atlassian-style: grouped by date with light typography. */
|
||||
.past-day { margin-bottom: 2rem; }
|
||||
.past-day-header { font-size: 0.95rem; font-weight: 600; color: var(--fg); padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); margin: 0 0 1rem; }
|
||||
.past-day-empty { font-size: 0.85rem; color: var(--muted); margin: 0.25rem 0 0; }
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
var timeOpts = { hour: "2-digit", minute: "2-digit" };
|
||||
if (barFrequency === "hourly") {
|
||||
return start.toLocaleDateString(undefined, dateOpts) + ", " +
|
||||
start.toLocaleTimeString(undefined, timeOpts) + " — " +
|
||||
start.toLocaleTimeString(undefined, timeOpts) + " - " +
|
||||
end.toLocaleTimeString(undefined, timeOpts);
|
||||
}
|
||||
return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||
@@ -51,11 +51,11 @@
|
||||
var lat = latRaw == null ? null : parseInt(latRaw, 10);
|
||||
if (!start) return;
|
||||
// Full precision pct so the formatter can decide. Anything below 100% gets
|
||||
// 2 truncated (not rounded) decimals — same rule as the page-level uptime
|
||||
// 2 truncated (not rounded) decimals - same rule as the page-level uptime
|
||||
// numbers, so a bucket with one failed check never displays as "100%".
|
||||
var pct = total > 0 ? (100 * up / total) : null;
|
||||
var pctText;
|
||||
if (pct == null) pctText = "—";
|
||||
if (pct == null) pctText = "-";
|
||||
else if (pct >= 100) pctText = "100%";
|
||||
else pctText = (Math.floor(pct * 100) / 100).toFixed(2) + "%";
|
||||
var html = '<div class="head">' + fmtBucketRange(start) + "</div>";
|
||||
@@ -67,7 +67,7 @@
|
||||
html += '<div class="row"><span>Avg ping</span><span>' + lat + "ms</span></div>";
|
||||
}
|
||||
} else {
|
||||
html += '<div class="row"><span>No data</span><span>—</span></div>';
|
||||
html += '<div class="row"><span>No data</span><span>-</span></div>';
|
||||
}
|
||||
tooltip.innerHTML = html;
|
||||
tooltip.style.display = "block";
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
const groupOrder = [...groups.map(g => g.id), ''];
|
||||
|
||||
function fmtPct(p) {
|
||||
if (p == null) return '—';
|
||||
// Only show "100%" when the value is *exactly* 100. Anything below — even
|
||||
// 99.9999% — must show 2 decimals so visitors can see there was downtime.
|
||||
if (p == null) return '-';
|
||||
// Only show "100%" when the value is *exactly* 100. Anything below - even
|
||||
// 99.9999% - must show 2 decimals so visitors can see there was downtime.
|
||||
// Truncate (floor) rather than round, otherwise 99.9999 would render as
|
||||
// "100.00" and silently swallow the downtime.
|
||||
if (p >= 100) return '100%';
|
||||
@@ -48,7 +48,7 @@
|
||||
return 'bad';
|
||||
}
|
||||
function fmtUptime(p) {
|
||||
if (p == null) return '—';
|
||||
if (p == null) return '-';
|
||||
// Only "100%" if the monitor was up for every single check in the window.
|
||||
// Truncate (not round) below that so 99.9999% never displays as "100.00".
|
||||
if (p >= 100) return '100%';
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
|
||||
// Overall status: down if any monitor is down, degraded if any partial, else up.
|
||||
// Paused monitors are operator-declared maintenance — they don't count
|
||||
// Paused monitors are operator-declared maintenance - they don't count
|
||||
// toward "down" or "degraded", but we surface a small note in the banner
|
||||
// when at least one is in maintenance so visitors aren't confused.
|
||||
let paused_count = 0;
|
||||
@@ -77,7 +77,7 @@
|
||||
: overall === 'down' ? 'Major outage in progress'
|
||||
: 'Partial outage';
|
||||
if (paused_count > 0) {
|
||||
overallText += ' — ' + paused_count + (paused_count === 1 ? ' service' : ' services') + ' under maintenance';
|
||||
overallText += ' - ' + paused_count + (paused_count === 1 ? ' service' : ' services') + ' under maintenance';
|
||||
}
|
||||
const overallBg = overall === 'up' ? 'rgba(16,185,129,0.1)'
|
||||
: overall === 'degraded' ? 'rgba(245,158,11,0.1)'
|
||||
@@ -204,7 +204,7 @@
|
||||
</div>
|
||||
<div class="monitor-meta">
|
||||
<% if (m.current_state === 'paused') { %>
|
||||
<span class="maintenance-pill" title="This service is paused for maintenance — checks are not running.">Maintenance</span>
|
||||
<span class="maintenance-pill" title="This service is paused for maintenance - checks are not running.">Maintenance</span>
|
||||
<% } else { %>
|
||||
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %>
|
||||
<% const winKey = page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90'; %>
|
||||
|
||||
Reference in New Issue
Block a user