<!-- htmlhint doctype-first:false, tag-pair:false -->
<!-- Advanced Metrics Dashboard -->
<script>
// Metrics Dashboard Controller Factory
window.createMetricsController = function() {
return {
timeRange: 24,
interval: 60,
limit: 10,
charts: {},
loading: false,
error: null,
autoRefreshInterval: null,
visibilityHandler: null,
/**
* Destroy all Chart.js instances to prevent canvas reuse errors
*/
destroyAllCharts() {
Object.keys(this.charts).forEach(key => {
if (this.charts[key]) {
try {
this.charts[key].destroy();
} catch (e) {
console.warn(`Failed to destroy chart ${key}:`, e);
}
this.charts[key] = null;
}
});
},
/**
* Initialize the metrics dashboard with proper cleanup
*/
async init() {
// Clean up any existing charts first
this.destroyAllCharts();
// Load initial data
await this.loadAllMetrics();
// Start auto-refresh
this.startAutoRefresh();
// Handle visibility changes to pause updates when tab is hidden
this.visibilityHandler = () => {
if (document.hidden) {
this.stopAutoRefresh();
} else {
this.startAutoRefresh();
// Refresh data when tab becomes visible
if (!this.loading) {
this.loadAllMetrics();
}
}
};
document.addEventListener('visibilitychange', this.visibilityHandler);
// Add cleanup on page unload as fallback
window.addEventListener('beforeunload', () => this.cleanup());
},
/**
* Cleanup resources on component destroy
*/
cleanup() {
this.destroyAllCharts();
this.stopAutoRefresh();
if (this.visibilityHandler) {
document.removeEventListener('visibilitychange', this.visibilityHandler);
}
},
/**
* Load all metrics with proper guards to prevent concurrent refreshes
*/
async loadAllMetrics() {
// Prevent concurrent refreshes
if (this.loading) {
console.log('Refresh already in progress, skipping');
return;
}
this.loading = true;
this.error = null;
try {
// Destroy existing charts before loading new data
this.destroyAllCharts();
// Load all metrics in parallel
await Promise.all([
this.loadPercentiles(),
this.loadTimeSeries(),
this.loadTopSlow(),
this.loadTopVolume(),
this.loadTopErrors(),
this.loadHeatmap(),
]);
} catch (e) {
console.error('Failed to load metrics:', e);
this.error = e.message;
} finally {
this.loading = false;
}
},
async loadPercentiles() {
const response = await fetch(
`{{ root_path }}/admin/observability/metrics/percentiles?hours=${this.timeRange}&interval_minutes=${this.interval}`
);
if (!response.ok) throw new Error('Failed to load percentiles');
const data = await response.json();
this.renderPercentileChart(data);
},
async loadTimeSeries() {
const response = await fetch(
`{{ root_path }}/admin/observability/metrics/timeseries?hours=${this.timeRange}&interval_minutes=${this.interval}`
);
if (!response.ok) throw new Error('Failed to load time series');
const data = await response.json();
this.renderTimeSeriesChart(data);
},
async loadTopSlow() {
const response = await fetch(
`{{ root_path }}/admin/observability/metrics/top-slow?hours=${this.timeRange}&limit=${this.limit}`
);
if (!response.ok) throw new Error('Failed to load top slow endpoints');
const data = await response.json();
this.renderTopSlowTable(data);
},
async loadTopVolume() {
const response = await fetch(
`{{ root_path }}/admin/observability/metrics/top-volume?hours=${this.timeRange}&limit=${this.limit}`
);
if (!response.ok) throw new Error('Failed to load top volume endpoints');
const data = await response.json();
this.renderTopVolumeTable(data);
},
async loadTopErrors() {
const response = await fetch(
`{{ root_path }}/admin/observability/metrics/top-errors?hours=${this.timeRange}&limit=${this.limit}`
);
if (!response.ok) throw new Error('Failed to load top error endpoints');
const data = await response.json();
this.renderTopErrorsTable(data);
},
async loadHeatmap() {
const response = await fetch(
`{{ root_path }}/admin/observability/metrics/heatmap?hours=${this.timeRange}&time_buckets=24&latency_buckets=20`
);
if (!response.ok) throw new Error('Failed to load heatmap');
const data = await response.json();
this.renderHeatmap(data);
},
/**
* Render percentile chart with proper cleanup and error handling
*/
renderPercentileChart(data) {
const ctx = document.getElementById('percentileChart');
if (!ctx) {
console.warn('percentileChart canvas not found');
return;
}
// Destroy existing chart
if (this.charts.percentile) {
try {
this.charts.percentile.destroy();
} catch (e) {
console.warn('Failed to destroy percentile chart:', e);
}
this.charts.percentile = null;
}
// Use requestAnimationFrame to ensure canvas is ready
requestAnimationFrame(() => {
try {
this.charts.percentile = new Chart(ctx, {
type: 'line',
data: {
labels: data.timestamps.map((t) => new Date(t).toLocaleTimeString()),
datasets: [
{
label: 'p50 (median)',
data: data.p50,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
},
{
label: 'p90',
data: data.p90,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
},
{
label: 'p95',
data: data.p95,
borderColor: '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
fill: true,
},
{
label: 'p99',
data: data.p99,
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
fill: true,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
title: {
display: true,
text: 'Latency Percentiles Over Time',
},
legend: {
position: 'bottom',
},
tooltip: {
callbacks: {
label: function (context) {
return context.dataset.label + ': ' + context.parsed.y.toFixed(2) + ' ms';
},
},
},
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Latency (ms)',
},
},
},
},
});
} catch (e) {
console.error('Failed to create percentile chart:', e);
this.error = 'Failed to render percentile chart';
}
});
},
/**
* Render time series chart with proper cleanup and error handling
*/
renderTimeSeriesChart(data) {
const ctx = document.getElementById('timeSeriesChart');
if (!ctx) {
console.warn('timeSeriesChart canvas not found');
return;
}
// Destroy existing chart
if (this.charts.timeSeries) {
try {
this.charts.timeSeries.destroy();
} catch (e) {
console.warn('Failed to destroy time series chart:', e);
}
this.charts.timeSeries = null;
}
// Use requestAnimationFrame to ensure canvas is ready
requestAnimationFrame(() => {
try {
this.charts.timeSeries = new Chart(ctx, {
type: 'line',
data: {
labels: data.timestamps.map((t) => new Date(t).toLocaleTimeString()),
datasets: [
{
label: 'Total Requests',
data: data.request_count,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
yAxisID: 'y',
fill: true,
},
{
label: 'Error Rate (%)',
data: data.error_rate,
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
yAxisID: 'y1',
fill: true,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
title: {
display: true,
text: 'Request Rate & Error Rate',
},
legend: {
position: 'bottom',
},
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Requests',
},
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Error Rate (%)',
},
grid: {
drawOnChartArea: false,
},
},
},
},
});
} catch (e) {
console.error('Failed to create time series chart:', e);
this.error = 'Failed to render time series chart';
}
});
},
renderTopSlowTable(data) {
const tbody = document.querySelector('#topSlowTable tbody');
if (!tbody) return;
tbody.innerHTML = data.endpoints
.map(
(ep, idx) => `
<tr>
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-500">${idx + 1}</td>
<td class="px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-300">${ep.endpoint}</td>
<td class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">${ep.count}</td>
<td class="px-4 py-2 text-sm text-orange-600 font-medium">${ep.avg_duration_ms} ms</td>
<td class="px-4 py-2 text-sm text-red-600">${ep.max_duration_ms} ms</td>
</tr>
`
)
.join('');
},
renderTopVolumeTable(data) {
const tbody = document.querySelector('#topVolumeTable tbody');
if (!tbody) return;
tbody.innerHTML = data.endpoints
.map(
(ep, idx) => `
<tr>
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-500">${idx + 1}</td>
<td class="px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-300">${ep.endpoint}</td>
<td class="px-4 py-2 text-sm font-medium text-blue-600">${ep.count}</td>
<td class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">${ep.avg_duration_ms} ms</td>
</tr>
`
)
.join('');
},
renderTopErrorsTable(data) {
const tbody = document.querySelector('#topErrorsTable tbody');
if (!tbody) return;
tbody.innerHTML = data.endpoints
.map(
(ep, idx) => `
<tr>
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-500">${idx + 1}</td>
<td class="px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-300">${ep.endpoint}</td>
<td class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">${ep.total_count}</td>
<td class="px-4 py-2 text-sm text-red-600 font-medium">${ep.error_count}</td>
<td class="px-4 py-2 text-sm text-red-700 font-bold">${ep.error_rate}%</td>
</tr>
`
)
.join('');
},
/**
* Render heatmap chart with proper cleanup and error handling
*/
renderHeatmap(data) {
const ctx = document.getElementById('heatmapChart');
if (!ctx) {
console.warn('heatmapChart canvas not found');
return;
}
// Destroy existing chart
if (this.charts.heatmap) {
try {
this.charts.heatmap.destroy();
} catch (e) {
console.warn('Failed to destroy heatmap chart:', e);
}
this.charts.heatmap = null;
}
// Flatten the 2D heatmap data for Chart.js matrix
const heatmapData = [];
for (let y = 0; y < data.data.length; y++) {
for (let x = 0; x < data.data[y].length; x++) {
heatmapData.push({
x: x,
y: y,
v: data.data[y][x],
});
}
}
// Use requestAnimationFrame to ensure canvas is ready
requestAnimationFrame(() => {
try {
this.charts.heatmap = new Chart(ctx, {
type: 'scatter',
data: {
datasets: [
{
label: 'Request Count',
data: heatmapData,
backgroundColor: function (context) {
const value = context.raw.v;
const maxValue = Math.max(...heatmapData.map((d) => d.v));
const alpha = value / maxValue;
return `rgba(239, 68, 68, ${alpha})`;
},
pointRadius: 10,
pointHoverRadius: 12,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Latency Distribution Heatmap',
},
legend: {
display: false,
},
tooltip: {
callbacks: {
title: function (context) {
const timeLabel = data.time_labels[context[0].raw.x];
const latencyLabel = data.latency_labels[context[0].raw.y];
return `${timeLabel} - ${latencyLabel}`;
},
label: function (context) {
return `Requests: ${context.raw.v}`;
},
},
},
},
scales: {
x: {
type: 'linear',
position: 'bottom',
ticks: {
stepSize: 1,
callback: function (value) {
return data.time_labels[value] || '';
},
},
title: {
display: true,
text: 'Time',
},
},
y: {
type: 'linear',
ticks: {
stepSize: 1,
callback: function (value) {
return data.latency_labels[value] || '';
},
},
title: {
display: true,
text: 'Latency',
},
},
},
},
});
} catch (e) {
console.error('Failed to create heatmap chart:', e);
this.error = 'Failed to render heatmap chart';
}
});
},
/**
* Start auto-refresh with proper cleanup
*/
startAutoRefresh() {
// Clear any existing interval
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
}
// Refresh every 60 seconds, only if not already loading
this.autoRefreshInterval = setInterval(() => {
if (!this.loading) {
this.loadAllMetrics();
}
}, 60000);
},
/**
* Stop auto-refresh
*/
stopAutoRefresh() {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = null;
}
},
async applyFilters() {
await this.loadAllMetrics();
}
};
};
</script>
<div class="metrics-dashboard" x-data="createMetricsController()" x-init="init()" @destroy.window="cleanup()">
<!-- Controls -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>Time Range</label
>
<select
x-model="timeRange"
@change="applyFilters()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="1">Last 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>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>Interval</label
>
<select
x-model="interval"
@change="applyFilters()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="5">5 Minutes</option>
<option value="15">15 Minutes</option>
<option value="60" selected>1 Hour</option>
<option value="360">6 Hours</option>
<option value="1440">1 Day</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>Top N Limit</label
>
<select
x-model="limit"
@change="applyFilters()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="5">Top 5</option>
<option value="10" selected>Top 10</option>
<option value="20">Top 20</option>
<option value="50">Top 50</option>
</select>
</div>
<div class="flex items-end">
<button
@click="applyFilters()"
:disabled="loading"
class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
<span x-show="!loading">Refresh</span>
<span x-show="loading">Loading...</span>
</button>
</div>
</div>
<div x-show="error" class="mt-4 p-3 bg-red-100 text-red-700 rounded-lg" x-text="error"></div>
</div>
<!-- Charts Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Percentile Chart -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div style="height: 300px">
<canvas id="percentileChart"></canvas>
</div>
</div>
<!-- Time Series Chart -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div style="height: 300px">
<canvas id="timeSeriesChart"></canvas>
</div>
</div>
</div>
<!-- Heatmap -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 mb-6">
<div style="height: 400px">
<canvas id="heatmapChart"></canvas>
</div>
</div>
<!-- Top N Tables -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Top Slow Endpoints -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="px-4 py-3 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">📉 Slowest Endpoints</h3>
</div>
<div class="overflow-x-auto">
<table id="topSlowTable" class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
#
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
Endpoint
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
Count
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
Avg
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
Max
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200">
<!-- Populated by Alpine.js -->
</tbody>
</table>
</div>
</div>
<!-- Top Volume Endpoints -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="px-4 py-3 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">📊 Highest Volume</h3>
</div>
<div class="overflow-x-auto">
<table id="topVolumeTable" class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
#
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
Endpoint
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
Requests
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
Avg
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200">
<!-- Populated by Alpine.js -->
</tbody>
</table>
</div>
</div>
<!-- Top Error Endpoints -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="px-4 py-3 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">❌ Most Errors</h3>
</div>
<div class="overflow-x-auto">
<table id="topErrorsTable" class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
#
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
Endpoint
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
Total
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
Errors
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-200 uppercase tracking-wider"
>
Rate
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200">
<!-- Populated by Alpine.js -->
</tbody>
</table>
</div>
</div>
</div>
</div>