120 lines
3.8 KiB
JavaScript
120 lines
3.8 KiB
JavaScript
// PingQL Dashboard — shared utilities
|
|
// Auth is now cookie-based. No localStorage needed.
|
|
|
|
const API_BASE = window.location.origin;
|
|
|
|
function logout() {
|
|
window.location.href = '/dashboard/logout';
|
|
}
|
|
|
|
// requireAuth is a no-op now — server redirects to /dashboard if not authed
|
|
function requireAuth() { return true; }
|
|
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
...opts,
|
|
credentials: 'same-origin', // send cookie automatically
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...opts.headers,
|
|
},
|
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
});
|
|
if (res.status === 401) {
|
|
window.location.href = '/dashboard';
|
|
throw new Error('Unauthorized');
|
|
}
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'API error');
|
|
return data;
|
|
}
|
|
|
|
// Format relative time
|
|
function formatAgo(ms) {
|
|
const s = Math.floor(ms / 1000);
|
|
if (s < 60) return `${s}s ago`;
|
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
if (s < 86400)return `${Math.floor(s / 3600)}h ago`;
|
|
return `${Math.floor(s / 86400)}d ago`;
|
|
}
|
|
|
|
function timeAgo(date) {
|
|
const ts = new Date(date).getTime();
|
|
const elapsed = Date.now() - ts;
|
|
return `<span class="timestamp" data-ts="${ts}">${formatAgo(elapsed)}</span>`;
|
|
}
|
|
|
|
// Tick all live timestamps every second
|
|
setInterval(() => {
|
|
document.querySelectorAll('.timestamp[data-ts]').forEach(el => {
|
|
const elapsed = Date.now() - Number(el.dataset.ts);
|
|
el.textContent = formatAgo(elapsed);
|
|
});
|
|
}, 1000);
|
|
|
|
// Render a tiny sparkline SVG from latency values
|
|
function sparkline(values, width = 120, height = 32) {
|
|
if (!values.length) return '';
|
|
const max = Math.max(...values, 1);
|
|
const min = Math.min(...values, 0);
|
|
const range = max - min || 1;
|
|
const step = width / Math.max(values.length - 1, 1);
|
|
const points = values.map((v, i) => {
|
|
const x = i * step;
|
|
const y = height - ((v - min) / range) * (height - 4) - 2;
|
|
return `${x},${y}`;
|
|
}).join(' ');
|
|
return `<svg width="${width}" height="${height}" class="inline-block"><polyline points="${points}" fill="none" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
}
|
|
|
|
// Status badge
|
|
function statusBadge(up) {
|
|
if (up === true) return '<span class="inline-block w-2.5 h-2.5 rounded-full bg-green-400 mr-2" title="Up"></span>';
|
|
if (up === false) return '<span class="inline-block w-2.5 h-2.5 rounded-full bg-red-400 mr-2" title="Down"></span>';
|
|
return '<span class="inline-block w-2.5 h-2.5 rounded-full bg-gray-600 mr-2" title="Unknown"></span>';
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Subscribe to live ping updates for a monitor via SSE (fetch-based for auth header support)
|
|
// Returns an AbortController — call .abort() to close
|
|
function watchMonitor(monitorId, onPing) {
|
|
const ac = new AbortController();
|
|
|
|
async function connect() {
|
|
try {
|
|
const res = await fetch(`/monitors/${monitorId}/stream`, {
|
|
credentials: 'same-origin',
|
|
signal: ac.signal,
|
|
});
|
|
if (!res.ok || !res.body) return;
|
|
const reader = res.body.getReader();
|
|
const dec = new TextDecoder();
|
|
let buf = '';
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += dec.decode(value, { stream: true });
|
|
const lines = buf.split('\n');
|
|
buf = lines.pop() ?? '';
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
try { onPing(JSON.parse(line.slice(6))); } catch {}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (e.name === 'AbortError') return;
|
|
// Reconnect after a short delay on unexpected disconnect
|
|
setTimeout(connect, 3000);
|
|
}
|
|
}
|
|
|
|
connect();
|
|
return ac;
|
|
}
|