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
|
||||
|
||||
Reference in New Issue
Block a user