remove em dashes

This commit is contained in:
nate 2026-04-09 21:07:28 +04:00
parent 5f20b41e91
commit 79ba63d86b
31 changed files with 154 additions and 154 deletions

View File

@ -3,7 +3,7 @@
// this cache each poll re-runs a 500-row scan against `monitors` with an array // this cache each poll re-runs a 500-row scan against `monitors` with an array
// predicate, which dominates the api's Postgres traffic at any real fleet size. // predicate, which dominates the api's Postgres traffic at any real fleet size.
// //
// The list almost never changes between polls monitor create/edit/delete is at // The list almost never changes between polls - monitor create/edit/delete is at
// most a few times per hour. So we memoize per-region with a short TTL and bust // most a few times per hour. So we memoize per-region with a short TTL and bust
// the cache from the monitor mutation handlers so edits are visible instantly. // the cache from the monitor mutation handlers so edits are visible instantly.
@ -54,7 +54,7 @@ export async function getMonitorsForRegion(region: string): Promise<MonitorRow[]
} }
// Called by monitor create/patch/delete/toggle handlers. Wipes the entire // Called by monitor create/patch/delete/toggle handlers. Wipes the entire
// region map fine because (a) entries are tiny, (b) refresh is cheap, and // region map - fine because (a) entries are tiny, (b) refresh is cheap, and
// (c) we don't know which regions a freshly-edited monitor belongs to without // (c) we don't know which regions a freshly-edited monitor belongs to without
// reading it back. Simpler than per-region invalidation, identical net effect. // reading it back. Simpler than per-region invalidation, identical net effect.
export function invalidateMonitorList(): void { export function invalidateMonitorList(): void {

View File

@ -9,7 +9,7 @@ import sql from "../db";
// Each periodic pass scans ONLY pings newer than the watermark, then merges // Each periodic pass scans ONLY pings newer than the watermark, then merges
// them into existing rollup rows additively (total = total + new_total, etc.) // them into existing rollup rows additively (total = total + new_total, etc.)
// via ON CONFLICT … DO UPDATE. This makes per-pass work proportional to the // via ON CONFLICT … DO UPDATE. This makes per-pass work proportional to the
// delta of new pings, not the bucket size critical once a single account has // delta of new pings, not the bucket size - critical once a single account has
// thousands of monitors. // thousands of monitors.
type BucketType = "hourly" | "daily"; type BucketType = "hourly" | "daily";
@ -50,7 +50,7 @@ async function rollupSinceWatermark(bucket: BucketType): Promise<number> {
// after `boundary` will be picked up on the next pass. // after `boundary` will be picked up on the next pass.
const boundary = new Date(); const boundary = new Date();
// GROUP BY 1,2,4 (ordinals) instead of repeating the date_trunc expression // GROUP BY 1,2,4 (ordinals) instead of repeating the date_trunc expression -
// when the unit is a $-bound parameter, Postgres won't recognize the two // when the unit is a $-bound parameter, Postgres won't recognize the two
// expressions as identical and will reject the column. Ordinals are safe. // expressions as identical and will reject the column. Ordinals are safe.
// //
@ -91,7 +91,7 @@ async function rollupSinceWatermark(bucket: BucketType): Promise<number> {
// One-shot recompute over an arbitrary window, fully overwriting matched rows. // One-shot recompute over an arbitrary window, fully overwriting matched rows.
// Used for the startup backfill and the "still empty after backfill" force-run. // Used for the startup backfill and the "still empty after backfill" force-run.
// Takes an explicit upper boundary so the caller can capture it BEFORE running // Takes an explicit upper boundary so the caller can capture it BEFORE running
// the recompute and use the same value for the watermark write afterwards // the recompute and use the same value for the watermark write afterwards -
// any ping with checked_at > boundary is guaranteed to be outside this window // any ping with checked_at > boundary is guaranteed to be outside this window
// and will be picked up by the first incremental pass instead. This closes // and will be picked up by the first incremental pass instead. This closes
// the race where a ping ingested between boundary capture and the SELECT // the race where a ping ingested between boundary capture and the SELECT
@ -137,7 +137,7 @@ export async function startRollupJob() {
// Startup backfill. Capture the boundary FIRST, then run the one-shot // Startup backfill. Capture the boundary FIRST, then run the one-shot
// recompute bounded by it. The watermark is then set to the same boundary, // recompute bounded by it. The watermark is then set to the same boundary,
// so any ping with checked_at > boundary is guaranteed to be picked up by // so any ping with checked_at > boundary is guaranteed to be picked up by
// the first incremental pass never folded in twice and never missed. // the first incremental pass - never folded in twice and never missed.
try { try {
const boundary = new Date(); const boundary = new Date();
const [h, d] = await Promise.all([ const [h, d] = await Promise.all([
@ -150,7 +150,7 @@ export async function startRollupJob() {
setWatermark("daily", boundary), setWatermark("daily", boundary),
]); ]);
} catch (e) { } catch (e) {
console.error("[rollup] backfill FAILED rollup table will be empty until fixed:", e); console.error("[rollup] backfill FAILED - rollup table will be empty until fixed:", e);
} }
// Force-run check: if any bucket type is still empty after the backfill, // Force-run check: if any bucket type is still empty after the backfill,
@ -159,7 +159,7 @@ export async function startRollupJob() {
try { try {
for (const b of ["hourly", "daily"] as BucketType[]) { for (const b of ["hourly", "daily"] as BucketType[]) {
if (await rollupIsEmpty(b)) { if (await rollupIsEmpty(b)) {
console.log(`[rollup] ${b} still empty forcing incremental aggregation`); console.log(`[rollup] ${b} still empty - forcing incremental aggregation`);
// Reset watermark so the pass picks up everything in retention. // Reset watermark so the pass picks up everything in retention.
await setWatermark(b, new Date(0)); await setWatermark(b, new Date(0));
await rollupSinceWatermark(b); await rollupSinceWatermark(b);
@ -170,7 +170,7 @@ export async function startRollupJob() {
} }
// Periodic incremental refreshes. Each pass scans only pings newer than the // Periodic incremental refreshes. Each pass scans only pings newer than the
// last watermark, so the work is proportional to the delta not the bucket // last watermark, so the work is proportional to the delta - not the bucket
// size. Hourly runs frequently so the current-hour bar appears quickly for // size. Hourly runs frequently so the current-hour bar appears quickly for
// fresh monitors; daily can run less often. // fresh monitors; daily can run less often.
setInterval(() => { rollupSinceWatermark("hourly").catch((e) => console.warn("[rollup] hourly failed:", e)); }, 30 * 1000); // every 30s setInterval(() => { rollupSinceWatermark("hourly").catch((e) => console.warn("[rollup] hourly failed:", e)); }, 30 * 1000); // every 30s

View File

@ -108,13 +108,13 @@ export const internal = new Elysia({ prefix: "/internal", detail: { hide: true }
const lookaheadMs = Math.min(Number(params.get('lookahead_ms') || 2000), 10000); const lookaheadMs = Math.min(Number(params.get('lookahead_ms') || 2000), 10000);
// No JOIN, no state lookup. The check schedule for a monitor is purely // No JOIN, no state lookup. The check schedule for a monitor is purely
// a function of its created_at and interval_s every interval since // a function of its created_at and interval_s - every interval since
// creation is a tick. We pull all enabled monitors that match this // creation is a tick. We pull all enabled monitors that match this
// region, compute the next tick in JS, and return the ones whose next // region, compute the next tick in JS, and return the ones whose next
// tick falls within the lookahead window. // tick falls within the lookahead window.
// //
// The monitor list itself is memoized in apps/api/src/cache/monitor-list.ts // The monitor list itself is memoized in apps/api/src/cache/monitor-list.ts
// with a 5s TTL runners poll this endpoint roughly once a second per // with a 5s TTL - runners poll this endpoint roughly once a second per
// region, but the underlying list almost never changes between polls. The // region, but the underlying list almost never changes between polls. The
// cache is busted from monitor create/patch/delete/toggle so edits show up // cache is busted from monitor create/patch/delete/toggle so edits show up
// immediately. // immediately.

View File

@ -17,7 +17,7 @@ const MonitorBody = t.Object({
retry_interval_s: t.Optional(t.Number({ minimum: 1, maximum: 600, default: 30, description: "Seconds between retries" })), retry_interval_s: t.Optional(t.Number({ minimum: 1, maximum: 600, default: 30, description: "Seconds between retries" })),
resend_interval: t.Optional(t.Number({ minimum: 0, maximum: 1000, default: 0, description: "Re-alert every Nth consecutive down beat. 0 = never resend." })), resend_interval: t.Optional(t.Number({ minimum: 0, maximum: 1000, default: 0, description: "Re-alert every Nth consecutive down beat. 0 = never resend." })),
cert_alert_days: t.Optional(t.Number({ minimum: 0, maximum: 365, default: 0, description: "Alert when TLS cert is within N days of expiry. 0 disables (default)." })), cert_alert_days: t.Optional(t.Number({ minimum: 0, maximum: 365, default: 0, description: "Alert when TLS cert is within N days of expiry. 0 disables (default)." })),
query: t.Optional(t.Any({ description: "PingQL query filter conditions for up/down" })), query: t.Optional(t.Any({ description: "PingQL query - filter conditions for up/down" })),
regions: t.Optional(t.Array(t.String(), { description: "Regions to run checks from. Empty array = all regions." })), regions: t.Optional(t.Array(t.String(), { description: "Regions to run checks from. Empty array = all regions." })),
channel_ids: t.Optional(t.Array(t.String(), { description: "Notification channel IDs to attach to this monitor." })), channel_ids: t.Optional(t.Array(t.String(), { description: "Notification channel IDs to attach to this monitor." })),
tags: t.Optional(t.Array(t.String({ pattern: "^[a-z0-9][a-z0-9-]{0,40}$" }), { description: "Lowercase tag slugs for grouping. Replaces the existing tag set." })), tags: t.Optional(t.Array(t.String({ pattern: "^[a-z0-9][a-z0-9-]{0,40}$" }), { description: "Lowercase tag slugs for grouping. Replaces the existing tag set." })),

View File

@ -61,7 +61,7 @@ export const ingest = new Elysia()
// notifications never carry an empty label. // notifications never carry an empty label.
const region = body.region && body.region.length > 0 ? body.region : 'default'; const region = body.region && body.region.length > 0 ? body.region : 'default';
// The monitor lookup and the per-region state lookup are independent // The monitor lookup and the per-region state lookup are independent -
// the state row's primary key doesn't depend on anything from the monitor // the state row's primary key doesn't depend on anything from the monitor
// row. Fire them in parallel to halve the wall-clock cost on the hottest // row. Fire them in parallel to halve the wall-clock cost on the hottest
// path in the system. (Combining them into a JOIN is a wash on a warm // path in the system. (Combining them into a JOIN is a wash on a warm

View File

@ -39,7 +39,7 @@ const StatusPageBody = t.Object({
}))), }))),
}); });
// Strip @import and expression() from custom CSS basic sanity, not a full // Strip @import and expression() from custom CSS - basic sanity, not a full
// parser. The CSS still runs in the visitor's browser; this just blocks the // parser. The CSS still runs in the visitor's browser; this just blocks the
// most common smuggling vectors. // most common smuggling vectors.
function sanitizeCss(css: string | null | undefined): string | null { function sanitizeCss(css: string | null | undefined): string | null {
@ -63,7 +63,7 @@ async function replaceGroupsAndMonitors(
await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`; await sql`DELETE FROM status_page_groups WHERE status_page_id = ${pageId}`;
} }
// Single bulk INSERT instead of one round-trip per group. The RETURNING set // Single bulk INSERT instead of one round-trip per group. The RETURNING set
// comes back in INSERT order, which equals the array order that lets us // comes back in INSERT order, which equals the array order - that lets us
// map index → id without a follow-up SELECT. Mirrors the bulk insert pattern // map index → id without a follow-up SELECT. Mirrors the bulk insert pattern
// used by the monitors block right below. // used by the monitors block right below.
const groupIds: string[] = []; const groupIds: string[] = [];

View File

@ -21,7 +21,7 @@ function isPrivateIP(ip: string): boolean {
if (second >= 16 && second <= 31) return true; if (second >= 16 && second <= 31) return true;
} }
// IPv6 normalize: strip zone ID (%eth0) and lowercase // IPv6 - normalize: strip zone ID (%eth0) and lowercase
const ip6 = ip.replace(/%.*$/, "").toLowerCase(); const ip6 = ip.replace(/%.*$/, "").toLowerCase();
if (ip6 === "::1" || ip6 === "::") return true; if (ip6 === "::1" || ip6 === "::") return true;
if (ip6.startsWith("fe80")) return true; // fe80::/10 link-local if (ip6.startsWith("fe80")) return true; // fe80::/10 link-local
@ -29,7 +29,7 @@ function isPrivateIP(ip: string): boolean {
if (ip6.startsWith("fd00:ec2::")) return true; // AWS EC2 metadata IPv6 if (ip6.startsWith("fd00:ec2::")) return true; // AWS EC2 metadata IPv6
if (ip6 === "::ffff:127.0.0.1") return true; if (ip6 === "::ffff:127.0.0.1") return true;
if (ip6.startsWith("::ffff:")) { if (ip6.startsWith("::ffff:")) {
// IPv4-mapped IPv6 extract the IPv4 part and re-check // IPv4-mapped IPv6 - extract the IPv4 part and re-check
const v4 = ip6.slice(7); const v4 = ip6.slice(7);
return isPrivateIP(v4); return isPrivateIP(v4);
} }
@ -55,7 +55,7 @@ export async function validateMonitorUrl(url: string): Promise<string | null> {
} }
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return `Blocked scheme: ${parsed.protocol} only http: and https: are allowed`; return `Blocked scheme: ${parsed.protocol} - only http: and https: are allowed`;
} }
const hostname = parsed.hostname.toLowerCase(); const hostname = parsed.hostname.toLowerCase();

View File

@ -29,7 +29,7 @@ describe("insertIntoStack", () => {
.toEqual([{ plan: "pro", remaining_days: 50 }]); .toEqual([{ plan: "pro", remaining_days: 50 }]);
}); });
test("merges same plan null wins (lifetime)", () => { test("merges same plan - null wins (lifetime)", () => {
const stack = [{ plan: "lifetime", remaining_days: null }]; const stack = [{ plan: "lifetime", remaining_days: null }];
expect(insertIntoStack(stack, { plan: "lifetime", remaining_days: null })) expect(insertIntoStack(stack, { plan: "lifetime", remaining_days: null }))
.toEqual([{ plan: "lifetime", remaining_days: null }]); .toEqual([{ plan: "lifetime", remaining_days: null }]);
@ -92,7 +92,7 @@ describe("computeApplyPlan", () => {
expect(result.plan_stack).toEqual([{ plan: "lifetime", remaining_days: null }]); expect(result.plan_stack).toEqual([{ plan: "lifetime", remaining_days: null }]);
}); });
test("pro4x (15d left) buys lifetime goes to stack", () => { test("pro4x (15d left) buys lifetime - goes to stack", () => {
const acc = { plan: "pro4x", plan_expires_at: daysFromNow(15), plan_stack: [] }; const acc = { plan: "pro4x", plan_expires_at: daysFromNow(15), plan_stack: [] };
const result = computeApplyPlan(acc, { plan: "lifetime", months: null }, now); const result = computeApplyPlan(acc, { plan: "lifetime", months: null }, now);
expect(result.plan).toBe("pro4x"); // stays active (higher tier) expect(result.plan).toBe("pro4x"); // stays active (higher tier)
@ -121,7 +121,7 @@ describe("computeApplyPlan", () => {
expect(result.plan_stack).toEqual([]); expect(result.plan_stack).toEqual([]);
}); });
test("free with existing stack preserves stack", () => { test("free with existing stack - preserves stack", () => {
const acc = { plan: "free", plan_expires_at: null, plan_stack: [{ plan: "lifetime", remaining_days: null }] }; const acc = { plan: "free", plan_expires_at: null, plan_stack: [{ plan: "lifetime", remaining_days: null }] };
const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now); const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now);
expect(result.plan).toBe("pro"); expect(result.plan).toBe("pro");

View File

@ -33,7 +33,7 @@ async function refreshMaps() {
} }
async function recordTx(paymentId: number, address: string, txid: string, amount: number, confirmed: boolean) { async function recordTx(paymentId: number, address: string, txid: string, amount: number, confirmed: boolean) {
// Verify the payment exists and the address matches prevents stale in-memory state // Verify the payment exists and the address matches - prevents stale in-memory state
// from attributing transactions to the wrong payment // from attributing transactions to the wrong payment
const [payment] = await sql` const [payment] = await sql`
SELECT id FROM payments WHERE id = ${paymentId} AND address = ${address} SELECT id FROM payments WHERE id = ${paymentId} AND address = ${address}

View File

@ -16,7 +16,7 @@ export async function generateReceipt(paymentId: number): Promise<string> {
const paidDate = payment.paid_at const paidDate = payment.paid_at
? new Date(payment.paid_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }) ? new Date(payment.paid_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })
: ""; : "-";
const createdDate = new Date(payment.created_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); const createdDate = new Date(payment.created_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
const planNames: Record<string, string> = { pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime" }; const planNames: Record<string, string> = { pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime" };
const planName = planNames[payment.plan] || payment.plan; const planName = planNames[payment.plan] || payment.plan;

View File

@ -1,7 +1,7 @@
// Password gate for protected status pages. We sign a short-lived cookie with // 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 // 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 // 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. // invalidates every cookie issued under the old password.
// //
// Cookie format: <pageId>.<pwTag>.<exp>.<sig> // Cookie format: <pageId>.<pwTag>.<exp>.<sig>

View File

@ -1,7 +1,7 @@
// Tiny in-memory TTL cache keyed by string. Status pages serve the same payload // 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 // 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 // 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 } interface Entry<T> { value: T; expires: number }

View File

@ -1,5 +1,5 @@
// Loads the read-only data needed to render a public status page. NEVER reads // 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. // `monitor_uptime_rollup` for historical uptime windows.
import sql from "./db"; import sql from "./db";
@ -46,12 +46,12 @@ export interface MonitorRow {
// etc.) must never leak to anonymous visitors via the JSON endpoint. // etc.) must never leak to anonymous visitors via the JSON endpoint.
// Group correlator. Emitted as the matching group's `position` index // Group correlator. Emitted as the matching group's `position` index
// (0-based string), NOT the underlying UUID, so the JSON doesn't leak // (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. // looks up groups by this token.
group_id: string | null; group_id: string | null;
position: number; position: number;
display_mode: "compact" | "expanded"; // resolved (per-monitor override → page default → 'expanded') 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 // stopped checking it, and the public page should treat it as planned
// maintenance rather than an outage. // maintenance rather than an outage.
current_state: "up" | "down" | "unknown" | "paused"; 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 // 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 // aggregation pass below. This used to be a second SQL round-trip running
// four FILTER aggregates that redid arithmetic the raw bucket rows already // four FILTER aggregates that redid arithmetic the raw bucket rows already
// contained. // contained.
@ -116,9 +116,9 @@ export async function loadMonitors(
// Step 1: page → monitors with display overrides + group + position. Pull // Step 1: page → monitors with display overrides + group + position. Pull
// m.enabled too so we can render disabled monitors as "Maintenance" on the // m.enabled too so we can render disabled monitors as "Maintenance" on the
// public page (the runner stops checking them when disabled, so their // 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). // 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. // raw target URL must never reach the public payload.
const monitorRows = await sql<any[]>` const monitorRows = await sql<any[]>`
SELECT 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 // 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 // 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. // no duplicate FILTER aggregates inside Postgres.
const bucket: BucketType = barFrequency; const bucket: BucketType = barFrequency;
const count = Math.max(1, Math.min(180, barCount)); const count = Math.max(1, Math.min(180, barCount));
@ -270,7 +270,7 @@ export async function loadMonitors(
const mid = r.monitor_id; const mid = r.monitor_id;
const bt: BucketType = r.bucket_type; 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 (bt === bucket) {
if (!barIndexed[mid]) barIndexed[mid] = {}; if (!barIndexed[mid]) barIndexed[mid] = {};
const slot = barIndexed[mid]![startIso] ?? { total: 0, up: 0 }; 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 // 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`. // Strict `<` to match the old SQL's `bucket_start > now() - interval`.
const wt = initWindowTotals(mid); const wt = initWindowTotals(mid);
if (bt === "hourly" && nowMs - startMs < ms24h) { 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 // 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 // sorts by bucket_type then region then bucket_start, so the per-monitor
// hourly subset is already ordered within a region but interleaved across // 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)) { for (const mid of Object.keys(latByMonitor)) {
latByMonitor[mid]!.sort((a, b) => a.ts.localeCompare(b.ts)); 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. // 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% // 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 // doesn't visually round up to "100.00%". Pre-rounding here would erase that
// information before the formatter ever sees it. // information before the formatter ever sees it.
@ -396,7 +396,7 @@ export async function loadMonitors(
const anyUp = region_states.some((s) => s.state === "up"); const anyUp = region_states.some((s) => s.state === "up");
current_state = anyDown ? "down" : anyUp ? "up" : "unknown"; 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 // stopped checking it. Override whatever the last region state was so the
// public page reads "Maintenance" instead of a stale "Operational". // public page reads "Maintenance" instead of a stale "Operational".
if (m.enabled === false) current_state = "paused"; if (m.enabled === false) current_state = "paused";
@ -405,7 +405,7 @@ export async function loadMonitors(
if (buckets.length > 0) { if (buckets.length > 0) {
const tot = buckets.reduce((a, b) => a + b.total, 0); const tot = buckets.reduce((a, b) => a + b.total, 0);
const upT = buckets.reduce((a, b) => a + b.up, 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%. // so any downtime, however small, never visually rounds up to 100%.
uptime_pct = tot > 0 ? (100 * upT / tot) : null; 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> { export async function loadMonitorDetail(slug: string, monitorId: string, window?: Window): Promise<MonitorDetailPayload | null> {
const page = await loadStatusPage(slug); const page = await loadStatusPage(slug);
if (!page) return null; 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 // 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 // purely so we can return null on a wrong slug/monitor combo without firing
// the bigger query at all. // the bigger query at all.
@ -499,7 +499,7 @@ export async function loadMonitorDetail(slug: string, monitorId: string, window?
if (!link) return null; if (!link) return null;
const win = (window ?? page.default_window) as Window; 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 // 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 // the page's groups so we can redact the monitor's group_id (UUID → public
// position-as-string token), matching what /:slug.json emits. // 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 // 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 // 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 // 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 // they're already publicly visible in the rendered HTML, so dropping them
// from JSON wouldn't actually add any privacy. // from JSON wouldn't actually add any privacy.
export interface PublicPageView { 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 // 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 // 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( function redactGroupsAndMonitors(
groups: GroupRow[], groups: GroupRow[],
monitors: MonitorRow[], monitors: MonitorRow[],

View File

@ -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. // schema is owned by apps/api. This file just opens a connection.
import postgres from "postgres"; import postgres from "postgres";

View File

@ -38,7 +38,7 @@ function clientIp(req: Request): string {
} }
// 404s must NOT be cached by browsers or Cloudflare. The same URL frequently // 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 // 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 // changes, or when a monitor is removed. If Cloudflare cached a 404 (default
// behaviour for unspecified Cache-Control on 404 responses) the operator // 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; if (!page.password_hash) return true;
// Pass the current password_hash so verifyAuthCookie can derive the // Pass the current password_hash so verifyAuthCookie can derive the
// expected pwTag and reject any cookie that was issued under a previous // 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); 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> { 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 // carries the live password_hash + index_search + display config; an
// operator changing any of those in the dashboard must take effect // operator changing any of those in the dashboard must take effect
// immediately, not after a TTL window. The single PK lookup is sub-ms, // 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); const page = await loadStatusPage(slug);
if (!page) return notFound(); if (!page) return notFound();
if (!isAuthorised(page, request)) { 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 // OSINT scraper iterating slugs harvest the human-readable name of every
// private page without ever authenticating. Slug is fine: it's already // private page without ever authenticating. Slug is fine: it's already
// in the URL the visitor typed. // 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)); const payload = await cached(`payload:${slug}`, 15, () => loadPagePayload(slug));
if (!payload) return notFound(); if (!payload) return notFound();
const html = eta.render("page", { ...payload, expandJsHash, appCssHash }); 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 // shared proxy hold a copy that some other visitor could pull. Public
// pages keep the 15s shared cache for performance under viral hits. // pages keep the 15s shared cache for performance under viral hits.
const cacheControl = page.password_hash 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 cacheKey = `payload:${slug}:${win ?? page.default_window}`;
const payload = await cached(cacheKey, 15, () => loadPagePayload(slug, win)); const payload = await cached(cacheKey, 15, () => loadPagePayload(slug, win));
if (!payload) return jsonNotFound(); 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 const cacheControl = page.password_hash
? "private, no-store, must-revalidate" ? "private, no-store, must-revalidate"
: "public, max-age=15, s-maxage=15"; : "public, max-age=15, s-maxage=15";
@ -182,11 +182,11 @@ async function renderRssResp(slug: string, request: Request): Promise<Response>
} }
const app = new Elysia() 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. // unknown slug, so a stray hit on the apex doesn't leak service identity.
.get("/", () => notFound()) .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), { .get("/_static/expand.js", () => new Response(Bun.file(expandJsPath), {
headers: { headers: {
"content-type": "application/javascript; charset=utf-8", "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 // the <link> tag is the file's MD5 hash, so deploys propagate immediately
// even though the asset itself is marked immutable for a year. // even though the asset itself is marked immutable for a year.
.get("/_static/app.css", () => new Response(Bun.file(appCssPath), { .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 }) => { .get("/:slug", async ({ params, request, query }) => {
const { slug, format } = splitSlugAndFormat(params.slug); const { slug, format } = splitSlugAndFormat(params.slug);
if (!allow(slug, clientIp(request))) return rateLimited(); 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 // 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 // crucially can't even confirm whether a private slug exists, since the
// 404 is identical to a totally bogus slug. // 404 is identical to a totally bogus slug.
.get("/:slug/badge.svg", async ({ params, request }) => { .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. // 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". // 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. // dodge memoirist's "two params at the same position" rule.
.get("/:slug/monitor/:idWithExt", async ({ params, request, query }) => { .get("/:slug/monitor/:idWithExt", async ({ params, request, query }) => {
if (!allow(params.slug, clientIp(request))) return rateLimited(); 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 port = Number(process.env.STATUS_PORT ?? 3003);
const server = Bun.serve({ const server = Bun.serve({
port, port,
// Wrap app.handle in a try/catch so any unexpected throw Postgres // Wrap app.handle in a try/catch so any unexpected throw - Postgres
// connection blip, template render error, missing env var, etc. turns // connection blip, template render error, missing env var, etc. - turns
// into a generic 500 with an opaque body. Without this wrapper Bun's // into a generic 500 with an opaque body. Without this wrapper Bun's
// default error path may include framework details, file paths, or stack // default error path may include framework details, file paths, or stack
// traces in the response, which would leak internal layout to anyone who // traces in the response, which would leak internal layout to anyone who

View File

@ -1,6 +1,6 @@
// Per-(slug, IP) token bucket. 30 requests in a 10s window. Cheap, in-memory, // 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 // 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. // box", not perfect distributed accounting.
interface Bucket { tokens: number; refillAt: number } interface Bucket { tokens: number; refillAt: number }

View File

@ -32,7 +32,7 @@ export async function renderRss(page: StatusPageRow, baseUrl: string): Promise<s
body_html: u.body_html, 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 channelDescription = escapeXml(page.description || `${page.title} status updates`);
const channelLink = `${baseUrl}/${page.slug}`; const channelLink = `${baseUrl}/${page.slug}`;

View File

@ -3,7 +3,7 @@
visitors as soon as the new bundle is deployed. visitors as soon as the new bundle is deployed.
Per-page custom CSS is still inlined in page.ejs (it's per-request data, 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. */ file is loaded so it always wins on specificity ties. */
:root { :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; } .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 { margin-top: 3rem; }
.past-incidents h2 { font-size: 1.1rem; margin-bottom: 1rem; } .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 { 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-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; } .past-day-empty { font-size: 0.85rem; color: var(--muted); margin: 0.25rem 0 0; }

View File

@ -31,7 +31,7 @@
var timeOpts = { hour: "2-digit", minute: "2-digit" }; var timeOpts = { hour: "2-digit", minute: "2-digit" };
if (barFrequency === "hourly") { if (barFrequency === "hourly") {
return start.toLocaleDateString(undefined, dateOpts) + ", " + return start.toLocaleDateString(undefined, dateOpts) + ", " +
start.toLocaleTimeString(undefined, timeOpts) + " " + start.toLocaleTimeString(undefined, timeOpts) + " - " +
end.toLocaleTimeString(undefined, timeOpts); end.toLocaleTimeString(undefined, timeOpts);
} }
return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); return start.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
@ -51,11 +51,11 @@
var lat = latRaw == null ? null : parseInt(latRaw, 10); var lat = latRaw == null ? null : parseInt(latRaw, 10);
if (!start) return; if (!start) return;
// Full precision pct so the formatter can decide. Anything below 100% gets // 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%". // numbers, so a bucket with one failed check never displays as "100%".
var pct = total > 0 ? (100 * up / total) : null; var pct = total > 0 ? (100 * up / total) : null;
var pctText; var pctText;
if (pct == null) pctText = ""; if (pct == null) pctText = "-";
else if (pct >= 100) pctText = "100%"; else if (pct >= 100) pctText = "100%";
else pctText = (Math.floor(pct * 100) / 100).toFixed(2) + "%"; else pctText = (Math.floor(pct * 100) / 100).toFixed(2) + "%";
var html = '<div class="head">' + fmtBucketRange(start) + "</div>"; 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>"; html += '<div class="row"><span>Avg ping</span><span>' + lat + "ms</span></div>";
} }
} else { } 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.innerHTML = html;
tooltip.style.display = "block"; tooltip.style.display = "block";

View File

@ -15,9 +15,9 @@
const groupOrder = [...groups.map(g => g.id), '']; const groupOrder = [...groups.map(g => g.id), ''];
function fmtPct(p) { function fmtPct(p) {
if (p == null) return ''; if (p == null) return '-';
// Only show "100%" when the value is *exactly* 100. Anything below even // 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. // 99.9999% - must show 2 decimals so visitors can see there was downtime.
// Truncate (floor) rather than round, otherwise 99.9999 would render as // Truncate (floor) rather than round, otherwise 99.9999 would render as
// "100.00" and silently swallow the downtime. // "100.00" and silently swallow the downtime.
if (p >= 100) return '100%'; if (p >= 100) return '100%';
@ -48,7 +48,7 @@
return 'bad'; return 'bad';
} }
function fmtUptime(p) { 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. // 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". // Truncate (not round) below that so 99.9999% never displays as "100.00".
if (p >= 100) return '100%'; if (p >= 100) return '100%';
@ -56,7 +56,7 @@
} }
// Overall status: down if any monitor is down, degraded if any partial, else up. // 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 // 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. // when at least one is in maintenance so visitors aren't confused.
let paused_count = 0; let paused_count = 0;
@ -77,7 +77,7 @@
: overall === 'down' ? 'Major outage in progress' : overall === 'down' ? 'Major outage in progress'
: 'Partial outage'; : 'Partial outage';
if (paused_count > 0) { 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)' const overallBg = overall === 'up' ? 'rgba(16,185,129,0.1)'
: overall === 'degraded' ? 'rgba(245,158,11,0.1)' : overall === 'degraded' ? 'rgba(245,158,11,0.1)'
@ -204,7 +204,7 @@
</div> </div>
<div class="monitor-meta"> <div class="monitor-meta">
<% if (m.current_state === 'paused') { %> <% 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 { %> <% } else { %>
<% if (page.show_response_time && m.avg_latency != null) { %><span><%= m.avg_latency %>ms</span><% } %> <% 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'; %> <% const winKey = page.default_window === '24h' ? 'd24' : page.default_window === '7d' ? 'd7' : page.default_window === '30d' ? 'd30' : 'd90'; %>

View File

@ -97,7 +97,7 @@ function watchAccount(onPing) {
// section is only present when an existing page already has a password set; // section is only present when an existing page already has a password set;
// the helper is a no-op on every other page. Lives here (rather than inline // the helper is a no-op on every other page. Lives here (rather than inline
// in the .eta template) because Eta's parser chokes on stray `%` characters // in the .eta template) because Eta's parser chokes on stray `%` characters
// inside <script> blocks see feedback_eta_no_inline_js. // inside <script> blocks - see feedback_eta_no_inline_js.
function initPasswordSection() { function initPasswordSection() {
var section = document.querySelector('[data-password-section]'); var section = document.querySelector('[data-password-section]');
if (!section) return; if (!section) return;

View File

@ -333,7 +333,7 @@ class QueryBuilder {
// Standard 2-space JSON prettifier with one twist: any subtree whose single-line // Standard 2-space JSON prettifier with one twist: any subtree whose single-line
// form fits within MAX_INLINE chars at its current column gets inlined. Breaks // form fits within MAX_INLINE chars at its current column gets inlined. Breaks
// always use clean 2-space indent never key-aligned. // always use clean 2-space indent - never key-aligned.
const MAX_INLINE = 60; const MAX_INLINE = 60;
function formatQueryJson(value, indent = 0, startCol = indent) { function formatQueryJson(value, indent = 0, startCol = indent) {

View File

@ -147,10 +147,10 @@ async function getAccountId(cookie: any, headers: any): Promise<{ accountId: str
} }
// Parse the status page edit form's monitor list. The form posts: // Parse the status page edit form's monitor list. The form posts:
// monitor_order full list of monitor IDs in DOM order (every row, not just checked) // monitor_order - full list of monitor IDs in DOM order (every row, not just checked)
// monitor_ids only the *checked* IDs, also in DOM order // monitor_ids - only the *checked* IDs, also in DOM order
// display_name[<id>] optional per-page name override // display_name[<id>] - optional per-page name override
// display_mode[<id>] '', 'compact', or 'expanded' // display_mode[<id>] - '', 'compact', or 'expanded'
// Bun's body parser surfaces bracket-keyed fields either as nested objects // Bun's body parser surfaces bracket-keyed fields either as nested objects
// (`b.display_name = { id: value }`) or as flat string keys // (`b.display_name = { id: value }`) or as flat string keys
// (`b['display_name[id]'] = value`) depending on parser version, so handle both. // (`b['display_name[id]'] = value`) depending on parser version, so handle both.
@ -225,7 +225,7 @@ export const dashboard = new Elysia()
if (key) { if (key) {
const resolved = await resolveKey(key); const resolved = await resolveKey(key);
if (resolved) return redirect("/dashboard/home"); if (resolved) return redirect("/dashboard/home");
// Invalid/stale key clear it and show login // Invalid/stale key - clear it and show login
cookie.pingql_key?.remove(); cookie.pingql_key?.remove();
} }
return html("login", {}); return html("login", {});
@ -253,7 +253,7 @@ export const dashboard = new Elysia()
`; `;
// One simple indexed query per monitor, run in parallel. Each is an index // One simple indexed query per monitor, run in parallel. Each is an index
// seek on (monitor_id, checked_at DESC) microseconds. Trivially scalable // seek on (monitor_id, checked_at DESC) - microseconds. Trivially scalable
// and easy to reason about. // and easy to reason about.
const pingResults = await Promise.all( const pingResults = await Promise.all(
monitors.map((m: any) => sql` monitors.map((m: any) => sql`
@ -286,7 +286,7 @@ export const dashboard = new Elysia()
const isSubKey = !!keyId; const isSubKey = !!keyId;
const loginKey = isSubKey ? null : (cookie?.pingql_key?.value ?? null); const loginKey = isSubKey ? null : (cookie?.pingql_key?.value ?? null);
// All four reads are independent fan them out in parallel instead of // All four reads are independent - fan them out in parallel instead of
// serializing four round-trips. Each individual query is fast (PK seek or // serializing four round-trips. Each individual query is fast (PK seek or
// small indexed scan); the win is just halving the wall-clock by not // small indexed scan); the win is just halving the wall-clock by not
// waiting on each one in turn. // waiting on each one in turn.

View File

@ -99,7 +99,7 @@
<label class="block text-sm text-gray-400 mb-2">How many months?</label> <label class="block text-sm text-gray-400 mb-2">How many months?</label>
<select id="months-select" name="months" class="input-base px-4 py-2.5 text-gray-100"> <select id="months-select" name="months" class="input-base px-4 py-2.5 text-gray-100">
<% for (let i = 1; i <= 12; i++) { %> <% for (let i = 1; i <= 12; i++) { %>
<option value="<%= i %>" data-base="<%= i * 12 %>"><%= i %> month<%= i > 1 ? 's' : '' %> $<%= i * 12 %></option> <option value="<%= i %>" data-base="<%= i * 12 %>"><%= i %> month<%= i > 1 ? 's' : '' %> - $<%= i * 12 %></option>
<% } %> <% } %>
</select> </select>
</div> </div>
@ -315,7 +315,7 @@
const mult = multipliers[plan] || 1; const mult = multipliers[plan] || 1;
for (const opt of sel.options) { for (const opt of sel.options) {
const base = parseInt(opt.dataset.base); const base = parseInt(opt.dataset.base);
opt.textContent = opt.value + ' month' + (opt.value > 1 ? 's' : '') + ' $' + (base * mult); opt.textContent = opt.value + ' month' + (opt.value > 1 ? 's' : '') + ' - $' + (base * mult);
} }
} }
</script> </script>

View File

@ -56,19 +56,19 @@
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8"> <div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<div class="stat-card rounded-xl p-4"> <div class="stat-card rounded-xl p-4">
<div class="text-xs text-gray-500 mb-1">Status</div> <div class="text-xs text-gray-500 mb-1">Status</div>
<div id="stat-status" class="text-lg font-semibold"><%~ lastPing ? (lastPing.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>') : '<span class="text-gray-500"></span>' %></div> <div id="stat-status" class="text-lg font-semibold"><%~ lastPing ? (lastPing.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>') : '<span class="text-gray-500">-</span>' %></div>
</div> </div>
<div class="stat-card rounded-xl p-4"> <div class="stat-card rounded-xl p-4">
<div class="text-xs text-gray-500 mb-1">Avg Latency</div> <div class="text-xs text-gray-500 mb-1">Avg Latency</div>
<div id="stat-latency" class="text-lg font-semibold text-gray-200"><%= avgLatency != null ? avgLatency + 'ms' : '' %></div> <div id="stat-latency" class="text-lg font-semibold text-gray-200"><%= avgLatency != null ? avgLatency + 'ms' : '-' %></div>
</div> </div>
<div class="stat-card rounded-xl p-4"> <div class="stat-card rounded-xl p-4">
<div class="text-xs text-gray-500 mb-1">Uptime</div> <div class="text-xs text-gray-500 mb-1">Uptime</div>
<div id="stat-uptime" class="text-lg font-semibold text-gray-200"><%= uptime != null ? uptime + '%' : '' %></div> <div id="stat-uptime" class="text-lg font-semibold text-gray-200"><%= uptime != null ? uptime + '%' : '-' %></div>
</div> </div>
<div class="stat-card rounded-xl p-4"> <div class="stat-card rounded-xl p-4">
<div class="text-xs text-gray-500 mb-1">Last Ping</div> <div class="text-xs text-gray-500 mb-1">Last Ping</div>
<div id="stat-last" class="text-lg font-semibold text-gray-200"><%~ lastPing ? it.timeAgoSSR(lastPing.checked_at) : '' %></div> <div id="stat-last" class="text-lg font-semibold text-gray-200"><%~ lastPing ? it.timeAgoSSR(lastPing.checked_at) : '-' %></div>
</div> </div>
</div> </div>
@ -139,10 +139,10 @@
%> %>
<tr class="table-row-alt cursor-pointer hover:bg-white/[0.02]" data-ping="<%~ pingJson %>" data-up="<%= c.up ? '1' : '0' %>"> <tr class="table-row-alt cursor-pointer hover:bg-white/[0.02]" data-ping="<%~ pingJson %>" data-up="<%= c.up ? '1' : '0' %>">
<td class="px-4 py-2"><%~ c.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>' %></td> <td class="px-4 py-2"><%~ c.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>' %></td>
<td class="px-4 py-2 text-gray-300"><%= c.status_code != null ? c.status_code : '' %></td> <td class="px-4 py-2 text-gray-300"><%= c.status_code != null ? c.status_code : '-' %></td>
<td class="px-4 py-2 text-gray-300"><%= c.latency_ms != null ? c.latency_ms + 'ms' : '' %></td> <td class="px-4 py-2 text-gray-300"><%= c.latency_ms != null ? c.latency_ms + 'ms' : '-' %></td>
<td class="px-4 py-2 text-gray-500 text-sm"><%= c.region || '' %></td> <td class="px-4 py-2 text-gray-500 text-sm"><%= c.region || '-' %></td>
<td class="px-4 py-2 text-gray-600 font-mono text-xs"><%= c.run_id || '' %></td> <td class="px-4 py-2 text-gray-600 font-mono text-xs"><%= c.run_id || '-' %></td>
<td class="px-4 py-2 text-gray-500"><%~ it.timeAgoSSR(c.checked_at) %><% if (c.jitter_ms != null) { %> <span class="text-gray-600 text-xs">(+<%= c.jitter_ms %>ms)</span><% } %></td> <td class="px-4 py-2 text-gray-500"><%~ it.timeAgoSSR(c.checked_at) %><% if (c.jitter_ms != null) { %> <span class="text-gray-600 text-xs">(+<%= c.jitter_ms %>ms)</span><% } %></td>
<td class="px-4 py-2 text-red-400/70 text-xs truncate max-w-[200px]"><%= c.error ? c.error : '' %></td> <td class="px-4 py-2 text-red-400/70 text-xs truncate max-w-[200px]"><%= c.error ? c.error : '' %></td>
</tr> </tr>
@ -393,7 +393,7 @@
html += '</div></div>'; html += '</div></div>';
} }
// Response body loaded on demand // Response body - loaded on demand
const contentType = getContentType(headers); const contentType = getContentType(headers);
const ctLabel = contentType ? ` <span class="text-gray-600">(${escapeHtml(contentType.split(';')[0].trim())})</span>` : ''; const ctLabel = contentType ? ` <span class="text-gray-600">(${escapeHtml(contentType.split(';')[0].trim())})</span>` : '';
html += '<div id="ping-body-section">'; html += '<div id="ping-body-section">';
@ -600,7 +600,7 @@
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
// Gentle Catmull-Rom spline low tension to avoid overshoot on noisy data // Gentle Catmull-Rom spline - low tension to avoid overshoot on noisy data
ctx.moveTo(pts[0].x, pts[0].y); ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 0; i < pts.length - 1; i++) { for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(i - 1, 0)]; const p0 = pts[Math.max(i - 1, 0)];
@ -851,7 +851,7 @@
// Last ping // Last ping
document.getElementById('stat-last').innerHTML = timeAgo(ping.checked_at); document.getElementById('stat-last').innerHTML = timeAgo(ping.checked_at);
// Status bar group by run_id, cap at 60 // Status bar - group by run_id, cap at 60
const bar = document.getElementById('status-bar'); const bar = document.getElementById('status-bar');
if (bar) { if (bar) {
const rid = ping.run_id || ping.checked_at; const rid = ping.run_id || ping.checked_at;
@ -884,13 +884,13 @@
updateStatusTooltip(); updateStatusTooltip();
} }
// Pings table prepend row if filter matches // Pings table - prepend row if filter matches
const tbody = document.getElementById('pings-table'); const tbody = document.getElementById('pings-table');
if (tbody) { if (tbody) {
const shouldShow = _pingFilter === 'all' const shouldShow = _pingFilter === 'all'
|| (_pingFilter === 'up' && ping.up) || (_pingFilter === 'up' && ping.up)
|| (_pingFilter === 'down' && !ping.up); || (_pingFilter === 'down' && !ping.up);
// For events filter, always prepend it's a state change if it differs from current status // For events filter, always prepend - it's a state change if it differs from current status
const isEvent = _pingFilter === 'events'; const isEvent = _pingFilter === 'events';
if (shouldShow || isEvent) { if (shouldShow || isEvent) {
@ -912,7 +912,7 @@
} }
} }
// Chart push new ping, trim oldest runs, re-render // Chart - push new ping, trim oldest runs, re-render
chartPings.push({ chartPings.push({
latency_ms: ping.latency_ms, region: ping.region || '__none__', latency_ms: ping.latency_ms, region: ping.region || '__none__',
checked_at: ping.checked_at, up: ping.up, run_id: ping.run_id || null, checked_at: ping.checked_at, up: ping.up, run_id: ping.run_id || null,
@ -930,10 +930,10 @@
function pingRowHtml(p) { function pingRowHtml(p) {
const up = p.up; const up = p.up;
const statusHtml = up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>'; const statusHtml = up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>';
const code = p.status_code != null ? p.status_code : ''; const code = p.status_code != null ? p.status_code : '-';
const latency = p.latency_ms != null ? p.latency_ms + 'ms' : ''; const latency = p.latency_ms != null ? p.latency_ms + 'ms' : '-';
const region = p.region || ''; const region = p.region || '-';
const runId = p.run_id || ''; const runId = p.run_id || '-';
const time = timeAgo(p.checked_at); const time = timeAgo(p.checked_at);
const jitter = p.jitter_ms != null ? ` <span class="text-gray-600 text-xs">(+${p.jitter_ms}ms)</span>` : ''; const jitter = p.jitter_ms != null ? ` <span class="text-gray-600 text-xs">(+${p.jitter_ms}ms)</span>` : '';
const error = p.error ? escapeHtml(p.error) : ''; const error = p.error ? escapeHtml(p.error) : '';

View File

@ -10,7 +10,7 @@
.nav-link.active { color: #93c5fd; border-left-color: #3b82f6; } .nav-link.active { color: #93c5fd; border-left-color: #3b82f6; }
.nav-section { font-size: 0.7rem; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.75rem 0.75rem 0.25rem; margin-top: 0.5rem; } .nav-section { font-size: 0.7rem; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.75rem 0.75rem 0.25rem; margin-top: 0.5rem; }
/* Content scoped to .docs */ /* Content - scoped to .docs */
.docs .section { padding-top: 3rem; padding-bottom: 1rem; border-top: 1px solid #111827; margin-top: 2rem; } .docs .section { padding-top: 3rem; padding-bottom: 1rem; border-top: 1px solid #111827; margin-top: 2rem; }
.docs .section:first-child { border-top: none; margin-top: 0; padding-top: 0; } .docs .section:first-child { border-top: none; margin-top: 0; padding-top: 0; }
.docs h2 { font-size: 1.2rem; font-weight: 700; color: #f9fafb; margin-bottom: 0.5rem; } .docs h2 { font-size: 1.2rem; font-weight: 700; color: #f9fafb; margin-bottom: 0.5rem; }
@ -105,11 +105,11 @@
<div class="endpoint"><span class="method post">POST</span><span class="path">/account/register</span></div> <div class="endpoint"><span class="method post">POST</span><span class="path">/account/register</span></div>
<p class="endpoint-desc">Create a new account. Email is optional and used only for account recovery.</p> <p class="endpoint-desc">Create a new account. Email is optional and used only for account recovery.</p>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json request body</span></div> <div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{ <span class="k">"email"</span>: <span class="s">"you@example.com"</span> } <span class="c">// optional</span></pre> <pre>{ <span class="k">"email"</span>: <span class="s">"you@example.com"</span> } <span class="c">// optional</span></pre>
</div> </div>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json response</span></div> <div class="cb-header"><span class="cb-lang">json - response</span></div>
<pre>{ <span class="k">"key"</span>: <span class="s">"5bf5311b56d09254c8a1f0e3..."</span>, <span class="k">"email_registered"</span>: <span class="n">true</span> }</pre> <pre>{ <span class="k">"key"</span>: <span class="s">"5bf5311b56d09254c8a1f0e3..."</span>, <span class="k">"email_registered"</span>: <span class="n">true</span> }</pre>
</div> </div>
@ -117,7 +117,7 @@
<div class="endpoint"><span class="method post">POST</span><span class="path">/account/email</span></div> <div class="endpoint"><span class="method post">POST</span><span class="path">/account/email</span></div>
<p class="endpoint-desc">Set or update the recovery email for an existing account.</p> <p class="endpoint-desc">Set or update the recovery email for an existing account.</p>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json request body</span></div> <div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{ <span class="k">"email"</span>: <span class="s">"you@example.com"</span> }</pre> <pre>{ <span class="k">"email"</span>: <span class="s">"you@example.com"</span> }</pre>
</div> </div>
</div> </div>
@ -133,22 +133,22 @@
<h3>Create</h3> <h3>Create</h3>
<div class="endpoint"><span class="method post">POST</span><span class="path">/monitors/</span></div> <div class="endpoint"><span class="method post">POST</span><span class="path">/monitors/</span></div>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json request body</span></div> <div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{ <pre>{
<span class="k">"name"</span>: <span class="s">"My API"</span>, <span class="k">"name"</span>: <span class="s">"My API"</span>,
<span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>, <span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>,
<span class="k">"interval_s"</span>: <span class="n">60</span>, <span class="c">// check every 60 seconds (min: 2)</span> <span class="k">"interval_s"</span>: <span class="n">60</span>, <span class="c">// check every 60 seconds (min: 2)</span>
<span class="k">"method"</span>: <span class="s">"POST"</span>, <span class="c">// optional default: GET</span> <span class="k">"method"</span>: <span class="s">"POST"</span>, <span class="c">// optional - default: GET</span>
<span class="k">"request_headers"</span>: { <span class="s">"X-Api-Key"</span>: <span class="s">"secret"</span> }, <span class="c">// optional</span> <span class="k">"request_headers"</span>: { <span class="s">"X-Api-Key"</span>: <span class="s">"secret"</span> }, <span class="c">// optional</span>
<span class="k">"request_body"</span>: <span class="s">"{\"ping\": true}"</span>, <span class="c">// optional Content-Type defaults to application/json</span> <span class="k">"request_body"</span>: <span class="s">"{\"ping\": true}"</span>, <span class="c">// optional - Content-Type defaults to application/json</span>
<span class="k">"regions"</span>: [<span class="s">"eu-central"</span>, <span class="s">"us-west"</span>], <span class="c">// optional default: all regions</span> <span class="k">"regions"</span>: [<span class="s">"eu-central"</span>, <span class="s">"us-west"</span>], <span class="c">// optional - default: all regions</span>
<span class="k">"timeout_ms"</span>: <span class="n">10000</span>, <span class="c">// optional default: 10000</span> <span class="k">"timeout_ms"</span>: <span class="n">10000</span>, <span class="c">// optional - default: 10000</span>
<span class="k">"max_retries"</span>: <span class="n">2</span>, <span class="c">// optional retry N times before declaring DOWN. Default: 0</span> <span class="k">"max_retries"</span>: <span class="n">2</span>, <span class="c">// optional - retry N times before declaring DOWN. Default: 0</span>
<span class="k">"retry_interval_s"</span>: <span class="n">30</span>, <span class="c">// optional seconds between retries. Default: 30</span> <span class="k">"retry_interval_s"</span>: <span class="n">30</span>, <span class="c">// optional - seconds between retries. Default: 30</span>
<span class="k">"resend_interval"</span>: <span class="n">10</span>, <span class="c">// optional re-alert every Nth consecutive DOWN beat. 0 = never. Default: 0</span> <span class="k">"resend_interval"</span>: <span class="n">10</span>, <span class="c">// optional - re-alert every Nth consecutive DOWN beat. 0 = never. Default: 0</span>
<span class="k">"cert_alert_days"</span>: <span class="n">0</span>, <span class="c">// optional alert when TLS cert is within N days of expiry. 0 disables. Default: 0 (disabled)</span> <span class="k">"cert_alert_days"</span>: <span class="n">0</span>, <span class="c">// optional - alert when TLS cert is within N days of expiry. 0 disables. Default: 0 (disabled)</span>
<span class="k">"channel_ids"</span>: [<span class="s">"&lt;uuid&gt;"</span>], <span class="c">// optional notification channels to attach</span> <span class="k">"channel_ids"</span>: [<span class="s">"&lt;uuid&gt;"</span>], <span class="c">// optional - notification channels to attach</span>
<span class="k">"query"</span>: { ... } <span class="c">// optional see Query Language below</span> <span class="k">"query"</span>: { ... } <span class="c">// optional - see Query Language below</span>
}</pre> }</pre>
</div> </div>
<table> <table>
@ -157,7 +157,7 @@
<tr><td>name</td><td>string</td><td>Display name for the monitor</td></tr> <tr><td>name</td><td>string</td><td>Display name for the monitor</td></tr>
<tr><td>url</td><td>string</td><td>URL to monitor</td></tr> <tr><td>url</td><td>string</td><td>URL to monitor</td></tr>
<tr><td>interval_s</td><td>number</td><td>Check interval in seconds (min: 30 free, 2 pro)</td></tr> <tr><td>interval_s</td><td>number</td><td>Check interval in seconds (min: 30 free, 2 pro)</td></tr>
<tr><td>method</td><td>string</td><td>HTTP method GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS</td></tr> <tr><td>method</td><td>string</td><td>HTTP method - GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS</td></tr>
<tr><td>request_headers</td><td>object</td><td>Custom headers as key-value pairs</td></tr> <tr><td>request_headers</td><td>object</td><td>Custom headers as key-value pairs</td></tr>
<tr><td>request_body</td><td>string</td><td>Request body (Content-Type defaults to application/json)</td></tr> <tr><td>request_body</td><td>string</td><td>Request body (Content-Type defaults to application/json)</td></tr>
<tr><td>regions</td><td>string[]</td><td>Regions to ping from: <code>eu-central</code>, <code>us-west</code>. Default: all</td></tr> <tr><td>regions</td><td>string[]</td><td>Regions to ping from: <code>eu-central</code>, <code>us-west</code>. Default: all</td></tr>
@ -167,7 +167,7 @@
<tr><td>resend_interval</td><td>number</td><td>If a monitor stays DOWN, re-fire a notification every Nth consecutive down beat. 0 disables resend. Default: 0.</td></tr> <tr><td>resend_interval</td><td>number</td><td>If a monitor stays DOWN, re-fire a notification every Nth consecutive down beat. 0 disables resend. Default: 0.</td></tr>
<tr><td>cert_alert_days</td><td>number</td><td>Fire a separate <code>cert</code> notification when the TLS certificate is within N days of expiring. 0 disables. Default: 0 (disabled).</td></tr> <tr><td>cert_alert_days</td><td>number</td><td>Fire a separate <code>cert</code> notification when the TLS certificate is within N days of expiring. 0 disables. Default: 0 (disabled).</td></tr>
<tr><td>channel_ids</td><td>string[]</td><td>Notification channel IDs to attach. See <a href="#notifications">Notifications</a>.</td></tr> <tr><td>channel_ids</td><td>string[]</td><td>Notification channel IDs to attach. See <a href="#notifications">Notifications</a>.</td></tr>
<tr><td>query</td><td>object</td><td>Query conditions see below</td></tr> <tr><td>query</td><td>object</td><td>Query conditions - see below</td></tr>
</tbody> </tbody>
</table> </table>
@ -188,7 +188,7 @@
<h3>Ping History</h3> <h3>Ping History</h3>
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/:id/pings?limit=100</span></div> <div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/:id/pings?limit=100</span></div>
<p class="endpoint-desc">Returns recent ping results for a monitor. Max 1000. Each ping carries an <code>important</code> boolean true on status transitions and resend ticks (the beats that triggered notifications).</p> <p class="endpoint-desc">Returns recent ping results for a monitor. Max 1000. Each ping carries an <code>important</code> boolean - true on status transitions and resend ticks (the beats that triggered notifications).</p>
</div> </div>
<!-- Notifications --> <!-- Notifications -->
@ -203,16 +203,16 @@
<h3>Create channel</h3> <h3>Create channel</h3>
<div class="endpoint"><span class="method post">POST</span><span class="path">/notifications/channels</span></div> <div class="endpoint"><span class="method post">POST</span><span class="path">/notifications/channels</span></div>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json request body</span></div> <div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{ <pre>{
<span class="k">"name"</span>: <span class="s">"On-call webhook"</span>, <span class="k">"name"</span>: <span class="s">"On-call webhook"</span>,
<span class="k">"kind"</span>: <span class="s">"webhook"</span>, <span class="k">"kind"</span>: <span class="s">"webhook"</span>,
<span class="k">"config"</span>: { <span class="k">"config"</span>: {
<span class="k">"url"</span>: <span class="s">"https://hooks.example.com/pingql"</span>, <span class="k">"url"</span>: <span class="s">"https://hooks.example.com/pingql"</span>,
<span class="k">"headers"</span>: { <span class="s">"X-Team"</span>: <span class="s">"infra"</span> }, <span class="c">// optional</span> <span class="k">"headers"</span>: { <span class="s">"X-Team"</span>: <span class="s">"infra"</span> }, <span class="c">// optional</span>
<span class="k">"secret"</span>: <span class="s">"shared-hmac-secret"</span> <span class="c">// optional signs payloads</span> <span class="k">"secret"</span>: <span class="s">"shared-hmac-secret"</span> <span class="c">// optional - signs payloads</span>
}, },
<span class="k">"enabled"</span>: <span class="n">true</span> <span class="c">// optional default true</span> <span class="k">"enabled"</span>: <span class="n">true</span> <span class="c">// optional - default true</span>
}</pre> }</pre>
</div> </div>
<table> <table>
@ -261,7 +261,7 @@ Content-Type: application/json
<h3>Create page</h3> <h3>Create page</h3>
<div class="endpoint"><span class="method post">POST</span><span class="path">/status-pages</span></div> <div class="endpoint"><span class="method post">POST</span><span class="path">/status-pages</span></div>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json request body</span></div> <div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{ <pre>{
<span class="k">"slug"</span>: <span class="s">"my-app"</span>, <span class="k">"slug"</span>: <span class="s">"my-app"</span>,
<span class="k">"title"</span>: <span class="s">"My App Status"</span>, <span class="k">"title"</span>: <span class="s">"My App Status"</span>,
@ -353,7 +353,7 @@ Content-Type: application/json
<h3>Create incident</h3> <h3>Create incident</h3>
<div class="endpoint"><span class="method post">POST</span><span class="path">/incidents</span></div> <div class="endpoint"><span class="method post">POST</span><span class="path">/incidents</span></div>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json request body</span></div> <div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{ <pre>{
<span class="k">"title"</span>: <span class="s">"API returning 503s"</span>, <span class="k">"title"</span>: <span class="s">"API returning 503s"</span>,
<span class="k">"status"</span>: <span class="s">"investigating"</span>, <span class="k">"status"</span>: <span class="s">"investigating"</span>,
@ -389,10 +389,10 @@ Content-Type: application/json
<h3>Post update</h3> <h3>Post update</h3>
<div class="endpoint"><span class="method post">POST</span><span class="path">/incidents/:id/updates</span></div> <div class="endpoint"><span class="method post">POST</span><span class="path">/incidents/:id/updates</span></div>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json request body</span></div> <div class="cb-header"><span class="cb-lang">json - request body</span></div>
<pre>{ <pre>{
<span class="k">"status"</span>: <span class="s">"identified"</span>, <span class="k">"status"</span>: <span class="s">"identified"</span>,
<span class="k">"body"</span>: <span class="s">"Root cause identified a bad deploy at 14:02 UTC. Rolling back now."</span> <span class="k">"body"</span>: <span class="s">"Root cause identified - a bad deploy at 14:02 UTC. Rolling back now."</span>
}</pre> }</pre>
</div> </div>
<p>The <code>body</code> field supports basic markdown: <code>**bold**</code>, <code>*italic*</code>, <code>`code`</code>, and <code>[links](url)</code>. The incident's status is automatically synced to the latest update's status.</p> <p>The <code>body</code> field supports basic markdown: <code>**bold**</code>, <code>*italic*</code>, <code>`code`</code>, and <code>[links](url)</code>. The incident's status is automatically synced to the latest update's status.</p>
@ -404,7 +404,7 @@ Content-Type: application/json
<!-- QL Fields --> <!-- QL Fields -->
<div id="ql-fields" class="section"> <div id="ql-fields" class="section">
<h2>Query Language Fields</h2> <h2>Query Language - Fields</h2>
<p>A PingQL query is a JSON object evaluated against each ping. If it returns <code style="color:#4ade80;background:#052e16;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">true</code>, the monitor is <strong style="color:#4ade80">up</strong>. Default (no query): up only on a <strong>2xx</strong> status. Redirects and errors all count as DOWN.</p> <p>A PingQL query is a JSON object evaluated against each ping. If it returns <code style="color:#4ade80;background:#052e16;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">true</code>, the monitor is <strong style="color:#4ade80">up</strong>. Default (no query): up only on a <strong>2xx</strong> status. Redirects and errors all count as DOWN.</p>
<table> <table>
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead> <thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
@ -422,7 +422,7 @@ Content-Type: application/json
<!-- QL Operators --> <!-- QL Operators -->
<div id="ql-operators" class="section"> <div id="ql-operators" class="section">
<h2>Query Language Operators</h2> <h2>Query Language - Operators</h2>
<table> <table>
<thead><tr><th>Operator</th><th>Description</th><th>Types</th></tr></thead> <thead><tr><th>Operator</th><th>Description</th><th>Types</th></tr></thead>
<tbody> <tbody>
@ -452,7 +452,7 @@ Content-Type: application/json
<!-- $json --> <!-- $json -->
<div id="ql-json" class="section"> <div id="ql-json" class="section">
<h2>$json JSONPath</h2> <h2>$json - JSONPath</h2>
<p>Extract and compare a value from a JSON response body. The key is a dot-notation path starting with <code style="color:#93c5fd;background:#0f172a;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">$.</code></p> <p>Extract and compare a value from a JSON response body. The key is a dot-notation path starting with <code style="color:#93c5fd;background:#0f172a;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">$.</code></p>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb-header"><span class="cb-lang">json</span></div>
@ -465,7 +465,7 @@ Content-Type: application/json
<!-- $select --> <!-- $select -->
<div id="ql-select" class="section"> <div id="ql-select" class="section">
<h2>$select CSS Selector</h2> <h2>$select - CSS Selector</h2>
<p>Extract text content from an HTML response using a CSS selector. Useful for monitoring public pages without an API.</p> <p>Extract text content from an HTML response using a CSS selector. Useful for monitoring public pages without an API.</p>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb-header"><span class="cb-lang">json</span></div>
@ -482,13 +482,13 @@ Content-Type: application/json
<h2>Logical Operators</h2> <h2>Logical Operators</h2>
<div class="cb"> <div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb-header"><span class="cb-lang">json</span></div>
<pre><span class="c">// $and all conditions must match</span> <pre><span class="c">// $and - all conditions must match</span>
{ <span class="o">"$and"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"ok"</span> } }] } { <span class="o">"$and"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"ok"</span> } }] }
<span class="c">// $or any condition must match</span> <span class="c">// $or - any condition must match</span>
{ <span class="o">"$or"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"status"</span>: <span class="n">204</span> }] } { <span class="o">"$or"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"status"</span>: <span class="n">204</span> }] }
<span class="c">// $not invert a condition</span> <span class="c">// $not - invert a condition</span>
{ <span class="o">"$not"</span>: { <span class="k">"status"</span>: <span class="n">500</span> } }</pre> { <span class="o">"$not"</span>: { <span class="k">"status"</span>: <span class="n">500</span> } }</pre>
</div> </div>
</div> </div>
@ -546,7 +546,7 @@ Content-Type: application/json
<pre>{ <span class="o">"$select"</span>: { <span class="s">".status-indicator"</span>: { <span class="o">"$eq"</span>: <span class="s">"All systems operational"</span> } } }</pre></div> <pre>{ <span class="o">"$select"</span>: { <span class="s">".status-indicator"</span>: { <span class="o">"$eq"</span>: <span class="s">"All systems operational"</span> } } }</pre></div>
<h3>Page down if a JSON queue is backed up</h3> <h3>Page down if a JSON queue is backed up</h3>
<p>The killer demo: alert when a JSON field crosses a threshold. Uptime Kuma's keyword/json-query checks can't compose this you'd need a script. Here it's one expression.</p> <p>The killer demo: alert when a JSON field crosses a threshold. Uptime Kuma's keyword/json-query checks can't compose this - you'd need a script. Here it's one expression.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div> <div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <pre>{
<span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="o">"$consider"</span>: <span class="s">"down"</span>,
@ -588,7 +588,7 @@ Content-Type: application/json
<p>If a check fails and <code>max_retries</code> is greater than zero, the runner waits <code>retry_interval_s</code> seconds and retries up to that many times <em>before</em> recording a DOWN result. A successful retry posts a single UP ping with <code>meta.retries</code> noting how many attempts it took. This kills almost all flapping caused by transient TCP resets, brief 5xx blips, or network jitter.</p> <p>If a check fails and <code>max_retries</code> is greater than zero, the runner waits <code>retry_interval_s</code> seconds and retries up to that many times <em>before</em> recording a DOWN result. A successful retry posts a single UP ping with <code>meta.retries</code> noting how many attempts it took. This kills almost all flapping caused by transient TCP resets, brief 5xx blips, or network jitter.</p>
<h3>Important beats &amp; transitions</h3> <h3>Important beats &amp; transitions</h3>
<p>Every check is recorded, but the <code>important</code> flag on a ping is only set when the monitor's state changes (UP↔DOWN) <em>for that region</em>. Notifications fire on important beats only never on every routine check. State is tracked independently per region: if <code>us-west</code> goes DOWN, only a subsequent <code>us-west</code> UP clears it. <code>eu-central</code> being healthy will not silence a <code>us-west</code> outage.</p> <p>Every check is recorded, but the <code>important</code> flag on a ping is only set when the monitor's state changes (UP↔DOWN) <em>for that region</em>. Notifications fire on important beats only - never on every routine check. State is tracked independently per region: if <code>us-west</code> goes DOWN, only a subsequent <code>us-west</code> UP clears it. <code>eu-central</code> being healthy will not silence a <code>us-west</code> outage.</p>
<h3>Resend interval</h3> <h3>Resend interval</h3>
<p>For long outages, set <code>resend_interval</code> to re-fire the notification every Nth consecutive DOWN beat. With <code>resend_interval: 10</code>, a still-broken monitor produces an extra alert every 10 down checks. <code>0</code> (the default) means: alert once on the transition, then stay quiet until recovery.</p> <p>For long outages, set <code>resend_interval</code> to re-fire the notification every Nth consecutive DOWN beat. With <code>resend_interval: 10</code>, a still-broken monitor produces an extra alert every 10 down checks. <code>0</code> (the default) means: alert once on the transition, then stay quiet until recovery.</p>
@ -603,7 +603,7 @@ Content-Type: application/json
<!-- Webhook payload --> <!-- Webhook payload -->
<div id="webhook-payload" class="section"> <div id="webhook-payload" class="section">
<h2>Webhook payload</h2> <h2>Webhook payload</h2>
<p>Webhook channels POST a JSON body to the configured URL on every event. The HTTP method is <code>POST</code>, the request times out after 5 seconds, and PingQL does not retry the next important beat is the retry. Failures are logged but never block ingest.</p> <p>Webhook channels POST a JSON body to the configured URL on every event. The HTTP method is <code>POST</code>, the request times out after 5 seconds, and PingQL does not retry - the next important beat is the retry. Failures are logged but never block ingest.</p>
<h3>Headers</h3> <h3>Headers</h3>
<table> <table>
@ -627,15 +627,15 @@ Content-Type: application/json
</div> </div>
<h3>Event types</h3> <h3>Event types</h3>
<p><code>down</code> fired on the first DOWN important beat for a region, and again every <code>resend_interval</code>th consecutive down if configured.</p> <p><code>down</code> - fired on the first DOWN important beat for a region, and again every <code>resend_interval</code>th consecutive down if configured.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json event</span></div> <div class="cb"><div class="cb-header"><span class="cb-lang">json - event</span></div>
<pre>{ <pre>{
<span class="k">"kind"</span>: <span class="s">"down"</span>, <span class="k">"kind"</span>: <span class="s">"down"</span>,
<span class="k">"monitor"</span>: { <span class="k">"monitor"</span>: {
<span class="k">"id"</span>: <span class="s">"abc123def456"</span>, <span class="k">"id"</span>: <span class="s">"abc123def456"</span>,
<span class="k">"name"</span>: <span class="s">"My API"</span>, <span class="k">"name"</span>: <span class="s">"My API"</span>,
<span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>, <span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>,
<span class="k">"region"</span>: <span class="s">"us-west"</span> <span class="c">// always present runners default to "default" if REGION env var is unset</span> <span class="k">"region"</span>: <span class="s">"us-west"</span> <span class="c">// always present - runners default to "default" if REGION env var is unset</span>
}, },
<span class="k">"ping"</span>: { <span class="k">"ping"</span>: {
<span class="k">"status_code"</span>: <span class="n">503</span>, <span class="k">"status_code"</span>: <span class="n">503</span>,
@ -645,18 +645,18 @@ Content-Type: application/json
} }
}</pre></div> }</pre></div>
<p><code>up</code> fired on recovery, only when <em>that same region</em> transitions back from DOWN. Same shape as <code>down</code>.</p> <p><code>up</code> - fired on recovery, only when <em>that same region</em> transitions back from DOWN. Same shape as <code>down</code>.</p>
<p><code>cert</code> fired once per renewal cycle when the TLS leaf cert drops at or below <code>cert_alert_days</code> for a region.</p> <p><code>cert</code> - fired once per renewal cycle when the TLS leaf cert drops at or below <code>cert_alert_days</code> for a region.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json event</span></div> <div class="cb"><div class="cb-header"><span class="cb-lang">json - event</span></div>
<pre>{ <pre>{
<span class="k">"kind"</span>: <span class="s">"cert"</span>, <span class="k">"kind"</span>: <span class="s">"cert"</span>,
<span class="k">"monitor"</span>: { <span class="k">"id"</span>: <span class="s">"…"</span>, <span class="k">"name"</span>: <span class="s">"…"</span>, <span class="k">"url"</span>: <span class="s">"…"</span>, <span class="k">"region"</span>: <span class="s">"us-west"</span> }, <span class="k">"monitor"</span>: { <span class="k">"id"</span>: <span class="s">"…"</span>, <span class="k">"name"</span>: <span class="s">"…"</span>, <span class="k">"url"</span>: <span class="s">"…"</span>, <span class="k">"region"</span>: <span class="s">"us-west"</span> },
<span class="k">"days"</span>: <span class="n">9</span> <span class="c">// days until certificate expires</span> <span class="k">"days"</span>: <span class="n">9</span> <span class="c">// days until certificate expires</span>
}</pre></div> }</pre></div>
<p><code>test</code> synthetic event from <code>POST /notifications/channels/:id/test</code>. The <code>monitor</code> object is a placeholder.</p> <p><code>test</code> - synthetic event from <code>POST /notifications/channels/:id/test</code>. The <code>monitor</code> object is a placeholder.</p>
<div class="cb"><div class="cb-header"><span class="cb-lang">json event</span></div> <div class="cb"><div class="cb-header"><span class="cb-lang">json - event</span></div>
<pre>{ <pre>{
<span class="k">"kind"</span>: <span class="s">"test"</span>, <span class="k">"kind"</span>: <span class="s">"test"</span>,
<span class="k">"monitor"</span>: { <span class="k">"id"</span>: <span class="s">"test"</span>, <span class="k">"name"</span>: <span class="s">"Test event"</span>, <span class="k">"url"</span>: <span class="s">"https://example.com"</span>, <span class="k">"region"</span>: <span class="s">""</span> } <span class="k">"monitor"</span>: { <span class="k">"id"</span>: <span class="s">"test"</span>, <span class="k">"name"</span>: <span class="s">"Test event"</span>, <span class="k">"url"</span>: <span class="s">"https://example.com"</span>, <span class="k">"region"</span>: <span class="s">""</span> }

View File

@ -44,7 +44,7 @@
<div class="flex items-center gap-6 shrink-0 ml-4"> <div class="flex items-center gap-6 shrink-0 ml-4">
<div class="hidden sm:block stat-sparkline" style="width:120px;height:32px"><%~ it.sparklineSSR(pingsForSparkline) %></div> <div class="hidden sm:block stat-sparkline" style="width:120px;height:32px"><%~ it.sparklineSSR(pingsForSparkline) %></div>
<div class="text-right" style="width:72px"> <div class="text-right" style="width:72px">
<div class="text-sm text-gray-300 stat-latency"><%= avgLatency != null ? avgLatency + 'ms' : '' %></div> <div class="text-sm text-gray-300 stat-latency"><%= avgLatency != null ? avgLatency + 'ms' : '-' %></div>
<div class="text-xs text-gray-500 stat-last"><%~ m.last_ping ? it.timeAgoSSR(m.last_ping.checked_at) : 'no pings' %></div> <div class="text-xs text-gray-500 stat-last"><%~ m.last_ping ? it.timeAgoSSR(m.last_ping.checked_at) : 'no pings' %></div>
</div> </div>
<div class="text-xs px-2 py-1 rounded <%= m.enabled ? 'bg-gray-800/50 text-gray-400 border border-border-subtle' : 'bg-yellow-900/20 text-yellow-500 border border-yellow-800/30' %>"><%= m.enabled ? m.interval_s + 's' : 'paused' %></div> <div class="text-xs px-2 py-1 rounded <%= m.enabled ? 'bg-gray-800/50 text-gray-400 border border-border-subtle' : 'bg-yellow-900/20 text-yellow-500 border border-yellow-800/30' %>"><%= m.enabled ? m.interval_s + 's' : 'paused' %></div>

View File

@ -451,7 +451,7 @@
</div> </div>
</div> </div>
<div class="flex gap-[0.1rem] h-5"> <div class="flex gap-[0.1rem] h-5">
<div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-yellow-500/70 demo-bar"><div class="demo-tip"><div class="tip-head">Mar 28, 14:00 15:00</div><div class="tip-row"><span>Checks</span><span>120</span></div><div class="tip-row"><span>Successful</span><span>118</span></div><div class="tip-row"><span>Uptime</span><span class="tip-warn">98.33%</span></div><div class="tip-row"><span>Avg ping</span><span>52ms</span></div></div></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div> <div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-yellow-500/70 demo-bar"><div class="demo-tip"><div class="tip-head">Mar 28, 14:00 - 15:00</div><div class="tip-row"><span>Checks</span><span>120</span></div><div class="tip-row"><span>Successful</span><span>118</span></div><div class="tip-row"><span>Uptime</span><span class="tip-warn">98.33%</span></div><div class="tip-row"><span>Avg ping</span><span>52ms</span></div></div></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div>
</div> </div>
</div> </div>
@ -483,7 +483,7 @@
</div> </div>
</div> </div>
<div class="flex gap-[0.1rem] h-5"> <div class="flex gap-[0.1rem] h-5">
<div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-red-500/70 demo-bar"><div class="demo-tip"><div class="tip-head">Mar 22, 03:00 04:00</div><div class="tip-row"><span>Checks</span><span>120</span></div><div class="tip-row"><span>Successful</span><span>0</span></div><div class="tip-row"><span>Uptime</span><span class="tip-bad">0.00%</span></div><div class="tip-row"><span>Avg ping</span><span></span></div></div></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div> <div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-red-500/70 demo-bar"><div class="demo-tip"><div class="tip-head">Mar 22, 03:00 - 04:00</div><div class="tip-row"><span>Checks</span><span>120</span></div><div class="tip-row"><span>Successful</span><span>0</span></div><div class="tip-row"><span>Uptime</span><span class="tip-bad">0.00%</span></div><div class="tip-row"><span>Avg ping</span><span>-</span></div></div></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div><div class="flex-1 rounded-sm bg-green-500/70"></div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -20,7 +20,7 @@
<div class="card-static p-6" style="box-shadow:0 0 60px rgba(59,130,246,0.08)"> <div class="card-static p-6" style="box-shadow:0 0 60px rgba(59,130,246,0.08)">
<!-- Sign in form works with or without JS --> <!-- Sign in form - works with or without JS -->
<div id="screen-login"> <div id="screen-login">
<form id="login-form" action="/account/login" method="POST"> <form id="login-form" action="/account/login" method="POST">
<input type="hidden" name="_form" value="1"> <input type="hidden" name="_form" value="1">
@ -109,7 +109,7 @@
} catch { showError('Connection error'); } } catch { showError('Connection error'); }
}); });
// Register JS-enhanced (overrides form POST for inline key display) // Register - JS-enhanced (overrides form POST for inline key display)
document.getElementById('register-form').addEventListener('submit', async (e) => { document.getElementById('register-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
try { try {

View File

@ -204,7 +204,7 @@
<script> <script>
// JS enhancements clipboard copy (progressive, not required) // JS enhancements - clipboard copy (progressive, not required)
function copyLoginKey() { function copyLoginKey() {
const val = document.getElementById('login-key-display').textContent; const val = document.getElementById('login-key-display').textContent;
navigator.clipboard.writeText(val); navigator.clipboard.writeText(val);

View File

@ -72,7 +72,7 @@
<div class="flex-1"> <div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Display mode</label> <label class="block text-sm text-gray-400 mb-1.5">Display mode</label>
<select name="display_mode" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500"> <select name="display_mode" class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% [['expanded','Expanded — show full detail per monitor'],['compact','Compact — one line per monitor, click to expand']].forEach(function([v, label]) { %> <% [['expanded','Expanded - show full detail per monitor'],['compact','Compact - one line per monitor, click to expand']].forEach(function([v, label]) { %>
<option value="<%= v %>" <%= (p.display_mode || 'expanded') === v ? 'selected' : '' %>><%= label %></option> <option value="<%= v %>" <%= (p.display_mode || 'expanded') === v ? 'selected' : '' %>><%= label %></option>
<% }) %> <% }) %>
</select> </select>
@ -131,7 +131,7 @@
</div> </div>
<script> <script>
// Tiny vanilla drag-and-drop reorder. The DOM order at submit time is // Tiny vanilla drag-and-drop reorder. The DOM order at submit time is
// the canonical order each row carries a hidden "monitor_order" input // the canonical order - each row carries a hidden "monitor_order" input
// that gets posted in DOM order naturally. // that gets posted in DOM order naturally.
(function() { (function() {
const list = document.getElementById('monitor-list'); const list = document.getElementById('monitor-list');
@ -195,7 +195,7 @@
<div data-password-input-wrap class="hidden mt-2"> <div data-password-input-wrap class="hidden mt-2">
<input data-password-input name="password" type="password" placeholder="New password" <input data-password-input name="password" type="password" placeholder="New password"
class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500"> class="w-full bg-surface-solid border border-border-subtle rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
<button type="button" data-password-cancel class="mt-1 text-xs text-gray-500 hover:text-gray-300">Cancel keep current password</button> <button type="button" data-password-cancel class="mt-1 text-xs text-gray-500 hover:text-gray-300">Cancel - keep current password</button>
</div> </div>
<div data-password-removing class="hidden mt-2 text-xs text-red-400"> <div data-password-removing class="hidden mt-2 text-xs text-red-400">
Password will be removed when you save. Password will be removed when you save.