Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
991
infra/perf-monitor/public/index.html
Normal file
991
infra/perf-monitor/public/index.html
Normal file
@@ -0,0 +1,991 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user