Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c7572e215 | ||
|
|
061738a63b | ||
|
|
e8bd97655f | ||
|
|
7d0b36450d | ||
|
|
b12c83e8b5 | ||
|
|
ff4e2a5c5d | ||
|
|
2ef9f80fc1 | ||
|
|
6fe549e34e | ||
|
|
406a8f86b1 |
+13
-72
@@ -2,6 +2,11 @@
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# CI for the self-hosted, host-executor Podman runners (see container/).
|
||||
# The runner image bakes in the Rust toolchain and all build dependencies,
|
||||
# so jobs go straight to cargo — no apt/rustup setup steps (which also
|
||||
# collided on the dpkg lock when jobs ran concurrently in the same runner).
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
@@ -17,90 +22,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake clang libclang-dev \
|
||||
libopus-dev libasound2-dev libsoapysdr-dev
|
||||
|
||||
- name: Set up Rust
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal
|
||||
fi
|
||||
rustup toolchain install stable --profile minimal \
|
||||
--component rustfmt --component clippy
|
||||
rustup default stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: cargo-${{ runner.os }}-
|
||||
|
||||
- name: rustfmt
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo fmt --all -- --check
|
||||
|
||||
run: cargo fmt --all -- --check
|
||||
- name: clippy
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake clang libclang-dev \
|
||||
libopus-dev libasound2-dev libsoapysdr-dev
|
||||
|
||||
- name: Set up Rust
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --profile minimal
|
||||
fi
|
||||
rustup toolchain install stable --profile minimal
|
||||
rustup default stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: cargo-${{ runner.os }}-
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo build --workspace --all-targets --locked
|
||||
|
||||
run: cargo build --workspace --all-targets --locked
|
||||
- name: Test
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo test --workspace --locked
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
reuse:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: REUSE compliance
|
||||
uses: fsfe/reuse-action@v5
|
||||
# `reuse` CLI instead of fsfe/reuse-action: the latter is a Docker
|
||||
# action, which the host-executor runners cannot run. `reuse` is baked
|
||||
# into the runner image (see container/Containerfile).
|
||||
run: reuse lint
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Gitea Actions runner image for trx-rs CI (host-executor / "Pattern B").
|
||||
#
|
||||
# All build dependencies, the Rust toolchain, Node.js (for JS actions such as
|
||||
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI
|
||||
# runs skip the per-run apt/rustup install cost. `sudo` is present so the
|
||||
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they
|
||||
# just become fast no-ops because everything is already installed.
|
||||
FROM docker.io/library/debian:bookworm-slim
|
||||
|
||||
ARG ACT_RUNNER_VERSION=0.2.11
|
||||
ARG NODE_MAJOR=20
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
RUSTUP_HOME=/opt/rustup \
|
||||
CARGO_HOME=/opt/cargo \
|
||||
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# Base tooling + trx-rs build dependencies (mirrors .gitea/workflows/ci.yml).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl xz-utils git sudo pipx \
|
||||
build-essential pkg-config cmake clang libclang-dev \
|
||||
libopus-dev libasound2-dev libsoapysdr-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js (JS-based actions need node in PATH under the host executor).
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml).
|
||||
# The [charset-normalizer] extra provides an encoding-detection backend;
|
||||
# without it (and without libmagic) reuse fails to import at runtime.
|
||||
RUN PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install 'reuse[charset-normalizer]'
|
||||
|
||||
# Rust stable with rustfmt + clippy, installed system-wide.
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal \
|
||||
--component rustfmt --component clippy \
|
||||
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME"
|
||||
|
||||
# act_runner binary.
|
||||
RUN arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \
|
||||
curl -fsSL -o /usr/local/bin/act_runner \
|
||||
"https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-${rarch}" \
|
||||
&& chmod +x /usr/local/bin/act_runner
|
||||
|
||||
# Default config template (seeded into the /data volume on first boot).
|
||||
COPY config.yaml /etc/act_runner/config.yaml
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# /data holds the .runner registration, cache and workflow workspaces.
|
||||
VOLUME /data
|
||||
WORKDIR /data
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
@@ -0,0 +1,118 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Podman-based Gitea Actions runners
|
||||
|
||||
Run two independent Gitea Actions runners on one host as rootless Podman
|
||||
containers managed by systemd (Quadlet) — one per project — instead of two
|
||||
VMs. Uses the **host executor**: workflow steps run directly inside a
|
||||
purpose-built runner image that already has the Rust toolchain and all build
|
||||
dependencies baked in, so CI runs skip the per-run install cost and no
|
||||
Docker/Podman socket is needed.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. |
|
||||
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. |
|
||||
| `config.yaml` | act_runner config template (seeded into each runner's volume). |
|
||||
| `trx-rs-runner.container` | Quadlet unit for the trx-rs runner. |
|
||||
| `project2-runner.container` | Quadlet unit for the second project's runner. |
|
||||
|
||||
## Prerequisites (once per host)
|
||||
|
||||
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged
|
||||
user that will own the runners:
|
||||
|
||||
```bash
|
||||
# Survive logout / start on boot without an interactive session.
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
No `podman.socket` is required for the host executor.
|
||||
|
||||
## 1. Build the image
|
||||
|
||||
```bash
|
||||
cd container
|
||||
podman build -t trx-rs-ci:latest .
|
||||
```
|
||||
|
||||
## 2. Get a registration token
|
||||
|
||||
For **each** repo: *Settings → Actions → Runners → Create new Runner* and copy
|
||||
the token. (Org- or instance-level tokens work too if you prefer wider scope.)
|
||||
|
||||
## 3. Install and start the runners
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/containers/systemd
|
||||
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/
|
||||
|
||||
# Paste each repo's token for the FIRST boot only:
|
||||
# Environment=GITEA_RUNNER_REGISTRATION_TOKEN=xxxx…
|
||||
$EDITOR ~/.config/containers/systemd/trx-rs-runner.container
|
||||
$EDITOR ~/.config/containers/systemd/project2-runner.container
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start trx-rs-runner
|
||||
systemctl --user start project2-runner
|
||||
|
||||
systemctl --user status trx-rs-runner
|
||||
podman logs -f gitea-runner-trx-rs
|
||||
```
|
||||
|
||||
Once each runner shows **online** in the repo's runner list, blank out the
|
||||
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in
|
||||
the `…-data` volume) and `systemctl --user daemon-reload`.
|
||||
|
||||
## Required workflow change: the `reuse` job
|
||||
|
||||
The host executor runs steps directly in the container and therefore **cannot
|
||||
run Docker-based actions**. The current `reuse` job uses `fsfe/reuse-action@v5`,
|
||||
which is a Docker action. `reuse` is baked into the image, so replace that job
|
||||
with a plain command:
|
||||
|
||||
```yaml
|
||||
reuse:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: REUSE compliance
|
||||
run: reuse lint
|
||||
```
|
||||
|
||||
The `lint` and `test` jobs need no changes: their `sudo apt-get …` and rustup
|
||||
steps still run, but become fast no-ops because the image already has those
|
||||
packages and the toolchain. (`sudo` is included in the image for exactly this
|
||||
reason.)
|
||||
|
||||
> If you would rather keep Docker-based actions and per-run images, use the
|
||||
> **Docker executor** instead: drop the `:host` suffix from the label in
|
||||
> `config.yaml`, enable `systemctl --user --now enable podman.socket`, mount it
|
||||
> into the container, and set `container.docker_host` to the socket path. That
|
||||
> trades the baked-in speed for stronger per-job isolation.
|
||||
|
||||
## Tuning
|
||||
|
||||
- **`capacity`** (in `config.yaml`) — concurrent jobs per runner. Rust builds
|
||||
are heavy; 1–2 is sensible when two runners share a host.
|
||||
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps
|
||||
so one project cannot starve the other.
|
||||
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is
|
||||
enforcing.
|
||||
|
||||
## Committing these files
|
||||
|
||||
If you add this directory to a REUSE-checked repo, register the markdown in
|
||||
`REUSE.toml` (the other files carry inline SPDX headers):
|
||||
|
||||
```toml
|
||||
[[annotations]]
|
||||
path = ["container/**"]
|
||||
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
|
||||
SPDX-License-Identifier = "GPL-2.0-or-later"
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# act_runner configuration template. Seeded into /data/config.yaml on first
|
||||
# boot; edit the copy inside the volume to change settings per runner.
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
# Registration state. Relative to the daemon's working directory (/data).
|
||||
file: .runner
|
||||
# Concurrent jobs this runner will pick up. Rust builds are heavy — keep this
|
||||
# modest, especially if two runners share one host. The trx-rs workflow has
|
||||
# three parallel jobs (lint, test, reuse); capacity 2 lets two overlap.
|
||||
capacity: 2
|
||||
timeout: 3h
|
||||
# Map the workflow's `runs-on: ubuntu-latest` to the HOST executor, i.e. run
|
||||
# steps directly inside THIS container (which already has all the toolchain).
|
||||
# No Docker/Podman socket is required in this mode.
|
||||
labels:
|
||||
- "ubuntu-latest:host"
|
||||
|
||||
cache:
|
||||
# Built-in actions cache server (used by actions/cache). Stored in the volume.
|
||||
enabled: true
|
||||
dir: "/data/cache"
|
||||
|
||||
host:
|
||||
# Where per-job workspaces are created.
|
||||
workdir_parent: /data/workflows
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Registers the runner on first boot (if no .runner state exists in /data),
|
||||
# then runs the act_runner daemon. Idempotent: on subsequent boots it reuses
|
||||
# the stored registration and ignores the token.
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG_FILE="${CONFIG_FILE:-/data/config.yaml}"
|
||||
|
||||
cd /data
|
||||
|
||||
# Seed the config from the image's template on first boot so it lives in the
|
||||
# persistent volume and can be edited there.
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
cp /etc/act_runner/config.yaml "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# runner.file in config.yaml is ".runner" (relative to this CWD => /data/.runner).
|
||||
if [ ! -f /data/.runner ]; then
|
||||
if [ -z "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]; then
|
||||
echo "ERROR: no /data/.runner registration and GITEA_RUNNER_REGISTRATION_TOKEN is empty." >&2
|
||||
echo " Grab a token from the repo's Settings -> Actions -> Runners and set it" >&2
|
||||
echo " in the Quadlet unit for the first boot only." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Registering runner '${GITEA_RUNNER_NAME:-podman}' with ${GITEA_INSTANCE_URL} ..."
|
||||
act_runner register --no-interactive \
|
||||
--config "$CONFIG_FILE" \
|
||||
--instance "${GITEA_INSTANCE_URL:?set GITEA_INSTANCE_URL}" \
|
||||
--token "$GITEA_RUNNER_REGISTRATION_TOKEN" \
|
||||
--name "${GITEA_RUNNER_NAME:-podman}" \
|
||||
--labels "${GITEA_RUNNER_LABELS:-ubuntu-latest:host}"
|
||||
fi
|
||||
|
||||
exec act_runner daemon --config "$CONFIG_FILE"
|
||||
@@ -0,0 +1,40 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Rootless Podman Quadlet for the SECOND project's Gitea Actions runner,
|
||||
# co-located on the same host as the trx-rs runner.
|
||||
#
|
||||
# It has its own name, its own data volume and its own registration token, so
|
||||
# the two runners are fully independent. They share the `ubuntu-latest` label,
|
||||
# but registration SCOPE (which repo each token came from) keeps their jobs
|
||||
# separate — neither will pick up the other's work.
|
||||
#
|
||||
# If project 2 needs different build dependencies, build it its own image from
|
||||
# an adjusted Containerfile and point Image= at that instead of reusing the
|
||||
# trx-rs image below.
|
||||
|
||||
[Unit]
|
||||
Description=Gitea Actions runner — project 2
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=localhost/gitea-act-runner:latest
|
||||
ContainerName=gitea-runner-project2
|
||||
Volume=gitea-runner-project2-data:/data:Z
|
||||
|
||||
Environment=CONFIG_FILE=/data/config.yaml
|
||||
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
|
||||
Environment=GITEA_RUNNER_NAME=project2-podman
|
||||
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
|
||||
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
|
||||
|
||||
PodmanArgs=--cpus=4.0 --memory=6g
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
TimeoutStartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,41 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Rootless Podman Quadlet for the trx-rs Gitea Actions runner.
|
||||
# Install to ~/.config/containers/systemd/trx-rs-runner.container then:
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user start trx-rs-runner
|
||||
#
|
||||
# First boot only: paste a registration token (repo Settings -> Actions ->
|
||||
# Runners) into GITEA_RUNNER_REGISTRATION_TOKEN. After the runner appears
|
||||
# online you can blank it again — the registration is persisted in the volume.
|
||||
|
||||
[Unit]
|
||||
Description=Gitea Actions runner — trx-rs
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=localhost/trx-rs-ci:latest
|
||||
ContainerName=gitea-runner-trx-rs
|
||||
# Persistent state: .runner registration, cache, workspaces.
|
||||
Volume=gitea-runner-trx-rs-data:/data:Z
|
||||
|
||||
Environment=CONFIG_FILE=/data/config.yaml
|
||||
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
|
||||
Environment=GITEA_RUNNER_NAME=trx-rs-podman
|
||||
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
|
||||
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
|
||||
|
||||
# Resource caps so a heavy Rust build here cannot starve the other project's
|
||||
# runner on the same host. Tune to your box.
|
||||
PodmanArgs=--cpus=4.0 --memory=6g
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
# A cold Rust build can be slow; don't let systemd consider startup failed.
|
||||
TimeoutStartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -274,7 +274,8 @@ mod tests {
|
||||
|
||||
// Pseudo-random noise vs gradient — correlation should be low.
|
||||
let noise: Vec<u8> = (0..256)
|
||||
.map(|i| ((i * 1103515245 + 12345) as u32 >> 8 & 0xff) as u8)
|
||||
.map(|i| (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345))
|
||||
.map(|value| ((value >> 8) & 0xff) as u8)
|
||||
.collect();
|
||||
let r = asm.correlation_with_last(&noise).expect("r");
|
||||
assert!(
|
||||
|
||||
@@ -1624,6 +1624,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script>
|
||||
<script defer src="/vendor/leaflet.js"></script>
|
||||
<script defer src="/leaflet-ais-tracksymbol.js"></script>
|
||||
<script defer src="/webgl-renderer.js"></script>
|
||||
<script defer src="/app.js"></script>
|
||||
<script>
|
||||
@@ -1632,41 +1634,67 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
var pluginScripts = {
|
||||
'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js', '/sat.js', '/wefax.js'],
|
||||
'map-data': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js'],
|
||||
'map': ['/map-core.js', '/leaflet-ais-tracksymbol.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'],
|
||||
'map': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'],
|
||||
'statistics': ['/map-core.js'],
|
||||
'bookmarks': ['/bookmarks.js'],
|
||||
'recorder': [],
|
||||
'settings': ['/vchan.js', '/scheduler.js']
|
||||
};
|
||||
var loaded = new Set();
|
||||
function loadPlugins(tab) {
|
||||
var scripts = pluginScripts[tab];
|
||||
if (!scripts) return;
|
||||
scripts.forEach(function(src) {
|
||||
if (loaded.has(src)) return;
|
||||
loaded.add(src);
|
||||
var loading = new Map();
|
||||
|
||||
function loadScript(src) {
|
||||
if (loaded.has(src)) return Promise.resolve();
|
||||
if (loading.has(src)) return loading.get(src);
|
||||
|
||||
var request = new Promise(function(resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.defer = true;
|
||||
s.onload = function() {
|
||||
loaded.add(src);
|
||||
loading.delete(src);
|
||||
resolve();
|
||||
};
|
||||
s.onerror = function() {
|
||||
loading.delete(src);
|
||||
reject(new Error('Failed to load plugin script: ' + src));
|
||||
};
|
||||
document.body.appendChild(s);
|
||||
});
|
||||
loading.set(src, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function loadPlugins(tab) {
|
||||
var scripts = pluginScripts[tab];
|
||||
if (!scripts) return Promise.resolve();
|
||||
return scripts.reduce(function(sequence, src) {
|
||||
return sequence.then(function() { return loadScript(src); });
|
||||
}, Promise.resolve());
|
||||
}
|
||||
|
||||
function requestPlugins(tab) {
|
||||
return loadPlugins(tab).catch(function(err) {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
// Eager plugin loading is triggered by app.js (after window.trx is set up)
|
||||
// via window.loadEagerPlugins(). Dynamic scripts are effectively async, so
|
||||
// loading them before app.js would cause map-core.js to crash when
|
||||
// window.trx is not yet defined.
|
||||
window.loadEagerPlugins = function() {
|
||||
['digital-modes', 'map-data', 'bookmarks', 'settings'].forEach(loadPlugins);
|
||||
return Promise.all(
|
||||
['digital-modes', 'map-data', 'bookmarks', 'settings'].map(requestPlugins)
|
||||
);
|
||||
};
|
||||
// Load others on tab switch
|
||||
document.addEventListener('click', function(e) {
|
||||
var tab = e.target.closest('[data-tab]');
|
||||
if (tab) loadPlugins(tab.dataset.tab);
|
||||
if (tab) requestPlugins(tab.dataset.tab);
|
||||
});
|
||||
window.loadPluginsForTab = loadPlugins;
|
||||
})();
|
||||
</script>
|
||||
<!-- Template cloning is handled by navigateToTab() in app.js -->
|
||||
<script defer src="/vendor/leaflet.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -378,7 +378,7 @@ pub struct DecoderHistories {
|
||||
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
|
||||
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
|
||||
/// Approximate total entry count across all decoders, maintained
|
||||
/// atomically so `estimated_total_count()` avoids 9 lock acquisitions.
|
||||
/// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
|
||||
total_count: AtomicUsize,
|
||||
}
|
||||
|
||||
@@ -845,6 +845,23 @@ impl DecoderHistories {
|
||||
pub fn estimated_total_count(&self) -> usize {
|
||||
self.total_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Rebuild the aggregate count after bulk restoration bypasses the normal
|
||||
/// record methods.
|
||||
pub(crate) fn recalculate_total_count(&self) {
|
||||
let total = lock_or_recover(&self.ais, "ais_history").len()
|
||||
+ lock_or_recover(&self.vdes, "vdes_history").len()
|
||||
+ lock_or_recover(&self.aprs, "aprs_history").len()
|
||||
+ lock_or_recover(&self.hf_aprs, "hf_aprs_history").len()
|
||||
+ lock_or_recover(&self.cw, "cw_history").len()
|
||||
+ lock_or_recover(&self.ft8, "ft8_history").len()
|
||||
+ lock_or_recover(&self.ft4, "ft4_history").len()
|
||||
+ lock_or_recover(&self.ft2, "ft2_history").len()
|
||||
+ lock_or_recover(&self.wspr, "wspr_history").len()
|
||||
+ lock_or_recover(&self.lrpt, "lrpt_history").len()
|
||||
+ lock_or_recover(&self.wefax, "wefax_history").len();
|
||||
self.total_count.store(total, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the audio capture thread.
|
||||
|
||||
@@ -19,7 +19,7 @@ use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, CwEvent, Ft8Message, VdesMessage, WefaxMessage, WsprMessage,
|
||||
AisMessage, AprsPacket, CwEvent, Ft8Message, LrptImage, VdesMessage, WefaxMessage, WsprMessage,
|
||||
};
|
||||
|
||||
use crate::audio::DecoderHistories;
|
||||
@@ -118,6 +118,11 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.hf_aprs.lock() {
|
||||
for e in load_key::<AprsPacket>(db, &k("hf_aprs")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.cw.lock() {
|
||||
for e in load_key::<CwEvent>(db, &k("cw")) {
|
||||
h.push_back(e);
|
||||
@@ -128,16 +133,32 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.ft4.lock() {
|
||||
for e in load_key::<Ft8Message>(db, &k("ft4")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.ft2.lock() {
|
||||
for e in load_key::<Ft8Message>(db, &k("ft2")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.wspr.lock() {
|
||||
for e in load_key::<WsprMessage>(db, &k("wspr")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.lrpt.lock() {
|
||||
for e in load_key::<LrptImage>(db, &k("lrpt")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
if let Ok(mut h) = histories.wefax.lock() {
|
||||
for e in load_key::<WefaxMessage>(db, &k("wefax")) {
|
||||
h.push_back(e);
|
||||
}
|
||||
}
|
||||
histories.recalculate_total_count();
|
||||
}
|
||||
|
||||
/// Flush `histories` to the database under `rig_id`-prefixed keys and sync.
|
||||
@@ -162,6 +183,11 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
|
||||
drop(h);
|
||||
save_key(db, &k("aprs"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.hf_aprs.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
save_key(db, &k("hf_aprs"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.cw.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
@@ -172,11 +198,26 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
|
||||
drop(h);
|
||||
save_key(db, &k("ft8"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.ft4.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
save_key(db, &k("ft4"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.ft2.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
save_key(db, &k("ft2"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.wspr.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
save_key(db, &k("wspr"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.lrpt.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
save_key(db, &k("lrpt"), &snapshot);
|
||||
}
|
||||
if let Ok(h) = histories.wefax.lock() {
|
||||
let snapshot = h.clone();
|
||||
drop(h);
|
||||
@@ -185,20 +226,38 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
|
||||
let _ = db.dump();
|
||||
}
|
||||
|
||||
fn flush_all_rigs(db: &Mutex<PickleDb>, rig_histories: &[(String, Arc<DecoderHistories>)]) {
|
||||
let Ok(mut guard) = db.lock() else {
|
||||
tracing::warn!("history database mutex poisoned; skipping periodic flush");
|
||||
return;
|
||||
};
|
||||
for (rig_id, histories) in rig_histories {
|
||||
flush_all(&mut guard, rig_id, histories);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a Tokio task that flushes all rigs' histories to disk every 60 seconds.
|
||||
///
|
||||
/// Snapshot cloning, JSON serialization, and disk I/O run on Tokio's blocking
|
||||
/// pool so a large history database cannot stall an async runtime worker.
|
||||
pub fn spawn_flush_task(
|
||||
db: Arc<Mutex<PickleDb>>,
|
||||
rig_histories: Vec<(String, Arc<DecoderHistories>)>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let rig_histories = Arc::new(rig_histories);
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
interval.tick().await; // consume the immediate first tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Ok(mut guard) = db.lock() {
|
||||
for (rig_id, histories) in &rig_histories {
|
||||
flush_all(&mut guard, rig_id, histories);
|
||||
}
|
||||
let db = Arc::clone(&db);
|
||||
let rig_histories = Arc::clone(&rig_histories);
|
||||
if let Err(err) = tokio::task::spawn_blocking(move || {
|
||||
flush_all_rigs(&db, rig_histories.as_slice());
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %err, "history flush worker failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -302,4 +361,79 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&db_file);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_and_load_all_restores_previously_omitted_histories() {
|
||||
let db_file = std::env::temp_dir().join(format!(
|
||||
"trx_history_all_{}_{}.db",
|
||||
std::process::id(),
|
||||
now_unix_ms()
|
||||
));
|
||||
let mut db = PickleDb::new(
|
||||
&db_file,
|
||||
PickleDbDumpPolicy::DumpUponRequest,
|
||||
SerializationMethod::Json,
|
||||
);
|
||||
let source = DecoderHistories::new();
|
||||
let now = Instant::now();
|
||||
|
||||
source.hf_aprs.lock().unwrap().push_back((
|
||||
now,
|
||||
AprsPacket {
|
||||
rig_id: Some("rig-a".into()),
|
||||
ts_ms: Some(now_unix_ms()),
|
||||
src_call: "TEST".into(),
|
||||
dest_call: "APRS".into(),
|
||||
path: String::new(),
|
||||
info: "history".into(),
|
||||
info_bytes: Vec::new(),
|
||||
packet_type: "position".into(),
|
||||
crc_ok: true,
|
||||
lat: None,
|
||||
lon: None,
|
||||
symbol_table: None,
|
||||
symbol_code: None,
|
||||
},
|
||||
));
|
||||
for (queue, mode) in [(&source.ft4, "FT4"), (&source.ft2, "FT2")] {
|
||||
queue.lock().unwrap().push_back((
|
||||
now,
|
||||
Ft8Message {
|
||||
rig_id: Some("rig-a".into()),
|
||||
ts_ms: now_unix_ms(),
|
||||
snr_db: -10.0,
|
||||
dt_s: 0.1,
|
||||
freq_hz: 1_000.0,
|
||||
message: mode.into(),
|
||||
},
|
||||
));
|
||||
}
|
||||
source.lrpt.lock().unwrap().push_back((
|
||||
now,
|
||||
LrptImage {
|
||||
rig_id: Some("rig-a".into()),
|
||||
pass_start_ms: now_unix_ms(),
|
||||
pass_end_ms: now_unix_ms(),
|
||||
mcu_count: 1,
|
||||
path: "/tmp/lrpt.png".into(),
|
||||
ts_ms: Some(now_unix_ms()),
|
||||
satellite: None,
|
||||
channels: None,
|
||||
geo_bounds: None,
|
||||
ground_track: None,
|
||||
},
|
||||
));
|
||||
|
||||
flush_all(&mut db, "rig-a", &source);
|
||||
let restored = DecoderHistories::new();
|
||||
load_all(&db, "rig-a", &restored);
|
||||
|
||||
assert_eq!(restored.hf_aprs.lock().unwrap().len(), 1);
|
||||
assert_eq!(restored.ft4.lock().unwrap().len(), 1);
|
||||
assert_eq!(restored.ft2.lock().unwrap().len(), 1);
|
||||
assert_eq!(restored.lrpt.lock().unwrap().len(), 1);
|
||||
assert_eq!(restored.estimated_total_count(), 4);
|
||||
|
||||
let _ = std::fs::remove_file(db_file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,9 +251,9 @@ fn mul_freq_domain(buf: &mut [FftComplex<f32>], h_freq: &[FftComplex<f32>], scal
|
||||
unsafe {
|
||||
mul_freq_domain_neon(buf, h_freq, scale);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "aarch64"))]
|
||||
mul_freq_domain_scalar(buf, h_freq, scale);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user