refactor: convert all static HTML to EJS with cssHash cache-busting, remove stale html files
This commit is contained in:
@@ -1,291 +0,0 @@
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/dashboard/tailwind.css">
|
||||
<style>
|
||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
||||
<script src="/dashboard/app.js"></script>
|
||||
<script src="/dashboard/query-builder.js"></script>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
|
||||
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Logout</button>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-4xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/dashboard/home" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back</a>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="text-center py-16 text-gray-600">Loading...</div>
|
||||
<div id="content" class="hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="status-dot"></span>
|
||||
<h2 id="monitor-name" class="text-xl font-semibold text-gray-100"></h2>
|
||||
</div>
|
||||
<p id="monitor-url" class="text-sm text-gray-500 mt-1"></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="toggle-btn" class="text-sm px-4 py-2 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors"></button>
|
||||
<button id="delete-btn" class="text-sm px-4 py-2 rounded-lg border border-red-900/50 text-red-400 hover:bg-red-900/20 transition-colors">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Status</div>
|
||||
<div id="stat-status" class="text-lg font-semibold"></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Avg Latency</div>
|
||||
<div id="stat-latency" class="text-lg font-semibold text-gray-200"></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Uptime</div>
|
||||
<div id="stat-uptime" class="text-lg font-semibold text-gray-200"></div>
|
||||
</div>
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<div class="text-xs text-gray-500 mb-1">Last Ping</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 pings 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 Pings</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="pings-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 lastPing = results[0];
|
||||
|
||||
// Header
|
||||
document.getElementById('monitor-name').textContent = data.name;
|
||||
document.getElementById('monitor-url').textContent = data.url;
|
||||
document.getElementById('status-dot').innerHTML = statusBadge(lastPing?.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 ping history?')) return;
|
||||
await api(`/monitors/${monitorId}`, { method: 'DELETE' });
|
||||
window.location.href = '/dashboard/home';
|
||||
};
|
||||
|
||||
// Stats
|
||||
const upPings = 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((upPings.length / results.length) * 100) : null;
|
||||
|
||||
document.getElementById('stat-status').innerHTML = lastPing
|
||||
? (lastPing.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 = lastPing ? timeAgo(lastPing.checked_at) : '—';
|
||||
|
||||
// Latency chart
|
||||
renderLatencyChart(results.slice().reverse());
|
||||
|
||||
// Status bar
|
||||
const statusBar = document.getElementById('status-bar');
|
||||
const barPings = results.slice(0, 60).reverse();
|
||||
statusBar.innerHTML = barPings.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>';
|
||||
|
||||
// Pings table
|
||||
document.getElementById('pings-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(pings) {
|
||||
const container = document.getElementById('latency-chart');
|
||||
const data = pings.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>
|
||||
@@ -1,370 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PingQL — Documentation</title>
|
||||
<link rel="stylesheet" href="/dashboard/tailwind.css">
|
||||
<style>
|
||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
||||
pre, code { font-family: inherit; }
|
||||
|
||||
/* Sidebar nav */
|
||||
.nav-link { display: block; padding: 0.3rem 0.75rem; font-size: 0.8rem; color: #6b7280; border-left: 2px solid transparent; transition: all 0.1s; }
|
||||
.nav-link:hover { color: #d1d5db; border-left-color: #374151; }
|
||||
.nav-link.active { color: #93c5fd; border-left-color: #3b82f6; }
|
||||
.nav-section { font-size: 0.7rem; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.75rem 0.75rem 0.25rem; margin-top: 0.5rem; }
|
||||
|
||||
/* Content */
|
||||
.section { padding-top: 3rem; padding-bottom: 1rem; border-top: 1px solid #111827; margin-top: 2rem; }
|
||||
.section:first-child { border-top: none; margin-top: 0; padding-top: 0; }
|
||||
h2 { font-size: 1.2rem; font-weight: 700; color: #f9fafb; margin-bottom: 0.5rem; }
|
||||
h3 { font-size: 0.85rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.06em; margin: 1.5rem 0 0.5rem; }
|
||||
p { font-size: 0.875rem; color: #6b7280; line-height: 1.7; margin-bottom: 0.75rem; }
|
||||
p a { color: #93c5fd; }
|
||||
p a:hover { color: #bfdbfe; }
|
||||
|
||||
/* Code blocks */
|
||||
.cb { background: #0f172a; border: 1px solid #1e293b; border-radius: 0.5rem; overflow: hidden; margin: 0.75rem 0 1.25rem; }
|
||||
.cb-header { background: #0a1628; border-bottom: 1px solid #1e293b; padding: 0.4rem 0.85rem; display: flex; align-items: center; justify-content: space-between; }
|
||||
.cb-lang { font-size: 0.7rem; color: #475569; }
|
||||
.cb-copy { font-size: 0.7rem; color: #3b82f6; cursor: pointer; }
|
||||
.cb-copy:hover { color: #93c5fd; }
|
||||
.cb pre { padding: 0.85rem; font-size: 0.78rem; line-height: 1.65; color: #e2e8f0; overflow-x: auto; margin: 0; }
|
||||
|
||||
/* Syntax */
|
||||
.k { color: #c084fc; } /* key */
|
||||
.s { color: #34d399; } /* string */
|
||||
.n { color: #f59e0b; } /* number */
|
||||
.o { color: #60a5fa; } /* operator */
|
||||
.c { color: #334155; } /* comment */
|
||||
|
||||
/* Tables */
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; margin: 0.5rem 0 1.25rem; }
|
||||
th { text-align: left; color: #4b5563; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.4rem 0.75rem; border-bottom: 1px solid #111827; }
|
||||
td { color: #6b7280; padding: 0.45rem 0.75rem; border-bottom: 1px solid #0d1117; vertical-align: top; }
|
||||
td:first-child { color: #93c5fd; white-space: nowrap; font-size: 0.78rem; }
|
||||
td code { background: #1e293b; color: #94a3b8; padding: 0.1em 0.35em; border-radius: 0.2rem; font-size: 0.75rem; }
|
||||
|
||||
/* Endpoint blocks */
|
||||
.endpoint { display: flex; align-items: baseline; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||
.method { font-size: 0.7rem; font-weight: 700; padding: 0.15rem 0.45rem; border-radius: 0.2rem; }
|
||||
.method.get { background: #052e16; color: #4ade80; }
|
||||
.method.post { background: #172554; color: #93c5fd; }
|
||||
.method.patch { background: #1c1917; color: #fb923c; }
|
||||
.method.delete { background: #3f1215; color: #f87171; }
|
||||
.path { color: #e2e8f0; font-size: 0.875rem; }
|
||||
.endpoint-desc { font-size: 0.8rem; color: #4b5563; margin-bottom: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
||||
|
||||
<!-- Top bar -->
|
||||
<div class="border-b border-gray-900 px-6 py-3 flex items-center justify-between">
|
||||
<a href="/" class="text-lg font-bold">Ping<span class="text-blue-400">QL</span> <span class="text-gray-600 font-normal text-sm">docs</span></a>
|
||||
<a href="/dashboard" class="text-sm text-gray-600 hover:text-gray-400 transition-colors">Dashboard →</a>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-6xl mx-auto">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-52 shrink-0 sticky top-0 h-screen overflow-y-auto py-6 hidden md:block">
|
||||
<div class="nav-section">Getting Started</div>
|
||||
<a href="#overview" class="nav-link">Overview</a>
|
||||
<a href="#auth" class="nav-link">Authentication</a>
|
||||
|
||||
<div class="nav-section">API Reference</div>
|
||||
<a href="#account" class="nav-link">Account</a>
|
||||
<a href="#monitors" class="nav-link">Monitors</a>
|
||||
|
||||
<div class="nav-section">Query Language</div>
|
||||
<a href="#ql-fields" class="nav-link">Fields</a>
|
||||
<a href="#ql-operators" class="nav-link">Operators</a>
|
||||
<a href="#ql-json" class="nav-link">$json</a>
|
||||
<a href="#ql-select" class="nav-link">$select</a>
|
||||
<a href="#ql-logical" class="nav-link">Logical</a>
|
||||
<a href="#ql-consider" class="nav-link">$consider</a>
|
||||
<a href="#ql-examples" class="nav-link">Examples</a>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 px-10 py-8 max-w-3xl">
|
||||
|
||||
<!-- Overview -->
|
||||
<div id="overview" class="section">
|
||||
<h2>Overview</h2>
|
||||
<p>PingQL is a developer-friendly uptime monitoring API. Monitors are defined with a URL, an interval, and an optional query that determines what "up" means for your service.</p>
|
||||
<p>Base URL: <code style="color:#93c5fd;background:#0f172a;padding:0.15em 0.4em;border-radius:0.25rem;font-size:0.8rem">https://api.pingql.com</code></p>
|
||||
</div>
|
||||
|
||||
<!-- Auth -->
|
||||
<div id="auth" class="section">
|
||||
<h2>Authentication</h2>
|
||||
<p>All API requests require an account key passed as a Bearer token:</p>
|
||||
<div class="cb">
|
||||
<div class="cb-header"><span class="cb-lang">http</span></div>
|
||||
<pre>Authorization: Bearer <your-64-char-hex-key></pre>
|
||||
</div>
|
||||
<p>Create an account at <a href="/dashboard">/dashboard</a> or via the API. Keys are 64-character hex strings (256-bit). Shown once at registration — store them securely.</p>
|
||||
</div>
|
||||
|
||||
<!-- Account -->
|
||||
<div id="account" class="section">
|
||||
<h2>Account</h2>
|
||||
|
||||
<h3>Register</h3>
|
||||
<div class="endpoint"><span class="method post">POST</span><span class="path">/account/register</span></div>
|
||||
<p class="endpoint-desc">Create a new account. Email is optional — used only for account recovery.</p>
|
||||
<div class="cb">
|
||||
<div class="cb-header"><span class="cb-lang">json — request body</span></div>
|
||||
<pre>{ <span class="k">"email"</span>: <span class="s">"you@example.com"</span> } <span class="c">// optional</span></pre>
|
||||
</div>
|
||||
<div class="cb">
|
||||
<div class="cb-header"><span class="cb-lang">json — response</span></div>
|
||||
<pre>{ <span class="k">"key"</span>: <span class="s">"5bf5311b56d09254c8a1f0e3..."</span>, <span class="k">"email_registered"</span>: <span class="n">true</span> }</pre>
|
||||
</div>
|
||||
|
||||
<h3>Update Email</h3>
|
||||
<div class="endpoint"><span class="method post">POST</span><span class="path">/account/email</span></div>
|
||||
<p class="endpoint-desc">Set or update the recovery email for an existing account.</p>
|
||||
<div class="cb">
|
||||
<div class="cb-header"><span class="cb-lang">json — request body</span></div>
|
||||
<pre>{ <span class="k">"email"</span>: <span class="s">"you@example.com"</span> }</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monitors -->
|
||||
<div id="monitors" class="section">
|
||||
<h2>Monitors</h2>
|
||||
|
||||
<h3>List</h3>
|
||||
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/</span></div>
|
||||
<p class="endpoint-desc">Returns all monitors for the authenticated account.</p>
|
||||
|
||||
<h3>Create</h3>
|
||||
<div class="endpoint"><span class="method post">POST</span><span class="path">/monitors/</span></div>
|
||||
<div class="cb">
|
||||
<div class="cb-header"><span class="cb-lang">json — request body</span></div>
|
||||
<pre>{
|
||||
<span class="k">"name"</span>: <span class="s">"My API"</span>,
|
||||
<span class="k">"url"</span>: <span class="s">"https://api.example.com/health"</span>,
|
||||
<span class="k">"interval_s"</span>: <span class="n">60</span>, <span class="c">// check every 60 seconds (min: 10)</span>
|
||||
<span class="k">"query"</span>: { ... } <span class="c">// optional — see Query Language below</span>
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<h3>Get</h3>
|
||||
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/:id</span></div>
|
||||
<p class="endpoint-desc">Returns a monitor including its most recent ping results.</p>
|
||||
|
||||
<h3>Update</h3>
|
||||
<div class="endpoint"><span class="method patch">PATCH</span><span class="path">/monitors/:id</span></div>
|
||||
<p class="endpoint-desc">Update any field. All fields are optional.</p>
|
||||
|
||||
<h3>Delete</h3>
|
||||
<div class="endpoint"><span class="method delete">DELETE</span><span class="path">/monitors/:id</span></div>
|
||||
|
||||
<h3>Toggle</h3>
|
||||
<div class="endpoint"><span class="method post">POST</span><span class="path">/monitors/:id/toggle</span></div>
|
||||
<p class="endpoint-desc">Enable or disable a monitor without deleting it.</p>
|
||||
|
||||
<h3>Ping History</h3>
|
||||
<div class="endpoint"><span class="method get">GET</span><span class="path">/monitors/:id/pings?limit=100</span></div>
|
||||
<p class="endpoint-desc">Returns recent ping results for a monitor. Max 1000.</p>
|
||||
</div>
|
||||
|
||||
<!-- QL Fields -->
|
||||
<div id="ql-fields" class="section">
|
||||
<h2>Query Language — Fields</h2>
|
||||
<p>A PingQL query is a JSON object evaluated against each ping. If it returns <code style="color:#4ade80;background:#052e16;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">true</code>, the monitor is <strong style="color:#4ade80">up</strong>. Default (no query): up when status < 400.</p>
|
||||
<table>
|
||||
<thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>status</td><td>number</td><td>HTTP status code</td></tr>
|
||||
<tr><td>body</td><td>string</td><td>Full response body as text</td></tr>
|
||||
<tr><td>headers.<em>name</em></td><td>string</td><td>Response header, e.g. <code>headers.content-type</code></td></tr>
|
||||
<tr><td>$responseTime</td><td>number</td><td>Request latency in milliseconds</td></tr>
|
||||
<tr><td>$certExpiry</td><td>number</td><td>Days until SSL certificate expires</td></tr>
|
||||
<tr><td>$json</td><td>object</td><td>JSONPath expression against response body</td></tr>
|
||||
<tr><td>$select</td><td>object</td><td>CSS selector against response HTML</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- QL Operators -->
|
||||
<div id="ql-operators" class="section">
|
||||
<h2>Query Language — Operators</h2>
|
||||
<table>
|
||||
<thead><tr><th>Operator</th><th>Description</th><th>Types</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>$eq</td><td>Equal to</td><td>any</td></tr>
|
||||
<tr><td>$ne</td><td>Not equal to</td><td>any</td></tr>
|
||||
<tr><td>$gt / $gte</td><td>Greater than / or equal</td><td>number</td></tr>
|
||||
<tr><td>$lt / $lte</td><td>Less than / or equal</td><td>number</td></tr>
|
||||
<tr><td>$contains</td><td>String contains substring</td><td>string</td></tr>
|
||||
<tr><td>$startsWith</td><td>String starts with</td><td>string</td></tr>
|
||||
<tr><td>$endsWith</td><td>String ends with</td><td>string</td></tr>
|
||||
<tr><td>$regex</td><td>Matches regular expression</td><td>string</td></tr>
|
||||
<tr><td>$exists</td><td>Field is present and non-null</td><td>any</td></tr>
|
||||
<tr><td>$in</td><td>Value is in array</td><td>any</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="cb">
|
||||
<div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre><span class="c">// simple equality shorthand</span>
|
||||
{ <span class="k">"status"</span>: <span class="n">200</span> }
|
||||
|
||||
<span class="c">// operator form</span>
|
||||
{ <span class="k">"status"</span>: { <span class="o">"$lt"</span>: <span class="n">400</span> } }
|
||||
{ <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"healthy"</span> } }
|
||||
{ <span class="k">"headers.content-type"</span>: { <span class="o">"$contains"</span>: <span class="s">"application/json"</span> } }</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- $json -->
|
||||
<div id="ql-json" class="section">
|
||||
<h2>$json — JSONPath</h2>
|
||||
<p>Extract and compare a value from a JSON response body. The key is a dot-notation path starting with <code style="color:#93c5fd;background:#0f172a;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">$.</code></p>
|
||||
<div class="cb">
|
||||
<div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre><span class="c">// response body: { "status": "ok", "db": { "connections": 12 } }</span>
|
||||
|
||||
{ <span class="o">"$json"</span>: { <span class="s">"$.status"</span>: { <span class="o">"$eq"</span>: <span class="s">"ok"</span> } } }
|
||||
{ <span class="o">"$json"</span>: { <span class="s">"$.db.connections"</span>: { <span class="o">"$lt"</span>: <span class="n">100</span> } } }</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- $select -->
|
||||
<div id="ql-select" class="section">
|
||||
<h2>$select — CSS Selector</h2>
|
||||
<p>Extract text content from an HTML response using a CSS selector. Useful for monitoring public pages without an API.</p>
|
||||
<div class="cb">
|
||||
<div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre><span class="c">// matches if <h1> text is exactly "Example Domain"</span>
|
||||
{ <span class="o">"$select"</span>: { <span class="s">"h1"</span>: { <span class="o">"$eq"</span>: <span class="s">"Example Domain"</span> } } }
|
||||
|
||||
<span class="c">// matches if status badge contains "operational"</span>
|
||||
{ <span class="o">"$select"</span>: { <span class="s">".status-badge"</span>: { <span class="o">"$contains"</span>: <span class="s">"operational"</span> } } }</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logical -->
|
||||
<div id="ql-logical" class="section">
|
||||
<h2>Logical Operators</h2>
|
||||
<div class="cb">
|
||||
<div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre><span class="c">// $and — all conditions must match</span>
|
||||
{ <span class="o">"$and"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"ok"</span> } }] }
|
||||
|
||||
<span class="c">// $or — any condition must match</span>
|
||||
{ <span class="o">"$or"</span>: [{ <span class="k">"status"</span>: <span class="n">200</span> }, { <span class="k">"status"</span>: <span class="n">204</span> }] }
|
||||
|
||||
<span class="c">// $not — invert a condition</span>
|
||||
{ <span class="o">"$not"</span>: { <span class="k">"status"</span>: <span class="n">500</span> } }</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- $consider -->
|
||||
<div id="ql-consider" class="section">
|
||||
<h2>$consider</h2>
|
||||
<p>By default, matching conditions mean the monitor is <strong style="color:#4ade80">up</strong>. Set <code style="color:#93c5fd;background:#0f172a;padding:0.1em 0.35em;border-radius:0.2rem;font-size:0.78rem">"$consider": "down"</code> to flip this — if the conditions match, the monitor is <strong style="color:#f87171">down</strong>.</p>
|
||||
<div class="cb">
|
||||
<div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre><span class="c">// down if response time exceeds 2 seconds</span>
|
||||
{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">2000</span> } }
|
||||
|
||||
<span class="c">// down if cert expires in less than 7 days</span>
|
||||
{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$certExpiry"</span>: { <span class="o">"$lt"</span>: <span class="n">7</span> } }
|
||||
|
||||
<span class="c">// down if any of these match</span>
|
||||
{
|
||||
<span class="o">"$consider"</span>: <span class="s">"down"</span>,
|
||||
<span class="o">"$or"</span>: [
|
||||
{ <span class="k">"status"</span>: { <span class="o">"$gte"</span>: <span class="n">500</span> } },
|
||||
{ <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">5000</span> } }
|
||||
]
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Examples -->
|
||||
<div id="ql-examples" class="section">
|
||||
<h2>Examples</h2>
|
||||
|
||||
<h3>Basic health endpoint</h3>
|
||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre>{ <span class="k">"status"</span>: <span class="n">200</span>, <span class="k">"body"</span>: { <span class="o">"$contains"</span>: <span class="s">"healthy"</span> } }</pre></div>
|
||||
|
||||
<h3>JSON API response shape</h3>
|
||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre>{
|
||||
<span class="o">"$and"</span>: [
|
||||
{ <span class="k">"status"</span>: <span class="n">200</span> },
|
||||
{ <span class="o">"$json"</span>: { <span class="s">"$.ok"</span>: { <span class="o">"$eq"</span>: <span class="n">true</span> } } }
|
||||
]
|
||||
}</pre></div>
|
||||
|
||||
<h3>Performance monitor (mark down if slow)</h3>
|
||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre>{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$responseTime"</span>: { <span class="o">"$gt"</span>: <span class="n">1000</span> } }</pre></div>
|
||||
|
||||
<h3>Cert expiry alert</h3>
|
||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre>{ <span class="o">"$consider"</span>: <span class="s">"down"</span>, <span class="k">"$certExpiry"</span>: { <span class="o">"$lt"</span>: <span class="n">14</span> } }</pre></div>
|
||||
|
||||
<h3>Status page (HTML)</h3>
|
||||
<div class="cb"><div class="cb-header"><span class="cb-lang">json</span></div>
|
||||
<pre>{ <span class="o">"$select"</span>: { <span class="s">".status-indicator"</span>: { <span class="o">"$eq"</span>: <span class="s">"All systems operational"</span> } } }</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 pt-6 border-t border-gray-900 text-sm text-gray-600">
|
||||
PingQL · <a href="/dashboard" class="hover:text-gray-400 transition-colors">Dashboard</a>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Highlight active nav link on scroll
|
||||
const sections = document.querySelectorAll('[id]');
|
||||
const navLinks = document.querySelectorAll('.nav-link');
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
navLinks.forEach(l => l.classList.remove('active'));
|
||||
const link = document.querySelector(`.nav-link[href="#${entry.target.id}"]`);
|
||||
if (link) link.classList.add('active');
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '-20% 0px -70% 0px' });
|
||||
sections.forEach(s => observer.observe(s));
|
||||
|
||||
// Copy buttons
|
||||
document.querySelectorAll('.cb').forEach(cb => {
|
||||
const btn = cb.querySelector('.cb-copy');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', () => {
|
||||
const text = cb.querySelector('pre').innerText;
|
||||
navigator.clipboard.writeText(text);
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => btn.textContent = 'Copy', 1500);
|
||||
});
|
||||
});
|
||||
// Add copy buttons to all codeblocks
|
||||
document.querySelectorAll('.cb-header').forEach(h => {
|
||||
if (!h.querySelector('.cb-copy')) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'cb-copy';
|
||||
btn.textContent = 'Copy';
|
||||
h.appendChild(btn);
|
||||
btn.addEventListener('click', () => {
|
||||
const text = h.nextElementSibling.innerText;
|
||||
navigator.clipboard.writeText(text);
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => btn.textContent = 'Copy', 1500);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,112 +0,0 @@
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/dashboard/tailwind.css">
|
||||
<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/settings" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Settings</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 class="flex items-center gap-4">
|
||||
<div id="summary" class="text-sm text-gray-500"></div>
|
||||
<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</a>
|
||||
</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 ping for each monitor
|
||||
const monitorsWithPings = await Promise.all(
|
||||
monitors.map(async (m) => {
|
||||
try {
|
||||
const pings = await api(`/monitors/${m.id}/pings?limit=20`);
|
||||
return { ...m, pings };
|
||||
} catch {
|
||||
return { ...m, pings: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const upCount = monitorsWithPings.filter(m => m.pings[0]?.up === true).length;
|
||||
const downCount = monitorsWithPings.filter(m => m.pings[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 = monitorsWithPings.map(m => {
|
||||
const lastPing = m.pings[0];
|
||||
const latencies = m.pings.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(lastPing?.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">${lastPing ? timeAgo(lastPing.checked_at) : 'no pings'}</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>
|
||||
@@ -1,155 +0,0 @@
|
||||
<!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 — Sign In</title>
|
||||
<link rel="stylesheet" href="/dashboard/tailwind.css">
|
||||
<link rel="stylesheet" href="/dashboard/app.css">
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<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 border border-gray-800" style="box-shadow:0 0 40px rgba(59,130,246,0.08)">
|
||||
|
||||
<!-- Sign in form — works with or without JS -->
|
||||
<div id="screen-login">
|
||||
<form id="login-form" action="/account/login" method="POST">
|
||||
<input type="hidden" name="_form" value="1">
|
||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Account Key</label>
|
||||
<input id="key-input" name="key" type="text" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" autocomplete="off" spellcheck="false"
|
||||
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 text-sm font-mono tracking-wider"
|
||||
maxlength="36">
|
||||
<button type="submit"
|
||||
class="w-full mt-3 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>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 pt-5 border-t border-gray-800 text-center">
|
||||
<p class="text-gray-500 text-sm mb-3">No account?</p>
|
||||
<button id="register-btn"
|
||||
class="w-full bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-300 font-medium py-3 rounded-lg transition-colors">
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post-registration: show new key -->
|
||||
<div id="screen-new-account" class="hidden">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center text-green-400 text-lg">✓</div>
|
||||
<div>
|
||||
<p class="font-semibold text-white">Account created</p>
|
||||
<p class="text-xs text-gray-500">Save your key — it's how you access your account</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-2">Your Account Key</label>
|
||||
<div class="flex gap-2 mb-5">
|
||||
<div id="new-key-display"
|
||||
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-blue-400 text-sm font-mono select-all break-all"></div>
|
||||
<button id="copy-key-btn"
|
||||
class="px-4 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors text-sm">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pt-5 border-t border-gray-800">
|
||||
<label class="block text-xs text-gray-500 uppercase tracking-wider mb-1">Email <span class="text-gray-600 normal-case">(optional — recovery only)</span></label>
|
||||
<input id="email-input" type="email" placeholder="you@example.com"
|
||||
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 text-sm mb-3">
|
||||
<div class="flex gap-2">
|
||||
<button id="save-email-btn"
|
||||
class="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-medium py-2.5 rounded-lg transition-colors text-sm">
|
||||
Save & Continue
|
||||
</button>
|
||||
<button id="skip-email-btn"
|
||||
class="px-4 bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-400 rounded-lg transition-colors text-sm">
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
<div id="email-error" class="text-red-400 text-xs mt-2 hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '';
|
||||
let newKey = null;
|
||||
|
||||
const keyInput = document.getElementById('key-input');
|
||||
|
||||
// JS-enhanced login (overrides form POST for better UX)
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const key = keyInput.value.trim();
|
||||
if (key.length < 10) return showError('Enter a valid account key');
|
||||
try {
|
||||
const res = await fetch('/account/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (!res.ok) return showError('Invalid account key');
|
||||
window.location.href = '/dashboard/home';
|
||||
} catch { showError('Connection error'); }
|
||||
});
|
||||
|
||||
// Register
|
||||
document.getElementById('register-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch('/account/register', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.key) return showError(data.error || 'Failed');
|
||||
newKey = data.key;
|
||||
|
||||
// Set cookie via login endpoint
|
||||
await fetch('/account/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: newKey }),
|
||||
});
|
||||
|
||||
document.getElementById('screen-login').classList.add('hidden');
|
||||
document.getElementById('screen-new-account').classList.remove('hidden');
|
||||
document.getElementById('new-key-display').textContent = newKey;
|
||||
} catch { showError('Connection error'); }
|
||||
});
|
||||
|
||||
document.getElementById('copy-key-btn').addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(newKey).then(() => {
|
||||
const btn = document.getElementById('copy-key-btn');
|
||||
btn.textContent = 'Copied!'; btn.classList.add('text-green-400');
|
||||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('save-email-btn').addEventListener('click', async () => {
|
||||
const email = document.getElementById('email-input').value.trim();
|
||||
if (!email) { document.getElementById('skip-email-btn').click(); return; }
|
||||
try {
|
||||
await fetch('/account/email', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }),
|
||||
});
|
||||
} catch {}
|
||||
window.location.href = '/dashboard/home';
|
||||
});
|
||||
|
||||
document.getElementById('skip-email-btn').addEventListener('click', () => {
|
||||
window.location.href = '/dashboard/home';
|
||||
});
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('login-error');
|
||||
el.textContent = msg; el.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,598 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark scroll-smooth">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PingQL — Uptime monitoring that thinks like a developer</title>
|
||||
<meta name="description" content="Monitor uptime with a MongoDB-style query language. Inspect JSON, HTML, headers, certs, and more — not just ping.">
|
||||
<link rel="stylesheet" href="/dashboard/tailwind.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
html { scroll-behavior: smooth; }
|
||||
body { background: #0a0a0a; }
|
||||
|
||||
/* Grid background */
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(59, 130, 246, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.03) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
}
|
||||
|
||||
/* Hero glow */
|
||||
.hero-glow {
|
||||
background: radial-gradient(ellipse 60% 40% at 50% 0%, rgba(59, 130, 246, 0.12) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* Terminal window */
|
||||
.terminal {
|
||||
background: #111111;
|
||||
border: 1px solid #1e1e1e;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 60px rgba(59, 130, 246, 0.06), 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.terminal-bar {
|
||||
background: #1a1a1a;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.terminal-dot {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
}
|
||||
.terminal-body {
|
||||
padding: 20px 24px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Syntax highlighting */
|
||||
.syn-key { color: #93c5fd; }
|
||||
.syn-str { color: #86efac; }
|
||||
.syn-num { color: #fbbf24; }
|
||||
.syn-op { color: #c084fc; }
|
||||
.syn-brace { color: #6b7280; }
|
||||
.syn-comment { color: #4b5563; font-style: italic; }
|
||||
|
||||
/* Fade-in animation */
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(24px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fade-up {
|
||||
animation: fade-up 0.7s ease-out both;
|
||||
}
|
||||
.fade-up-delay-1 { animation-delay: 0.1s; }
|
||||
.fade-up-delay-2 { animation-delay: 0.2s; }
|
||||
.fade-up-delay-3 { animation-delay: 0.3s; }
|
||||
.fade-up-delay-4 { animation-delay: 0.4s; }
|
||||
|
||||
/* Glow card */
|
||||
.glow-card {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(17, 17, 17, 1) 50%);
|
||||
border: 1px solid #1e1e1e;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.glow-card:hover {
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
box-shadow: 0 0 30px rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
/* Comparison bar */
|
||||
.ping-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Pulse animation */
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.pulse-dot {
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Typing cursor */
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 18px;
|
||||
background: #3b82f6;
|
||||
animation: blink 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
margin-left: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 font-sans antialiased grid-bg">
|
||||
|
||||
<!-- ─── HEADER ─── -->
|
||||
<header class="fixed top-0 left-0 right-0 z-50 border-b border-gray-800/60 bg-[#0a0a0a]/80 backdrop-blur-md">
|
||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" class="font-mono text-lg font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
||||
<nav class="hidden md:flex items-center gap-7 text-sm text-gray-400">
|
||||
<a href="/docs" class="hover:text-gray-200 transition-colors">Docs</a>
|
||||
<a href="/privacy" class="hover:text-gray-200 transition-colors">Privacy</a>
|
||||
<a href="#pricing" class="hover:text-gray-200 transition-colors">Pricing</a>
|
||||
</nav>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/dashboard" class="text-sm text-gray-400 hover:text-gray-200 transition-colors">Sign in</a>
|
||||
<a href="/dashboard" class="text-sm bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg font-medium transition-colors">Get started</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ─── HERO ─── -->
|
||||
<section class="relative min-h-screen flex items-start hero-glow pt-24">
|
||||
<div class="max-w-6xl mx-auto px-6 pt-16 pb-12 w-full">
|
||||
<div class="grid lg:grid-cols-2 gap-16 items-center">
|
||||
<!-- Left -->
|
||||
<div class="fade-up">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-gray-800 text-xs text-gray-400 mb-6 font-mono">
|
||||
<span class="w-2 h-2 rounded-full bg-green-500 pulse-dot"></span>
|
||||
All systems operational
|
||||
</div>
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.1] mb-6">
|
||||
Uptime monitoring<br>that <span class="text-brand">thinks like<br>a developer</span>
|
||||
</h1>
|
||||
<p class="text-lg text-gray-400 max-w-lg mb-8 leading-relaxed">
|
||||
Go beyond simple pings. Write queries against status codes, JSON bodies, HTML selectors, headers, cert expiry — everything your response contains.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<a href="/dashboard" class="inline-flex items-center gap-2 px-6 py-3 bg-brand hover:bg-blue-500 text-white font-medium rounded-lg transition-colors text-sm">
|
||||
Get Started Free
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
|
||||
</a>
|
||||
<a href="/docs" class="inline-flex items-center gap-2 px-6 py-3 border border-gray-700 hover:border-gray-500 text-gray-300 font-medium rounded-lg transition-colors text-sm">
|
||||
Read the Docs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: terminal -->
|
||||
<div class="fade-up fade-up-delay-2">
|
||||
<div class="terminal">
|
||||
<div class="terminal-bar">
|
||||
<div class="terminal-dot bg-[#ff5f57]"></div>
|
||||
<div class="terminal-dot bg-[#ffbd2e]"></div>
|
||||
<div class="terminal-dot bg-[#28c840]"></div>
|
||||
<span class="ml-3 text-xs text-gray-500 font-mono">query.json</span>
|
||||
</div>
|
||||
<div class="terminal-body">
|
||||
<pre><span class="syn-brace">{</span>
|
||||
<span class="syn-key">"$and"</span><span class="syn-brace">:</span> <span class="syn-brace">[</span>
|
||||
<span class="syn-brace">{</span> <span class="syn-key">"status"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$lt"</span><span class="syn-brace">:</span> <span class="syn-num">400</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>,
|
||||
<span class="syn-brace">{</span> <span class="syn-op">"$json"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-str">"$.db.status"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$eq"</span><span class="syn-brace">:</span> <span class="syn-str">"ok"</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>,
|
||||
<span class="syn-brace">{</span> <span class="syn-op">"$certExpiry"</span><span class="syn-brace">:</span> <span class="syn-brace">{</span> <span class="syn-op">"$gt"</span><span class="syn-brace">:</span> <span class="syn-num">14</span> <span class="syn-brace">}</span> <span class="syn-brace">}</span>
|
||||
<span class="syn-brace">]</span>
|
||||
<span class="syn-brace">}</span></pre>
|
||||
<div class="mt-4 pt-4 border-t border-gray-800 text-xs text-gray-500">
|
||||
<span class="syn-comment">// status < 400 AND $.db.status = "ok" AND $certExpiry > 14 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── NOT JUST PING ─── -->
|
||||
<section class="py-24 px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">Not just ping</h2>
|
||||
<p class="text-gray-400 max-w-2xl mx-auto text-lg">
|
||||
Most monitoring tools ping a URL and check if it returns 200. That tells you almost nothing.
|
||||
PingQL lets you query the <em>entire</em> response.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Comparison -->
|
||||
<div class="grid md:grid-cols-2 gap-8 max-w-3xl mx-auto">
|
||||
<!-- Others -->
|
||||
<div class="rounded-xl border border-gray-800 bg-[#111] p-6">
|
||||
<div class="text-sm text-gray-500 uppercase tracking-wider mb-4 font-mono">Others</div>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-green-500 shrink-0"></span>
|
||||
<span class="text-gray-400 text-sm">GET /api/health → 200</span>
|
||||
<span class="ml-auto text-green-500 text-xs font-mono">UP</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full bg-red-500 shrink-0"></span>
|
||||
<span class="text-gray-400 text-sm">GET /api/health → 500</span>
|
||||
<span class="ml-auto text-red-500 text-xs font-mono">DOWN</span>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-800 text-xs text-gray-500">
|
||||
That's it. That's all you get.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PingQL -->
|
||||
<div class="rounded-xl border border-blue-500/30 bg-[#111] p-6 shadow-[0_0_40px_rgba(59,130,246,0.06)]">
|
||||
<div class="text-sm text-brand uppercase tracking-wider mb-4 font-mono">PingQL</div>
|
||||
<div class="space-y-3 text-sm font-mono">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-green-400">✓</span>
|
||||
<span class="text-gray-300">Status code assertions</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-green-400">✓</span>
|
||||
<span class="text-gray-300">JSON body inspection</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-green-400">✓</span>
|
||||
<span class="text-gray-300">HTML / CSS selector matching</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-green-400">✓</span>
|
||||
<span class="text-gray-300">Response time thresholds</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-green-400">✓</span>
|
||||
<span class="text-gray-300">SSL cert expiry checks</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-green-400">✓</span>
|
||||
<span class="text-gray-300">Header & regex matching</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── FEATURES ─── -->
|
||||
<section class="py-24 px-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">Everything you need</h2>
|
||||
<p class="text-gray-400 text-lg">Powerful primitives, zero bloat.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Feature 1 -->
|
||||
<div class="glow-card rounded-xl p-6">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white mb-2">MongoDB-style queries</h3>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">Use <code class="text-brand font-mono text-xs">$and</code>, <code class="text-brand font-mono text-xs">$or</code>, <code class="text-brand font-mono text-xs">$lt</code>, <code class="text-brand font-mono text-xs">$regex</code> — a query language you already know.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 2 -->
|
||||
<div class="glow-card rounded-xl p-6">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white mb-2">JSONPath & CSS selectors</h3>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">Drill into JSON responses with <code class="text-brand font-mono text-xs">$json</code> or scrape any HTML page with <code class="text-brand font-mono text-xs">$css</code> — no API required.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 3 -->
|
||||
<div class="glow-card rounded-xl p-6">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white mb-2">SSL cert monitoring</h3>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">Built-in <code class="text-brand font-mono text-xs">$certExpiry</code> operator. Get alerted before your cert expires, not after.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 4 -->
|
||||
<div class="glow-card rounded-xl p-6">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white mb-2">Global monitoring network</h3>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">Physical servers across every continent. Ping from the regions that matter to your users, not just one location.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 5 -->
|
||||
<div class="glow-card rounded-xl p-6">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white mb-2">Custom HTTP requests</h3>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">Send GET, POST, PUT, PATCH or HEAD with custom headers and request bodies. Monitor authenticated APIs end-to-end.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 6 -->
|
||||
<div class="glow-card rounded-xl p-6">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white mb-2">Sub-keys & API access</h3>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">Create scoped sub-keys for CI pipelines, scripts, or teammates. Full REST API — no dashboard required.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 7 -->
|
||||
<div class="glow-card rounded-xl p-6">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white mb-2">Live dashboard</h3>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">Real-time updates via SSE. Latency charts, status history, uptime stats — all live without a page refresh.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 8 -->
|
||||
<div class="glow-card rounded-xl p-6">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white mb-2">Privacy-first accounts</h3>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">No email required. No passwords. Just an account key. Emails are hashed if provided — we literally can't read them.</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 9 -->
|
||||
<div class="glow-card rounded-xl p-6">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-brand" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-white mb-2">Pay with crypto</h3>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">We accept <span class="text-gray-300">Bitcoin, Litecoin, Dogecoin, Bitcoin Cash, Dash,</span> and <span class="text-gray-300">eCash</span>. No card, no name, no billing address required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── PRIVACY ─── -->
|
||||
<section id="privacy" class="py-24 px-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="rounded-2xl border border-gray-800 bg-gradient-to-b from-[#111] to-[#0a0a0a] p-8 sm:p-12">
|
||||
<div class="flex items-start gap-4 mb-8">
|
||||
<div class="w-12 h-12 rounded-xl bg-green-500/10 border border-green-500/20 flex items-center justify-center shrink-0">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold tracking-tight mb-2">Privacy by design</h2>
|
||||
<p class="text-gray-400">We built PingQL for developers who care about their data.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 gap-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
||||
<div>
|
||||
<p class="text-white font-medium text-sm">No tracking, no analytics, no ads</p>
|
||||
<p class="text-gray-500 text-xs mt-1">Zero third-party scripts. Zero cookies.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
||||
<div>
|
||||
<p class="text-white font-medium text-sm">Account keys, not passwords</p>
|
||||
<p class="text-gray-500 text-xs mt-1">Be completely anonymous. No email required.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
||||
<div>
|
||||
<p class="text-white font-medium text-sm">Emails hashed if provided</p>
|
||||
<p class="text-gray-500 text-xs mt-1">Optional email for recovery only. We hash it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-green-400 mt-0.5 shrink-0 font-mono text-sm">$</span>
|
||||
<div>
|
||||
<p class="text-white font-medium text-sm">We never sell data</p>
|
||||
<p class="text-gray-500 text-xs mt-1">Your monitors, your data. Period.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-gray-800 flex items-center gap-2 text-xs text-gray-500">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"/></svg>
|
||||
Hosted in EU (Prague) — GDPR compliant
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── API EXAMPLE ─── -->
|
||||
<section class="py-24 px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">API-first, always</h2>
|
||||
<p class="text-gray-400 text-lg max-w-2xl mx-auto">Create monitors, query results, and manage everything from scripts, your terminal, or anywhere with HTTP.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-6">
|
||||
<!-- Create monitor -->
|
||||
<div class="terminal">
|
||||
<div class="terminal-bar">
|
||||
<div class="terminal-dot bg-[#ff5f57]"></div>
|
||||
<div class="terminal-dot bg-[#ffbd2e]"></div>
|
||||
<div class="terminal-dot bg-[#28c840]"></div>
|
||||
<span class="ml-3 text-xs text-gray-500 font-mono">Create a monitor</span>
|
||||
</div>
|
||||
<div class="terminal-body text-xs sm:text-[13px]">
|
||||
<pre><span class="text-gray-500">$</span> <span class="text-white">curl</span> -X POST https://pingql.com/api/monitors \
|
||||
-H <span class="syn-str">"X-Key: XXXX-XXXX-XXXX-XXXX"</span> \
|
||||
-d <span class="syn-str">'{
|
||||
"url": "https://api.example.com/health",
|
||||
"interval": 60,
|
||||
"query": {
|
||||
"status": { "$lt": 400 },
|
||||
"$json": {
|
||||
"$.ok": { "$eq": true }
|
||||
}
|
||||
}
|
||||
}'</span>
|
||||
|
||||
<span class="text-gray-500">// → 201 Created</span>
|
||||
<span class="syn-brace">{</span> <span class="syn-key">"id"</span>: <span class="syn-str">"mon_a1b2c3"</span>, <span class="syn-key">"status"</span>: <span class="syn-str">"active"</span> <span class="syn-brace">}</span></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Get results -->
|
||||
<div class="terminal">
|
||||
<div class="terminal-bar">
|
||||
<div class="terminal-dot bg-[#ff5f57]"></div>
|
||||
<div class="terminal-dot bg-[#ffbd2e]"></div>
|
||||
<div class="terminal-dot bg-[#28c840]"></div>
|
||||
<span class="ml-3 text-xs text-gray-500 font-mono">Check results</span>
|
||||
</div>
|
||||
<div class="terminal-body text-xs sm:text-[13px]">
|
||||
<pre><span class="text-gray-500">$</span> <span class="text-white">curl</span> https://pingql.com/api/monitors/mon_a1b2c3 \
|
||||
-H <span class="syn-str">"X-Key: XXXX-XXXX-XXXX-XXXX"</span>
|
||||
|
||||
<span class="syn-brace">{</span>
|
||||
<span class="syn-key">"id"</span>: <span class="syn-str">"mon_a1b2c3"</span>,
|
||||
<span class="syn-key">"url"</span>: <span class="syn-str">"https://api.example.com/health"</span>,
|
||||
<span class="syn-key">"status"</span>: <span class="syn-str">"up"</span>,
|
||||
<span class="syn-key">"lastCheck"</span>: <span class="syn-str">"2024-01-15T10:30:00Z"</span>,
|
||||
<span class="syn-key">"responseTime"</span>: <span class="syn-num">142</span>,
|
||||
<span class="syn-key">"uptime30d"</span>: <span class="syn-num">99.97</span>
|
||||
<span class="syn-brace">}</span></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CLI teaser -->
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-gray-800 text-xs text-gray-500 font-mono">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"/></svg>
|
||||
CLI coming soon — <span class="text-gray-400">pingql watch mon_a1b2c3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── PRICING ─── -->
|
||||
<section id="pricing" class="py-24 px-6">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold tracking-tight mb-4">Simple pricing</h2>
|
||||
<p class="text-gray-400 text-lg mb-12">Start for free. No credit card required.</p>
|
||||
|
||||
<div class="grid sm:grid-cols-3 gap-6 max-w-4xl mx-auto">
|
||||
<!-- Free -->
|
||||
<div class="glow-card rounded-xl p-8 text-left">
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Free</div>
|
||||
<div class="text-4xl font-bold mb-1">$0</div>
|
||||
<div class="text-sm text-gray-500 mb-6">forever</div>
|
||||
<ul class="space-y-3 text-sm text-gray-400">
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
5 monitors
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
20s check interval
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
Full query language
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
API access
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-green-400 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
No credit card
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/dashboard" class="mt-8 block text-center w-full py-3 bg-brand hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Pro -->
|
||||
<div class="rounded-xl border border-gray-700 bg-[#111] p-8 text-left relative">
|
||||
<div class="absolute -top-3 right-6 px-3 py-1 bg-gray-800 border border-gray-700 rounded-full text-xs text-gray-400 font-mono">
|
||||
coming soon
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Pro</div>
|
||||
<div class="text-4xl font-bold mb-1 text-gray-500">$14</div>
|
||||
<div class="text-sm text-gray-600 mb-6">per month</div>
|
||||
<ul class="space-y-3 text-sm text-gray-500">
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
Unlimited monitors
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
1s check interval
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
Webhook notifications
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-gray-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
Priority support
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-8 block text-center w-full py-3 border border-gray-700 text-gray-500 text-sm font-medium rounded-lg cursor-default">
|
||||
Coming Soon
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lifetime -->
|
||||
<div class="rounded-xl border border-yellow-500/30 bg-[#111] p-8 text-left relative">
|
||||
<div class="absolute -top-3 right-6 px-3 py-1 bg-yellow-900/40 border border-yellow-500/30 rounded-full text-xs text-yellow-400 font-mono">
|
||||
coming soon
|
||||
</div>
|
||||
<div class="text-xs text-yellow-500/70 uppercase tracking-wider font-mono mb-2">Lifetime</div>
|
||||
<div class="flex items-baseline gap-3 mb-1">
|
||||
<div class="text-4xl font-bold text-gray-400">$149</div>
|
||||
<div class="text-xl text-gray-600 line-through">$179</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 mb-6">one-time</div>
|
||||
<ul class="space-y-3 text-sm text-gray-500">
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
Everything in Pro
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
Pay once, use forever
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
All future updates
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-yellow-500/50 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
Limited availability
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-8 block text-center w-full py-3 border border-yellow-500/20 text-yellow-500/50 text-sm font-medium rounded-lg cursor-default">
|
||||
Coming Soon
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── FOOTER ─── -->
|
||||
<footer class="border-t border-gray-800/50 py-12 px-6">
|
||||
<div class="max-w-5xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="/" class="text-lg font-bold tracking-tight font-mono">Ping<span class="text-brand">QL</span></a>
|
||||
<nav class="flex items-center gap-5 text-sm text-gray-500">
|
||||
<a href="/dashboard" class="hover:text-gray-300 transition-colors">Dashboard</a>
|
||||
<a href="/docs" class="hover:text-gray-300 transition-colors">Docs</a>
|
||||
<a href="/privacy" class="hover:text-gray-300 transition-colors">Privacy</a>
|
||||
<a href="#pricing" class="hover:text-gray-300 transition-colors">Pricing</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
Built by Ico ♟
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,106 +0,0 @@
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/dashboard/tailwind.css">
|
||||
<style>
|
||||
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 min-h-screen">
|
||||
<script src="/dashboard/app.js"></script>
|
||||
<script src="/dashboard/query-builder.js"></script>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
|
||||
<a href="/dashboard/home" class="text-xl font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-300 text-sm transition-colors">Logout</button>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-2xl mx-auto px-6 py-8">
|
||||
<div class="mb-6">
|
||||
<a href="/dashboard/home" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">← Back to monitors</a>
|
||||
<h2 class="text-lg font-semibold text-gray-200 mt-2">Create Monitor</h2>
|
||||
</div>
|
||||
|
||||
<form id="create-form" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Name</label>
|
||||
<input id="name" type="text" required placeholder="Production API"
|
||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">URL</label>
|
||||
<input id="url" type="url" required placeholder="https://api.example.com/health"
|
||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Ping Interval</label>
|
||||
<select id="interval"
|
||||
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
|
||||
<option value="10">10 seconds</option>
|
||||
<option value="30">30 seconds</option>
|
||||
<option value="60" selected>1 minute</option>
|
||||
<option value="300">5 minutes</option>
|
||||
<option value="600">10 minutes</option>
|
||||
<option value="1800">30 minutes</option>
|
||||
<option value="3600">1 hour</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Query Conditions <span class="text-gray-600">(optional)</span></label>
|
||||
<p class="text-xs text-gray-600 mb-3">Define when this monitor should be considered "up". Defaults to status < 400.</p>
|
||||
<div id="query-builder"></div>
|
||||
</div>
|
||||
|
||||
<div id="form-error" class="text-red-400 text-sm hidden"></div>
|
||||
|
||||
<button type="submit" id="submit-btn"
|
||||
class="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
|
||||
Create Monitor
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
if (!requireAuth()) throw 'auth';
|
||||
|
||||
let currentQuery = null;
|
||||
const qb = new QueryBuilder(document.getElementById('query-builder'), (q) => {
|
||||
currentQuery = q;
|
||||
});
|
||||
|
||||
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submit-btn');
|
||||
const errEl = document.getElementById('form-error');
|
||||
errEl.classList.add('hidden');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating...';
|
||||
|
||||
try {
|
||||
const body = {
|
||||
name: document.getElementById('name').value.trim(),
|
||||
url: document.getElementById('url').value.trim(),
|
||||
interval_s: Number(document.getElementById('interval').value),
|
||||
};
|
||||
if (currentQuery) body.query = currentQuery;
|
||||
|
||||
await api('/monitors/', { method: 'POST', body });
|
||||
window.location.href = '/dashboard/home';
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Monitor';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,155 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark scroll-smooth">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Privacy — PingQL</title>
|
||||
<meta name="description" content="PingQL's privacy policy. No tracking, no data sales, no ads. We store what we need and nothing more.">
|
||||
<link rel="stylesheet" href="/dashboard/tailwind.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #0a0a0a; }
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(59, 130, 246, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.03) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
}
|
||||
.prose-custom h2 { font-size: 1.25rem; font-weight: 600; color: #f3f4f6; margin-top: 2.5rem; margin-bottom: 0.75rem; }
|
||||
.prose-custom p { color: #9ca3af; line-height: 1.75; margin-bottom: 1rem; }
|
||||
.prose-custom ul { margin-bottom: 1rem; padding-left: 1.25rem; }
|
||||
.prose-custom li { color: #9ca3af; line-height: 1.75; margin-bottom: 0.25rem; list-style-type: disc; }
|
||||
.prose-custom strong { color: #e5e7eb; font-weight: 600; }
|
||||
.prose-custom a { color: #60a5fa; text-decoration: none; }
|
||||
.prose-custom a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#0a0a0a] text-gray-100 font-sans antialiased grid-bg">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="border-b border-gray-800/60 bg-[#0a0a0a]/80 backdrop-blur-md">
|
||||
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" class="font-mono text-lg font-bold tracking-tight">Ping<span class="text-blue-400">QL</span></a>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/dashboard" class="text-sm text-gray-400 hover:text-gray-200 transition-colors">Sign in</a>
|
||||
<a href="/dashboard" class="text-sm bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg font-medium transition-colors">Get started</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-2xl mx-auto px-6 py-20">
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="mb-12">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-green-800/50 text-xs text-green-400 mb-6 font-mono">
|
||||
<span class="w-2 h-2 rounded-full bg-green-500"></span>
|
||||
Actually private
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold tracking-tight mb-4">Privacy Policy</h1>
|
||||
<p class="text-gray-400 text-lg leading-relaxed">
|
||||
The short version: we collect the minimum needed to run the service. We don't sell it, share it, or monetize it. Ever.
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 mt-4">Last updated: March 2026</p>
|
||||
</div>
|
||||
|
||||
<!-- The industry problem -->
|
||||
<div class="rounded-xl border border-red-900/30 bg-red-950/10 p-6 mb-10">
|
||||
<h2 class="text-base font-semibold text-red-400 mb-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
|
||||
The rest of the industry
|
||||
</h2>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">
|
||||
Most SaaS monitoring tools are free or cheap because <strong>you're the product</strong>. They're backed by investors who need returns, which means your usage data, your infrastructure details, your URLs, your team — all of it gets fed into ad networks, sold to data brokers, or used to build "anonymized" datasets that aren't actually anonymous.
|
||||
</p>
|
||||
<p class="text-sm text-gray-400 leading-relaxed mt-3">
|
||||
They require your email "for alerts" and then spam you, add you to nurture sequences, sell your address, and share it with their parent company's 47 "trusted partners." They embed Segment, Mixpanel, Intercom, Hotjar, Google Analytics, and a dozen other trackers on every page — harvesting your behavior and shipping it to Silicon Valley servers whether you want it or not.
|
||||
</p>
|
||||
<p class="text-sm text-gray-400 leading-relaxed mt-3">
|
||||
That's the industry standard. We think it's garbage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="prose-custom">
|
||||
|
||||
<h2>What we collect</h2>
|
||||
<ul>
|
||||
<li><strong>Account key</strong> — a UUID you generate at signup. Stored in plaintext. It's your credential.</li>
|
||||
<li><strong>Email (optional)</strong> — only if you provide one for account recovery. Stored as a one-way SHA-256 hash. We cannot read it, search for it, or recover it. It's purely a verification token.</li>
|
||||
<li><strong>Monitors</strong> — the URLs and configurations you create. Obviously.</li>
|
||||
<li><strong>Ping results</strong> — status codes, latency, up/down, error messages. This is the product.</li>
|
||||
</ul>
|
||||
<p>That's it. No device fingerprints, no IP logs, no browser data, no analytics events, no session recordings.</p>
|
||||
|
||||
<h2>What we don't collect</h2>
|
||||
<ul>
|
||||
<li>Your name, company, or any identity information</li>
|
||||
<li>Your IP address (beyond what your browser sends in the HTTP request, which we don't log)</li>
|
||||
<li>Browsing behavior or page analytics</li>
|
||||
<li>Third-party tracking scripts of any kind</li>
|
||||
<li>Cookies beyond the session cookie that keeps you logged in</li>
|
||||
</ul>
|
||||
|
||||
<h2>What we never do</h2>
|
||||
<ul>
|
||||
<li><strong>Sell your data.</strong> Not to anyone, ever, for any reason.</li>
|
||||
<li><strong>Share your data</strong> with advertisers, data brokers, or "partners."</li>
|
||||
<li><strong>Use your monitor URLs or configs</strong> to build datasets, train models, or infer anything about your business.</li>
|
||||
<li><strong>Send marketing email</strong> unless you explicitly ask for it (you won't, because we don't have that feature).</li>
|
||||
<li><strong>Require your email.</strong> You can use PingQL completely anonymously.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Third parties</h2>
|
||||
<p>
|
||||
We use <strong>Cloudflare</strong> for DNS and DDoS protection. They see your IP when you connect. Their privacy policy applies to that layer.
|
||||
That's the complete list of third parties with access to anything.
|
||||
</p>
|
||||
<p>
|
||||
No Segment. No Mixpanel. No Intercom. No Hotjar. No Google Analytics. No Facebook Pixel. Nothing.
|
||||
</p>
|
||||
|
||||
<h2>Payments</h2>
|
||||
<p>
|
||||
We accept cryptocurrency. No card data, no billing address, no name — payments can be entirely anonymous. We never store or see your payment details.
|
||||
</p>
|
||||
|
||||
<h2>Data retention</h2>
|
||||
<p>
|
||||
Ping history is retained for 90 days by default. Account data is kept until you delete your account. If you want everything deleted, contact us and we'll do it.
|
||||
</p>
|
||||
|
||||
<h2>Where data is stored</h2>
|
||||
<p>
|
||||
Servers are in Prague, Czech Republic (EU). GDPR applies.
|
||||
</p>
|
||||
|
||||
<h2>Security</h2>
|
||||
<p>
|
||||
All data in transit is encrypted via TLS. All data at rest is on encrypted disks. Your account key is the only credential — there's no password database to breach, no hashed passwords to crack, no OAuth tokens to steal.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions? <a href="mailto:hi@pingql.com">hi@pingql.com</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="border-t border-gray-800/50 py-12 px-6 mt-12">
|
||||
<div class="max-w-5xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="/" class="text-lg font-bold tracking-tight font-mono">Ping<span class="text-blue-400">QL</span></a>
|
||||
<nav class="flex items-center gap-5 text-sm text-gray-500">
|
||||
<a href="/dashboard" class="hover:text-gray-300 transition-colors">Dashboard</a>
|
||||
<a href="/docs" class="hover:text-gray-300 transition-colors">Docs</a>
|
||||
<a href="/privacy" class="hover:text-gray-300 transition-colors">Privacy</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">Built by Ico ♟</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,234 +0,0 @@
|
||||
<!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 — Settings</title>
|
||||
<link rel="stylesheet" href="/dashboard/tailwind.css">
|
||||
<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">
|
||||
|
||||
<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-5 text-sm text-gray-500">
|
||||
<a href="/dashboard/home" class="hover:text-gray-300 transition-colors">Monitors</a>
|
||||
<span class="text-gray-300">Settings</span>
|
||||
<button onclick="logout()" class="hover:text-gray-300 transition-colors">Logout</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-2xl mx-auto px-6 py-10 space-y-8">
|
||||
|
||||
<h1 class="text-xl font-semibold text-white">Settings</h1>
|
||||
|
||||
<!-- Account info -->
|
||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-4">Account</h2>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Primary Key</label>
|
||||
<div class="flex gap-2">
|
||||
<code id="primary-key" class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-blue-400 text-sm tracking-widest"></code>
|
||||
<button onclick="copyKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors text-xs">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Member since</label>
|
||||
<p id="created-at" class="text-sm text-gray-400"></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Email -->
|
||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-1">Recovery Email</h2>
|
||||
<p class="text-xs text-gray-600 mb-4">Used for account recovery only. Stored as a one-way hash — we can't read it.</p>
|
||||
<div class="flex gap-2">
|
||||
<input id="email-input" type="email" placeholder="you@example.com"
|
||||
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<button onclick="saveEmail()" id="email-btn"
|
||||
class="px-4 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors">Save</button>
|
||||
<button onclick="removeEmail()" id="remove-email-btn" class="hidden px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-500 hover:text-red-400 text-xs transition-colors">Remove</button>
|
||||
</div>
|
||||
<p id="email-status" class="text-xs mt-2 hidden"></p>
|
||||
</section>
|
||||
|
||||
<!-- Reset primary key -->
|
||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-1">Rotate Primary Key</h2>
|
||||
<p class="text-xs text-gray-600 mb-4">Generates a new primary key. Your old key will stop working immediately. Sub-keys are not affected.</p>
|
||||
<button onclick="confirmReset()" class="px-4 py-2.5 bg-gray-800 hover:bg-gray-700 border border-red-900/50 hover:border-red-700/50 text-red-400 rounded-lg text-sm transition-colors">Rotate Key</button>
|
||||
</section>
|
||||
|
||||
<!-- Sub-keys -->
|
||||
<section class="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-300">API Keys</h2>
|
||||
<p class="text-xs text-gray-600 mt-0.5">Create separate keys for different apps, scripts, or teammates.</p>
|
||||
</div>
|
||||
<button onclick="showCreateKey()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-xs font-medium transition-colors">+ New Key</button>
|
||||
</div>
|
||||
|
||||
<!-- Create key form (hidden by default) -->
|
||||
<div id="create-key-form" class="hidden mb-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<label class="block text-xs text-gray-500 mb-1.5">Label</label>
|
||||
<div class="flex gap-2">
|
||||
<input id="key-label" type="text" placeholder="e.g. ci-pipeline, mobile-app"
|
||||
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 text-sm">
|
||||
<button onclick="createKey()" class="px-4 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors">Create</button>
|
||||
<button onclick="hideCreateKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-500 text-sm transition-colors">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New key reveal (shown after creation) -->
|
||||
<div id="new-key-reveal" class="hidden mb-4 p-4 bg-green-950/30 rounded-lg border border-green-900/50">
|
||||
<p class="text-xs text-green-400 mb-2">Key created — copy it now.</p>
|
||||
<div class="flex gap-2">
|
||||
<code id="new-key-value" class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-blue-400 text-sm tracking-widest"></code>
|
||||
<button onclick="copyNewKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keys list -->
|
||||
<div id="keys-list" class="space-y-2">
|
||||
<p class="text-xs text-gray-600 italic">No API keys yet.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="/dashboard/app.js"></script>
|
||||
<script>
|
||||
if (!requireAuth()) throw 'auth';
|
||||
|
||||
const key = localStorage.getItem('pingql_key');
|
||||
|
||||
async function loadSettings() {
|
||||
const data = await api('/account/settings');
|
||||
|
||||
document.getElementById('primary-key').textContent = key;
|
||||
document.getElementById('created-at').textContent = new Date(data.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
if (data.has_email) {
|
||||
document.getElementById('remove-email-btn').classList.remove('hidden');
|
||||
document.getElementById('email-input').placeholder = '●●●●●●●● (set)';
|
||||
}
|
||||
|
||||
renderKeys(data.api_keys);
|
||||
}
|
||||
|
||||
function renderKeys(keys) {
|
||||
const el = document.getElementById('keys-list');
|
||||
if (!keys.length) {
|
||||
el.innerHTML = '<p class="text-xs text-gray-600 italic">No API keys yet.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = keys.map(k => `
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
|
||||
<div>
|
||||
<p class="text-sm text-gray-200">${escapeHtml(k.label)}</p>
|
||||
<p class="text-xs text-gray-600 mt-0.5 font-mono">${k.id} · created ${new Date(k.created_at).toLocaleDateString()} ${k.last_used_at ? `· last used ${timeAgo(k.last_used_at)}` : '· never used'}</p>
|
||||
</div>
|
||||
<button onclick="deleteKey('${k.id}')" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function copyKey() {
|
||||
navigator.clipboard.writeText(key);
|
||||
const btn = event.target;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => btn.textContent = 'Copy', 1500);
|
||||
}
|
||||
|
||||
async function saveEmail() {
|
||||
const email = document.getElementById('email-input').value.trim();
|
||||
if (!email) return;
|
||||
const btn = document.getElementById('email-btn');
|
||||
btn.disabled = true; btn.textContent = 'Saving...';
|
||||
try {
|
||||
await api('/account/email', { method: 'POST', body: { email } });
|
||||
showStatus('email-status', 'Saved.', 'green');
|
||||
document.getElementById('remove-email-btn').classList.remove('hidden');
|
||||
document.getElementById('email-input').value = '';
|
||||
document.getElementById('email-input').placeholder = '●●●●●●●● (set)';
|
||||
} catch (e) {
|
||||
showStatus('email-status', e.message, 'red');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Save';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEmail() {
|
||||
if (!confirm('Remove recovery email?')) return;
|
||||
await api('/account/email', { method: 'POST', body: { email: null } });
|
||||
document.getElementById('remove-email-btn').classList.add('hidden');
|
||||
document.getElementById('email-input').placeholder = 'you@example.com';
|
||||
showStatus('email-status', 'Email removed.', 'gray');
|
||||
}
|
||||
|
||||
async function confirmReset() {
|
||||
if (!confirm('Rotate your primary key?\n\nYour current key will stop working immediately. Make sure to copy the new one.')) return;
|
||||
const data = await api('/account/reset-key', { method: 'POST', body: {} });
|
||||
localStorage.setItem('pingql_key', data.key);
|
||||
document.getElementById('primary-key').textContent = data.key;
|
||||
alert(`New key: ${data.key}\n\nYour old key is now invalid.`);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function showCreateKey() {
|
||||
document.getElementById('create-key-form').classList.remove('hidden');
|
||||
document.getElementById('new-key-reveal').classList.add('hidden');
|
||||
document.getElementById('key-label').focus();
|
||||
}
|
||||
|
||||
function hideCreateKey() {
|
||||
document.getElementById('create-key-form').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
const label = document.getElementById('key-label').value.trim();
|
||||
if (!label) return;
|
||||
const data = await api('/account/keys', { method: 'POST', body: { label } });
|
||||
document.getElementById('new-key-value').textContent = data.key;
|
||||
document.getElementById('new-key-reveal').classList.remove('hidden');
|
||||
hideCreateKey();
|
||||
document.getElementById('key-label').value = '';
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
function copyNewKey() {
|
||||
const val = document.getElementById('new-key-value').textContent;
|
||||
navigator.clipboard.writeText(val);
|
||||
const btn = event.target;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.classList.add('text-green-400');
|
||||
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('text-green-400'); }, 1500);
|
||||
}
|
||||
|
||||
async function deleteKey(id) {
|
||||
if (!confirm('Revoke this key? Any apps using it will stop working.')) return;
|
||||
await api(`/account/keys/${id}`, { method: 'DELETE' });
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
function showStatus(id, msg, color) {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = msg;
|
||||
el.className = `text-xs mt-2 text-${color}-400`;
|
||||
el.classList.remove('hidden');
|
||||
setTimeout(() => el.classList.add('hidden'), 3000);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user