From 78d72b7a441b1b074f035b8291b913786a3edfcc Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 13 Aug 2026 09:27:42 +0200 Subject: [PATCH] exporting ECB2 --- web/src/pages/admin/ecb/state.rs | 122 +++++++++++++++++++++- web/src/pages/admin/ecb/ui.rs | 28 +++++ web/static/app.css | 22 +++- web/templates/pages/admin/ecb/ecb.html | 46 +++++--- web/templates/pages/admin/ecb/status.html | 38 +++++-- 5 files changed, 227 insertions(+), 29 deletions(-) diff --git a/web/src/pages/admin/ecb/state.rs b/web/src/pages/admin/ecb/state.rs index 46b00b1a..205a446e 100644 --- a/web/src/pages/admin/ecb/state.rs +++ b/web/src/pages/admin/ecb/state.rs @@ -1,8 +1,62 @@ //! What the ECB page renders. //! -//! Dates and timestamps arrive as strings and stay strings: the backend -//! derived them from the accounting calendar, and reparsing them here would -//! only create a second opinion about what "today" means. +//! Dates stay strings: the backend derived them from the accounting calendar, +//! and reparsing them here would only create a second opinion about what +//! "today" means. Timestamps are reparsed, but only to be printed — an +//! RFC 3339 instant with fractional seconds is not something a reader should +//! have to decode, and how long a batch took is only knowable by subtracting +//! two of them. A string that will not parse is shown as it arrived. + +use jiff::{SignedDuration, Timestamp}; + +/// `2026-08-12T17:00:04.512Z` → `2026-08-12 17:00 UTC`. +fn moment(raw: &str) -> String { + match raw.parse::() { + Ok(timestamp) => timestamp.strftime("%Y-%m-%d %H:%M UTC").to_string(), + Err(_) => raw.to_string(), + } +} + +/// The clock part alone, for a column whose neighbour already says the day. +fn time_of_day(raw: &str) -> String { + match raw.parse::() { + Ok(timestamp) => timestamp.strftime("%H:%M:%S").to_string(), + Err(_) => raw.to_string(), + } +} + +/// Coarse duration, largest unit first and never more than two of them. +fn coarse(duration: SignedDuration) -> String { + let seconds = duration.as_secs().max(0); + let days = seconds / 86_400; + let hours = (seconds % 86_400) / 3600; + let minutes = (seconds % 3600) / 60; + if days > 0 { + format!("{days}d {hours}h") + } else if hours > 0 { + format!("{hours}h {minutes}m") + } else if minutes > 0 { + format!("{minutes}m {}s", seconds % 60) + } else { + format!("{seconds}s") + } +} + +fn elapsed_between(from: &str, to: &str) -> Option { + let (from, to) = (from.parse::().ok()?, to.parse::().ok()?); + Some(coarse(from.duration_until(to))) +} + +/// How far a timestamp is from now, phrased for whichever side of now it is on. +fn from_now(raw: &str) -> Option { + let timestamp = raw.parse::().ok()?; + let now = Timestamp::now(); + Some(if timestamp >= now { + format!("in {}", coarse(now.duration_until(timestamp))) + } else { + format!("{} ago", coarse(timestamp.duration_until(now))) + }) +} /// One import attempt, as the audit log recorded it. pub(crate) struct ImportBatchView { @@ -32,6 +86,22 @@ impl ImportBatchView { self.status == "failed" } + pub(crate) fn started(&self) -> String { + moment(&self.started_at) + } + + /// The finish reads as a clock time: it is all but always the same day as + /// the start, and repeating the date buys nothing. + pub(crate) fn finished(&self) -> Option { + self.completed_at.as_deref().map(time_of_day) + } + + /// How long the attempt took. A run that suddenly takes minutes instead of + /// seconds is the first sign the endpoint is struggling. + pub(crate) fn took(&self) -> Option { + elapsed_between(&self.started_at, self.completed_at.as_deref()?) + } + /// The row's own date range, collapsed when it covers a single day. pub(crate) fn window(&self) -> String { if self.requested_from == self.requested_through { @@ -107,6 +177,52 @@ impl EcbPageState { pub(crate) fn last_failure(&self) -> Option<&ImportBatchView> { self.batches.iter().find(|batch| batch.failed()) } + + /// When the scheduler next wakes, and how long that is from now — the + /// second half is what tells a reader whether waiting is an option. + pub(crate) fn next_import(&self) -> String { + match from_now(&self.next_import_at) { + Some(relative) => format!("{} ({relative})", moment(&self.next_import_at)), + None => self.next_import_at.clone(), + } + } + + /// The newest attempt of any outcome, which is what "has the importer run + /// at all lately" asks about. + pub(crate) fn last_attempt(&self) -> Option { + let batch = self.batches.first()?; + let when = batch.completed_at.as_deref().unwrap_or(&batch.started_at); + Some(match from_now(when) { + Some(relative) => format!("{} ({relative})", moment(when)), + None => moment(when), + }) + } + + /// Successes are what move coverage; a run of failures on top of an old + /// success is a different picture from no runs at all. + pub(crate) fn last_success(&self) -> Option { + let batch = self.batches.iter().find(|batch| batch.succeeded())?; + let when = batch.completed_at.as_deref().unwrap_or(&batch.started_at); + Some(match from_now(when) { + Some(relative) => format!("{} ({relative})", moment(when)), + None => moment(when), + }) + } + + /// Failures among the attempts on the page. One is an incident; several in + /// a row is a broken pipeline, and the count is the only thing that + /// distinguishes them at a glance. + pub(crate) fn failure_count(&self) -> usize { + self.batches.iter().filter(|batch| batch.failed()).count() + } + + /// Alphabetical, because the reader is looking one up rather than reading + /// the list through. + pub(crate) fn currencies(&self) -> Vec { + let mut currencies = self.covered_currencies.clone(); + currencies.sort(); + currencies + } } #[derive(Debug)] diff --git a/web/src/pages/admin/ecb/ui.rs b/web/src/pages/admin/ecb/ui.rs index e2f548e0..a2592cec 100644 --- a/web/src/pages/admin/ecb/ui.rs +++ b/web/src/pages/admin/ecb/ui.rs @@ -101,6 +101,34 @@ mod tests { assert!(html.contains("Rates are behind")); assert!(html.contains("unhealthy")); assert!(html.contains("3 publication days are missing")); + assert!(html.contains("3 publication days behind")); + } + + /// Timestamps arrive as RFC 3339 instants and must not reach the page that + /// way; how long a batch took has to be worked out here, since the backend + /// only sends the two ends. + #[test] + fn timestamps_are_printed_rather_than_dumped() { + let html = render_page(&healthy_page()); + + assert!(html.contains("2026-08-12 17:00 UTC"), "{html}"); + assert!(!html.contains("2026-08-12T17:00:00Z"), "{html}"); + // Started 17:00:00, finished 17:00:04. + assert!(html.contains("17:00:04")); + assert!(html.contains("4s")); + } + + /// The counts above the table are how a run of bad days is told apart from + /// one bad day without reading every row. + #[test] + fn failed_attempts_are_counted_above_the_table() { + let mut page = healthy_page(); + page.batches.push(batch(8, "failed")); + page.batches.push(batch(7, "failed")); + + let html = render_page(&page); + + assert!(html.contains("2 failed"), "{html}"); } /// A pipeline that has never succeeded is a different problem from one diff --git a/web/static/app.css b/web/static/app.css index 80a22913..2555ac10 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -232,13 +232,33 @@ .status-card.healthy { border-left-color: #21643a; } .status-card.unhealthy { border-left-color: #a12b2b; } .status-card.unhealthy h2 { color: #a12b2b; } - .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; margin: 14px 0 0; } + /* Coverage reads left to right as one sentence -- reached, gap, owed -- so + the two dates are compared rather than looked up one at a time. */ + .coverage { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f7f9fc; } + .coverage-point { display: flex; flex-direction: column; gap: 2px; } + .coverage-label { color: #7c8796; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; } + .coverage-date { color: #17202a; font-size: 20px; font-variant-numeric: tabular-nums; } + .coverage-gap { flex: 1; text-align: center; } + .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; margin: 16px 0 0; } .status-grid dt { color: #7c8796; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; } .status-grid dd { margin: 3px 0 0; color: #17202a; font-size: 15px; font-variant-numeric: tabular-nums; } .currency-list { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 14px; } .failure-note { margin-top: 14px; padding-top: 12px; border-top: 1px solid #edf0f3; } .tag-ok { color: #21643a; background: #f2f9f3; border-color: #cfe0c4; } .tag-bad { color: #a12b2b; background: #fdf3f3; border-color: #eccfcf; } + /* Two facts per cell, so the second sits under the first rather than + stretching the column. */ + .status-grid dd { font-size: 14px; line-height: 1.45; } + + /* The import log. Every column but the window is a number or an instant, so + they line up in a monospaced digit and are read down, not across. */ + .builder-table td.numeric, .builder-table th.numeric { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } + .builder-table td.moment { white-space: nowrap; font-variant-numeric: tabular-nums; } + /* The endpoint is the same long URL on nearly every row: kept for evidence, + truncated so it cannot set the column width, full text in the tooltip. */ + .endpoint { display: block; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .attempt-failed td { background: #fdf7f7; } + .attempt-error td { padding-top: 0; color: #a12b2b; font-size: 12px; background: #fdf7f7; border-top: 0; } .table-scroll { overflow-x: auto; } .link-action { color: #2563eb; text-decoration: none; } diff --git a/web/templates/pages/admin/ecb/ecb.html b/web/templates/pages/admin/ecb/ecb.html index b0875d51..84fe1878 100644 --- a/web/templates/pages/admin/ecb/ecb.html +++ b/web/templates/pages/admin/ecb/ecb.html @@ -23,7 +23,10 @@ {% include "pages/admin/ecb/status.html" %}
-

