diff --git a/.env.example b/.env.example index 8d1c09b..94bc866 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,2 @@ # Speedtest configuration CRON_SCHEDULE=*/30 * * * * -HTTP_PORT=8080 diff --git a/Dockerfile b/Dockerfile index 8026f33..afd5187 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,46 +1,50 @@ -FROM python:3.11-slim +FROM nginx:stable-alpine-slim # Install required packages -RUN apt-get update && apt-get install -y \ +RUN apk add --no-cache \ curl \ - gnupg \ - cron \ - && rm -rf /var/lib/apt/lists/* + bash \ + jq \ + coreutils \ + bc \ + dcron # Install Ookla Speedtest CLI -RUN curl -s https://install.speedtest.net/app/cli/install.deb.sh | bash \ - && apt-get update && apt-get install -y speedtest \ - && rm -rf /var/lib/apt/lists/* +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + curl -sL https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-linux-x86_64.tgz | tar -xzf - -C /usr/local/bin; \ + elif [ "$ARCH" = "aarch64" ]; then \ + curl -sL https://install.speedtest.net/app/cli/ookla-speedtest-1.2.0-linux-arm64.tgz | tar -xzf - -C /usr/local/bin; \ + fi && \ + chmod +x /usr/local/bin/speedtest # Accept license automatically -RUN mkdir -p ~/.config/ookla && echo '{"Settings": {"LicenseAccepted": "604ec14274326065ea260afd5035643b"}}' > ~/.config/ookla/speedtest-cli.json +RUN mkdir -p /root/.config/ookla && \ + echo '{"Settings": {"LicenseAccepted": "604ec14274326065ea260afd5035643b"}}' > /root/.config/ookla/speedtest-cli.json -# Set working directory -WORKDIR /app - -# Copy requirements and install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# Create directories +RUN mkdir -p /data /app # Copy application files -COPY speedtest_runner.py . -COPY web_server.py . -COPY entrypoint.sh . -RUN chmod +x entrypoint.sh - -# Create data directory -RUN mkdir -p /data +COPY speedtest_runner.sh /app/ +COPY generate_html.sh /app/ +COPY entrypoint.sh /app/ +RUN chmod +x /app/*.sh # Environment variables with defaults ENV CRON_SCHEDULE="*/30 * * * *" -ENV HTTP_PORT="8080" ENV DATA_FILE="/data/speedtest.csv" +ENV HTML_FILE="/usr/share/nginx/html/index.html" + +# Nginx configuration for port 8080 +RUN sed -i 's/listen 80;/listen 8080;/g' /etc/nginx/conf.d/default.conf && \ + sed -i 's/listen \[::\]:80;/listen [::]:8080;/g' /etc/nginx/conf.d/default.conf # Expose web port -EXPOSE ${HTTP_PORT} +EXPOSE 8080 # Volume for persistent data VOLUME ["/data"] # Start the application -ENTRYPOINT ["./entrypoint.sh"] +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index b17e68d..eace6f0 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,22 @@ # Speedtest Docker -A simple Docker container that runs Ookla Speedtest on a configurable cron schedule and serves graphs of historical results via HTTP. - -## Features - -- Runs Ookla Speedtest CLI on a configurable cron schedule -- Serves interactive graphs via Flask web server -- Persists historical data to a CSV file -- Simple configuration via environment variables +A lightweight Docker container (~25MB) that runs Ookla Speedtest on a configurable cron schedule and serves interactive graphs via nginx. ## Quick Start -### Using Docker Compose (Recommended) +### Using Docker Compose 1. Copy the example environment file: ```bash cp .env.example .env ``` -2. Edit `.env` to configure your settings: - ``` - CRON_SCHEDULE=*/30 * * * * # Every 30 minutes - HTTP_PORT=8080 - ``` - -3. Start the container: +2. Start the container: ```bash docker-compose up -d ``` -4. Access the dashboard at http://localhost:8080 +3. Access the dashboard at http://localhost:8080 ### Using Docker Run @@ -39,7 +26,6 @@ docker run -d \ -p 8080:8080 \ -v $(pwd)/data:/data \ -e CRON_SCHEDULE="*/30 * * * *" \ - -e HTTP_PORT=8080 \ --restart unless-stopped \ speedtest ``` @@ -49,8 +35,6 @@ docker run -d \ | Environment Variable | Default | Description | |---------------------|---------|-------------| | `CRON_SCHEDULE` | `*/30 * * * *` | Cron expression for test frequency | -| `HTTP_PORT` | `8080` | Port for the web dashboard | -| `DATA_FILE` | `/data/speedtest.csv` | Path to the CSV data file | ### Cron Schedule Examples @@ -73,6 +57,14 @@ The CSV file containing all speedtest results is stored at `/data/speedtest.csv` docker build -t speedtest . ``` +## Architecture + +- **Base image**: `nginx:stable-alpine-slim` (~12MB) +- **Speedtest**: Ookla's official CLI +- **Scheduling**: dcron (cron daemon) +- **Graphs**: Plotly.js (loaded from CDN) renders CSV data client-side +- **Image size**: ~25MB total (vs 648MB for Python-based image) + ## Viewing Logs ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 8a108d9..9fa9fc7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,11 +5,9 @@ services: build: . container_name: speedtest ports: - - "${HTTP_PORT:-8080}:${HTTP_PORT:-8080}" + - "${HTTP_PORT:-8080}:8080" volumes: - ./data:/data environment: - CRON_SCHEDULE=${CRON_SCHEDULE:-*/30 * * * *} - - HTTP_PORT=${HTTP_PORT:-8080} - - DATA_FILE=/data/speedtest.csv restart: unless-stopped diff --git a/entrypoint.sh b/entrypoint.sh index 865064a..c74e205 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,21 +1,22 @@ #!/bin/bash set -e -# Create data directory if it doesn't exist -mkdir -p /data - # Setup cron job -CRON_SCHEDULE=${CRON_SCHEDULE:-"*/30 * * * *"} +CRON_SCHEDULE="${CRON_SCHEDULE:-*/30 * * * *}" echo "Setting up speedtest cron job with schedule: $CRON_SCHEDULE" -# Create cron job -echo "$CRON_SCHEDULE cd /app && python3 speedtest_runner.py >> /var/log/speedtest.log 2>&1" | crontab - +# Create crontab file +echo "$CRON_SCHEDULE /app/speedtest_runner.sh >> /var/log/speedtest.log 2>&1" > /etc/crontabs/root + +# Create log file +touch /var/log/speedtest.log -# Start cron daemon -service cron start +# Start nginx +nginx # Run initial speedtest -python3 speedtest_runner.py +/app/speedtest_runner.sh -# Start web server -exec python3 web_server.py +# Start cron in foreground (using busybox crond) +echo "Starting cron daemon..." +crond -f -l 8 -L /var/log/cron.log diff --git a/generate_html.sh b/generate_html.sh new file mode 100644 index 0000000..7634621 --- /dev/null +++ b/generate_html.sh @@ -0,0 +1,165 @@ +#!/bin/bash +set -e + +DATA_FILE="${DATA_FILE:-/data/speedtest.csv}" +HTML_FILE="${HTML_FILE:-/usr/share/nginx/html/index.html}" + +# If no data exists yet, create a placeholder +if [ ! -f "$DATA_FILE" ] || [ ! -s "$DATA_FILE" ]; then + cat > "$HTML_FILE" << 'EOF' + + + + Speedtest Results + + + + +
+

