exporting ECB2

This commit is contained in:
Priec
2026-08-13 09:27:42 +02:00
parent fe8ea680e3
commit 78d72b7a44
5 changed files with 227 additions and 29 deletions

View File

@@ -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::<Timestamp>() {
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::<Timestamp>() {
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<String> {
let (from, to) = (from.parse::<Timestamp>().ok()?, to.parse::<Timestamp>().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<String> {
let timestamp = raw.parse::<Timestamp>().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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
let mut currencies = self.covered_currencies.clone();
currencies.sort();
currencies
}
}
#[derive(Debug)]

View File

@@ -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

View File

@@ -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; }

View File

@@ -23,7 +23,10 @@
{% include "pages/admin/ecb/status.html" %}
<section class="panel">
<h2>Import attempts <span class="count">{{ page.batches.len() }}</span></h2>
<h2>
Import attempts <span class="count">{{ page.batches.len() }}</span>
{%- if page.failure_count() > 0 %}<span class="tag tag-bad">{{ page.failure_count() }} failed</span>{% endif -%}
</h2>
{% if page.batches.is_empty() %}
<p class="hint">
The importer has not recorded an attempt. It runs on startup and then daily, so
@@ -35,36 +38,45 @@
<table class="builder-table">
<thead>
<tr>
<th>Started</th><th>Finished</th><th>Window</th><th>Result</th>
<th>Observations</th><th>Verified through</th>
<th class="mark">#</th><th>Result</th><th>Started</th><th>Finished</th>
<th>Took</th><th>Window</th><th class="numeric">Observations</th>
<th>Verified through</th>
</tr>
</thead>
<tbody>
{% for batch in page.batches %}
<tr>
<td>{{ batch.started_at }}</td>
<td>
{%- if let Some(finished) = batch.completed_at %}{{ finished }}
{%- else %}<span class="hint">still running</span>{% endif -%}
</td>
<td>
{{ batch.window() }}
<div class="hint"><code>{{ batch.endpoint }}</code></div>
</td>
<tr{% if batch.failed() %} class="attempt-failed"{% endif %}>
<td class="mark">{{ batch.batch_id }}</td>
<td>
{%- if batch.succeeded() %}<span class="tag tag-ok">succeeded</span>
{%- else if batch.running() %}<span class="tag">running</span>
{%- else %}<span class="tag tag-bad">failed</span>{% endif -%}
{%- if let Some(message) = batch.error_message %}
<div class="hint">{{ message }}</div>
{% endif -%}
</td>
<td>{{ batch.observations() }}</td>
<td class="moment">{{ batch.started() }}</td>
<td class="moment">
{%- if let Some(finished) = batch.finished() %}{{ finished }}
{%- else %}<span class="hint">still running</span>{% endif -%}
</td>
<td class="numeric">
{%- if let Some(took) = batch.took() %}{{ took }}
{%- else %}<span class="hint"></span>{% endif -%}
</td>
<td>
{{ batch.window() }}
<div class="hint endpoint" title="{{ batch.endpoint }}"><code>{{ batch.endpoint }}</code></div>
</td>
<td class="numeric">{{ batch.observations() }}</td>
<td class="moment">
{%- if let Some(date) = batch.verified_through_date %}{{ date }}
{%- else %}<span class="hint"></span>{% endif -%}
</td>
</tr>
{% if let Some(message) = batch.error_message %}
<tr class="attempt-error">
<td class="mark"></td>
<td colspan="7">{{ message }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View File

@@ -16,21 +16,43 @@
</h2>
<p class="hint">{{ page.explanation() }}</p>
{# 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. #}
<div class="coverage">
<div class="coverage-point">
<span class="coverage-label">Verified through</span>
<span class="coverage-date">
{%- if let Some(date) = page.verified_through_date %}{{ date }}
{%- else %}never{% endif -%}
</span>
</div>
<div class="coverage-gap">
{%- if page.healthy %}<span class="tag tag-ok">up to date</span>
{%- else if page.days_behind > 0 %}<span class="tag tag-bad">{{ page.days_behind }} publication day{% if page.days_behind != 1 %}s{% endif %} behind</span>
{%- else %}<span class="tag tag-bad">no coverage</span>{% endif -%}
</div>
<div class="coverage-point">
<span class="coverage-label">Should reach</span>
<span class="coverage-date">{{ page.latest_verifiable_date }}</span>
</div>
</div>
<dl class="status-grid">
<div>
<dt>Verified through</dt>
<dd>{% if let Some(date) = page.verified_through_date %}{{ date }}{% else %}<span class="hint">never</span>{% endif %}</dd>
<dt>Last attempt</dt>
<dd>{% if let Some(when) = page.last_attempt() %}{{ when }}{% else %}<span class="hint">none recorded</span>{% endif %}</dd>
</div>
<div>
<dt>Should reach</dt>
<dd>{{ page.latest_verifiable_date }}</dd>
<dt>Last success</dt>
<dd>{% if let Some(when) = page.last_success() %}{{ when }}{% else %}<span class="hint">none</span>{% endif %}</dd>
</div>
<div>
<dt>Next import</dt>
<dd>{{ page.next_import_at }}</dd>
<dd>{{ page.next_import() }}</dd>
</div>
<div>
<dt>Currencies on that day</dt>
<dt>Currencies on {% if let Some(date) = page.verified_through_date %}{{ date }}{% else %}the covered day{% endif %}</dt>
<dd>
{%- if page.covered_currencies.is_empty() %}<span class="hint">none</span>
{%- else %}{{ page.covered_currencies.len() }}{% endif -%}
@@ -40,13 +62,13 @@
{% if !page.covered_currencies.is_empty() %}
<div class="currency-list">
{% for currency in page.covered_currencies %}<span class="tag">{{ currency }}</span>{% endfor %}
{% for currency in page.currencies() %}<span class="tag">{{ currency }}</span>{% endfor %}
</div>
{% endif %}
{% if let Some(failure) = page.last_failure() %}
<p class="hint failure-note">
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 %}
</p>
{% endif %}