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

@@ -16,6 +16,70 @@ service EcbService {
// List immutable ECB conversion evidence for one money value. // List immutable ECB conversion evidence for one money value.
rpc ListEcbConversionEvidence(ListEcbConversionEvidenceRequest) rpc ListEcbConversionEvidence(ListEcbConversionEvidenceRequest)
returns (ListEcbConversionEvidenceResponse); returns (ListEcbConversionEvidenceResponse);
// Health of the reference-rate import pipeline: how far verified coverage
// reaches, how far it should reach by now, and how the recent import
// attempts went. Not profile-scoped -- one pipeline feeds every profile.
rpc GetEcbPipelineStatus(GetEcbPipelineStatusRequest)
returns (GetEcbPipelineStatusResponse);
}
message GetEcbPipelineStatusRequest {
// Optional. How many recent import attempts to return. Zero uses 20; the
// maximum is 100.
int32 batch_limit = 1;
}
// One row of the append-only import audit log.
message EcbImportBatch {
int64 batch_id = 1;
// "running", "succeeded" or "failed".
string status = 2;
string requested_from = 3;
string requested_through = 4;
string endpoint = 5;
string started_at = 6;
optional string completed_at = 7;
// Present on a batch that advanced coverage.
optional string verified_through_date = 8;
optional int32 observation_count = 9;
// May be lower than observation_count: an observation already recorded by an
// earlier batch is kept rather than replaced.
optional int32 inserted_observation_count = 10;
optional string error_message = 11;
}
message GetEcbPipelineStatusResponse {
// Latest date a successful batch verified. Absent when nothing has ever
// succeeded, which is what a pipeline that has never run looks like.
optional string verified_through_date = 1;
// The date coverage should have reached by now, derived from the same
// publication rule the importer schedules against. Comparing the two is what
// "healthy" means.
string latest_verifiable_date = 2;
// True when verified coverage has reached latest_verifiable_date. A
// conversion whose publication date falls beyond coverage is refused, so
// this answers "will posting work right now".
bool healthy = 3;
// Publication days between coverage and latest_verifiable_date. Zero when
// healthy.
int32 days_behind = 4;
// True while a batch holds the single-running lock.
bool import_running = 5;
// When the scheduler next wakes, from the same cutoff the importer uses.
string next_import_at = 6;
// Newest first, running and failed attempts included.
repeated EcbImportBatch batches = 7;
// Distinct currencies observed on verified_through_date, so a coverage gap
// for one currency is visible even when the date itself is covered.
repeated string covered_currencies = 8;
} }
// Conversion basis rule used to select an ECB publication. // Conversion basis rule used to select an ECB publication.

Binary file not shown.

View File

