exporting ECB
This commit is contained in:
@@ -7,6 +7,12 @@ pub(crate) const STRUCT_VALIDATION: &str = "struct:validation";
|
||||
pub(crate) const STRUCT_ROLE: &str = "struct:role";
|
||||
pub(crate) const STRUCT_USER: &str = "struct:user";
|
||||
pub(crate) const MANAGE: &str = "manage";
|
||||
pub(crate) const READ: &str = "read";
|
||||
|
||||
/// Every ECB object, which is what the pipeline status is checked against:
|
||||
/// one importer feeds every profile, so reading its health is not a
|
||||
/// per-profile question.
|
||||
pub(crate) const ALL_ECB: &str = "ecb:*";
|
||||
|
||||
pub(crate) fn permits(snapshot: &AuthorizationSnapshot, object: &str, action: &str) -> bool {
|
||||
permissions_permit(&snapshot.permissions, object, action)
|
||||
@@ -26,6 +32,12 @@ pub(crate) fn can_manage(snapshot: &AuthorizationSnapshot, area: &str) -> bool {
|
||||
permits(snapshot, area, MANAGE)
|
||||
}
|
||||
|
||||
/// Whether the caller may see the reference-rate pipeline. Mirrors the
|
||||
/// server's own check in `server/src/ecb/grpc.rs`.
|
||||
pub(crate) fn can_read_ecb(snapshot: &AuthorizationSnapshot) -> bool {
|
||||
permits(snapshot, ALL_ECB, READ)
|
||||
}
|
||||
|
||||
pub(crate) fn can_open_admin(snapshot: &AuthorizationSnapshot) -> bool {
|
||||
[
|
||||
STRUCT_PROFILE,
|
||||
|
||||
@@ -70,6 +70,13 @@ mod definitions {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod ecb {
|
||||
include!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../common/src/proto/komp_ac.ecb.rs"
|
||||
));
|
||||
}
|
||||
|
||||
use auth::auth_service_client::AuthServiceClient;
|
||||
use definitions::{
|
||||
table_definition::table_definition_client::TableDefinitionClient,
|
||||
@@ -81,6 +88,7 @@ use definitions::{
|
||||
use tonic::transport::Channel;
|
||||
|
||||
use analytics::analytics_service_client::AnalyticsServiceClient;
|
||||
use ecb::ecb_service_client::EcbServiceClient;
|
||||
|
||||
const APP_CSS: &str = include_str!("../static/app.css");
|
||||
|
||||
@@ -95,6 +103,7 @@ pub(crate) struct AppState {
|
||||
structures: TableStructureServiceClient<Channel>,
|
||||
validations: TableValidationServiceClient<Channel>,
|
||||
tables_data: TablesDataClient<Channel>,
|
||||
ecb: EcbServiceClient<Channel>,
|
||||
}
|
||||
|
||||
/// Starts the web UI as a detached task on the current Tokio runtime.
|
||||
@@ -126,6 +135,7 @@ pub async fn serve() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
scripts: TableScriptClient::new(channel.clone()),
|
||||
validations: TableValidationServiceClient::new(channel.clone()),
|
||||
tables_data: TablesDataClient::new(channel.clone()),
|
||||
ecb: EcbServiceClient::new(channel.clone()),
|
||||
structures: TableStructureServiceClient::new(channel),
|
||||
};
|
||||
|
||||
@@ -147,6 +157,7 @@ fn router(state: AppState) -> Router {
|
||||
.merge(pages::admin::admin::router())
|
||||
.merge(pages::permissions::router())
|
||||
.merge(pages::admin::table_definition::router())
|
||||
.merge(pages::admin::ecb::router())
|
||||
.merge(pages::add_table::router())
|
||||
.merge(pages::add_logic::router())
|
||||
.merge(pages::add_validation::router())
|
||||
@@ -189,6 +200,7 @@ mod tests {
|
||||
scripts: TableScriptClient::new(channel.clone()),
|
||||
validations: TableValidationServiceClient::new(channel.clone()),
|
||||
tables_data: TablesDataClient::new(channel.clone()),
|
||||
ecb: EcbServiceClient::new(channel.clone()),
|
||||
structures: TableStructureServiceClient::new(channel),
|
||||
})
|
||||
}
|
||||
@@ -334,6 +346,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The exchange-rate page is read-only and behind a session like every
|
||||
/// other page, so an anonymous visitor is sent to the login page rather
|
||||
/// than to the backend.
|
||||
#[tokio::test]
|
||||
async fn the_exchange_rate_pages_are_mounted_and_need_a_session() {
|
||||
for path in ["/admin/ecb", "/admin/ecb/status"] {
|
||||
let (status, _) = get(path).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
axum::http::StatusCode::SEE_OTHER,
|
||||
"{path} did not send an anonymous visitor to the login page"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Permissions is a nav section of its own, so its three pages are mounted
|
||||
/// at the top level rather than under /admin — and each of them, like every
|
||||
/// other page behind a session, sends an anonymous visitor to the login
|
||||
|
||||
@@ -167,6 +167,7 @@ pub(crate) async fn load_admin_page(
|
||||
can_export: authorization.permissions.iter().any(|permission| {
|
||||
permission.action == "read" && permission.object.starts_with("data:")
|
||||
}),
|
||||
can_ecb: crate::authz::can_read_ecb(&authorization),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ pub(crate) struct AdminPageState {
|
||||
pub can_manage_scripts: bool,
|
||||
pub can_manage_validations: bool,
|
||||
pub can_export: bool,
|
||||
/// Whether the exchange-rate pipeline is visible to this caller. Not a
|
||||
/// structural area: it is granted through the ECB object, like the
|
||||
/// conversions it reports on.
|
||||
pub can_ecb: bool,
|
||||
}
|
||||
|
||||
impl AdminPageState {
|
||||
|
||||
@@ -53,6 +53,7 @@ mod tests {
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: false,
|
||||
active: "admin",
|
||||
};
|
||||
let page = AdminPageState {
|
||||
@@ -66,6 +67,7 @@ mod tests {
|
||||
can_manage_scripts: true,
|
||||
can_manage_validations: true,
|
||||
can_export: true,
|
||||
can_ecb: true,
|
||||
};
|
||||
let html = render_page(&page);
|
||||
for route in [
|
||||
@@ -108,6 +110,7 @@ mod tests {
|
||||
can_manage_scripts: true,
|
||||
can_manage_validations: true,
|
||||
can_export: true,
|
||||
can_ecb: true,
|
||||
};
|
||||
|
||||
let html = render_workspace(&page);
|
||||
@@ -142,6 +145,7 @@ mod tests {
|
||||
can_manage_scripts: true,
|
||||
can_manage_validations: true,
|
||||
can_export: true,
|
||||
can_ecb: true,
|
||||
};
|
||||
|
||||
let html = render_workspace(&page);
|
||||
@@ -181,6 +185,7 @@ mod tests {
|
||||
can_manage_scripts: true,
|
||||
can_manage_validations: true,
|
||||
can_export: true,
|
||||
can_ecb: true,
|
||||
};
|
||||
|
||||
let html = render_workspace(&page);
|
||||
|
||||
90
web/src/pages/admin/ecb/loader.rs
Normal file
90
web/src/pages/admin/ecb/loader.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! Two calls: the caller's authorization, and the pipeline status itself.
|
||||
//!
|
||||
//! The status RPC is authorized server-side against the whole ECB object, so
|
||||
//! the check here is only about whether to draw the page at all — the backend
|
||||
//! is still the authority, and a caller who slips past this gets a
|
||||
//! `PermissionDenied` from it rather than data.
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::GetAuthorizationRequest,
|
||||
ecb::GetEcbPipelineStatusRequest,
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::state::{EcbPageState, ImportBatchView, LoadError};
|
||||
|
||||
/// How many attempts the table shows. Enough to cover a fortnight of daily
|
||||
/// runs plus the retries a bad day produces.
|
||||
const BATCH_LIMIT: i32 = 25;
|
||||
|
||||
pub(crate) async fn load_ecb_page(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<EcbPageState, LoadError> {
|
||||
let authorization_request = authenticated_request(headers, GetAuthorizationRequest {})
|
||||
.map_err(|_| LoadError::Unauthenticated)?;
|
||||
let mut auth = state.auth;
|
||||
let authorization = auth
|
||||
.get_authorization(authorization_request)
|
||||
.await
|
||||
.map_err(|error| match error.code() {
|
||||
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
|
||||
tonic::Code::PermissionDenied => LoadError::Forbidden,
|
||||
_ => LoadError::Backend(error.message().to_string()),
|
||||
})?
|
||||
.into_inner();
|
||||
|
||||
if !crate::authz::can_read_ecb(&authorization) {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
|
||||
let mut ecb = state.ecb;
|
||||
let status = ecb
|
||||
.get_ecb_pipeline_status(
|
||||
authenticated_request(
|
||||
headers,
|
||||
GetEcbPipelineStatusRequest {
|
||||
batch_limit: BATCH_LIMIT,
|
||||
},
|
||||
)
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match error.code() {
|
||||
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
|
||||
tonic::Code::PermissionDenied => LoadError::Forbidden,
|
||||
_ => LoadError::Backend(error.message().to_string()),
|
||||
})?
|
||||
.into_inner();
|
||||
|
||||
Ok(EcbPageState {
|
||||
nav: crate::ui::Nav::new(headers, "ecb").with_authorization(&authorization),
|
||||
verified_through_date: status.verified_through_date,
|
||||
latest_verifiable_date: status.latest_verifiable_date,
|
||||
healthy: status.healthy,
|
||||
days_behind: status.days_behind,
|
||||
import_running: status.import_running,
|
||||
next_import_at: status.next_import_at,
|
||||
covered_currencies: status.covered_currencies,
|
||||
batches: status
|
||||
.batches
|
||||
.into_iter()
|
||||
.map(|batch| ImportBatchView {
|
||||
batch_id: batch.batch_id,
|
||||
status: batch.status,
|
||||
requested_from: batch.requested_from,
|
||||
requested_through: batch.requested_through,
|
||||
endpoint: batch.endpoint,
|
||||
started_at: batch.started_at,
|
||||
completed_at: batch.completed_at,
|
||||
verified_through_date: batch.verified_through_date,
|
||||
observation_count: batch.observation_count,
|
||||
inserted_observation_count: batch.inserted_observation_count,
|
||||
error_message: batch.error_message,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
52
web/src/pages/admin/ecb/logic.rs
Normal file
52
web/src/pages/admin/ecb/logic.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! Two GETs and no writes. The pipeline is driven by a scheduler inside the
|
||||
//! server, so there is nothing here for a browser to start or stop — only to
|
||||
//! watch.
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{loader::load_ecb_page, state::LoadError, ui};
|
||||
|
||||
/// GET /admin/ecb
|
||||
pub(crate) async fn page(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
match load_ecb_page(state, &headers).await {
|
||||
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /admin/ecb/status — the status card alone.
|
||||
///
|
||||
/// Polled only while a batch is running; the card stops asking for itself once
|
||||
/// the batch it was watching has finished, because the fragment it swaps in
|
||||
/// carries no trigger.
|
||||
pub(crate) async fn status_fragment(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
match load_ecb_page(state, &headers).await {
|
||||
Ok(page) => Html(ui::render_status(&page)).into_response(),
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(error: LoadError) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
||||
LoadError::Forbidden => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Html(ui::render_error(
|
||||
"Reading exchange-rate data is required to open this page.",
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
LoadError::Backend(message) => {
|
||||
(StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
28
web/src/pages/admin/ecb/mod.rs
Normal file
28
web/src/pages/admin/ecb/mod.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! Health of the ECB reference-rate pipeline.
|
||||
//!
|
||||
//! Read-only, and deliberately not profile-scoped: one importer feeds every
|
||||
//! profile, so the question "are the rates current" has one answer for the
|
||||
//! whole deployment. That is also why it is its own page rather than a panel
|
||||
//! on a profile's — it is not about a profile at all.
|
||||
//!
|
||||
//! The page matters because coverage is a precondition, not a detail: a
|
||||
//! conversion whose publication date falls beyond verified coverage is refused
|
||||
//! outright by the backend. When posting starts failing for that reason, this
|
||||
//! is the screen that says so.
|
||||
|
||||
mod loader;
|
||||
mod logic;
|
||||
mod state;
|
||||
mod ui;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/ecb", get(logic::page))
|
||||
// The status card on its own, for the poll that keeps it live while a
|
||||
// batch is running.
|
||||
.route("/admin/ecb/status", get(logic::status_fragment))
|
||||
}
|
||||
117
web/src/pages/admin/ecb/state.rs
Normal file
117
web/src/pages/admin/ecb/state.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
//! 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.
|
||||
|
||||
/// One import attempt, as the audit log recorded it.
|
||||
pub(crate) struct ImportBatchView {
|
||||
pub batch_id: i64,
|
||||
pub status: String,
|
||||
pub requested_from: String,
|
||||
pub requested_through: String,
|
||||
pub endpoint: String,
|
||||
pub started_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
pub verified_through_date: Option<String>,
|
||||
pub observation_count: Option<i32>,
|
||||
pub inserted_observation_count: Option<i32>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl ImportBatchView {
|
||||
pub(crate) fn succeeded(&self) -> bool {
|
||||
self.status == "succeeded"
|
||||
}
|
||||
|
||||
pub(crate) fn running(&self) -> bool {
|
||||
self.status == "running"
|
||||
}
|
||||
|
||||
pub(crate) fn failed(&self) -> bool {
|
||||
self.status == "failed"
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
self.requested_from.clone()
|
||||
} else {
|
||||
format!("{} → {}", self.requested_from, self.requested_through)
|
||||
}
|
||||
}
|
||||
|
||||
/// New observations against those the response carried. They differ when a
|
||||
/// batch re-fetched days an earlier one already owned.
|
||||
pub(crate) fn observations(&self) -> String {
|
||||
match (self.inserted_observation_count, self.observation_count) {
|
||||
(Some(inserted), Some(total)) if inserted == total => inserted.to_string(),
|
||||
(Some(inserted), Some(total)) => format!("{inserted} new of {total}"),
|
||||
(None, Some(total)) => total.to_string(),
|
||||
_ => "—".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct EcbPageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub verified_through_date: Option<String>,
|
||||
pub latest_verifiable_date: String,
|
||||
pub healthy: bool,
|
||||
pub days_behind: i32,
|
||||
pub import_running: bool,
|
||||
pub next_import_at: String,
|
||||
pub batches: Vec<ImportBatchView>,
|
||||
pub covered_currencies: Vec<String>,
|
||||
}
|
||||
|
||||
impl EcbPageState {
|
||||
/// The headline. Three states, because "behind" and "never ran" need
|
||||
/// different things done about them.
|
||||
pub(crate) fn headline(&self) -> &'static str {
|
||||
if self.healthy {
|
||||
"Rates are current"
|
||||
} else if self.verified_through_date.is_none() {
|
||||
"No rates have ever been imported"
|
||||
} else {
|
||||
"Rates are behind"
|
||||
}
|
||||
}
|
||||
|
||||
/// What a reader should do about it, in one sentence.
|
||||
pub(crate) fn explanation(&self) -> String {
|
||||
if self.healthy {
|
||||
"Every publication day up to the latest one is verified, so conversions post normally.".to_string()
|
||||
} else if self.verified_through_date.is_none() {
|
||||
"Until one succeeds, every conversion in a foreign currency is refused for want of coverage.".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{} publication {} missing. A conversion dated inside the gap is refused until the importer catches up.",
|
||||
self.days_behind,
|
||||
if self.days_behind == 1 { "day is" } else { "days are" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The class the status card is painted with.
|
||||
pub(crate) fn health_class(&self) -> &'static str {
|
||||
if self.healthy { "healthy" } else { "unhealthy" }
|
||||
}
|
||||
|
||||
/// While a batch is running the card polls; otherwise it sits still. There
|
||||
/// is nothing to watch between the scheduled 17:00 runs.
|
||||
pub(crate) fn should_poll(&self) -> bool {
|
||||
self.import_running
|
||||
}
|
||||
|
||||
pub(crate) fn last_failure(&self) -> Option<&ImportBatchView> {
|
||||
self.batches.iter().find(|batch| batch.failed())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum LoadError {
|
||||
Unauthenticated,
|
||||
Forbidden,
|
||||
Backend(String),
|
||||
}
|
||||
177
web/src/pages/admin/ecb/ui.rs
Normal file
177
web/src/pages/admin/ecb/ui.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use askama::Template;
|
||||
|
||||
use crate::ui::{ErrorPage, Nav, render};
|
||||
|
||||
use super::state::EcbPageState;
|
||||
|
||||
/// GET /admin/ecb
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/admin/ecb/ecb.html")]
|
||||
struct EcbPage<'a> {
|
||||
nav: Nav,
|
||||
page: &'a EcbPageState,
|
||||
}
|
||||
|
||||
/// GET /admin/ecb/status — the card the page polls while a batch runs.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/admin/ecb/status.html")]
|
||||
struct StatusFragment<'a> {
|
||||
page: &'a EcbPageState,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(page: &EcbPageState) -> String {
|
||||
render(&EcbPage {
|
||||
nav: page.nav.clone(),
|
||||
page,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_status(page: &EcbPageState) -> String {
|
||||
render(&StatusFragment { page })
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(message: &str) -> String {
|
||||
render(&ErrorPage {
|
||||
nav: Nav::default(),
|
||||
heading: "Exchange rates unavailable",
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pages::admin::ecb::state::ImportBatchView;
|
||||
|
||||
fn batch(id: i64, status: &str) -> ImportBatchView {
|
||||
ImportBatchView {
|
||||
batch_id: id,
|
||||
status: status.to_string(),
|
||||
requested_from: "2026-08-10".to_string(),
|
||||
requested_through: "2026-08-12".to_string(),
|
||||
endpoint: "https://data.ecb.europa.eu/...".to_string(),
|
||||
started_at: "2026-08-12T17:00:00Z".to_string(),
|
||||
completed_at: Some("2026-08-12T17:00:04Z".to_string()),
|
||||
verified_through_date: Some("2026-08-12".to_string()),
|
||||
observation_count: Some(90),
|
||||
inserted_observation_count: Some(30),
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn healthy_page() -> EcbPageState {
|
||||
EcbPageState {
|
||||
nav: Nav::default(),
|
||||
verified_through_date: Some("2026-08-12".to_string()),
|
||||
latest_verifiable_date: "2026-08-12".to_string(),
|
||||
healthy: true,
|
||||
days_behind: 0,
|
||||
import_running: false,
|
||||
next_import_at: "2026-08-13T15:00:00Z".to_string(),
|
||||
batches: vec![batch(9, "succeeded")],
|
||||
covered_currencies: vec!["CZK".to_string(), "USD".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// The headline has to answer "can I post right now" without reading the
|
||||
/// table underneath it.
|
||||
#[test]
|
||||
fn a_current_pipeline_says_so_at_the_top() {
|
||||
let html = render_page(&healthy_page());
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("Rates are current"));
|
||||
assert!(html.contains("healthy"));
|
||||
assert!(html.contains("2026-08-12"));
|
||||
assert!(html.contains("CZK"));
|
||||
// Nothing is running, so the card does not ask for itself again.
|
||||
assert!(!html.contains("hx-trigger=\"every"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_behind_pipeline_counts_the_missing_publication_days() {
|
||||
let mut page = healthy_page();
|
||||
page.healthy = false;
|
||||
page.days_behind = 3;
|
||||
page.verified_through_date = Some("2026-08-07".to_string());
|
||||
|
||||
let html = render_page(&page);
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("Rates are behind"));
|
||||
assert!(html.contains("unhealthy"));
|
||||
assert!(html.contains("3 publication days are missing"));
|
||||
}
|
||||
|
||||
/// A pipeline that has never succeeded is a different problem from one
|
||||
/// that has fallen behind, and reads differently.
|
||||
#[test]
|
||||
fn a_pipeline_that_never_ran_says_that_instead() {
|
||||
let mut page = healthy_page();
|
||||
page.healthy = false;
|
||||
page.verified_through_date = None;
|
||||
page.covered_currencies.clear();
|
||||
page.batches.clear();
|
||||
|
||||
let html = render_page(&page);
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("No rates have ever been imported"));
|
||||
assert!(html.contains("every conversion in a foreign currency is refused"));
|
||||
}
|
||||
|
||||
/// While a batch runs the card watches itself, and the fragment it swaps
|
||||
/// in is what carries the next trigger.
|
||||
#[test]
|
||||
fn a_running_import_polls_until_it_finishes() {
|
||||
let mut page = healthy_page();
|
||||
page.import_running = true;
|
||||
page.batches.insert(0, batch(10, "running"));
|
||||
|
||||
let html = render_page(&page);
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("/admin/ecb/status"));
|
||||
assert!(html.contains("hx-trigger=\"every"));
|
||||
|
||||
// And the fragment alone carries it too, so the poll survives a swap.
|
||||
let fragment = render_status(&page);
|
||||
assert!(fragment.contains("hx-trigger=\"every"));
|
||||
|
||||
// Once it finishes, the swapped-in card stops asking.
|
||||
page.import_running = false;
|
||||
assert!(!render_status(&page).contains("hx-trigger=\"every"));
|
||||
}
|
||||
|
||||
/// A failed attempt has to surface its reason without expanding anything:
|
||||
/// it is the only thing on the page that says why coverage stopped.
|
||||
#[test]
|
||||
fn a_failure_shows_the_backend_message() {
|
||||
let mut page = healthy_page();
|
||||
page.healthy = false;
|
||||
page.batches = vec![ImportBatchView {
|
||||
status: "failed".to_string(),
|
||||
completed_at: None,
|
||||
verified_through_date: None,
|
||||
observation_count: None,
|
||||
inserted_observation_count: None,
|
||||
error_message: Some("the ECB endpoint returned 503".to_string()),
|
||||
..batch(11, "failed")
|
||||
}];
|
||||
|
||||
let html = render_page(&page);
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("the ECB endpoint returned 503"));
|
||||
}
|
||||
|
||||
/// Re-fetching a day an earlier batch already owns inserts nothing, and
|
||||
/// the count has to explain that rather than look like a lost import.
|
||||
#[test]
|
||||
fn partially_inserted_observations_are_explained() {
|
||||
let page = healthy_page();
|
||||
|
||||
let html = render_page(&page);
|
||||
|
||||
assert!(html.contains("30 new of 90"));
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub(crate) mod admin;
|
||||
pub(crate) mod ecb;
|
||||
pub(crate) mod table_definition;
|
||||
|
||||
@@ -48,6 +48,7 @@ mod tests {
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: true,
|
||||
active: "permissions",
|
||||
},
|
||||
tabs: Tabs {
|
||||
|
||||
@@ -37,6 +37,7 @@ mod tests {
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: true,
|
||||
active: "permissions",
|
||||
},
|
||||
tabs: Tabs {
|
||||
|
||||
@@ -37,6 +37,7 @@ mod tests {
|
||||
can_permissions: true,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: true,
|
||||
active: "permissions",
|
||||
},
|
||||
tabs: Tabs {
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) struct Nav {
|
||||
pub can_permissions: bool,
|
||||
pub can_import: bool,
|
||||
pub can_export: bool,
|
||||
pub can_ecb: bool,
|
||||
pub active: &'static str,
|
||||
}
|
||||
|
||||
@@ -34,6 +35,7 @@ impl Nav {
|
||||
can_permissions: false,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: false,
|
||||
active,
|
||||
}
|
||||
}
|
||||
@@ -54,6 +56,7 @@ impl Nav {
|
||||
self.can_export = authorization.permissions.iter().any(|permission| {
|
||||
permission.action == "read" && permission.object.starts_with("data:")
|
||||
});
|
||||
self.can_ecb = crate::authz::can_read_ecb(authorization);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -67,6 +70,7 @@ impl Default for Nav {
|
||||
can_permissions: false,
|
||||
can_import: false,
|
||||
can_export: false,
|
||||
can_ecb: false,
|
||||
active: "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +223,23 @@
|
||||
.tab.selected { border-color: #a9c7f6; background: #eaf2ff; }
|
||||
.tab.selected span { font-weight: 700; color: #1d4ed8; }
|
||||
|
||||
/* ---------- Exchange rates (pages/admin/ecb) ---------- */
|
||||
|
||||
/* The health card. Its colour is the answer to "can a foreign-currency
|
||||
amount be posted right now", so it is the one thing on the page that has
|
||||
to be readable without reading. */
|
||||
.status-card { border-left-width: 4px; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.link-action { color: #2563eb; text-decoration: none; }
|
||||
.notice { padding: 10px 12px; border: 1px solid #cfe0c4; border-radius: 8px; color: #21643a; background: #f2f9f3; }
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
{% if page.can_manage_validations %}<a href="/admin/validation/new">Add validation</a>{% endif %}
|
||||
{% if page.can_manage_validations %}<a href="/admin/validation/sets/new">Add rule</a>{% endif %}
|
||||
{% if page.can_export %}<a href="/admin/export">Export</a>{% endif %}
|
||||
{% if page.can_ecb %}<a href="/admin/ecb">Exchange rates</a>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<div id="admin-workspace">{% include "pages/admin/admin/workspace.html" %}</div>
|
||||
|
||||
80
web/templates/pages/admin/ecb/ecb.html
Normal file
80
web/templates/pages/admin/ecb/ecb.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{# GET /admin/ecb — crate::pages::admin::ecb::ui::EcbPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Exchange rates{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">Exchange rates</p>
|
||||
<h1>ECB reference rates</h1>
|
||||
<p>
|
||||
The importer fetches the ECB's published rates once a day and records every
|
||||
attempt. Conversions read only what it has verified, so coverage is what decides
|
||||
whether a foreign-currency amount can be posted at all.
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/admin">← Admin panel</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "pages/admin/ecb/status.html" %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Import attempts <span class="count">{{ page.batches.len() }}</span></h2>
|
||||
{% if page.batches.is_empty() %}
|
||||
<p class="hint">
|
||||
The importer has not recorded an attempt. It runs on startup and then daily, so
|
||||
an empty log means the server has not completed a run since this table was
|
||||
created.
|
||||
</p>
|
||||
{% else %}
|
||||
<div class="table-scroll">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
{%- if let Some(date) = batch.verified_through_date %}{{ date }}
|
||||
{%- else %}<span class="hint">—</span>{% endif -%}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Attempts are an audit record: they cannot be edited or deleted, and a batch that
|
||||
re-fetched a day an earlier one already owned inserts nothing, which is why the
|
||||
new count can be lower than the total.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
53
web/templates/pages/admin/ecb/status.html
Normal file
53
web/templates/pages/admin/ecb/status.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{#
|
||||
The health card — crate::pages::admin::ecb::ui::StatusFragment, and what
|
||||
ecb.html embeds on first load.
|
||||
|
||||
It carries its own poll trigger rather than the page carrying it, so the
|
||||
trigger travels with each swap: while a batch is running the card asks for
|
||||
itself every few seconds, and the card that comes back after the batch
|
||||
finishes has no trigger, which is what stops the polling.
|
||||
#}
|
||||
<section class="panel status-card {{ page.health_class() }}"
|
||||
{% if page.should_poll() %}hx-get="/admin/ecb/status" hx-trigger="every 3s" hx-target="#ecb-status" hx-swap="outerHTML"{% endif %}
|
||||
id="ecb-status">
|
||||
<h2>
|
||||
{{ page.headline() }}
|
||||
{% if page.import_running %}<span class="tag">import running</span>{% endif %}
|
||||
</h2>
|
||||
<p class="hint">{{ page.explanation() }}</p>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Should reach</dt>
|
||||
<dd>{{ page.latest_verifiable_date }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Next import</dt>
|
||||
<dd>{{ page.next_import_at }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Currencies on that day</dt>
|
||||
<dd>
|
||||
{%- if page.covered_currencies.is_empty() %}<span class="hint">none</span>
|
||||
{%- else %}{{ page.covered_currencies.len() }}{% endif -%}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{% if !page.covered_currencies.is_empty() %}
|
||||
<div class="currency-list">
|
||||
{% for currency in page.covered_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 }}:
|
||||
{% if let Some(message) = failure.error_message %}{{ message }}{% else %}no reason was recorded.{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
@@ -25,6 +25,7 @@
|
||||
{% if nav.can_admin %}<li><a href="/admin" class="{% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>{% endif %}
|
||||
{% if nav.can_permissions %}<li><a href="/permissions" class="{% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>Permissions</a></li>{% endif %}
|
||||
<li><a href="/" class="{% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
|
||||
{% if nav.can_ecb %}<li><a href="/admin/ecb" class="{% if nav.active == "ecb" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "ecb" %}aria-current="page"{% endif %}>Rates</a></li>{% endif %}
|
||||
{% if nav.can_import %}<li><a href="/admin/import" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Import</a></li>{% endif %}
|
||||
{% if nav.can_export %}<li><a href="/admin/export" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Export</a></li>{% endif %}
|
||||
{% if nav.authenticated %}
|
||||
@@ -49,6 +50,7 @@
|
||||
{% if nav.can_admin %}<li class="py-4"><a href="/admin" class="w-full text-lg {% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>{% endif %}
|
||||
{% if nav.can_permissions %}<li class="py-4"><a href="/permissions" class="w-full text-lg {% if nav.active == "permissions" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "permissions" %}aria-current="page"{% endif %}>Permissions</a></li>{% endif %}
|
||||
<li class="py-4"><a href="/" class="w-full text-lg {% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
|
||||
{% if nav.can_ecb %}<li class="py-4"><a href="/admin/ecb" class="w-full text-lg {% if nav.active == "ecb" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "ecb" %}aria-current="page"{% endif %}>Rates</a></li>{% endif %}
|
||||
{% if nav.can_import %}<li class="py-4"><a href="/admin/import" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Import</a></li>{% endif %}
|
||||
{% if nav.can_export %}<li class="py-4"><a href="/admin/export" class="w-full text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Export</a></li>{% endif %}
|
||||
{% if nav.authenticated %}
|
||||
|
||||
Reference in New Issue
Block a user