exporting ECB

This commit is contained in:
Priec
2026-08-13 09:11:41 +02:00
parent f8e483efa8
commit fe8ea680e3
24 changed files with 895 additions and 1 deletions

View File

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

View File

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

View File

@@ -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),
})
}

View File

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

View File

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

View 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(),
})
}

View 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()
}
}
}

View 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))
}

View 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),
}

View 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"));
}
}

View File

@@ -1,2 +1,3 @@
pub(crate) mod admin;
pub(crate) mod ecb;
pub(crate) mod table_definition;

View File

@@ -48,6 +48,7 @@ mod tests {
can_permissions: true,
can_import: false,
can_export: false,
can_ecb: true,
active: "permissions",
},
tabs: Tabs {

View File

@@ -37,6 +37,7 @@ mod tests {
can_permissions: true,
can_import: false,
can_export: false,
can_ecb: true,
active: "permissions",
},
tabs: Tabs {

View File

@@ -37,6 +37,7 @@ mod tests {
can_permissions: true,
can_import: false,
can_export: false,
can_ecb: true,
active: "permissions",
},
tabs: Tabs {

View File

@@ -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: "",
}
}