292 lines
13 KiB
HTML
292 lines
13 KiB
HTML
<!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>
|