From fe8ea680e32f6070392234cfd4c95288c5a160f6 Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 13 Aug 2026 09:11:41 +0200 Subject: [PATCH] exporting ECB --- common/proto/ecb.proto | 64 ++++++++ common/src/proto/descriptor.bin | Bin 167822 -> 171962 bytes common/src/proto/komp_ac.ecb.rs | 156 ++++++++++++++++++ server | 2 +- web/src/authz.rs | 12 ++ web/src/lib.rs | 27 ++++ web/src/pages/admin/admin/loader.rs | 1 + web/src/pages/admin/admin/state.rs | 4 + web/src/pages/admin/admin/ui.rs | 5 + web/src/pages/admin/ecb/loader.rs | 90 +++++++++++ web/src/pages/admin/ecb/logic.rs | 52 ++++++ web/src/pages/admin/ecb/mod.rs | 28 ++++ web/src/pages/admin/ecb/state.rs | 117 ++++++++++++++ web/src/pages/admin/ecb/ui.rs | 177 +++++++++++++++++++++ web/src/pages/admin/mod.rs | 1 + web/src/pages/permissions/grants/ui.rs | 1 + web/src/pages/permissions/roles/ui.rs | 1 + web/src/pages/permissions/users/ui.rs | 1 + web/src/ui/mod.rs | 4 + web/static/app.css | 17 ++ web/templates/pages/admin/admin/admin.html | 1 + web/templates/pages/admin/ecb/ecb.html | 80 ++++++++++ web/templates/pages/admin/ecb/status.html | 53 ++++++ web/templates/ui/navbar.html | 2 + 24 files changed, 895 insertions(+), 1 deletion(-) create mode 100644 web/src/pages/admin/ecb/loader.rs create mode 100644 web/src/pages/admin/ecb/logic.rs create mode 100644 web/src/pages/admin/ecb/mod.rs create mode 100644 web/src/pages/admin/ecb/state.rs create mode 100644 web/src/pages/admin/ecb/ui.rs create mode 100644 web/templates/pages/admin/ecb/ecb.html create mode 100644 web/templates/pages/admin/ecb/status.html diff --git a/common/proto/ecb.proto b/common/proto/ecb.proto index a3f81b42..717f4262 100644 --- a/common/proto/ecb.proto +++ b/common/proto/ecb.proto @@ -16,6 +16,70 @@ service EcbService { // List immutable ECB conversion evidence for one money value. rpc ListEcbConversionEvidence(ListEcbConversionEvidenceRequest) 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. diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 2505535c28a1c7e8d7aabf8809dba531754268cf..921214d28fa74fbbcbf6681d3d5f6c1e7929d0e1 100644 GIT binary patch delta 8095 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 delta 3894 zcmYM0+iM-y6~@o(Jx4lc&e7$JEbEe3(y=YcQXI*yEy=Pa>jt(Yw-_}7KL}KmV1sSR zx?fB|6E^{Yi`~-cLkS`2KhRJJr4Z64eJFMkXhH;iFXXLHCFHTt-kuG_OdOcHYY`JhzHzHciMO`v%Y&GXIC2NBhg3IaIHneT# zHC?mpY%@KXVrN@lfACu`Fg}HM$^J53=|Z0szy zW{RUaUwKt+EOnVqUGlJXIh#CKSFt@?!`5}GLv55aS~UZ@W@iO4$&%LBfR8PG4*H16zjODZ*cbYtNPq9N8R`yj-2ft5UerWsSsn6cW<>TsiE+1Ea zu~}U{uKw1x;5c=$b1<&|{A*l!`PoQT+!Ep!gfVp3rcqKTIBM0hW?J4GfI>W zkD5Z1NqnRFV%Ujqloylw*m>2Q){WBRtFB7v@zr8_Aw45KzS>n)8+BcCy{y4@&DmL zkOGkL3=)|Fl<_eAIM@nh{B-)cd8QcJ>!x21C_=gJC`BmOOO4qYkn5FXj&NQP$o1Yn zEhPx;ggK`h10WL)667OfR*MOsOoUmd11J;Sy-F#Cb}}6u98iKX=_n;inRJj6kjb!H z&np3$JlC&~a%iVauP&8=Oht}ThBDTHiv>Owo6&Miu*gEH+PbwH+F zSL%RFSF%@G2W0w8w?gVeJ3}cqWt4fQ9?FcP)I*tZrPM>2ai!ElnW2=I^7VVBA+)n* zxc!s3@te{3Pk(1F4j#7a#UrC23HYoGw2egzhw71-~ z34z>lkPyf%*Pjr`t?bq`F;kFSn&r~JT{ssE(>f!wZXy2S z#lU5084~D%exWF~F6hWb!CYYEP9#Q_7URK}BWjd+x9E&A?-q4#nPlEAGPed2BemU$ zKX}o4bjBK?;_?-ouaFW5@|@9FSIK)k1gq1bq@ zkPd$o#CtUHCWTpP)BSk$tBK^MBQf50M(O_jLfQ(}Fy5yXGRCc>%>$js2y73ugD7kd zG}}A|7hCA+SA6Z4pR$pobc6DA}svW+iP_-Q!8$SKV>a_f@?kY{a&z*=D7# zHJ$#5M5VuQP}tUr>Abfll18uTz_;?_zZw>VO!U1vxazyHe53$Q8t`U0%gNBQvzkfHIqQuH0e~eMgnD1?-UBirhe2Y zAe)+PC?K01UHT#klr7VpCbu<`C|iyaK-qE>36w3(Hg-bUI-UO1v4%j|)@(xn*>(^K zlx@A4L56ukpowX#K?i8NQ@k~QY1zWTqzPG2d)%S^O8!A=z=LS%{JI4>b#| zY2uD+79xOTzZFnGA_r*#5@{B)1|%X2%aVoGoCy85J;Jb#kU{y03^JJ)2}oD@UH-O5 z9;&n=3B+>pSk0mT#N)1|i_MUb8L1i46OX>?Dx@c)9`{W(s3#s5uFO>S!SIPDtLam+3g)TDkMuhUhRA*vuO7eAP3m#{v;QB@kVe)vD2Bo8 RKYQo9<3E3Q?EUkr{{et*IB@^~ diff --git a/common/src/proto/komp_ac.ecb.rs b/common/src/proto/komp_ac.ecb.rs index a46977ab..e8e8d4a4 100644 --- a/common/src/proto/komp_ac.ecb.rs +++ b/common/src/proto/komp_ac.ecb.rs @@ -1,4 +1,75 @@ // 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, + /// 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, + #[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, + /// 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 /// client never loses precision through binary floating point. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -349,6 +420,35 @@ pub mod ecb_service_client { ); 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, + ) -> std::result::Result< + tonic::Response, + 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. @@ -381,6 +481,16 @@ pub mod ecb_service_server { tonic::Response, 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, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } /// Read-only access to ECB conversion previews and audit evidence. /// @@ -561,6 +671,52 @@ pub mod ecb_service_server { }; Box::pin(fut) } + "/komp_ac.ecb.EcbService/GetEcbPipelineStatus" => { + #[allow(non_camel_case_types)] + struct GetEcbPipelineStatusSvc(pub Arc); + impl< + T: EcbService, + > tonic::server::UnaryService + for GetEcbPipelineStatusSvc { + type Response = super::GetEcbPipelineStatusResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::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 { let mut response = http::Response::new( diff --git a/server b/server index 0b0cb105..59acd7d8 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 0b0cb105beb11171f541d612a8a5f5950b1a43bc +Subproject commit 59acd7d8d389b57c4af69dd0428e66edfd8e9ac8 diff --git a/web/src/authz.rs b/web/src/authz.rs index 6ce7e132..6eea912c 100644 --- a/web/src/authz.rs +++ b/web/src/authz.rs @@ -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, diff --git a/web/src/lib.rs b/web/src/lib.rs index f7382226..aa54fd25 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -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, validations: TableValidationServiceClient, tables_data: TablesDataClient, + ecb: EcbServiceClient, } /// Starts the web UI as a detached task on the current Tokio runtime. @@ -126,6 +135,7 @@ pub async fn serve() -> Result<(), Box> { 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 diff --git a/web/src/pages/admin/admin/loader.rs b/web/src/pages/admin/admin/loader.rs index 32255067..4fc7da0c 100644 --- a/web/src/pages/admin/admin/loader.rs +++ b/web/src/pages/admin/admin/loader.rs @@ -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), }) } diff --git a/web/src/pages/admin/admin/state.rs b/web/src/pages/admin/admin/state.rs index 0f0d81f9..1d7b51af 100644 --- a/web/src/pages/admin/admin/state.rs +++ b/web/src/pages/admin/admin/state.rs @@ -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 { diff --git a/web/src/pages/admin/admin/ui.rs b/web/src/pages/admin/admin/ui.rs index 89cbb95a..67ce70fd 100644 --- a/web/src/pages/admin/admin/ui.rs +++ b/web/src/pages/admin/admin/ui.rs @@ -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); diff --git a/web/src/pages/admin/ecb/loader.rs b/web/src/pages/admin/ecb/loader.rs new file mode 100644 index 00000000..14ade665 --- /dev/null +++ b/web/src/pages/admin/ecb/loader.rs @@ -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 { + 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(), + }) +} diff --git a/web/src/pages/admin/ecb/logic.rs b/web/src/pages/admin/ecb/logic.rs new file mode 100644 index 00000000..f4e94a1a --- /dev/null +++ b/web/src/pages/admin/ecb/logic.rs @@ -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, 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, + 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() + } + } +} diff --git a/web/src/pages/admin/ecb/mod.rs b/web/src/pages/admin/ecb/mod.rs new file mode 100644 index 00000000..5ac8fab9 --- /dev/null +++ b/web/src/pages/admin/ecb/mod.rs @@ -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 { + 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)) +} diff --git a/web/src/pages/admin/ecb/state.rs b/web/src/pages/admin/ecb/state.rs new file mode 100644 index 00000000..46b00b1a --- /dev/null +++ b/web/src/pages/admin/ecb/state.rs @@ -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, + pub verified_through_date: Option, + pub observation_count: Option, + pub inserted_observation_count: Option, + pub error_message: Option, +} + +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, + pub latest_verifiable_date: String, + pub healthy: bool, + pub days_behind: i32, + pub import_running: bool, + pub next_import_at: String, + pub batches: Vec, + pub covered_currencies: Vec, +} + +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), +} diff --git a/web/src/pages/admin/ecb/ui.rs b/web/src/pages/admin/ecb/ui.rs new file mode 100644 index 00000000..e2f548e0 --- /dev/null +++ b/web/src/pages/admin/ecb/ui.rs @@ -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")); + } +} diff --git a/web/src/pages/admin/mod.rs b/web/src/pages/admin/mod.rs index e0097f09..06b1414e 100644 --- a/web/src/pages/admin/mod.rs +++ b/web/src/pages/admin/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod admin; +pub(crate) mod ecb; pub(crate) mod table_definition; diff --git a/web/src/pages/permissions/grants/ui.rs b/web/src/pages/permissions/grants/ui.rs index 223d528b..3472e072 100644 --- a/web/src/pages/permissions/grants/ui.rs +++ b/web/src/pages/permissions/grants/ui.rs @@ -48,6 +48,7 @@ mod tests { can_permissions: true, can_import: false, can_export: false, + can_ecb: true, active: "permissions", }, tabs: Tabs { diff --git a/web/src/pages/permissions/roles/ui.rs b/web/src/pages/permissions/roles/ui.rs index 3c32ec02..00059a43 100644 --- a/web/src/pages/permissions/roles/ui.rs +++ b/web/src/pages/permissions/roles/ui.rs @@ -37,6 +37,7 @@ mod tests { can_permissions: true, can_import: false, can_export: false, + can_ecb: true, active: "permissions", }, tabs: Tabs { diff --git a/web/src/pages/permissions/users/ui.rs b/web/src/pages/permissions/users/ui.rs index 7f531265..d253df95 100644 --- a/web/src/pages/permissions/users/ui.rs +++ b/web/src/pages/permissions/users/ui.rs @@ -37,6 +37,7 @@ mod tests { can_permissions: true, can_import: false, can_export: false, + can_ecb: true, active: "permissions", }, tabs: Tabs { diff --git a/web/src/ui/mod.rs b/web/src/ui/mod.rs index 0d12ff6d..b33570c2 100644 --- a/web/src/ui/mod.rs +++ b/web/src/ui/mod.rs @@ -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: "", } } diff --git a/web/static/app.css b/web/static/app.css index 8f5daad4..80a22913 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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; } diff --git a/web/templates/pages/admin/admin/admin.html b/web/templates/pages/admin/admin/admin.html index 4c74e4e5..48cc0c40 100644 --- a/web/templates/pages/admin/admin/admin.html +++ b/web/templates/pages/admin/admin/admin.html @@ -17,6 +17,7 @@ {% if page.can_manage_validations %}Add validation{% endif %} {% if page.can_manage_validations %}Add rule{% endif %} {% if page.can_export %}Export{% endif %} + {% if page.can_ecb %}Exchange rates{% endif %}
{% include "pages/admin/admin/workspace.html" %}
diff --git a/web/templates/pages/admin/ecb/ecb.html b/web/templates/pages/admin/ecb/ecb.html new file mode 100644 index 00000000..b0875d51 --- /dev/null +++ b/web/templates/pages/admin/ecb/ecb.html @@ -0,0 +1,80 @@ +{# GET /admin/ecb — crate::pages::admin::ecb::ui::EcbPage #} +{% extends "ui/base.html" %} + +{% block title %}Exchange rates{% endblock %} + +{% block content %} +
+
+
+

Exchange rates

+

ECB reference rates

+

+ 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. +

+
+ +
+ + {% include "pages/admin/ecb/status.html" %} + +
+

Import attempts {{ page.batches.len() }}

+ {% if page.batches.is_empty() %} +

+ 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. +

+ {% else %} +
+ + + + + + + + + {% for batch in page.batches %} + + + + + + + + + {% endfor %} + +
StartedFinishedWindowResultObservationsVerified through
{{ batch.started_at }} + {%- if let Some(finished) = batch.completed_at %}{{ finished }} + {%- else %}still running{% endif -%} + + {{ batch.window() }} +
{{ batch.endpoint }}
+
+ {%- if batch.succeeded() %}succeeded + {%- else if batch.running() %}running + {%- else %}failed{% endif -%} + {%- if let Some(message) = batch.error_message %} +
{{ message }}
+ {% endif -%} +
{{ batch.observations() }} + {%- if let Some(date) = batch.verified_through_date %}{{ date }} + {%- else %}{% endif -%} +
+
+

+ 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. +

+ {% endif %} +
+
+{% endblock %} diff --git a/web/templates/pages/admin/ecb/status.html b/web/templates/pages/admin/ecb/status.html new file mode 100644 index 00000000..db1d7654 --- /dev/null +++ b/web/templates/pages/admin/ecb/status.html @@ -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. +#} +
+

+ {{ page.headline() }} + {% if page.import_running %}import running{% endif %} +

+

{{ page.explanation() }}

+ +
+
+
Verified through
+
{% if let Some(date) = page.verified_through_date %}{{ date }}{% else %}never{% endif %}
+
+
+
Should reach
+
{{ page.latest_verifiable_date }}
+
+
+
Next import
+
{{ page.next_import_at }}
+
+
+
Currencies on that day
+
+ {%- if page.covered_currencies.is_empty() %}none + {%- else %}{{ page.covered_currencies.len() }}{% endif -%} +
+
+
+ + {% if !page.covered_currencies.is_empty() %} +
+ {% for currency in page.covered_currencies %}{{ currency }}{% endfor %} +
+ {% endif %} + + {% if let Some(failure) = page.last_failure() %} +

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

+ {% endif %} +
diff --git a/web/templates/ui/navbar.html b/web/templates/ui/navbar.html index 94cfda87..bd1494a0 100644 --- a/web/templates/ui/navbar.html +++ b/web/templates/ui/navbar.html @@ -25,6 +25,7 @@ {% if nav.can_admin %}
  • Admin
  • {% endif %} {% if nav.can_permissions %}
  • Permissions
  • {% endif %}
  • Analytics
  • + {% if nav.can_ecb %}
  • Rates
  • {% endif %} {% if nav.can_import %}
  • Import
  • {% endif %} {% if nav.can_export %}
  • Export
  • {% endif %} {% if nav.authenticated %} @@ -49,6 +50,7 @@ {% if nav.can_admin %}
  • Admin
  • {% endif %} {% if nav.can_permissions %}
  • Permissions
  • {% endif %}
  • Analytics
  • + {% if nav.can_ecb %}
  • Rates
  • {% endif %} {% if nav.can_import %}
  • Import
  • {% endif %} {% if nav.can_export %}
  • Export
  • {% endif %} {% if nav.authenticated %}