pingql/apps/web/src/dashboard/app.js

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;
}