411 lines
11 KiB
JavaScript

/**
* 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 = `
<small class="text-muted">
<i class="fas fa-info-circle"></i>
The line chart below shows exact timestamps when the scraper was started or stopped with proper time intervals.
</small>
`;
}
/**
* 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 =
'<p class="text-muted">Chart data unavailable</p>';
}
}
}
/**
* Destroy the chart instance
*/
destroy() {
if (this.chart) {
this.chart.destroy();
this.chart = null;
}
if (this.scraperChart) {
this.scraperChart.destroy();
this.scraperChart = null;
}
}
}