Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
992 lines
27 KiB
HTML
992 lines
27 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Emdash Perf Monitor</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3/dist/chartjs-plugin-annotation.min.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #0c0c0e;
|
|
--bg-card: #16161a;
|
|
--bg-hover: #1e1e24;
|
|
--border: #2a2a32;
|
|
--text: #e0e0e6;
|
|
--text-muted: #888894;
|
|
--text-dim: #5c5c66;
|
|
--accent: #4dabf7;
|
|
--accent-dim: #2a6cb5;
|
|
--green: #51cf66;
|
|
--yellow: #fcc419;
|
|
--red: #ff6b6b;
|
|
--orange: #ff922b;
|
|
--purple: #b197fc;
|
|
--radius: 6px;
|
|
--font: "SF Mono", "Cascadia Code", "Fira Code", Menlo, monospace;
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: var(--font);
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 24px;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 16px;
|
|
margin-bottom: 32px;
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 16px;
|
|
}
|
|
|
|
header h1 {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
header .target {
|
|
color: var(--text-muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
header .last-updated {
|
|
margin-left: auto;
|
|
color: var(--text-dim);
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* Controls */
|
|
.controls {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-bottom: 24px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
|
|
.controls label {
|
|
color: var(--text-muted);
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.controls select {
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
padding: 6px 10px;
|
|
border-radius: var(--radius);
|
|
font-family: var(--font);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.controls select:hover {
|
|
border-color: var(--accent-dim);
|
|
}
|
|
|
|
/* Summary cards */
|
|
.summary-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.summary-card {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 14px 16px;
|
|
}
|
|
|
|
.summary-card .label {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.summary-card .value {
|
|
font-size: 22px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.03em;
|
|
}
|
|
|
|
.summary-card .meta {
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.good {
|
|
color: var(--green);
|
|
}
|
|
.warn {
|
|
color: var(--yellow);
|
|
}
|
|
.bad {
|
|
color: var(--red);
|
|
}
|
|
|
|
/* Chart area */
|
|
.chart-section {
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.chart-section h2 {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
margin-bottom: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.chart-wrapper {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 16px;
|
|
position: relative;
|
|
}
|
|
|
|
.chart-wrapper canvas {
|
|
width: 100% !important;
|
|
}
|
|
|
|
/* Table */
|
|
.results-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.results-table th {
|
|
text-align: left;
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--text-dim);
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.results-table td {
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.results-table tr:hover td {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.results-table .mono {
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.pr-badge {
|
|
display: inline-block;
|
|
background: var(--accent-dim);
|
|
color: var(--accent);
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.pr-badge:hover {
|
|
background: #3577c5;
|
|
}
|
|
|
|
.source-badge {
|
|
display: inline-block;
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.source-cron {
|
|
background: #1a1f2a;
|
|
color: var(--text-muted);
|
|
}
|
|
.source-deploy {
|
|
background: #1a3a2a;
|
|
color: var(--green);
|
|
}
|
|
.source-manual {
|
|
background: #3a2a1a;
|
|
color: var(--yellow);
|
|
}
|
|
|
|
.timing-tag {
|
|
display: inline-block;
|
|
padding: 1px 5px;
|
|
margin-right: 4px;
|
|
border-radius: 3px;
|
|
font-size: 10px;
|
|
background: #1e1e24;
|
|
color: var(--text-muted);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.timing-tag strong {
|
|
color: var(--text);
|
|
font-weight: 500;
|
|
margin-right: 3px;
|
|
}
|
|
|
|
.note-text {
|
|
color: var(--text-muted);
|
|
font-size: 11px;
|
|
font-style: italic;
|
|
}
|
|
|
|
a.sha-link {
|
|
color: var(--text-dim);
|
|
text-decoration: none;
|
|
}
|
|
|
|
a.sha-link:hover {
|
|
color: var(--text-muted);
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.region-tag {
|
|
display: inline-block;
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.region-use {
|
|
background: #1a3a2a;
|
|
color: var(--green);
|
|
}
|
|
.region-euw {
|
|
background: #1a2a3a;
|
|
color: var(--accent);
|
|
}
|
|
.region-ape {
|
|
background: #2a1a3a;
|
|
color: var(--purple);
|
|
}
|
|
.region-aps {
|
|
background: #3a2a1a;
|
|
color: var(--orange);
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 48px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.error-msg {
|
|
background: #2a1a1a;
|
|
border: 1px solid #3a2020;
|
|
color: var(--red);
|
|
padding: 12px 16px;
|
|
border-radius: var(--radius);
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* Legend for chart */
|
|
.chart-legend {
|
|
display: flex;
|
|
gap: 16px;
|
|
margin-top: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.chart-legend .item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.chart-legend .swatch {
|
|
width: 12px;
|
|
height: 3px;
|
|
border-radius: 1px;
|
|
}
|
|
|
|
.chart-legend .swatch-marker {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
border: 2px solid var(--red);
|
|
background: transparent;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>emdash perf</h1>
|
|
<span class="target" id="target-label"></span>
|
|
<span class="last-updated" id="last-updated"></span>
|
|
</header>
|
|
|
|
<div class="controls">
|
|
<div>
|
|
<label>Site</label>
|
|
<select id="site-select"></select>
|
|
</div>
|
|
<div>
|
|
<label>Route</label>
|
|
<select id="route-select"></select>
|
|
</div>
|
|
<div>
|
|
<label>Region</label>
|
|
<select id="region-select">
|
|
<option value="all">All Regions</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>Period</label>
|
|
<select id="period-select">
|
|
<option value="1h">1 hour</option>
|
|
<option value="24h">24 hours</option>
|
|
<option value="7d" selected>7 days</option>
|
|
<option value="30d">30 days</option>
|
|
<option value="90d">90 days</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="summary-grid" id="summary-cards">
|
|
<div class="loading">Loading...</div>
|
|
</div>
|
|
|
|
<div class="chart-section">
|
|
<h2>Cold Start TTFB</h2>
|
|
<div class="chart-wrapper">
|
|
<canvas id="cold-chart" height="300"></canvas>
|
|
</div>
|
|
<div class="chart-legend">
|
|
<div class="item">
|
|
<span class="swatch" style="background: var(--green)"></span>
|
|
US East
|
|
</div>
|
|
<div class="item">
|
|
<span class="swatch" style="background: var(--accent)"></span>
|
|
Europe West
|
|
</div>
|
|
<div class="item">
|
|
<span class="swatch" style="background: var(--purple)"></span>
|
|
Asia Pacific East
|
|
</div>
|
|
<div class="item">
|
|
<span class="swatch" style="background: var(--orange)"></span>
|
|
Asia Pacific South
|
|
</div>
|
|
<div class="item">
|
|
<span class="swatch-marker"></span>
|
|
Deploy
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chart-section">
|
|
<h2>Warm TTFB (median)</h2>
|
|
<div class="chart-wrapper">
|
|
<canvas id="warm-chart" height="300"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chart-section">
|
|
<h2>Recent Results</h2>
|
|
<div class="chart-wrapper" style="overflow-x: auto">
|
|
<table class="results-table" id="results-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Route</th>
|
|
<th>Region</th>
|
|
<th>Cold TTFB</th>
|
|
<th>Warm TTFB</th>
|
|
<th>P95</th>
|
|
<th>Status</th>
|
|
<th>Colo</th>
|
|
<th>Cold Timings</th>
|
|
<th>Warm Timings</th>
|
|
<th>Source</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="results-body">
|
|
<tr>
|
|
<td colspan="11" class="loading">Loading...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const GITHUB_REPO = "emdash-cms/emdash";
|
|
const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
|
|
|
|
const REGION_COLORS = {
|
|
use: "#51cf66",
|
|
euw: "#4dabf7",
|
|
ape: "#b197fc",
|
|
aps: "#ff922b",
|
|
};
|
|
|
|
const REGION_LABELS = {
|
|
use: "US East",
|
|
euw: "Europe West",
|
|
ape: "Asia Pacific East",
|
|
aps: "Asia Pacific South",
|
|
};
|
|
|
|
let coldChart = null;
|
|
let warmChart = null;
|
|
let configData = null;
|
|
|
|
async function fetchJson(url) {
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
|
|
return resp.json();
|
|
}
|
|
|
|
function periodToSince(period) {
|
|
const now = new Date();
|
|
switch (period) {
|
|
case "1h":
|
|
return new Date(now - 60 * 60 * 1000).toISOString();
|
|
case "24h":
|
|
return new Date(now - 24 * 60 * 60 * 1000).toISOString();
|
|
case "7d":
|
|
return new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
case "30d":
|
|
return new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
case "90d":
|
|
return new Date(now - 90 * 24 * 60 * 60 * 1000).toISOString();
|
|
default:
|
|
return new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
}
|
|
}
|
|
|
|
// 7d/30d/90d views show per-point samples that are too spiky to read.
|
|
// Bucket by UTC day so the trend is visible. Median, not mean, so a
|
|
// single cron spike doesn't pull the bucket.
|
|
const DAILY_BUCKET_PERIODS = new Set(["7d", "30d", "90d"]);
|
|
|
|
/**
|
|
* Parse a D1-stored timestamp ("YYYY-MM-DD HH:MM:SS", no TZ) as UTC.
|
|
* `new Date("YYYY-MM-DD HH:MM:SS")` is implementation-defined and most
|
|
* browsers treat it as *local* time, which shifts samples across UTC
|
|
* day boundaries when we bucket with `getUTC*`. Normalize first.
|
|
*/
|
|
function parseStoredTimestamp(ts) {
|
|
if (!ts) return null;
|
|
if (ts.includes("T") || ts.endsWith("Z")) return new Date(ts);
|
|
return new Date(ts.replace(" ", "T") + "Z");
|
|
}
|
|
|
|
function median(nums) {
|
|
const sorted = nums.filter((n) => n != null).sort((a, b) => a - b);
|
|
if (sorted.length === 0) return null;
|
|
const mid = sorted.length >> 1;
|
|
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
}
|
|
|
|
function bucketByUtcDay(points) {
|
|
const byDay = new Map();
|
|
for (const p of points) {
|
|
const d = p.x instanceof Date ? p.x : new Date(p.x);
|
|
const day = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
|
if (!byDay.has(day)) byDay.set(day, []);
|
|
byDay.get(day).push(p.y);
|
|
}
|
|
return [...byDay.entries()]
|
|
.map(([day, ys]) => ({
|
|
x: new Date(`${day}T12:00:00Z`),
|
|
y: median(ys),
|
|
}))
|
|
.filter((p) => p.y != null)
|
|
.sort((a, b) => a.x - b.x);
|
|
}
|
|
|
|
function formatMs(ms) {
|
|
if (ms == null) return "-";
|
|
if (ms < 1000) return Math.round(ms) + "ms";
|
|
return (ms / 1000).toFixed(2) + "s";
|
|
}
|
|
|
|
function ttfbClass(ms, threshold) {
|
|
if (ms == null) return "";
|
|
if (ms <= threshold * 0.5) return "good";
|
|
if (ms <= threshold) return "warn";
|
|
return "bad";
|
|
}
|
|
|
|
function formatTime(ts) {
|
|
const d = parseStoredTimestamp(ts);
|
|
if (!d) return "";
|
|
const pad = (n) => String(n).padStart(2, "0");
|
|
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
const HTML_ESCAPES = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
};
|
|
function escapeHtml(s) {
|
|
if (s == null) return "";
|
|
return String(s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
|
|
}
|
|
function escapeAttr(s) {
|
|
// attribute-safe subset of characters
|
|
return escapeHtml(s).replace(/\//g, "/");
|
|
}
|
|
|
|
/**
|
|
* Render server timings as a row of small tagged pills.
|
|
* Input is the JSON string as stored in D1, or null.
|
|
* We keep the tooltip (title attr) with the full `desc` when present
|
|
* so hovering surfaces readable names without cluttering the table.
|
|
*/
|
|
function renderServerTimings(raw) {
|
|
if (!raw) return '<span style="color:var(--text-dim)">-</span>';
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return '<span style="color:var(--text-dim)">-</span>';
|
|
}
|
|
if (!parsed || typeof parsed !== "object") return "";
|
|
const entries = Object.entries(parsed);
|
|
if (entries.length === 0) return "";
|
|
return entries
|
|
.map(([name, t]) => {
|
|
const dur = Math.round(t.dur);
|
|
const title = t.desc ? `${t.desc} (${dur}ms)` : `${name}: ${dur}ms`;
|
|
return `<span class="timing-tag" title="${escapeAttr(title)}"><strong>${escapeHtml(name)}</strong>${dur}ms</span>`;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
async function loadConfig() {
|
|
configData = await fetchJson("/api/config");
|
|
|
|
const siteSelect = document.getElementById("site-select");
|
|
const sites = configData.sites ?? [];
|
|
for (const site of sites) {
|
|
const opt = document.createElement("option");
|
|
opt.value = site.id;
|
|
opt.textContent = site.label ? `${site.label} (${site.id})` : site.id;
|
|
if (site.id === configData.defaultSite) opt.selected = true;
|
|
siteSelect.appendChild(opt);
|
|
}
|
|
updateTargetLabel();
|
|
|
|
const routeSelect = document.getElementById("route-select");
|
|
for (const route of configData.routes) {
|
|
const opt = document.createElement("option");
|
|
opt.value = route.path;
|
|
opt.textContent = route.label;
|
|
routeSelect.appendChild(opt);
|
|
}
|
|
|
|
const regionSelect = document.getElementById("region-select");
|
|
for (const region of configData.regions) {
|
|
const opt = document.createElement("option");
|
|
opt.value = region.id;
|
|
opt.textContent = region.label;
|
|
regionSelect.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
function currentSite() {
|
|
return document.getElementById("site-select").value || configData?.defaultSite || "blog";
|
|
}
|
|
|
|
function updateTargetLabel() {
|
|
const site = (configData?.sites ?? []).find((s) => s.id === currentSite());
|
|
const label = document.getElementById("target-label");
|
|
if (site?.targetUrl) {
|
|
label.textContent = site.targetUrl.replace(/^https?:\/\//, "");
|
|
} else {
|
|
label.textContent = "";
|
|
}
|
|
}
|
|
|
|
async function loadSummary() {
|
|
const data = await fetchJson(`/api/summary?site=${encodeURIComponent(currentSite())}`);
|
|
const container = document.getElementById("summary-cards");
|
|
container.innerHTML = "";
|
|
|
|
if (!data.latest || data.latest.length === 0) {
|
|
container.innerHTML =
|
|
'<div class="summary-card"><div class="label">No data</div><div class="value">-</div></div>';
|
|
return;
|
|
}
|
|
|
|
// Group by region and show the latest cold TTFB for the selected route
|
|
const route = document.getElementById("route-select").value || configData.routes[0]?.path;
|
|
const routeConfig = configData.routes.find((r) => r.path === route);
|
|
|
|
for (const region of configData.regions) {
|
|
const result = data.latest.find((r) => r.route === route && r.region === region.id);
|
|
const median = data.medians.find((m) => m.route === route && m.region === region.id);
|
|
|
|
const card = document.createElement("div");
|
|
card.className = "summary-card";
|
|
|
|
const coldMs = result?.cold_ttfb_ms;
|
|
const threshold = routeConfig?.coldThresholdMs ?? 2000;
|
|
const cls = ttfbClass(coldMs, threshold);
|
|
|
|
card.innerHTML = `
|
|
<div class="label"><span class="region-tag region-${region.id}">${region.id}</span> ${region.label}</div>
|
|
<div class="value ${cls}">${formatMs(coldMs)}</div>
|
|
<div class="meta">
|
|
warm ${formatMs(result?.warm_ttfb_ms)}
|
|
${median ? ` · avg ${formatMs(median.median_cold)}` : ""}
|
|
${result?.cf_colo ? ` · ${result.cf_colo}` : ""}
|
|
</div>
|
|
`;
|
|
container.appendChild(card);
|
|
}
|
|
|
|
// Update timestamp
|
|
const newest = data.latest.reduce(
|
|
(a, b) => (a.timestamp > b.timestamp ? a : b),
|
|
data.latest[0],
|
|
);
|
|
if (newest) {
|
|
document.getElementById("last-updated").textContent =
|
|
`Updated ${formatTime(newest.timestamp)}`;
|
|
}
|
|
}
|
|
|
|
function createChart(canvasId, label) {
|
|
const ctx = document.getElementById(canvasId).getContext("2d");
|
|
|
|
return new Chart(ctx, {
|
|
type: "line",
|
|
data: { datasets: [] },
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { mode: "nearest", axis: "x", intersect: false },
|
|
scales: {
|
|
x: {
|
|
type: "time",
|
|
time: { tooltipFormat: "MMM d, HH:mm" },
|
|
grid: { color: "#2a2a32", lineWidth: 0.5 },
|
|
ticks: { color: "#5c5c66", font: { size: 10 } },
|
|
},
|
|
y: {
|
|
title: {
|
|
display: true,
|
|
text: "ms",
|
|
color: "#5c5c66",
|
|
font: { size: 10 },
|
|
},
|
|
grid: { color: "#2a2a32", lineWidth: 0.5 },
|
|
ticks: { color: "#5c5c66", font: { size: 10 } },
|
|
beginAtZero: true,
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
backgroundColor: "#16161a",
|
|
titleColor: "#e0e0e6",
|
|
bodyColor: "#888894",
|
|
borderColor: "#2a2a32",
|
|
borderWidth: 1,
|
|
titleFont: { size: 11, family: "monospace" },
|
|
bodyFont: { size: 11, family: "monospace" },
|
|
callbacks: {
|
|
label: function (context) {
|
|
const point = context.raw;
|
|
let text = `${context.dataset.label}: ${formatMs(context.parsed.y)}`;
|
|
if (point && point.sha) {
|
|
text += ` [${point.sha.slice(0, 7)}]`;
|
|
}
|
|
if (point && point.prNumber) {
|
|
text += ` PR #${point.prNumber}`;
|
|
}
|
|
return text;
|
|
},
|
|
},
|
|
},
|
|
annotation: { annotations: {} },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function loadCharts() {
|
|
const route = document.getElementById("route-select").value || configData.routes[0]?.path;
|
|
const regionFilter = document.getElementById("region-select").value;
|
|
const period = document.getElementById("period-select").value;
|
|
const since = periodToSince(period);
|
|
|
|
const regions =
|
|
regionFilter === "all" ? configData.regions.map((r) => r.id) : [regionFilter];
|
|
|
|
const site = currentSite();
|
|
const bucketDaily = DAILY_BUCKET_PERIODS.has(period);
|
|
|
|
// Fetch chart data for each region in parallel
|
|
const chartDataPromises = regions.map((region) =>
|
|
fetchJson(
|
|
`/api/chart?site=${encodeURIComponent(site)}&route=${encodeURIComponent(route)}®ion=${region}&since=${since}&limit=500`,
|
|
),
|
|
);
|
|
const chartResults = await Promise.all(chartDataPromises);
|
|
|
|
// Build datasets for cold chart
|
|
const coldDatasets = [];
|
|
const warmDatasets = [];
|
|
const annotations = {};
|
|
|
|
for (let i = 0; i < regions.length; i++) {
|
|
const region = regions[i];
|
|
const result = chartResults[i];
|
|
const color = REGION_COLORS[region] || "#888";
|
|
|
|
const rawCold = result.data.map((d) => ({
|
|
x: parseStoredTimestamp(d.timestamp),
|
|
y: d.coldTtfbMs,
|
|
prNumber: d.prNumber,
|
|
sha: d.sha,
|
|
source: d.source,
|
|
}));
|
|
const rawWarm = result.data.map((d) => ({
|
|
x: parseStoredTimestamp(d.timestamp),
|
|
y: d.warmTtfbMs,
|
|
prNumber: d.prNumber,
|
|
sha: d.sha,
|
|
source: d.source,
|
|
}));
|
|
const coldPoints = bucketDaily ? bucketByUtcDay(rawCold) : rawCold;
|
|
const warmPoints = bucketDaily ? bucketByUtcDay(rawWarm) : rawWarm;
|
|
|
|
coldDatasets.push({
|
|
label: REGION_LABELS[region] || region,
|
|
data: coldPoints,
|
|
borderColor: color,
|
|
backgroundColor: color + "20",
|
|
borderWidth: 1.5,
|
|
pointRadius: (ctx) => {
|
|
const point = ctx.raw;
|
|
return point && point.sha ? 5 : 1.5;
|
|
},
|
|
pointBackgroundColor: (ctx) => {
|
|
const point = ctx.raw;
|
|
return point && point.sha ? "#ff6b6b" : color;
|
|
},
|
|
pointBorderColor: (ctx) => {
|
|
const point = ctx.raw;
|
|
return point && point.sha ? "#ff6b6b" : color;
|
|
},
|
|
pointBorderWidth: (ctx) => {
|
|
const point = ctx.raw;
|
|
return point && point.sha ? 2 : 0;
|
|
},
|
|
tension: 0.3,
|
|
fill: false,
|
|
});
|
|
|
|
warmDatasets.push({
|
|
label: REGION_LABELS[region] || region,
|
|
data: warmPoints,
|
|
borderColor: color,
|
|
backgroundColor: color + "20",
|
|
borderWidth: 1.5,
|
|
pointRadius: (ctx) => {
|
|
const point = ctx.raw;
|
|
return point && point.sha ? 5 : 1.5;
|
|
},
|
|
pointBackgroundColor: (ctx) => {
|
|
const point = ctx.raw;
|
|
return point && point.sha ? "#ff6b6b" : color;
|
|
},
|
|
pointBorderColor: (ctx) => {
|
|
const point = ctx.raw;
|
|
return point && point.sha ? "#ff6b6b" : color;
|
|
},
|
|
pointBorderWidth: (ctx) => {
|
|
const point = ctx.raw;
|
|
return point && point.sha ? 2 : 0;
|
|
},
|
|
tension: 0.3,
|
|
fill: false,
|
|
});
|
|
|
|
// Add vertical lines for all deploys
|
|
if (result.deployMarkers) {
|
|
for (const marker of result.deployMarkers) {
|
|
const sha7 = marker.sha ? marker.sha.slice(0, 7) : "?";
|
|
const label = marker.prNumber ? `PR #${marker.prNumber}` : sha7;
|
|
const key = `deploy-${marker.sha || marker.timestamp}-${region}`;
|
|
annotations[key] = {
|
|
type: "line",
|
|
xMin: parseStoredTimestamp(marker.timestamp),
|
|
xMax: parseStoredTimestamp(marker.timestamp),
|
|
borderColor: "#ff6b6b40",
|
|
borderWidth: 1,
|
|
borderDash: [4, 4],
|
|
label: {
|
|
display: regions.length <= 2,
|
|
content: label,
|
|
position: "start",
|
|
backgroundColor: "#2a1a1a",
|
|
color: "#ff6b6b",
|
|
font: { size: 10, family: "monospace" },
|
|
padding: { top: 2, bottom: 2, left: 4, right: 4 },
|
|
},
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update cold chart
|
|
if (!coldChart) {
|
|
coldChart = createChart("cold-chart", "Cold TTFB");
|
|
}
|
|
coldChart.data.datasets = coldDatasets;
|
|
coldChart.options.plugins.annotation.annotations = annotations;
|
|
coldChart.update();
|
|
|
|
// Update warm chart
|
|
if (!warmChart) {
|
|
warmChart = createChart("warm-chart", "Warm TTFB");
|
|
}
|
|
warmChart.data.datasets = warmDatasets;
|
|
warmChart.options.plugins.annotation.annotations = annotations;
|
|
warmChart.update();
|
|
}
|
|
|
|
async function loadTable() {
|
|
const route = document.getElementById("route-select").value || configData.routes[0]?.path;
|
|
const regionFilter = document.getElementById("region-select").value;
|
|
const period = document.getElementById("period-select").value;
|
|
const since = periodToSince(period);
|
|
|
|
const params = new URLSearchParams({
|
|
site: currentSite(),
|
|
since,
|
|
limit: "50",
|
|
});
|
|
if (route) params.set("route", route);
|
|
if (regionFilter !== "all") params.set("region", regionFilter);
|
|
|
|
const data = await fetchJson(`/api/results?${params}`);
|
|
const tbody = document.getElementById("results-body");
|
|
|
|
if (!data.results || data.results.length === 0) {
|
|
tbody.innerHTML =
|
|
'<tr><td colspan="11" style="text-align:center;color:var(--text-dim)">No results</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.results
|
|
.map((r) => {
|
|
const routeConfig = configData.routes.find((rc) => rc.path === r.route);
|
|
const threshold = routeConfig?.coldThresholdMs ?? 2000;
|
|
const cls = ttfbClass(r.cold_ttfb_ms, threshold);
|
|
|
|
const prLink = r.pr_number
|
|
? ` <a class="pr-badge" href="${GITHUB_URL}/pull/${r.pr_number}" target="_blank" rel="noopener">PR #${r.pr_number}</a>`
|
|
: "";
|
|
const shaLink = r.sha
|
|
? ` <a class="sha-link mono" href="${GITHUB_URL}/commit/${r.sha}" target="_blank" rel="noopener">${r.sha.slice(0, 7)}</a>`
|
|
: "";
|
|
const sourceBadge = `<span class="source-badge source-${escapeAttr(r.source)}">${escapeHtml(r.source)}</span>`;
|
|
const note = r.note
|
|
? `<div class="note-text" title="${escapeAttr(r.note)}">${escapeHtml(r.note)}</div>`
|
|
: "";
|
|
|
|
return `<tr>
|
|
<td class="mono">${formatTime(r.timestamp)}</td>
|
|
<td>${escapeHtml(r.route)}${note}</td>
|
|
<td><span class="region-tag region-${escapeAttr(r.region)}">${escapeHtml(r.region)}</span></td>
|
|
<td class="mono ${cls}">${formatMs(r.cold_ttfb_ms)}</td>
|
|
<td class="mono">${formatMs(r.warm_ttfb_ms)}</td>
|
|
<td class="mono">${formatMs(r.p95_ttfb_ms)}</td>
|
|
<td class="mono">${r.status_code ?? "-"}</td>
|
|
<td class="mono">${r.cf_colo ?? "-"}</td>
|
|
<td>${renderServerTimings(r.cold_server_timings)}</td>
|
|
<td>${renderServerTimings(r.warm_server_timings)}</td>
|
|
<td>${sourceBadge}${prLink}${shaLink}</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
async function refresh() {
|
|
try {
|
|
await Promise.all([loadSummary(), loadCharts(), loadTable()]);
|
|
} catch (err) {
|
|
console.error("Failed to load data:", err);
|
|
}
|
|
}
|
|
|
|
async function init() {
|
|
try {
|
|
await loadConfig();
|
|
await refresh();
|
|
|
|
// Refresh on control changes
|
|
document.getElementById("site-select").addEventListener("change", () => {
|
|
updateTargetLabel();
|
|
refresh();
|
|
});
|
|
document.getElementById("route-select").addEventListener("change", refresh);
|
|
document.getElementById("region-select").addEventListener("change", refresh);
|
|
document.getElementById("period-select").addEventListener("change", refresh);
|
|
|
|
// Auto-refresh every 5 minutes
|
|
setInterval(refresh, 5 * 60 * 1000);
|
|
} catch (err) {
|
|
document.querySelector(".container").innerHTML = `
|
|
<div class="error-msg">Failed to load: ${err.message}</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|