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(ingest)
|
||||||
.use(internal);
|
.use(internal);
|
||||||
|
|
||||||
// Wrap Elysia with Bun.serve to guarantee CORS + security headers on every response
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: 3001,
|
port: 3001,
|
||||||
async fetch(req) {
|
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 { Elysia } from "elysia";
|
||||||
import sql from "../db";
|
import sql from "../db";
|
||||||
import { safeTokenCompare } from "../../../shared/auth";
|
import { safeTokenCompare } from "../../../shared/auth";
|
||||||
|
|
@ -10,7 +7,6 @@ export async function pruneOldPings(retentionDays = 90) {
|
||||||
return result.count;
|
return result.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run retention cleanup every hour
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
||||||
pruneOldPings(days).catch((err) => console.error("Retention cleanup failed:", err));
|
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 }) => {
|
.get("/due", async ({ request }) => {
|
||||||
const params = new URL(request.url).searchParams;
|
const params = new URL(request.url).searchParams;
|
||||||
const region = params.get('region') || undefined;
|
const region = params.get('region') || undefined;
|
||||||
|
|
@ -66,7 +59,6 @@ export const internal = new Elysia({ prefix: "/internal", detail: { hide: true }
|
||||||
return monitors;
|
return monitors;
|
||||||
})
|
})
|
||||||
|
|
||||||
// Manual retention cleanup trigger
|
|
||||||
.post("/prune", async () => {
|
.post("/prune", async () => {
|
||||||
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
||||||
const deleted = await pruneOldPings(days);
|
const deleted = await pruneOldPings(days);
|
||||||
|
|
|
||||||
|
|
@ -19,37 +19,31 @@ const MonitorBody = t.Object({
|
||||||
export const monitors = new Elysia({ prefix: "/monitors" })
|
export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
.use(requireAuth)
|
.use(requireAuth)
|
||||||
|
|
||||||
// List monitors
|
|
||||||
.get("/", async ({ accountId }) => {
|
.get("/", async ({ accountId }) => {
|
||||||
return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
return sql`SELECT * FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC`;
|
||||||
}, { detail: { summary: "List monitors", tags: ["monitors"] } })
|
}, { detail: { summary: "List monitors", tags: ["monitors"] } })
|
||||||
|
|
||||||
// Create monitor
|
|
||||||
.post("/", async ({ accountId, plan, body, set }) => {
|
.post("/", async ({ accountId, plan, body, set }) => {
|
||||||
const limits = getPlanLimits(plan);
|
const limits = getPlanLimits(plan);
|
||||||
|
|
||||||
// Enforce monitor count limit
|
|
||||||
const [{ count }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`;
|
const [{ count }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`;
|
||||||
if (count >= limits.maxMonitors) {
|
if (count >= limits.maxMonitors) {
|
||||||
set.status = 403;
|
set.status = 403;
|
||||||
return { error: `Plan limit reached: ${limits.maxMonitors} monitors (${plan}). Upgrade to create more.` };
|
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;
|
const interval = body.interval_s ?? limits.minIntervalS;
|
||||||
if (interval < limits.minIntervalS) {
|
if (interval < limits.minIntervalS) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
|
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce region limit for plan
|
|
||||||
const regions = body.regions ?? [];
|
const regions = body.regions ?? [];
|
||||||
if (regions.length > limits.maxRegions) {
|
if (regions.length > limits.maxRegions) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` };
|
return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF protection
|
|
||||||
const ssrfError = await validateMonitorUrl(body.url);
|
const ssrfError = await validateMonitorUrl(body.url);
|
||||||
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
|
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
|
||||||
const [monitor] = await sql`
|
const [monitor] = await sql`
|
||||||
|
|
@ -69,7 +63,6 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
return monitor;
|
return monitor;
|
||||||
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
|
}, { body: MonitorBody, detail: { summary: "Create monitor", tags: ["monitors"] } })
|
||||||
|
|
||||||
// Get monitor + recent status
|
|
||||||
.get("/:id", async ({ accountId, params, set }) => {
|
.get("/:id", async ({ accountId, params, set }) => {
|
||||||
const [monitor] = await sql`
|
const [monitor] = await sql`
|
||||||
SELECT * FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
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 };
|
return { ...monitor, results };
|
||||||
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
|
}, { detail: { summary: "Get monitor with results", tags: ["monitors"] } })
|
||||||
|
|
||||||
// Update monitor
|
|
||||||
.patch("/:id", async ({ accountId, plan, params, body, set }) => {
|
.patch("/:id", async ({ accountId, plan, params, body, set }) => {
|
||||||
const limits = getPlanLimits(plan);
|
const limits = getPlanLimits(plan);
|
||||||
|
|
||||||
// Enforce minimum interval for plan
|
|
||||||
if (body.interval_s != null && body.interval_s < limits.minIntervalS) {
|
if (body.interval_s != null && body.interval_s < limits.minIntervalS) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
|
return { error: `Minimum interval for ${plan} plan is ${limits.minIntervalS}s` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce region limit for plan
|
|
||||||
if (body.regions && body.regions.length > limits.maxRegions) {
|
if (body.regions && body.regions.length > limits.maxRegions) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` };
|
return { error: `Free plan allows ${limits.maxRegions} region per monitor. Upgrade to use multi-region.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF protection on URL change
|
|
||||||
if (body.url) {
|
if (body.url) {
|
||||||
const ssrfError = await validateMonitorUrl(body.url);
|
const ssrfError = await validateMonitorUrl(body.url);
|
||||||
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
|
if (ssrfError) { set.status = 400; return { error: ssrfError }; }
|
||||||
|
|
@ -123,7 +112,6 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
return monitor;
|
return monitor;
|
||||||
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
}, { body: t.Partial(MonitorBody), detail: { summary: "Update monitor", tags: ["monitors"] } })
|
||||||
|
|
||||||
// Delete monitor
|
|
||||||
.delete("/:id", async ({ accountId, params, set }) => {
|
.delete("/:id", async ({ accountId, params, set }) => {
|
||||||
const [deleted] = await sql`
|
const [deleted] = await sql`
|
||||||
DELETE FROM monitors WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
|
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 };
|
return { deleted: true };
|
||||||
}, { detail: { summary: "Delete monitor", tags: ["monitors"] } })
|
}, { detail: { summary: "Delete monitor", tags: ["monitors"] } })
|
||||||
|
|
||||||
// Toggle enabled
|
|
||||||
.post("/:id/toggle", async ({ accountId, params, set }) => {
|
.post("/:id/toggle", async ({ accountId, params, set }) => {
|
||||||
const [monitor] = await sql`
|
const [monitor] = await sql`
|
||||||
UPDATE monitors SET enabled = NOT enabled
|
UPDATE monitors SET enabled = NOT enabled
|
||||||
|
|
@ -143,7 +130,6 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
return monitor;
|
return monitor;
|
||||||
}, { detail: { summary: "Toggle monitor on/off", tags: ["monitors"] } })
|
}, { detail: { summary: "Toggle monitor on/off", tags: ["monitors"] } })
|
||||||
|
|
||||||
// Check history
|
|
||||||
.get("/:id/pings", async ({ accountId, params, query, set }) => {
|
.get("/:id/pings", async ({ accountId, params, query, set }) => {
|
||||||
const [monitor] = await sql`
|
const [monitor] = await sql`
|
||||||
SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
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") {
|
if (filter === "events") {
|
||||||
// State changes: pings where `up` differs from the previous ping's `up` for this monitor
|
|
||||||
return sql`
|
return sql`
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT *, LAG(up) OVER (ORDER BY checked_at) AS prev_up
|
SELECT *, LAG(up) OVER (ORDER BY checked_at) AS prev_up
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import sql from "../db";
|
||||||
import { resolveKey } from "./auth";
|
import { resolveKey } from "./auth";
|
||||||
import { extractAuthKey, safeTokenCompare } from "../../../shared/auth";
|
import { extractAuthKey, safeTokenCompare } from "../../../shared/auth";
|
||||||
|
|
||||||
// ── SSE bus ───────────────────────────────────────────────────────────────────
|
|
||||||
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
||||||
const bus = new Map<string, Set<SSEController>>(); // keyed by accountId
|
const bus = new Map<string, Set<SSEController>>(); // keyed by accountId
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
|
|
@ -50,22 +49,18 @@ function makeSSEStream(accountId: string): Response {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
|
||||||
export const ingest = new Elysia()
|
export const ingest = new Elysia()
|
||||||
|
|
||||||
// Internal: called by Rust monitor runner
|
|
||||||
.post("/internal/ingest", async ({ body, headers, set }) => {
|
.post("/internal/ingest", async ({ body, headers, set }) => {
|
||||||
const token = headers["x-monitor-token"];
|
const token = headers["x-monitor-token"];
|
||||||
if (!safeTokenCompare(token, process.env.MONITOR_TOKEN)) { set.status = 401; return { error: "Unauthorized" }; }
|
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}`;
|
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" }; }
|
if (!monitor_check) { set.status = 404; return { error: "Monitor not found" }; }
|
||||||
|
|
||||||
const meta = body.meta ? { ...body.meta } : {};
|
const meta = body.meta ? { ...body.meta } : {};
|
||||||
if (body.cert_expiry_days != null) meta.cert_expiry_days = body.cert_expiry_days;
|
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;
|
const responseBody: string | null = meta.body_preview ?? null;
|
||||||
delete meta.body_preview;
|
delete meta.body_preview;
|
||||||
|
|
||||||
|
|
@ -91,12 +86,10 @@ export const ingest = new Elysia()
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Store response body separately
|
|
||||||
if (responseBody != null && ping) {
|
if (responseBody != null && ping) {
|
||||||
await sql`INSERT INTO ping_bodies (ping_id, body) VALUES (${ping.id}, ${responseBody})`;
|
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}`;
|
const [monitor] = await sql`SELECT account_id FROM monitors WHERE id = ${body.monitor_id}`;
|
||||||
if (monitor) publish(monitor.account_id, ping);
|
if (monitor) publish(monitor.account_id, ping);
|
||||||
|
|
||||||
|
|
@ -119,7 +112,6 @@ export const ingest = new Elysia()
|
||||||
detail: { hide: true },
|
detail: { hide: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch response body for a specific ping
|
|
||||||
.get("/pings/:id/body", async ({ params, headers, cookie, set }) => {
|
.get("/pings/:id/body", async ({ params, headers, cookie, set }) => {
|
||||||
const key = extractAuthKey(headers, cookie);
|
const key = extractAuthKey(headers, cookie);
|
||||||
if (!key) { set.status = 401; return { error: "Unauthorized" }; }
|
if (!key) { set.status = 401; return { error: "Unauthorized" }; }
|
||||||
|
|
@ -127,7 +119,6 @@ export const ingest = new Elysia()
|
||||||
const resolved = await resolveKey(key);
|
const resolved = await resolveKey(key);
|
||||||
if (!resolved) { set.status = 401; return { error: "Unauthorized" }; }
|
if (!resolved) { set.status = 401; return { error: "Unauthorized" }; }
|
||||||
|
|
||||||
// Verify the ping belongs to this account
|
|
||||||
const [ping] = await sql`
|
const [ping] = await sql`
|
||||||
SELECT p.id FROM pings p
|
SELECT p.id FROM pings p
|
||||||
JOIN monitors m ON m.id = p.monitor_id
|
JOIN monitors m ON m.id = p.monitor_id
|
||||||
|
|
@ -139,7 +130,6 @@ export const ingest = new Elysia()
|
||||||
return { body: row?.body ?? null };
|
return { body: row?.body ?? null };
|
||||||
}, { detail: { hide: true } })
|
}, { detail: { hide: true } })
|
||||||
|
|
||||||
// SSE: single stream for all of the account's monitors
|
|
||||||
.get("/account/stream", async ({ headers, cookie }) => {
|
.get("/account/stream", async ({ headers, cookie }) => {
|
||||||
const key = extractAuthKey(headers, cookie);
|
const key = extractAuthKey(headers, cookie);
|
||||||
if (!key) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
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";
|
return "Invalid URL";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow http and https
|
|
||||||
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();
|
||||||
|
|
||||||
// Block localhost by name
|
|
||||||
if (BLOCKED_HOSTNAMES.includes(hostname)) {
|
if (BLOCKED_HOSTNAMES.includes(hostname)) {
|
||||||
return "Blocked hostname: localhost is not allowed";
|
return "Blocked hostname: localhost is not allowed";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block non-public TLDs
|
|
||||||
for (const tld of BLOCKED_TLDS) {
|
for (const tld of BLOCKED_TLDS) {
|
||||||
if (hostname.endsWith(tld)) {
|
if (hostname.endsWith(tld)) {
|
||||||
return `Blocked TLD: ${tld} is not allowed`;
|
return `Blocked TLD: ${tld} is not allowed`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve DNS and check all IPs
|
|
||||||
try {
|
try {
|
||||||
const ips: string[] = [];
|
const ips: string[] = [];
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,7 @@ use tracing::{error, info};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
// Install default rustls crypto provider (required for cert expiry checks)
|
rustls::crypto::ring::default_provider().install_default().ok();
|
||||||
rustls::crypto::ring::default_provider()
|
|
||||||
.install_default()
|
|
||||||
.ok(); // ok() — ignore error if already installed
|
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
|
.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 anyhow::{bail, Result};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
|
|
@ -46,11 +11,9 @@ pub struct Response {
|
||||||
pub cert_expiry_days: Option<i64>,
|
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> {
|
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
||||||
match query {
|
match query {
|
||||||
Value::Object(map) => {
|
Value::Object(map) => {
|
||||||
// $consider — "up" (default) or "down": flips result if conditions match
|
|
||||||
if let Some(consider) = map.get("$consider") {
|
if let Some(consider) = map.get("$consider") {
|
||||||
let is_down = consider.as_str() == Some("down");
|
let is_down = consider.as_str() == Some("down");
|
||||||
let rest: serde_json::Map<String, Value> = map.iter()
|
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 });
|
return Ok(if is_down { !matches } else { matches });
|
||||||
}
|
}
|
||||||
|
|
||||||
// $and / $or / $not
|
|
||||||
if let Some(and) = map.get("$and") {
|
if let Some(and) = map.get("$and") {
|
||||||
let Value::Array(clauses) = and else { bail!("$and expects array") };
|
let Value::Array(clauses) = and else { bail!("$and expects array") };
|
||||||
return Ok(clauses.iter().all(|c| evaluate(c, response).unwrap_or(false)));
|
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)?);
|
return Ok(!evaluate(not, response)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
// $responseTime
|
|
||||||
if let Some(cond) = map.get("$responseTime") {
|
if let Some(cond) = map.get("$responseTime") {
|
||||||
let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0)));
|
let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0)));
|
||||||
return eval_condition(cond, &val, response);
|
return eval_condition(cond, &val, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// $certExpiry
|
|
||||||
if let Some(cond) = map.get("$certExpiry") {
|
if let Some(cond) = map.get("$certExpiry") {
|
||||||
let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0)));
|
let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0)));
|
||||||
return eval_condition(cond, &val, response);
|
return eval_condition(cond, &val, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// $json — { "$json": { "$.path": { "$op": val } } }
|
|
||||||
if let Some(json_path_map) = map.get("$json") {
|
if let Some(json_path_map) = map.get("$json") {
|
||||||
let path_map = match json_path_map {
|
let path_map = match json_path_map {
|
||||||
Value::Object(m) => m,
|
Value::Object(m) => m,
|
||||||
|
|
@ -99,13 +56,11 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// $select — { "$select": { "css.selector": { "$op": val } } }
|
|
||||||
if let Some(sel_map) = map.get("$select") {
|
if let Some(sel_map) = map.get("$select") {
|
||||||
let sel_obj = match sel_map {
|
let sel_obj = match sel_map {
|
||||||
Value::Object(m) => m,
|
Value::Object(m) => m,
|
||||||
_ => bail!("$select expects an object {{ selector: condition }}"),
|
_ => bail!("$select expects an object {{ selector: condition }}"),
|
||||||
};
|
};
|
||||||
// Parse HTML once for all selectors
|
|
||||||
let doc = Html::parse_document(&response.body);
|
let doc = Html::parse_document(&response.body);
|
||||||
for (selector, condition) in sel_obj {
|
for (selector, condition) in sel_obj {
|
||||||
let sel = Selector::parse(selector)
|
let sel = Selector::parse(selector)
|
||||||
|
|
@ -118,7 +73,6 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Field-level checks
|
|
||||||
for (field, condition) in map {
|
for (field, condition) in map {
|
||||||
let field_val = resolve_field(field, response);
|
let field_val = resolve_field(field, response);
|
||||||
if !eval_condition(condition, &field_val, 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; }
|
if path.is_empty() { return obj; }
|
||||||
let mut current = &obj;
|
let mut current = &obj;
|
||||||
for part in path.split('.') {
|
for part in path.split('.') {
|
||||||
// Handle array indexing like "items[0]"
|
|
||||||
if let Some(idx_start) = part.find('[') {
|
if let Some(idx_start) = part.find('[') {
|
||||||
let key = &part[..idx_start];
|
let key = &part[..idx_start];
|
||||||
if !key.is_empty() {
|
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> {
|
fn eval_condition(condition: &Value, field_val: &Value, response: &Response) -> Result<bool> {
|
||||||
match condition {
|
match condition {
|
||||||
// Shorthand: { "status": 200 }
|
|
||||||
Value::Number(n) => Ok(field_val.as_f64() == n.as_f64()),
|
Value::Number(n) => Ok(field_val.as_f64() == n.as_f64()),
|
||||||
Value::String(s) => Ok(field_val.as_str() == Some(s.as_str())),
|
Value::String(s) => Ok(field_val.as_str() == Some(s.as_str())),
|
||||||
Value::Bool(b) => Ok(field_val.as_bool() == Some(*b)),
|
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" => {
|
"$select" => {
|
||||||
// Nested: { "body": { "$select": "css", "$eq": "val" } }
|
|
||||||
let sel_str = val.as_str().unwrap_or("");
|
let sel_str = val.as_str().unwrap_or("");
|
||||||
let selected = css_select(&response.body, sel_str);
|
css_select(&response.body, sel_str).is_some()
|
||||||
// If no comparison operator follows, just check existence
|
|
||||||
selected.is_some()
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
tracing::warn!("Unknown query operator: {op}");
|
tracing::warn!("Unknown query operator: {op}");
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ use std::time::Instant;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
// Cache native root certs per OS thread to avoid reloading from disk on every check.
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static ROOT_CERTS: Arc<Vec<ureq::tls::Certificate<'static>>> = Arc::new(
|
static ROOT_CERTS: Arc<Vec<ureq::tls::Certificate<'static>>> = Arc::new(
|
||||||
rustls_native_certs::load_native_certs()
|
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(
|
pub async fn fetch_and_run(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
coordinator_url: &str,
|
coordinator_url: &str,
|
||||||
|
|
@ -27,8 +25,6 @@ pub async fn fetch_and_run(
|
||||||
region: &str,
|
region: &str,
|
||||||
in_flight: &Arc<Mutex<HashSet<String>>>,
|
in_flight: &Arc<Mutex<HashSet<String>>>,
|
||||||
) -> Result<usize> {
|
) -> 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() {
|
let url = if region.is_empty() {
|
||||||
format!("{coordinator_url}/internal/due?lookahead_ms=2000")
|
format!("{coordinator_url}/internal/due?lookahead_ms=2000")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -45,12 +41,10 @@ pub async fn fetch_and_run(
|
||||||
let n = monitors.len();
|
let n = monitors.len();
|
||||||
if n == 0 { return Ok(0); }
|
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 coordinator_url: Arc<str> = Arc::from(coordinator_url);
|
||||||
let token: Arc<str> = Arc::from(token);
|
let token: Arc<str> = Arc::from(token);
|
||||||
let region: Arc<str> = Arc::from(region);
|
let region: Arc<str> = Arc::from(region);
|
||||||
|
|
||||||
// Spawn all checks — fire and forget, skip if already in-flight
|
|
||||||
let mut spawned = 0usize;
|
let mut spawned = 0usize;
|
||||||
for monitor in monitors {
|
for monitor in monitors {
|
||||||
{
|
{
|
||||||
|
|
@ -66,7 +60,6 @@ pub async fn fetch_and_run(
|
||||||
let coordinator_url = Arc::clone(&coordinator_url);
|
let coordinator_url = Arc::clone(&coordinator_url);
|
||||||
let token = Arc::clone(&token);
|
let token = Arc::clone(&token);
|
||||||
let region_owned = Arc::clone(®ion);
|
let region_owned = Arc::clone(®ion);
|
||||||
// run_id: hash(monitor_id, interval_bucket) — same across all regions for this window
|
|
||||||
let run_id_owned = {
|
let run_id_owned = {
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
@ -77,7 +70,6 @@ pub async fn fetch_and_run(
|
||||||
bucket.hash(&mut h);
|
bucket.hash(&mut h);
|
||||||
format!("{:016x}", h.finish())
|
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| {
|
let scheduled_at_iso = monitor.scheduled_at_ms.map(|ms| {
|
||||||
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)
|
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)
|
||||||
.map(|dt| dt.to_rfc3339())
|
.map(|dt| dt.to_rfc3339())
|
||||||
|
|
@ -85,7 +77,6 @@ pub async fn fetch_and_run(
|
||||||
});
|
});
|
||||||
let in_flight = in_flight.clone();
|
let in_flight = in_flight.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// Sleep until the exact scheduled moment — tight multi-region coordination
|
|
||||||
if let Some(ms) = monitor.scheduled_at_ms {
|
if let Some(ms) = monitor.scheduled_at_ms {
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
if ms > now_ms {
|
if ms > now_ms {
|
||||||
|
|
@ -94,7 +85,6 @@ pub async fn fetch_and_run(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let timeout_ms = monitor.timeout_ms.unwrap_or(30000);
|
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 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 {
|
let result = match tokio::time::timeout(deadline, run_check(&client, &monitor, scheduled_at_iso.clone(), ®ion_owned, &run_id_owned)).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
|
|
@ -113,8 +103,6 @@ pub async fn fetch_and_run(
|
||||||
run_id: Some(run_id_owned.clone()),
|
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 {
|
if let Err(e) = post_result(&client, &coordinator_url, &token, result).await {
|
||||||
warn!("Failed to post result for {}: {e}", monitor.id);
|
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 {
|
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();
|
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 jitter_ms: Option<i64> = scheduled_at.as_deref().and_then(|s| {
|
||||||
let scheduled = chrono::DateTime::parse_from_rfc3339(s).ok()?;
|
let scheduled = chrono::DateTime::parse_from_rfc3339(s).ok()?;
|
||||||
let now = chrono::Utc::now();
|
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();
|
let start = Instant::now();
|
||||||
|
|
||||||
// Build request with method, headers, body, timeout
|
|
||||||
let method = monitor.method.as_deref().unwrap_or("GET").to_uppercase();
|
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 timeout = std::time::Duration::from_millis(monitor.timeout_ms.unwrap_or(30000));
|
||||||
let is_https = monitor.url.starts_with("https://");
|
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 url = monitor.url.clone();
|
||||||
let req_headers = monitor.request_headers.clone();
|
let req_headers = monitor.request_headers.clone();
|
||||||
let req_body = monitor.request_body.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)) => {
|
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_handle = if is_https {
|
||||||
let cert_url = monitor.url.clone();
|
let cert_url = monitor.url.clone();
|
||||||
Some(tokio::spawn(async move {
|
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;
|
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 (up, query_error) = if let Some(q) = query {
|
||||||
let response = Response {
|
let response = Response {
|
||||||
status,
|
status,
|
||||||
|
|
@ -225,16 +199,13 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op
|
||||||
Ok(result) => (result, None),
|
Ok(result) => (result, None),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Query error for {}: {e}", monitor.id);
|
warn!("Query error for {}: {e}", monitor.id);
|
||||||
// Fall back to status-based up/down
|
|
||||||
(status < 400, Some(e.to_string()))
|
(status < 400, Some(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Default: up if 2xx/3xx
|
|
||||||
(status < 400, None)
|
(status < 400, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Await the cert check now (it's been running concurrently during query eval)
|
|
||||||
let cert_expiry_days = match cert_handle {
|
let cert_expiry_days = match cert_handle {
|
||||||
Some(h) => h.await.unwrap_or(None),
|
Some(h) => h.await.unwrap_or(None),
|
||||||
None => 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(
|
fn run_check_blocking(
|
||||||
url: &str,
|
url: &str,
|
||||||
method: &str,
|
method: &str,
|
||||||
|
|
@ -276,7 +243,6 @@ fn run_check_blocking(
|
||||||
body: Option<&str>,
|
body: Option<&str>,
|
||||||
timeout: std::time::Duration,
|
timeout: std::time::Duration,
|
||||||
) -> Result<(u16, HashMap<String, String>, String), String> {
|
) -> 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 root_certs = ROOT_CERTS.with(|c| Arc::clone(c));
|
||||||
|
|
||||||
let tls = ureq::tls::TlsConfig::builder()
|
let tls = ureq::tls::TlsConfig::builder()
|
||||||
|
|
@ -352,8 +318,6 @@ fn run_check_blocking(
|
||||||
Ok((status, resp_headers, body_out))
|
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>> {
|
async fn check_cert_expiry(url: &str) -> Result<Option<i64>> {
|
||||||
use rustls::ClientConfig;
|
use rustls::ClientConfig;
|
||||||
use rustls::pki_types::ServerName;
|
use rustls::pki_types::ServerName;
|
||||||
|
|
@ -361,12 +325,10 @@ async fn check_cert_expiry(url: &str) -> Result<Option<i64>> {
|
||||||
use tokio_rustls::TlsConnector;
|
use tokio_rustls::TlsConnector;
|
||||||
use x509_parser::prelude::*;
|
use x509_parser::prelude::*;
|
||||||
|
|
||||||
// Parse host and port from URL
|
|
||||||
let url_parsed = reqwest::Url::parse(url)?;
|
let url_parsed = reqwest::Url::parse(url)?;
|
||||||
let host = url_parsed.host_str().unwrap_or("");
|
let host = url_parsed.host_str().unwrap_or("");
|
||||||
let port = url_parsed.port().unwrap_or(443);
|
let port = url_parsed.port().unwrap_or(443);
|
||||||
|
|
||||||
// Build a rustls config that captures certificates
|
|
||||||
let mut root_store = rustls::RootCertStore::empty();
|
let mut root_store = rustls::RootCertStore::empty();
|
||||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
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 stream = TcpStream::connect(format!("{host}:{port}")).await?;
|
||||||
let tls_stream = connector.connect(server_name, stream).await?;
|
let tls_stream = connector.connect(server_name, stream).await?;
|
||||||
|
|
||||||
// Get peer certificates
|
|
||||||
let (_, conn) = tls_stream.get_ref();
|
let (_, conn) = tls_stream.get_ref();
|
||||||
let certs = conn.peer_certificates().unwrap_or(&[]);
|
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
|
// @ts-ignore
|
||||||
import bitcore from "bitcore-lib";
|
import bitcore from "bitcore-lib";
|
||||||
import bs58check from "bs58check";
|
import bs58check from "bs58check";
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ const app = new Elysia()
|
||||||
.onAfterHandle(({ set }) => {
|
.onAfterHandle(({ set }) => {
|
||||||
Object.assign(set.headers, SECURITY_HEADERS);
|
Object.assign(set.headers, SECURITY_HEADERS);
|
||||||
})
|
})
|
||||||
// CORS for web app
|
|
||||||
.onRequest(({ request, set }) => {
|
.onRequest(({ request, set }) => {
|
||||||
const origin = request.headers.get("origin") ?? "";
|
const origin = request.headers.get("origin") ?? "";
|
||||||
if (CORS_ORIGIN.includes(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}`);
|
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));
|
checkPayments().catch((err) => console.error("Payment check failed:", err));
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
checkPayments().catch((err) => console.error("Payment check failed:", err));
|
checkPayments().catch((err) => console.error("Payment check failed:", err));
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
// Expire pro plans every hour
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
expireProPlans().catch((err) => console.error("Plan expiry check failed:", err));
|
expireProPlans().catch((err) => console.error("Plan expiry check failed:", err));
|
||||||
}, 60 * 60_000);
|
}, 60 * 60_000);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
/// Payment monitor: raw SSE + polling fallback.
|
|
||||||
/// States: pending → underpaid → confirming → paid | expired
|
|
||||||
import sql from "./db";
|
import sql from "./db";
|
||||||
import { getAddressInfo, getAddressInfoBulk } from "./freedom";
|
import { getAddressInfo, getAddressInfoBulk } from "./freedom";
|
||||||
import { COINS, planTier } from "../../shared/plans";
|
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 SOCK_API = process.env.FREEDOM_SOCK ?? "https://sock-v1.freedom.st";
|
||||||
const THRESHOLD = 0.95;
|
const THRESHOLD = 0.95;
|
||||||
|
|
||||||
// ── In-memory lookups for SSE matching ──────────────────────────────
|
|
||||||
let addressMap = new Map<string, any>(); // address → payment
|
let addressMap = new Map<string, any>(); // address → payment
|
||||||
let txidToPayment = new Map<string, number>(); // txid → payment.id
|
let txidToPayment = new Map<string, number>(); // txid → payment.id
|
||||||
const seenTxids = new Set<string>();
|
const seenTxids = new Set<string>();
|
||||||
|
|
@ -35,8 +32,6 @@ async function refreshMaps() {
|
||||||
for (const t of seenTxids) { if (!newTxid.has(t)) seenTxids.delete(t); }
|
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) {
|
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
|
||||||
|
|
@ -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
|
ON CONFLICT (payment_id, txid) DO UPDATE SET confirmed = EXCLUDED.confirmed OR payment_txs.confirmed
|
||||||
RETURNING (xmax = 0) as is_new
|
RETURNING (xmax = 0) as is_new
|
||||||
`;
|
`;
|
||||||
// Extend expiry to 24h on new tx
|
|
||||||
if (ins?.is_new) {
|
if (ins?.is_new) {
|
||||||
const exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
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}`;
|
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) {
|
async function handleTxEvent(event: any) {
|
||||||
const txHash = event.data?.tx?.hash;
|
const txHash = event.data?.tx?.hash;
|
||||||
if (!txHash || seenTxids.has(txHash)) return;
|
if (!txHash || seenTxids.has(txHash)) return;
|
||||||
|
|
@ -179,8 +171,6 @@ async function connectSSE(url: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Polling fallback ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function checkPayments() {
|
export async function checkPayments() {
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE payments SET status = 'expired'
|
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) try { info = await getAddressInfo(payment.address); } catch { continue; }
|
||||||
if (!info || info.error) continue;
|
if (!info || info.error) continue;
|
||||||
|
|
||||||
// Sync txs from address API
|
|
||||||
for (const tx of info.in ?? []) {
|
for (const tx of info.in ?? []) {
|
||||||
if (!tx.txid) continue;
|
if (!tx.txid) continue;
|
||||||
await recordTx(payment.id, payment.address, tx.txid, Number(tx.amount ?? 0), tx.block != null);
|
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) {
|
export function watchPayment(payment: any) {
|
||||||
addressMap.set(payment.address, payment);
|
addressMap.set(payment.address, payment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Plan stacking logic (pure, testable) ─────────────────────────
|
|
||||||
|
|
||||||
interface StackEntry { plan: string; remaining_days: number | null }
|
interface StackEntry { plan: string; remaining_days: number | null }
|
||||||
interface AccountState { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] }
|
interface AccountState { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] }
|
||||||
interface AccountUpdate { 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[] {
|
export function insertIntoStack(stack: StackEntry[], entry: StackEntry): StackEntry[] {
|
||||||
const result = stack.slice();
|
const result = stack.slice();
|
||||||
// Merge if same plan already exists
|
|
||||||
const existing = result.findIndex(e => e.plan === entry.plan);
|
const existing = result.findIndex(e => e.plan === entry.plan);
|
||||||
if (existing !== -1) {
|
if (existing !== -1) {
|
||||||
const old = result[existing];
|
const old = result[existing];
|
||||||
|
|
@ -237,11 +221,9 @@ export function insertIntoStack(stack: StackEntry[], entry: StackEntry): StackEn
|
||||||
} else {
|
} else {
|
||||||
result[existing] = { plan: entry.plan, remaining_days: old.remaining_days + entry.remaining_days };
|
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));
|
result.sort((a, b) => planTier(b.plan) - planTier(a.plan));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
// Insert at correct position (tier descending)
|
|
||||||
const tier = planTier(entry.plan);
|
const tier = planTier(entry.plan);
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < result.length && planTier(result[i].plan) >= tier) i++;
|
while (i < result.length && planTier(result[i].plan) >= tier) i++;
|
||||||
|
|
@ -262,19 +244,16 @@ export function computeApplyPlan(
|
||||||
const currentIsActive = acc.plan === "lifetime"
|
const currentIsActive = acc.plan === "lifetime"
|
||||||
|| (acc.plan !== "free" && currentExpiry && currentExpiry > now);
|
|| (acc.plan !== "free" && currentExpiry && currentExpiry > now);
|
||||||
|
|
||||||
// No active plan worth saving — just activate the new one
|
|
||||||
if (!currentIsActive || acc.plan === "free") {
|
if (!currentIsActive || acc.plan === "free") {
|
||||||
const expiresAt = newDays != null ? new Date(now.getTime() + newDays * 86400000) : null;
|
const expiresAt = newDays != null ? new Date(now.getTime() + newDays * 86400000) : null;
|
||||||
return { plan: newPlan, plan_expires_at: expiresAt, plan_stack: stack };
|
return { plan: newPlan, plan_expires_at: expiresAt, plan_stack: stack };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same plan renewal — extend from current expiry
|
|
||||||
if (newPlan === acc.plan && newDays != null && currentExpiry) {
|
if (newPlan === acc.plan && newDays != null && currentExpiry) {
|
||||||
const extended = new Date(currentExpiry.getTime() + newDays * 86400000);
|
const extended = new Date(currentExpiry.getTime() + newDays * 86400000);
|
||||||
return { plan: acc.plan, plan_expires_at: extended, plan_stack: stack };
|
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)) {
|
if (planTier(newPlan) > planTier(acc.plan)) {
|
||||||
const remainingDays = acc.plan === "lifetime"
|
const remainingDays = acc.plan === "lifetime"
|
||||||
? null
|
? null
|
||||||
|
|
@ -284,38 +263,30 @@ export function computeApplyPlan(
|
||||||
return { plan: newPlan, plan_expires_at: expiresAt, plan_stack: newStack };
|
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 });
|
const newStack = insertIntoStack(stack, { plan: newPlan, remaining_days: newDays });
|
||||||
return { plan: acc.plan, plan_expires_at: currentExpiry, plan_stack: newStack };
|
return { plan: acc.plan, plan_expires_at: currentExpiry, plan_stack: newStack };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeExpiry(acc: AccountState, now: Date): AccountUpdate | null {
|
export function computeExpiry(acc: AccountState, now: Date): AccountUpdate | null {
|
||||||
// Only expire timed pro plans
|
|
||||||
if (!["pro", "pro2x", "pro4x"].includes(acc.plan)) return null;
|
if (!["pro", "pro2x", "pro4x"].includes(acc.plan)) return null;
|
||||||
if (!acc.plan_expires_at || new Date(acc.plan_expires_at) >= now) return null;
|
if (!acc.plan_expires_at || new Date(acc.plan_expires_at) >= now) return null;
|
||||||
|
|
||||||
const stack = (acc.plan_stack || []).slice();
|
const stack = (acc.plan_stack || []).slice();
|
||||||
|
|
||||||
// Pop layers until we find a valid one or exhaust the stack
|
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
const next = stack.shift()!;
|
const next = stack.shift()!;
|
||||||
if (next.remaining_days === null) {
|
if (next.remaining_days === null) {
|
||||||
// Permanent plan (lifetime)
|
|
||||||
return { plan: next.plan, plan_expires_at: null, plan_stack: stack };
|
return { plan: next.plan, plan_expires_at: null, plan_stack: stack };
|
||||||
}
|
}
|
||||||
if (next.remaining_days > 0) {
|
if (next.remaining_days > 0) {
|
||||||
const expiresAt = new Date(now.getTime() + next.remaining_days * 86400000);
|
const expiresAt = new Date(now.getTime() + next.remaining_days * 86400000);
|
||||||
return { plan: next.plan, plan_expires_at: expiresAt, plan_stack: stack };
|
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: [] };
|
return { plan: "free", plan_expires_at: null, plan_stack: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DB wrappers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function applyPlan(payment: any) {
|
async function applyPlan(payment: any) {
|
||||||
try {
|
try {
|
||||||
await sql.begin(async (tx) => {
|
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}`;
|
const [payment] = await sql`SELECT * FROM payments WHERE id = ${paymentId}`;
|
||||||
if (!payment) throw new Error("Payment not found");
|
if (!payment) throw new Error("Payment not found");
|
||||||
|
|
||||||
// Already locked — return as-is
|
|
||||||
if (payment.receipt_html) return payment.receipt_html;
|
if (payment.receipt_html) return payment.receipt_html;
|
||||||
|
|
||||||
const coinInfo = COINS[payment.coin];
|
const coinInfo = COINS[payment.coin];
|
||||||
|
|
@ -126,7 +125,6 @@ export async function generateReceipt(paymentId: number): Promise<string> {
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
// Minify and lock it
|
|
||||||
const minified = html.replace(/\n\s*/g, "").replace(/>\s+</g, "><").replace(/\s{2,}/g, " ");
|
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}`;
|
await sql`UPDATE payments SET receipt_html = ${minified} WHERE id = ${paymentId}`;
|
||||||
return minified;
|
return minified;
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ function requireAuth(app: Elysia) {
|
||||||
|
|
||||||
export const routes = new Elysia()
|
export const routes = new Elysia()
|
||||||
|
|
||||||
// Public: available coins and rates
|
|
||||||
.get("/coins", async () => {
|
.get("/coins", async () => {
|
||||||
const [available, rates] = await Promise.all([getAvailableCoins(), getExchangeRates()]);
|
const [available, rates] = await Promise.all([getAvailableCoins(), getExchangeRates()]);
|
||||||
const coins = Object.entries(COINS)
|
const coins = Object.entries(COINS)
|
||||||
|
|
@ -41,13 +40,11 @@ export const routes = new Elysia()
|
||||||
|
|
||||||
.use(requireAuth)
|
.use(requireAuth)
|
||||||
|
|
||||||
// Create a checkout
|
|
||||||
.post("/checkout", async ({ accountId, keyId, body, set }) => {
|
.post("/checkout", async ({ accountId, keyId, body, set }) => {
|
||||||
if (keyId) { set.status = 403; return { error: "Sub-keys cannot create checkouts" }; }
|
if (keyId) { set.status = 403; return { error: "Sub-keys cannot create checkouts" }; }
|
||||||
|
|
||||||
const { plan, months, coin } = body;
|
const { plan, months, coin } = body;
|
||||||
|
|
||||||
// Validate plan — block duplicate lifetime
|
|
||||||
if (plan === "lifetime") {
|
if (plan === "lifetime") {
|
||||||
const [acc] = await sql`SELECT plan, plan_stack FROM accounts WHERE id = ${accountId}`;
|
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 || []);
|
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" }; }
|
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}` }; }
|
if (!COINS[coin]) { set.status = 400; return { error: `Unknown coin: ${coin}` }; }
|
||||||
const available = await getAvailableCoins();
|
const available = await getAvailableCoins();
|
||||||
if (!available.includes(coin)) { set.status = 400; return { error: `${coin} is temporarily unavailable` }; }
|
if (!available.includes(coin)) { set.status = 400; return { error: `${coin} is temporarily unavailable` }; }
|
||||||
|
|
||||||
// Calculate amount
|
|
||||||
const planDef = PLANS[plan];
|
const planDef = PLANS[plan];
|
||||||
if (!planDef) { set.status = 400; return { error: `Unknown plan: ${plan}` }; }
|
if (!planDef) { set.status = 400; return { error: `Unknown plan: ${plan}` }; }
|
||||||
let amountUsd = planDef.priceUsd ?? (planDef.monthlyUsd! * (months ?? 1));
|
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) {
|
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 [{ 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);
|
const credit = Math.min(Number(total), planDef.priceUsd * 0.75);
|
||||||
|
|
@ -75,15 +69,12 @@ export const routes = new Elysia()
|
||||||
const rate = rates[coin];
|
const rate = rates[coin];
|
||||||
if (!rate) { set.status = 500; return { error: "Could not fetch exchange rate" }; }
|
if (!rate) { set.status = 500; return { error: "Could not fetch exchange rate" }; }
|
||||||
|
|
||||||
// Crypto amount with 8 decimal precision
|
|
||||||
const amountCrypto = (amountUsd / rate).toFixed(8);
|
const amountCrypto = (amountUsd / rate).toFixed(8);
|
||||||
|
|
||||||
// Get next derivation index for this coin
|
|
||||||
const [{ next_index }] = await sql`
|
const [{ next_index }] = await sql`
|
||||||
SELECT COALESCE(MAX(derivation_index), -1) + 1 as next_index FROM payments WHERE coin = ${coin}
|
SELECT COALESCE(MAX(derivation_index), -1) + 1 as next_index FROM payments WHERE coin = ${coin}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Derive address
|
|
||||||
let address: string;
|
let address: string;
|
||||||
try {
|
try {
|
||||||
address = derive(coin, next_index);
|
address = derive(coin, next_index);
|
||||||
|
|
@ -100,10 +91,8 @@ export const routes = new Elysia()
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Start watching this address immediately via SSE
|
|
||||||
watchPayment(payment);
|
watchPayment(payment);
|
||||||
|
|
||||||
// Build payment URI for QR code
|
|
||||||
const coinInfo = COINS[coin];
|
const coinInfo = COINS[coin];
|
||||||
const uri = `${coinInfo.uri}:${address.replace(/^.*:/, '')}?amount=${amountCrypto}`;
|
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 }) => {
|
.get("/checkout/:id", async ({ accountId, params, set }) => {
|
||||||
const [payment] = await sql`
|
const [payment] = await sql`
|
||||||
SELECT * FROM payments WHERE id = ${params.id} AND account_id = ${accountId}
|
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 }) => {
|
.get("/checkout/:id/receipt", async ({ accountId, params, set }) => {
|
||||||
const [payment] = await sql`
|
const [payment] = await sql`
|
||||||
SELECT * FROM payments WHERE id = ${params.id} AND account_id = ${accountId}
|
SELECT * FROM payments WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
export async function migrate(sql: any) {
|
export async function migrate(sql: any) {
|
||||||
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
|
await sql`CREATE EXTENSION IF NOT EXISTS pgcrypto`;
|
||||||
|
|
||||||
// ── Core tables ─────────────────────────────────────────────────
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS accounts (
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
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 scheduled_at TIMESTAMPTZ`;
|
||||||
await sql`ALTER TABLE pings ADD COLUMN IF NOT EXISTS jitter_ms INTEGER`;
|
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 '{}'`;
|
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_expires_at TIMESTAMPTZ`;
|
||||||
await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_stack JSONB NOT NULL DEFAULT '[]'`;
|
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_monitor ON pings(monitor_id, checked_at DESC)`;
|
||||||
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
|
await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`;
|
||||||
|
|
||||||
// ── Payment tables ─────────────────────────────────────────────
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS payments (
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// ── Types ─────────────────────────────────────────────────────────
|
|
||||||
export type Plan = "free" | "pro" | "pro2x" | "pro4x" | "lifetime";
|
export type Plan = "free" | "pro" | "pro2x" | "pro4x" | "lifetime";
|
||||||
|
|
||||||
export interface PlanLimits {
|
export interface PlanLimits {
|
||||||
|
|
@ -7,7 +6,6 @@ export interface PlanLimits {
|
||||||
maxRegions: number;
|
maxRegions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Limits ────────────────────────────────────────────────────────
|
|
||||||
const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||||
free: { maxMonitors: 10, minIntervalS: 30, maxRegions: 1 },
|
free: { maxMonitors: 10, minIntervalS: 30, maxRegions: 1 },
|
||||||
pro: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 },
|
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;
|
return PLAN_LIMITS[plan as Plan] || PLAN_LIMITS.free;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Display ───────────────────────────────────────────────────────
|
|
||||||
export const PLAN_LABELS: Record<string, string> = {
|
export const PLAN_LABELS: Record<string, string> = {
|
||||||
free: "Free", pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime",
|
free: "Free", pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Pricing ───────────────────────────────────────────────────────
|
|
||||||
export const PRO_MONTHLY_USD = 12;
|
export const PRO_MONTHLY_USD = 12;
|
||||||
export const LIFETIME_USD = 140;
|
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" },
|
xec: { label: "eCash", ticker: "XEC", confirmations: 0, uri: "ecash" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Tier ranking (for plan stacking) ──────────────────────────────
|
|
||||||
const PLAN_RANK: Record<string, number> = {
|
const PLAN_RANK: Record<string, number> = {
|
||||||
free: 0, pro: 1, lifetime: 1, pro2x: 2, pro4x: 3,
|
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;
|
return PLAN_RANK[plan] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Regions ───────────────────────────────────────────────────────
|
|
||||||
export const REGION_COLORS: Record<string, string> = {
|
export const REGION_COLORS: Record<string, string> = {
|
||||||
"eu-central": "#3b82f6",
|
"eu-central": "#3b82f6",
|
||||||
"us-west": "#f59e0b",
|
"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';
|
const API_BASE = 'https://api.pingql.com';
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
window.location.href = '/dashboard/logout';
|
window.location.href = '/dashboard/logout';
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireAuth is a no-op now — server redirects to /dashboard if not authed
|
|
||||||
function requireAuth() { return true; }
|
function requireAuth() { return true; }
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
|
|
@ -29,7 +25,6 @@ async function api(path, opts = {}) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format relative time
|
|
||||||
function formatAgo(ms) {
|
function formatAgo(ms) {
|
||||||
const s = Math.ceil(ms / 1000) || 1;
|
const s = Math.ceil(ms / 1000) || 1;
|
||||||
if (s < 60) return `${s}s ago`;
|
if (s < 60) return `${s}s ago`;
|
||||||
|
|
@ -44,7 +39,6 @@ function timeAgo(date) {
|
||||||
return `<span class="timestamp" data-ts="${ts}">${formatAgo(elapsed)}</span>`;
|
return `<span class="timestamp" data-ts="${ts}">${formatAgo(elapsed)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick all live timestamps
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
document.querySelectorAll('.timestamp[data-ts]').forEach(el => {
|
document.querySelectorAll('.timestamp[data-ts]').forEach(el => {
|
||||||
const elapsed = Date.now() - Number(el.dataset.ts);
|
const elapsed = Date.now() - Number(el.dataset.ts);
|
||||||
|
|
@ -58,9 +52,6 @@ function escapeHtml(str) {
|
||||||
return div.innerHTML;
|
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) {
|
function watchAccount(onPing) {
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
// PingQL Visual Query Builder
|
|
||||||
|
|
||||||
const FIELDS = [
|
const FIELDS = [
|
||||||
{ name: 'status', label: 'Status Code', type: 'number', operators: ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in'] },
|
{ 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'] },
|
{ 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 } };
|
return { [headerField]: { [operator]: parsedVal } };
|
||||||
}
|
}
|
||||||
if (operator === '$exists') return { [field]: { '$exists': parsedVal } };
|
if (operator === '$exists') return { [field]: { '$exists': parsedVal } };
|
||||||
// Simple shorthand for $eq on basic fields
|
|
||||||
if (operator === '$eq') return { [field]: parsedVal };
|
if (operator === '$eq') return { [field]: parsedVal };
|
||||||
return { [field]: { [operator]: parsedVal } };
|
return { [field]: { [operator]: parsedVal } };
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +90,6 @@ class QueryBuilder {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip $consider before parsing rules
|
|
||||||
this.consider = query.$consider === 'down' ? 'down' : 'up';
|
this.consider = query.$consider === 'down' ? 'down' : 'up';
|
||||||
const q = Object.fromEntries(Object.entries(query).filter(([k]) => k !== '$consider'));
|
const q = Object.fromEntries(Object.entries(query).filter(([k]) => k !== '$consider'));
|
||||||
|
|
||||||
|
|
@ -201,7 +197,6 @@ class QueryBuilder {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Bind events
|
|
||||||
this.container.querySelector('#qb-consider').addEventListener('change', (e) => {
|
this.container.querySelector('#qb-consider').addEventListener('change', (e) => {
|
||||||
this.consider = e.target.value;
|
this.consider = e.target.value;
|
||||||
this.render();
|
this.render();
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ export const account = new Elysia({ prefix: "/account" })
|
||||||
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
||||||
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
|
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)}`);
|
if ((body as any)._form) return redir(`/dashboard/welcome?key=${encodeURIComponent(key)}`);
|
||||||
|
|
||||||
return { key, email_registered: !!emailHash };
|
return { key, email_registered: !!emailHash };
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ function latencyChartSSR(pings: any[]): string {
|
||||||
const pad = { top: 8, bottom: 8 };
|
const pad = { top: 8, bottom: 8 };
|
||||||
const cH = h - pad.top - pad.bottom;
|
const cH = h - pad.top - pad.bottom;
|
||||||
|
|
||||||
// Build ordered list of unique runs, evenly spaced (matches canvas)
|
|
||||||
const runTimes: Record<string, number[]> = {};
|
const runTimes: Record<string, number[]> = {};
|
||||||
for (const p of data) {
|
for (const p of data) {
|
||||||
const rid = p.run_id || p.checked_at;
|
const rid = p.run_id || p.checked_at;
|
||||||
|
|
@ -49,7 +48,6 @@ function latencyChartSSR(pings: any[]): string {
|
||||||
runs.forEach((rid, i) => { runIndex[rid] = i; });
|
runs.forEach((rid, i) => { runIndex[rid] = i; });
|
||||||
const maxIdx = Math.max(runs.length - 1, 1);
|
const maxIdx = Math.max(runs.length - 1, 1);
|
||||||
|
|
||||||
// Group by region
|
|
||||||
const byRegion: Record<string, any[]> = {};
|
const byRegion: Record<string, any[]> = {};
|
||||||
for (const p of data) {
|
for (const p of data) {
|
||||||
const key = p.region || '__none__';
|
const key = p.region || '__none__';
|
||||||
|
|
@ -71,7 +69,6 @@ function latencyChartSSR(pings: any[]): string {
|
||||||
return pad.top + cH - ((v - yMin) / yRange) * cH;
|
return pad.top + cH - ((v - yMin) / yRange) * cH;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid lines
|
|
||||||
let grid = '';
|
let grid = '';
|
||||||
for (let i = 0; i <= 4; i++) {
|
for (let i = 0; i <= 4; i++) {
|
||||||
const y = (pad.top + (cH / 4) * i).toFixed(1);
|
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()
|
export const dashboard = new Elysia()
|
||||||
.get("/", () => html("landing", {}))
|
.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("/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/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.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" } }))
|
.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" } }))
|
.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 }) => {
|
.get("/dashboard", async ({ cookie }) => {
|
||||||
const key = cookie?.pingql_key?.value;
|
const key = cookie?.pingql_key?.value;
|
||||||
if (key) {
|
if (key) {
|
||||||
|
|
@ -172,21 +166,17 @@ export const dashboard = new Elysia()
|
||||||
return html("login", {});
|
return html("login", {});
|
||||||
})
|
})
|
||||||
|
|
||||||
// Logout
|
|
||||||
.get("/dashboard/logout", ({ cookie }) => {
|
.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" });
|
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");
|
return redirect("/dashboard");
|
||||||
})
|
})
|
||||||
|
|
||||||
// Welcome page — shows new account key after registration (no-JS flow)
|
|
||||||
.get("/dashboard/welcome", async ({ cookie, headers, query }) => {
|
.get("/dashboard/welcome", async ({ cookie, headers, query }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
return html("welcome", { key: query.key || cookie?.pingql_key?.value || "" });
|
return html("welcome", { key: query.key || cookie?.pingql_key?.value || "" });
|
||||||
})
|
})
|
||||||
|
|
||||||
// Home — SSR monitor list
|
|
||||||
.get("/dashboard/home", async ({ cookie, headers }) => {
|
.get("/dashboard/home", async ({ cookie, headers }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
const accountId = resolved?.accountId ?? null;
|
const accountId = resolved?.accountId ?? null;
|
||||||
|
|
@ -202,7 +192,6 @@ export const dashboard = new Elysia()
|
||||||
ORDER BY m.created_at DESC
|
ORDER BY m.created_at DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Fetch last 20 pings per monitor for sparklines
|
|
||||||
const monitorIds = monitors.map((m: any) => m.id);
|
const monitorIds = monitors.map((m: any) => m.id);
|
||||||
let pingsMap: Record<string, any[]> = {};
|
let pingsMap: Record<string, any[]> = {};
|
||||||
if (monitorIds.length > 0) {
|
if (monitorIds.length > 0) {
|
||||||
|
|
@ -226,7 +215,6 @@ export const dashboard = new Elysia()
|
||||||
return html("home", { nav: "monitors", monitors: monitorsWithPings, accountId });
|
return html("home", { nav: "monitors", monitors: monitorsWithPings, accountId });
|
||||||
})
|
})
|
||||||
|
|
||||||
// Settings — SSR account info
|
|
||||||
.get("/dashboard/settings", async ({ cookie, headers }) => {
|
.get("/dashboard/settings", async ({ cookie, headers }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
const accountId = resolved?.accountId ?? null;
|
const accountId = resolved?.accountId ?? null;
|
||||||
|
|
@ -239,7 +227,6 @@ export const dashboard = new Elysia()
|
||||||
const loginKey = isSubKey ? null : (cookie?.pingql_key?.value ?? null);
|
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}`;
|
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[] = [];
|
let invoices: any[] = [];
|
||||||
try {
|
try {
|
||||||
invoices = await sql`
|
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 });
|
return html("settings", { nav: "settings", account: acc, apiKeys, accountId, loginKey, isSubKey, monitorCount, invoices });
|
||||||
})
|
})
|
||||||
|
|
||||||
// Checkout — upgrade plan
|
|
||||||
.get("/dashboard/checkout", async ({ cookie, headers }) => {
|
.get("/dashboard/checkout", async ({ cookie, headers }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
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");
|
const hasLifetime = acc.plan === "lifetime" || stack.some((s: any) => s.plan === "lifetime");
|
||||||
if (acc.plan === "lifetime" && stack.length === 0) return redirect("/dashboard/settings");
|
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'`;
|
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";
|
const payApi = process.env.PAY_API || "https://pay.pingql.com";
|
||||||
let coins: any[] = [];
|
let coins: any[] = [];
|
||||||
try {
|
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 });
|
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 }) => {
|
.get("/dashboard/checkout/:id", async ({ cookie, headers, params }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
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 });
|
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 }) => {
|
.get("/dashboard/checkout/:id/receipt", async ({ cookie, headers, params, set }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
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 }) => {
|
.post("/dashboard/checkout", async ({ cookie, headers, body }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
|
|
@ -352,7 +333,6 @@ export const dashboard = new Elysia()
|
||||||
return redirect("/dashboard/checkout");
|
return redirect("/dashboard/checkout");
|
||||||
})
|
})
|
||||||
|
|
||||||
// New monitor
|
|
||||||
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
|
.get("/dashboard/monitors/new", async ({ cookie, headers }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
const accountId = resolved?.accountId ?? null;
|
const accountId = resolved?.accountId ?? null;
|
||||||
|
|
@ -361,7 +341,6 @@ export const dashboard = new Elysia()
|
||||||
return html("new", { nav: "monitors", plan: resolved?.plan || "free" });
|
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 }) => {
|
.get("/dashboard/home/data", async ({ cookie, headers }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
const accountId = resolved?.accountId ?? null;
|
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 }) => {
|
.get("/dashboard/monitors/:id", async ({ cookie, headers, params }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
const accountId = resolved?.accountId ?? null;
|
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" });
|
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 }) => {
|
.get("/dashboard/monitors/:id/chart", async ({ cookie, headers, params }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
const accountId = resolved?.accountId ?? null;
|
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 }) => {
|
.get("/dashboard/monitors/:id/sparkline", async ({ cookie, headers, params }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
const accountId = resolved?.accountId ?? null;
|
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 }) => {
|
.post("/dashboard/monitors/new", async ({ cookie, headers, body, set }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
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 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 query = b.query ? (typeof b.query === "string" ? JSON.parse(b.query) : b.query) : undefined;
|
||||||
const requestHeaders: Record<string, string> = {};
|
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 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] : []);
|
const hVals = Array.isArray(b.header_value) ? b.header_value : (b.header_value ? [b.header_value] : []);
|
||||||
for (let i = 0; i < hKeys.length; i++) {
|
for (let i = 0; i < hKeys.length; i++) {
|
||||||
|
|
@ -481,7 +453,6 @@ export const dashboard = new Elysia()
|
||||||
return redirect("/dashboard/home");
|
return redirect("/dashboard/home");
|
||||||
})
|
})
|
||||||
|
|
||||||
// Edit monitor via form POST
|
|
||||||
.post("/dashboard/monitors/:id/edit", async ({ cookie, headers, params, body }) => {
|
.post("/dashboard/monitors/:id/edit", async ({ cookie, headers, params, body }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
|
|
@ -519,7 +490,6 @@ export const dashboard = new Elysia()
|
||||||
return redirect(`/dashboard/monitors/${params.id}`);
|
return redirect(`/dashboard/monitors/${params.id}`);
|
||||||
})
|
})
|
||||||
|
|
||||||
// Delete monitor via form POST
|
|
||||||
.post("/dashboard/monitors/:id/delete", async ({ cookie, headers, params }) => {
|
.post("/dashboard/monitors/:id/delete", async ({ cookie, headers, params }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
|
|
@ -534,7 +504,6 @@ export const dashboard = new Elysia()
|
||||||
return redirect("/dashboard/home");
|
return redirect("/dashboard/home");
|
||||||
})
|
})
|
||||||
|
|
||||||
// Toggle monitor via form POST
|
|
||||||
.post("/dashboard/monitors/:id/toggle", async ({ cookie, headers, params }) => {
|
.post("/dashboard/monitors/:id/toggle", async ({ cookie, headers, params }) => {
|
||||||
const resolved = await getAccountId(cookie, headers);
|
const resolved = await getAccountId(cookie, headers);
|
||||||
if (!resolved?.accountId) return redirect("/dashboard");
|
if (!resolved?.accountId) return redirect("/dashboard");
|
||||||
|
|
@ -549,7 +518,6 @@ export const dashboard = new Elysia()
|
||||||
return redirect(`/dashboard/monitors/${params.id}`);
|
return redirect(`/dashboard/monitors/${params.id}`);
|
||||||
})
|
})
|
||||||
|
|
||||||
// Docs
|
|
||||||
.get("/docs", () => html("docs", {}))
|
.get("/docs", () => html("docs", {}))
|
||||||
.get("/privacy", () => html("privacy", {}))
|
.get("/privacy", () => html("privacy", {}))
|
||||||
.get("/terms", () => html("tos", {}));
|
.get("/terms", () => html("tos", {}));
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,10 @@ export function sparkline(values: number[], width = 120, height = 32, color = '#
|
||||||
|
|
||||||
import { REGION_COLORS } from "../../../shared/plans";
|
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 } {
|
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);
|
const withLatency = pings.filter(p => p.latency_ms != null);
|
||||||
if (!withLatency.length) return { region: '__none__', values: [], latest: null };
|
if (!withLatency.length) return { region: '__none__', values: [], latest: null };
|
||||||
|
|
||||||
// Group all pings by region
|
|
||||||
const byRegion: Record<string, number[]> = {};
|
const byRegion: Record<string, number[]> = {};
|
||||||
for (const p of withLatency) {
|
for (const p of withLatency) {
|
||||||
const key = p.region || '__none__';
|
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!);
|
byRegion[key].push(p.latency_ms!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only consider regions that appear in the 3 most recent pings
|
|
||||||
const recentRegions = new Set(
|
const recentRegions = new Set(
|
||||||
withLatency.slice(-3).map(p => p.region || '__none__')
|
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 };
|
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 {
|
export function sparklineFromPings(pings: Array<{latency_ms?: number|null, region?: string|null}>, width = 120, height = 32): string {
|
||||||
const { region, values } = pickBestRegion(pings);
|
const { region, values } = pickBestRegion(pings);
|
||||||
if (!values.length) return '';
|
if (!values.length) return '';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue