feat: custom method, headers, body, timeout on monitors
This commit is contained in:
parent
d98aa5e46f
commit
3368dbdd7f
|
|
@ -52,7 +52,31 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> PingResult {
|
||||||
None
|
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;
|
let latency_ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Monitor {
|
pub struct Monitor {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub interval_s: i64,
|
pub method: Option<String>,
|
||||||
pub query: Option<Value>,
|
pub request_headers: Option<HashMap<String, String>>,
|
||||||
|
pub request_body: Option<String>,
|
||||||
|
pub timeout_ms: Option<u64>,
|
||||||
|
pub interval_s: i64,
|
||||||
|
pub query: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct PingResult {
|
pub struct PingResult {
|
||||||
pub monitor_id: String,
|
pub monitor_id: String,
|
||||||
pub status_code: Option<u16>,
|
pub status_code: Option<u16>,
|
||||||
pub latency_ms: Option<u64>,
|
pub latency_ms: Option<u64>,
|
||||||
pub up: bool,
|
pub up: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub cert_expiry_days: Option<i64>,
|
pub cert_expiry_days: Option<i64>,
|
||||||
pub meta: Option<Value>,
|
pub meta: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,27 @@ export async function migrate() {
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS monitors (
|
CREATE TABLE IF NOT EXISTS monitors (
|
||||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
url TEXT NOT NULL,
|
url TEXT NOT NULL,
|
||||||
interval_s INTEGER NOT NULL DEFAULT 60, -- check interval in seconds
|
method TEXT NOT NULL DEFAULT 'GET',
|
||||||
query JSONB, -- pingql query filter
|
request_headers JSONB, -- { "key": "value", ... }
|
||||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
request_body TEXT, -- raw body for POST/PUT/PATCH
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
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`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS pings (
|
CREATE TABLE IF NOT EXISTS pings (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export const internal = new Elysia({ prefix: "/internal", detail: { hide: true }
|
||||||
.get("/due", async () => {
|
.get("/due", async () => {
|
||||||
|
|
||||||
return sql`
|
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
|
FROM monitors m
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT checked_at FROM pings
|
SELECT checked_at FROM pings
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,14 @@ import { requireAuth } from "./auth";
|
||||||
import sql from "../db";
|
import sql from "../db";
|
||||||
|
|
||||||
const MonitorBody = t.Object({
|
const MonitorBody = t.Object({
|
||||||
name: t.String({ description: "Human-readable name" }),
|
name: t.String({ description: "Human-readable name" }),
|
||||||
url: t.String({ format: "uri", description: "URL to check" }),
|
url: t.String({ format: "uri", description: "URL to check" }),
|
||||||
interval_s: t.Optional(t.Number({ minimum: 10, default: 60, description: "Check interval in seconds" })),
|
method: t.Optional(t.String({ default: "GET", description: "HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD" })),
|
||||||
query: t.Optional(t.Any({ description: "PingQL query — filter conditions for up/down" })),
|
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" })
|
export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
|
|
@ -20,8 +24,16 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
// Create monitor
|
// Create monitor
|
||||||
.post("/", async ({ accountId, body }) => {
|
.post("/", async ({ accountId, body }) => {
|
||||||
const [monitor] = await sql`
|
const [monitor] = await sql`
|
||||||
INSERT INTO monitors (account_id, name, url, interval_s, query)
|
INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, query)
|
||||||
VALUES (${accountId}, ${body.name}, ${body.url}, ${body.interval_s ?? 60}, ${body.query ? sql.json(body.query) : null})
|
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 *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
return monitor;
|
return monitor;
|
||||||
|
|
@ -45,10 +57,14 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
.patch("/:id", async ({ accountId, params, body, error }) => {
|
.patch("/:id", async ({ accountId, params, body, error }) => {
|
||||||
const [monitor] = await sql`
|
const [monitor] = await sql`
|
||||||
UPDATE monitors SET
|
UPDATE monitors SET
|
||||||
name = COALESCE(${body.name ?? null}, name),
|
name = COALESCE(${body.name ?? null}, name),
|
||||||
url = COALESCE(${body.url ?? null}, url),
|
url = COALESCE(${body.url ?? null}, url),
|
||||||
interval_s = COALESCE(${body.interval_s ?? null}, interval_s),
|
method = COALESCE(${body.method ? body.method.toUpperCase() : null}, method),
|
||||||
query = COALESCE(${body.query ? sql.json(body.query) : null}, query)
|
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}
|
WHERE id = ${params.id} AND account_id = ${accountId}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -16,22 +16,62 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">URL</label>
|
<label class="block text-sm text-gray-400 mb-1.5">URL</label>
|
||||||
<input id="url" type="url" required placeholder="https://api.example.com/health"
|
<div class="flex gap-2">
|
||||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
<select id="method"
|
||||||
|
class="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500 font-mono text-sm">
|
||||||
|
<option>GET</option>
|
||||||
|
<option>POST</option>
|
||||||
|
<option>PUT</option>
|
||||||
|
<option>PATCH</option>
|
||||||
|
<option>DELETE</option>
|
||||||
|
<option>HEAD</option>
|
||||||
|
<option>OPTIONS</option>
|
||||||
|
</select>
|
||||||
|
<input id="url" type="url" required placeholder="https://api.example.com/health"
|
||||||
|
class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Headers -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1.5">Ping Interval</label>
|
<div class="flex items-center justify-between mb-1.5">
|
||||||
<select id="interval"
|
<label class="text-sm text-gray-400">Headers <span class="text-gray-600">(optional)</span></label>
|
||||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
<button type="button" id="add-header" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">+ Add header</button>
|
||||||
<option value="10">10 seconds</option>
|
</div>
|
||||||
<option value="30">30 seconds</option>
|
<div id="headers-list" class="space-y-2"></div>
|
||||||
<option value="60" selected>1 minute</option>
|
</div>
|
||||||
<option value="300">5 minutes</option>
|
|
||||||
<option value="600">10 minutes</option>
|
<!-- Request Body -->
|
||||||
<option value="1800">30 minutes</option>
|
<div id="body-section" class="hidden">
|
||||||
<option value="3600">1 hour</option>
|
<label class="block text-sm text-gray-400 mb-1.5">Request Body <span class="text-gray-600">(optional)</span></label>
|
||||||
</select>
|
<textarea id="request-body" rows="4" placeholder='{"key": "value"}'
|
||||||
|
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 font-mono text-sm resize-y"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Ping Interval</label>
|
||||||
|
<select id="interval"
|
||||||
|
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<option value="10">10 seconds</option>
|
||||||
|
<option value="30">30 seconds</option>
|
||||||
|
<option value="60" selected>1 minute</option>
|
||||||
|
<option value="300">5 minutes</option>
|
||||||
|
<option value="600">10 minutes</option>
|
||||||
|
<option value="1800">30 minutes</option>
|
||||||
|
<option value="3600">1 hour</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm text-gray-400 mb-1.5">Timeout</label>
|
||||||
|
<select id="timeout"
|
||||||
|
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||||
|
<option value="5000">5 seconds</option>
|
||||||
|
<option value="10000">10 seconds</option>
|
||||||
|
<option value="30000" selected>30 seconds</option>
|
||||||
|
<option value="60000">60 seconds</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -53,6 +93,27 @@
|
||||||
if (!requireAuth()) throw 'auth';
|
if (!requireAuth()) throw 'auth';
|
||||||
|
|
||||||
let currentQuery = null;
|
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 = `
|
||||||
|
<input type="text" placeholder="Header name" class="hk flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||||
|
<input type="text" placeholder="Value" class="hv flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()" class="px-2 text-gray-600 hover:text-red-400 transition-colors text-sm">✕</button>
|
||||||
|
`;
|
||||||
|
document.getElementById('headers-list').appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
const qb = new QueryBuilder(document.getElementById('query-builder'), (q) => {
|
const qb = new QueryBuilder(document.getElementById('query-builder'), (q) => {
|
||||||
currentQuery = q;
|
currentQuery = q;
|
||||||
});
|
});
|
||||||
|
|
@ -66,11 +127,23 @@
|
||||||
btn.textContent = 'Creating...';
|
btn.textContent = 'Creating...';
|
||||||
|
|
||||||
try {
|
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 = {
|
const body = {
|
||||||
name: document.getElementById('name').value.trim(),
|
name: document.getElementById('name').value.trim(),
|
||||||
url: document.getElementById('url').value.trim(),
|
url: document.getElementById('url').value.trim(),
|
||||||
|
method: document.getElementById('method').value,
|
||||||
interval_s: Number(document.getElementById('interval').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;
|
if (currentQuery) body.query = currentQuery;
|
||||||
|
|
||||||
await api('/monitors/', { method: 'POST', body });
|
await api('/monitors/', { method: 'POST', body });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue