An open API service indexing awesome lists of open source software.

https://github.com/goldbarth/port-tidewatch

Water-level ingestion and storm-surge alerting service, modelled on Hamburg's WADI warning system. .NET ingestion pipeline with RabbitMQ, staged threshold evaluation, and a read-only Angular dashboard.
https://github.com/goldbarth/port-tidewatch

angular argocd aspnet-core azure-container-apps clean-architecture csharp dotnet event-driven kubernetes microservices observability opentelemetry rabbitmq testcontainers

Last synced: 6 days ago
JSON representation

Water-level ingestion and storm-surge alerting service, modelled on Hamburg's WADI warning system. .NET ingestion pipeline with RabbitMQ, staged threshold evaluation, and a read-only Angular dashboard.

Awesome Lists containing this project

README

          


Tidewatch

Tidewatch



Water-Level Ingestion & Storm-Surge Alerting

port-tidewatch


A small, focused ingestion service for port water-level telemetry, with
threshold-based storm-surge alerting and a read-only monitoring dashboard.


Release
CI
Deploy


.NET 10
RabbitMQ
OpenTelemetry
Angular
Docker
Kubernetes
Argo CD

> πŸ“– **Written up on my site:** [the project](https://www.goldbarth.dev/projects/port-tidewatch),
> and β€” in German, leicht verdaulich β€” [the surge-evaluator decision (ADR-004)](https://www.goldbarth.dev/decisions/surge-evaluator-decisions).


Tidewatch dashboard on the live PEGELONLINE Elbe feed β€” four Hamburg gauges, all normal


Live dashboard on real WSV/PEGELONLINE data β€” the public Hamburg Elbe gauges (St. Pauli, Zollenspieker, Over, Bunthaus), polled live.

---

The domain is modelled on the Hamburg storm-surge warning service (WADI):
a warning is raised when an expected surge peak can exceed **4.50 m above
sea level (NHN)** / 2.40 m above mean high water (MThw). tidewatch ingests
water-level readings β€” from the **live WSV/PEGELONLINE Elbe feed** (the German
Waterways and Shipping Administration's open gauge data) or a scripted simulator
β€” evaluates them against that threshold, and surfaces the result.

> **Scope is intentionally narrow:** one domain, one ingestion path, no write
> operations from the UI. The goal is a reliable, observable ingestion
> pipeline end to end.

---

## Why this project

The Hamburg port runs on reliable, observable, security-relevant
infrastructure. tidewatch works the ingestion-and-alerting pattern in a
domain I care about, and takes the next steps in my stack β€” Angular and
Kubernetes/GitOps β€” on real ground rather than in the abstract.

---

## What it does

- A reading-source host emits water-level readings for a set of gauges β€”
either a scripted simulator or the live PEGELONLINE Elbe feed, chosen by
config (same build, no recompile).
- An ingestion service consumes readings via RabbitMQ (with a dead-letter
path for poison messages), evaluates each reading against the WADI
threshold, and emits an alert state.
- A read-only Angular dashboard shows current levels, per-gauge alert status
(normal / warning / severe), and a short recent-history trend.

---

## Demo

### Scripted storm surge

Tidewatch dashboard with every gauge in the normal stage
Tidewatch dashboard during a storm surge, the CUX gauge in the warning stage
Tidewatch dashboard at the surge peak, the CUX gauge in the severe stage

Calm β€” every gauge normal, overall status normal.
Surge rising β€” CUX in warning (5.37 m), overall status warning.
Surge peak β€” CUX crosses severe (5.83 m), overall status severe.

β–Ά Watch the ~60-second surge demo β€” the full normal β†’ warning β†’ severe β†’ recede cascade.

**What you're seeing.** Each card is a gauge, plotted on a fixed **0–6 m NHN**
scale. The shaded bands *are* the WADI thresholds: **warning at 4.50 m** and
**severe at 5.50 m** (2.40 m over mean high water). One gauge β€” `CUX` β€” runs a
scripted storm surge and cascades `normal β†’ warning β†’ severe β†’ recede`; the
others hold `normal` for contrast. The header summarises gauges per stage, the
highest current level, and overall status, while the live / last-updated
indicator makes stale data obvious β€” turning the threshold story into a single
glance.

---

## Architecture

```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” readings β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” alerts / state β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ reading-source host (.NET) β”‚ ──────────────▢ β”‚ ingestion serviceβ”‚ ─────────────────▢ β”‚ dashboard β”‚
β”‚ one source, set by config: β”‚ RabbitMQ β”‚ (.NET) β”‚ REST (polling) β”‚ (Angular) β”‚
β”‚ β€’ simulator (scripted) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β€’ PEGELONLINE Elbe feed β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ poison messages
ReadingSource switch β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ dead-letter queueβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

### Alert-state lifecycle

A gauge moves between stages as its evaluated level crosses the configured
thresholds (m above NHN). Stages are strictly ordered; the evaluator derives
them from the reading window, not from a single spike.

```
normal ──(β‰₯ 4.50 m)──▢ warning ──(β‰₯ 5.50 m)──▢ severe
β–² β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
(level falls back below stage)
```

Architecture decisions are recorded as ADRs under `docs/adrs/`. Open questions
are tracked there too, so the reasoning is visible even where the
implementation is not finished.

| # | Concern | Decision |
|-----|-------------------------------|---------------------------------------------------------------------------------------------------|
| 001 | Where threshold logic lives | [Threshold evaluation lives in the ingestion service](docs/adrs/001-threshold-logic-in-service.md) |
| 002 | Dashboard client state | [Angular state structure](docs/adrs/002-angular-state-structure.md) |
| 003 | Deploy target | [Azure Container Apps vs. Kubernetes + Argo CD](docs/adrs/003-container-apps-vs-kubernetes.md) |
| 004 | Stage-determination algorithm | [Surge evaluator algorithm](docs/adrs/004-surge-evaluator-algorithm.md) Β· [Blog (DE)](https://www.goldbarth.dev/decisions/surge-evaluator-decisions) |

---

## API

The ingestion service exposes a deliberately thin, read-only HTTP surface for
the dashboard. The reading consumer runs as a hosted service alongside it; all
state is live and in-memory.

| Method | Path | Description |
|--------|---------------|-----------------------------------------------------------------------------|
| `GET` | `/healthz` | Liveness probe β€” returns `ok`. |
| `GET` | `/api/gauges` | Snapshot of every gauge: current level, alert stage, and downsampled trend. |

`/api/gauges` returns one object per gauge:

```jsonc
{
"gaugeId": "st-pauli",
"level": 4.62, // metres above NHN, latest reading (null if none yet)
"stage": "warning", // normal | warning | severe
"changedAt": "2026-06-12T08:41:00Z",
"trend": [ // recent window, downsampled to ≀ 24 points
{ "t": "2026-06-12T08:30:00Z", "v": 4.41 },
{ "t": "2026-06-12T08:35:00Z", "v": 4.58 }
],
"rateMetersPerMin": 0.04, // least-squares rate-of-change over the window (null if < 2 points)
"timeInStageSeconds": 180, // how long the gauge has held its current stage (null if none yet)
"windowMin": 4.38, // window extent β€” lowest reading (null if empty)
"windowMax": 4.71 // window extent β€” highest reading (null if empty)
}
```

> The derived signals (`rateMetersPerMin`, `timeInStageSeconds`, `windowMin` /
> `windowMax`) are computed in the API mapper, not held in state β€” the state
> holder stays raw (ADR-002).

---

## Tech Stack

| Concern | Technology |
|-------------------|-------------------------------------------|
| Service & API | .NET 10 / ASP.NET Core |
| Messaging | RabbitMQ |
| Observability | OpenTelemetry |
| Testing | xUnit Β· Testcontainers |
| Dashboard | Angular |
| Containerisation | Docker |
| Baseline deploy | Azure Container Apps |
| GitOps deploy | Kubernetes + Argo CD (final phase) |

---

## Out of scope (deliberately)

The narrow scope is a design choice, not a backlog. Kept out so the pipeline
stays the thing that gets done well:

- **No writes from the UI** β€” the dashboard is read-only by design (ADR-002).
- **No persistence layer** β€” gauge state is live, in-memory only; there is no
historical store. The point is the ingestion path, not a time-series database.
- **No auth on the API** β€” single-tenant showcase; the surface is two read-only
endpoints.
- **One domain, one ingestion path** β€” no multi-network federation, no
multi-tenancy. A real public gauge feed (PEGELONLINE) ships in v1.2 (M7),
selectable against the simulator via the `ReadingSource` switch; one source is
active per run (they are not run side by side).
- **Notification delivery** β€” `ApplyStageChange` now publishes alert events at
its single chokepoint (v1.1 / M6), but *acting* on them (email / push, a
notification consumer) stays out of scope; the showcase ends at the event.

---

## Status

Milestones M1–M7 are complete β€” **v1.0 is presentable end to end, v1.1 (demo
& polish) and v1.2 (real PEGELONLINE data) have landed.** M8 (v1.3) is the
remaining planned work. Every intermediate state is built to stay coherent β€”
see the roadmap.

## Roadmap

Built in milestones, each an intermediate state that stays coherent. The full
issue-by-issue breakdown lives in **[`docs/ISSUES.md`](docs/ISSUES.md)**.

| Milestone | Focus | Status |
|--------------------------------------------|-------|-------------|
| **M1 Β· Foundation** | Repo scaffold, contracts, threshold configuration | Done |
| **M2 Β· Ingestion** | RabbitMQ transport, consumer + dead-letter, per-gauge state, surge evaluator | Done |
| **M3 Β· Observability & Tests** | OpenTelemetry tracing, Testcontainers integration tests | Done |
| **M4 Β· Dashboard** | Angular read-only view β€” levels, status, trend | Done |
| **M5 Β· Deploy** | Kubernetes + Argo CD (GitOps, primary) and Azure Container Apps (IaC + CI) | Done |
| **M6 Β· Demo & polish (v1.1)** | Storm-surge scenario, dashboard polish, richer signals, demo assets, alert events | Done |
| **M7 Β· Real data (v1.2)** | Real PEGELONLINE Elbe feed alongside the simulator, source selection | Done |
| **M8 Β· Observability made visible (v1.3)** | Surface the OpenTelemetry path β€” latency pulse, Jaeger deep-link, optional trace waterfall | Planned |

> **M5 ordering:** Kubernetes + Argo CD is the primary deployment, run on a local
> cluster to stay at €0; Azure Container Apps ships as IaC + CI, deployed on demand.
> The reasoning is in [ADR-003](docs/adrs/003-container-apps-vs-kubernetes.md).

---

## Running it

| Stack | Runbook |
|-------|---------|
| Local dev (broker + ingestion + simulator + `ng serve`) | [runbook-local-dashboard.md](docs/runbook-local-dashboard.md) |
| Kubernetes + Argo CD (local kind, GitOps) | [runbook-k8s-argocd.md](docs/runbook-k8s-argocd.md) |
| Azure Container Apps (azd) | [runbook-container-apps.md](docs/runbook-container-apps.md) |

### Local surfaces

Once the local stack is up, these are the addresses you'll use:

| Surface | URL | Notes |
|---------|-----|-------|
| Dashboard (`ng serve`) | | Proxies `/api` to the service. |
| Ingestion API | | Read-only; also `/healthz`. |
| RabbitMQ management UI | | **Dev-only** β€” default `guest` / `guest`. |

### .NET solution commands

The solution is a `.slnx` β€” needs the **.NET 10 SDK**.

```bash
# Build / restore
dotnet build port-tidewatch.slnx
dotnet restore port-tidewatch.slnx

# Run the ingestion service (needs a reachable RabbitMQ β€” see appsettings RabbitMq section)
dotnet run --project src/Tidewatch.Ingestion

# Run the reading-source host (publishes Reading messages; RABBITMQ_HOST env var overrides host).
# Defaults to the scripted simulator; switch to the live Elbe feed with ReadingSource=Pegelonline.
dotnet run --project src/Tidewatch.Source
ReadingSource=Pegelonline dotnet run --project src/Tidewatch.Source
```

### Reading source: simulator vs. live feed

`Tidewatch.Source` runs exactly one reading source, chosen at startup by the
`ReadingSource` config switch (appsettings key or env var) β€” the same build serves
the scripted demo or the real feed without recompiling:

| `ReadingSource` | Source | Notes |
|-----------------|--------|-------|
| `Simulator` (default) | Scripted surge | One gauge runs warning β†’ severe β†’ recede; the rest stay normal. |
| `Pegelonline` | Live WSV/PEGELONLINE Elbe feed | Polls the configured Hamburg Elbe gauges; cm β†’ m and PNP β†’ NHN applied in an explicit mapping layer. |

A missing or unrecognised `ReadingSource` fails at startup (same fail-fast posture
as the threshold config). Both sources emit the identical `Reading` shape, so the
ingestion path cannot tell them apart.

The live feed covers four Hamburg Elbe gauges, configured by UUID under the
`Pegelonline` section: **St. Pauli**, **Bunthaus**, **Over**, **Zollenspieker**.
PEGELONLINE data is Datenlizenz Deutschland Zero 2.0 (free, no auth). HPA tidal
gauges expose no `gaugeZero`, so their PNP is set explicitly (Hamburg PNP =
NHN βˆ’5.00 m).

---

## Testing

Two .NET test projects plus the Angular suite. Integration tests stand up a
real broker via Testcontainers, so the message path is exercised end to end β€”
no mocked transport.

| Layer | Project / command | Scope |
|-------|-------------------|-------|
| Unit | `Tidewatch.Ingestion.UnitTests` | Surge-evaluator stage logic β€” thresholds, trend, outlier handling. |
| Integration | `Tidewatch.Ingestion.IntegrationTests` | Full consume path against a Testcontainers RabbitMQ, incl. the dead-letter route for poison messages. |
| Frontend | `npm test` (in `frontend/`) | Angular component / state tests. |

```bash
# All .NET tests (Docker daemon must be running β€” Testcontainers starts a RabbitMQ container)
dotnet test

# A single test or class
dotnet test --filter "FullyQualifiedName~SurgeEvaluatorTests"
```

---

## Effort accounting

This is an AI-assisted build, and I'd rather be transparent about it than coy.
v1.0 β€” the full pipeline (RabbitMQ transport with a dead-letter path, per-gauge
state, the surge evaluator, OpenTelemetry tracing, Testcontainers tests, the
Angular dashboard, and *both* a Kubernetes/Argo CD and an Azure Container Apps
deploy) β€” came together over roughly **five focused days**.

What got compressed was keystrokes: boilerplate, DTOs, wiring, test scaffolds.
What did **not** get compressed was the thinking. The architectural calls β€” where
threshold logic lives, the queue topology, the evaluator algorithm, the deploy
ordering β€” are mine, made deliberately and written down as ADRs before the code
followed. The AI is a fast pair of hands; the design ownership stayed with me.
That's the honest accounting, and it's why the ADRs exist.

---

## License

[MIT](LICENSE) Β© Felix Wahl