312 lines
13 KiB
Text
312 lines
13 KiB
Text
<cfsetting showdebugoutput="false">
|
|
<cfsetting enablecfoutputonly="false">
|
|
<cfscript>
|
|
// Localhost-only protection
|
|
remoteAddr = cgi.REMOTE_ADDR;
|
|
if (remoteAddr != "127.0.0.1" && remoteAddr != "::1" && remoteAddr != "0:0:0:0:0:0:0:1" && remoteAddr != "10.10.0.2") {
|
|
writeOutput("Forbidden"); abort;
|
|
}
|
|
</cfscript>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>API Performance Dashboard</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e1e4e8; padding: 20px; }
|
|
h1 { font-size: 22px; font-weight: 600; margin-bottom: 16px; color: #fff; }
|
|
.controls { display: flex; gap: 10px; margin-bottom: 20px; align-items: center; flex-wrap: wrap; }
|
|
.controls label { font-size: 13px; color: #8b949e; }
|
|
.controls select, .controls input { background: #1c1f26; border: 1px solid #30363d; color: #e1e4e8; padding: 6px 10px; border-radius: 6px; font-size: 13px; }
|
|
.controls button { background: #238636; color: #fff; border: none; padding: 6px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; font-weight: 500; }
|
|
.controls button:hover { background: #2ea043; }
|
|
.controls button.secondary { background: #30363d; }
|
|
.controls button.secondary:hover { background: #3d444d; }
|
|
|
|
.summary-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
|
.card { background: #1c1f26; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
|
|
.card .label { font-size: 11px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
|
.card .value { font-size: 28px; font-weight: 700; color: #fff; }
|
|
.card .unit { font-size: 13px; color: #8b949e; font-weight: 400; }
|
|
.card .value-sm { font-size: 18px; font-weight: 700; color: #fff; }
|
|
|
|
.section { margin-bottom: 28px; }
|
|
.section h2 { font-size: 15px; font-weight: 600; margin-bottom: 10px; color: #c9d1d9; display: flex; align-items: center; gap: 8px; }
|
|
.section h2 .badge { background: #30363d; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; color: #8b949e; }
|
|
|
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
thead th { text-align: left; padding: 8px 12px; border-bottom: 1px solid #30363d; color: #8b949e; font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; }
|
|
thead th.num { text-align: right; }
|
|
tbody td { padding: 8px 12px; border-bottom: 1px solid #1c1f26; }
|
|
tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
tbody tr:hover { background: #1c1f26; }
|
|
tbody td.endpoint { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; color: #58a6ff; }
|
|
|
|
.bar-cell { position: relative; }
|
|
.bar-bg { position: absolute; left: 0; top: 2px; bottom: 2px; border-radius: 3px; opacity: 0.15; }
|
|
.bar-db { background: #f0883e; }
|
|
.bar-app { background: #58a6ff; }
|
|
.bar-wrap { display: flex; height: 18px; border-radius: 3px; overflow: hidden; min-width: 80px; }
|
|
.bar-seg-db { background: #f0883e; height: 100%; }
|
|
.bar-seg-app { background: #58a6ff; height: 100%; }
|
|
|
|
.legend { display: flex; gap: 16px; font-size: 11px; color: #8b949e; margin-bottom: 12px; }
|
|
.legend span { display: flex; align-items: center; gap: 4px; }
|
|
.legend .dot { width: 10px; height: 10px; border-radius: 2px; }
|
|
.legend .dot.db { background: #f0883e; }
|
|
.legend .dot.app { background: #58a6ff; }
|
|
|
|
.status { padding: 8px 12px; background: #1c1f26; border-radius: 6px; font-size: 12px; color: #8b949e; margin-bottom: 16px; }
|
|
.status.error { border: 1px solid #da3633; color: #f85149; }
|
|
.status.loading { border: 1px solid #30363d; }
|
|
|
|
.tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: 500; }
|
|
.tag.fast { background: #23863620; color: #3fb950; }
|
|
.tag.ok { background: #d29b0020; color: #d29922; }
|
|
.tag.slow { background: #da363320; color: #f85149; }
|
|
|
|
.auto-refresh { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
|
.auto-refresh input[type=checkbox] { accent-color: #238636; }
|
|
.timestamp { font-size: 11px; color: #484f58; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>API Performance Dashboard</h1>
|
|
|
|
<div class="controls">
|
|
<label>Time range:
|
|
<select id="hours">
|
|
<option value="1">Last 1 hour</option>
|
|
<option value="6">Last 6 hours</option>
|
|
<option value="24" selected>Last 24 hours</option>
|
|
<option value="72">Last 3 days</option>
|
|
<option value="168">Last 7 days</option>
|
|
<option value="720">Last 30 days</option>
|
|
</select>
|
|
</label>
|
|
<label>Rows:
|
|
<input type="number" id="limit" value="20" min="5" max="100" style="width:60px">
|
|
</label>
|
|
<button onclick="loadAll()">Refresh</button>
|
|
<div class="auto-refresh">
|
|
<input type="checkbox" id="autoRefresh">
|
|
<label for="autoRefresh" style="font-size:12px;color:#8b949e;cursor:pointer">Auto-refresh 30s</label>
|
|
</div>
|
|
<span class="timestamp" id="lastUpdated"></span>
|
|
</div>
|
|
|
|
<div id="status" class="status loading">Loading...</div>
|
|
|
|
<div id="summarySection" class="section" style="display:none">
|
|
<div class="summary-cards" id="summaryCards"></div>
|
|
</div>
|
|
|
|
<div class="legend">
|
|
<span><span class="dot db"></span> DB time</span>
|
|
<span><span class="dot app"></span> App time</span>
|
|
</div>
|
|
|
|
<div id="countSection" class="section" style="display:none">
|
|
<h2>Top Endpoints by Volume <span class="badge" id="countBadge"></span></h2>
|
|
<div style="overflow-x:auto"><table id="countTable"><thead></thead><tbody></tbody></table></div>
|
|
</div>
|
|
|
|
<div id="latencySection" class="section" style="display:none">
|
|
<h2>Top Endpoints by Latency <span class="badge" id="latencyBadge"></span></h2>
|
|
<div style="overflow-x:auto"><table id="latencyTable"><thead></thead><tbody></tbody></table></div>
|
|
</div>
|
|
|
|
<div id="slowSection" class="section" style="display:none">
|
|
<h2>Slowest Individual Requests <span class="badge" id="slowBadge"></span></h2>
|
|
<div style="overflow-x:auto"><table id="slowTable"><thead></thead><tbody></tbody></table></div>
|
|
</div>
|
|
|
|
<script>
|
|
const API = 'perf.cfm';
|
|
let refreshTimer = null;
|
|
|
|
function qs(s) { return document.querySelector(s); }
|
|
function fmt(n) { return n == null ? '-' : Number(n).toLocaleString(); }
|
|
function fmtDate(s) {
|
|
if (!s || s === 'none') return 'none';
|
|
var d = new Date(s.replace(' ', 'T'));
|
|
if (isNaN(d)) return s;
|
|
var mo = d.getMonth() + 1, dy = d.getDate(), yr = String(d.getFullYear()).slice(-2);
|
|
var h = d.getHours(), mi = String(d.getMinutes()).padStart(2, '0'), ap = h >= 12 ? 'p' : 'a';
|
|
h = h % 12 || 12;
|
|
return mo + '/' + dy + '/' + yr + ' ' + h + '.' + mi + ap;
|
|
}
|
|
function fmtMs(n) {
|
|
if (n == null) return '-';
|
|
n = Number(n);
|
|
if (n < 100) return '<span class="tag fast">' + n + 'ms</span>';
|
|
if (n < 500) return '<span class="tag ok">' + n + 'ms</span>';
|
|
return '<span class="tag slow">' + n + 'ms</span>';
|
|
}
|
|
function fmtBytes(n) {
|
|
if (!n) return '-';
|
|
n = Number(n);
|
|
if (n < 1024) return n + ' B';
|
|
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
|
|
return (n / 1048576).toFixed(1) + ' MB';
|
|
}
|
|
function pct(part, total) { return total > 0 ? Math.round(part / total * 100) : 0; }
|
|
|
|
function timeBar(dbMs, appMs) {
|
|
var total = (dbMs || 0) + (appMs || 0);
|
|
if (total === 0) return '';
|
|
var dbPct = pct(dbMs, total);
|
|
var appPct = 100 - dbPct;
|
|
return '<div class="bar-wrap" title="DB: ' + dbMs + 'ms / App: ' + appMs + 'ms">'
|
|
+ '<div class="bar-seg-db" style="width:' + dbPct + '%"></div>'
|
|
+ '<div class="bar-seg-app" style="width:' + appPct + '%"></div>'
|
|
+ '</div>';
|
|
}
|
|
|
|
function endpointName(ep) {
|
|
return ep.replace(/^\/api\//, '');
|
|
}
|
|
|
|
async function fetchView(view) {
|
|
var h = qs('#hours').value;
|
|
var l = qs('#limit').value;
|
|
var r = await fetch(API + '?view=' + view + '&hours=' + h + '&limit=' + l);
|
|
return r.json();
|
|
}
|
|
|
|
async function loadAll() {
|
|
qs('#status').className = 'status loading';
|
|
qs('#status').textContent = 'Loading...';
|
|
qs('#status').style.display = '';
|
|
|
|
try {
|
|
var [summary, count, latency, slow] = await Promise.all([
|
|
fetchView('summary'), fetchView('count'), fetchView('latency'), fetchView('slow')
|
|
]);
|
|
|
|
if (!summary.OK) throw new Error(summary.MESSAGE || summary.ERROR);
|
|
|
|
qs('#status').style.display = 'none';
|
|
|
|
// Summary cards
|
|
var s = summary.DATA;
|
|
qs('#summaryCards').innerHTML =
|
|
card('Total Requests', fmt(s.TotalRequests), '') +
|
|
card('Unique Endpoints', fmt(s.UniqueEndpoints), '') +
|
|
card('Avg Latency', s.OverallAvgMs || 0, 'ms') +
|
|
card('Max Latency', s.OverallMaxMs || 0, 'ms') +
|
|
card('Avg DB Time', s.OverallAvgDbMs || 0, 'ms') +
|
|
card('Avg App Time', s.OverallAvgAppMs || 0, 'ms') +
|
|
card('Avg Queries', s.OverallAvgQueries || 0, '/req') +
|
|
cardSm('Data Since', fmtDate(s.FirstLog));
|
|
qs('#summarySection').style.display = '';
|
|
|
|
// Count table
|
|
renderCountTable(count.DATA || []);
|
|
|
|
// Latency table
|
|
renderLatencyTable(latency.DATA || []);
|
|
|
|
// Slow table
|
|
renderSlowTable(slow.DATA || []);
|
|
|
|
qs('#lastUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
|
|
|
} catch (e) {
|
|
qs('#status').className = 'status error';
|
|
qs('#status').textContent = 'Error: ' + e.message;
|
|
qs('#status').style.display = '';
|
|
}
|
|
}
|
|
|
|
function card(label, value, unit) {
|
|
return '<div class="card"><div class="label">' + label + '</div><div class="value">' + value + ' <span class="unit">' + unit + '</span></div></div>';
|
|
}
|
|
function cardSm(label, value) {
|
|
return '<div class="card"><div class="label">' + label + '</div><div class="value-sm">' + value + '</div></div>';
|
|
}
|
|
|
|
function renderCountTable(data) {
|
|
if (!data.length) { qs('#countSection').style.display = 'none'; return; }
|
|
qs('#countBadge').textContent = data.length + ' endpoints';
|
|
var maxCalls = Math.max(...data.map(r => r.Calls));
|
|
var html = '<thead><tr><th>Endpoint</th><th class="num">Calls</th><th class="num">Avg</th><th class="num">DB</th><th class="num">App</th><th>DB / App Split</th><th class="num">Max</th><th class="num">Queries</th><th class="num">Avg Size</th></tr></thead><tbody>';
|
|
data.forEach(function(r) {
|
|
html += '<tr>'
|
|
+ '<td class="endpoint">' + endpointName(r.Endpoint) + '</td>'
|
|
+ '<td class="num">' + fmt(r.Calls) + '</td>'
|
|
+ '<td class="num">' + fmtMs(r.AvgMs) + '</td>'
|
|
+ '<td class="num">' + fmt(r.AvgDbMs) + 'ms</td>'
|
|
+ '<td class="num">' + fmt(r.AvgAppMs) + 'ms</td>'
|
|
+ '<td>' + timeBar(r.AvgDbMs, r.AvgAppMs) + '</td>'
|
|
+ '<td class="num">' + fmtMs(r.MaxMs) + '</td>'
|
|
+ '<td class="num">' + r.AvgQueries + '</td>'
|
|
+ '<td class="num">' + fmtBytes(r.AvgBytes) + '</td>'
|
|
+ '</tr>';
|
|
});
|
|
html += '</tbody>';
|
|
qs('#countTable').innerHTML = html;
|
|
qs('#countSection').style.display = '';
|
|
}
|
|
|
|
function renderLatencyTable(data) {
|
|
if (!data.length) { qs('#latencySection').style.display = 'none'; return; }
|
|
qs('#latencyBadge').textContent = data.length + ' endpoints';
|
|
var html = '<thead><tr><th>Endpoint</th><th class="num">Calls</th><th class="num">Avg</th><th class="num">DB</th><th class="num">App</th><th>DB / App Split</th><th class="num">Max</th><th class="num">Queries</th></tr></thead><tbody>';
|
|
data.forEach(function(r) {
|
|
html += '<tr>'
|
|
+ '<td class="endpoint">' + endpointName(r.Endpoint) + '</td>'
|
|
+ '<td class="num">' + fmt(r.Calls) + '</td>'
|
|
+ '<td class="num">' + fmtMs(r.AvgMs) + '</td>'
|
|
+ '<td class="num">' + fmt(r.AvgDbMs) + 'ms</td>'
|
|
+ '<td class="num">' + fmt(r.AvgAppMs) + 'ms</td>'
|
|
+ '<td>' + timeBar(r.AvgDbMs, r.AvgAppMs) + '</td>'
|
|
+ '<td class="num">' + fmtMs(r.MaxMs) + '</td>'
|
|
+ '<td class="num">' + r.AvgQueries + '</td>'
|
|
+ '</tr>';
|
|
});
|
|
html += '</tbody>';
|
|
qs('#latencyTable').innerHTML = html;
|
|
qs('#latencySection').style.display = '';
|
|
}
|
|
|
|
function renderSlowTable(data) {
|
|
if (!data.length) { qs('#slowSection').style.display = 'none'; return; }
|
|
qs('#slowBadge').textContent = data.length + ' requests';
|
|
var html = '<thead><tr><th>Endpoint</th><th class="num">Total</th><th class="num">DB</th><th class="num">App</th><th>DB / App Split</th><th class="num">Queries</th><th class="num">Size</th><th class="num">Biz</th><th>When</th></tr></thead><tbody>';
|
|
data.forEach(function(r) {
|
|
html += '<tr>'
|
|
+ '<td class="endpoint">' + endpointName(r.Endpoint) + '</td>'
|
|
+ '<td class="num">' + fmtMs(r.TotalMs) + '</td>'
|
|
+ '<td class="num">' + fmt(r.DbMs) + 'ms</td>'
|
|
+ '<td class="num">' + fmt(r.AppMs) + 'ms</td>'
|
|
+ '<td>' + timeBar(r.DbMs, r.AppMs) + '</td>'
|
|
+ '<td class="num">' + r.QueryCount + '</td>'
|
|
+ '<td class="num">' + fmtBytes(r.ResponseBytes) + '</td>'
|
|
+ '<td class="num">' + (r.BusinessID || '-') + '</td>'
|
|
+ '<td style="font-size:11px;color:#8b949e;white-space:nowrap">' + (r.LoggedAt || '') + '</td>'
|
|
+ '</tr>';
|
|
});
|
|
html += '</tbody>';
|
|
qs('#slowTable').innerHTML = html;
|
|
qs('#slowSection').style.display = '';
|
|
}
|
|
|
|
// Auto-refresh
|
|
qs('#autoRefresh').addEventListener('change', function() {
|
|
if (this.checked) {
|
|
refreshTimer = setInterval(loadAll, 30000);
|
|
} else {
|
|
clearInterval(refreshTimer);
|
|
refreshTimer = null;
|
|
}
|
|
});
|
|
|
|
// Load on page open
|
|
loadAll();
|
|
</script>
|
|
</body>
|
|
</html>
|