Initial commit

This commit is contained in:
2025-11-29 15:24:11 -08:00
parent f91705ebf1
commit 8479c98e39
6 changed files with 690 additions and 1 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
*.pyc
__pycache__
.git
.vscode
node_modules
*.log

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
FROM debian:stable-slim
ENV DEBIAN_FRONTEND=noninteractive
# Install required packages: curl, cron, busybox (httpd), ca-certificates
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl cron busybox ca-certificates gnupg dirmngr \
&& rm -rf /var/lib/apt/lists/*
# Install Ookla Speedtest CLI via packagecloud repository
RUN curl -s https://packagecloud.io/install/repositories/ookla/speedtest-cli/script.deb.sh | bash \
&& apt-get update \
&& apt-get install -y --no-install-recommends speedtest \
&& rm -rf /var/lib/apt/lists/*
# Create web root and data directories
RUN mkdir -p /var/www && mkdir -p /var/www/data/speed
WORKDIR /var/www
# Copy web assets and scripts
COPY index.html /var/www/index.html
COPY run_speed_test.sh /usr/local/bin/run_speed_test.sh
COPY start.sh /usr/local/bin/start.sh
# Ensure scripts are executable
RUN chmod +x /usr/local/bin/run_speed_test.sh /usr/local/bin/start.sh
# Add cron job to run every hour at minute 0
RUN printf "0 * * * * root /usr/local/bin/run_speed_test.sh >> /var/log/cron.log 2>&1\n" > /etc/cron.d/speedtest-cron \
&& chmod 0644 /etc/cron.d/speedtest-cron
# Expose default HTTP port and declare data volume
VOLUME ["/var/www/data/speed"]
# Default port for http server inside container. You can override at runtime with -e PORT=xxxx
ENV PORT=8080
EXPOSE 8080
# Start script will launch cron and the HTTP server
CMD ["/usr/local/bin/start.sh"]

View File

@@ -1,3 +1,49 @@
# speed-data-docker
Docker container for Speed test app
Debian-based Docker image that runs hourly network speed tests (using speedtest-cli) and serves a simple web UI.
What the container provides
- Base: debian
- Installs: speedtest-cli
- Runs: a cron job to execute `run_speed_test.sh` hourly
- Serves: `index.html` and generated JS files on port 8080 via busybox httpd
Files added/edited
- `Dockerfile` - builds the image
- `start.sh` - entrypoint: starts cron and httpd
- `run_speed_test.sh` - existing script (copied into image)
Build locally
Make sure Docker is installed locally. From the project root run:
```bash
docker build -t speed-data-app .
```
Run container
```bash
# Run with a named Docker volume (recommended). The container serves on $PORT inside the container
# (default 8080). Map a host port to the container port with -p <host_port>:<container_port>.
docker volume create speed-data-volume
docker run -d --name speed-data -e PORT=8080 -p 8080:8080 --restart unless-stopped \
-v speed-data-volume:/var/www/data/speed \
speed-data-app
# Or run with a host bind mount (for direct access to files on the host):
docker run -d --name speed-data -e PORT=8080 -p 8080:8080 --restart unless-stopped \
-v /path/on/host/speed-data:/var/www/data/speed \
speed-data-app
```
Verify
- Visit http://localhost:8080 to view `index.html` (which reads `speedtest.js` and `speedtest.short.js`).
- Cron runs at minute 0 every hour. Logs go to `/var/log/cron.log` inside the container.
Notes
- Data files (`speedtest.js`, `speedtest.short.js`, and the raw JSON file) are written to `/var/www/data/speed` inside the container. That path is declared as a Docker VOLUME in the `Dockerfile` so you can mount a named volume or a host directory.
- If you mount a host directory, ensure the directory is writeable by the container process. You can either:
- run the container as root (not recommended), or
- chown/chmod the host directory appropriately (e.g., `chown 1000:1000 /path/on/host` or `chmod a+rw /path/on/host`) so the container can write files.
Reminder: I built the image successfully after you started Docker locally. If you'd like, I can remove/recreate the container on your machine; tell me to proceed and I'll run the commands here.

474
index.html Normal file
View File

@@ -0,0 +1,474 @@
<!DOCTYPE html>
<html>
<head>
<title>Speedtest data</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Mono:wght@700&family=Inter:wght@400;900&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style type="text/css">
/* highcharts */
.highcharts-figure,
.highcharts-data-table table {
/* min-width: 560px;
max-width: 1000px;*/
margin: 1em auto;
}
.highcharts-data-table table {
font-family: Verdana, sans-serif;
border-collapse: collapse;
border: 1px solid #ebebeb;
margin: 10px auto;
text-align: center;
width: 100%;
/*max-width: 500px;*/
}
.highcharts-data-table caption {
padding: 1em 0;
font-size: 1.2em;
color: #555;
}
.highcharts-data-table th {
font-weight: 600;
padding: 0.5em;
}
.highcharts-data-table td,
.highcharts-data-table th,
.highcharts-data-table caption {
padding: 0.5em;
}
.highcharts-data-table thead tr,
.highcharts-data-table tr:nth-child(even) {
background: #f8f8f8;
}
.highcharts-data-table tr:hover {
background: #f1f7ff;
}
/* General */
body {
font-family: "Inter", sans-serif;
}
h1, h2, h5 {
text-align: center;
}
/* Recent boxes */
#recent_samples {
width: max-content;
margin: auto;
}
#recent .recent_sample {
display: block;
background-color: #eee;
border: 2px solid #eee;
border-radius: 5px;
}
#recent .recent_sample .recent_dn,
#recent .recent_sample .recent_up {
font-family: "Fira Mono", sans-serif;
color: #777;
background-color: #fff;
margin: 5px;
border-radius: 5px;
padding: 0.3em;
}
#recent .recent_sample .recent_dn {
color: #2caffe;
}
#recent .recent_sample .recent_up {
color: #544fc5;
padding-left: 0.3em;
}
#recent .recent_spd {
}
.recent_box .recent_fld {
font-family: "Inter", sans-serif;
display: table-row;
}
.recent_box {
margin: 5px;
text-align: left;
}
.recent_box .recent_fld .recent_fld_name {
color: #777;
font-weight: bold;
}
.recent_box .recent_fld .recent_fld_val {
color: #333;
margin-bottom: 0.3em;
}
.recent_box .recent_fld .recent_fld_name,
.recent_box .recent_fld .recent_fld_val {
display: table-cell;
}
/* Spinner */
#spinner {
border: 16px solid #f3f3f3;
border-radius: 50%;
border-top: 16px solid #544fc5;
border-bottom: 16px solid #2caffe;
width: 80px;
height: 80px;
-webkit-animation: spin 2s linear infinite; /* Safari */
animation: spin 2s linear infinite;
margin: 5em auto;
}
#spinner-desc {
margin: 0 auto;
width: 300px;
text-align: center;
padding-top: 3em;
}
/* Safari */
@-webkit-keyframes spin {
0% { -webkit-transform: rotate(0deg); }
100% { -webkit-transform: rotate(360deg); }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.result_url {
text-decoration: none;
font-weight: bold;
font-size: 0.7em;
}
/* Responsive */
@media only screen and (max-width: 1100px) {
#recent #recent_1 .recent_dn, #recent #recent_1 .recent_up {
font-size: 2em;
}
#recent #recent_2 .recent_dn, #recent #recent_2 .recent_up {
font-size: 1.5em;
}
#recent #recent_3 .recent_dn, #recent #recent_3 .recent_up,
#recent #recent_4 .recent_dn, #recent #recent_4 .recent_up,
#recent #recent_5 .recent_dn, #recent #recent_5 .recent_up {
font-size: 1em;
}
#recent #recent_1 .recent_fld {
font-size: 0.7em;
}
#recent #recent_2 .recent_fld {
font-size: 0.5em;
}
#recent #recent_3 .recent_fld,
#recent #recent_4 .recent_fld,
#recent #recent_5 .recent_fld {
font-size: 0.4em;
}
#recent {
display: flex;
flex-wrap: wrap;
width: min-content;
}
.recent_box {
font-size: larger;
}
#recent .recent_sample {
flex-basis: content;
margin: 5px auto;
}
.sep {
width: 100%;
}
.highcharts-figure {
display: none;
}
}
@media only screen and (min-width: 1100px) {
#recent #recent_1 .recent_dn, #recent #recent_1 .recent_up {
font-size: 3em;
}
#recent #recent_2 .recent_dn, #recent #recent_2 .recent_up {
font-size: 1.5em;
}
#recent #recent_3 .recent_dn, #recent #recent_3 .recent_up,
#recent #recent_4 .recent_dn, #recent #recent_4 .recent_up,
#recent #recent_5 .recent_dn, #recent #recent_5 .recent_up {
font-size: 1.2em;
}
#recent #recent_1 .recent_fld {
font-size: 1em;
}
#recent #recent_2 .recent_fld {
font-size: 0.7em;
}
#recent #recent_3 .recent_fld,
#recent #recent_4 .recent_fld,
#recent #recent_5 .recent_fld {
font-size: 0.5em;
}
#recent {
display: inline-block;
width: max-content;
height: min-content;
}
#recent .recent_sample {
float: left;
margin: 5px;
}
}
</style>
</head>
<body>
<div id="spinner"></div>
<div id="spinner-desc">Preparing data ...</div>
<div id="recent_samples">
<div id="recent">
</div>
</div>
<div style="clear:both"></div>
<figure class="highcharts-figure">
<div id="container"></div>
</figure>
<script src="https://code.highcharts.com/highcharts.js"></script>
<script src="https://code.highcharts.com/modules/boost.js"></script>
<script src="https://code.highcharts.com/modules/exporting.js"></script>
<script src="https://code.highcharts.com/modules/accessibility.js"></script>
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<script>
var shortContent = ($(window).width() < 1000);
var refDate = new Date;
var isp = "";
function getDate(d) {
var dt = Date.parse(d);
dt = dt - refDate.getTimezoneOffset() * 60 * 1000;
return dt;
}
function getFmtDate(d) {
const dt = new Date(d);
return dt.toDateString()
+ ", " + dt.getHours() + ":" + dt.getMinutes() + ":" + dt.getSeconds();
}
function getDownloadData(d) {
var arr = [];
d.forEach((sd) => {
if (sd.error == undefined) {
var dt = getDate(sd.timestamp);
arr.push([
dt,
sd.download.bandwidth*8/1000000
]);
}
});
return arr;
}
function getUploadData(d) {
var arr = [];
d.forEach((sd) => {
if (sd.error == undefined) {
var dt = getDate(sd.timestamp);
arr.push([
dt,
sd.upload.bandwidth*8/1000000
]);
}
});
return arr;
}
function prepare_chart_data() {
dndata = getDownloadData(speeddata);
updata = getUploadData(speeddata);
}
function show_chart() {
prepare_chart_data();
console.time('line');
Highcharts.chart('container', {
chart: {
type: 'spline',
zoomType: 'x',
height: '40%'
},
title: {
text: 'Trend in last 500 samples'
},
subtitle: {
text: 'Measured hourly by Speedtest CLI'
},
accessibility: {
screenReaderSection: {
beforeChartFormat: '<{headingTagName}>{chartTitle}</{headingTagName}><div>{chartSubtitle}</div><div>{chartLongdesc}</div><div>{xAxisDescription}</div><div>{yAxisDescription}</div>'
}
},
tooltip: {
valueDecimals: 2
},
xAxis: {
type: 'datetime'
},
yAxis: {
title: {
text: 'Speed (MB/s)'
},
plotBands: [{ // Fast
from: 900,
to: 1000,
color: 'rgba(68, 170, 68, 0.1)',
label: {
text: 'Fast',
style: {
color: '#606060'
}
}
}, { // Slow
from: 300,
to: 499,
color: 'rgba(170, 50, 50, 0.1)',
label: {
text: 'Slow',
style: {
color: '#606060'
}
}
}, { // Crawling
from: 0,
to: 299,
color: 'rgba(200, 50, 50, 0.3)',
label: {
text: 'Crawling',
style: {
color: '#606060'
}
}
}]
},
series: [
{
data: dndata,
lineWidth: 2,
name: 'Download (MB/s)'
},
{
data: updata,
lineWidth: 2,
name: 'Upload (MB/s)'
}
]
});
console.timeEnd('line');
}
function get_speed(num) {
return (num*8/1000000).toFixed(2);
}
function get_up_dn_icon(dt, dtprev, isdn) {
var icon = "&bull;";
var colr = "#0a0";
var titl = "Upward trend";
if (isdn) {
if (dt.download.bandwidth < dtprev.download.bandwidth) {
colr = "#a00";
titl = "Downward trend";
}
} else {
if (dt.upload.bandwidth < dtprev.upload.bandwidth) {
colr = "#a00";
titl = "Downward trend";
}
}
return "<span style='color:" + colr + "' title='" + titl + "'>" + icon + "</span>";
}
function show_one_sample(dt, dtprev, idx, dest) {
dest.append($("<div class='recent_sample' id='recent_" + idx + "'></div>")
.append($("<div class='recent_spd_tbl'></div>")
.append($("<table></table>")
.append($("<tr class='recent_spd'></tr>")
.append($("<td class='recent_dn' title='Download speed in MB/s'>↓"
+ get_speed(dt.download.bandwidth) + get_up_dn_icon(dt, dtprev, 1) + "</td>"),
$("<td class='recent_up' title='Upload speed in MB/s'>↑"
+ get_speed(dt.upload.bandwidth) + get_up_dn_icon(dt, dtprev, 0) + "</td>")))))
.append($("<div class='recent_box'></div>")
.append($("<div class='recent_fld'></div>")
.append($("<div class='recent_fld_val'>" + getFmtDate(dt.timestamp) + "<a class='result_url' target='_blank' href='"
+ dt.result.url + "'> ➔</a></div>")),
$("<div class='recent_fld'></div>")
.append($("<div class='recent_fld_val'>" + dt.server.name + " ("
+ dt.server.location + ", "
+ dt.server.country + ")</div>")))));
}
function load_data() {
var data_js = (shortContent) ? "speedtest.short.js" : "speedtest.js";
var data_path = "/data/speed/" + data_js;
$.getScript(data_path, function() { show_content() });
}
function show_content() {
var snaps = 5;
isp = speeddata[speeddata.length-1].isp;
console.log(isp);
for (let itr = speeddata.length-1, idx = 1; idx <= snaps; itr --, idx ++ ) {
show_one_sample(speeddata[itr], speeddata[itr-1], idx, $('#recent'));
}
if (!shortContent) {
show_chart();
}
// On done
$("#spinner-desc").hide();
$("#spinner")
.after("<h2>Most recent samples</h2>")
.after("<h5>(ISP: " + isp + ")</h5>")
.after("<h1>Network Speed Data</h1>")
.hide();
console.timeEnd("speedtest");
}
$(document).ready(function() {
console.time("speedtest");
load_data();
});
// vim: ts=4 sts=4 sw=4 et
</script>
</body>
</html>

