From f1b10e1ea9ca295519eae11a633340bcf45d1f0e Mon Sep 17 00:00:00 2001 From: Filipriec Date: Wed, 16 Sep 2026 19:13:11 +0200 Subject: [PATCH] better charts2 --- client-gui2 | 2 +- common/build.rs | 1 + common/proto/analytics.proto | 13 +++ common/src/proto/descriptor.bin | Bin 309925 -> 310804 bytes common/src/proto/komp_ac.analytics.rs | 92 ++++++++++++++++++ common/src/reporting.rs | 131 +++++++++++++++++++++++--- komp-app/src/grpc/reporting.rs | 9 ++ server | 2 +- 8 files changed, 237 insertions(+), 13 deletions(-) diff --git a/client-gui2 b/client-gui2 index 09dd5432..43a95c35 160000 --- a/client-gui2 +++ b/client-gui2 @@ -1 +1 @@ -Subproject commit 09dd543243fc720b1737cb4fa950586c80949726 +Subproject commit 43a95c353369d17b5fd235ad67cdedc76026ad71 diff --git a/common/build.rs b/common/build.rs index a8a51dd0..b4a1d4dc 100644 --- a/common/build.rs +++ b/common/build.rs @@ -9,6 +9,7 @@ fn main() -> Result<(), Box> { .type_attribute(".komp_ac.accounting", serde) .type_attribute(".komp_ac.analytics", serde) .field_attribute(".komp_ac.analytics.ReportDashboardFilter.primary", "#[serde(default)]") + .field_attribute(".komp_ac.analytics.ReportDashboardDefinition.queries", "#[serde(default)]") .type_attribute(".komp_ac.auth", serde) .type_attribute(".komp_ac.common", serde) .type_attribute(".komp_ac.document_data", serde) diff --git a/common/proto/analytics.proto b/common/proto/analytics.proto index abc1b054..6f7c66dd 100644 --- a/common/proto/analytics.proto +++ b/common/proto/analytics.proto @@ -99,6 +99,7 @@ service ReportingService { rpc ListAssets(ListReportAssetsRequest) returns (ListReportAssetsResponse); rpc GetAsset(GetReportAssetRequest) returns (ReportAsset); rpc SaveDraft(SaveReportDraftRequest) returns (ReportAsset); + rpc Validate(ValidateReportRequest) returns (ReportAsset); rpc Publish(PublishReportRequest) returns (ReportAsset); rpc ListVersions(ReportAssetRef) returns (ListReportVersionsResponse); rpc RestoreDraft(RestoreReportDraftRequest) returns (ReportAsset); @@ -376,6 +377,13 @@ message ReportDashboardDefinition { uint32 refresh_seconds = 6; repeated ReportDatasetVersionRef dataset_versions = 7; ReportPeriod period = 8; + // Queries owned by this report; keys are used by panels and filter targets. + repeated ReportQuery queries = 9; +} + +message ReportQuery { + string key = 1; + ReportDatasetDefinition definition = 2; } message ReportDatasetVersionRef { @@ -445,6 +453,11 @@ message SaveReportDraftRequest { ReportDefinition definition = 3; } +message ValidateReportRequest { + ReportAssetRef asset = 1; + uint64 expected_revision = 2; +} + message PublishReportRequest { ReportAssetRef asset = 1; uint64 expected_revision = 2; diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index a95be341b2138c20769360c3daad071bb6204a70..359038c86bba5cdc968c9c8b158a6ce95a259ec3 100644 GIT binary patch delta 15992 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 delta 15396 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 diff --git a/common/src/proto/komp_ac.analytics.rs b/common/src/proto/komp_ac.analytics.rs index 482bc74b..19214f9e 100644 --- a/common/src/proto/komp_ac.analytics.rs +++ b/common/src/proto/komp_ac.analytics.rs @@ -426,6 +426,18 @@ pub struct ReportDashboardDefinition { pub dataset_versions: ::prost::alloc::vec::Vec, #[prost(message, optional, tag = "8")] pub period: ::core::option::Option, + /// Queries owned by this report; keys are used by panels and filter targets. + #[prost(message, repeated, tag = "9")] + #[serde(default)] + pub queries: ::prost::alloc::vec::Vec, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ReportQuery { + #[prost(string, tag = "1")] + pub key: ::prost::alloc::string::String, + #[prost(message, optional, tag = "2")] + pub definition: ::core::option::Option, } #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -547,6 +559,14 @@ pub struct SaveReportDraftRequest { } #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ValidateReportRequest { + #[prost(message, optional, tag = "1")] + pub asset: ::core::option::Option, + #[prost(uint64, tag = "2")] + pub expected_revision: u64, +} +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct PublishReportRequest { #[prost(message, optional, tag = "1")] pub asset: ::core::option::Option, @@ -1725,6 +1745,29 @@ pub mod reporting_service_client { ); self.inner.unary(req, path, codec).await } + pub async fn validate( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, 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.analytics.ReportingService/Validate", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("komp_ac.analytics.ReportingService", "Validate"), + ); + self.inner.unary(req, path, codec).await + } pub async fn publish( &mut self, request: impl tonic::IntoRequest, @@ -1966,6 +2009,10 @@ pub mod reporting_service_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; + async fn validate( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; async fn publish( &self, request: tonic::Request, @@ -2231,6 +2278,51 @@ pub mod reporting_service_server { }; Box::pin(fut) } + "/komp_ac.analytics.ReportingService/Validate" => { + #[allow(non_camel_case_types)] + struct ValidateSvc(pub Arc); + impl< + T: ReportingService, + > tonic::server::UnaryService + for ValidateSvc { + type Response = super::ReportAsset; + 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 { + ::validate(&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 = ValidateSvc(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) + } "/komp_ac.analytics.ReportingService/Publish" => { #[allow(non_camel_case_types)] struct PublishSvc(pub Arc); diff --git a/common/src/reporting.rs b/common/src/reporting.rs index eca47544..0783e29d 100644 --- a/common/src/reporting.rs +++ b/common/src/reporting.rs @@ -133,6 +133,62 @@ pub fn validate_parameter(parameter: &ReportParameter, allow_lookup: bool) -> Re Ok(()) } +/// Drafts may have unfinished SQL and bindings. Their envelope and domain values remain typed. +pub fn validate_draft(definition: &ReportDefinition) -> Result<(), String> { + if definition.schema_version != REPORT_SCHEMA_VERSION { + return Err(format!("Unsupported report schema version {}", definition.schema_version)); + } + if serde_json::to_vec(definition).map_err(|error| error.to_string())?.len() > MAX_REPORT_BYTES { + return Err("Report definition exceeds 1 MiB".into()); + } + asset_kind(definition)?; + if title(definition).len() > 160 || description(definition).len() > 4000 { + return Err("Report title or description is too long".into()); + } + let parameter = |p: &ReportParameter| -> Result<(), String> { + enum_value::(p.data_type, "parameter type")?; + enum_value::(p.control, "filter control")?; + enum_value::(p.default_kind, "default kind")?; + for scalar in p.default_values.iter().chain(p.choices.iter().filter_map(|choice| choice.value.as_ref())) { + let value = scalar.value.as_ref().ok_or("A scalar value is required")?; + if matches!(value, report_scalar::Value::NullValue(code) if *code != 0) { return Err("Invalid null value".into()); } + } + for choice in &p.choices { choice.value.as_ref().ok_or("A choice value is required")?; } + Ok(()) + }; + let dataset = |d: &ReportDatasetDefinition| -> Result<(), String> { + for p in &d.parameters { parameter(p)?; } + for c in &d.columns { + enum_value::(c.data_type, "column type")?; + enum_value::(c.number_format, "number format")?; + } + Ok(()) + }; + match definition.content.as_ref().ok_or("A report definition is required")? { + report_definition::Content::Dataset(d) => dataset(d)?, + report_definition::Content::Dashboard(d) => { + for q in &d.queries { dataset(q.definition.as_ref().ok_or("A query definition is required")?)?; } + for f in &d.filters { parameter(f.parameter.as_ref().ok_or("A filter parameter is required")?)?; } + for p in &d.panels { + enum_value::(p.kind, "chart kind")?; + enum_value::(p.orientation, "orientation")?; + enum_value::(p.sort_order, "sort order")?; + enum_value::(p.null_policy, "null policy")?; + for action in &p.actions { action.target.as_ref().ok_or("An action target is required")?; } + } + for g in &d.grants { + g.subject.as_ref().ok_or("A grant subject is required")?; + for c in &g.capabilities { enum_value::(*c, "capability")?; } + } + if let Some(period) = &d.period { period.selection.as_ref().ok_or("A period selection is required")?; } + if let Some(report_period::Selection::DateRange(range)) = d.period.as_ref().and_then(|p| p.selection.as_ref()) { + enum_value::(range.end_boundary, "period end boundary")?; + } + } + } + Ok(()) +} + pub fn validate_definition(definition: &ReportDefinition) -> Result<(), String> { if definition.schema_version != REPORT_SCHEMA_VERSION { return Err(format!( @@ -225,10 +281,18 @@ fn validate_dataset(dataset: &ReportDatasetDefinition) -> Result<(), String> { } fn validate_dashboard(dashboard: &ReportDashboardDefinition) -> Result<(), String> { + let mut query_keys = HashSet::new(); + if dashboard.queries.len() > 64 { return Err("A report allows at most 64 queries".into()); } + for query in &dashboard.queries { + nonempty(&query.key, "Query key", 64)?; + if !query_keys.insert(query.key.as_str()) { return Err(format!("Duplicate query key '{}'", query.key)); } + let dataset = query.definition.as_ref().ok_or_else(|| format!("SQL · {}: query definition is missing", query.key))?; + validate_dataset(dataset).map_err(|error| format!("SQL · {}: {error}", query.key))?; + } let referenced = referenced_datasets(dashboard); let mut dataset_versions = HashSet::new(); for reference in &dashboard.dataset_versions { - if !referenced.contains(reference.dataset_id.as_str()) || !dataset_versions.insert(&reference.dataset_id) { + if query_keys.contains(reference.dataset_id.as_str()) || !referenced.contains(reference.dataset_id.as_str()) || !dataset_versions.insert(&reference.dataset_id) { return Err("Dataset version references must be unique and used by the dashboard".into()); } } @@ -303,16 +367,16 @@ fn validate_dashboard(dashboard: &ReportDashboardDefinition) -> Result<(), Strin let sort: ReportSortOrder = enum_value(panel.sort_order, "sort order")?; let _: ReportNullPolicy = enum_value(panel.null_policy, "null policy")?; if !(1..=12).contains(&panel.width) || !(160..=1200).contains(&panel.height) { - return Err("Panel width must be 1–12 and height 160–1200".into()); + return Err(format!("Charts · {} [{}]: Panel width must be 1–12 and height 160–1200", panel.title, panel.id)); } if sort != ReportSortOrder::Source && panel.sort_field.is_empty() { - return Err("A sorted panel needs a sort field".into()); + return Err(format!("Charts · {} [{}]: A sorted panel needs a sort field", panel.title, panel.id)); } if panel.axis_min.is_some_and(|number| !number.is_finite()) || panel.axis_max.is_some_and(|number| !number.is_finite()) || matches!((panel.axis_min, panel.axis_max), (Some(min), Some(max)) if min >= max) { - return Err("Invalid chart axis bounds".into()); + return Err(format!("Charts · {} [{}]: Invalid chart axis bounds", panel.title, panel.id)); } if panel.colors.len() > 32 || panel.colors.iter().any(|color| { @@ -321,10 +385,10 @@ fn validate_dashboard(dashboard: &ReportDashboardDefinition) -> Result<(), Strin || !color[1..].bytes().all(|byte| byte.is_ascii_hexdigit()) }) { - return Err("Chart colors must be hexadecimal CSS colors".into()); + return Err(format!("Charts · {} [{}]: Chart colors must be hexadecimal CSS colors", panel.title, panel.id)); } if panel.actions.len() > 8 { - return Err("A panel can have at most eight actions".into()); + return Err(format!("Charts · {} [{}]: A panel can have at most eight actions", panel.title, panel.id)); } for action in &panel.actions { nonempty(&action.label, "Action label", 160)?; @@ -483,7 +547,7 @@ pub fn validate_dashboard_datasets( if panel.y_fields.iter().collect::>().len() != panel.y_fields.len() || panel.table_fields.iter().collect::>().len() != panel.table_fields.len() { - return Err("Panel field selections must be unique".into()); + return Err(format!("Charts · {} [{}]: Panel field selections must be unique", panel.title, panel.id)); } let field = |key: &str| { columns @@ -506,12 +570,12 @@ pub fn validate_dashboard_datasets( } if kind != ReportPanelKind::Table { if panel.y_fields.is_empty() || panel.y_fields.len() > 16 { - return Err("Charts need between 1 and 16 measures".into()); + return Err(format!("Charts · {} [{}]: Charts need between 1 and 16 measures", panel.title, panel.id)); } for key in &panel.y_fields { let kind: ReportDataType = enum_value(field(key)?.data_type, "measure type")?; if !matches!(kind, ReportDataType::Integer | ReportDataType::Decimal) { - return Err("Chart measures must be numeric".into()); + return Err(format!("Charts · {} [{}]: Chart measures must be numeric", panel.title, panel.id)); } } if !matches!(kind, ReportPanelKind::Kpi | ReportPanelKind::Gauge) { @@ -529,7 +593,7 @@ pub fn validate_dashboard_datasets( | ReportPanelKind::Kpi ) && panel.y_fields.len() != 1 { - return Err("This panel kind requires exactly one measure".into()); + return Err(format!("Charts · {} [{}]: This panel kind requires exactly one measure", panel.title, panel.id)); } if kind == ReportPanelKind::Heatmap { field(&panel.series_field)?; @@ -540,7 +604,7 @@ pub fn validate_dashboard_datasets( ].into_iter().flatten() { let data_type: ReportDataType = enum_value(field(key)?.data_type, "numeric field type")?; if !matches!(data_type, ReportDataType::Integer | ReportDataType::Decimal) { - return Err("Scatter coordinates and size fields must be numeric".into()); + return Err(format!("Charts · {} [{}]: Scatter coordinates and size fields must be numeric", panel.title, panel.id)); } } } @@ -575,3 +639,48 @@ pub fn validate_dashboard_datasets( } Ok(()) } + +#[cfg(test)] +mod authoring_tests { + use super::*; + + fn query() -> ReportDatasetDefinition { + ReportDatasetDefinition { + title: "Sales".into(), sql: "SELECT 1 AS total".into(), max_rows: 100, + columns: vec![ReportDatasetColumn { key: "total".into(), label: "Total".into(), + data_type: ReportDataType::Integer.into(), number_format: ReportNumberFormat::Number.into(), ..Default::default() }], + ..Default::default() + } + } + + #[test] + fn unfinished_drafts_save_but_cannot_publish_and_unknown_types_are_rejected() { + let mut dataset = query(); + dataset.sql.clear(); + let definition = |dataset| ReportDefinition { schema_version: REPORT_SCHEMA_VERSION, + content: Some(report_definition::Content::Dataset(dataset)) }; + assert!(validate_draft(&definition(dataset.clone())).is_ok()); + assert!(validate_definition(&definition(dataset.clone())).is_err()); + dataset.columns[0].data_type = 999; + assert!(validate_draft(&definition(dataset)).is_err()); + } + + #[test] + fn embedded_queries_require_unique_keys_and_cannot_have_external_version_pins() { + let mut dashboard = ReportDashboardDefinition { + title: "Sales".into(), queries: vec![ReportQuery { key: "sales".into(), definition: Some(query()) }], + panels: vec![ReportPanel { id: "total".into(), title: "Sales".into(), dataset_id: "sales".into(), + kind: ReportPanelKind::Table.into(), orientation: ReportOrientation::Vertical.into(), + sort_order: ReportSortOrder::Source.into(), null_policy: ReportNullPolicy::Gap.into(), width: 6, height: 360, + ..Default::default() }], ..Default::default() + }; + let definition = |dashboard| ReportDefinition { schema_version: REPORT_SCHEMA_VERSION, + content: Some(report_definition::Content::Dashboard(dashboard)) }; + assert!(validate_definition(&definition(dashboard.clone())).is_ok()); + dashboard.queries.push(dashboard.queries[0].clone()); + assert!(validate_definition(&definition(dashboard.clone())).is_err()); + dashboard.queries.pop(); + dashboard.dataset_versions.push(ReportDatasetVersionRef { dataset_id: "sales".into(), version: 1 }); + assert!(validate_definition(&definition(dashboard)).is_err()); + } +} diff --git a/komp-app/src/grpc/reporting.rs b/komp-app/src/grpc/reporting.rs index bf030f81..d8969d82 100644 --- a/komp-app/src/grpc/reporting.rs +++ b/komp-app/src/grpc/reporting.rs @@ -39,6 +39,15 @@ impl GrpcClient { Ok(response.into_inner()) } + pub async fn validate_report(&mut self, request: ValidateReportRequest) -> Result { + let request = self.authenticated_request(request)?; + let response = ReportingServiceClient::new(self.channel.clone()) + .validate(request) + .await + .context("gRPC ReportingService validate call failed")?; + Ok(response.into_inner()) + } + pub async fn publish_report(&mut self, request: PublishReportRequest) -> Result { let request = self.authenticated_request(request)?; let response = ReportingServiceClient::new(self.channel.clone()) diff --git a/server b/server index 7efce3df..96339d88 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 7efce3df1842958d09b4501ebb5199b6807f127d +Subproject commit 96339d88c4322cc8957d28524fca757f7e4ba8fa