/** * Chart utilities for activity visualization */ /** * Chart utilities for activity visualization */ class ActivityChart { constructor(canvasId) { this.canvasId = canvasId; this.chart = null; this.scraperChart = null; this.initChart(); } initChart() { // Check if Chart.js is available if (typeof Chart === "undefined") { console.error("Chart.js is not loaded"); return; } const chartElement = document.getElementById(this.canvasId); if (!chartElement) { console.error( `Chart canvas element with id "${this.canvasId}" not found` ); return; } // Set canvas height directly chartElement.style.height = "300px"; chartElement.height = 300; this.ctx = chartElement.getContext("2d"); // Initialize scraper activity chart this.initScraperChart(); } initScraperChart() { const scraperChartElement = document.getElementById("scraperActivityChart"); if (!scraperChartElement) { console.warn("Scraper activity chart element not found"); return; } this.scraperCtx = scraperChartElement.getContext("2d"); } /** * Render the activity chart with provided data * @param {Object} data - Chart data object with hourly_stats and scraper_timeline */ render(data) { if (!this.ctx) { console.error("Chart context not available"); return; } console.log("Render received data:", data); // Handle both old and new data formats for compatibility const hourlyStats = data.hourly_stats || data; const scraperTimeline = data.scraper_timeline || []; console.log("Extracted hourlyStats:", hourlyStats); console.log("Extracted scraperTimeline:", scraperTimeline); // Extract the data for the main chart (papers only) const labels = hourlyStats.map((item) => item.hour); const successData = hourlyStats.map((item) => item.success); const errorData = hourlyStats.map((item) => item.error); const pendingData = hourlyStats.map((item) => item.pending); // Destroy existing charts if they exist if (this.chart) { this.chart.destroy(); } if (this.scraperChart) { this.scraperChart.destroy(); } // Render main chart (papers only) this.chart = new Chart(this.ctx, { type: "bar", data: { labels: labels, datasets: [ { label: "Success", data: successData, backgroundColor: "#28a745", stack: "Papers", }, { label: "Error", data: errorData, backgroundColor: "#dc3545", stack: "Papers", }, { label: "Pending", data: pendingData, backgroundColor: "#ffc107", stack: "Papers", }, ], }, options: { responsive: true, maintainAspectRatio: true, aspectRatio: 2.5, layout: { padding: { top: 20, bottom: 20, }, }, plugins: { legend: { position: "top", }, tooltip: { mode: "index", intersect: false, }, }, scales: { x: { stacked: true, title: { display: true, text: "Time (Last Hours)", }, }, y: { type: "linear", display: true, stacked: true, beginAtZero: true, title: { display: true, text: "Papers Scraped", }, }, }, }, }); // Render scraper activity timeline chart with precise timing this.renderScraperChart(labels, scraperTimeline, hourlyStats.length); // Show simple legend for scraper activity this.showScraperStateLegend(); } /** * Render the separate scraper activity timeline chart with precise timestamps * @param {Array} hourLabels - Hour labels for main chart * @param {Array} scraperTimeline - Timeline of scraper state changes * @param {number} totalHours - Total hours range being displayed */ renderScraperChart(hourLabels, scraperTimeline, totalHours) { if (!this.scraperCtx) { console.warn("Scraper chart context not available"); return; } let timelineData = []; if (scraperTimeline && scraperTimeline.length > 0) { console.log("Original scraper timeline:", scraperTimeline); // Filter out duplicate events with the same action, status, and hours_ago const uniqueTimeline = scraperTimeline.filter((event, index, self) => { return ( index === self.findIndex( (e) => e.action === event.action && e.status === event.status && e.hours_ago === event.hours_ago ) ); }); console.log("Filtered unique timeline:", uniqueTimeline); // Sort timeline by hours_ago (oldest first = highest hours_ago first) const sortedTimeline = [...uniqueTimeline].sort( (a, b) => b.hours_ago - a.hours_ago ); console.log("Sorted scraper timeline:", sortedTimeline); // Create simple timeline with relative positions let currentState = 0; // Use hours_ago directly as x-coordinates (inverted so recent is on right) for (let i = 0; i < sortedTimeline.length; i++) { const event = sortedTimeline[i]; console.log(`Processing event ${i}:`, event); // Set the new state based on the action if (event.action === "start_scraper" && event.status === "success") { currentState = 1; } else if ( event.action === "stop_scraper" && event.status === "success" ) { currentState = 0; } else if ( event.action === "reset_scraper" && event.status === "success" ) { currentState = 0; } else if ( event.action === "pause_scraper" && event.status === "success" ) { currentState = 0; // Treat pause as inactive } console.log( `New state for ${event.action}: ${currentState} at ${event.hours_ago}h ago` ); // Use negative hours_ago so recent events are on the right timelineData.push({ x: -event.hours_ago, y: currentState, }); } // Add current time point timelineData.push({ x: 0, // Current time y: currentState, }); console.log("Final timeline data:", timelineData); } else { // No timeline data, show as inactive timelineData = [{ x: 0, y: 0 }]; } this.scraperChart = new Chart(this.scraperCtx, { type: "line", data: { datasets: [ { label: "Scraper Active", data: timelineData, borderColor: "#28a745", backgroundColor: "rgba(40, 167, 69, 0.1)", borderWidth: 3, fill: true, stepped: "before", // Creates step transitions pointRadius: 5, pointHoverRadius: 7, pointBackgroundColor: "#28a745", pointBorderColor: "#ffffff", pointBorderWidth: 2, tension: 0, }, ], }, options: { responsive: true, maintainAspectRatio: true, aspectRatio: 10, layout: { padding: { top: 10, bottom: 10, }, }, plugins: { legend: { display: false, }, tooltip: { callbacks: { label: function (context) { const status = context.parsed.y === 1 ? "Activated" : "Deactivated"; const timestamp = new Date(); timestamp.setHours( timestamp.getHours() - Math.abs(context.parsed.x) ); const formattedTime = timestamp.toLocaleString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit", day: "2-digit", month: "2-digit", year: "numeric", }); return `Scraper: ${status} at ${formattedTime}`; }, }, }, }, scales: { x: { type: "linear", title: { display: true, text: "Timeline (Hours Ago → Now)", }, ticks: { callback: function (value) { if (value === 0) return "Now"; return `${Math.abs(value)}h ago`; }, }, grid: { display: true, }, }, y: { type: "linear", display: true, beginAtZero: true, max: 1.2, min: -0.2, title: { display: true, text: "Active Status", }, ticks: { stepSize: 1, callback: function (value) { return value === 1 ? "Active" : value === 0 ? "Inactive" : ""; }, }, grid: { color: function (context) { return context.tick.value === 0.5 ? "rgba(0,0,0,0.1)" : "rgba(0,0,0,0.05)"; }, }, }, }, }, }); } /** * Show a simple legend for scraper states */ showScraperStateLegend() { let legendContainer = document.getElementById("scraper-state-legend"); if (!legendContainer) { return; } legendContainer.classList.remove("d-none"); legendContainer.innerHTML = ` The line chart below shows exact timestamps when the scraper was started or stopped with proper time intervals. `; } /** * Load and render chart data for specified time range * @param {number} hours - Number of hours to show data for */ async loadData(hours) { try { const response = await fetch(`/scraper/stats?hours=${hours}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); console.log("Stats data loaded:", data); this.render(data); } catch (error) { console.error("Failed to load activity stats:", error); // Hide the chart or show an error message const chartContainer = document.getElementById( this.canvasId ).parentElement; if (chartContainer) { chartContainer.innerHTML = '

Chart data unavailable

'; } } } /** * Destroy the chart instance */ destroy() { if (this.chart) { this.chart.destroy(); this.chart = null; } if (this.scraperChart) { this.scraperChart.destroy(); this.scraperChart = null; } } }