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>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
<nav class="admin-nav">
|
||||
<a href="{{ url_for('admin.dashboard') }}">Dashboard</a>
|
||||
<a href="{{ url_for('admin.new_note_form') }}">New Note</a>
|
||||
<a href="{{ url_for('admin.metrics_dashboard') }}">Metrics</a>
|
||||
<form action="{{ url_for('auth.logout') }}" method="POST" class="logout-form">
|
||||
<button type="submit" class="button button-secondary">Logout</button>
|
||||
</form>
|
||||
|
||||
398
templates/admin/metrics_dashboard.html
Normal file
398
templates/admin/metrics_dashboard.html
Normal file
@@ -0,0 +1,398 @@
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user