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>
399 lines
12 KiB
HTML
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 %}
|