Import attempts {{ page.batches.len() }}

+

+ Import attempts {{ page.batches.len() }} + {%- if page.failure_count() > 0 %}{{ page.failure_count() }} failed{% endif -%} +

{% if page.batches.is_empty() %}

The importer has not recorded an attempt. It runs on startup and then daily, so @@ -35,36 +38,45 @@ - - + + + {% for batch in page.batches %} - - - - + + - + + + + + + {% if let Some(message) = batch.error_message %} + + + + + {% endif %} {% endfor %}
StartedFinishedWindowResultObservationsVerified through#ResultStartedFinishedTookWindowObservationsVerified through
{{ batch.started_at }} - {%- if let Some(finished) = batch.completed_at %}{{ finished }} - {%- else %}still running{% endif -%} - - {{ batch.window() }} -
{{ batch.endpoint }}
-
{{ batch.batch_id }} {%- if batch.succeeded() %}succeeded {%- else if batch.running() %}running {%- else %}failed{% endif -%} - {%- if let Some(message) = batch.error_message %} -
{{ message }}
- {% endif -%}
{{ batch.observations() }}{{ batch.started() }} + {%- if let Some(finished) = batch.finished() %}{{ finished }} + {%- else %}still running{% endif -%} + + {%- if let Some(took) = batch.took() %}{{ took }} + {%- else %}{% endif -%} + + {{ batch.window() }} +
{{ batch.endpoint }}
+
{{ batch.observations() }} {%- if let Some(date) = batch.verified_through_date %}{{ date }} {%- else %}{% endif -%}
{{ message }}
diff --git a/web/templates/pages/admin/ecb/status.html b/web/templates/pages/admin/ecb/status.html index db1d7654..0fef0960 100644 --- a/web/templates/pages/admin/ecb/status.html +++ b/web/templates/pages/admin/ecb/status.html @@ -16,21 +16,43 @@

