feat: dashboard, visual query builder, expanded query language, cert expiry support

This commit is contained in:
M1 2026-03-16 12:26:17 +04:00
parent 97c08b1951
commit 500132ba05
14 changed files with 1578 additions and 34 deletions

View File

@ -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"

View File

@ -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,35 +188,9 @@ 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() {
"$eq" => field_val == val,
"$ne" => field_val != val,
"$gt" => cmp_num(field_val, val, |a,b| a > b),
"$gte" => cmp_num(field_val, val, |a,b| a >= b),
"$lt" => cmp_num(field_val, val, |a,b| a < b),
"$lte" => cmp_num(field_val, val, |a,b| a <= b),
"$contains" => {
let needle = val.as_str().unwrap_or("");
field_val.as_str().map(|s| s.contains(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)
}
"$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 {
selected.is_some()
}
}
_ => true, // unknown op — skip
};
if !ok { return Ok(false); }
if !eval_op(op, field_val, val, response)? {
return Ok(false);
}
}
Ok(true)
}
@ -127,6 +198,55 @@ fn eval_condition(condition: &Value, field_val: &Value, response: &Response) ->
}
}
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),
"$gte" => cmp_num(field_val, val, |a,b| a >= b),
"$lt" => cmp_num(field_val, val, |a,b| a < b),
"$lte" => cmp_num(field_val, val, |a,b| a <= b),
"$contains" => {
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 no comparison operator follows, just check existence
selected.is_some()
}
_ => true, // unknown op — skip
};
Ok(ok)
}
fn cmp_num(a: &Value, b: &Value, f: impl Fn(f64, f64) -> bool) -> bool {
match (a.as_f64(), b.as_f64()) {
(Some(x), Some(y)) => f(x, y),

View File

@ -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,

View File

@ -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>,
}

View File

@ -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;
}

View File

@ -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">&larr; 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>

View File

@ -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>

View File

@ -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>

View File

@ -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">&larr; 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 &lt; 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>

View File

@ -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';
}
}
}

View File

@ -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)

317
apps/web/src/query/index.ts Normal file
View File

@ -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;
}

View File

@ -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"] },

View File

@ -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`));