Compare commits

...

2 Commits

Author SHA1 Message Date
Jorijn Schrijvershof
3fc036fa0b Revert "build(docker): add armv7 container support (#68)"
This reverts commit 75e50f7ee9.
2026-01-13 23:24:00 +01:00
Jorijn Schrijvershof
97ebba4f2d fix(charts): skip short counter intervals (#73) 2026-01-13 17:09:01 +01:00
6 changed files with 84 additions and 66 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

@@ -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 |
|---------|--------------|

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.24@sha256:816fdce3387ed2142e37d2e56e1b1b97ccc1ea87731ba199dc8a25c04e4997c5 AS uv
# =============================================================================
# Stage 1: Build dependencies
# =============================================================================
FROM python:3.14-slim-bookworm@sha256:3be2c910db2dacfb3e576f94c7ffc07c10b115cbcd3de99d49bfb0b4ccfd75e7 AS builder
FROM python:3.14-slim-bookworm@sha256:e8a1ad81a9fef9dc56372fb49b50818cac71f5fae238b21d7738d73ccae8f803 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:e8a1ad81a9fef9dc56372fb49b50818cac71f5fae238b21d7738d73ccae8f803
# 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

@@ -36,6 +36,7 @@ ThemeName = Literal["light", "dark"]
BIN_30_MINUTES = 1800 # 30 minutes in seconds
BIN_2_HOURS = 7200 # 2 hours in seconds
BIN_1_DAY = 86400 # 1 day in seconds
MIN_COUNTER_INTERVAL_RATIO = 0.9 # Allow small scheduling jitter
@dataclass(frozen=True)
@@ -223,25 +224,38 @@ def load_timeseries_from_db(
# For counter metrics, calculate rate of change
if is_counter:
rate_points: list[tuple[datetime, float]] = []
cfg = get_config()
min_interval = max(
1.0,
(cfg.companion_step if role == "companion" else cfg.repeater_step)
* MIN_COUNTER_INTERVAL_RATIO,
)
for i in range(1, len(raw_points)):
prev_ts, prev_val = raw_points[i - 1]
curr_ts, curr_val = raw_points[i]
delta_val = curr_val - prev_val
prev_ts, prev_val = raw_points[0]
for curr_ts, curr_val in raw_points[1:]:
delta_secs = (curr_ts - prev_ts).total_seconds()
if delta_secs <= 0:
continue
if delta_secs < min_interval:
log.debug(
f"Skipping counter sample for {metric} at {curr_ts} "
f"({delta_secs:.1f}s < {min_interval:.1f}s)"
)
continue
delta_val = curr_val - prev_val
# Skip negative deltas (device reboot)
if delta_val < 0:
log.debug(f"Counter reset detected for {metric} at {curr_ts}")
prev_ts, prev_val = curr_ts, curr_val
continue
# Calculate per-second rate, then apply scaling (typically x60 for per-minute)
rate = (delta_val / delta_secs) * scale
rate_points.append((curr_ts, rate))
prev_ts, prev_val = curr_ts, curr_val
raw_points = rate_points
else:

View File

@@ -67,10 +67,49 @@ class TestCounterToRateConversion:
assert ts.points[0].value == pytest.approx(expected_rate)
assert ts.points[1].value == pytest.approx(expected_rate)
def test_applies_scale_factor(self, initialized_db, configured_env):
def test_counter_rate_short_interval_under_step_is_skipped(
self,
initialized_db,
configured_env,
monkeypatch,
):
"""Short sampling intervals are skipped to avoid rate spikes."""
base_ts = 1704067200
monkeypatch.setenv("REPEATER_STEP", "900")
import meshmon.env
meshmon.env._config = None
insert_metrics(base_ts, "repeater", {"nb_recv": 0.0}, initialized_db)
insert_metrics(base_ts + 900, "repeater", {"nb_recv": 100.0}, initialized_db)
insert_metrics(base_ts + 904, "repeater", {"nb_recv": 110.0}, initialized_db)
insert_metrics(base_ts + 1800, "repeater", {"nb_recv": 200.0}, initialized_db)
ts = load_timeseries_from_db(
role="repeater",
metric="nb_recv",
end_time=datetime.fromtimestamp(base_ts + 1800),
lookback=timedelta(hours=2),
period="day",
)
expected_rate = (100.0 / 900.0) * 60.0
assert len(ts.points) == 2
assert ts.points[0].timestamp == datetime.fromtimestamp(base_ts + 900)
assert ts.points[1].timestamp == datetime.fromtimestamp(base_ts + 1800)
for point in ts.points:
assert point.value == pytest.approx(expected_rate)
def test_applies_scale_factor(self, initialized_db, configured_env, monkeypatch):
"""Counter rate is scaled (typically x60 for per-minute)."""
base_ts = 1704067200
monkeypatch.setenv("REPEATER_STEP", "60")
import meshmon.env
meshmon.env._config = None
# Insert values 60 seconds apart for easy math
insert_metrics(base_ts, "repeater", {"nb_recv": 0.0}, initialized_db)
insert_metrics(base_ts + 60, "repeater", {"nb_recv": 60.0}, initialized_db)