feat: dashboard, visual query builder, expanded query language, cert expiry support
This commit is contained in:
parent
97c08b1951
commit
500132ba05
|
|
@ -14,3 +14,8 @@ regex = "1"
|
|||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
rustls = "0.23"
|
||||
rustls-native-certs = "0.8"
|
||||
webpki-roots = "0.26"
|
||||
x509-parser = "0.16"
|
||||
tokio-rustls = "0.26"
|
||||
|
|
|
|||
|
|
@ -10,14 +10,28 @@
|
|||
/// { "status": { "$ne": 500 } }
|
||||
/// { "status": { "$gte": 200, "$lt": 300 } }
|
||||
/// { "body": { "$contains": "healthy" } }
|
||||
/// { "body": { "$startsWith": "OK" } }
|
||||
/// { "body": { "$endsWith": "done" } }
|
||||
/// { "body": { "$regex": "ok|healthy" } }
|
||||
/// { "body": { "$exists": true } }
|
||||
/// { "status": { "$in": [200, 201, 204] } }
|
||||
///
|
||||
/// CSS selector (HTML parsing):
|
||||
/// { "$select": "span.status", "$eq": "operational" }
|
||||
///
|
||||
/// JSONPath:
|
||||
/// { "$json": "$.data.status", "$eq": "ok" }
|
||||
///
|
||||
/// Response time:
|
||||
/// { "$responseTime": { "$lt": 500 } }
|
||||
///
|
||||
/// Certificate expiry:
|
||||
/// { "$certExpiry": { "$gt": 30 } }
|
||||
///
|
||||
/// Logical:
|
||||
/// { "$and": [ { "status": 200 }, { "body": { "$contains": "ok" } } ] }
|
||||
/// { "$or": [ { "status": 200 }, { "status": 204 } ] }
|
||||
/// { "$not": { "status": 500 } }
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use regex::Regex;
|
||||
|
|
@ -28,13 +42,15 @@ pub struct Response {
|
|||
pub status: u16,
|
||||
pub body: String,
|
||||
pub headers: std::collections::HashMap<String, String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub cert_expiry_days: Option<i64>,
|
||||
}
|
||||
|
||||
/// Returns true if `query` matches `response`. No query = always up.
|
||||
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
||||
match query {
|
||||
Value::Object(map) => {
|
||||
// $and / $or
|
||||
// $and / $or / $not
|
||||
if let Some(and) = map.get("$and") {
|
||||
let Value::Array(clauses) = and else { bail!("$and expects array") };
|
||||
return Ok(clauses.iter().all(|c| evaluate(c, response).unwrap_or(false)));
|
||||
|
|
@ -43,6 +59,33 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
|||
let Value::Array(clauses) = or else { bail!("$or expects array") };
|
||||
return Ok(clauses.iter().any(|c| evaluate(c, response).unwrap_or(false)));
|
||||
}
|
||||
if let Some(not) = map.get("$not") {
|
||||
return Ok(!evaluate(not, response)?);
|
||||
}
|
||||
|
||||
// $responseTime
|
||||
if let Some(cond) = map.get("$responseTime") {
|
||||
let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0)));
|
||||
return eval_condition(cond, &val, response);
|
||||
}
|
||||
|
||||
// $certExpiry
|
||||
if let Some(cond) = map.get("$certExpiry") {
|
||||
let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0)));
|
||||
return eval_condition(cond, &val, response);
|
||||
}
|
||||
|
||||
// $json — JSONPath shorthand
|
||||
if let Some(json_path) = map.get("$json") {
|
||||
let path_str = json_path.as_str().unwrap_or("");
|
||||
let resolved = resolve_json_path(&response.body, path_str);
|
||||
for (op, val) in map {
|
||||
if op == "$json" { continue; }
|
||||
if !eval_op(op, &resolved, val, response)? { return Ok(false); }
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// CSS selector shorthand: { "$select": "...", "$eq": "..." }
|
||||
if let Some(sel) = map.get("$select") {
|
||||
let sel_str = sel.as_str().unwrap_or("");
|
||||
|
|
@ -50,12 +93,29 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
|||
if let Some(op_val) = map.get("$eq") {
|
||||
return Ok(selected.as_deref() == op_val.as_str());
|
||||
}
|
||||
if let Some(op_val) = map.get("$ne") {
|
||||
return Ok(selected.as_deref() != op_val.as_str());
|
||||
}
|
||||
if let Some(op_val) = map.get("$contains") {
|
||||
let needle = op_val.as_str().unwrap_or("");
|
||||
return Ok(selected.map(|s| s.contains(needle)).unwrap_or(false));
|
||||
}
|
||||
if let Some(op_val) = map.get("$startsWith") {
|
||||
let needle = op_val.as_str().unwrap_or("");
|
||||
return Ok(selected.map(|s| s.starts_with(needle)).unwrap_or(false));
|
||||
}
|
||||
if let Some(op_val) = map.get("$endsWith") {
|
||||
let needle = op_val.as_str().unwrap_or("");
|
||||
return Ok(selected.map(|s| s.ends_with(needle)).unwrap_or(false));
|
||||
}
|
||||
if let Some(op_val) = map.get("$regex") {
|
||||
let pattern = op_val.as_str().unwrap_or("");
|
||||
let re = Regex::new(pattern).unwrap_or_else(|_| Regex::new("$^").unwrap());
|
||||
return Ok(selected.map(|s| re.is_match(&s)).unwrap_or(false));
|
||||
}
|
||||
return Ok(selected.is_some());
|
||||
}
|
||||
|
||||
// Field-level checks
|
||||
for (field, condition) in map {
|
||||
let field_val = resolve_field(field, response);
|
||||
|
|
@ -83,6 +143,43 @@ fn resolve_field(field: &str, r: &Response) -> Value {
|
|||
}
|
||||
}
|
||||
|
||||
fn resolve_json_path(body: &str, path: &str) -> Value {
|
||||
let obj: Value = match serde_json::from_str(body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Value::Null,
|
||||
};
|
||||
let path = path.trim_start_matches("$").trim_start_matches(".");
|
||||
if path.is_empty() { return obj; }
|
||||
let mut current = &obj;
|
||||
for part in path.split('.') {
|
||||
// Handle array indexing like "items[0]"
|
||||
if let Some(idx_start) = part.find('[') {
|
||||
let key = &part[..idx_start];
|
||||
if !key.is_empty() {
|
||||
current = match current.get(key) {
|
||||
Some(v) => v,
|
||||
None => return Value::Null,
|
||||
};
|
||||
}
|
||||
let idx_str = part[idx_start + 1..].trim_end_matches(']');
|
||||
if let Ok(idx) = idx_str.parse::<usize>() {
|
||||
current = match current.get(idx) {
|
||||
Some(v) => v,
|
||||
None => return Value::Null,
|
||||
};
|
||||
} else {
|
||||
return Value::Null;
|
||||
}
|
||||
} else {
|
||||
current = match current.get(part) {
|
||||
Some(v) => v,
|
||||
None => return Value::Null,
|
||||
};
|
||||
}
|
||||
}
|
||||
current.clone()
|
||||
}
|
||||
|
||||
fn eval_condition(condition: &Value, field_val: &Value, response: &Response) -> Result<bool> {
|
||||
match condition {
|
||||
// Shorthand: { "status": 200 }
|
||||
|
|
@ -91,7 +188,18 @@ fn eval_condition(condition: &Value, field_val: &Value, response: &Response) ->
|
|||
Value::Bool(b) => Ok(field_val.as_bool() == Some(*b)),
|
||||
Value::Object(ops) => {
|
||||
for (op, val) in ops {
|
||||
let ok = match op.as_str() {
|
||||
if !eval_op(op, field_val, val, response)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(true),
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_op(op: &str, field_val: &Value, val: &Value, response: &Response) -> Result<bool> {
|
||||
let ok = match op {
|
||||
"$eq" => field_val == val,
|
||||
"$ne" => field_val != val,
|
||||
"$gt" => cmp_num(field_val, val, |a,b| a > b),
|
||||
|
|
@ -102,29 +210,41 @@ fn eval_condition(condition: &Value, field_val: &Value, response: &Response) ->
|
|||
let needle = val.as_str().unwrap_or("");
|
||||
field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false)
|
||||
}
|
||||
"$startsWith" => {
|
||||
let needle = val.as_str().unwrap_or("");
|
||||
field_val.as_str().map(|s| s.starts_with(needle)).unwrap_or(false)
|
||||
}
|
||||
"$endsWith" => {
|
||||
let needle = val.as_str().unwrap_or("");
|
||||
field_val.as_str().map(|s| s.ends_with(needle)).unwrap_or(false)
|
||||
}
|
||||
"$regex" => {
|
||||
let pattern = val.as_str().unwrap_or("");
|
||||
let re = Regex::new(pattern).unwrap_or_else(|_| Regex::new("$^").unwrap());
|
||||
field_val.as_str().map(|s| re.is_match(s)).unwrap_or(false)
|
||||
}
|
||||
"$exists" => {
|
||||
let should_exist = val.as_bool().unwrap_or(true);
|
||||
let exists = !field_val.is_null();
|
||||
exists == should_exist
|
||||
}
|
||||
"$in" => {
|
||||
if let Value::Array(arr) = val {
|
||||
arr.contains(field_val)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"$select" => {
|
||||
// Nested: { "body": { "$select": "css", "$eq": "val" } }
|
||||
let sel_str = val.as_str().unwrap_or("");
|
||||
let selected = css_select(&response.body, sel_str);
|
||||
if let Some(eq_val) = ops.get("$eq") {
|
||||
selected.as_deref() == eq_val.as_str()
|
||||
} else {
|
||||
// If no comparison operator follows, just check existence
|
||||
selected.is_some()
|
||||
}
|
||||
}
|
||||
_ => true, // unknown op — skip
|
||||
};
|
||||
if !ok { return Ok(false); }
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(true),
|
||||
}
|
||||
Ok(ok)
|
||||
}
|
||||
|
||||
fn cmp_num(a: &Value, b: &Value, f: impl Fn(f64, f64) -> bool) -> bool {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
use crate::query::{self, Response};
|
||||
use crate::types::{CheckResult, Monitor};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
|
|
@ -44,6 +45,13 @@ pub async fn fetch_and_run(
|
|||
async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
|
||||
let start = Instant::now();
|
||||
|
||||
// Check cert expiry for HTTPS URLs
|
||||
let cert_expiry_days = if monitor.url.starts_with("https://") {
|
||||
check_cert_expiry(&monitor.url).await.ok().flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = client.get(&monitor.url).send().await;
|
||||
let latency_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
|
|
@ -54,6 +62,7 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
|
|||
latency_ms: Some(latency_ms),
|
||||
up: false,
|
||||
error: Some(e.to_string()),
|
||||
cert_expiry_days,
|
||||
meta: None,
|
||||
},
|
||||
Ok(resp) => {
|
||||
|
|
@ -66,7 +75,13 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
|
|||
|
||||
// Evaluate query if present
|
||||
let (up, query_error) = if let Some(q) = &monitor.query {
|
||||
let response = Response { status, body: body.clone(), headers: headers.clone() };
|
||||
let response = Response {
|
||||
status,
|
||||
body: body.clone(),
|
||||
headers: headers.clone(),
|
||||
latency_ms: Some(latency_ms),
|
||||
cert_expiry_days,
|
||||
};
|
||||
match query::evaluate(q, &response) {
|
||||
Ok(result) => (result, None),
|
||||
Err(e) => {
|
||||
|
|
@ -93,12 +108,59 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
|
|||
latency_ms: Some(latency_ms),
|
||||
up,
|
||||
error: query_error,
|
||||
cert_expiry_days,
|
||||
meta: Some(meta),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check SSL certificate expiry for a given HTTPS URL.
|
||||
/// Returns the number of days until the certificate expires.
|
||||
async fn check_cert_expiry(url: &str) -> Result<Option<i64>> {
|
||||
use rustls::ClientConfig;
|
||||
use rustls::pki_types::ServerName;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use x509_parser::prelude::*;
|
||||
|
||||
// Parse host and port from URL
|
||||
let url_parsed = reqwest::Url::parse(url)?;
|
||||
let host = url_parsed.host_str().unwrap_or("");
|
||||
let port = url_parsed.port().unwrap_or(443);
|
||||
|
||||
// Build a rustls config that captures certificates
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
let config = ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector = TlsConnector::from(Arc::new(config));
|
||||
let server_name = ServerName::try_from(host.to_string())?;
|
||||
|
||||
let stream = TcpStream::connect(format!("{host}:{port}")).await?;
|
||||
let tls_stream = connector.connect(server_name, stream).await?;
|
||||
|
||||
// Get peer certificates
|
||||
let (_, conn) = tls_stream.get_ref();
|
||||
let certs = conn.peer_certificates().unwrap_or(&[]);
|
||||
|
||||
if let Some(cert_der) = certs.first() {
|
||||
let (_, cert) = X509Certificate::from_der(cert_der.as_ref())?;
|
||||
let not_after = cert.validity().not_after.timestamp();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let days = (not_after - now) / 86400;
|
||||
return Ok(Some(days));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn post_result(
|
||||
client: &reqwest::Client,
|
||||
coordinator_url: &str,
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@ pub struct CheckResult {
|
|||
pub latency_ms: Option<u64>,
|
||||
pub up: bool,
|
||||
pub error: Option<String>,
|
||||
pub cert_expiry_days: Option<i64>,
|
||||
pub meta: Option<Value>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
// PingQL Dashboard — shared utilities
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
function getAccountKey() {
|
||||
return localStorage.getItem('pingql_key');
|
||||
}
|
||||
|
||||
function setAccountKey(key) {
|
||||
localStorage.setItem('pingql_key', key);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('pingql_key');
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
|
||||
function requireAuth() {
|
||||
if (!getAccountKey()) {
|
||||
window.location.href = '/dashboard';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const key = getAccountKey();
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...opts,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
||||
...opts.headers,
|
||||
},
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
logout();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'API error');
|
||||
return data;
|
||||
}
|
||||
|
||||
// Format relative time
|
||||
function timeAgo(date) {
|
||||
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
|
||||
// Render a tiny sparkline SVG from latency values
|
||||
function sparkline(values, width = 120, height = 32) {
|
||||
if (!values.length) return '';
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const step = width / Math.max(values.length - 1, 1);
|
||||
const points = values.map((v, i) => {
|
||||
const x = i * step;
|
||||
const y = height - ((v - min) / range) * (height - 4) - 2;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return `<svg width="${width}" height="${height}" class="inline-block"><polyline points="${points}" fill="none" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||
}
|
||||
|
||||
// Status badge
|
||||
function statusBadge(up) {
|
||||
if (up === true) return '<span class="inline-block w-2.5 h-2.5 rounded-full bg-green-400 mr-2" title="Up"></span>';
|
||||
if (up === false) return '<span class="inline-block w-2.5 h-2.5 rounded-full bg-red-400 mr-2" title="Down"></span>';
|
||||
return '<span class="inline-block w-2.5 h-2.5 rounded-full bg-gray-600 mr-2" title="Unknown"></span>';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PingQL — Monitor Detail</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
||||
<script src="/dashboard/app.js"></script>
|
||||
<script src="/dashboard/query-builder.js"></script>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
|
||||
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Logout</button>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-4xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/dashboard/home" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back</a>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="text-center py-16 text-gray-600">Loading...</div>
|
||||
<div id="content" class="hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="status-dot"></span>
|
||||
<h2 id="monitor-name" class="text-xl font-semibold text-gray-100"></h2>
|
||||
</div>
|
||||
<p id="monitor-url" class="text-sm text-gray-500 mt-1"></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="toggle-btn" class="text-sm px-4 py-2 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors"></button>
|
||||
<button id="delete-btn" class="text-sm px-4 py-2 rounded-lg border border-red-900/50 text-red-400 hover:bg-red-900/20 transition-colors">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Status</div>
|
||||
<div id="stat-status" class="text-lg font-semibold"></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Avg Latency</div>
|
||||
<div id="stat-latency" class="text-lg font-semibold text-gray-200"></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Uptime</div>
|
||||
<div id="stat-uptime" class="text-lg font-semibold text-gray-200"></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Last Check</div>
|
||||
<div id="stat-last" class="text-lg font-semibold text-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status history chart -->
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-8">
|
||||
<h3 class="text-sm text-gray-400 mb-3">Response Time</h3>
|
||||
<div id="latency-chart" class="h-32"></div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar (up/down timeline) -->
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-8">
|
||||
<h3 class="text-sm text-gray-400 mb-3">Status History</h3>
|
||||
<div id="status-bar" class="flex gap-0.5 h-8 rounded overflow-hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Recent checks table -->
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl mb-8 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-800">
|
||||
<h3 class="text-sm text-gray-400">Recent Checks</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-gray-500 text-xs">
|
||||
<th class="text-left px-4 py-2 font-medium">Status</th>
|
||||
<th class="text-left px-4 py-2 font-medium">Code</th>
|
||||
<th class="text-left px-4 py-2 font-medium">Latency</th>
|
||||
<th class="text-left px-4 py-2 font-medium">Time</th>
|
||||
<th class="text-left px-4 py-2 font-medium">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="checks-table" class="divide-y divide-gray-800/50"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit form -->
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-6">
|
||||
<h3 class="text-sm text-gray-400 mb-4">Edit Monitor</h3>
|
||||
<form id="edit-form" class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
||||
<input id="edit-name" type="text" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">URL</label>
|
||||
<input id="edit-url" type="url" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Interval</label>
|
||||
<select id="edit-interval" class="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<option value="10">10s</option>
|
||||
<option value="30">30s</option>
|
||||
<option value="60">1m</option>
|
||||
<option value="300">5m</option>
|
||||
<option value="600">10m</option>
|
||||
<option value="1800">30m</option>
|
||||
<option value="3600">1h</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Query</label>
|
||||
<div id="edit-query-builder"></div>
|
||||
</div>
|
||||
<div id="edit-error" class="text-red-400 text-sm hidden"></div>
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-6 py-2.5 rounded-lg transition-colors">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
if (!requireAuth()) throw 'auth';
|
||||
|
||||
const monitorId = window.location.pathname.split('/').pop();
|
||||
let editQuery = null;
|
||||
|
||||
const editQb = new QueryBuilder(document.getElementById('edit-query-builder'), (q) => {
|
||||
editQuery = q;
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api(`/monitors/${monitorId}`);
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
document.getElementById('content').classList.remove('hidden');
|
||||
|
||||
const results = data.results || [];
|
||||
const lastCheck = results[0];
|
||||
|
||||
// Header
|
||||
document.getElementById('monitor-name').textContent = data.name;
|
||||
document.getElementById('monitor-url').textContent = data.url;
|
||||
document.getElementById('status-dot').innerHTML = statusBadge(lastCheck?.up);
|
||||
|
||||
// Toggle button
|
||||
const toggleBtn = document.getElementById('toggle-btn');
|
||||
toggleBtn.textContent = data.enabled ? 'Pause' : 'Resume';
|
||||
toggleBtn.className = `text-sm px-4 py-2 rounded-lg border transition-colors ${data.enabled ? 'border-gray-700 hover:border-gray-600 text-gray-300' : 'border-green-800 hover:border-green-700 text-green-400'}`;
|
||||
toggleBtn.onclick = async () => {
|
||||
await api(`/monitors/${monitorId}/toggle`, { method: 'POST' });
|
||||
load();
|
||||
};
|
||||
|
||||
// Delete button
|
||||
document.getElementById('delete-btn').onclick = async () => {
|
||||
if (!confirm('Delete this monitor and all its check history?')) return;
|
||||
await api(`/monitors/${monitorId}`, { method: 'DELETE' });
|
||||
window.location.href = '/dashboard/home';
|
||||
};
|
||||
|
||||
// Stats
|
||||
const upChecks = results.filter(r => r.up);
|
||||
const latencies = results.filter(r => r.latency_ms != null).map(r => r.latency_ms);
|
||||
const avgLatency = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null;
|
||||
const uptime = results.length ? Math.round((upChecks.length / results.length) * 100) : null;
|
||||
|
||||
document.getElementById('stat-status').innerHTML = lastCheck
|
||||
? (lastCheck.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>')
|
||||
: '<span class="text-gray-500">—</span>';
|
||||
document.getElementById('stat-latency').textContent = avgLatency != null ? `${avgLatency}ms` : '—';
|
||||
document.getElementById('stat-uptime').textContent = uptime != null ? `${uptime}%` : '—';
|
||||
document.getElementById('stat-last').textContent = lastCheck ? timeAgo(lastCheck.checked_at) : '—';
|
||||
|
||||
// Latency chart
|
||||
renderLatencyChart(results.slice().reverse());
|
||||
|
||||
// Status bar
|
||||
const statusBar = document.getElementById('status-bar');
|
||||
const barChecks = results.slice(0, 60).reverse();
|
||||
statusBar.innerHTML = barChecks.map(c =>
|
||||
`<div class="flex-1 ${c.up ? 'bg-green-500/70' : 'bg-red-500/70'}" title="${new Date(c.checked_at).toLocaleString()} — ${c.up ? 'Up' : 'Down'} ${c.latency_ms ? c.latency_ms + 'ms' : ''}"></div>`
|
||||
).join('') || '<div class="flex-1 bg-gray-800 text-center text-xs text-gray-600 leading-8">No data</div>';
|
||||
|
||||
// Checks table
|
||||
document.getElementById('checks-table').innerHTML = results.slice(0, 30).map(c => `
|
||||
<tr class="hover:bg-gray-800/50">
|
||||
<td class="px-4 py-2">${c.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>'}</td>
|
||||
<td class="px-4 py-2 text-gray-300">${c.status_code ?? '—'}</td>
|
||||
<td class="px-4 py-2 text-gray-300">${c.latency_ms != null ? c.latency_ms + 'ms' : '—'}</td>
|
||||
<td class="px-4 py-2 text-gray-500">${timeAgo(c.checked_at)}</td>
|
||||
<td class="px-4 py-2 text-red-400/70 text-xs truncate max-w-[200px]">${c.error ? escapeHtml(c.error) : ''}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Edit form
|
||||
document.getElementById('edit-name').value = data.name;
|
||||
document.getElementById('edit-url').value = data.url;
|
||||
document.getElementById('edit-interval').value = String(data.interval_s);
|
||||
editQuery = data.query;
|
||||
editQb.setQuery(data.query);
|
||||
} catch (e) {
|
||||
document.getElementById('loading').innerHTML = `<span class="text-red-400">${escapeHtml(e.message)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLatencyChart(checks) {
|
||||
const container = document.getElementById('latency-chart');
|
||||
const data = checks.filter(c => c.latency_ms != null);
|
||||
if (data.length < 2) {
|
||||
container.innerHTML = '<div class="h-full flex items-center justify-center text-gray-600 text-sm">Not enough data</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const values = data.map(c => c.latency_ms);
|
||||
const ups = data.map(c => c.up);
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const w = container.clientWidth || 600;
|
||||
const h = 128;
|
||||
const step = w / Math.max(values.length - 1, 1);
|
||||
|
||||
const points = values.map((v, i) => {
|
||||
const x = i * step;
|
||||
const y = h - ((v - min) / range) * (h - 16) - 8;
|
||||
return [x, y];
|
||||
});
|
||||
|
||||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0]},${p[1]}`).join(' ');
|
||||
const areaD = pathD + ` L${points[points.length - 1][0]},${h} L${points[0][0]},${h} Z`;
|
||||
|
||||
// Dots for down events
|
||||
const dots = points.map((p, i) =>
|
||||
!ups[i] ? `<circle cx="${p[0]}" cy="${p[1]}" r="3" fill="#f87171"/>` : ''
|
||||
).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<svg width="${w}" height="${h}" class="w-full">
|
||||
<defs>
|
||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.15"/>
|
||||
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="${areaD}" fill="url(#areaGrad)"/>
|
||||
<path d="${pathD}" fill="none" stroke="#3b82f6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
${dots}
|
||||
<text x="4" y="12" fill="#6b7280" font-size="10">${max}ms</text>
|
||||
<text x="4" y="${h - 2}" fill="#6b7280" font-size="10">${min}ms</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
// Edit form submission
|
||||
document.getElementById('edit-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errEl = document.getElementById('edit-error');
|
||||
errEl.classList.add('hidden');
|
||||
try {
|
||||
const body = {
|
||||
name: document.getElementById('edit-name').value.trim(),
|
||||
url: document.getElementById('edit-url').value.trim(),
|
||||
interval_s: Number(document.getElementById('edit-interval').value),
|
||||
};
|
||||
if (editQuery) body.query = editQuery;
|
||||
await api(`/monitors/${monitorId}`, { method: 'PATCH', body });
|
||||
load();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
setInterval(load, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PingQL — Dashboard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
||||
<script src="/dashboard/app.js"></script>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
|
||||
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">+ New Monitor</a>
|
||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Logout</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="max-w-5xl mx-auto px-6 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-200">Monitors</h2>
|
||||
<div id="summary" class="text-sm text-gray-500"></div>
|
||||
</div>
|
||||
|
||||
<div id="monitors-list" class="space-y-3">
|
||||
<div class="text-center py-16 text-gray-600">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div id="empty-state" class="hidden text-center py-16">
|
||||
<p class="text-gray-500 mb-4">No monitors yet</p>
|
||||
<a href="/dashboard/monitors/new" class="bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium px-6 py-3 rounded-lg transition-colors inline-block">Create your first monitor</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
if (!requireAuth()) throw 'auth';
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const monitors = await api('/monitors/');
|
||||
const list = document.getElementById('monitors-list');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const summary = document.getElementById('summary');
|
||||
|
||||
if (monitors.length === 0) {
|
||||
list.classList.add('hidden');
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch last check for each monitor
|
||||
const monitorsWithChecks = await Promise.all(
|
||||
monitors.map(async (m) => {
|
||||
try {
|
||||
const checks = await api(`/checks/${m.id}?limit=20`);
|
||||
return { ...m, checks };
|
||||
} catch {
|
||||
return { ...m, checks: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const upCount = monitorsWithChecks.filter(m => m.checks[0]?.up === true).length;
|
||||
const downCount = monitorsWithChecks.filter(m => m.checks[0]?.up === false).length;
|
||||
summary.innerHTML = `<span class="text-green-400">${upCount} up</span> · <span class="${downCount > 0 ? 'text-red-400' : 'text-gray-500'}">${downCount} down</span> · ${monitors.length} total`;
|
||||
|
||||
list.innerHTML = monitorsWithChecks.map(m => {
|
||||
const lastCheck = m.checks[0];
|
||||
const latencies = m.checks.filter(c => c.latency_ms != null).map(c => c.latency_ms).reverse();
|
||||
const avgLatency = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null;
|
||||
|
||||
return `
|
||||
<a href="/dashboard/monitors/${m.id}" class="block bg-gray-900 hover:bg-gray-800/80 border border-gray-800 rounded-xl p-4 transition-colors group">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
${statusBadge(lastCheck?.up)}
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-gray-100 group-hover:text-white truncate">${escapeHtml(m.name)}</div>
|
||||
<div class="text-xs text-gray-500 truncate">${escapeHtml(m.url)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-6 shrink-0 ml-4">
|
||||
<div class="hidden sm:block">${sparkline(latencies)}</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-300">${avgLatency != null ? avgLatency + 'ms' : '—'}</div>
|
||||
<div class="text-xs text-gray-500">${lastCheck ? timeAgo(lastCheck.checked_at) : 'no checks'}</div>
|
||||
</div>
|
||||
<div class="text-xs px-2 py-1 rounded ${m.enabled ? 'bg-gray-800 text-gray-400' : 'bg-yellow-900/30 text-yellow-500'}">${m.enabled ? m.interval_s + 's' : 'paused'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
document.getElementById('monitors-list').innerHTML = `<div class="text-center py-8 text-red-400">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
setInterval(load, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PingQL — Login</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
||||
.glow { box-shadow: 0 0 40px rgba(59, 130, 246, 0.08); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></h1>
|
||||
<p class="text-gray-500 text-sm mt-2">Uptime monitoring for developers</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-900 rounded-xl p-6 glow border border-gray-800">
|
||||
<div id="login-form">
|
||||
<label class="block text-sm text-gray-400 mb-2">Account Key</label>
|
||||
<input id="key-input" type="text" placeholder="XXXX-XXXX-XXXX-XXXX"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 tracking-widest text-center text-lg"
|
||||
maxlength="19" autocomplete="off" spellcheck="false">
|
||||
<button id="login-btn"
|
||||
class="w-full mt-4 bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
|
||||
Sign In
|
||||
</button>
|
||||
<div id="login-error" class="text-red-400 text-sm mt-3 text-center hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-6 border-t border-gray-800 text-center">
|
||||
<p class="text-gray-500 text-sm">No account?</p>
|
||||
<button id="register-btn"
|
||||
class="mt-2 text-blue-400 hover:text-blue-300 text-sm font-medium transition-colors">
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const keyInput = document.getElementById('key-input');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const registerBtn = document.getElementById('register-btn');
|
||||
const loginError = document.getElementById('login-error');
|
||||
|
||||
// Check if already logged in
|
||||
if (localStorage.getItem('pingql_key')) {
|
||||
window.location.href = '/dashboard/home';
|
||||
}
|
||||
|
||||
// Auto-format key input with dashes
|
||||
keyInput.addEventListener('input', (e) => {
|
||||
let v = e.target.value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||
if (v.length > 16) v = v.slice(0, 16);
|
||||
const parts = v.match(/.{1,4}/g) || [];
|
||||
e.target.value = parts.join('-');
|
||||
});
|
||||
|
||||
loginBtn.addEventListener('click', async () => {
|
||||
const key = keyInput.value.trim();
|
||||
if (!key || key.length < 19) {
|
||||
showError('Enter a valid account key');
|
||||
return;
|
||||
}
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Verifying...';
|
||||
try {
|
||||
const res = await fetch('/monitors/', {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
showError('Invalid account key');
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('pingql_key', key);
|
||||
window.location.href = '/dashboard/home';
|
||||
} catch (e) {
|
||||
showError('Connection error');
|
||||
} finally {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Sign In';
|
||||
}
|
||||
});
|
||||
|
||||
keyInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') loginBtn.click();
|
||||
});
|
||||
|
||||
registerBtn.addEventListener('click', async () => {
|
||||
registerBtn.disabled = true;
|
||||
registerBtn.textContent = 'Creating...';
|
||||
try {
|
||||
const res = await fetch('/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.account_key) {
|
||||
localStorage.setItem('pingql_key', data.account_key);
|
||||
// Show key to user briefly
|
||||
keyInput.value = data.account_key;
|
||||
loginError.textContent = 'Account created! Key: ' + data.account_key + ' — save this!';
|
||||
loginError.classList.remove('hidden');
|
||||
loginError.classList.remove('text-red-400');
|
||||
loginError.classList.add('text-green-400');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard/home';
|
||||
}, 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
showError('Failed to create account');
|
||||
} finally {
|
||||
registerBtn.disabled = false;
|
||||
registerBtn.textContent = 'Create Account';
|
||||
}
|
||||
});
|
||||
|
||||
function showError(msg) {
|
||||
loginError.textContent = msg;
|
||||
loginError.classList.remove('hidden', 'text-green-400');
|
||||
loginError.classList.add('text-red-400');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PingQL — New Monitor</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
||||
<script src="/dashboard/app.js"></script>
|
||||
<script src="/dashboard/query-builder.js"></script>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
|
||||
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Logout</button>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/dashboard/home" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back to monitors</a>
|
||||
<h2 class="text-lg font-semibold text-gray-200 mt-2">Create Monitor</h2>
|
||||
</div>
|
||||
|
||||
<form id="create-form" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Name</label>
|
||||
<input id="name" type="text" required placeholder="Production API"
|
||||
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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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"
|
||||
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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Check 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>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Query Conditions <span class="text-gray-600">(optional)</span></label>
|
||||
<p class="text-xs text-gray-600 mb-3">Define when this monitor should be considered "up". Defaults to status < 400.</p>
|
||||
<div id="query-builder"></div>
|
||||
</div>
|
||||
|
||||
<div id="form-error" class="text-red-400 text-sm hidden"></div>
|
||||
|
||||
<button type="submit" id="submit-btn"
|
||||
class="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
|
||||
Create Monitor
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
if (!requireAuth()) throw 'auth';
|
||||
|
||||
let currentQuery = null;
|
||||
const qb = new QueryBuilder(document.getElementById('query-builder'), (q) => {
|
||||
currentQuery = q;
|
||||
});
|
||||
|
||||
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submit-btn');
|
||||
const errEl = document.getElementById('form-error');
|
||||
errEl.classList.add('hidden');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating...';
|
||||
|
||||
try {
|
||||
const body = {
|
||||
name: document.getElementById('name').value.trim(),
|
||||
url: document.getElementById('url').value.trim(),
|
||||
interval_s: Number(document.getElementById('interval').value),
|
||||
};
|
||||
if (currentQuery) body.query = currentQuery;
|
||||
|
||||
await api('/monitors/', { method: 'POST', body });
|
||||
window.location.href = '/dashboard/home';
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Monitor';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
// PingQL Visual Query Builder
|
||||
|
||||
const FIELDS = [
|
||||
{ name: 'status', label: 'Status Code', type: 'number', operators: ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in'] },
|
||||
{ name: 'body', label: 'Response Body', type: 'string', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex', '$exists'] },
|
||||
{ name: 'headers.*', label: 'Header', type: 'string', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex', '$exists'] },
|
||||
{ name: '$select', label: 'CSS Selector', type: 'selector', operators: ['$eq', '$ne', '$contains', '$startsWith', '$endsWith', '$regex'] },
|
||||
{ name: '$json', label: 'JSON Path', type: 'jsonpath', operators: ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$contains', '$regex'] },
|
||||
{ name: '$responseTime', label: 'Response Time (ms)', type: 'number', operators: ['$eq', '$gt', '$gte', '$lt', '$lte'] },
|
||||
{ name: '$certExpiry', label: 'Cert Expiry (days)', type: 'number', operators: ['$eq', '$gt', '$gte', '$lt', '$lte'] },
|
||||
];
|
||||
|
||||
const OP_LABELS = {
|
||||
'$eq': '=', '$ne': '≠', '$gt': '>', '$gte': '≥', '$lt': '<', '$lte': '≤',
|
||||
'$contains': 'contains', '$startsWith': 'starts with', '$endsWith': 'ends with',
|
||||
'$regex': 'matches regex', '$exists': 'exists', '$in': 'in',
|
||||
};
|
||||
|
||||
class QueryBuilder {
|
||||
constructor(container, onChange) {
|
||||
this.container = container;
|
||||
this.onChange = onChange;
|
||||
this.logic = '$and';
|
||||
this.rules = [this._emptyRule()];
|
||||
this.render();
|
||||
}
|
||||
|
||||
_emptyRule() {
|
||||
return { field: 'status', operator: '$eq', value: '', headerName: '', selectorValue: '', jsonPath: '' };
|
||||
}
|
||||
|
||||
getQuery() {
|
||||
const conditions = this.rules
|
||||
.map(r => this._ruleToQuery(r))
|
||||
.filter(Boolean);
|
||||
if (conditions.length === 0) return null;
|
||||
if (conditions.length === 1) return conditions[0];
|
||||
return { [this.logic]: conditions };
|
||||
}
|
||||
|
||||
_ruleToQuery(rule) {
|
||||
const { field, operator, value } = rule;
|
||||
if (!value && operator !== '$exists') return null;
|
||||
|
||||
const parsedVal = this._parseValue(value, field, operator);
|
||||
|
||||
if (field === '$responseTime' || field === '$certExpiry') {
|
||||
return { [field]: { [operator]: parsedVal } };
|
||||
}
|
||||
if (field === '$select') {
|
||||
return { '$select': rule.selectorValue || '', [operator]: parsedVal };
|
||||
}
|
||||
if (field === '$json') {
|
||||
return { '$json': rule.jsonPath || '', [operator]: parsedVal };
|
||||
}
|
||||
if (field === 'headers.*') {
|
||||
const headerField = `headers.${rule.headerName || 'content-type'}`;
|
||||
if (operator === '$exists') return { [headerField]: { '$exists': parsedVal } };
|
||||
return { [headerField]: { [operator]: parsedVal } };
|
||||
}
|
||||
if (operator === '$exists') return { [field]: { '$exists': parsedVal } };
|
||||
// Simple shorthand for $eq on basic fields
|
||||
if (operator === '$eq') return { [field]: parsedVal };
|
||||
return { [field]: { [operator]: parsedVal } };
|
||||
}
|
||||
|
||||
_parseValue(value, field, operator) {
|
||||
if (operator === '$exists') return value !== 'false' && value !== '0';
|
||||
if (operator === '$in') {
|
||||
return value.split(',').map(v => {
|
||||
const trimmed = v.trim();
|
||||
const n = Number(trimmed);
|
||||
return isNaN(n) ? trimmed : n;
|
||||
});
|
||||
}
|
||||
const fieldDef = FIELDS.find(f => f.name === field);
|
||||
if (fieldDef?.type === 'number') {
|
||||
const n = Number(value);
|
||||
return isNaN(n) ? value : n;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
setQuery(query) {
|
||||
if (!query || typeof query !== 'object') {
|
||||
this.rules = [this._emptyRule()];
|
||||
this.logic = '$and';
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if ('$and' in query || '$or' in query) {
|
||||
this.logic = '$and' in query ? '$and' : '$or';
|
||||
const clauses = query[this.logic];
|
||||
if (Array.isArray(clauses)) {
|
||||
this.rules = clauses.map(c => this._queryToRule(c)).filter(Boolean);
|
||||
}
|
||||
} else {
|
||||
this.rules = [this._queryToRule(query)].filter(Boolean);
|
||||
}
|
||||
|
||||
if (this.rules.length === 0) this.rules = [this._emptyRule()];
|
||||
this.render();
|
||||
}
|
||||
|
||||
_queryToRule(clause) {
|
||||
if (!clause || typeof clause !== 'object') return this._emptyRule();
|
||||
|
||||
if ('$responseTime' in clause || '$certExpiry' in clause) {
|
||||
const field = '$responseTime' in clause ? '$responseTime' : '$certExpiry';
|
||||
const ops = clause[field];
|
||||
if (typeof ops === 'object') {
|
||||
const [operator, value] = Object.entries(ops)[0] || ['$lt', ''];
|
||||
return { field, operator, value: String(value), headerName: '', selectorValue: '', jsonPath: '' };
|
||||
}
|
||||
}
|
||||
|
||||
if ('$select' in clause) {
|
||||
const selectorValue = clause.$select;
|
||||
for (const [op, val] of Object.entries(clause)) {
|
||||
if (op !== '$select') {
|
||||
return { field: '$select', operator: op, value: String(val), headerName: '', selectorValue, jsonPath: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ('$json' in clause) {
|
||||
const jsonPath = clause.$json;
|
||||
for (const [op, val] of Object.entries(clause)) {
|
||||
if (op !== '$json') {
|
||||
return { field: '$json', operator: op, value: String(val), headerName: '', selectorValue: '', jsonPath };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [field, condition] of Object.entries(clause)) {
|
||||
if (field.startsWith('headers.')) {
|
||||
const headerName = field.slice(8);
|
||||
if (typeof condition === 'object' && condition !== null) {
|
||||
const [operator, value] = Object.entries(condition)[0] || ['$eq', ''];
|
||||
return { field: 'headers.*', operator, value: String(value), headerName, selectorValue: '', jsonPath: '' };
|
||||
}
|
||||
return { field: 'headers.*', operator: '$eq', value: String(condition), headerName, selectorValue: '', jsonPath: '' };
|
||||
}
|
||||
|
||||
if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) {
|
||||
const [operator, value] = Object.entries(condition)[0] || ['$eq', ''];
|
||||
return { field, operator, value: String(value), headerName: '', selectorValue: '', jsonPath: '' };
|
||||
}
|
||||
|
||||
return { field, operator: '$eq', value: String(condition), headerName: '', selectorValue: '', jsonPath: '' };
|
||||
}
|
||||
|
||||
return this._emptyRule();
|
||||
}
|
||||
|
||||
render() {
|
||||
const query = this.getQuery();
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-sm text-gray-400">Match</span>
|
||||
<select id="qb-logic" class="bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1 focus:border-blue-500 focus:outline-none">
|
||||
<option value="$and" ${this.logic === '$and' ? 'selected' : ''}>ALL</option>
|
||||
<option value="$or" ${this.logic === '$or' ? 'selected' : ''}>ANY</option>
|
||||
</select>
|
||||
<span class="text-sm text-gray-400">of the following conditions</span>
|
||||
</div>
|
||||
|
||||
<div id="qb-rules" class="space-y-2">
|
||||
${this.rules.map((rule, i) => this._renderRule(rule, i)).join('')}
|
||||
</div>
|
||||
|
||||
<button id="qb-add" class="text-sm text-blue-400 hover:text-blue-300 mt-2">+ Add condition</button>
|
||||
|
||||
<div class="mt-4 p-3 bg-gray-950 rounded border border-gray-800">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs text-gray-500 font-mono">Query JSON</span>
|
||||
<button id="qb-copy" class="text-xs text-blue-400 hover:text-blue-300">Copy</button>
|
||||
</div>
|
||||
<pre id="qb-preview" class="text-xs text-gray-300 font-mono whitespace-pre-wrap overflow-x-auto">${query ? escapeHtml(JSON.stringify(query, null, 2)) : '<span class="text-gray-600">No conditions set</span>'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Bind events
|
||||
this.container.querySelector('#qb-logic').addEventListener('change', (e) => {
|
||||
this.logic = e.target.value;
|
||||
this.render();
|
||||
this.onChange?.(this.getQuery());
|
||||
});
|
||||
|
||||
this.container.querySelector('#qb-add').addEventListener('click', () => {
|
||||
this.rules.push(this._emptyRule());
|
||||
this.render();
|
||||
this.onChange?.(this.getQuery());
|
||||
});
|
||||
|
||||
this.container.querySelector('#qb-copy').addEventListener('click', () => {
|
||||
const q = this.getQuery();
|
||||
navigator.clipboard.writeText(q ? JSON.stringify(q, null, 2) : '{}');
|
||||
const btn = this.container.querySelector('#qb-copy');
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
|
||||
this.container.querySelectorAll('.qb-rule').forEach((el, i) => {
|
||||
this._bindRuleEvents(el, i);
|
||||
});
|
||||
}
|
||||
|
||||
_renderRule(rule, index) {
|
||||
const fieldDef = FIELDS.find(f => f.name === rule.field) || FIELDS[0];
|
||||
const operators = fieldDef.operators;
|
||||
|
||||
const needsHeader = rule.field === 'headers.*';
|
||||
const needsSelector = rule.field === '$select';
|
||||
const needsJsonPath = rule.field === '$json';
|
||||
|
||||
return `
|
||||
<div class="qb-rule flex items-center gap-2 p-2 bg-gray-800/50 rounded border border-gray-700/50">
|
||||
<select class="qb-field bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 focus:border-blue-500 focus:outline-none min-w-[140px]">
|
||||
${FIELDS.map(f => `<option value="${f.name}" ${f.name === rule.field ? 'selected' : ''}>${f.label}</option>`).join('')}
|
||||
</select>
|
||||
|
||||
${needsHeader ? `<input type="text" class="qb-header bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 w-32 focus:border-blue-500 focus:outline-none" placeholder="header name" value="${escapeHtml(rule.headerName)}">` : ''}
|
||||
${needsSelector ? `<input type="text" class="qb-selector bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 w-32 focus:border-blue-500 focus:outline-none" placeholder="CSS selector" value="${escapeHtml(rule.selectorValue)}">` : ''}
|
||||
${needsJsonPath ? `<input type="text" class="qb-jsonpath bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 w-32 focus:border-blue-500 focus:outline-none" placeholder="$.path" value="${escapeHtml(rule.jsonPath)}">` : ''}
|
||||
|
||||
<select class="qb-op bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 focus:border-blue-500 focus:outline-none">
|
||||
${operators.map(op => `<option value="${op}" ${op === rule.operator ? 'selected' : ''}>${OP_LABELS[op] || op}</option>`).join('')}
|
||||
</select>
|
||||
|
||||
<input type="text" class="qb-value bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1.5 flex-1 focus:border-blue-500 focus:outline-none" placeholder="${rule.operator === '$exists' ? 'true/false' : 'value'}" value="${escapeHtml(rule.value)}">
|
||||
|
||||
<button class="qb-remove text-gray-500 hover:text-red-400 px-1 ${this.rules.length <= 1 ? 'invisible' : ''}" title="Remove">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_bindRuleEvents(el, index) {
|
||||
const rule = this.rules[index];
|
||||
|
||||
el.querySelector('.qb-field').addEventListener('change', (e) => {
|
||||
rule.field = e.target.value;
|
||||
const fieldDef = FIELDS.find(f => f.name === rule.field);
|
||||
if (fieldDef && !fieldDef.operators.includes(rule.operator)) {
|
||||
rule.operator = fieldDef.operators[0];
|
||||
}
|
||||
this.render();
|
||||
this.onChange?.(this.getQuery());
|
||||
});
|
||||
|
||||
el.querySelector('.qb-op').addEventListener('change', (e) => {
|
||||
rule.operator = e.target.value;
|
||||
this.render();
|
||||
this.onChange?.(this.getQuery());
|
||||
});
|
||||
|
||||
el.querySelector('.qb-value').addEventListener('input', (e) => {
|
||||
rule.value = e.target.value;
|
||||
this._updatePreview();
|
||||
this.onChange?.(this.getQuery());
|
||||
});
|
||||
|
||||
el.querySelector('.qb-header')?.addEventListener('input', (e) => {
|
||||
rule.headerName = e.target.value;
|
||||
this._updatePreview();
|
||||
this.onChange?.(this.getQuery());
|
||||
});
|
||||
|
||||
el.querySelector('.qb-selector')?.addEventListener('input', (e) => {
|
||||
rule.selectorValue = e.target.value;
|
||||
this._updatePreview();
|
||||
this.onChange?.(this.getQuery());
|
||||
});
|
||||
|
||||
el.querySelector('.qb-jsonpath')?.addEventListener('input', (e) => {
|
||||
rule.jsonPath = e.target.value;
|
||||
this._updatePreview();
|
||||
this.onChange?.(this.getQuery());
|
||||
});
|
||||
|
||||
el.querySelector('.qb-remove')?.addEventListener('click', () => {
|
||||
this.rules.splice(index, 1);
|
||||
this.render();
|
||||
this.onChange?.(this.getQuery());
|
||||
});
|
||||
}
|
||||
|
||||
_updatePreview() {
|
||||
const q = this.getQuery();
|
||||
const preview = this.container.querySelector('#qb-preview');
|
||||
if (preview) {
|
||||
preview.textContent = q ? JSON.stringify(q, null, 2) : 'No conditions set';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { checks } from "./routes/checks";
|
|||
import { monitors } from "./routes/monitors";
|
||||
import { auth } from "./routes/auth";
|
||||
import { internal } from "./routes/internal";
|
||||
import { dashboard } from "./routes/dashboard";
|
||||
import { migrate } from "./db";
|
||||
|
||||
await migrate();
|
||||
|
|
@ -12,7 +13,8 @@ await migrate();
|
|||
const app = new Elysia()
|
||||
.use(cors())
|
||||
.use(swagger({ path: "/docs", documentation: { info: { title: "PingQL API", version: "0.1.0" } } }))
|
||||
.get("/", () => ({ name: "PingQL", version: "0.1.0", docs: "/docs" }))
|
||||
.get("/", () => ({ name: "PingQL", version: "0.1.0", docs: "/docs", dashboard: "/dashboard" }))
|
||||
.use(dashboard)
|
||||
.use(auth)
|
||||
.use(monitors)
|
||||
.use(checks)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,317 @@
|
|||
/**
|
||||
* PingQL Query Engine — TypeScript implementation
|
||||
*
|
||||
* MongoDB-inspired query language for evaluating HTTP response conditions.
|
||||
* Mirrors the Rust implementation but also powers the visual query builder.
|
||||
*/
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface QueryField {
|
||||
name: string;
|
||||
description: string;
|
||||
type: "number" | "string" | "boolean" | "object";
|
||||
operators: string[];
|
||||
}
|
||||
|
||||
export interface EvalContext {
|
||||
status: number;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
latency_ms?: number;
|
||||
cert_expiry_days?: number;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ── Available fields ───────────────────────────────────────────────────
|
||||
|
||||
export function getAvailableFields(): QueryField[] {
|
||||
return [
|
||||
{
|
||||
name: "status",
|
||||
description: "HTTP status code (e.g. 200, 404, 500)",
|
||||
type: "number",
|
||||
operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"],
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
description: "Response body as text",
|
||||
type: "string",
|
||||
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"],
|
||||
},
|
||||
{
|
||||
name: "headers.*",
|
||||
description: "Response header value (e.g. headers.content-type)",
|
||||
type: "string",
|
||||
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"],
|
||||
},
|
||||
{
|
||||
name: "$select",
|
||||
description: "CSS selector — returns text content of first matching element",
|
||||
type: "string",
|
||||
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex"],
|
||||
},
|
||||
{
|
||||
name: "$json",
|
||||
description: "JSONPath expression evaluated against response body (e.g. $.data.status)",
|
||||
type: "string",
|
||||
operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$contains", "$regex"],
|
||||
},
|
||||
{
|
||||
name: "$responseTime",
|
||||
description: "Request latency in milliseconds",
|
||||
type: "number",
|
||||
operators: ["$eq", "$gt", "$gte", "$lt", "$lte"],
|
||||
},
|
||||
{
|
||||
name: "$certExpiry",
|
||||
description: "Days until SSL certificate expires",
|
||||
type: "number",
|
||||
operators: ["$eq", "$gt", "$gte", "$lt", "$lte"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Evaluate ───────────────────────────────────────────────────────────
|
||||
|
||||
export function evaluate(query: unknown, ctx: EvalContext): boolean {
|
||||
if (query === null || query === undefined) return true;
|
||||
if (typeof query !== "object" || Array.isArray(query)) {
|
||||
throw new Error("Query must be an object");
|
||||
}
|
||||
|
||||
const q = query as Record<string, unknown>;
|
||||
|
||||
// $and
|
||||
if ("$and" in q) {
|
||||
const clauses = q.$and;
|
||||
if (!Array.isArray(clauses)) throw new Error("$and expects array");
|
||||
return clauses.every((c) => evaluate(c, ctx));
|
||||
}
|
||||
|
||||
// $or
|
||||
if ("$or" in q) {
|
||||
const clauses = q.$or;
|
||||
if (!Array.isArray(clauses)) throw new Error("$or expects array");
|
||||
return clauses.some((c) => evaluate(c, ctx));
|
||||
}
|
||||
|
||||
// $not
|
||||
if ("$not" in q) {
|
||||
return !evaluate(q.$not, ctx);
|
||||
}
|
||||
|
||||
// $responseTime
|
||||
if ("$responseTime" in q) {
|
||||
const val = ctx.latency_ms ?? 0;
|
||||
return evalCondition(q.$responseTime, val);
|
||||
}
|
||||
|
||||
// $certExpiry
|
||||
if ("$certExpiry" in q) {
|
||||
const val = ctx.cert_expiry_days ?? Infinity;
|
||||
return evalCondition(q.$certExpiry, val);
|
||||
}
|
||||
|
||||
// $select — CSS selector shorthand
|
||||
if ("$select" in q) {
|
||||
// Server-side: we don't have a DOM parser here, so we just validate structure.
|
||||
// Actual evaluation happens in Rust runner. This returns true for validation purposes.
|
||||
return true;
|
||||
}
|
||||
|
||||
// $json — JSONPath shorthand
|
||||
if ("$json" in q) {
|
||||
const expr = q.$json as string;
|
||||
const cond = q as Record<string, unknown>;
|
||||
const resolved = resolveJsonPath(ctx.body, expr);
|
||||
// Apply operators from remaining keys
|
||||
for (const [op, opVal] of Object.entries(cond)) {
|
||||
if (op === "$json") continue;
|
||||
if (!evalOp(op, resolved, opVal)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Field-level checks
|
||||
for (const [field, condition] of Object.entries(q)) {
|
||||
if (field.startsWith("$")) continue; // skip unknown $ ops
|
||||
const fieldVal = resolveField(field, ctx);
|
||||
if (!evalCondition(condition, fieldVal)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveField(field: string, ctx: EvalContext): unknown {
|
||||
switch (field) {
|
||||
case "status":
|
||||
case "status_code":
|
||||
return ctx.status;
|
||||
case "body":
|
||||
return ctx.body;
|
||||
default:
|
||||
if (field.startsWith("headers.")) {
|
||||
const key = field.slice(8).toLowerCase();
|
||||
return ctx.headers[key] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveJsonPath(body: string, expr: string): unknown {
|
||||
try {
|
||||
const obj = JSON.parse(body);
|
||||
// Simple dot-notation JSONPath: $.foo.bar[0].baz
|
||||
const path = expr.replace(/^\$\.?/, "");
|
||||
if (!path) return obj;
|
||||
const parts = path.split(/\.|\[(\d+)\]/).filter(Boolean);
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return null;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function evalCondition(condition: unknown, fieldVal: unknown): boolean {
|
||||
if (condition === null || condition === undefined) return true;
|
||||
|
||||
// Direct equality shorthand: { "status": 200 }
|
||||
if (typeof condition === "number" || typeof condition === "string" || typeof condition === "boolean") {
|
||||
return fieldVal === condition;
|
||||
}
|
||||
|
||||
if (typeof condition === "object" && !Array.isArray(condition)) {
|
||||
const ops = condition as Record<string, unknown>;
|
||||
for (const [op, opVal] of Object.entries(ops)) {
|
||||
if (!evalOp(op, fieldVal, opVal)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean {
|
||||
switch (op) {
|
||||
case "$eq":
|
||||
return fieldVal === opVal;
|
||||
case "$ne":
|
||||
return fieldVal !== opVal;
|
||||
case "$gt":
|
||||
return toNum(fieldVal) > toNum(opVal);
|
||||
case "$gte":
|
||||
return toNum(fieldVal) >= toNum(opVal);
|
||||
case "$lt":
|
||||
return toNum(fieldVal) < toNum(opVal);
|
||||
case "$lte":
|
||||
return toNum(fieldVal) <= toNum(opVal);
|
||||
case "$contains":
|
||||
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.includes(opVal);
|
||||
case "$startsWith":
|
||||
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.startsWith(opVal);
|
||||
case "$endsWith":
|
||||
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.endsWith(opVal);
|
||||
case "$regex": {
|
||||
if (typeof fieldVal !== "string" || typeof opVal !== "string") return false;
|
||||
try {
|
||||
return new RegExp(opVal).test(fieldVal);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case "$exists":
|
||||
return opVal ? fieldVal !== null && fieldVal !== undefined : fieldVal === null || fieldVal === undefined;
|
||||
case "$in":
|
||||
return Array.isArray(opVal) && opVal.includes(fieldVal);
|
||||
default:
|
||||
return true; // unknown op — skip
|
||||
}
|
||||
}
|
||||
|
||||
function toNum(v: unknown): number {
|
||||
return typeof v === "number" ? v : Number(v) || 0;
|
||||
}
|
||||
|
||||
// ── Validate ───────────────────────────────────────────────────────────
|
||||
|
||||
const VALID_OPS = new Set([
|
||||
"$eq", "$ne", "$gt", "$gte", "$lt", "$lte",
|
||||
"$contains", "$startsWith", "$endsWith", "$regex",
|
||||
"$exists", "$in",
|
||||
"$select", "$json",
|
||||
"$and", "$or", "$not",
|
||||
"$responseTime", "$certExpiry",
|
||||
]);
|
||||
|
||||
const VALID_FIELDS = new Set([
|
||||
"status", "status_code", "body",
|
||||
]);
|
||||
|
||||
export function validateQuery(query: unknown, path = ""): ValidationError[] {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
if (query === null || query === undefined) return errors;
|
||||
|
||||
if (typeof query !== "object" || Array.isArray(query)) {
|
||||
errors.push({ path: path || "$", message: "Query must be an object" });
|
||||
return errors;
|
||||
}
|
||||
|
||||
const q = query as Record<string, unknown>;
|
||||
|
||||
for (const [key, value] of Object.entries(q)) {
|
||||
const keyPath = path ? `${path}.${key}` : key;
|
||||
|
||||
if (key === "$and" || key === "$or") {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push({ path: keyPath, message: `${key} expects an array` });
|
||||
} else {
|
||||
value.forEach((clause, i) => {
|
||||
errors.push(...validateQuery(clause, `${keyPath}[${i}]`));
|
||||
});
|
||||
}
|
||||
} else if (key === "$not") {
|
||||
errors.push(...validateQuery(value, keyPath));
|
||||
} else if (key === "$responseTime" || key === "$certExpiry") {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
errors.push({ path: keyPath, message: `${key} expects an operator object (e.g. { "$lt": 500 })` });
|
||||
} else {
|
||||
for (const op of Object.keys(value as Record<string, unknown>)) {
|
||||
if (!VALID_OPS.has(op)) {
|
||||
errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (key === "$select" || key === "$json") {
|
||||
if (typeof value !== "string") {
|
||||
errors.push({ path: keyPath, message: `${key} expects a string` });
|
||||
}
|
||||
} else if (key.startsWith("$")) {
|
||||
// It's an operator inside a field condition — skip validation here
|
||||
} else {
|
||||
// Field name
|
||||
if (!VALID_FIELDS.has(key) && !key.startsWith("headers.")) {
|
||||
errors.push({ path: keyPath, message: `Unknown field: ${key}. Use status, body, or headers.*` });
|
||||
}
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
for (const op of Object.keys(value as Record<string, unknown>)) {
|
||||
if (!op.startsWith("$")) continue;
|
||||
if (!VALID_OPS.has(op)) {
|
||||
errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
|
@ -8,6 +8,10 @@ export const checks = new Elysia()
|
|||
const token = headers["x-monitor-token"];
|
||||
if (token !== process.env.MONITOR_TOKEN) return error(401, { error: "Unauthorized" });
|
||||
|
||||
// Merge cert_expiry_days into meta if present
|
||||
const meta = body.meta ? { ...body.meta } : {};
|
||||
if (body.cert_expiry_days != null) meta.cert_expiry_days = body.cert_expiry_days;
|
||||
|
||||
await sql`
|
||||
INSERT INTO check_results (monitor_id, status_code, latency_ms, up, error, meta)
|
||||
VALUES (
|
||||
|
|
@ -16,7 +20,7 @@ export const checks = new Elysia()
|
|||
${body.latency_ms ?? null},
|
||||
${body.up},
|
||||
${body.error ?? null},
|
||||
${body.meta ? sql.json(body.meta) : null}
|
||||
${Object.keys(meta).length > 0 ? sql.json(meta) : null}
|
||||
)
|
||||
`;
|
||||
return { ok: true };
|
||||
|
|
@ -27,6 +31,7 @@ export const checks = new Elysia()
|
|||
latency_ms: t.Optional(t.Number()),
|
||||
up: t.Boolean(),
|
||||
error: t.Optional(t.Nullable(t.String())),
|
||||
cert_expiry_days: t.Optional(t.Nullable(t.Number())),
|
||||
meta: t.Optional(t.Any()),
|
||||
}),
|
||||
detail: { summary: "Ingest result (monitor runner)", tags: ["internal"] },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { Elysia } from "elysia";
|
||||
import { resolve } from "path";
|
||||
|
||||
const dir = resolve(import.meta.dir, "../dashboard");
|
||||
|
||||
export const dashboard = new Elysia()
|
||||
// Static assets
|
||||
.get("/dashboard/app.js", () => Bun.file(`${dir}/app.js`))
|
||||
.get("/dashboard/query-builder.js", () => Bun.file(`${dir}/query-builder.js`))
|
||||
|
||||
// Pages
|
||||
.get("/dashboard", () => Bun.file(`${dir}/index.html`))
|
||||
.get("/dashboard/home", () => Bun.file(`${dir}/home.html`))
|
||||
.get("/dashboard/monitors/new", () => Bun.file(`${dir}/new.html`))
|
||||
.get("/dashboard/monitors/:id", () => Bun.file(`${dir}/detail.html`));
|
||||
Loading…
Reference in New Issue