feat: major dashboard overhaul and polish
- Switched from mock data to real backend APIs. - Implemented unified ApiClient for consistent frontend data fetching. - Refactored dashboard structure and styles for a modern SaaS aesthetic. - Fixed Axum 0.8+ routing and parameter syntax issues. - Implemented real client creation/deletion and provider health monitoring. - Synchronized WebSocket event structures between backend and frontend.
This commit is contained in:
@@ -3,565 +3,121 @@
|
||||
class LogsPage {
|
||||
constructor() {
|
||||
this.logs = [];
|
||||
this.filters = {
|
||||
level: 'all',
|
||||
timeRange: '24h',
|
||||
search: ''
|
||||
};
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Load logs
|
||||
await this.loadLogs();
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Setup WebSocket subscription for live logs
|
||||
this.setupWebSocketSubscription();
|
||||
}
|
||||
|
||||
async loadLogs() {
|
||||
const tableBody = document.querySelector('#logs-table tbody');
|
||||
if (tableBody) tableBody.innerHTML = '<tr><td colspan="4" class="text-center">Loading logs...</td></tr>';
|
||||
|
||||
try {
|
||||
// In a real app, this would fetch from /api/system/logs
|
||||
// Generate demo logs
|
||||
this.generateDemoLogs(50);
|
||||
|
||||
this.applyFiltersAndRender();
|
||||
|
||||
const data = await window.api.get('/system/logs');
|
||||
this.logs = data;
|
||||
this.renderLogs();
|
||||
} catch (error) {
|
||||
console.error('Error loading logs:', error);
|
||||
window.authManager.showToast('Failed to load system logs', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
generateDemoLogs(count) {
|
||||
const levels = ['info', 'warn', 'error', 'debug'];
|
||||
const sources = ['server', 'database', 'auth', 'providers', 'clients', 'api'];
|
||||
const messages = [
|
||||
'Request processed successfully',
|
||||
'Cache hit for model gpt-4',
|
||||
'Rate limit check passed',
|
||||
'High latency detected for DeepSeek provider',
|
||||
'API key validation failed',
|
||||
'Database connection pool healthy',
|
||||
'New client registered: client-7',
|
||||
'Backup completed successfully',
|
||||
'Memory usage above 80% threshold',
|
||||
'Provider Grok is offline',
|
||||
'WebSocket connection established',
|
||||
'Authentication token expired',
|
||||
'Cost calculation completed',
|
||||
'Rate limit exceeded for client-2',
|
||||
'Database query optimization needed',
|
||||
'SSL certificate renewed',
|
||||
'System health check passed',
|
||||
'Error in OpenAI API response',
|
||||
'Gemini provider rate limited',
|
||||
'DeepSeek connection timeout'
|
||||
];
|
||||
|
||||
this.logs = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const level = levels[Math.floor(Math.random() * levels.length)];
|
||||
const source = sources[Math.floor(Math.random() * sources.length)];
|
||||
const message = messages[Math.floor(Math.random() * messages.length)];
|
||||
|
||||
// Generate timestamp (spread over last 24 hours)
|
||||
const hoursAgo = Math.random() * 24;
|
||||
const timestamp = new Date(now - hoursAgo * 60 * 60 * 1000);
|
||||
|
||||
this.logs.push({
|
||||
id: `log-${i}`,
|
||||
timestamp: timestamp.toISOString(),
|
||||
level: level,
|
||||
source: source,
|
||||
message: message,
|
||||
details: level === 'error' ? 'Additional error details would appear here' : null
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
this.logs.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
}
|
||||
|
||||
applyFiltersAndRender() {
|
||||
let filteredLogs = [...this.logs];
|
||||
|
||||
// Apply level filter
|
||||
if (this.filters.level !== 'all') {
|
||||
filteredLogs = filteredLogs.filter(log => log.level === this.filters.level);
|
||||
}
|
||||
|
||||
// Apply time range filter
|
||||
const now = Date.now();
|
||||
let timeLimit = now;
|
||||
|
||||
switch (this.filters.timeRange) {
|
||||
case '1h':
|
||||
timeLimit = now - 60 * 60 * 1000;
|
||||
break;
|
||||
case '24h':
|
||||
timeLimit = now - 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
case '7d':
|
||||
timeLimit = now - 7 * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
case '30d':
|
||||
timeLimit = now - 30 * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
|
||||
filteredLogs = filteredLogs.filter(log => new Date(log.timestamp) >= timeLimit);
|
||||
|
||||
// Apply search filter
|
||||
if (this.filters.search) {
|
||||
const searchLower = this.filters.search.toLowerCase();
|
||||
filteredLogs = filteredLogs.filter(log =>
|
||||
log.message.toLowerCase().includes(searchLower) ||
|
||||
log.source.toLowerCase().includes(searchLower) ||
|
||||
log.level.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
this.renderLogsTable(filteredLogs);
|
||||
}
|
||||
|
||||
renderLogsTable(logs) {
|
||||
renderLogs() {
|
||||
const tableBody = document.querySelector('#logs-table tbody');
|
||||
if (!tableBody) return;
|
||||
|
||||
if (logs.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="4" class="empty-table">
|
||||
<i class="fas fa-search"></i>
|
||||
<div>No logs found matching your filters</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (this.logs.length === 0) {
|
||||
tableBody.innerHTML = '<tr><td colspan="4" class="text-center">No logs found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableBody.innerHTML = logs.map(log => {
|
||||
const time = new Date(log.timestamp).toLocaleString();
|
||||
const levelClass = `log-${log.level}`;
|
||||
const levelIcon = this.getLevelIcon(log.level);
|
||||
|
||||
tableBody.innerHTML = this.logs.map(log => {
|
||||
const statusClass = log.status === 'success' ? 'success' : 'danger';
|
||||
const timestamp = luxon.DateTime.fromISO(log.timestamp).toFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return `
|
||||
<tr class="log-row ${levelClass}" data-log-id="${log.id}">
|
||||
<td>${time}</td>
|
||||
<tr class="log-row">
|
||||
<td class="whitespace-nowrap">${timestamp}</td>
|
||||
<td>
|
||||
<span class="log-level-badge ${levelClass}">
|
||||
<i class="fas fa-${levelIcon}"></i>
|
||||
${log.level.toUpperCase()}
|
||||
<span class="status-badge ${statusClass}">
|
||||
${log.status.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td>${log.source}</td>
|
||||
<td>
|
||||
<div class="log-message">${log.message}</div>
|
||||
${log.details ? `<div class="log-details">${log.details}</div>` : ''}
|
||||
<div class="log-meta">
|
||||
<span class="badge-client">${log.client_id}</span>
|
||||
<span class="log-provider">${log.provider}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="log-message-container">
|
||||
<code class="log-model">${log.model}</code>
|
||||
<span class="log-tokens">${log.tokens} tokens</span>
|
||||
<span class="log-duration">${log.duration}ms</span>
|
||||
${log.error ? `<div class="log-error-msg">${log.error}</div>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add CSS for logs table
|
||||
this.addLogsStyles();
|
||||
}
|
||||
|
||||
getLevelIcon(level) {
|
||||
switch (level) {
|
||||
case 'error': return 'exclamation-circle';
|
||||
case 'warn': return 'exclamation-triangle';
|
||||
case 'info': return 'info-circle';
|
||||
case 'debug': return 'bug';
|
||||
default: return 'circle';
|
||||
}
|
||||
}
|
||||
|
||||
addLogsStyles() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.log-level-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-error .log-level-badge {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.log-warn .log-level-badge {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.log-info .log-level-badge {
|
||||
background-color: rgba(6, 182, 212, 0.1);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.log-debug .log-level-badge {
|
||||
background-color: rgba(100, 116, 139, 0.1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.log-details {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--danger);
|
||||
}
|
||||
|
||||
.log-row:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.empty-table {
|
||||
text-align: center;
|
||||
padding: 3rem !important;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-table i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-table div {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Filter controls
|
||||
const refreshBtn = document.getElementById('refresh-btn');
|
||||
if (refreshBtn) {
|
||||
// Already handled by dashboard.js but we can add more specific logic if needed
|
||||
}
|
||||
|
||||
const logFilter = document.getElementById('log-filter');
|
||||
const timeRangeFilter = document.getElementById('log-time-range');
|
||||
const searchInput = document.getElementById('log-search');
|
||||
|
||||
if (logFilter) {
|
||||
logFilter.addEventListener('change', (e) => {
|
||||
this.filters.level = e.target.value;
|
||||
this.applyFiltersAndRender();
|
||||
});
|
||||
logFilter.onchange = () => this.filterLogs();
|
||||
}
|
||||
|
||||
if (timeRangeFilter) {
|
||||
timeRangeFilter.addEventListener('change', (e) => {
|
||||
this.filters.timeRange = e.target.value;
|
||||
this.applyFiltersAndRender();
|
||||
});
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
this.filters.search = e.target.value;
|
||||
this.applyFiltersAndRender();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
const downloadBtn = document.getElementById('download-logs');
|
||||
const clearBtn = document.getElementById('clear-logs');
|
||||
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
this.downloadLogs();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
this.clearLogs();
|
||||
});
|
||||
}
|
||||
|
||||
// Log row click for details
|
||||
document.addEventListener('click', (e) => {
|
||||
const logRow = e.target.closest('.log-row');
|
||||
if (logRow) {
|
||||
this.showLogDetails(logRow.dataset.logId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupWebSocketSubscription() {
|
||||
if (!window.wsManager) return;
|
||||
|
||||
// Subscribe to log updates
|
||||
window.wsManager.subscribe('logs', (log) => {
|
||||
this.addNewLog(log);
|
||||
});
|
||||
}
|
||||
|
||||
addNewLog(log) {
|
||||
// Add to beginning of logs array
|
||||
this.logs.unshift({
|
||||
id: `log-${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
level: log.level || 'info',
|
||||
source: log.source || 'unknown',
|
||||
message: log.message || '',
|
||||
details: log.details || null
|
||||
});
|
||||
|
||||
// Keep logs array manageable
|
||||
if (this.logs.length > 1000) {
|
||||
this.logs = this.logs.slice(0, 1000);
|
||||
}
|
||||
|
||||
// Apply filters and re-render
|
||||
this.applyFiltersAndRender();
|
||||
}
|
||||
|
||||
downloadLogs() {
|
||||
// Get filtered logs
|
||||
let filteredLogs = [...this.logs];
|
||||
|
||||
// Apply current filters
|
||||
if (this.filters.level !== 'all') {
|
||||
filteredLogs = filteredLogs.filter(log => log.level === this.filters.level);
|
||||
}
|
||||
|
||||
// Create CSV content
|
||||
const headers = ['Timestamp', 'Level', 'Source', 'Message', 'Details'];
|
||||
const rows = filteredLogs.map(log => [
|
||||
new Date(log.timestamp).toISOString(),
|
||||
log.level,
|
||||
log.source,
|
||||
`"${log.message.replace(/"/g, '""')}"`,
|
||||
log.details ? `"${log.details.replace(/"/g, '""')}"` : ''
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.join(','))
|
||||
].join('\n');
|
||||
|
||||
// Create download link
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `llm-proxy-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
if (window.authManager) {
|
||||
window.authManager.showToast('Logs downloaded successfully', 'success');
|
||||
const logSearch = document.getElementById('log-search');
|
||||
if (logSearch) {
|
||||
logSearch.oninput = (e) => this.searchLogs(e.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
clearLogs() {
|
||||
if (confirm('Are you sure you want to clear all logs? This action cannot be undone.')) {
|
||||
// In a real app, this would clear logs via API
|
||||
this.logs = [];
|
||||
this.applyFiltersAndRender();
|
||||
|
||||
if (window.authManager) {
|
||||
window.authManager.showToast('Logs cleared successfully', 'success');
|
||||
}
|
||||
filterLogs() {
|
||||
const filter = document.getElementById('log-filter').value;
|
||||
if (filter === 'all') {
|
||||
this.renderLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = this.logs.filter(log => log.status === (filter === 'error' ? 'error' : 'success'));
|
||||
this.renderFilteredLogs(filtered);
|
||||
}
|
||||
|
||||
showLogDetails(logId) {
|
||||
const log = this.logs.find(l => l.id === logId);
|
||||
if (!log) return;
|
||||
|
||||
// Show log details modal
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 800px;">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Log Details</h3>
|
||||
<button class="modal-close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="log-detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Timestamp:</span>
|
||||
<span class="detail-value">${new Date(log.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Level:</span>
|
||||
<span class="detail-value">
|
||||
<span class="log-level-badge log-${log.level}">
|
||||
<i class="fas fa-${this.getLevelIcon(log.level)}"></i>
|
||||
${log.level.toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Source:</span>
|
||||
<span class="detail-value">${log.source}</span>
|
||||
</div>
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label">Message:</span>
|
||||
<div class="detail-value message-box">${log.message}</div>
|
||||
</div>
|
||||
${log.details ? `
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label">Details:</span>
|
||||
<div class="detail-value details-box">${log.details}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="detail-item full-width">
|
||||
<span class="detail-label">Raw JSON:</span>
|
||||
<pre class="detail-value json-box">${JSON.stringify(log, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary close-modal">Close</button>
|
||||
<button class="btn btn-primary copy-json" data-json='${JSON.stringify(log)}'>
|
||||
<i class="fas fa-copy"></i> Copy JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Setup event listeners
|
||||
const closeBtn = modal.querySelector('.modal-close');
|
||||
const closeModalBtn = modal.querySelector('.close-modal');
|
||||
const copyBtn = modal.querySelector('.copy-json');
|
||||
|
||||
const closeModal = () => {
|
||||
modal.classList.remove('active');
|
||||
setTimeout(() => modal.remove(), 300);
|
||||
};
|
||||
|
||||
closeBtn.addEventListener('click', closeModal);
|
||||
closeModalBtn.addEventListener('click', closeModal);
|
||||
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const json = copyBtn.dataset.json;
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
if (window.authManager) {
|
||||
window.authManager.showToast('JSON copied to clipboard', 'success');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Close on background click
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Add CSS for log details
|
||||
this.addLogDetailStyles();
|
||||
}
|
||||
|
||||
addLogDetailStyles() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.log-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-item.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.message-box {
|
||||
padding: 0.75rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.details-box {
|
||||
padding: 0.75rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--warning);
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.json-box {
|
||||
padding: 0.75rem;
|
||||
background-color: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.loadLogs();
|
||||
|
||||
if (window.authManager) {
|
||||
window.authManager.showToast('Logs refreshed', 'success');
|
||||
searchLogs(query) {
|
||||
if (!query) {
|
||||
this.renderLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
const q = query.toLowerCase();
|
||||
const filtered = this.logs.filter(log =>
|
||||
log.client_id.toLowerCase().includes(q) ||
|
||||
log.model.toLowerCase().includes(q) ||
|
||||
log.provider.toLowerCase().includes(q) ||
|
||||
(log.error && log.error.toLowerCase().includes(q))
|
||||
);
|
||||
this.renderFilteredLogs(filtered);
|
||||
}
|
||||
|
||||
renderFilteredLogs(filteredLogs) {
|
||||
// reuse same rendering logic or similar
|
||||
const originalLogs = this.logs;
|
||||
this.logs = filteredLogs;
|
||||
this.renderLogs();
|
||||
this.logs = originalLogs;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize logs page when needed
|
||||
window.initLogs = async () => {
|
||||
window.logsPage = new LogsPage();
|
||||
};
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = LogsPage;
|
||||
}
|
||||
Reference in New Issue
Block a user