Tool Nº2 ChartJS

/Home /Journal /Services /Projects

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)

parseCSVAndPrepareData(csvText)

renderSingleMetricChart(containerName, metricType, data, chartsWrapper)

initializeTimelineCharts()