fet: reduce LOC by reducing comments
This commit is contained in:
parent
005f635fab
commit
8e554498f0
|
|
@ -19,7 +19,6 @@ const elysia = new Elysia()
|
|||
.use(ingest)
|
||||
.use(internal);
|
||||
|
||||
// Wrap Elysia with Bun.serve to guarantee CORS + security headers on every response
|
||||
const server = Bun.serve({
|
||||
port: 3001,
|
||||
async fetch(req) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
/// Internal endpoints used by the Rust monitor runner.
|
||||
/// Protected by MONITOR_TOKEN — not exposed to users.
|
||||
|
||||
import { Elysia } from "elysia";
|
||||
import sql from "../db";
|
||||
import { safeTokenCompare } from "../../../shared/auth";
|
||||
|
|
@ -10,7 +7,6 @@ export async function pruneOldPings(retentionDays = 90) {
|
|||
return result.count;
|
||||
}
|
||||
|
||||
// Run retention cleanup every hour
|
||||
setInterval(() => {
|
||||
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
||||
pruneOldPings(days).catch((err) => console.error("Retention cleanup failed:", err));
|
||||
|
|
@ -31,9 +27,6 @@ export const internal = new Elysia({ prefix: "/internal", detail: { hide: true }
|
|||
}
|
||||
})
|
||||
|
||||
// Returns monitors due within the next `lookahead_ms` milliseconds (default 2000).
|
||||
// Nodes receive scheduled_at as an exact unix ms timestamp and sleep until that
|
||||
// moment before firing — all regions coordinate to the same scheduled slot.
|
||||
.get("/due", async ({ request }) => {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const region = params.get('region') || undefined;
|
||||
|
|
@ -66,7 +59,6 @@ export const internal = new Elysia({ prefix: "/internal", detail: { hide: true }
|
|||
return monitors;
|
||||
})
|
||||
|
||||
// Manual retention cleanup trigger
|
||||
.post("/prune", async () => {
|
||||
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
||||
const deleted = await pruneOldPings(days);
|
||||
|
|
|
|||
|
|
@ -19,37 +19,31 @@ const MonitorBody = t.Object({
|
|||
export const monitors = new Elysia({ prefix: "/monitors" })
|
||||
.use(requireAuth)
|
||||
|
||||
// List monitors
|
||||
.get("/", async ({ accountId }) => {
|
||||
return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
||||
}, { detail: { summary: "List monitors", tags: ["monitors"] } })
|
||||
|
||||
// Create monitor
|
||||
.post("/", async ({ accountId, plan, body, set }) => {
|
||||
const limits = getPlanLimits(plan);
|
||||
|
||||
// Enforce monitor count limit
|
||||
const [{ count }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`;
|
||||
if (count >= limits.maxMonitors) {
|
||||
set.status = 403;
|
||||
return { error: `Plan limit reached: ${limits.maxMonitors} monitors (${plan}). Upgrade to create more.` };
|
||||
}
|
||||
|
||||
// Enforce minimum interval for plan
|
||||
const interval = body.interval_s ?? limits.minIntervalS;
|
||||
if (interval < limits.minIntervalS) {
|
||||
set.status = 400;
|
||||
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
|
||||
}
|
||||
|
||||
// Enforce region limit for plan
|
||||
const regions = body.regions ?? [];
|
||||
if (regions.length > limits.maxRegions) {
|
||||
set.status = 400;
|
||||
return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` };
|
||||
}
|
||||
|
||||
// SSRF protection
|
||||
const ssrfError = await validateMonitorUrl(body.url);
|
||||
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
|
||||
const [monitor] = await sql`
|
||||
|
|
@ -69,7 +63,6 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
|||
return monitor;
|
||||
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
|
||||
|
||||
// Get monitor + recent status
|
||||
.get("/:id", async ({ accountId, params, set }) => {
|
||||
const [monitor] = await sql`
|
||||
SELECT * FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
|
|
@ -83,23 +76,19 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
|||
return { ...monitor, results };
|
||||
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
|
||||
|
||||
// Update monitor
|
||||
.patch("/:id", async ({ accountId, plan, params, body, set }) => {
|
||||
const limits = getPlanLimits(plan);
|
||||
|
||||
// Enforce minimum interval for plan
|
||||
if (body.interval_s != null && body.interval_s < limits.minIntervalS) {
|
||||
set.status = 400;
|
||||
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
|
||||
}
|
||||
|
||||
// Enforce region limit for plan
|
||||
if (body.regions && body.regions.length > limits.maxRegions) {
|
||||
set.status = 400;
|
||||
return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` };
|
||||
}
|
||||
|
||||
// SSRF protection on URL change
|
||||
if (body.url) {
|
||||
const ssrfError = await validateMonitorUrl(body.url);
|
||||
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
|
||||
|
|
@ -123,7 +112,6 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
|||
return monitor;
|
||||
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
||||
|
||||
// Delete monitor
|
||||
.delete("/:id", async ({ accountId, params, set }) => {
|
||||
const [deleted] = await sql`
|
||||
DELETE FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
|
||||
|
|
@ -132,7 +120,6 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
|||
return { deleted: true };
|
||||
}, { detail: { summary: "Delete monitor", tags: ["monitors"] } })
|
||||
|
||||
// Toggle enabled
|
||||
.post("/:id/toggle", async ({ accountId, params, set }) => {
|
||||
const [monitor] = await sql`
|
||||
UPDATE monitors SET enabled = NOT enabled
|
||||
|
|
@ -143,7 +130,6 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
|||
return monitor;
|
||||
}, { detail: { summary: "Toggle monitor on/off", tags: ["monitors"] } })
|
||||
|
||||
// Check history
|
||||
.get("/:id/pings", async ({ accountId, params, query, set }) => {
|
||||
const [monitor] = await sql`
|
||||
SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
|
|
@ -169,7 +155,6 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
|||
`;
|
||||
}
|
||||
if (filter === "events") {
|
||||
// State changes: pings where `up` differs from the previous ping's `up` for this monitor
|
||||
return sql`
|
||||
SELECT * FROM (
|
||||
SELECT *, LAG(up) OVER (ORDER BY checked_at) AS prev_up
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import sql from "../db";
|
|||
import { resolveKey } from "./auth";
|
||||
import { extractAuthKey, safeTokenCompare } from "../../../shared/auth";
|
||||
|
||||
// ── SSE bus ───────────────────────────────────────────────────────────────────
|
||||
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
||||
const bus = new Map<string, Set<SSEController>>(); // keyed by accountId
|
||||
const enc = new TextEncoder();
|
||||
|
|
@ -50,22 +49,18 @@ function makeSSEStream(accountId: string): Response {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
export const ingest = new Elysia()
|
||||
|
||||
// Internal: called by Rust monitor runner
|
||||
.post("/internal/ingest", async ({ body, headers, set }) => {
|
||||
const token = headers["x-monitor-token"];
|
||||
if (!safeTokenCompare(token, process.env.MONITOR_TOKEN)) { set.status = 401; return { error: "Unauthorized" }; }
|
||||
|
||||
// Validate monitor exists
|
||||
const [monitor_check] = await sql`SELECT id FROM monitors WHERE id = ${body.monitor_id}`;
|
||||
if (!monitor_check) { set.status = 404; return { error: "Monitor not found" }; }
|
||||
|
||||
const meta = body.meta ? { ...body.meta } : {};
|
||||
if (body.cert_expiry_days != null) meta.cert_expiry_days = body.cert_expiry_days;
|
||||
|
||||
// Extract response body from meta — stored separately
|
||||
const responseBody: string | null = meta.body_preview ?? null;
|
||||
delete meta.body_preview;
|
||||
|
||||
|
|
@ -91,12 +86,10 @@ export const ingest = new Elysia()
|
|||
RETURNING *
|
||||
`;
|
||||
|
||||
// Store response body separately
|
||||
if (responseBody != null && ping) {
|
||||
await sql`INSERT INTO ping_bodies (ping_id, body) VALUES (${ping.id}, ${responseBody})`;
|
||||
}
|
||||
|
||||
// Look up account and publish to account-level bus (without body to keep SSE lean)
|
||||
const [monitor] = await sql`SELECT account_id FROM monitors WHERE id = ${body.monitor_id}`;
|
||||
if (monitor) publish(monitor.account_id, ping);
|
||||
|
||||
|
|
@ -119,7 +112,6 @@ export const ingest = new Elysia()
|
|||
detail: { hide: true },
|
||||
})
|
||||
|
||||
// Fetch response body for a specific ping
|
||||
.get("/pings/:id/body", async ({ params, headers, cookie, set }) => {
|
||||
const key = extractAuthKey(headers, cookie);
|
||||
if (!key) { set.status = 401; return { error: "Unauthorized" }; }
|
||||
|
|
@ -127,7 +119,6 @@ export const ingest = new Elysia()
|
|||
const resolved = await resolveKey(key);
|
||||
if (!resolved) { set.status = 401; return { error: "Unauthorized" }; }
|
||||
|
||||
// Verify the ping belongs to this account
|
||||
const [ping] = await sql`
|
||||
SELECT p.id FROM pings p
|
||||
JOIN monitors m ON m.id = p.monitor_id
|
||||
|
|
@ -139,7 +130,6 @@ export const ingest = new Elysia()
|
|||
return { body: row?.body ?? null };
|
||||
}, { detail: { hide: true } })
|
||||
|
||||
// SSE: single stream for all of the account's monitors
|
||||
.get("/account/stream", async ({ headers, cookie }) => {
|
||||
const key = extractAuthKey(headers, cookie);
|
||||
if (!key) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
|
|
|
|||
|
|
@ -54,26 +54,22 @@ export async function validateMonitorUrl(url: string): Promise<string | null> {
|
|||
return "Invalid URL";
|
||||
}
|
||||
|
||||
// Only allow http and https
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return `Blocked scheme: ${parsed.protocol} — only http: and https: are allowed`;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Block localhost by name
|
||||
if (BLOCKED_HOSTNAMES.includes(hostname)) {
|
||||
return "Blocked hostname: localhost is not allowed";
|
||||
}
|
||||
|
||||
// Block non-public TLDs
|
||||
for (const tld of BLOCKED_TLDS) {
|
||||
if (hostname.endsWith(tld)) {
|
||||
return `Blocked TLD: ${tld} is not allowed`;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve DNS and check all IPs
|
||||
try {
|
||||
const ips: string[] = [];
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@ use tracing::{error, info};
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Install default rustls crypto provider (required for cert expiry checks)
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.ok(); // ok() — ignore error if already installed
|
||||
rustls::crypto::ring::default_provider().install_default().ok();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
|
||||
|
|
|
|||
|
|
@ -1,38 +1,3 @@
|
|||
/// PingQL query evaluation against a check response.
|
||||
///
|
||||
/// Query shape (MongoDB-inspired):
|
||||
///
|
||||
/// Simple equality:
|
||||
/// { "status": 200 }
|
||||
///
|
||||
/// Operators:
|
||||
/// { "status": { "$eq": 200 } }
|
||||
/// { "status": { "$ne": 500 } }
|
||||
/// { "status": { "$gte": 200, "$lt": 300 } }
|
||||
/// { "body": { "$contains": "healthy" } }
|
||||
/// { "body": { "$startsWith": "OK" } }
|
||||
/// { "body": { "$endsWith": "done" } }
|
||||
/// { "body": { "$regex": "ok|healthy" } }
|
||||
/// { "body": { "$exists": true } }
|
||||
/// { "status": { "$in": [200, 201, 204] } }
|
||||
///
|
||||
/// CSS selector (HTML parsing):
|
||||
/// { "$select": "span.status", "$eq": "operational" }
|
||||
///
|
||||
/// JSONPath:
|
||||
/// { "$json": "$.data.status", "$eq": "ok" }
|
||||
///
|
||||
/// Response time:
|
||||
/// { "$responseTime": { "$lt": 500 } }
|
||||
///
|
||||
/// Certificate expiry:
|
||||
/// { "$certExpiry": { "$gt": 30 } }
|
||||
///
|
||||
/// Logical:
|
||||
/// { "$and": [ { "status": 200 }, { "body": { "$contains": "ok" } } ] }
|
||||
/// { "$or": [ { "status": 200 }, { "status": 204 } ] }
|
||||
/// { "$not": { "status": 500 } }
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use regex::Regex;
|
||||
use scraper::{Html, Selector};
|
||||
|
|
@ -46,11 +11,9 @@ pub struct Response {
|
|||
pub cert_expiry_days: Option<i64>,
|
||||
}
|
||||
|
||||
/// Returns true if `query` matches `response`. No query = always up.
|
||||
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
||||
match query {
|
||||
Value::Object(map) => {
|
||||
// $consider — "up" (default) or "down": flips result if conditions match
|
||||
if let Some(consider) = map.get("$consider") {
|
||||
let is_down = consider.as_str() == Some("down");
|
||||
let rest: serde_json::Map<String, Value> = map.iter()
|
||||
|
|
@ -61,7 +24,6 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
|||
return Ok(if is_down { !matches } else { matches });
|
||||
}
|
||||
|
||||
// $and / $or / $not
|
||||
if let Some(and) = map.get("$and") {
|
||||
let Value::Array(clauses) = and else { bail!("$and expects array") };
|
||||
return Ok(clauses.iter().all(|c| evaluate(c, response).unwrap_or(false)));
|
||||
|
|
@ -74,19 +36,14 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
|||
return Ok(!evaluate(not, response)?);
|
||||
}
|
||||
|
||||
// $responseTime
|
||||
if let Some(cond) = map.get("$responseTime") {
|
||||
let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0)));
|
||||
return eval_condition(cond, &val, response);
|
||||
}
|
||||
|
||||
// $certExpiry
|
||||
if let Some(cond) = map.get("$certExpiry") {
|
||||
let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0)));
|
||||
return eval_condition(cond, &val, response);
|
||||
}
|
||||
|
||||
// $json — { "$json": { "$.path": { "$op": val } } }
|
||||
if let Some(json_path_map) = map.get("$json") {
|
||||
let path_map = match json_path_map {
|
||||
Value::Object(m) => m,
|
||||
|
|
@ -99,13 +56,11 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
|||
return Ok(true);
|
||||
}
|
||||
|
||||
// $select — { "$select": { "css.selector": { "$op": val } } }
|
||||
if let Some(sel_map) = map.get("$select") {
|
||||
let sel_obj = match sel_map {
|
||||
Value::Object(m) => m,
|
||||
_ => bail!("$select expects an object {{ selector: condition }}"),
|
||||
};
|
||||
// Parse HTML once for all selectors
|
||||
let doc = Html::parse_document(&response.body);
|
||||
for (selector, condition) in sel_obj {
|
||||
let sel = Selector::parse(selector)
|
||||
|
|
@ -118,7 +73,6 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
|||
return Ok(true);
|
||||
}
|
||||
|
||||
// Field-level checks
|
||||
for (field, condition) in map {
|
||||
let field_val = resolve_field(field, response);
|
||||
if !eval_condition(condition, &field_val, response)? {
|
||||
|
|
@ -154,7 +108,6 @@ fn resolve_json_path(body: &str, path: &str) -> Value {
|
|||
if path.is_empty() { return obj; }
|
||||
let mut current = &obj;
|
||||
for part in path.split('.') {
|
||||
// Handle array indexing like "items[0]"
|
||||
if let Some(idx_start) = part.find('[') {
|
||||
let key = &part[..idx_start];
|
||||
if !key.is_empty() {
|
||||
|
|
@ -184,7 +137,6 @@ fn resolve_json_path(body: &str, path: &str) -> Value {
|
|||
|
||||
fn eval_condition(condition: &Value, field_val: &Value, response: &Response) -> Result<bool> {
|
||||
match condition {
|
||||
// Shorthand: { "status": 200 }
|
||||
Value::Number(n) => Ok(field_val.as_f64() == n.as_f64()),
|
||||
Value::String(s) => Ok(field_val.as_str() == Some(s.as_str())),
|
||||
Value::Bool(b) => Ok(field_val.as_bool() == Some(*b)),
|
||||
|
|
@ -242,11 +194,8 @@ fn eval_op(op: &str, field_val: &Value, val: &Value, response: &Response) -> Res
|
|||
}
|
||||
}
|
||||
"$select" => {
|
||||
// Nested: { "body": { "$select": "css", "$eq": "val" } }
|
||||
let sel_str = val.as_str().unwrap_or("");
|
||||
let selected = css_select(&response.body, sel_str);
|
||||
// If no comparison operator follows, just check existence
|
||||
selected.is_some()
|
||||
css_select(&response.body, sel_str).is_some()
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Unknown query operator: {op}");
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ use std::time::Instant;
|
|||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
// Cache native root certs per OS thread to avoid reloading from disk on every check.
|
||||
thread_local! {
|
||||
static ROOT_CERTS: Arc<Vec<ureq::tls::Certificate<'static>>> = Arc::new(
|
||||
rustls_native_certs::load_native_certs()
|
||||
|
|
@ -19,7 +18,6 @@ thread_local! {
|
|||
);
|
||||
}
|
||||
|
||||
/// Fetch due monitors from coordinator, run them, post results back.
|
||||
pub async fn fetch_and_run(
|
||||
client: &reqwest::Client,
|
||||
coordinator_url: &str,
|
||||
|
|
@ -27,8 +25,6 @@ pub async fn fetch_and_run(
|
|||
region: &str,
|
||||
in_flight: &Arc<Mutex<HashSet<String>>>,
|
||||
) -> Result<usize> {
|
||||
// Fetch monitors due within the next 2s — nodes receive exact scheduled_at_ms
|
||||
// and sleep until that moment, so all regions fire in tight coordination.
|
||||
let url = if region.is_empty() {
|
||||
format!("{coordinator_url}/internal/due?lookahead_ms=2000")
|
||||
} else {
|
||||
|
|
@ -45,12 +41,10 @@ pub async fn fetch_and_run(
|
|||
let n = monitors.len();
|
||||
if n == 0 { return Ok(0); }
|
||||
|
||||
// Shared read-only strings — clone the Arc instead of allocating per monitor
|
||||
let coordinator_url: Arc<str> = Arc::from(coordinator_url);
|
||||
let token: Arc<str> = Arc::from(token);
|
||||
let region: Arc<str> = Arc::from(region);
|
||||
|
||||
// Spawn all checks — fire and forget, skip if already in-flight
|
||||
let mut spawned = 0usize;
|
||||
for monitor in monitors {
|
||||
{
|
||||
|
|
@ -66,7 +60,6 @@ pub async fn fetch_and_run(
|
|||
let coordinator_url = Arc::clone(&coordinator_url);
|
||||
let token = Arc::clone(&token);
|
||||
let region_owned = Arc::clone(®ion);
|
||||
// run_id: hash(monitor_id, interval_bucket) — same across all regions for this window
|
||||
let run_id_owned = {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
|
@ -77,7 +70,6 @@ pub async fn fetch_and_run(
|
|||
bucket.hash(&mut h);
|
||||
format!("{:016x}", h.finish())
|
||||
};
|
||||
// Convert scheduled_at_ms to an ISO string for storage in the ping
|
||||
let scheduled_at_iso = monitor.scheduled_at_ms.map(|ms| {
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)
|
||||
.map(|dt| dt.to_rfc3339())
|
||||
|
|
@ -85,7 +77,6 @@ pub async fn fetch_and_run(
|
|||
});
|
||||
let in_flight = in_flight.clone();
|
||||
tokio::spawn(async move {
|
||||
// Sleep until the exact scheduled moment — tight multi-region coordination
|
||||
if let Some(ms) = monitor.scheduled_at_ms {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
if ms > now_ms {
|
||||
|
|
@ -94,7 +85,6 @@ pub async fn fetch_and_run(
|
|||
}
|
||||
}
|
||||
let timeout_ms = monitor.timeout_ms.unwrap_or(30000);
|
||||
// Hard deadline: timeout + 5s buffer, so hung checks always resolve
|
||||
let deadline = std::time::Duration::from_millis(timeout_ms + 5000);
|
||||
let result = match tokio::time::timeout(deadline, run_check(&client, &monitor, scheduled_at_iso.clone(), ®ion_owned, &run_id_owned)).await {
|
||||
Ok(r) => r,
|
||||
|
|
@ -113,8 +103,6 @@ pub async fn fetch_and_run(
|
|||
run_id: Some(run_id_owned.clone()),
|
||||
},
|
||||
};
|
||||
// Post result first, then clear in-flight — this prevents the next
|
||||
// poll from picking up the monitor again before the ping is persisted.
|
||||
if let Err(e) = post_result(&client, &coordinator_url, &token, result).await {
|
||||
warn!("Failed to post result for {}: {e}", monitor.id);
|
||||
}
|
||||
|
|
@ -126,10 +114,7 @@ pub async fn fetch_and_run(
|
|||
}
|
||||
|
||||
async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Option<String>, region: &str, run_id: &str) -> PingResult {
|
||||
// Record when the check actually started (used as checked_at in the ping)
|
||||
let checked_at = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// Compute jitter: how late we actually started vs when we were scheduled
|
||||
let jitter_ms: Option<i64> = scheduled_at.as_deref().and_then(|s| {
|
||||
let scheduled = chrono::DateTime::parse_from_rfc3339(s).ok()?;
|
||||
let now = chrono::Utc::now();
|
||||
|
|
@ -138,15 +123,10 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op
|
|||
|
||||
let start = Instant::now();
|
||||
|
||||
// Build request with method, headers, body, timeout
|
||||
let method = monitor.method.as_deref().unwrap_or("GET").to_uppercase();
|
||||
let timeout = std::time::Duration::from_millis(monitor.timeout_ms.unwrap_or(30000));
|
||||
let is_https = monitor.url.starts_with("https://");
|
||||
|
||||
// Run the check in a real OS thread using ureq (blocking, synchronous HTTP).
|
||||
// ureq sets SO_RCVTIMEO/SO_SNDTIMEO at the socket level, which reliably
|
||||
// interrupts even a hanging TLS handshake — unlike async reqwest which
|
||||
// cannot cancel syscall-level blocks via future cancellation.
|
||||
let url = monitor.url.clone();
|
||||
let req_headers = monitor.request_headers.clone();
|
||||
let req_body = monitor.request_body.clone();
|
||||
|
|
@ -190,9 +170,6 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op
|
|||
},
|
||||
Ok((status, headers, body)) => {
|
||||
|
||||
// Start cert expiry check in background — don't block result posting.
|
||||
// We'll use None for cert_expiry_days in query evaluation since it
|
||||
// shouldn't delay the main result by seconds of extra TLS handshake.
|
||||
let cert_handle = if is_https {
|
||||
let cert_url = monitor.url.clone();
|
||||
Some(tokio::spawn(async move {
|
||||
|
|
@ -210,9 +187,6 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op
|
|||
|
||||
let query = &monitor.query;
|
||||
|
||||
// Evaluate query if present (cert_expiry_days not yet available —
|
||||
// $certExpiry queries will use None here; the actual value is
|
||||
// attached to the result once the background check completes)
|
||||
let (up, query_error) = if let Some(q) = query {
|
||||
let response = Response {
|
||||
status,
|
||||
|
|
@ -225,16 +199,13 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op
|
|||
Ok(result) => (result, None),
|
||||
Err(e) => {
|
||||
warn!("Query error for {}: {e}", monitor.id);
|
||||
// Fall back to status-based up/down
|
||||
(status < 400, Some(e.to_string()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Default: up if 2xx/3xx
|
||||
(status < 400, None)
|
||||
};
|
||||
|
||||
// Await the cert check now (it's been running concurrently during query eval)
|
||||
let cert_expiry_days = match cert_handle {
|
||||
Some(h) => h.await.unwrap_or(None),
|
||||
None => None,
|
||||
|
|
@ -265,10 +236,6 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op
|
|||
}
|
||||
}
|
||||
|
||||
/// Run an HTTP check synchronously using ureq.
|
||||
/// ureq applies timeouts at the socket/IO level (not just future cancellation),
|
||||
/// which reliably interrupts hanging TLS handshakes.
|
||||
/// Must be called from a std::thread (not async context).
|
||||
fn run_check_blocking(
|
||||
url: &str,
|
||||
method: &str,
|
||||
|
|
@ -276,7 +243,6 @@ fn run_check_blocking(
|
|||
body: Option<&str>,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<(u16, HashMap<String, String>, String), String> {
|
||||
// Reuse cached root certs (loaded once per OS thread via thread_local)
|
||||
let root_certs = ROOT_CERTS.with(|c| Arc::clone(c));
|
||||
|
||||
let tls = ureq::tls::TlsConfig::builder()
|
||||
|
|
@ -352,8 +318,6 @@ fn run_check_blocking(
|
|||
Ok((status, resp_headers, body_out))
|
||||
}
|
||||
|
||||
/// Check SSL certificate expiry for a given HTTPS URL.
|
||||
/// Returns the number of days until the certificate expires.
|
||||
async fn check_cert_expiry(url: &str) -> Result<Option<i64>> {
|
||||
use rustls::ClientConfig;
|
||||
use rustls::pki_types::ServerName;
|
||||
|
|
@ -361,12 +325,10 @@ async fn check_cert_expiry(url: &str) -> Result<Option<i64>> {
|
|||
use tokio_rustls::TlsConnector;
|
||||
use x509_parser::prelude::*;
|
||||
|
||||
// Parse host and port from URL
|
||||
let url_parsed = reqwest::Url::parse(url)?;
|
||||
let host = url_parsed.host_str().unwrap_or("");
|
||||
let port = url_parsed.port().unwrap_or(443);
|
||||
|
||||
// Build a rustls config that captures certificates
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
|
|
@ -380,7 +342,6 @@ async fn check_cert_expiry(url: &str) -> Result<Option<i64>> {
|
|||
let stream = TcpStream::connect(format!("{host}:{port}")).await?;
|
||||
let tls_stream = connector.connect(server_name, stream).await?;
|
||||
|
||||
// Get peer certificates
|
||||
let (_, conn) = tls_stream.get_ref();
|
||||
let certs = conn.peer_certificates().unwrap_or(&[]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
/// HD address derivation using bitcore-lib family.
|
||||
/// Derives child addresses at m/0/{index} (external receive chain).
|
||||
|
||||
// @ts-ignore
|
||||
import bitcore from "bitcore-lib";
|
||||
import bs58check from "bs58check";
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ const app = new Elysia()
|
|||
.onAfterHandle(({ set }) => {
|
||||
Object.assign(set.headers, SECURITY_HEADERS);
|
||||
})
|
||||
// CORS for web app
|
||||
.onRequest(({ request, set }) => {
|
||||
const origin = request.headers.get("origin") ?? "";
|
||||
if (CORS_ORIGIN.includes(origin)) {
|
||||
|
|
@ -42,13 +41,11 @@ const app = new Elysia()
|
|||
|
||||
console.log(`PingQL Pay running at http://localhost:${app.server?.port}`);
|
||||
|
||||
// Run immediately on startup, then every 30 seconds
|
||||
checkPayments().catch((err) => console.error("Payment check failed:", err));
|
||||
setInterval(() => {
|
||||
checkPayments().catch((err) => console.error("Payment check failed:", err));
|
||||
}, 30_000);
|
||||
|
||||
// Expire pro plans every hour
|
||||
setInterval(() => {
|
||||
expireProPlans().catch((err) => console.error("Plan expiry check failed:", err));
|
||||
}, 60 * 60_000);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
/// Payment monitor: raw SSE + polling fallback.
|
||||
/// States: pending → underpaid → confirming → paid | expired
|
||||
import sql from "./db";
|
||||
import { getAddressInfo, getAddressInfoBulk } from "./freedom";
|
||||
import { COINS, planTier } from "../../shared/plans";
|
||||
|
|
@ -8,7 +6,6 @@ import { generateReceipt } from "./receipt";
|
|||
const SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st";
|
||||
const THRESHOLD = 0.95;
|
||||
|
||||
// ── In-memory lookups for SSE matching ──────────────────────────────
|
||||
let addressMap = new Map<string, any>(); // address → payment
|
||||
let txidToPayment = new Map<string, number>(); // txid → payment.id
|
||||
const seenTxids = new Set<string>();
|
||||
|
|
@ -35,8 +32,6 @@ async function refreshMaps() {
|
|||
for (const t of seenTxids) { if (!newTxid.has(t)) seenTxids.delete(t); }
|
||||
}
|
||||
|
||||
// ── Core logic: one place for all state transitions ─────────────────
|
||||
|
||||
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
|
||||
// from attributing transactions to the wrong payment
|
||||
|
|
@ -52,7 +47,6 @@ async function recordTx(paymentId: number, address: string, txid: string, amount
|
|||
ON CONFLICT (payment_id, txid) DO UPDATE SET confirmed = EXCLUDED.confirmed OR payment_txs.confirmed
|
||||
RETURNING (xmax = 0) as is_new
|
||||
`;
|
||||
// Extend expiry to 24h on new tx
|
||||
if (ins?.is_new) {
|
||||
const exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
await sql`UPDATE payments SET expires_at = ${exp} WHERE id = ${paymentId} AND expires_at < ${exp}`;
|
||||
|
|
@ -100,8 +94,6 @@ async function evaluatePayment(paymentId: number) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── SSE ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleTxEvent(event: any) {
|
||||
const txHash = event.data?.tx?.hash;
|
||||
if (!txHash || seenTxids.has(txHash)) return;
|
||||
|
|
@ -179,8 +171,6 @@ async function connectSSE(url: string) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Polling fallback ────────────────────────────────────────────────
|
||||
|
||||
export async function checkPayments() {
|
||||
await sql`
|
||||
UPDATE payments SET status = 'expired'
|
||||
|
|
@ -202,7 +192,6 @@ export async function checkPayments() {
|
|||
if (!info) try { info = await getAddressInfo(payment.address); } catch { continue; }
|
||||
if (!info || info.error) continue;
|
||||
|
||||
// Sync txs from address API
|
||||
for (const tx of info.in ?? []) {
|
||||
if (!tx.txid) continue;
|
||||
await recordTx(payment.id, payment.address, tx.txid, Number(tx.amount ?? 0), tx.block != null);
|
||||
|
|
@ -214,21 +203,16 @@ export async function checkPayments() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
export function watchPayment(payment: any) {
|
||||
addressMap.set(payment.address, payment);
|
||||
}
|
||||
|
||||
// ── Plan stacking logic (pure, testable) ─────────────────────────
|
||||
|
||||
interface StackEntry { plan: string; remaining_days: number | null }
|
||||
interface AccountState { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] }
|
||||
interface AccountUpdate { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] }
|
||||
|
||||
export function insertIntoStack(stack: StackEntry[], entry: StackEntry): StackEntry[] {
|
||||
const result = stack.slice();
|
||||
// Merge if same plan already exists
|
||||
const existing = result.findIndex(e => e.plan === entry.plan);
|
||||
if (existing !== -1) {
|
||||
const old = result[existing];
|
||||
|
|
@ -237,11 +221,9 @@ export function insertIntoStack(stack: StackEntry[], entry: StackEntry): StackEn
|
|||
} else {
|
||||
result[existing] = { plan: entry.plan, remaining_days: old.remaining_days + entry.remaining_days };
|
||||
}
|
||||
// Re-sort after merge
|
||||
result.sort((a, b) => planTier(b.plan) - planTier(a.plan));
|
||||
return result;
|
||||
}
|
||||
// Insert at correct position (tier descending)
|
||||
const tier = planTier(entry.plan);
|
||||
let i = 0;
|
||||
while (i < result.length && planTier(result[i].plan) >= tier) i++;
|
||||
|
|
@ -262,19 +244,16 @@ export function computeApplyPlan(
|
|||
const currentIsActive = acc.plan === "lifetime"
|
||||
|| (acc.plan !== "free" && currentExpiry && currentExpiry > now);
|
||||
|
||||
// No active plan worth saving — just activate the new one
|
||||
if (!currentIsActive || acc.plan === "free") {
|
||||
const expiresAt = newDays != null ? new Date(now.getTime() + newDays * 86400000) : null;
|
||||
return { plan: newPlan, plan_expires_at: expiresAt, plan_stack: stack };
|
||||
}
|
||||
|
||||
// Same plan renewal — extend from current expiry
|
||||
if (newPlan === acc.plan && newDays != null && currentExpiry) {
|
||||
const extended = new Date(currentExpiry.getTime() + newDays * 86400000);
|
||||
return { plan: acc.plan, plan_expires_at: extended, plan_stack: stack };
|
||||
}
|
||||
|
||||
// Upgrade: new plan takes over, current gets frozen onto stack
|
||||
if (planTier(newPlan) > planTier(acc.plan)) {
|
||||
const remainingDays = acc.plan === "lifetime"
|
||||
? null
|
||||
|
|
@ -284,38 +263,30 @@ export function computeApplyPlan(
|
|||
return { plan: newPlan, plan_expires_at: expiresAt, plan_stack: newStack };
|
||||
}
|
||||
|
||||
// Downgrade/side-grade: purchased plan goes onto stack, current stays active
|
||||
const newStack = insertIntoStack(stack, { plan: newPlan, remaining_days: newDays });
|
||||
return { plan: acc.plan, plan_expires_at: currentExpiry, plan_stack: newStack };
|
||||
}
|
||||
|
||||
export function computeExpiry(acc: AccountState, now: Date): AccountUpdate | null {
|
||||
// Only expire timed pro plans
|
||||
if (!["pro", "pro2x", "pro4x"].includes(acc.plan)) return null;
|
||||
if (!acc.plan_expires_at || new Date(acc.plan_expires_at) >= now) return null;
|
||||
|
||||
const stack = (acc.plan_stack || []).slice();
|
||||
|
||||
// Pop layers until we find a valid one or exhaust the stack
|
||||
while (stack.length > 0) {
|
||||
const next = stack.shift()!;
|
||||
if (next.remaining_days === null) {
|
||||
// Permanent plan (lifetime)
|
||||
return { plan: next.plan, plan_expires_at: null, plan_stack: stack };
|
||||
}
|
||||
if (next.remaining_days > 0) {
|
||||
const expiresAt = new Date(now.getTime() + next.remaining_days * 86400000);
|
||||
return { plan: next.plan, plan_expires_at: expiresAt, plan_stack: stack };
|
||||
}
|
||||
// 0 days — skip, try next
|
||||
}
|
||||
|
||||
// Stack exhausted — fall to free
|
||||
return { plan: "free", plan_expires_at: null, plan_stack: [] };
|
||||
}
|
||||
|
||||
// ── DB wrappers ──────────────────────────────────────────────────
|
||||
|
||||
async function applyPlan(payment: any) {
|
||||
try {
|
||||
await sql.begin(async (tx) => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ export async function generateReceipt(paymentId: number): Promise<string> {
|
|||
const [payment] = await sql`SELECT * FROM payments WHERE id = ${paymentId}`;
|
||||
if (!payment) throw new Error("Payment not found");
|
||||
|
||||
// Already locked — return as-is
|
||||
if (payment.receipt_html) return payment.receipt_html;
|
||||
|
||||
const coinInfo = COINS[payment.coin];
|
||||
|
|
@ -126,7 +125,6 @@ export async function generateReceipt(paymentId: number): Promise<string> {
|
|||
</body>
|
||||
</html>`;
|
||||
|
||||
// Minify and lock it
|
||||
const minified = html.replace(/\n\s*/g, "").replace(/>\s+</g, "><").replace(/\s{2,}/g, " ");
|
||||
await sql`UPDATE payments SET receipt_html = ${minified} WHERE id = ${paymentId}`;
|
||||
return minified;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ function requireAuth(app: Elysia) {
|
|||
|
||||
export const routes = new Elysia()
|
||||
|
||||
// Public: available coins and rates
|
||||
.get("/coins", async () => {
|
||||
const [available, rates] = await Promise.all([getAvailableCoins(), getExchangeRates()]);
|
||||
const coins = Object.entries(COINS)
|
||||
|
|
@ -41,13 +40,11 @@ export const routes = new Elysia()
|
|||
|
||||
.use(requireAuth)
|
||||
|
||||
// Create a checkout
|
||||
.post("/checkout", async ({ accountId, keyId, body, set }) => {
|
||||
if (keyId) { set.status = 403; return { error: "Sub-keys cannot create checkouts" }; }
|
||||
|
||||
const { plan, months, coin } = body;
|
||||
|
||||
// Validate plan — block duplicate lifetime
|
||||
if (plan === "lifetime") {
|
||||
const [acc] = await sql`SELECT plan, plan_stack FROM accounts WHERE id = ${accountId}`;
|
||||
const stack = typeof acc.plan_stack === "string" ? JSON.parse(acc.plan_stack) : (acc.plan_stack || []);
|
||||
|
|
@ -55,17 +52,14 @@ export const routes = new Elysia()
|
|||
if (hasLifetime) { set.status = 400; return { error: "You already have a lifetime plan" }; }
|
||||
}
|
||||
|
||||
// Validate coin
|
||||
if (!COINS[coin]) { set.status = 400; return { error: `Unknown coin: ${coin}` }; }
|
||||
const available = await getAvailableCoins();
|
||||
if (!available.includes(coin)) { set.status = 400; return { error: `${coin} is temporarily unavailable` }; }
|
||||
|
||||
// Calculate amount
|
||||
const planDef = PLANS[plan];
|
||||
if (!planDef) { set.status = 400; return { error: `Unknown plan: ${plan}` }; }
|
||||
let amountUsd = planDef.priceUsd ?? (planDef.monthlyUsd! * (months ?? 1));
|
||||
|
||||
// Lifetime discount: credit up to 50% of lifetime price from previous payments
|
||||
if (plan === "lifetime" && planDef.priceUsd) {
|
||||
const [{ total }] = await sql`SELECT COALESCE(SUM(amount_usd), 0)::numeric as total FROM payments WHERE account_id = ${accountId} AND status = 'paid'`;
|
||||
const credit = Math.min(Number(total), planDef.priceUsd * 0.75);
|
||||
|
|
@ -75,15 +69,12 @@ export const routes = new Elysia()
|
|||
const rate = rates[coin];
|
||||
if (!rate) { set.status = 500; return { error: "Could not fetch exchange rate" }; }
|
||||
|
||||
// Crypto amount with 8 decimal precision
|
||||
const amountCrypto = (amountUsd / rate).toFixed(8);
|
||||
|
||||
// Get next derivation index for this coin
|
||||
const [{ next_index }] = await sql`
|
||||
SELECT COALESCE(MAX(derivation_index), -1) + 1 as next_index FROM payments WHERE coin = ${coin}
|
||||
`;
|
||||
|
||||
// Derive address
|
||||
let address: string;
|
||||
try {
|
||||
address = derive(coin, next_index);
|
||||
|
|
@ -100,10 +91,8 @@ export const routes = new Elysia()
|
|||
RETURNING *
|
||||
`;
|
||||
|
||||
// Start watching this address immediately via SSE
|
||||
watchPayment(payment);
|
||||
|
||||
// Build payment URI for QR code
|
||||
const coinInfo = COINS[coin];
|
||||
const uri = `${coinInfo.uri}:${address.replace(/^.*:/, '')}?amount=${amountCrypto}`;
|
||||
|
||||
|
|
@ -133,7 +122,6 @@ export const routes = new Elysia()
|
|||
}),
|
||||
})
|
||||
|
||||
// Get checkout details
|
||||
.get("/checkout/:id", async ({ accountId, params, set }) => {
|
||||
const [payment] = await sql`
|
||||
SELECT * FROM payments WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
|
|
@ -178,7 +166,6 @@ export const routes = new Elysia()
|
|||
};
|
||||
})
|
||||
|
||||
// Serve locked receipt for a paid invoice
|
||||
.get("/checkout/:id/receipt", async ({ accountId, params, set }) => {
|
||||
const [payment] = await sql`
|
||||
SELECT * FROM payments WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
export async function migrate(sql: any) {
|
||||
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
|
||||
|
||||
// ── Core tables ─────────────────────────────────────────────────
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
|
@ -61,7 +60,6 @@ export async function migrate(sql: any) {
|
|||
)
|
||||
`;
|
||||
|
||||
// ── Column migrations ──────────────────────────────────────────
|
||||
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ`;
|
||||
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS jitter_ms INTEGER`;
|
||||
await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS regions TEXT[] NOT NULL DEFAULT '{}'`;
|
||||
|
|
@ -71,11 +69,9 @@ export async function migrate(sql: any) {
|
|||
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`;
|
||||
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_stack JSONB NOT NULL DEFAULT '[]'`;
|
||||
|
||||
// ── Indexes ────────────────────────────────────────────────────
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
|
||||
|
||||
// ── Payment tables ─────────────────────────────────────────────
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// ── Types ─────────────────────────────────────────────────────────
|
||||
export type Plan = "free" | "pro" | "pro2x" | "pro4x" | "lifetime";
|
||||
|
||||
export interface PlanLimits {
|
||||
|
|
@ -7,7 +6,6 @@ export interface PlanLimits {
|
|||
maxRegions: number;
|
||||
}
|
||||
|
||||
// ── Limits ────────────────────────────────────────────────────────
|
||||
const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||
free: { maxMonitors: 10, minIntervalS: 30, maxRegions: 1 },
|
||||
pro: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 },
|
||||
|
|
@ -20,12 +18,10 @@ export function getPlanLimits(plan: string): PlanLimits {
|
|||
return PLAN_LIMITS[plan as Plan] || PLAN_LIMITS.free;
|
||||
}
|
||||
|
||||
// ── Display ───────────────────────────────────────────────────────
|
||||
export const PLAN_LABELS: Record<string, string> = {
|
||||
free: "Free", pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime",
|
||||
};
|
||||
|
||||
// ── Pricing ───────────────────────────────────────────────────────
|
||||
export const PRO_MONTHLY_USD = 12;
|
||||
export const LIFETIME_USD = 140;
|
||||
|
||||
|
|
@ -45,7 +41,6 @@ export const COINS: Record<string, { label: string; ticker: string; confirmation
|
|||
xec: { label: "eCash", ticker: "XEC", confirmations: 0, uri: "ecash" },
|
||||
};
|
||||
|
||||
// ── Tier ranking (for plan stacking) ──────────────────────────────
|
||||
const PLAN_RANK: Record<string, number> = {
|
||||
free: 0, pro: 1, lifetime: 1, pro2x: 2, pro4x: 3,
|
||||
};
|
||||
|
|
@ -54,7 +49,6 @@ export function planTier(plan: string): number {
|
|||
return PLAN_RANK[plan] ?? 0;
|
||||
}
|
||||
|
||||
// ── Regions ───────────────────────────────────────────────────────
|
||||
export const REGION_COLORS: Record<string, string> = {
|
||||
"eu-central": "#3b82f6",
|
||||
"us-west": "#f59e0b",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
// PingQL Dashboard — shared utilities
|
||||
// Auth is now cookie-based. No localStorage needed.
|
||||
|
||||
const API_BASE = 'https://api.pingql.com';
|
||||
|
||||
function logout() {
|
||||
window.location.href = '/dashboard/logout';
|
||||
}
|
||||
|
||||
// requireAuth is a no-op now — server redirects to /dashboard if not authed
|
||||
function requireAuth() { return true; }
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
|
|
@ -29,7 +25,6 @@ async function api(path, opts = {}) {
|
|||
return data;
|
||||
}
|
||||
|
||||
// Format relative time
|
||||
function formatAgo(ms) {
|
||||
const s = Math.ceil(ms / 1000) || 1;
|
||||
if (s < 60) return `${s}s ago`;
|
||||
|
|
@ -44,7 +39,6 @@ function timeAgo(date) {
|
|||
return `<span class="timestamp" data-ts="${ts}">${formatAgo(elapsed)}</span>`;
|
||||
}
|
||||
|
||||
// Tick all live timestamps
|
||||
setInterval(() => {
|
||||
document.querySelectorAll('.timestamp[data-ts]').forEach(el => {
|
||||
const elapsed = Date.now() - Number(el.dataset.ts);
|
||||
|
|
@ -58,9 +52,6 @@ function escapeHtml(str) {
|
|||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Subscribe to live ping updates for the whole account via a single SSE stream.
|
||||
// onPing receives each ping object (includes monitor_id).
|
||||
// Returns an AbortController — call .abort() to close.
|
||||
function watchAccount(onPing) {
|
||||
const ac = new AbortController();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
// PingQL Visual Query Builder
|
||||
|
||||
const FIELDS = [
|
||||
{ name: 'status', label: 'Status Code', type: 'number', operators: ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in'] },
|
||||
{ name: 'body', label: 'Response Body', type: 'string', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex', '$exists'] },
|
||||
|
|
@ -61,7 +59,6 @@ class QueryBuilder {
|
|||
return { [headerField]: { [operator]: parsedVal } };
|
||||
}
|
||||
if (operator === '$exists') return { [field]: { '$exists': parsedVal } };
|
||||
// Simple shorthand for $eq on basic fields
|
||||
if (operator === '$eq') return { [field]: parsedVal };
|
||||
return { [field]: { [operator]: parsedVal } };
|
||||
}
|
||||
|
|
@ -93,7 +90,6 @@ class QueryBuilder {
|
|||
return;
|
||||
}
|
||||
|
||||
// Strip $consider before parsing rules
|
||||
this.consider = query.$consider === 'down' ? 'down' : 'up';
|
||||
const q = Object.fromEntries(Object.entries(query).filter(([k]) => k !== '$consider'));
|
||||
|
||||
|
|
@ -201,7 +197,6 @@ class QueryBuilder {
|
|||
</div>
|
||||
`;
|
||||
|
||||
// Bind events
|
||||
this.container.querySelector('#qb-consider').addEventListener('change', (e) => {
|
||||
this.consider = e.target.value;
|
||||
this.render();
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ export const account = new Elysia({ prefix: "/account" })
|
|||
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
||||
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
||||
|
||||
// Form submission → redirect to welcome page showing the key
|
||||
if ((body as any)._form) return redir(`/dashboard/welcome?key=${encodeURIComponent(key)}`);
|
||||
|
||||
return { key, email_registered: !!emailHash };
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ function latencyChartSSR(pings: any[]): string {
|
|||
const pad = { top: 8, bottom: 8 };
|
||||
const cH = h - pad.top - pad.bottom;
|
||||
|
||||
// Build ordered list of unique runs, evenly spaced (matches canvas)
|
||||
const runTimes: Record<string, number[]> = {};
|
||||
for (const p of data) {
|
||||
const rid = p.run_id || p.checked_at;
|
||||
|
|
@ -49,7 +48,6 @@ function latencyChartSSR(pings: any[]): string {
|
|||
runs.forEach((rid, i) => { runIndex[rid] = i; });
|
||||
const maxIdx = Math.max(runs.length - 1, 1);
|
||||
|
||||
// Group by region
|
||||
const byRegion: Record<string, any[]> = {};
|
||||
for (const p of data) {
|
||||
const key = p.region || '__none__';
|
||||
|
|
@ -71,7 +69,6 @@ function latencyChartSSR(pings: any[]): string {
|
|||
return pad.top + cH - ((v - yMin) / yRange) * cH;
|
||||
}
|
||||
|
||||
// Grid lines
|
||||
let grid = '';
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = (pad.top + (cH / 4) * i).toFixed(1);
|
||||
|
|
@ -152,15 +149,12 @@ const dashDir = resolve(import.meta.dir, "../dashboard");
|
|||
export const dashboard = new Elysia()
|
||||
.get("/", () => html("landing", {}))
|
||||
|
||||
// Shared assets
|
||||
.get("/favicon.svg", () => new Response(Bun.file(`${dashDir}/favicon.svg`), { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" } }))
|
||||
.get("/assets/tailwind.css", () => new Response(Bun.file(`${dashDir}/tailwind.css`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
|
||||
.get("/assets/app.css", () => new Response(Bun.file(`${dashDir}/app.css`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
|
||||
.get("/assets/app.js", () => new Response(Bun.file(`${dashDir}/app.js`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
|
||||
// Dashboard-only assets
|
||||
.get("/dashboard/query-builder.js", () => new Response(Bun.file(`${dashDir}/query-builder.js`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
|
||||
|
||||
// Login page
|
||||
.get("/dashboard", async ({ cookie }) => {
|
||||
const key = cookie?.pingql_key?.value;
|
||||
if (key) {
|
||||
|
|
@ -172,21 +166,17 @@ export const dashboard = new Elysia()
|
|||
return html("login", {});
|
||||
})
|
||||
|
||||
// Logout
|
||||
.get("/dashboard/logout", ({ cookie }) => {
|
||||
// Explicitly expire with same domain/path so browser actually clears it
|
||||
cookie.pingql_key?.set({ value: "", maxAge: 0, path: "/", domain: process.env.COOKIE_DOMAIN ?? ".pingql.com", secure: process.env.NODE_ENV !== "development", sameSite: "lax" });
|
||||
return redirect("/dashboard");
|
||||
})
|
||||
|
||||
// Welcome page — shows new account key after registration (no-JS flow)
|
||||
.get("/dashboard/welcome", async ({ cookie, headers, query }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
return html("welcome", { key: query.key || cookie?.pingql_key?.value || "" });
|
||||
})
|
||||
|
||||
// Home — SSR monitor list
|
||||
.get("/dashboard/home", async ({ cookie, headers }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
const accountId = resolved?.accountId ?? null;
|
||||
|
|
@ -202,7 +192,6 @@ export const dashboard = new Elysia()
|
|||
ORDER BY m.created_at DESC
|
||||
`;
|
||||
|
||||
// Fetch last 20 pings per monitor for sparklines
|
||||
const monitorIds = monitors.map((m: any) => m.id);
|
||||
let pingsMap: Record<string, any[]> = {};
|
||||
if (monitorIds.length > 0) {
|
||||
|
|
@ -226,7 +215,6 @@ export const dashboard = new Elysia()
|
|||
return html("home", { nav: "monitors", monitors: monitorsWithPings, accountId });
|
||||
})
|
||||
|
||||
// Settings — SSR account info
|
||||
.get("/dashboard/settings", async ({ cookie, headers }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
const accountId = resolved?.accountId ?? null;
|
||||
|
|
@ -239,7 +227,6 @@ export const dashboard = new Elysia()
|
|||
const loginKey = isSubKey ? null : (cookie?.pingql_key?.value ?? null);
|
||||
const [{ count: monitorCount }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`;
|
||||
|
||||
// Fetch paid + active (non-expired) invoices
|
||||
let invoices: any[] = [];
|
||||
try {
|
||||
invoices = await sql`
|
||||
|
|
@ -255,7 +242,6 @@ export const dashboard = new Elysia()
|
|||
return html("settings", { nav: "settings", account: acc, apiKeys, accountId, loginKey, isSubKey, monitorCount, invoices });
|
||||
})
|
||||
|
||||
// Checkout — upgrade plan
|
||||
.get("/dashboard/checkout", async ({ cookie, headers }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
|
|
@ -264,10 +250,8 @@ export const dashboard = new Elysia()
|
|||
const hasLifetime = acc.plan === "lifetime" || stack.some((s: any) => s.plan === "lifetime");
|
||||
if (acc.plan === "lifetime" && stack.length === 0) return redirect("/dashboard/settings");
|
||||
|
||||
// Total spent on paid invoices (for lifetime discount)
|
||||
const [{ total_spent }] = await sql`SELECT COALESCE(SUM(amount_usd), 0)::numeric as total_spent FROM payments WHERE account_id = ${resolved.accountId} AND status = 'paid'`;
|
||||
|
||||
// Fetch coins server-side for no-JS rendering
|
||||
const payApi = process.env.PAY_API || "https://pay.pingql.com";
|
||||
let coins: any[] = [];
|
||||
try {
|
||||
|
|
@ -279,7 +263,6 @@ export const dashboard = new Elysia()
|
|||
return html("checkout", { nav: "settings", account: acc, payApi, invoiceId: null, coins, invoice: null, totalSpent: Number(total_spent), hasLifetime });
|
||||
})
|
||||
|
||||
// Existing invoice by ID — SSR the payment status
|
||||
.get("/dashboard/checkout/:id", async ({ cookie, headers, params }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
|
|
@ -302,7 +285,6 @@ export const dashboard = new Elysia()
|
|||
return html("checkout", { nav: "settings", account: acc, payApi, invoiceId: params.id, coins, invoice });
|
||||
})
|
||||
|
||||
// Receipt (proxy to pay service, serves HTML directly)
|
||||
.get("/dashboard/checkout/:id/receipt", async ({ cookie, headers, params, set }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
|
|
@ -326,7 +308,6 @@ export const dashboard = new Elysia()
|
|||
}
|
||||
})
|
||||
|
||||
// Create checkout via form POST (no-JS)
|
||||
.post("/dashboard/checkout", async ({ cookie, headers, body }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
|
|
@ -352,7 +333,6 @@ export const dashboard = new Elysia()
|
|||
return redirect("/dashboard/checkout");
|
||||
})
|
||||
|
||||
// New monitor
|
||||
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
const accountId = resolved?.accountId ?? null;
|
||||
|
|
@ -361,7 +341,6 @@ export const dashboard = new Elysia()
|
|||
return html("new", { nav: "monitors", plan: resolved?.plan || "free" });
|
||||
})
|
||||
|
||||
// Home data endpoint for polling (monitor list change detection)
|
||||
.get("/dashboard/home/data", async ({ cookie, headers }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
const accountId = resolved?.accountId ?? null;
|
||||
|
|
@ -376,7 +355,6 @@ export const dashboard = new Elysia()
|
|||
});
|
||||
})
|
||||
|
||||
// Monitor detail — SSR with initial data
|
||||
.get("/dashboard/monitors/:id", async ({ cookie, headers, params }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
const accountId = resolved?.accountId ?? null;
|
||||
|
|
@ -396,7 +374,6 @@ export const dashboard = new Elysia()
|
|||
return html("detail", { nav: "monitors", monitor, pings, plan: resolved?.plan || "free" });
|
||||
})
|
||||
|
||||
// Chart partial endpoint — returns just the latency chart SVG
|
||||
.get("/dashboard/monitors/:id/chart", async ({ cookie, headers, params }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
const accountId = resolved?.accountId ?? null;
|
||||
|
|
@ -418,7 +395,6 @@ export const dashboard = new Elysia()
|
|||
});
|
||||
})
|
||||
|
||||
// Sparkline partial — returns just the SVG for one monitor
|
||||
.get("/dashboard/monitors/:id/sparkline", async ({ cookie, headers, params }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
const accountId = resolved?.accountId ?? null;
|
||||
|
|
@ -440,9 +416,6 @@ export const dashboard = new Elysia()
|
|||
});
|
||||
})
|
||||
|
||||
// ── Form-based monitor actions (no-JS support) ─────────────────────
|
||||
|
||||
// Create monitor via form POST
|
||||
.post("/dashboard/monitors/new", async ({ cookie, headers, body, set }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
|
|
@ -451,7 +424,6 @@ export const dashboard = new Elysia()
|
|||
const regions = Array.isArray(b.regions) ? b.regions : (b.regions ? [b.regions] : []);
|
||||
const query = b.query ? (typeof b.query === "string" ? JSON.parse(b.query) : b.query) : undefined;
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
// Collect header_key[]/header_value[] pairs
|
||||
const hKeys = Array.isArray(b.header_key) ? b.header_key : (b.header_key ? [b.header_key] : []);
|
||||
const hVals = Array.isArray(b.header_value) ? b.header_value : (b.header_value ? [b.header_value] : []);
|
||||
for (let i = 0; i < hKeys.length; i++) {
|
||||
|
|
@ -481,7 +453,6 @@ export const dashboard = new Elysia()
|
|||
return redirect("/dashboard/home");
|
||||
})
|
||||
|
||||
// Edit monitor via form POST
|
||||
.post("/dashboard/monitors/:id/edit", async ({ cookie, headers, params, body }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
|
|
@ -519,7 +490,6 @@ export const dashboard = new Elysia()
|
|||
return redirect(`/dashboard/monitors/${params.id}`);
|
||||
})
|
||||
|
||||
// Delete monitor via form POST
|
||||
.post("/dashboard/monitors/:id/delete", async ({ cookie, headers, params }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
|
|
@ -534,7 +504,6 @@ export const dashboard = new Elysia()
|
|||
return redirect("/dashboard/home");
|
||||
})
|
||||
|
||||
// Toggle monitor via form POST
|
||||
.post("/dashboard/monitors/:id/toggle", async ({ cookie, headers, params }) => {
|
||||
const resolved = await getAccountId(cookie, headers);
|
||||
if (!resolved?.accountId) return redirect("/dashboard");
|
||||
|
|
@ -549,7 +518,6 @@ export const dashboard = new Elysia()
|
|||
return redirect(`/dashboard/monitors/${params.id}`);
|
||||
})
|
||||
|
||||
// Docs
|
||||
.get("/docs", () => html("docs", {}))
|
||||
.get("/privacy", () => html("privacy", {}))
|
||||
.get("/terms", () => html("tos", {}));
|
||||
|
|
|
|||
|
|
@ -14,14 +14,10 @@ export function sparkline(values: number[], width = 120, height = 32, color = '#
|
|||
|
||||
import { REGION_COLORS } from "../../../shared/plans";
|
||||
|
||||
// Pick the best region: the one with the lowest avg latency across its last 3 pings.
|
||||
// Only considers regions that have at least one ping in the most recent 3 pings overall,
|
||||
// so stale regions that haven't reported recently are excluded.
|
||||
export function pickBestRegion(pings: Array<{latency_ms?: number|null, region?: string|null}>): { region: string, values: number[], latest: number | null } {
|
||||
const withLatency = pings.filter(p => p.latency_ms != null);
|
||||
if (!withLatency.length) return { region: '__none__', values: [], latest: null };
|
||||
|
||||
// Group all pings by region
|
||||
const byRegion: Record<string, number[]> = {};
|
||||
for (const p of withLatency) {
|
||||
const key = p.region || '__none__';
|
||||
|
|
@ -29,7 +25,6 @@ export function pickBestRegion(pings: Array<{latency_ms?: number|null, region?:
|
|||
byRegion[key].push(p.latency_ms!);
|
||||
}
|
||||
|
||||
// Only consider regions that appear in the 3 most recent pings
|
||||
const recentRegions = new Set(
|
||||
withLatency.slice(-3).map(p => p.region || '__none__')
|
||||
);
|
||||
|
|
@ -47,7 +42,6 @@ export function pickBestRegion(pings: Array<{latency_ms?: number|null, region?:
|
|||
return { region: bestRegion, values, latest: values.length ? values[values.length - 1] : null };
|
||||
}
|
||||
|
||||
// Given pings with region+latency, pick the best region and render its sparkline.
|
||||
export function sparklineFromPings(pings: Array<{latency_ms?: number|null, region?: string|null}>, width = 120, height = 32): string {
|
||||
const { region, values } = pickBestRegion(pings);
|
||||
if (!values.length) return '';
|
||||
|
|
|
|||
Loading…
Reference in New Issue