ChartJS
Chart.js is a free, open-source JavaScript library that makes adding elegant, responsive charts to any web page incredibly simple. It saved me from setting up multiple extra Docker containers just for monitoring, proving that sometimes, less infrastructure is more.
I’ve recently picked up on my personal website, “collected the dust” and played with it for a bit. I decided i wanted to implement charts to track my docker containers metrics and so i started spinning CAdvisor + Prometheus + Grafana containers, but felt overkill for just a simple page😫.
First built a simple python script to collect, aggregate and store the data in a public accessible place.
The Python Script
Python Script
import subprocess
import time
import csv
from datetime import datetime, timedelta
from pathlib import Path
import os
import re
# --- CONFIGURATION ---
# Path to the log file (expanded to absolute path)
LOG_FILE = Path("~/terramoto.xyz/public/docker_stats.csv").expanduser()
# Interval between data snapshots (in seconds)
SNAPSHOT_INTERVAL = 5
# NEW CONFIG: Final Log Interval (in seconds, 120 seconds = 2 minutes)
FINAL_LOG_INTERVAL_SEC = 120
# NEW CONFIG: Minimum duration a container must exist to be logged (60 seconds = 1 minute)
# This prevents logging for short-lived 'docker run --rm' containers.
MINIMUM_LIFESPAN_SEC = 60
# Calculated: Number of snapshots needed for one full log entry (2 minutes)
REQUIRED_SAMPLES = FINAL_LOG_INTERVAL_SEC // SNAPSHOT_INTERVAL
# Calculated: Minimum samples required to be CONSIDERED for logging (1 minute)
MINIMUM_SAMPLES_TO_LOG = MINIMUM_LIFESPAN_SEC // SNAPSHOT_INTERVAL
# Maximum data retention period (in days)
MAX_RETENTION_DAYS = 30
# Docker command format for outputting CSV-ready data
# NOTE: {{.PIDs}} has been removed as it is no longer required for aggregation.
DOCKER_FORMAT = '{{.ID}},{{.Name}},{{.CPUPerc}},{{.MemUsage}},{{.NetIO}},{{.BlockIO}}'
DOCKER_CMD = [
'docker',
'stats',
'--no-stream',
'--format',
DOCKER_FORMAT
]
# CSV Header fields - Reflecting the final aggregated data
# These are the headers that will be written to the output file.
CSV_HEADER = [
'timestamp',
'container_id',
'name',
'avg_cpu_perc',
'avg_mem_used_mib',
'avg_net_rx_mib',
'avg_net_tx_mib',
'avg_block_read_mib',
'avg_block_write_mib'
]
# --- Optimized In-Memory Aggregation Buffer ---
# Structure: {
# container_id: {
# 'count': N,
# 'name': 'web-app',
# 'cpu_perc_sum': X,
# 'mem_used_mib_sum': Y,
# ...
# }
# }
aggregation_buffer = {}
# --- Utility Functions for Data Parsing and Summation ---
def convert_unit_to_mib(value_str):
"""
Converts a Docker unit string (e.g., '10.5KB', '1.2GB') to float value in MiB.
"""
if not value_str or value_str.lower() == 'n/a':
return 0.0
# Standardize KiB/MiB/GiB to KB/MB/GB for simpler regex matching
processed_value_str = value_str.strip().upper().replace('IB', 'B')
match = re.match(r"(\d*\.?\d+)\s*([KMG]?)B?", processed_value_str)
if not match:
return 0.0
value = float(match.group(1))
# match.group(2) captures the unit prefix (K, M, G). It can be empty for Bytes.
unit = match.group(2)
# Convert everything to Megabytes (MiB)
if unit == 'G':
# GiB to MiB
return value * 1024.0
elif unit == 'M':
# MiB (base unit)
return value
elif unit == 'K':
# KiB to MiB
return value / 1024.0
elif not unit:
# Bytes to MiB
return value / (1024.0 * 1024.0)
return 0.0
def parse_docker_stats(line):
"""
Parses a single line of Docker stats output and returns a dictionary
of numeric stats ready for summation.
Now expects 6 fields: ID, Name, CPU, Mem, Net, Block.
"""
parts = line.split(',')
# Check for 6 fields (ID, Name, CPU, Mem, Net, Block)
if len(parts) != 6:
print(f"Warning: Skipping incomplete stats line (expected 6 parts, got {len(parts)}): {line}")
return None
# Destructure the raw stats line (PIDs field removed)
container_id, name, cpu_perc_raw, mem_usage_limit_raw, net_io_raw, block_io_raw = parts
# Clean up non-numeric fields
container_id = container_id.strip()
name = name.strip()
try:
cpu_perc = float(cpu_perc_raw.strip('%'))
except ValueError:
cpu_perc = 0.0
# Since PIDs are no longer collected, pids is set to 0 and aggregation logic for it is removed
pids = 0
# 3. Memory Usage (Used memory only)
mem_used_raw = mem_usage_limit_raw.split('/')[0].strip()
mem_used_mib = convert_unit_to_mib(mem_used_raw)
# 4. Network I/O (RX/TX)
net_rx_raw, net_tx_raw = net_io_raw.split('/') if '/' in net_io_raw else ('0B', '0B')
net_rx_mib = convert_unit_to_mib(net_rx_raw)
net_tx_mib = convert_unit_to_mib(net_tx_raw)
# 5. Block I/O (READ/WRITE)
block_read_raw, block_write_raw = block_io_raw.split('/') if '/' in block_io_raw else ('0B', '0B')
block_read_mib = convert_unit_to_mib(block_read_raw)
block_write_mib = convert_unit_to_mib(block_write_raw)
return {
'id': container_id,
'name': name,
'cpu_perc': cpu_perc,
'mem_used_mib': mem_used_mib,
'net_rx_mib': net_rx_mib,
'net_tx_mib': net_tx_mib,
'block_read_mib': block_read_mib,
'block_write_mib': block_write_mib,
# PIDs is kept here with value 0 for compatibility with the sum structure,
# but its sum is removed from the aggregation buffer.
'pids': pids
}
def update_running_sum(stats):
"""
Updates the aggregation buffer with the latest snapshot stats.
Initializes the container entry if it doesn't exist.
"""
container_id = stats['id']
# Initialize container's sum data structure if it's new
if container_id not in aggregation_buffer:
aggregation_buffer[container_id] = {
'count': 0,
'name': stats['name'],
'cpu_perc_sum': 0.0,
'mem_used_mib_sum': 0.0,
'net_rx_mib_sum': 0.0,
'net_tx_mib_sum': 0.0,
'block_read_mib_sum': 0.0,
'block_write_mib_sum': 0.0,
'has_been_logged': False # Track if this container has ever been logged
# PIDS sum removed
}
buffer = aggregation_buffer[container_id]
# Update running sums
buffer['count'] += 1
buffer['name'] = stats['name'] # Always use the latest name
buffer['cpu_perc_sum'] += stats['cpu_perc']
buffer['mem_used_mib_sum'] += stats['mem_used_mib']
buffer['net_rx_mib_sum'] += stats['net_rx_mib']
buffer['net_tx_mib_sum'] += stats['net_tx_mib']
buffer['block_read_mib_sum'] += stats['block_read_mib']
buffer['block_write_mib_sum'] += stats['block_write_mib']
# PIDs sum removed
def write_aggregated_data_to_file():
"""
Checks the aggregation_buffer. If any container has enough samples (REQUIRED_SAMPLES),
it calculates the 2-minute average, writes it to file, and clears
the buffer for that container.
"""
global aggregation_buffer
current_time = datetime.now().isoformat()
containers_to_save = []
# Use list() to iterate over a copy, allowing modification of the original dict
for container_id, buffer in list(aggregation_buffer.items()):
# Check if we have enough samples for a FULL aggregation window (2 minutes)
if buffer['count'] >= REQUIRED_SAMPLES and buffer['count'] > 0:
# --- NEW LIFESPAN CHECK (1 minute minimum) ---
# If the container has not been logged yet, it must meet the MINIMUM_SAMPLES_TO_LOG
# to prevent a container that appeared and disappeared quickly from being logged
# as an aggregated 2-minute entry.
if not buffer['has_been_logged'] and buffer['count'] < MINIMUM_SAMPLES_TO_LOG:
# If a new container has samples but hasn't reached the 1-minute threshold,
# we don't log it, but we also don't delete the buffer yet.
# This ensures short-lived containers are not logged.
continue
# ---------------------------------------------
num_samples = buffer['count']
# Calculate the final average (constant time operation: O(1))
row_data = {
'id': container_id,
'name': buffer['name'],
'avg_cpu_perc': buffer['cpu_perc_sum'] / num_samples,
'avg_mem_used_mib': buffer['mem_used_mib_sum'] / num_samples,
'avg_net_rx_mib': buffer['net_rx_mib_sum'] / num_samples,
'avg_net_tx_mib': buffer['net_tx_mib_sum'] / num_samples,
'avg_block_read_mib': buffer['block_read_mib_sum'] / num_samples,
'avg_block_write_mib': buffer['block_write_mib_sum'] / num_samples,
# avg_pids removed
}
# 2. Format the row for CSV writing (Order must match CSV_HEADER)
row = [
current_time,
row_data['id'],
row_data['name'],
# Rounding to 2 decimal places for storage efficiency and readability
round(row_data['avg_cpu_perc'], 2),
round(row_data['avg_mem_used_mib'], 2),
round(row_data['avg_net_rx_mib'], 2),
round(row_data['avg_net_tx_mib'], 2),
round(row_data['avg_block_read_mib'], 2),
round(row_data['avg_block_write_mib'], 2),
# PIDs output removed
]
containers_to_save.append(row)
# 3. Mark the container as logged and clear the buffer
aggregation_buffer[container_id]['has_been_logged'] = True
del aggregation_buffer[container_id]
# print(f"-> Logged 2m Avg: {row_data['name']} (Samples: {num_samples})") # Removed for less verbose output
if containers_to_save:
try:
# Ensure the directory exists
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
# Use 'a' for append mode to add data rows
with open(LOG_FILE, 'a', newline='') as f:
writer = csv.writer(f)
writer.writerows(containers_to_save)
print(f"--- WROTE {len(containers_to_save)} container 2-minute averages to file ---")
except Exception as e:
print(f"Error writing aggregated data to log file: {e}")
# --- Maintenance Functions ---
def cleanup_old_data():
"""Reads the log file, deletes any entries older than MAX_RETENTION_DAYS, and rewrites the file."""
if not LOG_FILE.exists():
print(f"Cleanup: Log file {LOG_FILE} not found. Skipping cleanup.")
return
print("Cleanup: Starting data retention check...")
cutoff_time = datetime.now() - timedelta(days=MAX_RETENTION_DAYS)
try:
with open(LOG_FILE, 'r', newline='') as f:
reader = csv.reader(f)
# IMPORTANT: Try to read the existing header to preserve it during rewrite
try:
header = next(reader)
except StopIteration:
# File exists but is empty, nothing to clean up.
return
all_rows = list(reader)
except Exception as e:
print(f"Error reading log file during cleanup: {e}")
return
kept_rows = []
deleted_count = 0
for row in all_rows:
if not row:
continue
try:
# Timestamp is the first element
row_timestamp = datetime.fromisoformat(row[0])
if row_timestamp >= cutoff_time:
kept_rows.append(row)
else:
deleted_count += 1
except Exception:
# Keep rows with bad timestamps, just log a warning
print(f"Warning: Keeping row with invalid timestamp during cleanup: {row[0]}")
kept_rows.append(row)
if deleted_count > 0:
try:
with open(LOG_FILE, 'w', newline='') as f:
writer = csv.writer(f)
# Ensure the header is always rewritten with the kept rows
writer.writerow(header)
writer.writerows(kept_rows)
print(f"Cleanup: Successfully deleted {deleted_count} rows older than {MAX_RETENTION_DAYS} days.")
except Exception as e:
print(f"Error writing file during cleanup: {e}")
else:
print("Cleanup: No old data found to delete.")
def write_header_if_needed():
"""
Writes the CSV header if the log file does not exist or is empty.
This meets the requirement of only writing the header once on file creation.
"""
# Ensure the directory exists before checking file existence
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
# Check 1: Does the file exist? OR Check 2: Is the file size 0 bytes (empty)?
if not LOG_FILE.exists() or os.path.getsize(LOG_FILE) == 0:
print(f"Creating new aggregated log file and writing header: {LOG_FILE}")
with open(LOG_FILE, 'w', newline='') as f:
writer = csv.writer(f)
# This is the primary point where the header is written
writer.writerow(CSV_HEADER)
def collect_and_aggregate():
"""
Executes the docker stats command, parses the output, and updates the
in-memory aggregation buffer. This function is called every SNAPSHOT_INTERVAL.
"""
try:
# Execute the docker stats command
result = subprocess.run(DOCKER_CMD, capture_output=True, text=True, check=True)
lines = result.stdout.strip().split('\n')
# Process each line
for line in lines:
if line:
stats = parse_docker_stats(line)
if stats:
update_running_sum(stats)
# Check if any container aggregation is complete and needs to be written
write_aggregated_data_to_file()
except subprocess.CalledProcessError as e:
# Handle cases where docker stats fails (e.g., no docker daemon, permission issues)
print(f"Error calling 'docker stats': {e.stderr.strip()}")
except Exception as e:
print(f"An unexpected error occurred during collection: {e}")
# --- Main Execution Loop ---
def main():
"""Main function to run the data collection and aggregation loop."""
print("--- Optimized Docker Stats Aggregator Initializing ---")
print(f"Logging to: {LOG_FILE.resolve()}")
print(f"Snapshot Interval: {SNAPSHOT_INTERVAL} seconds")
print(f"Final Log Interval: {FINAL_LOG_INTERVAL_SEC} seconds ({FINAL_LOG_INTERVAL_SEC/60:.0f} minutes)")
print(f"Minimum Lifespan to Log: {MINIMUM_LIFESPAN_SEC} seconds ({MINIMUM_SAMPLES_TO_LOG} samples)")
print(f"Required Samples for Full Aggregation: {REQUIRED_SAMPLES}")
print(f"Retention: {MAX_RETENTION_DAYS} days")
print("-" * 55)
# 1. Ensure the header is written only if the file is new or empty
write_header_if_needed()
# 2. Cleanup handles data removal and ensures the header is preserved when rewriting the file
cleanup_old_data()
try:
while True:
# Core loop: collect a snapshot, update sums, check for write threshold
collect_and_aggregate()
time.sleep(SNAPSHOT_INTERVAL)
except KeyboardInterrupt:
print("\n--- Docker Stats Aggregator Stopped by User ---")
# --- REMOVED: Forced write of partial data to prevent logging of short-lived containers ---
print(f"Note: Skipping final aggregation of partial data to honor minimum lifespan setting ({MINIMUM_LIFESPAN_SEC}s).")
print("Done.")
except Exception as e:
print(f"\n--- Critical Error ---: {e}")
if __name__ == "__main__":
main()
The JavaScript… 🤔 Script?
This is where the power of Chart.js truly shines.This script lives on the website page, fetches the docker_stats.csv file, and instantly draws all the charts without needing any server-side processing.
Chart.js Script
/**
* Single-Metric Timeline Chart Initializer (Chart.js)
*
* This script fetches a CSV file where the data is ALREADY processed and aggregated
* by a backend script (e.g., 5-minute averages, units converted to MiB).
*
* It reads the pre-calculated metrics (e.g., avg_cpu_perc, avg_mem_used_mib)
* and generates time-series charts for each container/metric combination.
*/
// --- Configuration ---
const metricsUrl = '/docker_stats.csv';
// --- Constants and Color Palette ---
const targetContainerId = 'container-charts';
// Consistent colors for different metric groups and streams
const metricColors = {
'cpu': { primary: 'rgb(255, 99, 132)' },
'mem': { primary: 'rgb(54, 162, 235)' },
'net': { primary: 'rgb(75, 192, 192)', secondary: 'rgb(102, 102, 255)' },
'block': { primary: 'rgb(255, 159, 64)', secondary: 'rgb(153, 102, 255)' },
};
// Expected headers from the AGGREGATED Python script output
const EXPECTED_HEADERS = [
'timestamp', 'container_id', 'name', 'avg_cpu_perc',
'avg_mem_used_mib', 'avg_net_rx_mib', 'avg_net_tx_mib',
'avg_block_read_mib', 'avg_block_write_mib'
];
/**
* Fetches CSV data from the specified URL.
* @param {string} url - The URL of the CSV file.
* @returns {Promise<string>} The raw CSV data string.
*/
const fetchCsvData = async (url) => {
const statusDiv = document.getElementById(`${targetContainerId}-status`);
if (statusDiv) statusDiv.innerHTML = 'Fetching data...';
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (statusDiv) statusDiv.innerHTML = 'Data fetched successfully. Processing...';
statusDiv.innerHTML = "";
return await response.text();
} catch (error) {
if (statusDiv) statusDiv.innerHTML =
`<p>Failed to load data. Check file path and console for errors.</p>`;
throw error;
}
};
/**
* Parses CSV data, groups by container/metric, and prepares the data for Chart.js.
* @param {string} csv - The raw CSV data.
* @returns {Object<string, Array<object>>} Grouped data ready for plotting.
*/
const parseCSVAndPrepareData = (csv) => {
const lines = csv.trim().split('\n');
if (lines.length < 2) return {};
const headers = lines[0].split(',').map(h => h.trim());
const dataRows = lines.slice(1);
// 1. Raw Data Grouping: { 'containerName': { allRecords: [] } }
const rawGroup = {};
// First pass: Parse all raw values and group records by container
dataRows.forEach(row => {
const values = row.split(',').map(v => v ? v.trim() : '');
const record = {};
headers.forEach((header, i) => {
record[header] = values[i];
});
const name = record.name;
if (!name) return;
// Ensure timestamp is parsed as a moment object's value for sorting
const timestamp = moment(record.timestamp).valueOf();
if (!rawGroup[name]) {
rawGroup[name] = { allRecords: [] };
}
// --- SIMPLIFIED PARSING: Read pre-calculated floats directly ---
const parsedRecord = {
timestamp: timestamp,
// CPU: Read float
cpuValue: parseFloat(record.avg_cpu_perc),
// Memory: Read float (MiB used)
memUsedMb: parseFloat(record.avg_mem_used_mib),
// NET: Read float (cumulative values)
netRecTotal: parseFloat(record.avg_net_rx_mib),
netTransTotal: parseFloat(record.avg_net_tx_mib),
// BLOCK: Read float (cumulative values)
blockReadTotal: parseFloat(record.avg_block_read_mib),
blockWriteTotal: parseFloat(record.avg_block_write_mib),
};
// ----------------------------------------------------------------
// Only push if the timestamp is valid
if (!isNaN(parsedRecord.timestamp)) {
rawGroup[name].allRecords.push(parsedRecord);
}
});
// 2. Prepare Metric Data
const metricDataGroup = {};
Object.keys(rawGroup).forEach(name => {
// Sort records by timestamp
const records = rawGroup[name].allRecords.sort((a, b) => a.timestamp - b.timestamp);
metricDataGroup[name] = {};
// CPU - Single stream (already an average percentage)
const cpuAvg = records.filter(r => !isNaN(r.cpuValue)).map(r => ({ x: r.timestamp, y: r.cpuValue }));
metricDataGroup[name].cpu = [{ label: `CPU (%) Avg`, metricType: 'cpu', dataPoints: cpuAvg }];
// Memory - Single stream (already an average MiB usage)
const memAvg = records.filter(r => !isNaN(r.memUsedMb)).map(r => ({ x: r.timestamp, y: r.memUsedMb }));
metricDataGroup[name].mem = [{ label: `Memory Used (MiB) Avg`, metricType: 'mem', dataPoints: memAvg }];
// Net I/O - Two streams (calculate change between consecutive data points)
const netRecChange = [];
const netTransChange = [];
for (let i = 1; i < records.length; i++) {
const prev = records[i - 1];
const curr = records[i];
// Calculate time difference in seconds
const timeDiff = (curr.timestamp - prev.timestamp) / 1000;
// Calculate change in values
if (!isNaN(curr.netRecTotal) && !isNaN(prev.netRecTotal) && timeDiff > 0) {
const change = curr.netRecTotal - prev.netRecTotal;
netRecChange.push({ x: curr.timestamp, y: change });
}
if (!isNaN(curr.netTransTotal) && !isNaN(prev.netTransTotal) && timeDiff > 0) {
const change = curr.netTransTotal - prev.netTransTotal;
netTransChange.push({ x: curr.timestamp, y: change });
}
}
metricDataGroup[name].net = [
{ label: `Net Received (Change)`, stream: 'primary', metricType: 'net', dataPoints: netRecChange },
{ label: `Net Transmitted (Change)`, stream: 'secondary', metricType: 'net', dataPoints: netTransChange },
];
// Block I/O - Two streams (calculate change between consecutive data points)
const blockReadChange = [];
const blockWriteChange = [];
for (let i = 1; i < records.length; i++) {
const prev = records[i - 1];
const curr = records[i];
// Calculate time difference in seconds
const timeDiff = (curr.timestamp - prev.timestamp) / 1000;
// Calculate change in values
if (!isNaN(curr.blockReadTotal) && !isNaN(prev.blockReadTotal) && timeDiff > 0) {
const change = curr.blockReadTotal - prev.blockReadTotal;
blockReadChange.push({ x: curr.timestamp, y: change });
}
if (!isNaN(curr.blockWriteTotal) && !isNaN(prev.blockWriteTotal) && timeDiff > 0) {
const change = curr.blockWriteTotal - prev.blockWriteTotal;
blockWriteChange.push({ x: curr.timestamp, y: change });
}
}
metricDataGroup[name].block = [
{ label: `Block Read (Change)`, stream: 'primary', metricType: 'block', dataPoints: blockReadChange },
{ label: `Block Write (Change)`, stream: 'secondary', metricType: 'block', dataPoints: blockWriteChange },
];
});
// 3. Final Formatting
const finalGroup = {};
Object.keys(metricDataGroup).forEach(name => {
Object.keys(metricDataGroup[name]).forEach(metricType => {
const itemArray = metricDataGroup[name][metricType];
const key = `${name}_${metricType}`;
finalGroup[key] = [];
const colors = metricColors[metricType];
itemArray.forEach((item) => {
// Determine color based on stream (primary for Rec/Read, secondary for Trans/Write)
const isSingleStream = metricType === 'cpu' || metricType === 'mem';
const baseColor = isSingleStream ? colors.primary : colors[item.stream];
finalGroup[key].push({
label: item.label,
data: item.dataPoints,
borderColor: baseColor,
backgroundColor: `rgba(${baseColor.match(/\d+/g).join(',')}, 0.1)`,
borderWidth: 2,
pointRadius: 1,
fill: false,
tension: 0.3,
});
});
});
});
return finalGroup;
};
/**
* Renders an individual single-metric chart.
* @param {string} containerName - The name of the container.
* @param {string} metricType - The type of metric ('cpu', 'mem', 'net', 'block').
* @param {Array<object>} datasets - The Chart.js datasets.
* @param {HTMLElement} targetContainer - The container element to append the chart to.
*/
const renderSingleMetricChart = (containerName, metricType, datasets, targetContainer) => {
const canvasId = `chart-${containerName.replace(/[^a-zA-Z0-9]/g, '-')}-${metricType}`;
const container = targetContainer || document.getElementById(targetContainerId);
// Determine Y-axis formatting
const isCpu = metricType === 'cpu';
const isMem = metricType === 'mem';
const isChange = metricType === 'net' || metricType === 'block';
let yAxisLabel = 'Value';
if (isCpu) yAxisLabel = 'Utilization (%)';
else if (isMem) yAxisLabel = 'Memory Used (MiB)';
else yAxisLabel = 'Change (MiB)';
// Chart title indicates AGGREGATED data
let chartTitle;
if (isChange) {
chartTitle = `${metricType === 'net' ? 'Network I/O' : 'Block I/O'} (Change)`;
} else {
chartTitle = `${datasets[0].label.replace(/ \(([^)]+)\)/, '')}`;
}
// 1. Create the card wrapper
const card = document.createElement('div');
card.className = 'chart-card';
// 2. Create the title
const title = document.createElement('h2');
title.className = 'chart-title';
title.textContent = chartTitle;
// 3. Create the chart wrapper
const chartWrapper = document.createElement('div');
chartWrapper.className = 'chart-wrapper';
// 4. Create the canvas element
const canvas = document.createElement('canvas');
canvas.id = canvasId;
// 5. Assemble the DOM structure
chartWrapper.appendChild(canvas);
card.appendChild(title);
card.appendChild(chartWrapper);
container.appendChild(card);
const ctx = canvas.getContext('2d');
// 6. Create the Chart.js instance
new Chart(ctx, {
type: 'line',
data: { datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
axis: 'x'
},
plugins: {
legend: {
display: true,
position: 'bottom',
},
tooltip: {
callbacks: {
title: (items) => {
return moment(items[0].parsed.x).format('MMM D, YYYY HH:mm:ss');
},
label: (context) => {
const value = context.parsed.y.toFixed(3);
if (isCpu) return `${context.dataset.label}: ${value}%`;
if (isMem) return `${context.dataset.label}: ${value} MiB`;
if (isChange) return `${context.dataset.label}: ${value} MiB`;
return `${context.dataset.label}: ${value}`;
}
}
}
},
scales: {
x: {
type: 'time',
time: {
unit: 'minute',
tooltipFormat: 'll HH:mm',
displayFormats: {
minute: 'HH:mm',
hour: 'hA',
}
},
title: {
display: true,
text: `Timestamp (5-Minute Average)`,
font: { size: 14 }
},
ticks: { autoSkip: true, maxTicksLimit: 15 }
},
y: {
type: 'linear',
position: 'left',
min: 0,
title: {
display: true,
text: yAxisLabel,
font: { size: 14, weight: 'bold' },
color: metricColors[metricType]?.primary ?? 'rgb(0, 0, 0)',
},
ticks: {
callback: (value) => {
if (isCpu) return value.toFixed(2) + '%';
if (isMem) return value.toFixed(2) + ' MiB';
if (isChange) return value.toFixed(3) + ' MiB';
return value.toFixed(2);
},
},
grid: {
drawOnChartArea: true,
},
beginAtZero: true
}
}
}
});
};
/**
* Sets up the DOM structure and runs the main execution flow.
*/
const initializeTimelineCharts = async () => {
const container = document.getElementById(targetContainerId);
if (!container) {
console.error(`Target container '${targetContainerId}' not found.`);
return;
}
// 1. Inject status placeholder
container.innerHTML = `
<div id="${targetContainerId}-status" class="">
Loading metrics data...
</div>
`;
const statusDiv = document.getElementById(`${targetContainerId}-status`);
// Clear the container before rendering charts, but keep the status div
const statusHtml = statusDiv.outerHTML;
container.innerHTML = statusHtml;
// 2. Fetch and render
try {
const csvText = await fetchCsvData(metricsUrl);
const groupedData = parseCSVAndPrepareData(csvText);
const chartKeys = Object.keys(groupedData);
if (chartKeys.length > 0) {
statusDiv.remove(); // Remove status
container.classList.remove('justify-center');
// Group charts by container name
const containerGroups = {};
// Organize data by container name
chartKeys.forEach(key => {
const [containerName, metricType] = key.split('_');
if (!containerGroups[containerName]) {
containerGroups[containerName] = {};
}
containerGroups[containerName][metricType] = groupedData[key];
});
// Render charts grouped by container
Object.keys(containerGroups).forEach(containerName => {
// Create a section for this container
const section = document.createElement('div');
section.className = 'chart-section';
// Create container title
const title = document.createElement('h1');
title.className = 'chart-section-title';
title.textContent = `${containerName}`;
section.appendChild(title);
// Create a wrapper for all charts of this container
const chartsWrapper = document.createElement('div');
chartsWrapper.className = 'chart-row';
section.appendChild(chartsWrapper);
// Render all metrics for this container
Object.keys(containerGroups[containerName]).forEach(metricType => {
renderSingleMetricChart(containerName, metricType, containerGroups[containerName][metricType], chartsWrapper);
});
container.appendChild(section);
});
} else {
statusDiv.innerHTML = '<p>No data records found.</p>';
}
} catch (error) {
const statusDiv = document.getElementById(`${targetContainerId}-status`);
if (statusDiv) {
statusDiv.innerHTML = `<p class="font-semibold">Failed to load data:</p><p class="text-sm">${error.message}</p>`;
}
console.error("Initialization failed:", error);
}
};
// Start the process once the necessary libraries are loaded.
document.addEventListener('DOMContentLoaded', initializeTimelineCharts);
fetchCsvData(url)
- Job: It makes an HTTP request to grab the contents of the docker_stats.csv file that the Python script maintains.
- Result: It returns the raw text content of the CSV file or error.
parseCSVAndPrepareData(csvText)
- Job: It takes the raw CSV text, splits it into rows, and for every container it finds:
- It creates a separate object containing the time-series arrays for every metric (CPU, Mem, Net RX, etc.).
- For Network and Block I/O, it converts the 2-minute total into a rate (MiB/minute) by dividing by the 2-minute interval (rateDivisor = 2), making the chart display meaningful throughput.
- Result: A nested JavaScript object structure that Chart.js is able to render.
renderSingleMetricChart(containerName, metricType, data, chartsWrapper)
- Job: For every unique metric (e.g., avg_cpu_perc for web-app), this function is called once to:
- Create a dedicated canvas element on the HTML page.
- Instantiate a new
Chart()object using the canvas context. - Set the chart type to line, define the appropriate Y-axis unit (e.g., % or MiB/min), and configure the X-axis for time.
- Result: A single, clean, responsive chart displayed on the page.
initializeTimelineCharts()
- Job: It runs when the page loads, managing the entire flow:
- It calls
fetchCsvData(). - It calls
parseCSVAndPrepareData()on the result. - It loops through all the prepared container data and calls
renderSingleMetricChart()for every single metric, building the entire dashboard dynamically.
- It calls