411 lines
11 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|