10 Commits
Author SHA1 Message Date
Marc Froehlich a8f12876ec Align private metrics with GoAccess request panel 2026-09-14 23:01:06 +02:00
Marc Froehlich 89422d18ad Fix private analytics aggregation 2026-09-14 22:40:53 +02:00
Marc Froehlich a3f3bf8872 Changes at the stats analytics 2026-09-14 20:33:50 +02:00
Marc Froehlich 393c9c49ee Changed link monitoring as described by on4kst, Alain 2026-09-13 19:50:33 +02:00
Marc Froehlich f0508f51a2 Added screenshot where thhey belongs to at the project hp 2026-09-13 16:57:36 +02:00
Marc Froehlich 923d76beb0 refactoring project website landing page 2026-09-13 10:38:53 +02:00
Marc Froehlich 821535a24f Documentation on user counter and analytics to the kst4contest page 2026-09-12 22:31:03 +02:00
Marc Froehlich 914a06cb7f Corrections on user counter and analytics to the kst4contest page 2026-09-12 03:55:27 +02:00
Marc Froehlich b23884bde1 Added user counter and analytics to the kst4contest page 2026-09-11 21:03:45 +02:00
Rsclub2_2andClaude Opus 5 4ad1cf71dd Document that a sandboxed logging program needs its own network permission
A logging program started as a Flatpak, or through a Wine environment
such as Bottles, uses the network permissions of that sandbox. Without
shared=network its 127.0.0.1 is not the loopback KST4Contest listens on,
so the connection is refused although KST4Contest reports the port
correctly.

