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
+81
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;
}
+291
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>
+109
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>
+129
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>
+106
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>
+301
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';
}
}
}