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