pingql/apps/web/src/views/docs.ejs

523 lines
34 KiB
Plaintext

<%~ include('./partials/public-head', { title: 'Documentation', nav: 'docs' }) %>
<style>
/* Code blocks use mono, rest uses sans */
.docs pre, .docs code { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; }
/* Sidebar nav */
.nav-link { display: block; padding: 0.3rem 0.75rem; font-size: 0.8rem; color: #6b7280; border-left: 2px solid transparent; transition: all 0.1s; }
.nav-link:hover { color: #d1d5db; border-left-color: #374151; }
.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; }
/* Content — scoped to .docs */
.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 h2 { font-size: 1.2rem; font-weight: 700; color: #f9fafb; margin-bottom: 0.5rem; }
.docs h3 { font-size: 0.85rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.06em; margin: 1.5rem 0 0.5rem; }
.docs p { font-size: 0.875rem; color: #6b7280; line-height: 1.7; margin-bottom: 0.75rem; }
.docs p a { color: #93c5fd; }
.docs p a:hover { color: #bfdbfe; }
.docs table { width: 100%; border-collapse: collapse; font-size: 0.8rem; margin: 0.5rem 0 1.25rem; }
.docs th { text-align: left; color: #4b5563; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.4rem 0.75rem; border-bottom: 1px solid #111827; }
.docs td { color: #6b7280; padding: 0.45rem 0.75rem; border-bottom: 1px solid #0d1117; vertical-align: top; }
.docs td:first-child { color: #93c5fd; white-space: nowrap; font-size: 0.78rem; }
.docs td code { background: #1e293b; color: #94a3b8; padding: 0.1em 0.35em; border-radius: 0.2rem; font-size: 0.75rem; }
/* Code blocks */
.cb { background: #0f172a; border: 1px solid #1e293b; border-radius: 0.5rem; overflow: hidden; margin: 0.75rem 0 1.25rem; }
.cb-header { background: #0a1628; border-bottom: 1px solid #1e293b; padding: 0.4rem 0.85rem; display: flex; align-items: center; justify-content: space-between; }
.cb-lang { font-size: 0.7rem; color: #475569; }
.cb-copy { font-size: 0.7rem; color: #3b82f6; cursor: pointer; }
.cb-copy:hover { color: #93c5fd; }
.cb pre { padding: 0.85rem; font-size: 0.78rem; line-height: 1.65; color: #e2e8f0; overflow-x: auto; margin: 0; }
/* Syntax */
.k { color: #c084fc; } .s { color: #34d399; } .n { color: #f59e0b; } .o { color: #60a5fa; } .c { color: #334155; }
/* Endpoint blocks */
.endpoint { display: flex; align-items: baseline; gap: 0.75rem; margin-bottom: 0.5rem; }
.method { font-size: 0.7rem; font-weight: 700; padding: 0.15rem 0.45rem; border-radius: 0.2rem; }
.method.get { background: #052e16; color: #4ade80; }
.method.post { background: #172554; color: #93c5fd; }
.method.patch { background: #1c1917; color: #fb923c; }
.method.delete { background: #3f1215; color: #f87171; }
.path { color: #e2e8f0; font-size: 0.875rem; }
.endpoint-desc { font-size: 0.8rem; color: #4b5563; margin-bottom: 1rem; }
</style>
<div class="flex max-w-6xl mx-auto pt-16">
<!-- Sidebar -->
<aside class="w-52 shrink-0 sticky top-16 h-[calc(100vh-4rem)] overflow-y-auto py-6 hidden md:block">
<div class="nav-section">Getting Started</div>
<a href="#overview" class="nav-link">Overview</a>
<a href="#auth" class="nav-link">Authentication</a>
<div class="nav-section">API Reference</div>
<a href="#account" class="nav-link">Account</a>
<a href="#monitors" class="nav-link">Monitors</a>
<a href="#reliability" class="nav-link">Reliability</a>
<a href="#notifications" class="nav-link">Notifications</a>
<a href="#webhook-payload" class="nav-link">Webhook payload</a>
<div class="nav-section">Query Language</div>
<a href="#ql-fields" class="nav-link">Fields</a>
<a href="#ql-operators" class="nav-link">Operators</a>
<a href="#ql-json" class="nav-link">$json</a>
<a href="#ql-select" class="nav-link">$select</a>
<a href="#ql-logical" class="nav-link">Logical</a>
<a href="#ql-consider" class="nav-link">$consider</a>
<a href="#ql-examples" class="nav-link">Examples</a>
</aside>
<!-- Main content -->
<main class="docs flex-1 px-10 py-8 max-w-3xl">
<!-- Overview -->
<div id="overview" class="section">
<h2>Overview</h2>
<p>PingQL is a developer-friendly uptime monitoring API. Monitors are defined with a URL, an interval, and an optional query that determines what "up" means for your service.</p>
<p>Base URL: <code style="color:#93c5fd;background:#0f172a;padding:0.15em 0.4em;border-radius:0.25rem;font-size:0.8rem">https://api.pingql.com</code></p>
</div>
<!-- Auth -->
<div id="auth" class="section">
<h2>Authentication</h2>
<p>All API requests require an account key passed as a Bearer token:</p>
<div class="cb">
<div class="cb-header"><span class="cb-lang">http</span></div>
<pre>Authorization: Bearer &lt;your-64-char-hex-key&gt;</pre>
</div>
<p>Create an account at <a href="/dashboard">/dashboard</a> or via the API. Keys are 64-character hex strings (256-bit). Shown once at registration &#8212; store them securely.</p>
</div>
<!-- Account -->
<div id="account" class="section">
<h2>Account</h2>
<h3>Register</h3>
<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>
<div class="cb">
<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>
</div>
<div class="cb">
<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>
</div>
<h3>Update Email</h3>
<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>
<div class="cb">
<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>
</div>
</div>
<!-- Monitors -->
<div id="monitors" class="section">
<h2>Monitors</h2>
<h3>List</h3>
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/</span></div>
<p class="endpoint-desc">Returns all monitors for the authenticated account.</p>
<h3>Create</h3>
<div class="endpoint"><span class="method post">POST</span><span class="path">/monitors/</span></div>
<div class="cb">
<div class="cb-header"><span class="cb-lang">json — request body</span></div>
<pre>{
<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">"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">"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">"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">"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">"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">14</span>, <span class="c">// optional — alert when TLS cert is within N days of expiry. 0 disables. Default: 14</span>
<span class="k">"channel_ids"</span>: [<span class="s">"&lt;uuid&gt;"</span>], <span class="c">// optional — notification channels to attach</span>
<span class="k">"query"</span>: { ... } <span class="c">// optional — see Query Language below</span>
}</pre>
</div>
<table>
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<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>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>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>regions</td><td>string[]</td><td>Regions to ping from: <code>eu-central</code>, <code>us-west</code>. Default: all</td></tr>
<tr><td>timeout_ms</td><td>number</td><td>Request timeout in milliseconds (default: 10000)</td></tr>
<tr><td>max_retries</td><td>number</td><td>Retry a failing check this many times before posting a DOWN result. Default: 0. Max: 10. See <a href="#reliability">Reliability</a>.</td></tr>
<tr><td>retry_interval_s</td><td>number</td><td>Seconds between retries. Default: 30.</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: 14.</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>
</tbody>
</table>
<h3>Get</h3>
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/:id</span></div>
<p class="endpoint-desc">Returns a monitor including its most recent ping results.</p>
<h3>Update</h3>
<div class="endpoint"><span class="method patch">PATCH</span><span class="path">/monitors/:id</span></div>
<p class="endpoint-desc">Update any field. All fields are optional.</p>
<h3>Delete</h3>
<div class="endpoint"><span class="method delete">DELETE</span><span class="path">/monitors/:id</span></div>
<h3>Toggle</h3>
<div class="endpoint"><span class="method post">POST</span><span class="path">/monitors/:id/toggle</span></div>
<p class="endpoint-desc">Enable or disable a monitor without deleting it.</p>
<h3>Ping History</h3>
<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>
</div>
<!-- Reliability -->
<div id="reliability" class="section">
<h2>Reliability &amp; alert noise</h2>
<p>PingQL doesn't immediately fire on a single failed check. Three knobs let you tune how reactive vs. how stable the alerting is:</p>
<h3>Retries before DOWN</h3>
<p>If a check fails and <code>max_retries</code> is greater than zero, the runner waits <code>retry_interval_s</code> seconds and retries up to that many times <em>before</em> recording a DOWN result. A successful retry posts a single UP ping with <code>meta.retries</code> noting how many attempts it took. This kills almost all flapping caused by transient TCP resets, brief 5xx blips, or network jitter.</p>
<h3>Important beats &amp; transitions</h3>
<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>
<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>
<h3>Cert expiry alerting</h3>
<p>For HTTPS monitors PingQL extracts the TLS leaf certificate's days-until-expiry on every check. When that drops at or below <code>cert_alert_days</code> for the first time, a separate <code>cert</code> notification fires (one per region). The flag clears when the cert is renewed, so each renewal cycle gets exactly one alert. Set <code>cert_alert_days: 0</code> to disable.</p>
<h3>Default empty query</h3>
<p>If you don't supply a <code>query</code>, the monitor is considered up only on a <strong style="color:#4ade80">2xx</strong> response. Redirects (3xx), client errors (4xx) and server errors (5xx) all count as DOWN. Use the QL if you want different behaviour.</p>
</div>
<!-- Notifications -->
<div id="notifications" class="section">
<h2>Notifications</h2>
<p>Notification channels are reusable destinations attached to monitors. When an important beat fires (DOWN, recovery, or cert), each attached channel is dispatched. PingQL ships with a <strong>webhook</strong> provider; more (Discord, Slack, Email) are designed as drop-ins.</p>
<h3>List channels</h3>
<div class="endpoint"><span class="method get">GET</span><span class="path">/notifications/channels</span></div>
<p class="endpoint-desc">Returns all channels for the authenticated account.</p>
<h3>Create channel</h3>
<div class="endpoint"><span class="method post">POST</span><span class="path">/notifications/channels</span></div>
<div class="cb">
<div class="cb-header"><span class="cb-lang">json — request body</span></div>
<pre>{
<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">"config"</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">"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>
}</pre>
</div>
<table>
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td>name</td><td>string</td><td>Display name (up to 200 chars)</td></tr>
<tr><td>kind</td><td>string</td><td>Provider type. Currently only <code>webhook</code>.</td></tr>
<tr><td>config</td><td>object</td><td>Provider-specific config. For <code>webhook</code>, requires <code>url</code> (http/https). Optional <code>headers</code> object and <code>secret</code> for HMAC signing.</td></tr>
<tr><td>enabled</td><td>boolean</td><td>Disabled channels are skipped during dispatch but remain attached.</td></tr>
</tbody>
</table>
<h3>Update channel</h3>
<div class="endpoint"><span class="method patch">PATCH</span><span class="path">/notifications/channels/:id</span></div>
<p class="endpoint-desc">All fields optional. Provide a partial body to change name, config, or enabled state.</p>
<h3>Delete channel</h3>
<div class="endpoint"><span class="method delete">DELETE</span><span class="path">/notifications/channels/:id</span></div>
<p class="endpoint-desc">Removes the channel and all monitor attachments to it.</p>
<h3>Test channel</h3>
<div class="endpoint"><span class="method post">POST</span><span class="path">/notifications/channels/:id/test</span></div>
<p class="endpoint-desc">Sends a synthetic <code>test</code> event through the channel and returns whether the provider accepted it. Useful to verify the URL and HMAC are wired correctly without waiting for a real outage.</p>
<h3>Attaching channels to monitors</h3>
<p>Pass <code>channel_ids</code> as an array of channel UUIDs when creating or patching a monitor. The PATCH replaces the full set; pass an empty array to detach all channels. Channels can only be attached to monitors in the same account.</p>
<div class="cb">
<div class="cb-header"><span class="cb-lang">http</span></div>
<pre>PATCH /monitors/abc123def456
Authorization: Bearer &lt;key&gt;
Content-Type: application/json
{ <span class="k">"channel_ids"</span>: [<span class="s">"5fb1c0bf-…"</span>, <span class="s">"a72e0d91-…"</span>] }</pre>
</div>
</div>
<!-- Webhook payload -->
<div id="webhook-payload" class="section">
<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>
<h3>Headers</h3>
<table>
<thead><tr><th>Header</th><th>Description</th></tr></thead>
<tbody>
<tr><td>content-type</td><td><code>application/json</code></td></tr>
<tr><td>user-agent</td><td><code>PingQL-Notifier/1</code></td></tr>
<tr><td>x-pingql-signature</td><td>Hex-encoded HMAC-SHA256 of the raw request body, keyed by <code>config.secret</code>. Only present when a secret is configured. Verify it server-side to confirm the request came from PingQL.</td></tr>
<tr><td><em>custom</em></td><td>Any headers from <code>config.headers</code> are forwarded as-is.</td></tr>
</tbody>
</table>
<h3>Body shape</h3>
<p>Every payload has the same envelope:</p>
<div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="k">"channel"</span>: { <span class="k">"id"</span>: <span class="s">"&lt;uuid&gt;"</span>, <span class="k">"name"</span>: <span class="s">"On-call webhook"</span> },
<span class="k">"event"</span>: { <span class="k">"kind"</span>: <span class="s">"down"</span> | <span class="s">"up"</span> | <span class="s">"cert"</span> | <span class="s">"test"</span>, ... }
}</pre>
</div>
<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>
<div class="cb"><div class="cb-header"><span class="cb-lang">json — event</span></div>
<pre>{
<span class="k">"kind"</span>: <span class="s">"down"</span>,
<span class="k">"monitor"</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">"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">// "" for unspecified/single-region monitors</span>
},
<span class="k">"ping"</span>: {
<span class="k">"status_code"</span>: <span class="n">503</span>,
<span class="k">"latency_ms"</span>: <span class="n">412</span>,
<span class="k">"error"</span>: <span class="n">null</span>,
<span class="k">"checked_at"</span>: <span class="s">"2026-04-08T14:23:00.000Z"</span>
}
}</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>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>
<pre>{
<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">"days"</span>: <span class="n">9</span> <span class="c">// days until certificate expires</span>
}</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>
<div class="cb"><div class="cb-header"><span class="cb-lang">json — event</span></div>
<pre>{
<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> }
}</pre></div>
<h3>Verifying the signature</h3>
<div class="cb">
<div class="cb-header"><span class="cb-lang">node</span></div>
<pre><span class="k">import</span> { createHmac, timingSafeEqual } <span class="k">from</span> <span class="s">"crypto"</span>;
<span class="k">function</span> verify(rawBody, headerSig, secret) {
<span class="k">const</span> expected = createHmac(<span class="s">"sha256"</span>, secret).update(rawBody).digest(<span class="s">"hex"</span>);
<span class="k">return</span> timingSafeEqual(Buffer.from(expected), Buffer.from(headerSig));
}</pre>
</div>
<p>Always verify against the <em>raw</em> request body before parsing JSON.</p>
</div>
<!-- QL Fields -->
<div id="ql-fields" class="section">
<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>
<table>
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td>status</td><td>number</td><td>HTTP status code</td></tr>
<tr><td>body</td><td>string</td><td>Full response body as text</td></tr>
<tr><td>headers.<em>name</em></td><td>string</td><td>Response header, e.g. <code>headers.content-type</code></td></tr>
<tr><td>$responseTime</td><td>number</td><td>Request latency in milliseconds</td></tr>
<tr><td>$certExpiry</td><td>number</td><td>Days until SSL certificate expires</td></tr>
<tr><td>$json</td><td>object</td><td>JSONPath expression against response body</td></tr>
<tr><td>$select</td><td>object</td><td>CSS selector against response HTML</td></tr>
</tbody>
</table>
</div>
<!-- QL Operators -->
<div id="ql-operators" class="section">
<h2>Query Language — Operators</h2>
<table>
<thead><tr><th>Operator</th><th>Description</th><th>Types</th></tr></thead>
<tbody>
<tr><td>$eq</td><td>Equal to</td><td>any</td></tr>
<tr><td>$ne</td><td>Not equal to</td><td>any</td></tr>
<tr><td>$gt / $gte</td><td>Greater than / or equal</td><td>number</td></tr>
<tr><td>$lt / $lte</td><td>Less than / or equal</td><td>number</td></tr>
<tr><td>$contains</td><td>String contains substring</td><td>string</td></tr>
<tr><td>$startsWith</td><td>String starts with</td><td>string</td></tr>
<tr><td>$endsWith</td><td>String ends with</td><td>string</td></tr>
<tr><td>$regex</td><td>Matches regular expression</td><td>string</td></tr>
<tr><td>$exists</td><td>Field is present and non-null</td><td>any</td></tr>
<tr><td>$in</td><td>Value is in array</td><td>any</td></tr>
</tbody>
</table>
<div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div>
<pre><span class="c">// simple equality shorthand</span>
{ <span class="k">"status"</span>: <span class="n">200</span> }
<span class="c">// operator form</span>
{ <span class="k">"status"</span>: { <span class="o">"$lt"</span>: <span class="n">400</span> } }
{ <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"healthy"</span> } }
{ <span class="k">"headers.content-type"</span>: { <span class="o">"$contains"</span>: <span class="s">"application/json"</span> } }</pre>
</div>
</div>
<!-- $json -->
<div id="ql-json" class="section">
<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>
<div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div>
<pre><span class="c">// response body: { "status": "ok", "db": { "connections": 12 } }</span>
{ <span class="o">"$json"</span>: { <span class="s">"$.status"</span>: { <span class="o">"$eq"</span>: <span class="s">"ok"</span> } } }
{ <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } }</pre>
</div>
</div>
<!-- $select -->
<div id="ql-select" class="section">
<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>
<div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div>
<pre><span class="c">// matches if &lt;h1&gt; text is exactly "Example Domain"</span>
{ <span class="o">"$select"</span>: { <span class="s">"h1"</span>: { <span class="o">"$eq"</span>: <span class="s">"Example Domain"</span> } } }
<span class="c">// matches if status badge contains "operational"</span>
{ <span class="o">"$select"</span>: { <span class="s">".status-badge"</span>: { <span class="o">"$contains"</span>: <span class="s">"operational"</span> } } }</pre>
</div>
</div>
<!-- Logical -->
<div id="ql-logical" class="section">
<h2>Logical Operators</h2>
<div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div>
<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="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="c">// $not — invert a condition</span>
{ <span class="o">"$not"</span>: { <span class="k">"status"</span>: <span class="n">500</span> } }</pre>
</div>
</div>
<!-- $consider -->
<div id="ql-consider" class="section">
<h2>$consider</h2>
<p>By default, matching conditions mean the monitor is <strong style="color:#4ade80">up</strong>. Set <code style="color:#93c5fd;background:#0f172a;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">"$consider": "down"</code> to flip this. If the conditions match, the monitor is <strong style="color:#f87171">down</strong>.</p>
<div class="cb">
<div class="cb-header"><span class="cb-lang">json</span></div>
<pre><span class="c">// down if response time exceeds 2 seconds</span>
{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">2000</span> } }
<span class="c">// down if cert expires in less than 7 days</span>
{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$certExpiry"</span>: { <span class="o">"$lt"</span>: <span class="n">7</span> } }
<span class="c">// down if any of these match</span>
{
<span class="o">"$consider"</span>: <span class="s">"down"</span>,
<span class="o">"$or"</span>: [
{ <span class="k">"status"</span>: { <span class="o">"$gte"</span>: <span class="n">500</span> } },
{ <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">5000</span> } }
]
}</pre>
</div>
</div>
<!-- Examples -->
<div id="ql-examples" class="section">
<h2>Examples</h2>
<h3>Basic health endpoint</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <span class="k">"status"</span>: <span class="n">200</span>, <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"healthy"</span> } }</pre></div>
<h3>JSON API response shape</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{
<span class="o">"$and"</span>: [
{ <span class="k">"status"</span>: <span class="n">200</span> },
{ <span class="o">"$json"</span>: { <span class="s">"$.ok"</span>: { <span class="o">"$eq"</span>: <span class="n">true</span> } } }
]
}</pre></div>
<h3>Performance monitor (mark down if slow)</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">1000</span> } }</pre></div>
<h3>Cert expiry alert</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
<pre>{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$certExpiry"</span>: { <span class="o">"$lt"</span>: <span class="n">14</span> } }</pre></div>
<h3>Status page (HTML)</h3>
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></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>
</div>
</main>
</div>
<script>
// Highlight active nav link on scroll
const sections = document.querySelectorAll('[id]');
const navLinks = document.querySelectorAll('.nav-link');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
navLinks.forEach(l => l.classList.remove('active'));
const link = document.querySelector(`.nav-link[href="#${entry.target.id}"]`);
if (link) link.classList.add('active');
}
});
}, { rootMargin: '-20% 0px -70% 0px' });
sections.forEach(s => observer.observe(s));
// Copy buttons
document.querySelectorAll('.cb-header').forEach(h => {
if (!h.querySelector('.cb-copy')) {
const btn = document.createElement('button');
btn.className = 'cb-copy';
btn.textContent = 'Copy';
h.appendChild(btn);
btn.addEventListener('click', () => {
const text = h.nextElementSibling.innerText;
navigator.clipboard.writeText(text);
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 1500);
});
}
});
</script>
<%~ include('./partials/public-foot') %>