refactor: full SSR dashboard, minimal SSE DOM patches, poll-based refresh
This commit is contained in:
parent
878829111f
commit
2f7273604b
|
|
@ -52,28 +52,6 @@ setInterval(() => {
|
|||
});
|
||||
}, 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;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,58 @@ import { Eta } from "eta";
|
|||
import { resolve } from "path";
|
||||
import { resolveKey } from "./auth";
|
||||
import sql from "../db";
|
||||
import { sparkline } from "../utils/sparkline";
|
||||
|
||||
const eta = new Eta({ views: resolve(import.meta.dir, "../views"), cache: true, defaultExtension: ".ejs" });
|
||||
|
||||
function timeAgoSSR(date: string | Date): string {
|
||||
const ts = new Date(date).getTime();
|
||||
const s = Math.floor((Date.now() - ts) / 1000);
|
||||
const text = s < 60 ? `${s}s ago` : s < 3600 ? `${Math.floor(s/60)}m ago` : s < 86400 ? `${Math.floor(s/3600)}h ago` : `${Math.floor(s/86400)}d ago`;
|
||||
return `<span class="timestamp" data-ts="${ts}">${text}</span>`;
|
||||
}
|
||||
|
||||
const sparklineSSR = sparkline;
|
||||
|
||||
function latencyChartSSR(pings: any[]): string {
|
||||
const data = pings.filter((c: any) => c.latency_ms != null);
|
||||
if (data.length < 2) {
|
||||
return '<div class="h-full flex items-center justify-center text-gray-600 text-sm">Not enough data</div>';
|
||||
}
|
||||
const values = data.map((c: any) => c.latency_ms);
|
||||
const ups = data.map((c: any) => c.up);
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const w = 800;
|
||||
const h = 128;
|
||||
const step = w / Math.max(values.length - 1, 1);
|
||||
const points = values.map((v: number, i: number) => {
|
||||
const x = i * step;
|
||||
const y = h - ((v - min) / range) * (h - 16) - 8;
|
||||
return [x, y];
|
||||
});
|
||||
const pathD = points.map((p: number[], i: number) => `${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`;
|
||||
const dots = points.map((p: number[], i: number) =>
|
||||
!ups[i] ? `<circle cx="${p[0]}" cy="${p[1]}" r="3" fill="#f87171"/>` : ''
|
||||
).join('');
|
||||
return `<svg viewBox="0 0 ${w} ${h}" class="w-full" preserveAspectRatio="none">
|
||||
<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>`;
|
||||
}
|
||||
|
||||
function escapeHtmlSSR(str: string): string {
|
||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function html(template: string, data: Record<string, unknown> = {}) {
|
||||
return new Response(eta.render(template, data), {
|
||||
return new Response(eta.render(template, { ...data, timeAgoSSR, sparklineSSR, latencyChartSSR, escapeHtmlSSR }), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
|
@ -56,7 +103,28 @@ export const dashboard = new Elysia()
|
|||
ORDER BY m.created_at DESC
|
||||
`;
|
||||
|
||||
return html("home", { nav: "monitors", monitors, accountId });
|
||||
// Fetch last 20 pings per monitor for sparklines
|
||||
const monitorIds = monitors.map((m: any) => m.id);
|
||||
let pingsMap: Record<string, any[]> = {};
|
||||
if (monitorIds.length > 0) {
|
||||
const allPings = await sql`
|
||||
SELECT * FROM (
|
||||
SELECT p.*, ROW_NUMBER() OVER (PARTITION BY p.monitor_id ORDER BY p.checked_at DESC) as rn
|
||||
FROM pings p WHERE p.monitor_id = ANY(${monitorIds})
|
||||
) sub WHERE rn <= 20 ORDER BY monitor_id, checked_at ASC
|
||||
`;
|
||||
for (const p of allPings) {
|
||||
if (!pingsMap[p.monitor_id]) pingsMap[p.monitor_id] = [];
|
||||
pingsMap[p.monitor_id].push(p);
|
||||
}
|
||||
}
|
||||
|
||||
const monitorsWithPings = monitors.map((m: any) => ({
|
||||
...m,
|
||||
pings: pingsMap[m.id] || [],
|
||||
}));
|
||||
|
||||
return html("home", { nav: "monitors", monitors: monitorsWithPings, accountId });
|
||||
})
|
||||
|
||||
// Settings — SSR account info
|
||||
|
|
@ -77,6 +145,19 @@ export const dashboard = new Elysia()
|
|||
return html("new", { nav: "monitors", scripts: ["/dashboard/query-builder.js"] });
|
||||
})
|
||||
|
||||
// Home data endpoint for polling (monitor list change detection)
|
||||
.get("/dashboard/home/data", async ({ cookie, headers }) => {
|
||||
const accountId = await getAccountId(cookie, headers);
|
||||
if (!accountId) return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
|
||||
const monitors = await sql`
|
||||
SELECT id FROM monitors WHERE account_id = ${accountId} ORDER BY created_at DESC
|
||||
`;
|
||||
return new Response(JSON.stringify({ monitorIds: monitors.map((m: any) => m.id) }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
})
|
||||
|
||||
// Monitor detail — SSR with initial data
|
||||
.get("/dashboard/monitors/:id", async ({ cookie, headers, params }) => {
|
||||
const accountId = await getAccountId(cookie, headers);
|
||||
|
|
@ -95,5 +176,25 @@ export const dashboard = new Elysia()
|
|||
return html("detail", { nav: "monitors", monitor, pings, scripts: ["/dashboard/query-builder.js"] });
|
||||
})
|
||||
|
||||
// Chart partial endpoint — returns just the latency chart SVG
|
||||
.get("/dashboard/monitors/:id/chart", async ({ cookie, headers, params }) => {
|
||||
const accountId = await getAccountId(cookie, headers);
|
||||
if (!accountId) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const [monitor] = await sql`
|
||||
SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
`;
|
||||
if (!monitor) return new Response("Not found", { status: 404 });
|
||||
|
||||
const pings = await sql`
|
||||
SELECT * FROM pings WHERE monitor_id = ${params.id}
|
||||
ORDER BY checked_at DESC LIMIT 100
|
||||
`;
|
||||
const chartPings = pings.slice().reverse();
|
||||
return new Response(latencyChartSSR(chartPings), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
})
|
||||
|
||||
// Docs
|
||||
.get("/docs", () => Bun.file(`${dashDir}/docs.html`));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
export function sparkline(values: number[], width = 120, height = 32): string {
|
||||
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>`;
|
||||
}
|
||||
|
|
@ -1,24 +1,35 @@
|
|||
<%~ 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">← Back</a>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="text-center py-16 text-gray-600">Loading...</div>
|
||||
<div id="content" class="hidden">
|
||||
<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"></span>
|
||||
<h2 id="monitor-name" class="text-xl font-semibold text-gray-100"></h2>
|
||||
<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"></p>
|
||||
<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 border-gray-700 hover:border-gray-600 transition-colors"></button>
|
||||
<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>
|
||||
|
|
@ -27,32 +38,40 @@
|
|||
<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 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"></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"></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"></div>
|
||||
<div id="stat-last" class="text-lg font-semibold text-gray-200"><%~ lastPing ? it.timeAgoSSR(lastPing.checked_at) : '—' %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status history chart -->
|
||||
<!-- 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"></div>
|
||||
<div id="latency-chart" class="h-32"><%~ it.latencyChartSSR(chartPings) %></div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar (up/down timeline) -->
|
||||
<!-- 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"></div>
|
||||
<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 -->
|
||||
|
|
@ -71,7 +90,17 @@
|
|||
<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>
|
||||
<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>
|
||||
|
|
@ -83,7 +112,7 @@
|
|||
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1.5">Name</label>
|
||||
<input id="edit-name" type="text"
|
||||
<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>
|
||||
|
||||
|
|
@ -92,10 +121,11 @@
|
|||
<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">
|
||||
<option>GET</option><option>POST</option><option>PUT</option>
|
||||
<option>PATCH</option><option>DELETE</option><option>HEAD</option><option>OPTIONS</option>
|
||||
<% ['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"
|
||||
<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>
|
||||
|
|
@ -105,35 +135,39 @@
|
|||
<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"></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="hidden">
|
||||
<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"></textarea>
|
||||
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">
|
||||
<option value="10">10 seconds</option>
|
||||
<option value="30">30 seconds</option>
|
||||
<option value="60">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>
|
||||
<% [['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">
|
||||
<option value="5000">5 seconds</option>
|
||||
<option value="10000">10 seconds</option>
|
||||
<option value="30000">30 seconds</option>
|
||||
<option value="60000">60 seconds</option>
|
||||
<% [['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>
|
||||
|
|
@ -153,12 +187,13 @@
|
|||
|
||||
<script>
|
||||
|
||||
const monitorId = window.location.pathname.split('/').pop();
|
||||
let editQuery = null;
|
||||
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');
|
||||
|
|
@ -184,27 +219,10 @@
|
|||
container.appendChild(row);
|
||||
}
|
||||
|
||||
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 () => {
|
||||
document.getElementById('toggle-btn').onclick = async () => {
|
||||
await api(`/monitors/${monitorId}/toggle`, { method: 'POST' });
|
||||
load();
|
||||
location.reload();
|
||||
};
|
||||
|
||||
// Delete button
|
||||
|
|
@ -214,115 +232,6 @@
|
|||
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;
|
||||
|
||||
// Seed running stats for SSE incremental updates
|
||||
_totalPings = results.length;
|
||||
_upPings = upPings.length;
|
||||
_latencies = results.filter(r => r.latency_ms != null).slice(-100).map(r => ({ ms: r.latency_ms, up: r.up }));
|
||||
|
||||
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').innerHTML = 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-method').value = data.method || 'GET';
|
||||
document.getElementById('edit-interval').value = String(data.interval_s);
|
||||
document.getElementById('edit-timeout').value = String(data.timeout_ms || 30000);
|
||||
document.getElementById('edit-request-body').value = data.request_body || '';
|
||||
updateEditBodyVisibility();
|
||||
|
||||
const headersList = document.getElementById('edit-headers-list');
|
||||
headersList.innerHTML = '';
|
||||
if (data.request_headers && typeof data.request_headers === 'object') {
|
||||
Object.entries(data.request_headers).forEach(([k, v]) => addHeaderRow(headersList, k, v));
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
@ -349,29 +258,26 @@
|
|||
body.request_body = rb || null;
|
||||
if (editQuery) body.query = editQuery;
|
||||
await api(`/monitors/${monitorId}`, { method: 'PATCH', body });
|
||||
load();
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
// No interval poll — SSE handles all live updates
|
||||
// Track running stats for SSE incremental updates
|
||||
let _totalPings = <%= pings.length %>, _upPings = <%= upPings.length %>;
|
||||
let _latencySum = <%= latencies.reduce((a, b) => a + b, 0) %>, _latencyCount = <%= latencies.length %>;
|
||||
|
||||
// Track running stats in memory for incremental updates
|
||||
let _totalPings = 0, _upPings = 0, _latencies = []; // _latencies: [{ms, up}]
|
||||
|
||||
// SSE: live ping updates
|
||||
const id = window.location.pathname.split('/').pop();
|
||||
watchMonitor(id, (ping) => {
|
||||
// ── Stats ───────────────────────────────────────────────────
|
||||
// SSE: live ping updates (minimal DOM patches only — no chart re-render)
|
||||
watchMonitor(monitorId, (ping) => {
|
||||
// Stats
|
||||
_totalPings++;
|
||||
if (ping.up) _upPings++;
|
||||
if (ping.latency_ms != null) {
|
||||
_latencies.push({ ms: ping.latency_ms, up: ping.up });
|
||||
if (_latencies.length > 100) _latencies.shift();
|
||||
const avg = Math.round(_latencies.reduce((a,b)=>a+b.ms,0) / _latencies.length);
|
||||
_latencySum += ping.latency_ms;
|
||||
_latencyCount++;
|
||||
const avg = Math.round(_latencySum / _latencyCount);
|
||||
document.getElementById('stat-latency').textContent = avg + 'ms';
|
||||
}
|
||||
|
||||
|
|
@ -379,13 +285,16 @@
|
|||
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" title="Up"></span>'
|
||||
: '<span class="inline-block w-2.5 h-2.5 rounded-full bg-red-400 mr-2" title="Down"></span>';
|
||||
|
||||
if (_totalPings > 0) {
|
||||
const pct = Math.round((_upPings / _totalPings) * 100);
|
||||
document.getElementById('stat-uptime').textContent = pct + '%';
|
||||
}
|
||||
|
||||
// ── Status history bar — prepend a segment ───────────────────
|
||||
// Status history bar — append new segment
|
||||
const bar = document.getElementById('status-bar');
|
||||
if (bar) {
|
||||
const seg = document.createElement('div');
|
||||
|
|
@ -395,12 +304,7 @@
|
|||
while (bar.children.length > 60) bar.removeChild(bar.lastChild);
|
||||
}
|
||||
|
||||
// ── Latency chart ─────────────────────────────────────────────
|
||||
if (ping.latency_ms != null) {
|
||||
renderLatencyChart(_latencies.map(e => ({ latency_ms: e.ms, up: e.up, checked_at: '' })));
|
||||
}
|
||||
|
||||
// ── Ping table ───────────────────────────────────────────────
|
||||
// Ping table — prepend plain HTML row
|
||||
const tbody = document.getElementById('pings-table');
|
||||
if (tbody) {
|
||||
const tr = document.createElement('tr');
|
||||
|
|
@ -416,6 +320,17 @@
|
|||
while (tbody.children.length > 100) tbody.removeChild(tbody.lastChild);
|
||||
}
|
||||
});
|
||||
|
||||
// Poll every 30s to refresh the latency chart from server
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/dashboard/monitors/${monitorId}/chart`, { credentials: 'same-origin' });
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
document.getElementById('latency-chart').innerHTML = html;
|
||||
}
|
||||
} catch {}
|
||||
}, 30000);
|
||||
</script>
|
||||
|
||||
<%~ include('./partials/foot') %>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,32 @@
|
|||
<%~ include('./partials/head', { title: 'Monitors' }) %>
|
||||
<%~ include('./partials/nav', { nav: 'monitors' }) %>
|
||||
|
||||
<%
|
||||
const upCount = it.monitors.filter(m => m.last_ping && m.last_ping.up === true).length;
|
||||
const downCount = it.monitors.filter(m => m.last_ping && m.last_ping.up === false).length;
|
||||
%>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="max-w-7xl mx-auto px-8 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>
|
||||
<div id="summary" class="text-sm text-gray-500"><% if (it.monitors.length > 0) { %><span class="text-green-400"><%= upCount %> up</span> · <span class="<%= downCount > 0 ? 'text-red-400' : 'text-gray-500' %>"><%= downCount %> down</span> · <%= it.monitors.length %> total<% } %></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">
|
||||
<% if (it.monitors && it.monitors.length === 0) { %>
|
||||
<% if (it.monitors.length === 0) { %>
|
||||
<div id="empty-state" class="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>
|
||||
<% } else if (it.monitors) { %>
|
||||
<% it.monitors.forEach(function(m) { %>
|
||||
<% } else { %>
|
||||
<% it.monitors.forEach(function(m) {
|
||||
const latencies = (m.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;
|
||||
%>
|
||||
<a href="/dashboard/monitors/<%= m.id %>" data-monitor-id="<%= 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">
|
||||
|
|
@ -29,115 +37,56 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-6 shrink-0 ml-4">
|
||||
<div class="hidden sm:block stat-sparkline"><%~ it.sparklineSSR(latencies) %></div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-300 stat-latency"><%= m.last_ping ? m.last_ping.latency_ms + 'ms' : '—' %></div>
|
||||
<div class="text-xs text-gray-500 stat-last"><%~ m.last_ping ? '<span class="timestamp" data-ts="' + new Date(m.last_ping.checked_at).getTime() + '">just now</span>' : 'no pings' %></div>
|
||||
<div class="text-sm text-gray-300 stat-latency"><%= avgLatency != null ? avgLatency + 'ms' : '—' %></div>
|
||||
<div class="text-xs text-gray-500 stat-last"><%~ m.last_ping ? it.timeAgoSSR(m.last_ping.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>
|
||||
<% }) %>
|
||||
<% } else { %>
|
||||
<div class="text-center py-16 text-gray-600">Loading...</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
|
||||
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`;
|
||||
|
||||
// Store latencies per monitor for live sparkline updates
|
||||
window._monitorLatencies = window._monitorLatencies || {};
|
||||
monitorsWithPings.forEach(m => {
|
||||
window._monitorLatencies[m.id] = m.pings.filter(c => c.latency_ms != null).map(c => c.latency_ms).reverse();
|
||||
});
|
||||
|
||||
list.innerHTML = monitorsWithPings.map(m => {
|
||||
const lastPing = m.pings[0];
|
||||
const latencies = window._monitorLatencies[m.id];
|
||||
const avgLatency = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : null;
|
||||
|
||||
return `
|
||||
<a href="/dashboard/monitors/${m.id}" data-monitor-id="${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">
|
||||
<span class="status-dot w-2.5 h-2.5 rounded-full ${lastPing == null ? 'bg-gray-600' : lastPing.up ? 'bg-green-500' : 'bg-red-500'}"></span>
|
||||
<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 stat-sparkline">${sparkline(latencies)}</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-300 stat-latency">${avgLatency != null ? avgLatency + 'ms' : '—'}</div>
|
||||
<div class="text-xs text-gray-500 stat-last">${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();
|
||||
// No interval poll — SSE handles live updates. Only reload on tab focus if stale.
|
||||
// Reload on tab focus if stale
|
||||
let lastLoad = Date.now();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && Date.now() - lastLoad > 60000) { load(); lastLoad = Date.now(); }
|
||||
if (!document.hidden && Date.now() - lastLoad > 60000) { location.reload(); }
|
||||
});
|
||||
|
||||
// SSE: subscribe to all monitors after load so cards update in real time
|
||||
const sseConnections = [];
|
||||
async function subscribeAll() {
|
||||
sseConnections.forEach(es => es.abort());
|
||||
sseConnections.length = 0;
|
||||
const monitors = await api('/monitors/');
|
||||
monitors.forEach(m => {
|
||||
const es = watchMonitor(m.id, (ping) => {
|
||||
const card = document.querySelector(`[data-monitor-id="${m.id}"]`);
|
||||
if (!card) return;
|
||||
// Known monitor IDs for change detection
|
||||
const _knownIds = [<% it.monitors.forEach(function(m, i) { %>'<%= m.id %>'<% if (i < it.monitors.length - 1) { %>,<% } %><% }) %>];
|
||||
|
||||
// Poll every 30s to detect monitor list changes
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch('/dashboard/home/data', { credentials: 'same-origin' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const ids = data.monitorIds || [];
|
||||
if (ids.length !== _knownIds.length || ids.some(id => !_knownIds.includes(id))) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {}
|
||||
}, 30000);
|
||||
|
||||
// SSE: subscribe to all monitors for live updates (minimal DOM patches only)
|
||||
const sseConnections = [];
|
||||
function subscribeAll() {
|
||||
const monitorCards = document.querySelectorAll('[data-monitor-id]');
|
||||
monitorCards.forEach(card => {
|
||||
const mid = card.dataset.monitorId;
|
||||
const es = watchMonitor(mid, (ping) => {
|
||||
// Status dot
|
||||
const statusDot = card.querySelector('.status-dot');
|
||||
if (statusDot) {
|
||||
const wasUp = statusDot.classList.contains('bg-green-500');
|
||||
statusDot.className = `status-dot w-2.5 h-2.5 rounded-full ${ping.up ? 'bg-green-500' : 'bg-red-500'}`;
|
||||
// If status changed, recount and update summary
|
||||
if (wasUp !== ping.up) {
|
||||
const dots = document.querySelectorAll('.status-dot');
|
||||
const upNow = [...dots].filter(d => d.classList.contains('bg-green-500')).length;
|
||||
|
|
@ -152,16 +101,6 @@
|
|||
|
||||
// Timestamp
|
||||
card.querySelector('.stat-last').innerHTML = timeAgo(ping.checked_at);
|
||||
|
||||
// Sparkline — push new value, keep last 20, redraw
|
||||
if (ping.latency_ms != null) {
|
||||
const lats = window._monitorLatencies[m.id] || [];
|
||||
lats.push(ping.latency_ms);
|
||||
if (lats.length > 20) lats.shift();
|
||||
window._monitorLatencies[m.id] = lats;
|
||||
const sparkEl = card.querySelector('.stat-sparkline');
|
||||
if (sparkEl) sparkEl.innerHTML = sparkline(lats);
|
||||
}
|
||||
});
|
||||
if (es) sseConnections.push(es);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
<%~ include('./partials/head', { title: 'Settings' }) %>
|
||||
<%~ include('./partials/nav', { nav: 'settings' }) %>
|
||||
|
||||
<%
|
||||
const hasEmail = !!it.account.email_hash;
|
||||
const createdDate = new Date(it.account.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
%>
|
||||
|
||||
<main class="max-w-3xl mx-auto px-8 py-10 space-y-8">
|
||||
|
||||
<h1 class="text-xl font-semibold text-white">Settings</h1>
|
||||
|
|
@ -12,13 +17,13 @@
|
|||
<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>
|
||||
<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"><%= it.account.id %></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>
|
||||
<p id="created-at" class="text-sm text-gray-400"><%= createdDate %></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -28,11 +33,11 @@
|
|||
<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"
|
||||
<input id="email-input" type="email" placeholder="<%= hasEmail ? '●●●●●●●● (set)' : '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>
|
||||
<button onclick="removeEmail()" id="remove-email-btn" class="<%= hasEmail ? '' : '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>
|
||||
|
|
@ -76,7 +81,19 @@
|
|||
|
||||
<!-- Keys list -->
|
||||
<div id="keys-list" class="space-y-2">
|
||||
<% if (it.apiKeys.length === 0) { %>
|
||||
<p class="text-xs text-gray-600 italic">No API keys yet.</p>
|
||||
<% } else { %>
|
||||
<% it.apiKeys.forEach(function(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"><%= 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 ' + it.timeAgoSSR(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>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -84,38 +101,18 @@
|
|||
|
||||
|
||||
<script>
|
||||
async function loadSettings() {
|
||||
const data = await api('/account/settings');
|
||||
|
||||
document.getElementById('primary-key').textContent = data.account_id;
|
||||
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('');
|
||||
// On load: check sessionStorage for a newly created key to reveal
|
||||
(function() {
|
||||
const newKey = sessionStorage.getItem('_newApiKey');
|
||||
if (newKey) {
|
||||
sessionStorage.removeItem('_newApiKey');
|
||||
document.getElementById('new-key-value').textContent = newKey;
|
||||
document.getElementById('new-key-reveal').classList.remove('hidden');
|
||||
}
|
||||
})();
|
||||
|
||||
function copyKey() {
|
||||
const key = document.getElementById('primary-key').textContent;
|
||||
navigator.clipboard.writeText(key);
|
||||
const btn = event.target;
|
||||
btn.textContent = 'Copied!';
|
||||
|
|
@ -125,35 +122,24 @@
|
|||
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)';
|
||||
location.reload();
|
||||
} 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');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
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: {} });
|
||||
// Re-auth with new key (updates cookie)
|
||||
await fetch('/account/login', { method: 'POST', credentials: 'same-origin', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key: data.key }) });
|
||||
document.getElementById('primary-key').textContent = data.key;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
|
|
@ -171,11 +157,8 @@
|
|||
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();
|
||||
sessionStorage.setItem('_newApiKey', data.key);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function copyNewKey() {
|
||||
|
|
@ -190,7 +173,7 @@
|
|||
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();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function showStatus(id, msg, color) {
|
||||
|
|
@ -200,12 +183,6 @@
|
|||
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>
|
||||
|
||||
<%~ include('./partials/foot') %>
|
||||
|
|
|
|||
Loading…
Reference in New Issue