circus/crates/server/templates/metrics.html
NotAShelf 83071514a3
fc-server: add metrics visualization dashboard
Adds a /metrics page with Chart.js charts, which requires an annoying
CDN fetch but until I figure out a good method of fetching things during
build it's our best bet. I've pinned the thing so it's probably good.

The page displays build counts, duration percentiles and system
distribution. Time range and project filters are included, and the
metrics page is linked from navigation.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I99059594c29a9b35d2fd4d140628d6f46a6a6964
2026-02-14 18:08:21 +03:00

407 lines
9.7 KiB
HTML

{% extends "base.html" %}
{% block title %}Metrics - FC CI{% endblock %}
{% block auth %}
{% if !auth_name.is_empty() %}
<span class="auth-user">{{ auth_name }}</span>
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
{% else %}
<a href="/login">Login</a>
{% endif %}
{% endblock %}
{% block content %}
<h1>Build Metrics Dashboard</h1>
{% if is_admin %}
<p class="admin-notice">Showing system-wide metrics. Filter by project below to view specific metrics.</p>
{% endif %}
<div class="metrics-controls">
<label for="time-range">Time Range:</label>
<select id="time-range">
<option value="24">Last 24 hours</option>
<option value="48">Last 48 hours</option>
<option value="168">Last 7 days</option>
</select>
<label for="project-filter">Project:</label>
<select id="project-filter">
<option value="">All Projects</option>
</select>
</div>
<div class="metrics-grid">
<div class="metric-card">
<h3>Build Counts Over Time</h3>
<canvas id="builds-chart"></canvas>
</div>
<div class="metric-card">
<h3>Build Duration Percentiles</h3>
<canvas id="duration-chart"></canvas>
</div>
<div class="metric-card">
<h3>Builds by System</h3>
<canvas id="systems-chart"></canvas>
</div>
<div class="metric-card">
<h3>Success Rate Trend</h3>
<canvas id="success-rate-chart"></canvas>
</div>
</div>
{% endblock %}
{% block scripts %}
<script
src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"
integrity="sha384-jb8JQMbMoBUzgWatfe6COACi2ljcDdZQ2OxczGA3bGNeWe+6DChMTBJemed7ZnvJ"
crossorigin="anonymous">
</script>
<script>
// Configuration
const API_BASE = '/api/v1/metrics';
let currentHours = 24;
let currentProjectId = null;
// Chart instances
let buildsChart, durationChart, systemsChart, successRateChart;
// Initialize charts on page load
document.addEventListener('DOMContentLoaded', function() {
initCharts();
loadData();
setupEventListeners();
loadProjects();
});
function setupEventListeners() {
document.getElementById('time-range').addEventListener('change', function(e) {
currentHours = parseInt(e.target.value);
loadData();
});
document.getElementById('project-filter').addEventListener('change', function(e) {
currentProjectId = e.target.value || null;
loadData();
});
}
async function loadProjects() {
try {
const response = await fetch('/api/v1/projects');
const data = await response.json();
const select = document.getElementById('project-filter');
data.data.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.name;
select.appendChild(option);
});
} catch (error) {
console.error('Failed to load projects:', error);
}
}
async function loadData() {
const params = new URLSearchParams();
params.set('hours', currentHours);
params.set('bucket', 60);
if (currentProjectId) {
params.set('project_id', currentProjectId);
}
try {
// Load build stats
const buildsResponse = await fetch(`${API_BASE}/timeseries/builds?${params}`);
const buildsData = await buildsResponse.json();
updateBuildsChart(buildsData);
updateSuccessRateChart(buildsData);
// Load duration percentiles
const durationResponse = await fetch(`${API_BASE}/timeseries/duration?${params}`);
const durationData = await durationResponse.json();
updateDurationChart(durationData);
// Load system distribution
const systemsResponse = await fetch(`${API_BASE}/systems?${params}`);
const systemsData = await systemsResponse.json();
updateSystemsChart(systemsData);
} catch (error) {
console.error('Failed to load metrics data:', error);
showError(document.querySelector('.metrics-grid'), 'Failed to load metrics data. Please try again later.');
}
}
function initCharts() {
Chart.defaults.font.family = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
Chart.defaults.color = '#666';
// Build counts chart
const buildsCtx = document.getElementById('builds-chart').getContext('2d');
buildsChart = new Chart(buildsCtx, {
type: 'bar',
data: {
labels: [],
datasets: [
{
label: 'Successful',
data: [],
backgroundColor: '#28a745',
borderRadius: 4
},
{
label: 'Failed',
data: [],
backgroundColor: '#dc3545',
borderRadius: 4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: { display: false }
},
y: {
stacked: true,
beginAtZero: true,
ticks: { stepSize: 1 }
}
},
plugins: {
legend: { position: 'top' }
}
}
});
// Duration percentiles chart
const durationCtx = document.getElementById('duration-chart').getContext('2d');
durationChart = new Chart(durationCtx, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'P50 (Median)',
data: [],
borderColor: '#007bff',
backgroundColor: 'rgba(0, 123, 255, 0.1)',
fill: true,
tension: 0.3
},
{
label: 'P95',
data: [],
borderColor: '#ffc107',
backgroundColor: 'rgba(255, 193, 7, 0.1)',
fill: true,
tension: 0.3
},
{
label: 'P99',
data: [],
borderColor: '#dc3545',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
fill: true,
tension: 0.3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Duration (seconds)'
}
}
},
plugins: {
legend: { position: 'top' }
}
}
});
// Systems chart
const systemsCtx = document.getElementById('systems-chart').getContext('2d');
systemsChart = new Chart(systemsCtx, {
type: 'doughnut',
data: {
labels: [],
datasets: [{
data: [],
backgroundColor: [
'#007bff',
'#28a745',
'#ffc107',
'#dc3545',
'#6f42c1',
'#20c997',
'#fd7e14',
'#e83e8c'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right' }
}
}
});
// Success rate chart
const successCtx = document.getElementById('success-rate-chart').getContext('2d');
successRateChart = new Chart(successCtx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Success Rate (%)',
data: [],
borderColor: '#28a745',
backgroundColor: 'rgba(40, 167, 69, 0.1)',
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: function(value) {
return value + '%';
}
}
}
},
plugins: {
legend: { position: 'top' }
}
}
});
}
function updateBuildsChart(data) {
const labels = data.timestamps.map(t => {
const date = new Date(t);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
});
const successful = data.total.map((total, i) => total - data.failed[i]);
buildsChart.data.labels = labels;
buildsChart.data.datasets[0].data = successful;
buildsChart.data.datasets[1].data = data.failed;
buildsChart.update();
}
function updateDurationChart(data) {
const labels = data.timestamps.map(t => {
const date = new Date(t);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
});
durationChart.data.labels = labels;
durationChart.data.datasets[0].data = data.p50;
durationChart.data.datasets[1].data = data.p95;
durationChart.data.datasets[2].data = data.p99;
durationChart.update();
}
function updateSystemsChart(data) {
systemsChart.data.labels = data.systems;
systemsChart.data.datasets[0].data = data.counts;
systemsChart.update();
}
function updateSuccessRateChart(data) {
const labels = data.timestamps.map(t => {
const date = new Date(t);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
});
const successRates = data.total.map((total, i) => {
if (total === 0) return 0;
return ((total - data.failed[i]) / total * 100).toFixed(1);
});
successRateChart.data.labels = labels;
successRateChart.data.datasets[0].data = successRates;
successRateChart.update();
}
</script>
<style>
.metrics-controls {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.metrics-controls label {
font-weight: 600;
color: #495057;
}
.metrics-controls select {
padding: 0.375rem 0.75rem;
border: 1px solid #ced4da;
border-radius: 4px;
background-color: #fff;
font-size: 0.875rem;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 1.5rem;
}
.metric-card {
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.metric-card h3 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.1rem;
color: #212529;
}
.metric-card canvas {
max-height: 300px;
}
@media (max-width: 768px) {
.metrics-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}