refactor: convert all static HTML to EJS with cssHash cache-busting, remove stale html files
This commit is contained in:
parent
ac693e55e0
commit
5c91cbc522
|
|
@ -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,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,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,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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ function escapeHtmlSSR(str: string): string {
|
|||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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", {}));
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -367,4 +367,4 @@
|
|||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
@ -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>
|
||||
|
|
@ -595,4 +595,4 @@
|
|||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
@ -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">
|
||||
|
|
@ -152,4 +152,4 @@
|
|||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
@ -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>
|
||||
|
|
@ -152,4 +152,4 @@
|
|||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
Loading…
Reference in New Issue