Speedtest Results

+

No data available yet. Please wait for the first speedtest to complete.

+
+ + +EOF + exit 0 +fi + +# Count records (excluding header) +count=$(tail -n +2 "$DATA_FILE" | wc -l) + +# Calculate averages using awk +stats=$(tail -n +2 "$DATA_FILE" | awk -F',' ' + { + download_sum += $2; + upload_sum += $3; + ping_sum += $4; + count++ + } + END { + printf "%.2f %.2f %.2f", + (count > 0 ? download_sum/count : 0), + (count > 0 ? upload_sum/count : 0), + (count > 0 ? ping_sum/count : 0) + }' +) + +avg_download=$(echo "$stats" | awk '{print $1}') +avg_upload=$(echo "$stats" | awk '{print $2}') +avg_ping=$(echo "$stats" | awk '{print $3}') + +# Generate JavaScript data arrays from CSV +data_js=$(tail -n +2 "$DATA_FILE" | awk -F',' ' + BEGIN { print "const data = [" } + { + gsub(/"/, "\\\"", $6); + gsub(/"/, "\\\"", $7); + printf " {timestamp: \"%s\", download: %s, upload: %s, ping: %s, jitter: %s, server: \"%s (%s)\"},\n", + $1, $2, $3, $4, $5, $6, $7 + } + END { print "];" }' +) + +# Create HTML with embedded data and Plotly.js +cat > "$HTML_FILE" << EOF + + + + Speedtest Results + + + + + +
+

