Add comprehensive database integration, authentication, and admin dashboard
This commit introduces major enhancements to Talk2Me: ## Database Integration - PostgreSQL support with SQLAlchemy ORM - Redis integration for caching and real-time analytics - Automated database initialization scripts - Migration support infrastructure ## User Authentication System - JWT-based API authentication - Session-based web authentication - API key authentication for programmatic access - User roles and permissions (admin/user) - Login history and session tracking - Rate limiting per user with customizable limits ## Admin Dashboard - Real-time analytics and monitoring - User management interface (create, edit, delete users) - System health monitoring - Request/error tracking - Language pair usage statistics - Performance metrics visualization ## Key Features - Dual authentication support (token + user accounts) - Graceful fallback for missing services - Non-blocking analytics middleware - Comprehensive error handling - Session management with security features ## Bug Fixes - Fixed rate limiting bypass for admin routes - Added missing email validation method - Improved error handling for missing database tables - Fixed session-based authentication for API endpoints 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
192
admin/static/css/admin.css
Normal file
192
admin/static/css/admin.css
Normal file
@@ -0,0 +1,192 @@
|
||||
/* Admin Dashboard Styles */
|
||||
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
padding-top: 56px; /* For fixed navbar */
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e3e6f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.badge {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.badge.bg-success {
|
||||
background-color: #1cc88a !important;
|
||||
}
|
||||
|
||||
.badge.bg-warning {
|
||||
background-color: #f6c23e !important;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.badge.bg-danger {
|
||||
background-color: #e74a3b !important;
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Login Page */
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.card-body h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
border-radius: 0.25rem !important;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading spinners */
|
||||
.spinner-border-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* Error list */
|
||||
.error-item {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.error-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.error-type {
|
||||
font-weight: 600;
|
||||
color: #e74a3b;
|
||||
}
|
||||
|
||||
.error-time {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.toast {
|
||||
background-color: white;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.updating {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #2a2a2a;
|
||||
border-bottom-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.table {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.table-striped tbody tr:nth-of-type(odd) {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: #3a3a3a;
|
||||
border-color: #4a4a4a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Performance optimization */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 40vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
519
admin/static/js/admin.js
Normal file
519
admin/static/js/admin.js
Normal file
@@ -0,0 +1,519 @@
|
||||
// Admin Dashboard JavaScript
|
||||
|
||||
// Global variables
|
||||
let charts = {};
|
||||
let currentTimeframe = 'minute';
|
||||
let eventSource = null;
|
||||
|
||||
// Chart.js default configuration
|
||||
Chart.defaults.responsive = true;
|
||||
Chart.defaults.maintainAspectRatio = false;
|
||||
|
||||
// Initialize dashboard
|
||||
function initializeDashboard() {
|
||||
// Initialize all charts
|
||||
initializeCharts();
|
||||
|
||||
// Set up event handlers
|
||||
setupEventHandlers();
|
||||
}
|
||||
|
||||
// Initialize all charts
|
||||
function initializeCharts() {
|
||||
// Request Volume Chart
|
||||
const requestCtx = document.getElementById('requestChart').getContext('2d');
|
||||
charts.request = new Chart(requestCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Requests',
|
||||
data: [],
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
tension: 0.1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Language Pairs Chart
|
||||
const languageCtx = document.getElementById('languageChart').getContext('2d');
|
||||
charts.language = new Chart(languageCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#FF6384',
|
||||
'#36A2EB',
|
||||
'#FFCE56',
|
||||
'#4BC0C0',
|
||||
'#9966FF',
|
||||
'#FF9F40'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Operations Chart
|
||||
const operationsCtx = document.getElementById('operationsChart').getContext('2d');
|
||||
charts.operations = new Chart(operationsCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Translations',
|
||||
data: [],
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.8)'
|
||||
},
|
||||
{
|
||||
label: 'Transcriptions',
|
||||
data: [],
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.8)'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Response Time Chart
|
||||
const responseCtx = document.getElementById('responseTimeChart').getContext('2d');
|
||||
charts.responseTime = new Chart(responseCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['Translation', 'Transcription', 'TTS'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Average',
|
||||
data: [],
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)'
|
||||
},
|
||||
{
|
||||
label: 'P95',
|
||||
data: [],
|
||||
borderColor: 'rgb(255, 206, 86)',
|
||||
backgroundColor: 'rgba(255, 206, 86, 0.2)'
|
||||
},
|
||||
{
|
||||
label: 'P99',
|
||||
data: [],
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.2)'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Response Time (ms)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Error Type Chart
|
||||
const errorCtx = document.getElementById('errorTypeChart').getContext('2d');
|
||||
charts.errorType = new Chart(errorCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#e74a3b',
|
||||
'#f6c23e',
|
||||
'#4e73df',
|
||||
'#1cc88a',
|
||||
'#36b9cc',
|
||||
'#858796'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load overview statistics
|
||||
function loadOverviewStats() {
|
||||
$.ajax({
|
||||
url: '/admin/api/stats/overview',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
// Update cards
|
||||
$('#total-requests').text(data.requests.total.toLocaleString());
|
||||
$('#today-requests').text(data.requests.today.toLocaleString());
|
||||
$('#active-sessions').text(data.active_sessions);
|
||||
$('#error-rate').text(data.error_rate + '%');
|
||||
$('#cache-hit-rate').text(data.cache_hit_rate + '%');
|
||||
|
||||
// Update system health
|
||||
updateSystemHealth(data.system_health);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load overview stats:', error);
|
||||
showToast('Failed to load overview statistics', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update system health indicators
|
||||
function updateSystemHealth(health) {
|
||||
// Redis status
|
||||
const redisStatus = $('#redis-status');
|
||||
redisStatus.removeClass('bg-success bg-warning bg-danger');
|
||||
if (health.redis === 'healthy') {
|
||||
redisStatus.addClass('bg-success').text('Healthy');
|
||||
} else if (health.redis === 'not_configured') {
|
||||
redisStatus.addClass('bg-warning').text('Not Configured');
|
||||
} else {
|
||||
redisStatus.addClass('bg-danger').text('Unhealthy');
|
||||
}
|
||||
|
||||
// PostgreSQL status
|
||||
const pgStatus = $('#postgresql-status');
|
||||
pgStatus.removeClass('bg-success bg-warning bg-danger');
|
||||
if (health.postgresql === 'healthy') {
|
||||
pgStatus.addClass('bg-success').text('Healthy');
|
||||
} else if (health.postgresql === 'not_configured') {
|
||||
pgStatus.addClass('bg-warning').text('Not Configured');
|
||||
} else {
|
||||
pgStatus.addClass('bg-danger').text('Unhealthy');
|
||||
}
|
||||
|
||||
// ML services status (check via main app health endpoint)
|
||||
$.ajax({
|
||||
url: '/health/detailed',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
const mlStatus = $('#ml-status');
|
||||
mlStatus.removeClass('bg-success bg-warning bg-danger');
|
||||
|
||||
if (data.components.whisper.status === 'healthy' &&
|
||||
data.components.tts.status === 'healthy') {
|
||||
mlStatus.addClass('bg-success').text('Healthy');
|
||||
} else if (data.status === 'degraded') {
|
||||
mlStatus.addClass('bg-warning').text('Degraded');
|
||||
} else {
|
||||
mlStatus.addClass('bg-danger').text('Unhealthy');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load request chart data
|
||||
function loadRequestChart(timeframe) {
|
||||
currentTimeframe = timeframe;
|
||||
|
||||
// Update button states
|
||||
$('.btn-group button').removeClass('active');
|
||||
$(`button[onclick="updateRequestChart('${timeframe}')"]`).addClass('active');
|
||||
|
||||
$.ajax({
|
||||
url: `/admin/api/stats/requests/${timeframe}`,
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
charts.request.data.labels = data.labels;
|
||||
charts.request.data.datasets[0].data = data.data;
|
||||
charts.request.update();
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load request chart:', error);
|
||||
showToast('Failed to load request data', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update request chart
|
||||
function updateRequestChart(timeframe) {
|
||||
loadRequestChart(timeframe);
|
||||
}
|
||||
|
||||
// Load operation statistics
|
||||
function loadOperationStats() {
|
||||
$.ajax({
|
||||
url: '/admin/api/stats/operations',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
// Update operations chart
|
||||
charts.operations.data.labels = data.translations.labels;
|
||||
charts.operations.data.datasets[0].data = data.translations.data;
|
||||
charts.operations.data.datasets[1].data = data.transcriptions.data;
|
||||
charts.operations.update();
|
||||
|
||||
// Update language pairs chart
|
||||
const langPairs = Object.entries(data.language_pairs)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 6); // Top 6 language pairs
|
||||
|
||||
charts.language.data.labels = langPairs.map(pair => pair[0]);
|
||||
charts.language.data.datasets[0].data = langPairs.map(pair => pair[1]);
|
||||
charts.language.update();
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load operation stats:', error);
|
||||
showToast('Failed to load operation data', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load error statistics
|
||||
function loadErrorStats() {
|
||||
$.ajax({
|
||||
url: '/admin/api/stats/errors',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
// Update error type chart
|
||||
const errorTypes = Object.entries(data.error_types)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 6);
|
||||
|
||||
charts.errorType.data.labels = errorTypes.map(type => type[0]);
|
||||
charts.errorType.data.datasets[0].data = errorTypes.map(type => type[1]);
|
||||
charts.errorType.update();
|
||||
|
||||
// Update recent errors list
|
||||
updateRecentErrors(data.recent_errors);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load error stats:', error);
|
||||
showToast('Failed to load error data', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update recent errors list
|
||||
function updateRecentErrors(errors) {
|
||||
const errorsList = $('#recent-errors-list');
|
||||
|
||||
if (errors.length === 0) {
|
||||
errorsList.html('<p class="text-muted text-center">No recent errors</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
errors.forEach(error => {
|
||||
const time = new Date(error.time).toLocaleString();
|
||||
html += `
|
||||
<div class="error-item">
|
||||
<div class="error-type">${error.type}</div>
|
||||
<div class="text-muted small">${error.endpoint}</div>
|
||||
<div>${error.message}</div>
|
||||
<div class="error-time">${time}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
errorsList.html(html);
|
||||
}
|
||||
|
||||
// Load performance statistics
|
||||
function loadPerformanceStats() {
|
||||
$.ajax({
|
||||
url: '/admin/api/stats/performance',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
// Update response time chart
|
||||
const operations = ['translation', 'transcription', 'tts'];
|
||||
const avgData = operations.map(op => data.response_times[op].avg);
|
||||
const p95Data = operations.map(op => data.response_times[op].p95);
|
||||
const p99Data = operations.map(op => data.response_times[op].p99);
|
||||
|
||||
charts.responseTime.data.datasets[0].data = avgData;
|
||||
charts.responseTime.data.datasets[1].data = p95Data;
|
||||
charts.responseTime.data.datasets[2].data = p99Data;
|
||||
charts.responseTime.update();
|
||||
|
||||
// Update performance table
|
||||
updatePerformanceTable(data.response_times);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load performance stats:', error);
|
||||
showToast('Failed to load performance data', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update performance table
|
||||
function updatePerformanceTable(responseData) {
|
||||
const tbody = $('#performance-table');
|
||||
let html = '';
|
||||
|
||||
const operations = {
|
||||
'translation': 'Translation',
|
||||
'transcription': 'Transcription',
|
||||
'tts': 'Text-to-Speech'
|
||||
};
|
||||
|
||||
for (const [key, label] of Object.entries(operations)) {
|
||||
const data = responseData[key];
|
||||
html += `
|
||||
<tr>
|
||||
<td>${label}</td>
|
||||
<td>${data.avg || '-'}</td>
|
||||
<td>${data.p95 || '-'}</td>
|
||||
<td>${data.p99 || '-'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
tbody.html(html);
|
||||
}
|
||||
|
||||
// Start real-time updates
|
||||
function startRealtimeUpdates() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
eventSource = new EventSource('/admin/api/stream/updates');
|
||||
|
||||
eventSource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Update real-time metrics
|
||||
if (data.requests_per_minute !== undefined) {
|
||||
$('#requests-per-minute').text(data.requests_per_minute);
|
||||
}
|
||||
|
||||
if (data.active_sessions !== undefined) {
|
||||
$('#active-sessions').text(data.active_sessions);
|
||||
}
|
||||
|
||||
// Update last update time
|
||||
$('#last-update').text('Just now');
|
||||
|
||||
// Show update indicator
|
||||
$('#update-status').text('Connected').removeClass('text-danger').addClass('text-success');
|
||||
};
|
||||
|
||||
eventSource.onerror = function(error) {
|
||||
console.error('EventSource error:', error);
|
||||
$('#update-status').text('Disconnected').removeClass('text-success').addClass('text-danger');
|
||||
|
||||
// Reconnect after 5 seconds
|
||||
setTimeout(startRealtimeUpdates, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
// Export data function
|
||||
function exportData(dataType) {
|
||||
window.location.href = `/admin/api/export/${dataType}`;
|
||||
}
|
||||
|
||||
// Show toast notification
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = $('#update-toast');
|
||||
const toastBody = toast.find('.toast-body');
|
||||
|
||||
toastBody.removeClass('text-success text-danger text-warning');
|
||||
|
||||
if (type === 'success') {
|
||||
toastBody.addClass('text-success');
|
||||
} else if (type === 'error') {
|
||||
toastBody.addClass('text-danger');
|
||||
} else if (type === 'warning') {
|
||||
toastBody.addClass('text-warning');
|
||||
}
|
||||
|
||||
toastBody.text(message);
|
||||
|
||||
const bsToast = new bootstrap.Toast(toast[0]);
|
||||
bsToast.show();
|
||||
}
|
||||
|
||||
// Setup event handlers
|
||||
function setupEventHandlers() {
|
||||
// Auto-refresh toggle
|
||||
$('#auto-refresh').on('change', function() {
|
||||
if ($(this).prop('checked')) {
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Export buttons
|
||||
$('.export-btn').on('click', function() {
|
||||
const dataType = $(this).data('type');
|
||||
exportData(dataType);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-refresh functionality
|
||||
let refreshIntervals = {};
|
||||
|
||||
function startAutoRefresh() {
|
||||
refreshIntervals.overview = setInterval(loadOverviewStats, 10000);
|
||||
refreshIntervals.operations = setInterval(loadOperationStats, 30000);
|
||||
refreshIntervals.errors = setInterval(loadErrorStats, 60000);
|
||||
refreshIntervals.performance = setInterval(loadPerformanceStats, 30000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
Object.values(refreshIntervals).forEach(interval => clearInterval(interval));
|
||||
refreshIntervals = {};
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (ms < 1000) return ms + 'ms';
|
||||
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
||||
return (ms / 60000).toFixed(1) + 'm';
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
$(document).ready(function() {
|
||||
if ($('#requestChart').length > 0) {
|
||||
initializeDashboard();
|
||||
}
|
||||
});
|
Reference in New Issue
Block a user