<!-- 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() {
// Use global registry for centralized cleanup
window.chartRegistry.destroyByPrefix('metrics-');
this.charts = {};
},
/**
* 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 {
// Check if we're still on the observability tab
const panel = document.getElementById('observability-panel');
if (panel && !panel.classList.contains('hidden')) {
this.startAutoRefresh();
// Refresh data when tab becomes visible
if (!this.loading) {
this.loadAllMetrics();
}
}
}
};
document.addEventListener('visibilitychange', this.visibilityHandler);
// Listen for tab leave event to stop auto-refresh and cleanup
this.leaveHandler = () => {
console.log('Metrics: received observability:leave event, stopping auto-refresh');
this.cleanup();
};
document.addEventListener('observability:leave', this.leaveHandler);
// 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);
}
if (this.leaveHandler) {
document.removeEventListener('observability:leave', this.leaveHandler);
}
},
/**
* 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 canvas = document.getElementById('percentileChart');
if (!canvas) {
console.warn('percentileChart canvas not found');
return;
}
// Check if canvas is visible before rendering
if (canvas.offsetParent === null) {
console.warn('percentileChart canvas is hidden, deferring render');
return;
}
// Destroy existing chart via global registry
window.chartRegistry.destroy('metrics-percentile');
// Use requestAnimationFrame to ensure canvas is ready
requestAnimationFrame(() => {
try {
// Double-check visibility after animation frame
if (canvas.offsetParent === null) {
console.warn('percentileChart canvas became hidden, aborting render');
return;
}
// Verify canvas is still in DOM and get valid context
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get 2d context for percentileChart');
return;
}
const defaults = getChartDefaults();
const chart = 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',
color: defaults.titleColor,
},
legend: {
position: 'bottom',
labels: {
color: defaults.color,
},
},
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)',
color: defaults.titleColor,
},
ticks: {
color: defaults.tickColor,
},
grid: {
color: defaults.gridColor,
},
},
x: {
ticks: {
color: defaults.tickColor,
},
grid: {
color: defaults.gridColor,
},
},
},
},
});
// Register with global registry
window.chartRegistry.register('metrics-percentile', chart);
this.charts.percentile = chart;
} 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 canvas = document.getElementById('timeSeriesChart');
if (!canvas) {
console.warn('timeSeriesChart canvas not found');
return;
}
// Check if canvas is visible before rendering
if (canvas.offsetParent === null) {
console.warn('timeSeriesChart canvas is hidden, deferring render');
return;
}
// Destroy existing chart via global registry
window.chartRegistry.destroy('metrics-timeseries');
// Use requestAnimationFrame to ensure canvas is ready
requestAnimationFrame(() => {
try {
// Double-check visibility after animation frame
if (canvas.offsetParent === null) {
console.warn('timeSeriesChart canvas became hidden, aborting render');
return;
}
// Verify canvas is still in DOM and get valid context
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get 2d context for timeSeriesChart');
return;
}
const defaults = getChartDefaults();
const chart = 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',
color: defaults.titleColor,
},
legend: {
position: 'bottom',
labels: {
color: defaults.color,
},
},
},
scales: {
x: {
ticks: {
color: defaults.tickColor,
},
grid: {
color: defaults.gridColor,
},
},
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Requests',
color: defaults.titleColor,
},
ticks: {
color: defaults.tickColor,
},
grid: {
color: defaults.gridColor,
},
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Error Rate (%)',
color: defaults.titleColor,
},
ticks: {
color: defaults.tickColor,
},
grid: {
drawOnChartArea: false,
},
},
},
},
});
// Register with global registry
window.chartRegistry.register('metrics-timeseries', chart);
this.charts.timeSeries = chart;
} 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 class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-400">${idx + 1}</td>
<td class="px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-200">${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 dark:text-orange-400 font-medium">${ep.avg_duration_ms} ms</td>
<td class="px-4 py-2 text-sm text-red-600 dark:text-red-400">${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 class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-400">${idx + 1}</td>
<td class="px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-200">${ep.endpoint}</td>
<td class="px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400">${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 class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-400">${idx + 1}</td>
<td class="px-4 py-2 text-sm font-mono text-gray-900 dark:text-gray-200">${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 dark:text-red-400 font-medium">${ep.error_count}</td>
<td class="px-4 py-2 text-sm text-red-700 dark:text-red-300 font-bold">${ep.error_rate}%</td>
</tr>
`
)
.join('');
},
/**
* Render heatmap chart with proper cleanup and error handling
*/
renderHeatmap(data) {
const canvas = document.getElementById('heatmapChart');
if (!canvas) {
console.warn('heatmapChart canvas not found');
return;
}
// Check if canvas is visible before rendering
if (canvas.offsetParent === null) {
console.warn('heatmapChart canvas is hidden, deferring render');
return;
}
// Destroy existing chart via global registry
window.chartRegistry.destroy('metrics-heatmap');
// 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 {
// Double-check visibility after animation frame
if (canvas.offsetParent === null) {
console.warn('heatmapChart canvas became hidden, aborting render');
return;
}
// Verify canvas is still in DOM and get valid context
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get 2d context for heatmapChart');
return;
}
const defaults = getChartDefaults();
const chart = 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',
color: defaults.titleColor,
},
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,
color: defaults.tickColor,
callback: function (value) {
return data.time_labels[value] || '';
},
},
title: {
display: true,
text: 'Time',
color: defaults.titleColor,
},
grid: {
color: defaults.gridColor,
},
},
y: {
type: 'linear',
ticks: {
stepSize: 1,
color: defaults.tickColor,
callback: function (value) {
return data.latency_labels[value] || '';
},
},
title: {
display: true,
text: 'Latency',
color: defaults.titleColor,
},
grid: {
color: defaults.gridColor,
},
},
},
},
});
// Register with global registry
window.chartRegistry.register('metrics-heatmap', chart);
this.charts.heatmap = chart;
} 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-sm dark:shadow-gray-900/30">
<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 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
>
<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 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
>
<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 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
>
<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 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 transition-colors"
>
<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 dark:bg-red-900/30 text-red-700 dark:text-red-400 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-sm dark:shadow-gray-900/30 p-4">
<div class="h-[300px]">
<canvas id="percentileChart"></canvas>
</div>
</div>
<!-- Time Series Chart -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm dark:shadow-gray-900/30 p-4">
<div class="h-[300px]">
<canvas id="timeSeriesChart"></canvas>
</div>
</div>
</div>
<!-- Heatmap -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm dark:shadow-gray-900/30 p-4 mb-6">
<div class="h-[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-sm dark:shadow-gray-900/30">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">📉 Slowest Endpoints</h3>
</div>
<div class="overflow-x-auto">
<table id="topSlowTable" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
#
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Endpoint
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Count
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Avg
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Max
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<!-- Populated by Alpine.js -->
</tbody>
</table>
</div>
</div>
<!-- Top Volume Endpoints -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm dark:shadow-gray-900/30">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">📊 Highest Volume</h3>
</div>
<div class="overflow-x-auto">
<table id="topVolumeTable" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
#
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Endpoint
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Requests
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Avg
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<!-- Populated by Alpine.js -->
</tbody>
</table>
</div>
</div>
<!-- Top Error Endpoints -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm dark:shadow-gray-900/30">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">❌ Most Errors</h3>
</div>
<div class="overflow-x-auto">
<table id="topErrorsTable" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
#
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Endpoint
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Total
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Errors
</th>
<th
class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Rate
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<!-- Populated by Alpine.js -->
</tbody>
</table>
</div>
</div>
</div>
</div>