Compare commits

...
Author SHA1 Message Date
Rsclub2_2andClaude Opus 5.5 d311092206 List direct commits on main in the release notes
GitHub's generated release notes only cover merged pull requests, so
commits pushed straight to main were missing. The release job now
builds the notes itself: "What's Changed" holds "Commits on main"
(first-parent, without bot and squash-merge commits) and GitHub's PR
list as "Merged PRs".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 23:20:40 +02:00
Rsclub2_2andClaude Opus 5.5 51cc6f65f7 Use the macOS system menu bar in all windows
On macOS the main menus move into the system menu bar and are shared
with the secondary windows, so they stay available whichever window
has the focus. The connection state indicator becomes a read-only
LINK menu there instead of the status bar button. Other platforms
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 23:20:38 +02:00
Rsclub2_2andClaude Opus 5.5 b96926a6bb Show a drag-to-install layout in the macOS DMG
The DMG window now has a background with an arrow from the app to
the Applications folder, in the KST4Contest colours. Finder applies
the layout through AppleScript; if that fails, e.g. on a headless
runner, the script warns and builds the plain DMG as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 23:19:55 +02:00
Rsclub2_2andClaude Opus 5.5 1dfd8bf46b Use one base font size and a per-profile startup design
JavaFX picks a 13px default font on macOS and Linux but 12px on
Windows, so labels in fixed-size controls were cut off outside
Windows. Both stylesheets now set 12px on the root.

Each operator profile now chooses light or dark mode for startup on
the GUI settings tab. The Windows menu entries remain a quick switch
for the running session. All windows, including the update window
and the top candidates list, follow the design, and the main window
no longer starts light while the station map starts dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 23:19:54 +02:00
Rsclub2_2andClaude Opus 5 27c093b108 Add the in-app update concept for 1.50
Captures the agreed design so it can be handed over later: extended
update feed with per-artifact metadata, a build-time package-type
marker, a testable check service replacing the blocking call in the
ChatController constructor, a non-modal hint suppressed while connected
to ON4KST, verified downloads and platform-specific execution.

Concept only, nothing implemented yet. Line anchors were verified
against this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HT8R7zTMoQnmJj6dGbaqqf
2026-09-22 23:17:03 +02:00
Rsclub2_2 3295b3f339 Code Version 1.50 2026-09-22 23:17:03 +02:00
Rsclub2_2andClaude Opus 5 500227bd07 Fail cleanly when the registry path has no directory
SpotBugs flagged a null passed to Files.createTempFile: the null check guarded
only createDirectories, while the temporary file creation would still have
dereferenced it. The path is resolved absolute so this is practically
unreachable, but bailing out with a log line is cheaper than the latent NPE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-22 23:17:02 +02:00
Rsclub2_2andClaude Opus 5 eeb94ad41a Make the terrain profile cache multi operator safe
The cache stored a single owner identity in a meta table and dropped the whole
TerrainProfileCache table whenever the configured callsign or locator differed
from it. With several operator profiles that turns the cache into a permanent
miss: every switch between two operators with different locators would discard
every computed profile.

Entries are separated by owner identity through the primary key already, so the
wipe is replaced by an owner table that simply records which identities are in
use. A different owner now misses the cache instead of clearing everybody's.

The cache also moves out of the worked station database into its own global
terrainprofilecache.db. Terrain profiles are pure geometry derived from two
locators and a sample count; at a multi operator station both operators share
one location, so a per profile copy would only double the traffic against the
terrain service. No migration is needed, the new file refills itself, and the
old tables stay readable for older releases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-22 23:17:01 +02:00
Rsclub2_2andClaude Opus 5 5573c403f4 Document operator profiles
Adds a manual section in both languages covering where profile files live, the
choice between shared and own worked stations, managing profiles, the startup
selection and the --profile argument, switching while running, and the fact
that passwords stay in clear text so profiles are not an access boundary.

Records the architecture in PROJECT_CONTEXT: lazy registry, derived paths, why
an additional profile database is created empty, why the login callsign default
is empty, and the constraints of rebuilding the runtime for a switch.

Adds a v1.50 changelog entry in both languages including the upgrade notes: no
file is moved, and going back to an older release stays possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-22 23:17:00 +02:00
Rsclub2_2andClaude Opus 5 2b20b54bda Manage operator profiles from the settings window
Adds a "Profiles" tab, appended last so no established tab position shifts,
offering create, duplicate, rename, delete, a switch between shared and own
worked stations, and activation of another profile.

Duplicating copies the whole configuration except callsign and password. The
antenna, locator, layout and integration settings are exactly the work nobody
wants to enter twice, while the credentials belong to one operator only. The
root profile can neither be deleted nor moved off the common station database,
because its files are the installation itself.

The login callsign and its raw form now default to empty instead of a real
callsign. The XML reader treats an empty element as "not set" and falls back to
the field default, so without this change a profile created without credentials
would come up carrying the callsign compiled into the defaults - and an
operator could transmit under someone else's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-22 23:16:59 +02:00
Rsclub2_2andClaude Opus 5 26717b86c7 Switch the operator profile while the application is running
Splits the teardown out of stop() into a reusable, idempotent shutdownRuntime()
and adds File - Switch operator profile..., which tears the current runtime
down and builds a fresh one for the selected profile.

The new runtime is a new application instance rather than a second start() on
the existing one. Many controls are instance fields created once, so reusing
the instance would re-parent mounted nodes and register every listener twice.
A fresh instance is safe because the class keeps no mutable static state.

Closing every window during a switch would end the process under the JavaFX
default, so the application takes over the exit decision: implicit exit is
turned off, the main window gets an explicit close handler, and every exit path
runs through the launcher. That also fixes losing the window layout on exit
after a switch, because JavaFX only calls stop() on the instance it launched.

The two view timers are now cancelled null-safe; stop() used to dereference
them unguarded, which would fail if shutdown happened before they were created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-22 23:16:58 +02:00
Rsclub2_2andClaude Opus 5 43aad1a8f0 Release the background resources a discarded runtime owns
Several resources outlived a disconnect on purpose, which was harmless while a
process ran exactly one session for its whole life. They are now released when
the controller itself is closed:

- the ON4KST connection supervisor thread, which stopByUser did not touch
- the sked reminder scheduler, which had no shutdown at all
- the reachability executor, whose shutdown method existed but was never called
- the PSTRotator retry scheduler and its pending retry
- the map tile proxy, whose stop method existed but was never called, leaving a
  server socket and a twelve thread pool behind
- the station map bridge listeners and its coalescing animation, which would
  otherwise keep firing into a dead user interface

These are real leaks today; they only become visible when a second runtime is
built in the same process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-22 23:16:57 +02:00
Rsclub2_2andClaude Opus 5 af93d0c08c Select the operator profile at startup
Resolves the active operator profile before the chat controller is built and
passes its two file names on, so preferences, layout and worked data follow the
profile.

The resolution is deliberately quiet for existing installations. With no
registry or exactly one profile nothing is asked and nothing is written, so a
single operator start is unchanged. Only from two profiles on does a small
picker appear with the last used profile preselected, where Enter or a double
click starts immediately. A "--profile" argument, or the equivalent system
property, skips the picker; an unknown name warns and falls back to the normal
selection instead of refusing to start.

The startup decision itself lives in OperatorProfileBootstrap and contains no
user interface code, so it is covered by headless tests. The window title gains
the profile name only when a second profile exists.

Command line parsing happens in init() and is kept in a process wide holder,
because JavaFX only knows the parameters of the instance it launched itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-22 23:16:56 +02:00
Rsclub2_2andClaude Opus 5 63e33d8d3e Introduce the operator profile model and its registry
Adds the descriptor, the path derivation and the registry persistence for
operator profiles. Nothing calls them yet, so behaviour is unchanged.

The descriptor stores only a shared/own flag, never a path. All file names are
derived in OperatorProfilePaths, so a stored path can never drift apart from
the flag that produced it. A profile identifier is a stable, file system safe
slug assigned once, so renaming a profile never moves a directory.

The registry is created lazily. An installation that only has the historic flat
layout gets no registry file and no profiles directory; the root profile is
synthesised in memory instead. That keeps a single operator installation
byte for byte the one it was before, and it keeps a downgrade to an older
release a no-op. A missing, unreadable or malformed registry is logged and
treated like an installation without additional profiles, never as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-22 23:16:54 +02:00
Rsclub2_2andClaude Opus 5 19ad4d87a8 Allow preferences and chat controller to be bound to a file set
ChatPreferences gains a constructor taking a preferences file name relative to
the application directory, so "profiles/OP2/preferences.xml" is as valid as the
historic flat "preferences.xml". A missing file is still seeded from the
bundled template, which gives an additional operator the same clean defaults a
first installation gets.

ChatController gains a constructor that passes both relative file names and the
seed flag through to ChatPreferences and DBController. The existing
constructors delegate to the historic file names, so nothing changes yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-22 23:16:53 +02:00
Rsclub2_2andClaude Opus 5 933413f8ef Make DBController work on one database file per instance
The controller was a static singleton: an eagerly created static instance
opened the root database during class initialization, and both the connection
and the path were static fields. A second operator profile in the same process
was therefore impossible, and ChatController's own "new DBController()" never
opened anything - it silently adopted the eagerly opened root connection.

- drop the eager static instance in favour of a lazily created default instance
- turn connection and path into instance state
- add a constructor taking a database file name relative to the application
  directory plus a flag whether a missing file is seeded from the bundled
  template
- create additional profile databases empty instead of seeding them: the
  bundled template carries 3452 foreign callsigns and user_version 0, which
  would show a new operator foreign data and trigger the full callsign
  normalization rebuild. The schema is created by the existing table setup.
- remember the shutdown hook so closeDBConnection can deregister it; otherwise
  every profile switch would leave another hook holding a dead connection

All SQL statements keep referencing the plain field and are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
2026-09-22 23:16:49 +02:00
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
76 changed files with 7832 additions and 401 deletions

No files matched your search

