Compare commits

...

9 Commits

Author SHA1 Message Date
Jorijn Schrijvershof
3ed1b0e495 fix(html): use relative asset and nav paths for subpath deploys 2026-01-18 10:12:34 +01:00
renovate[bot]
c0758f4c0d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.26 (#82)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 01:09:37 +00:00
renovate[bot]
6afd70b0d9 chore(deps): update nginx:1.29-alpine docker digest to b0f7830 (#81)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 02:54:42 +00:00
renovate[bot]
2455f35d32 chore(deps): update nginx:1.29-alpine docker digest to 66d420c (#78)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 14:26:57 +00:00
renovate[bot]
3007845bd2 chore(deps): update python:3.14-slim-bookworm docker digest to adb6bdf (#79)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 10:36:04 +00:00
renovate[bot]
df9bfffa78 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.25 (#77)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 05:41:28 +00:00
Jorijn Schrijvershof
81adc25540 chore(main): release 0.2.15 (#74) 2026-01-13 23:33:43 +01:00
renovate[bot]
392ba226ba chore(deps): update python:3.14-slim-bookworm docker digest to 55b18d5 (#69)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 22:32:01 +00:00
Jorijn Schrijvershof
42d141f4fa Revert "build(docker): add armv7 container support (#68)" (#76)
This reverts commit 75e50f7ee9.
2026-01-13 23:28:39 +01:00
15 changed files with 130 additions and 82 deletions

View File

@@ -151,7 +151,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-release.outputs.tags }}
labels: ${{ steps.meta-release.outputs.labels }}
@@ -167,7 +167,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-nightly.outputs.tags }}
labels: ${{ steps.meta-nightly.outputs.labels }}
@@ -183,7 +183,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
push: ${{ inputs.push }}
tags: ${{ steps.meta-manual.outputs.tags }}
labels: ${{ steps.meta-manual.outputs.labels }}

View File

@@ -1,3 +1,3 @@
{
".": "0.2.14"
".": "0.2.15"
}

View File

@@ -359,7 +359,7 @@ Jobs configured in `docker/ofelia.ini`:
### GitHub Actions Workflow
`.github/workflows/docker-publish.yml` builds and publishes Docker images for `linux/amd64`, `linux/arm64`, and `linux/arm/v7`:
`.github/workflows/docker-publish.yml` builds and publishes Docker images:
| Trigger | Tags Created |
|---------|--------------|
@@ -678,6 +678,7 @@ The static site uses a modern, responsive design with the following features:
- **Repeater pages at root**: `/day.html`, `/week.html`, etc. (entry point)
- **Companion pages**: `/companion/day.html`, `/companion/week.html`, etc.
- **`.htaccess`**: Sets `DirectoryIndex day.html` so `/` loads repeater day view
- **Relative links**: All internal navigation and static asset references are relative (no leading `/`) so the dashboard can be served from a reverse-proxy subpath.
### Page Layout
1. **Header**: Site branding, node name, pubkey prefix, status indicator, last updated time

View File

@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
This changelog is automatically generated by [release-please](https://github.com/googleapis/release-please) based on [Conventional Commits](https://www.conventionalcommits.org/).
## [0.2.15](https://github.com/jorijn/meshcore-stats/compare/v0.2.14...v0.2.15) (2026-01-13)
### Bug Fixes
* **charts:** skip short counter intervals ([#73](https://github.com/jorijn/meshcore-stats/issues/73)) ([97ebba4](https://github.com/jorijn/meshcore-stats/commit/97ebba4f2da723100ec87d21b6f8780ee0793e46))
### Miscellaneous Chores
* **deps:** update python:3.14-slim-bookworm docker digest to 55b18d5 ([#69](https://github.com/jorijn/meshcore-stats/issues/69)) ([392ba22](https://github.com/jorijn/meshcore-stats/commit/392ba226babdaa7bd4beb0c6ff7b832a3aca5e71))
## [0.2.14](https://github.com/jorijn/meshcore-stats/compare/v0.2.13...v0.2.14) (2026-01-13)

View File

@@ -1,49 +1,18 @@
# =============================================================================
# Stage 0: Ofelia binary
# Stage 0: uv binary
# =============================================================================
FROM golang:1.25-bookworm@sha256:2c7c65601b020ee79db4c1a32ebee0bf3d6b298969ec683e24fcbea29305f10e AS ofelia-builder
# Ofelia version (built from source for multi-arch support)
ARG OFELIA_VERSION=0.3.12
ARG TARGETARCH
ARG TARGETVARIANT
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src/ofelia
RUN git clone --depth 1 --branch "v${OFELIA_VERSION}" https://github.com/mcuadros/ofelia.git /src/ofelia
RUN set -ex; \
if [ "$TARGETARCH" = "amd64" ]; then \
GOARCH="amd64"; \
elif [ "$TARGETARCH" = "arm64" ]; then \
GOARCH="arm64"; \
elif [ "$TARGETARCH" = "arm" ] && [ "$TARGETVARIANT" = "v7" ]; then \
GOARCH="arm"; \
GOARM="7"; \
else \
echo "Unsupported architecture: $TARGETARCH${TARGETVARIANT:+/$TARGETVARIANT}" && exit 1; \
fi; \
if [ -n "${GOARM:-}" ]; then \
export GOARM; \
fi; \
CGO_ENABLED=0 GOOS=linux GOARCH="$GOARCH" go build -o /usr/local/bin/ofelia .
FROM ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b AS uv
# =============================================================================
# Stage 1: Build dependencies
# =============================================================================
FROM python:3.14-slim-bookworm@sha256:3be2c910db2dacfb3e576f94c7ffc07c10b115cbcd3de99d49bfb0b4ccfd75e7 AS builder
FROM python:3.14-slim-bookworm@sha256:adb6bdfbcc7c744c3b1a05976136555e2d82b7df01ac3efe71737d7f95ef0f2d AS builder
# uv version and checksums (verified from GitHub releases)
ARG UV_VERSION=0.9.24
ARG UV_SHA256_AMD64=fb13ad85106da6b21dd16613afca910994446fe94a78ee0b5bed9c75cd066078
ARG UV_SHA256_ARM64=9b291a1a4f2fefc430e4fc49c00cb93eb448d41c5c79edf45211ceffedde3334
ARG UV_SHA256_ARMV7=8d05b55fe2108ecab3995c2b656679a72c543fd9dc72eeb3a525106a709cfdcb
# Ofelia version and checksums (verified from GitHub releases)
ARG OFELIA_VERSION=0.3.12
ARG TARGETARCH
ARG TARGETVARIANT
ARG OFELIA_SHA256_AMD64=cf06d2199abafbd3aa5afe0f8266e478818faacd11555b99200707321035c931
ARG OFELIA_SHA256_ARM64=57760ef7f17a2cd55b5b1e1946f79b91b24bde40d47e81a0d75fd1470d883c1a
# Install build dependencies for Python packages
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -53,32 +22,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Download and verify uv binary in builder stage
# Download and verify Ofelia binary in builder stage (keeps curl out of runtime)
RUN set -ex; \
if [ "$TARGETARCH" = "amd64" ]; then \
UV_ARCH="x86_64"; \
UV_SHA256="$UV_SHA256_AMD64"; \
OFELIA_SHA256="$OFELIA_SHA256_AMD64"; \
elif [ "$TARGETARCH" = "arm64" ]; then \
UV_ARCH="aarch64"; \
UV_SHA256="$UV_SHA256_ARM64"; \
elif [ "$TARGETARCH" = "arm" ] && [ "$TARGETVARIANT" = "v7" ]; then \
UV_ARCH="armv7"; \
UV_SHA256="$UV_SHA256_ARMV7"; \
OFELIA_SHA256="$OFELIA_SHA256_ARM64"; \
else \
echo "Unsupported architecture: $TARGETARCH${TARGETVARIANT:+/$TARGETVARIANT}" && exit 1; \
echo "Unsupported architecture: $TARGETARCH" && exit 1; \
fi; \
curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${UV_ARCH}-unknown-linux-gnu.tar.gz" \
-o /tmp/uv.tar.gz \
&& echo "${UV_SHA256} /tmp/uv.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/uv.tar.gz -C /usr/local/bin --strip-components=1 --wildcards "*/uv" \
&& rm /tmp/uv.tar.gz \
&& chmod +x /usr/local/bin/uv
curl -fsSL "https://github.com/mcuadros/ofelia/releases/download/v${OFELIA_VERSION}/ofelia_${OFELIA_VERSION}_linux_${TARGETARCH}.tar.gz" -o /tmp/ofelia.tar.gz \
&& echo "${OFELIA_SHA256} /tmp/ofelia.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/ofelia.tar.gz -C /usr/local/bin ofelia \
&& rm /tmp/ofelia.tar.gz \
&& chmod +x /usr/local/bin/ofelia
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
UV_PROJECT_ENVIRONMENT=/opt/venv
# Copy uv binary from pinned image
COPY --from=uv /uv /usr/local/bin/uv
# Install Python dependencies
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir --upgrade pip && \
@@ -87,7 +53,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
# =============================================================================
# Stage 2: Runtime
# =============================================================================
FROM python:3.14-slim-bookworm@sha256:3be2c910db2dacfb3e576f94c7ffc07c10b115cbcd3de99d49bfb0b4ccfd75e7
FROM python:3.14-slim-bookworm@sha256:adb6bdfbcc7c744c3b1a05976136555e2d82b7df01ac3efe71737d7f95ef0f2d
# OCI Labels
LABEL org.opencontainers.image.source="https://github.com/jorijn/meshcore-stats"
@@ -117,7 +83,7 @@ RUN groupadd -g 1000 meshmon \
&& chown -R meshmon:meshmon /data /out /tmp/matplotlib
# Copy Ofelia binary from builder (keeps curl out of runtime image)
COPY --from=ofelia-builder /usr/local/bin/ofelia /usr/local/bin/ofelia
COPY --from=builder /usr/local/bin/ofelia /usr/local/bin/ofelia
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv

View File

@@ -56,7 +56,6 @@ docker compose logs meshcore-stats | head -20
- Remote repeater node reachable via LoRa from the companion
**Resource requirements:** ~100MB memory, ~100MB disk per year of data.
**Container architectures:** `linux/amd64`, `linux/arm64`, and `linux/arm/v7` (32-bit).
## Installation

View File

@@ -15,7 +15,7 @@ services:
# MeshCore Stats - Data collection and rendering
# ==========================================================================
meshcore-stats:
image: ghcr.io/jorijn/meshcore-stats:0.2.14 # x-release-please-version
image: ghcr.io/jorijn/meshcore-stats:0.2.15 # x-release-please-version
container_name: meshcore-stats
restart: unless-stopped
@@ -78,7 +78,7 @@ services:
# nginx - Static site server
# ==========================================================================
nginx:
image: nginx:1.29-alpine@sha256:c083c3799197cfff91fe5c3c558db3d2eea65ccbbfd419fa42a64d2c39a24027
image: nginx:1.29-alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55
container_name: meshcore-stats-nginx
restart: unless-stopped

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meshcore-stats"
version = "0.2.14"
version = "0.2.15"
description = "MeshCore LoRa mesh network monitoring and statistics"
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -1,3 +1,3 @@
"""MeshCore network monitoring library."""
__version__ = "0.2.14" # x-release-please-version
__version__ = "0.2.15" # x-release-please-version

View File

@@ -480,6 +480,7 @@ def build_chart_groups(
role: str,
period: str,
chart_stats: dict | None = None,
asset_prefix: str = "",
) -> list[dict]:
"""Build chart groups for template.
@@ -490,6 +491,7 @@ def build_chart_groups(
role: "companion" or "repeater"
period: Time period ("day", "week", etc.)
chart_stats: Stats dict from chart_stats.json (optional)
asset_prefix: Relative path prefix to reach /assets from page location
"""
cfg = get_config()
groups_config = REPEATER_CHART_GROUPS if role == "repeater" else COMPANION_CHART_GROUPS
@@ -551,8 +553,9 @@ def build_chart_groups(
chart_data["use_svg"] = True
else:
# Fallback to PNG paths
chart_data["src_light"] = f"/assets/{role}/{metric}_{period}_light.png"
chart_data["src_dark"] = f"/assets/{role}/{metric}_{period}_dark.png"
asset_base = f"{asset_prefix}assets/{role}/"
chart_data["src_light"] = f"{asset_base}{metric}_{period}_light.png"
chart_data["src_dark"] = f"{asset_base}{metric}_{period}_dark.png"
chart_data["use_svg"] = False
charts.append(chart_data)
@@ -614,7 +617,10 @@ def build_page_context(
# Load chart stats and build chart groups
chart_stats = load_chart_stats(role)
chart_groups = build_chart_groups(role, period, chart_stats)
# Relative path prefixes (avoid absolute paths for subpath deployments)
css_path = "" if at_root else "../"
asset_prefix = "" if at_root else "../"
# Period config
page_title, page_subtitle = PERIOD_CONFIG.get(period, ("Observations", "Radio telemetry"))
@@ -634,9 +640,18 @@ def build_page_context(
),
}
# CSS and link paths - depend on whether we're at root or in /companion/
css_path = "/" if at_root else "../"
base_path = "" if at_root else "/companion"
chart_groups = build_chart_groups(role, period, chart_stats, asset_prefix=asset_prefix)
# Navigation links depend on whether we're at root or in /companion/
base_path = ""
if at_root:
repeater_link = "day.html"
companion_link = "companion/day.html"
reports_link = "reports/"
else:
repeater_link = "../day.html"
companion_link = "day.html"
reports_link = "../reports/"
return {
# Page meta
@@ -665,9 +680,9 @@ def build_page_context(
# Navigation
"period": period,
"base_path": base_path,
"repeater_link": f"{css_path}day.html",
"companion_link": f"{css_path}companion/day.html",
"reports_link": f"{css_path}reports/",
"repeater_link": repeater_link,
"companion_link": companion_link,
"reports_link": reports_link,
# Timestamps
"last_updated": last_updated,

View File

@@ -113,10 +113,10 @@
<main class="main-content">
<!-- Period Navigation -->
<nav class="period-nav">
<a href="{{ base_path }}/day.html"{% if period == 'day' %} class="active"{% endif %}>Day</a>
<a href="{{ base_path }}/week.html"{% if period == 'week' %} class="active"{% endif %}>Week</a>
<a href="{{ base_path }}/month.html"{% if period == 'month' %} class="active"{% endif %}>Month</a>
<a href="{{ base_path }}/year.html"{% if period == 'year' %} class="active"{% endif %}>Year</a>
<a href="{{ base_path }}day.html"{% if period == 'day' %} class="active"{% endif %}>Day</a>
<a href="{{ base_path }}week.html"{% if period == 'week' %} class="active"{% endif %}>Week</a>
<a href="{{ base_path }}month.html"{% if period == 'month' %} class="active"{% endif %}>Month</a>
<a href="{{ base_path }}year.html"{% if period == 'year' %} class="active"{% endif %}>Year</a>
</nav>
<header class="page-header">

View File

@@ -229,5 +229,28 @@ class TestBuildPageContext:
at_root=False,
)
assert root_context["css_path"] == "/"
assert root_context["css_path"] == ""
assert non_root_context["css_path"] == "../"
def test_links_use_relative_paths(self, configured_env, sample_row):
"""Navigation and asset links are relative for subpath deployments."""
root_context = build_page_context(
role="repeater",
period="day",
row=sample_row,
at_root=True,
)
non_root_context = build_page_context(
role="companion",
period="day",
row=sample_row,
at_root=False,
)
assert root_context["repeater_link"] == "day.html"
assert root_context["companion_link"] == "companion/day.html"
assert root_context["reports_link"] == "reports/"
assert non_root_context["repeater_link"] == "../day.html"
assert non_root_context["companion_link"] == "day.html"
assert non_root_context["reports_link"] == "../reports/"

View File

@@ -266,7 +266,8 @@ class TestHtmlOutput:
content = (out_dir / "day.html").read_text()
assert "styles.css" in content
assert 'href="styles.css"' in content
assert 'href="/styles.css"' not in content
def test_companion_pages_relative_css(self, html_env, metrics_rows):
"""Companion pages use relative path to CSS."""
@@ -277,4 +278,5 @@ class TestHtmlOutput:
content = (out_dir / "companion" / "day.html").read_text()
# Should reference parent directory CSS
assert "../styles.css" in content or "styles.css" in content
assert 'href="../styles.css"' in content
assert 'href="/styles.css"' not in content

View File

@@ -6,6 +6,7 @@ from meshmon.html import (
PERIOD_CONFIG,
REPEATER_CHART_GROUPS,
_build_traffic_table_rows,
build_chart_groups,
build_companion_metrics,
build_node_details,
build_radio_config,
@@ -457,3 +458,32 @@ class TestChartGroupConstants:
for _period, (title, subtitle) in PERIOD_CONFIG.items():
assert isinstance(title, str)
assert isinstance(subtitle, str)
class TestBuildChartGroups:
"""Tests for build_chart_groups."""
def test_png_paths_use_relative_prefix(self, configured_env):
"""PNG fallback paths respect provided asset prefix."""
out_dir = configured_env["out_dir"]
asset_dir = out_dir / "assets" / "repeater"
asset_dir.mkdir(parents=True, exist_ok=True)
(asset_dir / "bat_day_light.png").write_bytes(b"fake")
groups = build_chart_groups(
role="repeater",
period="day",
chart_stats={},
asset_prefix="../",
)
chart = next(
chart
for group in groups
for chart in group["charts"]
if chart["metric"] == "bat"
)
assert chart["use_svg"] is False
assert chart["src_light"] == "../assets/repeater/bat_day_light.png"
assert chart["src_dark"] == "../assets/repeater/bat_day_dark.png"

2
uv.lock generated
View File

@@ -752,7 +752,7 @@ wheels = [
[[package]]
name = "meshcore-stats"
version = "0.2.14"
version = "0.2.15"
source = { editable = "." }
dependencies = [
{ name = "jinja2" },