{{ page.explanation() }}

+ {# Coverage first and on its own line: the two dates and the gap between + them are the whole answer, and burying them in a four-column grid made + them read as trivia. #} +
+
+ Verified through + + {%- if let Some(date) = page.verified_through_date %}{{ date }} + {%- else %}never{% endif -%} + +
+
+ {%- if page.healthy %}up to date + {%- else if page.days_behind > 0 %}{{ page.days_behind }} publication day{% if page.days_behind != 1 %}s{% endif %} behind + {%- else %}no coverage{% endif -%} +
+
+ Should reach + {{ page.latest_verifiable_date }} +
+
+
-
Verified through
-
{% if let Some(date) = page.verified_through_date %}{{ date }}{% else %}never{% endif %}
+
Last attempt
+
{% if let Some(when) = page.last_attempt() %}{{ when }}{% else %}none recorded{% endif %}
-
Should reach
-
{{ page.latest_verifiable_date }}
+
Last success
+
{% if let Some(when) = page.last_success() %}{{ when }}{% else %}none{% endif %}
Next import
-
{{ page.next_import_at }}
+
{{ page.next_import() }}
-
Currencies on that day
+
Currencies on {% if let Some(date) = page.verified_through_date %}{{ date }}{% else %}the covered day{% endif %}
{%- if page.covered_currencies.is_empty() %}none {%- else %}{{ page.covered_currencies.len() }}{% endif -%} @@ -40,13 +62,13 @@ {% if !page.covered_currencies.is_empty() %}
- {% for currency in page.covered_currencies %}{{ currency }}{% endfor %} + {% for currency in page.currencies() %}{{ currency }}{% endfor %}
{% endif %} {% if let Some(failure) = page.last_failure() %}

- Most recent failure — batch {{ failure.batch_id }}, started {{ failure.started_at }}: + Most recent failure — batch {{ failure.batch_id }}, started {{ failure.started() }}: {% if let Some(message) = failure.error_message %}{{ message }}{% else %}no reason was recorded.{% endif %}

{% endif %}