Files
StarPunk/templates/admin/metrics_dashboard.html
Phil Skentelbery 07fff01fab feat: Complete v1.1.1 Phases 2 & 3 - Enhancements and Polish
Phase 2 - Enhancements:
- Add performance monitoring infrastructure with MetricsBuffer
- Implement three-tier health checks (/health, /health?detailed, /admin/health)
- Enhance search with FTS5 fallback and XSS-safe highlighting
- Add Unicode slug generation with timestamp fallback
- Expose database pool statistics via /admin/metrics
- Create missing error templates (400, 401, 403, 405, 503)

Phase 3 - Polish:
- Implement RSS streaming optimization (memory O(n) → O(1))
- Add admin metrics dashboard with htmx and Chart.js
- Fix flaky migration race condition tests
- Create comprehensive operational documentation
- Add upgrade guide and troubleshooting guide

Testing: 632 tests passing, zero flaky tests
Documentation: Complete operational guides
Security: All security reviews passed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 20:10:41 -07:00

399 lines
12 KiB
HTML

{% extends "admin/base.html" %}
{% block title %}Metrics Dashboard - StarPunk Admin{% endblock %}
{% block head %}
{{ super() }}
<!-- Chart.js from CDN for visualizations -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<!-- htmx for auto-refresh -->
<script src="https://unpkg.com/htmx.org@1.9.10" crossorigin="anonymous"></script>
<style>
.metrics-dashboard {
max-width: 1200px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.metric-card {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.metric-card h3 {
margin-top: 0;
font-size: 1.1em;
color: #333;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
margin-bottom: 15px;
}
.metric-value {
font-size: 2em;
font-weight: bold;
color: #007bff;
margin: 10px 0;
}
.metric-label {
color: #666;
font-size: 0.9em;
margin-bottom: 5px;
}
.metric-detail {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.metric-detail:last-child {
border-bottom: none;
}
.metric-detail-label {
color: #666;
}
.metric-detail-value {
font-weight: bold;
}
.chart-container {
position: relative;
height: 300px;
margin-top: 20px;
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.status-healthy {
background-color: #28a745;
}
.status-warning {
background-color: #ffc107;
}
.status-error {
background-color: #dc3545;
}
.refresh-info {
color: #666;
font-size: 0.9em;
text-align: center;
margin-top: 20px;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
}
.no-js-message {
display: none;
background-color: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
noscript .no-js-message {
display: block;
}
</style>
{% endblock %}
{% block admin_content %}
<div class="metrics-dashboard">
<h2>Metrics Dashboard</h2>
<noscript>
<div class="no-js-message">
Note: Auto-refresh and charts require JavaScript. Data is displayed below in text format.
</div>
</noscript>
<!-- Auto-refresh container -->
<div hx-get="{{ url_for('admin.metrics') }}" hx-trigger="every 10s" hx-swap="none" hx-on::after-request="updateDashboard(event)"></div>
<!-- Database Pool Statistics -->
<div class="metrics-grid">
<div class="metric-card">
<h3>Database Connection Pool</h3>
<div class="metric-detail">
<span class="metric-detail-label">Active Connections</span>
<span class="metric-detail-value" id="pool-active">{{ pool.active_connections|default(0) }}</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Idle Connections</span>
<span class="metric-detail-value" id="pool-idle">{{ pool.idle_connections|default(0) }}</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Total Connections</span>
<span class="metric-detail-value" id="pool-total">{{ pool.total_connections|default(0) }}</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Pool Size</span>
<span class="metric-detail-value" id="pool-size">{{ pool.pool_size|default(5) }}</span>
</div>
</div>
<div class="metric-card">
<h3>Database Operations</h3>
<div class="metric-detail">
<span class="metric-detail-label">Total Queries</span>
<span class="metric-detail-value" id="db-total">{{ metrics.database.count|default(0) }}</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Average Time</span>
<span class="metric-detail-value" id="db-avg">{{ "%.2f"|format(metrics.database.avg|default(0)) }} ms</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Min Time</span>
<span class="metric-detail-value" id="db-min">{{ "%.2f"|format(metrics.database.min|default(0)) }} ms</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Max Time</span>
<span class="metric-detail-value" id="db-max">{{ "%.2f"|format(metrics.database.max|default(0)) }} ms</span>
</div>
</div>
<div class="metric-card">
<h3>HTTP Requests</h3>
<div class="metric-detail">
<span class="metric-detail-label">Total Requests</span>
<span class="metric-detail-value" id="http-total">{{ metrics.http.count|default(0) }}</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Average Time</span>
<span class="metric-detail-value" id="http-avg">{{ "%.2f"|format(metrics.http.avg|default(0)) }} ms</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Min Time</span>
<span class="metric-detail-value" id="http-min">{{ "%.2f"|format(metrics.http.min|default(0)) }} ms</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Max Time</span>
<span class="metric-detail-value" id="http-max">{{ "%.2f"|format(metrics.http.max|default(0)) }} ms</span>
</div>
</div>
<div class="metric-card">
<h3>Template Rendering</h3>
<div class="metric-detail">
<span class="metric-detail-label">Total Renders</span>
<span class="metric-detail-value" id="render-total">{{ metrics.render.count|default(0) }}</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Average Time</span>
<span class="metric-detail-value" id="render-avg">{{ "%.2f"|format(metrics.render.avg|default(0)) }} ms</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Min Time</span>
<span class="metric-detail-value" id="render-min">{{ "%.2f"|format(metrics.render.min|default(0)) }} ms</span>
</div>
<div class="metric-detail">
<span class="metric-detail-label">Max Time</span>
<span class="metric-detail-value" id="render-max">{{ "%.2f"|format(metrics.render.max|default(0)) }} ms</span>
</div>
</div>
</div>
<!-- Charts -->
<div class="metrics-grid">
<div class="metric-card">
<h3>Connection Pool Usage</h3>
<div class="chart-container">
<canvas id="poolChart"></canvas>
</div>
</div>
<div class="metric-card">
<h3>Performance Overview</h3>
<div class="chart-container">
<canvas id="performanceChart"></canvas>
</div>
</div>
</div>
<div class="refresh-info">
Auto-refresh every 10 seconds (requires JavaScript)
</div>
</div>
<script>
// Initialize charts with current data
let poolChart, performanceChart;
function initCharts() {
// Pool usage chart (doughnut)
const poolCtx = document.getElementById('poolChart');
if (poolCtx && !poolChart) {
poolChart = new Chart(poolCtx, {
type: 'doughnut',
data: {
labels: ['Active', 'Idle'],
datasets: [{
data: [
{{ pool.active_connections|default(0) }},
{{ pool.idle_connections|default(0) }}
],
backgroundColor: ['#007bff', '#6c757d'],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
},
title: {
display: true,
text: 'Connection Distribution'
}
}
}
});
}
// Performance chart (bar)
const perfCtx = document.getElementById('performanceChart');
if (perfCtx && !performanceChart) {
performanceChart = new Chart(perfCtx, {
type: 'bar',
data: {
labels: ['Database', 'HTTP', 'Render'],
datasets: [{
label: 'Average Time (ms)',
data: [
{{ metrics.database.avg|default(0) }},
{{ metrics.http.avg|default(0) }},
{{ metrics.render.avg|default(0) }}
],
backgroundColor: ['#007bff', '#28a745', '#ffc107'],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Milliseconds'
}
}
},
plugins: {
legend: {
display: false
},
title: {
display: true,
text: 'Average Response Times'
}
}
}
});
}
}
// Update dashboard with new data from htmx
function updateDashboard(event) {
if (!event.detail.xhr) return;
try {
const data = JSON.parse(event.detail.xhr.responseText);
// Update pool statistics
if (data.database && data.database.pool) {
const pool = data.database.pool;
document.getElementById('pool-active').textContent = pool.active_connections || 0;
document.getElementById('pool-idle').textContent = pool.idle_connections || 0;
document.getElementById('pool-total').textContent = pool.total_connections || 0;
document.getElementById('pool-size').textContent = pool.pool_size || 5;
// Update pool chart
if (poolChart) {
poolChart.data.datasets[0].data = [
pool.active_connections || 0,
pool.idle_connections || 0
];
poolChart.update();
}
}
// Update performance metrics
if (data.performance) {
const perf = data.performance;
// Database
if (perf.database) {
document.getElementById('db-total').textContent = perf.database.count || 0;
document.getElementById('db-avg').textContent = (perf.database.avg || 0).toFixed(2) + ' ms';
document.getElementById('db-min').textContent = (perf.database.min || 0).toFixed(2) + ' ms';
document.getElementById('db-max').textContent = (perf.database.max || 0).toFixed(2) + ' ms';
}
// HTTP
if (perf.http) {
document.getElementById('http-total').textContent = perf.http.count || 0;
document.getElementById('http-avg').textContent = (perf.http.avg || 0).toFixed(2) + ' ms';
document.getElementById('http-min').textContent = (perf.http.min || 0).toFixed(2) + ' ms';
document.getElementById('http-max').textContent = (perf.http.max || 0).toFixed(2) + ' ms';
}
// Render
if (perf.render) {
document.getElementById('render-total').textContent = perf.render.count || 0;
document.getElementById('render-avg').textContent = (perf.render.avg || 0).toFixed(2) + ' ms';
document.getElementById('render-min').textContent = (perf.render.min || 0).toFixed(2) + ' ms';
document.getElementById('render-max').textContent = (perf.render.max || 0).toFixed(2) + ' ms';
}
// Update performance chart
if (performanceChart && perf.database && perf.http && perf.render) {
performanceChart.data.datasets[0].data = [
perf.database.avg || 0,
perf.http.avg || 0,
perf.render.avg || 0
];
performanceChart.update();
}
}
} catch (e) {
console.error('Error updating dashboard:', e);
}
}
// Initialize charts when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCharts);
} else {
initCharts();
}
</script>
{% endblock %}