exporting ECB2
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user