remove em dashes
This commit is contained in:
parent
5f20b41e91
commit
79ba63d86b
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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." })),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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[] = [];
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[],
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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'; %>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) : '';
|
||||||
|
|
|
||||||
|
|
@ -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">"<uuid>"</span>], <span class="c">// optional — notification channels to attach</span>
|
<span class="k">"channel_ids"</span>: [<span class="s">"<uuid>"</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 & transitions</h3>
|
<h3>Important beats & 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> }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue