pingql/apps/web/src/views/detail.ejs

339 lines
18 KiB
Plaintext

<%~ include('./partials/head', { title: 'Monitor', scripts: ['/dashboard/query-builder.js'] }) %>
<%~ include('./partials/nav', { nav: 'monitors' }) %>
<%
const m = it.monitor;
const pings = it.pings || [];
const lastPing = pings[0];
const upPings = pings.filter(p => p.up);
const latencies = pings.filter(p => p.latency_ms != null).map(p => p.latency_ms);
const avgLatency = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null;
const uptime = pings.length ? Math.round((upPings.length / pings.length) * 100) : null;
const barPings = pings.slice(0, 60).reverse();
const chartPings = pings.slice().reverse();
%>
<main class="max-w-7xl mx-auto px-8 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="content">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div>
<div class="flex items-center gap-3">
<span id="status-dot"><%~ lastPing ? (lastPing.up ? '<span class="inline-block w-2.5 h-2.5 rounded-full bg-green-400 mr-2" title="Up"></span>' : '<span class="inline-block w-2.5 h-2.5 rounded-full bg-red-400 mr-2" title="Down"></span>') : '<span class="inline-block w-2.5 h-2.5 rounded-full bg-gray-600 mr-2" title="Unknown"></span>' %></span>
<h2 id="monitor-name" class="text-xl font-semibold text-gray-100"><%= m.name %></h2>
</div>
<p id="monitor-url" class="text-sm text-gray-500 mt-1"><%= m.url %></p>
</div>
<div class="flex items-center gap-3">
<button id="toggle-btn" class="text-sm px-4 py-2 rounded-lg border transition-colors <%= m.enabled ? 'border-gray-700 hover:border-gray-600 text-gray-300' : 'border-green-800 hover:border-green-700 text-green-400' %>"><%= m.enabled ? 'Pause' : 'Resume' %></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"><%~ lastPing ? (lastPing.up ? '<span class="text-green-400">Up</span>' : '<span class="text-red-400">Down</span>') : '<span class="text-gray-500">—</span>' %></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"><%= avgLatency != null ? avgLatency + 'ms' : '—' %></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"><%= uptime != null ? uptime + '%' : '—' %></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"><%~ lastPing ? it.timeAgoSSR(lastPing.checked_at) : '—' %></div>
</div>
</div>
<!-- Latency 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 w-full overflow-hidden"><%~ it.latencyChartSSR(chartPings) %></div>
</div>
<!-- Status bar -->
<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">
<% if (barPings.length > 0) { %>
<% barPings.forEach(function(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>
<% }) %>
<% } else { %>
<div class="flex-1 bg-gray-800 text-center text-xs text-gray-600 leading-8">No data</div>
<% } %>
</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">
<% pings.slice(0, 30).forEach(function(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 != null ? 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"><%~ it.timeAgoSSR(c.checked_at) %></td>
<td class="px-4 py-2 text-red-400/70 text-xs truncate max-w-[200px]"><%= c.error ? c.error : '' %></td>
</tr>
<% }) %>
</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-5">
<div>
<label class="block text-sm text-gray-400 mb-1.5">Name</label>
<input id="edit-name" type="text" value="<%= m.name %>"
class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">URL</label>
<div class="flex gap-2">
<select id="edit-method"
class="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500 font-mono text-sm">
<% ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'].forEach(function(method) { %>
<option <%= (m.method || 'GET') === method ? 'selected' : '' %>><%= method %></option>
<% }) %>
</select>
<input id="edit-url" type="url" value="<%= m.url %>"
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
</div>
</div>
<div>
<div class="flex items-center justify-between mb-1.5">
<label class="text-sm text-gray-400">Headers <span class="text-gray-600">(optional)</span></label>
<button type="button" id="edit-add-header" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">+ Add header</button>
</div>
<div id="edit-headers-list" class="space-y-2">
<% if (m.request_headers && typeof m.request_headers === 'object') {
Object.entries(m.request_headers).forEach(function([k, v]) { %>
<div class="header-row flex gap-2">
<input type="text" value="<%= k %>" placeholder="Header name" class="hk 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">
<input type="text" value="<%= v %>" placeholder="Value" class="hv 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 type="button" onclick="this.parentElement.remove()" class="px-2 text-gray-600 hover:text-red-400 transition-colors text-sm">✕</button>
</div>
<% }) } %>
</div>
</div>
<div id="edit-body-section" class="<%= ['GET','HEAD','OPTIONS'].includes(m.method || 'GET') ? 'hidden' : '' %>">
<label class="block text-sm text-gray-400 mb-1.5">Request Body <span class="text-gray-600">(optional)</span></label>
<textarea id="edit-request-body" rows="4" placeholder='{"key": "value"}'
class="w-full 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 font-mono text-sm resize-y"><%= m.request_body || '' %></textarea>
</div>
<div class="flex gap-4">
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Interval</label>
<select id="edit-interval" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% [['10','10 seconds'],['30','30 seconds'],['60','1 minute'],['300','5 minutes'],['600','10 minutes'],['1800','30 minutes'],['3600','1 hour']].forEach(function([val, label]) { %>
<option value="<%= val %>" <%= String(m.interval_s) === val ? 'selected' : '' %>><%= label %></option>
<% }) %>
</select>
</div>
<div class="flex-1">
<label class="block text-sm text-gray-400 mb-1.5">Timeout</label>
<select id="edit-timeout" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-gray-100 focus:outline-none focus:border-blue-500">
<% [['5000','5 seconds'],['10000','10 seconds'],['30000','30 seconds'],['60000','60 seconds']].forEach(function([val, label]) { %>
<option value="<%= val %>" <%= String(m.timeout_ms || 30000) === val ? 'selected' : '' %>><%= label %></option>
<% }) %>
</select>
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1.5">Conditions <span class="text-gray-600">(optional)</span></label>
<p class="text-xs text-gray-600 mb-3">Define up/down conditions. Defaults to status &lt; 400.</p>
<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>
const monitorId = '<%= m.id %>';
let editQuery = <%~ JSON.stringify(m.query || null) %>;
const editQb = new QueryBuilder(document.getElementById('edit-query-builder'), (q) => {
editQuery = q;
});
editQb.setQuery(editQuery);
// Method/body visibility
const editMethod = document.getElementById('edit-method');
const editBodySection = document.getElementById('edit-body-section');
function updateEditBodyVisibility() {
editBodySection.classList.toggle('hidden', ['GET','HEAD','OPTIONS'].includes(editMethod.value));
}
editMethod.addEventListener('change', updateEditBodyVisibility);
// Dynamic headers
document.getElementById('edit-add-header').addEventListener('click', () => {
addHeaderRow(document.getElementById('edit-headers-list'));
});
function addHeaderRow(container, key='', value='') {
const row = document.createElement('div');
row.className = 'header-row flex gap-2';
row.innerHTML = `
<input type="text" value="${escapeHtml(key)}" placeholder="Header name" class="hk 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">
<input type="text" value="${escapeHtml(value)}" placeholder="Value" class="hv 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 type="button" onclick="this.parentElement.remove()" class="px-2 text-gray-600 hover:text-red-400 transition-colors text-sm">✕</button>
`;
container.appendChild(row);
}
// Toggle button
document.getElementById('toggle-btn').onclick = async () => {
await api(`/monitors/${monitorId}/toggle`, { method: 'POST' });
location.reload();
};
// 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';
};
// 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 headers = {};
document.querySelectorAll('#edit-headers-list .header-row').forEach(row => {
const k = row.querySelector('.hk').value.trim();
const v = row.querySelector('.hv').value.trim();
if (k) headers[k] = v;
});
const body = {
name: document.getElementById('edit-name').value.trim(),
url: document.getElementById('edit-url').value.trim(),
method: document.getElementById('edit-method').value,
interval_s: Number(document.getElementById('edit-interval').value),
timeout_ms: Number(document.getElementById('edit-timeout').value),
};
if (Object.keys(headers).length) body.request_headers = headers;
else body.request_headers = null;
const rb = document.getElementById('edit-request-body').value.trim();
body.request_body = rb || null;
if (editQuery) body.query = editQuery;
await api(`/monitors/${monitorId}`, { method: 'PATCH', body });
location.reload();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
// Running totals for incremental stat updates
let _total = <%= pings.length %>, _up = <%= upPings.length %>;
let _latSum = <%= latencies.reduce((a,b)=>a+b,0) %>, _latCount = <%= latencies.length %>;
// SSE: update everything on ping
let _fetchingChart = false;
watchAccount(async (ping) => {
if (ping.monitor_id !== monitorId) return;
// Accumulate
_total++;
if (ping.up) _up++;
if (ping.latency_ms != null) { _latSum += ping.latency_ms; _latCount++; }
// Status
document.getElementById('stat-status').innerHTML = ping.up
? '<span class="text-green-400">Up</span>'
: '<span class="text-red-400">Down</span>';
document.getElementById('status-dot').innerHTML = ping.up
? '<span class="inline-block w-2.5 h-2.5 rounded-full bg-green-400 mr-2"></span>'
: '<span class="inline-block w-2.5 h-2.5 rounded-full bg-red-400 mr-2"></span>';
// Avg latency
if (_latCount > 0)
document.getElementById('stat-latency').textContent = Math.round(_latSum / _latCount) + 'ms';
// Uptime
if (_total > 0)
document.getElementById('stat-uptime').textContent = Math.round((_up / _total) * 100) + '%';
// Last ping
document.getElementById('stat-last').innerHTML = timeAgo(ping.checked_at);
// Status bar — prepend segment, cap at 60
const bar = document.getElementById('status-bar');
if (bar) {
const seg = document.createElement('div');
seg.className = `flex-1 ${ping.up ? 'bg-green-500/70' : 'bg-red-500/70'}`;
seg.title = `${new Date(ping.checked_at).toLocaleString()} — ${ping.up ? 'Up' : 'Down'}${ping.latency_ms ? ' ' + ping.latency_ms + 'ms' : ''}`;
bar.prepend(seg);
while (bar.children.length > 60) bar.removeChild(bar.lastChild);
}
// Pings table — prepend row, cap at 100
const tbody = document.getElementById('pings-table');
if (tbody) {
const tr = document.createElement('tr');
tr.className = 'hover:bg-gray-800/50';
tr.innerHTML = `
<td class="px-4 py-2">${ping.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">${ping.status_code ?? '—'}</td>
<td class="px-4 py-2 text-gray-300">${ping.latency_ms != null ? ping.latency_ms + 'ms' : '—'}</td>
<td class="px-4 py-2 text-gray-500">${timeAgo(ping.checked_at)}</td>
<td class="px-4 py-2 text-red-400/70 text-xs truncate max-w-[200px]">${ping.error ? escapeHtml(ping.error) : ''}</td>
`;
tbody.prepend(tr);
while (tbody.children.length > 100) tbody.removeChild(tbody.lastChild);
}
// Chart
if (_fetchingChart) return;
_fetchingChart = true;
try {
const res = await fetch(`/dashboard/monitors/${monitorId}/chart`, { credentials: 'same-origin' });
if (res.ok) document.getElementById('latency-chart').innerHTML = await res.text();
} catch {}
_fetchingChart = false;
});
</script>
<%~ include('./partials/foot') %>