diff --git a/.env.example b/.env.example
index b52d5b0..9a2eee0 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,4 @@
-# General
+# Core / App
ROUTEFORGE_DEMO_MODE=false
LOG_LEVEL=INFO
@@ -8,15 +8,6 @@ POSTGRES_USER=routeforge
POSTGRES_PASSWORD=change-me
DATABASE_URL=postgresql+psycopg://routeforge:change-me@postgres:5432/routeforge
-# CORS (comma-separated list; mainly needed for split frontend/backend deployments)
-CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
-
-# Optional. Leave empty for same-origin /api proxy via frontend nginx.
-VITE_API_URL=
-
-# Frontend dev proxy target (for `frontend/vite` local development)
-VITE_API_PROXY_TARGET=http://backend:8000
-
# RIPEstat
RIPESTAT_BASE_URL=https://stat.ripe.net/data
RIPESTAT_CACHE_TTL_SECONDS=900
@@ -25,15 +16,6 @@ RIPESTAT_MAX_RETRIES=1
RIPESTAT_RETRY_BACKOFF_SECONDS=0.5
RIPESTAT_USE_STALE_CACHE_ON_ERROR=true
-# Optional general HTTP timeout for backend calls
-HTTP_TIMEOUT_SECONDS=10
-
-# Auth
-SECRET_KEY=change-me
-SESSION_COOKIE_NAME=routeforge_session
-SESSION_EXPIRE_HOURS=12
-COOKIE_SECURE=false
-
# RPKI Provider
RPKI_PROVIDER=ripestat
RPKI_ROUTINATOR_URL=http://routinator:8323
@@ -41,17 +23,17 @@ RPKI_LOCAL_JSON_PATH=
RPKI_PROVIDER_TIMEOUT_SECONDS=5
RPKI_FALLBACK_TO_RIPESTAT=true
-# BGP Multi-source
+# BGP Visibility Provider
BGP_VISIBILITY_PROVIDERS=ripestat
BGP_GENERIC_URL_TEMPLATE=
BGP_PROVIDER_TIMEOUT_SECONDS=5
BGP_VISIBILITY_REQUIRE_SOURCE_AGREEMENT=false
BGP_VISIBILITY_MIN_CONFIDENCE=60
-# Change workflow
+# Change / Post-Change
POST_CHANGE_DEFAULT_RECHECK_MINUTES=15,30,60
-# Watch alerts
+# Alerts / Webhooks
ALERT_WEBHOOK_ENABLED=false
ALERT_WEBHOOK_URL=
ALERT_WEBHOOK_SECRET=
@@ -59,6 +41,17 @@ ALERT_ON_STATUS_CHANGE_ONLY=true
ALERT_WEBHOOK_TIMEOUT_SECONDS=5
ALERT_WEBHOOK_MAX_RETRIES=1
-# Watch alerting
-ALERT_WEBHOOK_MAX_RETRIES=2
-ALERT_WEBHOOK_TIMEOUT_SECONDS=5
+# Security / CORS
+# CORS origins (comma-separated list; mainly needed for split frontend/backend deployments)
+CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
+# Optional. Leave empty for same-origin /api proxy via frontend nginx.
+VITE_API_URL=
+# Frontend dev proxy target (for `frontend/vite` local development)
+VITE_API_PROXY_TARGET=http://backend:8000
+# Optional general HTTP timeout for backend calls
+HTTP_TIMEOUT_SECONDS=10
+# Auth
+SECRET_KEY=change-me
+SESSION_COOKIE_NAME=routeforge_session
+SESSION_EXPIRE_HOURS=12
+COOKIE_SECURE=false
diff --git a/backend/app/templates/report.html.j2 b/backend/app/templates/report.html.j2
index 8e8b5ba..5464c17 100644
--- a/backend/app/templates/report.html.j2
+++ b/backend/app/templates/report.html.j2
@@ -1,11 +1,11 @@
RouteForge Report
-Zeitpunkt: {{ report.generated_at }}
+Generated At: {{ report.generated_at }}
Check-ID: {{ report.check_id }}
-Eingabe
+Input
- - Check-Typ: {{ report.input_check_type or '-' }}
+ - Check Type: {{ report.input_check_type or '-' }}
- Prefix: {{ report.input.prefix if report.input else '-' }}
- Origin-AS: {{ report.input.origin_as if report.input and report.input.origin_as else '-' }}
- Planned Origin-AS: {{ report.input.planned_origin_as if report.input and report.input.planned_origin_as else '-' }}
@@ -13,10 +13,10 @@
- Holder: {{ report.details.resource_holder.holder if report.details and report.details.resource_holder else 'Unknown' }}
-Gesamtbewertung
+Overall Assessment
- Status: {{ report.status }}
- - Kurzfassung: {{ report.summary }}
+ - Summary: {{ report.summary }}
- Preflight Decision: {{ report.details.preflight_decision if report.details and report.details.preflight_decision else '-' }}
- Explanation: {{ report.explanation or '-' }}
- Risk: {{ report.risk or '-' }}
@@ -65,20 +65,20 @@
Data Source Diagnostics
-{% if report.details and report.details.source_diagnostics %}| Quelle | Endpoint | Status | Freshness | Attempts | Retries | Fallback | Stale Cache | Dauer | Cache | Cache Age | TTL | Message |
{% for d in report.details.source_diagnostics %}| {{ d.name or '-' }} | {{ d.endpoint or '-' }} | {{ d.status or '-' }} | {{ d.freshness or 'UNKNOWN' }} | {{ d.attempts if d.attempts is not none else '-' }} | {{ d.retry_count if d.retry_count is not none else '-' }} | {{ 'yes' if d.fallback_used is sameas true else ('no' if d.fallback_used is sameas false else '-') }} | {{ 'yes' if d.stale_cache_used is sameas true else ('no' if d.stale_cache_used is sameas false else '-') }} | {{ d.duration_ms if d.duration_ms is not none else '-' }} | {{ 'HIT' if d.cached is sameas true else ('MISS' if d.cached is sameas false else '-') }} | {{ d.cache_age_seconds if d.cache_age_seconds is not none else 'Unknown' }} | {{ d.cache_ttl_seconds if d.cache_ttl_seconds is not none else 'Unknown' }} | {{ d.message or '-' }}{% if d.fallback_used is sameas true %} Live request failed. Cached data was used. {% endif %}{% if d.details %}Details{{ d.details | tojson(indent=2) }} {% endif %} |
{% endfor %}
{% else %}No source diagnostics available.
{% endif %}
+{% if report.details and report.details.source_diagnostics %}| Source | Endpoint | Status | Freshness | Attempts | Retries | Fallback | Stale Cache | Duration | Cache | Cache Age | TTL | Message |
{% for d in report.details.source_diagnostics %}| {{ d.name or '-' }} | {{ d.endpoint or '-' }} | {{ d.status or '-' }} | {{ d.freshness or 'UNKNOWN' }} | {{ d.attempts if d.attempts is not none else '-' }} | {{ d.retry_count if d.retry_count is not none else '-' }} | {{ 'yes' if d.fallback_used is sameas true else ('no' if d.fallback_used is sameas false else '-') }} | {{ 'yes' if d.stale_cache_used is sameas true else ('no' if d.stale_cache_used is sameas false else '-') }} | {{ d.duration_ms if d.duration_ms is not none else '-' }} | {{ 'HIT' if d.cached is sameas true else ('MISS' if d.cached is sameas false else '-') }} | {{ d.cache_age_seconds if d.cache_age_seconds is not none else 'Unknown' }} | {{ d.cache_ttl_seconds if d.cache_ttl_seconds is not none else 'Unknown' }} | {{ d.message or '-' }}{% if d.fallback_used is sameas true %} Live request failed. Cached data was used. {% endif %}{% if d.details %}Details{{ d.details | tojson(indent=2) }} {% endif %} |
{% endfor %}
{% else %}No source diagnostics available.
{% endif %}
-Rohdaten
-RPKI Rohdaten
+Raw Data
+RPKI Raw Data
{{ (report.checks.rpki.raw if report.checks and report.checks.rpki else {}) | tojson(indent=2) }}
-Registry/IRR Rohdaten
+Registry/IRR Raw Data
{{ (report.checks.registry.raw if report.checks and report.checks.registry else {}) | tojson(indent=2) }}
-Routing Visibility Rohdaten
-Ein-/ausklappen
{{ (report.checks.routing_visibility.raw if report.checks and report.checks.routing_visibility else {}) | tojson(indent=2) }}
+Routing Visibility Raw Data
+Expand/collapse
{{ (report.checks.routing_visibility.raw if report.checks and report.checks.routing_visibility else {}) | tojson(indent=2) }}
-Technische Details
{{ report.details | tojson(indent=2) }}
+Technical Details
{{ report.details | tojson(indent=2) }}
{% if report.details and report.details.rpki_summary %}RPKI Batch Summary
{{ report.details.rpki_summary | tojson(indent=2) }}{% endif %}
-{% if report.details and report.details.results %}RPKI Batch Ergebnisse
{{ report.details.results | tojson(indent=2) }}{% endif %}
+{% if report.details and report.details.results %}RPKI Batch Results
{{ report.details.results | tojson(indent=2) }}{% endif %}
{% if report.details and report.details.preflight_mode %}Preflight Assessment
- Planned Prefix: {{ report.input.prefix if report.input else '-' }}
- Planned Origin-AS: {{ report.input.planned_origin_as if report.input and report.input.planned_origin_as else '-' }}
- Overall Status: {{ report.status }}
- Summary: {{ report.summary }}
- Explanation: {{ report.explanation or '-' }}
- Risk: {{ report.risk or '-' }}
{% endif %}
diff --git a/backend/app/templates/report.md.j2 b/backend/app/templates/report.md.j2
index 3137c70..dd596aa 100644
--- a/backend/app/templates/report.md.j2
+++ b/backend/app/templates/report.md.j2
@@ -1,19 +1,19 @@
# RouteForge Report
-- Zeitpunkt: {{ report.generated_at }}
+- Generated At: {{ report.generated_at }}
- Check-ID: {{ report.check_id }}
-## Eingabe
-- Check-Typ: {{ report.input_check_type or '-' }}
+## Input
+- Check Type: {{ report.input_check_type or '-' }}
- Prefix: {{ report.input.prefix if report.input else '-' }}
- Origin-AS: {{ report.input.origin_as if report.input and report.input.origin_as else '-' }}
- Planned Origin-AS: {{ report.input.planned_origin_as if report.input and report.input.planned_origin_as else '-' }}
- ASN: {{ report.input.asn if report.input and report.input.asn else '-' }}
- Holder: {{ report.details.resource_holder.holder if report.details and report.details.resource_holder else 'Unknown' }}
-## Gesamtbewertung
+## Overall Assessment
- Status: **{{ report.status }}**
-- Kurzfassung: {{ report.summary }}
+- Summary: {{ report.summary }}
- Preflight Decision: {{ report.details.preflight_decision if report.details and report.details.preflight_decision else '-' }}
- Explanation: {{ report.explanation or '-' }}
- Risk: {{ report.risk or '-' }}
@@ -57,30 +57,30 @@
## Data Source Diagnostics
{% if report.details and report.details.source_diagnostics %}
-| Quelle | Endpoint | Status | Freshness | Attempts | Retries | Fallback | Stale Cache | Dauer | Cache | Cache Age | TTL | Message |
+| Source | Endpoint | Status | Freshness | Attempts | Retries | Fallback | Stale Cache | Duration | Cache | Cache Age | TTL | Message |
|---|---|---|---:|---|---|
{% for d in report.details.source_diagnostics %}| {{ d.name or '-' }} | {{ d.endpoint or '-' }} | {{ d.status or '-' }} | {{ d.freshness or 'UNKNOWN' }} | {{ d.attempts if d.attempts is not none else '-' }} | {{ d.retry_count if d.retry_count is not none else '-' }} | {{ 'yes' if d.fallback_used is sameas true else ('no' if d.fallback_used is sameas false else '-') }} | {{ 'yes' if d.stale_cache_used is sameas true else ('no' if d.stale_cache_used is sameas false else '-') }} | {{ d.duration_ms if d.duration_ms is not none else '-' }} | {{ 'HIT' if d.cached is sameas true else ('MISS' if d.cached is sameas false else '-') }} | {{ d.cache_age_seconds if d.cache_age_seconds is not none else 'Unknown' }} | {{ d.cache_ttl_seconds if d.cache_ttl_seconds is not none else 'Unknown' }} | {{ d.message or '-' }}{% if d.fallback_used is sameas true %} (Live request failed. Cached data was used.){% endif %} |
{% endfor %}
{% else %}No source diagnostics available.
{% endif %}
-## Rohdaten
-### RPKI Rohdaten
+## Raw Data
+### RPKI Raw Data
```json
{{ (report.checks.rpki.raw if report.checks and report.checks.rpki else {}) | tojson(indent=2) }}
```
-### Registry/IRR Rohdaten
+### Registry/IRR Raw Data
```json
{{ (report.checks.registry.raw if report.checks and report.checks.registry else {}) | tojson(indent=2) }}
```
-### Routing Visibility Rohdaten
+### Routing Visibility Raw Data
```json
{{ (report.checks.routing_visibility.raw if report.checks and report.checks.routing_visibility else {}) | tojson(indent=2) }}
```
-## Technische Details
+## Technical Details
```json
{{ report.details | tojson(indent=2) }}
```
@@ -93,7 +93,7 @@
{% endif %}
{% if report.details and report.details.results %}
-## RPKI Batch Ergebnisse
+## RPKI Batch Results
```json
{{ report.details.results | tojson(indent=2) }}
```
diff --git a/backend/tests/test_release_cleanup_guards.py b/backend/tests/test_release_cleanup_guards.py
new file mode 100644
index 0000000..aa7ed3e
--- /dev/null
+++ b/backend/tests/test_release_cleanup_guards.py
@@ -0,0 +1,35 @@
+from pathlib import Path
+
+
+def _parse_env_keys(path: Path) -> list[str]:
+ keys: list[str] = []
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _ = line.split("=", 1)
+ keys.append(key.strip())
+ return keys
+
+
+def test_env_example_has_no_duplicate_keys() -> None:
+ env_path = Path(__file__).resolve().parents[2] / ".env.example"
+ keys = _parse_env_keys(env_path)
+ duplicates = sorted({key for key in keys if keys.count(key) > 1})
+ assert duplicates == []
+
+
+def test_markdown_report_template_uses_english_headings() -> None:
+ template = (Path(__file__).resolve().parents[1] / "app" / "templates" / "report.md.j2").read_text(encoding="utf-8")
+ assert "## Input" in template
+ assert "## Overall Assessment" in template
+ assert "## Raw Data" in template
+ assert "## Technical Details" in template
+
+
+def test_html_report_template_uses_english_headings() -> None:
+ template = (Path(__file__).resolve().parents[1] / "app" / "templates" / "report.html.j2").read_text(encoding="utf-8")
+ assert "Input
" in template
+ assert "Overall Assessment
" in template
+ assert "Raw Data
" in template
+ assert "Technical Details
" in template