@@ -1,4 +1,75 @@
// This file is @generated by prost-build. // This file is @generated by prost-build.
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct GetEcbPipelineStatusRequest {
/// Optional. How many recent import attempts to return. Zero uses 20; the
/// maximum is 100.
#[prost(int32, tag = "1")]
pub batch_limit: i32,
}
/// One row of the append-only import audit log.
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct EcbImportBatch {
#[prost(int64, tag = "1")]
pub batch_id: i64,
/// "running", "succeeded" or "failed".
#[prost(string, tag = "2")]
pub status: ::prost::alloc::string::String,
#[prost(string, tag = "3")]
pub requested_from: ::prost::alloc::string::String,
#[prost(string, tag = "4")]
pub requested_through: ::prost::alloc::string::String,
#[prost(string, tag = "5")]
pub endpoint: ::prost::alloc::string::String,
#[prost(string, tag = "6")]
pub started_at: ::prost::alloc::string::String,
#[prost(string, optional, tag = "7")]
pub completed_at: ::core::option::Option<::prost::alloc::string::String>,
/// Present on a batch that advanced coverage.
#[prost(string, optional, tag = "8")]
pub verified_through_date: ::core::option::Option<::prost::alloc::string::String>,
#[prost(int32, optional, tag = "9")]
pub observation_count: ::core::option::Option<i32>,
/// May be lower than observation_count: an observation already recorded by an
/// earlier batch is kept rather than replaced.
#[prost(int32, optional, tag = "10")]
pub inserted_observation_count: ::core::option::Option<i32>,
#[prost(string, optional, tag = "11")]
pub error_message: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct GetEcbPipelineStatusResponse {
/// Latest date a successful batch verified. Absent when nothing has ever
/// succeeded, which is what a pipeline that has never run looks like.
#[prost(string, optional, tag = "1")]
pub verified_through_date: ::core::option::Option<::prost::alloc::string::String>,
/// The date coverage should have reached by now, derived from the same
/// publication rule the importer schedules against. Comparing the two is what
/// "healthy" means.
#[prost(string, tag = "2")]
pub latest_verifiable_date: ::prost::alloc::string::String,
/// True when verified coverage has reached latest_verifiable_date. A
/// conversion whose publication date falls beyond coverage is refused, so
/// this answers "will posting work right now".
#[prost(bool, tag = "3")]
pub healthy: bool,
/// Publication days between coverage and latest_verifiable_date. Zero when
/// healthy.
#[prost(int32, tag = "4")]
pub days_behind: i32,
/// True while a batch holds the single-running lock.
#[prost(bool, tag = "5")]
pub import_running: bool,
/// When the scheduler next wakes, from the same cutoff the importer uses.
#[prost(string, tag = "6")]
pub next_import_at: ::prost::alloc::string::String,
/// Newest first, running and failed attempts included.
#[prost(message, repeated, tag = "7")]
pub batches: ::prost::alloc::vec::Vec<EcbImportBatch>,
/// Distinct currencies observed on verified_through_date, so a coverage gap
/// for one currency is visible even when the date itself is covered.
#[prost(string, repeated, tag = "8")]
pub covered_currencies: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
}
/// Exact inputs for a conversion preview. Decimal values are strings so the /// Exact inputs for a conversion preview. Decimal values are strings so the
/// client never loses precision through binary floating point. /// client never loses precision through binary floating point.
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
@@ -349,6 +420,35 @@ pub mod ecb_service_client {
); );
self.inner.unary(req, path, codec).await self.inner.unary(req, path, codec).await
} }
/// Health of the reference-rate import pipeline: how far verified coverage
/// reaches, how far it should reach by now, and how the recent import
/// attempts went. Not profile-scoped -- one pipeline feeds every profile.
pub async fn get_ecb_pipeline_status(
&mut self,
request: impl tonic::IntoRequest<super::GetEcbPipelineStatusRequest>,
) -> std::result::Result<
tonic::Response<super::GetEcbPipelineStatusResponse>,
tonic::Status,
> {
self.inner
.ready()
.await
.map_err(|e| {
tonic::Status::unknown(
format!("Service was not ready: {}", e.into()),
)
})?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static(
"/komp_ac.ecb.EcbService/GetEcbPipelineStatus",
);
let mut req = request.into_request();
req.extensions_mut()
.insert(
GrpcMethod::new("komp_ac.ecb.EcbService", "GetEcbPipelineStatus"),
);
self.inner.unary(req, path, codec).await
}
} }
} }
/// Generated server implementations. /// Generated server implementations.
@@ -381,6 +481,16 @@ pub mod ecb_service_server {
tonic::Response<super::ListEcbConversionEvidenceResponse>, tonic::Response<super::ListEcbConversionEvidenceResponse>,
tonic::Status, tonic::Status,
>; >;
/// Health of the reference-rate import pipeline: how far verified coverage
/// reaches, how far it should reach by now, and how the recent import
/// attempts went. Not profile-scoped -- one pipeline feeds every profile.
async fn get_ecb_pipeline_status(
&self,
request: tonic::Request<super::GetEcbPipelineStatusRequest>,
) -> std::result::Result<
tonic::Response<super::GetEcbPipelineStatusResponse>,
tonic::Status,
>;
} }
/// Read-only access to ECB conversion previews and audit evidence. /// Read-only access to ECB conversion previews and audit evidence.
/// ///
@@ -561,6 +671,52 @@ pub mod ecb_service_server {
}; };
Box::pin(fut) Box::pin(fut)
} }
"/komp_ac.ecb.EcbService/GetEcbPipelineStatus" => {
#[allow(non_camel_case_types)]
struct GetEcbPipelineStatusSvc<T: EcbService>(pub Arc<T>);
impl<
T: EcbService,
> tonic::server::UnaryService<super::GetEcbPipelineStatusRequest>
for GetEcbPipelineStatusSvc<T> {
type Response = super::GetEcbPipelineStatusResponse;
type Future = BoxFuture<
tonic::Response<Self::Response>,
tonic::Status,
>;
fn call(
&mut self,
request: tonic::Request<super::GetEcbPipelineStatusRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as EcbService>::get_ecb_pipeline_status(&inner, request)
.await
};
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = GetEcbPipelineStatusSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(
accept_compression_encodings,
send_compression_encodings,
)
.apply_max_message_size_config(
max_decoding_message_size,
max_encoding_message_size,
);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
_ => { _ => {
Box::pin(async move { Box::pin(async move {
let mut response = http::Response::new( let mut response = http::Response::new(

2
server

Submodule server updated: 0b0cb105be...59acd7d8d3

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_ROLE: &str = "struct:role";
pub(crate) const STRUCT_USER: &str = "struct:user"; pub(crate) const STRUCT_USER: &str = "struct:user";
pub(crate) const MANAGE: &str = "manage"; 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 { pub(crate) fn permits(snapshot: &AuthorizationSnapshot, object: &str, action: &str) -> bool {
permissions_permit(&snapshot.permissions, object, action) permissions_permit(&snapshot.permissions, object, action)
@@ -26,6 +32,12 @@ pub(crate) fn can_manage(snapshot: &AuthorizationSnapshot, area: &str) -> bool {
permits(snapshot, area, MANAGE) 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 { pub(crate) fn can_open_admin(snapshot: &AuthorizationSnapshot) -> bool {
[ [
STRUCT_PROFILE, 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 auth::auth_service_client::AuthServiceClient;
use definitions::{ use definitions::{
table_definition::table_definition_client::TableDefinitionClient, table_definition::table_definition_client::TableDefinitionClient,
@@ -81,6 +88,7 @@ use definitions::{
use tonic::transport::Channel; use tonic::transport::Channel;
use analytics::analytics_service_client::AnalyticsServiceClient; use analytics::analytics_service_client::AnalyticsServiceClient;
use ecb::ecb_service_client::EcbServiceClient;
const APP_CSS: &str = include_str!("../static/app.css"); const APP_CSS: &str = include_str!("../static/app.css");
@@ -95,6 +103,7 @@ pub(crate) struct AppState {
structures: TableStructureServiceClient<Channel>, structures: TableStructureServiceClient<Channel>,
validations: TableValidationServiceClient<Channel>, validations: TableValidationServiceClient<Channel>,
tables_data: TablesDataClient<Channel>, tables_data: TablesDataClient<Channel>,
ecb: EcbServiceClient<Channel>,
} }
/// Starts the web UI as a detached task on the current Tokio runtime. /// 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()), scripts: TableScriptClient::new(channel.clone()),
validations: TableValidationServiceClient::new(channel.clone()), validations: TableValidationServiceClient::new(channel.clone()),
tables_data: TablesDataClient::new(channel.clone()), tables_data: TablesDataClient::new(channel.clone()),
ecb: EcbServiceClient::new(channel.clone()),
structures: TableStructureServiceClient::new(channel), structures: TableStructureServiceClient::new(channel),
}; };
@@ -147,6 +157,7 @@ fn router(state: AppState) -> Router {
.merge(pages::admin::admin::router()) .merge(pages::admin::admin::router())
.merge(pages::permissions::router()) .merge(pages::permissions::router())
.merge(pages::admin::table_definition::router()) .merge(pages::admin::table_definition::router())
.merge(pages::admin::ecb::router())
.merge(pages::add_table::router()) .merge(pages::add_table::router())
.merge(pages::add_logic::router()) .merge(pages::add_logic::router())
.merge(pages::add_validation::router()) .merge(pages::add_validation::router())
@@ -189,6 +200,7 @@ mod tests {
scripts: TableScriptClient::new(channel.clone()), scripts: TableScriptClient::new(channel.clone()),
validations: TableValidationServiceClient::new(channel.clone()), validations: TableValidationServiceClient::new(channel.clone()),
tables_data: TablesDataClient::new(channel.clone()), tables_data: TablesDataClient::new(channel.clone()),
ecb: EcbServiceClient::new(channel.clone()),
structures: TableStructureServiceClient::new(channel), 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 /// 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 /// 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 /// 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| { can_export: authorization.permissions.iter().any(|permission| {
permission.action == "read" && permission.object.starts_with("data:") 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_scripts: bool,
pub can_manage_validations: bool, pub can_manage_validations: bool,
pub can_export: 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 { impl AdminPageState {

View File

@@ -53,6 +53,7 @@ mod tests {
can_permissions: true, can_permissions: true,
can_import: false, can_import: false,
can_export: false, can_export: false,
can_ecb: false,
active: "admin", active: "admin",
}; };
let page = AdminPageState { let page = AdminPageState {
@@ -66,6 +67,7 @@ mod tests {
can_manage_scripts: true, can_manage_scripts: true,
can_manage_validations: true, can_manage_validations: true,
can_export: true, can_export: true,
can_ecb: true,
}; };
let html = render_page(&page); let html = render_page(&page);
for route in [ for route in [
@@ -108,6 +110,7 @@ mod tests {
can_manage_scripts: true, can_manage_scripts: true,
can_manage_validations: true, can_manage_validations: true,
can_export: true, can_export: true,
can_ecb: true,
}; };
let html = render_workspace(&page); let html = render_workspace(&page);
@@ -142,6 +145,7 @@ mod tests {
can_manage_scripts: true, can_manage_scripts: true,
can_manage_validations: true, can_manage_validations: true,
can_export: true, can_export: true,
can_ecb: true,
}; };
let html = render_workspace(&page); let html = render_workspace(&page);
@@ -181,6 +185,7 @@ mod tests {
can_manage_scripts: true, can_manage_scripts: true,
can_manage_validations: true, can_manage_validations: true,
can_export: true, can_export: true,
can_ecb: true,
}; };
let html = render_workspace(&page); 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 admin;
pub(crate) mod ecb;
pub(crate) mod table_definition; pub(crate) mod table_definition;

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ pub(crate) struct Nav {
pub can_permissions: bool, pub can_permissions: bool,
pub can_import: bool, pub can_import: bool,
pub can_export: bool, pub can_export: bool,
pub can_ecb: bool,
pub active: &'static str, pub active: &'static str,
} }
@@ -34,6 +35,7 @@ impl Nav {
can_permissions: false, can_permissions: false,
can_import: false, can_import: false,
can_export: false, can_export: false,
can_ecb: false,
active, active,
} }
} }
@@ -54,6 +56,7 @@ impl Nav {
self.can_export = authorization.permissions.iter().any(|permission| { self.can_export = authorization.permissions.iter().any(|permission| {
permission.action == "read" && permission.object.starts_with("data:") permission.action == "read" && permission.object.starts_with("data:")
}); });
self.can_ecb = crate::authz::can_read_ecb(authorization);
self self
} }
} }
@@ -67,6 +70,7 @@ impl Default for Nav {
can_permissions: false, can_permissions: false,
can_import: false, can_import: false,
can_export: false, can_export: false,
can_ecb: false,
active: "", active: "",
} }
} }

View File

@@ -223,6 +223,23 @@
.tab.selected { border-color: #a9c7f6; background: #eaf2ff; } .tab.selected { border-color: #a9c7f6; background: #eaf2ff; }
.tab.selected span { font-weight: 700; color: #1d4ed8; } .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; } .table-scroll { overflow-x: auto; }
.link-action { color: #2563eb; text-decoration: none; } .link-action { color: #2563eb; text-decoration: none; }
.notice { padding: 10px 12px; border: 1px solid #cfe0c4; border-radius: 8px; color: #21643a; background: #f2f9f3; } .notice { padding: 10px 12px; border: 1px solid #cfe0c4; border-radius: 8px; color: #21643a; background: #f2f9f3; }

View File

@@ -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/new">Add validation</a>{% endif %}
{% if page.can_manage_validations %}<a href="/admin/validation/sets/new">Add rule</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_export %}<a href="/admin/export">Export</a>{% endif %}
{% if page.can_ecb %}<a href="/admin/ecb">Exchange rates</a>{% endif %}
</div> </div>
</section> </section>
<div id="admin-workspace">{% include "pages/admin/admin/workspace.html" %}</div> <div id="admin-workspace">{% include "pages/admin/admin/workspace.html" %}</div>

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

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

View File

@@ -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_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 %} {% 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> <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_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.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 %} {% 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_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 %} {% 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> <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_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.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 %} {% if nav.authenticated %}