Speedtest Results

+
+
+
Tests Run
+
$count
+
+
+
Avg Download
+
${avg_download} Mbps
+
+
+
Avg Upload
+
${avg_upload} Mbps
+
+
+
Avg Ping
+
${avg_ping} ms
+
+
+
+
+ + + + +EOF + +echo "Generated HTML report at $HTML_FILE" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2645c63..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -flask -plotly -pandas diff --git a/speedtest_runner.py b/speedtest_runner.py deleted file mode 100644 index d4539c8..0000000 --- a/speedtest_runner.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -import subprocess -import json -import csv -import os -from datetime import datetime - -DATA_FILE = os.environ.get('DATA_FILE', '/data/speedtest.csv') - -def run_speedtest(): - """Run Ookla speedtest and return results""" - try: - result = subprocess.run( - ['speedtest', '--format=json', '--accept-license', '--accept-gdpr'], - capture_output=True, - text=True, - timeout=120 - ) - - if result.returncode != 0: - print(f"Speedtest failed: {result.stderr}") - return None - - data = json.loads(result.stdout) - - # Extract relevant data - timestamp = datetime.now().isoformat() - download_mbps = data['download']['bandwidth'] * 8 / 1_000_000 # Convert bytes/s to Mbps - upload_mbps = data['upload']['bandwidth'] * 8 / 1_000_000 - ping_ms = data['ping']['latency'] - jitter_ms = data['ping'].get('jitter', 0) - server_name = data['server']['name'] - server_location = data['server']['location'] - - return { - 'timestamp': timestamp, - 'download_mbps': round(download_mbps, 2), - 'upload_mbps': round(upload_mbps, 2), - 'ping_ms': round(ping_ms, 2), - 'jitter_ms': round(jitter_ms, 2), - 'server_name': server_name, - 'server_location': server_location - } - except Exception as e: - print(f"Error running speedtest: {e}") - return None - -def save_result(result): - """Save result to CSV file""" - if result is None: - return - - # Ensure directory exists - os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True) - - # Check if file exists to determine if we need headers - file_exists = os.path.isfile(DATA_FILE) - - with open(DATA_FILE, 'a', newline='') as f: - writer = csv.DictWriter(f, fieldnames=['timestamp', 'download_mbps', 'upload_mbps', 'ping_ms', 'jitter_ms', 'server_name', 'server_location']) - - if not file_exists: - writer.writeheader() - - writer.writerow(result) - - print(f"Saved speedtest result at {result['timestamp']}") - -if __name__ == '__main__': - result = run_speedtest() - save_result(result) diff --git a/speedtest_runner.sh b/speedtest_runner.sh new file mode 100644 index 0000000..58d083e --- /dev/null +++ b/speedtest_runner.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -e + +DATA_FILE="${DATA_FILE:-/data/speedtest.csv}" +HTML_FILE="${HTML_FILE:-/usr/share/nginx/html/index.html}" + +# Ensure data directory exists +mkdir -p "$(dirname "$DATA_FILE")" +mkdir -p "$(dirname "$HTML_FILE")" + +# Run speedtest and capture JSON output +result=$(speedtest --format=json --accept-license --accept-gdpr 2>/dev/null) + +if [ -z "$result" ]; then + echo "Speedtest failed or returned empty result" + exit 1 +fi + +# Parse results using jq +timestamp=$(date -Iseconds) +download_raw=$(echo "$result" | jq -r '.download.bandwidth') +upload_raw=$(echo "$result" | jq -r '.upload.bandwidth') +ping_ms=$(echo "$result" | jq -r '.ping.latency') +jitter_ms=$(echo "$result" | jq -r '.ping.jitter // 0') +server_name=$(echo "$result" | jq -r '.server.name') +server_location=$(echo "$result" | jq -r '.server.location') + +# Convert bytes/sec to Mbps (multiply by 8, divide by 1,000,000) +download_mbps=$(echo "scale=2; $download_raw * 8 / 1000000" | bc) +upload_mbps=$(echo "scale=2; $upload_raw * 8 / 1000000" | bc) + +# Write CSV header if file doesn't exist +if [ ! -f "$DATA_FILE" ]; then + echo "timestamp,download_mbps,upload_mbps,ping_ms,jitter_ms,server_name,server_location" > "$DATA_FILE" +fi + +# Append results to CSV +echo "$timestamp,$download_mbps,$upload_mbps,$ping_ms,$jitter_ms,$server_name,$server_location" >> "$DATA_FILE" + +echo "Saved speedtest result at $timestamp" +echo " Download: ${download_mbps} Mbps" +echo " Upload: ${upload_mbps} Mbps" +echo " Ping: ${ping_ms} ms" + +# Regenerate HTML +/app/generate_html.sh diff --git a/web_server.py b/web_server.py deleted file mode 100644 index 9e4e69c..0000000 --- a/web_server.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 -import os -import pandas as pd -import plotly.graph_objs as go -from flask import Flask, render_template_string -from datetime import datetime, timedelta - -app = Flask(__name__) - -DATA_FILE = os.environ.get('DATA_FILE', '/data/speedtest.csv') - -HTML_TEMPLATE = ''' - - - - Speedtest Results - - - - -
-

