Releases: goldbarth/port-tidewatch
v1.3.0 — Readability
port-tidewatch v1.3.0
Release date: 2026-06-16
Readability (M9). v1.2 brought real data; v1.3 makes the displayed values
legible — what each number means and how current it is. This milestone
replaces the dropped M8 observability work (see the ADR-003 amendment): the
OpenTelemetry path stays wired but unsurfaced, and the effort went into honest,
domain-true freshness instead.
Highlights
- Freshness reflects the measurement, not the poll — the per-gauge indicator
showed the age of the last successful API poll, so a fresh poll against a
minutes-old PEGELONLINE reading read as "live". It now shows the age of the
reading itself (Reading.Timestamp): "Messwert: vor N min". - Staleness keys off data arrival, not measurement age — PEGELONLINE
publishes with a multi-minute lag, so a healthy feed is always minutes-old; an
age-based threshold would flag a permanent false "stale". A tile now turns
stale only when new data stops flowing — measured from when the last
reading arrived, against a threshold derived from the inferred source cadence
(2× cadence, with a floor for the dashboard's own poll interval). So the
Simulator (~seconds) and PEGELONLINE (~minutes) get the right threshold
automatically, with nothing hard-coded. - Wall-clock time anchor in the header — a running clock (
HH:MM:SS,
Europe/Berlin) next to the live indicator, so a measurement's age reads against
the current time: current time → measurement N min old → status. Client-side,
updated every second, no API call. - Local dashboard works on a fresh clone —
config.jsonships the deploy
placeholderhttps://<INGESTION_FQDN>, substituted only at deploy time.
AppConfignow treats an unresolved placeholder as same-origin, so following
the local runbook reaches the dev-proxied/apiinstead of a dead host.
What's changed
Freshness (ingestion + dashboard)
- The gauge API exposes three new fields per gauge:
measuredAt(the newest
reading's measurement time, source clock),cadenceSeconds(the inferred
source cadence — the median gap between reading timestamps, so a single
missed poll does not inflate it), andlastReadingAt(when that reading
arrived, on our clock). - The state holder records each gauge's last arrival time; the API mapper derives
the cadence. State stays raw — view concerns live in the mapper (ADR-002). - The dashboard shows a per-tile freshness chip from
measuredAt, classifies
stale from arrival recency vs the cadence-derived threshold, and drives all
relative times from one shared 1 Hz clock (no per-component timers). - The header indicator is now poll-liveness only (
live/veraltet/
verbinde…); all measurement times moved into the tiles.
Config / local dev
AppConfignormalizes an empty or unresolved"<…>"apiBaseUrlto
same-origin/api; a real FQDN is kept (trailing slash trimmed). The deploy
substitution (sedin the workflow / theazure.yamlhook) is unchanged, so
production still serves the real ingestion FQDN — the change only affects the
pre-substitution (local) case.
Tests
- Mapper:
measuredAt, mediancadenceSeconds(incl. robustness to a missed
interval), andlastReadingAtpass-through. - Freshness threshold: PEGELONLINE
2× cadence, no flapping at a normal
interval, Simulator's few-second window, and unknown-cadence handling. AppConfig: placeholder, empty, real FQDN, and fetch-error fallback.
Known limitations (by design)
- PEGELONLINE readings are inherently minutes old — the public feed publishes
with a multi-minute lag. The dashboard now shows this honestly: the age can sit
at several minutes while the tile stays "live", because new data keeps arriving
on cadence. Stale means the feed has actually stalled. - Dashboard remains read-only and polling-based.
- No notification delivery — alert events are published but acting on them is
still out of scope. - OpenTelemetry is wired but not surfaced — the M8 dashboard observability
work was attempted and reverted; the trace path still exports to Jaeger/OTLP,
just without an in-dashboard view.
What's next
The roadmap beyond M9 is open. The core pipeline, real data feed, deploy paths
(k8s + Argo CD, Container Apps), and now legible freshness are in place; see
docs/ISSUES.md
for the milestone history and any new work.
v1.2.0 — Real data
port-tidewatch v1.2.0
Release date: 2026-06-15
Real data (M7). v1.1 made the pipeline demonstrable with a scripted surge; v1.2
feeds it from the live PEGELONLINE Elbe gauges alongside that simulator,
behind a source-selection switch — without touching the Reading contract or
the consumer path.
Highlights
- Live PEGELONLINE Elbe feed — a source adapter polls the public WSV REST-API
for four Hamburg gauges (St. Pauli, Bunthaus, Over, Zollenspieker) and emits the
sameReadingrecords the simulator does. Centimetres-above-gauge-zero are
converted to metres NHN in an explicit mapping layer (W/100 + PNP offset);
conditional GET (If-None-Match) means a304emits no duplicate reading, and
transient API failures are logged and retried without crashing the host. - Source-selection switch — a single
ReadingSourceconfig value
(Simulator | Pegelonline) chooses the active source at startup, so the same
build serves the scripted demo or the real feed without recompiling. A bad or
empty value fails fast at startup, consistent with the threshold-options
validation. - Producer renamed to
Tidewatch.Source— the simulator project became a
Generic Host that hosts interchangeable reading sources behindIReadingSource;
the simulator is now one source among others, so the name and the deployed
workload (reading-source) are source-neutral.
What's seen on the dashboard
Live dashboard on real WSV/PEGELONLINE data — the public Hamburg Elbe gauges (St. Pauli, Zollenspieker, Over, Bunthaus), all normal.
The scripted-surge demo (video and screenshots) from
v1.1
still tells the alerting story best — real Elbe levels normally sit well below the
4.50 m NHN warning boundary, so the live feed reads calm normal. The live feed's
value is the real-data integration: cm → PNP → NHN mapping and conditional polling
against the public WSV API.
What's changed
Reading-source host (was Tidewatch.Simulator)
- Moved to a Generic Host (
Microsoft.NET.Sdk.Worker): DI, Options, graceful
shutdown. The publish path is theIReadingPublisherseam; sources depend on
intent, not transport. SimulatorSourceholds the scripted surge (behaviour unchanged — sameReading,
exchange, routing key;SURGE_PEAK_M/SURGE_PERIOD_S/RABBITMQ_HOSTstill
honoured).PegelonlineSource+PegelonlineClient+PegelMapper: latest value, window
backfill on start, livegaugeZerolookup, and the cm → m / PNP → NHN mapping.
Stations are configured by UUID. HPA tidal gauges expose nogaugeZero, so their
PNP is set explicitly (Hamburg PNP = NHN −5.00 m); the live lookup remains for
other WSV stations.ReadingSourceOptions+ validator select the active source at startup
(ValidateOnStart); only the chosen source and its dependencies are registered.
Deploy
- The deployed producer runs the live feed:
ReadingSource=Pegelonlineis set via
env var on the k8sreading-sourceDeployment and the Container Apps app; the
local appsettings default staysSimulatorfor the documented demo. - The producer workload was renamed
simulator→reading-sourceacross the k8s
manifests, kustomization,azure.yaml, and the bicep (resource, name,
azd-service-name), plus the runbook.
Tests
PegelMappercm → m / PNP → NHN pinned end to end against a known St. Pauli
value;ReadingSourcevalidator (known / missing / unknown / numeric).
Known limitations (by design)
- Live feed reads calm — real Elbe levels stay below the storm-surge
thresholds, so the live source showsnormal; the simulator remains the
dramatic demo. The two are not run side by side (single source per run). - Dashboard remains read-only and polling-based.
- No notification delivery — alert events are published but acting on them is
still out of scope.
What's next (v1.3)
Observability made visible — see M8 in
docs/ISSUES.md:
surface the existing OpenTelemetry path in the dashboard (a pipeline latency pulse,
a Jaeger deep-link, and an optional self-rendered trace waterfall).
v1.1.0 — Demo & polish
port-tidewatch v1.1.0
Release date: 2026-06-12
Demo & polish (M6). v1.0 was a complete, observable pipeline; v1.1 makes it
demonstrable and richer — a scripted storm surge so severe actually happens,
a presentable dashboard, more to watch per gauge, and alert events published off
the stage-change chokepoint.
Demo
The simulator drives one gauge (CUX) through a scripted surge while the others
hold normal, so the full normal → warning → severe → recede cascade is
visible end to end:
If the player does not load, download the clip
(≈ 60 s).
![]() |
![]() |
![]() |
Calm — all normal. |
Peak — CUX severe (5.65 m). |
Held severe (5.75 m). |
What you're seeing. Cards are gauges on a fixed 0–6 m NHN scale; the
shaded bands are the WADI thresholds (warning 4.50 m, severe 5.50 m).
The header summarises gauges per stage, the highest current level, and overall
status, and the live / last-updated indicator makes stale data obvious.
Highlights
- Scripted storm-surge scenario — the simulator now drives a tide baseline
plus a smooth raised-cosine surge (peak/period parameterisable via
SURGE_PEAK_M/SURGE_PERIOD_S), sosevereis reachable and the cascade
tells a story instead of random noise. - Tidal operations dashboard — banded area charts on a fixed 0–6 m domain
(the bands are the thresholds), a header summary, relative time-in-stage
("warning for 3 min"), a live / stale / offline indicator, self-hosted fonts,
and animated stage transitions. - Richer per-gauge signals — the API DTO gains rate-of-change (m/min),
time-in-current-stage, and window min/max, computed in the mapper (state stays
raw, ADR-002) and surfaced as a trend arrow. - Alert-event publishing — a genuine stage change publishes an
AlertEvent
(gauge, previous → new stage, level, timestamp) to a fanout exchange from the
singleApplyStageChangechokepoint (ADR-001), traced consistently with the
existing OpenTelemetry path.
What's changed
Simulator
- Composite level model (baseline + tide + scripted surge + small noise) with
per-gauge roles; one gauge surges, the rest stay normal. TheReading
contract and the publish path are unchanged — only level generation differs.
API & dashboard
GaugeDtogainsrateMetersPerMin,timeInStageSeconds,windowMin,
windowMax; the endpoint passes a singleUtcNowso a response is internally
consistent.- Dashboard reworked: banded charts with a peak marker, header summary, relative
stage time, connection indicator (last good snapshot held across a failed
poll — ADR-002 addendum), dark "tidal operations" theme with self-hosted
Fraunces + IBM Plex Mono.
Ingestion
- New
AlertEventcontract and anIAlertPublisherseam;RabbitMqTransport
declares a fanout alert exchange plus a durable audit queue and owns a
dedicated publish channel. The initialnormalestablishment is suppressed,
so a held stage publishes nothing.
Tests
- Unit coverage for the computed DTO fields; integration tests that a single
transition yields exactly one alert event and a held stage yields none.
Known limitations (by design)
- No notification delivery — the chokepoint publishes alert events, but
acting on them (email / push, a notification consumer) is out of scope. - Simulator is still the sole source — a real PEGELONLINE Elbe feed is the
v1.2 (M7) step. - Dashboard remains read-only and polling-based.
What's next (v1.2)
Real data — see M7 in docs/ISSUES.md: a PEGELONLINE source
adapter (cm → m, PNP → NHN) alongside the simulator, a source-selection switch,
and a threshold "what-if" panel.
v1.0.0
port-tidewatch v1.0.0
Release date: 2026-06-11
First release. A small, focused storm-surge ingestion service modelled on the
Hamburg warning service (WADI): it ingests water-level readings, evaluates them
against staged thresholds in metres above NHN, and surfaces per-gauge alert
state on a read-only dashboard — observable end to end and deployable two ways.
Highlights
- End-to-end ingestion pipeline — simulator → RabbitMQ → ingestion → state,
with a dead-letter path for poison messages. - Staged, trend-aware surge evaluation —
normal/warning(4.50 m) /
severe(5.50 m), with a rising-trend nudge and de-escalation hysteresis so
the stage does not flap on single outliers. - Distributed tracing — OpenTelemetry across the whole path (simulator →
broker → ingestion) with W3C context propagation through the AMQP headers. - Read-only dashboard — Angular (standalone + signals), per-gauge level,
stage, and a recent-history sparkline, polling a small HTTP API. - Two deployment targets — Kubernetes + Argo CD (GitOps) and Azure Container
Apps, both verified.
What's included
Service & domain
Tidewatch.Contracts—Reading(decimal metres,DateTimeOffset) and
AlertState.Tidewatch.Ingestion— RabbitMQ transport with exchange/queue/dead-letter
topology;ReadingConsumer(dead-letters malformed/invalid messages);
per-gaugeGaugeStateHolderwith a single isolated stage-change point;
SurgeEvaluator(median base stage + trend pre-escalation + hysteresis).SurgeThresholdOptionsbound from configuration and validated at startup
(a bad threshold config fails fast).Tidewatch.Simulator— emits readings for four gauges (CUX, HEL, STP, BHV).
API & dashboard
- Read-only HTTP API on the ingestion host:
GET /api/gauges(snapshot + a
trend downsampled to a fixed bucket count) and/healthz. - Angular dashboard: gauge cards (level, stage badge, sparkline), 4 s polling,
runtime-configurable API base URL, same-origin in dev/k8s and cross-origin
(CORS) on the cloud stack.
Observability & tests
- OpenTelemetry tracing with OTLP export; spans for receipt → evaluation →
stage change, dead-letter outcomes marked as errors. - Unit tests for the evaluator (boundary, trend, hysteresis, outlier damping)
and Testcontainers integration tests (boundary and rising-trend, end to end
against a real broker).
Deployment
- Kubernetes + Argo CD (primary) — Kustomize manifests for rabbitmq,
ingestion, simulator, dashboard, and Jaeger, with an Ingress for same-origin
/apirouting; an Argo CDApplicationsyncs from the repo. Runs on a local
kind cluster (€0); portable to AKS. - Azure Container Apps — azd + Bicep (ACR, Container Apps environment, the
three apps) plus Azure Static Web Apps (Free) for the dashboard. GitHub
Actions: CI on push/PR, manual deploy via federated OIDC.
Decisions
Recorded as ADRs 001–004 under docs/adrs/ (threshold placement, dashboard
state, deploy target, evaluator algorithm). Runbooks for all three run modes are
under docs/.
Known limitations (by design)
- No alert-event publishing yet — the stage-change chokepoint updates state
only; publishing to a dedicated exchange is the v1.1 step (ADR-001). - Dashboard is intentionally minimal and read-only.
- No cloud tracing backend in the Container Apps stack; tracing is exercised
locally (and in the Kubernetes stack via an in-cluster Jaeger). - Kubernetes target is a local cluster (kind), chosen to keep cost at €0;
AKS is a later swap.
What's next (v1.1)
Demo and polish — see M6 in docs/ISSUES.md: a storm-surge
scenario in the simulator (so severe is demonstrable), dashboard visual polish,
richer per-gauge signals, demo assets, and alert-event publishing.