82
run_speed_test.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/bin/sh
RAW_DATA=/var/www/data/speed/raw.speedtest.json
TMP_DATA=/tmp/speedtest.js
WEB_DATA=/var/www/data/speed/speedtest.js
WEB_SHORT_DATA=/var/www/data/speed/speedtest.short.js
/usr/bin/speedtest --format=json --accept-license >> ${RAW_DATA} 2>/dev/null || speedtest --format=json --accept-license >> ${RAW_DATA} 2>/dev/null
# Prepare information for web display
tail -500 ${RAW_DATA} > ${TMP_DATA} && \
sed -i -e 's/$/,/' ${TMP_DATA} && \
echo "var speeddata = [" > ${WEB_DATA} && \
cat ${TMP_DATA} >> ${WEB_DATA} && \
echo "];" >> ${WEB_DATA}
# Prepare short information for quick web display
tail -6 ${RAW_DATA} > ${TMP_DATA} && \
sed -i -e 's/$/,/' ${TMP_DATA} && \
echo "var speeddata = [" > ${WEB_SHORT_DATA} && \
cat ${TMP_DATA} >> ${WEB_SHORT_DATA} && \
echo "];" >> ${WEB_SHORT_DATA}
# Trim raw data to keep it lean
tail -1000 ${RAW_DATA} > ${TMP_DATA} && \
cp ${TMP_DATA} ${RAW_DATA}
# Above arguments produce the following output in single line. Expanded for readability below:
#
# {
# "type":"result",
# "timestamp":"2023-04-24T03:57:04Z",
# "ping":{
# "jitter":0.193,
# "latency":1.751,
# "low":1.530,
# "high":1.982
# },
# "download":{
# "bandwidth":85545427,
# "bytes":1017170768,
# "elapsed":12616,
# "latency":{
# "iqm":1.894,
# "low":1.374,
# "high":2.916,
# "jitter":0.300
# }
# },
# "upload":{
# "bandwidth":91982628,
# "bytes":331336109,
# "elapsed":3601,
# "latency":{
# "iqm":4.135,
# "low":2.281,
# "high":5.322,
# "jitter":0.498
# }
# },
# "packetLoss":0,
# "isp":"GigaMonster",
# "interface":{
# "internalIp":"192.168.1.200",
# "name":"eno1",
# "macAddr":"B8:AE:ED:7D:8A:36",
# "isVpn":false,
# "externalIp":"24.72.150.52"
# },
# "server":{
# "id":36565,
# "host":"speedpdx2.ortelco.net",
# "port":8080,
# "name":"OTC Connections",
# "location":"Portland, OR",
# "country":"United States",
# "ip":"38.87.96.27"},
# "result":{
# "id":"9b547022-8e1b-42c5-871c-2b6810b641bb",
# "url":"https://www.speedtest.net/result/c/9b547022-8e1b-42c5-871c-2b6810b641bb",
# "persisted":true
# }
# }

