Speedtest Monitor
Este proyecto proporciona una solución para monitorizar la velocidad de tu conexión a internet y visualizar los resultados en un dashboard. Utiliza speedtest-cli
para realizar las pruebas y un script de Python para registrar los datos.
Despliegue con Docker
El siguiente comando despliega el servicio directamente, descargando una imagen base de Python, instalando las dependencias y ejecutando el script.
-d
: Ejecuta el contenedor en segundo plano.--name speedtest-dashboard
: Asigna un nombre al contenedor.-p 8000:8000
: Mapea el puerto 8000 del host al puerto 8000 del contenedor.-v "$(pwd)":/app
: Monta el directorio actual en/app
dentro del contenedor para persistir los datos.-w /app
: Establece/app
como el directorio de trabajo.--restart unless-stopped
: Reinicia el contenedor automáticamente a menos que se detenga manualmente.python:3.9-slim
: Imagen de Docker a utilizar.sh -c "..."
: Comando que se ejecuta al iniciar el contenedor, instalando dependencias y lanzando el script.
docker run -d \ --name speedtest-dashboard \ -p 8000:8000 \ -v "$(pwd)":/app \ -w /app \ --restart unless-stopped \ python:3.9-slim \ sh -c "pip install speedtest-cli pandas flask && python script.py"
Opciones de Despliegue Adicionales
Para un enfoque más estructurado, puedes construir una imagen de Docker personalizada usando un Dockerfile
.
-
Crea un
Dockerfile
:Crea un archivo llamado
Dockerfile
en la raíz de tu proyecto con el siguiente contenido:FROM python:3.9-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY . .CMD ["python", "script.py"] -
Crea un archivo
requirements.txt
:Este archivo listará las dependencias de Python:
speedtest-clipandasflask -
Construye y ejecuta la imagen:
Terminal window # Construir la imagendocker build -t speedtest-dashboard .# Ejecutar el contenedordocker run -d \--name speedtest-dashboard \-p 8000:8000 \--restart unless-stopped \speedtest-dashboard
Backend (script.py)
El script de Python utiliza speedtest-cli
para realizar pruebas de velocidad cada 30 minutos y expone un servidor web en el puerto 8000 para visualizar los resultados.
import http.serverimport jsonimport loggingimport osimport socketserverimport threadingimport timefrom datetime import datetime, timezonefrom speedtest import Speedtest
# --- Threading Lock ---data_lock = threading.Lock()
# --- Logging Configuration ---# Defines the name of the log file.log_file = "logs.txt"# Clears the log file on startup if it exceeds 1MB to prevent it from growing indefinitely.if os.path.exists(log_file) and os.path.getsize(log_file) > 1_000_000: # 1MB os.remove(log_file)
# Configures logging to output to both a file and the console.logging.basicConfig( level=logging.INFO, # Set the minimum level of messages to log. format="%(asctime)s - %(levelname)s - %(message)s", # Define the format of the log messages. handlers=[ logging.FileHandler(log_file), # Log messages to a file. logging.StreamHandler() # Also log messages to the console. ])
# --- Data Handling ---# Defines the name of the JSON file used to store speed test results.data_file = "data.json"# Initializes an in-memory list to hold the speed test data.data = []
def load_data(): """Loads speed test data from the JSON file into the global 'data' list.""" global data # Use a lock to prevent race conditions when accessing the data from multiple threads. with data_lock: # Check if the data file already exists. if os.path.exists(data_file): try: # Open and read the JSON file. with open(data_file, "r") as f: data = json.load(f) logging.info(f"Loaded {len(data)} existing records from {data_file}") # Handle cases where the file is empty, corrupted, or doesn't exist. except (json.JSONDecodeError, FileNotFoundError): logging.warning(f"Could not read or decode {data_file}. Starting with an empty list.") data = [] else: logging.info("No data file found. Starting with an empty list.") data = []
def save_data(): """Saves the in-memory data to the JSON file, keeping only the last 500 records.""" global data # Use a lock to ensure data integrity during file writes. with data_lock: # Keep only the last 500 records to prevent the file from growing too large. data = data[-500:] try: # Write the data to the JSON file with indentation for readability. with open(data_file, "w") as f: json.dump(data, f, indent=2) logging.info(f"Data saved. Total records: {len(data)}") except Exception as e: logging.error(f"Failed to save data: {e}")
def perform_test(): """Performs a single speed test using the speedtest-cli library and saves the results.""" global data try: logging.info("--- Starting new speed test ---") # Initialize the Speedtest client. s = Speedtest()
logging.info("Finding the best server...") # Let the library automatically find the best server based on ping. s.get_best_server()
# Safely access server details, providing default values if they are not available. server_name = s.results.server.get('name', 'Unknown Server') server_location = s.results.server.get('location', 'N/A') server_country = s.results.server.get('country', 'N/A') logging.info(f"Server found: {server_name} in {server_location}, {server_country}")
logging.info("Performing download test...") # Measure download speed and convert it from bits/s to Mbps. download = round(s.download() / 1e6, 2) logging.info(f"Download test finished: {download} Mbps")
logging.info("Performing upload test...") # Measure upload speed and convert it from bits/s to Mbps. upload = round(s.upload() / 1e6, 2) logging.info(f"Upload test finished: {upload} Mbps")
# Get the ping (latency) in milliseconds. ping = round(s.results.ping, 2)
# Get the current timestamp in UTC ISO 8601 format. timestamp = datetime.now(timezone.utc).isoformat()
# Structure the test results into a dictionary. test_data = { "timestamp": timestamp, "ping": ping, "download": download, "upload": upload, "server": server_name }
# Append the new data to the in-memory list under a lock. with data_lock: data.append(test_data)
# Save the updated list to the JSON file. save_data() logging.info(f"--- Speed test successful: Ping: {ping}ms, Down: {download}Mbps, Up: {upload}Mbps ---")
except Exception as e: # Log any exceptions that occur during the test. logging.error(f"Speedtest failed: {e}", exc_info=True)
def speedtest_loop(): """Continuously runs the speed test at a regular interval in a loop.""" # Run the first test immediately on application startup. perform_test() while True: # Set the interval between tests (e.g., 1800 seconds = 30 minutes). interval = 1800 logging.info(f"Sleeping for {int(interval / 60)} minutes until the next test.") # Wait for the specified interval before running the next test. time.sleep(interval) perform_test()
# --- Web Server ---class MyHandler(http.server.SimpleHTTPRequestHandler): """Custom HTTP request handler to serve the dashboard and handle API requests."""
def do_POST(self): """Handles POST requests, specifically for triggering a manual speed test.""" # Check if the request path is for running a test. if self.path == '/run-test': logging.info("Manual test triggered via API.") # Run the test in a new thread to avoid blocking the HTTP server. test_thread = threading.Thread(target=perform_test) test_thread.start() # Respond with 202 Accepted to indicate the request was received. self.send_response(202) self.send_header('Content-type', 'application/json') self.end_headers() self.wfile.write(b'{"message": "Test initiated."}') return
# If the path is not recognized, respond with 404 Not Found. self.send_response(404) self.end_headers()
def end_headers(self): """Overrides the default method to add headers that prevent browser caching.""" self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") self.send_header("Pragma", "no-cache") self.send_header("Expires", "0") super().end_headers()
# --- Application Startup ---if __name__ == "__main__": # Load any existing data from the JSON file. load_data()
# Start the background thread for running automatic, scheduled speed tests. # The 'daemon=True' flag ensures the thread will exit when the main program does. thread = threading.Thread(target=speedtest_loop, daemon=True) thread.start()
# Start the web server to serve the HTML dashboard. # It will run forever, handling HTTP requests. with socketserver.TCPServer(("", 8000), MyHandler) as httpd: logging.info("Server started at http://localhost:8000") httpd.serve_forever()
Frontend (index.html)
El dashboard proporciona una interfaz web moderna para visualizar los resultados de las pruebas de velocidad, incluyendo gráficos, estadísticas y logs en tiempo real.
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Speedtest Dashboard</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background-color: #f4f7f6; min-height: 100vh; padding: 20px; color: #333; } .container { max-width: 1400px; margin: 0 auto; } .header { background: #4a90e2; color: white; padding: 30px; text-align: center; border-radius: 16px 16px 0 0; } .header h1 { font-size: 2.5rem; font-weight: 700; } .card { background: #ffffff; border-radius: 16px; box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08); margin-top: 20px; overflow: hidden; } .card-header { padding: 20px; border-bottom: 1px solid #e2e8f0; } .card-header h2 { font-size: 1.3rem; font-weight: 600; } .card-content { padding: 20px; } .filters { display: flex; gap: 20px; align-items: center; } .filters label { font-weight: 600; } .filters select, .filters button { padding: 10px 15px; border-radius: 8px; border: 1px solid #ccc; font-size: 14px; cursor: pointer; } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; } .stat-card { background: #f8f9fa; padding: 20px; border-radius: 12px; text-align: center; border: 1px solid #e2e8f0; } .stat-card h3 { font-size: 0.9rem; color: #555; margin-bottom: 8px; font-weight: 600; text-transform: uppercase; } .stat-card .value { font-size: 1.8rem; font-weight: 700; color: #111; } .stat-card .unit { font-size: 0.8rem; color: #777; margin-left: 4px; } .chart-container { height: 450px; } #logContent { background-color: #2d3748; color: #e2e8f0; padding: 15px; border-radius: 8px; font-family: 'Courier New', Courier, monospace; font-size: 14px; white-space: pre-wrap; word-break: break-all; max-height: 400px; overflow-y: auto; } table { width: 100%; border-collapse: collapse; } th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #dee2e6; } th { background-color: #f8f9fa; font-weight: 600; position: sticky; top: 0; } tr:hover { background-color: #f1f3f5; } .ping-cell { color: #f59e0b; font-weight: 600; } .download-cell { color: #10b981; font-weight: 600; } .upload-cell { color: #ef4444; font-weight: 600; } .no-data { text-align: center; padding: 40px; color: #777; font-style: italic; } </style></head><body> <!-- Main container for the dashboard --> <div class="container"> <!-- Header section --> <div class="header"><h1>🌐 Internet Speed Dashboard</h1></div>
<!-- Filters card --> <div class="card"> <div class="card-header filters"> <label for="dayFilter">Filter by Day:</label> <select id="dayFilter"><option value="all">All Days</option></select> <button onclick="refreshAllData()">🔄 Refresh Data</button> <button id="runTestBtn" onclick="runTestNow()">🚀 Run Test Now</button> </div> </div>
<!-- Average statistics card --> <div class="card"> <div class="card-header"><h2>📊 Average Statistics</h2></div> <div class="card-content stats-grid"> <div class="stat-card"><h3>Avg Ping</h3><div class="value" id="avgPing">--<span class="unit">ms</span></div></div> <div class="stat-card"><h3>Avg Download</h3><div class="value" id="avgDownload">--<span class="unit">Mbps</span></div></div> <div class="stat-card"><h3>Avg Upload</h3><div class="value" id="avgUpload">--<span class="unit">Mbps</span></div></div> <div class="stat-card"><h3>Total Tests</h3><div class="value" id="totalTests">--</div></div> </div> </div>
<!-- Chart card --> <div class="card"> <div class="card-header"><h2>📈 Speed Over Time</h2></div> <div class="card-content chart-container"><canvas id="speedChart"></canvas></div> </div>
<!-- Detailed results table card --> <div class="card"> <div class="card-header"><h2>📋 Detailed Test Results</h2></div> <div class="card-content" style="max-height: 400px; overflow-y: auto;"> <table> <thead><tr><th>Timestamp</th><th>Server</th><th>Ping</th><th>Download</th><th>Upload</th></tr></thead> <tbody id="tableBody"></tbody> </table> </div> </div>
<!-- Application logs card --> <div class="card"> <div class="card-header"><h2>📝 Application Logs</h2></div> <div class="card-content"><pre id="logContent">Loading logs...</pre></div> </div> </div>
<script> // --- Global Variables --- let allData = []; // This array will store all the speed test data fetched from the server. let chart = null; // This variable will hold the Chart.js instance, so it can be destroyed and recreated.
/** * Fetches a resource from a URL. * It appends a timestamp query parameter to the URL to bypass the browser cache, * ensuring that the latest version of the file is always retrieved. * @param {string} url - The URL of the resource to fetch. * @returns {Promise<Response>} A promise that resolves to the fetch Response object. */ async function fetchData(url) { // Use the fetch API to get the resource. const response = await fetch(`${url}?t=${Date.now()}`); // Check if the request was successful. if (!response.ok) { // If not, throw an error with the status text. throw new Error(`Failed to fetch ${url}: ${response.statusText}`); } return response; }
/** * Loads the speed test data from the 'data.json' file on the server. * After fetching, it populates the day filter and updates all display components. */ async function loadData() { try { // Fetch the JSON data file. const response = await fetchData("data.json"); // Parse the JSON response into the global 'allData' array. allData = await response.json(); // Update the UI with the new data. populateDayFilter(allData); updateDisplay(); } catch (e) { // Log any errors to the console. console.error("Failed to load speedtest data:", e); // If the table is empty, show a user-friendly error message. if (document.getElementById("tableBody").innerHTML === "") { document.querySelector("#tableBody").innerHTML = '<tr><td colspan="4" class="no-data">Could not load data.json. The first test might be running.</td></tr>'; } } }
/** * Loads and displays the application logs from the 'logs.txt' file. */ async function loadLogs() { try { // Fetch the logs text file. const response = await fetchData("logs.txt"); // Get the text content from the response. const text = await response.text(); const logElement = document.getElementById("logContent"); // Set the text content of the log display element. logElement.textContent = text; // Automatically scroll the log display to the most recent entries at the bottom. logElement.scrollTop = logElement.scrollHeight; } catch (e) { console.error("Failed to load logs:", e); // Display an error message if the logs can't be loaded. document.getElementById("logContent").textContent = "Could not load logs.txt."; } }
/** * A utility function to refresh both the speed test data and the application logs. */ function refreshAllData() { loadData(); loadLogs(); }
/** * Sends a POST request to the server to trigger a new, on-demand speed test. */ async function runTestNow() { const runBtn = document.getElementById("runTestBtn"); // Disable the button to prevent multiple clicks while a test is running. runBtn.disabled = true; runBtn.textContent = "Testing...";
try { // Send a POST request to the '/run-test' endpoint. const response = await fetch('/run-test', { method: 'POST' }); // The server should respond with 202 (Accepted). if (response.status !== 202) { throw new Error(`Server responded with status ${response.status}`); } // After triggering the test, wait a bit for it to complete before refreshing the data. setTimeout(() => { refreshAllData(); // Re-enable the button after the process is complete. runBtn.disabled = false; runBtn.textContent = "🚀 Run Test Now"; }, 45000); // A 45-second delay should be sufficient for most tests. } catch (e) { console.error("Failed to trigger test:", e); alert("Failed to start the test. Check the console for details."); // Re-enable the button in case of an error. runBtn.disabled = false; runBtn.textContent = "🚀 Run Test Now"; } }
/** * Populates the 'dayFilter' dropdown with unique days extracted from the test data. * This allows the user to filter the displayed results by a specific day. * @param {Array} data - The array of speed test data objects. */ function populateDayFilter(data) { const dayFilter = document.getElementById("dayFilter"); const existingSelection = dayFilter.value; // Save the currently selected day.
// Use a Set to get a list of unique days from the timestamps. const uniqueDays = [...new Set(data.map(item => { const date = new Date(item.timestamp); // Format the date as 'YYYY-MM-DD'. return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0'); }))];
// Clear the existing options and add the default "All Days" option. dayFilter.innerHTML = '<option value="all">All Days</option>'; // Sort days in descending order and create an <option> for each. uniqueDays.sort().reverse().forEach(day => { const option = document.createElement("option"); option.value = day; option.textContent = day; dayFilter.appendChild(option); });
// If the previously selected day still exists in the list, restore the selection. if (Array.from(dayFilter.options).some(opt => opt.value === existingSelection)) { dayFilter.value = existingSelection; } }
/** * Filters the global 'allData' array based on the day selected in the dropdown. * @returns {Array} An array of data objects filtered by the selected day. */ function getFilteredData() { const selectedDay = document.getElementById("dayFilter").value; // If "All Days" is selected, return the full dataset. if (selectedDay === "all") return allData;
// Otherwise, filter the data to include only items from the selected day. return allData.filter(item => { const date = new Date(item.timestamp); const itemDay = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0'); return itemDay === selectedDay; }); }
/** * Updates the average statistics cards (Ping, Download, Upload, Total Tests). * @param {Array} data - The data to use for calculating the statistics. */ function updateStats(data) { const [avgPingEl, avgDownloadEl, avgUploadEl, totalTestsEl] = [ document.getElementById("avgPing"), document.getElementById("avgDownload"), document.getElementById("avgUpload"), document.getElementById("totalTests") ];
// If there's no data, display default placeholders. if (data.length === 0) { avgPingEl.innerHTML = '--<span class="unit">ms</span>'; avgDownloadEl.innerHTML = '--<span class="unit">Mbps</span>'; avgUploadEl.innerHTML = '--<span class="unit">Mbps</span>'; totalTestsEl.textContent = '0'; return; }
// Calculate the average for each metric. const avgPing = (data.reduce((sum, item) => sum + item.ping, 0) / data.length).toFixed(1); const avgDownload = (data.reduce((sum, item) => sum + item.download, 0) / data.length).toFixed(1); const avgUpload = (data.reduce((sum, item) => sum + item.upload, 0) / data.length).toFixed(1);
// Update the HTML elements with the calculated values. avgPingEl.innerHTML = `${avgPing}<span class="unit">ms</span>`; avgDownloadEl.innerHTML = `${avgDownload}<span class="unit">Mbps</span>`; avgUploadEl.innerHTML = `${avgUpload}<span class="unit">Mbps</span>`; totalTestsEl.textContent = data.length; }
/** * Updates the line chart with the provided data. * @param {Array} data - The data to display on the chart. */ function updateChart(data) { const ctx = document.getElementById("speedChart").getContext("2d"); // If a chart instance already exists, destroy it before creating a new one. if (chart) chart.destroy(); // If there's no data, clear the canvas. if (data.length === 0) { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); return; }
// Create a new Chart.js instance. chart = new Chart(ctx, { type: "line", // Line chart for time-series data. data: { // Use the time part of the timestamp for the x-axis labels. labels: data.map(d => new Date(d.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})), datasets: [ { label: "Download (Mbps)", data: data.map(d => d.download), borderColor: "#10b981", backgroundColor: "rgba(16, 185, 129, 0.1)", tension: 0.4, fill: true }, { label: "Upload (Mbps)", data: data.map(d => d.upload), borderColor: "#ef4444", backgroundColor: "rgba(239, 68, 68, 0.1)", tension: 0.4, fill: true }, // Assign Ping to a secondary Y-axis because its scale is different. { label: "Ping (ms)", data: data.map(d => d.ping), borderColor: "#f59e0b", backgroundColor: "rgba(245, 158, 11, 0.1)", tension: 0.4, fill: true, yAxisID: 'yPing' } ] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { type: 'linear', position: 'left', beginAtZero: true, title: { display: true, text: 'Speed (Mbps)' } }, yPing: { type: 'linear', position: 'right', beginAtZero: true, title: { display: true, text: 'Ping (ms)' }, grid: { drawOnChartArea: false } } // Secondary axis config. } } }); }
/** * Updates the detailed results table with the provided data. * @param {Array} data - The data to display in the table. */ function updateTable(data) { const tableBody = document.getElementById("tableBody"); tableBody.innerHTML = ""; // Clear existing rows. if (data.length === 0) { tableBody.innerHTML = '<tr><td colspan="5" class="no-data">No data for the selected day.</td></tr>'; return; } // Display data in reverse chronological order (newest first). [...data].reverse().forEach(item => { const row = document.createElement("tr"); // Convert the UTC timestamp from the server to the user's local time zone. const localTimestamp = new Date(item.timestamp).toLocaleString(); row.innerHTML = `<td>${localTimestamp}</td><td>${item.server || 'N/A'}</td><td class="ping-cell">${item.ping} ms</td><td class="download-cell">${item.download} Mbps</td><td class="upload-cell">${item.upload} Mbps</td>`; tableBody.appendChild(row); }); }
/** * Main function to update all UI components with the currently filtered data. */ function updateDisplay() { const filteredData = getFilteredData(); updateStats(filteredData); updateChart(filteredData); updateTable(filteredData); }
// --- Event Listeners --- // This runs when the initial HTML document has been completely loaded and parsed. document.addEventListener("DOMContentLoaded", () => { // Perform the initial data load when the page is ready. refreshAllData(); // Add an event listener to the day filter to update the display when the selection changes. document.getElementById("dayFilter").addEventListener("change", updateDisplay); // Set up an interval to automatically refresh the data every 30 seconds. setInterval(refreshAllData, 30000); }); </script></body></html>