refactor: convert all static HTML to EJS with cssHash cache-busting, remove stale html files

This commit is contained in:
M1 2026-03-17 09:54:44 +04:00
parent ac693e55e0
commit 5c91cbc522
10 changed files with 15 additions and 757 deletions

View File

@ -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">&larr; Back</a>
</div>
<div id="loading" class="text-center py-16 text-gray-600">Loading...</div>
<div id="content" class="hidden">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div>
<div class="flex items-center gap-3">
<span id="status-dot"></span>
<h2 id="monitor-name" class="text-xl font-semibold text-gray-100"></h2>
</div>
<p id="monitor-url" class="text-sm text-gray-500 mt-1"></p>
</div>
<div class="flex items-center gap-3">
<button id="toggle-btn" class="text-sm px-4 py-2 rounded-lg border border-gray-700 hover:border-gray-600 transition-colors"></button>
<button id="delete-btn" class="text-sm px-4 py-2 rounded-lg border border-red-900/50 text-red-400 hover:bg-red-900/20 transition-colors">Delete</button>
</div>
</div>
<!-- Stats row -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
<div class="text-xs text-gray-500 mb-1">Status</div>
<div id="stat-status" class="text-lg font-semibold"></div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
<div class="text-xs text-gray-500 mb-1">Avg Latency</div>
<div id="stat-latency" class="text-lg font-semibold text-gray-200"></div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
<div class="text-xs text-gray-500 mb-1">Uptime</div>
<div id="stat-uptime" class="text-lg font-semibold text-gray-200"></div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
<div class="text-xs text-gray-500 mb-1">Last 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>

View File

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

View File

@ -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">&larr; Back to monitors</a>
<h2 class="text-lg font-semibold text-gray-200 mt-2">Create Monitor</h2>
</div>
<form id="create-form" class="space-y-6">
<div>
<label class="block text-sm text-gray-400 mb-1.5">Name</label>
<input id="name" type="text" required placeholder="Production API"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">URL</label>
<input id="url" type="url" required placeholder="https://api.example.com/health"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">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 &lt; 400.</p>
<div id="query-builder"></div>
</div>
<div id="form-error" class="text-red-400 text-sm hidden"></div>
<button type="submit" id="submit-btn"
class="w-full bg-blue-600 hover:bg-blue-500 text-white font-medium py-3 rounded-lg transition-colors">
Create Monitor
</button>
</form>
</main>
<script>
if (!requireAuth()) throw 'auth';
let currentQuery = null;
const qb = new QueryBuilder(document.getElementById('query-builder'), (q) => {
currentQuery = q;
});
document.getElementById('create-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const errEl = document.getElementById('form-error');
errEl.classList.add('hidden');
btn.disabled = true;
btn.textContent = 'Creating...';
try {
const body = {
name: document.getElementById('name').value.trim(),
url: document.getElementById('url').value.trim(),
interval_s: Number(document.getElementById('interval').value),
};
if (currentQuery) body.query = currentQuery;
await api('/monitors/', { method: 'POST', body });
window.location.href = '/dashboard/home';
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'Create Monitor';
}
});
</script>
</body>
</html>

View File

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
loadSettings();
</script>
</body>
</html>

View File

@ -14,7 +14,6 @@ const app = new Elysia()
origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com", "https://api.pingql.com"],
credentials: true,
}))
.get("/", ({ set }) => { set.headers["content-type"] = "text/html"; return Bun.file(`${import.meta.dir}/dashboard/landing.html`); })
.use(dashboard)
.use(account)
.use(monitors)

View File

@ -60,7 +60,7 @@ function escapeHtmlSSR(str: string): string {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function html(template: string, data: Record<string, unknown> = {}) {
export function html(template: string, data: Record<string, unknown> = {}) {
return new Response(eta.render(template, { ...data, timeAgoSSR, sparklineSSR, latencyChartSSR, escapeHtmlSSR, cssHash }), {
headers: { "content-type": "text/html; charset=utf-8" },
});
@ -82,6 +82,8 @@ async function getAccountId(cookie: any, headers: any): Promise<string | null> {
const dashDir = resolve(import.meta.dir, "../dashboard");
export const dashboard = new Elysia()
.get("/", () => html("landing", {}))
.get("/dashboard/app.js", () => new Response(Bun.file(`${dashDir}/app.js`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
.get("/dashboard/app.css", () => new Response(Bun.file(`${dashDir}/app.css`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
.get("/dashboard/tailwind.css", () => new Response(Bun.file(`${dashDir}/tailwind.css`), { headers: { "cache-control": "public, max-age=31536000, immutable" } }))
@ -96,7 +98,7 @@ export const dashboard = new Elysia()
// Invalid/stale key — clear it and show login
cookie.pingql_key?.remove();
}
return Bun.file(`${dashDir}/index.html`);
return html("login", {});
})
// Logout
@ -235,5 +237,5 @@ export const dashboard = new Elysia()
})
// Docs
.get("/docs", () => Bun.file(`${dashDir}/docs.html`))
.get("/privacy", () => Bun.file(`${dashDir}/privacy.html`));
.get("/docs", () => html("docs", {}))
.get("/privacy", () => html("privacy", {}));

View File

@ -4,7 +4,7 @@
<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">
<link rel="stylesheet" href="/dashboard/tailwind.css?v=<%= it.cssHash %>">
<style>
body { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; background: #0a0a0a; }
pre, code { font-family: inherit; }

View File

@ -5,7 +5,7 @@
<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="stylesheet" href="/dashboard/tailwind.css?v=<%= it.cssHash %>">
<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>

View File

@ -4,8 +4,8 @@
<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">
<link rel="stylesheet" href="/dashboard/tailwind.css?v=<%= it.cssHash %>">
<link rel="stylesheet" href="/dashboard/app.css?v=<%= it.cssHash %>">
</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">

View File

@ -5,7 +5,7 @@
<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="stylesheet" href="/dashboard/tailwind.css?v=<%= it.cssHash %>">
<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>