42
start.sh Normal file
View File

@@ -0,0 +1,42 @@
#!/bin/sh
set -e
# Ensure data directory exists
mkdir -p /var/www/data/speed
# Ensure raw data file exists
touch /var/www/data/speed/raw.speedtest.json
# If the data directory is a mounted volume it may be owned by root; allow writes
# by ensuring the directory is writable.
chown -R root:root /var/www/data || true
chmod -R a+rwX /var/www/data || true
# Run the initial speed test once (in background) to populate files if possible
# We run it in background so server starts promptly. The cron will run hourly.
/usr/local/bin/run_speed_test.sh || true &
# Ensure the cron job exists in root's crontab (so `crontab -l` shows it)
CRON_ENTRY="* * * * * /usr/local/bin/run_speed_test.sh >> /var/log/cron.log 2>&1"
if crontab -l 2>/dev/null | grep -F "$CRON_ENTRY" >/dev/null 2>&1; then
echo "cron entry already present"
else
(crontab -l 2>/dev/null; echo "$CRON_ENTRY") | crontab -
echo "installed cron entry into root crontab"
fi
# Start Debian cron in background
service cron start || cron || true
PORT=${PORT:-8080}
# Start busybox httpd serving /var/www on configured port in foreground
if command -v busybox >/dev/null 2>&1; then
echo "starting busybox httpd on port ${PORT} serving /var/www"
busybox httpd -f -p ${PORT} -h /var/www
else
echo "warning: busybox httpd not found; container will keep running with cron only" >&2
# keep the script running so container doesn't exit
tail -f /var/log/cron.log
fi