Speedtest Results

-
-
-
Tests Run
-
{{ count }}
-
-
-
Avg Download
-
{{ avg_download }} Mbps
-
-
-
Avg Upload
-
{{ avg_upload }} Mbps
-
-
-
Avg Ping
-
{{ avg_ping }} ms
-
-
-
{{ graph_html|safe }}
-
- - -''' - -def load_data(): - """Load speedtest data from CSV""" - if not os.path.exists(DATA_FILE): - return None - - df = pd.read_csv(DATA_FILE) - df['timestamp'] = pd.to_datetime(df['timestamp']) - return df - -@app.route('/') -def index(): - df = load_data() - - if df is None or df.empty: - return '

No data available yet

Run a speedtest first!

' - - # Create graphs - fig = go.Figure() - - # Download speed line - fig.add_trace(go.Scatter( - x=df['timestamp'], - y=df['download_mbps'], - name='Download (Mbps)', - line=dict(color='green', width=2) - )) - - # Upload speed line - fig.add_trace(go.Scatter( - x=df['timestamp'], - y=df['upload_mbps'], - name='Upload (Mbps)', - line=dict(color='blue', width=2) - )) - - # Ping line on secondary y-axis - fig.add_trace(go.Scatter( - x=df['timestamp'], - y=df['ping_ms'], - name='Ping (ms)', - line=dict(color='red', width=2), - yaxis='y2' - )) - - fig.update_layout( - title='Internet Speed Over Time', - xaxis_title='Time', - yaxis_title='Speed (Mbps)', - yaxis2=dict( - title='Ping (ms)', - overlaying='y', - side='right' - ), - hovermode='x unified', - height=500 - ) - - # Calculate stats - stats = { - 'count': len(df), - 'avg_download': round(df['download_mbps'].mean(), 2), - 'avg_upload': round(df['upload_mbps'].mean(), 2), - 'avg_ping': round(df['ping_ms'].mean(), 2), - 'graph_html': fig.to_html(full_html=False, include_plotlyjs='cdn') - } - - return render_template_string(HTML_TEMPLATE, **stats) - -if __name__ == '__main__': - port = int(os.environ.get('HTTP_PORT', '8080')) - app.run(host='0.0.0.0', port=port, debug=False)