|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { formatNumber, formatCurrency, formatPercentage } from '../../shared/js/utils/formatters.js'; |
|
|
import { apiClient } from '../../shared/js/api-client.js'; |
|
|
import logger from '../../shared/js/utils/logger.js'; |
|
|
|
|
|
class DashboardPage { |
|
|
constructor() { |
|
|
this.charts = {}; |
|
|
this.marketData = []; |
|
|
this.watchlist = []; |
|
|
this.priceAlerts = []; |
|
|
this.newsCache = []; |
|
|
this.updateInterval = null; |
|
|
this.isLoading = false; |
|
|
this.consecutiveFailures = 0; |
|
|
this.isOffline = false; |
|
|
this.expandedNews = new Set(); |
|
|
|
|
|
this.config = { |
|
|
refreshInterval: 30000, |
|
|
maxWatchlistItems: 8, |
|
|
maxNewsItems: 6 |
|
|
}; |
|
|
|
|
|
this.loadPersistedData(); |
|
|
} |
|
|
|
|
|
async init() { |
|
|
try { |
|
|
logger.info('Dashboard', 'Initializing enhanced dashboard...'); |
|
|
|
|
|
|
|
|
this.showLoadingState(); |
|
|
|
|
|
|
|
|
this.injectEnhancedLayout(); |
|
|
this.bindEvents(); |
|
|
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 300)); |
|
|
|
|
|
|
|
|
await this.loadAllData(); |
|
|
|
|
|
|
|
|
this.hideLoadingState(); |
|
|
|
|
|
|
|
|
if (window.requestIdleCallback) { |
|
|
window.requestIdleCallback(() => this.loadChartJS(), { timeout: 3000 }); |
|
|
} else { |
|
|
setTimeout(() => this.loadChartJS(), 500); |
|
|
} |
|
|
this.setupAutoRefresh(); |
|
|
|
|
|
|
|
|
setTimeout(() => this.showRatingWidget(), 5000); |
|
|
|
|
|
this.showToast('Dashboard ready', 'success'); |
|
|
} catch (error) { |
|
|
logger.error('Dashboard', 'Init error:', error); |
|
|
this.showToast('Failed to load dashboard', 'error'); |
|
|
} |
|
|
} |
|
|
|
|
|
loadPersistedData() { |
|
|
try { |
|
|
const savedWatchlist = localStorage.getItem('crypto_watchlist'); |
|
|
this.watchlist = savedWatchlist ? JSON.parse(savedWatchlist) : ['bitcoin', 'ethereum', 'solana', 'cardano', 'ripple']; |
|
|
const savedAlerts = localStorage.getItem('crypto_price_alerts'); |
|
|
this.priceAlerts = savedAlerts ? JSON.parse(savedAlerts) : []; |
|
|
} catch (error) { |
|
|
logger.error('Dashboard', 'Error loading persisted data:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
savePersistedData() { |
|
|
try { |
|
|
localStorage.setItem('crypto_watchlist', JSON.stringify(this.watchlist)); |
|
|
localStorage.setItem('crypto_price_alerts', JSON.stringify(this.priceAlerts)); |
|
|
} catch (error) { |
|
|
logger.error('Dashboard', 'Error saving:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
destroy() { |
|
|
if (this.updateInterval) clearInterval(this.updateInterval); |
|
|
Object.values(this.charts).forEach(chart => chart?.destroy()); |
|
|
this.charts = {}; |
|
|
this.savePersistedData(); |
|
|
} |
|
|
|
|
|
showLoadingState() { |
|
|
const pageContent = document.querySelector('.page-content'); |
|
|
if (!pageContent) return; |
|
|
|
|
|
|
|
|
const loadingOverlay = document.createElement('div'); |
|
|
loadingOverlay.id = 'dashboard-loading'; |
|
|
loadingOverlay.className = 'dashboard-loading-overlay'; |
|
|
loadingOverlay.innerHTML = ` |
|
|
<div class="loading-content"> |
|
|
<div class="loading-spinner"></div> |
|
|
<p class="loading-text">Loading Dashboard...</p> |
|
|
</div> |
|
|
`; |
|
|
pageContent.appendChild(loadingOverlay); |
|
|
} |
|
|
|
|
|
hideLoadingState() { |
|
|
const loadingOverlay = document.getElementById('dashboard-loading'); |
|
|
if (loadingOverlay) { |
|
|
loadingOverlay.classList.add('fade-out'); |
|
|
setTimeout(() => loadingOverlay.remove(), 400); |
|
|
} |
|
|
} |
|
|
|
|
|
showRatingWidget() { |
|
|
|
|
|
const hasRated = sessionStorage.getItem('dashboard_rated'); |
|
|
if (hasRated) return; |
|
|
|
|
|
const ratingWidget = document.createElement('div'); |
|
|
ratingWidget.id = 'rating-widget'; |
|
|
ratingWidget.className = 'rating-widget'; |
|
|
ratingWidget.innerHTML = ` |
|
|
<div class="rating-content"> |
|
|
<button class="rating-close" onclick="this.closest('.rating-widget').remove()">×</button> |
|
|
<h4>How's your experience?</h4> |
|
|
<p>Rate the Crypto Monitor Dashboard</p> |
|
|
<div class="rating-stars"> |
|
|
<button class="star-btn" data-rating="1">★</button> |
|
|
<button class="star-btn" data-rating="2">★</button> |
|
|
<button class="star-btn" data-rating="3">★</button> |
|
|
<button class="star-btn" data-rating="4">★</button> |
|
|
<button class="star-btn" data-rating="5">★</button> |
|
|
</div> |
|
|
<p class="rating-feedback" style="display:none; margin-top:10px; color: var(--success); font-weight:600;"></p> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
document.body.appendChild(ratingWidget); |
|
|
|
|
|
|
|
|
const stars = ratingWidget.querySelectorAll('.star-btn'); |
|
|
const feedback = ratingWidget.querySelector('.rating-feedback'); |
|
|
|
|
|
stars.forEach((star, index) => { |
|
|
star.addEventListener('mouseenter', () => { |
|
|
stars.forEach((s, i) => { |
|
|
s.classList.toggle('active', i <= index); |
|
|
}); |
|
|
}); |
|
|
|
|
|
star.addEventListener('click', () => { |
|
|
const rating = parseInt(star.dataset.rating); |
|
|
sessionStorage.setItem('dashboard_rated', rating); |
|
|
|
|
|
feedback.textContent = `Thank you for rating ${rating} stars!`; |
|
|
feedback.style.display = 'block'; |
|
|
|
|
|
setTimeout(() => { |
|
|
ratingWidget.classList.add('fade-out'); |
|
|
setTimeout(() => ratingWidget.remove(), 400); |
|
|
}, 2000); |
|
|
}); |
|
|
}); |
|
|
|
|
|
ratingWidget.addEventListener('mouseleave', () => { |
|
|
stars.forEach(s => s.classList.remove('active')); |
|
|
}); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
if (ratingWidget.parentNode) { |
|
|
ratingWidget.classList.add('fade-out'); |
|
|
setTimeout(() => ratingWidget.remove(), 400); |
|
|
} |
|
|
}, 20000); |
|
|
} |
|
|
|
|
|
async loadChartJS() { |
|
|
if (window.Chart) { |
|
|
console.log('[Dashboard] Chart.js already loaded'); |
|
|
return; |
|
|
} |
|
|
|
|
|
console.log('[Dashboard] Loading Chart.js...'); |
|
|
|
|
|
return new Promise((resolve, reject) => { |
|
|
const script = document.createElement('script'); |
|
|
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js'; |
|
|
script.async = true; |
|
|
script.defer = true; |
|
|
script.crossOrigin = 'anonymous'; |
|
|
script.onload = () => { |
|
|
console.log('[Dashboard] Chart.js loaded successfully'); |
|
|
|
|
|
setTimeout(() => { |
|
|
this.renderAllCharts(); |
|
|
}, 100); |
|
|
resolve(); |
|
|
}; |
|
|
script.onerror = (e) => { |
|
|
console.error('[Dashboard] Chart.js load failed:', e); |
|
|
reject(e); |
|
|
}; |
|
|
document.head.appendChild(script); |
|
|
}); |
|
|
} |
|
|
|
|
|
renderAllCharts() { |
|
|
console.log('[Dashboard] Charts will be rendered when data is loaded...'); |
|
|
|
|
|
console.log('[Dashboard] Charts rendered'); |
|
|
} |
|
|
|
|
|
injectEnhancedLayout() { |
|
|
const pageContent = document.querySelector('.page-content'); |
|
|
if (!pageContent) return; |
|
|
|
|
|
|
|
|
pageContent.innerHTML = ` |
|
|
<!-- Live Ticker Bar --> |
|
|
<div class="ticker-bar" id="ticker-bar"> |
|
|
<div class="ticker-track" id="ticker-track"></div> |
|
|
</div> |
|
|
|
|
|
<!-- Hero Stats Section --> |
|
|
<section class="hero-stats" id="hero-stats"> |
|
|
<div class="hero-stat-card primary"> |
|
|
<div class="hero-stat-bg"></div> |
|
|
<div class="hero-stat-content"> |
|
|
<div class="hero-stat-icon"> |
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> |
|
|
</div> |
|
|
<div class="hero-stat-info"> |
|
|
<span class="hero-stat-label">Total Resources</span> |
|
|
<span class="hero-stat-value" id="stat-resources">--</span> |
|
|
<div class="hero-stat-trend positive"> |
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m18 15-6-6-6 6"/></svg> |
|
|
<span>Active</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="hero-stat-progress"> |
|
|
<div class="progress-bar" style="width: 100%"></div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="hero-stat-card accent"> |
|
|
<div class="hero-stat-bg"></div> |
|
|
<div class="hero-stat-content"> |
|
|
<div class="hero-stat-icon"> |
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg> |
|
|
</div> |
|
|
<div class="hero-stat-info"> |
|
|
<span class="hero-stat-label">API Keys</span> |
|
|
<span class="hero-stat-value" id="stat-apikeys">--</span> |
|
|
<div class="hero-stat-trend"> |
|
|
<span class="badge badge-info">Configured</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="hero-stat-card success"> |
|
|
<div class="hero-stat-bg"></div> |
|
|
<div class="hero-stat-content"> |
|
|
<div class="hero-stat-icon"> |
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/></svg> |
|
|
</div> |
|
|
<div class="hero-stat-info"> |
|
|
<span class="hero-stat-label">AI Models</span> |
|
|
<span class="hero-stat-value" id="stat-models">--</span> |
|
|
<div class="hero-stat-trend"> |
|
|
<span class="badge badge-success">Ready</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="hero-stat-card warning"> |
|
|
<div class="hero-stat-bg"></div> |
|
|
<div class="hero-stat-content"> |
|
|
<div class="hero-stat-icon"> |
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></svg> |
|
|
</div> |
|
|
<div class="hero-stat-info"> |
|
|
<span class="hero-stat-label">Providers</span> |
|
|
<span class="hero-stat-value" id="stat-providers">--</span> |
|
|
<div class="hero-stat-trend positive"> |
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m18 15-6-6-6 6"/></svg> |
|
|
<span>Online</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
|
|
|
<!-- Main Dashboard Grid --> |
|
|
<div class="dashboard-grid"> |
|
|
<!-- Left Column --> |
|
|
<div class="dashboard-col-main"> |
|
|
<!-- Market Overview Card --> |
|
|
<div class="glass-card market-card"> |
|
|
<div class="card-header"> |
|
|
<div class="card-title"> |
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg> |
|
|
<h2>Market Overview</h2> |
|
|
</div> |
|
|
<div class="card-controls"> |
|
|
<input type="text" class="search-pill" id="market-search" placeholder="Search..."> |
|
|
<select class="select-pill" id="market-sort"> |
|
|
<option value="rank">Rank</option> |
|
|
<option value="price">Price</option> |
|
|
<option value="change">24h %</option> |
|
|
</select> |
|
|
</div> |
|
|
</div> |
|
|
<div class="card-body" id="market-table-container"> |
|
|
<div class="loading-pulse">Loading market data...</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Charts Row --> |
|
|
<div class="charts-row"> |
|
|
<!-- Sentiment Chart --> |
|
|
<div class="glass-card chart-card"> |
|
|
<div class="card-header"> |
|
|
<div class="card-title"> |
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> |
|
|
<h2>Fear & Greed Index</h2> |
|
|
</div> |
|
|
<div class="timeframe-pills" id="sentiment-timeframe"> |
|
|
<button class="pill active" data-tf="1D">1D</button> |
|
|
<button class="pill" data-tf="7D">7D</button> |
|
|
<button class="pill" data-tf="30D">30D</button> |
|
|
</div> |
|
|
</div> |
|
|
<div class="chart-wrapper"> |
|
|
<canvas id="sentiment-chart"></canvas> |
|
|
</div> |
|
|
<div class="sentiment-gauge" id="sentiment-gauge"></div> |
|
|
</div> |
|
|
|
|
|
<!-- Resources Chart --> |
|
|
<div class="glass-card chart-card"> |
|
|
<div class="card-header"> |
|
|
<div class="card-title"> |
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> |
|
|
<h2>API Resources</h2> |
|
|
</div> |
|
|
</div> |
|
|
<div class="chart-wrapper donut-wrapper"> |
|
|
<canvas id="categories-chart"></canvas> |
|
|
<div class="donut-center" id="donut-center"> |
|
|
<span class="donut-value">--</span> |
|
|
<span class="donut-label">Total</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Right Column - Sidebar --> |
|
|
<div class="dashboard-col-side"> |
|
|
<!-- News Accordion Card --> |
|
|
<div class="glass-card news-card"> |
|
|
<div class="card-header compact"> |
|
|
<div class="card-title"> |
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg> |
|
|
<h3>Latest News</h3> |
|
|
</div> |
|
|
<a href="/static/pages/news/index.html" class="btn-ghost">View All</a> |
|
|
</div> |
|
|
<div class="news-accordion" id="news-accordion"></div> |
|
|
</div> |
|
|
|
|
|
<!-- Price Alerts Card --> |
|
|
<div class="glass-card alerts-card"> |
|
|
<div class="card-header compact"> |
|
|
<div class="card-title"> |
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> |
|
|
<h3>Price Alerts</h3> |
|
|
</div> |
|
|
<button class="btn-ghost" id="alert-add" title="Add alert">+</button> |
|
|
</div> |
|
|
<div class="alerts-list" id="alerts-list"></div> |
|
|
</div> |
|
|
|
|
|
<!-- Quick Stats --> |
|
|
<div class="glass-card mini-stats-card"> |
|
|
<div class="mini-stat"> |
|
|
<span class="mini-stat-label">Response Time</span> |
|
|
<span class="mini-stat-value" id="stat-response">-- ms</span> |
|
|
</div> |
|
|
<div class="mini-stat"> |
|
|
<span class="mini-stat-label">Cache Hit</span> |
|
|
<span class="mini-stat-value" id="stat-cache">-- %</span> |
|
|
</div> |
|
|
<div class="mini-stat"> |
|
|
<span class="mini-stat-label">Sessions</span> |
|
|
<span class="mini-stat-value" id="stat-sessions">--</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
bindEvents() { |
|
|
|
|
|
document.getElementById('refresh-btn')?.addEventListener('click', () => { |
|
|
this.showToast('Refreshing...', 'info'); |
|
|
this.loadAllData(); |
|
|
}); |
|
|
|
|
|
|
|
|
document.getElementById('market-search')?.addEventListener('input', (e) => { |
|
|
this.filterMarketTable(e.target.value); |
|
|
}); |
|
|
|
|
|
|
|
|
document.getElementById('market-sort')?.addEventListener('change', (e) => { |
|
|
this.sortMarketData(e.target.value); |
|
|
}); |
|
|
|
|
|
|
|
|
document.querySelectorAll('#sentiment-timeframe .pill').forEach(btn => { |
|
|
btn.addEventListener('click', () => { |
|
|
document.querySelectorAll('#sentiment-timeframe .pill').forEach(b => b.classList.remove('active')); |
|
|
btn.classList.add('active'); |
|
|
this.updateSentimentTimeframe(btn.dataset.tf); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('alert-add')?.addEventListener('click', () => this.showAddAlertModal()); |
|
|
|
|
|
|
|
|
document.addEventListener('visibilitychange', () => { |
|
|
if (!document.hidden && !this.isOffline) this.loadAllData(); |
|
|
}); |
|
|
} |
|
|
|
|
|
setupAutoRefresh() { |
|
|
this.updateInterval = setInterval(() => { |
|
|
if (!this.isOffline && !document.hidden && !this.isLoading) { |
|
|
this.loadAllData(); |
|
|
} |
|
|
}, this.config.refreshInterval); |
|
|
} |
|
|
|
|
|
async loadAllData() { |
|
|
if (this.isLoading) return; |
|
|
this.isLoading = true; |
|
|
|
|
|
try { |
|
|
|
|
|
const marketContainer = document.getElementById('market-table-container'); |
|
|
if (marketContainer) { |
|
|
marketContainer.innerHTML = '<div class="loading-pulse">Loading market data...</div>'; |
|
|
} |
|
|
|
|
|
const [stats, market, sentiment, resources, news] = await Promise.allSettled([ |
|
|
this.fetchStats(), |
|
|
this.fetchMarket(), |
|
|
this.fetchSentiment(), |
|
|
this.fetchResources(), |
|
|
this.fetchNews() |
|
|
]); |
|
|
|
|
|
|
|
|
if (stats.status === 'fulfilled' && stats.value) { |
|
|
this.renderStats(stats.value); |
|
|
} else { |
|
|
console.warn('[Dashboard] Stats unavailable'); |
|
|
this.renderStats({ total_resources: 0, api_keys: 0, models_loaded: 0, active_providers: 0 }); |
|
|
} |
|
|
|
|
|
if (market.status === 'fulfilled' && market.value && market.value.length > 0) { |
|
|
await this.renderMarketTable(market.value); |
|
|
this.renderTicker(market.value); |
|
|
} else { |
|
|
console.warn('[Dashboard] Market data unavailable'); |
|
|
if (marketContainer) { |
|
|
marketContainer.innerHTML = '<div class="empty-state"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin: 0 auto 12px; opacity: 0.3;"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg><p>No market data available</p><p style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">Please check your connection</p></div>'; |
|
|
} |
|
|
} |
|
|
|
|
|
if (sentiment.status === 'fulfilled' && sentiment.value) { |
|
|
await this.renderSentimentChart(sentiment.value); |
|
|
} else { |
|
|
console.warn('[Dashboard] Sentiment data unavailable'); |
|
|
} |
|
|
|
|
|
if (resources.status === 'fulfilled' && resources.value) { |
|
|
this.renderResourcesChart(resources.value); |
|
|
} else { |
|
|
console.warn('[Dashboard] Resources data unavailable'); |
|
|
} |
|
|
|
|
|
if (news.status === 'fulfilled' && news.value && news.value.length > 0) { |
|
|
this.renderNewsAccordion(news.value); |
|
|
} else { |
|
|
console.warn('[Dashboard] News unavailable'); |
|
|
} |
|
|
|
|
|
this.renderAlerts(); |
|
|
this.renderMiniStats(); |
|
|
this.updateTimestamp(); |
|
|
|
|
|
|
|
|
this.consecutiveFailures = 0; |
|
|
this.isOffline = false; |
|
|
|
|
|
} catch (error) { |
|
|
logger.error('Dashboard', 'Load error:', error); |
|
|
this.consecutiveFailures++; |
|
|
if (this.consecutiveFailures >= 3) { |
|
|
this.isOffline = true; |
|
|
this.showToast('Connection lost. Please check your internet.', 'error'); |
|
|
} else { |
|
|
this.showToast('Failed to load some data', 'warning'); |
|
|
} |
|
|
} finally { |
|
|
this.isLoading = false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async fetchStats() { |
|
|
try { |
|
|
const [res1, res2] = await Promise.allSettled([ |
|
|
apiClient.fetch('/api/resources/summary', {}, 15000).then(r => r.ok ? r.json() : null), |
|
|
apiClient.fetch('/api/models/status', {}, 10000).then(r => r.ok ? r.json() : null) |
|
|
]); |
|
|
|
|
|
const data = res1.value?.summary || res1.value || {}; |
|
|
const models = res2.value || {}; |
|
|
|
|
|
return { |
|
|
total_resources: data.total_resources || 0, |
|
|
api_keys: data.total_api_keys || 0, |
|
|
models_loaded: models.models_loaded || data.models_available || 0, |
|
|
active_providers: data.total_resources || 0 |
|
|
}; |
|
|
} catch (error) { |
|
|
console.error('[Dashboard] Stats fetch failed:', error); |
|
|
return null; |
|
|
} |
|
|
} |
|
|
|
|
|
async fetchMarket() { |
|
|
try { |
|
|
|
|
|
try { |
|
|
const response = await apiClient.fetch('/api/market?limit=50', {}, 10000); |
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
const markets = data.markets || data.coins || data.data || data; |
|
|
if (Array.isArray(markets) && markets.length > 0) { |
|
|
this.marketData = markets; |
|
|
console.log('[Dashboard] Market data loaded from backend:', this.marketData.length, 'coins'); |
|
|
return this.marketData; |
|
|
} |
|
|
} |
|
|
} catch (e) { |
|
|
console.warn('[Dashboard] Backend API unavailable, trying CoinGecko'); |
|
|
} |
|
|
|
|
|
|
|
|
const response = await fetch('https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=50&page=1&sparkline=true&price_change_percentage=24h'); |
|
|
|
|
|
if (!response.ok) throw new Error('CoinGecko API failed'); |
|
|
|
|
|
const data = await response.json(); |
|
|
this.marketData = data || []; |
|
|
|
|
|
console.log('[Dashboard] Market data loaded from CoinGecko:', this.marketData.length, 'coins'); |
|
|
return this.marketData; |
|
|
} catch (error) { |
|
|
console.error('[Dashboard] Market fetch failed:', error.message); |
|
|
return []; |
|
|
} |
|
|
} |
|
|
|
|
|
async fetchSentiment() { |
|
|
try { |
|
|
|
|
|
const response = await fetch('https://api.alternative.me/fng/'); |
|
|
if (!response.ok) throw new Error('Fear & Greed API failed'); |
|
|
|
|
|
const data = await response.json(); |
|
|
const val = parseInt(data.data?.[0]?.value || 50); |
|
|
|
|
|
return { |
|
|
fear_greed_index: val, |
|
|
sentiment: val > 50 ? 'greed' : 'fear' |
|
|
}; |
|
|
} catch (error) { |
|
|
console.error('[Dashboard] Sentiment fetch failed:', error); |
|
|
return { fear_greed_index: 50, sentiment: 'neutral' }; |
|
|
} |
|
|
} |
|
|
|
|
|
async fetchResources() { |
|
|
try { |
|
|
const response = await apiClient.fetch('/api/resources/stats', {}, 15000); |
|
|
if (!response.ok) throw new Error(); |
|
|
const data = await response.json(); |
|
|
const stats = data.data || data; |
|
|
|
|
|
return { |
|
|
categories: { |
|
|
'Market': stats.categories?.market_data?.total || 13, |
|
|
'News': stats.categories?.news?.total || 10, |
|
|
'Sentiment': stats.categories?.sentiment?.total || 6, |
|
|
'Analytics': stats.categories?.analytics?.total || 13, |
|
|
'Explorers': stats.categories?.block_explorers?.total || 6, |
|
|
'RPC': stats.categories?.rpc_nodes?.total || 8, |
|
|
'AI/ML': stats.categories?.ai_ml?.total || 1 |
|
|
} |
|
|
}; |
|
|
} catch (error) { |
|
|
console.error('[Dashboard] Resources fetch failed:', error); |
|
|
return null; |
|
|
} |
|
|
} |
|
|
|
|
|
async fetchNews() { |
|
|
try { |
|
|
|
|
|
let response = await apiClient.fetch('/api/news/latest?limit=6', {}, 10000); |
|
|
|
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
this.newsCache = data.news || data.articles || []; |
|
|
console.log('[Dashboard] News loaded from backend:', this.newsCache.length, 'articles'); |
|
|
return this.newsCache; |
|
|
} |
|
|
|
|
|
|
|
|
response = await fetch('https://min-api.cryptocompare.com/data/v2/news/?lang=EN'); |
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
if (data.Data) { |
|
|
this.newsCache = data.Data.slice(0, 6).map(item => ({ |
|
|
id: item.id, |
|
|
title: item.title, |
|
|
summary: item.body?.substring(0, 150) + '...', |
|
|
source: item.source, |
|
|
published_at: new Date(item.published_on * 1000).toISOString(), |
|
|
url: item.url |
|
|
})); |
|
|
console.log('[Dashboard] News loaded from CryptoCompare:', this.newsCache.length, 'articles'); |
|
|
return this.newsCache; |
|
|
} |
|
|
} |
|
|
|
|
|
return []; |
|
|
} catch (error) { |
|
|
console.error('[Dashboard] News fetch failed:', error); |
|
|
return []; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getCoinImage(coin, size = 32) { |
|
|
const imageUrl = coin.image || `https://assets.coingecko.com/coins/images/1/small/${coin.id}.png`; |
|
|
const symbol = (coin.symbol || '?').charAt(0).toUpperCase(); |
|
|
const fallbackSvg = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${size}' height='${size}'%3E%3Ccircle cx='${size/2}' cy='${size/2}' r='${size/2-2}' fill='%2394a3b8'/%3E%3Ctext x='${size/2}' y='${size/2+size/4}' text-anchor='middle' fill='white' font-size='${size/2}' font-weight='bold'%3E${symbol}%3C/text%3E%3C/svg%3E`; |
|
|
|
|
|
return `<img src="${imageUrl}" |
|
|
alt="${coin.name || coin.symbol || 'Coin'}" |
|
|
width="${size}" |
|
|
height="${size}" |
|
|
onerror="this.onerror=null; this.src='${fallbackSvg}';" |
|
|
loading="lazy" |
|
|
style="border-radius: 50%; object-fit: cover;">`; |
|
|
} |
|
|
|
|
|
renderStats(stats) { |
|
|
const animate = (el, val, delay = 0) => { |
|
|
if (!el) return; |
|
|
setTimeout(() => { |
|
|
el.classList.add('updating'); |
|
|
|
|
|
const current = parseInt(el.textContent) || 0; |
|
|
const target = val > 0 ? val : 0; |
|
|
const duration = 800; |
|
|
const steps = 30; |
|
|
const increment = (target - current) / steps; |
|
|
let step = 0; |
|
|
|
|
|
const counter = setInterval(() => { |
|
|
step++; |
|
|
const newVal = Math.round(current + (increment * step)); |
|
|
el.textContent = formatNumber(newVal); |
|
|
|
|
|
if (step >= steps) { |
|
|
el.textContent = val > 0 ? formatNumber(val) : '--'; |
|
|
clearInterval(counter); |
|
|
setTimeout(() => el.classList.remove('updating'), 300); |
|
|
} |
|
|
}, duration / steps); |
|
|
}, delay); |
|
|
}; |
|
|
|
|
|
|
|
|
animate(document.getElementById('stat-resources'), stats.total_resources, 0); |
|
|
animate(document.getElementById('stat-apikeys'), stats.api_keys, 100); |
|
|
animate(document.getElementById('stat-models'), stats.models_loaded, 200); |
|
|
animate(document.getElementById('stat-providers'), stats.active_providers, 300); |
|
|
} |
|
|
|
|
|
renderTicker(data) { |
|
|
const track = document.getElementById('ticker-track'); |
|
|
if (!track) return; |
|
|
|
|
|
if (!data || !data.length) { |
|
|
console.warn('[Dashboard] No ticker data available'); |
|
|
track.innerHTML = '<div style="padding: 8px 16px; color: var(--text-muted);">No market data available</div>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const items = data.slice(0, 10).map(coin => { |
|
|
const change = coin.price_change_percentage_24h || 0; |
|
|
const cls = change >= 0 ? 'up' : 'down'; |
|
|
const arrow = change >= 0 ? '▲' : '▼'; |
|
|
const symbol = coin.symbol || coin.id || 'N/A'; |
|
|
const price = coin.current_price || 0; |
|
|
|
|
|
|
|
|
const coinImage = coin.image || `https://assets.coingecko.com/coins/images/1/small/${coin.id}.png`; |
|
|
|
|
|
return ` |
|
|
<div class="ticker-item"> |
|
|
<img src="${coinImage}" alt="${symbol}" width="20" height="20" style="border-radius: 50%;" onerror="this.style.display='none'"> |
|
|
<span class="ticker-symbol">${symbol.toUpperCase()}</span> |
|
|
<span class="ticker-price">${formatCurrency(price)}</span> |
|
|
<span class="ticker-change ${cls}">${arrow} ${Math.abs(change).toFixed(1)}%</span> |
|
|
</div> |
|
|
`; |
|
|
}).join(''); |
|
|
|
|
|
track.innerHTML = items; |
|
|
} |
|
|
|
|
|
async renderMarketTable(data) { |
|
|
const container = document.getElementById('market-table-container'); |
|
|
if (!container) return; |
|
|
|
|
|
if (!data || !data.length) { |
|
|
container.innerHTML = '<div class="empty-state"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin: 0 auto 12px; opacity: 0.3;"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg><p>No market data available</p><p style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">Please check your connection</p></div>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const sparklinePromises = data.slice(0, 10).map(async (coin) => { |
|
|
let sparklineData = coin.sparkline_in_7d?.price || coin.sparkline?.price || []; |
|
|
if (!sparklineData || sparklineData.length === 0) { |
|
|
sparklineData = await this.generateSparkline(coin.symbol || coin.id || 'BTC'); |
|
|
} |
|
|
return { coin, sparklineData }; |
|
|
}); |
|
|
|
|
|
const coinsWithSparklines = await Promise.all(sparklinePromises); |
|
|
|
|
|
const rows = coinsWithSparklines.map(({ coin, sparklineData }, i) => { |
|
|
const change = coin.price_change_percentage_24h || 0; |
|
|
const cls = change >= 0 ? 'up' : 'down'; |
|
|
|
|
|
|
|
|
const coinImage = coin.image || `https://assets.coingecko.com/coins/images/1/small/${coin.id}.png`; |
|
|
|
|
|
return ` |
|
|
<div class="market-row" data-id="${coin.id}"> |
|
|
<div class="market-rank">${coin.market_cap_rank || i + 1}</div> |
|
|
<div class="market-coin"> |
|
|
<img src="${coinImage}" alt="${coin.name}" width="36" height="36" style="border-radius: 50%; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);" onerror="this.style.display='none'"> |
|
|
<div class="market-coin-info"> |
|
|
<span class="market-coin-name">${coin.name || 'Unknown'}</span> |
|
|
<span class="market-coin-symbol" style="display: block; font-size: 11px; color: var(--text-muted); font-weight: 500; margin-top: 2px;">${(coin.symbol || coin.id || 'N/A').toUpperCase()}</span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="market-price">${formatCurrency(coin.current_price || 0)}</div> |
|
|
<div class="market-change ${cls}"> |
|
|
<span class="change-badge ${cls}"> |
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
|
${change >= 0 ? '<path d="m18 15-6-6-6 6"/>' : '<path d="m6 9 6 6 6-6"/>'} |
|
|
</svg> |
|
|
${change >= 0 ? '+' : ''}${change.toFixed(2)}% |
|
|
</span> |
|
|
</div> |
|
|
<div class="market-sparkline">${this.renderSparkline(sparklineData, change >= 0)}</div> |
|
|
<div class="market-cap">${formatCurrency(coin.market_cap || 0)}</div> |
|
|
<div class="market-actions"> |
|
|
<button class="btn-view" data-coin='${JSON.stringify(coin).replace(/'/g, "'")}' title="View Details"> |
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> |
|
|
View |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join(''); |
|
|
|
|
|
container.innerHTML = ` |
|
|
<div class="market-header"> |
|
|
<span class="header-rank">#</span> |
|
|
<span class="header-coin">COIN</span> |
|
|
<span class="header-price">PRICE</span> |
|
|
<span class="header-change">24H %</span> |
|
|
<span class="header-chart">7D CHART</span> |
|
|
<span class="header-mcap">MARKET CAP</span> |
|
|
<span class="header-actions">ACTION</span> |
|
|
</div> |
|
|
<div class="market-body">${rows}</div> |
|
|
`; |
|
|
|
|
|
// Bind View buttons |
|
|
container.querySelectorAll('.btn-view').forEach(btn => { |
|
|
btn.addEventListener('click', () => { |
|
|
try { |
|
|
const coin = JSON.parse(btn.dataset.coin.replace(/'/g, "'")); |
|
|
this.showCoinDetailsModal(coin); |
|
|
} catch (e) { |
|
|
console.error('[Dashboard] Error parsing coin data:', e); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
showCoinDetailsModal(coin) { |
|
|
const change = coin.price_change_percentage_24h || 0; |
|
|
const changeClass = change >= 0 ? 'positive' : 'negative'; |
|
|
const arrow = change >= 0 ? '↑' : '↓'; |
|
|
|
|
|
// USE REAL CRYPTOCURRENCY ICON |
|
|
const coinImage = coin.image || `https://assets.coingecko.com/coins/images/1/small/${coin.id}.png`; |
|
|
|
|
|
const modal = document.createElement('div'); |
|
|
modal.className = 'modal-overlay'; |
|
|
modal.innerHTML = ` |
|
|
<div class="modal-content coin-details-modal"> |
|
|
<div class="modal-header"> |
|
|
<div class="modal-title-group"> |
|
|
<img src="${coinImage}" alt="${coin.name}" width="48" height="48" style="border-radius: 50%; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);" onerror="this.style.display='none'"> |
|
|
<div> |
|
|
<h2>${coin.name}</h2> |
|
|
<p class="coin-symbol">${coin.symbol?.toUpperCase()}</p> |
|
|
</div> |
|
|
</div> |
|
|
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">×</button> |
|
|
</div> |
|
|
<div class="modal-body"> |
|
|
<div class="coin-details-grid"> |
|
|
<div class="detail-card"> |
|
|
<span class="detail-label">Current Price</span> |
|
|
<span class="detail-value">${formatCurrency(coin.current_price)}</span> |
|
|
</div> |
|
|
<div class="detail-card"> |
|
|
<span class="detail-label">24h Change</span> |
|
|
<span class="detail-value ${changeClass}">${arrow} ${Math.abs(change).toFixed(2)}%</span> |
|
|
</div> |
|
|
<div class="detail-card"> |
|
|
<span class="detail-label">Market Cap</span> |
|
|
<span class="detail-value">${formatCurrency(coin.market_cap)}</span> |
|
|
</div> |
|
|
<div class="detail-card"> |
|
|
<span class="detail-label">24h Volume</span> |
|
|
<span class="detail-value">${formatCurrency(coin.total_volume)}</span> |
|
|
</div> |
|
|
<div class="detail-card"> |
|
|
<span class="detail-label">Market Cap Rank</span> |
|
|
<span class="detail-value">#${coin.market_cap_rank || 'N/A'}</span> |
|
|
</div> |
|
|
<div class="detail-card"> |
|
|
<span class="detail-label">Circulating Supply</span> |
|
|
<span class="detail-value">${coin.circulating_supply ? formatNumber(coin.circulating_supply) : 'N/A'}</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="modal-footer"> |
|
|
<button class="btn-secondary" onclick="this.closest('.modal-overlay').remove()">Close</button> |
|
|
<a href="/static/pages/market/index.html" class="btn-primary">View Full Market</a> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
document.body.appendChild(modal); |
|
|
|
|
|
// Close on overlay click |
|
|
modal.addEventListener('click', (e) => { |
|
|
if (e.target === modal) { |
|
|
modal.remove(); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
renderSparkline(data, isUp = true) { |
|
|
if (!data || data.length < 2) { |
|
|
// Generate a simple placeholder |
|
|
const w = 80, h = 28; |
|
|
const mid = h / 2; |
|
|
const points = Array.from({length: 10}, (_, i) => `${(i / 9) * w},${mid + Math.sin(i) * 4}`).join(' '); |
|
|
const color = '#94a3b8'; |
|
|
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" style="opacity: 0.5;"><polyline fill="none" stroke="${color}" stroke-width="1.5" points="${points}"/></svg>`; |
|
|
} |
|
|
const w = 80, h = 28; |
|
|
const min = Math.min(...data), max = Math.max(...data); |
|
|
const range = max - min || 1; |
|
|
const points = data.map((v, i) => `${(i / (data.length - 1)) * w},${h - ((v - min) / range) * h}`).join(' '); |
|
|
const color = isUp ? '#22c55e' : '#ef4444'; |
|
|
const fillColor = isUp ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)'; |
|
|
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"> |
|
|
<defs> |
|
|
<linearGradient id="grad-${isUp ? 'up' : 'down'}" x1="0%" y1="0%" x2="0%" y2="100%"> |
|
|
<stop offset="0%" style="stop-color:${fillColor};stop-opacity:1" /> |
|
|
<stop offset="100%" style="stop-color:${fillColor};stop-opacity:0" /> |
|
|
</linearGradient> |
|
|
</defs> |
|
|
<polygon fill="url(#grad-${isUp ? 'up' : 'down'})" points="${points} ${w},${h} 0,${h}"/> |
|
|
<polyline fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="${points}"/> |
|
|
</svg>`; |
|
|
} |
|
|
|
|
|
async generateSparkline(symbol) { |
|
|
// Fetch real sparkline data from API |
|
|
try { |
|
|
const response = await apiClient.fetch(`/api/market/ohlc?symbol=${symbol}&interval=1h&limit=24`, {}, 10000); |
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
const ohlc = data.data || data.ohlc || data; |
|
|
if (Array.isArray(ohlc) && ohlc.length > 0) { |
|
|
// Extract close prices for sparkline |
|
|
return ohlc.map(candle => { |
|
|
const price = candle.close || candle.c || candle[4] || 0; |
|
|
return parseFloat(price) || 0; |
|
|
}).filter(p => p > 0); |
|
|
} |
|
|
} |
|
|
} catch (e) { |
|
|
console.warn(`[Dashboard] Sparkline data unavailable for ${symbol}`); |
|
|
} |
|
|
|
|
|
// If API fails, return empty array (no fake data) |
|
|
return []; |
|
|
} |
|
|
|
|
|
async renderSentimentChart(data, timeframe = '1D') { |
|
|
if (!window.Chart) return; |
|
|
const canvas = document.getElementById('sentiment-chart'); |
|
|
if (!canvas) return; |
|
|
|
|
|
const value = data.fear_greed_index || 50; |
|
|
const { labels, values } = await this.generateSentimentData(value, timeframe); |
|
|
|
|
|
// Render gauge |
|
|
this.renderSentimentGauge(value); |
|
|
|
|
|
if (this.charts.sentiment) { |
|
|
this.charts.sentiment.data.labels = labels; |
|
|
this.charts.sentiment.data.datasets[0].data = values; |
|
|
this.charts.sentiment.update('active'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const ctx = canvas.getContext('2d'); |
|
|
const gradient = ctx.createLinearGradient(0, 0, 0, 200); |
|
|
gradient.addColorStop(0, 'rgba(45, 212, 191, 0.5)'); |
|
|
gradient.addColorStop(0.5, 'rgba(45, 212, 191, 0.2)'); |
|
|
gradient.addColorStop(1, 'rgba(45, 212, 191, 0)'); |
|
|
|
|
|
this.charts.sentiment = new Chart(ctx, { |
|
|
type: 'line', |
|
|
data: { |
|
|
labels, |
|
|
datasets: [{ |
|
|
data: values, |
|
|
borderColor: '#2dd4bf', |
|
|
backgroundColor: gradient, |
|
|
borderWidth: 3, |
|
|
tension: 0.4, |
|
|
fill: true, |
|
|
pointRadius: 0, |
|
|
pointHoverRadius: 8, |
|
|
pointHoverBackgroundColor: '#2dd4bf', |
|
|
pointHoverBorderColor: '#ffffff', |
|
|
pointHoverBorderWidth: 3 |
|
|
}] |
|
|
}, |
|
|
options: { |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
animation: { |
|
|
duration: 1500, |
|
|
easing: 'easeInOutQuart' |
|
|
}, |
|
|
plugins: { |
|
|
legend: { display: false }, |
|
|
tooltip: { |
|
|
backgroundColor: 'rgba(15, 23, 42, 0.95)', |
|
|
titleColor: '#ffffff', |
|
|
bodyColor: '#e2e8f0', |
|
|
borderColor: '#2dd4bf', |
|
|
borderWidth: 2, |
|
|
padding: 12, |
|
|
cornerRadius: 8, |
|
|
displayColors: false, |
|
|
callbacks: { |
|
|
label: (context) => `Fear & Greed: ${context.parsed.y.toFixed(0)}` |
|
|
} |
|
|
} |
|
|
}, |
|
|
scales: { |
|
|
y: { min: 0, max: 100, display: false }, |
|
|
x: { display: false } |
|
|
}, |
|
|
interaction: { mode: 'index', intersect: false } |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
renderSentimentGauge(value) { |
|
|
const gauge = document.getElementById('sentiment-gauge'); |
|
|
if (!gauge) return; |
|
|
|
|
|
let label = 'Neutral', color = '#eab308'; |
|
|
if (value < 25) { label = 'Extreme Fear'; color = '#ef4444'; } |
|
|
else if (value < 45) { label = 'Fear'; color = '#f97316'; } |
|
|
else if (value < 55) { label = 'Neutral'; color = '#eab308'; } |
|
|
else if (value < 75) { label = 'Greed'; color = '#22c55e'; } |
|
|
else { label = 'Extreme Greed'; color = '#10b981'; } |
|
|
|
|
|
gauge.innerHTML = ` |
|
|
<div class="gauge-container"> |
|
|
<div class="gauge-bar"> |
|
|
<div class="gauge-fill" style="width: ${value}%; background: ${color};"></div> |
|
|
<div class="gauge-indicator" style="left: ${value}%;"> |
|
|
<span class="gauge-value">${value}</span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="gauge-labels"> |
|
|
<span>Extreme Fear</span> |
|
|
<span>Neutral</span> |
|
|
<span>Extreme Greed</span> |
|
|
</div> |
|
|
<div class="gauge-result" style="color: ${color};">${label}</div> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
async generateSentimentData(base, tf) { |
|
|
const labels = [], values = []; |
|
|
let points = tf === '1D' ? 24 : tf === '7D' ? 7 : 30; |
|
|
|
|
|
// Try to fetch real historical data from API |
|
|
try { |
|
|
const limit = tf === '1D' ? 24 : tf === '7D' ? 7 : 30; |
|
|
const response = await apiClient.fetch(`/api/sentiment/global?limit=${limit}`, {}, 10000); |
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
// Handle different response formats |
|
|
const history = data.history || data.data || data.values || []; |
|
|
if (Array.isArray(history) && history.length > 0) { |
|
|
// Use real historical data |
|
|
for (let i = 0; i < Math.min(points, history.length); i++) { |
|
|
const item = history[i]; |
|
|
const value = item.value || item.fear_greed_index || item.index || base; |
|
|
const timestamp = item.timestamp || item.time || item.date; |
|
|
labels.push(i === 0 ? 'Now' : timestamp ? new Date(timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : `-${points - i}${tf === '1D' ? 'h' : 'd'}`); |
|
|
values.push(Math.max(0, Math.min(100, value))); |
|
|
} |
|
|
// Fill remaining with base value if needed |
|
|
while (values.length < points) { |
|
|
labels.push(`-${points - values.length}${tf === '1D' ? 'h' : 'd'}`); |
|
|
values.push(base); |
|
|
} |
|
|
return { labels, values }; |
|
|
} |
|
|
} |
|
|
} catch (e) { |
|
|
console.warn('[Dashboard] Historical sentiment data unavailable, using base value'); |
|
|
} |
|
|
|
|
|
// Fallback: Use base value with small variations (not random, but based on base) |
|
|
for (let i = points - 1; i >= 0; i--) { |
|
|
labels.push(i === 0 ? 'Now' : `-${i}${tf === '1D' ? 'h' : 'd'}`); |
|
|
// Use base value with small time-based variation (not random) |
|
|
const variation = Math.sin(i * 0.1) * 2; // Small sine wave variation |
|
|
values.push(Math.max(0, Math.min(100, base + variation))); |
|
|
} |
|
|
return { labels, values }; |
|
|
} |
|
|
|
|
|
async updateSentimentTimeframe(tf) { |
|
|
const data = await this.fetchSentiment(); |
|
|
await this.renderSentimentChart(data, tf); |
|
|
} |
|
|
|
|
|
renderResourcesChart(data) { |
|
|
if (!window.Chart) return; |
|
|
const canvas = document.getElementById('categories-chart'); |
|
|
if (!canvas) return; |
|
|
|
|
|
const categories = data.categories || {}; |
|
|
const labels = Object.keys(categories); |
|
|
const values = Object.values(categories); |
|
|
const total = values.reduce((a, b) => a + b, 0); |
|
|
|
|
|
// Update center - simple and clean |
|
|
const center = document.getElementById('donut-center'); |
|
|
if (center) { |
|
|
const valueEl = center.querySelector('.donut-value'); |
|
|
const labelEl = center.querySelector('.donut-label'); |
|
|
valueEl.textContent = total; |
|
|
labelEl.textContent = 'RESOURCES'; |
|
|
} |
|
|
|
|
|
if (this.charts.categories) { |
|
|
this.charts.categories.data.labels = labels; |
|
|
this.charts.categories.data.datasets[0].data = values; |
|
|
this.charts.categories.update('none'); |
|
|
return; |
|
|
} |
|
|
|
|
|
// Clean, modern colors - solid, no gradients |
|
|
const colors = [ |
|
|
'#8b5cf6', // Purple - Market |
|
|
'#2dd4bf', // Teal - News |
|
|
'#22c55e', // Green - Sentiment |
|
|
'#f97316', // Orange - Analytics |
|
|
'#ec4899', // Pink - Explorers |
|
|
'#3b82f6', // Blue - RPC |
|
|
'#fbbf24' // Yellow - AI/ML |
|
|
]; |
|
|
|
|
|
const ctx = canvas.getContext('2d'); |
|
|
this.charts.categories = new Chart(ctx, { |
|
|
type: 'doughnut', |
|
|
data: { |
|
|
labels, |
|
|
datasets: [{ |
|
|
data: values, |
|
|
backgroundColor: colors, |
|
|
borderWidth: 8, |
|
|
borderColor: '#ffffff', |
|
|
hoverOffset: 8, |
|
|
hoverBorderWidth: 8 |
|
|
}] |
|
|
}, |
|
|
options: { |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
cutout: '75%', |
|
|
animation: { |
|
|
animateRotate: true, |
|
|
duration: 800, |
|
|
easing: 'easeOutQuart' |
|
|
}, |
|
|
plugins: { |
|
|
legend: { |
|
|
display: false |
|
|
}, |
|
|
tooltip: { |
|
|
enabled: false |
|
|
} |
|
|
}, |
|
|
interaction: { |
|
|
mode: 'nearest', |
|
|
intersect: true |
|
|
} |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
// Watchlist removed - not needed in dashboard |
|
|
|
|
|
renderNewsAccordion(news) { |
|
|
const container = document.getElementById('news-accordion'); |
|
|
if (!container) return; |
|
|
|
|
|
// ONLY SHOW REAL NEWS - NO DEMO DATA |
|
|
if (!news || !news.length) { |
|
|
container.innerHTML = ` |
|
|
<div class="empty-state small" style="padding: 20px; text-align: center;"> |
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin: 0 auto 12px; opacity: 0.3;"> |
|
|
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/> |
|
|
</svg> |
|
|
<p style="color: var(--text-muted); font-size: 13px;">No news available</p> |
|
|
<p style="color: var(--text-light); font-size: 11px; margin-top: 4px;">News API is not responding</p> |
|
|
</div> |
|
|
`; |
|
|
return; |
|
|
} |
|
|
|
|
|
const items = news.slice(0, this.config.maxNewsItems).map((item, i) => { |
|
|
const isExpanded = this.expandedNews.has(i); |
|
|
const time = this.formatRelativeTime(item.published_at); |
|
|
return ` |
|
|
<div class="accordion-item ${isExpanded ? 'expanded' : ''}" data-index="${i}"> |
|
|
<div class="accordion-header"> |
|
|
<div class="accordion-title"> |
|
|
<span class="news-source-badge">${item.source || 'News'}</span> |
|
|
<span class="news-title-text">${item.title}</span> |
|
|
</div> |
|
|
<div class="accordion-meta"> |
|
|
<span class="news-time">${time}</span> |
|
|
<svg class="accordion-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg> |
|
|
</div> |
|
|
</div> |
|
|
<div class="accordion-body"> |
|
|
<p class="news-summary">${item.summary || item.description || 'No summary available.'}</p> |
|
|
<a href="${item.url || '#'}" class="news-link" target="_blank" rel="noopener">Read full article →</a> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
}).join(''); |
|
|
|
|
|
container.innerHTML = items; |
|
|
|
|
|
// Bind accordion toggle |
|
|
container.querySelectorAll('.accordion-header').forEach(header => { |
|
|
header.addEventListener('click', () => { |
|
|
const item = header.closest('.accordion-item'); |
|
|
const index = parseInt(item.dataset.index); |
|
|
item.classList.toggle('expanded'); |
|
|
if (this.expandedNews.has(index)) { |
|
|
this.expandedNews.delete(index); |
|
|
} else { |
|
|
this.expandedNews.add(index); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
renderAlerts() { |
|
|
const container = document.getElementById('alerts-list'); |
|
|
if (!container) return; |
|
|
|
|
|
if (!this.priceAlerts.length) { |
|
|
container.innerHTML = '<div class="empty-state small">No alerts set</div>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
container.innerHTML = this.priceAlerts.map((alert, i) => ` |
|
|
<div class="alert-item ${alert.triggered ? 'triggered' : ''}"> |
|
|
<div class="alert-icon">${alert.type === 'above' ? '📈' : '📉'}</div> |
|
|
<div class="alert-info"> |
|
|
<span class="alert-symbol">${alert.symbol}</span> |
|
|
<span class="alert-condition">${alert.type === 'above' ? '>' : '<'} ${formatCurrency(alert.price)}</span> |
|
|
</div> |
|
|
<button class="remove-btn" data-index="${i}">×</button> |
|
|
</div> |
|
|
`).join(''); |
|
|
|
|
|
container.querySelectorAll('.remove-btn').forEach(btn => { |
|
|
btn.addEventListener('click', () => { |
|
|
this.priceAlerts.splice(parseInt(btn.dataset.index), 1); |
|
|
this.savePersistedData(); |
|
|
this.renderAlerts(); |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
async renderMiniStats() { |
|
|
// Fetch real system stats from API |
|
|
try { |
|
|
const response = await apiClient.fetch('/api/resources/stats', {}, 10000); |
|
|
if (response.ok) { |
|
|
const data = await response.json(); |
|
|
const stats = data.data || data; |
|
|
|
|
|
// Use real stats from API |
|
|
const rt = stats.response_time_avg || stats.avg_response_time || 0; |
|
|
const cache = stats.cache_hit_rate || stats.cache_efficiency || 0; |
|
|
const sessions = stats.active_sessions || stats.concurrent_requests || 0; |
|
|
|
|
|
// Update UI with real data |
|
|
const rtEl = document.querySelector('.mini-stat[data-stat="rt"]'); |
|
|
const cacheEl = document.querySelector('.mini-stat[data-stat="cache"]'); |
|
|
const sessionsEl = document.querySelector('.mini-stat[data-stat="sessions"]'); |
|
|
|
|
|
if (rtEl) rtEl.textContent = `${rt}ms`; |
|
|
if (cacheEl) cacheEl.textContent = `${cache}%`; |
|
|
if (sessionsEl) sessionsEl.textContent = `${sessions}`; |
|
|
return; |
|
|
} |
|
|
} catch (e) { |
|
|
console.warn('[Dashboard] System stats unavailable'); |
|
|
} |
|
|
|
|
|
// If API fails, show "N/A" instead of random data |
|
|
const rtEl = document.querySelector('.mini-stat[data-stat="rt"]'); |
|
|
const cacheEl = document.querySelector('.mini-stat[data-stat="cache"]'); |
|
|
const sessionsEl = document.querySelector('.mini-stat[data-stat="sessions"]'); |
|
|
|
|
|
if (rtEl) rtEl.textContent = 'N/A'; |
|
|
if (cacheEl) cacheEl.textContent = 'N/A'; |
|
|
if (sessionsEl) sessionsEl.textContent = 'N/A'; |
|
|
|
|
|
const el1 = document.getElementById('stat-response'); |
|
|
const el2 = document.getElementById('stat-cache'); |
|
|
const el3 = document.getElementById('stat-sessions'); |
|
|
|
|
|
if (el1) el1.textContent = `${rt}ms`; |
|
|
if (el2) el2.textContent = `${cache}%`; |
|
|
if (el3) el3.textContent = sessions; |
|
|
} |
|
|
|
|
|
// ============================================================================ |
|
|
// HELPERS |
|
|
// ============================================================================ |
|
|
|
|
|
// Watchlist methods removed - not needed in dashboard |
|
|
|
|
|
showAddAlertModal() { |
|
|
const symbol = prompt('Enter symbol (e.g., BTC):'); |
|
|
if (!symbol) return; |
|
|
const price = parseFloat(prompt('Target price:')); |
|
|
if (isNaN(price)) return; |
|
|
const type = confirm('Alert when ABOVE? (Cancel for below)') ? 'above' : 'below'; |
|
|
this.priceAlerts.push({ symbol: symbol.toUpperCase(), price, type, triggered: false }); |
|
|
this.savePersistedData(); |
|
|
this.renderAlerts(); |
|
|
this.showToast('Alert created', 'success'); |
|
|
} |
|
|
|
|
|
filterMarketTable(q) { |
|
|
if (!this.marketData) return; |
|
|
const filtered = q ? this.marketData.filter(c => c.name?.toLowerCase().includes(q.toLowerCase()) || c.symbol?.toLowerCase().includes(q.toLowerCase())) : this.marketData; |
|
|
await this.renderMarketTable(filtered); |
|
|
} |
|
|
|
|
|
sortMarketData(by) { |
|
|
if (!this.marketData) return; |
|
|
const sorted = [...this.marketData].sort((a, b) => { |
|
|
if (by === 'price') return (b.current_price || 0) - (a.current_price || 0); |
|
|
if (by === 'change') return Math.abs(b.price_change_percentage_24h || 0) - Math.abs(a.price_change_percentage_24h || 0); |
|
|
return (a.market_cap_rank || 0) - (b.market_cap_rank || 0); |
|
|
}); |
|
|
await this.renderMarketTable(sorted); |
|
|
} |
|
|
|
|
|
formatRelativeTime(date) { |
|
|
if (!date) return ''; |
|
|
const diff = Date.now() - new Date(date).getTime(); |
|
|
const min = Math.floor(diff / 60000); |
|
|
if (min < 60) return `${min}m ago`; |
|
|
const hr = Math.floor(min / 60); |
|
|
if (hr < 24) return `${hr}h ago`; |
|
|
return `${Math.floor(hr / 24)}d ago`; |
|
|
} |
|
|
|
|
|
updateTimestamp() { |
|
|
const el = document.getElementById('last-update'); |
|
|
if (el) el.textContent = new Date().toLocaleTimeString(); |
|
|
} |
|
|
|
|
|
showToast(msg, type = 'info') { |
|
|
const colors = { success: '#22c55e', error: '#ef4444', warning: '#f59e0b', info: '#3b82f6' }; |
|
|
const toast = document.createElement('div'); |
|
|
toast.className = 'toast-notification'; |
|
|
toast.style.cssText = `position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:12px;background:${colors[type]};color:#fff;z-index:9999;animation:slideIn .3s ease;font-weight:500;box-shadow:0 8px 24px rgba(0,0,0,.3);`; |
|
|
toast.textContent = msg; |
|
|
document.body.appendChild(toast); |
|
|
setTimeout(() => { toast.style.animation = 'slideOut .3s ease'; setTimeout(() => toast.remove(), 300); }, 3000); |
|
|
} |
|
|
} |
|
|
|
|
|
// Initialize |
|
|
const dashboard = new DashboardPage(); |
|
|
window.dashboardPage = dashboard; |
|
|
if (document.readyState === 'loading') { |
|
|
document.addEventListener('DOMContentLoaded', () => dashboard.init()); |
|
|
} else { |
|
|
setTimeout(() => dashboard.init(), 0); |
|
|
} |
|
|
|
|
|
export default dashboard; |
|
|
|