Adds the check and the override command to the DX Cluster troubleshooting
section in both language versions, and notes that the published
KST4Contest Flatpak manifest already carries --share=network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT8R7zTMoQnmJj6dGbaqqf
2026-09-07 22:21:47 +02:00
52 changed files with 6830 additions and 182 deletions
@@ -0,0 +1,416 @@
From afb77c6a8674ff5571a9c57cbe7108b9a5189407 Mon Sep 17 00:00:00 2001
From: Codex <codex@openai.com>
Date: Fri, 4 Sep 2026 00:17:15 +0300
Subject: [PATCH] Document v1.43 and fix release metadata
---
.github/workflows/tagged-release.yml | 84 +++++++++----------
docs/PROJECT_CONTEXT.md | 4 +
github_docs/de-Changelog.md | 80 +++++++++++++++---
github_docs/en-Changelog.md | 80 +++++++++++++++---
.../kst4contest/ApplicationConstants.java | 2 +-
website/src/news/2026-09-03-version-1-43.md | 29 +++++++
6 files changed, 212 insertions(+), 67 deletions(-)
create mode 100644 website/src/news/2026-09-03-version-1-43.md
diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml
index d9133550..be7bf536 100644
--- a/.github/workflows/tagged-release.yml
+++ b/.github/workflows/tagged-release.yml
@@ -786,45 +786,45 @@ jobs:
release-assets/macos/KST4Contest-${{ github.ref_name }}-macos-*.dmg,
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-en.pdf,
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
-
- # The update feed is generated only after GitHub has published the
- # release. Otherwise the Releases API cannot return the release notes
- # belonging to the tag which triggered this workflow.
- - name: Set up Node.js for website build
- uses: actions/setup-node@v4
- with:
- node-version: "24"
- cache: npm
- cache-dependency-path: website/package-lock.json
-
- - name: Build and validate website after release publication
- working-directory: website
- run: |
- npm ci
- npm test
- npm run build
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Verify Stable release in update feed
- if: ${{ !startsWith(github.ref_name, 'beta-') }}
- working-directory: website
- run: npm run validate:version-info
- env:
- EXPECTED_STABLE_VERSION: ${{ github.ref_name }}
-
- - name: Attach verified version info to tagged release
- run: >-
- gh release upload "${GITHUB_REF_NAME}"
- website/_site/kst4ContestVersionInfo.xml
- --clobber
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Upload verified website artifact
- uses: actions/upload-artifact@v4.3.4
- with:
- name: kst4contest-website-${{ github.ref_name }}
- path: website/_site/
- if-no-files-found: error
- retention-days: 14
\ No newline at end of file
+
+ # The update feed is generated only after GitHub has published the
+ # release. Otherwise the Releases API cannot return the release notes
+ # belonging to the tag which triggered this workflow.
+ - name: Set up Node.js for website build
+ uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+ cache: npm
+ cache-dependency-path: website/package-lock.json
+
+ - name: Build and validate website after release publication
+ working-directory: website
+ run: |
+ npm ci
+ npm test
+ npm run build
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Verify Stable release in update feed
+ if: ${{ !startsWith(github.ref_name, 'beta-') }}
+ working-directory: website
+ run: npm run validate:version-info
+ env:
+ EXPECTED_STABLE_VERSION: ${{ github.ref_name }}
+
+ - name: Attach verified version info to tagged release
+ run: >-
+ gh release upload "${GITHUB_REF_NAME}"
+ website/_site/kst4ContestVersionInfo.xml
+ --clobber
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload verified website artifact
+ uses: actions/upload-artifact@v4.3.4
+ with:
+ name: kst4contest-website-${{ github.ref_name }}
+ path: website/_site/
+ if-no-files-found: error
+ retention-days: 14
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
index b0ca4b63..c4d70f1f 100644
--- a/docs/PROJECT_CONTEXT.md
+++ b/docs/PROJECT_CONTEXT.md
@@ -162,6 +162,10 @@ The repository contains the KST4Contest website under `website/`, published sepa
Current website/deployment scripts and update-feed behaviour must be inspected before changes; do not rely on historical assumptions.
+- `APPLICATION_CURRENT_VERSION` is the user-visible semantic version and must use the dotted `major.minor.patch` form. `APPLICATION_CURRENTVERSIONNUMBER` is retained only for older feeds and encodes patch releases by appending the patch digit, for example `1.43.1` as `1.431`.
+- The tagged-release workflow creates the GitHub Release before building the website update feed. This ordering is required because `versionInfo.js` reads the published release body through the GitHub Releases API.
+- After publication, the workflow tests and builds the website, validates the expected Stable version, attaches `kst4ContestVersionInfo.xml` to the release and uploads the complete website build as a workflow artifact.
+
## Important Decisions and Workarounds
- Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features.
diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md
index 2022f904..622bb446 100644
--- a/github_docs/de-Changelog.md
+++ b/github_docs/de-Changelog.md
@@ -8,6 +8,72 @@ Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [Git
---
+## v1.43.1 (2026-09-03)
+
+**Korrigierte Versionsmetadaten**
+
+v1.43.1 enthält dieselben funktionalen Änderungen wie v1.43.0. Korrigiert wurden die Anwendungs- und Build-Metadaten für Anzeige, ON4KST-Kennung und Update-Vergleich. Ein Teil der Metadaten des ersten v1.43.0-Pakets wies den Build noch als Version 1.42 aus.
+
+### Behoben
+
+- **Einheitliche semantische Version:** Die sichtbare Anwendungsversion verwendet jetzt vollständig `1.43.1`. Der kompakte Wert `1.431` bleibt ausschließlich im veralteten numerischen Feld für die Kompatibilität mit älteren Update-Feeds erhalten.
+
+- **Update-Feed im Tagged-Release-Workflow:** Website-Build, Prüfung des Versionsfeeds und Upload der Artefakte laufen jetzt tatsächlich nach der Veröffentlichung des GitHub Releases. Diese Schritte waren zuvor versehentlich in die Artefaktliste der Release-Action eingerückt und wurden deshalb übersprungen.
+
+Wer v1.43.0 installiert hat, sollte v1.43.1 verwenden. Die Funktionen bleiben unverändert; korrigiert werden nur die Versionsmetadaten und der Release-Workflow.
+
+Die korrigierte Version ist als [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1) verfügbar.
+
+---
+
+## v1.43.0 (2026-09-03)
+
+**Zuverlässigere Log-Synchronisation, gespeicherte Tabellenlayouts und bessere DX-Cluster-Kompatibilität**
+
+v1.43 konzentriert sich auf die zuverlässige Verarbeitung externer Logdaten und den stabilen Betrieb während längerer Conteste. Hinzu kommen praktische Einstellmöglichkeiten für Tabellenlayouts und die Stationsgruppierung auf der Karte.
+
+### Neu
+
+- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt.
+
+- **Tabellenlayout automatisch gesichert:** Tabellen erhalten beim ersten brauchbaren Inhalt sinnvolle Breiten. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden nach kurzer Verzögerung in `preferences.xml` geschrieben. Haupt- und Monitorfenster behalten getrennte Layouts für DXCluster und QSO of the other.
+
+- **Tooltips für gekürzte Tabellenwerte:** Normale Tabellenzellen zeigen ihren vollständigen Inhalt, wenn die sichtbare Spalte zu schmal ist. Funktionale Tooltips und anklickbare Links bleiben davon unberührt.
+
+- **Hinweis bei neu angelegtem Simplelogfile:** Fehlt die ausgewählte Datei, legt KST4Contest sie an und zeigt einen Hinweis mit Dateipfad, konkretem Testablauf und Link zum passenden Abschnitt des Handbuchs.
+
+### Geändert
+
+- **Robuste Simplelogfile-Auswertung:** Die ausgewählte Datei wird einmal pro Minute ausgewertet und nach jedem Durchlauf geschlossen, damit das Logprogramm sie ersetzen oder rotieren kann. Erkannte Rufzeichen setzen den globalen Worked-Status für alle aktiven Suffixvarianten des Basisrufzeichens. Bei deaktivierter Funktion findet kein Dateizugriff statt; Lese- oder Erstellungsfehler beenden die periodische Aufgabe nicht mehr.
+
+- **Einheitliche Bandwerte externer Logger:** UCXLog-kompatible Pakete und Win-Test-Ereignisse verwenden eine gemeinsame Bandnormalisierung. Numerische Werte, Meter- und Zentimeterangaben sowie die vorhandenen Win-Test-IDs setzen damit dieselben Worked-Markierungen und Worked-Großfelder. Insbesondere `2320`, `5760` und `10368` werden zuverlässig verarbeitet; bei fehlendem oder unbekanntem Band wird nur der globale Worked-Status gesetzt.
+
+- **Kompakte vollständige Frequenzen erkannt:** Vollständige Frequenzangaben ohne Dezimaltrenner werden auf allen unterstützten Bändern akzeptiert; die letzten drei Ziffern bilden den kHz-Anteil. Nackte dreistellige Zahlen benötigen weiterhin einen erkennbaren Frequenzkontext, damit Signalrapporte und andere Zahlen nicht als QRG behandelt werden.
+
+- **DXSpider-kompatibles Spotformat:** Lokale DX-Cluster-Spots verwenden eine feste 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt unbemerkt abgeschnitten zu werden. Damit ist [Issue #86](https://github.com/praktimarc/kst4contest/issues/86) behoben.
+
+- **Aktive ON4KST-Verbindungsprüfung:** Ein ruhiger Chatserver wird mit einer expliziten, sitzungsweiten Abfrage geprüft, bevor die Verbindung als unterbrochen gilt. Heartbeat und Prüftelegramme behalten das erforderliche CR/LF-Framing.
+
+- **Zuverlässige Altersmarkierung privater Nachrichten:** Eingehende Privatnachrichten verwenden bis zu fünf Minuten lang die definierten grünen Altersstufen. Eigene Nachrichten behalten ihre separate Darstellung; leere oder wiederverwendete Tabellenzeilen kehren zum normalen Design zurück und behalten keine veraltete Hervorhebung.
+
+### Behoben
+
+- **Worked-Status nach dem Login:** Persistierte SQLite-Informationen werden vor der Veröffentlichung jeder initialen ON4KST-Benutzerliste geladen und angewendet. Erneute Verbindungen, beide Kategorien und alle aktiven Varianten eines Basisrufzeichens starten dadurch mit dem richtigen Status. Damit ist [Issue #85](https://github.com/praktimarc/kst4contest/issues/85) behoben.
+
+- **Fehlerhafte Trennung bei ruhigem Server:** Eine gültige ON4KST-Verbindung wird nicht mehr beendet, nur weil der Server gerade keine Aktivitätszeilen überträgt.
+
+### Dokumentation und Auslieferung
+
+- Das deutsche und englische Handbuch wurden mit der Implementierung abgeglichen und überarbeitet. Ein neues Kapitel zum Contest-Workflow verbindet die einzelnen Funktionen zu einem praktischen Betriebsablauf; außerdem wurden die Abschnitte zu Dual Chat, Privatnachrichten, QRG-Synchronisation, Simplelogfile-Auswertung und Konfiguration präzisiert.
+
+- Die Website beschreibt Band- und Richtungsgelegenheiten, Stationskarte, QRG-Verarbeitung, Filter, globale Nachrichtenansichten, Privatnachrichten und Log-Synchronisation jetzt ausführlicher.
+
+- Die AUR-Paketdefinitionen wurden auf v1.43.0 aktualisiert.
+
+Die vollständige Funktionalität von v1.43.0 steht im [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0) bereit. Wegen der inkonsistenten eingebetteten Versionsmetadaten ist v1.43.1 der empfohlene Paketstand.
+
+---
+
## v1.42.0 (2026-08-22)
**Gemeinsamer Bandkontext, sitzungsbasierte ON4KST-Verbindung und signierte macOS-Pakete**
@@ -38,21 +104,17 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
- **Filter zurücksetzen:** Ein eigener Reset-Button entfernt die aktiven Filterprädikate der Benutzerliste zuverlässig.
-- **Optionale Kartencluster:** **Group nearby stations** schaltet die räumliche Gruppierung bei niedrigen Zoomstufen unmittelbar ein oder aus. Die automatisch gespeicherte Auswahl verändert weder Kartenausschnitt noch Stationsauswahl; die Zusammenfassung aktiver Varianten desselben Basisrufzeichens bleibt davon unabhängig. Damit ist [Issue #79](https://github.com/praktimarc/kst4contest/issues/79) umgesetzt.
+- **Kartencluster:** Räumlich dicht beieinanderliegende Stationen werden bei niedrigen Zoomstufen zusammengefasst. Die ausgewählte Station und relevante Richtungsgelegenheiten bleiben einzeln sichtbar.
- **Ausblendbare Streckenanalyse:** Geländeprofil und Analysebereich der Stationskarte können vollständig ausgeblendet werden. Die Auswahl wird gespeichert und beim nächsten Programmstart wiederhergestellt.
### Geändert
-- **Tabellenlayout automatisch gesichert:** Tabellen werden beim ersten brauchbaren Inhalt sinnvoll dimensioniert. Manuell geänderte Spaltenbreiten, Fenstergrößen und relevante Divider werden verzögert in `preferences.xml` gespeichert; Haupt- und Monitorfenster behalten für DXCluster und QSO of the other getrennte Layouts. Gekürzte normale Zellwerte zeigen ihren Volltext im Tooltip, ohne funktionale Tooltips oder anklickbare Links zu verdrängen.
-
-- **DX-Cluster-Zeilenformat vereinheitlicht:** Lokale Spots verwenden jetzt eine feste, DXSpider-kompatible 75-Zeichen-Nutzzeile mit dem DX-Rufzeichen ab Spalte 27, einem 30 Zeichen breiten Kommentarfeld und der UTC-Zeit ab Spalte 71. Das Format bleibt auch bei Mikrowellenfrequenzen bis 24 GHz stabil. Überlange DX-Rufzeichen werden verworfen und protokolliert, statt still abgeschnitten zu werden; AirScout- und Testkommentare sind entsprechend kompakter.
-
- **Sessionbezogene ON4KST-Verbindungssteuerung:** Socket, Reader, Writer, Messagebus und Warteschlangen gehören jetzt zu einer eindeutig identifizierten Verbindungssession. Veraltete Threads einer abgelösten Verbindung können dadurch keine Daten mehr verarbeiten oder die neue Verbindung schließen. `ONLINE` wird erst nach bestätigtem Login und vollständig empfangenen Benutzerlisten gemeldet. Verbindungsaufbau, Login und Synchronisation besitzen feste Zeitlimits; Heartbeats, ausbleibende Eingangsdaten, EOF sowie Lese- und Schreibfehler werden überwacht und lösen bei Bedarf einen kontrollierten Neuaufbau mit Backoff aus.
- **ON4KST-Protokollbefehle abgesichert:** Ausgehende Befehle werden zentral aufgebaut und auf gültige Kategorien, Locatoren und unerlaubte Frame-Trennzeichen geprüft. Da ON4KST pro TCP-Session nur einen Locator verwaltet, wird für beide Chat-Kategorien der Hauptlocator verwendet und eine abweichende zweite Konfiguration protokolliert, statt widersprüchliche Befehle an den Server zu senden.
-- **QRG-Erkennung präzisiert:** Vollständige Frequenzangaben werden auch ohne Dezimaltrenner erkannt; die letzten drei Ziffern bilden dabei den kHz-Anteil. Relative Frequenzen bleiben unverändert, und nackte dreistellige Zahlen gelten weiterhin nur bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen.
+- **QRG-Erkennung präzisiert:** Vollständige und relative Frequenzangaben werden weiterhin erkannt. Nackte dreistellige Zahlen gelten nur noch bei erkennbarem Frequenzkontext als QRG. Signalrapporte, Bandangaben und andere Zahlen erzeugen dadurch seltener falsche Frequenzen.
- **Stationsbezogener Frequenzkontext:** Bei relativen QRGs verwendet KST4Contest zuerst einen höchstens 30 Minuten alten Bandkontext derselben Station. Erst wenn dieser fehlt, wird das global konfigurierte Fallback-Band verwendet.
@@ -86,10 +148,6 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
- **DXLog-Gesamtlog übernommen:** Der UCXLog-kompatible UDP-Listener verarbeitet neben `contactinfo` auch `contactreplace`. Dadurch kann ein von DXLog.net als vollständiges Log ausgesendeter Datenbestand eingelesen werden.
-- **Logger-Bandwerte vereinheitlicht:** Numerische sowie Meter- und Zentimeterangaben aus UCXLog-kompatiblen QSO-Paketen und die Band-IDs von Win-Test werden einmal normalisiert und danach einheitlich für Worked-Markierungen und Worked-Großfelder verwendet. Dadurch setzen insbesondere `2320`, `5760` und `10368` zuverlässig ihre vorhandenen Bandmarkierungen. Bei einem fehlenden oder unbekannten Band bleibt es beim globalen Worked-Status.
-
-- **Simplelogfile-Verhalten präzisiert:** Die ausgewählte Textdatei wird einmal pro Minute mit einem festen Rufzeichenmuster ausgewertet. Treffer setzen den globalen Worked-Status aller aktiven Varianten des Basisrufzeichens, werden aber nicht in SQLite persistiert. Eine fehlende Datei wird angelegt; Lese- und Erstellungsfehler beenden die periodische Auswertung nicht. Ein Datenbank-Reset verändert die Datei nicht, sodass enthaltene Rufzeichen bei der nächsten Auswertung erneut als gearbeitet markiert werden.
-
- **Automatische QRG-Übernahme abgesichert:** `MYQRG` wird nur von einer aktivierten Schnittstelle aktualisiert, die tatsächlich gültige `RadioInfo`- beziehungsweise Win-Test-`STATUS`-Pakete liefert. Eine aktivierte, aber nicht liefernde Quelle ersetzt die notwendige Funktionsprüfung oder manuelle QRG-Pflege nicht.
- **Versionserkennung verbessert:** Versionsnummern werden semantisch verglichen, damit beispielsweise Patch-Versionen und Nightly-Stände nicht mehr durch eine einfache Fließkommazahl falsch eingeordnet werden.
@@ -98,8 +156,6 @@ v1.42 führt mehrere bisher getrennte Auswertungen zusammen. Bandinformationen,
- **Zuverlässige Benutzerliste beim Login:** Ungültige oder unvollständige `UA0`-Teilnehmerdatensätze werden einzeln verworfen und protokolliert, ohne die Verarbeitung der alphabetisch folgenden Teilnehmer abzubrechen. Die gültigen Einträge werden zunächst pro Kategorie gesammelt und erst mit dem ersten zugehörigen `UE`-Abschlussframe vollständig veröffentlicht.
-- **Persistierter Worked-Status beim Listenaufbau:** Beim Abschluss jeder initialen ON4KST-Benutzerliste wird der SQLite-Zustand einmal geladen und vor der Veröffentlichung auf die neuen Chatmember angewendet. Das gilt für beide Kategorien, erneute Verbindungen und alle aktiven Varianten eines Basisrufzeichens.
-
- **Benutzerliste verschwindet nach dem Login:** ON4KST kann nach Namens-, Status- oder anderen Live-Änderungen weitere `UE`-Frames für dieselbe Kategorie senden. Wiederholte Abschlussframes werden jetzt erkannt und ignoriert, damit eine bereits gefüllte Benutzerliste nicht durch eine leere Momentaufnahme ersetzt wird.
- **Fehlgeschlagener Erstaufbau und Verbindungsverlust:** Wenn beim Programmstart keine Verbindung zum Server hergestellt werden kann, läuft KST4Contest nicht mehr in eine Endlos- oder Busy-Wait-Schleife. Die Oberfläche bleibt bedienbar und weitere Versuche erfolgen mit begrenztem Backoff. Auch ein vom Server geschlossener oder über längere Zeit stummer Socket wird zuverlässig erkannt.
diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md
index 1635ce44..af1cb47b 100644
--- a/github_docs/en-Changelog.md
+++ b/github_docs/en-Changelog.md
@@ -8,6 +8,72 @@ Published Stable versions and their application packages are available under [Gi
---
+## v1.43.1 (2026-09-03)
+
+**Corrected version metadata**
+
+v1.43.1 contains the same functional changes as v1.43.0. It corrects the application and build metadata used for display, ON4KST identification and update comparison. Some metadata in the first v1.43.0 package set still identified the build as version 1.42.
+
+### Fixed
+
+- **Consistent semantic version:** The user-visible application version now uses the complete `1.43.1` form. The compact value `1.431` remains only in the deprecated numeric field required for compatibility with older update feeds.
+
+- **Tagged-release update feed:** The website build, version-feed validation and artifact upload now run as actual workflow steps after the GitHub Release has been published. They were previously indented into the release action's artifact list and therefore skipped.
+
+Users of v1.43.0 should install v1.43.1. The functionality is unchanged; the update only corrects the version metadata and release workflow.
+
+The corrected version is available as [Release v1.43.1](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1).
+
+---
+
+## v1.43.0 (2026-09-03)
+
+**More reliable log synchronisation, persistent layouts and better DX Cluster compatibility**
+
+v1.43 concentrates on reliability around external log data and long-running contest operation. It also adds practical control over table layouts and station grouping on the map.
+
+### Added
+
+- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79).
+
+- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay. The main and monitor windows keep separate DXCluster and QSO-of-the-other layouts.
+
+- **Tooltips for truncated table values:** Normal table cells expose their complete text when the visible column is too narrow, without replacing functional tooltips or clickable links.
+
+- **Simplelogfile creation notice:** When the selected file does not exist, KST4Contest creates it and displays a notice with the file path, a concrete test procedure and a link to the relevant manual section.
+
+### Changed
+
+- **Robust Simplelogfile evaluation:** The selected file is evaluated once per minute and closed after every pass so that the logging application can replace or rotate it. Detected callsigns apply the global Worked state to every active suffix variant of the base callsign. Disabling the function prevents any file access, while read or creation errors no longer terminate the periodic task.
+
+- **Consistent external logger bands:** UCXLog-compatible packets and Win-Test events use one shared band normalisation. Numeric values, metre and centimetre designators and the existing Win-Test IDs now set the same Worked flags and worked grid squares. Values including `2320`, `5760` and `10368` are handled reliably; a missing or unknown band sets only the global Worked state.
+
+- **Compact full-frequency recognition:** Complete frequencies without a decimal separator are accepted across the supported bands, with the final three digits interpreted as the kHz part. Bare three-digit numbers still require recognisable frequency context so that signal reports and unrelated numbers are not treated as QRGs.
+
+- **DXSpider-compatible spot format:** Local DX Cluster spots use a fixed 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The format remains stable up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated. This resolves [Issue #86](https://github.com/praktimarc/kst4contest/issues/86).
+
+- **Active ON4KST connection probe:** A quiet chat server is checked with an explicit session-wide probe before the connection is treated as dead. Heartbeats and probe frames retain the required CR/LF framing.
+
+- **Reliable private-message age highlighting:** Incoming private messages use the defined green age levels for up to five minutes. Locally sent messages retain their separate style, and empty or reused table rows return to the normal design instead of keeping an obsolete highlight.
+
+### Fixed
+
+- **Worked state after login:** Persisted SQLite Worked information is loaded and applied before each initial ON4KST user list is published. Reconnects, both categories and all active variants of a base callsign therefore start with the correct state. This resolves [Issue #85](https://github.com/praktimarc/kst4contest/issues/85).
+
+- **False disconnect during quiet periods:** A valid ON4KST connection is no longer closed merely because the server currently has no activity lines to send.
+
+### Documentation and packaging
+
+- The German and English manuals were revised against the implementation. A new contest-workflow chapter connects the individual functions into a practical operating sequence, while the sections on dual chat, private-message handling, QRG synchronisation, Simplelogfile evaluation and configuration were clarified.
+
+- The website feature pages now describe band and direction opportunities, the station map, QRG handling, filters, global message views, private-message handling and logger synchronisation in more detail.
+
+- The AUR package definitions were updated for v1.43.0.
+
+The complete v1.43.0 functionality is available in [Release v1.43.0](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.0). Because its embedded version metadata is inconsistent, v1.43.1 is the recommended package set.
+
+---
+
## v1.42.0 (2026-08-22)
**Shared band context, session-based ON4KST connection and signed macOS packages**
@@ -38,21 +104,17 @@ v1.42 brings several previously separate calculations together. Band information
- **Filter reset:** A dedicated reset button reliably removes the active user-list filter predicates.
-- **Optional map clustering:** **Group nearby stations** immediately enables or disables spatial grouping at lower zoom levels. The automatically stored setting changes neither the viewport nor the selected station; aggregation of active variants sharing one base callsign remains independent. This implements [Issue #79](https://github.com/praktimarc/kst4contest/issues/79).
+- **Map clustering:** Stations close to each other are grouped at lower zoom levels. The selected station and relevant direction opportunities remain individually visible.
- **Hideable path analysis:** The terrain profile and analysis section of the station map can be hidden completely. The selected state is stored and restored at the next application start.
### Changed
-- **Automatic table-layout persistence:** Tables receive useful widths when their first meaningful contents arrive. Manually changed column widths, window sizes and relevant dividers are written to `preferences.xml` after a short delay; the main and monitor windows keep separate DXCluster and QSO-of-the-other layouts. Truncated normal cell values expose their full text in a tooltip without replacing functional tooltips or clickable links.
-
-- **Unified DX Cluster line format:** Local spots now use a fixed, DXSpider-compatible 75-character payload line with the DX callsign in column 27, a 30-character comment field and UTC time in column 71. The layout remains stable for microwave frequencies up to 24 GHz. Overlong DX callsigns are rejected and logged instead of being silently truncated; AirScout and test comments are correspondingly more compact.
-
- **Session-based ON4KST connection lifecycle:** Each socket, reader, writer, message bus and queue now belongs to an explicitly identified connection session. Delayed threads from an obsolete connection can therefore no longer process data or close its replacement. `ONLINE` is reported only after the login has been accepted and all requested user lists have been received. Connection setup, login and synchronisation use bounded timeouts, while heartbeats, missing inbound traffic, EOF and read or write failures trigger controlled reconnect attempts with backoff where appropriate.
- **Validated ON4KST protocol commands:** Outgoing frames are built centrally and checked for valid categories, locators and prohibited frame delimiters. Because ON4KST maintains one locator per TCP session, the main locator is used for both chat categories and a conflicting secondary configuration is logged instead of sending contradictory commands to the server.
-- **More precise QRG recognition:** Complete frequencies are also recognised without a decimal separator, with the final three digits interpreted as the kHz part. Relative frequencies remain unchanged, and bare three-digit numbers still require recognisable frequency context. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies.
+- **More precise QRG recognition:** Complete and relative frequency references continue to be recognised. Bare three-digit numbers are treated as QRGs only when a frequency context is available. Signal reports, band designators and unrelated numbers therefore produce fewer false frequencies.
- **Station-specific frequency context:** For relative QRGs, KST4Contest first uses a band context for the same station which is no more than 30 minutes old. The globally configured fallback band is used only when this context is unavailable.
@@ -86,10 +148,6 @@ v1.42 brings several previously separate calculations together. Band information
- **DXLog full-log import:** In addition to `contactinfo`, the UCXLog-compatible UDP listener processes `contactreplace`. This allows a complete log broadcast by DXLog.net to be imported.
-- **Consistent logger band values:** Numeric, metre and centimetre values from UCXLog-compatible QSO packets and Win-Test band IDs are normalised once and then used consistently for Worked marks and worked grid squares. In particular, `2320`, `5760` and `10368` now reliably set their existing band marks. A missing or unknown band continues to set only the global Worked status.
-
-- **Defined Simplelogfile behaviour:** The selected text file is evaluated once per minute using a fixed callsign pattern. Matches set the global Worked status for all active variants of the base callsign but are not persisted in SQLite. A missing file is created, and read or creation errors do not terminate the periodic task. A database reset does not change the file, so callsigns contained in it are marked as worked again during the next evaluation.
-
- **Guarded automatic QRG updates:** `MYQRG` is updated only by an enabled interface which actually supplies valid `RadioInfo` or Win-Test `STATUS` packets. An enabled source which provides no data does not remove the need for a functional check or manual QRG maintenance.
- **Improved version comparison:** Versions are compared semantically so that patch releases and Nightly versions are not misclassified by conversion to a floating-point number.
@@ -98,8 +156,6 @@ v1.42 brings several previously separate calculations together. Band information
- **Reliable initial user list:** Invalid or incomplete `UA0` member records are rejected and logged individually without preventing alphabetically following members from being processed. Valid entries are staged per category and published as one complete snapshot when the first corresponding `UE` end marker is received.
-- **Persisted Worked state during initial-list setup:** At the end of each initial ON4KST user list, the SQLite state is loaded once and applied to the new chat members before publication. This covers both categories, reconnects and every active variant of a base callsign.
-
- **User list disappearing after login:** ON4KST may send additional `UE` frames for the same category after name, state or other live updates. Repeated end markers are now detected and ignored so that an already populated user list cannot be replaced by an empty snapshot.
- **Failed initial connection and lost sockets:** An unavailable server during startup no longer sends KST4Contest into an endless or busy-wait loop. The user interface remains responsive and further attempts use bounded reconnect backoff. Sockets closed by the server, or connections without inbound traffic for an excessive period, are also detected reliably.
diff --git a/src/main/java/kst4contest/ApplicationConstants.java b/src/main/java/kst4contest/ApplicationConstants.java
index 6da42ff4..71f64575 100644
--- a/src/main/java/kst4contest/ApplicationConstants.java
+++ b/src/main/java/kst4contest/ApplicationConstants.java
@@ -20,7 +20,7 @@ public class ApplicationConstants {
/**
* Version shown to the user and used for semantic version comparison.
*/
- public static final String APPLICATION_CURRENT_VERSION = "1.431";
+ public static final String APPLICATION_CURRENT_VERSION = "1.43.1";
/**
* Legacy numeric representation used only while older update feeds and
diff --git a/website/src/news/2026-09-03-version-1-43.md b/website/src/news/2026-09-03-version-1-43.md
new file mode 100644
index 00000000..30754c3d
--- /dev/null
+++ b/website/src/news/2026-09-03-version-1-43.md
@@ -0,0 +1,29 @@
+---
+title: Version 1.43.1 released
+summary: Reliable log synchronisation, persistent layouts and better DX Cluster compatibility
+date: 2026-09-03
+---
+
+## Version 1.43.1
+
+Version 1.43 focuses on the parts of KST4Contest that have to keep working during a long contest: ON4KST connection monitoring, log synchronisation, Worked information and the data sent to external logging software.
+
+The first v1.43.0 packages already contained all functional changes described below, but some embedded version metadata still identified the build as version 1.42. v1.43.1 corrects this. If you already downloaded v1.43.0, use v1.43.1 instead.
+
+### What changed
+
+- **More reliable log synchronisation:** The Simplelogfile parser now closes the selected file after every pass, handles suffix variants by their base callsign and reports when a missing file has been created. UCXLog-compatible packets and Win-Test events share the same band normalisation, including the existing microwave bands.
+- **Correct Worked state after login:** Persisted Worked information is applied before the initial ON4KST user list becomes visible. Reconnects and both chat categories therefore start with the correct state.
+- **No false disconnect on a quiet server:** KST4Contest actively checks a quiet ON4KST session before treating it as dead. A period without new activity lines no longer causes an unnecessary reconnect.
+- **DX Cluster compatibility:** Local spots now use a fixed, DXSpider-compatible 75-character line format that is also accepted reliably by DXLog. Frequencies up to 24 GHz keep the required column positions.
+- **Layouts that stay where you put them:** Table column widths, window sizes and relevant dividers are saved automatically. Truncated table values expose their full text in a tooltip.
+- **Controllable map grouping:** **Group nearby stations** switches map clustering on or off without changing the current viewport or selected station.
+- **Smaller corrections:** Complete QRGs without a decimal separator are recognised across the supported bands, and private-message age highlighting no longer remains attached to reused table rows.
+
+The complete technical list is available in the [changelog](/manual/en/changelog/) and the [GitHub release notes](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1).
+
+### Getting it
+
+Packages for Windows, Linux and macOS are available on the [download page](/download/) and in the [GitHub release](https://github.com/praktimarc/kst4contest/releases/tag/v1.43.1). The AUR packages are updated through their normal release workflow.
+
+The German and English manuals have also been revised. The new contest-workflow chapter connects the individual functions into a practical operating sequence instead of describing them only in isolation. 73
--
2.51.1
Binary file not shown.
Binary file not shown.
+49 -5
View File
@@ -1,6 +1,6 @@
# KST4Contest Project Context
Last reviewed: 2026-09-03
Last reviewed: 2026-09-14
This file is the durable technical project context for KST4Contest. It is not a user manual and not a replacement for the changelog. Current code, tests and authoritative external specifications remain the source of truth when this document is stale or ambiguous.
@@ -126,11 +126,11 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
### ON4KST session liveness
- After 90 seconds without inbound data, the application keeps the established empty CRLF heartbeat.
- At about 180 seconds of inbound idle time, the TCP session sends one `RDXQ|<main chat id>|` probe. The probe state belongs to the session, so a two-category session still sends only one probe per idle phase.
- Any subsequent inbound server frame confirms the probe. `DXQ` is accepted as the expected internal response and is not published as chat content.
- Only after the session is fully authenticated and synchronized, more than 90 seconds without inbound server data trigger one client-side `CK|\r\n` liveness probe. The trailing pipe matches the framing used by the server for its own `CK|\r\n` probe. The probe state belongs to the TCP session, so a two-category session still sends only one probe per idle phase.
- The live ON4KST test returned `OK|\r\n` for `CK|\r\n`. KST4Contest treats both that confirmed frame and the originally specified `OK\r\n` form as internal responses: it records inbound activity, confirms the outstanding probe and does not publish the response as chat content. Any other inbound server frame also confirms reachability and starts a new idle phase.
- A `CK` initiated by the server remains a separate protocol case and receives the established empty CRLF response. It must not be confused with the client-side probe.
- If no inbound frame arrives by about 210 seconds, the existing reconnect flow remains responsible for replacing the session.
- Probe diagnostics contain the session id, main category, opcode and timing only. They must not include credentials, complete server frames or normal chat messages.
- Liveness diagnostics contain the session id, opcode and timing only. They must not include credentials, complete server frames or normal chat messages.
## User Workflow / UI Invariants
@@ -170,12 +170,56 @@ After implementation use targeted documentation-impact checks. Do not run a comp
The repository contains the KST4Contest website under `website/`, published separately from the desktop application build.
- Historical GHz-Tagung papers are published as German and English PDF assets below `website/src/assets/papers/` and explained on `/background/`. Their editable ODT sources remain outside the public Eleventy input tree and must never be copied into the generated site.
- The home-page news teaser is derived from the date-sorted Eleventy `news` tag collection. It deliberately renders nothing when that collection is empty; it must not be pinned to a release version.
Current website/deployment scripts and update-feed behaviour must be inspected before changes; do not rely on historical assumptions.
- `APPLICATION_CURRENT_VERSION` is the user-visible semantic version and must use the dotted `major.minor.patch` form. `APPLICATION_CURRENTVERSIONNUMBER` is retained only for older feeds and encodes patch releases by appending the patch digit, for example `1.43.1` as `1.431`.
- The tagged-release workflow creates the GitHub Release before building the website update feed. This ordering is required because `versionInfo.js` reads the published release body through the GitHub Releases API.
- After publication, the workflow tests and builds the website, validates the expected Stable version, attaches `kst4ContestVersionInfo.xml` to the release and uploads the complete website build as a workflow artifact.
### Server-side website statistics
The detailed operator runbook is
[`website/ops/analytics/README.md`](../website/ops/analytics/README.md). This
section records only the durable architecture and operational boundaries.
#### Architecture and data flow
- Production runs on Ubuntu Server 24.04 with Nginx 1.24, Node.js 18.19.1 and GoAccess 1.8.1. GoAccess has GeoIP2/MMDB and OpenSSL support but no Zlib support; missing Zlib is intentional for this operating model.
- Nginx writes eligible `GET` page requests to a reduced, tab-separated website analytics log in addition to the normal operational log. It records server name, client IP, time, method, normalized path without query string, protocol, status, bytes and user agent; referrer and remote user are omitted.
- Nginx removes assets, downloads, status/monitoring paths, update feed, counter requests and known crawler clients from the website log. GoAccess supplies the second crawler-classification layer, anonymises IP addresses at the configured level and performs Country lookup locally through GeoLite2-Country.
- A second reduced Nginx log contains only case-sensitive `GET` requests with final status `200` for the exact path `/kst4ContestVersionInfo.xml`. It deliberately retains browser and bot requests and stays excluded from website page views, visits and the public visitor counter.
- The hourly systemd timer starts the Node.js generator. Each registered site and both reduced streams are processed from the optional uncompressed `.1` rotation followed by the current log; older `.gz` files are not part of regular processing. Logrotate retains raw logs for 14 days and requires `delaycompress` for this handover.
- GoAccess maintains one private report/database per registered site and one combined report/database. The generator then updates its separate daily visit-counter state, publishes `visitor-count.json` for enabled sites, and produces durable private website/update-information day and year views. Private reports are served with Basic Auth from `stats.hamradioonline.de`; the public home page requests only its same-origin counter file.
#### Durable invariants and persistence
- GoAccess defines a visit by IP address, date and user agent. The public value is therefore an approximate visit total, not a count of unique people, and remains separate from page views.
- The public contract contains only `schemaVersion`, `visits`, `since` and `updatedAt`. The browser uses no analytics cookie, local storage or third-party request; failed or invalid responses leave the counter hidden.
- The site registry at `/etc/hamradioonline-analytics/sites.json` owns stable site IDs, hostnames, current uncompressed log paths, activation/coverage dates and output targets. `stats.hamradioonline.de` is never registered as an analytics site. Existing state cannot be continued with a changed hostname or activation/coverage identity without an explicit migration or reset decision.
- GoAccess databases below `/var/lib/hamradioonline-analytics/db` retain rolling 395-day detail through `--persist` and `--restore`. `/var/lib/hamradioonline-analytics/public-counter-state.json` retains daily visit values from activation onward and remains the durable business source for the lifetime public total. Its schema and public meaning are unchanged.
- `/var/lib/hamradioonline-analytics/private-metrics-state.json` permanently retains website page-view totals, absolute Country values and normalized path totals per day, plus update-information request totals and absolute Country values per day. It contains no per-request IP address, raw user agent or timestamp. Update hours and conservative client groups are retained only for the latest 14 `Europe/Berlin` calendar days. There is no permanent path-by-Country request matrix.
- Update-information requests are not program starts or user counts. Browsers and bots can fetch the XML, and existing application versions do not send a reliable KST4Contest-specific user agent; a Java user agent identifies only an unknown Java application.
- GoAccess 1.8.1 exposes Country data as `geolocation`; combined jobs explicitly enable `VIRTUAL_HOSTS` and require `vhosts`. Individual site jobs do not enable that panel. `geo_location` and `virtual_hosts` are invalid interface names.
- Private daily values are upserted rather than added. Regular hourly reprocessing and historical imports are repeatable; overlapping aggregates must be equal or monotonically more complete. The generator fails on inconsistent overlaps or when path sums differ from the existing GoAccess page-request definition.
- The explicit historical-import mode accepts verified Nginx combined or reduced TSV logs and inclusive coverage dates. Node.js reads `.gz` files without relying on Zlib support in GoAccess. Missing earlier history remains unknown rather than becoming zero, and the first covered date is stored and displayed.
- Generator inputs and staged GoAccess outputs are validated before publication. Files are replaced atomically, temporary normalized per-day inputs use private run directories and are removed after the run, dry-runs use temporary state without the production lock, and productive runs use `/run/hamradioonline-analytics/generator.lock`. Exit codes are 1 for runtime/publication errors, 2 for configuration errors and 3 when the production lock cannot be acquired.
#### Components, roles and external services
- `hamradio-analytics` is the locked, non-login service account which reads configuration, Country MMDB and reduced logs and writes service state. Nginx runs as `www-data`, writes both reduced logs and can read only reports and the public counter, not private databases or either state file. `stats-reader` is a local Nginx Basic Auth login whose password file remains outside Git.
- Root-managed program/configuration lives below `/opt/hamradioonline-analytics` and `/etc/hamradioonline-analytics`; service state lives below `/var/lib/hamradioonline-analytics`. Report/public directories are shared read-only with Nginx through group ownership; no ACL dependency exists.
- MaxMind is used only by `geoipupdate` to download GeoLite2-Country. Visitor lookups are local; Account ID and License Key remain in the protected server configuration. Let's Encrypt supplies TLS for the statistics vhost through the permanent ACME webroot and `/snap/bin/certbot`. GitHub supplies website source and deployment, not analytics processing.
- The website deploy checks out `origin/main`, builds the static site and publishes it to the Nginx document root. It does not install or overwrite analytics programs/configuration, systemd, Nginx, Logrotate, GeoIP, Basic Auth or Certbot state; those remain separate manual server operations.
- Passwords, hashes, deployment credentials, MaxMind credentials, ACME account data and private TLS keys must remain outside the repository and ordinary diagnostic output.
#### Open operational work
- The server has no comprehensive automated backup plan yet. A future server-wide plan must include both non-regenerable state files, GoAccess databases and protected operational configuration without extending the published 14-day raw-log retention.
- Repository implementation and tests do not install server configuration or import production data. The first real rotation after installing the XML log still requires an explicit operational check of both `.1` handovers, ownership/readability, subsequent generator success and absence of duplicate counting. The actual regular Nginx log format and complete historical coverage must be verified on the server before import.
## Important Decisions and Workarounds
- Preserve full callsign/category identity while applying base-call normalisation only to specifically defined features.
+18
View File
@@ -240,6 +240,24 @@ Prüfe zuerst, welche Frequenzen für die betreffende Station innerhalb der letz
Ist kein aktueller Stationskontext vorhanden, prüfe die Auswahl unter **Fallback band for relative QRG detection**. Das Fallback wird nur benötigt, wenn sich das Band weder aus einer vollständigen Frequenz noch aus dem aktuellen Kontext des Absenders ergibt.
### Das Logprogramm läuft in einer eigenen Sandbox
Ein Logprogramm, das als Flatpak oder über eine Wine-Umgebung wie Bottles gestartet wird, benutzt die Netzwerkrechte dieser Sandbox. Teilt die Sandbox das Netzwerk des Rechners nicht, ist `127.0.0.1` darin nicht das `127.0.0.1`, auf dem KST4Contest lauscht. Die Verbindung wird dann abgewiesen, obwohl KST4Contest den Port korrekt meldet.
Bei einem Flatpak-Logprogramm lassen sich die Rechte so prüfen:
```bash
flatpak info --show-permissions <Anwendungs-ID>
```
Im Abschnitt `[Context]` muss `shared=network` stehen. Nachträglich vergeben lässt es sich mit:
```bash
flatpak override --user --share=network <Anwendungs-ID>
```
Das Gleiche gilt, wenn KST4Contest selbst als Flatpak läuft. Dessen veröffentlichtes Manifest enthält `--share=network` bereits, ein lauschender Port ist daher vom Rechner selbst und von anderen Anwendungen darauf erreichbar.
### Der Spot wird vom Logger ausgeblendet
Verwende ein Spotter-Rufzeichen, das nicht mit dem eigenen Contest-Rufzeichen identisch ist. Abhängig vom Logger können eigene Spots gefiltert oder besonders behandelt werden.
+18
View File
@@ -240,6 +240,24 @@ First check which frequencies were detected for the station during the previous
If no current station context exists, check **Fallback band for relative QRG detection**. The fallback is used only when the band cannot be determined from a complete frequency or the sender's current context.
### The Logger Runs in Its Own Sandbox
A logging programme started as a Flatpak, or through a Wine environment such as Bottles, uses the network permissions of that sandbox. If the sandbox does not share the host network, `127.0.0.1` inside it is not the `127.0.0.1` on which KST4Contest is listening, and the connection is refused even though KST4Contest reports the port correctly.
For a Flatpak logger, check its permission with:
```bash
flatpak info --show-permissions <application id>
```
The `[Context]` section has to contain `shared=network`. It can be granted with:
```bash
flatpak override --user --share=network <application id>
```
The same applies when KST4Contest itself runs as a Flatpak. Its published manifest already contains `--share=network`, so a listening port is reachable from the host and from other applications on the same computer.
### The Logger Hides the Spot
Try a spotter callsign which differs from the contest callsign. Depending on the logger, spots from the local callsign may be filtered or handled specially. KST4Contest itself does not require the two callsigns to differ.
@@ -877,10 +877,9 @@ public class MessageBusManagementThread extends Thread {
|| messageToProcess.getMessageText().isEmpty()) {
// No processable data.
} else {
if (On4KstProtocol.isConnectionProbeResponse(
if (On4KstProtocol.isInternalDxqResponse(
messageToProcess.getMessageText())) {
// DXQ is the internal response to the active connection probe.
// Liveness was already recorded by the session manager.
// Preserve the established internal handling of DXQ server data.
return;
}
@@ -2183,7 +2182,7 @@ public class MessageBusManagementThread extends Thread {
// e.printStackTrace();
// }
if (!On4KstProtocol.isConnectionProbeResponse(
if (!On4KstProtocol.isInternalDxqResponse(
messageTextRaw.getMessageText())) {
System.out.println(messageTextRaw.getMessageText() + " <- RXed"); // Stdout at
// Console#######################################################TODO:Wichtig
@@ -47,9 +47,7 @@ final class On4KstConnectionManager {
static final int CONNECT_TIMEOUT_MILLIS = 10_000; //TCP-Connect-Timeout
static final long LOGIN_FALLBACK_MILLIS = 2_000L; //Login-Fallback
static final long HANDSHAKE_TIMEOUT_MILLIS = 45_000L; //Handshake-Timeout
static final long APPLICATION_HEARTBEAT_AFTER_MILLIS = 90_000L; //Application-Heartbeat
/** Idle duration after which the server is asked for current DX data. */
static final long CONNECTION_PROBE_AFTER_MILLIS = 180_000L; //Active connection probe
static final long CLIENT_LIVENESS_PROBE_AFTER_MILLIS = 90_000L;
static final long INBOUND_STALE_AFTER_MILLIS = 210_000L; //Stale-Timeout - time without rxed data
static final List<Long> RECONNECT_DELAYS_MILLIS =
List.of(2_000L, 5_000L, 10_000L, 20_000L, 30_000L); //Reconnect-Backoff if no connection possible
@@ -181,10 +179,10 @@ final class On4KstConnectionManager {
session.lastProgressMillis.set(now);
String opcode = On4KstProtocol.opcode(line);
long probeResponseMillis = session.connectionProbe.acknowledge(now);
long probeResponseMillis = session.clientLivenessProbe.acknowledge(now);
if (probeResponseMillis >= 0L) {
LOGGER.log(Level.INFO,
"ON4KST connection probe confirmed: session {0}, "
"ON4KST client liveness probe confirmed: session {0}, "
+ "received opcode {1}, response time {2} ms",
new Object[] {
sessionId,
@@ -193,8 +191,8 @@ final class On4KstConnectionManager {
});
}
if ("CK".equals(opcode)) {
sendHeartbeat(session);
if (On4KstProtocol.isServerLivenessProbe(line)) {
sendServerLivenessProbeResponse(session);
}
if (!session.loginSent
@@ -265,8 +263,7 @@ final class On4KstConnectionManager {
token,
socket,
receiveQueue,
transmitQueue,
mainCategory);
transmitQueue);
ReadThread readThread = new ReadThread(
token, socket, receiveQueue, this::isActiveSession,
@@ -541,42 +538,35 @@ final class On4KstConnectionManager {
session.transmitQueue.offer(message);
}
private void sendHeartbeat(Session session) {
private void sendServerLivenessProbeResponse(Session session) {
if (session == null || !isActiveSession(session.id)) {
return;
}
long now = System.currentTimeMillis();
session.lastHeartbeatMillis.set(now);
LOGGER.log(Level.FINE,
"Sending application heartbeat for ON4KST session {0}",
"Responding to ON4KST server liveness probe: session {0}, "
+ "opcode CK",
session.id);
ChatMessage heartbeat = new ChatMessage();
heartbeat.setMessageDirectedToServer(true);
heartbeat.setMessageText("");
session.transmitQueue.offer(heartbeat);
sendControl(session, On4KstProtocol.serverLivenessProbeResponse());
}
private void sendConnectionProbe(
private void sendClientLivenessProbe(
Session session,
long now,
long inboundIdle
) {
if (session == null || !isActiveSession(session.id)
|| !session.connectionProbe.tryStart(now)) {
|| !session.clientLivenessProbe.tryStart(now)) {
return;
}
LOGGER.log(Level.INFO,
"Sending ON4KST connection probe: session {0}, main category "
+ "{1}, inbound idle {2} seconds",
"Sending ON4KST client liveness probe: session {0}, "
+ "opcode CK, inbound idle {1} seconds",
new Object[] {
session.id,
session.mainCategory,
inboundIdle / 1_000L
});
sendControl(
session,
On4KstProtocol.connectionProbe(session.mainCategory));
sendControl(session, On4KstProtocol.clientLivenessProbe());
}
private void onConnectionFailure(long sessionId, Throwable failure) {
@@ -682,8 +672,8 @@ final class On4KstConnectionManager {
long inboundIdle = now - lastInboundMillis;
IdleAction idleAction = determineIdleAction(
inboundIdle,
session.lastHeartbeatMillis.get() >= lastInboundMillis,
session.connectionProbe.isOutstanding());
session.online,
session.clientLivenessProbe.isOutstanding());
if (session.lastInboundMillis.get() != lastInboundMillis) {
return;
@@ -695,16 +685,15 @@ final class On4KstConnectionManager {
return;
}
long probeWaitMillis =
session.connectionProbe.responseWaitMillis(now);
session.clientLivenessProbe.responseWaitMillis(now);
if (probeWaitMillis >= 0L) {
LOGGER.log(Level.WARNING,
"ON4KST connection probe timed out: session "
+ "{0}, main category {1}, no response "
+ "for {2} ms, inbound idle {3} seconds; "
"ON4KST client liveness probe timed out: "
+ "session {0}, opcode CK, no response "
+ "for {1} ms, inbound idle {2} seconds; "
+ "reconnecting",
new Object[] {
session.id,
session.mainCategory,
probeWaitMillis,
inboundIdle / 1_000L
});
@@ -713,9 +702,8 @@ final class On4KstConnectionManager {
new SocketException("No ON4KST data received for "
+ inboundIdle / 1_000L + " seconds"));
}
case CONNECTION_PROBE ->
sendConnectionProbe(session, now, inboundIdle);
case HEARTBEAT -> sendHeartbeat(session);
case CLIENT_LIVENESS_PROBE ->
sendClientLivenessProbe(session, now, inboundIdle);
case NONE -> {
// The session is active or already has the required idle action.
}
@@ -731,19 +719,18 @@ final class On4KstConnectionManager {
*/
static IdleAction determineIdleAction(
long inboundIdleMillis,
boolean heartbeatSentForIdlePhase,
boolean online,
boolean probeOutstanding
) {
if (!online) {
return IdleAction.NONE;
}
if (inboundIdleMillis > INBOUND_STALE_AFTER_MILLIS) {
return IdleAction.TIMEOUT;
}
if (inboundIdleMillis >= CONNECTION_PROBE_AFTER_MILLIS
if (inboundIdleMillis > CLIENT_LIVENESS_PROBE_AFTER_MILLIS
&& !probeOutstanding) {
return IdleAction.CONNECTION_PROBE;
}
if (inboundIdleMillis > APPLICATION_HEARTBEAT_AFTER_MILLIS
&& !heartbeatSentForIdlePhase) {
return IdleAction.HEARTBEAT;
return IdleAction.CLIENT_LIVENESS_PROBE;
}
return IdleAction.NONE;
}
@@ -894,15 +881,13 @@ final class On4KstConnectionManager {
private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LinkedBlockingQueue<ChatMessage> transmitQueue;
private final int mainCategory;
private final long connectedMillis = System.currentTimeMillis();
private final AtomicLong lastInboundMillis =
new AtomicLong(connectedMillis);
private final AtomicLong lastProgressMillis =
new AtomicLong(connectedMillis);
private final AtomicLong lastHeartbeatMillis = new AtomicLong();
private final ConnectionProbeState connectionProbe =
new ConnectionProbeState();
private final ClientLivenessProbeState clientLivenessProbe =
new ClientLivenessProbeState();
private final Map<Integer, Map<String, ChatMember>> initialMembers =
new ConcurrentHashMap<>();
@@ -920,27 +905,24 @@ final class On4KstConnectionManager {
long id,
Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue,
LinkedBlockingQueue<ChatMessage> transmitQueue,
int mainCategory
LinkedBlockingQueue<ChatMessage> transmitQueue
) {
this.id = id;
this.socket = socket;
this.receiveQueue = receiveQueue;
this.transmitQueue = transmitQueue;
this.mainCategory = mainCategory;
}
}
/** Maintenance action selected by the session monitor. */
enum IdleAction {
NONE,
HEARTBEAT,
CONNECTION_PROBE,
CLIENT_LIVENESS_PROBE,
TIMEOUT
}
/** Tracks one outstanding liveness probe for the complete TCP session. */
static final class ConnectionProbeState {
/** Tracks one client-initiated liveness probe for the complete TCP session. */
static final class ClientLivenessProbeState {
private final AtomicLong sentMillis = new AtomicLong();
boolean tryStart(long now) {
@@ -54,13 +54,32 @@ final class On4KstProtocol {
+ "|0|";
}
/** Builds the active liveness probe for the session's main chat. */
static String connectionProbe(int category) {
return "RDXQ|" + category(category) + "|";
/** Builds the session-wide liveness probe initiated by this client. */
static String clientLivenessProbe() {
return "CK|";
}
/** Returns whether a server frame is the expected liveness-probe response. */
static boolean isConnectionProbeResponse(String frame) {
/** Returns whether a server frame acknowledges a client-initiated probe. */
static boolean isClientLivenessProbeResponse(String frame) {
if (frame == null) {
return false;
}
String normalized = frame.trim().toUpperCase(Locale.ROOT);
return "OK".equals(normalized) || "OK|".equals(normalized);
}
/** Returns whether ON4KST initiated its own liveness check. */
static boolean isServerLivenessProbe(String frame) {
return "CK".equals(opcode(frame));
}
/** Builds the established empty response to a server-initiated {@code CK}. */
static String serverLivenessProbeResponse() {
return "";
}
/** Preserves the existing internal handling of DXQ server data. */
static boolean isInternalDxqResponse(String frame) {
return "DXQ".equals(opcode(frame));
}
@@ -86,11 +86,20 @@ public class ReadThread extends Thread {
throw new EOFException("ON4KST closed the TCP connection");
}
if (!sessionIsActive.test(sessionId)) {
break;
}
inboundActivity.accept(response);
if (!sessionIsActive.test(sessionId)) {
break;
}
if (On4KstProtocol.isClientLivenessProbeResponse(response)) {
// OK and OK| acknowledge the client-side CK| probe.
continue;
}
ChatMessage message = new ChatMessage();
message.setMessageText(response);
receiveQueue.put(message);
@@ -127,4 +136,4 @@ public class ReadThread extends Thread {
socket.close();
return true;
}
}
}
@@ -3,46 +3,63 @@ package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.OutputStreamWriter;
import java.lang.reflect.Field;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
class On4KstConnectionProbeTest {
@Test
void buildsMainChatProbeAndAcceptsExpectedResponse() {
assertEquals("RDXQ|2|", On4KstProtocol.connectionProbe(2));
assertTrue(On4KstProtocol.isConnectionProbeResponse("DXQ|2|data|"));
assertFalse(On4KstProtocol.isConnectionProbeResponse(
"CH|2|123|DL1ABC|Name|0|text|0|"));
void separatesClientProbeServerProbeAndInternalResponses() {
assertEquals("CK|", On4KstProtocol.clientLivenessProbe());
assertTrue(On4KstProtocol.isClientLivenessProbeResponse("OK"));
assertTrue(On4KstProtocol.isClientLivenessProbeResponse("OK|"));
assertFalse(On4KstProtocol.isClientLivenessProbeResponse(
"OK|unexpected|"));
assertTrue(On4KstProtocol.isServerLivenessProbe("CK|"));
assertEquals("", On4KstProtocol.serverLivenessProbeResponse());
assertTrue(On4KstProtocol.isInternalDxqResponse("DXQ|2|data|"));
}
@Test
void selectsHeartbeatProbeAndTimeoutAtIdleBoundaries() {
void selectsOneClientProbeAndTimeoutAtIdleBoundaries() {
assertEquals(
On4KstConnectionManager.IdleAction.NONE,
idleAction(90_000L, false, false));
assertEquals(
On4KstConnectionManager.IdleAction.HEARTBEAT,
idleAction(90_001L, false, false));
idleAction(90_000L, true, false));
assertEquals(
On4KstConnectionManager.IdleAction.NONE,
idleAction(179_999L, true, false));
idleAction(90_001L, false, false),
"No client probe may be sent before the session is online");
assertEquals(
On4KstConnectionManager.IdleAction.CONNECTION_PROBE,
idleAction(180_000L, true, false));
On4KstConnectionManager.IdleAction.CLIENT_LIVENESS_PROBE,
idleAction(90_001L, true, false));
assertEquals(
On4KstConnectionManager.IdleAction.NONE,
idleAction(180_000L, true, true),
"The 90-second CK remains the only probe in this idle phase");
assertEquals(
On4KstConnectionManager.IdleAction.CLIENT_LIVENESS_PROBE,
idleAction(180_000L, true, false),
"Even without probe state, the only 180-second action is CK");
assertEquals(
On4KstConnectionManager.IdleAction.NONE,
idleAction(210_000L, true, true));
@@ -52,9 +69,9 @@ class On4KstConnectionProbeTest {
}
@Test
void oneSessionProbeIsAcknowledgedByAnyInboundTraffic() {
On4KstConnectionManager.ConnectionProbeState probe =
new On4KstConnectionManager.ConnectionProbeState();
void sessionProbeCoversTwoCategoriesAndRepeatedIdlePhases() {
On4KstConnectionManager.ClientLivenessProbeState probe =
new On4KstConnectionManager.ClientLivenessProbeState();
assertTrue(probe.tryStart(1_000L));
assertFalse(probe.tryStart(1_001L),
@@ -71,8 +88,8 @@ class On4KstConnectionProbeTest {
@Test
@Timeout(5)
void writerUsesExactCrLfForHeartbeatAndConnectionProbe() throws Exception {
byte[] expected = "\r\nRDXQ|2|\r\n".getBytes(StandardCharsets.UTF_8);
void writerUsesExactBytesForClientAndServerLivenessFrames() throws Exception {
byte[] expected = "CK|\r\n\r\n".getBytes(StandardCharsets.UTF_8);
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<byte[]> received = CompletableFuture.supplyAsync(() -> {
@@ -98,8 +115,9 @@ class On4KstConnectionProbeTest {
ignored -> { });
writer.start();
queue.add(serverFrame(""));
queue.add(serverFrame(On4KstProtocol.connectionProbe(2)));
queue.add(serverFrame(On4KstProtocol.clientLivenessProbe()));
queue.add(serverFrame(
On4KstProtocol.serverLivenessProbeResponse()));
assertArrayEquals(
expected,
@@ -112,14 +130,153 @@ class On4KstConnectionProbeTest {
}
}
@Test
@Timeout(5)
void readerRecordsOkAsActivityWithoutPublishingIt() throws Exception {
CountDownLatch releaseChatFrame = new CountDownLatch(1);
String chatFrame = "CH|2|123|DL1ABC|Name|0|text|0|";
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<Void> serverDone = CompletableFuture.runAsync(() -> {
try (Socket accepted = server.accept();
OutputStreamWriter out = new OutputStreamWriter(
accepted.getOutputStream(), StandardCharsets.UTF_8)) {
out.write("OK|\r\n");
out.flush();
releaseChatFrame.await(2, TimeUnit.SECONDS);
out.write(chatFrame + "\r\n");
out.flush();
} catch (Exception exception) {
throw new RuntimeException(exception);
}
});
try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
LinkedBlockingQueue<ChatMessage> messages =
new LinkedBlockingQueue<>();
LinkedBlockingQueue<String> activity =
new LinkedBlockingQueue<>();
AtomicBoolean active = new AtomicBoolean(true);
ReadThread reader = new ReadThread(
21L,
client,
messages,
ignored -> active.get(),
activity::offer,
ignored -> { });
reader.start();
assertEquals("OK|", activity.poll(2, TimeUnit.SECONDS));
assertNull(messages.poll(200, TimeUnit.MILLISECONDS));
releaseChatFrame.countDown();
assertEquals(chatFrame,
messages.poll(2, TimeUnit.SECONDS).getMessageText());
active.set(false);
reader.join(Duration.ofSeconds(2).toMillis());
}
serverDone.get(2, TimeUnit.SECONDS);
}
}
@Test
@Timeout(5)
void readerIgnoresDelayedResponseFromReplacedSession() throws Exception {
CountDownLatch releaseOldResponse = new CountDownLatch(1);
AtomicBoolean inboundCallbackUsed = new AtomicBoolean();
try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<Void> serverDone = CompletableFuture.runAsync(() -> {
try (Socket accepted = server.accept();
OutputStreamWriter out = new OutputStreamWriter(
accepted.getOutputStream(), StandardCharsets.UTF_8)) {
releaseOldResponse.await(2, TimeUnit.SECONDS);
out.write("OK\r\n");
out.flush();
} catch (Exception exception) {
throw new RuntimeException(exception);
}
});
try (Socket client = new Socket("127.0.0.1", server.getLocalPort())) {
LinkedBlockingQueue<ChatMessage> messages =
new LinkedBlockingQueue<>();
AtomicBoolean active = new AtomicBoolean(true);
ReadThread reader = new ReadThread(
22L,
client,
messages,
ignored -> active.get(),
ignored -> inboundCallbackUsed.set(true),
ignored -> { });
reader.start();
active.set(false);
releaseOldResponse.countDown();
reader.join(Duration.ofSeconds(2).toMillis());
assertFalse(inboundCallbackUsed.get());
assertNull(messages.poll());
}
serverDone.get(2, TimeUnit.SECONDS);
}
}
@Test
@Timeout(15)
void unansweredProbeUsesExistingReconnectFlow() throws Exception {
ChatPreferences preferences = localPreferences();
ChatController controller = org.mockito.Mockito.mock(
ChatController.class);
org.mockito.Mockito.when(controller.getChatPreferences())
.thenReturn(preferences);
org.mockito.Mockito.when(controller.getChatCategoryMain())
.thenReturn(preferences.getLoginChatCategoryMain());
org.mockito.Mockito.when(controller.getChatCategorySecondChat())
.thenReturn(preferences.getLoginChatCategorySecond());
try (ServerSocket server = new ServerSocket(0)) {
preferences.setStn_on4kstServersPort(server.getLocalPort());
CompletableFuture<Integer> acceptedConnections =
CompletableFuture.supplyAsync(() -> {
try (Socket first = server.accept();
Socket second = server.accept()) {
return 2;
} catch (Exception exception) {
throw new RuntimeException(exception);
}
});
On4KstConnectionManager manager =
new On4KstConnectionManager(controller);
try {
manager.start();
awaitState(manager, On4KstConnectionState.AUTHENTICATING);
manager.onLogstat(1L, new String[] {"LOGSTAT", "100"});
awaitState(manager, On4KstConnectionState.SYNCING_MAIN_CHAT);
manager.onInitialUserListCompleted(1L,
preferences.getLoginChatCategoryMain());
awaitState(manager, On4KstConnectionState.ONLINE);
setTimedOutProbe(manager, System.currentTimeMillis());
awaitState(manager, On4KstConnectionState.RECONNECT_WAIT);
assertEquals(2, acceptedConnections.get(7, TimeUnit.SECONDS));
} finally {
manager.stopByUser();
}
}
}
private On4KstConnectionManager.IdleAction idleAction(
long inboundIdleMillis,
boolean heartbeatSent,
boolean online,
boolean probeOutstanding
) {
return On4KstConnectionManager.determineIdleAction(
inboundIdleMillis,
heartbeatSent,
online,
probeOutstanding);
}
@@ -129,4 +286,55 @@ class On4KstConnectionProbeTest {
message.setMessageText(text);
return message;
}
private ChatPreferences localPreferences() {
ChatPreferences preferences = new ChatPreferences();
preferences.setStn_on4kstServersDns("127.0.0.1");
preferences.setStn_loginCallSign("DL1ABC");
preferences.setStn_loginPassword("test-password");
preferences.setStn_loginNameMainCat("");
preferences.setStn_loginLocatorMainCat("JO50AA");
preferences.setLoginChatCategoryMain(new ChatCategory(2));
preferences.setLoginChatCategorySecond(new ChatCategory(3));
preferences.setLoginToSecondChatEnabled(false);
return preferences;
}
private void awaitState(
On4KstConnectionManager manager,
On4KstConnectionState expected
) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(7L);
while (System.nanoTime() < deadline) {
if (manager.getState() == expected) {
return;
}
TimeUnit.MILLISECONDS.sleep(25L);
}
assertEquals(expected, manager.getState());
}
private void setTimedOutProbe(
On4KstConnectionManager manager,
long now
) throws ReflectiveOperationException {
Field activeSession = On4KstConnectionManager.class
.getDeclaredField("activeSession");
activeSession.setAccessible(true);
Object session = activeSession.get(manager);
Field lastInboundMillis = session.getClass()
.getDeclaredField("lastInboundMillis");
lastInboundMillis.setAccessible(true);
((AtomicLong) lastInboundMillis.get(session)).set(
now - On4KstConnectionManager.INBOUND_STALE_AFTER_MILLIS - 1L);
Field clientLivenessProbe = session.getClass()
.getDeclaredField("clientLivenessProbe");
clientLivenessProbe.setAccessible(true);
((On4KstConnectionManager.ClientLivenessProbeState)
clientLivenessProbe.get(session)).tryStart(
now - On4KstConnectionManager
.CLIENT_LIVENESS_PROBE_AFTER_MILLIS);
}
}
@@ -0,0 +1,296 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
/**
* Opt-in practice test against the configured ON4KST server.
*
* <p>The normal test suite skips this class. Run it explicitly with
* {@code -Don4kst.live=true -Dtest=On4KstIdlePracticeTest} from the IDE or
* Maven when the locally stored test credentials may be used. The practice
* connection uses only the usually quiet category 9.</p>
*/
class On4KstIdlePracticeTest {
private static final int LIVE_CATEGORY = ChatCategory.VUHFR3;
private static final Duration ONLINE_TIMEOUT = Duration.ofSeconds(90);
private static final Duration QUIET_PHASE_TIMEOUT = Duration.ofMinutes(45);
private static final Duration POST_RESPONSE_OBSERVATION = Duration.ofSeconds(135);
@Test
@Timeout(value = 50, unit = TimeUnit.MINUTES)
void observesCkOkWithoutQuietReconnect() throws Exception {
assumeTrue(Boolean.getBoolean("on4kst.live"),
"Live ON4KST test requires -Don4kst.live=true");
ChatPreferences preferences = new ChatPreferences();
assumeTrue(preferences.readPreferencesFromXmlFile(),
"No readable local ON4KST test configuration");
assumeTrue(hasText(preferences.getStn_loginCallSign())
&& hasText(preferences.getStn_loginPassword()),
"Local ON4KST test credentials are incomplete");
preferences.setLoginChatCategoryMain(
new ChatCategory(LIVE_CATEGORY));
preferences.setLoginToSecondChatEnabled(false);
LinkedBlockingQueue<StateEvent> stateEvents =
new LinkedBlockingQueue<>();
LinkedBlockingQueue<ProbeEvent> probeEvents =
new LinkedBlockingQueue<>();
AtomicBoolean reconnectObserved = new AtomicBoolean();
AtomicReference<On4KstConnectionManager> managerReference =
new AtomicReference<>();
ChatController controller = mock(ChatController.class);
when(controller.getChatPreferences()).thenReturn(preferences);
ChatCategory mainCategory = preferences.getLoginChatCategoryMain();
ChatCategory secondCategory = preferences.getLoginChatCategorySecond();
if (secondCategory == null) {
secondCategory = new ChatCategory(
mainCategory.getCategoryNumber() == 2 ? 3 : 2);
}
when(controller.getChatCategoryMain()).thenReturn(mainCategory);
when(controller.getChatCategorySecondChat()).thenReturn(secondCategory);
DBController database = mock(DBController.class);
when(database.fetchChatMemberWkdDataForOnlyOneCallsignFromDB(
any(ChatMember.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
doNothing().when(database).storeChatMember(any(ChatMember.class));
when(controller.getDbHandler()).thenReturn(database);
doAnswer(invocation -> {
On4KstConnectionState state = invocation.getArgument(0);
stateEvents.offer(new StateEvent(state, ZonedDateTime.now()));
if (state == On4KstConnectionState.RECONNECT_WAIT) {
reconnectObserved.set(true);
}
return null;
}).when(controller).updateOn4KstConnectionState(
any(On4KstConnectionState.class), anyString(), anyBoolean());
doAnswer(invocation -> {
managerReference.get().onLogstat(
invocation.getArgument(0), invocation.getArgument(1));
return null;
}).when(controller).onOn4KstLogstat(anyLong(), any(String[].class));
doAnswer(invocation -> {
managerReference.get().stageInitialChatMember(
invocation.getArgument(0), invocation.getArgument(1));
return null;
}).when(controller).stageInitialOn4KstChatMember(
anyLong(), any(ChatMember.class));
doAnswer(invocation -> {
managerReference.get().onInitialUserListCompleted(
invocation.getArgument(0), invocation.getArgument(1));
return null;
}).when(controller).onOn4KstInitialUserListCompleted(
anyLong(), any(ChatCategory.class));
On4KstConnectionManager manager =
new On4KstConnectionManager(controller);
managerReference.set(manager);
Logger logger = Logger.getLogger(
On4KstConnectionManager.class.getName());
Level previousLevel = logger.getLevel();
Handler probeHandler = probeHandler(probeEvents);
logger.setLevel(Level.INFO);
logger.addHandler(probeHandler);
try {
manager.start();
StateEvent online = awaitOnline(stateEvents);
System.out.println("[ON4KST live] Category " + LIVE_CATEGORY
+ " ONLINE at "
+ timestamp(online.at()));
reconnectObserved.set(false);
ProbeCycle successfulCycle = awaitCkOkCycle(
probeEvents, reconnectObserved);
System.out.println("[ON4KST live] CK sent at "
+ timestamp(successfulCycle.sentAt())
+ ", OK received at "
+ timestamp(successfulCycle.confirmedAt())
+ ", response time "
+ successfulCycle.responseMillis() + " ms");
reconnectObserved.set(false);
long observationDeadline = System.nanoTime()
+ POST_RESPONSE_OBSERVATION.toNanos();
while (System.nanoTime() < observationDeadline) {
if (reconnectObserved.get()) {
fail("ON4KST entered reconnect after the confirmed CK/OK cycle");
}
TimeUnit.SECONDS.sleep(1L);
}
System.out.println("[ON4KST live] Observation completed at "
+ timestamp(ZonedDateTime.now())
+ "; no reconnect followed the quiet CK/OK cycle");
} finally {
manager.stopByUser();
logger.removeHandler(probeHandler);
logger.setLevel(previousLevel);
}
}
private StateEvent awaitOnline(
LinkedBlockingQueue<StateEvent> stateEvents
) throws InterruptedException {
long deadline = System.nanoTime() + ONLINE_TIMEOUT.toNanos();
while (System.nanoTime() < deadline) {
StateEvent event = stateEvents.poll(1L, TimeUnit.SECONDS);
if (event == null) {
continue;
}
if (event.state() == On4KstConnectionState.ONLINE) {
return event;
}
if (event.state() == On4KstConnectionState.DISCONNECTED) {
fail("ON4KST live test disconnected before reaching ONLINE");
}
}
fail("ON4KST live test did not reach ONLINE within "
+ ONLINE_TIMEOUT.toSeconds() + " seconds");
throw new IllegalStateException("unreachable");
}
private ProbeCycle awaitCkOkCycle(
LinkedBlockingQueue<ProbeEvent> probeEvents,
AtomicBoolean reconnectObserved
) throws InterruptedException {
long deadline = System.nanoTime() + QUIET_PHASE_TIMEOUT.toNanos();
ProbeEvent sent = null;
while (System.nanoTime() < deadline) {
if (reconnectObserved.get()) {
fail("ON4KST reconnected before a CK/OK idle cycle was observed");
}
ProbeEvent event = probeEvents.poll(1L, TimeUnit.SECONDS);
if (event == null) {
continue;
}
if (event.type() == ProbeEventType.SENT) {
sent = event;
continue;
}
if (sent != null && event.sessionId() == sent.sessionId()) {
if ("OK".equals(event.opcode())) {
return new ProbeCycle(
sent.at(), event.at(), event.responseMillis());
}
// Normal server data ended this idle phase before OK arrived.
sent = null;
}
}
fail("No uninterrupted CK/OK idle cycle was observed within "
+ QUIET_PHASE_TIMEOUT.toMinutes() + " minutes");
throw new IllegalStateException("unreachable");
}
private Handler probeHandler(
LinkedBlockingQueue<ProbeEvent> probeEvents
) {
return new Handler() {
@Override
public void publish(LogRecord record) {
if (record == null || record.getParameters() == null) {
return;
}
Object[] parameters = record.getParameters();
if (record.getMessage().startsWith(
"Sending ON4KST client liveness probe")
&& parameters.length >= 1) {
probeEvents.offer(new ProbeEvent(
ProbeEventType.SENT,
((Number) parameters[0]).longValue(),
"CK",
-1L,
ZonedDateTime.now()));
} else if (record.getMessage().startsWith(
"ON4KST client liveness probe confirmed")
&& parameters.length >= 3) {
probeEvents.offer(new ProbeEvent(
ProbeEventType.CONFIRMED,
((Number) parameters[0]).longValue(),
String.valueOf(parameters[1]),
((Number) parameters[2]).longValue(),
ZonedDateTime.now()));
}
}
@Override
public void flush() {
// Nothing is buffered.
}
@Override
public void close() {
// The handler owns no external resource.
}
};
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private String timestamp(ZonedDateTime value) {
return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value);
}
private record StateEvent(
On4KstConnectionState state,
ZonedDateTime at
) {
}
private enum ProbeEventType {
SENT,
CONFIRMED
}
private record ProbeEvent(
ProbeEventType type,
long sessionId,
String opcode,
long responseMillis,
ZonedDateTime at
) {
}
private record ProbeCycle(
ZonedDateTime sentAt,
ZonedDateTime confirmedAt,
long responseMillis
) {
}
}
@@ -0,0 +1,425 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Writer;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatPreferences;
/**
* Opt-in raw-wire probe matrix against ON4KST category 9.
*
* <p>Login traffic is deliberately excluded from the evidence file so that no
* credentials or login tokens are persisted. Capture begins after the initial
* category-9 user list has completed.</p>
*/
class On4KstProbeVariantPracticeTest {
private static final int CATEGORY = ChatCategory.VUHFR3;
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
private static final Duration HANDSHAKE_TIMEOUT = Duration.ofSeconds(45);
private static final Duration QUIET_PERIOD = Duration.ofSeconds(90);
private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(125);
private static final Duration VARIANT_TIMEOUT = Duration.ofMinutes(8);
private static final Duration TOTAL_TIMEOUT = Duration.ofMinutes(45);
private static final List<ProbeVariant> VARIANTS = List.of(
variant("CK_CRLF", "CK\r\n"),
variant("CK_PIPE_CRLF", "CK|\r\n"),
variant("CK_CR_NUL", "CK\r\0"),
variant("CK_PIPE_CR_NUL", "CK|\r\0"),
variant("PIPE_CK_PIPE_CRLF", "|CK|\r\n")
);
@Test
@Timeout(value = 47, unit = TimeUnit.MINUTES)
void recordsProbeVariantResponsesWithoutLoginData() throws Exception {
assumeTrue(Boolean.getBoolean("on4kst.live.variants"),
"Live probe matrix requires -Don4kst.live.variants=true");
ChatPreferences preferences = new ChatPreferences();
assumeTrue(preferences.readPreferencesFromXmlFile(),
"No readable local ON4KST test configuration");
assumeTrue(hasText(preferences.getStn_loginCallSign())
&& hasText(preferences.getStn_loginPassword()),
"Local ON4KST test credentials are incomplete");
Path evidenceDirectory = Path.of(
"target", "on4kst-live-evidence");
Files.createDirectories(evidenceDirectory);
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
.format(ZonedDateTime.now());
Path evidenceFile = evidenceDirectory.resolve(
timestamp + "-category-9-probe-matrix.log");
List<ProbeResult> results = new ArrayList<>();
long totalDeadline = System.nanoTime() + TOTAL_TIMEOUT.toNanos();
try (Writer evidence = Files.newBufferedWriter(
evidenceFile,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE)) {
writeHeader(evidence);
for (ProbeVariant variant : VARIANTS) {
if (System.nanoTime() >= totalDeadline) {
ProbeResult result = new ProbeResult(
variant.name(), "TOTAL_BUDGET_EXHAUSTED", "");
results.add(result);
writeResult(evidence, result);
continue;
}
ProbeResult result;
try {
result = runVariant(
preferences, variant, totalDeadline, evidence);
} catch (Exception exception) {
result = new ProbeResult(
variant.name(),
"ERROR",
exception.getClass().getSimpleName()
+ ": " + safeMessage(exception));
writeResult(evidence, result);
}
results.add(result);
evidence.flush();
}
evidence.write("\nSUMMARY\n");
for (ProbeResult result : results) {
writeResult(evidence, result);
}
}
System.out.println("[ON4KST variant test] Evidence: "
+ evidenceFile.toAbsolutePath());
assertTrue(
results.stream().anyMatch(result ->
"OK_DIRECT".equals(result.status())),
"No probe variant received a direct OK response. Evidence: "
+ evidenceFile.toAbsolutePath());
}
private ProbeResult runVariant(
ChatPreferences preferences,
ProbeVariant variant,
long totalDeadline,
Writer evidence
) throws Exception {
long variantDeadline = Math.min(
totalDeadline,
System.nanoTime() + VARIANT_TIMEOUT.toNanos());
evidence.write("\nVARIANT " + variant.name() + "\n");
evidence.write("probe hex=" + HexFormat.ofDelimiter(" ")
.withUpperCase().formatHex(variant.bytes())
+ " ascii=" + escapedAscii(variant.bytes()) + "\n");
try (Socket socket = new Socket()) {
socket.connect(
new InetSocketAddress(
preferences.getStn_on4kstServersDns(),
preferences.getStn_on4kstServersPort()),
Math.toIntExact(CONNECT_TIMEOUT.toMillis()));
socket.setSoTimeout(1_000);
InputStream input = socket.getInputStream();
OutputStream output = socket.getOutputStream();
WireReader reader = new WireReader(input);
completeHandshake(preferences, reader, output, variantDeadline);
writeEvent(evidence, "STATE", new byte[0],
"category 9 synchronized; login capture suppressed");
long lastInbound = System.nanoTime();
while (System.nanoTime() < variantDeadline) {
long quietDeadline = Math.min(
variantDeadline,
lastInbound + QUIET_PERIOD.toNanos());
byte[] inbound = reader.readFrame(quietDeadline);
if (inbound == null) {
if (System.nanoTime() >= variantDeadline) {
ProbeResult result = new ProbeResult(
variant.name(), "NO_QUIET_PHASE", "");
writeResult(evidence, result);
return result;
}
break;
}
writeEvent(evidence, "RX", inbound, "pre-probe");
lastInbound = System.nanoTime();
if ("CK".equals(On4KstProtocol.opcode(frameText(inbound)))) {
byte[] response = "\r\n".getBytes(StandardCharsets.US_ASCII);
output.write(response);
output.flush();
writeEvent(evidence, "TX", response,
"response to server-initiated CK");
}
}
output.write(variant.bytes());
output.flush();
writeEvent(evidence, "TX", variant.bytes(), "client probe");
long responseDeadline = Math.min(
variantDeadline,
System.nanoTime() + RESPONSE_TIMEOUT.toNanos());
boolean receivedOtherFrame = false;
while (System.nanoTime() < responseDeadline) {
byte[] inbound = reader.readFrame(responseDeadline);
if (inbound == null) {
break;
}
writeEvent(evidence, "RX", inbound, "post-probe");
String text = frameText(inbound);
if (On4KstProtocol.isClientLivenessProbeResponse(text)) {
String status = receivedOtherFrame
? "OK_AFTER_OTHER_DATA"
: "OK_DIRECT";
ProbeResult result = new ProbeResult(
variant.name(), status, escapedAscii(inbound));
writeResult(evidence, result);
return result;
}
receivedOtherFrame = true;
}
ProbeResult result = new ProbeResult(
variant.name(),
receivedOtherFrame ? "OTHER_DATA_ONLY" : "NO_RESPONSE",
"");
writeResult(evidence, result);
return result;
}
}
private void completeHandshake(
ChatPreferences preferences,
WireReader reader,
OutputStream output,
long variantDeadline
) throws Exception {
long handshakeDeadline = Math.min(
variantDeadline,
System.nanoTime() + HANDSHAKE_TIMEOUT.toNanos());
byte[] prompt = requireFrame(reader, handshakeDeadline,
"ON4KST login prompt");
if (!frameText(prompt).toLowerCase().contains("login")) {
throw new IOException("Unexpected ON4KST login prompt opcode: "
+ On4KstProtocol.opcode(frameText(prompt)));
}
String login = On4KstProtocol.login(
preferences.getStn_loginCallSign(),
preferences.getStn_loginPassword(),
CATEGORY,
"KST4Contest v"
+ ApplicationConstants.APPLICATION_CURRENT_VERSION,
0L);
writeCrLfFrame(output, login);
boolean loginAccepted = false;
while (!loginAccepted) {
byte[] inbound = requireFrame(reader, handshakeDeadline,
"ON4KST LOGSTAT");
String text = frameText(inbound);
if (!"LOGSTAT".equals(On4KstProtocol.opcode(text))) {
continue;
}
String[] fields = text.split("\\|", -1);
if (fields.length < 2 || !"100".equals(fields[1])) {
throw new IOException("ON4KST login rejected with code "
+ (fields.length < 2 ? "missing" : fields[1]));
}
loginAccepted = true;
}
writeCrLfFrame(output, On4KstProtocol.settingsDone(CATEGORY));
while (true) {
byte[] inbound = requireFrame(reader, handshakeDeadline,
"category-9 user-list completion");
String text = frameText(inbound);
if (text.startsWith("UE|" + CATEGORY + "|")) {
return;
}
}
}
private byte[] requireFrame(
WireReader reader,
long deadline,
String description
) throws IOException {
byte[] frame = reader.readFrame(deadline);
if (frame == null) {
throw new SocketTimeoutException(
"Timed out waiting for " + description);
}
return frame;
}
private void writeCrLfFrame(OutputStream output, String frame)
throws IOException {
output.write(frame.getBytes(StandardCharsets.US_ASCII));
output.write('\r');
output.write('\n');
output.flush();
}
private void writeHeader(Writer evidence) throws IOException {
evidence.write("ON4KST client-probe wire evidence\n");
evidence.write("started=" + DateTimeFormatter.ISO_OFFSET_DATE_TIME
.format(ZonedDateTime.now()) + "\n");
evidence.write("category=9\n");
evidence.write("login and initial synchronization frames are suppressed"
+ " to exclude credentials and login tokens\n");
evidence.write("wtKST reference: server CK frame="
+ "43 4B 7C 0D 0A (CK|<CR><LF>)\n");
evidence.write("wtKST reference: no client CK and no server OK occur"
+ " in wtkstcomm.c\n");
evidence.write("wtKST reference: repeated client idle payload="
+ "0D 00 0D 0A (<CR><NUL><CR><LF>)\n");
}
private void writeEvent(
Writer evidence,
String direction,
byte[] bytes,
String note
) throws IOException {
evidence.write(DateTimeFormatter.ISO_OFFSET_DATE_TIME
.format(ZonedDateTime.now()));
evidence.write(" " + direction);
if (bytes.length > 0) {
evidence.write(" hex=" + HexFormat.ofDelimiter(" ")
.withUpperCase().formatHex(bytes));
evidence.write(" ascii=" + escapedAscii(bytes));
}
if (hasText(note)) {
evidence.write(" note=" + note);
}
evidence.write("\n");
evidence.flush();
}
private void writeResult(Writer evidence, ProbeResult result)
throws IOException {
evidence.write("RESULT variant=" + result.variant()
+ " status=" + result.status());
if (hasText(result.detail())) {
evidence.write(" detail=" + result.detail());
}
evidence.write("\n");
}
private static ProbeVariant variant(String name, String wireText) {
return new ProbeVariant(
name, wireText.getBytes(StandardCharsets.US_ASCII));
}
private String frameText(byte[] frame) {
int length = frame.length;
while (length > 0 && (frame[length - 1] == '\r'
|| frame[length - 1] == '\n'
|| frame[length - 1] == 0)) {
length--;
}
return new String(frame, 0, length, StandardCharsets.US_ASCII);
}
private String escapedAscii(byte[] bytes) {
StringBuilder escaped = new StringBuilder();
for (byte value : bytes) {
int unsigned = Byte.toUnsignedInt(value);
switch (unsigned) {
case 0 -> escaped.append("<NUL>");
case '\r' -> escaped.append("<CR>");
case '\n' -> escaped.append("<LF>");
default -> {
if (unsigned >= 0x20 && unsigned <= 0x7e) {
escaped.append((char) unsigned);
} else {
escaped.append(String.format("<%02X>", unsigned));
}
}
}
}
return escaped.toString();
}
private String safeMessage(Exception exception) {
String message = exception.getMessage();
return message == null || message.isBlank()
? "no detail" : message.replaceAll("[\\r\\n]+", " ");
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private record ProbeVariant(String name, byte[] bytes) {
private ProbeVariant {
bytes = bytes.clone();
}
@Override
public byte[] bytes() {
return bytes.clone();
}
}
private record ProbeResult(String variant, String status, String detail) {
}
private static final class WireReader {
private final InputStream input;
private WireReader(InputStream input) {
this.input = input;
}
private byte[] readFrame(long deadlineNanos) throws IOException {
ByteArrayOutputStream frame = new ByteArrayOutputStream();
while (System.nanoTime() < deadlineNanos) {
try {
int value = input.read();
if (value < 0) {
throw new IOException("ON4KST closed the TCP session");
}
frame.write(value);
if (value == '\n' || value == 0) {
return frame.toByteArray();
}
} catch (SocketTimeoutException timeout) {
// Continue until the caller's monotonic deadline expires.
}
}
return frame.size() == 0 ? null : frame.toByteArray();
}
}
}
+14
View File
@@ -73,6 +73,12 @@ function getManualPageOrder(lang, slug) {
return index >= 0 ? index : 999;
}
function selectLatestNews(newsItems) {
return [...(newsItems || [])]
.sort((first, second) => second.date - first.date)
.slice(0, 1);
}
function githubCompatibleSlug(value) {
return (value || "")
.trim()
@@ -152,6 +158,12 @@ module.exports = function (eleventyConfig) {
});
});
eleventyConfig.addCollection("latestNews", function (collectionApi) {
return selectLatestNews(
collectionApi.getFilteredByTag("news")
);
});
eleventyConfig.addFilter("whereTag", function(collection, tag) {
return collection.filter(item => item.data.tags && item.data.tags.includes(tag));
});
@@ -231,3 +243,5 @@ module.exports = function (eleventyConfig) {
htmlTemplateEngine: "njk"
};
};
module.exports.selectLatestNews = selectLatestNews;
+10 -1
View File
@@ -48,4 +48,13 @@ Nginx serves this directory.
## Build Strategy
GitHub Actions will later build the 11ty website and deploy the generated output to the server.
The production server keeps a checkout at `/srv/git/kst4contest`. A root cron
job runs `/srv/scripts/deploy-kst4contest-website.sh` every five minutes. The
script resets the checkout to `origin/main`, installs the locked npm
dependencies, builds and validates the Eleventy site, and synchronises the
result to `/srv/www/kst4contest/current` with the ownership required by Nginx.
This deploy path updates only the static website. Server-side analytics,
Nginx, systemd, Logrotate, GeoIP, Basic Auth and Certbot configuration are
installed and maintained separately as described in
[`ops/analytics/README.md`](ops/analytics/README.md).
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
# Rendered by generate-reports.js. Do not use this file without replacing
# all {{...}} placeholders.
datetime-format %Y-%m-%dT%H:%M:%S%z
log-format %v\t%h\t%x\t%m\t%U\t%H\t%s\t%b\t"%u"
anonymize-ip true
anonymize-level 2
ignore-crawlers true
unknowns-as-crawlers true
keep-last 395
persist true
{{RESTORE_DIRECTIVE}}
db-path {{DB_PATH}}
geoip-database {{GEOIP_COUNTRY_DATABASE}}
json-pretty-print true
no-progress true
no-parsing-spinner true
no-color true
agent-list false
http-method true
http-protocol true
max-items 500
# The Nginx analytics log contains $uri rather than $request_uri, so query
# strings never reach GoAccess. These panels are deliberately unavailable.
ignore-panel HOSTS
ignore-panel OS
ignore-panel BROWSERS
ignore-panel REFERRERS
ignore-panel REFERRING_SITES
ignore-panel KEYPHRASES
ignore-panel REMOTE_USER
ignore-panel REQUESTS_STATIC
ignore-panel VISIT_TIMES
ignore-panel NOT_FOUND
ignore-panel ASN
ignore-panel MIME_TYPE
ignore-panel TLS_TYPE
ignore-panel CACHE_STATUS
@@ -0,0 +1,14 @@
/var/log/nginx/*-analytics.log /var/log/nginx/*-update-information.log {
daily
rotate 14
missingok
notifempty
compress
# Keep .1 uncompressed because the regular generator reads it directly.
delaycompress
create 0640 www-data hamradio-analytics
sharedscripts
postrotate
invoke-rc.d nginx rotate >/dev/null 2>&1
endscript
}
@@ -0,0 +1,43 @@
# Only page GET requests which are candidates for the reach statistics enter
# the dedicated analytics log. GoAccess performs the second bot-classification
# layer, including unknown browsers and operating systems.
map $request_method $hamradioonline_analytics_method {
default 0;
GET 1;
}
map $uri $hamradioonline_analytics_path {
default 1;
/visitor-count.json 0;
/kst4ContestVersionInfo.xml 0;
/sitemap.xml 0;
/robots.txt 0;
/favicon.ico 0;
/assets/favicon.svg 0;
/health 0;
/healthz 0;
/ping 0;
/status 0;
~*^/(?:assets|manual/assets)/ 0;
~*\.(?:css|js|mjs|map|json|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|xml|txt|pdf|zip|gz|wasm|mp4|webm)$ 0;
}
map $http_user_agent $hamradioonline_analytics_known_bot {
default 0;
~*(?:bot|crawler|spider|slurp|headless|monitor|healthcheck|uptime|wget|curl) 1;
}
map "$hamradioonline_analytics_method:$hamradioonline_analytics_path:$hamradioonline_analytics_known_bot"
$hamradioonline_analytics_loggable {
default 0;
"1:1:0" 1;
}
# Count the update-information file separately. This deliberately does not
# filter user agents: browsers and bots can fetch the file too.
map "$request_method:$uri:$status" $hamradioonline_update_information_loggable {
default 0;
"GET:/kst4ContestVersionInfo.xml:200" 1;
}
@@ -0,0 +1,16 @@
# Include in the Nginx http context. $uri is the normalized path and excludes
# the query string. The format intentionally omits referrer and remote user.
log_format hamradioonline_analytics
'$server_name\t$remote_addr\t$time_iso8601\t$request_method\t$uri\t'
'$server_protocol\t$status\t$body_bytes_sent\t"$http_user_agent"';
# Include the maps below in the Nginx http context as well.
include /etc/nginx/snippets/hamradioonline-analytics-filters.conf;
# Add these extra logs to each registered project server block. Keep the
# existing operational access_log directive; do not replace it implicitly.
#
# access_log /var/log/nginx/kst4contest-analytics.log
# hamradioonline_analytics if=$hamradioonline_analytics_loggable;
# access_log /var/log/nginx/kst4contest-update-information.log
# hamradioonline_analytics if=$hamradioonline_update_information_loggable;
@@ -0,0 +1,9 @@
# Include inside the kst4contest.hamradioonline.de HTTPS server block. Grant
# the Nginx worker read access to the file and directory, but no write access.
location = /visitor-count.json {
alias /var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json;
default_type application/json;
access_log off;
add_header Cache-Control "public, max-age=3600" always;
add_header X-Content-Type-Options "nosniff" always;
}
@@ -0,0 +1,17 @@
# Temporary HTTP-only virtual host for initial certificate provisioning.
# Replace it with stats-vhost.conf.example after Certbot has succeeded.
server {
listen 80;
server_name stats.hamradioonline.de;
access_log off;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/letsencrypt;
default_type text/plain;
}
location / {
return 404;
}
}
@@ -0,0 +1,57 @@
# Keep this HTTP server active so Certbot can renew the webroot certificate.
server {
listen 80;
server_name stats.hamradioonline.de;
access_log off;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/letsencrypt;
default_type text/plain;
}
location / {
return 301 https://$host$request_uri;
}
}
# This server exposes static reports only. Provision the certificate and the
# htpasswd file outside the repository. Do not add this host to sites.json.
server {
listen 443 ssl http2;
server_name stats.hamradioonline.de;
ssl_certificate /etc/letsencrypt/live/stats.hamradioonline.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stats.hamradioonline.de/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
root /var/lib/hamradioonline-analytics/reports;
index report.html;
auth_basic "Private project statistics";
auth_basic_user_file /etc/nginx/htpasswd/hamradioonline-analytics;
access_log off;
add_header Cache-Control "private, no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
location = / {
try_files /__no_report_at_root__ @combined_reports;
}
location @combined_reports {
return 302 /combined/;
}
location / {
try_files $uri $uri/ =404;
}
location ~ /\. {
deny all;
}
}
# IPv6 is deliberately omitted until the DNS AAAA record has been confirmed.
+835
View File
@@ -0,0 +1,835 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const zlib = require("node:zlib");
const PRIVATE_STATE_SCHEMA_VERSION = 1;
const UPDATE_INFO_PATH = "/kst4ContestVersionInfo.xml";
const UNKNOWN_COUNTRY = "Unknown";
const MAX_IMPORT_BYTES = 1024 * 1024 * 1024;
function parseIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || "");
if (!match) return null;
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
return date.getUTCFullYear() === Number(match[1])
&& date.getUTCMonth() === Number(match[2]) - 1
&& date.getUTCDate() === Number(match[3])
? value
: null;
}
function dateRange(from, through) {
const result = [];
const current = new Date(`${from}T12:00:00Z`);
const end = new Date(`${through}T12:00:00Z`);
while (current <= end) {
result.push(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 1);
}
return result;
}
function shiftDate(date, days) {
const value = new Date(`${date}T12:00:00Z`);
value.setUTCDate(value.getUTCDate() + days);
return value.toISOString().slice(0, 10);
}
function localParts(timestamp, timeZone) {
const instant = timestamp instanceof Date ? timestamp : new Date(timestamp);
if (Number.isNaN(instant.getTime())) return null;
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23"
}).formatToParts(instant).filter(part => part.type !== "literal")
.map(part => [part.type, part.value]));
return { date: `${parts.year}-${parts.month}-${parts.day}`, hour: parts.hour };
}
function normalizePath(value) {
if (typeof value !== "string" || /[\r\n\0]/.test(value)) return null;
const withoutQuery = value.split(/[?#]/, 1)[0];
if (!withoutQuery.startsWith("/")) return null;
const collapsed = withoutQuery.replace(/\/{2,}/g, "/");
const trailingSlash = collapsed.length > 1 && collapsed.endsWith("/");
const normalized = path.posix.normalize(collapsed);
return trailingSlash && normalized !== "/" && !normalized.endsWith("/")
? `${normalized}/`
: normalized;
}
function unescapeNginx(value) {
return value.replace(/\\x([0-9A-Fa-f]{2})/g, (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/\\"/g, "\"")
.replace(/\\\\/g, "\\");
}
function parseAnalyticsLine(line, timeZone = "Europe/Berlin") {
const fields = line.split("\t");
if (fields.length !== 9) return null;
const status = Number(fields[6]);
const bytes = fields[7] === "-" ? 0 : Number(fields[7]);
const local = localParts(fields[2], timeZone);
const requestPath = fields[4] === "" ? null : normalizePath(fields[4]);
if (!local || (fields[4] !== "" && !requestPath) || !/^\d{3}$/.test(fields[6])
|| !Number.isSafeInteger(bytes) || bytes < 0) return null;
let userAgent = fields[8];
if (userAgent.startsWith("\"") && userAgent.endsWith("\"")) {
userAgent = userAgent.slice(1, -1);
}
const record = {
host: fields[0],
ip: fields[1],
timestamp: fields[2],
date: local.date,
hour: local.hour,
method: fields[3],
path: requestPath,
protocol: fields[5],
status,
bytes,
userAgent: unescapeNginx(userAgent)
};
record.line = toAnalyticsLine(record);
return record;
}
const NGINX_MONTHS = {
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12"
};
function parseCombinedLine(line, hostname, timeZone = "Europe/Berlin") {
const match = /^(\S+) \S+ \S+ \[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2}) ([+-]\d{4})\] "([A-Z]+) ([^ ]+) ([^"]+)" (\d{3}) (\d+|-) "(?:[^"\\]|\\.)*" "((?:[^"\\]|\\.)*)"(?: .*)?$/.exec(line);
if (!match || !NGINX_MONTHS[match[3]]) return null;
const timestamp = `${match[4]}-${NGINX_MONTHS[match[3]]}-${match[2]}T${match[5]}:${match[6]}:${match[7]}${match[8]}`;
const local = localParts(timestamp, timeZone);
const requestPath = normalizePath(match[10]);
const status = Number(match[12]);
const bytes = match[13] === "-" ? 0 : Number(match[13]);
if (!local || !requestPath || !Number.isSafeInteger(bytes) || bytes < 0) return null;
const userAgent = unescapeNginx(match[14]);
const canonicalTimestamp = timestamp.replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
return {
host: hostname,
ip: match[1],
timestamp: canonicalTimestamp,
date: local.date,
hour: local.hour,
method: match[9],
path: requestPath,
protocol: match[11],
status,
bytes,
userAgent,
line: toAnalyticsLine({
host: hostname,
ip: match[1],
timestamp: canonicalTimestamp,
method: match[9],
path: requestPath,
protocol: match[11],
status,
bytes,
userAgent
})
};
}
function escapeLogField(value) {
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")
.replace(/[\t\r\n]/g, character => `\\x${character.charCodeAt(0).toString(16).padStart(2, "0")}`);
}
function toAnalyticsLine(record) {
return [record.host, record.ip, record.timestamp, record.method, record.path,
record.protocol, record.status, record.bytes, `"${escapeLogField(record.userAgent)}"`].join("\t");
}
function isKnownBot(userAgent) {
return /(?:bot|crawler|spider|slurp|headless|monitor|healthcheck|uptime|wget|curl)/i.test(userAgent);
}
function isEligibleWebsiteRequest(record) {
if (record.method !== "GET" || !record.path || isKnownBot(record.userAgent)) return false;
if (["/visitor-count.json", UPDATE_INFO_PATH, "/sitemap.xml", "/robots.txt", "/favicon.ico",
"/assets/favicon.svg", "/health", "/healthz", "/ping", "/status"].includes(record.path)) {
return false;
}
return !/^\/(?:assets|manual\/assets)\//i.test(record.path)
&& !/\.(?:css|js|mjs|map|json|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|xml|txt|pdf|zip|gz|wasm|mp4|webm)$/i.test(record.path);
}
function isUpdateRequest(record) {
return record.method === "GET" && record.status === 200 && record.path === UPDATE_INFO_PATH;
}
function clientGroup(userAgent) {
if (!userAgent || userAgent === "-") return "Missing user agent";
if (/KST4Contest/i.test(userAgent)) return "KST4Contest (explicit)";
if (/(?:Edg|Edge)\//i.test(userAgent)) return "Microsoft Edge";
if (/Firefox\//i.test(userAgent)) return "Firefox";
if (/(?:Chrome|Chromium)\//i.test(userAgent)) return "Chrome/Chromium";
if (/Safari\//i.test(userAgent)) return "Safari";
if (/^Java\//i.test(userAgent)) return "Java runtime (application unknown)";
if (/curl\//i.test(userAgent)) return "curl";
if (/Wget\//i.test(userAgent)) return "Wget";
if (/(?:bot|crawler|spider|slurp)/i.test(userAgent)) return "Bot/crawler";
return "Other or unrecognised";
}
function readLogFile(filePath) {
const stats = fs.statSync(filePath);
if (!stats.isFile()) throw new Error(`log input is not a file: ${filePath}`);
if (stats.size > MAX_IMPORT_BYTES) throw new Error(`log input exceeds the 1 GiB safety limit: ${filePath}`);
const content = fs.readFileSync(filePath);
try {
return filePath.toLowerCase().endsWith(".gz")
? zlib.gunzipSync(content, { maxOutputLength: MAX_IMPORT_BYTES }).toString("utf8")
: content.toString("utf8");
} catch (error) {
throw new Error(`could not read compressed log ${filePath}: ${error.message}`);
}
}
function parseLogFiles(filePaths, parser, { deduplicateAcrossFiles = false, label = "log" } = {}) {
const parsedFiles = [];
for (const filePath of filePaths) {
const records = [];
const lines = readLogFile(filePath).split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
if (lines[index] === "" && index === lines.length - 1) continue;
if (lines[index].trim() === "") continue;
const record = parser(lines[index]);
if (!record) throw new Error(`${label} has an invalid line at ${filePath}:${index + 1}`);
records.push(record);
}
parsedFiles.push(records);
}
if (!deduplicateAcrossFiles) return parsedFiles.flat();
const maxima = new Map();
for (const records of parsedFiles) {
const counts = new Map();
for (const record of records) counts.set(record.line, (counts.get(record.line) || 0) + 1);
for (const [line, count] of counts) maxima.set(line, Math.max(maxima.get(line) || 0, count));
}
const byLine = new Map(parsedFiles.flat().map(record => [record.line, record]));
const result = [];
for (const [line, count] of maxima) {
for (let index = 0; index < count; index += 1) result.push({ ...byLine.get(line) });
}
return result;
}
function addCount(values, key, count) {
const value = (Object.hasOwn(values, key) ? values[key] : 0) + count;
Object.defineProperty(values, key, {
value,
writable: true,
enumerable: true,
configurable: true
});
}
function countPanel(report, panelName) {
const panel = report[panelName];
if (!panel || !Array.isArray(panel.data)) throw new Error(`GoAccess JSON report has no ${panelName} panel`);
const values = {};
for (const row of panel.data) {
const rawName = typeof row.data === "string" ? row.data.trim() : "";
const name = !rawName || /^(?:unknown|n\/a|-|\(not set\))$/i.test(rawName)
? UNKNOWN_COUNTRY
: rawName;
const count = row && row.hits && row.hits.count;
if (!Number.isSafeInteger(count) || count < 0) throw new Error(`GoAccess ${panelName} panel contains invalid data`);
addCount(values, name, count);
}
return values;
}
function countCountries(report) {
const panel = report.geolocation;
if (!panel || !Array.isArray(panel.data)) {
throw new Error("GoAccess JSON report has no geolocation panel");
}
const rows = panel.data.flatMap(row => Array.isArray(row.items) && row.items.length
? row.items
: [row]);
return countPanel({ geolocation: { data: rows } }, "geolocation");
}
function sumValues(values) {
return Object.values(values).reduce((sum, value) => sum + value, 0);
}
function aggregateGoAccessReport(report, kind) {
const validRequests = report && report.general && report.general.valid_requests;
if (!Number.isSafeInteger(validRequests) || validRequests < 0) {
throw new Error("GoAccess report contains an invalid valid-request count");
}
const paths = countPanel(report, "requests");
const total = sumValues(paths);
if (validRequests !== total) {
throw new Error(
`GoAccess valid-request count ${validRequests} differs from request-panel total ${total}`
);
}
const countries = countCountries(report);
const countryTotal = sumValues(countries);
if (countryTotal > total) throw new Error("GoAccess country total exceeds the request total");
if (countryTotal < total) addCount(countries, UNKNOWN_COUNTRY, total - countryTotal);
if (kind === "updateInfo") {
if (Object.keys(paths).some(requestPath => requestPath !== UPDATE_INFO_PATH)) {
throw new Error("update-information report contains an unexpected request path");
}
return { requests: total, countries };
}
return { pageViews: total, countries, paths };
}
function renderMetricsConfig(template, dbPath, geoIpCountryDatabase, includeCrawlers) {
let rendered = template
.replaceAll("{{DB_PATH}}", dbPath)
.replaceAll("{{RESTORE_DIRECTIVE}}", "# private daily aggregation uses a fresh database")
.replaceAll("{{GEOIP_COUNTRY_DATABASE}}", geoIpCountryDatabase)
.replace(/^max-items\s+\d+$/m, "max-items 1000000")
.replace(/^persist true$/m, "# persistence is disabled for private daily aggregation");
if (includeCrawlers) {
rendered = rendered.replace(/^ignore-crawlers true$/m, "ignore-crawlers false")
.replace(/^unknowns-as-crawlers true$/m, "unknowns-as-crawlers false");
}
return rendered;
}
function runMetricGoAccess({ records, jobId, metricKind, site, context, includeCrawlers }) {
const jobDirectory = path.join(context.runDirectory, jobId);
const dbPath = path.join(jobDirectory, "db");
const inputPath = path.join(jobDirectory, "input.log");
const outputJson = path.join(jobDirectory, "report.json");
const runConfig = path.join(jobDirectory, "goaccess.conf");
fs.mkdirSync(dbPath, { recursive: true });
fs.writeFileSync(inputPath, `${records.map(record => record.line).join("\n")}\n`, { mode: 0o600 });
fs.writeFileSync(runConfig, renderMetricsConfig(
context.configTemplate,
dbPath,
context.registry.geoIpCountryDatabase,
includeCrawlers
), { mode: 0o600 });
context.runGoAccess({
binary: context.goaccessBinary,
args: [inputPath, "--no-global-config", "--config-file", runConfig, "--output", outputJson],
id: jobId,
metricKind,
date: records[0].date,
outputJson,
dbPath
});
try {
return JSON.parse(fs.readFileSync(outputJson, "utf8"));
} catch (error) {
throw new Error(`private metric GoAccess JSON is invalid for ${site.id}/${records[0].date}: ${error.message}`);
}
}
function filterWebsiteRecordsWithGoAccess(records, date, site, context) {
const recordIndexByPath = new Map();
const representatives = records.map((record, index) => {
const extension = path.posix.extname(record.path);
const classifierPath = `/__request/${index}${extension}`;
recordIndexByPath.set(classifierPath, index);
const representative = {
...record,
ip: "192.0.2.1",
path: classifierPath
};
representative.line = toAnalyticsLine(representative);
return representative;
});
const report = runMetricGoAccess({
records: representatives,
jobId: `metrics-${site.id}-website-classifier-${date}`,
metricKind: "websiteClassifier",
site,
context,
includeCrawlers: false
});
const acceptedPaths = countPanel(report, "requests");
const acceptedIndexes = new Set();
for (const [classifierPath, count] of Object.entries(acceptedPaths)) {
if (!recordIndexByPath.has(classifierPath) || count !== 1) {
throw new Error(`website client classification is invalid for ${site.id}/${date}`);
}
acceptedIndexes.add(recordIndexByPath.get(classifierPath));
}
return records.filter((_record, index) => acceptedIndexes.has(index));
}
function aggregateDays({ records, dates, kind, site, context }) {
const byDate = new Map(dates.map(date => [date, []]));
for (const record of records) {
if (byDate.has(record.date)) byDate.get(record.date).push(record);
}
const daily = {};
for (const date of dates) {
let dayRecords = byDate.get(date);
if (dayRecords.length === 0) {
daily[date] = kind === "website"
? { pageViews: 0, countries: {}, paths: {} }
: { requests: 0, countries: {}, hours: {}, clients: {} };
continue;
}
if (kind === "website") {
dayRecords = filterWebsiteRecordsWithGoAccess(dayRecords, date, site, context);
}
if (dayRecords.length === 0) {
daily[date] = kind === "website"
? { pageViews: 0, countries: {}, paths: {} }
: { requests: 0, countries: {}, hours: {}, clients: {} };
continue;
}
const jobId = `metrics-${site.id}-${kind}-${date}`;
const report = runMetricGoAccess({
records: dayRecords,
jobId,
metricKind: kind,
site,
context,
includeCrawlers: true
});
try {
daily[date] = aggregateGoAccessReport(report, kind);
} catch (error) {
throw new Error(`private metric aggregation failed for ${site.id}/${kind}/${date}: ${error.message}`);
}
if (kind === "updateInfo") {
const hours = {};
const clients = {};
for (const record of dayRecords) {
addCount(hours, record.hour, 1);
const group = clientGroup(record.userAgent);
addCount(clients, group, 1);
}
if (sumValues(hours) !== daily[date].requests || sumValues(clients) !== daily[date].requests) {
throw new Error(`update detail total differs from GoAccess for ${site.id}/${date}`);
}
daily[date].hours = hours;
daily[date].clients = clients;
}
}
return daily;
}
function loadState(statePath) {
if (!fs.existsSync(statePath)) return { schemaVersion: PRIVATE_STATE_SCHEMA_VERSION, sites: {} };
let state;
try {
state = JSON.parse(fs.readFileSync(statePath, "utf8"));
} catch (error) {
throw new Error(`private metrics state is not valid JSON: ${error.message}`);
}
if (!state || state.schemaVersion !== PRIVATE_STATE_SCHEMA_VERSION || !state.sites
|| typeof state.sites !== "object" || Array.isArray(state.sites)) {
throw new Error("private metrics state has an unsupported structure");
}
return state;
}
function containsAtLeast(candidate, current) {
const keys = new Set([...Object.keys(candidate), ...Object.keys(current)]);
return [...keys].every(key => (candidate[key] || 0) >= (current[key] || 0));
}
function sameCounts(left, right) {
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
return [...keys].every(key => (left[key] || 0) === (right[key] || 0));
}
function mergeDay(current, candidate, totalKey, label) {
if (!current) return candidate;
const currentTotal = current[totalKey];
const candidateTotal = candidate[totalKey];
if (candidateTotal === currentTotal) {
if (!sameCounts(current.countries, candidate.countries)
|| (totalKey === "pageViews" && !sameCounts(current.paths, candidate.paths))) {
throw new Error(`overlapping ${label} aggregates disagree at equal totals`);
}
return { ...candidate, ...(current.hours ? { hours: current.hours, clients: current.clients } : {}) };
}
const candidateLarger = candidateTotal > currentTotal;
const larger = candidateLarger ? candidate : current;
const smaller = candidateLarger ? current : candidate;
if (!containsAtLeast(larger.countries, smaller.countries)
|| (totalKey === "pageViews" && !containsAtLeast(larger.paths, smaller.paths))) {
throw new Error(`overlapping ${label} aggregates are not monotonic`);
}
return larger;
}
function mergeSeries(target, incoming, totalKey, label) {
for (const [date, candidate] of Object.entries(incoming)) {
target[date] = mergeDay(target[date], candidate, totalKey, `${label}/${date}`);
}
}
function purgeDetails(siteState, today, retentionDays) {
const firstRetained = shiftDate(today, -(retentionDays - 1));
for (const [date, value] of Object.entries(siteState.updateInfo.daily)) {
if (date < firstRetained) {
delete value.hours;
delete value.clients;
}
}
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, character => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;"
})[character]);
}
function page(title, body) {
return `<!doctype html>\n<html lang="en"><head><meta charset="utf-8">\n`
+ `<meta name="viewport" content="width=device-width,initial-scale=1">\n`
+ `<meta name="robots" content="noindex,nofollow"><title>${escapeHtml(title)}</title>\n`
+ `<style>body{font:16px/1.5 system-ui,sans-serif;max-width:1100px;margin:2rem auto;padding:0 1rem;color:#18202a}`
+ `nav a{margin-right:1rem}table{border-collapse:collapse;width:100%;margin:1rem 0 2rem}`
+ `th,td{border:1px solid #ccd2d8;padding:.35rem .5rem;text-align:left;vertical-align:top}`
+ `th{background:#eef1f4}td.num{text-align:right;font-variant-numeric:tabular-nums}`
+ `.note{color:#4a5560}.scroll{overflow-x:auto}</style></head><body>`
+ `<nav><a href="/combined/">GoAccess combined</a><a href="/metrics/">Private metrics</a></nav>`
+ body + `</body></html>\n`;
}
function table(headers, rows) {
return `<div class="scroll"><table><thead><tr>${headers.map(value => `<th>${escapeHtml(value)}</th>`).join("")}</tr></thead>`
+ `<tbody>${rows.length ? rows.map(row => `<tr>${row.map((value, index) => `<td${index === row.length - 1 ? " class=\"num\"" : ""}>${escapeHtml(value)}</td>`).join("")}</tr>`).join("") : `<tr><td colspan="${headers.length}">No data</td></tr>`}</tbody></table></div>`;
}
function sortedEntries(values) {
return Object.entries(values).sort(([left], [right]) => left.localeCompare(right, "en"));
}
function annualTotals(daily, totalKey) {
const years = {};
for (const [date, value] of Object.entries(daily)) {
const year = date.slice(0, 4);
if (!years[year]) years[year] = { total: 0, countries: {}, paths: {} };
years[year].total += value[totalKey];
for (const [country, count] of Object.entries(value.countries)) {
addCount(years[year].countries, country, count);
}
for (const [requestPath, count] of Object.entries(value.paths || {})) {
addCount(years[year].paths, requestPath, count);
}
}
return years;
}
function coverageText(series) {
return series.firstCoveredOn
? `The first covered day is ${escapeHtml(series.firstCoveredOn)}. Earlier dates are unknown, not zero.`
: "No covered day has been recorded yet.";
}
function renderDaily(site, kind, visitSite = null) {
const series = site[kind];
const dailyVisits = visitSite ? visitSite.dailyVisits : {};
const totalKey = kind === "website" ? "pageViews" : "requests";
const label = kind === "website" ? "Website page views" : "Update-information requests";
const dates = [...new Set([
...Object.keys(series.daily),
...(kind === "website" ? Object.keys(dailyVisits) : [])
])].sort();
const totals = dates.map(date => kind === "website"
? [date, series.daily[date] ? series.daily[date][totalKey] : "unknown", dailyVisits[date] ?? "unknown"]
: [date, series.daily[date][totalKey]]);
const metricDates = Object.keys(series.daily).sort();
const countries = metricDates.flatMap(date => sortedEntries(series.daily[date].countries)
.map(([country, count]) => [date, country, count]));
let details = table(kind === "website"
? ["Date", label, "Approximate visits"]
: ["Date", label], totals) + `<h2>Countries by day</h2>`
+ table(["Date", "Country", kind === "website" ? "Page views" : "Requests"], countries);
if (kind === "website") {
const paths = metricDates.flatMap(date => sortedEntries(series.daily[date].paths)
.map(([requestPath, count]) => [date, requestPath, count]));
details += `<h2>Pages by day</h2>${table(["Date", "Path", "Page views"], paths)}`;
} else {
const hours = dates.flatMap(date => sortedEntries(series.daily[date].hours || {})
.map(([hour, count]) => [date, `${hour}:00${hour}:59`, count]));
const clients = dates.flatMap(date => sortedEntries(series.daily[date].clients || {})
.map(([client, count]) => [date, client, count]));
details += `<h2>Hourly detail (last 14 days)</h2>${table(["Date", "Hour (Europe/Berlin)", "Requests"], hours)}`
+ `<h2>Recognisable client groups (last 14 days)</h2>`
+ `<p class="note">Existing KST4Contest versions do not send a reliable application-specific user agent. A Java user agent therefore identifies only a Java runtime, not a KST4Contest start or user.</p>`
+ table(["Date", "Client group", "Requests"], clients);
}
const visitCoverage = kind === "website" && visitSite
? ` Approximate visits have their own coverage beginning ${escapeHtml(visitSite.since)}.`
: "";
return page(`${label} by day`, `<h1>${label}: daily view</h1><p class="note">${coverageText(series)}${visitCoverage}</p>${details}`);
}
function renderAnnual(site, kind, visitSite = null) {
const series = site[kind];
const dailyVisits = visitSite ? visitSite.dailyVisits : {};
const totalKey = kind === "website" ? "pageViews" : "requests";
const label = kind === "website" ? "Website page views" : "Update-information requests";
const years = annualTotals(series.daily, totalKey);
const visitYears = {};
for (const [date, count] of Object.entries(dailyVisits)) {
visitYears[date.slice(0, 4)] = (visitYears[date.slice(0, 4)] || 0) + count;
}
const allYears = [...new Set([
...Object.keys(years),
...(kind === "website" ? Object.keys(visitYears) : [])
])].sort();
const summary = allYears.map(year => kind === "website"
? [year, years[year] ? years[year].total : "unknown", visitYears[year] ?? "unknown"]
: [year, years[year].total]);
const allDates = [...new Set([
...Object.keys(series.daily),
...(kind === "website" ? Object.keys(dailyVisits) : [])
])].sort();
const trend = allDates.map(date => kind === "website"
? [date, series.daily[date] ? series.daily[date][totalKey] : "unknown", dailyVisits[date] ?? "unknown"]
: [date, series.daily[date][totalKey]]);
const countries = sortedEntries(years).flatMap(([year, value]) => sortedEntries(value.countries)
.map(([country, count]) => [year, country, count]));
let details = table(kind === "website"
? ["Year", label, "Approximate visits"]
: ["Year", label], summary) + `<h2>Daily values by year</h2>`
+ table(kind === "website"
? ["Date", label, "Approximate visits"]
: ["Date", label], trend) + `<h2>Country totals by year</h2>`
+ table(["Year", "Country", kind === "website" ? "Page views" : "Requests"], countries);
if (kind === "website") {
const paths = sortedEntries(years).flatMap(([year, value]) => sortedEntries(value.paths)
.map(([requestPath, count]) => [year, requestPath, count]));
details += `<h2>Page totals by year</h2>${table(["Year", "Path", "Page views"], paths)}`;
}
const visitCoverage = kind === "website" && visitSite
? ` Approximate visits have their own coverage beginning ${escapeHtml(visitSite.since)}.`
: "";
return page(`${label} by year`, `<h1>${label}: annual view</h1><p class="note">${coverageText(series)}${visitCoverage}</p>${details}`);
}
function renderReports(state, registry, now, visitState = { sites: {} }) {
const files = [];
const siteLinks = [];
for (const siteConfig of registry.sites) {
const site = state.sites[siteConfig.id];
const visitSite = Object.hasOwn(visitState.sites, siteConfig.id)
? visitState.sites[siteConfig.id]
: null;
const base = path.join(registry.privateMetrics.reportOutputDirectory, siteConfig.id);
files.push({ destination: path.join(base, "website", "daily", "report.html"), content: renderDaily(site, "website", visitSite) });
files.push({ destination: path.join(base, "website", "yearly", "report.html"), content: renderAnnual(site, "website", visitSite) });
files.push({ destination: path.join(base, "updates", "daily", "report.html"), content: renderDaily(site, "updateInfo") });
files.push({ destination: path.join(base, "updates", "yearly", "report.html"), content: renderAnnual(site, "updateInfo") });
siteLinks.push(`<h2>${escapeHtml(siteConfig.hostname)}</h2><ul>`
+ `<li><a href="/metrics/${encodeURIComponent(siteConfig.id)}/website/daily/">Website: daily</a></li>`
+ `<li><a href="/metrics/${encodeURIComponent(siteConfig.id)}/website/yearly/">Website: annual</a></li>`
+ `<li><a href="/metrics/${encodeURIComponent(siteConfig.id)}/updates/daily/">Update information: daily</a></li>`
+ `<li><a href="/metrics/${encodeURIComponent(siteConfig.id)}/updates/yearly/">Update information: annual</a></li></ul>`);
}
files.push({
destination: path.join(registry.privateMetrics.reportOutputDirectory, "report.html"),
content: page("Private website metrics", `<h1>Private website metrics</h1>`
+ `<p>Generated ${escapeHtml(now.toISOString())}. Visits, page views and update-information requests are separate measures.</p>`
+ `<p class="note">An update-information request is a successful GET request for the exact XML path. Browsers and bots may request it too; it is neither a program-start count nor a user count.</p>${siteLinks.join("")}`)
});
return files;
}
function validateImportOptions(options, registry) {
const hasImport = Array.isArray(options.importLogs) && options.importLogs.length > 0;
const companions = [options.importFormat, options.importSite, options.coverageFrom, options.coverageThrough];
if (!hasImport && companions.some(Boolean)) throw new Error("historical import options require at least one --import-log");
if (!hasImport) return null;
if (!options.importFormat || !["nginx-combined", "analytics-tsv"].includes(options.importFormat)) {
throw new Error("--import-format must be nginx-combined or analytics-tsv");
}
const site = registry.sites.find(entry => entry.id === options.importSite);
if (!site) throw new Error("--import-site must identify a registered site");
if (!parseIsoDate(options.coverageFrom) || !parseIsoDate(options.coverageThrough)
|| options.coverageFrom > options.coverageThrough) {
throw new Error("--coverage-from and --coverage-through must define a valid inclusive range");
}
const importLogs = options.importLogs.map(filePath => path.resolve(filePath));
for (const filePath of importLogs) fs.accessSync(filePath, fs.constants.R_OK);
return { ...options, site, importLogs };
}
function validCountMap(values) {
return values && typeof values === "object" && !Array.isArray(values)
&& Object.entries(values).every(([key, value]) => key !== ""
&& Number.isSafeInteger(value) && value >= 0);
}
function validateStoredSeries(series, totalKey, label) {
if (!series || typeof series !== "object" || !parseIsoDate(series.liveSince)
|| (series.firstCoveredOn !== null && !parseIsoDate(series.firstCoveredOn))
|| (series.lastSuccessfulOn !== null && !parseIsoDate(series.lastSuccessfulOn))
|| !series.daily || typeof series.daily !== "object" || Array.isArray(series.daily)) {
throw new Error(`private metrics state for ${label} is invalid`);
}
for (const [date, value] of Object.entries(series.daily)) {
if (!parseIsoDate(date) || !value || !Number.isSafeInteger(value[totalKey])
|| value[totalKey] < 0 || !validCountMap(value.countries)
|| sumValues(value.countries) !== value[totalKey]) {
throw new Error(`private metrics state value for ${label}/${date} is invalid`);
}
if (totalKey === "pageViews") {
if (!validCountMap(value.paths) || sumValues(value.paths) !== value[totalKey]) {
throw new Error(`private metrics path value for ${label}/${date} is invalid`);
}
} else if ((value.hours !== undefined || value.clients !== undefined)
&& (!validCountMap(value.hours) || !validCountMap(value.clients)
|| sumValues(value.hours) !== value[totalKey]
|| sumValues(value.clients) !== value[totalKey])) {
throw new Error(`private metrics detail value for ${label}/${date} is invalid`);
}
}
}
function ensureSiteState(state, site) {
const current = Object.hasOwn(state.sites, site.id) ? state.sites[site.id] : null;
if (current) {
validateStoredSeries(current.website, "pageViews", `${site.id}/website`);
validateStoredSeries(current.updateInfo, "requests", `${site.id}/update information`);
if (current.hostname !== site.hostname
|| current.website.liveSince !== site.websiteMetricsSince
|| current.updateInfo.liveSince !== site.updateInfo.metricsSince) {
throw new Error(`private metrics identity for ${site.id} differs from existing state`);
}
}
if (!current) {
state.sites[site.id] = {
hostname: site.hostname,
website: { liveSince: site.websiteMetricsSince, firstCoveredOn: null, lastSuccessfulOn: null, daily: {} },
updateInfo: { liveSince: site.updateInfo.metricsSince, firstCoveredOn: null, lastSuccessfulOn: null, daily: {} }
};
}
return state.sites[site.id];
}
function liveDates(series, today, historicalBridge = null) {
// Re-read the last successful day as well. The first run after midnight must
// still pick up requests which arrived after the final run of the previous day.
const start = series.lastSuccessfulOn || series.liveSince;
const oldestAvailable = shiftDate(today, -1);
if (start < oldestAvailable) {
const dayBeforeHandover = shiftDate(oldestAvailable, -1);
const bridgeIsComplete = historicalBridge
&& historicalBridge.coverageFrom <= start
&& historicalBridge.coverageThrough >= dayBeforeHandover;
if (!bridgeIsComplete) {
throw new Error(
`private metrics gap begins ${start}; regular current/.1 processing starts no earlier than ${oldestAvailable}`
);
}
return dateRange(oldestAvailable, today);
}
return dateRange(start > today ? today : start, today);
}
function generatePrivateMetrics({ registry, analyticsLogsBySite, updateLogsBySite, options, context, visitState }) {
const state = loadState(registry.privateMetrics.statePath);
const today = localParts(context.now, registry.privateMetrics.timeZone).date;
const imported = validateImportOptions(options, registry);
if (imported && imported.coverageThrough > today) {
throw new Error("historical import coverage must not extend into the future");
}
for (const site of registry.sites) {
const siteState = ensureSiteState(state, site);
if (site.websiteMetricsSince > today || site.updateInfo.metricsSince > today) {
throw new Error(`private metrics live coverage for ${site.id} must not begin in the future`);
}
const historicalBridge = imported && imported.site.id === site.id ? imported : null;
const websiteDates = liveDates(siteState.website, today, historicalBridge);
const updateDates = liveDates(siteState.updateInfo, today, historicalBridge);
const websiteRecords = parseLogFiles(
analyticsLogsBySite.get(site.id),
line => parseAnalyticsLine(line, registry.privateMetrics.timeZone),
{ label: "website analytics log" }
).filter(isEligibleWebsiteRequest);
const updateRecords = parseLogFiles(
updateLogsBySite.get(site.id),
line => parseAnalyticsLine(line, registry.privateMetrics.timeZone),
{ label: "update-information log" }
).filter(isUpdateRequest);
mergeSeries(siteState.website.daily, aggregateDays({
records: websiteRecords,
dates: websiteDates,
kind: "website",
site,
context
}), "pageViews", "website");
mergeSeries(siteState.updateInfo.daily, aggregateDays({
records: updateRecords,
dates: updateDates,
kind: "updateInfo",
site,
context
}), "requests", "update information");
for (const series of [siteState.website, siteState.updateInfo]) {
series.firstCoveredOn = !series.firstCoveredOn || series.liveSince < series.firstCoveredOn ? series.liveSince : series.firstCoveredOn;
series.lastSuccessfulOn = today;
}
}
if (imported) {
const parser = imported.importFormat === "nginx-combined"
? line => parseCombinedLine(line, imported.site.hostname, registry.privateMetrics.timeZone)
: line => parseAnalyticsLine(line, registry.privateMetrics.timeZone);
const allRecords = parseLogFiles(imported.importLogs, parser, { deduplicateAcrossFiles: true, label: "historical import log" });
const dates = dateRange(imported.coverageFrom, imported.coverageThrough);
const siteState = state.sites[imported.site.id];
const website = aggregateDays({ records: allRecords.filter(isEligibleWebsiteRequest), dates, kind: "website", site: imported.site, context });
const updates = aggregateDays({ records: allRecords.filter(isUpdateRequest), dates, kind: "updateInfo", site: imported.site, context });
mergeSeries(siteState.website.daily, website, "pageViews", "website import");
mergeSeries(siteState.updateInfo.daily, updates, "requests", "update-information import");
for (const series of [siteState.website, siteState.updateInfo]) {
series.firstCoveredOn = !series.firstCoveredOn || imported.coverageFrom < series.firstCoveredOn
? imported.coverageFrom : series.firstCoveredOn;
}
}
for (const site of Object.values(state.sites)) {
purgeDetails(site, today, registry.privateMetrics.detailRetentionDays);
}
return {
state,
files: renderReports(state, registry, context.now, visitState),
imported: Boolean(imported)
};
}
module.exports = {
UPDATE_INFO_PATH,
aggregateGoAccessReport,
clientGroup,
escapeHtml,
generatePrivateMetrics,
isEligibleWebsiteRequest,
isUpdateRequest,
localParts,
mergeDay,
normalizePath,
parseAnalyticsLine,
parseCombinedLine,
parseLogFiles,
purgeDetails,
renderReports,
validateImportOptions
};
+33
View File
@@ -0,0 +1,33 @@
{
"schemaVersion": 1,
"stateDirectory": "/var/lib/hamradioonline-analytics",
"counterStatePath": "/var/lib/hamradioonline-analytics/public-counter-state.json",
"lockFile": "/run/hamradioonline-analytics/generator.lock",
"geoIpCountryDatabase": "/var/lib/GeoIP/GeoLite2-Country.mmdb",
"privateMetrics": {
"statePath": "/var/lib/hamradioonline-analytics/private-metrics-state.json",
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/metrics",
"timeZone": "Europe/Berlin",
"detailRetentionDays": 14
},
"combined": {
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/combined"
},
"sites": [
{
"id": "kst4contest",
"hostname": "kst4contest.hamradioonline.de",
"analyticsLog": "/var/log/nginx/kst4contest-analytics.log",
"activatedOn": "2026-09-11",
"websiteMetricsSince": "2026-09-14",
"updateInfo": {
"path": "/kst4ContestVersionInfo.xml",
"analyticsLog": "/var/log/nginx/kst4contest-update-information.log",
"metricsSince": "2026-09-14"
},
"publicCounter": true,
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/kst4contest",
"publicJsonPath": "/var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json"
}
]
}
@@ -0,0 +1,34 @@
[Unit]
Description=Generate private analytics reports and public project counters
After=nginx.service
[Service]
Type=oneshot
User=hamradio-analytics
Group=hamradio-analytics
UMask=0027
Environment=LC_TIME=C
ExecStart=/usr/bin/node /opt/hamradioonline-analytics/generate-reports.js --registry /etc/hamradioonline-analytics/sites.json --config-template /etc/hamradioonline-analytics/goaccess.conf.template
NoNewPrivileges=true
PrivateDevices=true
PrivateNetwork=true
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict
PrivateTmp=true
ProtectProc=invisible
ProcSubset=pid
RestrictAddressFamilies=AF_UNIX
ReadOnlyPaths=/etc/hamradioonline-analytics /opt/hamradioonline-analytics /var/log/nginx /var/lib/GeoIP/GeoLite2-Country.mmdb
ReadWritePaths=/var/lib/hamradioonline-analytics
StateDirectory=hamradioonline-analytics
StateDirectoryMode=0711
RuntimeDirectory=hamradioonline-analytics
RuntimeDirectoryMode=0750
RestrictSUIDSGID=true
LockPersonality=true
@@ -0,0 +1,11 @@
[Unit]
Description=Run hamradioonline analytics once per hour
[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=4m
AccuracySec=1m
[Install]
WantedBy=timers.target
+3
View File
@@ -7,6 +7,9 @@
"": {
"name": "kst4contest-website",
"version": "0.1.0",
"engines": {
"node": ">=18.19.1"
},
"dependencies": {
"markdown-it": "^14.3.0",
"markdown-it-anchor": "^9.2.0"
+3
View File
@@ -2,6 +2,9 @@
"name": "kst4contest-website",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=18.19.1"
},
"scripts": {
"start": "eleventy --serve",
"build": "eleventy && npm run validate:version-info",
+24 -15
View File
@@ -1,23 +1,32 @@
module.exports = [
{
id: "main-window",
title: "Main Window",
image: "/assets/screenshots/main-window.png",
alt: "KST4Contest ON4KST contest chat client main window",
caption: "Contest-oriented ON4KST chat workflow with candidate awareness."
id: "main-window-light",
title: "Main window · Light theme",
image: "/assets/completeViewLight.png",
alt: "KST4Contest main window in the light theme with ON4KST chat, station filters, priority candidates and sked controls",
caption: "The complete contest view brings chat messages, active bands, filters and priority candidates together."
},
{
id: "priority-candidates",
title: "Priority Candidates",
image: "/assets/screenshots/priority-candidates.png",
alt: "Priority candidate list in KST4Contest",
caption: "Score-based candidate ranking for faster operator decisions."
id: "main-window-dark",
title: "Main window · Dark theme",
image: "/assets/completeViewDark.png",
alt: "KST4Contest main window in the dark theme with ON4KST chat, station filters, priority candidates and sked controls",
caption: "The same operating view in the dark theme, including worked status, candidate priorities and message preparation."
},
{
id: "timeline",
title: "Timeline View",
image: "/assets/screenshots/timeline.png",
alt: "KST4Contest AP timeline view",
caption: "Timeline support for AP windows and candidate timing."
title: "AP and sked timeline",
image: "/assets/timeline.png",
alt: "KST4Contest timeline showing scheduled contacts and Aircraft Scatter crossing windows",
caption: "Skeds and Aircraft Scatter opportunities share one time axis, making upcoming operating windows easier to coordinate.",
wide: true
},
{
id: "airscout",
title: "KST4Contest with AirScout",
image: "/assets/complete_incl_AS.png",
alt: "KST4Contest main window alongside AirScout path and aircraft information",
caption: "AirScout adds path and aircraft information while KST4Contest keeps the related chat and station context visible.",
wide: true
}
];
];
+4
View File
@@ -61,6 +61,7 @@
<p><a href="/features/">Features</a></p>
<p><a href="/download/">Download</a></p>
<p><a href="/manual/">Manual</a></p>
<p><a href="/background/">Background</a></p>
<p><a href="/roadmap/">Roadmap</a></p>
</div>
<div>
@@ -81,5 +82,8 @@
{% if heroFx %}
<script src="/assets/js/hero-radio-fx.js?v={{ build.version }}" defer></script>
{% endif %}
{% if visitorCount %}
<script src="/assets/js/visitor-count.js?v={{ build.version }}" defer></script>
{% endif %}
</body>
</html>
+3 -1
View File
@@ -25,6 +25,8 @@ description: About KST4Contest and its contest-oriented ON4KST workflow.
<p>
The project is open source and focused on practical contest station workflows.
Features are designed around real operating pressure, not theoretical UI concepts.
The <a href="/background/">background and conference papers</a> explain how
work at DM5M and later analysis of archived ON4KST chat data influenced the client.
</p>
<h2>Community supported</h2>
@@ -42,4 +44,4 @@ description: About KST4Contest and its contest-oriented ON4KST workflow.
</article>
</section>
</section>
Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+36 -9
View File
@@ -641,24 +641,43 @@ a:hover {
margin-top: 30px;
}
.screenshot-placeholder {
display: grid;
place-items: center;
min-height: 180px;
.screenshot-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.screenshot-card--wide {
grid-column: 1 / -1;
}
.screenshot-link {
display: block;
margin-bottom: 16px;
border-radius: 18px;
background:
linear-gradient(135deg, rgba(56, 189, 248, 0.20), rgba(168, 85, 247, 0.20)),
rgba(2, 6, 23, 0.7);
overflow: hidden;
border: 1px solid var(--border);
color: white;
font-weight: 800;
background: rgba(2, 6, 23, 0.7);
}
.screenshot-image {
display: block;
width: 100%;
height: auto;
}
.screenshot-card h3 {
margin-bottom: 8px;
}
.cta-panel {
padding: clamp(28px, 5vw, 60px);
}
.visitor-count {
margin: 24px 0 0;
color: var(--soft);
font-size: 0.9rem;
}
.manual-content {
max-width: 980px;
}
@@ -712,6 +731,14 @@ code {
align-items: flex-start;
flex-direction: column;
}
.screenshot-grid {
grid-template-columns: 1fr;
}
.screenshot-card--wide {
grid-column: auto;
}
}
@media (prefers-reduced-motion: no-preference) {
+147
View File
@@ -0,0 +1,147 @@
"use strict";
const VISITOR_COUNT_ENDPOINT = "/visitor-count.json";
const VISITOR_COUNT_TIMEOUT_MS = 2500;
function parseIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day) {
return null;
}
return date;
}
function isIsoTimestamp(value) {
if (typeof value !== "string") {
return false;
}
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
if (!match || !parseIsoDate(match[1])) {
return false;
}
const hour = Number(match[2]);
const minute = Number(match[3]);
const second = Number(match[4]);
const offsetHour = match[6] === undefined ? 0 : Number(match[6]);
const offsetMinute = match[7] === undefined ? 0 : Number(match[7]);
return hour <= 23
&& minute <= 59
&& second <= 59
&& offsetHour <= 23
&& offsetMinute <= 59
&& Number.isFinite(Date.parse(value));
}
function validateVisitorCount(data) {
if (!data || typeof data !== "object" || Array.isArray(data)) {
return null;
}
if (data.schemaVersion !== 1
|| !Number.isSafeInteger(data.visits)
|| data.visits < 0
|| !isIsoTimestamp(data.updatedAt)) {
return null;
}
const since = typeof data.since === "string"
? parseIsoDate(data.since)
: null;
return since ? { visits: data.visits, since } : null;
}
function formatVisitorCount(data) {
const valid = validateVisitorCount(data);
if (!valid) {
return null;
}
const date = new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC"
}).format(valid.since);
const visits = new Intl.NumberFormat("en-GB").format(valid.visits);
return `Visits since ${date}: ${visits}`;
}
async function loadVisitorCount({
element,
fetchImpl = globalThis.fetch,
AbortControllerImpl = globalThis.AbortController,
timeoutMs = VISITOR_COUNT_TIMEOUT_MS
}) {
if (!element || typeof fetchImpl !== "function"
|| typeof AbortControllerImpl !== "function") {
return false;
}
element.hidden = true;
element.textContent = "";
const controller = new AbortControllerImpl();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(VISITOR_COUNT_ENDPOINT, {
credentials: "omit",
signal: controller.signal
});
if (!response.ok) {
return false;
}
const text = formatVisitorCount(await response.json());
if (!text) {
return false;
}
element.textContent = text;
element.hidden = false;
return true;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
if (typeof document !== "undefined") {
const element = document.querySelector("[data-visitor-count]");
if (element) {
loadVisitorCount({ element });
}
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
formatVisitorCount,
loadVisitorCount,
parseIsoDate,
validateVisitorCount
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+98
View File
@@ -0,0 +1,98 @@
---
layout: base.njk
lang: en
title: Background and conference papers
description: The operating background of KST4Contest and the German and English papers presented at GHz-Tagung Dorsten in 2025 and 2026.
---
<section class="hero">
<p class="badge">Project background</p>
<h1>Background and conference papers</h1>
<p class="lead">
KST4Contest grew out of practical contest operation at DM5M. Individual
functions started with concrete problems encountered while using the ON4KST
chat; later development was also influenced by analysis of archived chat data.
</p>
</section>
<section class="section narrow">
<div class="section-heading">
<p class="eyebrow">Two points in the project history</p>
<h2>What the papers document</h2>
<p>
These papers preserve the implementation and knowledge available at the
respective GHz-Tagung. Their historical version references and plans have
deliberately not been rewritten to match the current application.
</p>
</div>
<div class="grid">
<article class="card" id="paper-2025">
<p class="eyebrow">GHz-Tagung Dorsten · 2025</p>
<h3>Softwaregestützte Optimierung der Betriebstechnik von Conteststationen am Beispiel von KST4Contest</h3>
<p><strong>English title:</strong> Software-assisted optimisation of contest station operating practice using KST4Contest</p>
<p>
The paper covers the development of KST4Contest, practical contest
operating technique, the limits of conventional chat clients, Worked
information, Genius frequency and message analysis, AirScout, logger,
rotator and DX Cluster integration, and the operating and UI concept.
</p>
<p>
Marc Fröhlich, “Softwaregestützte Optimierung der Betriebstechnik von
Conteststationen am Beispiel von KST4Contest”, paper for the presentation
at <a href="https://ghztagung.darc.de/">GHz-Tagung Dorsten 2025</a>.
</p>
<p>
The English edition is a translation of the German original, not a
separate publication.
</p>
<div class="actions">
<a class="button" href="/assets/papers/kst4contest-ghz-tagung-2025-de.pdf">Download German PDF</a>
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2025-en.pdf">Download English PDF</a>
</div>
<p><small>German PDF · 3.4 MB · English PDF · 3.4 MB</small></p>
</article>
<article class="card" id="paper-2026">
<p class="eyebrow">GHz-Tagung Dorsten · 2026</p>
<h3>Chat-Mining: Jagdinstinkte wecken mit KST4Contest</h3>
<p><strong>English title:</strong> Chat mining: awakening hunting instincts with KST4Contest</p>
<p>
The paper examines archived ON4KST chats, presence in both categories,
activity periods, sked requests and reply behaviour, the relationship
between useful information and reply rate, and the functions derived
from those observations.
</p>
<p>
Marc Fröhlich, “Chat-Mining: Jagdinstinkte wecken mit KST4Contest”, paper
for the presentation at <a href="https://ghztagung.darc.de/">GHz-Tagung
Dorsten 2026</a>, including section “Erfolgsrezept”.
</p>
<p>
The English edition is a translation of the German original, not a
separate study.
</p>
<div class="actions">
<a class="button" href="/assets/papers/kst4contest-ghz-tagung-2026-de.pdf">Download German PDF</a>
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2026-en.pdf">Download English PDF</a>
</div>
<p><small>German PDF · 1.8 MB · English PDF · 1.7 MB</small></p>
</article>
</div>
</section>
<section class="section narrow">
<div class="cta-panel">
<p class="eyebrow">Current software</p>
<h2>The papers explain the background, not today's complete feature set</h2>
<p>
For current behaviour and configuration, use the complete function
overview and the manual. The conference papers remain historical sources.
</p>
<div class="actions">
<a class="button" href="/features/">Open all functions</a>
<a class="button secondary" href="/manual/">Read the current manual</a>
<a class="button ghost" href="/">Return to the home page</a>
</div>
</div>
</section>
+39
View File
@@ -212,6 +212,45 @@ description: Download the latest stable KST4Contest packages for Windows, Linux
</div>
</section>
<section class="section">
<div class="section-heading">
<p class="eyebrow">Conference papers</p>
<h2>Background from GHz-Tagung Dorsten</h2>
<p>
These historical conference papers document KST4Contest at the time of the 2025 and 2026 talks.
They provide project background, not a complete description of the current feature set.
</p>
</div>
<div class="grid">
<article class="card content-card">
<h3>GHz-Tagung 2025</h3>
<p>
How KST4Contest grew from practical contest operation and addressed limitations of conventional
chat clients.
</p>
<div class="actions">
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2025-de.pdf">Download German PDF</a>
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2025-en.pdf">Download English PDF</a>
</div>
<p><a href="/background/#paper-2025">Background and source details →</a></p>
</article>
<article class="card content-card">
<h3>GHz-Tagung 2026</h3>
<p>
How archived ON4KST chat data was evaluated and how the findings influenced later KST4Contest
functions.
</p>
<div class="actions">
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2026-de.pdf">Download German PDF</a>
<a class="button secondary" href="/assets/papers/kst4contest-ghz-tagung-2026-en.pdf">Download English PDF</a>
</div>
<p><a href="/background/#paper-2026">Background and source details →</a></p>
</article>
</div>
</section>
<section class="section">
<div class="cta-panel">
<p class="eyebrow">Choosing a package</p>
+121 -55
View File
@@ -3,6 +3,7 @@ layout: base.njk
title: KST4Contest ON4KST Contest Client for VHF, UHF and SHF
description: KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration in one desktop client.
heroFx: true
visitorCount: true
---
<section class="hero hero-split">
@@ -14,15 +15,15 @@ heroFx: true
<h1>KST4Contest</h1>
<p class="lead">
ON4KST shows you the traffic. KST4Contest helps turn it into an operating
decision by combining chat, candidate prioritisation, sked planning,
AirScout data and logger integration in one desktop client.
KST4Contest is an ON4KST client for VHF, UHF and SHF contest operation
on 144 MHz and above. It keeps active stations, frequencies, skeds and
aircraft-scatter opportunities in view while connecting chat information
with logger and station interfaces.
</p>
<p>
The available information is filtered, related to active stations and
presented in a contest-oriented workflow. The final decision still
belongs to the operator.
The client helps the operator choose the next useful station and prepare
the next operating step. It does not automate the radio contact itself.
</p>
<div class="actions">
@@ -35,57 +36,71 @@ heroFx: true
<div class="hero-panel">
<div class="terminal-bar">
<span></span><span></span><span></span>
<strong>Functions sharing the same station context</strong>
<strong>Contest information ready for the next decision</strong>
</div>
<div class="mock-grid">
{% for feature in collections.sortedFeatures %}
{% if loop.index <= 6 %}
<div class="mini-card">
<strong>{{ feature.data.icon }} {{ feature.data.title }}</strong>
<small>{{ feature.data.summary }}</small>
</div>
{% endif %}
{% endfor %}
<div class="mini-card">
<strong>Active stations and QRGs</strong>
<small>Chat messages, recognised frequencies and known bands stay connected to the station.</small>
</div>
<div class="mini-card">
<strong>Skeds and AP windows</strong>
<small>Scheduled contacts and AirScout timing remain visible while the contest continues.</small>
</div>
<div class="mini-card">
<strong>Logger and station status</strong>
<small>Worked information, the current TRX QRG and rotor direction inform the same workflow.</small>
</div>
</div>
</div>
</section>
{% if collections.latestNews.length %}
{% set latestPost = collections.latestNews[0] %}
<section class="section narrow" aria-labelledby="latest-news-heading">
<article class="card">
<p class="eyebrow">Latest news · {{ latestPost.date | readableDate }}</p>
<h2 id="latest-news-heading"><a href="{{ latestPost.url }}">{{ latestPost.data.title }}</a></h2>
<p>{{ latestPost.data.summary }}</p>
<a href="{{ latestPost.url }}">Open this update →</a>
</article>
</section>
{% endif %}
<section class="section">
<div class="section-heading">
<p class="eyebrow">The operating problem</p>
<h2>The useful station is rarely the only station in the chat</h2>
<p class="eyebrow">Practical tasks</p>
<h2>From chat traffic to the next operating step</h2>
<p>
During an active contest, messages, sked requests, frequency information
and band changes arrive continuously. The task is to identify which part
of that traffic matters now, which candidate should be monitored and
which contact is better scheduled for later.
Messages, station activity and station interfaces provide different
pieces of the same operating situation. KST4Contest brings them together
where they are needed.
</p>
</div>
<div class="grid">
<article class="card">
<h3>Observe</h3>
<h3>Keep track of chat and active stations</h3>
<p>
Messages, locators, detected frequencies and known band activity are
assigned to stations across as many as two ON4KST chat categories.
Follow messages across two ON4KST categories, see active bands and keep
recognised frequencies attached to the callsign that sent them.
</p>
</article>
<article class="card">
<h3>Evaluate</h3>
<h3>Select and plan contacts</h3>
<p>
Direction, distance, worked status, NOT-QRV information, chat activity,
sked context and aircraft scatter data feed filters and candidate scores.
Use filters and priorities to find candidates, schedule skeds and keep
their timing visible in the timeline together with AirScout data.
</p>
</article>
<article class="card">
<h3>Act</h3>
<h3>Connect logger and station components</h3>
<p>
Candidates can be contacted, scheduled or monitored. Skeds remain
visible, while supported loggers and station interfaces receive the
information required for the next operating step.
Synchronise Worked status with supported loggers and pass useful context
to the TRX, rotator and local DX Cluster interfaces.
</p>
</article>
</div>
@@ -93,25 +108,77 @@ heroFx: true
<section class="section">
<div class="section-heading">
<p class="eyebrow">Functions</p>
<h2>One workflow, shared context</h2>
<p class="eyebrow">What the chat data showed</p>
<h2>Useful detail makes a reply easier</h2>
<p>
The individual functions are not isolated tools. They use the same
station, message, band and timing information, so that a change in one
part of the workflow can also affect filters, priorities and reminders.
Archived ON4KST chat logs from several IARU contests in the preceding two
years were analysed. The concrete example comes from the July 2025 contest
and combines the 144/432 MHz and Microwave categories. It compares sked
requests with the replies and sked windows they produced.
</p>
<p>
The text analysis considered action and sked words, frequency, timing,
beam direction, aircraft-scatter information, locator, mode and courtesy
or anchor words. Within this sample, requests containing AP and beam
information were associated with an almost 30% higher chat reply rate
than short, unspecific requests such as “Hi noname, try 2m?”.
</p>
<p>
This measures chat replies, not completed QSOs. It is a correlation within
the investigated sample, not general proof of causation and not a promise
that an individual sked will succeed. The practical point is modest:
information that can be used immediately saves the other operator from
having to ask for it first. KST4Contest supplies part of that context when
preparing messages and skeds.
</p>
<p>
Source: Marc Fröhlich, “Chat-Mining: Jagdinstinkte wecken mit KST4Contest”,
conference paper for GHz-Tagung Dorsten 2026, section “Erfolgsrezept”.
<a href="/background/#paper-2026">Read the background and download the paper</a>.
</p>
</div>
</section>
<section class="section">
<div class="section-heading">
<p class="eyebrow">Selected functions</p>
<h2>The parts most often used during a contest</h2>
<p>
These examples show how station, band, message and timing information is
used in practice. The complete function overview remains available for
configuration details and less frequently used tools.
</p>
</div>
<div class="grid">
{% for feature in collections.sortedFeatures %}
<article class="card feature-card">
<div class="feature-icon">{{ feature.data.icon }}</div>
<p class="eyebrow">{{ feature.data.category }}</p>
<h3><a href="{{ feature.url }}">{{ feature.data.title }}</a></h3>
<p>{{ feature.data.summary }}</p>
<a href="{{ feature.url }}">Read how it works →</a>
</article>
{% endfor %}
<article class="card feature-card">
<h3><a href="/features/global-message-views/">Chat overview and station filters</a></h3>
<p>Separate global and private views keep the traffic readable, while station filters narrow the candidate list without hiding the underlying chat context.</p>
</article>
<article class="card feature-card">
<h3><a href="/features/priority-score/">Priority and band opportunities</a></h3>
<p>Candidate scores combine available station information with Worked and band state so that a useful next call is easier to find.</p>
</article>
<article class="card feature-card">
<h3><a href="/features/timeline/">Skeds and AP timeline</a></h3>
<p>Manual skeds, reminders and aircraft-scatter windows remain visible instead of disappearing in the continuing chat stream.</p>
</article>
<article class="card feature-card">
<h3><a href="/features/airscout/">AirScout and path information</a></h3>
<p>AirScout data adds timing and path context to a selected station without turning that assessment into a propagation promise.</p>
</article>
<article class="card feature-card">
<h3><a href="/features/log-sync/">Logger and Worked synchronisation</a></h3>
<p>Supported logger data updates global and per-band Worked information across the active callsign variants.</p>
</article>
<article class="card feature-card">
<h3><a href="/features/trx-qrg-synchronisation/">TRX, rotator and DX Cluster integration</a></h3>
<p>The current QRG, rotor direction and local spots carry selected station information into the next manual operating step.</p>
</article>
</div>
<div class="actions">
<a class="button secondary" href="/features/">Open the complete function overview</a>
</div>
</section>
@@ -120,10 +187,9 @@ heroFx: true
<p class="eyebrow">Limits</p>
<h2>A priority score is not a propagation forecast</h2>
<p>
KST4Contest can only evaluate the information it knows. Scores, AP
windows, filters and path profiles support the operator's decision; they
do not guarantee a contact. If the input data is incomplete or outdated,
the result can be incomplete or outdated as well.
Priorities are not a propagation forecast. Scores, filters and AP windows
depend on the data available to the client. They help with sorting and
timing, but they do not guarantee a QSO.
</p>
<div class="actions">
@@ -138,11 +204,9 @@ heroFx: true
<p class="eyebrow">Project status</p>
<h2>Stable for operation, Nightly for testing</h2>
<p>
KST4Contest is open-source software. The current stable release is the
normal choice for contest operation. Nightly builds follow ongoing
development and are useful when a particular fix or feature needs
testing. A few minutes before a contest is not the ideal time to discover
what changed.
Stable is the normal choice for contest operation. Nightly contains the
latest development state and is intended for testing. Changing versions
immediately before a contest is still not recommended.
</p>
<div class="actions">
@@ -150,6 +214,8 @@ heroFx: true
<a class="button secondary" href="/roadmap/">Development status</a>
<a class="button ghost" href="/support/">Support development</a>
</div>
<p class="visitor-count" data-visitor-count hidden aria-live="polite"></p>
</div>
</section>
@@ -165,4 +231,4 @@ heroFx: true
"description": "KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration for VHF, UHF and SHF contest operation.",
"license": "https://github.com/praktimarc/kst4contest/blob/main/LICENSE"
}
</script>
</script>
+60 -5
View File
@@ -7,7 +7,7 @@ description: Privacy policy for the KST4Contest website.
<section class="hero">
<p class="badge">Privacy</p>
<h1>Privacy Policy</h1>
<p class="lead">Information about data processing on this static website.</p>
<p class="lead">Information about data processing on this website.</p>
</section>
<section class="section narrow">
@@ -30,12 +30,67 @@ description: Privacy policy for the KST4Contest website.
<h2>Legal basis</h2>
<p>
The legal basis is Art. 6(1)(f) GDPR: legitimate interest in secure and reliable operation
of the website.
of the website and in understanding the approximate use of its project pages.
</p>
<h2>Cookies and tracking</h2>
<h2>Server-side reach statistics</h2>
<p>
This website currently does not use cookies, analytics tracking, advertising pixels or user profiling.
The website uses server-side reach statistics to understand approximate demand and which
project pages are used. Measurement is based on a dedicated, reduced server access log. It
does not use cookies, an analytics or tracking script, advertising pixels, local storage,
third-party requests or user profiles.
</p>
<p>
The analytics log contains the server name, IP address, time, HTTP method, normalised path
without a query string, protocol, response status, transferred size and user agent. It does
not contain a referrer or authenticated user name. Known crawlers are filtered, and unknown
browsers or operating systems are treated as crawlers.
</p>
<p>
IP addresses are anonymised before data is stored in the GoAccess statistics aggregates. The
dedicated analytics raw logs are retained for 14 days. Anonymised GoAccess detail is retained
on a rolling basis for 395 days. Daily totals for visits and page views, absolute country
totals per day and page views for each normalised path are retained permanently. No permanent
table combines an individual page view with both its path and country.
</p>
<p>
A visit is an approximate daily value based on the combination of IP address, date and user
agent used by GoAccess. It must not be read as a number of unique people. Page views and visits
remain separate measures. Known bots, monitoring requests, software update checks and static
assets are excluded from the website page and visit statistics as far as they can be identified.
Query strings are not included in the recorded page paths.
</p>
<h2>Update-information statistics</h2>
<p>
Successful <code>GET</code> requests with status <code>200</code> for the exact path
<code>/kst4ContestVersionInfo.xml</code> are recorded separately from the website statistics.
They do not increase its page views or the public visitor count. Daily totals and absolute
country totals per day are retained permanently. Hourly totals and recognisable client groups
derived from the user agent are retained for no more than 14 days.
</p>
<p>
Browsers and bots can request the update-information file too. Existing KST4Contest versions
do not send a reliable application-specific user agent. The resulting number therefore does
not establish a program start, a single user or a particular person.
</p>
<p>
Country information for both statistics is derived locally from the GeoLite2-Country database.
No visitor address is sent to an external location service. Country assignment is approximate;
it is not an exact location determination.
</p>
<h2>Public visitor count</h2>
<p>
The home page displays an element containing the total number of these approximate daily visits
since the stated activation date. The display loads a same-origin JSON file and remains hidden
if that file is unavailable or invalid. This request is not included in the statistic.
</p>
<p>
The public file contains only the counter value, activation date, schema version and update time.
It contains no personal data or daily breakdown. The non-personal daily counter values are kept
permanently so that the total does not decrease when detailed aggregates reach the end of their
395-day retention period.
</p>
<h2>External links</h2>
@@ -71,4 +126,4 @@ description: Privacy policy for the KST4Contest website.
You have the right to lodge a complaint with a competent data protection supervisory authority.
</p>
</article>
</section>
</section>
+17 -6
View File
@@ -14,15 +14,26 @@ description: Screenshots of KST4Contest, the contest-optimized ON4KST chat clien
</section>
<section class="section">
<div class="grid">
<div class="section-heading">
<p class="eyebrow">Four working views</p>
<h2>From chat overview to Aircraft Scatter timing</h2>
<p>
These screenshots were captured in a test environment. Version-like text inside chat messages is
test data, not an announcement of a published KST4Contest version.
</p>
</div>
<div class="grid screenshot-grid">
{% for shot in screenshots %}
<article class="card screenshot-card">
<div class="screenshot-placeholder">
<span>{{ shot.title }}</span>
</div>
<article class="card screenshot-card{% if shot.wide %} screenshot-card--wide{% endif %}">
<a class="screenshot-link" href="{{ shot.image }}" target="_blank" rel="noopener"
aria-label="Open {{ shot.title }} in full size">
<img class="screenshot-image" src="{{ shot.image }}" alt="{{ shot.alt }}" loading="lazy" decoding="async">
</a>
<h3>{{ shot.title }}</h3>
<p>{{ shot.caption }}</p>
<p><a href="{{ shot.image }}" target="_blank" rel="noopener">Open full-size image →</a></p>
</article>
{% endfor %}
</div>
</section>
</section>
+846
View File
@@ -0,0 +1,846 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const {
formatGoAccessCheck,
generateReports,
parseArguments,
parseGoAccessVersion,
validateReport
} = require("../ops/analytics/generate-reports");
const GOACCESS_WITHOUT_ZLIB = {
version: "1.8.1",
geoIpMmdb: true,
openSsl: true,
zlib: false
};
function goAccessReport(dailyVisits, combined = false) {
const report = {
general: { total_requests: 1 },
visitors: {
data: Object.entries(dailyVisits).map(([date, count]) => ({
data: date.replaceAll("-", ""),
visitors: { count }
}))
},
requests: { data: [] },
status_codes: { data: [] },
geolocation: { data: [] }
};
if (combined) {
report.vhosts = { data: [] };
}
return report;
}
function analyticsLine({
host = "alpha.example.test",
ip = "192.0.2.1",
timestamp = "2026-09-11T09:00:00+02:00",
method = "GET",
requestPath = "/",
status = 200,
userAgent = "Mozilla/5.0 Firefox/130.0"
} = {}) {
return `${host}\t${ip}\t${timestamp}\t${method}\t${requestPath}\tHTTP/1.1\t${status}\t123\t"${userAgent}"\n`;
}
function fixture(siteDefinitions) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "kst4-analytics-test-"));
const stateDirectory = path.join(root, "state");
const geoIpCountryDatabase = path.join(root, "GeoLite2-Country.mmdb");
const configTemplatePath = path.join(root, "goaccess.conf.template");
const registryPath = path.join(root, "sites.json");
fs.writeFileSync(geoIpCountryDatabase, "test database placeholder");
fs.copyFileSync(
path.join(__dirname, "../ops/analytics/goaccess.conf.template"),
configTemplatePath
);
const sites = siteDefinitions.map((definition, index) => {
const id = definition.id || `site-${index}`;
const log = path.join(root, `${id}.log`);
const updateLog = path.join(root, `${id}-update-information.log`);
fs.writeFileSync(log, analyticsLine({ host: definition.hostname || `${id}.example.test` }));
fs.writeFileSync(updateLog, analyticsLine({
host: definition.hostname || `${id}.example.test`,
requestPath: "/kst4ContestVersionInfo.xml",
userAgent: "Java/21.0.1"
}));
if (definition.rotated !== false) {
fs.writeFileSync(`${log}.1`, analyticsLine({
host: definition.hostname || `${id}.example.test`,
timestamp: "2026-09-11T08:00:00+02:00",
requestPath: "/privacy/"
}));
fs.writeFileSync(`${updateLog}.1`, analyticsLine({
host: definition.hostname || `${id}.example.test`,
timestamp: "2026-09-11T08:00:00+02:00",
requestPath: "/kst4ContestVersionInfo.xml",
userAgent: "Mozilla/5.0 Firefox/130.0"
}));
}
if (definition.compressed) {
fs.writeFileSync(`${log}.2.gz`, "compressed placeholder\n");
}
return {
id,
hostname: definition.hostname || `${id}.example.test`,
analyticsLog: log,
activatedOn: definition.activatedOn || "2026-01-01",
websiteMetricsSince: "2026-09-11",
updateInfo: {
path: "/kst4ContestVersionInfo.xml",
analyticsLog: updateLog,
metricsSince: "2026-09-11"
},
publicCounter: definition.publicCounter,
reportOutputDirectory: path.join(stateDirectory, "reports", id),
...(definition.publicCounter
? { publicJsonPath: path.join(stateDirectory, "public", `${id}.json`) }
: {})
};
});
const registry = {
schemaVersion: 1,
stateDirectory,
counterStatePath: path.join(stateDirectory, "counter-state.json"),
lockFile: path.join(root, "run", "generator.lock"),
geoIpCountryDatabase,
privateMetrics: {
statePath: path.join(stateDirectory, "private-metrics-state.json"),
reportOutputDirectory: path.join(stateDirectory, "reports", "metrics"),
timeZone: "Europe/Berlin",
detailRetentionDays: 14
},
combined: {
reportOutputDirectory: path.join(stateDirectory, "reports", "combined")
},
sites
};
fs.mkdirSync(stateDirectory, { recursive: true });
fs.mkdirSync(registry.combined.reportOutputDirectory, { recursive: true });
fs.mkdirSync(registry.privateMetrics.reportOutputDirectory, { recursive: true });
for (const site of sites) {
fs.mkdirSync(site.reportOutputDirectory, { recursive: true });
if (site.publicCounter) {
fs.mkdirSync(path.dirname(site.publicJsonPath), { recursive: true });
}
}
fs.writeFileSync(registryPath, JSON.stringify(registry));
return {
root,
registry,
registryPath,
configTemplatePath,
cleanup: () => fs.rmSync(root, { recursive: true, force: true })
};
}
function fakeGoAccess(reports, calls, failureId) {
return invocation => {
calls.push(invocation);
if (invocation.id === failureId) {
throw new Error("simulated GoAccess failure");
}
if (invocation.metricKind) {
const sourceLines = fs.readFileSync(invocation.args[0], "utf8").trim().split(/\r?\n/);
invocation.inputLines = sourceLines;
const lines = invocation.metricKind === "websiteClassifier"
? sourceLines.filter(line => {
const fields = line.split("\t");
return fields[6] !== "404" && !line.includes("UnknownScanner/1.0");
})
: sourceLines;
const paths = {};
for (const line of lines) {
const requestPath = line.split("\t")[4];
paths[requestPath] = (paths[requestPath] || 0) + 1;
}
const report = {
general: { total_requests: lines.length, valid_requests: lines.length },
requests: {
data: Object.entries(paths).map(([requestPath, count]) => ({
data: requestPath,
hits: { count }
}))
},
geolocation: { data: [{ data: "Test Country", hits: { count: lines.length } }] }
};
fs.writeFileSync(invocation.outputJson, JSON.stringify(report));
return;
}
fs.writeFileSync(
invocation.outputJson,
JSON.stringify(reports[invocation.id])
);
fs.writeFileSync(
invocation.outputHtml,
`<!doctype html><html><body>${"report".repeat(30)}</body></html>`
);
fs.writeFileSync(path.join(invocation.dbPath, "persisted.db"), invocation.id);
};
}
test("uses GoAccess client classification before aggregating website paths and countries", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
const calls = [];
const reports = {
alpha: goAccessReport({ "2026-09-11": 2 }),
combined: goAccessReport({ "2026-09-11": 2 }, true)
};
fs.appendFileSync(testFixture.registry.sites[0].analyticsLog, analyticsLine({
requestPath: "/scanner/",
userAgent: "UnknownScanner/1.0"
}));
fs.appendFileSync(testFixture.registry.sites[0].analyticsLog, analyticsLine({
requestPath: "/missing/",
status: 404
}));
fs.appendFileSync(testFixture.registry.sites[0].analyticsLog, analyticsLine({
requestPath: "/cached/",
status: 304
}));
fs.appendFileSync(testFixture.registry.sites[0].analyticsLog, analyticsLine({
requestPath: "/page.html",
userAgent: "Mozilla/5.0 Chrome/140.0"
}));
try {
generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess"
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: fakeGoAccess(reports, calls),
now: () => new Date("2026-09-11T12:00:00Z"),
skipLock: true
});
const state = JSON.parse(fs.readFileSync(testFixture.registry.privateMetrics.statePath, "utf8"));
assert.equal(state.sites.alpha.website.daily["2026-09-11"].pageViews, 4);
assert.equal(state.sites.alpha.website.daily["2026-09-11"].paths["/scanner/"], undefined);
assert.equal(state.sites.alpha.website.daily["2026-09-11"].paths["/missing/"], undefined);
assert.equal(state.sites.alpha.website.daily["2026-09-11"].paths["/cached/"], 1);
assert.equal(state.sites.alpha.website.daily["2026-09-11"].paths["/page.html"], 1);
const classifierCall = calls.find(call => call.metricKind === "websiteClassifier");
const aggregationCall = calls.find(call => call.metricKind === "website");
assert.equal(classifierCall.inputLines.some(line => line.includes("UnknownScanner/1.0")), true);
assert.equal(classifierCall.inputLines.every(line => line.split("\t")[4].startsWith("/__request/")), true);
assert.equal(classifierCall.inputLines.some(line => line.split("\t")[4].endsWith(".html")), true);
assert.equal(aggregationCall.inputLines.some(line => line.includes("UnknownScanner/1.0")), false);
assert.equal(aggregationCall.inputLines.some(line => line.split("\t")[6] === "404"), false);
} finally {
testFixture.cleanup();
}
});
function run(testFixture, reports, calls = [], failureId) {
return generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess"
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: fakeGoAccess(reports, calls, failureId),
now: () => new Date("2026-09-11T07:00:00Z"),
skipLock: true
});
}
test("replaces repeated daily values and retains older public days", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
fs.mkdirSync(path.dirname(testFixture.registry.counterStatePath), { recursive: true });
fs.writeFileSync(testFixture.registry.counterStatePath, JSON.stringify({
schemaVersion: 1,
sites: {
alpha: {
hostname: "alpha.example.test",
since: "2026-01-01",
dailyVisits: { "2026-01-01": 7 }
}
}
}));
run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
});
run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 5 }),
combined: goAccessReport({ "2026-09-11": 5 }, true)
});
const state = JSON.parse(fs.readFileSync(
testFixture.registry.counterStatePath,
"utf8"
));
const published = JSON.parse(fs.readFileSync(
testFixture.registry.sites[0].publicJsonPath,
"utf8"
));
assert.deepEqual(state.sites.alpha.dailyVisits, {
"2026-01-01": 7,
"2026-09-11": 5
});
assert.deepEqual(published, {
schemaVersion: 1,
visits: 12,
since: "2026-01-01",
updatedAt: "2026-09-11T07:00:00Z"
});
} finally {
testFixture.cleanup();
}
});
test("keeps valid outputs unchanged when a GoAccess job fails", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
const reportDirectory = testFixture.registry.sites[0].reportOutputDirectory;
fs.mkdirSync(reportDirectory, { recursive: true });
fs.mkdirSync(path.dirname(testFixture.registry.counterStatePath), { recursive: true });
fs.mkdirSync(path.dirname(testFixture.registry.sites[0].publicJsonPath), { recursive: true });
fs.writeFileSync(path.join(reportDirectory, "report.json"), "old-json");
fs.writeFileSync(path.join(reportDirectory, "report.html"), "old-html");
fs.writeFileSync(testFixture.registry.counterStatePath, "old-state");
fs.writeFileSync(testFixture.registry.sites[0].publicJsonPath, "old-public");
assert.throws(() => run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
}, [], "combined"), /simulated GoAccess failure/);
assert.equal(fs.readFileSync(path.join(reportDirectory, "report.json"), "utf8"), "old-json");
assert.equal(fs.readFileSync(path.join(reportDirectory, "report.html"), "utf8"), "old-html");
assert.equal(fs.readFileSync(testFixture.registry.counterStatePath, "utf8"), "old-state");
assert.equal(fs.readFileSync(testFixture.registry.sites[0].publicJsonPath, "utf8"), "old-public");
} finally {
testFixture.cleanup();
}
});
test("keeps all published outputs unchanged when private metric generation fails", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
const site = testFixture.registry.sites[0];
const metricReport = path.join(testFixture.registry.privateMetrics.reportOutputDirectory, "report.html");
const normalRunner = fakeGoAccess({
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
}, []);
try {
fs.writeFileSync(path.join(site.reportOutputDirectory, "report.html"), "old-html");
fs.writeFileSync(site.publicJsonPath, "old-public");
fs.writeFileSync(metricReport, "old-metrics");
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess"
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: invocation => {
if (invocation.metricKind) throw new Error("simulated private metric failure");
normalRunner(invocation);
},
now: () => new Date("2026-09-11T07:00:00Z"),
skipLock: true
}), /simulated private metric failure/);
assert.equal(fs.readFileSync(path.join(site.reportOutputDirectory, "report.html"), "utf8"), "old-html");
assert.equal(fs.readFileSync(site.publicJsonPath, "utf8"), "old-public");
assert.equal(fs.readFileSync(metricReport, "utf8"), "old-metrics");
assert.equal(fs.existsSync(testFixture.registry.privateMetrics.statePath), false);
} finally {
testFixture.cleanup();
}
});
test("processes subdomains separately and together without publishing disabled counters", () => {
const testFixture = fixture([
{ id: "alpha", publicCounter: true, compressed: true },
{ id: "bravo", publicCounter: false, rotated: false, compressed: true }
]);
const calls = [];
try {
const result = run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 3 }),
bravo: goAccessReport({ "2026-09-11": 4 }),
combined: goAccessReport({ "2026-09-11": 6 }, true)
}, calls);
assert.equal(result.reports, 3);
const reportCalls = calls.filter(call => !call.metricKind);
assert.deepEqual(reportCalls.map(call => call.id), ["alpha", "bravo", "combined"]);
const alphaLogs = [
`${testFixture.registry.sites[0].analyticsLog}.1`,
testFixture.registry.sites[0].analyticsLog
];
const bravoLogs = [testFixture.registry.sites[1].analyticsLog];
assert.deepEqual(reportCalls[0].args.slice(0, 2), alphaLogs);
assert.deepEqual(reportCalls[1].args.slice(0, 1), bravoLogs);
assert.deepEqual(
reportCalls[2].args.slice(0, 3),
[...alphaLogs, ...bravoLogs]
);
assert.equal(reportCalls[0].args.includes("--enable-panel=VIRTUAL_HOSTS"), false);
assert.equal(reportCalls[1].args.includes("--enable-panel=VIRTUAL_HOSTS"), false);
assert.equal(reportCalls[2].args.includes("--enable-panel=VIRTUAL_HOSTS"), true);
assert.equal(reportCalls.some(call => call.args.some(argument => argument.endsWith(".gz"))), false);
assert.equal(fs.existsSync(path.join(
testFixture.registry.stateDirectory,
"public",
"bravo.json"
)), false);
assert.equal(fs.existsSync(path.join(
testFixture.registry.combined.reportOutputDirectory,
"report.html"
)), true);
assert.match(fs.readFileSync(path.join(
testFixture.registry.combined.reportOutputDirectory,
"report.html"
), "utf8"), /href="\/metrics\/"/);
const privateState = JSON.parse(fs.readFileSync(
testFixture.registry.privateMetrics.statePath,
"utf8"
));
assert.equal(privateState.sites.alpha.website.daily["2026-09-11"].pageViews, 2);
assert.equal(privateState.sites.alpha.updateInfo.daily["2026-09-11"].requests, 2);
} finally {
testFixture.cleanup();
}
});
test("validates GoAccess 1.8.1 panel names strictly", () => {
const siteReport = goAccessReport({ "2026-09-11": 3 });
const combinedReport = goAccessReport({ "2026-09-11": 3 }, true);
assert.doesNotThrow(() => validateReport(siteReport, false));
assert.doesNotThrow(() => validateReport(combinedReport, true));
const missingGeolocation = goAccessReport({ "2026-09-11": 3 });
delete missingGeolocation.geolocation;
missingGeolocation.geo_location = { data: [] };
assert.throws(
() => validateReport(missingGeolocation, false),
/GoAccess JSON report has no geolocation panel/
);
const missingVhosts = goAccessReport({ "2026-09-11": 3 }, true);
delete missingVhosts.vhosts;
missingVhosts.virtual_hosts = { data: [] };
assert.throws(
() => validateReport(missingVhosts, true),
/GoAccess JSON report has no vhosts panel/
);
});
test("dry-run validates generated data without changing production paths", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
const blockedLockParent = path.join(testFixture.root, "blocked-lock-parent");
fs.writeFileSync(blockedLockParent, "not a directory");
testFixture.registry.lockFile = path.join(blockedLockParent, "generator.lock");
fs.writeFileSync(testFixture.registryPath, JSON.stringify(testFixture.registry));
const result = generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess",
dryRun: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: fakeGoAccess({
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
}, []),
now: () => new Date("2026-09-11T07:00:00Z")
});
assert.equal(result.dryRun, true);
assert.equal(fs.statSync(blockedLockParent).isFile(), true);
assert.equal(fs.readdirSync(
testFixture.registry.sites[0].reportOutputDirectory
).length, 0);
assert.equal(fs.existsSync(testFixture.registry.sites[0].publicJsonPath), false);
assert.equal(fs.existsSync(testFixture.registry.counterStatePath), false);
assert.equal(fs.existsSync(testFixture.registry.privateMetrics.statePath), false);
} finally {
testFixture.cleanup();
}
});
test("configuration check accepts and explains a GoAccess build without Zlib", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true, rotated: false }]);
try {
const versionOutput = [
"GoAccess - 1.8.1.",
"Build configure arguments:",
" --enable-geoip=mmdb",
" --with-openssl"
].join("\n");
const capabilities = parseGoAccessVersion(versionOutput);
const result = generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess",
check: true
}, {
checkGoAccess: () => capabilities,
runGoAccess: () => {
throw new Error("configuration check must not generate reports");
}
});
const message = formatGoAccessCheck(result.goAccess);
assert.deepEqual(capabilities, GOACCESS_WITHOUT_ZLIB);
assert.equal(result.checked, true);
assert.match(message, /Zlib not detected/);
assert.match(message, /no Zlib support/);
assert.match(message, /valid for regular mode/);
assert.match(message, /optional uncompressed \.1 rotation/);
assert.match(message, /older \.gz logs are not imported/);
} finally {
testFixture.cleanup();
}
});
test("detects Zlib support when it is present", () => {
const capabilities = parseGoAccessVersion([
"GoAccess - 1.8.1.",
"Build configure arguments:",
" --enable-geoip=mmdb --with-openssl --with-zlib"
].join("\n"));
assert.equal(capabilities.zlib, true);
assert.match(formatGoAccessCheck(capabilities), /Zlib enabled/);
assert.doesNotMatch(formatGoAccessCheck(capabilities), /older \.gz logs/);
});
test("configuration check rejects GoAccess without GeoIP2 MMDB support", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => ({
version: "1.8.1",
geoIpMmdb: false,
openSsl: true,
zlib: false
})
}), error => error.exitCode === 2
&& /must be built with GeoIP2\/MMDB support/.test(error.message));
} finally {
testFixture.cleanup();
}
});
test("configuration check verifies analytics-log readability", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true, rotated: false }]);
const originalAccessSync = fs.accessSync;
try {
fs.accessSync = (filePath, mode) => {
if (filePath === testFixture.registry.sites[0].analyticsLog
&& mode === fs.constants.R_OK) {
const error = new Error("permission denied");
error.code = "EACCES";
throw error;
}
return originalAccessSync(filePath, mode);
};
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
}), /analytics log is not readable by the current user/);
} finally {
fs.accessSync = originalAccessSync;
testFixture.cleanup();
}
});
test("configuration check rejects a missing update-information log", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
fs.rmSync(testFixture.registry.sites[0].updateInfo.analyticsLog);
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
}), /analytics log is not readable/);
} finally {
testFixture.cleanup();
}
});
test("configuration check reports missing output directories", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
fs.rmSync(testFixture.registry.sites[0].reportOutputDirectory, {
recursive: true,
force: true
});
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
}), /report output directory for alpha does not exist/);
} finally {
testFixture.cleanup();
}
});
test("rejects a compressed log as the regular analytics input", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
testFixture.registry.sites[0].analyticsLog += ".2.gz";
fs.writeFileSync(testFixture.registryPath, JSON.stringify(testFixture.registry));
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
}), /must identify the current uncompressed log/);
} finally {
testFixture.cleanup();
}
});
test("Nginx filters exclude non-page traffic before analytics logging", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/analytics-filters.conf.example"
), "utf8");
for (const excluded of [
"/visitor-count.json",
"/kst4ContestVersionInfo.xml",
"/sitemap.xml",
"/robots.txt",
"manual/assets",
"healthz"
]) {
assert.match(source, new RegExp(excluded.replaceAll("/", "\\/"), "i"));
}
assert.doesNotMatch(source, /^\s*=\//m);
assert.match(source, /^\s*\/visitor-count\.json\s+0;/m);
assert.match(source, /^\s*\/health\s+0;/m);
assert.match(source, /\|map\|/);
assert.match(source, /\$uri/);
assert.match(source, /known_bot/);
assert.match(source, /map "\$request_method:\$uri:\$status" \$hamradioonline_update_information_loggable/);
assert.match(source, /"GET:\/kst4ContestVersionInfo\.xml:200" 1;/);
});
test("historical import is repeatable and overlaps live aggregation without addition", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
const importPath = path.join(testFixture.root, "access.log.2.gz");
const historical = [
'192.0.2.10 - - [10/Sep/2026:12:00:00 +0200] "GET /privacy/?x=1 HTTP/1.1" 200 42 "-" "Firefox/130"',
'192.0.2.11 - - [10/Sep/2026:12:01:00 +0200] "GET /kst4ContestVersionInfo.xml HTTP/1.1" 200 43 "-" "Java/21.0.1"'
].join("\n");
const reports = {
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
};
try {
testFixture.registry.sites[0].websiteMetricsSince = "2026-09-08";
testFixture.registry.sites[0].updateInfo.metricsSince = "2026-09-08";
fs.writeFileSync(testFixture.registryPath, JSON.stringify(testFixture.registry));
fs.writeFileSync(importPath, require("node:zlib").gzipSync(`${historical}\n`));
const options = {
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess",
importLogs: [importPath],
importFormat: "nginx-combined",
importSite: "alpha",
coverageFrom: "2026-09-08",
coverageThrough: "2026-09-10"
};
const dependencies = {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: fakeGoAccess(reports, []),
now: () => new Date("2026-09-11T07:00:00Z"),
skipLock: true
};
generateReports(options, dependencies);
generateReports(options, dependencies);
const state = JSON.parse(fs.readFileSync(testFixture.registry.privateMetrics.statePath, "utf8"));
assert.equal(state.sites.alpha.website.daily["2026-09-10"].pageViews, 1);
assert.equal(state.sites.alpha.updateInfo.daily["2026-09-10"].requests, 1);
assert.equal(state.sites.alpha.website.daily["2026-09-11"].pageViews, 2);
assert.equal(state.sites.alpha.updateInfo.daily["2026-09-11"].requests, 2);
assert.equal(state.sites.alpha.website.firstCoveredOn, "2026-09-08");
assert.equal(state.sites.alpha.updateInfo.firstCoveredOn, "2026-09-08");
assert.equal(fs.existsSync(path.join(
testFixture.registry.privateMetrics.reportOutputDirectory,
"alpha", "updates", "yearly", "report.html"
)), true);
} finally {
testFixture.cleanup();
}
});
test("first run after midnight refreshes the previous day without double counting", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
const reports = {
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
};
const baseOptions = {
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess"
};
try {
generateReports(baseOptions, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: fakeGoAccess(reports, []),
now: () => new Date("2026-09-11T20:00:00Z"),
skipLock: true
});
fs.appendFileSync(testFixture.registry.sites[0].analyticsLog, analyticsLine({
timestamp: "2026-09-11T23:59:00+02:00",
requestPath: "/news/"
}));
generateReports(baseOptions, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: fakeGoAccess(reports, []),
now: () => new Date("2026-09-12T00:30:00Z"),
skipLock: true
});
const state = JSON.parse(fs.readFileSync(testFixture.registry.privateMetrics.statePath, "utf8"));
assert.equal(state.sites.alpha.website.daily["2026-09-11"].pageViews, 3);
assert.equal(state.sites.alpha.website.daily["2026-09-12"].pageViews, 0);
} finally {
testFixture.cleanup();
}
});
test("historical import CLI requires explicit format, site and coverage", () => {
const options = parseArguments([
"--registry", "/etc/hamradioonline-analytics/sites.json",
"--config-template", "/etc/hamradioonline-analytics/goaccess.conf.template",
"--import-site", "kst4contest",
"--import-format", "nginx-combined",
"--coverage-from", "2026-09-01",
"--coverage-through", "2026-09-10",
"--import-log", "/protected/access.log.2.gz",
"--import-log", "/protected/access.log.1"
]);
assert.deepEqual(options.importLogs, [
"/protected/access.log.2.gz",
"/protected/access.log.1"
]);
assert.equal(options.importFormat, "nginx-combined");
assert.equal(options.importSite, "kst4contest");
assert.equal(options.coverageFrom, "2026-09-01");
assert.equal(options.coverageThrough, "2026-09-10");
});
test("server templates use the production GeoIP path and permission model", () => {
const registry = JSON.parse(fs.readFileSync(path.join(
__dirname,
"../ops/analytics/sites.example.json"
), "utf8"));
const service = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/systemd/hamradioonline-analytics.service.example"
), "utf8");
const generator = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/generate-reports.js"
), "utf8");
assert.equal(registry.geoIpCountryDatabase, "/var/lib/GeoIP/GeoLite2-Country.mmdb");
assert.match(service, /\/var\/lib\/GeoIP\/GeoLite2-Country\.mmdb/);
assert.match(service, /^StateDirectoryMode=0711$/m);
assert.match(generator, /fs\.fchmodSync\(handle, mode\)/);
assert.match(generator, /publicFile\.destination, publicFile\.content, 0o644/);
});
test("public counter template disables logging and sets cache and type headers", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/public-counter.conf.example"
), "utf8");
assert.match(source, /^\s*access_log off;$/m);
assert.match(source, /Cache-Control "public, max-age=3600" always/);
assert.match(source, /X-Content-Type-Options "nosniff" always/);
});
test("statistics vhost preserves ACME renewal and protects HTTPS reports", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/stats-vhost.conf.example"
), "utf8");
const bootstrap = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/stats-vhost-http-bootstrap.conf.example"
), "utf8");
const httpsOffset = source.search(/server\s*\{\s*listen 443 ssl http2;/);
assert.notEqual(httpsOffset, -1);
const httpSource = source.slice(0, httpsOffset);
const httpsSource = source.slice(httpsOffset);
assert.match(httpSource, /^\s*listen 80;$/m);
assert.match(httpSource, /^\s*access_log off;$/m);
assert.match(httpSource, /location \^~ \/\.well-known\/acme-challenge\//);
assert.match(httpSource, /root \/var\/lib\/letsencrypt;/);
assert.match(httpSource, /return 301 https:\/\/\$host\$request_uri;/);
assert.match(httpsSource, /^\s*listen 443 ssl http2;$/m);
assert.match(httpsSource, /^\s*auth_basic "Private project statistics";$/m);
assert.match(httpsSource, /^\s*access_log off;$/m);
assert.match(httpsSource, /location = \/[\s\S]*return 302 \/combined\/;/);
assert.match(httpsSource, /include \/etc\/letsencrypt\/options-ssl-nginx\.conf;/);
assert.match(httpsSource, /ssl_dhparam \/etc\/letsencrypt\/ssl-dhparams\.pem;/);
assert.doesNotMatch(source, /listen \[::\]/);
assert.match(bootstrap, /^\s*listen 80;$/m);
assert.doesNotMatch(bootstrap, /ssl_certificate/);
});
test("logrotate uses the Ubuntu Nginx rotation action", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/logrotate/hamradioonline-analytics.example"
), "utf8");
assert.match(source, /invoke-rc\.d nginx rotate >\/dev\/null 2>&1/);
assert.match(source, /^\s*delaycompress$/m);
assert.match(source, /^\s*rotate 14$/m);
assert.match(source, /^\s*create 0640 www-data hamradio-analytics$/m);
assert.match(source, /\*-update-information\.log/);
});
test("website package records the Node.js 18.19.1 baseline", () => {
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "../package.json")));
const packageLock = JSON.parse(fs.readFileSync(path.join(__dirname, "../package-lock.json")));
assert.equal(packageJson.engines.node, ">=18.19.1");
assert.equal(packageLock.packages[""].engines.node, ">=18.19.1");
});
+120
View File
@@ -0,0 +1,120 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const eleventyConfig = require("../.eleventy.js");
test("selects the newest news article without relying on a version", () => {
const older = { date: new Date("2026-08-22"), data: { title: "Older" } };
const newest = { date: new Date("2026-09-03"), data: { title: "Newest" } };
assert.deepEqual(
eleventyConfig.selectLatestNews([older, newest]),
[newest]
);
});
test("an empty news collection omits the latest-news item safely", () => {
assert.deepEqual(eleventyConfig.selectLatestNews([]), []);
assert.deepEqual(eleventyConfig.selectLatestNews(), []);
});
test("homepage keeps the statistical result tied to its limits", () => {
const source = fs.readFileSync(
path.join(__dirname, "../src/index.njk"),
"utf8"
);
assert.match(source, /almost 30% higher chat reply rate/);
assert.match(source, /chat replies, not completed QSOs/);
assert.match(source, /correlation within[\s\S]*not general proof of causation/);
assert.match(source, /July 2025 contest/);
assert.match(source, /144\/432 MHz and Microwave categories/);
});
test("background page links all public papers and no ODT source", () => {
const source = fs.readFileSync(
path.join(__dirname, "../src/background/index.njk"),
"utf8"
);
for (const name of [
"kst4contest-ghz-tagung-2025-de.pdf",
"kst4contest-ghz-tagung-2025-en.pdf",
"kst4contest-ghz-tagung-2026-de.pdf",
"kst4contest-ghz-tagung-2026-en.pdf"
]) {
assert.match(source, new RegExp(name.replaceAll(".", "\\.")));
}
assert.doesNotMatch(source, /\.odt\b/i);
});
test("homepage contains one selected function grid and a complete-overview link", () => {
const source = fs.readFileSync(
path.join(__dirname, "../src/index.njk"),
"utf8"
);
assert.equal((source.match(/<h2>The parts most often used during a contest<\/h2>/g) || []).length, 1);
assert.match(source, /href="\/features\/"[^>]*>Open the complete function overview/);
assert.doesNotMatch(source, /Observe|Evaluate|>Act<|One workflow, shared context/);
});
test("screenshot page uses the four approved originals as full-size links", () => {
const page = fs.readFileSync(
path.join(__dirname, "../src/screenshots/index.njk"),
"utf8"
);
const css = fs.readFileSync(
path.join(__dirname, "../src/assets/css/main.css"),
"utf8"
);
const screenshots = require("../src/_data/screenshots.js");
assert.equal(screenshots.length, 4);
assert.deepEqual(
screenshots.map(({ image }) => image),
[
"/assets/completeViewLight.png",
"/assets/completeViewDark.png",
"/assets/timeline.png",
"/assets/complete_incl_AS.png"
]
);
assert.ok(screenshots.every(({ alt, caption }) => alt && caption));
assert.equal(screenshots.find(({ id }) => id === "timeline").wide, true);
assert.doesNotMatch(JSON.stringify(screenshots), /Priority Candidates/);
assert.match(page, /href="{{ shot\.image }}"[\s\S]*<img[^>]*src="{{ shot\.image }}"/);
assert.match(page, /Version-like text inside chat messages is[\s\S]*not an announcement/);
assert.match(css, /\.screenshot-image\s*\{[\s\S]*?width:\s*100%;[\s\S]*?height:\s*auto;/);
assert.match(css, /\.screenshot-card--wide\s*\{\s*grid-column:\s*1\s*\/\s*-1;/);
});
test("footer and download page expose background and conference papers", () => {
const layout = fs.readFileSync(
path.join(__dirname, "../src/_layouts/base.njk"),
"utf8"
);
const download = fs.readFileSync(
path.join(__dirname, "../src/download/index.njk"),
"utf8"
);
assert.match(layout, /<p><a href="\/background\/">Background<\/a><\/p>/);
for (const name of [
"kst4contest-ghz-tagung-2025-de.pdf",
"kst4contest-ghz-tagung-2025-en.pdf",
"kst4contest-ghz-tagung-2026-de.pdf",
"kst4contest-ghz-tagung-2026-en.pdf"
]) {
assert.match(download, new RegExp(`/assets/papers/${name.replaceAll(".", "\\.")}`));
}
assert.match(download, /href="\/background\/#paper-2025"/);
assert.match(download, /href="\/background\/#paper-2026"/);
assert.match(download, /historical conference papers[\s\S]*not a complete description of the current feature set/);
assert.ok(
download.indexOf("Background from GHz-Tagung Dorsten") >
download.indexOf('<div class="channel-panel" data-channel="nightly">')
);
});
+283
View File
@@ -0,0 +1,283 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const zlib = require("node:zlib");
const {
aggregateGoAccessReport,
clientGroup,
isEligibleWebsiteRequest,
isUpdateRequest,
localParts,
mergeDay,
normalizePath,
parseAnalyticsLine,
parseCombinedLine,
parseLogFiles,
purgeDetails,
renderReports
} = require("../ops/analytics/private-metrics");
function record(overrides = {}) {
return {
method: "GET",
status: 200,
path: "/kst4ContestVersionInfo.xml",
userAgent: "Java/21.0.1",
...overrides
};
}
test("counts only exact successful GET requests for the update-information path", () => {
assert.equal(isUpdateRequest(record()), true);
assert.equal(isUpdateRequest(record({ method: "HEAD" })), false);
assert.equal(isUpdateRequest(record({ status: 304 })), false);
assert.equal(isUpdateRequest(record({ status: 404 })), false);
assert.equal(isUpdateRequest(record({ path: "/kst4ContestVersionInfo.xml/" })), false);
assert.equal(isUpdateRequest(record({ path: "/Kst4ContestVersionInfo.xml" })), false);
});
test("keeps website page and bot exclusions separate from update requests", () => {
assert.equal(isEligibleWebsiteRequest(record({ path: "/privacy/", userAgent: "Firefox/130" })), true);
assert.equal(isEligibleWebsiteRequest(record({ path: "/privacy/?source=test", userAgent: "Firefox/130" })), true);
assert.equal(isEligibleWebsiteRequest(record({ path: "/assets/site.css", userAgent: "Firefox/130" })), false);
assert.equal(isEligibleWebsiteRequest(record({ path: "/manual/assets/page.png", userAgent: "Firefox/130" })), false);
assert.equal(isEligibleWebsiteRequest(record({ path: "/privacy/", userAgent: "ExampleBot/1" })), false);
assert.equal(isEligibleWebsiteRequest(record()), false);
assert.equal(normalizePath("//docs/../privacy/?source=test"), "/privacy/");
});
test("parses analytics and regular Nginx combined records without query strings", () => {
const analytics = parseAnalyticsLine(
'kst4contest.hamradioonline.de\t192.0.2.1\t2026-09-14T23:30:00+02:00\tGET\t/privacy/?x=1\tHTTP/1.1\t200\t42\t"Firefox/130"'
);
assert.equal(analytics.date, "2026-09-14");
assert.equal(analytics.hour, "23");
assert.equal(analytics.path, "/privacy/");
const emptyPath = parseAnalyticsLine(
'kst4contest.hamradioonline.de\t192.0.2.1\t2026-09-14T23:30:00+02:00\tGET\t\tHTTP/1.1\t400\t166\t"-"'
);
assert.equal(emptyPath.path, null);
assert.equal(isEligibleWebsiteRequest(emptyPath), false);
const combined = parseCombinedLine(
'192.0.2.2 - - [14/Sep/2026:23:31:00 +0200] "GET /news/?x=1 HTTP/1.1" 200 43 "-" "Mozilla/5.0 Firefox/130"',
"kst4contest.hamradioonline.de"
);
assert.equal(combined.date, "2026-09-14");
assert.equal(combined.path, "/news/");
assert.match(combined.line, /\t\/news\/\t/);
assert.equal(parseCombinedLine("broken", "example.test"), null);
});
test("uses Europe/Berlin across both daylight-saving transitions", () => {
assert.deepEqual(localParts("2026-03-29T00:30:00Z", "Europe/Berlin"), {
date: "2026-03-29", hour: "01"
});
assert.deepEqual(localParts("2026-03-29T01:30:00Z", "Europe/Berlin"), {
date: "2026-03-29", hour: "03"
});
assert.deepEqual(localParts("2026-10-25T00:30:00Z", "Europe/Berlin"), {
date: "2026-10-25", hour: "02"
});
assert.deepEqual(localParts("2026-10-25T01:30:00Z", "Europe/Berlin"), {
date: "2026-10-25", hour: "02"
});
});
test("keeps absolute countries including Switzerland, United Kingdom and unknown", () => {
const website = aggregateGoAccessReport({
general: { total_requests: 2245, valid_requests: 5 },
geolocation: { data: [
{ data: "Europe", hits: { count: 3 }, items: [
{ data: "Germany", hits: { count: 1 } },
{ data: "Switzerland", hits: { count: 1 } },
{ data: "United Kingdom", hits: { count: 1 } }
] },
{ data: "Unknown", hits: { count: 1 } }
] },
requests: { data: [
{ data: "/", hits: { count: 2 } },
{ data: "/privacy/", hits: { count: 2 } },
{ data: "/news/", hits: { count: 1 } }
] }
}, "website");
assert.deepEqual(website.countries, {
Germany: 1,
Switzerland: 1,
"United Kingdom": 1,
Unknown: 2
});
assert.deepEqual(website.paths, { "/": 2, "/privacy/": 2, "/news/": 1 });
assert.throws(() => aggregateGoAccessReport({
general: { total_requests: 10, valid_requests: 2 },
geolocation: { data: [{ data: "Germany", hits: { count: 2 } }] },
requests: { data: [{ data: "/", hits: { count: 1 } }] }
}, "website"), /valid-request count 2 differs from request-panel total 1/);
const updates = aggregateGoAccessReport({
general: { total_requests: 9, valid_requests: 2 },
geolocation: { data: [{ data: "Germany", hits: { count: 2 } }] },
requests: { data: [{ data: "/kst4ContestVersionInfo.xml", hits: { count: 2 } }] }
}, "updateInfo");
assert.equal(updates.requests, 2);
assert.throws(() => aggregateGoAccessReport({
general: { total_requests: 2, valid_requests: 1 },
geolocation: { data: [{ data: "Germany", hits: { count: 2 } }] },
requests: { data: [{ data: "/", hits: { count: 1 } }] }
}, "website"), /country total exceeds/);
assert.throws(() => aggregateGoAccessReport({
general: { total_requests: 1, valid_requests: 1 },
geolocation: { data: [{ data: "Germany", hits: { count: 1 } }] },
requests: { data: [{ data: "/unexpected", hits: { count: 1 } }] }
}, "updateInfo"), /unexpected request path/);
});
test("deduplicates overlapping import files and reads gzip without GoAccess Zlib", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "kst4-private-import-"));
const line = '192.0.2.2 - - [14/Sep/2026:23:31:00 +0200] "GET /news/ HTTP/1.1" 200 43 "-" "Firefox/130"';
const plain = path.join(root, "access.log.1");
const compressed = path.join(root, "access.log.2.gz");
try {
fs.writeFileSync(plain, `${line}\n${line}\n`);
fs.writeFileSync(compressed, zlib.gzipSync(`${line}\n`));
const parsed = parseLogFiles(
[compressed, plain],
value => parseCombinedLine(value, "kst4contest.hamradioonline.de"),
{ deduplicateAcrossFiles: true }
);
assert.equal(parsed.length, 2);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test("rejects malformed and unreadable historical input", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "kst4-private-invalid-"));
const invalid = path.join(root, "access.log");
const invalidGzip = path.join(root, "access.log.gz");
try {
fs.writeFileSync(invalid, "not an access-log record\n");
fs.writeFileSync(invalidGzip, "not gzip");
assert.throws(() => parseLogFiles(
[invalid],
value => parseCombinedLine(value, "kst4contest.hamradioonline.de"),
{ label: "historical import log" }
), /invalid line/);
assert.throws(() => parseLogFiles(
[invalidGzip],
value => parseCombinedLine(value, "kst4contest.hamradioonline.de")
), /could not read compressed log/);
assert.throws(() => parseLogFiles(
[path.join(root, "missing.log")],
value => parseCombinedLine(value, "kst4contest.hamradioonline.de")
));
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test("merges repeated and overlapping daily aggregates without addition", () => {
const current = { pageViews: 2, countries: { Germany: 2 }, paths: { "/": 2 } };
assert.deepEqual(mergeDay(current, { ...current }, "pageViews", "test"), current);
assert.deepEqual(mergeDay({
pageViews: 2,
countries: { Germany: 1, Switzerland: 1 },
paths: { "/": 1, "/privacy/": 1 }
}, {
pageViews: 2,
countries: { Switzerland: 1, Germany: 1 },
paths: { "/privacy/": 1, "/": 1 }
}, "pageViews", "test").pageViews, 2);
assert.deepEqual(mergeDay(current, {
pageViews: 3,
countries: { Germany: 2, Switzerland: 1 },
paths: { "/": 2, "/privacy/": 1 }
}, "pageViews", "test"), {
pageViews: 3,
countries: { Germany: 2, Switzerland: 1 },
paths: { "/": 2, "/privacy/": 1 }
});
assert.throws(() => mergeDay(current, {
pageViews: 3,
countries: { Germany: 1, Switzerland: 2 },
paths: { "/": 1, "/privacy/": 2 }
}, "pageViews", "test"), /not monotonic/);
});
test("removes hourly and client detail after 14 days but preserves daily totals", () => {
const site = {
updateInfo: {
daily: {
"2026-08-31": { requests: 2, countries: { Germany: 2 }, hours: { "10": 2 }, clients: { Java: 2 } },
"2026-09-01": { requests: 3, countries: { Germany: 3 }, hours: { "11": 3 }, clients: { Java: 3 } },
"2026-09-14": { requests: 1, countries: { Unknown: 1 }, hours: { "12": 1 }, clients: { Other: 1 } }
}
}
};
purgeDetails(site, "2026-09-14", 14);
assert.equal(site.updateInfo.daily["2026-08-31"].requests, 2);
assert.equal(site.updateInfo.daily["2026-08-31"].hours, undefined);
assert.deepEqual(site.updateInfo.daily["2026-09-01"].hours, { "11": 3 });
assert.deepEqual(site.updateInfo.daily["2026-09-14"].clients, { Other: 1 });
});
test("renders annual sums and escapes all dynamic report labels", () => {
const root = path.join(os.tmpdir(), "metrics-report-output");
const state = { sites: { alpha: {
hostname: "alpha.example.test",
website: {
firstCoveredOn: "2025-12-31",
daily: {
"2025-12-31": { pageViews: 2, countries: { Germany: 2 }, paths: { "/": 2 } },
"2026-01-01": { pageViews: 3, countries: { Switzerland: 3 }, paths: { "/<script>": 3 } }
}
},
updateInfo: {
firstCoveredOn: "2025-12-31",
daily: {
"2026-01-01": {
requests: 1,
countries: { "<img src=x onerror=alert(1)>": 1 },
hours: { "00": 1 },
clients: { "<script>alert(1)</script>": 1 }
}
}
}
} } };
const registry = {
privateMetrics: { reportOutputDirectory: root },
sites: [{ id: "alpha", hostname: "alpha.example.test" }]
};
const files = renderReports(state, registry, new Date("2026-01-02T00:00:00Z"));
const html = files.map(file => file.content).join("\n");
const websiteAnnual = files.find(file => file.destination.endsWith(
path.join("website", "yearly", "report.html")
)).content;
assert.match(html, /2025/);
assert.match(html, /2026/);
assert.match(html, /Switzerland/);
assert.doesNotMatch(html, /<script>alert\(1\)<\/script>/);
assert.doesNotMatch(html, /<img src=x/);
assert.match(html, /&lt;script&gt;/);
assert.match(websiteAnnual, /<td>2025<\/td><td>2<\/td>/);
assert.match(websiteAnnual, /<td>2026<\/td><td>3<\/td>/);
});
test("uses conservative client groups and never presents Java as KST4Contest", () => {
assert.equal(clientGroup("Java/21.0.1"), "Java runtime (application unknown)");
assert.equal(clientGroup("KST4Contest/2.0"), "KST4Contest (explicit)");
assert.equal(clientGroup("<script>alert(1)</script>"), "Other or unrecognised");
});
test("privacy notice distinguishes page statistics from private update aggregation", () => {
const privacy = fs.readFileSync(path.join(__dirname, "../src/privacy/index.njk"), "utf8");
assert.match(privacy, /exact path\s*<code>\/kst4ContestVersionInfo\.xml<\/code>/);
assert.match(privacy, /do not increase its page views or the public visitor count/);
assert.match(privacy, /retained for no more than 14 days/);
assert.match(privacy, /does\s+not establish a program start, a single user/);
assert.match(privacy, /No visitor address is sent to an external location service/);
});
+119
View File
@@ -0,0 +1,119 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const {
formatVisitorCount,
loadVisitorCount,
validateVisitorCount
} = require("../src/assets/js/visitor-count");
const VALID_DATA = {
schemaVersion: 1,
visits: 1234,
since: "2026-09-11",
updatedAt: "2026-09-11T07:00:00Z"
};
function element() {
return { hidden: true, textContent: "" };
}
test("formats valid data for the English website", async () => {
const target = element();
let request;
const shown = await loadVisitorCount({
element: target,
fetchImpl: async (url, options) => {
request = { url, options };
return { ok: true, json: async () => VALID_DATA };
}
});
assert.equal(shown, true);
assert.equal(target.hidden, false);
assert.equal(target.textContent, "Visits since 11 September 2026: 1,234");
assert.equal(request.url, "/visitor-count.json");
assert.equal(request.options.credentials, "omit");
});
test("rejects unsupported schemas and invalid visit counts", () => {
for (const changes of [
{ schemaVersion: 2 },
{ visits: -1 },
{ visits: 1.5 },
{ visits: Number.MAX_SAFE_INTEGER + 1 }
]) {
assert.equal(validateVisitorCount({ ...VALID_DATA, ...changes }), null);
}
});
test("rejects invalid dates and timestamps without timezone shifts", () => {
for (const changes of [
{ since: "2026-02-30" },
{ since: "11-09-2026" },
{ updatedAt: "2026-09-11" },
{ updatedAt: "2026-02-30T07:00:00Z" },
{ updatedAt: "2026-09-11T25:00:00Z" },
{ updatedAt: "not-a-timestamp" }
]) {
assert.equal(formatVisitorCount({ ...VALID_DATA, ...changes }), null);
}
});
test("keeps the element hidden for HTTP and fetch failures", async () => {
for (const fetchImpl of [
async () => ({ ok: false }),
async () => { throw new Error("network failure"); }
]) {
const target = element();
assert.equal(await loadVisitorCount({ element: target, fetchImpl }), false);
assert.equal(target.hidden, true);
assert.equal(target.textContent, "");
}
});
test("aborts a slow request and keeps the element hidden", async () => {
const target = element();
let aborted = false;
const fetchImpl = (url, options) => new Promise((resolve, reject) => {
options.signal.addEventListener("abort", () => {
aborted = true;
reject(new Error("aborted"));
});
});
assert.equal(await loadVisitorCount({
element: target,
fetchImpl,
timeoutMs: 5
}), false);
assert.equal(aborted, true);
assert.equal(target.hidden, true);
});
test("uses textContent and loads the script only for the home page", () => {
const script = fs.readFileSync(path.join(
__dirname,
"../src/assets/js/visitor-count.js"
), "utf8");
const home = fs.readFileSync(path.join(__dirname, "../src/index.njk"), "utf8");
const layout = fs.readFileSync(path.join(
__dirname,
"../src/_layouts/base.njk"
), "utf8");
const sourceRoot = path.join(__dirname, "../src");
const otherPages = fs.readdirSync(sourceRoot, { recursive: true })
.filter(entry => entry.endsWith(".njk") && entry !== "index.njk");
assert.doesNotMatch(script, /innerHTML/);
assert.match(script, /textContent/);
assert.match(home, /^visitorCount: true$/m);
assert.match(layout, /\{% if visitorCount %\}/);
assert.equal(
otherPages.some(entry => fs.readFileSync(path.join(sourceRoot, entry), "utf8")
.includes("visitorCount: true")),
false
);
});