From 3368dbdd7ff410bab5f4b3e533b5dfa650a7f293 Mon Sep 17 00:00:00 2001 From: M1 Date: Mon, 16 Mar 2026 15:30:35 +0400 Subject: [PATCH] feat: custom method, headers, body, timeout on monitors --- apps/monitor/src/runner.rs | 26 +++++++- apps/monitor/src/types.rs | 25 ++++---- apps/web/src/db.ts | 26 +++++--- apps/web/src/routes/internal.ts | 2 +- apps/web/src/routes/monitors.ts | 36 +++++++---- apps/web/src/views/new.ejs | 103 +++++++++++++++++++++++++++----- 6 files changed, 173 insertions(+), 45 deletions(-) diff --git a/apps/monitor/src/runner.rs b/apps/monitor/src/runner.rs index 4da5195..96a2b16 100644 --- a/apps/monitor/src/runner.rs +++ b/apps/monitor/src/runner.rs @@ -52,7 +52,31 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> PingResult { None }; - let result = client.get(&monitor.url).send().await; + // Build request with method, headers, body, timeout + let method = monitor.method.as_deref().unwrap_or("GET").to_uppercase(); + let timeout = std::time::Duration::from_millis(monitor.timeout_ms.unwrap_or(30000)); + + let req_method = reqwest::Method::from_bytes(method.as_bytes()) + .unwrap_or(reqwest::Method::GET); + + let mut req = client.request(req_method, &monitor.url).timeout(timeout); + + if let Some(headers) = &monitor.request_headers { + for (k, v) in headers { + if let (Ok(name), Ok(value)) = ( + reqwest::header::HeaderName::from_bytes(k.as_bytes()), + reqwest::header::HeaderValue::from_str(v), + ) { + req = req.header(name, value); + } + } + } + + if let Some(body) = &monitor.request_body { + req = req.body(body.clone()); + } + + let result = req.send().await; let latency_ms = start.elapsed().as_millis() as u64; match result { diff --git a/apps/monitor/src/types.rs b/apps/monitor/src/types.rs index d44a326..16d9ca5 100644 --- a/apps/monitor/src/types.rs +++ b/apps/monitor/src/types.rs @@ -1,21 +1,26 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashMap; #[derive(Debug, Deserialize)] pub struct Monitor { - pub id: String, - pub url: String, - pub interval_s: i64, - pub query: Option, + pub id: String, + pub url: String, + pub method: Option, + pub request_headers: Option>, + pub request_body: Option, + pub timeout_ms: Option, + pub interval_s: i64, + pub query: Option, } #[derive(Debug, Serialize)] pub struct PingResult { - pub monitor_id: String, - pub status_code: Option, - pub latency_ms: Option, - pub up: bool, - pub error: Option, + pub monitor_id: String, + pub status_code: Option, + pub latency_ms: Option, + pub up: bool, + pub error: Option, pub cert_expiry_days: Option, - pub meta: Option, + pub meta: Option, } diff --git a/apps/web/src/db.ts b/apps/web/src/db.ts index 14e4a84..602acbd 100644 --- a/apps/web/src/db.ts +++ b/apps/web/src/db.ts @@ -16,17 +16,27 @@ export async function migrate() { await sql` CREATE TABLE IF NOT EXISTS monitors ( - id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, - account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, - name TEXT NOT NULL, - url TEXT NOT NULL, - interval_s INTEGER NOT NULL DEFAULT 60, -- check interval in seconds - query JSONB, -- pingql query filter - enabled BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ DEFAULT now() + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + url TEXT NOT NULL, + method TEXT NOT NULL DEFAULT 'GET', + request_headers JSONB, -- { "key": "value", ... } + request_body TEXT, -- raw body for POST/PUT/PATCH + timeout_ms INTEGER NOT NULL DEFAULT 30000, + interval_s INTEGER NOT NULL DEFAULT 60, + query JSONB, + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ DEFAULT now() ) `; + // Add new columns to existing installs + await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS method TEXT NOT NULL DEFAULT 'GET'`; + await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS request_headers JSONB`; + await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS request_body TEXT`; + await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS timeout_ms INTEGER NOT NULL DEFAULT 30000`; + await sql` CREATE TABLE IF NOT EXISTS pings ( id BIGSERIAL PRIMARY KEY, diff --git a/apps/web/src/routes/internal.ts b/apps/web/src/routes/internal.ts index a5da71c..b5f03ec 100644 --- a/apps/web/src/routes/internal.ts +++ b/apps/web/src/routes/internal.ts @@ -15,7 +15,7 @@ export const internal = new Elysia({ prefix: "/internal", detail: { hide: true } .get("/due", async () => { return sql` - SELECT m.id, m.url, m.interval_s, m.query + SELECT m.id, m.url, m.method, m.request_headers, m.request_body, m.timeout_ms, m.interval_s, m.query FROM monitors m LEFT JOIN LATERAL ( SELECT checked_at FROM pings diff --git a/apps/web/src/routes/monitors.ts b/apps/web/src/routes/monitors.ts index 84b5318..ce92cab 100644 --- a/apps/web/src/routes/monitors.ts +++ b/apps/web/src/routes/monitors.ts @@ -3,10 +3,14 @@ import { requireAuth } from "./auth"; import sql from "../db"; const MonitorBody = t.Object({ - name: t.String({ description: "Human-readable name" }), - url: t.String({ format: "uri", description: "URL to check" }), - interval_s: t.Optional(t.Number({ minimum: 10, default: 60, description: "Check interval in seconds" })), - query: t.Optional(t.Any({ description: "PingQL query — filter conditions for up/down" })), + name: t.String({ description: "Human-readable name" }), + url: t.String({ format: "uri", description: "URL to check" }), + method: t.Optional(t.String({ default: "GET", description: "HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD" })), + request_headers: t.Optional(t.Any({ description: "Request headers as key-value object" })), + request_body: t.Optional(t.Nullable(t.String({ description: "Request body for POST/PUT/PATCH" }))), + timeout_ms: t.Optional(t.Number({ minimum: 1000, maximum: 60000, default: 30000, description: "Request timeout in ms" })), + interval_s: t.Optional(t.Number({ minimum: 10, default: 60, description: "Check interval in seconds" })), + query: t.Optional(t.Any({ description: "PingQL query — filter conditions for up/down" })), }); export const monitors = new Elysia({ prefix: "/monitors" }) @@ -20,8 +24,16 @@ export const monitors = new Elysia({ prefix: "/monitors" }) // Create monitor .post("/", async ({ accountId, body }) => { const [monitor] = await sql` - INSERT INTO monitors (account_id, name, url, interval_s, query) - VALUES (${accountId}, ${body.name}, ${body.url}, ${body.interval_s ?? 60}, ${body.query ? sql.json(body.query) : null}) + INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, query) + VALUES ( + ${accountId}, ${body.name}, ${body.url}, + ${(body.method ?? 'GET').toUpperCase()}, + ${body.request_headers ? sql.json(body.request_headers) : null}, + ${body.request_body ?? null}, + ${body.timeout_ms ?? 30000}, + ${body.interval_s ?? 60}, + ${body.query ? sql.json(body.query) : null} + ) RETURNING * `; return monitor; @@ -45,10 +57,14 @@ export const monitors = new Elysia({ prefix: "/monitors" }) .patch("/:id", async ({ accountId, params, body, error }) => { const [monitor] = await sql` UPDATE monitors SET - name = COALESCE(${body.name ?? null}, name), - url = COALESCE(${body.url ?? null}, url), - interval_s = COALESCE(${body.interval_s ?? null}, interval_s), - query = COALESCE(${body.query ? sql.json(body.query) : null}, query) + name = COALESCE(${body.name ?? null}, name), + url = COALESCE(${body.url ?? null}, url), + method = COALESCE(${body.method ? body.method.toUpperCase() : null}, method), + request_headers = COALESCE(${body.request_headers ? sql.json(body.request_headers) : null}, request_headers), + request_body = COALESCE(${body.request_body ?? null}, request_body), + timeout_ms = COALESCE(${body.timeout_ms ?? null}, timeout_ms), + interval_s = COALESCE(${body.interval_s ?? null}, interval_s), + query = COALESCE(${body.query ? sql.json(body.query) : null}, query) WHERE id = ${params.id} AND account_id = ${accountId} RETURNING * `; diff --git a/apps/web/src/views/new.ejs b/apps/web/src/views/new.ejs index c503109..ac8475f 100644 --- a/apps/web/src/views/new.ejs +++ b/apps/web/src/views/new.ejs @@ -16,22 +16,62 @@
- +
+ + +
+
- - +
+ + +
+
+
+ + + + +
+
+ + +
+
+ + +
@@ -53,6 +93,27 @@ if (!requireAuth()) throw 'auth'; let currentQuery = null; + // Show body section for non-GET methods + const methodSel = document.getElementById('method'); + const bodySection = document.getElementById('body-section'); + function updateBodyVisibility() { + const m = methodSel.value; + bodySection.classList.toggle('hidden', ['GET','HEAD','OPTIONS'].includes(m)); + } + methodSel.addEventListener('change', updateBodyVisibility); + + // Dynamic headers + document.getElementById('add-header').addEventListener('click', () => { + const row = document.createElement('div'); + row.className = 'header-row flex gap-2'; + row.innerHTML = ` + + + + `; + document.getElementById('headers-list').appendChild(row); + }); + const qb = new QueryBuilder(document.getElementById('query-builder'), (q) => { currentQuery = q; }); @@ -66,11 +127,23 @@ btn.textContent = 'Creating...'; try { + const headers = {}; + document.querySelectorAll('.header-row').forEach(row => { + const k = row.querySelector('.hk').value.trim(); + const v = row.querySelector('.hv').value.trim(); + if (k) headers[k] = v; + }); + const body = { - name: document.getElementById('name').value.trim(), - url: document.getElementById('url').value.trim(), + name: document.getElementById('name').value.trim(), + url: document.getElementById('url').value.trim(), + method: document.getElementById('method').value, interval_s: Number(document.getElementById('interval').value), + timeout_ms: Number(document.getElementById('timeout').value), }; + if (Object.keys(headers).length) body.request_headers = headers; + const rb = document.getElementById('request-body').value.trim(); + if (rb) body.request_body = rb; if (currentQuery) body.query = currentQuery; await api('/monitors/', { method: 'POST', body });