+86 -1
View File
@@ -718,6 +718,90 @@ jobs:
steps: steps:
- name: Checkout release source - name: Checkout release source
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
with:
# Full history and tags, needed to list the commits since the previous release.
fetch-depth: 0
# GitHub's generated release notes list merged pull requests only. Commits
# pushed directly to main are added here as their own section:
# ## What's Changed
# ### Commits on main (first-parent commits, no merges, no bots)
# ### Merged PRs (GitHub's generated PR list)
# The body also feeds the in-app update feed (website/src/_data/versionInfo.js,
# cleanReleaseBody), which drops headings and keeps the "* ..." lines.
- name: Build release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Stable releases compare against the previous stable release, betas
# against the previous tag of any kind.
if [[ "$TAG" == beta-* ]]; then
PREVIOUS_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
else
PREVIOUS_TAG="$(git describe --tags --abbrev=0 --exclude 'beta-*' "${TAG}^" 2>/dev/null || true)"
fi
echo "Previous tag: ${PREVIOUS_TAG:-<none>}"
RANGE="$TAG"
[ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${TAG}"
# --first-parent keeps the commits made directly on main; commits inside
# pull requests are covered by the PR list. Squash-merged PRs end in
# "(#123)" and are listed by GitHub as well.
git log --first-parent --no-merges --format='%an%x09%s' "$RANGE" \
| awk -F '\t' '$1 != "github-actions[bot]" { print $2 }' \
| grep -Ev '\(#[0-9]+\)$' \
| sed 's/^/* /' > direct-commits.md || true
# Let GitHub generate the PR part for the same range.
if [ -n "$PREVIOUS_TAG" ]; then
gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f tag_name="$TAG" -f previous_tag_name="$PREVIOUS_TAG" --jq .body > generated-notes.md
else
gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f tag_name="$TAG" --jq .body > generated-notes.md
fi
python3 - <<'PY'
import re
commits = open("direct-commits.md", encoding="utf-8").read().strip()
generated = open("generated-notes.md", encoding="utf-8").read()
# GitHub's body: "## What's Changed" with the PR list, optionally
# "## New Contributors", and a closing "**Full Changelog**" line.
prs, rest = [], []
section = None
for line in generated.splitlines():
if re.match(r"^##\s+What's Changed", line):
section = "prs"
continue
if line.startswith("## ") or line.startswith("**Full Changelog**"):
section = "rest"
if section == "prs":
if line.strip():
prs.append(line)
elif line.strip() or rest:
rest.append(line)
parts = []
if commits or prs:
parts.append("## What's Changed")
if commits:
parts.append("### Commits on main\n" + commits)
if prs:
parts.append("### Merged PRs\n" + "\n".join(prs))
if rest:
parts.append("\n".join(rest).strip())
with open("release-notes.md", "w", encoding="utf-8") as out:
out.write("\n\n".join(parts) + "\n")
PY
cat release-notes.md
- name: Download Windows artifact - name: Download Windows artifact
uses: actions/download-artifact@v4.1.3 uses: actions/download-artifact@v4.1.3
@@ -778,7 +862,8 @@ jobs:
allowUpdates: false allowUpdates: false
replacesArtifacts: false replacesArtifacts: false
makeLatest: ${{ !startsWith(github.ref_name, 'beta-') }} makeLatest: ${{ !startsWith(github.ref_name, 'beta-') }}
generateReleaseNotes: true bodyFile: release-notes.md
generateReleaseNotes: false
artifacts: >- artifacts: >-
release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip, release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip,
release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage, release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage,
+306
View File
@@ -0,0 +1,306 @@
# Konzept: In-App-Update für KST4Contest
> Status: Konzept, noch nicht umgesetzt. Zielrelease **1.50**. Alle Zeilenanker beziehen sich auf `nextMajorRelease/version1_50` (Version 1.50.0) und wurden gegen den Branch verifiziert.
## Ziel
Stable-Updates werden nach Zustimmung des Nutzers heruntergeladen. KST4Contest beendet sich danach kontrolliert und startet den plattformspezifischen Aktualisierungsvorgang. Updates dürfen den Programmstart nicht blockieren und während eines Contests nicht erzwungen werden.
## Ausgangslage
Der vorhandene Mechanismus erfüllt das nicht:
- `UpdateChecker.downloadLatestVersionInfoXML()` (`src/main/java/kst4contest/controller/UpdateChecker.java:54-71`) benutzt `new URL(...).openStream()` + `Files.copy(..., REPLACE_EXISTING)` **direkt auf die Zieldatei** — ohne Timeouts, ohne HTTP-Statusprüfung, ohne temporäre Datei. Ein Fehlschlag beschädigt zusätzlich die lokale Feed-Kopie.
- Der Aufruf steht **synchron im Konstruktor** von `ChatController` (`ChatController.java:2937-2941`); `ChatController` wird in `Kst4ContestApplication.start(Stage)` erzeugt (`Kst4ContestApplication.java:6971`, `start` ab `:6914`). Der Netzwerkaufruf läuft damit unbegrenzt **auf dem JavaFX-Application-Thread während des Starts** — genau der Punkt „darf den Programmstart nicht blockieren".
- `parseUpdateXMLFile()` (`UpdateChecker.java:73-236`) liest Kindelemente **positionsabhängig** über einen Zähler in `String[7]` bzw. `String[3]` (`:144-145`, `:182-183`). Ein achtes Kindelement in `<changeLog>` erzeugt eine `ArrayIndexOutOfBoundsException` und killt den gesamten Check.
- Die Anzeige ist ein eigenes `Stage` mit `setAlwaysOnTop(true)` (`Kst4ContestApplication.java:9162`), inline in `start()` aufgebaut (`:9152-9294`) und beim Start automatisch geöffnet (`:9280`). Es gibt **keinen Menüpunkt** zum erneuten Prüfen. Ein Catch-All bei `:9289-9292` schaltet die Funktion stillschweigend ab, sobald der Feed fehlt oder nicht passt.
- Toter Code: `main()` (`:27-34`) und ein Testdatenblock (`:208-234`).
## Bestätigte Festlegungen
| Punkt | Entscheidung |
|---|---|
| Contest-Schutz | Hinweis wird unterdrückt, solange eine ON4KST-Verbindung besteht |
| Update-Starter | Kein eigenes Starter-Programm; Installer/Paketverwaltung wird direkt gestartet, danach beendet sich KST4Contest |
| Flatpak | Nur Hinweis mit Update-Befehl, kein Download, kein Artefakt |
| AppImage | Kontrollierter Dateiaustausch, kein `AppImageUpdate` |
| Kanal | Ausschließlich Stable |
| Feed-Auslieferung | Bleibt wie bisher: der Webserver zieht das Repo alle 5 Minuten per Git und baut die Website selbst |
| Zuschnitt | Drei Stufen |
| Signatur | SignPath Foundation, als **optionaler additiver** CI-Schritt; unsigniert bleibt lauffähig |
| Windows-ZIP | Bleibt als portable Alternative |
## Was unverändert bleibt
1. **Alte Clients (≤ 1.44) am erweiterten Feed.** `UpdateChecker` sucht per `getElementsByTagName` nach `latestVersion`, `versionNumber`, `semanticVersion`, `adminMessage`, `majorChanges`, `latestVersionPathOnWebserver`, `changeLog`, `bug`. Das ist eine **Descendant-Suche** — deshalb kommt der neue Block als *Geschwister* neben `<latestVersion>` und benutzt ausschließlich neue Elementnamen.
2. **`<changeLog>` behält exakt seine 7 Kinder** (`changedVersionNumber, date, description, added, changed, fixed, removed`) in unveränderter Reihenfolge, `<bug>` seine Struktur. `<needUpdateSinceLastVersion>`, `<roadmap>`, `<bugsReported>` bleiben unangetastet.
3. **`~/.praktiKST/`** — nur ein neues Unterverzeichnis `updates/`. `kst4ContestVersionInfo.xml` behält Name und Ort, damit ein Downgrade auf 1.44 funktioniert.
4. **Windows-ZIP als Portable** — die App verändert ihr eigenes Verzeichnis nie und führt für diesen Typ keinen Installer aus.
5. **Kein Eingriff** in Flatpak-, AUR-, DEB-, RPM- oder Arch-Installationen; **kein `sudo`/`pkexec`** aus der Anwendung heraus.
6. **Offlinebetrieb** — ein fehlgeschlagener Check ist stumm (nur `java.util.logging`), der Programmstart ist davon vollständig entkoppelt.
7. **Legacy-Vergleich** — `APPLICATION_CURRENTVERSIONNUMBER` und `<versionNumber>` bleiben als Fallback; `VersionUtils.compareStableVersions` wird wiederverwendet und nicht verändert.
8. **Bei unbekannter Installationsart** wird ausschließlich die Downloadseite geöffnet.
---
## Stufe 1 — Feed, Paketkennzeichnung, Prüfdienst, Hinweis-UI
Kein Download, kein Installationsstart.
### 1a. Installationsart zur Buildzeit kennzeichnen
Es existiert heute **keinerlei** Build-Metadatum: kein `--java-options`, keine `.properties` unter `src`, kein Resource-Filtering in `pom.xml`, keine Manifest-Einträge.
**Gewählter Mechanismus:** jpackage-Flag `--java-options -Dkst4contest.packageType=<typ>`.
| Alternative | Warum nicht |
|---|---|
| Gefilterte `.properties`-Resource | **Alle Pakettypen entstehen aus demselben Maven-Artefakt**; die Unterscheidung fällt erst im jpackage-Aufruf. Lokale Builds und Tests bekämen einen falschen Marker. |
| Manifest-Eintrag | Gleiches JAR-Problem, mehr Bewegungsteile. |
| Verzeichnis-/Prozessheuristik | Laut Zielbild ausdrücklich ausgeschlossen; bei ZIP-Portable ohnehin frei verschiebbar. |
| Reines Env-Sniffing | Erkennt Flatpak und AppImage, aber nicht deb/rpm/arch/zip/msi. |
Vorteile: keine Auswirkung auf `mvn`/Tests/lokale Entwicklung (Property fehlt → `UNKNOWN` → sicherer Fallback); der Wert landet in `lib/app/<Name>.cfg` des app-image und wird dadurch vom AUR-`-bin`-Repack (`packaging/aur/kst4contest-bin/PKGBUILD` macht nur `cp -a usr`) und vom Flatpak-Build (bindet das app-image als `type: dir`-Source ein) **automatisch mitkopiert**.
Werte: `windows-zip`, `windows-msi`, `macos-dmg`, `linux-appimage`, `flatpak`, `linux-deb`, `linux-rpm`, `linux-arch`.
**15 Aufrufstellen:**
| Datei | Zeilen | Wert |
|---|---|---|
| `.github/workflows/tagged-release.yml` | 53 / 104 / 184 / 238 / 292 / 403 | `windows-zip` / `linux-appimage` / `linux-deb` / `linux-rpm` / `linux-arch` / `flatpak` |
| `.github/workflows/nightly-artifacts.yml` | 69 / 127 / 214 / 275 / 338 / 458 | dieselbe Reihenfolge |
| `packaging/macos/build-signed-dmg.sh` | 72 | `macos-dmg` |
| `packaging/aur/kst4contest/PKGBUILD` | 44 | `linux-arch` |
| `packaging/aur/kst4contest-git/PKGBUILD` | 37 | `linux-arch` |
`packaging/aur/kst4contest-bin/PKGBUILD` erbt korrekt — **keine Änderung**.
PowerShell-Syntax (tagged-release:53, nightly:69) braucht einfache Anführungszeichen, sonst frisst PowerShell das `-D`:
`--java-options '-Dkst4contest.packageType=windows-zip'`
**Auflösung zur Laufzeit** (`RuntimePackageTypeResolver`), bewusst konservativ:
1. `System.getProperty("kst4contest.packageType")`; unbekannter Wert → weiter.
2. `/.flatpak-info` vorhanden **oder** `FLATPAK_ID` gesetzt → `FLATPAK`. Diese Prüfung **überschreibt** die Property — eine Fehlklassifikation richtet ausgerechnet im Flatpak den größten Schaden an (Download in eine Sandbox, die den Host nie aktualisieren kann).
3. `APPIMAGE` gesetzt → `LINUX_APPIMAGE`.
4. sonst `UNKNOWN`.
### 1b. Feed erweitern
Der Feed wird **von der Website** erzeugt (`website/src/_data/versionInfo.js`, ausgeliefert über `website/src/version-info.njk`), gespeist aus der GitHub-Releases-API — nicht aus den Build-Jobs. Der Webserver zieht das Repository alle 5 Minuten per Git und baut selbst; eine geänderte `versionInfo.js` wird dadurch automatisch wirksam, **ohne** Upload-Schritt in den Workflows.
**Platzierung: neues Top-Level-Element als Geschwister von `<latestVersion>`.** Ein Kindelement *innerhalb* `<latestVersion>` wäre riskant, weil der alte Parser dort per Descendant-Suche liest; als Geschwister ist jede Kollision strukturell ausgeschlossen.
```xml
<updatePackages formatVersion="1" semanticVersion="1.45.0">
<updatePackage>
<packageType>linux-deb</packageType>
<operatingSystem>linux</operatingSystem>
<architecture>x86_64</architecture>
<fileName>KST4Contest-v1.45.0-debian-amd64.deb</fileName>
<downloadUrl>https://github.com/praktimarc/kst4contest/releases/download/v1.45.0/…</downloadUrl>
<fileSizeBytes>142335488</fileSizeBytes>
<sha256>9f2c…</sha256> <!-- 64 hex, klein -->
<signatureState>unsigned</signatureState> <!-- unsigned | authenticode | apple-notarized | gpg-repo -->
<releaseNotesUrl>…/releases/tag/v1.45.0</releaseNotesUrl>
<installHint>package-manager</installHint> <!-- run | package-manager | portable | hint-only -->
</updatePackage>
<updatePackage>
<packageType>flatpak</packageType>
<operatingSystem>linux</operatingSystem>
<architecture>x86_64</architecture>
<signatureState>gpg-repo</signatureState>
<releaseNotesUrl>…/releases/tag/v1.45.0</releaseNotesUrl>
<installHint>hint-only</installHint>
<hintCommand>flatpak update de.x08.KST4Contest</hintCommand>
<!-- bewusst ohne downloadUrl/sha256 -->
</updatePackage>
</updatePackages>
```
**Datenherkunft in `versionInfo.js`:**
- `fileName`, `downloadUrl`, `fileSizeBytes` stehen **bereits in der vorhandenen `/releases`-Antwort** (`release.assets[]` mit `name`, `browser_download_url`, `size`) — kein zusätzlicher API-Aufruf. Aktuell werden die Assets weggeworfen.
- `packageType`/`operatingSystem`/`architecture` über einen deterministischen Klassifikator auf die Assetnamen, die die Workflows fest vergeben (`tagged-release.yml:783-791`): `-windows-x64.zip`, `-linux-x86_64.AppImage`, `-debian-amd64.deb`, `-fedora-x86_64.rpm`, `-archlinux-*.pkg.tar.zst`, `-macos-*.dmg`. `.flatpakref` erzeugt keinen Download, sondern den synthetischen Hint-Eintrag. Manuals und der Feed selbst werden übersprungen.
- **`sha256` muss der Release-Workflow erzeugen.** Im Job `release-tag` liegen alle Artefakte bereits unter `release-assets/*`, bevor `ncipollo/release-action` läuft. Dort nach den `download-artifact`-Schritten und **vor** „Create tagged release":
```yaml
- name: Compute release asset checksums
run: |
cd release-assets
find . -type f ! -name SHA256SUMS.txt -print0 \
| xargs -0 sha256sum | sed 's#\./##' > SHA256SUMS.txt
```
und `release-assets/SHA256SUMS.txt` in die `artifacts:`-Liste (`:783-791`) aufnehmen. Die bestehende Reihenfolge (Release zuerst, dann Website-Build, `:771-825`) bleibt korrekt — die Summen liegen zum Zeitpunkt des Website-Builds bereits am Release. `versionInfo.js` lädt die Datei **nur für das neueste Stable-Release** und baut daraus eine `filename → sha256`-Map; das ist ein Download-URL-Abruf und belastet das API-Ratelimit nicht.
- `signatureState` aus einer statischen Tabelle je packageType (`macos-dmg → apple-notarized`, `flatpak → gpg-repo`, Rest `unsigned`). In Stufe 3 wird `windows-msi` nur dann auf `authenticode` gesetzt, wenn ein signiertes Asset tatsächlich existiert — der Feed darf nicht lügen, wenn der SignPath-Schritt übersprungen wurde.
**Robustheit:** Weil der Server unabhängig vom Release-Workflow baut, kann er einen Feed erzeugen, bevor `SHA256SUMS.txt` am Release hängt. Fehlt `<sha256>`, gilt das Artefakt als **nicht automatisch ladbar** — der Client öffnet nur die Downloadseite. Ein Artefakt ohne Prüfsumme wird nie geladen und nie gestartet. Die bestehende `try/catch`-Klammer in `versionInfo.js` bricht den Website-Build bei einem unvollständigen Feed ab; die neue Sektion darf **nicht** so scharf sein — bei Problemen wird `<updatePackages>` weggelassen, der Rest des Feeds bleibt gültig.
**Mitzuziehen:**
- `website/scripts/validate-version-info.js` — bekannter `packageType`; `sha256` matcht `^[0-9a-f]{64}$`; `downloadUrl` beginnt mit `https://github.com/praktimarc/kst4contest/releases/download/`; `fileSizeBytes` numerisch > 0. **Zusätzlich neu und wichtig:** `<changeLog>` muss weiterhin exakt die 7 Kinder in der Altreihenfolge haben — das nagelt die Alt-Client-Kompatibilität fest.
- `website/test/version-info.test.js` — Klassifikator, SHA256SUMS-Parsing, „Release ohne Assets".
**Warnung, nicht Teil dieser Umsetzung:** `.github/workflows/test-publish-version-xml.yml` (ungetrackt, nur `workflow_dispatch`) erzeugt mit einem eigenen Python-Generator einen konkurrierenden Feed **ohne** `<semanticVersion>` und lädt ihn per WebDAV hoch. Würde er ausgelöst, fielen alle Clients auf den kaputten Double-Vergleich (`1.41.1` → `1.411`) zurück.
### 1c. Prüfdienst neu bauen
Neues Package `kst4contest.service.update` (konsistent zum vorhandenen `kst4contest/service/path/`).
| Datei | Verantwortung |
|---|---|
| `model/PackageType.java` | Enum + `feedId()`, `fromFeedId()` (null/unbekannt → `UNKNOWN`, wirft nie), `updateStrategy()` |
| `model/UpdatePackageInfo.java` | Transport-DTO, **explizite Klasse, kein Record** (AGENTS.md) |
| `model/UpdateCheckResult.java` | Ergebnis-DTO inkl. `resultSource` (`NETWORK`/`CACHE`/`NONE`) |
| `service/update/UpdateFeedParser.java` | **Reiner Parser**, kein IO, kein Netz |
| `service/update/UpdateFeedClient.java` | **Nur HTTP**, injizierbarer `HttpClient` + Timeouts |
| `service/update/UpdateCheckService.java` | Orchestrierung, injizierbare Abhängigkeiten + `Clock` |
| `service/update/RuntimePackageTypeResolver.java` | Installationsart, Test-Konstruktor für Property/Env/Pfad |
Muster: `OpenMeteoTerrainProfileProvider.java:76-88` (injizierbarer Client, konfigurierbare Timeouts), `TerrainPackageDownloader.java:107-145` (Temp-Datei → Statusprüfung → `Files.move`).
Verhalten:
- Aufruf **asynchron im Daemon-Thread, erst nach `primaryStage.show()`** — nie vorher. `ChatController.java:2937-2941` entfällt.
- Ergebnis über eine `ObjectProperty<UpdateCheckResult>` im `ChatController`, gesetzt via `Platform.runLater`. Muster: `lastUiReminderEvent` (`ChatController.java:4236-4243`). Der Worker fasst **keine** `ObservableList` an.
- Versionsvergleich über `VersionUtils.compareStableVersions`. **Achtung:** `parseVersion` wirft bei leerem oder nicht-numerischem Input (`IllegalArgumentException`/`NumberFormatException`) — der Dienst muss das fangen und auf den Double-Pfad zurückfallen. Heute ist das in der View (`:9266-9278`) nicht abgesichert.
- Ausschließlich Stable. Fehlende Verbindung erzeugt **keine** Meldung, nur einen Logeintrag über `java.util.logging` statt `System.out`. Höchstens eine Meldung je Sitzung. Bei Netzfehler Rückfall auf die zuvor gecachte Datei.
**Positionales Parsing entschärfen:** Primärpfad namensbasiert (`readChildText(element, "changedVersionNumber")` …), Ergebnis weiterhin als `String[7]` in derselben Reihenfolge, damit der TreeView-Aufbau im Update-Fenster unverändert bleibt. Kompatibilitätspfad für uralte Feeds behält den positionalen Walk, aber mit `if (counter >= entry.length) break;`. Präfixe (`"Date: "`, `"Desc: "`) exakt erhalten. Bei `<bug>` ist `aChangeLogEntry[2]` heute uninitialisiert → explizit `""`.
`UpdateChecker.java` wird durch den Dienst ersetzt.
### 1d. Hinweis-UI
Wiederverwendet wird das etablierte Muster `initSkedWarnIndicatorButton()` (`Kst4ContestApplication.java:5790-5807`) und `initBandUpgradeIndicatorButton()` (`:5965`): versteckt per Default, `managedProperty().bind(visibleProperty())`, eingehängt in `flwpne_StatusBar` (Aufbau `:7158`, `setTop` `:7161`, Indicator-Buttons `:7163`, `:7166`, `:7189`).
Abweichung: **nicht** `setMouseTransparent(true)` — der Button ist klickbar und öffnet ein `ContextMenu`.
- Text: `KST4Contest 1.x.y is available`
- Aktionen: Änderungen anzeigen · Update herunterladen (Stufe 1: deaktiviert bzw. „Release-Seite öffnen") · für diese Sitzung schließen
- **Contest-Regel:** `visible = updateAvailable && !dismissedForSession && Verbindungszustand != ONLINE`. `onConnectionStateChanged(...)` (`:12566`) ruft zusätzlich `refreshUpdateHintVisibility()` im vorhandenen `Platform.runLater`-Block — der Hinweis verschwindet beim Login in den Chat und kommt beim Trennen zurück.
- FX-Thread-Guard-Idiom aus `:5833` übernehmen.
- **Neuer Menüpunkt** im Info-Menü (Info-Menü, `helpMenu.getItems().addAll(...)` `:5774`): „Check for updates…" und „Show update details…". Damit existiert erstmals ein manueller Re-Check.
- Das bestehende `stage_updateStage` wird aus `start()` in eine eigene, **lazy** aufgerufene Methode herausgezogen; gespeicherte Fenstergröße (`getGUIstage_updateStage_SceneSizeHW`, `:9253-9260`) bleibt. Das Auto-`show()` (`:9280`) entfällt, `setAlwaysOnTop(true)` (`:9162`) wird entfernt — ein Always-on-Top-Fenster über einem laufenden Contest ist genau das, was das Ziel verbietet.
### 1e. Versionsdrift absichern
`pom.xml:9` und `ApplicationConstants.APPLICATION_CURRENT_VERSION` werden **von Hand gepflegt, ohne jede Prüfung**. Für einen Mechanismus, der genau diese Zeichenkette vergleicht, ist das die riskanteste Stelle. Analog zu `packaging/AddModules.java --verify-pom` (an `validate` gebunden, `pom.xml:219-240`) einen `--verify-version`-Wächter ergänzen. Kein Auto-Bump.
`docs/PROJECT_CONTEXT.md:198` beschreibt zusätzlich die Kodierung von `APPLICATION_CURRENTVERSIONNUMBER` (`1.43.1` → `1.431`) — beim Bump mitführen, solange das Legacy-Feld existiert. Auf 1.50 stehen die Werte in `ApplicationConstants.java:23` und `:30`.
### 1f. `--app-version` nachziehen
Außer im macOS-Skript (`build-signed-dmg.sh:75`) setzt **kein** jpackage-Aufruf `--app-version`. DEB und RPM tragen dadurch intern die Vorgabeversion `1.0`; ein `apt install ./KST4Contest-v1.45.0.deb` wäre für dpkg **kein Upgrade**. Vor Stufe 3 zwingend, sinnvollerweise überall — dieselbe `sed`-Ableitung wie `build-signed-dmg.sh:59-65`. Nebenwirkung: bestehende DEB/RPM-Installationen springen einmalig von `1.0` auf die echte Version.
---
## Stufe 2 — Gesicherter Download
Erst nach „Update herunterladen".
| Datei | Verantwortung |
|---|---|
| `service/update/UpdateDownloadPolicy.java` | Reine Validierung, kein IO |
| `service/update/UpdateDownloadService.java` | Download + Verifikation |
| `service/update/UpdateStorage.java` | `~/.praktiKST/updates/`, Aufräumen verwaister Dateien |
| `model/UpdateDownloadResult.java` | Ergebnis-DTO |
**Ablauf** (Muster `TerrainPackageDownloader.java:93-165`):
1. Download nach `<fileName>.download`;
2. Prüfung von Dateigröße **und** SHA-256 (`computeSha256`, `:175-187`, 8-KiB-Puffer + `HexFormat`);
3. `Files.move` erst nach erfolgreicher Prüfung;
4. bei Abbruch oder falscher Prüfsumme temporäre Datei löschen, verständliche Meldung;
5. **niemals** ein ungeprüftes Artefakt starten.
Empfehlung: `BodyHandlers.ofInputStream()` + `DigestInputStream` statt `ofFile` — ein Durchlauf statt zwei, und ein Fortschrittsbalken ist bei 150-MB-Dateien Pflicht. Short-circuit, wenn die Zieldatei bereits mit passendem Hash existiert.
**Sicherheitsschranke — nicht optional:** SHA-256 aus dem Feed beweist nur, dass die Datei zum Feed passt. Da der Feed über einen selbst gebauten Host ausgeliefert wird, ist eine **Host-Allowlist** (`github.com`, `objects.githubusercontent.com`) die eigentliche Schutzschicht: selbst ein manipulierter Feed kann keine beliebige URL unterschieben. `UpdateDownloadPolicy` prüft zusätzlich: Schema `https`; `sha256` matcht `^[0-9a-fA-F]{64}$`; `fileName` ohne `/`, `\`, `..`; `fileSizeBytes` > 0 und < 1 GiB; ausreichend Plattenplatz; `packageType` erlaubt Download.
Stufe 2 führt **nichts** aus. Nach Erfolg: „Jetzt beenden und aktualisieren" / „Später installieren". Flatpak erhält gar keinen Download-Pfad, sondern den Hinweistext aus `<hintCommand>`.
---
## Stufe 3 — Plattformausführung, Windows-Installer, Signatur
### 3a. Windows-Installer
`--type msi` mit **dauerhaft konstanter** `--win-upgrade-uuid`, dazu `--win-menu --win-shortcut --win-dir-chooser`. `choco install wixtoolset` steht bereits in beiden Workflows (`tagged-release.yml:33-35`, `nightly-artifacts.yml:49-51`) und wird bisher **nicht genutzt** — die Voraussetzung ist vorhanden. Der ZIP-Build bleibt bestehen.
Die GUID muss dokumentiert werden (z. B. `packaging/windows/README.md`) — ändert sie sich, sind MSI-Upgrades dauerhaft kaputt. Asset in die `artifacts:`-Liste und in den SHA256SUMS-Schritt aufnehmen.
### 3b. Ausführung je Installationsart
| Installation | Verhalten |
|---|---|
| `windows-msi` | `msiexec /i "<datei>" /qb` detached, danach beenden |
| `windows-zip` | Keine Ausführung. Installer laden, Ordner öffnen, Benutzerdaten bleiben. **Hinweis nötig**, dass der alte ZIP-Ordner bestehen bleibt und künftig der Installer-Eintrag zu starten ist — sonst startet der Nutzer weiter die alte `praktiKST.exe` und sieht nie wieder ein Update |
| `macos-dmg` | `open "<dmg>"`, danach beenden; der Nutzer zieht selbst. Ein laufendes `.app` kann sich ohne separates Updater-Binary nicht ersetzen |
| `linux-appimage` | Neues AppImage neben `$APPIMAGE` ablegen, `chmod +x`, Ordner öffnen, beenden. Kein In-Place-Overwrite einer laufenden Datei |
| `flatpak` | Nur Verweis auf `flatpak update de.x08.KST4Contest`; keine Datei im Paket anfassen |
| `linux-deb/rpm/arch` | `xdg-open "<datei>"` → GNOME Software / Discover / gdebi übernehmen inkl. Rechteabfrage. **Bewusst kein `pkexec`/`sudo`** aus der Anwendung heraus |
| AUR | Verweis auf den vorhandenen AUR-Helper |
| `UNKNOWN` | Ausschließlich Downloadseite öffnen |
Kein eigener Update-Starter. Der kontrollierte Ausstieg: Verbindung prüfen und ggf. bestätigen lassen → ON4KST sauber trennen → Layout/Preferences flushen → DB schließen → Installer starten → `Platform.exit()` + `System.exit(0)`. Kein `Runtime.halt`. Der vorhandene Weg über `Kst4ContestApplication.stop()` (`disconnect("CLOSEALL")` + `System.exit(0)`) bleibt die Basis.
### 3c. SignPath
Eigener Step im MSI-Job, gegated über das Vorhandensein des Secrets. Fehlt es (bis zur Foundation-Freigabe), läuft die Pipeline unverändert durch und liefert ein unsigniertes MSI. Signiertes Ergebnis unter eigenem Namen hochladen; `versionInfo.js` setzt `signatureState` nur, wenn dieses Asset existiert.
**Vorbedingung für die Abhängigkeitsprüfung:** `module-info.java:14-15` deklariert `requires org.junit.jupiter.api;` und `requires org.mockito;`, weil Testklassen unter `src/main/java/` liegen (u. a. `controller/On4KstProtocolTest.java`, `controller/On4KstSocketThreadTest.java`, `controller/ReadUDPByWintestThreadTest.java`, `test/MockKstServer.java`, `view/map/mapTest.java`). `pom.xml` deklariert `junit-jupiter-api` und `mockito-core` deshalb mit `<scope>compile</scope>`, und JUnit, Mockito, ByteBuddy und Objenesis landen im ausgelieferten Runtime-Image — im Flatpak in `lib/app/` nachgewiesen. Ausgerechnet ByteBuddy/Mockito (Bytecode-Manipulation, Agent) in einem signierten Produktionsbinary ist für so eine Prüfung ungünstig. Als **eigener, abgegrenzter Commit vor dem Antrag** bereinigen: Klassen nach `src/test/java/`, `requires` entfernen, Scopes zurück auf `test`.
---
## Tests
Alle neuen Tests unter `src/test/java/kst4contest/service/update/` bzw. `.../model/`, Fixtures unter `src/test/resources/update/`. **Nichts Neues unter `src/main/java`** — die dort liegenden Testklassen sind Altlast, keine Vorlage.
| Testklasse | Assertions |
|---|---|
| `UpdateFeedParserTest` | Legacy-Feed 1.44 ohne `<updatePackages>` parst korrekt; erweiterter Feed, Reihenfolge irrelevant; `<changeLog>` mit 8 Kindern → **keine** `ArrayIndexOutOfBoundsException`; kaputtes XML → leeres Ergebnis statt Exception; XXE-Payload wird nicht expandiert; `sha256` in Großbuchstaben wird normalisiert |
| `UpdateFeedClientTest` | 200 → Zieldatei ersetzt; 404/500 → Temp gelöscht und **vorhandene Zieldatei unverändert** (die konkrete Regression gegenüber `UpdateChecker:54-71`); Timeout → `success=false`, kein Throw; leerer Body verworfen |
| `UpdateCheckServiceTest` | 1.44.0/1.44.0 → kein Update; /1.45.0 → Update; /1.4.4 → kein Update; /1.44.1 → Update; kaputte `semanticVersion` → Double-Fallback statt Exception; Netz down + Cache → `CACHE`; ohne Cache → `NONE`; Paketauswahl je Typ; `UNKNOWN` → kein Paket; injizierte `Clock` für den Prüfrhythmus |
| `RuntimePackageTypeResolverTest` | Property gewinnt; unbekannter Wert → `UNKNOWN`; `/.flatpak-info` → `FLATPAK` **auch gegen** widersprechende Property; `APPIMAGE` → AppImage; alles `null` → kein NPE |
| `UpdateDownloadPolicyTest` | `http://` abgelehnt; fremder Host abgelehnt; fehlende/zu kurze/nicht-hex `sha256` abgelehnt; `fileName` mit `../` abgelehnt; Größe ≤ 0 abgelehnt; `FLATPAK`/`UNKNOWN` abgelehnt |
| `UpdateDownloadServiceTest` | Ohne Netz über `com.sun.net.httpserver.HttpServer` auf Port 0 (JDK-only, keine neue Dependency): Hash-Mismatch → Temp gelöscht, Ziel **nicht** angelegt; Match → Bytes identisch; Non-2xx → Temp gelöscht; bereits gültige Datei → kein HTTP-Call; Progress monoton bis `totalBytes`; Abbruch → Temp gelöscht |
| `UpdateLauncher*Test` (Stufe 3) | Injizierter `ProcessStarter` fängt die Kommandozeile: MSI → `[msiexec, /i, <abs>, /qb]`; deb/rpm/arch → `[xdg-open, <abs>]`; dmg → `[open, <abs>]`; **kein Kommando enthält `sudo` oder `pkexec`** (explizite Assertion); `FLATPAK`/`UNKNOWN`/`WINDOWS_ZIP` → `ProcessStarter` nie aufgerufen |
| `website/test/version-info.test.js` | Klassifikator für alle Namensmuster; SHA256SUMS-Parsing inkl. Doppelspace; Release ohne Assets → Feed bleibt valide; erzeugter Feed hat weiterhin exakt 7 `<changeLog>`-Kinder |
Versionsvergleich: bestehende `VersionUtils`-Abdeckung um nicht-numerische Eingaben ergänzen.
---
## Dokumentation
Nach jeder Stufe eine gezielte Einordnung, kein Voll-Audit (`AGENTS.md`):
- `github_docs/de-Installation.md` und `github_docs/en-Installation.md` — Windows-Installer, Verhältnis zum ZIP, Update-Weg je Paketformat, Flatpak-Hinweis. Beide Sprachversionen semantisch gleichziehen.
- Neuer Abschnitt zur Updatefunktion (Hinweis, Zustimmung, Contest-Regel, Verhalten je Installationsart) — DE und EN.
- `docs/PROJECT_CONTEXT.md` — Abschnitt „Website / Deployment Relationship" (`:192-200`) um Feed-Format, Paketkennzeichnung, Host-Allowlist und die Entscheidung gegen ein Updater-Binary ergänzen.
- `website/src/features/` prüfen, ob die Funktion dort dargestellt werden soll.
- Screenshots des Hauptfensters veralten durch den Hinweisbereich — als veraltet markieren, nicht erfinden.
## Verifikation
1. `./mvnw -B test` — **Surefire-Berichte einzeln lesen**, nicht am Exit-Code messen (`AGENTS.md`: Testfehler brechen den Build nicht ab). Hinweis: auf Host-JDK 26 schlagen 8 Mockito-Tests umgebungsbedingt fehl; mit Temurin 21 prüfen.
2. `cd website && npm test && npm run build && npm run validate:version-info` gegen einen erweiterten Beispiel-Feed.
3. **Abwärtskompatibilität:** erweiterten Feed gegen den **alten** `UpdateChecker` laufen lassen und bestätigen, dass `<latestVersion>` und `<changeLog>` unverändert gelesen werden.
4. **Startverhalten:** Anwendung mit nicht erreichbarer Feed-URL starten — die UI muss ohne Verzögerung und ohne Meldung hochkommen.
5. **Contest-Regel:** mit ON4KST verbunden erscheint kein Hinweis; nach dem Trennen erscheint er.
6. **Paketkennzeichnung:** je Artefakt einmal starten und die erkannte Installationsart prüfen. Der Flatpak lässt sich lokal testen — die Sandbox ist netzwerkseitig transparent (verifiziert).
7. **Stufe 2:** Download mit absichtlich falscher Prüfsumme — es darf keine Zieldatei entstehen und nichts gestartet werden.
## Offene Punkte
1. **Zweite Feed-Quelle?** Wenn der serverseitige Build hinterherhinkt, sehen Clients einen veralteten Feed. Soll der Client hilfsweise `https://github.com/praktimarc/kst4contest/releases/latest/download/kst4ContestVersionInfo.xml` verwenden?
2. **`test-publish-version-xml.yml`** bleibt vorerst unverändert, ist aber scharf (Feed ohne `<semanticVersion>`). Bei der späteren Umstellung auf GitHub Actions zusammenführen oder entfernen.
3. **`--app-version` überall setzen** ändert die Paketversion bestehender DEB/RPM-Installationen einmalig von `1.0` auf die echte. Akzeptabel?
4. **`UpdateChecker.java`** löschen oder als deprecated Fassade behalten? (`module-info.java` exportiert `kst4contest.controller`.)
5. **MSI vs. ZIP** — soll MSI der empfohlene Windows-Weg werden? Beide Installationen können parallel existieren; ein MSI-Upgrade findet eine ZIP-Installation nicht.
6. **Opt-out-Preference?** Soll `ChatPreferences` ein „Check for updates on start" bekommen? Der Abruf verrät IP und Version an den Feed-Host.
7. **Prüfrhythmus** — einmal pro Start, oder höchstens alle 24 h über einen Zeitstempel in `~/.praktiKST/`?
8. **Beta-Nutzer** — `1.46.0-beta` gegen Stable `1.45.0` ergibt „kein Update", weil `compareStableVersions` das Suffix abschneidet. Gewollt?
9. **Signaturzustand** darf nur automatisch aus dem Build stammen, sonst driftet er.
10. **Vertrauensmodell** — soll der Feed mittelfristig selbst signiert werden (Detached-Signatur + eingebauter Public Key)?
11. **ZIP-Migration** — der Hinweistext für den Wechsel ZIP → Installer muss formuliert und in beiden Sprachversionen dokumentiert werden.
+44 -16
View File
@@ -1,6 +1,6 @@
# KST4Contest Project Context # KST4Contest Project Context
Last reviewed: 2026-09-12 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. 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.
@@ -52,7 +52,7 @@ JavaFX ObservableList / UI state
## Configuration and Layout Persistence ## Configuration and Layout Persistence
- The current `preferences.xml` configuration version is 7. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns. - Configuration and layout live in the `preferences.xml` of the **active operator profile**; see "Operator Profiles and Per-Profile Persistence" below. The current `preferences.xml` configuration version is 7. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns.
- `GUIstationMapClusteringEnabled` is a layout preference below `guiOptions`. It defaults to `true`, is selectively autosaved and controls only screen-based clustering of nearby map markers. Missing or malformed values retain the enabled default for backward compatibility. - `GUIstationMapClusteringEnabled` is a layout preference below `guiOptions`. It defaults to `true`, is selectively autosaved and controls only screen-based clustering of nearby map markers. Missing or malformed values retain the enabled default for backward compatibility.
- Stored widths take precedence. Without a usable entry, a managed column is sized once when meaningful table data first becomes available. Message and similar free-text columns use a flexible initial width instead of following the longest value. - Stored widths take precedence. Without a usable entry, a managed column is sized once when meaningful table data first becomes available. Message and similar free-text columns use a flexible initial width instead of following the longest value.
- Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores. - Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores.
@@ -61,6 +61,27 @@ JavaFX ObservableList / UI state
- Full and selective writes are synchronized and replace `preferences.xml` atomically. Missing, unknown or malformed width entries do not prevent loading and fall back to initial sizing. - Full and selective writes are synchronized and replace `preferences.xml` atomically. Missing, unknown or malformed width entries do not prevent loading and fall back to initial sizing.
- Older configuration files require no migration. Older KST4Contest versions can ignore the additional elements; a complete rewrite by such a version may discard column widths without invalidating the remaining file. - Older configuration files require no migration. Older KST4Contest versions can ignore the additional elements; a complete rewrite by such a version may discard column widths without invalidating the remaining file.
## Operator Profiles and Per-Profile Persistence
- One operator profile owns one `preferences.xml` and one worked-station database. Everything else under `~/.praktiKST/` stays global: CSS, audio files, DEM and terrain packages, the error log and the version-info feed.
- The **root profile** is the historic flat installation: `preferences.xml` and `praktiKST.db` directly below the application directory. It is never moved, and it always uses the common station database, because that database is the installation's own.
- Additional profiles live under `profiles/<profileId>/`. `profileId` is a stable, file-system-safe slug assigned once; renaming a profile changes only its display name and never moves a directory.
- The registry `profiles.xml` is created lazily. An installation that has only the flat layout gets no registry and no `profiles/` directory; the root profile is synthesised in memory. Startup with no or exactly one profile therefore asks nothing and writes nothing, and a downgrade to an older release is a no-op.
- The registry stores `profileId`, `displayName`, `rootProfile` and `sharedWorkedDatabase`, never a path. Both file names are derived in `OperatorProfilePaths` alone, so a stored path cannot drift apart from the flag that produced it.
- `sharedWorkedDatabase` resolves to a path, not to a schema change: a sharing profile points at the flat `praktiKST.db`, an owning profile at its own file. There is no owner column, and no SQL statement in `DBController` knows about profiles.
- A profile database is created **empty**. The bundled `/praktiKST.db` resource carries several thousand foreign callsigns and `user_version = 0`; seeding an additional profile from it would show a new operator foreign data and trigger the full callsign-normalization rebuild. Only the root installation is seeded from the resource. `preferences.xml` of a new profile *is* seeded from `/praktiKSTpreferences.xml`, because its defaults are what a first installation gets.
- Worked-state semantics are unchanged and now apply per database file: normalized base callsign as key, worked state shared across suffix variants, three-day expiry, manual reset.
- `stn_loginCallSign` and `stn_loginCallSignRaw` default to empty. The preferences reader treats an empty element as "not set" and falls back to the field default, so a non-empty default would make a profile created without credentials come up carrying a compiled-in callsign.
- Passwords remain plaintext per profile. Profiles separate configuration; they are explicitly not an access-control boundary. This is documented in both manuals.
### Runtime profile switching
- A switch tears the current runtime down through `Kst4ContestApplication.shutdownRuntime()` and builds a **new** `Kst4ContestApplication` instance. Reusing the instance is not possible: many controls are inline-initialised instance fields, so a second `start()` would re-parent mounted nodes and register every listener twice. The approach is only sound because the class holds no mutable static state.
- `Platform.setImplicitExit(false)` is required, because closing every window during a switch would otherwise end the process. All exits therefore run through `ApplicationRuntimeLauncher.exitApplication()`, including the main window's close handler; JavaFX calls `stop()` only on the instance it launched itself.
- `shutdownRuntime()` is idempotent and must release everything that outlives a disconnect: the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler, the map tile proxy, the station map bridge listeners and its coalescing animation, both view timers and every owned stage. Several of these were real leaks before; they only became visible once a second runtime could exist.
- `ApplicationConstants.sessionRuntimeUniqueId` must not be regenerated during a switch, so UDP readers started earlier still recognise their own poison pill.
- The layout autosave is flushed and then cancelled before a switch, so a pending debounced write cannot land after the profile changed.
## External Interfaces ## External Interfaces
Treat current implementation/tests and authoritative upstream documentation as source of truth before modifying any interface. Treat current implementation/tests and authoritative upstream documentation as source of truth before modifying any interface.
@@ -114,6 +135,8 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
### Terrain data providers ### Terrain data providers
- The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data. - The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data.
- The terrain profile cache lives in its own global database `~/.praktiKST/terrainprofilecache.db`. It is deliberately not part of an operator profile: terrain profiles are pure geometry derived from two locators and a sample count, and at a multi operator station both operators share one location, so a per-profile copy would only double the traffic against the terrain service.
- Cached entries are separated by owner identity through the primary key (`owner_callsign_raw` + `owner_locator6`). Earlier versions stored a single owner identity in a meta table and dropped the whole cache whenever the configured callsign or locator changed; with several operator profiles that would discard every computed profile on each switch. The old `TerrainProfileCache*` tables inside `praktiKST.db` are left in place and are still readable by older releases; the new file starts empty and refills itself.
- `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain. - `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain.
### ON4KST session and authentication ### ON4KST session and authentication
@@ -126,11 +149,11 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
### ON4KST session liveness ### ON4KST session liveness
- After 90 seconds without inbound data, the application keeps the established empty CRLF heartbeat. - 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.
- 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. - 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.
- Any subsequent inbound server frame confirms the probe. `DXQ` is accepted as the expected internal response and is not published as chat content. - 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. - 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 ## User Workflow / UI Invariants
@@ -188,23 +211,28 @@ section records only the durable architecture and operational boundaries.
#### Architecture and data flow #### 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. - 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 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 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 before logging. GoAccess supplies the second crawler-classification layer, anonymises IP addresses at the configured level and performs Country lookup locally through GeoLite2-Country. - 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.
- The hourly systemd timer starts the Node.js generator. Each registered site is processed from its optional uncompressed `.1` rotation followed by the current log; older `.gz` files are not part of regular processing. Logrotate retains raw analytics logs for 14 days and requires `delaycompress` for this handover. - 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.
- GoAccess maintains one private report/database per registered site and one combined report/database. The generator then updates its separate daily counter state and publishes `visitor-count.json` for enabled sites. Private reports are served with Basic Auth from `stats.hamradioonline.de`; the public home page requests only its same-origin counter file. - 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 #### 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. - 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 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 dates and output targets. `stats.hamradioonline.de` is never registered as an analytics site. Existing counter state cannot be continued with a changed hostname or `activatedOn` without an explicit migration or reset decision. - 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 values from activation onward and is the durable business source for the lifetime public total. Reports and public JSON files are derived outputs. - 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. - 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.
- Generator inputs and staged GoAccess outputs are validated before publication. Files are replaced atomically, 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. - 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 #### Components, roles and external services
- `hamradio-analytics` is the locked, non-login service account which reads configuration, Country MMDB and analytics logs and writes service state. Nginx runs as `www-data`, writes the analytics log and can read only reports and the public counter, not private databases or counter state. `stats-reader` is a local Nginx Basic Auth login whose password file remains outside Git. - `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. - 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. - 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. - 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.
@@ -212,8 +240,8 @@ section records only the durable architecture and operational boundaries.
#### Open operational work #### Open operational work
- The server has no comprehensive automated backup plan yet. A future server-wide plan must include the non-regenerable counter state, GoAccess databases and protected operational configuration without extending the published 14-day raw-log retention. - 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.
- The first real rotation of the dedicated analytics log still requires an explicit operational check of `.1`, ownership/readability, subsequent generator success and absence of duplicate counting. This is not a current service blocker. - 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 ## Important Decisions and Workarounds
+3 -2
View File
@@ -482,8 +482,9 @@ Die serverbezogenen Funktionen sind nur bei vollständig aufgebauter ON4KST-Verb
- **Hide cluster / stranger QSOs** beziehungsweise **Show cluster / stranger QSOs** blendet das zusätzliche Cluster- und QSO-Monitorfenster aus oder wieder ein. - **Hide cluster / stranger QSOs** beziehungsweise **Show cluster / stranger QSOs** blendet das zusätzliche Cluster- und QSO-Monitorfenster aus oder wieder ein.
- **hide options** beziehungsweise **show options** blendet das Einstellungsfenster aus oder wieder ein. - **hide options** beziehungsweise **show options** blendet das Einstellungsfenster aus oder wieder ein.
- **Use dark mode design** aktiviert das dunkle Farbschema. - **Use dark mode design** aktiviert das dunkle Farbschema für die laufende Sitzung.
- **Use default mode design** aktiviert das normale helle Farbschema. - **Use default mode design** aktiviert das normale helle Farbschema für die laufende Sitzung.
Das Farbschema beim Start wird pro Profil im Einstellungsreiter **GUI** unter **Design** festgelegt.
- **Show / hide station map** öffnet beziehungsweise schließt das separate Fenster mit Stationskarte und Streckenanalyse. - **Show / hide station map** öffnet beziehungsweise schließt das separate Fenster mit Stationskarte und Streckenanalyse.
--- ---
+41
View File
@@ -8,6 +8,47 @@ Die veröffentlichten Stable-Versionen und ihre Programmpakete stehen unter [Git
--- ---
## v1.50 (in Entwicklung)
**Operator-Profile**
Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators und Layouts verwenden, ohne sich gegenseitig die Konfiguration zu überschreiben. Damit ist [Issue #57](https://github.com/praktimarc/kst4contest/issues/57) umgesetzt.
### Neu
- **Operator-Profile:** Jedes Profil hat seine eigene `preferences.xml` und damit eigene fachliche Einstellungen und einen eigenen Layoutstand. Verwaltet werden Profile im neuen Reiter **Profiles** des Einstellungsfensters: anlegen, duplizieren, umbenennen, löschen und aktivieren.
- **Gemeinsame oder eigene gearbeitete Stationen:** Pro Profil wird entschieden, ob es eine eigene Worked-Datenbank bekommt oder die gemeinsame Stationsdatenbank benutzt. Eine Multi-OP-Station mit einem einzigen Stationslog teilt den Worked-Status, zwei OMs mit verschiedenen Rufzeichen an einem Rechner trennen ihn.
- **Profilwahl beim Start:** Mit nur einem Profil fragt KST4Contest beim Start nichts und verhält sich unverändert. Ab zwei Profilen erscheint eine kleine Auswahl mit vorausgewähltem letzten Profil; Enter oder Doppelklick starten sofort. Der Aufrufparameter `--profile=<Name>` überspringt die Auswahl.
- **Profilwechsel im laufenden Betrieb:** **File → Switch operator profile...** trennt die Verbindung und baut die Oberfläche mit den Einstellungen des gewählten Profils neu auf, ohne Programmneustart.
### Geändert
- **Zwischenspeicher der Geländeprofile getrennt:** Berechnete Geländeprofile liegen jetzt in der eigenen, gemeinsam genutzten Datei `terrainprofilecache.db` und werden nach Besitzer getrennt gespeichert. Bisher wurde der gesamte Zwischenspeicher gelöscht, sobald sich Rufzeichen oder Locator änderten; bei einem Profilwechsel wäre damit jedes berechnete Profil verloren gegangen.
- **Rufzeichen-Vorgabe ist leer:** Fehlt in der `preferences.xml` ein Login-Rufzeichen, bleibt das Feld jetzt leer, statt auf ein im Programm hinterlegtes Rufzeichen zurückzufallen. Ein neu angelegtes Profil startet damit bewusst ohne Anmeldedaten.
- **Dark Mode pro Profil:** Im Reiter **GUI** legt jedes Operator-Profil unter **Design** fest, ob KST4Contest hell oder dunkel startet. Die Menüeinträge unter **Windows** schalten weiterhin schnell um, gelten aber nur für die laufende Sitzung. Das Farbschema erfasst jetzt auch das Update-Fenster und die Liste der Top-Kandidaten.
### Behoben
- **Einheitliche Schriftgröße:** Unter macOS und Linux verwendet JavaFX eine etwas größere Standardschrift als unter Windows. Dadurch wurden Beschriftungen in fest bemessenen Bedienelementen abgeschnitten. KST4Contest verwendet jetzt auf allen Systemen dieselbe Grundschriftgröße; unter Windows ändert sich nichts.
- **Dark Mode nach dem Neustart:** Das Hauptfenster startete immer hell, auch wenn zuletzt der Dark Mode aktiv war, während die Stationskarte dunkel blieb. Jetzt starten alle Fenster einheitlich mit dem Farbschema des Profils.
- **Freigegebene Hintergrundressourcen:** Der ON4KST-Überwachungsthread, der Sked-Erinnerungs-Scheduler, der Reachability-Executor, der PSTRotator-Wiederholungs-Scheduler und der Kachel-Proxy der Karte werden beim Schließen des Chatcontrollers freigegeben. Bisher liefen sie bis zum Programmende weiter.
### Hinweise zur Aktualisierung
- Bestehende Installationen werden **nicht** verändert: `preferences.xml` und `praktiKST.db` bleiben genau dort liegen, wo sie sind, und werden zum Profil **Default**. Es wird keine Datei verschoben oder kopiert.
- Eine Profil-Registry entsteht erst beim Anlegen des zweiten Profils. Wer nur ein Profil benutzt, merkt von der Änderung nichts.
- Eine Rückkehr zu einer älteren KST4Contest-Version bleibt möglich; sie findet ihre Dateien unverändert vor.
- Passwörter stehen weiterhin im Klartext in der `preferences.xml` des jeweiligen Profils. Profile trennen die Konfiguration, sie sind kein Zugriffsschutz.
---
## v1.43.1 (2026-09-03) ## v1.43.1 (2026-09-03)
**Korrigierte Versionsmetadaten** **Korrigierte Versionsmetadaten**
+1 -1
View File
@@ -318,7 +318,7 @@ KST4Contest meldet sich mit einem lokalen Login-Rufzeichen und Passwort einmal b
## Dark Mode (ab v1.26) ## Dark Mode (ab v1.26)
Aktivierbar über **Windows → Use dark mode design**. Mit **Windows → Use default mode design** wird wieder auf das normale helle Farbschema umgeschaltet. Welches Farbschema beim Start gilt, wird pro Operator-Profil im Reiter **GUI** unter **Design** festgelegt. Über **Windows → Use dark mode design** und **Windows → Use default mode design** lässt sich für die laufende Sitzung schnell umschalten.
Die grüne Altersskala der Privatnachrichten bleibt in beiden Darstellungen erhalten. Textfarbe, normale Tabellenfarbe und die separate Hervorhebung eigener Nachrichten folgen dem jeweils geladenen Standarddesign. Die grüne Altersskala der Privatnachrichten bleibt in beiden Darstellungen erhalten. Textfarbe, normale Tabellenfarbe und die separate Hervorhebung eigener Nachrichten folgen dem jeweils geladenen Standarddesign.
+58 -1
View File
@@ -878,9 +878,66 @@ Anzeige und Herleitung: [Gearbeitete Rufzeichen, neue Bänder und neue Großfeld
--- ---
## Operator-Profile (ab v1.50)
Mehrere Operateure an einem Rechner brauchen unterschiedliche Rufzeichen, Locators und Layouts. Ein Operator-Profil bündelt genau das: **jedes Profil hat seine eigene `preferences.xml` und damit seine eigenen fachlichen Einstellungen und seinen eigenen Layoutstand.**
### Wo die Profile liegen
| Profil | Einstellungen | Gearbeitete Stationen |
|---|---|---|
| **Default** | `~/.praktiKST/preferences.xml` | `~/.praktiKST/praktiKST.db` |
| weitere Profile | `~/.praktiKST/profiles/<Profil-ID>/preferences.xml` | je nach Einstellung gemeinsam oder `~/.praktiKST/profiles/<Profil-ID>/praktiKST.db` |
Unter Windows entsprechend unterhalb von `%USERPROFILE%\.praktiKST\`.
Alle übrigen Daten bleiben gemeinsam: Klangdateien, Farbschemata, DEM- und Terrainpakete, der Zwischenspeicher der Geländeprofile (`terrainprofilecache.db`), das Fehlerprotokoll und die Versionsinformationen.
Das Profil **Default** benutzt weiterhin genau die Dateien, die eine bestehende Installation schon hat. **Bei der Aktualisierung auf v1.50 wird keine Datei verschoben, kopiert oder umgeschrieben.** Wer eine ältere KST4Contest-Version wieder installiert, findet seine Konfiguration und seine gearbeiteten Stationen unverändert vor.
### Gemeinsame oder eigene gearbeitete Stationen
Beim Anlegen eines Profils wird entschieden, woher dessen Worked-, NOT-QRV- und Großfeld-Daten kommen:
- **Eigene gearbeitete Stationen** (Vorgabe): Das Profil bekommt eine eigene, zunächst leere Datenbank. Sinnvoll, wenn sich zwei OMs mit verschiedenen Rufzeichen einen Rechner teilen.
- **Gemeinsame Stationsdatenbank**: Das Profil benutzt `~/.praktiKST/praktiKST.db`, also dieselbe Datenbank wie das Profil **Default**. Das ist der Fall der **Multi-OP-Station**: es gibt nur ein Stationslog, also soll auch der Worked-Status für alle Operateure derselbe sein.
Ein Wechsel zwischen beiden Einstellungen verschiebt keine Daten. Bereits gesammelte Worked-Daten bleiben dort liegen, wo sie entstanden sind. Der Drei-Tage-Ablauf und die Reset-Schaltfläche wirken jeweils auf die Datenbank des gerade aktiven Profils.
### Profile verwalten
Der Reiter **Profiles** im Einstellungsfenster zeigt alle Profile mit Name, Art der Worked-Daten und letzter Benutzung.
- **New profile...** legt ein Profil an. Es startet **ohne Rufzeichen und ohne Passwort**; beides wird anschließend im Reiter **Station** eingetragen.
- **Duplicate...** übernimmt die komplette Konfiguration des gewählten Profils — Antenne, Locator, Layout, Beacon, Integrationen — **außer Rufzeichen und Passwort**. Gearbeitete Stationen werden nie mitkopiert.
- **Rename...** ändert nur den angezeigten Namen. Verzeichnisse und Dateien bleiben unberührt.
- **Delete...** entfernt das Profilverzeichnis endgültig. Das Profil **Default** und das gerade aktive Profil lassen sich nicht löschen. Bei einem Profil mit gemeinsamer Stationsdatenbank bleibt diese unangetastet.
- **Change worked stations...** schaltet zwischen gemeinsamer und eigener Datenbank um.
- **Switch to selected profile...** wechselt das Profil im laufenden Betrieb.
### Profilwahl beim Start
- Solange nur **ein** Profil existiert, fragt KST4Contest beim Start **nichts** und startet wie bisher. Es wird auch keine Profil-Registry angelegt. Erst das Anlegen des zweiten Profils erzeugt `~/.praktiKST/profiles.xml`.
- Ab **zwei** Profilen erscheint beim Start eine kleine Auswahl. Das zuletzt benutzte Profil ist vorausgewählt, **Enter** oder ein Doppelklick starten sofort.
- Der Aufrufparameter `--profile=<Name>` überspringt die Auswahl und startet direkt das genannte Profil. Erlaubt sind die Profil-ID und der angezeigte Name, Groß- und Kleinschreibung spielen keine Rolle. Ein unbekannter Name führt zu einem Hinweis und danach zur normalen Auswahl — der Start wird nie verweigert.
### Profil im laufenden Betrieb wechseln
**File → Switch operator profile...** oder die Schaltfläche im Reiter **Profiles** wechseln ohne Programmneustart. Nach einer Sicherheitsabfrage wird die ON4KST-Verbindung getrennt und die Oberfläche mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut. Der Layoutstand des bisherigen Profils wird vorher gesichert; noch nicht mit **Save Settings** bestätigte fachliche Änderungen gehen dabei verloren.
Sobald mehr als ein Profil existiert, zeigt die Titelzeile des Hauptfensters zusätzlich den Profilnamen.
### Hinweis zu Passwörtern
Das ON4KST-Passwort steht wie bisher im Klartext in der `preferences.xml` des jeweiligen Profils. Auf einem gemeinsam genutzten Rechner kann jeder, der Zugriff auf das Benutzerkonto hat, die Passwörter aller Profile lesen. Profile trennen die Konfiguration, sie sind **kein** Zugriffsschutz.
---
## Dark Mode (ab v1.26) ## Dark Mode (ab v1.26)
Der Dark Mode wird über **Windows → Use dark mode design** aktiviert. Mit **Windows → Use default mode design** wird wieder das normale helle Farbschema geladen. Mit welchem Farbschema KST4Contest startet, legt jedes Operator-Profil selbst fest: im Reiter **GUI** unter **Design** mit **Light mode** oder **Dark mode**. Die Auswahl wirkt sofort und wird mit **Save Settings** in der `preferences.xml` des Profils gespeichert.
Für einen kurzen Wechsel während des Betriebs gibt es **Windows → Use dark mode design** und **Windows → Use default mode design**. Diese Umschaltung gilt nur bis zum nächsten Start oder Profilwechsel; danach gilt wieder die Einstellung im Reiter **GUI**.
--- ---
+41
View File
@@ -8,6 +8,47 @@ Published Stable versions and their application packages are available under [Gi
--- ---
## v1.50 (in development)
**Operator profiles**
Several operators sharing one computer can now use their own callsigns, locators and layouts without overwriting each other's configuration. This implements [Issue #57](https://github.com/praktimarc/kst4contest/issues/57).
### New
- **Operator profiles:** every profile has its own `preferences.xml`, and therefore its own settings and its own window layout. Profiles are managed on the new **Profiles** tab of the settings window: create, duplicate, rename, delete and activate.
- **Shared or own worked stations:** each profile decides whether it gets its own worked database or uses the common station database. A multi operator station with a single station log shares the worked state; two operators with different callsigns on one computer keep it apart.
- **Choosing a profile at startup:** with only one profile, KST4Contest asks nothing at startup and behaves exactly as before. From two profiles on, a small selection appears with the last used profile preselected; Enter or a double click start immediately. The `--profile=<name>` argument skips the selection.
- **Switching profiles while running:** **File > Switch operator profile...** closes the connection and rebuilds the user interface with the settings of the selected profile, without restarting the program.
### Changed
- **The terrain profile cache is separate:** computed terrain profiles now live in their own shared file `terrainprofilecache.db` and are stored per owner. Previously the whole cache was dropped whenever the callsign or locator changed, which would have discarded every computed profile on each profile switch.
- **The default login callsign is empty:** if `preferences.xml` has no login callsign, the field now stays empty instead of falling back to a callsign compiled into the program. A newly created profile therefore deliberately starts without credentials.
- **Dark mode per profile:** on the **GUI** tab each operator profile sets under **Design** whether KST4Contest starts light or dark. The entries in the **Windows** menu still switch quickly, but only for the running session. The colour scheme now also covers the update window and the top candidates list.
### Fixed
- **Consistent font size:** on macOS and Linux JavaFX uses a slightly larger default font than on Windows, which cut off labels in fixed-size controls. KST4Contest now uses the same base font size on all systems; nothing changes on Windows.
- **Dark mode after a restart:** the main window always started light, even if dark mode had been active, while the station map stayed dark. All windows now start consistently with the profile's colour scheme.
- **Background resources are released:** the ON4KST supervisor thread, the sked reminder scheduler, the reachability executor, the PSTRotator retry scheduler and the map tile proxy are released when the chat controller is closed. They used to keep running until the program ended.
### Upgrade notes
- Existing installations are **not** modified: `preferences.xml` and `praktiKST.db` stay exactly where they are and become the **Default** profile. No file is moved or copied.
- A profile registry only appears when the second profile is created. Anyone using a single profile will not notice the change.
- Going back to an older KST4Contest release stays possible; it finds its files unchanged.
- Passwords are still stored in clear text in each profile's `preferences.xml`. Profiles separate configuration; they are not an access control mechanism.
---
## v1.43.1 (2026-09-03) ## v1.43.1 (2026-09-03)
**Corrected version metadata** **Corrected version metadata**
+58 -1
View File
@@ -935,9 +935,66 @@ Display and derivation: [Worked Callsigns, New Bands and New Grid Squares](en-Fe
--- ---
## Operator Profiles (from v1.50)
Several operators sharing one computer need different callsigns, locators and layouts. An operator profile bundles exactly that: **every profile has its own `preferences.xml`, and therefore its own settings and its own window layout.**
### Where the profiles live
| Profile | Settings | Worked stations |
|---|---|---|
| **Default** | `~/.praktiKST/preferences.xml` | `~/.praktiKST/praktiKST.db` |
| additional profiles | `~/.praktiKST/profiles/<profile ID>/preferences.xml` | shared, or `~/.praktiKST/profiles/<profile ID>/praktiKST.db` |
On Windows the same files live below `%USERPROFILE%\.praktiKST\`.
Everything else stays shared: audio files, colour schemes, DEM and terrain packages, the terrain profile cache (`terrainprofilecache.db`), the error log and the version information.
The **Default** profile keeps using exactly the files an existing installation already has. **Upgrading to v1.50 moves, copies and rewrites nothing.** Anyone reinstalling an older KST4Contest release finds their configuration and their worked stations unchanged.
### Shared or own worked stations
When a profile is created you decide where its worked, NOT-QRV and grid data come from:
- **Own worked stations** (default): the profile gets its own, initially empty database. This is what two operators with different callsigns sharing a private computer want.
- **Common station database**: the profile uses `~/.praktiKST/praktiKST.db`, the same database as the **Default** profile. This is the **multi operator station** case: there is only one station log, so the worked state should be the same for every operator.
Switching between the two settings moves no data. Worked data already collected stays where it was created. The three day expiry and the reset button always act on the database of the currently active profile.
### Managing profiles
The **Profiles** tab in the settings window lists all profiles with their name, the kind of worked data they use and when they were last used.
- **New profile...** creates a profile. It starts **without callsign and password**; both are entered afterwards on the **Station** tab.
- **Duplicate...** copies the complete configuration of the selected profile - antenna, locator, layout, beacon, integrations - **except callsign and password**. Worked stations are never copied.
- **Rename...** changes the displayed name only. Folders and files are untouched.
- **Delete...** removes the profile folder permanently. The **Default** profile and the currently active profile cannot be deleted. For a profile using the common station database, that database is left untouched.
- **Change worked stations...** switches between the common and an own database.
- **Switch to selected profile...** changes the profile while the application is running.
### Choosing a profile at startup
- As long as only **one** profile exists, KST4Contest asks **nothing** at startup and starts exactly as before. No profile registry is created either; only creating the second profile writes `~/.praktiKST/profiles.xml`.
- From **two** profiles on, a small selection appears at startup. The last used profile is preselected, and **Enter** or a double click start immediately.
- The `--profile=<name>` argument skips the selection and starts the named profile directly. Both the profile ID and the displayed name are accepted, case insensitively. An unknown name produces a note and then the normal selection - startup is never refused.
### Switching profiles while running
**File > Switch operator profile...**, or the button on the **Profiles** tab, switches without restarting the program. After a confirmation the ON4KST connection is closed and the user interface is rebuilt with the settings and layout of the selected profile. The layout of the previous profile is saved first; settings not yet confirmed with **Save Settings** are lost.
Once more than one profile exists, the main window title also shows the profile name.
### A note about passwords
As before, the ON4KST password is stored in clear text in the `preferences.xml` of each profile. On a shared computer, anyone with access to the user account can read the passwords of all profiles. Profiles separate configuration; they are **not** an access control mechanism.
---
## Dark Mode (from v1.26) ## Dark Mode (from v1.26)
Enable Dark Mode through **Windows → Use dark mode design**. Use **Windows → Use default mode design** to restore the normal light colour scheme. Each operator profile sets its own startup colour scheme: on the **GUI** tab under **Design**, choose **Light mode** or **Dark mode**. The choice takes effect immediately and is stored in the profile's `preferences.xml` with **Save Settings**.
For a quick change during operation, use **Windows → Use dark mode design** and **Windows → Use default mode design**. This switch only lasts until the next start or profile switch; after that the setting on the **GUI** tab applies again.
--- ---
+1 -1
View File
@@ -317,7 +317,7 @@ KST4Contest signs in to ON4KST once with one local login callsign and password.
## Dark Mode (from v1.26) ## Dark Mode (from v1.26)
Enable it through **Windows → Use dark mode design**. Use **Windows → Use default mode design** to return to the normal light colour scheme. Each operator profile sets its startup colour scheme on the **GUI** tab under **Design**. **Windows → Use dark mode design** and **Windows → Use default mode design** switch quickly for the running session.
The green private-message age scale remains available in both designs. Text colour, normal table colour and the separate highlight for locally sent messages follow the selected built-in design. The green private-message age scale remains available in both designs. Text colour, normal table colour and the separate highlight for locally sent messages follow the selected built-in design.
+3 -2
View File
@@ -486,8 +486,9 @@ Functions which communicate with the server are available only after the ON4KST
- **Hide cluster / stranger QSOs** and **Show cluster / stranger QSOs** hide or restore the separate cluster and QSO monitor window. - **Hide cluster / stranger QSOs** and **Show cluster / stranger QSOs** hide or restore the separate cluster and QSO monitor window.
- **hide options** and **show options** hide or restore the settings window. - **hide options** and **show options** hide or restore the settings window.
- **Use dark mode design** activates the dark colour scheme. - **Use dark mode design** activates the dark colour scheme for the running session.
- **Use default mode design** restores the standard light colour scheme. - **Use default mode design** restores the standard light colour scheme for the running session.
The startup colour scheme is set per profile on the **GUI** settings tab under **Design**.
- **Show / hide station map** opens or closes the separate station-map and path-analysis window. - **Show / hide station map** opens or closes the separate station-map and path-analysis window.
--- ---
+86 -2
View File
@@ -208,8 +208,92 @@ STAGE="$(mktemp -d)"
ditto "$APP" "$STAGE/KST4Contest.app" ditto "$APP" "$STAGE/KST4Contest.app"
ln -s /Applications "$STAGE/Applications" ln -s /Applications "$STAGE/Applications"
hdiutil create -volname "KST4Contest" -srcfolder "$STAGE" \ # The installer window shows a background with an arrow from the app to the
-ov -format UDZO -quiet "$DMG" # Applications folder. Finder only stores that layout (.DS_Store) when it has
# arranged a mounted, writable image itself, so this drives Finder through
# AppleScript. That can fail, e.g. without automation permission for Finder or
# on a headless CI runner; the DMG is then built without the layout and only a
# warning is printed. Window size and icon positions must match
# packaging/macos/dmg/render-background.swift.
DMG_BACKGROUND="packaging/macos/dmg/background.tiff"
VOLUME_NAME="KST4Contest"
build_dmg_with_layout() {
local mount_point="/Volumes/$VOLUME_NAME"
local rw_dir rw_dmg size_mb
if [ ! -f "$DMG_BACKGROUND" ]; then
echo " $DMG_BACKGROUND is missing" >&2
return 1
fi
# Finder addresses the disk by its name; a second mounted volume of the same
# name would get another name and break the stored background reference.
if [ -e "$mount_point" ]; then
echo " $mount_point is already mounted, eject it first" >&2
return 1
fi
rw_dir="$(mktemp -d)"
rw_dmg="$rw_dir/KST4Contest-rw.dmg"
# Some headroom for the background picture and the .DS_Store Finder writes.
size_mb=$(( $(du -sm "$STAGE" | cut -f1) + 20 ))
hdiutil create -volname "$VOLUME_NAME" -srcfolder "$STAGE" -fs HFS+ \
-format UDRW -size "${size_mb}m" -ov -quiet "$rw_dmg" || { rm -rf "$rw_dir"; return 1; }
hdiutil attach "$rw_dmg" -readwrite -noverify -noautoopen -quiet || { rm -rf "$rw_dir"; return 1; }
local layout_ok=0
mkdir -p "$mount_point/.background" \
&& cp "$DMG_BACKGROUND" "$mount_point/.background/background.tiff" \
&& osascript <<APPLESCRIPT || layout_ok=1
tell application "Finder"
tell disk "$VOLUME_NAME"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {200, 120, 840, 520}
set viewOptions to the icon view options of container window
set arrangement of viewOptions to not arranged
set icon size of viewOptions to 128
set text size of viewOptions to 13
set background picture of viewOptions to file ".background:background.tiff"
set position of item "KST4Contest.app" of container window to {170, 180}
set position of item "Applications" of container window to {470, 180}
close
open
update without registering applications
delay 2
close
end tell
end tell
APPLESCRIPT
# Give Finder time to write .DS_Store before the image is detached.
sync
sleep 2
rm -rf "$mount_point/.fseventsd"
hdiutil detach "$mount_point" -quiet \
|| hdiutil detach "$mount_point" -force -quiet \
|| { rm -rf "$rw_dir"; return 1; }
if [ "$layout_ok" -ne 0 ]; then
rm -rf "$rw_dir"
return 1
fi
hdiutil convert "$rw_dmg" -format UDZO -ov -quiet -o "$DMG" || { rm -rf "$rw_dir"; return 1; }
rm -rf "$rw_dir"
}
if build_dmg_with_layout; then
echo " installer window layout applied"
else
echo "WARNING: could not apply the DMG window layout, building a plain DMG instead" >&2
rm -f "$DMG"
hdiutil create -volname "$VOLUME_NAME" -srcfolder "$STAGE" \
-ov -format UDZO -quiet "$DMG"
fi
rm -rf "$STAGE" rm -rf "$STAGE"
[ -f "$DMG" ] || { echo "hdiutil produced no DMG" >&2; exit 1; } [ -f "$DMG" ] || { echo "hdiutil produced no DMG" >&2; exit 1; }
Binary file not shown.
+131
View File
@@ -0,0 +1,131 @@
// Renders the background of the macOS installer DMG window.
//
// Run from the repository root after changing the design; only the TIFF is kept:
// TMP="$(mktemp -d)"
// swift packaging/macos/dmg/render-background.swift "$TMP"
// tiffutil -cathidpicheck "$TMP/background.png" "$TMP/background@2x.png" \
// -out packaging/macos/dmg/background.tiff
//
// The layout (window size, icon positions) must match build-signed-dmg.sh.
// Colors follow the green KST4Contest website theme and the application icon.
import AppKit
let windowWidth: CGFloat = 640
let windowHeight: CGFloat = 400
// Icon centers in Finder coordinates (origin top left), same values as in the build script.
let appIconCenter = CGPoint(x: 170, y: 180)
let applicationsIconCenter = CGPoint(x: 470, y: 180)
let iconSize: CGFloat = 128
func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
CGColor(srgbRed: CGFloat((hex >> 16) & 0xff) / 255,
green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255,
alpha: alpha)
}
func render(scale: CGFloat, to path: String) {
let pixelWidth = Int(windowWidth * scale)
let pixelHeight = Int(windowHeight * scale)
guard let ctx = CGContext(data: nil, width: pixelWidth, height: pixelHeight,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
fatalError("Could not create drawing context")
}
// Draw in Finder coordinates: origin top left, y pointing down.
ctx.scaleBy(x: scale, y: scale)
ctx.translateBy(x: 0, y: windowHeight)
ctx.scaleBy(x: 1, y: -1)
let space = CGColorSpace(name: CGColorSpace.sRGB)!
// Background: dark green gradient of the website theme.
let background = CGGradient(colorsSpace: space,
colors: [color(0x0b140f), color(0x050806)] as CFArray,
locations: [0, 1])!
ctx.drawLinearGradient(background, start: CGPoint(x: 0, y: 0),
end: CGPoint(x: 0, y: windowHeight), options: [])
// Soft accent glow behind the arrow.
let glow = CGGradient(colorsSpace: space,
colors: [color(0x54d63d, 0.16), color(0x54d63d, 0)] as CFArray,
locations: [0, 1])!
let middle = CGPoint(x: (appIconCenter.x + applicationsIconCenter.x) / 2, y: appIconCenter.y)
ctx.drawRadialGradient(glow, startCenter: middle, startRadius: 0,
endCenter: middle, endRadius: 260, options: [])
// Radio waves like in the icon, above the arrow.
ctx.setLineCap(.round)
ctx.setLineWidth(4)
let waveCenter = CGPoint(x: middle.x, y: middle.y - 18)
for (index, radius) in [CGFloat(14), 26].enumerated() {
ctx.setStrokeColor(color(0x8aef64, index == 0 ? 0.55 : 0.3))
ctx.addArc(center: waveCenter, radius: radius, startAngle: -.pi / 4 - .pi / 2,
endAngle: .pi / 4 - .pi / 2, clockwise: false)
ctx.strokePath()
}
// Arrow from the app to the Applications folder.
let arrowStart = appIconCenter.x + iconSize / 2 + 22
let arrowEnd = applicationsIconCenter.x - iconSize / 2 - 22
let arrowY = appIconCenter.y
let headLength: CGFloat = 22
let headHalfWidth: CGFloat = 16
ctx.saveGState()
ctx.setShadow(offset: .zero, blur: 12, color: color(0x54d63d, 0.6))
let arrowPath = CGMutablePath()
arrowPath.move(to: CGPoint(x: arrowStart, y: arrowY))
arrowPath.addLine(to: CGPoint(x: arrowEnd - headLength + 2, y: arrowY))
ctx.addPath(arrowPath)
ctx.setLineWidth(10)
ctx.setStrokeColor(color(0x54d63d))
ctx.strokePath()
let head = CGMutablePath()
head.move(to: CGPoint(x: arrowEnd, y: arrowY))
head.addLine(to: CGPoint(x: arrowEnd - headLength, y: arrowY - headHalfWidth))
head.addLine(to: CGPoint(x: arrowEnd - headLength, y: arrowY + headHalfWidth))
head.closeSubpath()
ctx.addPath(head)
ctx.setFillColor(color(0x54d63d))
ctx.setLineJoin(.round)
ctx.setLineWidth(4)
ctx.setStrokeColor(color(0x54d63d))
ctx.drawPath(using: .fillStroke)
ctx.restoreGState()
// Label plates: Finder draws icon labels black in light mode and white in dark
// mode, and the color cannot be configured. This mid tone keeps both readable
// (contrast about 4.6:1 for white and for black text).
let labelTop = appIconCenter.y + iconSize / 2 + 4
for center in [appIconCenter, applicationsIconCenter] {
let plate = CGRect(x: center.x - 80, y: labelTop, width: 160, height: 26)
ctx.addPath(CGPath(roundedRect: plate, cornerWidth: 13, cornerHeight: 13, transform: nil))
ctx.setFillColor(color(0x5c7d63))
ctx.fillPath()
}
// Thin accent line at the bottom, as used on the website.
ctx.setFillColor(color(0x31b54a))
ctx.fill(CGRect(x: 0, y: windowHeight - 4, width: windowWidth, height: 4))
guard let image = ctx.makeImage() else {
fatalError("Could not render image")
}
let rep = NSBitmapImageRep(cgImage: image)
// 72 dpi for 1x and 144 dpi for 2x, so tiffutil can combine them into one HiDPI TIFF.
rep.size = NSSize(width: windowWidth, height: windowHeight)
guard let data = rep.representation(using: .png, properties: [:]) else {
fatalError("Could not encode PNG")
}
try! data.write(to: URL(fileURLWithPath: path))
}
let outputDirectory = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "."
render(scale: 1, to: outputDirectory + "/background.png")
render(scale: 2, to: outputDirectory + "/background@2x.png")
print("Rendered background.png and background@2x.png into \(outputDirectory)")
+1 -1
View File
@@ -6,7 +6,7 @@
<groupId>de.x08</groupId> <groupId>de.x08</groupId>
<artifactId>praktiKST</artifactId> <artifactId>praktiKST</artifactId>
<version>1.44.0-nightly</version> <version>1.50.0-nightly</version>
<name>praktiKST</name> <name>praktiKST</name>
@@ -20,14 +20,14 @@ public class ApplicationConstants {
/** /**
* Version shown to the user and used for semantic version comparison. * Version shown to the user and used for semantic version comparison.
*/ */
public static final String APPLICATION_CURRENT_VERSION = "1.44.0"; public static final String APPLICATION_CURRENT_VERSION = "1.50.0";
/** /**
* Legacy numeric representation used only while older update feeds and * Legacy numeric representation used only while older update feeds and
* application versions still exist. * application versions still exist.
*/ */
@Deprecated @Deprecated
public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.44; public static final double APPLICATION_CURRENTVERSIONNUMBER = 1.50;
public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://kst4contest.hamradioonline.de/kst4ContestVersionInfo.xml"; public static final String VERSIONINFOURLFORUPDATES_KST4CONTEST = "https://kst4contest.hamradioonline.de/kst4ContestVersionInfo.xml";
public static final String VERSIONINFDOWNLOADEDLOCALFILE = "kst4ContestVersionInfo.xml"; public static final String VERSIONINFDOWNLOADEDLOCALFILE = "kst4ContestVersionInfo.xml";
@@ -0,0 +1,48 @@
package kst4contest.controller;
import kst4contest.model.OperatorProfileSelection;
/**
* Holds the operator profile the current runtime works with.
*
* <p>The state is deliberately static, which is safe here for reasons that did not apply
* to the former static database connection: the value is immutable, it holds no live
* resource, and it is set on the JavaFX Application Thread before anything reads it -
* during startup, and again during a profile switch after the previous runtime has been
* shut down completely.</p>
*/
public final class ActiveOperatorProfile {
private static volatile OperatorProfileSelection currentSelection;
private ActiveOperatorProfile() {
// Utility class.
}
/**
* Returns the active profile selection.
*
* @return the active selection, or null when startup has not resolved one yet
*/
public static OperatorProfileSelection get() {
return currentSelection;
}
/**
* Sets the active profile selection.
*
* @param selection selection to activate
*/
public static void set(final OperatorProfileSelection selection) {
currentSelection = selection;
}
/**
* Returns whether a profile has already been resolved for this runtime.
*
* @return true if a selection is present
*/
public static boolean isInitialized() {
return currentSelection != null;
}
}
@@ -1083,9 +1083,35 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
rotatorClient.stop(); rotatorClient.stop();
rotatorClient = null; rotatorClient = null;
} }
releaseBackgroundExecutors();
} }
} }
/**
* Stops the background executors that live as long as this controller.
*
* <p>These are not bound to one ON4KST session, so disconnecting leaves them running
* on purpose. When the controller itself is discarded they have to go, otherwise a
* discarded controller stays reachable through its own threads.</p>
*/
private void releaseBackgroundExecutors() {
on4KstConnectionManager.shutdown();
skedReminderService.shutdown();
if (reachabilityService != null) {
reachabilityService.shutdown();
}
if (pendingRotatorRetry != null) {
pendingRotatorRetry.cancel(false);
pendingRotatorRetry = null;
}
rotatorCommandScheduler.shutdownNow();
}
private void cancelTimer(Timer timer) { private void cancelTimer(Timer timer) {
if (timer != null) { if (timer != null) {
timer.cancel(); timer.cancel();
@@ -2866,9 +2892,35 @@ private ObservableList<String>
* @param setOwnChatMemberObject * @param setOwnChatMemberObject
*/ */
public ChatController(ChatMember setOwnChatMemberObject,StatusUpdateListener listener) { public ChatController(ChatMember setOwnChatMemberObject,StatusUpdateListener listener) {
this(setOwnChatMemberObject,
listener,
ChatPreferences.PREFERENCES_FILE,
DBController.DATABASE_FILE,
true);
}
/**
* Creates a chat controller bound to the files of one operator profile.
*
* <p>Both file names are resolved below the application directory. This is the only
* place where the active operator profile enters the controller; everything below
* works on the resulting {@link ChatPreferences} and {@link DBController} instances
* without knowing about profiles at all.</p>
*
* @param setOwnChatMemberObject chat member object representing the local station
* @param listener callback for thread status updates
* @param preferencesRelativeFileName preferences file name relative to the application directory
* @param workedDatabaseRelativeFileName worked-station database file name relative to the application directory
* @param seedWorkedDatabaseFromResource true to seed a missing database from the bundled template
*/
public ChatController(ChatMember setOwnChatMemberObject,
StatusUpdateListener listener,
String preferencesRelativeFileName,
String workedDatabaseRelativeFileName,
boolean seedWorkedDatabaseFromResource) {
super(); super();
chatPreferences = new ChatPreferences(); chatPreferences = new ChatPreferences(preferencesRelativeFileName);
chatPreferences.readPreferencesFromXmlFile(); chatPreferences.readPreferencesFromXmlFile();
// this.statusListener = listener; // this.statusListener = listener;
lstNotify_QSOSniffer_sniffedCallSignList = lstNotify_QSOSniffer_sniffedCallSignList =
@@ -2930,7 +2982,7 @@ private ObservableList<String>
} }
}); });
dbHandler = new DBController(); dbHandler = new DBController(workedDatabaseRelativeFileName, seedWorkedDatabaseFromResource);
reachabilityService = new ReachabilityService(this); reachabilityService = new ReachabilityService(this);
rebuildWorkedGrossFieldCacheFromDatabase(); rebuildWorkedGrossFieldCacheFromDatabase();
@@ -6,9 +6,13 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Statement; import java.sql.Statement;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import kst4contest.ApplicationConstants; import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
@@ -52,34 +56,128 @@ public class DBController {
*/ */
private static final long EXPIRATION_CLEANUP_MIN_INTERVAL_IN_MILLISECONDS = 60L * 1000L; private static final long EXPIRATION_CLEANUP_MIN_INTERVAL_IN_MILLISECONDS = 60L * 1000L;
private static final DBController dbcontroller = new DBController(); /**
private static Connection connection; * Lazily created controller for the root installation database. It is created on
private static String DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE); * first use only, because an eagerly created instance would open a database file
* before the application knows which operator profile is active.
*/
private static volatile DBController defaultInstance;
private Connection connection;
/**
* File name of this database relative to the application directory, for example
* "praktiKST.db" or "profiles/OP2/praktiKST.db".
*/
private final String databaseRelativeFileName;
/**
* Absolute path of the database file, resolved once during construction.
*/
private final String databaseFilePath;
/**
* True if a missing database file should be seeded from the shipped template.
*/
private final boolean seedFromResource;
/**
* Shutdown hook of this instance. It is remembered so it can be deregistered when
* the connection is closed. Without that, every operator profile switch would leave
* another hook behind that keeps a dead connection alive until the process ends.
*/
private Thread databaseShutdownHook;
/** /**
* Remembers the last timestamp at which the expiration cleanup had been executed. * Remembers the last timestamp at which the expiration cleanup had been executed.
*/ */
private long lastExpirationCleanupExecutionEpochMs = 0L; private long lastExpirationCleanupExecutionEpochMs = 0L;
/**
* Creates a controller for the worked-station database of the root installation.
*/
public DBController() { public DBController() {
initDBConnection(); this(DATABASE_FILE, true);
}
public static DBController getInstance() {
return dbcontroller;
} }
/** /**
* Closes the database connection if it is still open. * Creates a controller for the worked-station database of one operator profile.
*
* @param databaseRelativeFileName file name relative to the application directory
* @param seedFromResource true to copy the shipped template database when the file
* does not exist yet, false to create an empty database and
* let the schema creation build all required tables
*/
public DBController(final String databaseRelativeFileName, final boolean seedFromResource) {
this.databaseRelativeFileName =
Objects.requireNonNull(databaseRelativeFileName, "databaseRelativeFileName");
this.seedFromResource = seedFromResource;
this.databaseFilePath = ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME,
databaseRelativeFileName
);
initDBConnection();
}
/**
* Returns a controller for the root installation database, creating it on first use.
*
* @return the shared controller for the root installation database
*/
public static synchronized DBController getInstance() {
if (defaultInstance == null) {
defaultInstance = new DBController();
}
return defaultInstance;
}
/**
* Returns the absolute path of the database file this controller works on.
*
* @return absolute database file path
*/
public String getDatabaseFilePath() {
return databaseFilePath;
}
/**
* Closes the database connection if it is still open and deregisters the shutdown
* hook of this instance.
*/ */
public synchronized void closeDBConnection() { public synchronized void closeDBConnection() {
closeConnectionQuietly();
if (databaseShutdownHook != null) {
try {
Runtime.getRuntime().removeShutdownHook(databaseShutdownHook);
} catch (IllegalStateException shutdownAlreadyInProgress) {
// Expected while the JVM is shutting down; the hook is running anyway.
}
databaseShutdownHook = null;
}
}
/**
* Closes the connection without touching the shutdown hook. This is also the body of
* the shutdown hook itself.
*/
private synchronized void closeConnectionQuietly() {
try { try {
if (connection != null && !connection.isClosed()) { if (connection != null && !connection.isClosed()) {
connection.close(); connection.close();
System.out.println("Connection to Database closed: " + databaseFilePath);
} }
} catch (SQLException e) { } catch (SQLException e) {
e.printStackTrace(); e.printStackTrace();
} }
connection = null;
} }
/** /**
@@ -91,22 +189,17 @@ public class DBController {
System.out.println("DBH: initiate new db connection"); System.out.println("DBH: initiate new db connection");
try { try {
ApplicationFileUtils.copyResourceIfRequired(
ApplicationConstants.APPLICATION_NAME,
DATABASE_RESOURCE,
DATABASE_FILE
);
if (connection != null && !connection.isClosed()) { if (connection != null && !connection.isClosed()) {
return; return;
} }
prepareDatabaseFile();
System.out.println("Creating Connection to Database..."); System.out.println("Creating Connection to Database...");
DB_PATH = ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, DATABASE_FILE); connection = DriverManager.getConnection("jdbc:sqlite:" + databaseFilePath);
connection = DriverManager.getConnection("jdbc:sqlite:" + DB_PATH);
System.out.println("[DBH, Info]: Path = " + DB_PATH); System.out.println("[DBH, Info]: Path = " + databaseFilePath);
if (!connection.isClosed()) { if (!connection.isClosed()) {
System.out.println("...Connection established"); System.out.println("...Connection established");
@@ -115,25 +208,50 @@ public class DBController {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
Runtime.getRuntime().addShutdownHook(new Thread() { databaseShutdownHook = new Thread(this::closeConnectionQuietly,
public void run() { "DBController-shutdown-" + databaseRelativeFileName);
try { Runtime.getRuntime().addShutdownHook(databaseShutdownHook);
if (connection != null && !connection.isClosed()) {
connection.close();
if (connection.isClosed()) {
System.out.println("Connection to Database closed");
}
}
} catch (SQLException e) {
e.printStackTrace();
}
}
});
ensureChatMemberTableCompatibility(); ensureChatMemberTableCompatibility();
} }
/**
* Makes sure the database file can be opened.
*
* <p>The database of the root installation is seeded from the shipped template so
* existing installations keep their historic content. A database that belongs to an
* additional operator profile is created empty on purpose: the shipped template
* carries several thousand foreign callsigns and an outdated schema version, which
* would present a new operator with foreign data and trigger the full callsign
* normalization rebuild. The required tables are created by
* {@link #ensureChatMemberTableCompatibility()} in both cases.</p>
*/
private synchronized void prepareDatabaseFile() {
if (seedFromResource) {
ApplicationFileUtils.copyResourceIfRequired(
ApplicationConstants.APPLICATION_NAME,
DATABASE_RESOURCE,
databaseRelativeFileName
);
return;
}
Path parentDirectory = Path.of(databaseFilePath).getParent();
if (parentDirectory == null) {
return;
}
try {
Files.createDirectories(parentDirectory);
} catch (IOException e) {
throw new RuntimeException(
"[DBH, ERROR:] Could not create database directory " + parentDirectory, e);
}
}
/** /**
* Ensures that the ChatMember table exists, that all required columns are * Ensures that the ChatMember table exists, that all required columns are
* available for newer software versions, that existing old callsign keys are * available for newer software versions, that existing old callsign keys are
@@ -877,10 +877,9 @@ public class MessageBusManagementThread extends Thread {
|| messageToProcess.getMessageText().isEmpty()) { || messageToProcess.getMessageText().isEmpty()) {
// No processable data. // No processable data.
} else { } else {
if (On4KstProtocol.isConnectionProbeResponse( if (On4KstProtocol.isInternalDxqResponse(
messageToProcess.getMessageText())) { messageToProcess.getMessageText())) {
// DXQ is the internal response to the active connection probe. // Preserve the established internal handling of DXQ server data.
// Liveness was already recorded by the session manager.
return; return;
} }
@@ -2183,7 +2182,7 @@ public class MessageBusManagementThread extends Thread {
// e.printStackTrace(); // e.printStackTrace();
// } // }
if (!On4KstProtocol.isConnectionProbeResponse( if (!On4KstProtocol.isInternalDxqResponse(
messageTextRaw.getMessageText())) { messageTextRaw.getMessageText())) {
System.out.println(messageTextRaw.getMessageText() + " <- RXed"); // Stdout at System.out.println(messageTextRaw.getMessageText() + " <- RXed"); // Stdout at
// Console#######################################################TODO:Wichtig // Console#######################################################TODO:Wichtig
@@ -47,9 +47,7 @@ final class On4KstConnectionManager {
static final int CONNECT_TIMEOUT_MILLIS = 10_000; //TCP-Connect-Timeout static final int CONNECT_TIMEOUT_MILLIS = 10_000; //TCP-Connect-Timeout
static final long LOGIN_FALLBACK_MILLIS = 2_000L; //Login-Fallback static final long LOGIN_FALLBACK_MILLIS = 2_000L; //Login-Fallback
static final long HANDSHAKE_TIMEOUT_MILLIS = 45_000L; //Handshake-Timeout static final long HANDSHAKE_TIMEOUT_MILLIS = 45_000L; //Handshake-Timeout
static final long APPLICATION_HEARTBEAT_AFTER_MILLIS = 90_000L; //Application-Heartbeat static final long CLIENT_LIVENESS_PROBE_AFTER_MILLIS = 90_000L;
/** 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 INBOUND_STALE_AFTER_MILLIS = 210_000L; //Stale-Timeout - time without rxed data static final long INBOUND_STALE_AFTER_MILLIS = 210_000L; //Stale-Timeout - time without rxed data
static final List<Long> RECONNECT_DELAYS_MILLIS = 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 List.of(2_000L, 5_000L, 10_000L, 20_000L, 30_000L); //Reconnect-Backoff if no connection possible
@@ -139,6 +137,20 @@ final class On4KstConnectionManager {
scheduler.execute(() -> openConnection(token)); scheduler.execute(() -> openConnection(token));
} }
/**
* Stops the supervisor thread of this manager for good.
*
* <p>{@link #stopByUser()} only ends the current ON4KST session; the periodic
* session monitor keeps running. That is correct while the application lives, but a
* manager belonging to a discarded runtime must release its thread, otherwise every
* operator profile switch would leave another supervisor behind holding the dead
* controller.</p>
*/
void shutdown() {
scheduler.shutdownNow();
LOGGER.fine("ON4KST connection supervisor shut down");
}
/** /**
* Stops the current session and invalidates every scheduled callback or reconnect * Stops the current session and invalidates every scheduled callback or reconnect
* belonging to it. * belonging to it.
@@ -181,10 +193,10 @@ final class On4KstConnectionManager {
session.lastProgressMillis.set(now); session.lastProgressMillis.set(now);
String opcode = On4KstProtocol.opcode(line); String opcode = On4KstProtocol.opcode(line);
long probeResponseMillis = session.connectionProbe.acknowledge(now); long probeResponseMillis = session.clientLivenessProbe.acknowledge(now);
if (probeResponseMillis >= 0L) { if (probeResponseMillis >= 0L) {
LOGGER.log(Level.INFO, LOGGER.log(Level.INFO,
"ON4KST connection probe confirmed: session {0}, " "ON4KST client liveness probe confirmed: session {0}, "
+ "received opcode {1}, response time {2} ms", + "received opcode {1}, response time {2} ms",
new Object[] { new Object[] {
sessionId, sessionId,
@@ -193,8 +205,8 @@ final class On4KstConnectionManager {
}); });
} }
if ("CK".equals(opcode)) { if (On4KstProtocol.isServerLivenessProbe(line)) {
sendHeartbeat(session); sendServerLivenessProbeResponse(session);
} }
if (!session.loginSent if (!session.loginSent
@@ -265,8 +277,7 @@ final class On4KstConnectionManager {
token, token,
socket, socket,
receiveQueue, receiveQueue,
transmitQueue, transmitQueue);
mainCategory);
ReadThread readThread = new ReadThread( ReadThread readThread = new ReadThread(
token, socket, receiveQueue, this::isActiveSession, token, socket, receiveQueue, this::isActiveSession,
@@ -541,42 +552,35 @@ final class On4KstConnectionManager {
session.transmitQueue.offer(message); session.transmitQueue.offer(message);
} }
private void sendHeartbeat(Session session) { private void sendServerLivenessProbeResponse(Session session) {
if (session == null || !isActiveSession(session.id)) { if (session == null || !isActiveSession(session.id)) {
return; return;
} }
long now = System.currentTimeMillis();
session.lastHeartbeatMillis.set(now);
LOGGER.log(Level.FINE, LOGGER.log(Level.FINE,
"Sending application heartbeat for ON4KST session {0}", "Responding to ON4KST server liveness probe: session {0}, "
+ "opcode CK",
session.id); session.id);
ChatMessage heartbeat = new ChatMessage(); sendControl(session, On4KstProtocol.serverLivenessProbeResponse());
heartbeat.setMessageDirectedToServer(true);
heartbeat.setMessageText("");
session.transmitQueue.offer(heartbeat);
} }
private void sendConnectionProbe( private void sendClientLivenessProbe(
Session session, Session session,
long now, long now,
long inboundIdle long inboundIdle
) { ) {
if (session == null || !isActiveSession(session.id) if (session == null || !isActiveSession(session.id)
|| !session.connectionProbe.tryStart(now)) { || !session.clientLivenessProbe.tryStart(now)) {
return; return;
} }
LOGGER.log(Level.INFO, LOGGER.log(Level.INFO,
"Sending ON4KST connection probe: session {0}, main category " "Sending ON4KST client liveness probe: session {0}, "
+ "{1}, inbound idle {2} seconds", + "opcode CK, inbound idle {1} seconds",
new Object[] { new Object[] {
session.id, session.id,
session.mainCategory,
inboundIdle / 1_000L inboundIdle / 1_000L
}); });
sendControl( sendControl(session, On4KstProtocol.clientLivenessProbe());
session,
On4KstProtocol.connectionProbe(session.mainCategory));
} }
private void onConnectionFailure(long sessionId, Throwable failure) { private void onConnectionFailure(long sessionId, Throwable failure) {
@@ -682,8 +686,8 @@ final class On4KstConnectionManager {
long inboundIdle = now - lastInboundMillis; long inboundIdle = now - lastInboundMillis;
IdleAction idleAction = determineIdleAction( IdleAction idleAction = determineIdleAction(
inboundIdle, inboundIdle,
session.lastHeartbeatMillis.get() >= lastInboundMillis, session.online,
session.connectionProbe.isOutstanding()); session.clientLivenessProbe.isOutstanding());
if (session.lastInboundMillis.get() != lastInboundMillis) { if (session.lastInboundMillis.get() != lastInboundMillis) {
return; return;
@@ -695,16 +699,15 @@ final class On4KstConnectionManager {
return; return;
} }
long probeWaitMillis = long probeWaitMillis =
session.connectionProbe.responseWaitMillis(now); session.clientLivenessProbe.responseWaitMillis(now);
if (probeWaitMillis >= 0L) { if (probeWaitMillis >= 0L) {
LOGGER.log(Level.WARNING, LOGGER.log(Level.WARNING,
"ON4KST connection probe timed out: session " "ON4KST client liveness probe timed out: "
+ "{0}, main category {1}, no response " + "session {0}, opcode CK, no response "
+ "for {2} ms, inbound idle {3} seconds; " + "for {1} ms, inbound idle {2} seconds; "
+ "reconnecting", + "reconnecting",
new Object[] { new Object[] {
session.id, session.id,
session.mainCategory,
probeWaitMillis, probeWaitMillis,
inboundIdle / 1_000L inboundIdle / 1_000L
}); });
@@ -713,9 +716,8 @@ final class On4KstConnectionManager {
new SocketException("No ON4KST data received for " new SocketException("No ON4KST data received for "
+ inboundIdle / 1_000L + " seconds")); + inboundIdle / 1_000L + " seconds"));
} }
case CONNECTION_PROBE -> case CLIENT_LIVENESS_PROBE ->
sendConnectionProbe(session, now, inboundIdle); sendClientLivenessProbe(session, now, inboundIdle);
case HEARTBEAT -> sendHeartbeat(session);
case NONE -> { case NONE -> {
// The session is active or already has the required idle action. // The session is active or already has the required idle action.
} }
@@ -731,19 +733,18 @@ final class On4KstConnectionManager {
*/ */
static IdleAction determineIdleAction( static IdleAction determineIdleAction(
long inboundIdleMillis, long inboundIdleMillis,
boolean heartbeatSentForIdlePhase, boolean online,
boolean probeOutstanding boolean probeOutstanding
) { ) {
if (!online) {
return IdleAction.NONE;
}
if (inboundIdleMillis > INBOUND_STALE_AFTER_MILLIS) { if (inboundIdleMillis > INBOUND_STALE_AFTER_MILLIS) {
return IdleAction.TIMEOUT; return IdleAction.TIMEOUT;
} }
if (inboundIdleMillis >= CONNECTION_PROBE_AFTER_MILLIS if (inboundIdleMillis > CLIENT_LIVENESS_PROBE_AFTER_MILLIS
&& !probeOutstanding) { && !probeOutstanding) {
return IdleAction.CONNECTION_PROBE; return IdleAction.CLIENT_LIVENESS_PROBE;
}
if (inboundIdleMillis > APPLICATION_HEARTBEAT_AFTER_MILLIS
&& !heartbeatSentForIdlePhase) {
return IdleAction.HEARTBEAT;
} }
return IdleAction.NONE; return IdleAction.NONE;
} }
@@ -894,15 +895,13 @@ final class On4KstConnectionManager {
private final Socket socket; private final Socket socket;
private final LinkedBlockingQueue<ChatMessage> receiveQueue; private final LinkedBlockingQueue<ChatMessage> receiveQueue;
private final LinkedBlockingQueue<ChatMessage> transmitQueue; private final LinkedBlockingQueue<ChatMessage> transmitQueue;
private final int mainCategory;
private final long connectedMillis = System.currentTimeMillis(); private final long connectedMillis = System.currentTimeMillis();
private final AtomicLong lastInboundMillis = private final AtomicLong lastInboundMillis =
new AtomicLong(connectedMillis); new AtomicLong(connectedMillis);
private final AtomicLong lastProgressMillis = private final AtomicLong lastProgressMillis =
new AtomicLong(connectedMillis); new AtomicLong(connectedMillis);
private final AtomicLong lastHeartbeatMillis = new AtomicLong(); private final ClientLivenessProbeState clientLivenessProbe =
private final ConnectionProbeState connectionProbe = new ClientLivenessProbeState();
new ConnectionProbeState();
private final Map<Integer, Map<String, ChatMember>> initialMembers = private final Map<Integer, Map<String, ChatMember>> initialMembers =
new ConcurrentHashMap<>(); new ConcurrentHashMap<>();
@@ -920,27 +919,24 @@ final class On4KstConnectionManager {
long id, long id,
Socket socket, Socket socket,
LinkedBlockingQueue<ChatMessage> receiveQueue, LinkedBlockingQueue<ChatMessage> receiveQueue,
LinkedBlockingQueue<ChatMessage> transmitQueue, LinkedBlockingQueue<ChatMessage> transmitQueue
int mainCategory
) { ) {
this.id = id; this.id = id;
this.socket = socket; this.socket = socket;
this.receiveQueue = receiveQueue; this.receiveQueue = receiveQueue;
this.transmitQueue = transmitQueue; this.transmitQueue = transmitQueue;
this.mainCategory = mainCategory;
} }
} }
/** Maintenance action selected by the session monitor. */ /** Maintenance action selected by the session monitor. */
enum IdleAction { enum IdleAction {
NONE, NONE,
HEARTBEAT, CLIENT_LIVENESS_PROBE,
CONNECTION_PROBE,
TIMEOUT TIMEOUT
} }
/** Tracks one outstanding liveness probe for the complete TCP session. */ /** Tracks one client-initiated liveness probe for the complete TCP session. */
static final class ConnectionProbeState { static final class ClientLivenessProbeState {
private final AtomicLong sentMillis = new AtomicLong(); private final AtomicLong sentMillis = new AtomicLong();
boolean tryStart(long now) { boolean tryStart(long now) {
@@ -54,13 +54,32 @@ final class On4KstProtocol {
+ "|0|"; + "|0|";
} }
/** Builds the active liveness probe for the session's main chat. */ /** Builds the session-wide liveness probe initiated by this client. */
static String connectionProbe(int category) { static String clientLivenessProbe() {
return "RDXQ|" + category(category) + "|"; return "CK|";
} }
/** Returns whether a server frame is the expected liveness-probe response. */ /** Returns whether a server frame acknowledges a client-initiated probe. */
static boolean isConnectionProbeResponse(String frame) { 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)); return "DXQ".equals(opcode(frame));
} }
@@ -0,0 +1,312 @@
package kst4contest.controller;
import kst4contest.ApplicationConstants;
import kst4contest.model.ChatPreferences;
import kst4contest.model.OperatorProfile;
import kst4contest.utils.ApplicationFileUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;
/**
* Creates, renames, duplicates and removes operator profiles.
*
* <p>Kept free of user interface code so the behaviour can be tested without a JavaFX
* runtime. All methods work on the registry and on the files below the application
* directory.</p>
*/
public class OperatorProfileManagementService {
private static final Logger LOGGER =
Logger.getLogger(OperatorProfileManagementService.class.getName());
private final OperatorProfileStore profileStore;
public OperatorProfileManagementService() {
this(new OperatorProfileStore());
}
public OperatorProfileManagementService(final OperatorProfileStore profileStore) {
this.profileStore = profileStore;
}
/**
* Lists all profiles, including the implicit root profile of a plain installation.
*
* @return the known profiles, never empty
*/
public List<OperatorProfile> listProfiles() {
List<OperatorProfile> knownProfiles = profileStore.loadProfiles();
if (knownProfiles.isEmpty()) {
knownProfiles = new ArrayList<>();
knownProfiles.add(profileStore.buildImplicitRootProfile());
}
return knownProfiles;
}
/**
* Creates a new operator profile with its own preferences file.
*
* <p>Creating the first additional profile is also the moment the registry appears:
* the root profile is written alongside, so both are selectable afterwards.</p>
*
* @param displayName name entered by the operator
* @param sharedWorkedDatabase true to use the common station worked database
* @return the created profile, or null when it could not be stored
*/
public OperatorProfile createProfile(final String displayName, final boolean sharedWorkedDatabase) {
List<OperatorProfile> knownProfiles = listProfiles();
Set<String> takenProfileIds = new LinkedHashSet<>();
for (OperatorProfile existingProfile : knownProfiles) {
takenProfileIds.add(existingProfile.getProfileId());
}
OperatorProfile createdProfile = new OperatorProfile(
OperatorProfilePaths.toProfileId(displayName, takenProfileIds),
displayName == null || displayName.isBlank() ? "New profile" : displayName.trim(),
false,
sharedWorkedDatabase);
knownProfiles.add(createdProfile);
if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) {
return null;
}
createPreferencesFile(createdProfile, null);
return createdProfile;
}
/**
* Creates a copy of an existing profile.
*
* <p>The preferences are taken over completely except for the login credentials:
* callsign and password are cleared on purpose, because a duplicate is meant for
* another operator. Antenna, locator, layout and integration settings are exactly
* what the operator does not want to enter twice.</p>
*
* <p>The worked-station database is never copied.</p>
*
* @param sourceProfile profile to copy
* @param displayName name of the new profile
* @return the created profile, or null when it could not be stored
*/
public OperatorProfile duplicateProfile(final OperatorProfile sourceProfile, final String displayName) {
if (sourceProfile == null) {
return null;
}
List<OperatorProfile> knownProfiles = listProfiles();
Set<String> takenProfileIds = new LinkedHashSet<>();
for (OperatorProfile existingProfile : knownProfiles) {
takenProfileIds.add(existingProfile.getProfileId());
}
OperatorProfile createdProfile = new OperatorProfile(
OperatorProfilePaths.toProfileId(displayName, takenProfileIds),
displayName == null || displayName.isBlank() ? "Copy" : displayName.trim(),
false,
sourceProfile.isSharedWorkedDatabase());
knownProfiles.add(createdProfile);
if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) {
return null;
}
createPreferencesFile(createdProfile, sourceProfile);
return createdProfile;
}
/**
* Changes the visible name of a profile. The identifier and all paths stay as they are.
*
* @param profile profile to rename
* @param newDisplayName new name
* @return true if the registry was updated
*/
public boolean renameProfile(final OperatorProfile profile, final String newDisplayName) {
if (profile == null || newDisplayName == null || newDisplayName.isBlank()) {
return false;
}
List<OperatorProfile> knownProfiles = listProfiles();
for (OperatorProfile currentProfile : knownProfiles) {
if (currentProfile.getProfileId().equals(profile.getProfileId())) {
currentProfile.setDisplayName(newDisplayName.trim());
}
}
return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null));
}
/**
* Switches a profile between the common station database and its own one.
*
* @param profile profile to change
* @param sharedWorkedDatabase true to use the common station worked database
* @return true if the registry was updated
*/
public boolean setSharedWorkedDatabase(final OperatorProfile profile, final boolean sharedWorkedDatabase) {
if (profile == null || profile.isRootProfile()) {
return false;
}
List<OperatorProfile> knownProfiles = listProfiles();
for (OperatorProfile currentProfile : knownProfiles) {
if (currentProfile.getProfileId().equals(profile.getProfileId())) {
currentProfile.setSharedWorkedDatabase(sharedWorkedDatabase);
}
}
return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null));
}
/**
* Removes a profile and its directory.
*
* <p>The root profile can never be removed, because its files are the installation
* itself. A profile using the common station database keeps that database untouched;
* only its own directory is deleted.</p>
*
* @param profile profile to remove
* @return true if the profile was removed
*/
public boolean deleteProfile(final OperatorProfile profile) {
if (profile == null || profile.isRootProfile()) {
return false;
}
List<OperatorProfile> remainingProfiles = new ArrayList<>();
for (OperatorProfile currentProfile : listProfiles()) {
if (!currentProfile.getProfileId().equals(profile.getProfileId())) {
remainingProfiles.add(currentProfile);
}
}
if (!profileStore.saveProfiles(remainingProfiles,
profileStore.loadLastUsedProfileId().orElse(null))) {
return false;
}
deleteProfileDirectory(profile);
return true;
}
/**
* Returns the absolute directory of a profile.
*
* @param profile profile to resolve
* @return absolute profile directory
*/
public String getProfileDirectory(final OperatorProfile profile) {
return ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME,
OperatorProfilePaths.profileRelativeDirectory(profile));
}
/**
* Creates the preferences file of a new profile.
*
* <p>The file is either seeded from the bundled template or copied from the source
* profile. In both cases the login credentials are cleared, so a new profile never
* carries another operator's callsign or password.</p>
*
* @param createdProfile profile that needs a preferences file
* @param sourceProfile profile to copy the preferences from, or null for the template
*/
private void createPreferencesFile(final OperatorProfile createdProfile,
final OperatorProfile sourceProfile) {
String createdRelativeFileName = OperatorProfilePaths.preferencesRelativeFileName(createdProfile);
if (sourceProfile != null) {
copyPreferencesFile(
OperatorProfilePaths.preferencesRelativeFileName(sourceProfile),
createdRelativeFileName);
}
// Seeds from the bundled template when nothing was copied, and always resolves
// the preferences of the new profile.
ChatPreferences createdPreferences = new ChatPreferences(createdRelativeFileName);
createdPreferences.readPreferencesFromXmlFile();
createdPreferences.setStn_loginCallSign("");
createdPreferences.setStn_loginPassword("");
createdPreferences.writePreferencesToXmlFile();
}
private void copyPreferencesFile(final String sourceRelativeFileName,
final String targetRelativeFileName) {
Path sourcePath = Path.of(ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, sourceRelativeFileName));
Path targetPath = Path.of(ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, targetRelativeFileName));
if (!Files.isRegularFile(sourcePath)) {
return;
}
try {
Path targetDirectory = targetPath.getParent();
if (targetDirectory != null) {
Files.createDirectories(targetDirectory);
}
Files.copy(sourcePath, targetPath);
} catch (IOException e) {
LOGGER.log(Level.WARNING,
"Could not copy the preferences of the source profile, using the defaults instead", e);
}
}
private void deleteProfileDirectory(final OperatorProfile profile) {
Path profileDirectory = Path.of(getProfileDirectory(profile));
if (!Files.isDirectory(profileDirectory)) {
return;
}
try (Stream<Path> containedPaths = Files.walk(profileDirectory)) {
List<Path> deepestFirst = containedPaths
.sorted(Comparator.reverseOrder())
.toList();
for (Path currentPath : deepestFirst) {
Files.deleteIfExists(currentPath);
}
} catch (IOException e) {
LOGGER.log(Level.WARNING,
"Could not remove the directory of the deleted operator profile", e);
}
}
}
@@ -0,0 +1,199 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import java.util.Collection;
import java.util.Locale;
/**
* Derives the file names of an operator profile.
*
* <p>This is the single place that knows how a profile maps onto files. The registry
* stores only the shared/own flag, never a path, so the two can never drift apart.</p>
*/
public final class OperatorProfilePaths {
/**
* Directory below the application directory that holds the additional profiles.
*/
public static final String PROFILES_DIRECTORY = "profiles";
/**
* Identifier of the profile that uses the historic flat installation layout.
*/
public static final String ROOT_PROFILE_ID = "default";
/**
* Maximum length of a generated profile identifier.
*/
private static final int MAX_PROFILE_ID_LENGTH = 32;
private OperatorProfilePaths() {
// Utility class.
}
/**
* Builds the profile descriptor of the historic flat installation.
*
* @param displayName name to show for the root profile
* @return the root profile descriptor
*/
public static OperatorProfile buildRootProfile(final String displayName) {
return new OperatorProfile(ROOT_PROFILE_ID, displayName, true, true);
}
/**
* Returns the profile directory relative to the application directory.
*
* @param profile profile to resolve
* @return relative directory name
*/
public static String profileRelativeDirectory(final OperatorProfile profile) {
return PROFILES_DIRECTORY + "/" + profile.getProfileId();
}
/**
* Returns the preferences file name relative to the application directory.
*
* @param profile profile to resolve
* @return relative preferences file name
*/
public static String preferencesRelativeFileName(final OperatorProfile profile) {
if (profile.isRootProfile()) {
return ChatPreferences.PREFERENCES_FILE;
}
return profileRelativeDirectory(profile) + "/" + ChatPreferences.PREFERENCES_FILE;
}
/**
* Returns the worked-station database file name relative to the application directory.
*
* <p>A profile using the common station database always resolves to the historic flat
* file, which is what a multi operator station wants: the existing contest state stays
* the shared one.</p>
*
* @param profile profile to resolve
* @return relative database file name
*/
public static String workedDatabaseRelativeFileName(final OperatorProfile profile) {
if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) {
return DBController.DATABASE_FILE;
}
return profileRelativeDirectory(profile) + "/" + DBController.DATABASE_FILE;
}
/**
* Resolves a profile descriptor into the runtime selection used during startup.
*
* @param profile profile to resolve
* @return resolved selection
*/
public static OperatorProfileSelection resolve(final OperatorProfile profile) {
boolean usesSharedStationDatabase = profile.isRootProfile() || profile.isSharedWorkedDatabase();
return new OperatorProfileSelection(
profile,
preferencesRelativeFileName(profile),
workedDatabaseRelativeFileName(profile),
usesSharedStationDatabase
);
}
/**
* Derives a stable, file system safe identifier from a display name.
*
* <p>The identifier becomes a directory name and is never changed afterwards, so a
* later rename of the profile does not move any file.</p>
*
* @param displayName name entered by the operator
* @param takenProfileIds identifiers that are already in use
* @return an identifier that is not yet taken
*/
public static String toProfileId(final String displayName, final Collection<String> takenProfileIds) {
StringBuilder sanitized = new StringBuilder();
if (displayName != null) {
String foldedDisplayName = foldGermanUmlauts(displayName.toUpperCase(Locale.ROOT));
for (char currentCharacter : foldedDisplayName.toCharArray()) {
boolean isAcceptable = (currentCharacter >= 'A' && currentCharacter <= 'Z')
|| (currentCharacter >= '0' && currentCharacter <= '9')
|| currentCharacter == '-';
if (isAcceptable) {
sanitized.append(currentCharacter);
} else if (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) != '_') {
sanitized.append('_');
}
}
}
while (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) == '_') {
sanitized.setLength(sanitized.length() - 1);
}
if (sanitized.length() > MAX_PROFILE_ID_LENGTH) {
sanitized.setLength(MAX_PROFILE_ID_LENGTH);
}
String candidate = sanitized.toString();
if (candidate.isEmpty() || ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) {
candidate = "OP";
}
if (!isProfileIdTaken(candidate, takenProfileIds)) {
return candidate;
}
int suffix = 2;
while (isProfileIdTaken(candidate + "_" + suffix, takenProfileIds)) {
suffix++;
}
return candidate + "_" + suffix;
}
/**
* Folds German umlauts so a name like "Muller" written with an umlaut still produces a
* readable identifier instead of a placeholder character.
*
* @param upperCaseText already upper-cased text
* @return text with umlauts replaced by their base letters
*/
private static String foldGermanUmlauts(final String upperCaseText) {
return upperCaseText
.replace("\u00C4", "A")
.replace("\u00D6", "O")
.replace("\u00DC", "U")
.replace("\u00DF", "SS");
}
private static boolean isProfileIdTaken(final String candidate, final Collection<String> takenProfileIds) {
if (ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) {
return true;
}
if (takenProfileIds == null) {
return false;
}
for (String takenProfileId : takenProfileIds) {
if (candidate.equalsIgnoreCase(takenProfileId)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,386 @@
package kst4contest.controller;
import kst4contest.ApplicationConstants;
import kst4contest.model.OperatorProfile;
import kst4contest.utils.ApplicationFileUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.File;
import java.io.OutputStream;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Reads and writes the operator profile registry.
*
* <p>The registry file is created lazily. As long as an installation has only the
* historic flat layout, no registry exists and none is written, so a single operator
* installation behaves exactly as before. The file appears when the second profile is
* created; at that moment the root profile is materialised as well.</p>
*
* <p>A missing, unreadable or malformed registry is never fatal. It is logged and
* treated like an installation without additional profiles.</p>
*/
public class OperatorProfileStore {
private static final Logger LOGGER = Logger.getLogger(OperatorProfileStore.class.getName());
/**
* Name of the registry file inside the application directory.
*/
public static final String PROFILES_REGISTRY_FILE = "profiles.xml";
private static final String TAG_ROOT = "praktiKSTProfiles";
private static final String TAG_REGISTRY_VERSION = "registryVersion";
private static final String TAG_LAST_USED_PROFILE_ID = "lastUsedProfileId";
private static final String TAG_PROFILE = "profile";
private static final String TAG_PROFILE_ID = "profileId";
private static final String TAG_DISPLAY_NAME = "displayName";
private static final String TAG_ROOT_PROFILE = "rootProfile";
private static final String TAG_SHARED_WORKED_DATABASE = "sharedWorkedDatabase";
private static final String TAG_LAST_USED_EPOCH_MS = "lastUsedEpochMs";
private static final int REGISTRY_VERSION = 1;
private final String registryFilePath;
/**
* Creates a store working on the registry of the current installation.
*/
public OperatorProfileStore() {
this(ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, PROFILES_REGISTRY_FILE));
}
/**
* Creates a store working on an explicit registry file.
*
* @param registryFilePath absolute path of the registry file
*/
public OperatorProfileStore(final String registryFilePath) {
this.registryFilePath = registryFilePath;
}
/**
* Returns whether a registry file exists at all.
*
* @return true if the installation already has more than the historic flat layout
*/
public boolean isRegistryPresent() {
return new File(registryFilePath).isFile();
}
/**
* Builds the in-memory descriptor of the historic flat installation.
*
* <p>Nothing is written. This keeps a single operator installation untouched.</p>
*
* @return the implicit root profile
*/
public OperatorProfile buildImplicitRootProfile() {
return OperatorProfilePaths.buildRootProfile("Default");
}
/**
* Reads all stored profiles.
*
* @return the stored profiles, or an empty list when no usable registry exists
*/
public List<OperatorProfile> loadProfiles() {
List<OperatorProfile> loadedProfiles = new ArrayList<>();
Document document = readRegistryDocument();
if (document == null) {
return loadedProfiles;
}
NodeList profileNodes = document.getElementsByTagName(TAG_PROFILE);
for (int profileIndex = 0; profileIndex < profileNodes.getLength(); profileIndex++) {
Node currentNode = profileNodes.item(profileIndex);
if (currentNode.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
Element profileElement = (Element) currentNode;
String profileId = readText(profileElement, TAG_PROFILE_ID);
if (profileId == null || profileId.isBlank()) {
LOGGER.log(Level.WARNING, "Skipping operator profile entry without an identifier");
continue;
}
OperatorProfile loadedProfile = new OperatorProfile();
loadedProfile.setProfileId(profileId.trim());
loadedProfile.setDisplayName(readText(profileElement, TAG_DISPLAY_NAME));
loadedProfile.setRootProfile(readBoolean(profileElement, TAG_ROOT_PROFILE, false));
loadedProfile.setSharedWorkedDatabase(
readBoolean(profileElement, TAG_SHARED_WORKED_DATABASE, true));
loadedProfile.setLastUsedEpochMs(readLong(profileElement, TAG_LAST_USED_EPOCH_MS));
if (loadedProfile.getDisplayName() == null || loadedProfile.getDisplayName().isBlank()) {
loadedProfile.setDisplayName(loadedProfile.getProfileId());
}
// The root profile always uses the common station database, because its
// database is the historic flat file itself.
if (loadedProfile.isRootProfile()) {
loadedProfile.setSharedWorkedDatabase(true);
}
loadedProfiles.add(loadedProfile);
}
return loadedProfiles;
}
/**
* Reads the identifier of the profile that was activated last.
*
* @return the identifier, or empty when unknown
*/
public Optional<String> loadLastUsedProfileId() {
Document document = readRegistryDocument();
if (document == null) {
return Optional.empty();
}
Element rootElement = document.getDocumentElement();
if (rootElement == null) {
return Optional.empty();
}
String lastUsedProfileId = readText(rootElement, TAG_LAST_USED_PROFILE_ID);
if (lastUsedProfileId == null || lastUsedProfileId.isBlank()) {
return Optional.empty();
}
return Optional.of(lastUsedProfileId.trim());
}
/**
* Writes the complete registry.
*
* @param profiles profiles to store
* @param lastUsedProfileId identifier of the profile that was activated last, may be null
* @return true if the registry was written
*/
public boolean saveProfiles(final List<OperatorProfile> profiles, final String lastUsedProfileId) {
try {
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
Document document = documentBuilder.newDocument();
Element rootElement = document.createElement(TAG_ROOT);
document.appendChild(rootElement);
appendTextElement(document, rootElement, TAG_REGISTRY_VERSION, String.valueOf(REGISTRY_VERSION));
if (lastUsedProfileId != null && !lastUsedProfileId.isBlank()) {
appendTextElement(document, rootElement, TAG_LAST_USED_PROFILE_ID, lastUsedProfileId);
}
for (OperatorProfile currentProfile : profiles) {
Element profileElement = document.createElement(TAG_PROFILE);
rootElement.appendChild(profileElement);
appendTextElement(document, profileElement, TAG_PROFILE_ID, currentProfile.getProfileId());
appendTextElement(document, profileElement, TAG_DISPLAY_NAME, currentProfile.getDisplayName());
appendTextElement(document, profileElement, TAG_ROOT_PROFILE,
String.valueOf(currentProfile.isRootProfile()));
appendTextElement(document, profileElement, TAG_SHARED_WORKED_DATABASE,
String.valueOf(currentProfile.isRootProfile() || currentProfile.isSharedWorkedDatabase()));
appendTextElement(document, profileElement, TAG_LAST_USED_EPOCH_MS,
String.valueOf(currentProfile.getLastUsedEpochMs()));
}
return writeDocumentAtomically(document);
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Could not write the operator profile registry", e);
return false;
}
}
/**
* Records that a profile has been activated.
*
* <p>Does nothing when no registry exists, so a single operator installation is not
* turned into a multi profile installation by merely starting the application.</p>
*
* @param profileId identifier of the activated profile
* @return true if the registry was updated
*/
public boolean recordLastUsed(final String profileId) {
if (!isRegistryPresent()) {
return false;
}
List<OperatorProfile> storedProfiles = loadProfiles();
if (storedProfiles.isEmpty()) {
return false;
}
for (OperatorProfile currentProfile : storedProfiles) {
if (currentProfile.getProfileId().equalsIgnoreCase(profileId)) {
currentProfile.setLastUsedEpochMs(System.currentTimeMillis());
}
}
return saveProfiles(storedProfiles, profileId);
}
/**
* Returns the absolute path of the registry file.
*
* @return absolute registry path
*/
public String getRegistryFilePath() {
return registryFilePath;
}
private Document readRegistryDocument() {
File registryFile = new File(registryFilePath);
if (!registryFile.isFile()) {
return null;
}
try {
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
return documentBuilderFactory.newDocumentBuilder().parse(registryFile);
} catch (Exception e) {
LOGGER.log(Level.WARNING,
"Could not read the operator profile registry, continuing without additional profiles", e);
return null;
}
}
private static String readText(final Element parentElement, final String tagName) {
NodeList matchingNodes = parentElement.getElementsByTagName(tagName);
if (matchingNodes.getLength() == 0) {
return null;
}
return matchingNodes.item(0).getTextContent();
}
private static boolean readBoolean(final Element parentElement,
final String tagName,
final boolean defaultValue) {
String rawValue = readText(parentElement, tagName);
if (rawValue == null || rawValue.isBlank()) {
return defaultValue;
}
return Boolean.parseBoolean(rawValue.trim());
}
private static long readLong(final Element parentElement, final String tagName) {
String rawValue = readText(parentElement, tagName);
if (rawValue == null || rawValue.isBlank()) {
return 0L;
}
try {
return Long.parseLong(rawValue.trim());
} catch (NumberFormatException e) {
return 0L;
}
}
private static void appendTextElement(final Document document,
final Element parentElement,
final String tagName,
final String textContent) {
Element createdElement = document.createElement(tagName);
createdElement.setTextContent(textContent == null ? "" : textContent);
parentElement.appendChild(createdElement);
}
/**
* Writes the registry through a temporary file so a crash can never leave a
* half-written registry behind. This mirrors the established preferences writer.
*
* @param document document to write
* @return true if the registry file was replaced
*/
private boolean writeDocumentAtomically(final Document document) {
Path targetPath = Path.of(registryFilePath).toAbsolutePath();
Path parentDirectory = targetPath.getParent();
if (parentDirectory == null) {
LOGGER.log(Level.SEVERE,
"The operator profile registry path has no directory: {0}", registryFilePath);
return false;
}
try {
Files.createDirectories(parentDirectory);
// The temporary file has to live next to the target so the final move can be
// atomic; both must be on the same file system.
Path temporaryPath = Files.createTempFile(
parentDirectory, PROFILES_REGISTRY_FILE, ".tmp");
Transformer transformer = TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");
try (OutputStream outputStream = Files.newOutputStream(temporaryPath)) {
transformer.transform(new DOMSource(document), new StreamResult(outputStream));
}
try {
Files.move(temporaryPath, targetPath,
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException atomicMoveUnsupported) {
Files.move(temporaryPath, targetPath, StandardCopyOption.REPLACE_EXISTING);
}
return true;
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Could not store the operator profile registry", e);
return false;
}
}
}
@@ -86,11 +86,20 @@ public class ReadThread extends Thread {
throw new EOFException("ON4KST closed the TCP connection"); throw new EOFException("ON4KST closed the TCP connection");
} }
if (!sessionIsActive.test(sessionId)) {
break;
}
inboundActivity.accept(response); inboundActivity.accept(response);
if (!sessionIsActive.test(sessionId)) { if (!sessionIsActive.test(sessionId)) {
break; break;
} }
if (On4KstProtocol.isClientLivenessProbeResponse(response)) {
// OK and OK| acknowledge the client-side CK| probe.
continue;
}
ChatMessage message = new ChatMessage(); ChatMessage message = new ChatMessage();
message.setMessageText(response); message.setMessageText(response);
receiveQueue.put(message); receiveQueue.put(message);
@@ -127,4 +136,4 @@ public class ReadThread extends Thread {
socket.close(); socket.close();
return true; return true;
} }
} }
@@ -33,6 +33,24 @@ public final class SkedReminderService {
this.controller = controller; this.controller = controller;
} }
/**
* Cancels every armed reminder and stops the scheduler thread.
*
* <p>Called when the runtime that owns this service is torn down, so a discarded
* runtime does not keep a thread and pending reminders alive.</p>
*/
public void shutdown() {
for (List<ScheduledFuture<?>> remindersOfOneCall : scheduledByCallRaw.values()) {
for (ScheduledFuture<?> armedReminder : remindersOfOneCall) {
armedReminder.cancel(false);
}
}
scheduledByCallRaw.clear();
scheduler.shutdownNow();
}
/** /**
* Arms reminders for one sked. Existing reminders for this call are cancelled. * Arms reminders for one sked. Existing reminders for this call are cancelled.
* *
@@ -79,7 +79,26 @@ public class ChatPreferences {
* TODO: delete this from the kst4contest.view/Main.java! * TODO: delete this from the kst4contest.view/Main.java!
*/ */
public ChatPreferences() { public ChatPreferences() {
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, PREFERENCES_FILE); this(PREFERENCES_FILE);
}
/**
* Creates preferences bound to one operator profile.
*
* <p>The file name is resolved below the application directory, so both
* "preferences.xml" for the root installation and "profiles/OP2/preferences.xml"
* for an additional operator profile are valid. A missing file is seeded from the
* bundled template, which gives a new profile the same clean defaults a first-ever
* installation gets.</p>
*
* @param applicationRelativeFileName preferences file name relative to the application directory
*/
public ChatPreferences(final String applicationRelativeFileName) {
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, PREFERENCE_RESOURCE, applicationRelativeFileName);
this.storeAndRestorePreferencesFileName = ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME,
applicationRelativeFileName
);
// lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB"); // lstNotify_QSOSniffer_sniffedCallSignList.add("DF0GEB");
@@ -168,8 +187,14 @@ public class ChatPreferences {
int stn_pstRotatorPort = 12000; int stn_pstRotatorPort = 12000;
boolean stn_loginAFKState = false; //always start as here boolean stn_loginAFKState = false; //always start as here
String stn_loginCallSign = "do5amf"; /*
String stn_loginCallSignRaw = "do5amf"; //for example: do5amf instead of logincallsign do5amf-2 * The login credentials default to empty on purpose. A missing or empty value in
* preferences.xml means "not configured yet", and falling back to a real callsign
* would let an operator transmit under someone else's call. This matters for every
* additional operator profile, whose preferences are created without credentials.
*/
String stn_loginCallSign = "";
String stn_loginCallSignRaw = ""; //for example: do5amf instead of logincallsign do5amf-2
String stn_loginPassword = ""; String stn_loginPassword = "";
String stn_loginNameMainCat = "KST4Contest"; String stn_loginNameMainCat = "KST4Contest";
String stn_loginNameSecondCat = "KST4ContestSHF"; String stn_loginNameSecondCat = "KST4ContestSHF";
@@ -0,0 +1,133 @@
package kst4contest.model;
import java.util.Objects;
/**
* Descriptor of one operator profile.
*
* <p>A profile always owns its own preferences file. Whether it also owns its own
* worked-station database is decided by {@link #isSharedWorkedDatabase()}: a multi
* operator contest station keeps one common log and therefore shares the database,
* while two operators sharing a private computer usually want their worked data kept
* apart.</p>
*
* <p>The descriptor deliberately carries no file paths. They are derived in exactly one
* place, {@link kst4contest.controller.OperatorProfilePaths}, so a stored path can never
* drift apart from the flag that produced it.</p>
*/
public class OperatorProfile {
/**
* Stable identifier of the profile. It is assigned once and never changes, so
* renaming a profile never moves a directory.
*/
private String profileId;
/**
* Name shown in the profile picker and in the settings window.
*/
private String displayName;
/**
* True for the profile that uses the historic flat installation layout directly.
*/
private boolean rootProfile;
/**
* True if this profile uses the common station worked-station database.
*/
private boolean sharedWorkedDatabase;
/**
* Timestamp of the last activation, used to preselect an entry in the picker.
*/
private long lastUsedEpochMs;
public OperatorProfile() {
// Default constructor for stepwise construction while reading the registry.
}
public OperatorProfile(final String profileId,
final String displayName,
final boolean rootProfile,
final boolean sharedWorkedDatabase) {
this.profileId = profileId;
this.displayName = displayName;
this.rootProfile = rootProfile;
this.sharedWorkedDatabase = sharedWorkedDatabase;
}
public String getProfileId() {
return profileId;
}
public void setProfileId(final String profileId) {
this.profileId = profileId;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(final String displayName) {
this.displayName = displayName;
}
public boolean isRootProfile() {
return rootProfile;
}
public void setRootProfile(final boolean rootProfile) {
this.rootProfile = rootProfile;
}
public boolean isSharedWorkedDatabase() {
return sharedWorkedDatabase;
}
public void setSharedWorkedDatabase(final boolean sharedWorkedDatabase) {
this.sharedWorkedDatabase = sharedWorkedDatabase;
}
public long getLastUsedEpochMs() {
return lastUsedEpochMs;
}
public void setLastUsedEpochMs(final long lastUsedEpochMs) {
this.lastUsedEpochMs = lastUsedEpochMs;
}
@Override
public boolean equals(final Object other) {
if (this == other) {
return true;
}
if (!(other instanceof OperatorProfile)) {
return false;
}
return Objects.equals(profileId, ((OperatorProfile) other).profileId);
}
@Override
public int hashCode() {
return Objects.hashCode(profileId);
}
/**
* Returns the display name so the descriptor can be shown in a list control directly.
*
* @return the display name, or the profile id when no name was set
*/
@Override
public String toString() {
if (displayName == null || displayName.isBlank()) {
return String.valueOf(profileId);
}
return displayName;
}
}
@@ -0,0 +1,70 @@
package kst4contest.model;
import kst4contest.ApplicationConstants;
import kst4contest.utils.ApplicationFileUtils;
import java.util.Objects;
/**
* Resolved runtime view of the active operator profile.
*
* <p>This is the only profile information the rest of the application needs: two file
* names relative to the application directory plus the flag whether a missing
* worked-station database may be seeded from the bundled template. Everything else is
* derived from the descriptor.</p>
*/
public class OperatorProfileSelection {
private final OperatorProfile profile;
private final String preferencesRelativeFileName;
private final String workedDatabaseRelativeFileName;
private final boolean seedWorkedDatabaseFromResource;
public OperatorProfileSelection(final OperatorProfile profile,
final String preferencesRelativeFileName,
final String workedDatabaseRelativeFileName,
final boolean seedWorkedDatabaseFromResource) {
this.profile = Objects.requireNonNull(profile, "profile");
this.preferencesRelativeFileName =
Objects.requireNonNull(preferencesRelativeFileName, "preferencesRelativeFileName");
this.workedDatabaseRelativeFileName =
Objects.requireNonNull(workedDatabaseRelativeFileName, "workedDatabaseRelativeFileName");
this.seedWorkedDatabaseFromResource = seedWorkedDatabaseFromResource;
}
public OperatorProfile getProfile() {
return profile;
}
public String getPreferencesRelativeFileName() {
return preferencesRelativeFileName;
}
public String getWorkedDatabaseRelativeFileName() {
return workedDatabaseRelativeFileName;
}
public boolean isSeedWorkedDatabaseFromResource() {
return seedWorkedDatabaseFromResource;
}
/**
* Returns the absolute preferences path, for display in the settings window.
*
* @return absolute path of the preferences file
*/
public String getPreferencesAbsolutePath() {
return ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, preferencesRelativeFileName);
}
/**
* Returns the absolute worked-station database path, for display in the settings window.
*
* @return absolute path of the worked-station database
*/
public String getWorkedDatabaseAbsolutePath() {
return ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, workedDatabaseRelativeFileName);
}
}
@@ -0,0 +1,29 @@
package kst4contest.utils;
import java.util.Locale;
/**
* Small helpers for operating-system specific UI behaviour.
*/
public final class PlatformUtils {
private PlatformUtils() {
}
/**
* @return true if the running JVM reports macOS as operating system
*/
public static boolean isMacOs() {
return isMacOs(System.getProperty("os.name"));
}
/**
* Checks an {@code os.name} value for macOS. A missing value is treated as "not macOS".
*/
public static boolean isMacOs(String osName) {
if (osName == null) {
return false;
}
return osName.toLowerCase(Locale.ROOT).startsWith("mac");
}
}
@@ -0,0 +1,106 @@
package kst4contest.view;
import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import javafx.application.Platform;
import javafx.stage.Stage;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Owns the lifecycle of the running application.
*
* <p>Switching the operator profile tears the current runtime down completely and builds
* a fresh one in the same process. Rebinding the existing windows is not an option: the
* user interface is built from the chat controller outwards, with several hundred
* references to the active preferences, and many controls are instance fields created
* once. A new {@link Kst4ContestApplication} instance gets fresh controls, which is safe
* here because the class keeps no mutable static state.</p>
*/
public final class ApplicationRuntimeLauncher {
private static final Logger LOGGER = Logger.getLogger(ApplicationRuntimeLauncher.class.getName());
private static Kst4ContestApplication currentRuntime;
private ApplicationRuntimeLauncher() {
// Utility class.
}
/**
* Registers the runtime that is currently live.
*
* @param runtime the running application instance
*/
public static void setCurrent(final Kst4ContestApplication runtime) {
currentRuntime = runtime;
}
/**
* Returns the runtime that is currently live.
*
* @return the running application instance, or null before the first startup
*/
public static Kst4ContestApplication getCurrent() {
return currentRuntime;
}
/**
* Shuts the application down.
*
* <p>JavaFX only calls {@code stop()} on the instance it launched itself, so an exit
* after a profile switch has to release the resources explicitly.</p>
*/
public static void exitApplication() {
if (currentRuntime != null) {
currentRuntime.shutdownRuntime();
}
Platform.exit();
System.exit(0);
}
/**
* Replaces the running runtime with one bound to another operator profile.
*
* @param targetProfile profile to activate
* @return true if the new runtime was built
*/
public static boolean switchProfile(final OperatorProfile targetProfile) {
if (targetProfile == null) {
return false;
}
new OperatorProfileStore().recordLastUsed(targetProfile.getProfileId());
if (currentRuntime != null) {
currentRuntime.shutdownRuntime();
}
ActiveOperatorProfile.set(OperatorProfilePaths.resolve(targetProfile));
Kst4ContestApplication nextRuntime = new Kst4ContestApplication();
try {
nextRuntime.start(new Stage());
} catch (Exception e) {
// The previous runtime is already gone, so there is nothing left to return to.
LOGGER.log(Level.SEVERE, "Could not start the selected operator profile", e);
Kst4ContestApplication.alertWindowEvent(
"The operator profile could not be started: " + e.getMessage()
+ "\n\nKST4Contest has to be closed.");
Platform.exit();
System.exit(1);
return false;
}
setCurrent(nextRuntime);
return true;
}
}
@@ -0,0 +1,102 @@
package kst4contest.view;
import java.util.List;
/**
* Command line options of the application.
*
* <p>The parsed value is additionally kept in a process wide holder. JavaFX only knows
* the parameters of the {@code Application} instance it launched itself, so an instance
* created during a profile switch would see no parameters at all. Parsing once at
* startup and remembering the result avoids that entirely.</p>
*/
public class CommandLineOptions {
/**
* Command line switch selecting the operator profile to start with.
*/
public static final String PROFILE_ARGUMENT = "--profile";
/**
* System property used as an alternative to the command line switch.
*/
public static final String PROFILE_SYSTEM_PROPERTY = "kst4contest.profile";
private static volatile CommandLineOptions rememberedOptions = new CommandLineOptions(null);
private final String requestedProfileName;
public CommandLineOptions(final String requestedProfileName) {
this.requestedProfileName = requestedProfileName;
}
/**
* Parses the raw application arguments.
*
* <p>Unknown arguments are ignored on purpose. A typo in a command line must never
* keep an operator out of the application shortly before a contest.</p>
*
* @param rawArguments raw arguments, may be null
* @return the parsed options
*/
public static CommandLineOptions parse(final List<String> rawArguments) {
String requestedProfileName = null;
if (rawArguments != null) {
for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) {
String currentArgument = rawArguments.get(argumentIndex);
if (currentArgument == null) {
continue;
}
if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
requestedProfileName = currentArgument.substring(PROFILE_ARGUMENT.length() + 1);
} else if (PROFILE_ARGUMENT.equals(currentArgument)
&& argumentIndex + 1 < rawArguments.size()) {
requestedProfileName = rawArguments.get(argumentIndex + 1);
argumentIndex++;
}
}
}
if (requestedProfileName == null || requestedProfileName.isBlank()) {
requestedProfileName = System.getProperty(PROFILE_SYSTEM_PROPERTY);
}
if (requestedProfileName != null && requestedProfileName.isBlank()) {
requestedProfileName = null;
}
return new CommandLineOptions(
requestedProfileName == null ? null : requestedProfileName.trim());
}
/**
* Stores the parsed options for the lifetime of the process.
*
* @param options options to remember
*/
public static void remember(final CommandLineOptions options) {
rememberedOptions = options == null ? new CommandLineOptions(null) : options;
}
/**
* Returns the options parsed at application startup.
*
* @return the remembered options, never null
*/
public static CommandLineOptions remembered() {
return rememberedOptions;
}
/**
* Returns the operator profile requested on the command line.
*
* @return the requested profile name, or null when none was given
*/
public String getRequestedProfileName() {
return requestedProfileName;
}
}
@@ -15,6 +15,7 @@ import java.util.logging.SimpleFormatter;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Predicate; import java.util.function.Predicate;
import javafx.animation.Animation;
import javafx.animation.KeyFrame; import javafx.animation.KeyFrame;
import javafx.animation.Timeline; import javafx.animation.Timeline;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
@@ -69,7 +70,14 @@ import javafx.stage.Screen;
import kst4contest.logic.BandOpportunityResolver; import kst4contest.logic.BandOpportunityResolver;
import kst4contest.utils.ApplicationFileUtils; import kst4contest.utils.ApplicationFileUtils;
import kst4contest.utils.PlatformUtils;
import kst4contest.view.map.StationMapBridge; import kst4contest.view.map.StationMapBridge;
import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.controller.OperatorProfileManagementService;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import kst4contest.view.map.StationMapView; import kst4contest.view.map.StationMapView;
import kst4contest.view.map.OfflineDemImportService; import kst4contest.view.map.OfflineDemImportService;
import kst4contest.controller.WorkedGrossFieldCache; import kst4contest.controller.WorkedGrossFieldCache;
@@ -126,6 +134,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private On4KstConnectionState lastDisplayedConnectionState; private On4KstConnectionState lastDisplayedConnectionState;
private String lastDisplayedConnectionDetail = ""; private String lastDisplayedConnectionDetail = "";
/** Main window menu bar; its menus are shared with the secondary windows on macOS. */
private MenuBar mainScreenMenuBar;
/** macOS only: connection state shown as menu title in the system menu bar. */
private final Menu menuConnectionStateMacOs = new Menu("LINK");
private final MenuItem menuItemConnectionStateDetailMacOs = new MenuItem();
/** Scenes whose stylesheet follows the light/dark design switch. */
private final List<Scene> themedScenes = new ArrayList<>();
private final Button btnBandUpgradeIndicator = new Button("BAND+"); private final Button btnBandUpgradeIndicator = new Button("BAND+");
private final Tooltip tipBandUpgradeIndicator = new Tooltip(); private final Tooltip tipBandUpgradeIndicator = new Tooltip();
private Timeline bandUpgradeBlinkTimeline; private Timeline bandUpgradeBlinkTimeline;
@@ -191,6 +207,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatcontroller.getChatPreferences(), chatcontroller.getChatPreferences(),
this::requestLayoutSave this::requestLayoutSave
); );
installSharedSystemMenuBar(stationMapView.getScene());
stationMapBridge = new StationMapBridge( stationMapBridge = new StationMapBridge(
chatcontroller, chatcontroller,
tbl_chatMember, tbl_chatMember,
@@ -4582,7 +4599,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
root.setCenter(listView); root.setCenter(listView);
root.setBottom(new Label("Double-click a candidate to select it.")); root.setBottom(new Label("Double-click a candidate to select it."));
stage.setScene(new Scene(root, 360, 500)); Scene candidatesScene = new Scene(root, 360, 500);
registerThemedScene(candidatesScene);
stage.setOnHidden(event -> themedScenes.remove(candidatesScene));
installSharedSystemMenuBar(candidatesScene);
stage.setScene(candidatesScene);
stage.show(); stage.show();
} }
@@ -5500,6 +5521,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}); });
MenuItem menuItemFileSwitchProfile = new MenuItem("Switch operator profile...");
menuItemFileSwitchProfile.setOnAction(event -> showOperatorProfileSwitchDialog());
MenuItem m10 = new MenuItem("Exit + disconnect"); MenuItem m10 = new MenuItem("Exit + disconnect");
m10.setOnAction(new EventHandler<ActionEvent>() { m10.setOnAction(new EventHandler<ActionEvent>() {
public void handle(ActionEvent event) { public void handle(ActionEvent event) {
@@ -5510,6 +5534,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// add menu items to menu // add menu items to menu
fileMenu.getItems().add(menuItemFileConnect); fileMenu.getItems().add(menuItemFileConnect);
fileMenu.getItems().add(menuItemFileDisconnect); fileMenu.getItems().add(menuItemFileDisconnect);
fileMenu.getItems().add(menuItemFileSwitchProfile);
fileMenu.getItems().add(m10); fileMenu.getItems().add(m10);
Menu optionsMenu = new Menu("Options"); Menu optionsMenu = new Menu("Options");
@@ -5610,21 +5635,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
window30.setOnAction(new EventHandler<ActionEvent>() { window30.setOnAction(new EventHandler<ActionEvent>() {
public void handle(ActionEvent event) { public void handle(ActionEvent event) {
System.out.println("KST4ContestApp, info: switching to dark mode"); // quick switch for this session only, the startup design is set per profile in the GUI options
applyTheme(true);
scn_ChatwindowMainScene.getStylesheets().clear();
clusterAndQSOMonScene.getStylesheets().clear();
settingsScene.getStylesheets().clear();
setUserAgentStylesheet(null);
scn_ChatwindowMainScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING);
clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING);
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING);
chatcontroller.getChatPreferences().setGUI_darkModeActive(true);
if (stationMapBridge != null) {
stationMapBridge.applyThemeFromPreferences(); //dark mode for the map
}
} }
}); });
@@ -5632,21 +5644,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
window40.setOnAction(new EventHandler<ActionEvent>() { window40.setOnAction(new EventHandler<ActionEvent>() {
public void handle(ActionEvent event) { public void handle(ActionEvent event) {
System.out.println("KST4ContestApp, info: switching to default mode"); // quick switch for this session only, the startup design is set per profile in the GUI options
applyTheme(false);
scn_ChatwindowMainScene.getStylesheets().clear();
clusterAndQSOMonScene.getStylesheets().clear();
settingsScene.getStylesheets().clear();
setUserAgentStylesheet(null);
scn_ChatwindowMainScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
chatcontroller.getChatPreferences().setGUI_darkModeActive(false);
if (stationMapBridge != null) {
stationMapBridge.applyThemeFromPreferences();
}
} }
}); });
@@ -5765,9 +5764,99 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
MenuBar menubar = new MenuBar(); MenuBar menubar = new MenuBar();
menubar.getMenus().addAll(fileMenu, optionsMenu, windowMenu, helpMenu); // macromenu deleted menubar.getMenus().addAll(fileMenu, optionsMenu, windowMenu, helpMenu); // macromenu deleted
// On macOS the menu belongs into the system menu bar at the top of the screen.
// The node stays in the status bar pane but is not rendered there.
// The connection state indicator moves into the menu bar as a read-only menu.
if (PlatformUtils.isMacOs()) {
menuItemConnectionStateDetailMacOs.setDisable(true);
menuConnectionStateMacOs.getItems().setAll(menuItemConnectionStateDetailMacOs);
menubar.getMenus().add(menuConnectionStateMacOs);
menubar.setUseSystemMenuBar(true);
}
return menubar; return menubar;
} }
/**
* On macOS, attaches an invisible system menu bar to a secondary window, so the
* main menus stay visible in the screen menu bar while that window has the focus.
* The menus are shared with the main window instead of copied, so enabled state and
* labels stay consistent everywhere. Does nothing on other platforms.
*/
private void installSharedSystemMenuBar(Scene scene) {
if (!PlatformUtils.isMacOs() || mainScreenMenuBar == null || scene == null) {
return;
}
Pane rootPane;
if (scene.getRoot() instanceof Pane pane) {
rootPane = pane;
} else {
// Controls like SplitPane do not accept extra children, so wrap them.
rootPane = new BorderPane(scene.getRoot());
scene.setRoot(rootPane);
}
MenuBar sharedMenuBar = new MenuBar();
sharedMenuBar.setUseSystemMenuBar(true);
sharedMenuBar.setManaged(false);
Bindings.bindContent(sharedMenuBar.getMenus(), mainScreenMenuBar.getMenus());
rootPane.getChildren().add(sharedMenuBar);
}
/**
* Registers a scene whose stylesheet follows the light/dark design switch and
* applies the currently active design to it.
*/
private void registerThemedScene(Scene scene) {
if (scene == null) {
return;
}
themedScenes.add(scene);
applyThemeStylesheet(scene, chatcontroller.getChatPreferences().isGUI_darkModeActive());
}
/**
* Switches all registered windows and the station map to the dark or light design
* for the running session. The per-profile startup design is not changed here.
*/
private void applyTheme(boolean darkMode) {
LOGGER.info("Switching GUI design to " + (darkMode ? "dark" : "light") + " mode");
chatcontroller.getChatPreferences().setGUI_darkModeActive(darkMode);
setUserAgentStylesheet(null);
for (Scene scene : themedScenes) {
applyThemeStylesheet(scene, darkMode);
}
if (stationMapBridge != null) {
stationMapBridge.applyThemeFromPreferences();
}
}
private static void applyThemeStylesheet(Scene scene, boolean darkMode) {
scene.getStylesheets().setAll(darkMode
? ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING
: ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
}
/**
* Title of the macOS connection state menu, e.g. "🟢 LINK: Connected".
*/
static String macOsConnectionStateMenuTitle(On4KstConnectionState state) {
On4KstConnectionState effectiveState = state == null
? On4KstConnectionState.DISCONNECTED : state;
return switch (effectiveState) {
case ONLINE -> "🟢 LINK: Connected";
case CONNECTING, WAITING_FOR_LOGIN_PROMPT, AUTHENTICATING,
SYNCING_MAIN_CHAT, SYNCING_SECOND_CHAT -> "🟡 LINK: Connecting…";
case STOPPING -> "🟡 LINK: Disconnecting…";
case RECONNECT_WAIT -> "🔴 LINK: Reconnecting…";
case DISCONNECTED -> "🔴 LINK: Disconnected";
};
}
/***************************************************** /*****************************************************
* Sked warning Initializing and functional section * Sked warning Initializing and functional section
@@ -5836,6 +5925,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
tipConnectionStateIndicator.setText( tipConnectionStateIndicator.setText(
"ON4KST link: " + effectiveState.name() + "\n" + stateDetail); "ON4KST link: " + effectiveState.name() + "\n" + stateDetail);
btnConnectionStateIndicator.setAccessibleHelp(stateDetail); btnConnectionStateIndicator.setAccessibleHelp(stateDetail);
menuConnectionStateMacOs.setText(macOsConnectionStateMenuTitle(effectiveState));
menuItemConnectionStateDetailMacOs.setText(stateDetail);
String commonStyle = String commonStyle =
"-fx-font-size: 10px;" "-fx-font-size: 10px;"
@@ -6096,6 +6187,17 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
FlowPane flwPane_textSnippets; FlowPane flwPane_textSnippets;
FlowPane flwpne_StatusBar; FlowPane flwpne_StatusBar;
/**
* True once this runtime released its resources. Shutdown must stay idempotent
* because it is reached both through the JavaFX stop() callback and explicitly.
*/
private boolean runtimeShutdownDone;
/**
* The primary stage of this runtime, remembered so shutdown can close it.
*/
private Stage ownPrimaryStage;
Stage clusterAndQSOMonStage; Stage clusterAndQSOMonStage;
// Stage stage_selectedCallSignInfoStage; // Stage stage_selectedCallSignInfoStage;
ChatMember selectedCallSignInfoStageChatMember; ChatMember selectedCallSignInfoStageChatMember;
@@ -6227,29 +6329,264 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return txMessageButtons; return txMessageButtons;
} }
/**
* Resolves the operator profile this runtime works with.
*
* <p>Only executed on the very first launch. A profile switch sets the profile before
* building the new runtime, so the resolution is skipped there.</p>
*
* <p>An installation with no or exactly one profile is resolved without asking
* anything, which keeps the single operator startup exactly as it was.</p>
*
* @return true if the application may continue starting up
*/
private boolean resolveOperatorProfileIfRequired() {
if (ActiveOperatorProfile.isInitialized()) {
return true;
}
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
OperatorProfileSelection resolvedProfile = bootstrap.resolveAtStartup(
new OperatorProfileStore(),
CommandLineOptions.remembered(),
OperatorProfilePickerDialog::showAndSelect);
if (bootstrap.getStartupWarning() != null) {
Alert startupWarning = new Alert(AlertType.WARNING);
startupWarning.setTitle("Operator profile");
startupWarning.setHeaderText("The requested operator profile was not found.");
startupWarning.setContentText(bootstrap.getStartupWarning());
startupWarning.showAndWait();
}
if (resolvedProfile == null) {
Platform.exit();
System.exit(0);
return false;
}
ActiveOperatorProfile.set(resolvedProfile);
return true;
}
/**
* Returns the window title suffix naming the active operator profile.
*
* <p>Empty for the historic single profile installation, so nothing changes visually
* for operators who never create a second profile.</p>
*
* @return the suffix to append to a window title, never null
*/
private String buildOperatorProfileTitleSuffix() {
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
if (activeProfile == null || activeProfile.getProfile().isRootProfile()) {
return "";
}
return " - " + activeProfile.getProfile().getDisplayName();
}
/**
* Lets the operator pick another profile and rebuilds the runtime for it.
*
* <p>Offers to create a second profile when only one exists, because the menu entry
* is the discoverable place to find the feature at all.</p>
*/
private void showOperatorProfileSwitchDialog() {
OperatorProfileStore profileStore = new OperatorProfileStore();
List<OperatorProfile> selectableProfiles = profileStore.loadProfiles();
if (selectableProfiles.size() < 2) {
Alert noProfilesYet = new Alert(AlertType.INFORMATION);
noProfilesYet.setTitle("Operator profiles");
noProfilesYet.setHeaderText("Only one operator profile is configured.");
noProfilesYet.setContentText(
"Additional profiles are created in the settings window on the "
+ "\"Profiles\" tab. Each profile keeps its own settings and layout, "
+ "and can either share the station worked database or use its own.");
noProfilesYet.showAndWait();
return;
}
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
String activeProfileId = activeProfile == null ? null : activeProfile.getProfile().getProfileId();
Optional<OperatorProfile> chosenProfile =
OperatorProfilePickerDialog.showAndSelect(selectableProfiles, activeProfileId);
if (chosenProfile.isEmpty()) {
return;
}
if (chosenProfile.get().getProfileId().equalsIgnoreCase(activeProfileId)) {
return;
}
requestOperatorProfileSwitch(chosenProfile.get());
}
/**
* Confirms and performs a switch to another operator profile.
*
* <p>Shared by the File menu and the profile settings tab, so both ask the same
* question before giving up the running session.</p>
*
* @param targetProfile profile to activate
*/
private void requestOperatorProfileSwitch(OperatorProfile targetProfile) {
if (targetProfile == null || !confirmOperatorProfileSwitch(targetProfile)) {
return;
}
ApplicationRuntimeLauncher.switchProfile(targetProfile);
}
/**
* Asks whether the running session may be given up for a profile switch.
*
* @param targetProfile profile the operator selected
* @return true if the switch may proceed
*/
private boolean confirmOperatorProfileSwitch(OperatorProfile targetProfile) {
Alert confirmation = new Alert(AlertType.CONFIRMATION);
confirmation.setTitle("Switch operator profile");
confirmation.setHeaderText("Switch to \"" + targetProfile.getDisplayName() + "\"?");
confirmation.setContentText(
"The ON4KST connection is closed and all windows are rebuilt with the "
+ "settings and layout of the selected profile.\n\n"
+ "Unsaved settings of the current profile are lost. Window sizes, "
+ "divider and column widths are saved automatically.");
ButtonType switchButton = new ButtonType("Switch profile", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
confirmation.getButtonTypes().setAll(switchButton, cancelButton);
return confirmation.showAndWait().orElse(cancelButton) == switchButton;
}
@Override @Override
public void stop() { public void stop() {
System.out.println("[Main.java, Info:] Stage is closing, killing all resources"); shutdownRuntime();
if (layoutAutosave != null) { System.exit(0);
layoutAutosave.flushPending(); }
/**
* Releases every resource this runtime owns, without terminating the process.
*
* <p>Separated from {@link #stop()} so the same teardown can be reused when the
* operator switches to another profile and a fresh runtime is built afterwards.
* The method is idempotent and tolerates a runtime that never connected, because a
* switch may happen before the first login.</p>
*/
public void shutdownRuntime() {
if (runtimeShutdownDone) {
return;
} }
timer_buildWindowTitle.purge();
timer_buildWindowTitle.cancel(); runtimeShutdownDone = true;
System.out.println("[Main.java, Info:] Stage is closing, killing all resources");
if (layoutAutosave != null) {
// Flush before cancelling, otherwise a pending debounced write would either
// be lost or land after a profile switch.
layoutAutosave.flushPending();
layoutAutosave.cancelPending();
}
cancelViewTimer(timer_buildWindowTitle);
timer_buildWindowTitle = null;
// timer_chatMemberTableSortTimer.purge(); // timer_chatMemberTableSortTimer.purge();
// timer_chatMemberTableSortTimer.cancel(); // timer_chatMemberTableSortTimer.cancel();
timer_updatePrivatemessageTable.purge(); cancelViewTimer(timer_updatePrivatemessageTable);
timer_updatePrivatemessageTable.cancel(); timer_updatePrivatemessageTable = null;
stopAnimation(userListRefreshCoalescer);
userListRefreshCoalescer = null;
stopAnimation(skedWarnBlinkTimeline);
skedWarnBlinkTimeline = null;
stopAnimation(bandUpgradeBlinkTimeline);
bandUpgradeBlinkTimeline = null;
if (stationMapBridge != null) {
stationMapBridge.uninstall();
stationMapBridge = null;
}
if (stationMapView != null) {
stationMapView.dispose();
stationMapView = null;
}
closeOwnedStages();
try { try {
chatcontroller.disconnect("CLOSEALL"); if (chatcontroller != null) {
chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECT_AND_CLOSE);
}
} catch (Exception e) { } catch (Exception e) {
System.out.println("[Main.java, Warning:] Exception during disconnect: " + e.getMessage()); System.out.println("[Main.java, Warning:] Exception during disconnect: " + e.getMessage());
} }
}
// Platform.exit(); /**
System.exit(0); * Cancels a timer created during user interface construction.
*
* @param timerToCancel timer to cancel, may be null when startup did not get that far
*/
private static void cancelViewTimer(Timer timerToCancel) {
if (timerToCancel == null) {
return;
}
timerToCancel.purge();
timerToCancel.cancel();
}
/**
* Stops a JavaFX animation if it exists.
*
* @param animationToStop animation to stop, may be null
*/
private static void stopAnimation(Animation animationToStop) {
if (animationToStop != null) {
animationToStop.stop();
}
}
/**
* Closes every window this runtime opened, so no stale window survives a profile
* switch. The map window is closed by its own dispose method.
*/
private void closeOwnedStages() {
for (Stage ownedStage : new Stage[] {
settingsStage, clusterAndQSOMonStage, stage_updateStage, ownPrimaryStage }) {
if (ownedStage != null) {
try {
ownedStage.close();
} catch (Exception e) {
System.out.println("[Main.java, Warning:] Could not close a window: " + e.getMessage());
}
}
}
settingsStage = null;
clusterAndQSOMonStage = null;
stage_updateStage = null;
ownPrimaryStage = null;
} }
private void requestLayoutSave() { private void requestLayoutSave() {
@@ -6644,9 +6981,36 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}; };
} }
@Override
public void init() {
Parameters applicationParameters = getParameters();
CommandLineOptions.remember(CommandLineOptions.parse(
applicationParameters == null ? null : applicationParameters.getRaw()));
}
@Override @Override
public void start(Stage primaryStage) throws InterruptedException, IOException, URISyntaxException { public void start(Stage primaryStage) throws InterruptedException, IOException, URISyntaxException {
if (!resolveOperatorProfileIfRequired()) {
return;
}
ownPrimaryStage = primaryStage;
/*
* A profile switch closes every window of the old runtime before the new one
* exists. With the JavaFX default that would end the process, so the application
* takes over the exit decision and closing the main window is handled explicitly.
*/
Platform.setImplicitExit(false);
primaryStage.setOnCloseRequest(closeRequest -> {
closeRequest.consume();
ApplicationRuntimeLauncher.exitApplication();
});
ApplicationRuntimeLauncher.setCurrent(this);
GuiUtils.applyApplicationIcon(primaryStage); GuiUtils.applyApplicationIcon(primaryStage);
VBox pnl_inputAndSendButtons = new VBox(); //gets the sendtext field, send button and the timeline VBox pnl_inputAndSendButtons = new VBox(); //gets the sendtext field, send button and the timeline
@@ -6681,9 +7045,19 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSDAY_RESOURCE, STYLE_DEFAULTCSSDAY_FILE); ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSDAY_RESOURCE, STYLE_DEFAULTCSSDAY_FILE);
ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSEVENING_RESOURCE, STYLE_DEFAULTCSSEVENING_FILE); ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSEVENING_RESOURCE, STYLE_DEFAULTCSSEVENING_FILE);
ChatMember ownChatMemberObject = new ChatMember(); ChatMember ownChatMemberObject = new ChatMember();
OperatorProfileSelection activeOperatorProfile = ActiveOperatorProfile.get();
chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object // instantiate the Chatcontroller with the user object and the files of the active profile
chatcontroller = new ChatController(
ownChatMemberObject,
this,
activeOperatorProfile.getPreferencesRelativeFileName(),
activeOperatorProfile.getWorkedDatabaseRelativeFileName(),
activeOperatorProfile.isSeedWorkedDatabaseFromResource());
layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences()); layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences());
// Every profile starts with its own configured design; the menu quick switch only lasts for the session.
chatcontroller.getChatPreferences().setGUI_darkModeActive(
chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault());
messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences()); messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences());
chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual
@@ -6818,7 +7192,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
screenAwareMainSceneSizeHW[0] screenAwareMainSceneSizeHW[0]
); );
scn_ChatwindowMainScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); registerThemedScene(scn_ChatwindowMainScene);
@@ -6863,14 +7237,17 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
MenuBar mainScreenMenuBar = initMenuBar(); mainScreenMenuBar = initMenuBar();
flwpne_StatusBar = new FlowPane(); flwpne_StatusBar = new FlowPane();
flwpne_StatusBar.getChildren().add(mainScreenMenuBar); flwpne_StatusBar.getChildren().add(mainScreenMenuBar);
bPaneChatWindow.setTop(flwpne_StatusBar); bPaneChatWindow.setTop(flwpne_StatusBar);
initConnectionStateIndicatorButton(); initConnectionStateIndicatorButton();
flwpne_StatusBar.getChildren().add(btnConnectionStateIndicator); // On macOS the connection state is shown in the system menu bar instead.
if (!PlatformUtils.isMacOs()) {
flwpne_StatusBar.getChildren().add(btnConnectionStateIndicator);
}
initSkedWarnIndicatorButton(); initSkedWarnIndicatorButton();
@@ -7200,7 +7577,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
txt_ownqrgSecondCategory.setFocusTraversable(false); txt_ownqrgSecondCategory.setFocusTraversable(false);
txt_ownqrgSecondCategory.setTooltip(new Tooltip("Enter frequency for second chat-category here by hand! <fixme>")); txt_ownqrgSecondCategory.setTooltip(new Tooltip("Enter frequency for second chat-category here by hand! <fixme>"));
primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState()); primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState() + buildOperatorProfileTitleSuffix());
timer_buildWindowTitle = new Timer(); timer_buildWindowTitle = new Timer();
timer_buildWindowTitle.scheduleAtFixedRate(new TimerTask() { timer_buildWindowTitle.scheduleAtFixedRate(new TimerTask() {
@@ -7255,7 +7632,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatcontroller.getChatPreferences().setChatState(chatState); chatcontroller.getChatPreferences().setChatState(chatState);
} }
primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState()); primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState() + buildOperatorProfileTitleSuffix());
// System.out.println(chatcontroller.getChatPreferences().getChatState()); // System.out.println(chatcontroller.getChatPreferences().getChatState());
}); });
@@ -8832,7 +9209,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
clusterAndQSOMonScene = new Scene(pnl_directedMSGWin, chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1]); clusterAndQSOMonScene = new Scene(pnl_directedMSGWin, chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1]);
clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); registerThemedScene(clusterAndQSOMonScene);
clusterAndQSOMonScene.heightProperty().addListener(new ChangeListener<Number>() { clusterAndQSOMonScene.heightProperty().addListener(new ChangeListener<Number>() {
@Override @Override
@@ -8850,6 +9227,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} }
}); });
installSharedSystemMenuBar(clusterAndQSOMonScene);
clusterAndQSOMonStage.setScene(clusterAndQSOMonScene); clusterAndQSOMonStage.setScene(clusterAndQSOMonScene);
clusterAndQSOMonStage.show(); clusterAndQSOMonStage.show();
@@ -8960,6 +9338,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
System.out.println("SRVR Version: " + chatcontroller.getUpdateInformation().getLatestVersionNumberOnServer() + " // installed version " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER); System.out.println("SRVR Version: " + chatcontroller.getUpdateInformation().getLatestVersionNumberOnServer() + " // installed version " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER);
stage_updateStage.setScene(new Scene(vbxUpdateWindow, chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1])); stage_updateStage.setScene(new Scene(vbxUpdateWindow, chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1]));
registerThemedScene(stage_updateStage.getScene());
installSharedSystemMenuBar(stage_updateStage.getScene());
stage_updateStage.getScene().widthProperty().addListener((observable, oldValue, newValue) -> { stage_updateStage.getScene().widthProperty().addListener((observable, oldValue, newValue) -> {
chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0] = newValue.doubleValue(); chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0] = newValue.doubleValue();
requestLayoutSave(); requestLayoutSave();
@@ -11653,6 +12033,33 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
grdPnlGuiOptions.add(lblShowFreshCallHint, 0, 6); grdPnlGuiOptions.add(lblShowFreshCallHint, 0, 6);
grdPnlGuiOptions.add(chkBxShowFreshCallHint, 1, 6); grdPnlGuiOptions.add(chkBxShowFreshCallHint, 1, 6);
// Startup design of this operator profile; the Windows menu only switches for the session.
grdPnlGuiOptions.add(generateLabeledSeparator(100, "Design"), 0, 7, 2, 1);
ToggleGroup guiOptions_tglGrpDesign = new ToggleGroup();
RadioButton designLightModeRB = new RadioButton("Light mode ");
designLightModeRB.setToggleGroup(guiOptions_tglGrpDesign);
RadioButton designDarkModeRB = new RadioButton("Dark mode ");
designDarkModeRB.setToggleGroup(guiOptions_tglGrpDesign);
if (chatcontroller.getChatPreferences().isGUI_darkModeActiveByDefault()) {
designDarkModeRB.setSelected(true);
} else {
designLightModeRB.setSelected(true);
}
guiOptions_tglGrpDesign.selectedToggleProperty().addListener((obs, oldToggle, newToggle) -> {
if (newToggle == null) {
return;
}
boolean darkMode = newToggle == designDarkModeRB;
chatcontroller.getChatPreferences().setGUI_darkModeActiveByDefault(darkMode);
applyTheme(darkMode);
});
grdPnlGuiOptions.add(new Label("Design at startup of this profile:"), 0, 8);
grdPnlGuiOptions.add(new HBox(designLightModeRB, designDarkModeRB), 1, 8);
@@ -11751,6 +12158,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
Tab tbInternalDB = new Tab("Workedstn database", vbxInternalDB); Tab tbInternalDB = new Tab("Workedstn database", vbxInternalDB);
Tab tbGui = new Tab("GUI", vbxGuiOptions); Tab tbGui = new Tab("GUI", vbxGuiOptions);
/*
* Appended last on purpose so no established tab position shifts. Contest
* operators navigate these tabs by muscle memory.
*/
Tab tbProfiles = new Tab("Profiles", new OperatorProfileSettingsPane(
new OperatorProfileManagementService(),
this::requestOperatorProfileSwitch));
/** /**
* Automatic update of tab contents out of the database * Automatic update of tab contents out of the database
@@ -11766,7 +12181,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
tabPaneOptions.getTabs().addAll(tbStationSettings, tbLogSynchSet, tbTRXSynchSet, tbAirScoutSettings, tbNotify, tabPaneOptions.getTabs().addAll(tbStationSettings, tbLogSynchSet, tbTRXSynchSet, tbAirScoutSettings, tbNotify,
tbShorts, tbBeacon, tbMsgHandling, tbInternalDB, tbGui); tbShorts, tbBeacon, tbMsgHandling, tbInternalDB, tbGui, tbProfiles);
optionsPanel.setLeft(tabPaneOptions); optionsPanel.setLeft(tabPaneOptions);
@@ -11961,7 +12376,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// VBox vBox = new VBox(tabPaneOptions); // VBox vBox = new VBox(tabPaneOptions);
settingsScene = new Scene(optionsPanel, chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1]); settingsScene = new Scene(optionsPanel, chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0], chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1]);
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); registerThemedScene(settingsScene);
settingsScene.widthProperty().addListener((observable, oldValue, newValue) -> { settingsScene.widthProperty().addListener((observable, oldValue, newValue) -> {
chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0] = newValue.doubleValue(); chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0] = newValue.doubleValue();
requestLayoutSave(); requestLayoutSave();
@@ -11971,6 +12386,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
requestLayoutSave(); requestLayoutSave();
}); });
installSharedSystemMenuBar(settingsScene);
settingsStage.setScene(settingsScene); settingsStage.setScene(settingsScene);
// settingsStage.getScene().getWindow().addEventFilter(WindowEvent.WINDOW_CLOSE_REQUEST, this::closeWindowEvent); // settingsStage.getScene().getWindow().addEventFilter(WindowEvent.WINDOW_CLOSE_REQUEST, this::closeWindowEvent);
@@ -12178,10 +12594,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (res.get().equals(ButtonType.CANCEL)) { if (res.get().equals(ButtonType.CANCEL)) {
// event.consume(); // event.consume();
} else { } else {
System.out.println("closewindowevent: Platform.exit"); System.out.println("closewindowevent: exiting the application");
// Routed through the launcher so the runtime that is actually live
Platform.exit(); // releases its resources. After a profile switch that is no longer the
// instance JavaFX would call stop() on.
ApplicationRuntimeLauncher.exitApplication();
} }
} }
// } // }
@@ -0,0 +1,107 @@
package kst4contest.view;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import java.util.List;
import java.util.Optional;
/**
* Decides which operator profile the application starts with.
*
* <p>The class contains no user interface code so the decision can be tested headless.
* Asking the operator is delegated to an {@link OperatorProfileChoiceRequester}, and a
* problem worth telling the operator about is reported through
* {@link #getStartupWarning()} instead of being shown here.</p>
*
* <p>The most important property of this logic is what it does <em>not</em> do: an
* installation with no or exactly one profile is resolved without asking anything and
* without touching a single file, so a single operator start stays exactly as fast and
* as quiet as it was before profiles existed.</p>
*/
public class OperatorProfileBootstrap {
private String startupWarning;
/**
* Resolves the operator profile to start with.
*
* @param store registry to read the profiles from
* @param commandLineOptions parsed command line options
* @param choiceRequester requester used when the operator has to choose
* @return the resolved selection, or null when the operator chose to quit
*/
public OperatorProfileSelection resolveAtStartup(final OperatorProfileStore store,
final CommandLineOptions commandLineOptions,
final OperatorProfileChoiceRequester choiceRequester) {
startupWarning = null;
List<OperatorProfile> availableProfiles = store.loadProfiles();
String requestedProfileName = commandLineOptions == null
? null
: commandLineOptions.getRequestedProfileName();
if (requestedProfileName != null) {
OperatorProfile requestedProfile = findProfile(availableProfiles, requestedProfileName);
if (requestedProfile != null) {
return OperatorProfilePaths.resolve(requestedProfile);
}
startupWarning = "The operator profile \"" + requestedProfileName
+ "\" is unknown. KST4Contest continues with the normal profile selection.";
}
if (availableProfiles.isEmpty()) {
// No registry at all: the historic flat installation is the only profile.
return OperatorProfilePaths.resolve(store.buildImplicitRootProfile());
}
if (availableProfiles.size() == 1) {
return OperatorProfilePaths.resolve(availableProfiles.get(0));
}
String preselectedProfileId = store.loadLastUsedProfileId().orElse(null);
Optional<OperatorProfile> chosenProfile =
choiceRequester.requestProfileChoice(availableProfiles, preselectedProfileId);
return chosenProfile.map(OperatorProfilePaths::resolve).orElse(null);
}
/**
* Returns a message that should be shown to the operator after startup.
*
* @return the warning text, or null when startup was unremarkable
*/
public String getStartupWarning() {
return startupWarning;
}
/**
* Finds a profile by identifier or display name, ignoring case.
*
* @param availableProfiles profiles to search
* @param requestedName identifier or display name entered by the operator
* @return the matching profile, or null
*/
private static OperatorProfile findProfile(final List<OperatorProfile> availableProfiles,
final String requestedName) {
for (OperatorProfile currentProfile : availableProfiles) {
if (requestedName.equalsIgnoreCase(currentProfile.getProfileId())) {
return currentProfile;
}
}
for (OperatorProfile currentProfile : availableProfiles) {
if (requestedName.equalsIgnoreCase(currentProfile.getDisplayName())) {
return currentProfile;
}
}
return null;
}
}
@@ -0,0 +1,26 @@
package kst4contest.view;
import kst4contest.model.OperatorProfile;
import java.util.List;
import java.util.Optional;
/**
* Asks the operator which profile to start with.
*
* <p>The startup logic depends on this interface rather than on a dialog, so the
* decision which profile to use can be tested without a JavaFX runtime.</p>
*/
@FunctionalInterface
public interface OperatorProfileChoiceRequester {
/**
* Requests a profile choice.
*
* @param selectableProfiles profiles to choose from, never empty
* @param preselectedProfileId identifier to preselect, may be null
* @return the chosen profile, or empty when the operator wants to quit
*/
Optional<OperatorProfile> requestProfileChoice(List<OperatorProfile> selectableProfiles,
String preselectedProfileId);
}
@@ -0,0 +1,145 @@
package kst4contest.view;
import kst4contest.model.OperatorProfile;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.Modality;
import javafx.stage.Stage;
import java.util.List;
import java.util.Optional;
/**
* Asks the operator which profile to start with.
*
* <p>The dialog is shown only when more than one profile exists. It is intentionally
* minimal, because it stands between the operator and a contest: the last used profile
* is preselected, the list has the focus, and Enter or a double click start immediately.</p>
*/
public final class OperatorProfilePickerDialog {
private OperatorProfilePickerDialog() {
// Utility class.
}
/**
* Shows the picker and waits for the operator's choice.
*
* @param selectableProfiles profiles to choose from
* @param preselectedProfileId identifier of the profile to preselect, may be null
* @return the chosen profile, or empty when the operator wants to quit
*/
public static Optional<OperatorProfile> showAndSelect(final List<OperatorProfile> selectableProfiles,
final String preselectedProfileId) {
Stage dialogStage = new Stage();
GuiUtils.applyApplicationIcon(dialogStage);
dialogStage.initModality(Modality.APPLICATION_MODAL);
dialogStage.setTitle("Select operator profile");
ListView<OperatorProfile> profileListView = new ListView<>();
profileListView.getItems().addAll(selectableProfiles);
profileListView.setCellFactory(listView -> new OperatorProfileListCell());
VBox.setVgrow(profileListView, Priority.ALWAYS);
selectPreselectedProfile(profileListView, selectableProfiles, preselectedProfileId);
OperatorProfile[] chosenProfile = new OperatorProfile[1];
Button startButton = new Button("Start");
startButton.setDefaultButton(true);
startButton.setOnAction(event -> {
chosenProfile[0] = profileListView.getSelectionModel().getSelectedItem();
dialogStage.close();
});
Button quitButton = new Button("Quit");
quitButton.setCancelButton(true);
quitButton.setOnAction(event -> {
chosenProfile[0] = null;
dialogStage.close();
});
profileListView.setOnMouseClicked(event -> {
if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
startButton.fire();
}
});
profileListView.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER) {
startButton.fire();
}
});
HBox buttonRow = new HBox(10, startButton, quitButton);
buttonRow.setPadding(new Insets(10, 0, 0, 0));
VBox dialogContent = new VBox(8,
new Label("More than one operator profile is configured."),
profileListView,
buttonRow);
dialogContent.setPadding(new Insets(15));
// No application stylesheet yet: use the same base font size as the main windows.
dialogContent.setStyle("-fx-font-size: 12px;");
dialogStage.setScene(new Scene(dialogContent, 380, 280));
profileListView.requestFocus();
dialogStage.showAndWait();
return Optional.ofNullable(chosenProfile[0]);
}
private static void selectPreselectedProfile(final ListView<OperatorProfile> profileListView,
final List<OperatorProfile> selectableProfiles,
final String preselectedProfileId) {
int profileIndexToSelect = 0;
if (preselectedProfileId != null) {
for (int profileIndex = 0; profileIndex < selectableProfiles.size(); profileIndex++) {
if (preselectedProfileId.equalsIgnoreCase(
selectableProfiles.get(profileIndex).getProfileId())) {
profileIndexToSelect = profileIndex;
break;
}
}
}
profileListView.getSelectionModel().select(profileIndexToSelect);
profileListView.scrollTo(profileIndexToSelect);
}
/**
* Renders a profile with its name and the kind of worked data it uses.
*/
private static final class OperatorProfileListCell extends ListCell<OperatorProfile> {
@Override
protected void updateItem(final OperatorProfile profile, final boolean empty) {
super.updateItem(profile, empty);
if (empty || profile == null) {
setText(null);
return;
}
String workedDataDescription = profile.isRootProfile() || profile.isSharedWorkedDatabase()
? "shared station worked database"
: "own worked database";
setText(profile.getDisplayName() + "\n" + workedDataDescription);
}
}
}
@@ -0,0 +1,488 @@
package kst4contest.view;
import kst4contest.controller.ActiveOperatorProfile;
import kst4contest.controller.OperatorProfileManagementService;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import javafx.beans.property.SimpleStringProperty;
import javafx.geometry.Insets;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.RadioButton;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.TextInputDialog;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.util.Pair;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
/**
* Settings tab that manages the operator profiles.
*
* <p>Every profile keeps its own settings and window layout. Whether it also keeps its
* own worked stations is chosen per profile, because a multi operator contest station
* shares one log while two operators on a private computer usually do not.</p>
*/
public class OperatorProfileSettingsPane extends VBox {
private static final DateTimeFormatter LAST_USED_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault());
private final OperatorProfileManagementService managementService;
private final Consumer<OperatorProfile> profileActivationRequest;
private final TableView<OperatorProfile> profileTable = new TableView<>();
private final Label activeProfileLabel = new Label();
private final Label preferencesPathLabel = new Label();
private final Label workedDatabasePathLabel = new Label();
public OperatorProfileSettingsPane(final OperatorProfileManagementService managementService,
final Consumer<OperatorProfile> profileActivationRequest) {
this.managementService = managementService;
this.profileActivationRequest = profileActivationRequest;
setSpacing(10);
setPadding(new Insets(15));
getChildren().addAll(
buildActiveProfileHeader(),
buildProfileTable(),
buildButtonRows(),
buildExplanationLabel());
refreshActiveProfileHeader();
refreshProfileTable();
}
private GridPane buildActiveProfileHeader() {
GridPane headerGrid = new GridPane();
headerGrid.setHgap(10);
headerGrid.setVgap(4);
headerGrid.add(new Label("Active profile:"), 0, 0);
headerGrid.add(activeProfileLabel, 1, 0);
headerGrid.add(new Label("Settings file:"), 0, 1);
headerGrid.add(preferencesPathLabel, 1, 1);
headerGrid.add(new Label("Worked stations:"), 0, 2);
headerGrid.add(workedDatabasePathLabel, 1, 2);
return headerGrid;
}
private TableView<OperatorProfile> buildProfileTable() {
TableColumn<OperatorProfile, String> nameColumn = new TableColumn<>("Profile");
nameColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(cellData.getValue().getDisplayName()));
nameColumn.setPrefWidth(200);
TableColumn<OperatorProfile, String> workedDataColumn = new TableColumn<>("Worked stations");
workedDataColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(describeWorkedDataMode(cellData.getValue())));
workedDataColumn.setPrefWidth(200);
TableColumn<OperatorProfile, String> lastUsedColumn = new TableColumn<>("Last used");
lastUsedColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(describeLastUsed(cellData.getValue())));
lastUsedColumn.setPrefWidth(140);
profileTable.getColumns().add(nameColumn);
profileTable.getColumns().add(workedDataColumn);
profileTable.getColumns().add(lastUsedColumn);
profileTable.setPlaceholder(new Label("No operator profile configured."));
VBox.setVgrow(profileTable, Priority.ALWAYS);
return profileTable;
}
private VBox buildButtonRows() {
Button newProfileButton = new Button("New profile...");
newProfileButton.setOnAction(event -> createProfile());
Button duplicateProfileButton = new Button("Duplicate...");
duplicateProfileButton.setOnAction(event -> duplicateSelectedProfile());
Button renameProfileButton = new Button("Rename...");
renameProfileButton.setOnAction(event -> renameSelectedProfile());
Button deleteProfileButton = new Button("Delete...");
deleteProfileButton.setOnAction(event -> deleteSelectedProfile());
Button changeWorkedDataButton = new Button("Change worked stations...");
changeWorkedDataButton.setOnAction(event -> changeWorkedDataModeOfSelectedProfile());
Button switchProfileButton = new Button("Switch to selected profile...");
switchProfileButton.setOnAction(event -> activateSelectedProfile());
HBox managementRow = new HBox(8,
newProfileButton, duplicateProfileButton, renameProfileButton, deleteProfileButton);
HBox activationRow = new HBox(8, changeWorkedDataButton, switchProfileButton);
return new VBox(8, managementRow, activationRow);
}
private Label buildExplanationLabel() {
Label explanation = new Label(
"Each profile has its own settings and window layout. A profile can either share the "
+ "common station worked stations, which is what a multi operator station wants, "
+ "or keep its own. Duplicating a profile copies everything except callsign and "
+ "password, and never copies worked stations.");
explanation.setWrapText(true);
return explanation;
}
private void refreshActiveProfileHeader() {
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
if (activeProfile == null) {
activeProfileLabel.setText("unknown");
return;
}
activeProfileLabel.setText(activeProfile.getProfile().getDisplayName());
preferencesPathLabel.setText(activeProfile.getPreferencesAbsolutePath());
workedDatabasePathLabel.setText(activeProfile.getWorkedDatabaseAbsolutePath());
}
private void refreshProfileTable() {
List<OperatorProfile> knownProfiles = managementService.listProfiles();
OperatorProfile previouslySelected = profileTable.getSelectionModel().getSelectedItem();
profileTable.getItems().setAll(knownProfiles);
if (previouslySelected != null && knownProfiles.contains(previouslySelected)) {
profileTable.getSelectionModel().select(previouslySelected);
} else if (!knownProfiles.isEmpty()) {
profileTable.getSelectionModel().select(0);
}
}
private void createProfile() {
Optional<Pair<String, Boolean>> enteredProfile =
showProfileCreationDialog("New operator profile", "");
if (enteredProfile.isEmpty()) {
return;
}
OperatorProfile createdProfile = managementService.createProfile(
enteredProfile.get().getKey(), enteredProfile.get().getValue());
if (createdProfile == null) {
showError("The profile could not be created. The profile registry could not be written.");
return;
}
refreshProfileTable();
profileTable.getSelectionModel().select(createdProfile);
showInformation("The profile \"" + createdProfile.getDisplayName() + "\" was created without "
+ "callsign and password. Enter them on the Station tab after switching to it.");
}
private void duplicateSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
TextInputDialog nameDialog =
new TextInputDialog("Copy of " + selectedProfile.getDisplayName());
nameDialog.setTitle("Duplicate operator profile");
nameDialog.setHeaderText("Name of the new profile");
nameDialog.setContentText(
"Everything is copied except callsign and password. Worked stations are never copied.");
Optional<String> enteredName = nameDialog.showAndWait();
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
return;
}
OperatorProfile duplicatedProfile =
managementService.duplicateProfile(selectedProfile, enteredName.get());
if (duplicatedProfile == null) {
showError("The profile could not be duplicated.");
return;
}
refreshProfileTable();
profileTable.getSelectionModel().select(duplicatedProfile);
}
private void renameSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
TextInputDialog nameDialog = new TextInputDialog(selectedProfile.getDisplayName());
nameDialog.setTitle("Rename operator profile");
nameDialog.setHeaderText("New name of the profile");
nameDialog.setContentText("Files and folders of the profile are not touched.");
Optional<String> enteredName = nameDialog.showAndWait();
if (enteredName.isEmpty() || enteredName.get().isBlank()) {
return;
}
managementService.renameProfile(selectedProfile, enteredName.get());
refreshProfileTable();
refreshActiveProfileHeader();
}
private void deleteSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (selectedProfile.isRootProfile()) {
showError("The default profile uses the files of the installation itself "
+ "and cannot be deleted.");
return;
}
if (isActiveProfile(selectedProfile)) {
showError("The profile currently in use cannot be deleted. Switch to another "
+ "profile first.");
return;
}
Alert confirmation = new Alert(AlertType.CONFIRMATION);
confirmation.setTitle("Delete operator profile");
confirmation.setHeaderText("Delete the profile \"" + selectedProfile.getDisplayName() + "\"?");
confirmation.setContentText(
"The following folder is removed permanently:\n"
+ managementService.getProfileDirectory(selectedProfile)
+ "\n\n"
+ (selectedProfile.isSharedWorkedDatabase()
? "The common station worked stations are not touched."
: "The worked stations of this profile are deleted as well."));
ButtonType deleteButton = new ButtonType("Delete profile", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
confirmation.getButtonTypes().setAll(deleteButton, cancelButton);
if (confirmation.showAndWait().orElse(cancelButton) != deleteButton) {
return;
}
if (!managementService.deleteProfile(selectedProfile)) {
showError("The profile could not be deleted.");
}
refreshProfileTable();
}
private void changeWorkedDataModeOfSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (selectedProfile.isRootProfile()) {
showError("The default profile always uses the common station worked stations, "
+ "because that database is the one of the installation itself.");
return;
}
Optional<Boolean> chosenMode = showWorkedDataModeDialog(selectedProfile);
if (chosenMode.isEmpty() || chosenMode.get() == selectedProfile.isSharedWorkedDatabase()) {
return;
}
managementService.setSharedWorkedDatabase(selectedProfile, chosenMode.get());
refreshProfileTable();
if (isActiveProfile(selectedProfile)) {
showInformation("The change takes effect after switching to this profile again.");
}
}
private void activateSelectedProfile() {
OperatorProfile selectedProfile = requireSelectedProfile();
if (selectedProfile == null) {
return;
}
if (isActiveProfile(selectedProfile)) {
showInformation("This profile is already active.");
return;
}
profileActivationRequest.accept(selectedProfile);
}
private Optional<Pair<String, Boolean>> showProfileCreationDialog(final String title,
final String initialName) {
Dialog<Pair<String, Boolean>> creationDialog = new Dialog<>();
creationDialog.setTitle(title);
creationDialog.setHeaderText("Name and worked stations of the new profile");
ButtonType createButton = new ButtonType("Create profile", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
creationDialog.getDialogPane().getButtonTypes().setAll(createButton, cancelButton);
TextField nameField = new TextField(initialName);
nameField.setPromptText("for example DN9APW");
ToggleGroup workedDataGroup = new ToggleGroup();
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
ownDatabaseOption.setToggleGroup(workedDataGroup);
ownDatabaseOption.setSelected(true);
RadioButton sharedDatabaseOption =
new RadioButton("Share the common station worked stations (multi operator station)");
sharedDatabaseOption.setToggleGroup(workedDataGroup);
VBox dialogContent = new VBox(8,
new Label("Profile name"),
nameField,
new Label("Worked stations"),
ownDatabaseOption,
sharedDatabaseOption);
dialogContent.setPadding(new Insets(10));
creationDialog.getDialogPane().setContent(dialogContent);
creationDialog.setResultConverter(pressedButton -> {
if (pressedButton != createButton || nameField.getText().isBlank()) {
return null;
}
return new Pair<>(nameField.getText().trim(), sharedDatabaseOption.isSelected());
});
return creationDialog.showAndWait();
}
private Optional<Boolean> showWorkedDataModeDialog(final OperatorProfile profile) {
Dialog<Boolean> modeDialog = new Dialog<>();
modeDialog.setTitle("Worked stations");
modeDialog.setHeaderText("Worked stations of \"" + profile.getDisplayName() + "\"");
ButtonType applyButton = new ButtonType("Apply", ButtonBar.ButtonData.OK_DONE);
ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
modeDialog.getDialogPane().getButtonTypes().setAll(applyButton, cancelButton);
ToggleGroup workedDataGroup = new ToggleGroup();
RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
ownDatabaseOption.setToggleGroup(workedDataGroup);
RadioButton sharedDatabaseOption =
new RadioButton("Share the common station worked stations (multi operator station)");
sharedDatabaseOption.setToggleGroup(workedDataGroup);
sharedDatabaseOption.setSelected(profile.isSharedWorkedDatabase());
ownDatabaseOption.setSelected(!profile.isSharedWorkedDatabase());
Label pathHint = new Label("Switching does not move any data. Worked stations already "
+ "collected under the other setting stay where they are.");
pathHint.setWrapText(true);
VBox dialogContent = new VBox(8, ownDatabaseOption, sharedDatabaseOption, pathHint);
dialogContent.setPadding(new Insets(10));
modeDialog.getDialogPane().setContent(dialogContent);
modeDialog.setResultConverter(pressedButton ->
pressedButton == applyButton ? sharedDatabaseOption.isSelected() : null);
return modeDialog.showAndWait();
}
private OperatorProfile requireSelectedProfile() {
OperatorProfile selectedProfile = profileTable.getSelectionModel().getSelectedItem();
if (selectedProfile == null) {
showInformation("Select a profile in the table first.");
}
return selectedProfile;
}
private static boolean isActiveProfile(final OperatorProfile profile) {
OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
return activeProfile != null
&& activeProfile.getProfile().getProfileId().equals(profile.getProfileId());
}
private static String describeWorkedDataMode(final OperatorProfile profile) {
if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) {
return "common station database";
}
return "own database";
}
private static String describeLastUsed(final OperatorProfile profile) {
if (profile.getLastUsedEpochMs() <= 0L) {
return "";
}
return LAST_USED_FORMATTER.format(Instant.ofEpochMilli(profile.getLastUsedEpochMs()));
}
private static void showInformation(final String message) {
Alert information = new Alert(AlertType.INFORMATION);
information.setTitle("Operator profiles");
information.setContentText(message);
information.showAndWait();
}
private static void showError(final String message) {
Alert error = new Alert(AlertType.ERROR);
error.setTitle("Operator profiles");
error.setContentText(message);
error.showAndWait();
}
}
@@ -2,6 +2,7 @@ package kst4contest.view.map;
import javafx.animation.PauseTransition; import javafx.animation.PauseTransition;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.collections.ListChangeListener; import javafx.collections.ListChangeListener;
import javafx.scene.control.TableView; import javafx.scene.control.TableView;
import javafx.util.Duration; import javafx.util.Duration;
@@ -54,6 +55,16 @@ public final class StationMapBridge {
private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0)); private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0));
/*
* The listeners are kept so install() can be undone. Without that, the coalescing
* animation and the registered listeners would keep a discarded runtime reachable
* after an operator profile switch.
*/
private ListChangeListener<ChatMember> chatMemberListListener;
private ChangeListener<ChatMember> selectedChatMemberListener;
private ChangeListener<Number> antennaDirectionListener;
private ListChangeListener<Predicate<ChatMember>> filterPredicateListener;
public StationMapBridge(ChatController chatController, public StationMapBridge(ChatController chatController,
TableView<ChatMember> chatMemberTable, TableView<ChatMember> chatMemberTable,
StationMapView stationMapView, StationMapView stationMapView,
@@ -81,25 +92,59 @@ public final class StationMapBridge {
stationMapView.setOnResetView(this::handleMapReset); stationMapView.setOnResetView(this::handleMapReset);
chatController.getLst_chatMemberSortedFilteredList().addListener( chatMemberListListener = change -> scheduleRefresh();
(ListChangeListener<ChatMember>) change -> scheduleRefresh() chatController.getLst_chatMemberSortedFilteredList().addListener(chatMemberListListener);
);
chatController.getScoreService().selectedChatMemberProperty().addListener( selectedChatMemberListener = (obs, oldValue, newValue) -> requestImmediateRefresh();
(obs, oldValue, newValue) -> requestImmediateRefresh() chatController.getScoreService().selectedChatMemberProperty()
); .addListener(selectedChatMemberListener);
chatController.getChatPreferences().getActualQTF().addListener( antennaDirectionListener = (obs, oldValue, newValue) -> scheduleRefresh();
(obs, oldValue, newValue) -> scheduleRefresh() chatController.getChatPreferences().getActualQTF().addListener(antennaDirectionListener);
);
chatController.getLst_chatMemberListFilterPredicates().addListener( filterPredicateListener = change -> requestImmediateRefresh();
(ListChangeListener<Predicate<ChatMember>>) change -> requestImmediateRefresh() chatController.getLst_chatMemberListFilterPredicates().addListener(filterPredicateListener);
);
requestImmediateRefresh(); requestImmediateRefresh();
} }
/**
* Removes everything {@link #install()} registered and stops the coalescing timer.
*
* <p>Needed when the runtime owning this bridge is discarded, for example during an
* operator profile switch. A running {@link PauseTransition} would otherwise keep
* firing into a dead user interface.</p>
*/
public void uninstall() {
refreshCoalescer.stop();
stationMapView.setOnCallsignRawSelected(null);
stationMapView.setOnTriggerClusterSpot(null);
stationMapView.setOnResetView(null);
if (chatMemberListListener != null) {
chatController.getLst_chatMemberSortedFilteredList().removeListener(chatMemberListListener);
chatMemberListListener = null;
}
if (selectedChatMemberListener != null) {
chatController.getScoreService().selectedChatMemberProperty()
.removeListener(selectedChatMemberListener);
selectedChatMemberListener = null;
}
if (antennaDirectionListener != null) {
chatController.getChatPreferences().getActualQTF().removeListener(antennaDirectionListener);
antennaDirectionListener = null;
}
if (filterPredicateListener != null) {
chatController.getLst_chatMemberListFilterPredicates().removeListener(filterPredicateListener);
filterPredicateListener = null;
}
}
private void handleMapReset() { private void handleMapReset() {
Runnable resetAction = () -> { Runnable resetAction = () -> {
/* /*
@@ -244,10 +244,36 @@ public final class StationMapView {
stage.hide(); stage.hide();
} }
/**
* Releases every resource this map window owns.
*
* <p>The tile proxy is a local server socket with its own thread pool. It used to
* live until the process ended, which was harmless while the map existed exactly
* once per process. A runtime that is discarded, for example during an operator
* profile switch, has to hand it back.</p>
*/
public void dispose() {
if (tileProxyServer != null) {
tileProxyServer.stop();
tileProxyServer = null;
}
webEngine.load(null);
stage.close();
}
public boolean isShowing() { public boolean isShowing() {
return stage.isShowing(); return stage.isShowing();
} }
/**
* @return the map window scene, e.g. to attach the shared macOS system menu bar
*/
public Scene getScene() {
return scene;
}
public void applyThemeFromPreferences() { public void applyThemeFromPreferences() {
boolean darkMode = chatPreferences.isGUI_darkModeActive(); boolean darkMode = chatPreferences.isGUI_darkModeActive();
applySceneTheme(darkMode); applySceneTheme(darkMode);
@@ -1,9 +1,11 @@
package kst4contest.view.map; package kst4contest.view.map;
import kst4contest.ApplicationConstants; import kst4contest.ApplicationConstants;
import kst4contest.controller.DBController;
import kst4contest.utils.ApplicationFileUtils; import kst4contest.utils.ApplicationFileUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection; import java.sql.Connection;
import java.sql.DriverManager; import java.sql.DriverManager;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
@@ -15,30 +17,44 @@ import java.util.Locale;
import java.util.Optional; import java.util.Optional;
/** /**
* Persistent terrain profile cache stored in the application's existing SQLite database. * Persistent terrain profile cache, stored globally in its own SQLite database.
* *
* The cache is intentionally owner-bound: * <p>Terrain profiles are pure geometry derived from two locators and a sample count.
* if the configured own callsign or own locator changes, all cached terrain * They do not belong to one operator, so the cache is deliberately not part of an
* profiles are cleared automatically. * operator profile: at a multi operator station both operators share one location, and
* duplicating the cache would double the traffic against an external terrain service.</p>
*
* <p>Entries are separated by owner identity through the primary key instead. Earlier
* versions kept a single owner identity and dropped the whole cache whenever the
* configured callsign or locator changed; with several operator profiles that would
* discard every computed profile on each switch.</p>
*/ */
public final class TerrainProfileCacheRepository { public final class TerrainProfileCacheRepository {
private static final String META_KEY_OWNER_CALLSIGN_RAW = "terrain_cache_owner_callsign_raw"; /**
private static final String META_KEY_OWNER_LOCATOR6 = "terrain_cache_owner_locator6"; * File name of the global terrain profile cache below the application directory.
*/
public static final String TERRAIN_CACHE_DATABASE_FILE = "terrainprofilecache.db";
private final String databasePath; private final String databasePath;
public TerrainProfileCacheRepository() { public TerrainProfileCacheRepository() {
ApplicationFileUtils.copyResourceIfRequired(
ApplicationConstants.APPLICATION_NAME,
DBController.DATABASE_RESOURCE,
DBController.DATABASE_FILE
);
this.databasePath = ApplicationFileUtils.getFilePath( this.databasePath = ApplicationFileUtils.getFilePath(
ApplicationConstants.APPLICATION_NAME, ApplicationConstants.APPLICATION_NAME,
DBController.DATABASE_FILE TERRAIN_CACHE_DATABASE_FILE
); );
// SQLite only creates the database file itself, not the directory holding it.
Path applicationDirectory = Path.of(databasePath).getParent();
if (applicationDirectory != null) {
try {
Files.createDirectories(applicationDirectory);
} catch (IOException exception) {
System.err.println("[StationMap] Terrain cache directory could not be created: "
+ exception.getMessage());
}
}
} }
public synchronized Optional<TerrainProfileData> load(String ownerCallsignRaw, public synchronized Optional<TerrainProfileData> load(String ownerCallsignRaw,
@@ -183,60 +199,37 @@ public final class TerrainProfileCacheRepository {
"""); """);
statement.executeUpdate(""" statement.executeUpdate("""
CREATE TABLE IF NOT EXISTS TerrainProfileCacheMeta ( CREATE TABLE IF NOT EXISTS TerrainProfileCacheOwner (
meta_key TEXT NOT NULL PRIMARY KEY, owner_callsign_raw TEXT NOT NULL,
meta_value TEXT NOT NULL owner_locator6 TEXT NOT NULL,
last_used_epoch_ms INTEGER NOT NULL,
PRIMARY KEY (owner_callsign_raw, owner_locator6)
) )
"""); """);
} }
} }
/**
* Records that the given owner identity is in use.
*
* <p>Entries of other owners stay untouched. The cached profiles of an identity are
* separated by the primary key already, so a different callsign or locator simply
* misses the cache instead of invalidating everybody else's entries.</p>
*/
private void ensureOwnerIdentity(Connection connection, private void ensureOwnerIdentity(Connection connection,
String currentOwnerCallsignRaw, String currentOwnerCallsignRaw,
String currentOwnerLocator6) throws Exception { String currentOwnerLocator6) throws Exception {
String normalizedOwnerCallsignRaw = normalize(currentOwnerCallsignRaw);
String normalizedOwnerLocator6 = normalize(currentOwnerLocator6);
String storedOwnerCallsignRaw = readMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW);
String storedOwnerLocator6 = readMetaValue(connection, META_KEY_OWNER_LOCATOR6);
boolean callsignChanged = storedOwnerCallsignRaw != null && !storedOwnerCallsignRaw.equals(normalizedOwnerCallsignRaw);
boolean locatorChanged = storedOwnerLocator6 != null && !storedOwnerLocator6.equals(normalizedOwnerLocator6);
if (callsignChanged || locatorChanged) {
clearTerrainCache(connection);
}
writeMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW, normalizedOwnerCallsignRaw);
writeMetaValue(connection, META_KEY_OWNER_LOCATOR6, normalizedOwnerLocator6);
}
private void clearTerrainCache(Connection connection) throws Exception {
try (Statement statement = connection.createStatement()) {
statement.executeUpdate("DELETE FROM TerrainProfileCache");
}
}
private String readMetaValue(Connection connection, String key) throws Exception {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT meta_value FROM TerrainProfileCacheMeta WHERE meta_key = ?")) {
statement.setString(1, key);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next() ? resultSet.getString(1) : null;
}
}
}
private void writeMetaValue(Connection connection, String key, String value) throws Exception {
try (PreparedStatement statement = connection.prepareStatement(""" try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO TerrainProfileCacheMeta (meta_key, meta_value) INSERT INTO TerrainProfileCacheOwner (
VALUES (?, ?) owner_callsign_raw, owner_locator6, last_used_epoch_ms
ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value ) VALUES (?, ?, ?)
ON CONFLICT(owner_callsign_raw, owner_locator6) DO UPDATE SET
last_used_epoch_ms = excluded.last_used_epoch_ms
""")) { """)) {
statement.setString(1, key); statement.setString(1, normalize(currentOwnerCallsignRaw));
statement.setString(2, value == null ? "" : value); statement.setString(2, normalize(currentOwnerLocator6));
statement.setLong(3, System.currentTimeMillis());
statement.executeUpdate(); statement.executeUpdate();
} }
} }
@@ -1,3 +1,8 @@
.root {
/* Same base font size on all platforms: macOS/Linux default to 13px, Windows to 12px. */
-fx-font-size: 12px;
}
.button:pressed { .button:pressed {
-fx-border-color: #ff0000; -fx-border-color: #ff0000;
} }
@@ -1,4 +1,6 @@
.root { .root {
/* Same base font size on all platforms: macOS/Linux default to 13px, Windows to 12px. */
-fx-font-size: 12px;
-fx-accent: #1e74c6; -fx-accent: #1e74c6;
-fx-focus-color: -fx-accent; -fx-focus-color: -fx-accent;
-fx-base: #373e43; -fx-base: #373e43;
@@ -0,0 +1,127 @@
package kst4contest.controller;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.SQLException;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies that one DBController instance works on exactly one database file, so two
* operator profiles can keep independent worked-station data inside the same process.
*/
class DBControllerProfileDatabaseTest {
private static final String USER_HOME_PROPERTY = "user.home";
@TempDir
Path temporaryHomeDirectory;
private String originalUserHome;
@BeforeEach
void redirectUserHomeToTemporaryDirectory() {
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
}
@AfterEach
void restoreUserHome() {
if (originalUserHome == null) {
System.clearProperty(USER_HOME_PROPERTY);
} else {
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
}
}
@Test
void profileDatabaseIsCreatedEmptyAndKeepsWorkedDataSeparate() throws SQLException {
DBController firstProfileDatabase =
new DBController("profiles/OP1/praktiKST.db", false);
DBController secondProfileDatabase =
new DBController("profiles/OP2/praktiKST.db", false);
try {
// A profile database must not inherit the several thousand callsigns of the
// bundled template database.
assertTrue(firstProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty());
assertTrue(secondProfileDatabase.fetchChatMemberWkdDataFromDB().isEmpty());
assertNotEquals(firstProfileDatabase.getDatabaseFilePath(),
secondProfileDatabase.getDatabaseFilePath());
assertTrue(Files.exists(Path.of(firstProfileDatabase.getDatabaseFilePath())));
assertTrue(Files.exists(Path.of(secondProfileDatabase.getDatabaseFilePath())));
ChatMember workedOnFirstProfile = new ChatMember();
workedOnFirstProfile.setCallSign("DL0XYZ");
workedOnFirstProfile.setQra("JO51IJ");
workedOnFirstProfile.setWorked(true);
workedOnFirstProfile.setWorked144(true);
firstProfileDatabase.storeChatMember(workedOnFirstProfile);
Map<String, ChatMember> firstProfileContent =
firstProfileDatabase.fetchChatMemberWkdDataFromDB();
Map<String, ChatMember> secondProfileContent =
secondProfileDatabase.fetchChatMemberWkdDataFromDB();
assertEquals(1, firstProfileContent.size());
assertTrue(firstProfileContent.get("DL0XYZ").isWorked144());
assertTrue(secondProfileContent.isEmpty(),
"A worked station of one profile must not appear in the other profile");
} finally {
firstProfileDatabase.closeDBConnection();
secondProfileDatabase.closeDBConnection();
}
}
@Test
void twoProfilesPointingAtTheSameFileShareTheirWorkedData() throws SQLException {
DBController sharedStationDatabase = new DBController("praktiKST.db", false);
DBController sameSharedDatabaseAgain = new DBController("praktiKST.db", false);
try {
ChatMember workedAtTheStation = new ChatMember();
workedAtTheStation.setCallSign("DL0ABC");
workedAtTheStation.setWorked(true);
workedAtTheStation.setWorked432(true);
sharedStationDatabase.storeChatMember(workedAtTheStation);
Map<String, ChatMember> seenByTheOtherOperator =
sameSharedDatabaseAgain.fetchChatMemberWkdDataFromDB();
assertTrue(seenByTheOtherOperator.containsKey("DL0ABC"),
"Operators sharing one station database must see the same worked stations");
assertTrue(seenByTheOtherOperator.get("DL0ABC").isWorked432());
} finally {
sharedStationDatabase.closeDBConnection();
sameSharedDatabaseAgain.closeDBConnection();
}
}
@Test
void closingTheConnectionDeregistersTheShutdownHook() {
DBController profileDatabase = new DBController("profiles/OP3/praktiKST.db", false);
profileDatabase.closeDBConnection();
// A second close must stay harmless, and the hook must already be gone.
profileDatabase.closeDBConnection();
assertFalse(Files.notExists(Path.of(profileDatabase.getDatabaseFilePath())),
"The database file stays on disk after the connection was closed");
}
}
@@ -3,46 +3,63 @@ package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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 static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.OutputStreamWriter;
import java.lang.reflect.Field;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; import java.net.Socket;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.Timeout;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage; import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
class On4KstConnectionProbeTest { class On4KstConnectionProbeTest {
@Test @Test
void buildsMainChatProbeAndAcceptsExpectedResponse() { void separatesClientProbeServerProbeAndInternalResponses() {
assertEquals("RDXQ|2|", On4KstProtocol.connectionProbe(2)); assertEquals("CK|", On4KstProtocol.clientLivenessProbe());
assertTrue(On4KstProtocol.isConnectionProbeResponse("DXQ|2|data|")); assertTrue(On4KstProtocol.isClientLivenessProbeResponse("OK"));
assertFalse(On4KstProtocol.isConnectionProbeResponse( assertTrue(On4KstProtocol.isClientLivenessProbeResponse("OK|"));
"CH|2|123|DL1ABC|Name|0|text|0|")); assertFalse(On4KstProtocol.isClientLivenessProbeResponse(
"OK|unexpected|"));
assertTrue(On4KstProtocol.isServerLivenessProbe("CK|"));
assertEquals("", On4KstProtocol.serverLivenessProbeResponse());
assertTrue(On4KstProtocol.isInternalDxqResponse("DXQ|2|data|"));
} }
@Test @Test
void selectsHeartbeatProbeAndTimeoutAtIdleBoundaries() { void selectsOneClientProbeAndTimeoutAtIdleBoundaries() {
assertEquals( assertEquals(
On4KstConnectionManager.IdleAction.NONE, On4KstConnectionManager.IdleAction.NONE,
idleAction(90_000L, false, false)); idleAction(90_000L, true, false));
assertEquals(
On4KstConnectionManager.IdleAction.HEARTBEAT,
idleAction(90_001L, false, false));
assertEquals( assertEquals(
On4KstConnectionManager.IdleAction.NONE, 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( assertEquals(
On4KstConnectionManager.IdleAction.CONNECTION_PROBE, On4KstConnectionManager.IdleAction.CLIENT_LIVENESS_PROBE,
idleAction(180_000L, true, false)); 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( assertEquals(
On4KstConnectionManager.IdleAction.NONE, On4KstConnectionManager.IdleAction.NONE,
idleAction(210_000L, true, true)); idleAction(210_000L, true, true));
@@ -52,9 +69,9 @@ class On4KstConnectionProbeTest {
} }
@Test @Test
void oneSessionProbeIsAcknowledgedByAnyInboundTraffic() { void sessionProbeCoversTwoCategoriesAndRepeatedIdlePhases() {
On4KstConnectionManager.ConnectionProbeState probe = On4KstConnectionManager.ClientLivenessProbeState probe =
new On4KstConnectionManager.ConnectionProbeState(); new On4KstConnectionManager.ClientLivenessProbeState();
assertTrue(probe.tryStart(1_000L)); assertTrue(probe.tryStart(1_000L));
assertFalse(probe.tryStart(1_001L), assertFalse(probe.tryStart(1_001L),
@@ -71,8 +88,8 @@ class On4KstConnectionProbeTest {
@Test @Test
@Timeout(5) @Timeout(5)
void writerUsesExactCrLfForHeartbeatAndConnectionProbe() throws Exception { void writerUsesExactBytesForClientAndServerLivenessFrames() throws Exception {
byte[] expected = "\r\nRDXQ|2|\r\n".getBytes(StandardCharsets.UTF_8); byte[] expected = "CK|\r\n\r\n".getBytes(StandardCharsets.UTF_8);
try (ServerSocket server = new ServerSocket(0)) { try (ServerSocket server = new ServerSocket(0)) {
CompletableFuture<byte[]> received = CompletableFuture.supplyAsync(() -> { CompletableFuture<byte[]> received = CompletableFuture.supplyAsync(() -> {
@@ -98,8 +115,9 @@ class On4KstConnectionProbeTest {
ignored -> { }); ignored -> { });
writer.start(); writer.start();
queue.add(serverFrame("")); queue.add(serverFrame(On4KstProtocol.clientLivenessProbe()));
queue.add(serverFrame(On4KstProtocol.connectionProbe(2))); queue.add(serverFrame(
On4KstProtocol.serverLivenessProbeResponse()));
assertArrayEquals( assertArrayEquals(
expected, 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( private On4KstConnectionManager.IdleAction idleAction(
long inboundIdleMillis, long inboundIdleMillis,
boolean heartbeatSent, boolean online,
boolean probeOutstanding boolean probeOutstanding
) { ) {
return On4KstConnectionManager.determineIdleAction( return On4KstConnectionManager.determineIdleAction(
inboundIdleMillis, inboundIdleMillis,
heartbeatSent, online,
probeOutstanding); probeOutstanding);
} }
@@ -129,4 +286,55 @@ class On4KstConnectionProbeTest {
message.setMessageText(text); message.setMessageText(text);
return message; 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();
}
}
}
@@ -0,0 +1,177 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.model.OperatorProfile;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OperatorProfileManagementServiceTest {
private static final String USER_HOME_PROPERTY = "user.home";
@TempDir
Path temporaryHomeDirectory;
private String originalUserHome;
private OperatorProfileManagementService managementService;
@BeforeEach
void redirectUserHomeToTemporaryDirectory() {
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
managementService = new OperatorProfileManagementService();
}
@AfterEach
void restoreUserHome() {
if (originalUserHome == null) {
System.clearProperty(USER_HOME_PROPERTY);
} else {
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
}
}
@Test
void aPlainInstallationReportsExactlyOneImplicitRootProfile() {
List<OperatorProfile> knownProfiles = managementService.listProfiles();
assertEquals(1, knownProfiles.size());
assertTrue(knownProfiles.get(0).isRootProfile());
assertFalse(Files.exists(applicationFile("profiles.xml")),
"Merely listing profiles must not create a registry");
}
@Test
void creatingTheSecondProfileMaterialisesTheRegistryIncludingTheRootProfile() {
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
assertNotNull(createdProfile);
assertEquals("DN9APW", createdProfile.getProfileId());
assertTrue(Files.exists(applicationFile("profiles.xml")));
List<OperatorProfile> knownProfiles = managementService.listProfiles();
assertEquals(2, knownProfiles.size());
assertTrue(knownProfiles.get(0).isRootProfile());
assertEquals("DN9APW", knownProfiles.get(1).getProfileId());
assertTrue(Files.exists(applicationFile("profiles/DN9APW/preferences.xml")));
// The historic files must stay exactly where an older release expects them.
assertFalse(Files.exists(applicationFile("profiles/default")));
}
@Test
void aNewProfileStartsWithoutLoginCredentials() {
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
ChatPreferences createdPreferences =
preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(createdProfile));
assertEquals("", createdPreferences.getStn_loginCallSign());
assertEquals("", createdPreferences.getStn_loginPassword());
}
@Test
void duplicatingKeepsTheStationSetupButClearsCallsignAndPassword() {
OperatorProfile sourceProfile = managementService.createProfile("Source", false);
ChatPreferences sourcePreferences =
preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(sourceProfile));
sourcePreferences.setStn_loginCallSign("DM5M");
sourcePreferences.setStn_loginPassword("secret");
sourcePreferences.setStn_loginLocatorMainCat("JO51IJ");
sourcePreferences.setStn_antennaBeamWidthDeg(17.5);
assertTrue(sourcePreferences.writePreferencesToXmlFile());
OperatorProfile duplicatedProfile =
managementService.duplicateProfile(sourceProfile, "Copy of source");
assertNotNull(duplicatedProfile);
ChatPreferences duplicatedPreferences =
preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(duplicatedProfile));
// The work worth keeping.
assertEquals("JO51IJ", duplicatedPreferences.getStn_loginLocatorMainCat());
assertEquals(17.5, duplicatedPreferences.getStn_antennaBeamWidthDeg());
// The identity that must not be inherited.
assertEquals("", duplicatedPreferences.getStn_loginCallSign());
assertEquals("", duplicatedPreferences.getStn_loginPassword());
}
@Test
void switchingBetweenSharedAndOwnWorkedDataChangesOnlyTheDatabasePath() {
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
assertEquals("profiles/DN9APW/praktiKST.db",
OperatorProfilePaths.workedDatabaseRelativeFileName(createdProfile));
assertTrue(managementService.setSharedWorkedDatabase(createdProfile, true));
OperatorProfile reloadedProfile = managementService.listProfiles().get(1);
assertTrue(reloadedProfile.isSharedWorkedDatabase());
assertEquals("praktiKST.db",
OperatorProfilePaths.workedDatabaseRelativeFileName(reloadedProfile));
assertEquals("profiles/DN9APW/preferences.xml",
OperatorProfilePaths.preferencesRelativeFileName(reloadedProfile));
}
@Test
void deletingRemovesTheProfileDirectoryButNeverTheRootProfile() {
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
assertTrue(Files.exists(applicationFile("profiles/DN9APW/preferences.xml")));
OperatorProfile rootProfile = managementService.listProfiles().get(0);
assertFalse(managementService.deleteProfile(rootProfile),
"The root profile is the installation itself and must not be removable");
assertTrue(managementService.deleteProfile(createdProfile));
assertFalse(Files.exists(applicationFile("profiles/DN9APW")));
assertEquals(1, managementService.listProfiles().size());
}
@Test
void renamingKeepsTheIdentifierAndTherebyAllPaths() {
OperatorProfile createdProfile = managementService.createProfile("DN9APW", false);
assertTrue(managementService.renameProfile(createdProfile, "Philipp portable"));
OperatorProfile renamedProfile = managementService.listProfiles().get(1);
assertEquals("Philipp portable", renamedProfile.getDisplayName());
assertEquals("DN9APW", renamedProfile.getProfileId());
assertEquals("profiles/DN9APW/preferences.xml",
OperatorProfilePaths.preferencesRelativeFileName(renamedProfile));
}
private ChatPreferences preferencesAt(final String relativeFileName) {
ChatPreferences preferences = new ChatPreferences(relativeFileName);
preferences.readPreferencesFromXmlFile();
return preferences;
}
private Path applicationFile(final String relativeFileName) {
return temporaryHomeDirectory.resolve(".praktiKST").resolve(relativeFileName);
}
}
@@ -0,0 +1,81 @@
package kst4contest.controller;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OperatorProfilePathsTest {
@Test
void rootProfileKeepsTheHistoricFlatFileNames() {
OperatorProfileSelection resolved =
OperatorProfilePaths.resolve(OperatorProfilePaths.buildRootProfile("Default"));
// This is the downgrade guard: an older KST4Contest release reads exactly these
// two files. If this test ever fails, existing installations would silently lose
// their configuration and worked data when the operator reverts a version.
assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName());
assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
assertTrue(resolved.isSeedWorkedDatabaseFromResource());
}
@Test
void additionalProfileWithSharedDatabaseUsesItsOwnPreferencesButTheStationDatabase() {
OperatorProfile sharedProfile = new OperatorProfile("OP2", "DN9APW", false, true);
OperatorProfileSelection resolved = OperatorProfilePaths.resolve(sharedProfile);
assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName());
assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
assertTrue(resolved.isSeedWorkedDatabaseFromResource());
}
@Test
void additionalProfileWithOwnDatabaseIsFullySeparatedAndNotSeeded() {
OperatorProfile ownDatabaseProfile = new OperatorProfile("OP2", "DN9APW", false, false);
OperatorProfileSelection resolved = OperatorProfilePaths.resolve(ownDatabaseProfile);
assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName());
assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
// Seeding would hand a new operator the several thousand callsigns of the
// bundled template database.
assertFalse(resolved.isSeedWorkedDatabaseFromResource());
}
@Test
void profileIdIsFileSystemSafe() {
assertEquals("DN9APW", OperatorProfilePaths.toProfileId("dn9apw", Set.of()));
assertEquals("DM5M_CONTEST", OperatorProfilePaths.toProfileId("DM5M Contest", Set.of()));
assertEquals("A_B", OperatorProfilePaths.toProfileId("a/../b", Set.of()));
assertEquals("MULLER", OperatorProfilePaths.toProfileId("Müller", Set.of()));
assertEquals("OP", OperatorProfilePaths.toProfileId(" ", Set.of()));
assertEquals("OP", OperatorProfilePaths.toProfileId(null, Set.of()));
String longName = "A".repeat(60);
assertEquals(32, OperatorProfilePaths.toProfileId(longName, Set.of()).length());
}
@Test
void profileIdNeverCollidesAndNeverClaimsTheRootIdentifier() {
assertEquals("DN9APW_2", OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW")));
assertEquals("DN9APW_3",
OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW", "DN9APW_2")));
// "default" is reserved for the historic flat installation.
assertNotEquals(OperatorProfilePaths.ROOT_PROFILE_ID,
OperatorProfilePaths.toProfileId("default", Set.of()));
}
}
@@ -0,0 +1,117 @@
package kst4contest.controller;
import kst4contest.model.OperatorProfile;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OperatorProfileStoreTest {
@TempDir
Path temporaryDirectory;
@Test
void missingRegistryIsNotAnErrorAndIsNotCreated() {
OperatorProfileStore store = storeAt("profiles.xml");
assertFalse(store.isRegistryPresent());
assertTrue(store.loadProfiles().isEmpty());
assertEquals(Optional.empty(), store.loadLastUsedProfileId());
// A single operator installation must stay untouched by merely starting up.
assertFalse(Files.exists(temporaryDirectory.resolve("profiles.xml")));
}
@Test
void profilesSurviveAWriteReadRoundTrip() {
OperatorProfileStore store = storeAt("profiles.xml");
OperatorProfile rootProfile = OperatorProfilePaths.buildRootProfile("DM5M station");
OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false);
secondProfile.setLastUsedEpochMs(1757328000000L);
assertTrue(store.saveProfiles(List.of(rootProfile, secondProfile), "OP2"));
assertTrue(store.isRegistryPresent());
List<OperatorProfile> restored = store.loadProfiles();
assertEquals(2, restored.size());
assertEquals("default", restored.get(0).getProfileId());
assertEquals("DM5M station", restored.get(0).getDisplayName());
assertTrue(restored.get(0).isRootProfile());
assertTrue(restored.get(0).isSharedWorkedDatabase());
assertEquals("OP2", restored.get(1).getProfileId());
assertEquals("DN9APW", restored.get(1).getDisplayName());
assertFalse(restored.get(1).isRootProfile());
assertFalse(restored.get(1).isSharedWorkedDatabase());
assertEquals(1757328000000L, restored.get(1).getLastUsedEpochMs());
assertEquals(Optional.of("OP2"), store.loadLastUsedProfileId());
}
@Test
void atomicWriteLeavesNoTemporaryFileBehind() throws IOException {
OperatorProfileStore store = storeAt("profiles.xml");
store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default");
try (var directoryEntries = Files.list(temporaryDirectory)) {
assertTrue(directoryEntries.noneMatch(entry -> entry.getFileName().toString().endsWith(".tmp")));
}
}
@Test
void malformedRegistryFallsBackToNoAdditionalProfiles() throws IOException {
Path registryFile = temporaryDirectory.resolve("profiles.xml");
Files.writeString(registryFile, "<praktiKSTProfiles><profile><profileId>OP2");
OperatorProfileStore store = storeAt("profiles.xml");
assertTrue(store.isRegistryPresent());
assertTrue(store.loadProfiles().isEmpty());
assertEquals(Optional.empty(), store.loadLastUsedProfileId());
}
@Test
void entriesWithoutAnIdentifierAreSkippedInsteadOfBreakingTheRegistry() throws IOException {
Path registryFile = temporaryDirectory.resolve("profiles.xml");
Files.writeString(registryFile,
"<praktiKSTProfiles>"
+ "<profile><displayName>broken</displayName></profile>"
+ "<profile><profileId>OP2</profileId><displayName>DN9APW</displayName>"
+ "<sharedWorkedDatabase>false</sharedWorkedDatabase></profile>"
+ "</praktiKSTProfiles>");
List<OperatorProfile> restored = storeAt("profiles.xml").loadProfiles();
assertEquals(1, restored.size());
assertEquals("OP2", restored.get(0).getProfileId());
}
@Test
void recordLastUsedDoesNothingWhenNoRegistryExists() {
OperatorProfileStore store = storeAt("profiles.xml");
assertFalse(store.recordLastUsed("default"));
assertFalse(store.isRegistryPresent());
}
private OperatorProfileStore storeAt(final String fileName) {
return new OperatorProfileStore(temporaryDirectory.resolve(fileName).toString());
}
}
@@ -0,0 +1,24 @@
package kst4contest.test;
import kst4contest.utils.PlatformUtils;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PlatformUtilsTest {
@Test
void macOsNamesAreDetected() {
assertTrue(PlatformUtils.isMacOs("Mac OS X"));
assertTrue(PlatformUtils.isMacOs("macOS"));
}
@Test
void otherOrMissingNamesAreNotMacOs() {
assertFalse(PlatformUtils.isMacOs("Windows 11"));
assertFalse(PlatformUtils.isMacOs("Linux"));
assertFalse(PlatformUtils.isMacOs(""));
assertFalse(PlatformUtils.isMacOs(null));
}
}
@@ -0,0 +1,46 @@
package kst4contest.view;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class CommandLineOptionsTest {
@Test
void profileArgumentIsAcceptedInBothSpellings() {
assertEquals("OP2", CommandLineOptions.parse(List.of("--profile=OP2")).getRequestedProfileName());
assertEquals("OP2", CommandLineOptions.parse(List.of("--profile", "OP2")).getRequestedProfileName());
assertEquals("DM5M Contest",
CommandLineOptions.parse(List.of("--profile", "DM5M Contest")).getRequestedProfileName());
}
@Test
void missingOrEmptyProfileArgumentsAreTreatedAsAbsent() {
assertNull(CommandLineOptions.parse(List.of()).getRequestedProfileName());
assertNull(CommandLineOptions.parse(null).getRequestedProfileName());
assertNull(CommandLineOptions.parse(List.of("--profile=")).getRequestedProfileName());
assertNull(CommandLineOptions.parse(List.of("--profile")).getRequestedProfileName());
}
@Test
void unrelatedArgumentsAreIgnoredInsteadOfFailing() {
assertEquals("OP2",
CommandLineOptions.parse(List.of("--verbose", "--profile=OP2", "somefile.adi"))
.getRequestedProfileName());
assertNull(CommandLineOptions.parse(List.of("--verbose", "-x")).getRequestedProfileName());
}
@Test
void rememberedOptionsDefaultToEmptyInsteadOfNull() {
CommandLineOptions.remember(null);
assertNull(CommandLineOptions.remembered().getRequestedProfileName());
CommandLineOptions.remember(new CommandLineOptions("OP2"));
assertEquals("OP2", CommandLineOptions.remembered().getRequestedProfileName());
CommandLineOptions.remember(null);
}
}
@@ -0,0 +1,34 @@
package kst4contest.view;
import kst4contest.controller.On4KstConnectionState;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class MacOsConnectionStateMenuTitleTest {
@Test
void everyStateHasTheAgreedTitle() {
assertEquals("🟢 LINK: Connected",
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.ONLINE));
for (On4KstConnectionState state : new On4KstConnectionState[]{
On4KstConnectionState.CONNECTING,
On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
On4KstConnectionState.AUTHENTICATING,
On4KstConnectionState.SYNCING_MAIN_CHAT,
On4KstConnectionState.SYNCING_SECOND_CHAT}) {
assertEquals("🟡 LINK: Connecting…", Kst4ContestApplication.macOsConnectionStateMenuTitle(state));
}
assertEquals("🟡 LINK: Disconnecting…",
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.STOPPING));
assertEquals("🔴 LINK: Reconnecting…",
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.RECONNECT_WAIT));
assertEquals("🔴 LINK: Disconnected",
Kst4ContestApplication.macOsConnectionStateMenuTitle(On4KstConnectionState.DISCONNECTED));
}
@Test
void missingStateIsShownAsDisconnected() {
assertEquals("🔴 LINK: Disconnected", Kst4ContestApplication.macOsConnectionStateMenuTitle(null));
}
}
@@ -0,0 +1,161 @@
package kst4contest.view;
import kst4contest.controller.OperatorProfilePaths;
import kst4contest.controller.OperatorProfileStore;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OperatorProfileBootstrapTest {
@TempDir
Path temporaryDirectory;
@Test
void installationWithoutRegistryStartsSilentlyOnTheHistoricLayout() {
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileStore store = storeAt();
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store, new CommandLineOptions(null), countingPicker(pickerInvocations, null));
assertNotNull(resolved);
assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName());
assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
// Nothing may be asked, and nothing may be written.
assertEquals(0, pickerInvocations.get());
assertTrue(store.loadProfiles().isEmpty());
}
@Test
void singleProfileStartsWithoutAskingAnything() {
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileStore store = storeAt();
store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default");
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store, new CommandLineOptions(null), countingPicker(pickerInvocations, null));
assertNotNull(resolved);
assertEquals(0, pickerInvocations.get());
}
@Test
void twoProfilesAskTheOperatorAndPreselectTheLastUsedOne() {
OperatorProfileStore store = storeAt();
OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false);
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"), secondProfile), "OP2");
AtomicInteger pickerInvocations = new AtomicInteger();
String[] observedPreselection = new String[1];
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store,
new CommandLineOptions(null),
(profiles, preselectedProfileId) -> {
pickerInvocations.incrementAndGet();
observedPreselection[0] = preselectedProfileId;
return Optional.of(profiles.get(1));
});
assertEquals(1, pickerInvocations.get());
assertEquals("OP2", observedPreselection[0]);
assertNotNull(resolved);
assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName());
}
@Test
void aValidProfileArgumentSkipsThePicker() {
OperatorProfileStore store = storeAt();
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"),
new OperatorProfile("OP2", "DN9APW", false, false)),
"default");
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
OperatorProfileSelection byId = bootstrap.resolveAtStartup(
store, new CommandLineOptions("op2"), countingPicker(pickerInvocations, null));
assertEquals(0, pickerInvocations.get());
assertEquals("profiles/OP2/preferences.xml", byId.getPreferencesRelativeFileName());
assertNull(bootstrap.getStartupWarning());
OperatorProfileSelection byDisplayName = bootstrap.resolveAtStartup(
store, new CommandLineOptions("DN9APW"), countingPicker(pickerInvocations, null));
assertEquals(0, pickerInvocations.get());
assertEquals("profiles/OP2/preferences.xml", byDisplayName.getPreferencesRelativeFileName());
}
@Test
void anUnknownProfileArgumentWarnsAndFallsBackToTheNormalSelection() {
OperatorProfileStore store = storeAt();
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"),
new OperatorProfile("OP2", "DN9APW", false, false)),
"default");
AtomicInteger pickerInvocations = new AtomicInteger();
OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap();
OperatorProfileSelection resolved = bootstrap.resolveAtStartup(
store, new CommandLineOptions("NOPE"), countingPicker(pickerInvocations, 0));
assertEquals(1, pickerInvocations.get());
assertNotNull(resolved);
assertNotNull(bootstrap.getStartupWarning());
assertTrue(bootstrap.getStartupWarning().contains("NOPE"));
}
@Test
void quittingInThePickerYieldsNoSelection() {
OperatorProfileStore store = storeAt();
store.saveProfiles(
List.of(OperatorProfilePaths.buildRootProfile("Default"),
new OperatorProfile("OP2", "DN9APW", false, false)),
"default");
OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup(
store, new CommandLineOptions(null), (profiles, preselected) -> Optional.empty());
assertNull(resolved);
}
private OperatorProfileChoiceRequester countingPicker(final AtomicInteger invocationCounter,
final Integer profileIndexToChoose) {
return (profiles, preselectedProfileId) -> {
invocationCounter.incrementAndGet();
if (profileIndexToChoose == null) {
return Optional.of(profiles.get(0));
}
return Optional.of(profiles.get(profileIndexToChoose));
};
}
private OperatorProfileStore storeAt() {
return new OperatorProfileStore(temporaryDirectory.resolve("profiles.xml").toString());
}
}
@@ -0,0 +1,111 @@
package kst4contest.view.map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Guards the property that made the terrain cache useless with several operator
* profiles: it used to drop every cached profile whenever the configured callsign or
* locator changed.
*/
class TerrainProfileCacheRepositoryTest {
private static final String USER_HOME_PROPERTY = "user.home";
private static final String PROVIDER_ID = "test-provider";
private static final int SAMPLE_COUNT = 3;
@TempDir
Path temporaryHomeDirectory;
private String originalUserHome;
@BeforeEach
void redirectUserHomeToTemporaryDirectory() {
originalUserHome = System.getProperty(USER_HOME_PROPERTY);
System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString());
}
@AfterEach
void restoreUserHome() {
if (originalUserHome == null) {
System.clearProperty(USER_HOME_PROPERTY);
} else {
System.setProperty(USER_HOME_PROPERTY, originalUserHome);
}
}
@Test
void profilesOfDifferentOwnersCoexistAndSurviveSwitchingBackAndForth() {
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
// Another operator profile with a different callsign and locator.
repository.save("DN9APW", "JN59AA", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-b"));
Optional<TerrainProfileData> firstOwnerEntry = repository.load(
"DM5M", "JO51IJ", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
Optional<TerrainProfileData> secondOwnerEntry = repository.load(
"DN9APW", "JN59AA", "DL0ABC", "JN49FK", SAMPLE_COUNT, PROVIDER_ID);
assertTrue(firstOwnerEntry.isPresent(),
"Working under a second operator identity must not discard the first one's cache");
assertTrue(secondOwnerEntry.isPresent());
assertEquals("station-a", firstOwnerEntry.get().sourceName());
assertEquals("station-b", secondOwnerEntry.get().sourceName());
}
@Test
void anUnknownOwnerSimplyMissesTheCacheInsteadOfClearingIt() {
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
assertTrue(repository.load("DL0XYZ", "JO60AA", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID).isEmpty());
assertTrue(repository.load("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID).isPresent(),
"A cache miss of one owner must not remove the entries of another");
}
@Test
void theCacheLivesInItsOwnGlobalFileAndNotInTheWorkedStationDatabase() {
TerrainProfileCacheRepository repository = new TerrainProfileCacheRepository();
repository.save("DM5M", "JO51IJ", "DL0ABC", "JN49FK",
SAMPLE_COUNT, PROVIDER_ID, profileData("station-a"));
Path applicationDirectory = temporaryHomeDirectory.resolve(".praktiKST");
assertTrue(Files.exists(applicationDirectory.resolve("terrainprofilecache.db")));
assertFalse(Files.exists(applicationDirectory.resolve("praktiKST.db")),
"The terrain cache must not pull in the worked station database");
}
private static TerrainProfileData profileData(final String sourceName) {
return new TerrainProfileData(
List.of(
new PathProfilePoint(0.0, 51.0, 10.0, 100.0),
new PathProfilePoint(10.0, 51.1, 10.1, 220.0),
new PathProfilePoint(20.0, 51.2, 10.2, 150.0)),
sourceName,
false);
}
}
+257 -63
View File
@@ -5,10 +5,12 @@ runbook for the production analytics service on Ubuntu Server 24.04. Nothing in
the repository installs, updates or activates the server-side components the repository installs, updates or activates the server-side components
automatically. automatically.
The design has two separate outputs: The design has three separate outputs:
- private static GoAccess HTML and JSON reports for each registered project - private static GoAccess HTML and JSON reports for each registered project
subdomain and for all registered project subdomains combined; subdomain and for all registered project subdomains combined;
- private durable daily and annual views of website page views and successful
requests for the update-information XML file;
- a small public `visitor-count.json` file for sites which explicitly enable - a small public `visitor-count.json` file for sites which explicitly enable
the counter. the counter.
@@ -37,6 +39,17 @@ durable daily counter state --> public visitor-count.json
same-origin home-page request same-origin home-page request
private HTML reports --> Nginx Basic Auth --> stats.hamradioonline.de private HTML reports --> Nginx Basic Auth --> stats.hamradioonline.de
exact GET + HTTP 200 for /kst4ContestVersionInfo.xml
|
v
separate Nginx update-information log (14 days)
|
v
durable daily totals and countries
+--> hourly and client-group detail (14 days)
eligible page log --> durable daily page views, countries and paths
``` ```
Nginx writes a dedicated, reduced log. For each site, the generator gives the Nginx writes a dedicated, reduced log. For each site, the generator gives the
@@ -54,6 +67,13 @@ being added again. This makes repeated runs idempotent. Values older than 395
days remain in the counter state and continue to contribute to the public days remain in the counter state and continue to contribute to the public
total. total.
The private metric state is separate again. It retains website page-view
totals, absolute country values and individual normalized paths per day, plus
the equivalent totals and countries for update-information requests. Hourly
and recognisable client-group detail for update requests is removed after 14
days. The generator derives annual totals from the durable daily values. It
does not build a permanent path-by-country table.
## Production platform ## Production platform
The confirmed production baseline is: The confirmed production baseline is:
@@ -64,7 +84,7 @@ The confirmed production baseline is:
- GoAccess 1.8.1 with GeoIP2/MMDB and OpenSSL support, but without Zlib; - GoAccess 1.8.1 with GeoIP2/MMDB and OpenSSL support, but without Zlib;
- a local GeoLite2-Country database; - a local GeoLite2-Country database;
- systemd for the oneshot generator and its hourly timer; - systemd for the oneshot generator and its hourly timer;
- Logrotate for the dedicated analytics log. - Logrotate for the dedicated website and update-information logs.
Missing Zlib support is intentional for this operating model. The generator Missing Zlib support is intentional for this operating model. The generator
processes the current uncompressed analytics log and the optional uncompressed processes the current uncompressed analytics log and the optional uncompressed
@@ -74,18 +94,21 @@ processes the current uncompressed analytics log and the optional uncompressed
- `generate-reports.js` validates configuration and state, runs GoAccess and - `generate-reports.js` validates configuration and state, runs GoAccess and
publishes outputs atomically. publishes outputs atomically.
- `private-metrics.js` parses the reduced logs, maintains durable daily
aggregates and renders the protected daily and annual views.
- `sites.example.json` is the registry template. - `sites.example.json` is the registry template.
- `goaccess.conf.template` is rendered per report with a private database path - `goaccess.conf.template` is rendered per report with a private database path
and the configured GeoIP2 Country database. and the configured GeoIP2 Country database.
- `nginx/` contains the reduced log format, request filters, public endpoint - `nginx/` contains the reduced log format, request filters, public endpoint
and protected report-vhost examples. and protected report-vhost examples.
- `systemd/` contains a hardened oneshot service and hourly timer. - `systemd/` contains a hardened oneshot service and hourly timer.
- `logrotate/` retains 14 daily analytics-log rotations. - `logrotate/` retains 14 daily rotations for both reduced logs.
These repository files are templates and source files. Their productive These repository files are templates and source files. Their productive
counterparts are installed separately: counterparts are installed separately:
- generator: `/opt/hamradioonline-analytics/generate-reports.js`; - generator: `/opt/hamradioonline-analytics/generate-reports.js`;
- private metric module: `/opt/hamradioonline-analytics/private-metrics.js`;
- registry: `/etc/hamradioonline-analytics/sites.json`; - registry: `/etc/hamradioonline-analytics/sites.json`;
- GoAccess template: - GoAccess template:
`/etc/hamradioonline-analytics/goaccess.conf.template`; `/etc/hamradioonline-analytics/goaccess.conf.template`;
@@ -104,7 +127,8 @@ counterparts are installed separately:
examples. examples.
No npm package is required by the generator. GoAccess is the only external No npm package is required by the generator. GoAccess is the only external
program it starts. program it starts. Historical `.gz` input is decompressed by Node.js and does
not require Zlib support in the installed GoAccess 1.8.1 binary.
The production compatibility baseline is GoAccess 1.8.1 built with The production compatibility baseline is GoAccess 1.8.1 built with
`--enable-geoip=mmdb` and `--with-openssl`, but without `--with-zlib`. Zlib is `--enable-geoip=mmdb` and `--with-openssl`, but without `--with-zlib`. Zlib is
@@ -128,10 +152,10 @@ home directory, `/usr/sbin/nologin` as its shell and a locked password. It runs
the generator and GoAccess, reads the reduced logs and configuration, and the generator and GoAccess, reads the reduced logs and configuration, and
writes only below the configured service-state directories. writes only below the configured service-state directories.
Nginx runs as `www-data`. It writes the dedicated analytics log and reads the Nginx runs as `www-data`. It writes the dedicated website and update-information
private reports and public counter. It must not be able to read the private logs and reads the private reports and public counter. It must not be able to
GoAccess databases or `public-counter-state.json`, and it has no write access read the private GoAccess databases, `public-counter-state.json` or
to generated output. `private-metrics-state.json`, and it has no write access to generated output.
`stats-reader` is the current local Nginx Basic Auth username. It is not a `stats-reader` is the current local Nginx Basic Auth username. It is not a
Linux service account and not an account with an external analytics provider. Linux service account and not an account with an external analytics provider.
@@ -161,6 +185,9 @@ sudo install -d -o root -g hamradio-analytics -m 0750 \
sudo install -o root -g hamradio-analytics -m 0750 \ sudo install -o root -g hamradio-analytics -m 0750 \
website/ops/analytics/generate-reports.js \ website/ops/analytics/generate-reports.js \
/opt/hamradioonline-analytics/generate-reports.js /opt/hamradioonline-analytics/generate-reports.js
sudo install -o root -g hamradio-analytics -m 0640 \
website/ops/analytics/private-metrics.js \
/opt/hamradioonline-analytics/private-metrics.js
sudo install -o root -g hamradio-analytics -m 0640 \ sudo install -o root -g hamradio-analytics -m 0640 \
website/ops/analytics/goaccess.conf.template \ website/ops/analytics/goaccess.conf.template \
/etc/hamradioonline-analytics/goaccess.conf.template /etc/hamradioonline-analytics/goaccess.conf.template
@@ -191,6 +218,7 @@ sudo install -d -o hamradio-analytics -g www-data -m 2750 \
/var/lib/hamradioonline-analytics/reports \ /var/lib/hamradioonline-analytics/reports \
/var/lib/hamradioonline-analytics/reports/combined \ /var/lib/hamradioonline-analytics/reports/combined \
/var/lib/hamradioonline-analytics/reports/kst4contest \ /var/lib/hamradioonline-analytics/reports/kst4contest \
/var/lib/hamradioonline-analytics/reports/metrics \
/var/lib/hamradioonline-analytics/public \ /var/lib/hamradioonline-analytics/public \
/var/lib/hamradioonline-analytics/public/kst4contest /var/lib/hamradioonline-analytics/public/kst4contest
``` ```
@@ -204,13 +232,16 @@ boundary after systemd has prepared the state directory.
The productive ownership and mode boundaries are: The productive ownership and mode boundaries are:
- generator: `0750 root:hamradio-analytics`; - generator: `0750 root:hamradio-analytics`;
- private metric module: `0640 root:hamradio-analytics`;
- registry and GoAccess configuration: `0640 root:hamradio-analytics`; - registry and GoAccess configuration: `0640 root:hamradio-analytics`;
- report directories, including `reports/combined` and - report directories, including `reports/combined`, `reports/kst4contest` and
`reports/kst4contest`: `2750 hamradio-analytics:www-data`; `reports/metrics`: `2750 hamradio-analytics:www-data`;
- report files: `0640 hamradio-analytics:www-data`; - report files: `0640 hamradio-analytics:www-data`;
- private database files: `0640 hamradio-analytics:hamradio-analytics`; - private database files: `0640 hamradio-analytics:hamradio-analytics`;
- `public-counter-state.json`: mode `0640`, owner and group - `public-counter-state.json`: mode `0640`, owner and group
`hamradio-analytics:hamradio-analytics`; `hamradio-analytics:hamradio-analytics`;
- `private-metrics-state.json`: mode `0640`, owner and group
`hamradio-analytics:hamradio-analytics`;
- public output directories, including `public/kst4contest`: mode `2750`, - public output directories, including `public/kst4contest`: mode `2750`,
owner and group `hamradio-analytics:www-data`; owner and group `hamradio-analytics:www-data`;
- public `visitor-count.json`: `0644 hamradio-analytics:www-data`; - public `visitor-count.json`: `0644 hamradio-analytics:www-data`;
@@ -234,11 +265,17 @@ if [ ! -e /var/log/nginx/kst4contest-analytics.log ]; then
sudo install -o www-data -g hamradio-analytics -m 0640 /dev/null \ sudo install -o www-data -g hamradio-analytics -m 0640 /dev/null \
/var/log/nginx/kst4contest-analytics.log /var/log/nginx/kst4contest-analytics.log
fi fi
if [ ! -e /var/log/nginx/kst4contest-update-information.log ]; then
sudo install -o www-data -g hamradio-analytics -m 0640 /dev/null \
/var/log/nginx/kst4contest-update-information.log
fi
sudo stat -c '%U:%G %a %n' /var/log/nginx/kst4contest-analytics.log sudo stat -c '%U:%G %a %n' /var/log/nginx/kst4contest-analytics.log
sudo stat -c '%U:%G %a %n' \
/var/log/nginx/kst4contest-update-information.log
``` ```
The resulting log owner and mode must be The resulting log owner and mode must be
`www-data:hamradio-analytics 640`. Logrotate preserves that ownership. The `www-data:hamradio-analytics 640` for both files. Logrotate preserves that ownership. The
generator's configuration check fails clearly if required output directories generator's configuration check fails clearly if required output directories
are missing or if the executing user cannot read an analytics log or the are missing or if the executing user cannot read an analytics log or the
Country database. Country database.
@@ -252,33 +289,48 @@ server layout. Each site entry contains:
- its exact `hostname`; - its exact `hostname`;
- the current, uncompressed `analyticsLog` path; - the current, uncompressed `analyticsLog` path;
- the `activatedOn` date used by the public counter; - the `activatedOn` date used by the public counter;
- `websiteMetricsSince`, the first day covered by the new live website
page-view aggregation;
- an `updateInfo` object containing the exact XML path, its separate current
log and the first day covered by live update-information aggregation;
- a `publicCounter` switch; - a `publicCounter` switch;
- the private `reportOutputDirectory`; - the private `reportOutputDirectory`;
- a `publicJsonPath` when the public counter is enabled. - a `publicJsonPath` when the public counter is enabled.
The top-level `combined.reportOutputDirectory` receives the combined report. The top-level `privateMetrics` object defines its private state and report
paths, fixes the time zone to `Europe/Berlin` and fixes detailed retention to
14 days. The top-level `combined.reportOutputDirectory` receives the combined report.
Only registered sites are included. The generator rejects Only registered sites are included. The generator rejects
`stats.hamradioonline.de`, so the report host cannot accidentally become part `stats.hamradioonline.de`, so the report host cannot accidentally become part
of the project statistics. of the project statistics.
The dates in `sites.example.json` document the repository snapshot; they are
not installation defaults. Before enabling the new logs, replace
`websiteMetricsSince` and `updateInfo.metricsSince` with the actual first live
coverage day. If collection started earlier than the current/`.1` handover,
import the covered rotations before the first regular run. Do not backdate a
field merely to obtain an earlier-looking report.
To add another project subdomain later, add one registry entry and one matching To add another project subdomain later, add one registry entry and one matching
dedicated `access_log` line to its Nginx server block. Do not enable a public dedicated `access_log` line to its Nginx server block. Do not enable a public
counter unless that site should publish one. counter unless that site should publish one.
Treat `activatedOn` as persistent data. Once counting has started, changing it Treat `activatedOn`, `websiteMetricsSince` and `updateInfo.metricsSince` as
persistent data. Once counting has started, changing one of these values
would change the meaning of the total. The generator refuses to combine a new would change the meaning of the total. The generator refuses to combine a new
activation date with existing counter state. date with the corresponding existing state.
The hostname is persistent identity as well. If an existing site state has a The hostname is persistent identity as well. If an existing site state has a
different `activatedOn` or hostname, do not delete the state to make the next different `activatedOn` or hostname, do not delete the state to make the next
run pass. Changing either value requires a deliberate migration or a run pass. Changing either value requires a deliberate migration or a
specifically approved reset of the public count. specifically approved reset of the public count.
The generator derives the optional `.1` path from `analyticsLog`. It is valid The generator derives the optional `.1` paths from `analyticsLog` and
for `.1` not to exist before the first rotation. Do not enter a rotation or a `updateInfo.analyticsLog`. It is valid for `.1` not to exist before the first
compressed `.gz` file in the registry. rotation. Do not enter a rotation or a compressed `.gz` file in the registry.
Adding another project subdomain also requires its own Nginx analytics log, Adding another project subdomain also requires its own Nginx website and
update-information logs,
the corresponding Logrotate ownership, a prepared report directory and, when the corresponding Logrotate ownership, a prepared report directory and, when
enabled, a public-output directory and counter location. The combined report enabled, a public-output directory and counter location. The combined report
uses the logs of every registered project site. The statistics vhost remains uses the logs of every registered project site. The statistics vhost remains
@@ -295,9 +347,9 @@ The relevant production configuration files are:
- `/etc/nginx/sites-available/stats.hamradioonline.de`. - `/etc/nginx/sites-available/stats.hamradioonline.de`.
Install the log-format and filter maps from `nginx/` in the `http` context. Install the log-format and filter maps from `nginx/` in the `http` context.
Then add a dedicated analytics `access_log` to every registered project server Then add the dedicated website and update-information `access_log` directives
block. Keep the existing operational access log unless its replacement has to every registered project server block. Keep the existing operational access
been reviewed separately. If the operational log is inherited from the log unless its replacement has been reviewed separately. If the operational log is inherited from the
`http` context, repeat its directive in the server block before adding the `http` context, repeat its directive in the server block before adding the
analytics log; an `access_log` at a lower level changes inheritance. analytics log; an `access_log` at a lower level changes inheritance.
@@ -333,9 +385,10 @@ The analytics format contains only:
- transferred body size; - transferred body size;
- user agent. - user agent.
The fields are tab-separated. The production log is The fields are tab-separated. The production website log is
`/var/log/nginx/kst4contest-analytics.log`. Nginx writes it as `www-data`; the `/var/log/nginx/kst4contest-analytics.log`; the separate XML log is
`hamradio-analytics` group can read it. The confirmed owner and mode are `/var/log/nginx/kst4contest-update-information.log`. Nginx writes both as
`www-data`; the `hamradio-analytics` group can read them. The confirmed owner and mode are
`www-data:hamradio-analytics 0640`. The analytics service receives read-only `www-data:hamradio-analytics 0640`. The analytics service receives read-only
access and must never truncate or otherwise modify this log. access and must never truncate or otherwise modify this log.
@@ -344,7 +397,7 @@ analytics log. It also omits referrer and authenticated remote-user data. The
user agent is retained because GoAccess needs it for crawler classification user agent is retained because GoAccess needs it for crawler classification
and its visit definition. and its visit definition.
Only eligible `GET` requests can be logged. Assets, downloads, status and Only eligible `GET` requests enter the website analytics log. Assets, downloads, status and
monitoring paths, sitemap, robots file, favicons, the update feed and the monitoring paths, sitemap, robots file, favicons, the update feed and the
public counter endpoint are excluded. Known bots, crawlers, monitoring public counter endpoint are excluded. Known bots, crawlers, monitoring
clients, `wget` and `curl` are rejected before logging. GoAccess applies its clients, `wget` and `curl` are rejected before logging. GoAccess applies its
@@ -352,6 +405,14 @@ own crawler list as a second layer and treats unknown browser or operating
system combinations as crawlers. The public counter request therefore cannot system combinations as crawlers. The public counter request therefore cannot
count itself, and the statistics vhost has no analytics logging of its own. count itself, and the statistics vhost has no analytics logging of its own.
The update-information map is deliberately independent of the website and bot
filters. It logs only an exact case-sensitive `GET` request for
`/kst4ContestVersionInfo.xml` when the final response status is `200`. `HEAD`,
`304`, redirects and error responses do not enter this log. Browsers and bots
are not excluded, because the metric describes successful file requests, not
program starts or people. The XML remains excluded from the website log, so it
does not change website page views, visits or `visitor-count.json`.
Review the monitoring-path list against the real server before activation. Review the monitoring-path list against the real server before activation.
When a new health endpoint or asset family is added, update the filter first. When a new health endpoint or asset family is added, update the filter first.
@@ -363,8 +424,8 @@ sudo nginx -t
### Log rotation ### Log rotation
`/etc/logrotate.d/hamradioonline-analytics` rotates the dedicated analytics `/etc/logrotate.d/hamradioonline-analytics` rotates both dedicated logs daily,
logs daily, retains 14 rotations and compresses older files. `delaycompress` retains 14 rotations and compresses older files. `delaycompress`
is an operational requirement: it keeps the immediately preceding rotation is an operational requirement: it keeps the immediately preceding rotation
as an uncompressed `.1` file for the next generator run. The `create 0640 as an uncompressed `.1` file for the next generator run. The `create 0640
www-data hamradio-analytics` directive preserves the write/read boundary. www-data hamradio-analytics` directive preserves the write/read boundary.
@@ -372,8 +433,8 @@ After rotation, `invoke-rc.d nginx rotate` makes Nginx reopen its logs.
The generator processes, in this order: The generator processes, in this order:
1. the optional, uncompressed `.1` rotation; 1. each optional, uncompressed `.1` rotation;
2. the current analytics log. 2. each corresponding current log.
A missing `.1` is normal, including before the first rotation. Older `.gz` A missing `.1` is normal, including before the first rotation. Older `.gz`
files are retained according to Logrotate but are not imported by the regular files are retained according to Logrotate but are not imported by the regular
@@ -419,20 +480,45 @@ succeeded.
Logrotate must use `delaycompress`, as shown in the example. This leaves `.1` Logrotate must use `delaycompress`, as shown in the example. This leaves `.1`
uncompressed for one rotation cycle. Older `.gz` files are not part of the uncompressed for one rotation cycle. Older `.gz` files are not part of the
regular hourly run, and importing them is a separate maintenance task outside regular hourly run. The explicit historical-import mode can read them through
this repository workflow. Do not add an unstable decompression pipeline to Node.js; it never asks the GoAccess binary to decompress them. Do not add an
the timer service. incremental decompression pipeline to the timer service.
If the generator is unavailable for longer than the uncompressed rotation If the generator is unavailable for longer than the uncompressed rotation
window, the regular run cannot recover entries found only in older `.gz` window, the regular run cannot recover entries found only in older `.gz`
files. Preserve those files under the raw-log retention policy and plan any files. Preserve those files under the raw-log retention policy and plan any
necessary historical import separately before resuming normal processing. necessary historical import separately before resuming normal processing.
### Durable private aggregates
For each covered day, the generator rebuilds the relevant slice from the
available reduced logs and replaces or monotonically extends the stored value.
It does not add a whole hourly result to the previous result. Website page
views use the same Nginx page/bot filters and the same GoAccess crawler
classification as the existing reports. The generator verifies that the sum
of all path values equals GoAccess's request total; a discrepancy stops the run
instead of silently establishing a second page-view definition.
The website series stores page-view totals, absolute Country-panel values and
every normalized path per day. The update-information series stores successful
request totals and absolute Country-panel values per day. A missing Country
assignment is stored as `Unknown`; no city or exact location is inferred.
Hourly update-request totals and conservative client groups are kept only for
the latest 14 calendar days in `Europe/Berlin`. Raw user agents never enter the
durable metric state. A Java user agent is labelled as an unknown Java
application, not as KST4Contest.
Annual totals, annual Country totals and annual page totals are calculated from
the daily state when the static reports are generated. The first covered day
is shown for every series. Earlier dates are unknown and are not emitted as
zero. There is deliberately no permanent path-by-Country-by-request table.
### Visit and privacy boundary ### Visit and privacy boundary
GoAccess treats requests with the same IP address, date and user agent as one GoAccess treats requests with the same IP address, date and user agent as one
visit. The public number is therefore an approximate visit total, not a count visit. The public number is therefore an approximate visit total, not a count
of uniquely identified people. Page views remain a separate statistic. of uniquely identified people. Page views remain a separate statistic and are
displayed separately from the durable daily visit values.
IP addresses are processed with the configured GoAccess anonymisation level. IP addresses are processed with the configured GoAccess anonymisation level.
Country resolution happens locally against GeoLite2-Country; City and host Country resolution happens locally against GeoLite2-Country; City and host
@@ -447,14 +533,15 @@ no visitor-level or daily detail.
## Persistence and publication ## Persistence and publication
The installation has four distinct persistence layers. The installation has five distinct persistence layers.
### Raw logs ### Raw logs
The current analytics log and its rotations are short-lived input. The current The current website and update-information logs and their rotations are
file and `.1` bridge requests across the most recent rotation. Logrotate limits short-lived input. Each current file and `.1` bridge requests across the most
raw-log retention to the published 14-day policy; backups must not silently recent rotation. These files contain individual IP addresses, timestamps and
extend that period. raw user agents. Logrotate limits their retention to the published 14-day
policy; backups must not silently extend that period.
### GoAccess databases ### GoAccess databases
@@ -480,6 +567,22 @@ The stored hostname and `activatedOn` must continue to match the registry.
Changing either value requires a planned migration or an approved reset, not Changing either value requires a planned migration or an approved reset, not
an ad-hoc edit or deletion of the state file. an ad-hoc edit or deletion of the state file.
### Private metric state
`/var/lib/hamradioonline-analytics/private-metrics-state.json` is the durable
source for daily website page views and update-information requests. It stores
daily totals and absolute Country values; website days also store normalized
path totals. Only update-information days within the latest 14-day window may
contain hourly and client-group aggregates. The file contains no individual
IP address, raw user agent or individual timestamp and remains unreadable by
Nginx.
Daily values are upserted, not added. Re-reading the current log, `.1` or an
overlapping historical import therefore does not multiply a day. A conflicting
or decreasing overlap stops the run for investigation. Keep this file with
the public counter state and GoAccess databases in the later separate
backup/recovery plan.
### Reports and public files ### Reports and public files
The derived outputs are: The derived outputs are:
@@ -488,6 +591,8 @@ The derived outputs are:
- `/var/lib/hamradioonline-analytics/reports/kst4contest/report.json`; - `/var/lib/hamradioonline-analytics/reports/kst4contest/report.json`;
- `/var/lib/hamradioonline-analytics/reports/combined/report.html`; - `/var/lib/hamradioonline-analytics/reports/combined/report.html`;
- `/var/lib/hamradioonline-analytics/reports/combined/report.json`; - `/var/lib/hamradioonline-analytics/reports/combined/report.json`;
- `/var/lib/hamradioonline-analytics/reports/metrics/report.html` and its
per-site daily/yearly pages;
- `/var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json`. - `/var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json`.
The generator prepares every GoAccess job in a run directory, validates the The generator prepares every GoAccess job in a run directory, validates the
@@ -500,7 +605,7 @@ transaction across every report, database and counter file; after a storage or
permission failure during publication, inspect the complete set and rerun the permission failure during publication, inspect the complete set and rerun the
service after correcting the cause. service after correcting the cause.
Counter state and GoAccess databases are the important persistent sources. Counter state, private metric state and GoAccess databases are the important persistent sources.
HTML/JSON reports and `visitor-count.json` are derived and can be rebuilt when HTML/JSON reports and `visitor-count.json` are derived and can be rebuilt when
their corresponding source state is available. their corresponding source state is available.
@@ -512,10 +617,14 @@ the reviewed repository file:
```sh ```sh
sudo stat -c '%U:%G %a %s %n' \ sudo stat -c '%U:%G %a %s %n' \
/opt/hamradioonline-analytics/generate-reports.js /opt/hamradioonline-analytics/generate-reports.js \
/opt/hamradioonline-analytics/private-metrics.js
sha256sum /srv/git/kst4contest/website/ops/analytics/generate-reports.js \ sha256sum /srv/git/kst4contest/website/ops/analytics/generate-reports.js \
/opt/hamradioonline-analytics/generate-reports.js /opt/hamradioonline-analytics/generate-reports.js \
/srv/git/kst4contest/website/ops/analytics/private-metrics.js \
/opt/hamradioonline-analytics/private-metrics.js
/usr/bin/node --check /opt/hamradioonline-analytics/generate-reports.js /usr/bin/node --check /opt/hamradioonline-analytics/generate-reports.js
/usr/bin/node --check /opt/hamradioonline-analytics/private-metrics.js
``` ```
A zero-byte JavaScript file is syntactically valid and exits successfully A zero-byte JavaScript file is syntactically valid and exits successfully
@@ -563,6 +672,56 @@ history which is no longer present in the raw logs. Back up this state file: it
is the durable source for public totals older than the detailed retention is the durable source for public totals older than the detailed retention
window. window.
### Historical import
Historical import is a manual maintenance operation. Do not add import options
to the systemd unit. First make a protected working copy of the still available
regular Nginx access logs, including `.1` and `.gz`, and inventory their actual
first and last records. Use only a continuous date range which is genuinely
covered by the selected files. A missing earlier file is missing history, not a
zero day.
The importer accepts either the standard Nginx combined format or the reduced
tab-separated analytics format. It rejects malformed lines, unreadable files,
unknown sites and invalid coverage ranges. Do not guess a production log
format: compare a redacted sample with the selected parser before the import.
For a standard combined-log import:
```sh
sudo -u hamradio-analytics /usr/bin/node \
/opt/hamradioonline-analytics/generate-reports.js \
--registry /etc/hamradioonline-analytics/sites.json \
--config-template /etc/hamradioonline-analytics/goaccess.conf.template \
--import-site kst4contest \
--import-format nginx-combined \
--coverage-from YYYY-MM-DD \
--coverage-through YYYY-MM-DD \
--import-log /protected/import/access.log.3.gz \
--import-log /protected/import/access.log.2.gz \
--import-log /protected/import/access.log.1
```
Use `analytics-tsv` only when the source is genuinely in the maintained
reduced format. Query strings are removed and paths normalized by the importer.
Website requests pass the same maintained page and known-bot filters and then
GoAccess's crawler classification. Update-information requests require exact
`GET`/`200` semantics and do not exclude bots.
The selected input set is aggregated by complete day. Duplicate copies of the
same records across selected files are collapsed while repeated identical
records within one source remain counted. Each imported day replaces or
monotonically extends its stored aggregate; rerunning the same command is
idempotent. An overlap which changes distributions without a consistent higher
total fails instead of adding uncertain data. Run once against a protected copy
of the private state or with `--dry-run`, inspect the first covered dates and
totals, then run productively. Keep a pre-import backup until a second identical
run confirms stable totals.
Node.js decompresses `.gz` inputs itself. The production GoAccess 1.8.1 binary
does not need Zlib support. Remove the protected import copies according to the
14-day raw-data limit after the verified import; do not put them in a durable
backup.
### Safe verification sequence ### Safe verification sequence
Use this order for a new installation, a recovered service or a material Use this order for a new installation, a recovered service or a material
@@ -585,8 +744,9 @@ generator/configuration update:
run ends with `Analytics generation completed`. run ends with `Analytics generation completed`.
9. Check every generated file's path, owner, group, mode and timestamp. 9. Check every generated file's path, owner, group, mode and timestamp.
10. Request the public JSON through HTTPS and validate its four fields. 10. Request the public JSON through HTTPS and validate its four fields.
11. Request both private report URLs with Basic Auth. Let the client prompt for 11. Request the existing GoAccess URLs and `/metrics/` with Basic Auth. Follow
the password; never put it directly on a command line. its website and update-information daily/yearly links. Let the client prompt
for the password; never put it directly on a command line.
12. Run the service a second time and confirm that the total and report values 12. Run the service a second time and confirm that the total and report values
develop plausibly rather than multiplying the existing history. develop plausibly rather than multiplying the existing history.
13. Enable or re-enable the timer only after these checks pass. 13. Enable or re-enable the timer only after these checks pass.
@@ -655,6 +815,11 @@ The current private endpoints are:
- `https://stats.hamradioonline.de/combined/`, which serves the combined - `https://stats.hamradioonline.de/combined/`, which serves the combined
report; report;
- `https://stats.hamradioonline.de/kst4contest/`, which serves the site report. - `https://stats.hamradioonline.de/kst4contest/`, which serves the site report.
- `https://stats.hamradioonline.de/metrics/`, which links to the separate
website and update-information daily/yearly views.
The generator adds a small `/metrics/` link to each generated GoAccess HTML
report. The authenticated `/` redirect to `/combined/` remains unchanged.
All HTTPS paths, including the redirect target, remain behind Basic Auth. All HTTPS paths, including the redirect target, remain behind Basic Auth.
Reports use `Cache-Control: private, no-store`, Reports use `Cache-Control: private, no-store`,
@@ -767,12 +932,13 @@ analytics logging is active.
## Regular operation ## Regular operation
Nginx continuously writes only eligible requests to the dedicated analytics Nginx continuously writes eligible website requests and exact successful
log. The systemd timer starts the generator once per hour. Every successful update-information requests to separate reduced logs. The systemd timer starts
run refreshes the per-site and combined reports, persists the corresponding the generator once per hour. Every successful run refreshes the per-site and
GoAccess databases, updates daily counter values and finally publishes enabled combined GoAccess reports, private daily/yearly views, daily visit counter
public counters. Logrotate handles the raw log once per day and preserves the values and enabled public counters. Logrotate handles both raw logs once per
uncompressed `.1` handover file required by the generator. day and preserves each uncompressed `.1` handover file required by the
generator.
The normal operator signal is the service result and journal, not a permanently The normal operator signal is the service result and journal, not a permanently
running process: the generator is a short-lived oneshot service. There is no running process: the generator is a short-lived oneshot service. There is no
@@ -880,8 +1046,9 @@ present. The current analytics log remains required.
### `.gz` rotations on a GoAccess build without Zlib ### `.gz` rotations on a GoAccess build without Zlib
This is normal. Regular operation does not read `.gz` files. Do not configure This is normal. Regular operation does not read `.gz` files. Do not configure
a compressed or rotated file as `analyticsLog`; any exceptional historical a compressed or rotated file as `analyticsLog` or `updateInfo.analyticsLog`.
import must be planned separately. Use `.gz` only through the explicit historical-import options; Node.js, not
GoAccess, decompresses that input.
### MMDB missing or unreadable ### MMDB missing or unreadable
@@ -900,11 +1067,32 @@ replace or repurpose the normal operational access log.
sudo nginx -t sudo nginx -t
sudo nginx -T sudo nginx -T
sudo stat -c '%U:%G %a %s %y %n' \ sudo stat -c '%U:%G %a %s %y %n' \
/var/log/nginx/kst4contest-analytics.log /var/log/nginx/kst4contest-analytics.log \
/var/log/nginx/kst4contest-update-information.log
sudo -u hamradio-analytics test -r \ sudo -u hamradio-analytics test -r \
/var/log/nginx/kst4contest-analytics.log /var/log/nginx/kst4contest-analytics.log
sudo -u hamradio-analytics test -r \
/var/log/nginx/kst4contest-update-information.log
``` ```
### Private metrics gap
If the journal says that a private metric gap starts before the regular
current/`.1` handover window, stop the timer. Do not turn the missing dates
into zeros and do not delete `private-metrics-state.json`. Inventory the
remaining regular and reduced logs, make a protected state backup and use the
documented historical import only for a genuinely covered range. If no
reliable source remains, the first covered date must move forward through a
reviewed state migration; that is not an automatic repair.
### Historical import rejects a log
An invalid line normally means that the selected `nginx-combined` or
`analytics-tsv` parser does not match the real file, or that a file is damaged.
Inspect a redacted sample and the file boundaries. Do not delete failing lines
or switch formats until the actual Nginx log format is confirmed. A failed
import leaves the existing private state and reports unchanged.
### Public counter returns `404` ### Public counter returns `404`
This is expected before the first successful generation. Afterwards inspect This is expected before the first successful generation. Afterwards inspect
@@ -983,6 +1171,7 @@ part of this repository change.
At minimum, the later plan must cover: At minimum, the later plan must cover:
- `/var/lib/hamradioonline-analytics/public-counter-state.json`; - `/var/lib/hamradioonline-analytics/public-counter-state.json`;
- `/var/lib/hamradioonline-analytics/private-metrics-state.json`;
- `/var/lib/hamradioonline-analytics/db`; - `/var/lib/hamradioonline-analytics/db`;
- `/etc/hamradioonline-analytics`; - `/etc/hamradioonline-analytics`;
- the installed systemd units; - the installed systemd units;
@@ -997,20 +1186,23 @@ Handle the Basic Auth hash, MaxMind Account ID and License Key, GitHub deploy
token, ACME account data and private TLS keys as secrets. Never copy them into token, ACME account data and private TLS keys as secrets. Never copy them into
Git, public documentation, logs or ordinary support bundles. Git, public documentation, logs or ordinary support bundles.
The generator is recoverable from GitHub, and GeoLite2-Country can be fetched The generator modules are recoverable from GitHub, and GeoLite2-Country can be
again with `geoipupdate`. HTML/JSON reports can be rebuilt when the GoAccess fetched again with `geoipupdate`. HTML/JSON reports can be rebuilt when their
databases or sufficient raw logs remain. The public JSON can be rebuilt from GoAccess databases or durable state remain. The public JSON can be rebuilt
the counter state. from the counter state; the private daily/yearly pages can be rebuilt from
`private-metrics-state.json`.
The accumulated public total is not fully recoverable without The accumulated public total is not fully recoverable without
`public-counter-state.json`. Older detailed aggregates are not recoverable `public-counter-state.json`. Older detailed aggregates are not recoverable
without the GoAccess databases, and historical raw requests disappear after without the GoAccess databases, and historical raw requests disappear after
the 14-day rotation window. the 14-day rotation window.
Do not let backups extend the published raw-log retention by accident. Either The durable website page-view and update-information history is not fully
exclude analytics raw logs from durable backups or enforce the same confirmed recoverable without `private-metrics-state.json`. This state is aggregated and
retention limit in backup storage. Counter state and anonymised/aggregated belongs in the protected backup plan. Do not let backups extend the published
GoAccess state can be governed separately. raw-log retention by accident: exclude raw logs or enforce the same 14-day
maximum in backup storage. Counter state and anonymised or aggregated GoAccess
state can be governed separately.
### Recovery order ### Recovery order
@@ -1020,7 +1212,8 @@ GoAccess state can be governed separately.
4. Install the generator and non-secret configuration. 4. Install the generator and non-secret configuration.
5. Restore secrets and certificate state from protected backup storage. 5. Restore secrets and certificate state from protected backup storage.
6. Restore GeoLite2-Country or download it again. 6. Restore GeoLite2-Country or download it again.
7. Restore the GoAccess databases and public counter state. 7. Restore the GoAccess databases, public counter state and private metric
state.
8. Validate Nginx, systemd and Logrotate configuration. 8. Validate Nginx, systemd and Logrotate configuration.
9. Run the generator with `--check`. 9. Run the generator with `--check`.
10. Run `--dry-run` and verify that production state remains unchanged. 10. Run `--dry-run` and verify that production state remains unchanged.
@@ -1030,8 +1223,9 @@ GoAccess state can be governed separately.
## Outstanding operational checks ## Outstanding operational checks
The first real rotation of the dedicated analytics log still needs explicit The first real rotation after installing the separate update-information log
observation. This is not a current service blocker. After rotation, confirm: still needs explicit observation. This is not a current service blocker. For
both reduced logs, confirm:
- a new current log exists; - a new current log exists;
- `.1` exists and remains uncompressed; - `.1` exists and remains uncompressed;
+138 -13
View File
@@ -5,6 +5,11 @@ const fs = require("node:fs");
const os = require("node:os"); const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
const { spawnSync } = require("node:child_process"); const { spawnSync } = require("node:child_process");
const {
UPDATE_INFO_PATH,
generatePrivateMetrics,
validateImportOptions
} = require("./private-metrics");
const STATE_SCHEMA_VERSION = 1; const STATE_SCHEMA_VERSION = 1;
const PUBLIC_SCHEMA_VERSION = 1; const PUBLIC_SCHEMA_VERSION = 1;
@@ -156,6 +161,34 @@ function validateRegistry(registry) {
} }
analyticsLogPaths.add(analyticsLog); analyticsLogPaths.add(analyticsLog);
const websiteMetricsSince = parseIsoDate(site.websiteMetricsSince);
if (!websiteMetricsSince) {
throw new ConfigurationError(`${label}.websiteMetricsSince must be a valid ISO date`);
}
if (!site.updateInfo || typeof site.updateInfo !== "object" || Array.isArray(site.updateInfo)) {
throw new ConfigurationError(`${label}.updateInfo must be an object`);
}
if (site.updateInfo.path !== UPDATE_INFO_PATH) {
throw new ConfigurationError(`${label}.updateInfo.path must be ${UPDATE_INFO_PATH}`);
}
const updateInfoLog = requireAbsolutePath(
site.updateInfo.analyticsLog,
`${label}.updateInfo.analyticsLog`
);
if (/\.(?:\d+|gz)$/i.test(path.basename(updateInfoLog))) {
throw new ConfigurationError(
`${label}.updateInfo.analyticsLog must identify the current uncompressed log`
);
}
if (analyticsLogPaths.has(updateInfoLog)) {
throw new ConfigurationError(`analytics log is registered more than once: ${updateInfoLog}`);
}
analyticsLogPaths.add(updateInfoLog);
const updateMetricsSince = parseIsoDate(site.updateInfo.metricsSince);
if (!updateMetricsSince) {
throw new ConfigurationError(`${label}.updateInfo.metricsSince must be a valid ISO date`);
}
const reportOutputDirectory = requireAbsolutePath( const reportOutputDirectory = requireAbsolutePath(
site.reportOutputDirectory, site.reportOutputDirectory,
`${label}.reportOutputDirectory` `${label}.reportOutputDirectory`
@@ -190,6 +223,12 @@ function validateRegistry(registry) {
activatedOn, activatedOn,
publicCounter: site.publicCounter, publicCounter: site.publicCounter,
analyticsLog, analyticsLog,
websiteMetricsSince,
updateInfo: {
path: UPDATE_INFO_PATH,
analyticsLog: updateInfoLog,
metricsSince: updateMetricsSince
},
reportOutputDirectory, reportOutputDirectory,
publicJsonPath publicJsonPath
}; };
@@ -214,6 +253,34 @@ function validateRegistry(registry) {
); );
} }
if (!registry.privateMetrics || typeof registry.privateMetrics !== "object"
|| Array.isArray(registry.privateMetrics)) {
throw new ConfigurationError("registry.privateMetrics must be an object");
}
const privateMetricsStatePath = requireAbsolutePath(
registry.privateMetrics.statePath,
"registry.privateMetrics.statePath"
);
if (!isWithin(stateDirectory, privateMetricsStatePath)
|| privateMetricsStatePath === counterStatePath) {
throw new ConfigurationError("private metrics statePath must be a distinct path below stateDirectory");
}
const privateMetricsReportOutputDirectory = requireAbsolutePath(
registry.privateMetrics.reportOutputDirectory,
"registry.privateMetrics.reportOutputDirectory"
);
if (!isWithin(stateDirectory, privateMetricsReportOutputDirectory)
|| outputDirectories.has(privateMetricsReportOutputDirectory)
|| privateMetricsReportOutputDirectory === combinedReportOutputDirectory) {
throw new ConfigurationError("private metrics reportOutputDirectory must be distinct below stateDirectory");
}
if (registry.privateMetrics.timeZone !== "Europe/Berlin") {
throw new ConfigurationError("registry.privateMetrics.timeZone must be Europe/Berlin");
}
if (registry.privateMetrics.detailRetentionDays !== 14) {
throw new ConfigurationError("registry.privateMetrics.detailRetentionDays must be 14");
}
return { return {
schemaVersion: 1, schemaVersion: 1,
stateDirectory, stateDirectory,
@@ -226,6 +293,12 @@ function validateRegistry(registry) {
combined: { combined: {
reportOutputDirectory: combinedReportOutputDirectory reportOutputDirectory: combinedReportOutputDirectory
}, },
privateMetrics: {
statePath: privateMetricsStatePath,
reportOutputDirectory: privateMetricsReportOutputDirectory,
timeZone: "Europe/Berlin",
detailRetentionDays: 14
},
sites sites
}; };
} }
@@ -304,6 +377,10 @@ function validateInputs(registry, configTemplate) {
site.id, site.id,
resolveAnalyticsLogs(site) resolveAnalyticsLogs(site)
])); ]));
const updateLogsBySite = new Map(registry.sites.map(site => [
site.id,
resolveAnalyticsLogs({ analyticsLog: site.updateInfo.analyticsLog })
]));
let geoStats; let geoStats;
try { try {
@@ -330,6 +407,10 @@ function validateInputs(registry, configTemplate) {
registry.combined.reportOutputDirectory, registry.combined.reportOutputDirectory,
"combined report output directory" "combined report output directory"
); );
requireWritableDirectory(
registry.privateMetrics.reportOutputDirectory,
"private metrics report output directory"
);
for (const site of registry.sites) { for (const site of registry.sites) {
requireWritableDirectory( requireWritableDirectory(
site.reportOutputDirectory, site.reportOutputDirectory,
@@ -343,7 +424,7 @@ function validateInputs(registry, configTemplate) {
} }
} }
return analyticsLogsBySite; return { analyticsLogsBySite, updateLogsBySite };
} }
function renderGoAccessConfig(template, dbPath, restore, geoIpCountryDatabase) { function renderGoAccessConfig(template, dbPath, restore, geoIpCountryDatabase) {
@@ -604,6 +685,11 @@ function atomicCopyFile(source, destination) {
atomicWriteFile(destination, fs.readFileSync(source)); atomicWriteFile(destination, fs.readFileSync(source));
} }
function addPrivateMetricsLink(html) {
const link = '<p style="margin:1rem"><a href="/metrics/">Private daily and annual metrics</a></p>';
return /<\/body>/i.test(html) ? html.replace(/<\/body>/i, `${link}</body>`) : `${html}\n${link}\n`;
}
function replaceDirectory(source, destination) { function replaceDirectory(source, destination) {
fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.mkdirSync(path.dirname(destination), { recursive: true });
const backup = `${destination}.previous-${process.pid}`; const backup = `${destination}.previous-${process.pid}`;
@@ -720,7 +806,12 @@ function generateReports(options, dependencies = {}) {
const checkGoAccess = dependencies.checkGoAccess || defaultCheckGoAccess; const checkGoAccess = dependencies.checkGoAccess || defaultCheckGoAccess;
const now = dependencies.now ? dependencies.now() : new Date(); const now = dependencies.now ? dependencies.now() : new Date();
const analyticsLogsBySite = validateInputs(registry, configTemplate); const { analyticsLogsBySite, updateLogsBySite } = validateInputs(registry, configTemplate);
try {
validateImportOptions(options, registry);
} catch (error) {
throw new ConfigurationError(error.message);
}
const goAccess = checkGoAccess(options.goaccessBinary || "goaccess"); const goAccess = checkGoAccess(options.goaccessBinary || "goaccess");
if (!goAccess || !goAccess.geoIpMmdb) { if (!goAccess || !goAccess.geoIpMmdb) {
throw new ConfigurationError( throw new ConfigurationError(
@@ -777,15 +868,24 @@ function generateReports(options, dependencies = {}) {
content: `${JSON.stringify(publicPayload(state, site, now), null, 2)}\n` content: `${JSON.stringify(publicPayload(state, site, now), null, 2)}\n`
})); }));
const privateMetrics = generatePrivateMetrics({
registry,
analyticsLogsBySite,
updateLogsBySite,
options,
visitState: state,
context: {
...context,
now
}
});
if (!options.dryRun) { if (!options.dryRun) {
for (const report of prepared) { for (const report of prepared) {
atomicCopyFile( atomicCopyFile(report.outputJson, path.join(report.outputDirectory, "report.json"));
report.outputJson, atomicWriteFile(
path.join(report.outputDirectory, "report.json") path.join(report.outputDirectory, "report.html"),
); addPrivateMetricsLink(fs.readFileSync(report.outputHtml, "utf8"))
atomicCopyFile(
report.outputHtml,
path.join(report.outputDirectory, "report.html")
); );
} }
for (const report of prepared) { for (const report of prepared) {
@@ -798,6 +898,14 @@ function generateReports(options, dependencies = {}) {
for (const publicFile of publicFiles) { for (const publicFile of publicFiles) {
atomicWriteFile(publicFile.destination, publicFile.content, 0o644); atomicWriteFile(publicFile.destination, publicFile.content, 0o644);
} }
atomicWriteFile(
registry.privateMetrics.statePath,
`${JSON.stringify(privateMetrics.state, null, 2)}\n`
);
for (const report of privateMetrics.files) {
fs.mkdirSync(path.dirname(report.destination), { recursive: true });
atomicWriteFile(report.destination, report.content);
}
} }
return { return {
@@ -805,7 +913,9 @@ function generateReports(options, dependencies = {}) {
dryRun: Boolean(options.dryRun), dryRun: Boolean(options.dryRun),
sites: registry.sites.length, sites: registry.sites.length,
reports: prepared.length, reports: prepared.length,
publicCounters: publicFiles.length publicCounters: publicFiles.length,
privateMetricReports: privateMetrics.files.length,
historicalImport: privateMetrics.imported
}; };
} finally { } finally {
if (runDirectory && fs.existsSync(runDirectory)) { if (runDirectory && fs.existsSync(runDirectory)) {
@@ -816,7 +926,12 @@ function generateReports(options, dependencies = {}) {
} }
function parseArguments(argv) { function parseArguments(argv) {
const options = { check: false, dryRun: false, goaccessBinary: "goaccess" }; const options = {
check: false,
dryRun: false,
goaccessBinary: "goaccess",
importLogs: []
};
for (let index = 0; index < argv.length; index += 1) { for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]; const argument = argv[index];
@@ -824,7 +939,10 @@ function parseArguments(argv) {
options.check = true; options.check = true;
} else if (argument === "--dry-run") { } else if (argument === "--dry-run") {
options.dryRun = true; options.dryRun = true;
} else if (["--registry", "--config-template", "--goaccess"].includes(argument)) { } else if ([
"--registry", "--config-template", "--goaccess", "--import-log", "--import-format",
"--import-site", "--coverage-from", "--coverage-through"
].includes(argument)) {
const value = argv[index + 1]; const value = argv[index + 1];
if (!value) { if (!value) {
throw new ConfigurationError(`${argument} requires a value`); throw new ConfigurationError(`${argument} requires a value`);
@@ -833,6 +951,11 @@ function parseArguments(argv) {
if (argument === "--registry") options.registryPath = value; if (argument === "--registry") options.registryPath = value;
if (argument === "--config-template") options.configTemplatePath = value; if (argument === "--config-template") options.configTemplatePath = value;
if (argument === "--goaccess") options.goaccessBinary = value; if (argument === "--goaccess") options.goaccessBinary = value;
if (argument === "--import-log") options.importLogs.push(value);
if (argument === "--import-format") options.importFormat = value;
if (argument === "--import-site") options.importSite = value;
if (argument === "--coverage-from") options.coverageFrom = value;
if (argument === "--coverage-through") options.coverageThrough = value;
} else { } else {
throw new ConfigurationError(`unknown argument: ${argument}`); throw new ConfigurationError(`unknown argument: ${argument}`);
} }
@@ -841,7 +964,9 @@ function parseArguments(argv) {
if (!options.registryPath || !options.configTemplatePath) { if (!options.registryPath || !options.configTemplatePath) {
throw new ConfigurationError( throw new ConfigurationError(
"usage: generate-reports.js --registry FILE --config-template FILE " "usage: generate-reports.js --registry FILE --config-template FILE "
+ "[--goaccess FILE] [--check|--dry-run]" + "[--goaccess FILE] [--check|--dry-run] "
+ "[--import-log FILE ... --import-format nginx-combined|analytics-tsv "
+ "--import-site ID --coverage-from DATE --coverage-through DATE]"
); );
} }
if (options.check && options.dryRun) { if (options.check && options.dryRun) {
@@ -1,4 +1,4 @@
/var/log/nginx/*-analytics.log { /var/log/nginx/*-analytics.log /var/log/nginx/*-update-information.log {
daily daily
rotate 14 rotate 14
missingok missingok
@@ -34,3 +34,10 @@ map "$hamradioonline_analytics_method:$hamradioonline_analytics_path:$hamradioon
default 0; default 0;
"1:1:0" 1; "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;
}
@@ -7,9 +7,10 @@ log_format hamradioonline_analytics
# Include the maps below in the Nginx http context as well. # Include the maps below in the Nginx http context as well.
include /etc/nginx/snippets/hamradioonline-analytics-filters.conf; include /etc/nginx/snippets/hamradioonline-analytics-filters.conf;
# Add this extra log to each registered project server block. Keep the # Add these extra logs to each registered project server block. Keep the
# existing operational access_log directive; do not replace it implicitly. # existing operational access_log directive; do not replace it implicitly.
# #
# access_log /var/log/nginx/kst4contest-analytics.log # access_log /var/log/nginx/kst4contest-analytics.log
# hamradioonline_analytics if=$hamradioonline_analytics_loggable; # hamradioonline_analytics if=$hamradioonline_analytics_loggable;
# access_log /var/log/nginx/kst4contest-update-information.log
# hamradioonline_analytics if=$hamradioonline_update_information_loggable;
+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
};
+12
View File
@@ -4,6 +4,12 @@
"counterStatePath": "/var/lib/hamradioonline-analytics/public-counter-state.json", "counterStatePath": "/var/lib/hamradioonline-analytics/public-counter-state.json",
"lockFile": "/run/hamradioonline-analytics/generator.lock", "lockFile": "/run/hamradioonline-analytics/generator.lock",
"geoIpCountryDatabase": "/var/lib/GeoIP/GeoLite2-Country.mmdb", "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": { "combined": {
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/combined" "reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/combined"
}, },
@@ -13,6 +19,12 @@
"hostname": "kst4contest.hamradioonline.de", "hostname": "kst4contest.hamradioonline.de",
"analyticsLog": "/var/log/nginx/kst4contest-analytics.log", "analyticsLog": "/var/log/nginx/kst4contest-analytics.log",
"activatedOn": "2026-09-11", "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, "publicCounter": true,
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/kst4contest", "reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/kst4contest",
"publicJsonPath": "/var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json" "publicJsonPath": "/var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json"
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Generate private GoAccess reports and public project counters Description=Generate private analytics reports and public project counters
After=nginx.service After=nginx.service
[Service] [Service]
+24 -15
View File
@@ -1,23 +1,32 @@
module.exports = [ module.exports = [
{ {
id: "main-window", id: "main-window-light",
title: "Main Window", title: "Main window · Light theme",
image: "/assets/screenshots/main-window.png", image: "/assets/completeViewLight.png",
alt: "KST4Contest ON4KST contest chat client main window", alt: "KST4Contest main window in the light theme with ON4KST chat, station filters, priority candidates and sked controls",
caption: "Contest-oriented ON4KST chat workflow with candidate awareness." caption: "The complete contest view brings chat messages, active bands, filters and priority candidates together."
}, },
{ {
id: "priority-candidates", id: "main-window-dark",
title: "Priority Candidates", title: "Main window · Dark theme",
image: "/assets/screenshots/priority-candidates.png", image: "/assets/completeViewDark.png",
alt: "Priority candidate list in KST4Contest", alt: "KST4Contest main window in the dark theme with ON4KST chat, station filters, priority candidates and sked controls",
caption: "Score-based candidate ranking for faster operator decisions." caption: "The same operating view in the dark theme, including worked status, candidate priorities and message preparation."
}, },
{ {
id: "timeline", id: "timeline",
title: "Timeline View", title: "AP and sked timeline",
image: "/assets/screenshots/timeline.png", image: "/assets/timeline.png",
alt: "KST4Contest AP timeline view", alt: "KST4Contest timeline showing scheduled contacts and Aircraft Scatter crossing windows",
caption: "Timeline support for AP windows and candidate timing." 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
} }
]; ];
+1
View File
@@ -61,6 +61,7 @@
<p><a href="/features/">Features</a></p> <p><a href="/features/">Features</a></p>
<p><a href="/download/">Download</a></p> <p><a href="/download/">Download</a></p>
<p><a href="/manual/">Manual</a></p> <p><a href="/manual/">Manual</a></p>
<p><a href="/background/">Background</a></p>
<p><a href="/roadmap/">Roadmap</a></p> <p><a href="/roadmap/">Roadmap</a></p>
</div> </div>
<div> <div>
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

+30 -9
View File
@@ -641,18 +641,31 @@ a:hover {
margin-top: 30px; margin-top: 30px;
} }
.screenshot-placeholder { .screenshot-grid {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
place-items: center; }
min-height: 180px;
.screenshot-card--wide {
grid-column: 1 / -1;
}
.screenshot-link {
display: block;
margin-bottom: 16px; margin-bottom: 16px;
border-radius: 18px; border-radius: 18px;
background: overflow: hidden;
linear-gradient(135deg, rgba(56, 189, 248, 0.20), rgba(168, 85, 247, 0.20)),
rgba(2, 6, 23, 0.7);
border: 1px solid var(--border); border: 1px solid var(--border);
color: white; background: rgba(2, 6, 23, 0.7);
font-weight: 800; }
.screenshot-image {
display: block;
width: 100%;
height: auto;
}
.screenshot-card h3 {
margin-bottom: 8px;
} }
.cta-panel { .cta-panel {
@@ -718,6 +731,14 @@ code {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.screenshot-grid {
grid-template-columns: 1fr;
}
.screenshot-card--wide {
grid-column: auto;
}
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+39
View File
@@ -212,6 +212,45 @@ description: Download the latest stable KST4Contest packages for Windows, Linux
</div> </div>
</section> </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"> <section class="section">
<div class="cta-panel"> <div class="cta-panel">
<p class="eyebrow">Choosing a package</p> <p class="eyebrow">Choosing a package</p>
+27 -6
View File
@@ -47,16 +47,37 @@ description: Privacy policy for the KST4Contest website.
browsers or operating systems are treated as crawlers. browsers or operating systems are treated as crawlers.
</p> </p>
<p> <p>
IP addresses are anonymised before data is stored in the statistics aggregates. The dedicated IP addresses are anonymised before data is stored in the GoAccess statistics aggregates. The
analytics raw logs are retained for 14 days. Anonymised detailed aggregates are retained on a dedicated analytics raw logs are retained for 14 days. Anonymised GoAccess detail is retained
rolling basis for 395 days. They include countries, but no city or host statistics. Countries on a rolling basis for 395 days. Daily totals for visits and page views, absolute country
are derived with a Country database only. 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>
<p> <p>
A visit is an approximate daily value based on the combination of IP address, date and user 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 agent used by GoAccess. It must not be read as a number of unique people. Page views and visits
remain separate measures. Bots, monitoring requests, software update checks and static assets remain separate measures. Known bots, monitoring requests, software update checks and static
are excluded as far as they can be identified. 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> </p>
<h2>Public visitor count</h2> <h2>Public visitor count</h2>
+17 -6
View File
@@ -14,15 +14,26 @@ description: Screenshots of KST4Contest, the contest-optimized ON4KST chat clien
</section> </section>
<section class="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 %} {% for shot in screenshots %}
<article class="card screenshot-card"> <article class="card screenshot-card{% if shot.wide %} screenshot-card--wide{% endif %}">
<div class="screenshot-placeholder"> <a class="screenshot-link" href="{{ shot.image }}" target="_blank" rel="noopener"
<span>{{ shot.title }}</span> aria-label="Open {{ shot.title }} in full size">
</div> <img class="screenshot-image" src="{{ shot.image }}" alt="{{ shot.alt }}" loading="lazy" decoding="async">
</a>
<h3>{{ shot.title }}</h3> <h3>{{ shot.title }}</h3>
<p>{{ shot.caption }}</p> <p>{{ shot.caption }}</p>
<p><a href="{{ shot.image }}" target="_blank" rel="noopener">Open full-size image →</a></p>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
+309 -10
View File
@@ -7,6 +7,7 @@ const test = require("node:test");
const { const {
formatGoAccessCheck, formatGoAccessCheck,
generateReports, generateReports,
parseArguments,
parseGoAccessVersion, parseGoAccessVersion,
validateReport validateReport
} = require("../ops/analytics/generate-reports"); } = require("../ops/analytics/generate-reports");
@@ -37,6 +38,18 @@ function goAccessReport(dailyVisits, combined = false) {
return report; 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) { function fixture(siteDefinitions) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "kst4-analytics-test-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "kst4-analytics-test-"));
const stateDirectory = path.join(root, "state"); const stateDirectory = path.join(root, "state");
@@ -52,9 +65,25 @@ function fixture(siteDefinitions) {
const sites = siteDefinitions.map((definition, index) => { const sites = siteDefinitions.map((definition, index) => {
const id = definition.id || `site-${index}`; const id = definition.id || `site-${index}`;
const log = path.join(root, `${id}.log`); const log = path.join(root, `${id}.log`);
fs.writeFileSync(log, "example log line\n"); 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) { if (definition.rotated !== false) {
fs.writeFileSync(`${log}.1`, "rotated example log line\n"); 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) { if (definition.compressed) {
fs.writeFileSync(`${log}.2.gz`, "compressed placeholder\n"); fs.writeFileSync(`${log}.2.gz`, "compressed placeholder\n");
@@ -64,6 +93,12 @@ function fixture(siteDefinitions) {
hostname: definition.hostname || `${id}.example.test`, hostname: definition.hostname || `${id}.example.test`,
analyticsLog: log, analyticsLog: log,
activatedOn: definition.activatedOn || "2026-01-01", activatedOn: definition.activatedOn || "2026-01-01",
websiteMetricsSince: "2026-09-11",
updateInfo: {
path: "/kst4ContestVersionInfo.xml",
analyticsLog: updateLog,
metricsSince: "2026-09-11"
},
publicCounter: definition.publicCounter, publicCounter: definition.publicCounter,
reportOutputDirectory: path.join(stateDirectory, "reports", id), reportOutputDirectory: path.join(stateDirectory, "reports", id),
...(definition.publicCounter ...(definition.publicCounter
@@ -77,6 +112,12 @@ function fixture(siteDefinitions) {
counterStatePath: path.join(stateDirectory, "counter-state.json"), counterStatePath: path.join(stateDirectory, "counter-state.json"),
lockFile: path.join(root, "run", "generator.lock"), lockFile: path.join(root, "run", "generator.lock"),
geoIpCountryDatabase, geoIpCountryDatabase,
privateMetrics: {
statePath: path.join(stateDirectory, "private-metrics-state.json"),
reportOutputDirectory: path.join(stateDirectory, "reports", "metrics"),
timeZone: "Europe/Berlin",
detailRetentionDays: 14
},
combined: { combined: {
reportOutputDirectory: path.join(stateDirectory, "reports", "combined") reportOutputDirectory: path.join(stateDirectory, "reports", "combined")
}, },
@@ -84,6 +125,7 @@ function fixture(siteDefinitions) {
}; };
fs.mkdirSync(stateDirectory, { recursive: true }); fs.mkdirSync(stateDirectory, { recursive: true });
fs.mkdirSync(registry.combined.reportOutputDirectory, { recursive: true }); fs.mkdirSync(registry.combined.reportOutputDirectory, { recursive: true });
fs.mkdirSync(registry.privateMetrics.reportOutputDirectory, { recursive: true });
for (const site of sites) { for (const site of sites) {
fs.mkdirSync(site.reportOutputDirectory, { recursive: true }); fs.mkdirSync(site.reportOutputDirectory, { recursive: true });
if (site.publicCounter) { if (site.publicCounter) {
@@ -107,6 +149,33 @@ function fakeGoAccess(reports, calls, failureId) {
if (invocation.id === failureId) { if (invocation.id === failureId) {
throw new Error("simulated GoAccess failure"); 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( fs.writeFileSync(
invocation.outputJson, invocation.outputJson,
JSON.stringify(reports[invocation.id]) JSON.stringify(reports[invocation.id])
@@ -119,6 +188,59 @@ function fakeGoAccess(reports, calls, failureId) {
}; };
} }
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) { function run(testFixture, reports, calls = [], failureId) {
return generateReports({ return generateReports({
registryPath: testFixture.registryPath, registryPath: testFixture.registryPath,
@@ -205,6 +327,42 @@ test("keeps valid outputs unchanged when a GoAccess job fails", () => {
} }
}); });
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", () => { test("processes subdomains separately and together without publishing disabled counters", () => {
const testFixture = fixture([ const testFixture = fixture([
{ id: "alpha", publicCounter: true, compressed: true }, { id: "alpha", publicCounter: true, compressed: true },
@@ -219,22 +377,23 @@ test("processes subdomains separately and together without publishing disabled c
}, calls); }, calls);
assert.equal(result.reports, 3); assert.equal(result.reports, 3);
assert.deepEqual(calls.map(call => call.id), ["alpha", "bravo", "combined"]); const reportCalls = calls.filter(call => !call.metricKind);
assert.deepEqual(reportCalls.map(call => call.id), ["alpha", "bravo", "combined"]);
const alphaLogs = [ const alphaLogs = [
`${testFixture.registry.sites[0].analyticsLog}.1`, `${testFixture.registry.sites[0].analyticsLog}.1`,
testFixture.registry.sites[0].analyticsLog testFixture.registry.sites[0].analyticsLog
]; ];
const bravoLogs = [testFixture.registry.sites[1].analyticsLog]; const bravoLogs = [testFixture.registry.sites[1].analyticsLog];
assert.deepEqual(calls[0].args.slice(0, 2), alphaLogs); assert.deepEqual(reportCalls[0].args.slice(0, 2), alphaLogs);
assert.deepEqual(calls[1].args.slice(0, 1), bravoLogs); assert.deepEqual(reportCalls[1].args.slice(0, 1), bravoLogs);
assert.deepEqual( assert.deepEqual(
calls[2].args.slice(0, 3), reportCalls[2].args.slice(0, 3),
[...alphaLogs, ...bravoLogs] [...alphaLogs, ...bravoLogs]
); );
assert.equal(calls[0].args.includes("--enable-panel=VIRTUAL_HOSTS"), false); assert.equal(reportCalls[0].args.includes("--enable-panel=VIRTUAL_HOSTS"), false);
assert.equal(calls[1].args.includes("--enable-panel=VIRTUAL_HOSTS"), false); assert.equal(reportCalls[1].args.includes("--enable-panel=VIRTUAL_HOSTS"), false);
assert.equal(calls[2].args.includes("--enable-panel=VIRTUAL_HOSTS"), true); assert.equal(reportCalls[2].args.includes("--enable-panel=VIRTUAL_HOSTS"), true);
assert.equal(calls.some(call => call.args.some(argument => argument.endsWith(".gz"))), false); assert.equal(reportCalls.some(call => call.args.some(argument => argument.endsWith(".gz"))), false);
assert.equal(fs.existsSync(path.join( assert.equal(fs.existsSync(path.join(
testFixture.registry.stateDirectory, testFixture.registry.stateDirectory,
"public", "public",
@@ -244,6 +403,16 @@ test("processes subdomains separately and together without publishing disabled c
testFixture.registry.combined.reportOutputDirectory, testFixture.registry.combined.reportOutputDirectory,
"report.html" "report.html"
)), true); )), 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 { } finally {
testFixture.cleanup(); testFixture.cleanup();
} }
@@ -302,6 +471,7 @@ test("dry-run validates generated data without changing production paths", () =>
).length, 0); ).length, 0);
assert.equal(fs.existsSync(testFixture.registry.sites[0].publicJsonPath), false); assert.equal(fs.existsSync(testFixture.registry.sites[0].publicJsonPath), false);
assert.equal(fs.existsSync(testFixture.registry.counterStatePath), false); assert.equal(fs.existsSync(testFixture.registry.counterStatePath), false);
assert.equal(fs.existsSync(testFixture.registry.privateMetrics.statePath), false);
} finally { } finally {
testFixture.cleanup(); testFixture.cleanup();
} }
@@ -402,6 +572,22 @@ test("configuration check verifies analytics-log readability", () => {
} }
}); });
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", () => { test("configuration check reports missing output directories", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]); const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try { try {
@@ -462,6 +648,118 @@ test("Nginx filters exclude non-page traffic before analytics logging", () => {
assert.match(source, /\|map\|/); assert.match(source, /\|map\|/);
assert.match(source, /\$uri/); assert.match(source, /\$uri/);
assert.match(source, /known_bot/); 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", () => { test("server templates use the production GeoIP path and permission model", () => {
@@ -536,6 +834,7 @@ test("logrotate uses the Ubuntu Nginx rotation action", () => {
assert.match(source, /^\s*delaycompress$/m); assert.match(source, /^\s*delaycompress$/m);
assert.match(source, /^\s*rotate 14$/m); assert.match(source, /^\s*rotate 14$/m);
assert.match(source, /^\s*create 0640 www-data hamradio-analytics$/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", () => { test("website package records the Node.js 18.19.1 baseline", () => {
+58
View File
@@ -60,3 +60,61 @@ test("homepage contains one selected function grid and a complete-overview link"
assert.match(source, /href="\/features\/"[^>]*>Open the complete function overview/); assert.match(source, /href="\/features\/"[^>]*>Open the complete function overview/);
assert.doesNotMatch(source, /Observe|Evaluate|>Act<|One workflow, shared context/); 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/);
});