diff --git a/crates/mesh-llm-host-runtime/src/api/routes/logs/cleanup.rs b/crates/mesh-llm-host-runtime/src/api/routes/logs/cleanup.rs index c2ed4b53fb..c34cfcbdcc 100644 --- a/crates/mesh-llm-host-runtime/src/api/routes/logs/cleanup.rs +++ b/crates/mesh-llm-host-runtime/src/api/routes/logs/cleanup.rs @@ -49,6 +49,8 @@ struct CleanupScopeDto { #[serde(skip_serializing_if = "Option::is_none")] route: Option, #[serde(skip_serializing_if = "Option::is_none")] + exclude_route: Option, + #[serde(skip_serializing_if = "Option::is_none")] model: Option, #[serde(skip_serializing_if = "Option::is_none")] provider: Option, @@ -146,6 +148,7 @@ impl CleanupScopeDto { from: filters.from().map(str::to_owned), to: filters.to().map(str::to_owned), route: filters.route().map(str::to_owned), + exclude_route: filters.exclude_route().map(str::to_owned), model: filters.model().map(str::to_owned), provider: filters.provider().map(str::to_owned), engine: filters.engine().map(str::to_owned), diff --git a/crates/mesh-llm-host-runtime/src/api/routes/logs/events/protocol/audit_entry/tests.rs b/crates/mesh-llm-host-runtime/src/api/routes/logs/events/protocol/audit_entry/tests.rs index 3928f39feb..234561eb49 100644 --- a/crates/mesh-llm-host-runtime/src/api/routes/logs/events/protocol/audit_entry/tests.rs +++ b/crates/mesh-llm-host-runtime/src/api/routes/logs/events/protocol/audit_entry/tests.rs @@ -24,7 +24,11 @@ fn audit_record(sequence: u64) -> AuditReplayRecord { } } -fn durable_audit_detail(entry_id: &str, detail: serde_json::Value) -> AuditEntryDetail { +fn durable_audit_detail( + entry_id: &str, + source: &str, + detail: serde_json::Value, +) -> AuditEntryDetail { let root = tempfile::tempdir().expect("temporary durable audit store"); let store = LogStore::open(root.path(), Arc::new(RealClock)).expect("open audit store"); store @@ -32,7 +36,7 @@ fn durable_audit_detail(entry_id: &str, detail: serde_json::Value) -> AuditEntry entry_id, None, "2026-01-01T00:00:00Z", - "cli", + source, "command_completed", Some(&detail.to_string()), ) @@ -270,6 +274,7 @@ fn live_audit_frame_preserves_valid_command_summary() { fn durable_audit_frame_drops_malformed_command_summary() { let record = durable_audit_detail( "id-9", + "cli", serde_json::json!({ "context_version": 1, "command_summary": "mesh-llm gpus --draft run-benchmark --backend cuda", @@ -284,6 +289,7 @@ fn durable_audit_frame_drops_malformed_command_summary() { fn durable_audit_frame_drops_deep_malformed_command_summary() { let record = durable_audit_detail( "id-12", + "cli", serde_json::json!({ "context_version": 1, "command_summary": "mesh-llm load unload status discover rotate-key setup --port 1234", @@ -298,6 +304,7 @@ fn durable_audit_frame_drops_deep_malformed_command_summary() { fn durable_audit_frame_preserves_valid_command_summary() { let record = durable_audit_detail( "id-11", + "cli", serde_json::json!({ "context_version": 1, "command_summary": "mesh-llm runtime guardrails --mode metrics --port 41731 --root-relay [REDACTED]", @@ -314,6 +321,7 @@ fn durable_audit_frame_preserves_valid_command_summary() { fn durable_audit_frame_redacts_unsafe_rest_parity_metadata() { let record = durable_audit_detail( "id-14", + "cli", serde_json::json!({ "context_version": 1, "subject_id": "https://alice:subject-secret@example.test/model?api_key=subject-query", @@ -340,3 +348,22 @@ fn durable_audit_frame_redacts_unsafe_rest_parity_metadata() { assert!(!serialized.contains(unsafe_value)); } } + +#[test] +fn durable_audit_frame_projects_legacy_logging_source_as_canonical() { + let record = durable_audit_detail( + "legacy-logging-entry", + "logging-runtime", + serde_json::json!({}), + ); + + let frame = durable_audit_entry_frame(record).expect("durable audit frame"); + let data = frame_data(&frame); + + assert!(frame.contains("event: audit_entry")); + assert!(frame.contains("id: a1:1")); + assert_eq!(data["entryId"], "legacy-logging-entry"); + assert_eq!(data["source"], "logging_service"); + assert_eq!(data["sequence"], 1); + assert!(!frame.contains("logging-runtime")); +} diff --git a/crates/mesh-llm-host-runtime/src/api/routes/logs/parse.rs b/crates/mesh-llm-host-runtime/src/api/routes/logs/parse.rs index f7611e3ad8..6dea36a3d6 100644 --- a/crates/mesh-llm-host-runtime/src/api/routes/logs/parse.rs +++ b/crates/mesh-llm-host-runtime/src/api/routes/logs/parse.rs @@ -133,6 +133,8 @@ struct CleanupPreviewBody { #[serde(default)] route: Option, #[serde(default)] + exclude_route: Option, + #[serde(default)] model: Option, #[serde(default)] provider: Option, @@ -236,7 +238,8 @@ pub(super) fn cleanup_preview_request( .as_deref() .map(mesh_llm_log_store::CleanupOutcome::try_from) .transpose()?, - )?; + )? + .with_exclude_route(body.exclude_route)?; let scope = mesh_llm_log_store::CleanupScope::new( mesh_llm_log_store::MaintenanceTimestamp::try_from(cutoff.as_str())?, body.request_limit, @@ -648,7 +651,7 @@ mod tests { fn cleanup_parsing_normalizes_and_rejects_unbounded_input_before_store_access() { let operation_id = uuid::Uuid::new_v4(); let body = format!( - r#"{{"operationId":"{operation_id}","cutoffBefore":"2026-08-03T01:00:00+01:00","requestLimit":1,"source":"durable","from":"2026-08-01T01:00:00+01:00","to":"2026-08-03T00:00:00Z","route":"route-a","model":"Qwen/Qwen3","provider":"mesh","engine":"skippy","outcome":"completed","reason":"operator cleanup"}}"# + r#"{{"operationId":"{operation_id}","cutoffBefore":"2026-08-03T01:00:00+01:00","requestLimit":1,"source":"durable","from":"2026-08-01T01:00:00+01:00","to":"2026-08-03T00:00:00Z","route":"route-a","excludeRoute":"models","model":"Qwen/Qwen3","provider":"mesh","engine":"skippy","outcome":"completed","reason":"operator cleanup"}}"# ); let preview = cleanup_preview_request("/api/logs/cleanup/preview", &body) .expect("bounded cleanup preview"); @@ -667,6 +670,7 @@ mod tests { Some("2026-08-03T00:00:00.000000000Z") ); assert_eq!(preview.scope.filters().model(), Some("Qwen/Qwen3")); + assert_eq!(preview.scope.filters().exclude_route(), Some("models")); assert_eq!( preview.scope.filters().outcome(), Some(mesh_llm_log_store::CleanupOutcome::Completed) diff --git a/crates/mesh-llm-host-runtime/src/api/routes/logs/tests/audit.rs b/crates/mesh-llm-host-runtime/src/api/routes/logs/tests/audit.rs index 117f8e09fa..4dc5f33141 100644 --- a/crates/mesh-llm-host-runtime/src/api/routes/logs/tests/audit.rs +++ b/crates/mesh-llm-host-runtime/src/api/routes/logs/tests/audit.rs @@ -116,6 +116,53 @@ async fn audit_filters_by_source() { assert_eq!(json["items"][0]["source"], "mesh"); } +#[tokio::test] +async fn audit_logging_service_filter_includes_legacy_rows_with_canonical_source() { + let (_temp, state) = runtime(); + let store = state.store().expect("store"); + for (entry_id, occurred_at, source) in [ + ( + "00000000-0000-4000-8000-000000000012", + "2026-01-01T00:00:00Z", + "logging-runtime", + ), + ( + "00000000-0000-4000-8000-000000000013", + "2026-01-01T00:00:01Z", + "logging_service", + ), + ( + "00000000-0000-4000-8000-000000000014", + "2026-01-01T00:00:02Z", + "runtime", + ), + ] { + store + .insert_audit_entry(entry_id, None, occurred_at, source, "health_check", None) + .expect("seed audit row"); + } + + let page = list_audits(&state, "/api/logs/audit?source=logging_service&limit=10") + .await + .expect("filter logging service rows"); + let json = serde_json::to_value(page).expect("serialize page"); + let items = json["items"].as_array().expect("items"); + + assert_eq!(items.len(), 2); + assert_eq!( + items + .iter() + .map(|item| item["entryId"].as_str().expect("entry id")) + .collect::>(), + vec![ + "00000000-0000-4000-8000-000000000013", + "00000000-0000-4000-8000-000000000012", + ] + ); + assert!(items.iter().all(|item| item["source"] == "logging_service")); + assert!(!json.to_string().contains("logging-runtime")); +} + #[tokio::test] async fn audit_filters_by_severity() { let (_temp, state) = runtime(); diff --git a/crates/mesh-llm-host-runtime/src/api/tests/logs_api_routes/read_and_export.rs b/crates/mesh-llm-host-runtime/src/api/tests/logs_api_routes/read_and_export.rs index 60241b6f49..be4cc54574 100644 --- a/crates/mesh-llm-host-runtime/src/api/tests/logs_api_routes/read_and_export.rs +++ b/crates/mesh-llm-host-runtime/src/api/tests/logs_api_routes/read_and_export.rs @@ -322,6 +322,7 @@ async fn cleanup_preview_and_run_share_receipt_and_cascade_only_selected_artifac "from": "2026-08-01T00:00:00Z", "to": "2026-08-02T00:00:00Z", "route": "cleanup-route", + "excludeRoute": "models", "model": "cleanup-model", "provider": "mesh", "engine": "skippy", @@ -364,6 +365,7 @@ async fn cleanup_preview_and_run_share_receipt_and_cascade_only_selected_artifac "from": "2026-08-01T00:00:00.000000000Z", "to": "2026-08-02T00:00:00.000000000Z", "route": "cleanup-route", + "excludeRoute": "models", "model": "cleanup-model", "provider": "mesh", "engine": "skippy", diff --git a/crates/mesh-llm-host-runtime/src/logging/runtime_state.rs b/crates/mesh-llm-host-runtime/src/logging/runtime_state.rs index 2d9dcf73f1..6c8bb436f1 100644 --- a/crates/mesh-llm-host-runtime/src/logging/runtime_state.rs +++ b/crates/mesh-llm-host-runtime/src/logging/runtime_state.rs @@ -39,7 +39,7 @@ use super::{ WebhookDeliveryScheduler, WebhookDeliveryWorker, }; -const HEALTH_AUDIT_ACTOR: &str = "logging-runtime"; +const HEALTH_AUDIT_ACTOR: &str = "logging_service"; /// Internal capability state for local logging storage. /// diff --git a/crates/mesh-llm-host-runtime/src/logging/runtime_state/tests/artifact_capture.rs b/crates/mesh-llm-host-runtime/src/logging/runtime_state/tests/artifact_capture.rs index b5d7979355..70bacf6e06 100644 --- a/crates/mesh-llm-host-runtime/src/logging/runtime_state/tests/artifact_capture.rs +++ b/crates/mesh-llm-host-runtime/src/logging/runtime_state/tests/artifact_capture.rs @@ -488,6 +488,15 @@ fn write_time_privacy_failure_publishes_one_marker_and_keeps_metadata_available( ); assert!(state.health().metadata_available); assert_eq!(marker_audit_count(&store), 1); + let marker_actor: String = store + .conn() + .query_row( + "SELECT actor FROM audit_entries WHERE action = ?", + [ARTIFACT_CAPTURE_DISABLED_PRIVACY_UNAVAILABLE], + |row| row.get(0), + ) + .expect("query marker audit actor"); + assert_eq!(marker_actor, "logging_service"); } #[test] diff --git a/crates/mesh-llm-host-runtime/src/network/openai/response/routing.rs b/crates/mesh-llm-host-runtime/src/network/openai/response/routing.rs index ee4e767a2f..53135a1598 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/response/routing.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/response/routing.rs @@ -231,6 +231,8 @@ mod tests { use tokio::io::ReadBuf; use tokio::net::TcpListener; use tokio::sync::Notify; + use tokio::sync::oneshot; + use tokio::time::{Duration, timeout}; /// A real duplex pipe as the upstream half of `CancelUpstream`, wrapped to /// signal a `Notify` the moment a read finds nothing buffered yet. @@ -325,9 +327,27 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); + let (disconnected_tx, disconnected_rx) = oneshot::channel(); let task = tokio::spawn(async move { - let (mut client, _) = listener.accept().await.unwrap(); - route_remote_attempt_after_forward( + let (client, _) = listener.accept().await.unwrap(); + let client_std = client.into_std().unwrap(); + let observer_std = client_std.try_clone().unwrap(); + let mut client = TcpStream::from_std(client_std).unwrap(); + let observer = TcpStream::from_std(observer_std).unwrap(); + let observer_task = tokio::spawn(async move { + let mut peeked = [0; 1]; + loop { + if observer.readable().await.is_err() { + break; + } + match observer.peek(&mut peeked).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + let _ = disconnected_tx.send(()); + }); + let result = route_remote_attempt_after_forward( &mut client, &mut upstream, host_id, @@ -336,7 +356,9 @@ mod tests { ResponseAdapter::None, OpenAiRouteObserver::default(), ) - .await + .await; + observer_task.await.unwrap(); + result }); let client_socket = TcpStream::connect(address).await.unwrap(); @@ -355,13 +377,15 @@ mod tests { // it makes once the body arrives lands on an actually disconnected // socket, rather than an upstream read failure standing in for one. // - // A graceful close (a plain `drop`) sends only a FIN; the server's - // very next write can still succeed locally before it learns the - // peer is gone, which is exactly the kind of timing-dependent gap - // this test exists to close. Zero linger forces an RST instead, so - // the eventual write fails deterministically. + // A graceful close (a plain `drop`) sends only a FIN; wait until the + // accepted socket observes EOF/reset before releasing the body. This + // closes the propagation window without consuming route data. client_socket.set_zero_linger().unwrap(); drop(client_socket); + timeout(Duration::from_secs(5), disconnected_rx) + .await + .unwrap() + .unwrap(); upstream_writer.write_all(body.as_bytes()).await.unwrap(); diff --git a/crates/mesh-llm-log-store/src/maintenance/execution.rs b/crates/mesh-llm-log-store/src/maintenance/execution.rs index dd784910dc..f242da5dd3 100644 --- a/crates/mesh-llm-log-store/src/maintenance/execution.rs +++ b/crates/mesh-llm-log-store/src/maintenance/execution.rs @@ -614,6 +614,10 @@ fn select_targets( parameters.push(rusqlite::types::Value::Text(value.to_owned())); } } + if let Some(exclude_route) = filters.exclude_route() { + sql.push_str(" AND (route IS NULL OR route != ?)"); + parameters.push(rusqlite::types::Value::Text(exclude_route.to_owned())); + } if let Some(outcome) = filters.outcome() { sql.push_str(" AND state = ?"); parameters.push(rusqlite::types::Value::Text(outcome.as_str().to_owned())); @@ -960,6 +964,7 @@ pub(super) fn selection_fingerprint( scope.filters.from(), scope.filters.to(), scope.filters.route(), + scope.filters.exclude_route(), scope.filters.model(), scope.filters.provider(), scope.filters.engine(), diff --git a/crates/mesh-llm-log-store/src/maintenance/scope_filters.rs b/crates/mesh-llm-log-store/src/maintenance/scope_filters.rs index 589fcb570e..ac105c6c11 100644 --- a/crates/mesh-llm-log-store/src/maintenance/scope_filters.rs +++ b/crates/mesh-llm-log-store/src/maintenance/scope_filters.rs @@ -16,6 +16,7 @@ pub struct CleanupFilters { from: Option, to: Option, route: Option, + exclude_route: Option, model: Option, provider: Option, engine: Option, @@ -37,6 +38,7 @@ impl CleanupFilters { from: from.map(|value| value.0), to: to.map(|value| value.0), route: normalize_scope_filter(route, "route")?, + exclude_route: None, model: normalize_scope_filter(model, "model")?, provider: normalize_scope_filter(provider, "provider")?, engine: normalize_scope_filter(engine, "engine")?, @@ -48,6 +50,14 @@ impl CleanupFilters { Ok(filters) } + pub fn with_exclude_route( + mut self, + exclude_route: Option, + ) -> Result { + self.exclude_route = normalize_scope_filter(exclude_route, "exclude_route")?; + Ok(self) + } + pub fn from(&self) -> Option<&str> { self.from.as_deref() } @@ -57,6 +67,9 @@ impl CleanupFilters { pub fn route(&self) -> Option<&str> { self.route.as_deref() } + pub fn exclude_route(&self) -> Option<&str> { + self.exclude_route.as_deref() + } pub fn model(&self) -> Option<&str> { self.model.as_deref() } diff --git a/crates/mesh-llm-log-store/src/maintenance/tests/cleanup.rs b/crates/mesh-llm-log-store/src/maintenance/tests/cleanup.rs index 22fcd33b88..b7a9041e27 100644 --- a/crates/mesh-llm-log-store/src/maintenance/tests/cleanup.rs +++ b/crates/mesh-llm-log-store/src/maintenance/tests/cleanup.rs @@ -129,6 +129,92 @@ fn cleanup_targets_visible_requests_instead_of_hidden_management_traffic() { ); } +#[test] +fn cleanup_exact_route_exclusion_is_persisted_and_omits_matching_targets() { + let (_root, artifacts) = fixture(); + let store = artifacts.store_ref(); + seed_terminal_with_metadata( + store, + "models-request", + "2025-01-01T00:00:00Z", + "models", + "model-a", + "mesh", + "skippy", + "completed", + ); + seed_terminal_with_metadata( + store, + "chat-request", + "2025-01-01T00:00:01Z", + "chat_completions", + "model-a", + "mesh", + "skippy", + "completed", + ); + let filters = serde_json::from_value::(serde_json::json!({ + "excludeRoute": "models", + })) + .expect("deserialize exact-route exclusion"); + let request = CleanupPreviewRequest { + scope: CleanupScope::new( + MaintenanceTimestamp::try_from("2025-02-01T00:00:00Z").expect("cutoff"), + 10, + ) + .expect("scope") + .with_filters(filters), + ..request_with_limit(0x51, "2025-02-01T00:00:00Z", 10) + }; + + let receipt = store + .preview_cleanup(&request, &NeverCancelled) + .expect("preview exact-route exclusion"); + let targets = store + .conn() + .prepare( + "SELECT request_id FROM maintenance_operation_targets WHERE operation_id = ?1 ORDER BY ordinal", + ) + .expect("prepare cleanup targets") + .query_map([request.operation_id.to_string()], |row| row.get::<_, String>(0)) + .expect("query cleanup targets") + .collect::, _>>() + .expect("collect cleanup targets"); + + assert_eq!(targets, ["chat-request"]); + let persisted_filters = + serde_json::to_value(receipt.scope.filters()).expect("serialize receipt filters"); + assert_eq!(persisted_filters["excludeRoute"], "models"); +} + +#[test] +fn cleanup_exact_route_exclusion_changes_the_selection_fingerprint() { + let baseline = CleanupScope::new( + MaintenanceTimestamp::try_from("2025-02-01T00:00:00Z").expect("cutoff"), + 10, + ) + .expect("baseline scope"); + let excluded = baseline.clone().with_filters( + serde_json::from_value::(serde_json::json!({ + "excludeRoute": "models", + })) + .expect("deserialize exact-route exclusion"), + ); + + assert_ne!( + super::super::execution::selection_fingerprint( + MaintenanceAction::Cleanup, + &baseline, + &["chat-request".to_owned()], + ), + super::super::execution::selection_fingerprint( + MaintenanceAction::Cleanup, + &excluded, + &["chat-request".to_owned()], + ) + ); +} + #[test] fn stale_preview_only_cleanup_receipt_is_ttl_eligible() { let (_root, artifacts) = fixture(); diff --git a/crates/mesh-llm-log-store/src/repositories/audit/model.rs b/crates/mesh-llm-log-store/src/repositories/audit/model.rs index 664c1a96fe..f9b3720bc8 100644 --- a/crates/mesh-llm-log-store/src/repositories/audit/model.rs +++ b/crates/mesh-llm-log-store/src/repositories/audit/model.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; pub const DEFAULT_AUDIT_ENTRY_LIMIT: usize = 50; pub const MAX_AUDIT_ENTRY_LIMIT: usize = 100; +pub(super) const LEGACY_LOGGING_RUNTIME_SOURCE: &str = "logging-runtime"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuditEntryRow { @@ -44,6 +45,14 @@ impl AuditEntrySource { } } +pub(super) fn canonicalize_persisted_source(source: &str) -> &str { + if source == LEGACY_LOGGING_RUNTIME_SOURCE { + AuditEntrySource::LoggingService.as_str() + } else { + source + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuditEntrySeverity { Info, diff --git a/crates/mesh-llm-log-store/src/repositories/audit/paging.rs b/crates/mesh-llm-log-store/src/repositories/audit/paging.rs index adc264c1f8..93fad966b4 100644 --- a/crates/mesh-llm-log-store/src/repositories/audit/paging.rs +++ b/crates/mesh-llm-log-store/src/repositories/audit/paging.rs @@ -1,6 +1,9 @@ use rusqlite::types::Value; -use super::{AuditEntryFilters, DEFAULT_AUDIT_ENTRY_LIMIT, MAX_AUDIT_ENTRY_LIMIT}; +use super::model::LEGACY_LOGGING_RUNTIME_SOURCE; +use super::{ + AuditEntryFilters, AuditEntrySource, DEFAULT_AUDIT_ENTRY_LIMIT, MAX_AUDIT_ENTRY_LIMIT, +}; use crate::error::LogStoreError; use crate::timestamps::canonical_comparison_timestamp; @@ -27,8 +30,20 @@ pub(super) fn query_parts( parameters.push(Value::Text(entry_id.clone())); } if let Some(source) = filters.source { - clauses.push("actor = ?".to_string()); - parameters.push(Value::Text(source.as_str().to_string())); + match source { + AuditEntrySource::LoggingService => { + clauses.push("actor IN (?, ?)".to_string()); + parameters.push(Value::Text(source.as_str().to_string())); + parameters.push(Value::Text(LEGACY_LOGGING_RUNTIME_SOURCE.to_string())); + } + AuditEntrySource::Runtime + | AuditEntrySource::Mesh + | AuditEntrySource::Cli + | AuditEntrySource::LogsApi => { + clauses.push("actor = ?".to_string()); + parameters.push(Value::Text(source.as_str().to_string())); + } + } } if let Some(severity) = filters.severity { clauses.push("CASE WHEN json_valid(detail_json) THEN json_extract(detail_json, '$.severity') END = ?".to_string()); diff --git a/crates/mesh-llm-log-store/src/repositories/audit/projection.rs b/crates/mesh-llm-log-store/src/repositories/audit/projection.rs index 62caad00db..666bbb761f 100644 --- a/crates/mesh-llm-log-store/src/repositories/audit/projection.rs +++ b/crates/mesh-llm-log-store/src/repositories/audit/projection.rs @@ -1,20 +1,20 @@ use mesh_llm_events::audit::SanitizedAuditScalar; use super::detail::StoredAuditDetail; +use super::model::canonicalize_persisted_source; use super::{AuditEntryRow, AuditEntrySeverity}; use crate::AuditEntryDetail; pub(super) fn audit_entry_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result { let detail = StoredAuditDetail::parse(row.get(6)?).bounded(); + let source = SanitizedAuditScalar::sanitize(&row.get::<_, String>(4)?); Ok(AuditEntryDetail { entry: AuditEntryRow { sequence: row.get(0)?, entry_id: row.get(1)?, request_id: row.get(2)?, occurred_at: row.get(3)?, - source: SanitizedAuditScalar::sanitize(&row.get::<_, String>(4)?) - .as_str() - .to_owned(), + source: canonicalize_persisted_source(source.as_str()).to_owned(), code: SanitizedAuditScalar::sanitize(&row.get::<_, String>(5)?) .as_str() .to_owned(), diff --git a/crates/mesh-llm-log-store/tests/public_api_compat.rs b/crates/mesh-llm-log-store/tests/public_api_compat.rs index ee9f5598bc..3b8f27c5b2 100644 --- a/crates/mesh-llm-log-store/tests/public_api_compat.rs +++ b/crates/mesh-llm-log-store/tests/public_api_compat.rs @@ -1,8 +1,8 @@ use std::collections::BTreeMap; use mesh_llm_log_store::{ - AuditEntryFilters, AuditEntryRow, LogStore, LogStoreError, Page, QueryPage, RequestQuery, - RequestRecord, + AuditEntryFilters, AuditEntryRow, CleanupFilters, LogStore, LogStoreError, + MaintenanceTimestamp, Page, QueryPage, RequestQuery, RequestRecord, }; type LegacyAuditPageMethod = fn( @@ -22,6 +22,15 @@ type LegacySummaryMetadataMethod = fn( Option<&str>, &str, ) -> Result<(), LogStoreError>; +type LegacyCleanupFiltersConstructor = fn( + Option, + Option, + Option, + Option, + Option, + Option, + Option, +) -> Result; #[test] fn released_request_record_struct_literal_remains_source_compatible() { @@ -80,3 +89,8 @@ fn released_query_and_list_methods_keep_legacy_return_shapes() { fn released_summary_metadata_method_keeps_legacy_signature() { let _: LegacySummaryMetadataMethod = LogStore::upsert_summary_metadata; } + +#[test] +fn released_cleanup_filters_constructor_keeps_legacy_signature() { + let _: LegacyCleanupFiltersConstructor = CleanupFilters::new; +} diff --git a/crates/mesh-llm-ui/e2e/a11y/logs-a11y.spec.ts b/crates/mesh-llm-ui/e2e/a11y/logs-a11y.spec.ts index f4ccb33a51..a29d95a9fa 100644 --- a/crates/mesh-llm-ui/e2e/a11y/logs-a11y.spec.ts +++ b/crates/mesh-llm-ui/e2e/a11y/logs-a11y.spec.ts @@ -175,29 +175,58 @@ test('fallback log polling toggle remains AA-compliant while paused', async ({ p (storageKey) => window.localStorage.setItem(storageKey, 'live'), 'mesh-llm-ui-preview:data-mode:v2' ) - await page.route('**/api/logs/requests*', (route) => route.fulfill({ json: { items: [], nextCursor: null } })) - await page.route('**/api/logs/audit*', (route) => route.fulfill({ json: { items: [], nextCursor: null } })) - // Hold the SSE connection open — see the comment on the previous test. - let releaseEventsStream: (() => void) | undefined + let resolveFallbackRequestsFulfillment: (() => void) | undefined + let resolveFallbackAuditFulfillment: (() => void) | undefined + await page.route('**/api/logs/requests*', async (route) => { + const resolveFulfillment = resolveFallbackRequestsFulfillment + resolveFallbackRequestsFulfillment = undefined + await route.fulfill({ json: { items: [], nextCursor: null } }) + resolveFulfillment?.() + }) + await page.route('**/api/logs/audit*', async (route) => { + const resolveFulfillment = resolveFallbackAuditFulfillment + resolveFallbackAuditFulfillment = undefined + await route.fulfill({ json: { items: [], nextCursor: null } }) + resolveFulfillment?.() + }) + // Hold both SSE connections open — see the comment on the previous test. + let releaseRequestsStream: (() => void) | undefined + let releaseAuditStream: (() => void) | undefined await page.route('**/api/logs/events*', async (route) => { + const isAuditStream = new URL(route.request().url()).searchParams.get('audit') === '1' await new Promise((resolve) => { - releaseEventsStream = resolve + if (isAuditStream) { + releaseAuditStream = resolve + } else { + releaseRequestsStream = resolve + } }) await route.fulfill({ contentType: 'text/event-stream', - body: 'retry: 600000\nid: v1:0.0.0\nevent: stream_error\ndata: {"code":"invalid_event"}\n\n' + body: 'retry: 600000\n\n' }) }) await page.goto('/logs') await expect(page.getByRole('heading', { level: 1, name: 'System logs' })).toBeVisible() - await expect.poll(() => releaseEventsStream).toBeDefined() + await expect.poll(() => releaseRequestsStream).toBeDefined() + await expect.poll(() => releaseAuditStream).toBeDefined() await page.clock.pauseAt(new Date(await page.evaluate(() => Date.now() + 50))) - releaseEventsStream?.() + releaseRequestsStream?.() + releaseAuditStream?.() await expect(page.getByText('Reconnecting', { exact: true })).toBeVisible() - // Step deliberately past FALLBACK_DELAY_MS (1s) into the `polling` state, - // where the toggle under test renders. + const fallbackRequestsFulfilled = new Promise((resolve) => { + resolveFallbackRequestsFulfillment = resolve + }) + const fallbackAuditFulfilled = new Promise((resolve) => { + resolveFallbackAuditFulfillment = resolve + }) + // Trigger fallback at exactly FALLBACK_DELAY_MS (1s). Active fallback stays + // `reconnecting`; await both hydrations and flush query notifications. await page.clock.runFor(1_000) + await Promise.all([fallbackRequestsFulfilled, fallbackAuditFulfilled]) + await page.clock.runFor(0) + await expect(page.getByText('Updating', { exact: true })).toHaveCount(0) const pollingToggle = page.getByRole('button', { name: 'Fallback log polling' }) await expect(pollingToggle).toHaveAttribute('aria-pressed', 'true') diff --git a/crates/mesh-llm-ui/e2e/logs/log-workflows.spec.ts b/crates/mesh-llm-ui/e2e/logs/log-workflows.spec.ts index 81df70bcd5..4e286f8f5e 100644 --- a/crates/mesh-llm-ui/e2e/logs/log-workflows.spec.ts +++ b/crates/mesh-llm-ui/e2e/logs/log-workflows.spec.ts @@ -7,6 +7,7 @@ const ARTIFACT_ID = '00000000-0000-4000-8000-000000000003' const OPERATION_ID = '00000000-0000-4000-8000-000000000004' const AUDIT_ID = '00000000-0000-4000-8000-000000000005' const OCCURRED_AT = '2026-08-04T12:00:00Z' +const LATER_OCCURRED_AT = '2026-08-04T12:30:00Z' const TERMINAL_AT = '2026-08-04T12:00:01Z' const FILTER_TO = '2026-08-04T13:00:00.000Z' const DATA_MODE_STORAGE_KEY = 'mesh-llm-ui-preview:data-mode:v2' @@ -24,6 +25,7 @@ type LogsBackendOptions = { cleanupRunResults?: readonly MaintenanceResult[] deleteResults?: readonly MaintenanceResult[] auditIdentity?: AuditIdentity + auditOccurredAt?: string delaySecondRequestsResponse?: boolean } @@ -63,10 +65,13 @@ function logsPage(items: readonly object[]) { return { items, nextCursor: null } } -function auditEntry(identity: AuditIdentity = { entryId: 'audit-0001', code: 'runtime_config_diagnostics_warning' }) { +function auditEntry( + identity: AuditIdentity = { entryId: 'audit-0001', code: 'runtime_config_diagnostics_warning' }, + occurredAt = OCCURRED_AT +) { return { entryId: identity.entryId, - occurredAt: OCCURRED_AT, + occurredAt, source: 'logs_api', code: identity.code, severity: 'warning', @@ -198,7 +203,7 @@ async function installLogsBackend(page: Page, options: LogsBackendOptions = {}) } if (url.pathname === '/api/logs/audit' && method === 'GET') { state.auditListCalls += 1 - await route.fulfill({ json: logsPage([auditEntry(options.auditIdentity)]) }) + await route.fulfill({ json: logsPage([auditEntry(options.auditIdentity, options.auditOccurredAt)]) }) return } if (url.pathname === '/api/logs/events') { @@ -212,7 +217,7 @@ async function installLogsBackend(page: Page, options: LogsBackendOptions = {}) body: 'id: a1:2\n' + 'event: audit_entry\n' + - `data: ${JSON.stringify({ ...auditEntry(options.auditIdentity), sequence: 2 })}\n\n` + `data: ${JSON.stringify({ ...auditEntry(options.auditIdentity, options.auditOccurredAt), sequence: 2 })}\n\n` }) return } @@ -374,6 +379,37 @@ test('logs ledger follows a lifecycle event into immediate details and safe arti await expect(requestInspector.getByText('Artifact download started.')).toBeVisible() }) +test('events chart applies the active populated bucket with real keyboard input', async ({ page: browserPage }) => { + // Given + await browserPage.clock.setFixedTime(new Date(FILTER_TO)) + await installLogsBackend(browserPage, { + auditOccurredAt: LATER_OCCURRED_AT, + lifecycle: 'completed', + streamMode: 'unavailable' + }) + await browserPage.goto('/logs?timeRange=1h') + const listbox = browserPage.getByRole('listbox', { name: /Events over time stacked bar chart/ }) + const options = listbox.getByRole('option') + await expect(options).toHaveCount(2) + await listbox.focus() + + // When + await browserPage.keyboard.press('ArrowRight') + await expect(options.nth(1)).toHaveAttribute('aria-selected', 'true') + await browserPage.keyboard.press('Enter') + + // Then + await expect(browserPage.getByLabel('Chart time range')).toHaveValue('selected') + await expect(browserPage.getByRole('button', { name: 'Clear window' })).toBeVisible() + await expect(browserPage).toHaveURL((url) => { + return ( + url.pathname === '/logs' && + url.searchParams.get('from') === '2026-08-04T12:30:00.000Z' && + url.searchParams.get('to') === '2026-08-04T12:30:59.999Z' + ) + }) +}) + test('logs recovery uses the dedicated stream gap and bounded polling fallback', async ({ page: browserPage }) => { await browserPage.clock.install({ time: new Date(FILTER_TO) }) const backend = await installLogsBackend(browserPage, { lifecycle: 'failed', streamMode: 'gap' }) @@ -391,7 +427,7 @@ test('logs recovery uses the dedicated stream gap and bounded polling fallback', await browserPage.clock.pauseAt(new Date(await browserPage.evaluate(() => Date.now() + 2_000))) const pollingToggle = browserPage.getByRole('button', { name: 'Fallback log polling' }) await expect(pollingToggle).toHaveAttribute('aria-pressed', 'true') - await expect(pollingToggle).toContainText('Polling') + await expect(pollingToggle).toContainText('Reconnecting') backend.streamMode = 'event' const streamAttemptsBeforePause = backend.streamUrls.length @@ -408,7 +444,7 @@ test('logs recovery uses the dedicated stream gap and bounded polling fallback', await pollingToggle.click() await expect(pollingToggle).toHaveAttribute('aria-pressed', 'true') - await expect(pollingToggle).toContainText('Polling') + await expect(pollingToggle).toContainText('Reconnecting') expect(backend.listCalls).toBe(listCallsBeforePause) expect(backend.streamUrls).toHaveLength(streamAttemptsBeforeResume) await browserPage.clock.runFor(5_000) @@ -431,6 +467,7 @@ test('metadata-only export and previewed cleanup stay separated without dead-let }) => { const backend = await installLogsBackend(browserPage, { lifecycle: 'completed', streamMode: 'unavailable' }) + await browserPage.setViewportSize({ width: 1280, height: 900 }) await browserPage.goto('/logs') const infoBanner = browserPage.getByRole('region', { name: 'System logs' }) const ledgerControls = browserPage.getByRole('region', { name: 'Event log controls' }) @@ -458,6 +495,77 @@ test('metadata-only export and previewed cleanup stay separated without dead-let await expect( cleanupDialog.getByRole('button', { name: /System chart layer.*retained during cleanup/ }) ).toHaveAttribute('data-state', 'on') + const layerControls = cleanupDialog.getByRole('button', { name: /chart layer/ }) + const desktopLayerLayout = await layerControls.evaluateAll(([requests, system, quic]) => { + if (!(requests instanceof HTMLElement) || !(system instanceof HTMLElement) || !(quic instanceof HTMLElement)) { + return { controlsFit: false, twoColumns: false } + } + const requestsBounds = requests.getBoundingClientRect() + const systemBounds = system.getBoundingClientRect() + const quicBounds = quic.getBoundingClientRect() + return { + controlsFit: [system, quic].every((control) => control.scrollWidth <= control.clientWidth), + twoColumns: Math.abs(requestsBounds.top - systemBounds.top) <= 1 && quicBounds.top >= requestsBounds.bottom - 1 + } + }) + expect.soft(desktopLayerLayout).toEqual({ controlsFit: true, twoColumns: true }) + + await browserPage.setViewportSize({ width: 375, height: 900 }) + const mobileLayerLayout = await layerControls.evaluateAll(([requests, system, quic]) => { + if (!(requests instanceof HTMLElement) || !(system instanceof HTMLElement) || !(quic instanceof HTMLElement)) { + return { controlsFit: false, oneColumn: false } + } + const requestsBounds = requests.getBoundingClientRect() + const systemBounds = system.getBoundingClientRect() + const quicBounds = quic.getBoundingClientRect() + return { + controlsFit: [system, quic].every((control) => control.scrollWidth <= control.clientWidth), + oneColumn: + systemBounds.top >= requestsBounds.bottom - 1 && + quicBounds.top >= systemBounds.bottom - 1 && + Math.abs(requestsBounds.left - systemBounds.left) <= 1 && + Math.abs(systemBounds.left - quicBounds.left) <= 1 + } + }) + expect.soft(mobileLayerLayout).toEqual({ controlsFit: true, oneColumn: true }) + + const selectorPanelBottom = await cleanupDialog + .getByRole('slider', { name: 'Window start' }) + .evaluate( + (element) => element.parentElement?.parentElement?.getBoundingClientRect().bottom ?? Number.POSITIVE_INFINITY + ) + const helperTop = await cleanupDialog + .getByText('Drag either edge to narrow the loaded history. The server preview confirms what can be removed.', { + exact: true + }) + .evaluate((element) => element.getBoundingClientRect().top) + expect.soft(helperTop).toBeGreaterThanOrEqual(selectorPanelBottom) + + const requestSummaryGeometry = await cleanupDialog + .locator('p') + .filter({ + hasText: /loaded request events? in this window\. Server review identifies removable terminal request groups\./ + }) + .evaluate((explanation) => { + const summary = explanation.parentElement + const counter = summary?.querySelector(':scope > span') + if (!(summary instanceof HTMLElement) || !(counter instanceof HTMLElement)) return null + const counterBounds = counter.getBoundingClientRect() + const explanationBounds = explanation.getBoundingClientRect() + const counterStyle = getComputedStyle(counter) + return { + counter: counter.textContent?.trim() ?? '', + fontFamily: counterStyle.fontFamily, + fontVariantNumeric: counterStyle.fontVariantNumeric, + gap: explanationBounds.left - counterBounds.right + } + }) + expect.soft(requestSummaryGeometry?.counter ?? '').toMatch(/^\d+$/) + expect.soft(requestSummaryGeometry?.fontFamily ?? '').toMatch(/JetBrains Mono|ui-monospace|Menlo|monospace/i) + expect.soft(requestSummaryGeometry?.fontVariantNumeric ?? '').toContain('tabular-nums') + expect.soft(requestSummaryGeometry?.gap ?? 0).toBeGreaterThan(0) + + await browserPage.setViewportSize({ width: 1280, height: 900 }) await cleanupDialog.getByRole('button', { name: /System chart layer.*retained during cleanup/ }).click() await expect( cleanupDialog.getByRole('button', { name: /System chart layer.*retained during cleanup/ }) diff --git a/crates/mesh-llm-ui/e2e/logs/logs-chart-stability.spec.ts b/crates/mesh-llm-ui/e2e/logs/logs-chart-stability.spec.ts index f171dece7e..59ef7f7a54 100644 --- a/crates/mesh-llm-ui/e2e/logs/logs-chart-stability.spec.ts +++ b/crates/mesh-llm-ui/e2e/logs/logs-chart-stability.spec.ts @@ -209,7 +209,7 @@ async function mountChart(page: Page) { const capture = captureErrors(page) await page.goto('/logs') await expect(page.getByRole('heading', { name: /Logs/i })).toBeVisible({ timeout: 30_000 }) - await expect(page.getByRole('img', { name: /Events over time/i }).first()).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('listbox', { name: /Events over time/i }).first()).toBeVisible({ timeout: 30_000 }) await expect(page.locator('path.recharts-rectangle').first()).toBeVisible({ timeout: 30_000 }) return capture } @@ -224,7 +224,7 @@ async function assertChartAndNoLoop(page: Page, capture: ReturnType { body: capture.consoleIssues.join('\n') || '(none)' }) - const chart = page.getByRole('img', { name: /Events over time/i }).first() + const chart = page.getByRole('listbox', { name: /Events over time/i }).first() await expect(chart).toBeVisible({ timeout: 30_000 }) const barRects = page.locator('path.recharts-rectangle') await expect(barRects.first()).toBeVisible({ timeout: 30_000 }) @@ -264,13 +264,9 @@ test.describe('events over time chart stability', () => { test('renders a high-volume dataset without exceeding React update depth', async ({ page }) => { // 370 requests spanning [now-370min, now-1min] under frozen time. // - // mergeLogEventWindow caps the MERGED request+audit list at 64 rows - // (LOG_EVENT_WINDOW_LIMIT), newest first — the cap is not per-category. - // AUDIT_ROWS contributes 3 entries inside the newest 64 (system@20min, - // quic@30min, gossip@50min); the 4th (gossip@490min) falls outside the - // window. So the legend reports Requests61 + System1 + QUIC1 + Gossip1, - // summing to exactly 64. Asserting the total proves the full mock dataset - // reached the ledger and was windowed rather than silently dropped. + // With the ledger window cap removed, every loaded fixture row is + // expected in the legend: all 370 requests plus System3, QUIC2, and + // Gossip2 from the seven audit rows. // The bar count itself stays small, so the fidelity gate is "bars render // at all and no render loop fires". const manyRequests = Array.from({ length: 370 }, (_, i) => @@ -284,10 +280,10 @@ test.describe('events over time chart stability', () => { await page.waitForTimeout(2500) const legend = page.getByRole('list', { name: 'Visible event categories' }) - await expect(legend).toContainText('Requests61') - await expect(legend).toContainText('System1') - await expect(legend).toContainText('QUIC1') - await expect(legend).toContainText('Gossip1') + await expect(legend).toContainText('Requests370') + await expect(legend).toContainText('System3') + await expect(legend).toContainText('QUIC2') + await expect(legend).toContainText('Gossip2') const bars = await page.locator('path.recharts-rectangle').count() expect(bars).toBeGreaterThan(0) expect(capture.depthErrors(), `render loop detected:\n${capture.depthErrors().join('\n')}`).toEqual([]) @@ -299,7 +295,7 @@ test.describe('events over time chart stability', () => { const capture = captureErrors(page) await page.goto('/logs') await expect(page.getByRole('heading', { name: /Logs/i })).toBeVisible({ timeout: 30_000 }) - await expect(page.getByRole('img', { name: /Events over time/i }).first()).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('listbox', { name: /Events over time/i }).first()).toBeVisible({ timeout: 30_000 }) await page.setViewportSize({ width: 900, height: 600 }) await page.waitForTimeout(700) diff --git a/crates/mesh-llm-ui/e2e/logs/request-inspector-fixtures.ts b/crates/mesh-llm-ui/e2e/logs/request-inspector-fixtures.ts index 16b52c1b8c..957556527d 100644 --- a/crates/mesh-llm-ui/e2e/logs/request-inspector-fixtures.ts +++ b/crates/mesh-llm-ui/e2e/logs/request-inspector-fixtures.ts @@ -4,7 +4,8 @@ export const REQUEST_INSPECTOR_IDS = { empty: '10000000-0000-4000-8000-000000000003', failed: '10000000-0000-4000-8000-000000000004', active: '10000000-0000-4000-8000-000000000005', - transient: '10000000-0000-4000-8000-000000000006' + transient: '10000000-0000-4000-8000-000000000006', + streaming: '10000000-0000-4000-8000-000000000007' } as const export const REQUEST_INSPECTOR_ARTIFACT_IDS = { @@ -15,7 +16,8 @@ export const REQUEST_INSPECTOR_ARTIFACT_IDS = { unavailable: '30000000-0000-4000-8000-000000000005', corrupt: '30000000-0000-4000-8000-000000000006', errorCorrupt: '30000000-0000-4000-8000-000000000007', - errorMissing: '30000000-0000-4000-8000-000000000008' + errorMissing: '30000000-0000-4000-8000-000000000008', + streamingResponse: '30000000-0000-4000-8000-000000000009' } as const export const REQUEST_INSPECTOR_SHELL_STATUS = { @@ -26,6 +28,8 @@ export const REQUEST_INSPECTOR_SHELL_STATUS = { gpus: [] } +const CALLER_ENDPOINT_ID = '9f0c4cbe8cb7a8d5d577c20e50ef03fd2f63a2e7fd9897c155823bcbb281bb04' + type EventFixture = readonly [ sequence: number, occurredAt: string, @@ -137,11 +141,23 @@ const failedArtifacts = [ artifact(REQUEST.failed, [ARTIFACT.errorCorrupt, 'error_diagnostic', 'corrupt', 2048, 4]), artifact(REQUEST.failed, [ARTIFACT.errorMissing, 'error_trace', 'missing']) ] as const +const streamingResponseArtifact = { + ...artifact(REQUEST.streaming, [ARTIFACT.streamingResponse, 'response_body', 'available']), + mediaKind: 'text/event-stream' +} + +export const REQUEST_INSPECTOR_STREAM_HOSTILE_TEXT = '' export const REQUEST_INSPECTOR_SCENARIOS: Readonly> = { [REQUEST.completed]: { - summary: summary(REQUEST.completed, 'completed', 0), + summary: { + ...summary(REQUEST.completed, 'completed', 0), + callerEndpointId: CALLER_ENDPOINT_ID, + callerAddr: '203.0.113.24:48712', + callerPathType: 'remote_quic_http' + }, events: [ + event(REQUEST.completed, [0, timestamp(0, '00.500'), 'admitted']), event(REQUEST.completed, [1, timestamp(0, '01.200'), 'stream_started']), event(REQUEST.completed, [2, timestamp(0, '02'), 'stream_chunk']), event(REQUEST.completed, [3, timestamp(0, '03'), 'stream_completed', undefined, undefined, 42]) @@ -188,6 +204,12 @@ export const REQUEST_INSPECTOR_SCENARIOS: Readonly"') + }, + [ARTIFACT.streamingResponse]: { + ...streamingResponseArtifact, + contentBase64: encoded( + [ + 'event: delta', + 'id: stream-1', + 'data: {"delta":"hello"}', + '', + `data: ${REQUEST_INSPECTOR_STREAM_HOSTILE_TEXT}`, + '', + 'event: done', + 'data: [DONE]', + '' + ].join('\n') + ) } } @@ -219,8 +257,8 @@ export function requestDeleteReceipt(requestId: string) { requestId, state: 'completed', selectionFingerprint: 'request-inspector-delete', - planned: { requests: 1, events: 3, artifacts: 5, proxyRecords: 1, databaseRows: 10 }, - executed: { requests: 1, events: 3, artifacts: 5, proxyRecords: 1, databaseRows: 10 }, + planned: { requests: 1, events: 4, artifacts: 5, proxyRecords: 1, databaseRows: 11 }, + executed: { requests: 1, events: 4, artifacts: 5, proxyRecords: 1, databaseRows: 11 }, artifactDeletion: { removed: 5, failed: 0 } } } diff --git a/crates/mesh-llm-ui/e2e/logs/request-inspector-footer-clearance.spec.ts b/crates/mesh-llm-ui/e2e/logs/request-inspector-footer-clearance.spec.ts new file mode 100644 index 0000000000..cfd1c1288a --- /dev/null +++ b/crates/mesh-llm-ui/e2e/logs/request-inspector-footer-clearance.spec.ts @@ -0,0 +1,49 @@ +import type { Page } from '@playwright/test' +import { openInspector } from './request-inspector-helpers' +import { installRequestInspectorRoutes, REQUEST_INSPECTOR_IDS } from './request-inspector-routes' +import { expect, test } from './request-inspector-test' + +const VIEWPORTS = [ + { label: 'mobile', width: 375, height: 520 }, + { label: 'desktop', width: 1280, height: 800 } +] as const + +async function footerClearance(page: Page) { + const inspector = await openInspector(page, REQUEST_INSPECTOR_IDS.completed) + const scrollBody = inspector.locator('[data-request-inspector-scroll="body"]') + const footer = inspector.getByRole('contentinfo', { name: 'Request inspector actions' }) + const overview = inspector.getByRole('region', { name: 'Request overview' }) + await expect.poll(() => scrollBody.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) + await scrollBody.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + await expect + .poll(() => scrollBody.evaluate((element) => element.scrollTop + element.clientHeight)) + .toBe(await scrollBody.evaluate((element) => element.scrollHeight)) + + const [contentBottom, footerTop, spacing] = await Promise.all([ + overview.evaluate((element) => element.lastElementChild?.getBoundingClientRect().bottom ?? null), + footer.evaluate((element) => element.getBoundingClientRect().top), + scrollBody.evaluate((element) => ({ + expected: Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--shell-normal')), + paddingBottom: Number.parseFloat(getComputedStyle(element).paddingBottom) + })) + ]) + if (contentBottom === null) throw new Error('Request overview final panel bounds missing') + return { contentBottom, footerTop, ...spacing } +} + +for (const viewport of VIEWPORTS) { + test(`keeps the final overview panel one normal shell space above the footer at ${viewport.label} width`, async ({ + page + }) => { + await installRequestInspectorRoutes(page) + await page.setViewportSize({ width: viewport.width, height: viewport.height }) + await page.goto('/logs') + + const { contentBottom, expected, footerTop, paddingBottom } = await footerClearance(page) + + expect(paddingBottom).toBeCloseTo(expected, 1) + expect(footerTop - contentBottom).toBeGreaterThanOrEqual(expected - 1) + }) +} diff --git a/crates/mesh-llm-ui/e2e/logs/request-inspector-overview.spec.ts b/crates/mesh-llm-ui/e2e/logs/request-inspector-overview.spec.ts index fce01b64a1..5c4c258162 100644 --- a/crates/mesh-llm-ui/e2e/logs/request-inspector-overview.spec.ts +++ b/crates/mesh-llm-ui/e2e/logs/request-inspector-overview.spec.ts @@ -46,9 +46,21 @@ test('keeps completed evidence and footer actions visible while only the inspect await expect(inspector).toHaveAttribute('data-request-inspector-shell', 'fixed') await expect(overview.getByText('1 attempt / 0 retries', { exact: true })).toBeVisible() - await expect(overview.getByRole('list', { name: 'Lifecycle events' })).toContainText( - /stream_started[\s\S]*stream_chunk[\s\S]*stream_completed/ - ) + const caller = overview.getByRole('region', { name: 'Caller' }) + await expect(caller).toContainText('9f0c…bb04') + await expect(caller).toContainText('203.0.113.24:48712') + await expect(caller).toContainText('Remote QUIC HTTP') + const callerCopy = caller.getByRole('button', { name: 'Copy caller endpoint ID' }) + await expect(callerCopy).toBeVisible() + await expect + .poll(() => callerCopy.evaluate((element) => element.getBoundingClientRect().height)) + .toBeGreaterThanOrEqual(44) + const lifecycle = overview.getByRole('list', { name: 'Lifecycle events' }) + await expect(lifecycle.locator('li[data-event-kind="stream_started"]')).toHaveCount(1) + await expect(lifecycle).toContainText('Stream started') + await overview.getByRole('button', { name: 'Later lifecycle events' }).click() + await expect(lifecycle.locator('li[data-event-kind="stream_completed"]')).toHaveCount(1) + await expect(lifecycle).toContainText('Stream done') await expect(overview.getByRole('list', { name: 'Routing attempts' })).toContainText('mesh-primary') const retention = overview.getByRole('region', { name: 'Artifact retention' }) await expect(retention).toContainText('2 available · 1 unavailable · 1 missing · 1 corrupt') diff --git a/crates/mesh-llm-ui/e2e/logs/request-inspector-payloads.spec.ts b/crates/mesh-llm-ui/e2e/logs/request-inspector-payloads.spec.ts index 80afe336c9..6565b90d8f 100644 --- a/crates/mesh-llm-ui/e2e/logs/request-inspector-payloads.spec.ts +++ b/crates/mesh-llm-ui/e2e/logs/request-inspector-payloads.spec.ts @@ -2,14 +2,76 @@ import { openInspector, selectInspectorTab } from './request-inspector-helpers' import { installRequestInspectorRoutes, REQUEST_INSPECTOR_ARTIFACT_IDS, - REQUEST_INSPECTOR_IDS + REQUEST_INSPECTOR_IDS, + REQUEST_INSPECTOR_STREAM_HOSTILE_TEXT } from './request-inspector-routes' import { expect, test } from './request-inspector-test' +import type { Locator, Page } from '@playwright/test' + +type ScrollEndpoint = 'start' | 'end' + +async function expectCopyAnchoredAtEndpoint( + page: Page, + payloadViewport: Locator, + copy: Locator, + endpoint: ScrollEndpoint +) { + const expectedState = { + atEndpoint: true, + copyContained: true, + copyPainted: true, + horizontallyScrollable: true, + toolbarMatchesViewport: true + } + + await payloadViewport.evaluate((viewport, target) => { + viewport.scrollLeft = target === 'start' ? 0 : viewport.scrollWidth - viewport.clientWidth + }, endpoint) + await expect + .poll(() => + payloadViewport.evaluate((viewport, target) => { + const button = viewport.querySelector('button[aria-label="Copy JSON payload"]') + const buttonRect = button?.getBoundingClientRect() + const toolbarRect = button?.parentElement?.getBoundingClientRect() + const viewportRect = viewport.getBoundingClientRect() + const targetScrollLeft = target === 'start' ? 0 : viewport.scrollWidth - viewport.clientWidth + const hitTarget = + buttonRect === undefined + ? null + : document.elementFromPoint(buttonRect.left + buttonRect.width / 2, buttonRect.top + buttonRect.height / 2) + + return { + atEndpoint: Math.abs(viewport.scrollLeft - targetScrollLeft) <= 1, + copyContained: + buttonRect !== undefined && + buttonRect.left >= viewportRect.left - 1 && + buttonRect.right <= viewportRect.right + 1, + copyPainted: button !== null && hitTarget !== null && (hitTarget === button || button.contains(hitTarget)), + horizontallyScrollable: viewport.scrollWidth > viewport.clientWidth, + toolbarMatchesViewport: toolbarRect !== undefined && Math.abs(toolbarRect.width - viewportRect.width) <= 1 + } + }, endpoint) + ) + .toEqual(expectedState) + + await payloadViewport.focus() + await page.keyboard.press('Tab') + await expect(copy).toBeFocused() + await expect + .poll(() => + payloadViewport.evaluate((viewport, target) => { + const targetScrollLeft = target === 'start' ? 0 : viewport.scrollWidth - viewport.clientWidth + return Math.abs(viewport.scrollLeft - targetScrollLeft) <= 1 + }, endpoint) + ) + .toBe(true) +} test('loads only the selected payload and safely exposes format, lines, and copy controls', async ({ context, page }) => { + await page.setViewportSize({ width: 375, height: 812 }) await context.grantPermissions(['clipboard-read', 'clipboard-write']) const backend = await installRequestInspectorRoutes(page) await page.goto('/logs') @@ -21,25 +83,83 @@ test('loads only the selected payload and safely exposes format, lines, and copy const responsePane = inspector.getByRole('region', { name: 'Response', exact: true }) // Only the Request pane is visible by default (single-pane toggle) - await expect(inspector.getByRole('button', { name: 'Load payload' })).toHaveCount(1) for (const state of ['missing', 'unavailable', 'corrupt'] as const) { await expect(inspector.getByText(state, { exact: true }).first()).toBeVisible() } - expect(backend.artifactDetailCalls).toEqual([]) - - // Load request payload - await requestPane.getByRole('button', { name: 'Load payload' }).click() await expect.poll(() => backend.artifactDetailCalls).toEqual([REQUEST_INSPECTOR_ARTIFACT_IDS.request]) const requestJson = requestPane.getByRole('region', { name: 'Request JSON payload' }) + const paneHeader = requestPane.locator('header').first() + const payloadControl = paneHeader.getByRole('radiogroup', { name: 'Payload' }) + const displayToolbar = inspector.getByRole('toolbar', { name: 'Display' }) + const formatControl = displayToolbar.getByRole('radiogroup', { name: 'Display' }) + await expect(payloadControl.getByRole('radio', { name: 'Request' })).toBeChecked() + await expect(payloadControl.getByRole('radio', { name: 'Response' })).toBeVisible() + await expect(displayToolbar).toHaveCount(1) + await expect(formatControl).toHaveCount(1) + await expect(paneHeader.getByRole('radio', { name: 'Pretty' })).toHaveCount(0) + await expect(displayToolbar.getByRole('radio', { name: 'Request' })).toHaveCount(0) + await expect(requestJson.getByRole('radiogroup', { name: 'Display' })).toHaveCount(0) await expect(requestJson.locator('[data-json-token="key"]').first()).toBeVisible() await expect(requestJson.locator('[data-line-number]')).toHaveCount(5) - await expect(requestJson.getByRole('radio', { name: 'Pretty' })).toBeChecked() + await expect(formatControl.getByRole('radio', { name: 'Pretty' })).toBeChecked() + + const payloadViewport = requestPane.getByRole('region', { name: 'Request payload content' }) + const copy = requestJson.getByRole('button', { name: 'Copy JSON payload' }) + const scrollArea = payloadViewport.locator('..') + const horizontalScrollbar = scrollArea.locator('[data-orientation="horizontal"]') + const verticalScrollbar = scrollArea.locator('[data-orientation="vertical"]') + const horizontalThumb = horizontalScrollbar.locator(':scope > div').first() + const inspectorBody = inspector.locator('[data-request-inspector-scroll="body"]') + await expect(inspectorBody).toHaveCount(1) + await expect + .poll(() => + inspectorBody.evaluate((body) => ({ + overflowY: getComputedStyle(body).overflowY, + verticallyScrollable: body.scrollHeight > body.clientHeight + })) + ) + .toEqual({ overflowY: 'auto', verticallyScrollable: true }) + await expect(inspector.locator('[data-radix-scroll-area-viewport]')).toHaveCount(1) + await expect(payloadViewport.locator('[data-radix-scroll-area-viewport]')).toHaveCount(0) + await expect(horizontalScrollbar).toBeVisible() + await expect(horizontalThumb).toBeVisible() + await expect(verticalScrollbar).toHaveCount(0) + await expect(scrollArea).not.toHaveClass(/(?:^|\s)(?:h-64|h-80|sm:h-\[28rem\]|lg:h-\[32rem\])(?:\s|$)/) + await expect + .poll(() => + payloadViewport.evaluate((viewport) => ({ + overflowY: getComputedStyle(viewport).overflowY, + verticallyScrollable: viewport.scrollHeight > viewport.clientHeight + })) + ) + .toEqual({ overflowY: 'hidden', verticallyScrollable: false }) + const prettyPayloadHeight = await payloadViewport.evaluate((viewport) => viewport.getBoundingClientRect().height) + await expectCopyAnchoredAtEndpoint(page, payloadViewport, copy, 'start') + await expectCopyAnchoredAtEndpoint(page, payloadViewport, copy, 'end') + await expect + .poll(() => + horizontalScrollbar.evaluate((track) => { + const thumb = track.firstElementChild + const trackColor = getComputedStyle(track).backgroundColor + const thumbColor = thumb === null ? '' : getComputedStyle(thumb).backgroundColor + return { + colorsDiffer: trackColor !== thumbColor, + thumbOpaque: thumbColor !== '' && thumbColor !== 'rgba(0, 0, 0, 0)', + trackOpaque: trackColor !== 'rgba(0, 0, 0, 0)' + } + }) + ) + .toEqual({ colorsDiffer: true, thumbOpaque: true, trackOpaque: true }) // Format, lines, copy controls on request JSON - await requestJson.getByRole('radio', { name: 'Raw' }).click() - await expect(requestJson.getByRole('radio', { name: 'Raw' })).toBeChecked() + await formatControl.getByRole('radio', { name: 'Raw' }).click() + await expect(formatControl.getByRole('radio', { name: 'Raw' })).toBeChecked() await expect(requestJson.locator('[data-line-number]')).toHaveCount(1) - const copy = requestJson.getByRole('button', { name: 'Copy JSON payload' }) + await expect + .poll(() => payloadViewport.evaluate((viewport) => viewport.getBoundingClientRect().height)) + .toBeLessThan(prettyPayloadHeight) + await expectCopyAnchoredAtEndpoint(page, payloadViewport, copy, 'start') + await expectCopyAnchoredAtEndpoint(page, payloadViewport, copy, 'end') await expect(copy).toBeVisible() await copy.click() await expect(requestJson.getByRole('status')).toContainText('Raw JSON representation selected. JSON payload copied.') @@ -48,28 +168,26 @@ test('loads only the selected payload and safely exposes format, lines, and copy await expect(requestJson.locator('img')).toHaveCount(0) await expect(requestJson.locator('script')).toHaveCount(0) - // Toggle to Response, load response payload await inspector.getByRole('radio', { name: 'Response' }).click() - await responsePane.getByRole('button', { name: 'Load payload' }).click() await expect .poll(() => backend.artifactDetailCalls) .toEqual([REQUEST_INSPECTOR_ARTIFACT_IDS.request, REQUEST_INSPECTOR_ARTIFACT_IDS.response]) const responseJson = responsePane.getByRole('region', { name: 'Response JSON payload' }) await expect(responseJson.locator('[data-json-token="boolean"]').first()).toBeVisible() + await expect(formatControl.getByRole('radio', { name: 'Raw' })).toBeChecked() + await expect(responseJson.locator('[data-line-number]')).toHaveCount(1) + + await inspector.getByRole('radio', { name: 'Request' }).click() + await expect(formatControl.getByRole('radio', { name: 'Raw' })).toBeChecked() + await expect(requestJson.locator('[data-line-number]')).toHaveCount(1) // Tab round-trip — both panes cached, no new artifactDetailCalls - // Toggle resets to Request on remount after tab switch await selectInspectorTab(page, inspector, { id: 'overview', name: 'Overview' }) await selectInspectorTab(page, inspector, { id: 'payloads', name: 'Payloads' }) - // Request pane shows cached ready-to-view state (1 View payload button visible) - await expect(requestPane.getByRole('button', { name: 'View payload' })).toBeVisible() - await requestPane.getByRole('button', { name: 'View payload' }).click() await expect(requestJson).toBeVisible() - // Toggle to Response — also cached, no new fetches await inspector.getByRole('radio', { name: 'Response' }).click() - await responsePane.getByRole('button', { name: 'View payload' }).click() await expect(responseJson).toBeVisible() expect(backend.artifactDetailCalls).toEqual([ @@ -78,19 +196,103 @@ test('loads only the selected payload and safely exposes format, lines, and copy ]) }) -test('renders malformed retained JSON as inert plaintext only after explicit load', async ({ page }) => { +test('pages one multi-frame SSE response with numbered controls while preserving format and inert frame content', async ({ + page +}) => { + await page.setViewportSize({ width: 375, height: 812 }) + const backend = await installRequestInspectorRoutes(page) + await page.goto('/logs') + const inspector = await openInspector(page, REQUEST_INSPECTOR_IDS.streaming) + await selectInspectorTab(page, inspector, { id: 'payloads', name: 'Payloads' }) + + const displayToolbar = inspector.getByRole('toolbar', { name: 'Display' }) + const formatControl = displayToolbar.getByRole('radiogroup', { name: 'Display' }) + await expect(displayToolbar).toHaveCount(1) + await expect(formatControl).toHaveCount(1) + await formatControl.getByRole('radio', { name: 'Raw' }).click() + await inspector.getByRole('radio', { name: 'Response' }).click() + await expect.poll(() => backend.artifactDetailCalls).toEqual([REQUEST_INSPECTOR_ARTIFACT_IDS.streamingResponse]) + + const payloadViewport = inspector.getByRole('region', { name: 'Response payload content' }) + const firstFrame = inspector.getByRole('region', { name: 'Response event stream frame 1', exact: true }) + await expect(firstFrame).toContainText('Frame 1 of 3') + await expect(firstFrame).toContainText('delta') + await expect(firstFrame).toContainText('stream-1') + await expect(firstFrame.locator('[data-json-line]')).toHaveCount(1) + await expect(firstFrame.getByRole('button', { name: 'Copy JSON payload' })).toBeVisible() + await expect(inspector.getByRole('region', { name: /^Response event stream frame \d$/ })).toHaveCount(1) + await expect(inspector.getByRole('listitem')).toHaveCount(0) + await expect(payloadViewport.locator('[data-radix-scroll-area-viewport]')).toHaveCount(0) + const framePager = inspector.getByRole('radiogroup', { name: 'Response frames' }) + const frameNavigator = framePager.locator('..') + const frameChoices = framePager.getByRole('radio') + await expect(frameChoices).toHaveText(['1', '2', '3']) + const previous = inspector.getByRole('button', { name: 'Previous response frame' }) + const next = inspector.getByRole('button', { name: 'Next response frame' }) + await expect(previous).toBeDisabled() + await expect(next).toBeEnabled() + await expect(inspector.getByRole('status').filter({ hasText: 'Frame 1 of 3' })).toHaveCount(1) + await expect + .poll(() => + frameNavigator.evaluate((navigator) => { + const header = navigator.closest('header') + const context = header?.firstElementChild + if (!(header instanceof HTMLElement) || !(context instanceof HTMLElement)) { + return { fillsRow: false, followsContext: false } + } + const navigatorRect = navigator.getBoundingClientRect() + const headerRect = header.getBoundingClientRect() + const contextRect = context.getBoundingClientRect() + return { + fillsRow: navigatorRect.width >= headerRect.width - 26, + followsContext: navigatorRect.top >= contextRect.bottom + } + }) + ) + .toEqual({ fillsRow: true, followsContext: true }) + for (const target of [previous, ...(await frameChoices.all()), next]) { + await expect + .poll(() => + target.evaluate((control) => { + const rect = control.getBoundingClientRect() + return Math.min(rect.width, rect.height) + }) + ) + .toBeGreaterThanOrEqual(32) + } + + await framePager.getByRole('radio', { name: 'Response frame 2 of 3' }).click() + const secondFrame = inspector.getByRole('region', { name: 'Response event stream frame 2', exact: true }) + await expect(firstFrame).toHaveCount(0) + await expect(secondFrame).toContainText(REQUEST_INSPECTOR_STREAM_HOSTILE_TEXT) + await expect(secondFrame.locator('img')).toHaveCount(0) + await expect(secondFrame.locator('script')).toHaveCount(0) + await expect(secondFrame.getByRole('button', { name: 'Copy JSON payload' })).toHaveCount(0) + await expect(inspector.getByRole('region', { name: /^Response event stream frame \d$/ })).toHaveCount(1) + await expect(formatControl.getByRole('radio', { name: 'Raw' })).toBeChecked() + + await framePager.getByRole('radio', { name: 'Response frame 2 of 3' }).focus() + await page.keyboard.press('ArrowRight') + const doneFrame = inspector.getByRole('region', { name: 'Response event stream frame 3', exact: true }) + await expect(secondFrame).toHaveCount(0) + await expect(doneFrame).toContainText('Frame 3 of 3') + await expect(doneFrame).toContainText('done') + await expect(doneFrame).toContainText('[DONE]') + await expect(doneFrame.getByRole('button', { name: 'Copy JSON payload' })).toHaveCount(0) + await expect(inspector.getByRole('region', { name: /^Response event stream frame \d$/ })).toHaveCount(1) + await expect(next).toBeDisabled() + await expect(previous).toBeEnabled() + await expect(inspector.getByRole('status').filter({ hasText: 'Frame 3 of 3' })).toHaveCount(1) + await expect(formatControl.getByRole('radio', { name: 'Raw' })).toBeChecked() + expect(backend.artifactDetailCalls).toEqual([REQUEST_INSPECTOR_ARTIFACT_IDS.streamingResponse]) +}) + +test('renders malformed retained JSON as inert plaintext when the selected payload loads', async ({ page }) => { const backend = await installRequestInspectorRoutes(page) await page.goto('/logs') const inspector = await openInspector(page, REQUEST_INSPECTOR_IDS.malformed) await selectInspectorTab(page, inspector, { id: 'payloads', name: 'Payloads' }) - expect(backend.artifactDetailCalls).toEqual([]) - await inspector - .getByRole('region', { name: 'Request', exact: true }) - .getByRole('button', { - name: 'Load payload' - }) - .click() await expect.poll(() => backend.artifactDetailCalls).toEqual([REQUEST_INSPECTOR_ARTIFACT_IDS.malformed]) await expect(inspector.getByText('Malformed JSON. Showing inert plaintext; no markup is interpreted.')).toBeVisible() await expect(inspector.getByRole('region', { name: 'Request malformed JSON plaintext' })).toContainText( diff --git a/crates/mesh-llm-ui/e2e/logs/request-inspector-routes.ts b/crates/mesh-llm-ui/e2e/logs/request-inspector-routes.ts index 9f351512b8..afb4dc660d 100644 --- a/crates/mesh-llm-ui/e2e/logs/request-inspector-routes.ts +++ b/crates/mesh-llm-ui/e2e/logs/request-inspector-routes.ts @@ -5,12 +5,13 @@ import { REQUEST_INSPECTOR_ARTIFACT_IDS, REQUEST_INSPECTOR_IDS, REQUEST_INSPECTOR_SCENARIOS, - REQUEST_INSPECTOR_SHELL_STATUS + REQUEST_INSPECTOR_SHELL_STATUS, + REQUEST_INSPECTOR_STREAM_HOSTILE_TEXT } from './request-inspector-fixtures' const DATA_MODE_STORAGE_KEY = 'mesh-llm-ui-preview:data-mode:v2' -export { REQUEST_INSPECTOR_ARTIFACT_IDS, REQUEST_INSPECTOR_IDS } +export { REQUEST_INSPECTOR_ARTIFACT_IDS, REQUEST_INSPECTOR_IDS, REQUEST_INSPECTOR_STREAM_HOSTILE_TEXT } type RequestCapability = 'supported' | 'unsupported' | 'loading' diff --git a/crates/mesh-llm-ui/e2e/smoke/topnav-responsive.spec.ts b/crates/mesh-llm-ui/e2e/smoke/topnav-responsive.spec.ts index 6758c5967d..69e2803612 100644 --- a/crates/mesh-llm-ui/e2e/smoke/topnav-responsive.spec.ts +++ b/crates/mesh-llm-ui/e2e/smoke/topnav-responsive.spec.ts @@ -7,6 +7,7 @@ async function readTopNavMetrics(page: Page) { const header = document.querySelector('header') const apiTarget = document.querySelector('[aria-label="API target instructions"]') const actionsMenu = document.querySelector('[aria-label="Open navigation actions"]') + const compactNavigation = document.querySelector('[aria-label="Primary compact navigation"]') const joinButton = document.querySelector('[aria-label="Mesh join and invite instructions"]') const themeButton = document.querySelector('[aria-label^="Theme:"]') const preferencesButton = document.querySelector('[aria-label="Open interface preferences"]') @@ -25,6 +26,7 @@ async function readTopNavMetrics(page: Page) { const visibleControls = [ apiTarget, actionsMenu, + compactNavigation, joinButton, themeButton, preferencesButton, @@ -36,6 +38,7 @@ async function readTopNavMetrics(page: Page) { return { actionsMenuVisible: isVisible(actionsMenu), apiTargetVisible: isVisible(apiTarget), + compactNavigationVisible: isVisible(compactNavigation), controlTopSpread, fullTabLabels: fullTabLabels.filter(isVisible).map((element) => (element.textContent ?? '').trim()), headerHeight: header ? Math.round(header.getBoundingClientRect().height) : 0, @@ -57,12 +60,16 @@ test('top navigation stays on one row at every responsive breakpoint', async ({ .poll(async () => { const metrics = await readTopNavMetrics(page) let responsiveControlsReady: boolean - if (width < 768) { - responsiveControlsReady = !metrics.apiTargetVisible && metrics.actionsMenuVisible - } else if (width < 1024) { - responsiveControlsReady = metrics.apiTargetVisible && metrics.actionsMenuVisible + if (width < 1024) { + responsiveControlsReady = + metrics.compactNavigationVisible && + metrics.fullTabLabels.length === 0 && + !metrics.apiTargetVisible && + metrics.actionsMenuVisible } else { responsiveControlsReady = + !metrics.compactNavigationVisible && + metrics.fullTabLabels.join('|') === 'Network|Chat|Configuration' && metrics.apiTargetVisible && !metrics.actionsMenuVisible && metrics.joinButtonVisible && @@ -83,13 +90,18 @@ test('top navigation stays on one row at every responsive breakpoint', async ({ expect(metrics.controlTopSpread, `${width}px controls should not split onto separate rows`).toBeLessThanOrEqual(3) expect(metrics.horizontalOverflow, `${width}px should not create horizontal document overflow`).toBe(false) - if (width < 768) { + if (width < 1024) { + expect(metrics.compactNavigationVisible, `${width}px compact state shows primary icon navigation`).toBe(true) + expect(metrics.fullTabLabels, `${width}px compact state hides full primary labels`).toEqual([]) expect(metrics.apiTargetVisible, `${width}px compact state hides API target chip`).toBe(false) expect(metrics.actionsMenuVisible, `${width}px compact state keeps actions in the menu`).toBe(true) - } else if (width < 1024) { - expect(metrics.apiTargetVisible, `${width}px shows the API target chip`).toBe(true) - expect(metrics.actionsMenuVisible, `${width}px middle state uses the actions menu`).toBe(true) } else { + expect(metrics.compactNavigationVisible, `${width}px desktop state hides primary icon navigation`).toBe(false) + expect(metrics.fullTabLabels, `${width}px desktop state shows full primary labels`).toEqual([ + 'Network', + 'Chat', + 'Configuration' + ]) expect(metrics.apiTargetVisible, `${width}px shows the API target chip`).toBe(true) expect(metrics.actionsMenuVisible, `${width}px desktop state shows direct actions`).toBe(false) expect(metrics.joinButtonVisible, `${width}px desktop state shows join actions`).toBe(true) diff --git a/crates/mesh-llm-ui/src/app/layout/RootLayout.test.tsx b/crates/mesh-llm-ui/src/app/layout/RootLayout.test.tsx index 78d08d839b..b15b679ee3 100644 --- a/crates/mesh-llm-ui/src/app/layout/RootLayout.test.tsx +++ b/crates/mesh-llm-ui/src/app/layout/RootLayout.test.tsx @@ -109,6 +109,12 @@ describe('RootLayout', () => { expect(useStatusStreamSpy).toHaveBeenCalledWith({ enabled: true }) }) + it('keeps the app shell width stable by reserving the gutter on its scroll container', () => { + renderRootLayout('harness') + + expect(document.querySelector('main')).toHaveClass('overflow-y-auto', '[scrollbar-gutter:stable]') + }) + it('selects the Logs tab for the logs route', () => { routerState.pathname = '/logs' featureFlagState.logsPage = true diff --git a/crates/mesh-llm-ui/src/app/layout/RootLayout.tsx b/crates/mesh-llm-ui/src/app/layout/RootLayout.tsx index 0601858fc9..5fa61831f7 100644 --- a/crates/mesh-llm-ui/src/app/layout/RootLayout.tsx +++ b/crates/mesh-llm-ui/src/app/layout/RootLayout.tsx @@ -212,7 +212,7 @@ export function RootLayout({ data = SHELL_HARNESS }: RootLayoutProps = {}) { /> ) : null} -
+
diff --git a/crates/mesh-llm-ui/src/components/ui/AccentIconFrame.tsx b/crates/mesh-llm-ui/src/components/ui/AccentIconFrame.tsx index 6fd09532a1..9098af2d48 100644 --- a/crates/mesh-llm-ui/src/components/ui/AccentIconFrame.tsx +++ b/crates/mesh-llm-ui/src/components/ui/AccentIconFrame.tsx @@ -1,13 +1,13 @@ import type { CSSProperties, ReactNode } from 'react' import { cn } from '@/lib/cn' -type AccentIconFrameTone = 'accent' | 'subtle' +type AccentIconFrameTone = 'accent' | 'subtle' | 'bad' type AccentIconFrameProps = { - children: ReactNode - className?: string - style?: CSSProperties - tone?: AccentIconFrameTone + readonly children: ReactNode + readonly className?: string + readonly style?: CSSProperties + readonly tone?: AccentIconFrameTone } const frameStyleByTone: Record = { @@ -19,6 +19,11 @@ const frameStyleByTone: Record = { background: 'color-mix(in oklab, var(--color-accent-soft) 42%, var(--color-panel-strong))', border: '1px solid color-mix(in oklab, var(--color-accent) 18%, var(--color-border))', color: 'color-mix(in oklab, var(--color-accent) 48%, var(--color-fg-dim))' + }, + bad: { + background: 'color-mix(in oklab, var(--color-bad) 18%, transparent)', + border: '1px solid color-mix(in oklab, var(--color-bad) 30%, var(--color-border))', + color: 'var(--color-bad-text)' } } diff --git a/crates/mesh-llm-ui/src/components/ui/InfoBanner.test.tsx b/crates/mesh-llm-ui/src/components/ui/InfoBanner.test.tsx new file mode 100644 index 0000000000..563d277518 --- /dev/null +++ b/crates/mesh-llm-ui/src/components/ui/InfoBanner.test.tsx @@ -0,0 +1,39 @@ +import '@testing-library/jest-dom/vitest' + +import { render, screen, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { InfoBanner } from '@/components/ui/InfoBanner' + +describe('InfoBanner', () => { + it('keeps the default banner as a named region and renders its annotation', () => { + render( + Window notice} + description="Current activity remains available." + title="System activity" + titleId="system-activity-title" + /> + ) + + const region = screen.getByRole('region', { name: 'System activity' }) + expect(within(region).getByText('Window notice')).toBeVisible() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('announces the error variant once while keeping annotation details visible', () => { + render( + Request history: Unavailable} + description="Request history could not be loaded." + title="System logs" + titleId="system-logs-title" + variant="error" + /> + ) + + const alert = screen.getByRole('alert', { name: 'System logs' }) + expect(alert).toHaveTextContent('Request history could not be loaded.') + expect(within(alert).getByText('Request history: Unavailable')).toBeVisible() + expect(screen.getAllByRole('alert')).toHaveLength(1) + }) +}) diff --git a/crates/mesh-llm-ui/src/components/ui/InfoBanner.tsx b/crates/mesh-llm-ui/src/components/ui/InfoBanner.tsx index b344b3890e..96c0648764 100644 --- a/crates/mesh-llm-ui/src/components/ui/InfoBanner.tsx +++ b/crates/mesh-llm-ui/src/components/ui/InfoBanner.tsx @@ -1,21 +1,61 @@ import type { ReactNode } from 'react' +import { CircleAlert } from 'lucide-react' import { AccentIconFrame } from '@/components/ui/AccentIconFrame' import { cn } from '@/lib/cn' -type InfoBannerProps = { - title: ReactNode - description: ReactNode - action?: ReactNode - actionClassName?: string - className?: string - contentClassName?: string - descriptionClassName?: string - leadingIcon?: ReactNode - leadingIconClassName?: string - status?: ReactNode - titleClassName?: string - titleId?: string - titleLevel?: 'h1' | 'h2' | 'h3' +type InfoBannerVariant = 'default' | 'error' +type InfoBannerAnnotationTone = 'warn' | 'bad' + +export type InfoBannerProps = { + readonly title: ReactNode + readonly description: ReactNode + readonly action?: ReactNode + readonly actionClassName?: string + readonly annotation?: ReactNode + readonly className?: string + readonly contentClassName?: string + readonly descriptionClassName?: string + readonly leadingIcon?: ReactNode + readonly leadingIconClassName?: string + readonly status?: ReactNode + readonly titleClassName?: string + readonly titleId?: string + readonly titleLevel?: 'h1' | 'h2' | 'h3' + readonly variant?: InfoBannerVariant +} + +type InfoBannerAnnotationProps = { + readonly ariaLabel: string + readonly children: ReactNode + readonly className?: string + readonly tone?: InfoBannerAnnotationTone +} + +const bannerBackground: Record = { + default: + 'linear-gradient(90deg, color-mix(in oklab, var(--color-accent) 10%, var(--color-panel)) 0%, var(--color-panel) 60%)', + error: + 'linear-gradient(90deg, color-mix(in oklab, var(--color-bad) 10%, var(--color-panel)) 0%, var(--color-panel) 60%)' +} + +const annotationToneClass: Record = { + warn: 'text-warn', + bad: 'text-bad' +} + +export function InfoBannerAnnotation({ ariaLabel, children, className, tone = 'warn' }: InfoBannerAnnotationProps) { + return ( +
+
+ ) } export function InfoBanner({ @@ -31,7 +71,9 @@ export function InfoBanner({ status, titleClassName, titleId, - titleLevel = 'h2' + titleLevel = 'h2', + variant = 'default', + annotation }: InfoBannerProps) { const Heading = titleLevel @@ -40,14 +82,17 @@ export function InfoBanner({ aria-labelledby={titleId} className={cn( 'panel-shell flex items-center gap-5 rounded-[var(--radius-lg)] border border-border px-5 py-4', + variant === 'error' && 'border-bad/40', className )} - style={{ - background: - 'linear-gradient(90deg, color-mix(in oklab, var(--color-accent) 10%, var(--color-panel)) 0%, var(--color-panel) 60%)' - }} + role={variant === 'error' ? 'alert' : undefined} + style={{ background: bannerBackground[variant] }} > - {leadingIcon ? {leadingIcon} : null} + {leadingIcon ? ( + + {leadingIcon} + + ) : null}
{status}
: null}
{description}
+ {annotation} {action ? (
{action}
diff --git a/crates/mesh-llm-ui/src/components/ui/Pager.test.tsx b/crates/mesh-llm-ui/src/components/ui/Pager.test.tsx new file mode 100644 index 0000000000..7731f01885 --- /dev/null +++ b/crates/mesh-llm-ui/src/components/ui/Pager.test.tsx @@ -0,0 +1,353 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { Pager } from '@/components/ui/Pager' + +describe('Pager', () => { + it('renders nothing when a single page covers the content', () => { + // Given a pager with one page + const { container } = render() + + // Then no paging affordance is offered + expect(container).toBeEmptyDOMElement() + }) + + it('renders one dot per page and marks the active page', () => { + // Given a pager across four pages positioned on the second + render() + + // Then every page is reachable and the active one is checked + const dots = screen.getAllByRole('radio') + expect(dots).toHaveLength(4) + expect(dots[1]).toBeChecked() + expect(screen.getByRole('radiogroup', { name: 'Pages' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Page 3 of 4' })).toBeInTheDocument() + }) + + it('keeps radio hit targets separate from the visual dot sizes', () => { + // Given a pager with active and inactive page choices + render() + + // Then each radio has a 24px hit target and an aria-hidden visual child + const radios = screen.getAllByRole('radio') + expect(radios).toHaveLength(2) + for (const radio of radios) { + expect(radio).toHaveClass('size-6', 'p-0') + expect(radio.querySelector('[aria-hidden="true"]')).toBeInTheDocument() + } + expect(radios[0].querySelector('[aria-hidden="true"]')).toHaveClass('h-1.5', 'w-4') + expect(radios[1].querySelector('[aria-hidden="true"]')).toHaveClass('size-1.5') + }) + + it('renders visible page numbers only when the numbered variant is requested', () => { + // Given a numbered pager positioned on the second of four pages + render() + + // Then each radio shows its page number and the active page uses the selected control treatment + const radios = screen.getAllByRole('radio') + expect(radios).toHaveLength(4) + expect(radios.map((radio) => radio.textContent)).toEqual(['1', '2', '3', '4']) + expect(radios[0].querySelector('[aria-hidden="true"]')).toHaveClass('text-fg-dim') + expect(radios[1].querySelector('[aria-hidden="true"]')).toHaveClass('bg-accent', 'text-accent-ink') + }) + + it('gives numbered controls distinct operational geometry and surfaces', () => { + // Given a numbered pager on its first page + render() + + // Then the navigator uses a wrapping grid with deliberate spacing + const group = screen.getByRole('radiogroup', { name: 'Frames' }) + expect(group.parentElement).toHaveClass('grid', 'grid-cols-[auto_minmax(0,1fr)_auto]', 'gap-2') + expect(group).toHaveClass('flex-wrap', 'gap-1.5') + + // And every step and direct choice is a bordered 32px operational target + const previous = screen.getByRole('button', { name: 'Previous page' }) + const next = screen.getByRole('button', { name: 'Next page' }) + expect(previous).toHaveClass('size-10', 'border', 'border-border', 'bg-panel') + expect(previous).toHaveClass('disabled:opacity-50') + expect(previous).toBeDisabled() + expect(next).toHaveClass('size-10', 'border', 'border-border', 'bg-panel') + expect(next).toBeEnabled() + + const radios = screen.getAllByRole('radio') + expect(radios.map((radio) => radio.textContent)).toEqual(['1', '2', '3']) + for (const radio of radios) { + expect(radio).toHaveClass('size-10', 'border') + } + expect(radios[0]).toHaveClass('border-accent', 'bg-accent', 'text-accent-ink') + expect(radios[1]).toHaveClass('border-border', 'bg-panel', 'hover:border-border-strong', 'hover:bg-panel-strong') + }) + + it('supports direct and keyboard numbered movement with a domain status', async () => { + // Given a controlled numbered frame pager + const user = userEvent.setup() + const onValueChange = vi.fn() + const statusLabel = (index: number, count: number) => `Frame ${index + 1} of ${count}` + const { rerender } = render( + + ) + + // Then the first boundary and live status are explicit + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled() + expect(screen.getByRole('status')).toHaveTextContent('Frame 1 of 3') + + // When a numbered choice is selected directly + await user.click(screen.getByRole('radio', { name: 'Page 3 of 3' })) + + // Then the direct logical index is reported + expect(onValueChange).toHaveBeenLastCalledWith(2) + + // When the controlled value moves to frame two and ArrowRight is pressed + rerender( + + ) + screen.getByRole('radio', { name: 'Page 2 of 3' }).focus() + await user.keyboard('{ArrowRight}') + + // Then one logical frame is requested + expect(onValueChange).toHaveBeenLastCalledWith(2) + + // When the controlled pager reaches the final frame + rerender( + + ) + + // Then the final boundary and domain status are explicit + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Previous page' })).toBeEnabled() + expect(screen.getByRole('status')).toHaveTextContent('Frame 3 of 3') + }) + + it('uses zero adjacent spacing so seven targets and arrows fit the narrow contract', () => { + // Given the maximum visible page window + render() + + // Then nine 24px controls occupy 216px before zero-width visual gaps + expect(screen.getByRole('radiogroup').parentElement).toHaveClass('gap-0') + expect(screen.getByRole('radiogroup')).toHaveClass('gap-0') + expect(screen.getByRole('button', { name: 'Previous page' })).toHaveClass('size-6') + expect(screen.getByRole('button', { name: 'Next page' })).toHaveClass('size-6') + expect(screen.getAllByRole('radio')).toHaveLength(7) + for (const radio of screen.getAllByRole('radio')) { + expect(radio).toHaveClass('size-6') + } + for (const gap of screen.getAllByTestId('pager-gap')) { + expect(gap).toHaveClass('w-0', 'text-foreground') + } + }) + + it('announces the current page once through a polite status', () => { + // Given a pager with multiple pages + const { rerender } = render() + + // Then the current page is available as one non-focus-moving live status + const status = screen.getByRole('status') + expect(status).toHaveTextContent('Page 1 of 4') + expect(status).toHaveAttribute('aria-live', 'polite') + expect(status).toHaveAttribute('aria-atomic', 'true') + + // When the controlled page changes + rerender() + + // Then only the status text changes + expect(screen.getByRole('status')).toHaveTextContent('Page 2 of 4') + }) + + it('disables the indicator transition under reduced motion', () => { + // Given a pager with active and inactive indicators + render() + + // Then indicator motion has an explicit reduced-motion override + expect(screen.getAllByRole('radio')[0].querySelector('[aria-hidden="true"]')).toHaveClass( + 'motion-reduce:transition-none' + ) + }) + + it('bounds large page selections while retaining the current, first, and last pages', async () => { + // Given a pager with 84 pages positioned in the middle + const user = userEvent.setup() + const onValueChange = vi.fn() + render( + `Page ${index + 1} of 84`} + value={41} + onValueChange={onValueChange} + /> + ) + + // Then the visible page choices remain bounded and retain navigation anchors + const radios = screen.getAllByRole('radio') + expect(radios.length).toBeLessThanOrEqual(7) + expect(screen.getByRole('radio', { name: 'Page 1 of 84' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Page 42 of 84' })).toBeChecked() + expect(screen.getByRole('radio', { name: 'Page 84 of 84' })).toBeInTheDocument() + + // When the reader uses previous and next navigation + await user.click(screen.getByRole('button', { name: 'Previous page' })) + await user.click(screen.getByRole('button', { name: 'Next page' })) + + // Then each control advances exactly one page + expect(onValueChange).toHaveBeenNthCalledWith(1, 40) + expect(onValueChange).toHaveBeenNthCalledWith(2, 42) + }) + + it('keeps visible large-page choices in Radix arrow-key order', async () => { + // Given a bounded page window focused on the current page + const user = userEvent.setup() + render() + const current = screen.getByRole('radio', { name: 'Page 42 of 84' }) + + // When the reader moves forward with the horizontal arrow key + current.focus() + await user.keyboard('{ArrowRight}') + + // Then focus advances to the next visible radio without adding controls + expect(screen.getByRole('radio', { name: 'Page 43 of 84' })).toHaveFocus() + expect(screen.getAllByRole('radio')).toHaveLength(7) + }) + + it('marks nonadjacent visible page choices with hidden visual gaps', () => { + // Given a bounded pager around page six of a large collection + render() + + // Then gaps separate nonconsecutive choices without becoming radio options or focus targets + const gaps = screen.getAllByTestId('pager-gap') + expect(gaps).toHaveLength(2) + for (const gap of gaps) { + expect(gap).toHaveAttribute('aria-hidden', 'true') + expect(gap).not.toHaveAttribute('role') + expect(gap).not.toHaveAttribute('tabindex') + } + expect(screen.getAllByRole('radio')).toHaveLength(7) + expect(screen.getByRole('radio', { name: 'Page 1 of 125' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Page 4 of 125' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Page 8 of 125' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Page 125 of 125' })).toBeInTheDocument() + }) + + it('moves one logical page per horizontal arrow and clamps at both ends', async () => { + // Given a large pager positioned on page six + const user = userEvent.setup() + const onValueChange = vi.fn() + const { rerender } = render() + const current = screen.getByRole('radio', { name: 'Page 6 of 125' }) + + // When the reader presses ArrowRight + current.focus() + await user.keyboard('{ArrowRight}') + + // Then exactly the next logical page is requested + expect(onValueChange).toHaveBeenLastCalledWith(6) + + // When the controlled pager recomputes around page seven + rerender() + + // Then the selected page remains in the visible window + expect(screen.getByRole('radio', { name: 'Page 7 of 125' })).toBeChecked() + + // When the pager is at the first page and ArrowLeft is pressed + rerender() + screen.getByRole('radio', { name: 'Page 1 of 125' }).focus() + await user.keyboard('{ArrowLeft}') + expect(onValueChange).toHaveBeenLastCalledWith(0) + + // When the pager is at the last page and ArrowRight is pressed + rerender() + screen.getByRole('radio', { name: 'Page 125 of 125' }).focus() + await user.keyboard('{ArrowRight}') + expect(onValueChange).toHaveBeenLastCalledWith(124) + }) + + it('moves one logical page for vertical arrow keys', async () => { + // Given a large pager positioned on page six + const user = userEvent.setup() + const onValueChange = vi.fn() + render() + const current = screen.getByRole('radio', { name: 'Page 6 of 125' }) + current.focus() + + // When the reader presses ArrowDown and ArrowUp + await user.keyboard('{ArrowDown}') + await user.keyboard('{ArrowUp}') + + // Then each key requests one adjacent logical page + expect(onValueChange).toHaveBeenNthCalledWith(1, 6) + expect(onValueChange).toHaveBeenNthCalledWith(2, 4) + }) + + it('steps through pages and clamps at both ends', async () => { + // Given a pager on the first of three pages + const user = userEvent.setup() + const onValueChange = vi.fn() + const { rerender } = render() + + // Then the backwards step is unavailable and the forwards step advances + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled() + await user.click(screen.getByRole('button', { name: 'Next page' })) + expect(onValueChange).toHaveBeenCalledWith(1) + + // When the pager reaches the final page + rerender() + + // Then the forwards step is unavailable + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Previous page' })).toBeEnabled() + }) + + it('selects a page directly from its dot', async () => { + // Given a pager across three pages + const user = userEvent.setup() + const onValueChange = vi.fn() + render() + + // When the last dot is chosen + await user.click(screen.getByRole('radio', { name: 'Page 3 of 3' })) + + // Then the pager reports that page + expect(onValueChange).toHaveBeenCalledWith(2) + }) + + it('uses caller-supplied labels', () => { + // Given a pager with domain labels + render( + `Segment ${index + 1}`} + previousLabel="Earlier events" + value={0} + onValueChange={vi.fn()} + /> + ) + + // Then those labels reach assistive technology + expect(screen.getByRole('button', { name: 'Later events' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Earlier events' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: 'Segment 2' })).toBeInTheDocument() + }) +}) diff --git a/crates/mesh-llm-ui/src/components/ui/Pager.tsx b/crates/mesh-llm-ui/src/components/ui/Pager.tsx new file mode 100644 index 0000000000..bad40be969 --- /dev/null +++ b/crates/mesh-llm-ui/src/components/ui/Pager.tsx @@ -0,0 +1,169 @@ +import * as RadioGroup from '@radix-ui/react-radio-group' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { Fragment } from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/cn' + +type PagerProps = { + readonly ariaLabel: string + readonly className?: string + readonly count: number + readonly nextLabel?: string + readonly pageLabel?: (index: number) => string + readonly previousLabel?: string + readonly statusLabel?: (index: number, count: number) => string + readonly value: number + readonly variant?: 'dots' | 'numbered' + readonly onValueChange: (value: number) => void +} + +const dotStepClassName = 'size-6 rounded-full text-fg-dim hover:text-foreground disabled:opacity-40' + +const dotRadioClassName = + 'inline-grid size-6 shrink-0 place-items-center rounded-full p-0 outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent' + +const dotClassName = + 'rounded-full transition-[width,background-color] duration-150 ease-out motion-reduce:transition-none' + +const numberedStepClassName = + 'size-10 rounded-[var(--radius-control)] border border-border bg-panel text-fg-dim transition-colors hover:border-border-strong hover:bg-panel-strong hover:text-foreground disabled:pointer-events-none disabled:border-border-soft disabled:bg-panel disabled:text-fg-faint disabled:opacity-50' + +const numberedRadioClassName = + 'inline-grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border p-0 font-mono type-caption tabular-nums outline-none transition-colors duration-150 ease-out focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent motion-reduce:transition-none' + +const numberClassName = + 'grid size-full place-items-center rounded-[var(--radius-control)] font-mono type-caption tabular-nums transition-colors duration-150 ease-out motion-reduce:transition-none' + +const MAX_VISIBLE_PAGE_ITEMS = 7 + +const defaultStatusLabel = (index: number, count: number) => `Page ${index + 1} of ${count}` + +function visiblePageIndexes(count: number, current: number): readonly number[] { + if (count <= MAX_VISIBLE_PAGE_ITEMS) return Array.from({ length: count }, (_, index) => index) + + const windowSize = MAX_VISIBLE_PAGE_ITEMS - 2 + const windowStart = Math.max(1, Math.min(current - Math.floor(windowSize / 2), count - windowSize - 1)) + return [0, ...Array.from({ length: windowSize }, (_, offset) => windowStart + offset), count - 1] +} + +export function Pager({ + ariaLabel, + className, + count, + nextLabel = 'Next page', + pageLabel, + previousLabel = 'Previous page', + statusLabel = defaultStatusLabel, + value, + variant = 'dots', + onValueChange +}: PagerProps) { + if (count < 2) return null + const clamped = (next: number) => Math.min(count - 1, Math.max(0, next)) + const currentPage = clamped(value) + const pageIndexes = visiblePageIndexes(count, currentPage) + const numbered = variant === 'numbered' + + return ( +
+ + { + const direction = + event.key === 'ArrowLeft' || event.key === 'ArrowUp' + ? -1 + : event.key === 'ArrowRight' || event.key === 'ArrowDown' + ? 1 + : 0 + if (direction === 0) return + event.preventDefault() + onValueChange(clamped(currentPage + direction)) + }} + onValueChange={(next) => onValueChange(clamped(Number(next)))} + orientation="horizontal" + value={String(currentPage)} + > + {pageIndexes.map((index, position) => { + const hasGap = position < pageIndexes.length - 1 && pageIndexes[position + 1] !== index + 1 + return ( + + + + + {hasGap ? ( + + ) : null} + + ) + })} + + + {statusLabel(currentPage, count)} + + +
+ ) +} diff --git a/crates/mesh-llm-ui/src/components/ui/SegmentedControl.test.tsx b/crates/mesh-llm-ui/src/components/ui/SegmentedControl.test.tsx index 830d9c5e58..ae4da70eee 100644 --- a/crates/mesh-llm-ui/src/components/ui/SegmentedControl.test.tsx +++ b/crates/mesh-llm-ui/src/components/ui/SegmentedControl.test.tsx @@ -9,6 +9,34 @@ const options = [ ] describe('SegmentedControl', () => { + it.each(['buttons', 'pill'] as const)('uses a semantic focus ring for the %s variant', (variant) => { + // Given + render( + + ) + + // When + const selectedItem = screen.getByRole('radio', { name: 'On' }) + + // Then + expect(selectedItem).toHaveClass( + 'focus-visible:outline-none', + 'focus-visible:!ring-2', + 'focus-visible:!ring-accent-contrast', + 'focus-visible:!ring-offset-1', + 'focus-visible:!ring-offset-background' + ) + expect(selectedItem).not.toHaveClass('focus-visible:!ring-accent') + expect(selectedItem).not.toHaveClass('focus-visible:outline', 'focus-visible:outline-2') + }) + it('renders radio group with the given options', () => { render( export function SegmentedControl({ diff --git a/crates/mesh-llm-ui/src/components/ui/SharedModal.tsx b/crates/mesh-llm-ui/src/components/ui/SharedModal.tsx index 65bb789ce6..007bb3a768 100644 --- a/crates/mesh-llm-ui/src/components/ui/SharedModal.tsx +++ b/crates/mesh-llm-ui/src/components/ui/SharedModal.tsx @@ -78,7 +78,7 @@ function SharedModalActionStrip({ className, ...props }: React.HTMLAttributes { } render() + await user.click(screen.getByRole('button', { name: /Go to next page/ })) await user.click(screen.getByRole('button', { name: /Name/i })) await user.click(await screen.findByRole('menuitem', { name: 'Asc' })) @@ -38,15 +39,22 @@ describe('DataTable', () => { expect(renders).toBe(settledRenders) expect(renders).toBeLessThan(20) expect(screen.getByRole('button', { name: 'Name, sorted ascending' })).toBeInTheDocument() + expect(screen.getByText('row-10')).toBeInTheDocument() }) it('settles after a page change instead of re-rendering in a loop', async () => { const user = userEvent.setup() - render() + const { rerender } = render() await user.click(screen.getByRole('button', { name: /Go to next page/ })) expect(screen.getByText('row-10')).toBeInTheDocument() expect(screen.queryByText('row-0')).not.toBeInTheDocument() + + const refreshedRows = rows.map((row) => ({ ...row, name: `${row.name}-refreshed` })) + rerender() + + expect(screen.getByText('row-10-refreshed')).toBeInTheDocument() + expect(screen.queryByText('row-0-refreshed')).not.toBeInTheDocument() }) it('settles while typing a filter instead of re-rendering in a loop', async () => { @@ -67,6 +75,141 @@ describe('DataTable', () => { expect(renders).toBeLessThan(20) }) + it('clamps to the final page when refreshed data shrinks', async () => { + const user = userEvent.setup() + const { rerender } = render() + + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + expect(screen.getByText('row-20')).toBeInTheDocument() + + rerender() + + expect(screen.getByText('row-10')).toBeInTheDocument() + expect(screen.queryByText('row-0')).not.toBeInTheDocument() + }) + + it('never commits an impossible page while refreshed data shrinks', async () => { + const user = userEvent.setup() + const snapshots: Array<{ pageIndex: number; pageCount: number; rowCount: number }> = [] + const { rerender } = render( + + {(table) => { + snapshots.push({ + pageIndex: table.state.pagination.pageIndex, + pageCount: table.getPageCount(), + rowCount: table.getRowModel().rows.length + }) + return null + }} + + ) + + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + rerender( + + {(table) => { + snapshots.push({ + pageIndex: table.state.pagination.pageIndex, + pageCount: table.getPageCount(), + rowCount: table.getRowModel().rows.length + }) + return null + }} + + ) + + expect(snapshots).not.toContainEqual({ pageIndex: 2, pageCount: 2, rowCount: 0 }) + expect(snapshots.at(-1)).toEqual({ pageIndex: 1, pageCount: 2, rowCount: 5 }) + }) + + it('clamps to the final page when filtering reduces the page count', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + expect(screen.getByText('row-20')).toBeInTheDocument() + + await user.type(screen.getByLabelText('Filter...'), 'row-1') + + expect(screen.getByText('row-19')).toBeInTheDocument() + expect(screen.queryByText('row-0')).not.toBeInTheDocument() + }) + + it('never commits an impossible page while filtering reduces the page count', async () => { + const user = userEvent.setup() + const snapshots: Array<{ pageIndex: number; pageCount: number; rowCount: number }> = [] + render( + + {(table) => { + snapshots.push({ + pageIndex: table.state.pagination.pageIndex, + pageCount: table.getPageCount(), + rowCount: table.getRowModel().rows.length + }) + return null + }} + + ) + + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + await user.type(screen.getByLabelText('Filter...'), 'row-1') + + expect(snapshots).not.toContainEqual({ pageIndex: 2, pageCount: 2, rowCount: 0 }) + expect(snapshots.at(-1)).toEqual({ pageIndex: 1, pageCount: 2, rowCount: 1 }) + }) + + it('renders the valid empty page immediately when all data is removed', async () => { + const user = userEvent.setup() + const snapshots: Array<{ pageIndex: number; pageCount: number; rowCount: number }> = [] + const { rerender } = render( + + {(table) => { + snapshots.push({ + pageIndex: table.state.pagination.pageIndex, + pageCount: table.getPageCount(), + rowCount: table.getRowModel().rows.length + }) + return null + }} + + ) + + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + rerender( + + {(table) => { + snapshots.push({ + pageIndex: table.state.pagination.pageIndex, + pageCount: table.getPageCount(), + rowCount: table.getRowModel().rows.length + }) + return null + }} + + ) + + expect(snapshots.at(-1)).toEqual({ pageIndex: 0, pageCount: 0, rowCount: 0 }) + expect(screen.getByText('Page 0 of 0')).toBeInTheDocument() + expect(screen.getByText('No results.')).toBeInTheDocument() + }) + + it('clamps the current page when a larger page size reduces page count', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + await user.click(screen.getByRole('button', { name: /Go to next page/ })) + await user.selectOptions(screen.getByRole('combobox', { name: 'Rows per page' }), '25') + + expect(screen.getByText('Page 1 of 1')).toBeInTheDocument() + expect(screen.getByText('row-0')).toBeInTheDocument() + }) + it('reflects column visibility changes when the Columns menu is reopened', async () => { const user = userEvent.setup() render( diff --git a/crates/mesh-llm-ui/src/components/ui/data-table.tsx b/crates/mesh-llm-ui/src/components/ui/data-table.tsx index 964b65f1bc..478d9c7245 100644 --- a/crates/mesh-llm-ui/src/components/ui/data-table.tsx +++ b/crates/mesh-llm-ui/src/components/ui/data-table.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react' +import { startTransition, useEffect, useMemo, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react' import { type ColumnFiltersState, type ColumnVisibilityState, @@ -78,11 +78,30 @@ export function DataTable({ onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, onPaginationChange: setPagination, + autoResetPageIndex: false, manualPagination: !enablePagination }), [columnFilters, columnVisibility, columns, data, enablePagination, getRowId, pagination, sorting] ) - const table = useTable(tableOptions) + const rowModelTable = useTable(tableOptions) + const filteredRowCount = rowModelTable.getFilteredRowModel().rows.length + const lastPageIndex = Math.max(Math.ceil(filteredRowCount / pagination.pageSize) - 1, 0) + const effectiveTableOptions = useMemo(() => { + if (pagination.pageIndex <= lastPageIndex) return tableOptions + const effectivePagination = { ...pagination, pageIndex: lastPageIndex } + return { + ...tableOptions, + state: { ...tableOptions.state, pagination: effectivePagination } + } + }, [lastPageIndex, pagination, tableOptions]) + const table = useTable(effectiveTableOptions) + + useEffect(() => { + const nextPageIndex = Math.max(Math.ceil(filteredRowCount / pagination.pageSize) - 1, 0) + if (pagination.pageIndex <= nextPageIndex) return + + startTransition(() => setPagination((current) => ({ ...current, pageIndex: nextPageIndex }))) + }, [filteredRowCount, pagination.pageIndex, pagination.pageSize]) const filterValue = filterColumnId ? ((table.getColumn(filterColumnId)?.getFilterValue() as string) ?? '') : undefined diff --git a/crates/mesh-llm-ui/src/components/ui/scroll-area.test.tsx b/crates/mesh-llm-ui/src/components/ui/scroll-area.test.tsx new file mode 100644 index 0000000000..8a21dc16c2 --- /dev/null +++ b/crates/mesh-llm-ui/src/components/ui/scroll-area.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom + +import '@testing-library/jest-dom/vitest' + +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { ScrollArea } from '@/components/ui/scroll-area' + +describe('ScrollArea', () => { + it('renders the vertical scrollbar by default', () => { + // Given / When + render( + + Payload + + ) + + // Then + const viewport = screen.getByRole('region', { name: 'Default content' }) + const scrollArea = viewport.parentElement + expect(scrollArea?.querySelector('[data-orientation="vertical"]')).toBeInTheDocument() + expect(scrollArea?.querySelector('[data-orientation="horizontal"]')).not.toBeInTheDocument() + }) + + it('suppresses only the vertical scrollbar when horizontal scrolling is enabled', () => { + // Given / When + render( + + Payload + + ) + + // Then + const viewport = screen.getByRole('region', { name: 'Horizontal payload' }) + const scrollArea = viewport.parentElement + expect(viewport).toHaveAttribute('tabindex', '0') + expect(viewport).not.toHaveClass('overflow-x-hidden') + expect(scrollArea?.querySelector('[data-orientation="vertical"]')).not.toBeInTheDocument() + expect(scrollArea?.querySelector('[data-orientation="horizontal"]')).toBeInTheDocument() + }) +}) diff --git a/crates/mesh-llm-ui/src/components/ui/scroll-area.tsx b/crates/mesh-llm-ui/src/components/ui/scroll-area.tsx index f00a0a987d..4197d6d976 100644 --- a/crates/mesh-llm-ui/src/components/ui/scroll-area.tsx +++ b/crates/mesh-llm-ui/src/components/ui/scroll-area.tsx @@ -7,19 +7,21 @@ const ScrollArea = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { readonly horizontal?: boolean + readonly vertical?: boolean + readonly viewportClassName?: string readonly viewportLabel?: string } ->(({ className, children, horizontal = false, viewportLabel, ...props }, ref) => ( +>(({ className, children, horizontal = false, vertical = true, viewportClassName, viewportLabel, ...props }, ref) => ( {children} - + {vertical ? : null} {horizontal ? : null} diff --git a/crates/mesh-llm-ui/src/features/logs/api/audit-terminal-recovery.test.ts b/crates/mesh-llm-ui/src/features/logs/api/audit-terminal-recovery.test.ts new file mode 100644 index 0000000000..d8a2c7e55a --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/audit-terminal-recovery.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' +import { + beginAuditTerminalRecovery, + completeAuditTerminalEof, + completeAuditTerminalHydration, + type AuditTerminalRecovery +} from './audit-terminal-recovery' + +type AuditSource = { readonly id: string } +type PendingHydrationPhase = 'hydrating' | 'awaiting_hydration' +type SettledHydrationPhase = 'awaiting_eof' | 'failed' | 'replaced' +type TerminalRecoveryPhase = 'failed' | 'replaced' + +const ORIGINAL_SOURCE: AuditSource = { id: 'original' } +const REPLACEMENT_SOURCE: AuditSource = { id: 'replacement' } +const PENDING_HYDRATION_PHASES: readonly PendingHydrationPhase[] = ['hydrating', 'awaiting_hydration'] +const SETTLED_HYDRATION_PHASES: readonly SettledHydrationPhase[] = ['awaiting_eof', 'failed', 'replaced'] +const TERMINAL_RECOVERY_PHASES: readonly TerminalRecoveryPhase[] = ['failed', 'replaced'] +const RECOVERIES: readonly AuditTerminalRecovery[] = [ + { phase: 'hydrating', source: ORIGINAL_SOURCE }, + { phase: 'awaiting_eof', source: ORIGINAL_SOURCE }, + { phase: 'awaiting_hydration', source: ORIGINAL_SOURCE }, + { phase: 'failed', source: ORIGINAL_SOURCE }, + { phase: 'replaced', source: ORIGINAL_SOURCE } +] + +describe('beginAuditTerminalRecovery', () => { + it('starts hydration when no terminal recovery exists', () => { + const transition = beginAuditTerminalRecovery(undefined, ORIGINAL_SOURCE) + + expect(transition).toEqual({ + recovery: { phase: 'hydrating', source: ORIGINAL_SOURCE }, + shouldReconnect: false, + shouldMarkStale: false + }) + }) + + it('starts hydration for a different source', () => { + const transition = beginAuditTerminalRecovery( + { phase: 'awaiting_eof', source: ORIGINAL_SOURCE }, + REPLACEMENT_SOURCE + ) + + expect(transition).toEqual({ + recovery: { phase: 'hydrating', source: REPLACEMENT_SOURCE }, + shouldReconnect: false, + shouldMarkStale: false + }) + }) + + it.each(RECOVERIES)('ignores a duplicate begin for the same source from $phase', (recovery) => { + const transition = beginAuditTerminalRecovery(recovery, ORIGINAL_SOURCE) + + expect(transition).toEqual({ recovery, shouldReconnect: false, shouldMarkStale: false }) + expect(transition.recovery).toBe(recovery) + }) +}) + +describe('completeAuditTerminalHydration', () => { + it('waits for EOF when hydration succeeds first', () => { + const transition = completeAuditTerminalHydration({ phase: 'hydrating', source: ORIGINAL_SOURCE }, true) + + expect(transition).toEqual({ + recovery: { phase: 'awaiting_eof', source: ORIGINAL_SOURCE }, + shouldReconnect: false, + shouldMarkStale: false + }) + }) + + it('replaces the source when hydration succeeds after EOF', () => { + const transition = completeAuditTerminalHydration({ phase: 'awaiting_hydration', source: ORIGINAL_SOURCE }, true) + + expect(transition).toEqual({ + recovery: { phase: 'replaced', source: ORIGINAL_SOURCE }, + shouldReconnect: true, + shouldMarkStale: false + }) + }) + + it('preserves an awaiting EOF recovery after duplicate hydration success', () => { + const recovery: AuditTerminalRecovery = { phase: 'awaiting_eof', source: ORIGINAL_SOURCE } + + const transition = completeAuditTerminalHydration(recovery, true) + + expect(transition).toEqual({ recovery, shouldReconnect: false, shouldMarkStale: false }) + expect(transition.recovery).toBe(recovery) + }) + + it.each(PENDING_HYDRATION_PHASES)('marks hydration failure stale from %s', (phase) => { + const recovery: AuditTerminalRecovery = { phase, source: ORIGINAL_SOURCE } + + const transition = completeAuditTerminalHydration(recovery, false) + + expect(transition).toEqual({ + recovery: { phase: 'failed', source: ORIGINAL_SOURCE }, + shouldReconnect: false, + shouldMarkStale: true + }) + }) + + it.each(SETTLED_HYDRATION_PHASES)('ignores late hydration failure from %s', (phase) => { + const recovery: AuditTerminalRecovery = { phase, source: ORIGINAL_SOURCE } + + const transition = completeAuditTerminalHydration(recovery, false) + + expect(transition).toEqual({ recovery, shouldReconnect: false, shouldMarkStale: false }) + expect(transition.recovery).toBe(recovery) + }) + + it.each(TERMINAL_RECOVERY_PHASES)('ignores late hydration success from %s', (phase) => { + const recovery: AuditTerminalRecovery = { phase, source: ORIGINAL_SOURCE } + + const transition = completeAuditTerminalHydration(recovery, true) + + expect(transition).toEqual({ recovery, shouldReconnect: false, shouldMarkStale: false }) + expect(transition.recovery).toBe(recovery) + }) +}) + +describe('completeAuditTerminalEof', () => { + it('waits for hydration when EOF arrives first', () => { + const transition = completeAuditTerminalEof({ phase: 'hydrating', source: ORIGINAL_SOURCE }) + + expect(transition).toEqual({ + recovery: { phase: 'awaiting_hydration', source: ORIGINAL_SOURCE }, + shouldReconnect: false, + shouldMarkStale: false + }) + }) + + it('replaces the source when EOF arrives after hydration', () => { + const transition = completeAuditTerminalEof({ phase: 'awaiting_eof', source: ORIGINAL_SOURCE }) + + expect(transition).toEqual({ + recovery: { phase: 'replaced', source: ORIGINAL_SOURCE }, + shouldReconnect: true, + shouldMarkStale: false + }) + }) + + it('preserves recovery when EOF repeats while hydration is pending', () => { + const recovery: AuditTerminalRecovery = { + phase: 'awaiting_hydration', + source: ORIGINAL_SOURCE + } + + const transition = completeAuditTerminalEof(recovery) + + expect(transition).toEqual({ recovery, shouldReconnect: false, shouldMarkStale: false }) + expect(transition.recovery).toBe(recovery) + }) + + it('keeps a failed recovery stale after late EOF', () => { + const recovery: AuditTerminalRecovery = { phase: 'failed', source: ORIGINAL_SOURCE } + + const transition = completeAuditTerminalEof(recovery) + + expect(transition).toEqual({ recovery, shouldReconnect: false, shouldMarkStale: true }) + expect(transition.recovery).toBe(recovery) + }) + + it('ignores late EOF after the source was replaced', () => { + const recovery: AuditTerminalRecovery = { phase: 'replaced', source: ORIGINAL_SOURCE } + + const transition = completeAuditTerminalEof(recovery) + + expect(transition).toEqual({ recovery, shouldReconnect: false, shouldMarkStale: false }) + expect(transition.recovery).toBe(recovery) + }) +}) diff --git a/crates/mesh-llm-ui/src/features/logs/api/audit-terminal-recovery.ts b/crates/mesh-llm-ui/src/features/logs/api/audit-terminal-recovery.ts new file mode 100644 index 0000000000..063026fbf5 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/audit-terminal-recovery.ts @@ -0,0 +1,89 @@ +type AuditTerminalRecoveryPhase = 'hydrating' | 'awaiting_eof' | 'awaiting_hydration' | 'failed' | 'replaced' + +export type AuditTerminalRecovery = { + readonly phase: AuditTerminalRecoveryPhase + readonly source: Source +} + +export type AuditTerminalRecoveryTransition = { + readonly recovery: AuditTerminalRecovery + readonly shouldReconnect: boolean + readonly shouldMarkStale: boolean +} + +function remain( + recovery: AuditTerminalRecovery, + shouldMarkStale = false +): AuditTerminalRecoveryTransition { + return { recovery, shouldReconnect: false, shouldMarkStale } +} + +function advance( + recovery: AuditTerminalRecovery, + phase: AuditTerminalRecoveryPhase, + shouldReconnect = false +): AuditTerminalRecoveryTransition { + return { + recovery: { phase, source: recovery.source }, + shouldReconnect, + shouldMarkStale: false + } +} + +function fail(recovery: AuditTerminalRecovery): AuditTerminalRecoveryTransition { + return { recovery: { phase: 'failed', source: recovery.source }, shouldReconnect: false, shouldMarkStale: true } +} + +function unreachablePhase(phase: never): never { + throw new RangeError(`Unknown audit terminal recovery phase: ${phase}`) +} + +export function beginAuditTerminalRecovery( + recovery: AuditTerminalRecovery | undefined, + source: Source +): AuditTerminalRecoveryTransition { + if (recovery?.source === source) return remain(recovery) + return { + recovery: { phase: 'hydrating', source }, + shouldReconnect: false, + shouldMarkStale: false + } +} + +export function completeAuditTerminalHydration( + recovery: AuditTerminalRecovery, + succeeded: boolean +): AuditTerminalRecoveryTransition { + const phase = recovery.phase + switch (phase) { + case 'hydrating': + return succeeded ? advance(recovery, 'awaiting_eof') : fail(recovery) + case 'awaiting_hydration': + return succeeded ? advance(recovery, 'replaced', true) : fail(recovery) + case 'awaiting_eof': + case 'failed': + case 'replaced': + return remain(recovery) + default: + return unreachablePhase(phase) + } +} + +export function completeAuditTerminalEof( + recovery: AuditTerminalRecovery +): AuditTerminalRecoveryTransition { + const phase = recovery.phase + switch (phase) { + case 'hydrating': + return advance(recovery, 'awaiting_hydration') + case 'awaiting_eof': + return advance(recovery, 'replaced', true) + case 'failed': + return remain(recovery, true) + case 'awaiting_hydration': + case 'replaced': + return remain(recovery) + default: + return unreachablePhase(phase) + } +} diff --git a/crates/mesh-llm-ui/src/features/logs/api/client-info-schemas.test.ts b/crates/mesh-llm-ui/src/features/logs/api/client-info-schemas.test.ts new file mode 100644 index 0000000000..cc85cd88d2 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/client-info-schemas.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { parseAuditEntry, parseLogRequest } from '@/features/logs/api/schemas' + +const ENDPOINT_ID = '9f0c4cbe8cb7a8d5d577c20e50ef03fd2f63a2e7fd9897c155823bcbb281bb04' +const TIMESTAMP = '2026-02-20T14:22:12.944Z' + +function requestDto(requestId: string) { + return { + requestId, + outcome: 'completed', + createdAt: TIMESTAMP, + terminalAt: TIMESTAMP, + route: 'chat_completions', + model: 'Qwen3-30B-A3B-Q4_K_M.gguf', + provider: 'openai_frontend', + engine: 'skippy', + statusCode: 200, + source: 'durable' + } +} + +describe('client information schemas', () => { + it('retains optional peer fields on mesh audit entries', () => { + const parsed = parseAuditEntry({ + entryId: 'audit-gossip-1', + occurredAt: '2026-02-20T14:22:08.301Z', + sequence: 18, + severity: 'info', + code: 'gossip_peer_discovered', + source: 'mesh', + subjectKind: 'mesh_peer', + subjectId: ENDPOINT_ID, + remoteAddr: '203.0.113.24:48712', + pathType: 'direct' + }) + + expect(parsed).toMatchObject({ + subjectKind: 'mesh_peer', + subjectId: ENDPOINT_ID, + remoteAddr: '203.0.113.24:48712', + pathType: 'direct' + }) + }) + + it('accepts relay peer entries and legacy audit entries without client fields', () => { + const relay = parseAuditEntry({ + entryId: 'audit-quic-1', + occurredAt: '2026-02-20T14:22:10.944Z', + sequence: 19, + severity: 'warning', + code: 'quic_path_degraded', + source: 'mesh', + subjectKind: 'mesh_peer', + subjectId: ENDPOINT_ID, + pathType: 'relay' + }) + const legacy = parseAuditEntry({ + entryId: 'audit-legacy-1', + occurredAt: '2026-02-20T14:22:11.944Z', + sequence: 20, + severity: 'info', + code: 'auto_join_started', + source: 'mesh' + }) + + expect(relay).toMatchObject({ subjectKind: 'mesh_peer', subjectId: ENDPOINT_ID, pathType: 'relay' }) + expect(legacy).not.toHaveProperty('subjectKind') + expect(legacy).not.toHaveProperty('remoteAddr') + expect(legacy).not.toHaveProperty('pathType') + }) + + it('retains optional caller fields while accepting legacy requests', () => { + const direct = parseLogRequest({ + ...requestDto('00000000-0000-4000-8000-000000000101'), + callerEndpointId: ENDPOINT_ID, + callerAddr: '203.0.113.24:48712', + callerPathType: 'remote_quic_http' + }) + const relay = parseLogRequest({ + ...requestDto('00000000-0000-4000-8000-000000000102'), + callerEndpointId: ENDPOINT_ID, + callerPathType: 'relay' + }) + const legacy = parseLogRequest(requestDto('00000000-0000-4000-8000-000000000103')) + + expect(direct).toMatchObject({ + callerEndpointId: ENDPOINT_ID, + callerAddr: '203.0.113.24:48712', + callerPathType: 'remote_quic_http' + }) + expect(relay).toMatchObject({ callerEndpointId: ENDPOINT_ID, callerPathType: 'relay' }) + expect(legacy).not.toHaveProperty('callerEndpointId') + expect(legacy).not.toHaveProperty('callerAddr') + expect(legacy).not.toHaveProperty('callerPathType') + }) + + it('rejects stage transport as a top-level request caller', () => { + expect(() => + parseLogRequest({ + ...requestDto('00000000-0000-4000-8000-000000000104'), + callerEndpointId: ENDPOINT_ID, + callerAddr: '203.0.113.24:48712', + callerPathType: 'remote_quic_stage' + }) + ).toThrow() + }) +}) diff --git a/crates/mesh-llm-ui/src/features/logs/api/client.test.ts b/crates/mesh-llm-ui/src/features/logs/api/client.test.ts index 182b2d8f60..8aa7020a18 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/client.test.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/client.test.ts @@ -103,7 +103,7 @@ describe('LogsApiClient', () => { error: { code: 'logging_schema_incompatible', message: 'the local log database schema is incompatible with this MeshLLM version', - details: { schema_version: 14, supported_schema_version: 11 } + details: { schema_version: 2, supported_schema_version: 1 } } }, 503 @@ -117,7 +117,7 @@ describe('LogsApiClient', () => { status: 503, code: 'logging_schema_incompatible', message: 'the local log database schema is incompatible with this MeshLLM version', - details: { schemaVersion: 14, supportedSchemaVersion: 11 } + details: { schemaVersion: 2, supportedSchemaVersion: 1 } }) }) @@ -281,6 +281,20 @@ describe('LogsApiClient', () => { expect(fetchMock).toHaveBeenCalledWith('/api/logs/requests?cursor=opaque+cursor%2B%2F%3D') }) + it('serializes exact and prefix route exclusions as singular request keys', async () => { + // Given + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ items: [], nextCursor: null })) + + // When + await new LogsApiClient(fetchMock).listRequests({ + excludeRoute: 'models', + excludeRoutePrefix: 'management_' + }) + + // Then + expect(fetchMock).toHaveBeenCalledWith('/api/logs/requests?exclude_route=models&exclude_route_prefix=management_') + }) + it('uses strict POST bodies for bounded export, cleanup, and request deletion', async () => { const operationId = LogOperationId.parse('00000000-0000-4000-8000-000000000002') const fetchMock = vi @@ -307,6 +321,7 @@ describe('LogsApiClient', () => { from: '2026-08-01T00:00:00Z', to: TIMESTAMP, route: 'reserve', + excludeRoute: 'models', model: 'Qwen/Qwen3', provider: 'reserve-a', engine: 'skippy', @@ -372,6 +387,7 @@ describe('LogsApiClient', () => { from: '2026-08-01T00:00:00Z', to: TIMESTAMP, route: 'reserve', + excludeRoute: 'models', model: 'Qwen/Qwen3', provider: 'reserve-a', engine: 'skippy', @@ -384,7 +400,12 @@ describe('LogsApiClient', () => { reason: 'incident cleanup' }) expect(preview.auditId.toString()).toBe(AUDIT_ID) - expect(preview.scope).toMatchObject({ source: 'durable', model: 'Qwen/Qwen3', outcome: 'completed' }) + expect(preview.scope.excludeRoute).toBe('models') + expect(preview.scope).toMatchObject({ + source: 'durable', + model: 'Qwen/Qwen3', + outcome: 'completed' + }) expect(completed.auditId.toString()).toBe(AUDIT_ID) expect(completed.scope).toMatchObject({ source: 'durable', model: 'Qwen/Qwen3', outcome: 'completed' }) expect(deleted.state).toBe('completed') @@ -558,6 +579,28 @@ describe('LogsApiClient', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it('filters harness route exclusions before applying the page limit', async () => { + // Given + const fetchMock = vi.fn() + const client = new LogsApiClient(fetchMock) + + // When + const result = await client.listRequests( + { limit: 2, excludeRoute: 'models', excludeRoutePrefix: 'management_' }, + 'harness' + ) + + // Then + expect(result).toMatchObject({ state: 'supported', value: { items: expect.any(Array) } }) + if (result.state === 'supported') { + expect(result.value.items).toHaveLength(2) + expect( + result.value.items.every((item) => item.route !== 'models' && !item.route?.startsWith('management_')) + ).toBe(true) + } + expect(fetchMock).not.toHaveBeenCalled() + }) + it('returns a typed harness not-found error for an unknown request', async () => { const fetchMock = vi.fn() const client = new LogsApiClient(fetchMock) diff --git a/crates/mesh-llm-ui/src/features/logs/api/client.ts b/crates/mesh-llm-ui/src/features/logs/api/client.ts index e664cb4859..c2e86ac443 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/client.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/client.ts @@ -63,6 +63,8 @@ export type LogsRequestQuery = { readonly from?: string readonly to?: string readonly route?: string + readonly excludeRoute?: string + readonly excludeRoutePrefix?: string readonly model?: string readonly provider?: string readonly engine?: string @@ -118,6 +120,7 @@ export type LogCleanupPreviewRequest = { readonly from?: string readonly to?: string readonly route?: string + readonly excludeRoute?: string readonly model?: string readonly provider?: string readonly engine?: string @@ -152,6 +155,8 @@ function serializeRequestQuery(input: LogsRequestQuery) { setQueryValue(query, 'from', input.from) setQueryValue(query, 'to', input.to) setQueryValue(query, 'route', input.route) + setQueryValue(query, 'exclude_route', input.excludeRoute) + setQueryValue(query, 'exclude_route_prefix', input.excludeRoutePrefix) setQueryValue(query, 'model', input.model) setQueryValue(query, 'provider', input.provider) setQueryValue(query, 'engine', input.engine) @@ -243,6 +248,8 @@ function filterHarnessRequests(items: readonly LogRequest[], query: LogsRequestQ if (query.from && compareLogInstants(item.createdAt, query.from) < 0) return false if (query.to && compareLogInstants(item.createdAt, query.to) > 0) return false if (query.route && item.route !== query.route) return false + if (query.excludeRoute && item.route === query.excludeRoute) return false + if (query.excludeRoutePrefix && item.route?.startsWith(query.excludeRoutePrefix)) return false if (query.model && item.model !== query.model) return false if (query.provider && item.provider !== query.provider) return false if (query.engine && item.engine !== query.engine) return false diff --git a/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptor-options.ts b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptor-options.ts new file mode 100644 index 0000000000..ca32a62cb2 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptor-options.ts @@ -0,0 +1,93 @@ +import type { SummaryDescriptor, SummaryRawKind } from './command-summary-descriptor-types' + +export const NONE = [] as const +export const JSON_FLAGS = ['--json'] as const +export const YES_JSON = ['--yes', '--json'] as const +export const SETUP_FLAGS = [ + '--yes', + '--no-interactive', + '--service', + '--no-service', + '--skip-runtime', + '--verbose' +] as const +export const UNINSTALL_FLAGS = [ + '--dry-run', + '--yes', + '--keep-cache', + '--keep-service-files', + '--purge-config', + '--keep-config', + '--json', + '--verbose' +] as const +export const DRAFT = ['--draft'] as const +export const DISCOVER_FLAGS = ['--auto'] as const +export const MODEL_PACKAGE_FLAGS = [ + '--experimental', + '--dry-run', + '--confirm', + '--follow', + '--list', + '--update-script', + '--json' +] as const +export const MODEL_PREPARE_FLAGS = [ + '--dry-run', + '--confirm', + '--follow', + '--json', + '--list', + '--update-script' +] as const +export const RUNTIME_LIST_FLAGS = ['--available', '--installed', '--json'] as const +export const RUNTIME_PRUNE_FLAGS = ['--active-only', '--json'] as const +export const AUTH_INIT_FLAGS = ['--force', '--no-passphrase', '--keychain'] as const +export const AUTH_ROTATE_NODE_FLAGS = ['--revoke-current'] as const +export const TUNE_FLAGS = [ + '--json', + '--no-speculative-tune', + '--apply', + '--replace-existing', + '--launch-args', + '--debug-telemetry' +] as const +export const SEARCH_FLAGS = ['--gguf', '--mlx', '--catalog', '--json'] as const +export const MODEL_CERTIFY_FLAGS = ['--json', '--package-only'] as const +export const SETUP_CONFLICTS = [['--service', '--no-service']] as const +export const UNINSTALL_CONFLICTS = [['--purge-config', '--keep-config']] as const +export const AUTH_INIT_CONFLICTS = [['--no-passphrase', '--keychain']] as const +export const UPDATE_CONFLICTS = [['--flavor', '--detect-flavor']] as const +export const PLUGIN_INSTALL_CONFLICTS = [['reference', '--archive']] as const +export const SKILLS_INSTALL_CONFLICTS = [['--agent', '--all']] as const +export const MODEL_SEARCH_CONFLICTS = [['--gguf', '--mlx']] as const +export const TUNE_CONFLICTS = [ + ['--model', '--models'], + ['--no-speculative-tune', '--speculative-types'], + ['--no-speculative-tune', '--spec-draft-models'], + ['--no-speculative-tune', '--spec-draft-max-tokens'], + ['--no-speculative-tune', '--spec-draft-min-tokens'], + ['--no-speculative-tune', '--spec-draft-acceptance-threshold'], + ['--no-speculative-tune', '--spec-draft-split-probability'], + ['--no-speculative-tune', '--spec-ngram-min'], + ['--no-speculative-tune', '--spec-ngram-max'] +] as const +export const RUNTIME_LIST_CONFLICTS = [['--available', '--installed']] as const +export const REMOTE_MODEL_CONFLICTS = [['--model', '--instance-id']] as const + +export const REDACTED_NAME = ['name'] as const +export const REDACTED_MODEL = ['model'] as const +export const REDACTED_REMOTE = ['--endpoint'] as const +export const REDACTED_REMOTE_MODEL = ['--endpoint', '--model', '--profile'] as const +export const REDACTED_APPLY_CONFIG = ['--endpoint', '--expected-revision', '--config'] as const + +const REDACTED_NONE = [] as const + +export const descriptor = ( + path: readonly string[], + booleans: readonly string[] = NONE, + redacted: readonly string[] = REDACTED_NONE, + hasPort = false, + raw: SummaryRawKind = 'none', + conflicts: readonly (readonly string[])[] = NONE +): SummaryDescriptor => ({ path, booleans, redacted, conflicts, hasPort, raw }) diff --git a/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptor-types.ts b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptor-types.ts new file mode 100644 index 0000000000..42c6fced17 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptor-types.ts @@ -0,0 +1,10 @@ +export type SummaryRawKind = 'backend' | 'mode' | 'none' + +export type SummaryDescriptor = { + readonly path: readonly string[] + readonly booleans: readonly string[] + readonly redacted: readonly string[] + readonly conflicts: readonly (readonly string[])[] + readonly hasPort: boolean + readonly raw: SummaryRawKind +} diff --git a/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-auth.ts b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-auth.ts new file mode 100644 index 0000000000..991a47bb35 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-auth.ts @@ -0,0 +1,51 @@ +import { + AUTH_INIT_CONFLICTS, + AUTH_INIT_FLAGS, + AUTH_ROTATE_NODE_FLAGS, + NONE, + descriptor +} from './command-summary-descriptor-options' +import type { SummaryDescriptor } from './command-summary-descriptor-types' + +export const AUTH_DESCRIPTORS: readonly SummaryDescriptor[] = [ + descriptor(['mesh-llm', 'auth', 'init'], AUTH_INIT_FLAGS, ['--owner-key'], false, 'none', AUTH_INIT_CONFLICTS), + descriptor(['mesh-llm', 'auth', 'status'], NONE, ['--owner-key', '--node-key', '--node-ownership', '--trust-store']), + descriptor(['mesh-llm', 'auth', 'sign-node'], NONE, [ + '--owner-key', + '--node-key', + '--out', + '--hostname-hint', + '--node-label', + '--expires-in-hours' + ]), + descriptor(['mesh-llm', 'auth', 'renew-node'], NONE, [ + '--owner-key', + '--node-key', + '--out', + '--hostname-hint', + '--node-label', + '--expires-in-hours' + ]), + descriptor(['mesh-llm', 'auth', 'verify-node'], NONE, [ + '--file', + '--node-id', + '--trust-store', + '--verify-trust-policy' + ]), + descriptor(['mesh-llm', 'auth', 'rotate-node'], AUTH_ROTATE_NODE_FLAGS, [ + '--owner-key', + '--node-key', + '--out', + '--hostname-hint', + '--node-label', + '--expires-in-hours', + '--reason', + '--trust-store' + ]), + descriptor(['mesh-llm', 'auth', 'revoke-owner'], NONE, ['owner_id', '--reason', '--trust-store']), + descriptor(['mesh-llm', 'auth', 'revoke-node'], NONE, ['--cert-id', '--node-id', '--reason', '--trust-store']), + descriptor(['mesh-llm', 'auth', 'rotate-owner'], ['--no-passphrase', '--force'], ['--owner-key']), + descriptor(['mesh-llm', 'auth', 'trust', 'add'], NONE, ['owner_id', '--label', '--trust-store']), + descriptor(['mesh-llm', 'auth', 'trust', 'remove'], NONE, ['owner_id', '--trust-store']), + descriptor(['mesh-llm', 'auth', 'trust', 'list'], NONE, ['--trust-store']) +] diff --git a/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-models.ts b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-models.ts new file mode 100644 index 0000000000..f4b5ef9346 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-models.ts @@ -0,0 +1,50 @@ +import { + JSON_FLAGS, + MODEL_CERTIFY_FLAGS, + MODEL_PACKAGE_FLAGS, + MODEL_SEARCH_CONFLICTS, + REDACTED_MODEL, + SEARCH_FLAGS, + YES_JSON, + descriptor +} from './command-summary-descriptor-options' +import type { SummaryDescriptor } from './command-summary-descriptor-types' + +export const MODEL_DESCRIPTORS: readonly SummaryDescriptor[] = [ + descriptor(['mesh-llm', 'models', 'package'], MODEL_PACKAGE_FLAGS, [ + 'source_repo', + '--quant', + '--target', + '--model-id', + '--flavor', + '--timeout', + '--mesh-llm-ref', + '--status', + '--logs', + '--cancel' + ]), + descriptor(['mesh-llm', 'models', 'recommended'], JSON_FLAGS), + descriptor(['mesh-llm', 'models', 'installed'], JSON_FLAGS), + descriptor(['mesh-llm', 'models', 'cleanup'], YES_JSON, ['--unused-since']), + descriptor(['mesh-llm', 'models', 'prune'], YES_JSON), + descriptor(['mesh-llm', 'models', 'certify'], MODEL_CERTIFY_FLAGS, [ + 'model', + '--report-out', + '--api-base', + '--prompt', + '--max-tokens' + ]), + descriptor(['mesh-llm', 'models', 'list'], JSON_FLAGS), + descriptor( + ['mesh-llm', 'models', 'search'], + SEARCH_FLAGS, + ['query', '--limit', '--sort'], + false, + 'none', + MODEL_SEARCH_CONFLICTS + ), + descriptor(['mesh-llm', 'models', 'show'], JSON_FLAGS, REDACTED_MODEL), + descriptor(['mesh-llm', 'models', 'download'], ['--draft', '--direct', '--json'], REDACTED_MODEL), + descriptor(['mesh-llm', 'models', 'updates'], ['--all', '--check', '--json'], ['repo']), + descriptor(['mesh-llm', 'models', 'delete'], YES_JSON, REDACTED_MODEL) +] diff --git a/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-plugins-benchmark.ts b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-plugins-benchmark.ts new file mode 100644 index 0000000000..cb14178639 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-plugins-benchmark.ts @@ -0,0 +1,61 @@ +import { + NONE, + PLUGIN_INSTALL_CONFLICTS, + REDACTED_NAME, + TUNE_CONFLICTS, + TUNE_FLAGS, + descriptor +} from './command-summary-descriptor-options' +import type { SummaryDescriptor } from './command-summary-descriptor-types' + +export const PLUGIN_DESCRIPTORS: readonly SummaryDescriptor[] = [ + descriptor( + ['mesh-llm', 'plugins', 'install'], + NONE, + ['reference', '--archive', '--name', '--version'], + false, + 'none', + PLUGIN_INSTALL_CONFLICTS + ), + descriptor(['mesh-llm', 'plugins', 'update'], NONE, REDACTED_NAME), + descriptor(['mesh-llm', 'plugins', 'enable'], NONE, REDACTED_NAME), + descriptor(['mesh-llm', 'plugins', 'disable'], NONE, REDACTED_NAME), + descriptor(['mesh-llm', 'plugins', 'delete'], NONE, REDACTED_NAME), + descriptor(['mesh-llm', 'plugins', 'info'], NONE, REDACTED_NAME), + descriptor(['mesh-llm', 'plugins', 'search'], NONE, ['query']), + descriptor(['mesh-llm', 'plugins', 'list']) +] + +export const BENCHMARK_DESCRIPTORS: readonly SummaryDescriptor[] = [ + descriptor( + ['mesh-llm', 'benchmark', 'tune'], + TUNE_FLAGS, + [ + '--model', + '--models', + '--ctx-sizes', + '--batch-sizes', + '--ubatch-sizes', + '--mmap-values', + '--mlock-values', + '--flash-attention', + '--speculative-types', + '--spec-draft-models', + '--spec-draft-max-tokens', + '--spec-draft-min-tokens', + '--spec-ngram-min', + '--spec-ngram-max', + '--spec-draft-acceptance-threshold', + '--spec-draft-split-probability', + '--throughput-tolerance-pct', + '--max-tokens', + '--startup-timeout-secs', + '--request-timeout-secs', + '--prompt' + ], + false, + 'none', + TUNE_CONFLICTS + ), + descriptor(['mesh-llm', 'benchmark', 'import-prompts'], NONE, ['--source', '--limit', '--max-tokens', '--output']) +] diff --git a/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-runtime.ts b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-runtime.ts new file mode 100644 index 0000000000..7af9532b7e --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-runtime.ts @@ -0,0 +1,49 @@ +import { + JSON_FLAGS, + NONE, + REDACTED_APPLY_CONFIG, + REDACTED_NAME, + REDACTED_REMOTE, + REDACTED_REMOTE_MODEL, + REMOTE_MODEL_CONFLICTS, + RUNTIME_LIST_CONFLICTS, + RUNTIME_LIST_FLAGS, + RUNTIME_PRUNE_FLAGS, + descriptor +} from './command-summary-descriptor-options' +import type { SummaryDescriptor } from './command-summary-descriptor-types' + +export const RUNTIME_DESCRIPTORS: readonly SummaryDescriptor[] = [ + descriptor(['mesh-llm', 'runtime', 'status'], NONE, NONE, true), + descriptor(['mesh-llm', 'runtime']), + descriptor(['mesh-llm', 'runtime', 'load'], NONE, REDACTED_NAME, true), + descriptor(['mesh-llm', 'runtime', 'unload'], NONE, REDACTED_NAME, true), + descriptor(['mesh-llm', 'runtime', 'guardrails'], JSON_FLAGS, NONE, true, 'mode'), + descriptor(['mesh-llm', 'runtime', 'bootstrap'], JSON_FLAGS, NONE, true), + descriptor( + ['mesh-llm', 'runtime', 'list'], + RUNTIME_LIST_FLAGS, + ['--manifest', '--bundle-dir', '--cache-dir'], + false, + 'none', + RUNTIME_LIST_CONFLICTS + ), + descriptor(['mesh-llm', 'runtime', 'install'], JSON_FLAGS, [ + 'runtime_ref', + '--manifest', + '--bundle-dir', + '--cache-dir' + ]), + descriptor(['mesh-llm', 'runtime', 'remove'], JSON_FLAGS, ['native_runtime_id', '--mesh-version', '--cache-dir']), + descriptor(['mesh-llm', 'runtime', 'prune'], RUNTIME_PRUNE_FLAGS, ['--mesh-version', '--cache-dir']), + descriptor(['mesh-llm', 'runtime', 'remote'], JSON_FLAGS, REDACTED_REMOTE, true), + descriptor( + ['mesh-llm', 'runtime', 'remote-model'], + JSON_FLAGS, + [...REDACTED_REMOTE_MODEL, '--instance-id'], + true, + 'none', + REMOTE_MODEL_CONFLICTS + ), + descriptor(['mesh-llm', 'runtime', 'apply-config'], JSON_FLAGS, REDACTED_APPLY_CONFIG, true) +] diff --git a/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-top-level.ts b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-top-level.ts new file mode 100644 index 0000000000..1ef37e5a45 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors-top-level.ts @@ -0,0 +1,60 @@ +import { + DISCOVER_FLAGS, + DRAFT, + JSON_FLAGS, + MODEL_PREPARE_FLAGS, + NONE, + REDACTED_NAME, + SETUP_CONFLICTS, + SETUP_FLAGS, + SKILLS_INSTALL_CONFLICTS, + UNINSTALL_CONFLICTS, + UNINSTALL_FLAGS, + UPDATE_CONFLICTS, + descriptor +} from './command-summary-descriptor-options' +import type { SummaryDescriptor } from './command-summary-descriptor-types' + +export const TOP_LEVEL_DESCRIPTORS: readonly SummaryDescriptor[] = [ + descriptor(['mesh-llm', 'setup'], SETUP_FLAGS, NONE, false, 'none', SETUP_CONFLICTS), + descriptor(['mesh-llm', 'uninstall'], UNINSTALL_FLAGS, ['--binary-path'], false, 'none', UNINSTALL_CONFLICTS), + descriptor(['mesh-llm', 'download'], DRAFT, REDACTED_NAME), + descriptor(['mesh-llm', 'update'], ['--detect-flavor'], ['--version', '--flavor'], false, 'none', UPDATE_CONFLICTS), + descriptor(['mesh-llm', 'status'], NONE, NONE, true), + descriptor(['mesh-llm', 'load'], NONE, REDACTED_NAME, true), + descriptor(['mesh-llm', 'unload'], NONE, REDACTED_NAME, true), + descriptor(['mesh-llm', 'discover'], DISCOVER_FLAGS, ['--name', '--model', '--min-vram', '--region', '--relay']), + descriptor(['mesh-llm', 'rotate-key']), + descriptor(['mesh-llm', 'goose'], NONE, ['--model'], true), + descriptor(['mesh-llm', 'claude'], NONE, ['--model'], true), + descriptor(['mesh-llm', 'pi'], ['--write'], ['--model', '--host']), + descriptor(['mesh-llm', 'opencode'], ['--write'], ['--model', '--host']), + descriptor(['mesh-llm', 'stop']), + descriptor(['mesh-llm', 'external-plugin'], NONE, ['argv']), + descriptor(['mesh-llm', 'model-prepare'], MODEL_PREPARE_FLAGS, [ + 'source_repo', + '--quant', + '--target', + '--model-id', + '--flavor', + '--timeout', + '--mesh-llm-ref', + '--status', + '--logs', + '--cancel' + ]), + descriptor(['mesh-llm', 'gpus'], JSON_FLAGS), + descriptor(['mesh-llm', 'gpus', 'detect'], JSON_FLAGS), + descriptor(['mesh-llm', 'gpus', 'run-benchmark'], JSON_FLAGS, NONE, false, 'backend'), + descriptor(['mesh-llm', 'config', 'validate'], JSON_FLAGS, ['--config-path']), + descriptor(['mesh-llm', 'doctor'], JSON_FLAGS), + descriptor(['mesh-llm', 'doctor', 'split'], JSON_FLAGS, ['--model-ref', '--output-dir'], true), + descriptor( + ['mesh-llm', 'skills', 'install'], + ['--all', '--dry-run', '--force'], + ['--agent'], + false, + 'none', + SKILLS_INSTALL_CONFLICTS + ) +] diff --git a/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors.ts b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors.ts new file mode 100644 index 0000000000..ed6a3f2efa --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/command-summary-descriptors.ts @@ -0,0 +1,17 @@ +import { AUTH_DESCRIPTORS } from './command-summary-descriptors-auth' +import { MODEL_DESCRIPTORS } from './command-summary-descriptors-models' +import { BENCHMARK_DESCRIPTORS, PLUGIN_DESCRIPTORS } from './command-summary-descriptors-plugins-benchmark' +import { RUNTIME_DESCRIPTORS } from './command-summary-descriptors-runtime' +import { TOP_LEVEL_DESCRIPTORS } from './command-summary-descriptors-top-level' +import type { SummaryDescriptor } from './command-summary-descriptor-types' + +export type { SummaryDescriptor, SummaryRawKind } from './command-summary-descriptor-types' + +export const SUMMARY_DESCRIPTORS: readonly SummaryDescriptor[] = [ + ...TOP_LEVEL_DESCRIPTORS, + ...PLUGIN_DESCRIPTORS, + ...MODEL_DESCRIPTORS, + ...BENCHMARK_DESCRIPTORS, + ...RUNTIME_DESCRIPTORS, + ...AUTH_DESCRIPTORS +] diff --git a/crates/mesh-llm-ui/src/features/logs/api/command-summary-vocabulary.ts b/crates/mesh-llm-ui/src/features/logs/api/command-summary-vocabulary.ts new file mode 100644 index 0000000000..68e98d65c9 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/command-summary-vocabulary.ts @@ -0,0 +1,196 @@ +export const STATIC_SUMMARY_TOKENS: ReadonlySet = new Set([ + 'mesh-llm', + 'load', + 'unload', + 'status', + 'discover', + 'rotate-key', + 'goose', + 'claude', + 'pi', + 'opencode', + 'stop', + 'external-plugin', + 'setup', + 'uninstall', + 'gpus', + 'detect', + 'run-benchmark', + 'config', + 'validate', + 'doctor', + 'split', + 'skills', + 'install', + 'plugins', + 'update', + 'enable', + 'disable', + 'delete', + 'info', + 'search', + 'list', + 'models', + 'package', + 'recommended', + 'installed', + 'cleanup', + 'prune', + 'certify', + 'show', + 'download', + 'updates', + 'benchmark', + 'tune', + 'import-prompts', + 'model-prepare', + 'runtime', + 'guardrails', + 'bootstrap', + 'remove', + 'remote', + 'remote-model', + 'apply-config', + 'auth', + 'init', + 'sign-node', + 'renew-node', + 'verify-node', + 'rotate-node', + 'revoke-owner', + 'revoke-node', + 'rotate-owner', + 'trust', + 'add' +]) + +export const BOOLEAN_SUMMARY_TOKENS: ReadonlySet = new Set([ + '--draft', + '--detect-flavor', + '--yes', + '--no-interactive', + '--service', + '--no-service', + '--skip-runtime', + '--verbose', + '--dry-run', + '--keep-cache', + '--keep-config', + '--keep-service-files', + '--json', + '--auto', + '--write', + '--all', + '--force', + '--experimental', + '--confirm', + '--follow', + '--list', + '--update-script', + '--gguf', + '--mlx', + '--catalog', + '--direct', + '--check', + '--no-speculative-tune', + '--apply', + '--replace-existing', + '--launch-args', + '--debug-telemetry', + '--available', + '--installed', + '--active-only', + '--keychain', + '--no-passphrase', + '--revoke-current', + '--package-only', + '--purge-config' +]) + +export const REDACTED_SUMMARY_TOKENS: ReadonlySet = new Set([ + 'argv', + 'model', + 'name', + 'native_runtime_id', + 'owner_id', + 'query', + 'reference', + 'repo', + 'runtime_ref', + 'source_repo', + '--version', + '--flavor', + '--binary-path', + '--name', + '--model', + '--min-vram', + '--region', + '--relay', + '--root-relay', + '--join', + '--relay-auth', + '--sort', + '--host', + '--config-path', + '--model-ref', + '--output-dir', + '--agent', + '--archive', + '--quant', + '--target', + '--model-id', + '--timeout', + '--mesh-llm-ref', + '--status', + '--logs', + '--cancel', + '--unused-since', + '--report-out', + '--api-base', + '--prompt', + '--max-tokens', + '--limit', + '--models', + '--ctx-sizes', + '--batch-sizes', + '--ubatch-sizes', + '--mmap-values', + '--mlock-values', + '--flash-attention', + '--speculative-types', + '--spec-draft-models', + '--spec-draft-max-tokens', + '--spec-draft-min-tokens', + '--spec-ngram-min', + '--spec-ngram-max', + '--spec-draft-acceptance-threshold', + '--spec-draft-split-probability', + '--throughput-tolerance-pct', + '--startup-timeout-secs', + '--request-timeout-secs', + '--source', + '--output', + '--manifest', + '--bundle-dir', + '--cache-dir', + '--mesh-version', + '--endpoint', + '--profile', + '--instance-id', + '--expected-revision', + '--config', + '--owner-key', + '--node-key', + '--node-ownership', + '--trust-store', + '--out', + '--hostname-hint', + '--node-label', + '--expires-in-hours', + '--file', + '--node-id', + '--verify-trust-policy', + '--reason', + '--cert-id', + '--label' +]) diff --git a/crates/mesh-llm-ui/src/features/logs/api/command-summary.test.ts b/crates/mesh-llm-ui/src/features/logs/api/command-summary.test.ts new file mode 100644 index 0000000000..2c97978de6 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/command-summary.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' +import { safeParse } from 'valibot' +import { commandSummarySchema, isSafeCommandSummary } from './command-summary' +import { SUMMARY_DESCRIPTORS } from './command-summary-descriptors' + +describe('command summary grammar', () => { + it('accepts every parsed producer raw-option context', () => { + const validSummaries = [ + 'mesh-llm status --port 41731', + 'mesh-llm load --port 41731 name [REDACTED]', + 'mesh-llm goose --port 41731 --model [REDACTED]', + 'mesh-llm doctor split --json --port 41731 --model-ref [REDACTED]', + 'mesh-llm gpus run-benchmark --backend cuda --json', + 'mesh-llm runtime guardrails --mode metrics --json --port 41731', + 'mesh-llm runtime bootstrap --json --port 41731', + 'mesh-llm runtime remote --json --port 41731 --endpoint [REDACTED]', + 'mesh-llm runtime remote-model --json --port 41731 --endpoint [REDACTED] --model [REDACTED]', + 'mesh-llm runtime apply-config --json --port 41731 --endpoint [REDACTED] --expected-revision [REDACTED] --config [REDACTED]' + ] + + for (const summary of validSummaries) { + expect(isSafeCommandSummary(summary), summary).toBe(true) + expect(safeParse(commandSummarySchema, summary).success, summary).toBe(true) + } + }) + + it('accepts ordinary static and redacted summaries', () => { + expect(isSafeCommandSummary('mesh-llm runtime load name [REDACTED]')).toBe(true) + expect(isSafeCommandSummary('mesh-llm models list --json')).toBe(true) + }) + + it('rejects private values, controls, bounds, and deep malformed prefixes', () => { + const malformedSummaries = [ + 'mesh-llm load private-value', + `mesh-llm load\u0001name [REDACTED]`, + `mesh-llm ${new Array(32).fill('load').join(' ')}`, + 'mesh-llm load unload status discover rotate-key setup --port 1234', + 'mesh-llm gpus run-benchmark --backend rocm', + 'mesh-llm runtime guardrails --mode strict', + 'mesh-llm load name [REDACTED] --port nope' + ] + + for (const summary of malformedSummaries) { + expect(isSafeCommandSummary(summary), summary).toBe(false) + expect(safeParse(commandSummarySchema, summary).success, summary).toBe(false) + } + }) + + it('rejects inserted safe tokens and impossible raw-option ordering', () => { + const malformedSummaries = [ + 'mesh-llm gpus --draft run-benchmark --backend cuda', + 'mesh-llm gpus run-benchmark model [REDACTED] --backend cuda', + 'mesh-llm gpus --json run-benchmark --backend cuda', + 'mesh-llm doctor --json split --port 41731', + 'mesh-llm runtime guardrails --mode metrics --port 41731 --json', + 'mesh-llm runtime bootstrap --port 41731 --json', + 'mesh-llm runtime remote --port 41731 --json --endpoint [REDACTED]' + ] + + for (const summary of malformedSummaries) { + expect(isSafeCommandSummary(summary), summary).toBe(false) + } + }) + + it('rejects non-canonical whole-command shapes', () => { + const malformedSummaries = [ + ' ', + 'mesh-llm models list --json --json', + 'mesh-llm models --json list', + 'mesh-llm load name [REDACTED] name [REDACTED]', + 'mesh-llm gpus run-benchmark --backend cuda --json --json', + 'mesh-llm load --port 41731 --port 41732', + 'mesh-llm load --port 41731 name [REDACTED] --json', + 'mesh-llm load name [REDACTED] status', + 'mesh-llm models nonsense', + 'mesh-llm load --json name [REDACTED]', + 'mesh-llm runtime status name [REDACTED]', + 'mesh-llm models list name [REDACTED] --json' + ] + + for (const summary of malformedSummaries) { + expect(isSafeCommandSummary(summary), summary).toBe(false) + expect(safeParse(commandSummarySchema, summary).success, summary).toBe(false) + } + }) + + it('rejects non-canonical ASCII whitespace', () => { + const malformedSummaries = [ + ' mesh-llm models list', + 'mesh-llm models list ', + 'mesh-llm models list', + 'mesh-llm\tmodels list', + 'mesh-llm models\nlist' + ] + + for (const summary of malformedSummaries) { + expect(isSafeCommandSummary(summary), summary).toBe(false) + } + }) + + it('rejects conflicting boolean pairs', () => { + const malformedSummaries = [ + 'mesh-llm setup --service --no-service', + 'mesh-llm setup --no-service --service', + 'mesh-llm uninstall --purge-config --keep-config', + 'mesh-llm uninstall --keep-config --purge-config', + 'mesh-llm auth init --no-passphrase --keychain', + 'mesh-llm auth init --keychain --no-passphrase' + ] + + for (const summary of malformedSummaries) { + expect(isSafeCommandSummary(summary), summary).toBe(false) + } + }) + + it('rejects each speculative option when benchmark speculative tuning is disabled', () => { + const speculativeOptions = [ + '--speculative-types', + '--spec-draft-models', + '--spec-draft-max-tokens', + '--spec-draft-min-tokens', + '--spec-draft-acceptance-threshold', + '--spec-draft-split-probability', + '--spec-ngram-min', + '--spec-ngram-max' + ] as const + + for (const option of speculativeOptions) { + const summary = `mesh-llm benchmark tune --no-speculative-tune ${option} [REDACTED]` + expect(isSafeCommandSummary(summary), option).toBe(false) + } + }) + + it('accepts only ASCII decimal u16 port values', () => { + for (const port of ['0', '1', '65535']) { + expect(isSafeCommandSummary(`mesh-llm status --port ${port}`), port).toBe(true) + } + + for (const port of ['+1', '-1', '65536', '1.0', '١']) { + expect(isSafeCommandSummary(`mesh-llm status --port ${port}`), port).toBe(false) + } + }) + + it('accepts only the redacted global relay suffix shape', () => { + expect(isSafeCommandSummary('mesh-llm load name [REDACTED] --root-relay [REDACTED]')).toBe(true) + for (const summary of [ + 'mesh-llm load name [REDACTED] --relay private-relay', + 'mesh-llm load name [REDACTED] --root-relay [REDACTED] value', + 'mesh-llm load name [REDACTED] --relay-auth private-token', + 'mesh-llm load --root-relay [REDACTED] name [REDACTED]', + 'mesh-llm load name [REDACTED] --relay-auth [REDACTED] --root-relay [REDACTED]' + ]) { + expect(isSafeCommandSummary(summary), summary).toBe(false) + } + }) + + it('accepts every descriptor option set in canonical phase order', () => { + for (const descriptor of SUMMARY_DESCRIPTORS) { + const tokens = [...descriptor.path] + if (descriptor.raw === 'backend') tokens.push('--backend', 'cuda') + if (descriptor.raw === 'mode') tokens.push('--mode', 'metrics') + tokens.push(...descriptor.booleans) + if (descriptor.hasPort) tokens.push('--port', '41731') + for (const marker of descriptor.redacted) tokens.push(marker, '[REDACTED]') + const summary = tokens.join(' ') + if (descriptor.conflicts.some((pair) => pair.every((flag) => tokens.includes(flag)))) { + expect(isSafeCommandSummary(summary), summary).toBe(false) + continue + } + if (tokens.length <= 32 && Array.from(summary).length <= 256) { + expect(isSafeCommandSummary(summary), summary).toBe(true) + } else { + for (const marker of [...descriptor.booleans, ...descriptor.redacted]) { + const single = [...descriptor.path] + if (descriptor.raw === 'backend') single.push('--backend', 'cuda') + if (descriptor.raw === 'mode') single.push('--mode', 'metrics') + single.push(marker) + if (descriptor.redacted.includes(marker)) single.push('[REDACTED]') + expect(isSafeCommandSummary(single.join(' ')), marker).toBe(true) + } + } + } + }) +}) diff --git a/crates/mesh-llm-ui/src/features/logs/api/command-summary.ts b/crates/mesh-llm-ui/src/features/logs/api/command-summary.ts new file mode 100644 index 0000000000..2bd39a98c7 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/command-summary.ts @@ -0,0 +1,116 @@ +import * as v from 'valibot' +import { BOOLEAN_SUMMARY_TOKENS, REDACTED_SUMMARY_TOKENS, STATIC_SUMMARY_TOKENS } from './command-summary-vocabulary' +import { SUMMARY_DESCRIPTORS, type SummaryDescriptor } from './command-summary-descriptors' + +const CONTROL_CHARACTER = /\p{Cc}/u + +function hasValidPort(value: string): boolean { + if (!/^[0-9]+$/.test(value)) return false + const port = Number(value) + return port >= 0 && port <= 65535 +} + +const BACKEND_VALUES = ['metal', 'cuda', 'hip', 'intel'] as const +const MODE_VALUES = ['disabled', 'metrics', 'enforce'] as const +const GLOBAL_REDACTED_TOKENS = ['--join', '--root-relay', '--relay-auth'] as const + +function isBackendValue(value: string): boolean { + return BACKEND_VALUES.some((candidate) => candidate === value) +} + +function isModeValue(value: string): boolean { + return MODE_VALUES.some((candidate) => candidate === value) +} + +function isAllowedRedactedToken(descriptor: SummaryDescriptor, token: string): boolean { + return descriptor.redacted.includes(token) || GLOBAL_REDACTED_TOKENS.some((candidate) => candidate === token) +} + +function matchesPath(tokens: readonly string[], descriptor: SummaryDescriptor): boolean { + return ( + descriptor.path.length <= tokens.length && + descriptor.path.every((token, index) => { + return STATIC_SUMMARY_TOKENS.has(token) && tokens[index] === token + }) + ) +} + +function hasDuplicate(seen: readonly string[], token: string): boolean { + return seen.includes(token) +} + +function validateDescriptor(tokens: readonly string[], descriptor: SummaryDescriptor): boolean { + if (!matchesPath(tokens, descriptor)) return false + + let index = descriptor.path.length + if (descriptor.raw === 'backend' || descriptor.raw === 'mode') { + const rawOption = descriptor.raw === 'backend' ? '--backend' : '--mode' + const rawValue = tokens[index + 1] + if (tokens[index] !== rawOption || rawValue === undefined) return false + if (descriptor.raw === 'backend' && !isBackendValue(rawValue)) return false + if (descriptor.raw === 'mode' && !isModeValue(rawValue)) return false + index += 2 + } + + let phase: 'booleans' | 'port' | 'redacted' = 'booleans' + let portSeen = false + const seenBooleans: string[] = [] + const seenRedacted: string[] = [] + const seenTokens: string[] = [] + let globalPhase = false + let lastGlobalRank = 0 + while (index < tokens.length) { + const token = tokens[index] + if (token === undefined) return false + if (BOOLEAN_SUMMARY_TOKENS.has(token)) { + if (phase !== 'booleans' || !descriptor.booleans.includes(token) || hasDuplicate(seenBooleans, token)) + return false + seenBooleans.push(token) + seenTokens.push(token) + index += 1 + continue + } + if (token === '--port') { + if (phase === 'redacted' || portSeen || !descriptor.hasPort) return false + const rawValue = tokens[index + 1] + if (rawValue === undefined || !hasValidPort(rawValue)) return false + portSeen = true + phase = 'port' + index += 2 + continue + } + if (REDACTED_SUMMARY_TOKENS.has(token)) { + const isGlobal = GLOBAL_REDACTED_TOKENS.some((candidate) => candidate === token) + if (globalPhase && !isGlobal) return false + if (isGlobal) { + const globalRank = GLOBAL_REDACTED_TOKENS.findIndex((candidate) => candidate === token) + 1 + if (globalRank <= lastGlobalRank) return false + globalPhase = true + lastGlobalRank = globalRank + } + if (!isAllowedRedactedToken(descriptor, token) || hasDuplicate(seenRedacted, token)) return false + if (tokens[index + 1] !== '[REDACTED]') return false + seenRedacted.push(token) + seenTokens.push(token) + phase = 'redacted' + index += 2 + continue + } + return false + } + return !descriptor.conflicts.some((pair) => pair.every((flag) => seenTokens.includes(flag))) +} + +export function isSafeCommandSummary(value: string): boolean { + const tokens = value.split(' ') + if ( + Array.from(value).length === 0 || + Array.from(value).length > 256 || + CONTROL_CHARACTER.test(value) || + tokens.some((token) => token.length === 0 || /\s/u.test(token)) + ) + return false + return tokens.length <= 32 && SUMMARY_DESCRIPTORS.some((descriptor) => validateDescriptor(tokens, descriptor)) +} + +export const commandSummarySchema = v.pipe(v.string(), v.minLength(1), v.maxLength(256), v.check(isSafeCommandSummary)) diff --git a/crates/mesh-llm-ui/src/features/logs/api/ledger-route-exclusions.ts b/crates/mesh-llm-ui/src/features/logs/api/ledger-route-exclusions.ts new file mode 100644 index 0000000000..d5d3ef3b15 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/ledger-route-exclusions.ts @@ -0,0 +1,10 @@ +import type { LogsRequestQuery } from './client' + +const LEDGER_ROUTE_EXCLUSIONS = { + excludeRoute: 'models', + excludeRoutePrefix: 'management_' +} as const satisfies LogsRequestQuery + +export function withLedgerRouteExclusions(query: LogsRequestQuery): LogsRequestQuery { + return { ...query, ...LEDGER_ROUTE_EXCLUSIONS } +} diff --git a/crates/mesh-llm-ui/src/features/logs/api/schemas.test.ts b/crates/mesh-llm-ui/src/features/logs/api/schemas.test.ts index 07cab4c40d..641c74d00e 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/schemas.test.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/schemas.test.ts @@ -71,6 +71,7 @@ function cleanupReceiptDto() { from: '2026-08-01T00:00:00Z', to: TIMESTAMP, route: 'reserve', + excludeRoute: 'models', model: 'Qwen/Qwen3', provider: 'reserve-a', engine: 'skippy', @@ -242,7 +243,12 @@ describe('logs operation DTO parser', () => { const parsed = parseLogCleanupReceipt(receipt) expect(parsed.auditId.toString()).toBe(AUDIT_ID) - expect(parsed.scope).toMatchObject({ source: 'durable', model: 'Qwen/Qwen3', outcome: 'completed' }) + expect(parsed.scope).toMatchObject({ + source: 'durable', + excludeRoute: 'models', + model: 'Qwen/Qwen3', + outcome: 'completed' + }) const { auditId: _auditId, ...missingAuditId } = receipt expect(() => parseLogCleanupReceipt(missingAuditId)).toThrow(LogsDtoError) expect(() => parseLogCleanupReceipt({ ...receipt, auditId: 'audit:/private/secret' })).toThrow(LogsDtoError) @@ -257,6 +263,9 @@ describe('logs operation DTO parser', () => { expect(() => parseLogCleanupReceipt({ ...receipt, scope: { ...receipt.scope, model: '/private/model?token=secret' } }) ).toThrow(LogsDtoError) + expect(() => + parseLogCleanupReceipt({ ...receipt, scope: { ...receipt.scope, excludeRoute: '/private/models' } }) + ).toThrow(LogsDtoError) expect(() => parseLogCleanupReceipt({ ...receipt, scope: { ...receipt.scope, requestLimit: 2 } })).toThrow( LogsDtoError ) @@ -395,12 +404,57 @@ describe('dedicated logs SSE frame parser', () => { reasonCode: 'model_loaded', outcome: 'ready', durationMs: 412, - numericSummaries: { layers: 36 } + numericSummaries: { layers: 36 }, + commandSummary: 'mesh-llm load name [REDACTED] --root-relay [REDACTED]' } const page = parseLogAuditPage({ items: [oldEntry, typedEntry], nextCursor: null }) expect(page.items[0]).toEqual(oldEntry) expect(page.items[1]).toEqual(typedEntry) + expect(page.items[0]?.commandSummary).toBeUndefined() + expect(page.items[1]?.commandSummary).toBe('mesh-llm load name [REDACTED] --root-relay [REDACTED]') + + const sse = parseLogsSseFrame({ + event: 'audit_entry', + lastEventId: 'a1:2', + data: JSON.stringify(typedEntry) + }) + expect(sse).toMatchObject({ + type: 'audit_entry', + entry: { commandSummary: 'mesh-llm load name [REDACTED] --root-relay [REDACTED]' } + }) + }) + + it('rejects malformed command summaries at REST and SSE boundaries', () => { + const malformedSummaries = [ + 'mesh-llm load private-value', + 'mesh-llm models list --json --json', + 'mesh-llm models --json list', + 'mesh-llm load name [REDACTED] name [REDACTED]', + 'mesh-llm gpus run-benchmark --backend cuda --json --json', + 'mesh-llm load name [REDACTED] --relay private-relay', + 'mesh-llm models list --json' + ] + + for (const [index, commandSummary] of malformedSummaries.entries()) { + const malformedEntry = { + entryId: `audit-malformed-summary-${index}`, + occurredAt: TIMESTAMP, + source: 'cli', + code: 'command_completed', + sequence: index + 3, + commandSummary + } + + expect(() => parseLogAuditPage({ items: [malformedEntry], nextCursor: null })).toThrow(LogsDtoError) + expect(() => + parseLogsSseFrame({ + event: 'audit_entry', + lastEventId: `a1:${index + 3}`, + data: JSON.stringify(malformedEntry) + }) + ).toThrow(LogsDtoError) + } }) it('parses lifecycle, gap, and typed stream-error frames', () => { @@ -470,14 +524,89 @@ describe('dedicated logs SSE frame parser', () => { ) }) - it('parses audit stream errors with the audit cursor family', () => { - expect( - parseLogsSseFrame({ - event: 'stream_error', - lastEventId: 'a1:42', - data: JSON.stringify({ code: 'invalid_event' }) - }) - ).toEqual({ type: 'stream_error', cursor: LogAuditCursor.parse('a1:42'), code: 'invalid_event' }) + it('parses invalid-event stream errors with either valid cursor family', () => { + // Given + const auditInput = { + event: 'stream_error', + lastEventId: 'a1:42', + data: JSON.stringify({ code: 'invalid_event' }) + } + const lifecycleInput = { + event: 'stream_error', + lastEventId: 'v1:2.0.0', + data: JSON.stringify({ code: 'invalid_event' }) + } + + // When + const auditFrame = parseLogsSseFrame(auditInput) + const lifecycleFrame = parseLogsSseFrame(lifecycleInput) + + // Then + expect(auditFrame).toEqual({ type: 'stream_error', cursor: LogAuditCursor.parse('a1:42'), code: 'invalid_event' }) + expect(lifecycleFrame).toEqual({ + type: 'stream_error', + cursor: LogReplayCursor.parse('v1:2.0.0'), + code: 'invalid_event' + }) + }) + + it('parses audit reconciliation failures only with a valid audit cursor', () => { + // Given + const input = { + event: 'stream_error', + lastEventId: 'a1:43', + data: JSON.stringify({ code: 'audit_reconcile_failed' }) + } + + // When + const frame = parseLogsSseFrame(input) + + // Then + expect(frame).toEqual({ + type: 'stream_error', + cursor: LogAuditCursor.parse('a1:43'), + code: 'audit_reconcile_failed' + }) + }) + + it('rejects an audit reconciliation failure paired with a lifecycle cursor', () => { + // Given + const input = { + event: 'stream_error', + lastEventId: 'v1:2.0.0', + data: JSON.stringify({ code: 'audit_reconcile_failed' }) + } + + // When / Then + expect(() => parseLogsSseFrame(input)).toThrow() + }) + + it.each([ + ['audit invalid-event cursor', 'a1:not-a-sequence', 'invalid_event'], + ['lifecycle invalid-event cursor', 'v1:2.0', 'invalid_event'], + ['audit reconciliation cursor', 'a1:not-a-sequence', 'audit_reconcile_failed'] + ])('rejects a malformed %s', (_label, lastEventId, code) => { + // Given + const input = { + event: 'stream_error', + lastEventId, + data: JSON.stringify({ code }) + } + + // When / Then + expect(() => parseLogsSseFrame(input)).toThrow() + }) + + it('rejects unknown audit stream-error codes', () => { + // Given + const input = { + event: 'stream_error', + lastEventId: 'a1:44', + data: JSON.stringify({ code: 'future_error' }) + } + + // When / Then + expect(() => parseLogsSseFrame(input)).toThrow(LogsDtoError) }) it('parses audit replay gaps from the shared replay_gap event name', () => { diff --git a/crates/mesh-llm-ui/src/features/logs/api/schemas.ts b/crates/mesh-llm-ui/src/features/logs/api/schemas.ts index d7213213fa..a357fc6417 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/schemas.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/schemas.ts @@ -1,4 +1,5 @@ import * as v from 'valibot' +import { commandSummarySchema } from './command-summary' import { LogArtifactId, LogAuditId, @@ -22,6 +23,7 @@ import type { LogRequest, LogsPage } from './schemas/types' + type LogArtifactBase = { readonly artifactId: LogArtifactId readonly requestId: LogRequestId @@ -34,6 +36,7 @@ type LogArtifactBase = { readonly redacted: boolean readonly truncated: boolean } + export { LogsDtoError } from './schemas/types' export type { LogArtifact, @@ -46,6 +49,7 @@ export type { LogCleanupOutcome, LogCleanupReceipt, LogCleanupScope, + LogCallerPathType, LogDeleteReceipt, LogEventKind, LogExport, @@ -53,6 +57,7 @@ export type { LogLifecycleEvent, LogMaintenanceCounts, LogOutcome, + LogPeerPathType, LogProxyAttempt, LogRequest, LogSource, @@ -84,6 +89,8 @@ const eventKindSchema = v.picklist([ const channelSchema = v.picklist(['requests', 'operations', 'system']) const auditSourceSchema = v.picklist(['logging_service', 'logs_api', 'runtime', 'mesh', 'cli']) const auditSeveritySchema = v.picklist(['info', 'warning', 'error']) +const peerPathTypeSchema = v.picklist(['direct', 'relay']) +const callerPathTypeSchema = v.picklist(['local_http', 'remote_quic_http', 'relay']) const artifactUnavailableReasonSchema = v.picklist([ 'streaming_response_not_assembled', 'response_body_not_bounded', @@ -151,7 +158,10 @@ const requestSchema = v.object({ provider: v.nullable(v.string()), engine: v.nullable(v.string()), statusCode: v.nullable(statusCodeSchema), - source: sourceSchema + source: sourceSchema, + callerEndpointId: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(256))), + callerAddr: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(256))), + callerPathType: v.optional(callerPathTypeSchema) }) const lifecycleEventSchema = v.object({ @@ -219,6 +229,7 @@ const cleanupScopeSchema = v.strictObject({ from: v.optional(timestampSchema), to: v.optional(timestampSchema), route: v.optional(cleanupScopeFilterSchema), + excludeRoute: v.optional(cleanupScopeFilterSchema), model: v.optional(cleanupScopeFilterSchema), provider: v.optional(cleanupScopeFilterSchema), engine: v.optional(cleanupScopeFilterSchema), @@ -290,15 +301,24 @@ const auditEntrySchema = v.object({ sequence: v.pipe(nonNegativeIntegerSchema, v.minValue(1)), contextVersion: v.optional(v.literal(1)), subjectKind: v.optional( - v.union([v.literal('runtime'), v.literal('model'), v.literal('runtime_instance'), v.literal('cli_command')]) + v.union([ + v.literal('runtime'), + v.literal('model'), + v.literal('runtime_instance'), + v.literal('cli_command'), + v.literal('mesh_peer') + ]) ), subjectId: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(256))), + remoteAddr: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(256))), + pathType: v.optional(peerPathTypeSchema), operationId: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(256))), requestId: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(256))), reasonCode: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))), outcome: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))), durationMs: v.optional(nonNegativeIntegerSchema), - numericSummaries: v.optional(v.record(v.string(), nonNegativeIntegerSchema)) + numericSummaries: v.optional(v.record(v.string(), nonNegativeIntegerSchema)), + commandSummary: v.optional(commandSummarySchema) }) const auditGapSchema = v.object({ diff --git a/crates/mesh-llm-ui/src/features/logs/api/schemas/types.ts b/crates/mesh-llm-ui/src/features/logs/api/schemas/types.ts index 3fc5fbd76e..3271e8b8e8 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/schemas/types.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/schemas/types.ts @@ -11,6 +11,8 @@ export type LogOutcome = 'active' | 'completed' | 'failed' | 'rejected' | 'cance export type LogSource = 'active' | 'durable' export type LogAuditSource = 'logging_service' | 'logs_api' | 'runtime' | 'mesh' | 'cli' export type LogAuditSeverity = 'info' | 'warning' | 'error' +export type LogPeerPathType = 'direct' | 'relay' +export type LogCallerPathType = 'local_http' | 'remote_quic_http' | 'relay' export type LogArtifactUnavailableReason = | 'streaming_response_not_assembled' | 'response_body_not_bounded' @@ -48,6 +50,9 @@ export type LogRequest = { readonly engine: string | undefined readonly statusCode: number | undefined readonly source: LogSource + readonly callerEndpointId?: string + readonly callerAddr?: string + readonly callerPathType?: LogCallerPathType } export type LogAuditEntry = { @@ -58,14 +63,17 @@ export type LogAuditEntry = { readonly severity?: LogAuditSeverity readonly sequence: number readonly contextVersion?: 1 - readonly subjectKind?: 'runtime' | 'model' | 'runtime_instance' | 'cli_command' + readonly subjectKind?: 'runtime' | 'model' | 'runtime_instance' | 'cli_command' | 'mesh_peer' readonly subjectId?: string + readonly remoteAddr?: string + readonly pathType?: LogPeerPathType readonly operationId?: string readonly requestId?: string readonly reasonCode?: string readonly outcome?: string readonly durationMs?: number readonly numericSummaries?: Readonly> + readonly commandSummary?: string } export type LogLifecycleEvent = { @@ -159,6 +167,7 @@ export type LogCleanupScope = { readonly from?: string readonly to?: string readonly route?: string + readonly excludeRoute?: string readonly model?: string readonly provider?: string readonly engine?: string diff --git a/crates/mesh-llm-ui/src/features/logs/api/sse.ts b/crates/mesh-llm-ui/src/features/logs/api/sse.ts index a68483e03a..5774a6a556 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/sse.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/sse.ts @@ -12,6 +12,20 @@ import { export type LogsSseFilterKey = 'from' | 'to' | 'route' | 'model' | 'provider' | 'engine' | 'outcome' +export type LogsStreamErrorFrame = + | { + readonly type: 'stream_error' + readonly cursor: LogReplayCursor | LogAuditCursor + readonly code: 'invalid_event' + } + | { + readonly type: 'stream_error' + readonly cursor: LogAuditCursor + readonly code: 'audit_reconcile_failed' + } + +export type LogsStreamErrorCode = LogsStreamErrorFrame['code'] + export type LogsSseFilter = { readonly key: LogsSseFilterKey readonly value: string @@ -32,11 +46,7 @@ export type LogsSseSubscription = { export type LogsSseFrame = | { readonly type: 'log_event'; readonly cursor: LogReplayCursor; readonly event: ParsedReplayEvent } | { readonly type: 'replay_gap'; readonly cursor: LogReplayCursor; readonly gap: ParsedReplayGap } - | { - readonly type: 'stream_error' - readonly cursor: LogReplayCursor | LogAuditCursor - readonly code: 'invalid_event' - } + | LogsStreamErrorFrame | { readonly type: 'audit_entry' readonly cursor: LogAuditCursor @@ -68,11 +78,26 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' } -function parseStreamError(input: unknown): 'invalid_event' { - if (isRecord(input) && input['code'] === 'invalid_event') { - return 'invalid_event' +function parseStreamError(input: unknown, lastEventId: string): LogsStreamErrorFrame { + if (!isRecord(input)) throw new LogsDtoError() + + const code = input['code'] + switch (code) { + case 'invalid_event': { + switch (lastEventId.slice(0, 3)) { + case 'a1:': + return { type: 'stream_error', cursor: LogAuditCursor.parse(lastEventId), code } + case 'v1:': + return { type: 'stream_error', cursor: LogReplayCursor.parse(lastEventId), code } + default: + throw new LogsDtoError() + } + } + case 'audit_reconcile_failed': + return { type: 'stream_error', cursor: LogAuditCursor.parse(lastEventId), code } + default: + throw new LogsDtoError() } - throw new LogsDtoError() } export function parseLogsSseFrame(input: LogsSseFrameInput): LogsSseFrame { @@ -99,12 +124,8 @@ export function parseLogsSseFrame(input: LogsSseFrameInput): LogsSseFrame { const cursor = LogReplayCursor.parse(input.lastEventId) return { type: 'replay_gap', cursor, gap: parseReplayGap(data) } } - case 'stream_error': { - const cursor = input.lastEventId.startsWith('a1:') - ? LogAuditCursor.parse(input.lastEventId) - : LogReplayCursor.parse(input.lastEventId) - return { type: 'stream_error', cursor, code: parseStreamError(data) } - } + case 'stream_error': + return parseStreamError(data, input.lastEventId) case 'audit_entry': { const cursor = LogAuditCursor.parse(input.lastEventId) const entry = parseAuditEntry(data) diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-audit-live-recovery.ts b/crates/mesh-llm-ui/src/features/logs/api/use-audit-live-recovery.ts new file mode 100644 index 0000000000..8b5a7e9fb3 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/use-audit-live-recovery.ts @@ -0,0 +1,242 @@ +import { useEffect, useRef, useState } from 'react' +import { LogsApiClient } from '@/features/logs/api/client' +import { LogAuditCursor } from '@/features/logs/api/ids' +import type { LogAuditEntry } from '@/features/logs/api/schemas' +import { parseLogsSseFrame } from '@/features/logs/api/sse' +import { sortByOccurredAt } from '@/features/logs/lib/log-instant' +import * as auditTerminal from './audit-terminal-recovery' +import type { LogsEventSourceFactory, LogsLiveConnectionState } from './use-logs-live-recovery' + +const POLL_INTERVAL_MS = 5_000 +const FALLBACK_DELAY_MS = 1_000 +/** + * A stable empty value prevents the disabled audit stream from invalidating + * consumers that memoize the returned entry list by identity. + */ +const EMPTY_AUDIT_ENTRIES: readonly LogAuditEntry[] = [] + +type AuditEventSource = ReturnType +type AuditHydrationRequest = { readonly kind: 'standard'; readonly clearGap: boolean } | { readonly kind: 'terminal' } +type AuditLiveRecoveryOptions = { + readonly enabled: boolean + readonly hydrate: () => Promise + readonly cursor: LogAuditCursor | undefined + readonly pollingEnabledRef: { readonly current: boolean } + readonly eventSourceFactory: LogsEventSourceFactory +} +function mergeAuditEntries(current: readonly LogAuditEntry[], next: LogAuditEntry): LogAuditEntry[] { + return sortByOccurredAt([...current.filter((entry) => entry.entryId !== next.entryId), next]).slice(-64) +} + +export function useAuditLiveRecovery({ + enabled, + hydrate, + cursor, + pollingEnabledRef, + eventSourceFactory +}: AuditLiveRecoveryOptions) { + const [state, setState] = useState('reconnecting') + const [liveEntries, setLiveEntries] = useState([]) + const [fallbackPollingActive, setFallbackPollingActive] = useState(false) + const latestCursorRef = useRef(undefined) + const sequenceRef = useRef(0n) + const hydrateInFlightRef = useRef(false) + const hydratePendingRequestRef = useRef(undefined) + const hydrateRef = useRef(hydrate) + + useEffect(() => { + hydrateRef.current = hydrate + }, [hydrate]) + + useEffect(() => { + if (cursor && (!latestCursorRef.current || cursor.sequence() > latestCursorRef.current.sequence())) { + latestCursorRef.current = cursor + sequenceRef.current = cursor.sequence() + } + }, [cursor]) + + useEffect(() => { + if (!enabled) return + + let disposed = false + let source: AuditEventSource | undefined + let reconciliationTimer: number | undefined + let fallbackTimer: number | undefined + let terminalRecovery: auditTerminal.AuditTerminalRecovery | undefined + + const clearRecoveryTimers = () => { + if (fallbackTimer !== undefined) window.clearTimeout(fallbackTimer) + fallbackTimer = undefined + if (reconciliationTimer !== undefined) window.clearInterval(reconciliationTimer) + reconciliationTimer = undefined + setFallbackPollingActive(false) + } + const hydrateAuthoritatively = (request: AuditHydrationRequest) => { + if (disposed) return + if (hydrateInFlightRef.current) { + const pending = hydratePendingRequestRef.current + if (request.kind === 'terminal' || pending === undefined) { + hydratePendingRequestRef.current = request + } else if (pending.kind === 'standard') { + hydratePendingRequestRef.current = { + kind: 'standard', + clearGap: pending.clearGap || request.clearGap + } + } + return + } + hydrateInFlightRef.current = true + void Promise.resolve(hydrateRef.current()) + .then(() => { + if (disposed) return + if (request.kind === 'terminal') { + finishTerminalHydration(true) + } else if (request.clearGap && terminalRecovery === undefined) { + setState(source ? 'connected' : 'polling') + } + }) + .catch(() => { + if (disposed) return + setState('stale') + if (request.kind === 'terminal') finishTerminalHydration(false) + }) + .finally(() => { + if (disposed) return + hydrateInFlightRef.current = false + const pending = hydratePendingRequestRef.current + hydratePendingRequestRef.current = undefined + if (pending) hydrateAuthoritatively(pending) + }) + } + const startPolling = () => { + setState(source ? 'reconnecting' : 'polling') + if (reconciliationTimer !== undefined) return + if (pollingEnabledRef.current) hydrateAuthoritatively({ kind: 'standard', clearGap: false }) + reconciliationTimer = window.setInterval(() => { + if (pollingEnabledRef.current) hydrateAuthoritatively({ kind: 'standard', clearGap: false }) + }, POLL_INTERVAL_MS) + setFallbackPollingActive(true) + } + + function applyTerminalTransition(transition: auditTerminal.AuditTerminalRecoveryTransition) { + terminalRecovery = transition.recovery + if (transition.shouldMarkStale) setState('stale') + if (transition.shouldReconnect) { + setState('reconnecting') + connectAuditSource() + return + } + if (transition.recovery.phase === 'failed' && source === undefined) startPolling() + } + + function finishTerminalHydration(succeeded: boolean) { + if (!terminalRecovery) return + applyTerminalTransition(auditTerminal.completeAuditTerminalHydration(terminalRecovery, succeeded)) + } + + const markForReconciliation = (nextState: 'gap' | 'stale') => { + setState(nextState) + hydrateAuthoritatively({ kind: 'standard', clearGap: true }) + } + + const acceptAuditEvent = (connectedSource: AuditEventSource, event: MessageEvent) => { + if (disposed || source !== connectedSource) return + try { + const frame = parseLogsSseFrame({ event: event.type, lastEventId: event.lastEventId, data: event.data }) + if (!(frame.cursor instanceof LogAuditCursor)) { + markForReconciliation('stale') + return + } + latestCursorRef.current = frame.cursor + if (frame.type === 'audit_gap') { + markForReconciliation('gap') + return + } + if (frame.type === 'stream_error') { + setState('stale') + if (frame.code === 'audit_reconcile_failed') { + const transition = auditTerminal.beginAuditTerminalRecovery(terminalRecovery, connectedSource) + if (transition.recovery === terminalRecovery) return + clearRecoveryTimers() + applyTerminalTransition(transition) + hydrateAuthoritatively({ kind: 'terminal' }) + } else { + hydrateAuthoritatively({ kind: 'standard', clearGap: true }) + } + return + } + if (frame.type !== 'audit_entry') { + markForReconciliation('stale') + return + } + const sequence = BigInt(frame.entry.sequence) + if (sequence <= sequenceRef.current) return + sequenceRef.current = sequence + setLiveEntries((current) => mergeAuditEntries(current, frame.entry)) + } catch { + markForReconciliation('stale') + } + } + + function connectAuditSource(): void { + const url = new LogsApiClient().logsEventSourceUrl({ + channels: [], + audit: { cursor: latestCursorRef.current } + }) + try { + const connectedSource = eventSourceFactory(url) + source = connectedSource + connectedSource.onopen = () => { + if (disposed || source !== connectedSource) return + clearRecoveryTimers() + if (terminalRecovery?.phase === 'replaced') terminalRecovery = undefined + setState('connected') + } + connectedSource.onerror = () => { + if (disposed || source !== connectedSource) return + const recovery = terminalRecovery + if (!recovery || recovery.source !== connectedSource) { + if (fallbackTimer !== undefined) return + setState('reconnecting') + fallbackTimer = window.setTimeout(() => { + fallbackTimer = undefined + startPolling() + }, FALLBACK_DELAY_MS) + return + } + connectedSource.onopen = null + connectedSource.onerror = null + connectedSource.close() + source = undefined + applyTerminalTransition(auditTerminal.completeAuditTerminalEof(recovery)) + } + const acceptConnectedAuditEvent = (event: MessageEvent) => acceptAuditEvent(connectedSource, event) + connectedSource.addEventListener('audit_entry', acceptConnectedAuditEvent) + connectedSource.addEventListener('replay_gap', acceptConnectedAuditEvent) + connectedSource.addEventListener('stream_error', acceptConnectedAuditEvent) + } catch { + startPolling() + } + } + connectAuditSource() + + return () => { + disposed = true + hydrateInFlightRef.current = false + hydratePendingRequestRef.current = undefined + clearRecoveryTimers() + if (source) { + source.onopen = null + source.onerror = null + source.close() + source = undefined + } + } + }, [enabled, eventSourceFactory, pollingEnabledRef]) + + return { + state, + entries: enabled ? liveEntries : EMPTY_AUDIT_ENTRIES, + fallbackPollingActive: enabled && fallbackPollingActive + } +} diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-log-artifact-content-query.test.tsx b/crates/mesh-llm-ui/src/features/logs/api/use-log-artifact-content-query.test.tsx index 9b07edf781..ce54d81966 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/use-log-artifact-content-query.test.tsx +++ b/crates/mesh-llm-ui/src/features/logs/api/use-log-artifact-content-query.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, renderHook, waitFor } from '@testing-library/react' +import { renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { LogArtifactId, LogRequestId } from '@/features/logs/api/ids' @@ -38,37 +38,25 @@ afterEach(() => { }) describe('useLogArtifactContentQuery', () => { - it('waits for an explicit refetch before using the audited artifact endpoint', async () => { + it('uses the audited artifact endpoint when the selected payload mounts', async () => { // Given api.getArtifact.mockResolvedValue({ ...ARTIFACT, contentBase64: btoa('{}') }) // When - const { result } = renderHook(() => useLogArtifactContentQuery(ARTIFACT), { wrapper: createWrapper() }) - await waitFor(() => expect(result.current.fetchStatus).toBe('idle')) + renderHook(() => useLogArtifactContentQuery(ARTIFACT), { wrapper: createWrapper() }) // Then - expect(api.getArtifact).not.toHaveBeenCalled() - - // When - await act(async () => { - await result.current.refetch() - }) - - // Then - expect(api.getArtifact).toHaveBeenCalledOnce() + await waitFor(() => expect(api.getArtifact).toHaveBeenCalledOnce()) expect(api.getArtifact).toHaveBeenCalledWith(ARTIFACT.artifactId, 'live') }) - it('keeps explicit artifact reads in harness mode', async () => { + it('keeps automatic artifact reads in harness mode', async () => { api.getArtifact.mockResolvedValue({ ...ARTIFACT, contentBase64: btoa('{}') }) - const { result } = renderHook(() => useLogArtifactContentQuery(ARTIFACT), { + renderHook(() => useLogArtifactContentQuery(ARTIFACT), { wrapper: createWrapper('harness') }) - await act(async () => { - await result.current.refetch() - }) - + await waitFor(() => expect(api.getArtifact).toHaveBeenCalledOnce()) expect(api.getArtifact).toHaveBeenCalledWith(ARTIFACT.artifactId, 'harness') }) }) diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-log-artifact-content-query.ts b/crates/mesh-llm-ui/src/features/logs/api/use-log-artifact-content-query.ts index 6c39e9bd0b..2be08066d9 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/use-log-artifact-content-query.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/use-log-artifact-content-query.ts @@ -14,7 +14,7 @@ export function useLogArtifactContentQuery(artifact: AvailableLogArtifact) { return useQuery({ queryKey: logArtifactContentKeys.detail(artifact, dataMode.mode), queryFn: () => new LogsApiClient().getArtifact(artifact.artifactId, dataMode.mode), - enabled: false, + enabled: true, retry: false, staleTime: 10_000 }) diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-log-request-details-query.ts b/crates/mesh-llm-ui/src/features/logs/api/use-log-request-details-query.ts index 845b98af50..3a0ce8b7dd 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/use-log-request-details-query.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/use-log-request-details-query.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { useDataMode, type DataMode } from '@/lib/data-mode' import { LogsApiClient } from '@/features/logs/api/client' import { LogPageCursor, type LogRequestId } from '@/features/logs/api/ids' -import type { LogsPage } from '@/features/logs/api/schemas' +import type { LogRequest, LogsPage } from '@/features/logs/api/schemas' const DETAIL_PAGE_SIZE = 50 export const DETAIL_ITEM_LIMIT = 250 @@ -63,11 +63,19 @@ export const logRequestDetailsKeys = { ] } -export function useLogRequestSummaryQuery(requestId: LogRequestId) { +/** + * Read one request summary, optionally seeded with the ledger row the caller + * already holds. The seed is treated as immediately stale, so the inspector + * paints real data on the first frame and the authoritative record still + * arrives from a background refetch. + */ +export function useLogRequestSummaryQuery(requestId: LogRequestId, knownRequest?: LogRequest) { const dataMode = useDataMode() return useQuery({ queryKey: logRequestDetailsKeys.summary(requestId, dataMode.mode), queryFn: () => new LogsApiClient().getRequest(requestId, dataMode.mode as DataMode), + initialData: knownRequest, + initialDataUpdatedAt: 0, staleTime: 10_000 }) } diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-log-request-summary-query.test.tsx b/crates/mesh-llm-ui/src/features/logs/api/use-log-request-summary-query.test.tsx new file mode 100644 index 0000000000..67b95b54af --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/use-log-request-summary-query.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { LogsApiClient } from '@/features/logs/api/client' +import { LogRequestId } from '@/features/logs/api/ids' +import type { LogRequest } from '@/features/logs/api/schemas' +import { useLogRequestSummaryQuery } from '@/features/logs/api/use-log-request-details-query' +import { DataModeProvider } from '@/lib/data-mode' + +const REQUEST_ID = LogRequestId.parse('00000000-0000-4000-8000-000000000001') + +const LEDGER_ROW: LogRequest = { + requestId: REQUEST_ID, + outcome: 'active', + createdAt: '2026-08-08T12:00:00Z', + terminalAt: undefined, + statusCode: undefined, + route: 'chat_completions', + model: 'Qwen3', + provider: 'mesh', + engine: 'skippy', + source: 'durable' +} + +const SERVER_RECORD: LogRequest = { + ...LEDGER_ROW, + outcome: 'completed', + terminalAt: '2026-08-08T12:00:01Z', + statusCode: 200 +} + +afterEach(() => vi.restoreAllMocks()) + +describe('useLogRequestSummaryQuery', () => { + it('paints the ledger row the caller already holds instead of a loading pass', async () => { + const getRequest = vi.spyOn(LogsApiClient.prototype, 'getRequest').mockResolvedValue(SERVER_RECORD) + + const { result } = renderHook(() => useLogRequestSummaryQuery(REQUEST_ID, LEDGER_ROW), { + wrapper: createWrapper() + }) + + expect(result.current.isLoading).toBe(false) + expect(result.current.data).toEqual(LEDGER_ROW) + + await waitFor(() => expect(result.current.data).toEqual(SERVER_RECORD)) + expect(getRequest).toHaveBeenCalledTimes(1) + }) + + it('reports a loading pass when no ledger row is available to seed the summary', async () => { + vi.spyOn(LogsApiClient.prototype, 'getRequest').mockResolvedValue(SERVER_RECORD) + + const { result } = renderHook(() => useLogRequestSummaryQuery(REQUEST_ID), { wrapper: createWrapper() }) + + expect(result.current.isLoading).toBe(true) + expect(result.current.data).toBeUndefined() + + await waitFor(() => expect(result.current.data).toEqual(SERVER_RECORD)) + }) +}) + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + return function Wrapper({ children }: { readonly children: ReactNode }) { + return ( + + + {children} + + + ) + } +} diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-logs-audit-query.ts b/crates/mesh-llm-ui/src/features/logs/api/use-logs-audit-query.ts index 027e6b3af7..f83d87081a 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/use-logs-audit-query.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/use-logs-audit-query.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' import { LogsApiClient, type LogAuditQuery, type LogsCapability } from '@/features/logs/api/client' import type { LogAuditEntry, LogAuditPage } from '@/features/logs/api/schemas' import { logsKeys } from '@/features/logs/api/use-logs-ledger-query' @@ -52,9 +53,17 @@ export async function loadCompleteAudits(query: LogAuditQuery, mode: DataMode): export function useLogsAuditQuery(query: LogAuditQuery = {}) { const dataMode = useDataMode() - return useQuery({ + const retainedSuccessfulData = useRef | undefined>(undefined) + const result = useQuery({ queryKey: logsKeys.audit(query, dataMode.mode), queryFn: () => loadCompleteAudits(query, dataMode.mode), + placeholderData: (previousData) => previousData ?? retainedSuccessfulData.current, staleTime: 10_000 }) + + useEffect(() => { + if (result.data !== undefined && !result.isPlaceholderData) retainedSuccessfulData.current = result.data + }, [result.data, result.isPlaceholderData]) + + return result } diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-logs-ledger-query.test.ts b/crates/mesh-llm-ui/src/features/logs/api/use-logs-ledger-query.test.ts index 9a87079e50..674bc00097 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/use-logs-ledger-query.test.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/use-logs-ledger-query.test.ts @@ -34,6 +34,12 @@ describe('logsKeys.ledger', () => { expect(logsKeys.ledger(filteredQuery, 'live')).not.toEqual(logsKeys.ledger(REQUEST_QUERY, 'live')) }) + + it('includes the ledger route exclusions in the stable request cache key', () => { + expect(logsKeys.ledger(REQUEST_QUERY, 'live')).toContainEqual( + expect.objectContaining({ excludeRoute: 'models', excludeRoutePrefix: 'management_' }) + ) + }) }) describe('loadCompleteLedger', () => { @@ -60,6 +66,8 @@ describe('loadCompleteLedger', () => { from: '2026-08-01T00:00:00Z', to: '2026-08-02T00:00:00Z', model: 'Qwen3', + excludeRoute: 'models', + excludeRoutePrefix: 'management_', cursor: undefined, limit: LEDGER_PAGE_SIZE }, @@ -71,6 +79,8 @@ describe('loadCompleteLedger', () => { from: '2026-08-01T00:00:00Z', to: '2026-08-02T00:00:00Z', model: 'Qwen3', + excludeRoute: 'models', + excludeRoutePrefix: 'management_', cursor: LogPageCursor.parse('page-2'), limit: LEDGER_PAGE_SIZE }, @@ -98,6 +108,9 @@ describe('loadCompleteLedger', () => { }) if (result.state === 'supported') expect(result.value.nextCursor?.toString()).toBe('10') expect(listRequests).toHaveBeenCalledTimes(10) + for (const [query] of listRequests.mock.calls) { + expect(query).toMatchObject({ excludeRoute: 'models', excludeRoutePrefix: 'management_' }) + } }) it('stops safely when an empty page advertises a continuation cursor', async () => { diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-logs-ledger-query.ts b/crates/mesh-llm-ui/src/features/logs/api/use-logs-ledger-query.ts index 3aabb3d1d0..56c51e4e5d 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/use-logs-ledger-query.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/use-logs-ledger-query.ts @@ -1,8 +1,10 @@ import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' import { useDataMode, type DataMode } from '@/lib/data-mode' import { LogsApiClient } from '@/features/logs/api/client' import type { LogAuditQuery, LogsCapability, LogsRequestQuery } from '@/features/logs/api/client' import type { LogsPage, LogRequest } from '@/features/logs/api/schemas' +import { withLedgerRouteExclusions } from '@/features/logs/api/ledger-route-exclusions' export const LEDGER_PAGE_SIZE = 100 export const LEDGER_MAX_RECORDS = 1_000 @@ -12,10 +14,11 @@ export async function loadCompleteLedger( mode: DataMode ): Promise>> { const client = new LogsApiClient() + const scopedQuery = withLedgerRouteExclusions(query) const items: LogRequest[] = [] - let cursor = query.cursor + let cursor = scopedQuery.cursor while (items.length < LEDGER_MAX_RECORDS) { - const result = await client.listRequests({ ...query, cursor, limit: LEDGER_PAGE_SIZE }, mode) + const result = await client.listRequests({ ...scopedQuery, cursor, limit: LEDGER_PAGE_SIZE }, mode) if (result.state === 'unsupported') return result const remaining = LEDGER_MAX_RECORDS - items.length items.push(...result.value.items.slice(0, remaining)) @@ -38,6 +41,8 @@ function requestQueryKey(query: LogsRequestQuery) { from: query.from, to: query.to, route: query.route, + excludeRoute: query.excludeRoute, + excludeRoutePrefix: query.excludeRoutePrefix, model: query.model, provider: query.provider, engine: query.engine, @@ -50,7 +55,12 @@ function requestQueryKey(query: LogsRequestQuery) { export const logsKeys = { all: ['logs'], - ledger: (query: LogsRequestQuery, mode: DataMode) => [...logsKeys.all, 'ledger', requestQueryKey(query), mode], + ledger: (query: LogsRequestQuery, mode: DataMode) => [ + ...logsKeys.all, + 'ledger', + requestQueryKey(withLedgerRouteExclusions(query)), + mode + ], audit: (query: LogAuditQuery, mode: DataMode) => [ ...logsKeys.all, 'audit', @@ -61,9 +71,17 @@ export const logsKeys = { export function useLogsLedgerQuery(query: LogsRequestQuery) { const dataMode = useDataMode() - return useQuery({ + const retainedSuccessfulData = useRef> | undefined>(undefined) + const result = useQuery({ queryKey: logsKeys.ledger(query, dataMode.mode), queryFn: () => loadCompleteLedger(query, dataMode.mode as DataMode), + placeholderData: (previousData) => previousData ?? retainedSuccessfulData.current, staleTime: 10_000 }) + + useEffect(() => { + if (result.data !== undefined && !result.isPlaceholderData) retainedSuccessfulData.current = result.data + }, [result.data, result.isPlaceholderData]) + + return result } diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.test-fixtures.tsx b/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.test-fixtures.tsx index 86802db2bf..3f956f4ed0 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.test-fixtures.tsx +++ b/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.test-fixtures.tsx @@ -11,10 +11,37 @@ export const unsupportedEventSourceFactory: LogsEventSourceFactory = () => { export type Listener = (event: MessageEvent) => void +export class DeferredHydration { + readonly promise: Promise + readonly #resolve: () => void + readonly #reject: (error: Error) => void + + constructor() { + let resolveHydration: () => void = () => undefined + let rejectHydration: (error: Error) => void = () => undefined + this.promise = new Promise((resolve, reject) => { + resolveHydration = () => resolve() + rejectHydration = (error) => reject(error) + }) + this.#resolve = resolveHydration + this.#reject = rejectHydration + } + + resolve() { + this.#resolve() + } + + reject(error: Error) { + this.#reject(error) + } +} + export class FakeEventSource { readonly listeners = new Map() readonly url: string closed = false + closeCalls = 0 + serverClosed = false onopen: ((event: Event) => void) | null = null onerror: ((event: Event) => void) | null = null @@ -28,6 +55,7 @@ export class FakeEventSource { close() { this.closed = true + this.closeCalls += 1 } open() { @@ -38,6 +66,11 @@ export class FakeEventSource { this.onerror?.(new Event('error')) } + serverClose() { + this.serverClosed = true + this.onerror?.(new Event('error')) + } + emit(type: string, data: string, lastEventId: string) { const event = new MessageEvent(type, { data }) Object.defineProperty(event, 'lastEventId', { value: lastEventId }) diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.test.tsx b/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.test.tsx index b13fa227f4..e8caf0afff 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.test.tsx +++ b/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.test.tsx @@ -1,12 +1,13 @@ // @vitest-environment jsdom import { act, renderHook } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { useLogsLiveRecovery, type LogsEventSourceFactory } from '@/features/logs/api/use-logs-live-recovery' import type { LogsLedgerSearch } from '@/features/logs/lib/log-search' import { REQUEST_A, REQUEST_B, + DeferredHydration, FakeEventSource, auditData, eventData, @@ -17,6 +18,11 @@ import { } from './use-logs-live-recovery.test-fixtures' describe('useLogsLiveRecovery', () => { + afterEach(() => { + if (vi.isFakeTimers()) vi.clearAllTimers() + vi.useRealTimers() + }) + it('preserves lifecycle gap recovery when the initial hydration is still in flight', async () => { let resolveInitial: (() => void) | undefined let calls = 0 @@ -101,6 +107,76 @@ describe('useLogsLiveRecovery', () => { expect(result.current.state).toBe('connected') }) + it('isolates audit hydration ownership across effect reruns', async () => { + // Given + const hydrationA = new DeferredHydration() + const hydrationB = new DeferredHydration() + const queuedHydrationB = new DeferredHydration() + const hydrate = vi.fn(async () => undefined) + const hydrateAuditA = vi.fn(() => hydrationA.promise) + let hydrationBCalls = 0 + const hydrateAuditB = vi.fn(() => { + hydrationBCalls += 1 + return hydrationBCalls === 1 ? hydrationB.promise : queuedHydrationB.promise + }) + const sourcesA: FakeEventSource[] = [] + const sourcesB: FakeEventSource[] = [] + const factoryA: LogsEventSourceFactory = (url) => { + const source = new FakeEventSource(url) + sourcesA.push(source) + return source + } + const factoryB: LogsEventSourceFactory = (url) => { + const source = new FakeEventSource(url) + sourcesB.push(source) + return source + } + const { rerender } = renderHook( + ({ hydrateAudit, eventSourceFactory }) => + useLogsLiveRecovery({ + enabled: false, + search: {}, + hydrate, + auditEnabled: true, + hydrateAudit, + eventSourceFactory + }), + { initialProps: { hydrateAudit: hydrateAuditA, eventSourceFactory: factoryA } } + ) + await flush() + act(() => sourcesA[0]?.emit('stream_error', JSON.stringify({ code: 'audit_reconcile_failed' }), 'a1:1')) + expect(hydrateAuditA).toHaveBeenCalledTimes(1) + + // When + rerender({ hydrateAudit: hydrateAuditB, eventSourceFactory: factoryB }) + act(() => sourcesB[0]?.emit('stream_error', JSON.stringify({ code: 'audit_reconcile_failed' }), 'a1:2')) + + // Then + expect(hydrateAuditB).toHaveBeenCalledTimes(1) + + // When + await act(async () => { + hydrationA.resolve() + await hydrationA.promise + }) + await flush() + act(() => sourcesB[0]?.emit('stream_error', JSON.stringify({ code: 'invalid_event' }), 'a1:3')) + + // Then + expect(hydrateAuditB).toHaveBeenCalledTimes(1) + + // When + await act(async () => { + hydrationB.resolve() + await hydrationB.promise + }) + await flush() + + // Then + expect(hydrateAuditB).toHaveBeenCalledTimes(2) + expect(hydrate).not.toHaveBeenCalled() + }) + it('accepts server-reconciled cross-process audit rows without browser polling', async () => { vi.useFakeTimers() const { hydrateAudit, result, sources } = renderLive({ enabled: false, auditEnabled: true }) @@ -238,6 +314,179 @@ describe('useLogsLiveRecovery', () => { expect(hydrateAudit).toHaveBeenCalledTimes(2) }) + it('waits for terminal hydration after EOF before replacing the audit source exactly once', async () => { + // Given + vi.useFakeTimers() + const hydration = new DeferredHydration() + const hydrateAudit = vi.fn(() => hydration.promise) + const { hydrate, result, sources } = renderLive({ enabled: false, auditEnabled: true, hydrateAudit }) + await flush() + const terminalSource = sources[0] + act(() => { + terminalSource?.open() + terminalSource?.emit('audit_entry', auditData(1), 'a1:1') + }) + expect(result.current.state).toBe('connected') + + // When + act(() => terminalSource?.emit('stream_error', JSON.stringify({ code: 'audit_reconcile_failed' }), 'a1:2')) + + // Then + expect(result.current.state).toBe('stale') + expect(hydrateAudit).toHaveBeenCalledTimes(1) + expect(hydrate).not.toHaveBeenCalled() + expect(sources).toHaveLength(1) + + // When + act(() => terminalSource?.serverClose()) + + // Then + expect(terminalSource?.serverClosed).toBe(true) + expect(terminalSource?.closed).toBe(true) + expect(terminalSource?.closeCalls).toBe(1) + expect(sources).toHaveLength(1) + expect(result.current.state).toBe('stale') + + act(() => terminalSource?.error()) + expect(sources).toHaveLength(1) + + // When + await act(async () => { + hydration.resolve() + await hydration.promise + }) + await flush() + + // Then + expect(sources).toHaveLength(2) + expect(sources[1]?.url).toBe('/api/logs/events?audit=1&cursor=a1%3A2') + act(() => { + sources[1]?.open() + terminalSource?.error() + terminalSource?.serverClose() + }) + expect(result.current.state).toBe('connected') + expect(sources).toHaveLength(2) + expect(terminalSource?.closeCalls).toBe(1) + + act(() => vi.advanceTimersByTime(5_000)) + await flush() + expect(hydrateAudit).toHaveBeenCalledTimes(1) + expect(hydrate).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('waits for terminal EOF when authoritative hydration succeeds first', async () => { + // Given + const hydration = new DeferredHydration() + const hydrateAudit = vi.fn(() => hydration.promise) + const { result, sources } = renderLive({ enabled: false, auditEnabled: true, hydrateAudit }) + await flush() + const terminalSource = sources[0] + act(() => terminalSource?.open()) + act(() => terminalSource?.emit('stream_error', JSON.stringify({ code: 'audit_reconcile_failed' }), 'a1:2')) + + // When + await act(async () => { + hydration.resolve() + await hydration.promise + }) + await flush() + + // Then + expect(hydrateAudit).toHaveBeenCalledTimes(1) + expect(result.current.state).toBe('stale') + expect(sources).toHaveLength(1) + + act(() => terminalSource?.serverClose()) + expect(terminalSource?.closed).toBe(true) + expect(terminalSource?.closeCalls).toBe(1) + expect(sources).toHaveLength(2) + expect(sources[1]?.url).toBe('/api/logs/events?audit=1&cursor=a1%3A2') + + act(() => terminalSource?.error()) + expect(sources).toHaveLength(2) + expect(terminalSource?.closeCalls).toBe(1) + }) + + it('falls back to polling when terminal audit hydration rejects before EOF', async () => { + // Given + vi.useFakeTimers() + const terminalHydration = new DeferredHydration() + let hydrationCalls = 0 + const hydrateAudit = vi.fn(() => { + hydrationCalls += 1 + return hydrationCalls === 1 ? terminalHydration.promise : Promise.resolve() + }) + const { result, sources } = renderLive({ enabled: false, auditEnabled: true, hydrateAudit }) + await flush() + const terminalSource = sources[0] + act(() => terminalSource?.open()) + act(() => terminalSource?.emit('stream_error', JSON.stringify({ code: 'audit_reconcile_failed' }), 'a1:2')) + expect(hydrateAudit).toHaveBeenCalledTimes(1) + + // When + act(() => terminalHydration.reject(new Error('authoritative audit hydration failed'))) + await flush() + expect(result.current.state).toBe('stale') + act(() => terminalSource?.serverClose()) + await flush() + await flush() + + // Then + expect(terminalSource?.closed).toBe(true) + expect(terminalSource?.closeCalls).toBe(1) + expect(result.current.state).toBe('polling') + expect(hydrateAudit).toHaveBeenCalledTimes(2) + + act(() => vi.advanceTimersByTime(4_999)) + await flush() + expect(hydrateAudit).toHaveBeenCalledTimes(2) + act(() => vi.advanceTimersByTime(1)) + await flush() + expect(hydrateAudit).toHaveBeenCalledTimes(3) + }) + + it('falls back to polling when terminal audit EOF precedes hydration rejection', async () => { + // Given + vi.useFakeTimers() + const terminalHydration = new DeferredHydration() + let hydrationCalls = 0 + const hydrateAudit = vi.fn(() => { + hydrationCalls += 1 + return hydrationCalls === 1 ? terminalHydration.promise : Promise.resolve() + }) + const { result, sources } = renderLive({ enabled: false, auditEnabled: true, hydrateAudit }) + await flush() + const terminalSource = sources[0] + act(() => terminalSource?.open()) + act(() => terminalSource?.emit('stream_error', JSON.stringify({ code: 'audit_reconcile_failed' }), 'a1:2')) + expect(hydrateAudit).toHaveBeenCalledTimes(1) + + // When + act(() => terminalSource?.serverClose()) + expect(terminalSource?.closed).toBe(true) + expect(terminalSource?.closeCalls).toBe(1) + act(() => terminalHydration.reject(new Error('authoritative audit hydration failed'))) + await flush() + await flush() + + // Then + expect(result.current.state).toBe('polling') + expect(hydrateAudit).toHaveBeenCalledTimes(2) + + act(() => vi.advanceTimersByTime(4_999)) + await flush() + expect(hydrateAudit).toHaveBeenCalledTimes(2) + act(() => vi.advanceTimersByTime(1)) + await flush() + expect(hydrateAudit).toHaveBeenCalledTimes(3) + }) + + it('starts the test after fake-timer coverage with real timers', () => { + expect(vi.isFakeTimers()).toBe(false) + }) + it('does not re-hydrate when the audit stream fails to reconnect a second time while already polling', async () => { vi.useFakeTimers() const { hydrateAudit, result, sources } = renderLive({ enabled: false, auditEnabled: true }) @@ -248,18 +497,21 @@ describe('useLogsLiveRecovery', () => { expect(result.current.state).toBe('reconnecting') act(() => vi.advanceTimersByTime(1_000)) await flush() - expect(result.current.state).toBe('polling') + expect(result.current.state).toBe('reconnecting') expect(hydrateAudit).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(1) // Native EventSource retries on its own schedule and calls onerror again on // every failed attempt. A second failure while already polling must not // re-enter startPolling and fire a duplicate hydrate — the reconciliation // interval from the first entry is still live and owns future refreshes. act(() => source?.error()) + expect(result.current.state).toBe('reconnecting') act(() => vi.advanceTimersByTime(1_000)) await flush() - expect(result.current.state).toBe('polling') + expect(result.current.state).toBe('reconnecting') expect(hydrateAudit).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(1) // The reconciliation interval from the first entry must still be the one // driving refreshes — the second failure should not have restarted or @@ -267,6 +519,41 @@ describe('useLogsLiveRecovery', () => { act(() => vi.advanceTimersByTime(5_000)) await flush() expect(hydrateAudit).toHaveBeenCalledTimes(2) + + act(() => source?.open()) + expect(result.current.state).toBe('connected') + expect(vi.getTimerCount()).toBe(0) + act(() => vi.advanceTimersByTime(5_000)) + await flush() + expect(hydrateAudit).toHaveBeenCalledTimes(2) + expect(sources).toHaveLength(1) + expect(source?.closed).toBe(false) + }) + + it('reports combined fallback polling until both lifecycle and audit timers reconnect', async () => { + vi.useFakeTimers() + const { result, sources } = renderLive({ auditEnabled: true }) + await flush() + act(() => { + sources[0]?.open() + sources[1]?.open() + sources[0]?.error() + sources[1]?.error() + }) + + expect(result.current.state).toBe('reconnecting') + expect(result.current.fallbackPollingActive).toBe(false) + + act(() => vi.advanceTimersByTime(1_000)) + await flush() + expect(result.current.fallbackPollingActive).toBe(true) + + act(() => sources[0]?.open()) + expect(result.current.fallbackPollingActive).toBe(true) + + act(() => sources[1]?.open()) + expect(result.current.state).toBe('connected') + expect(result.current.fallbackPollingActive).toBe(false) }) it('serializes route and reconnects while source remains unsupported', async () => { @@ -495,13 +782,34 @@ describe('useLogsLiveRecovery', () => { const source = sources[0] act(() => source?.error()) expect(result.current.state).toBe('reconnecting') + expect(result.current.fallbackPollingActive).toBe(false) act(() => vi.advanceTimersByTime(1_000)) - expect(result.current.state).toBe('polling') - act(() => vi.advanceTimersByTime(15_000)) + await flush() + expect(result.current.state).toBe('reconnecting') + expect(result.current.fallbackPollingActive).toBe(true) + expect(hydrate).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(1) + + act(() => source?.error()) + expect(result.current.state).toBe('reconnecting') + act(() => vi.advanceTimersByTime(1_000)) + await flush() + expect(result.current.state).toBe('reconnecting') + expect(hydrate).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(1) + + act(() => vi.advanceTimersByTime(5_000)) + await flush() + expect(hydrate).toHaveBeenCalledTimes(2) expect(sources).toHaveLength(1) act(() => source?.open()) expect(result.current.state).toBe('connected') - expect(hydrate.mock.calls.length).toBeLessThanOrEqual(2) + expect(result.current.fallbackPollingActive).toBe(false) + expect(vi.getTimerCount()).toBe(0) + act(() => vi.advanceTimersByTime(5_000)) + await flush() + expect(hydrate).toHaveBeenCalledTimes(2) + expect(source?.closed).toBe(false) }) it('pauses only future fallback interval hydrations without replacing the source or timer', async () => { diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.ts b/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.ts index 5e1bbf7871..26cf429473 100644 --- a/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.ts +++ b/crates/mesh-llm-ui/src/features/logs/api/use-logs-live-recovery.ts @@ -5,18 +5,11 @@ import { parseLogsSseFrame, type LogsSseFilter } from '@/features/logs/api/sse' import type { LogAuditEntry, LogRequest } from '@/features/logs/api/schemas' import { sortByOccurredAt } from '@/features/logs/lib/log-instant' import { resolveRelativeTime, type LogsLedgerSearch } from '@/features/logs/lib/log-search' +import { useAuditLiveRecovery } from './use-audit-live-recovery' const POLL_INTERVAL_MS = 5_000 const FALLBACK_DELAY_MS = 1_000 const DEFAULT_CHANNELS: readonly LogReplayChannel[] = ['requests', 'operations'] -/** - * Returned when audit streaming is disabled. Must be a shared module-level - * value, not a fresh `[]` literal: a new identity per render invalidates the - * ledger memo chain, which hands the events chart a new `data` array on every - * render and drives recharts into a synchronous re-dispatch loop until React's - * nested-update ceiling trips the `/logs` error boundary. - */ -const EMPTY_AUDIT_ENTRIES: readonly LogAuditEntry[] = [] export type LogsLiveConnectionState = 'connected' | 'reconnecting' | 'polling' | 'gap' | 'stale' @@ -47,6 +40,7 @@ export type LogsLiveRecovery = { readonly requestUpdates: readonly LogRequest[] readonly excludedRequestIds: readonly string[] readonly auditEntries: readonly LogAuditEntry[] + readonly fallbackPollingActive: boolean readonly pollingEnabled: boolean readonly togglePolling: () => void } @@ -135,10 +129,6 @@ function mergeLiveRequests(current: readonly LiveRequest[], next: LiveRequest): return sortByOccurredAt([...current.filter((entry) => entry.requestId !== next.requestId), next]).slice(-32) } -function mergeAuditEntries(current: readonly LogAuditEntry[], next: LogAuditEntry): LogAuditEntry[] { - return sortByOccurredAt([...current.filter((entry) => entry.entryId !== next.entryId), next]).slice(-64) -} - function requestMatchesSearch(request: LogRequest, search: LogsLedgerSearch) { const bounds = search.timeRange ? resolveRelativeTime(search.timeRange) : { from: search.from, to: search.to } const createdAt = Date.parse(request.createdAt) @@ -184,9 +174,8 @@ export function useLogsLiveRecovery({ eventSourceFactory: createEventSource = eventSourceFactory }: LogsLiveRecoveryOptions): LogsLiveRecovery { const [lifecycleState, setLifecycleState] = useState('reconnecting') - const [auditState, setAuditState] = useState('reconnecting') const [liveRequests, setLiveRequests] = useState({ subscriptionKey: '', entries: [] }) - const [liveAuditEntries, setLiveAuditEntries] = useState([]) + const [lifecycleFallbackPollingActive, setLifecycleFallbackPollingActive] = useState(false) const [pollingEnabled, setPollingEnabled] = useState(true) const pollingEnabledRef = useRef(true) const sequenceByChannelRef = useRef(new Map()) @@ -197,20 +186,10 @@ export function useLogsLiveRecovery({ const hydratePendingRef = useRef(false) const hydratePendingClearGapRef = useRef(false) const latestCursorRef = useRef(undefined) - const latestAuditCursorRef = useRef(undefined) - const auditSequenceRef = useRef(0n) - const auditHydrateInFlightRef = useRef(false) - const auditHydratePendingRef = useRef(false) - const auditHydratePendingClearGapRef = useRef(false) - const hydrateAuditRef = useRef(hydrateAudit) const searchRef = useRef(search) const restoredCursorValueRef = useRef(undefined) const previousAuthoritativeSnapshotRef = useRef(authoritativeSnapshot) - useEffect(() => { - hydrateAuditRef.current = hydrateAudit - }, [hydrateAudit]) - useEffect(() => { searchRef.current = search }, [search]) @@ -226,16 +205,6 @@ export function useLogsLiveRecovery({ })) }, [authoritativeSnapshot]) - useEffect(() => { - if ( - auditCursor && - (!latestAuditCursorRef.current || auditCursor.sequence() > latestAuditCursorRef.current.sequence()) - ) { - latestAuditCursorRef.current = auditCursor - auditSequenceRef.current = auditCursor.sequence() - } - }, [auditCursor]) - const togglePolling = useCallback(() => { setPollingEnabled((current) => { const next = !current @@ -316,9 +285,9 @@ export function useLogsLiveRecovery({ } const clearPolling = () => { - if (pollingTimer === undefined) return - window.clearInterval(pollingTimer) + if (pollingTimer !== undefined) window.clearInterval(pollingTimer) pollingTimer = undefined + setLifecycleFallbackPollingActive(false) } const clearFallback = () => { @@ -372,11 +341,12 @@ export function useLogsLiveRecovery({ const startPolling = () => { if (pollingTimer !== undefined) return - setLifecycleState('polling') + setLifecycleState(source ? 'reconnecting' : 'polling') if (pollingEnabledRef.current) hydrateAuthoritatively(false) pollingTimer = window.setInterval(() => { if (pollingEnabledRef.current) hydrateAuthoritatively(false) }, POLL_INTERVAL_MS) + setLifecycleFallbackPollingActive(true) } const queuePollingFallback = () => { @@ -482,145 +452,23 @@ export function useLogsLiveRecovery({ clearPolling() closeSource() } - }, [channels, createEventSource, enabled, filterScope, hydrate, key, search.replayCursor, subscriptionFilters]) - - useEffect(() => { - if (!auditEnabled) return - - let disposed = false - let source: LogsEventSource | undefined - let reconciliationTimer: number | undefined - let fallbackTimer: number | undefined - - const clearReconciliation = () => { - if (reconciliationTimer === undefined) return - window.clearInterval(reconciliationTimer) - reconciliationTimer = undefined - } - const clearFallback = () => { - if (fallbackTimer === undefined) return - window.clearTimeout(fallbackTimer) - fallbackTimer = undefined - } - const closeSource = () => { - if (!source) return - source.onopen = null - source.onerror = null - source.close() - source = undefined - } - const hydrateAuditAuthoritatively = (clearGap: boolean) => { - if (disposed) return - if (auditHydrateInFlightRef.current) { - auditHydratePendingRef.current = true - auditHydratePendingClearGapRef.current ||= clearGap - return - } - auditHydrateInFlightRef.current = true - void Promise.resolve(hydrateAuditRef.current()) - .then(() => { - if (!disposed && clearGap) setAuditState(source ? 'connected' : 'polling') - }) - .catch(() => { - if (!disposed) setAuditState('stale') - }) - .finally(() => { - auditHydrateInFlightRef.current = false - if (!disposed && auditHydratePendingRef.current) { - auditHydratePendingRef.current = false - const pendingClearGap = auditHydratePendingClearGapRef.current - auditHydratePendingClearGapRef.current = false - hydrateAuditAuthoritatively(pendingClearGap) - } - }) - } - const startReconciliation = () => { - if (reconciliationTimer !== undefined) return - reconciliationTimer = window.setInterval(() => { - if (pollingEnabledRef.current) hydrateAuditAuthoritatively(false) - }, POLL_INTERVAL_MS) - } - const startPolling = () => { - setAuditState('polling') - if (reconciliationTimer !== undefined) return - if (pollingEnabledRef.current) hydrateAuditAuthoritatively(false) - startReconciliation() - } - const queuePollingFallback = () => { - if (fallbackTimer !== undefined) return - setAuditState('reconnecting') - fallbackTimer = window.setTimeout(() => { - fallbackTimer = undefined - startPolling() - }, FALLBACK_DELAY_MS) - } - const acceptAuditEvent = (event: MessageEvent) => { - if (disposed) return - try { - const frame = parseLogsSseFrame({ event: event.type, lastEventId: event.lastEventId, data: event.data }) - if (!(frame.cursor instanceof LogAuditCursor)) { - setAuditState('stale') - hydrateAuditAuthoritatively(true) - return - } - latestAuditCursorRef.current = frame.cursor - if (frame.type === 'audit_gap') { - setAuditState('gap') - hydrateAuditAuthoritatively(true) - return - } - if (frame.type === 'stream_error') { - setAuditState('stale') - hydrateAuditAuthoritatively(true) - return - } - if (frame.type !== 'audit_entry') { - setAuditState('stale') - hydrateAuditAuthoritatively(true) - return - } - const sequence = BigInt(frame.entry.sequence) - if (sequence <= auditSequenceRef.current) return - auditSequenceRef.current = sequence - setLiveAuditEntries((current) => mergeAuditEntries(current, frame.entry)) - } catch { - setAuditState('stale') - hydrateAuditAuthoritatively(true) - } - } - - const url = new LogsApiClient().logsEventSourceUrl({ - channels: [], - audit: { cursor: latestAuditCursorRef.current } - }) - try { - const connectedSource = createEventSource(url) - source = connectedSource - connectedSource.onopen = () => { - if (disposed) return - clearFallback() - clearReconciliation() - setAuditState('connected') - } - connectedSource.onerror = () => { - if (!disposed) queuePollingFallback() - } - connectedSource.addEventListener('audit_entry', acceptAuditEvent) - connectedSource.addEventListener('replay_gap', acceptAuditEvent) - connectedSource.addEventListener('stream_error', acceptAuditEvent) - } catch { - startPolling() - } - - return () => { - disposed = true - clearFallback() - clearReconciliation() - closeSource() - } - }, [auditEnabled, createEventSource]) + }, [channels, createEventSource, enabled, hydrate, key, search.replayCursor, subscriptionFilters]) + + const { + state: auditState, + entries: auditEntries, + fallbackPollingActive: auditFallbackPollingActive + } = useAuditLiveRecovery({ + enabled: auditEnabled, + hydrate: hydrateAudit, + cursor: auditCursor, + pollingEnabledRef, + eventSourceFactory: createEventSource + }) const state = combinedConnectionState(lifecycleState, auditState, enabled, auditEnabled) + const fallbackPollingActive = + (enabled && lifecycleFallbackPollingActive) || (auditEnabled && auditFallbackPollingActive) const activeLiveRequests = useMemo( () => (enabled && liveRequests.subscriptionKey === key ? liveRequests.entries : []), [enabled, key, liveRequests] @@ -640,7 +488,8 @@ export function useLogsLiveRecovery({ liveRequestIds, requestUpdates, excludedRequestIds, - auditEntries: auditEnabled ? liveAuditEntries : EMPTY_AUDIT_ENTRIES, + auditEntries, + fallbackPollingActive, pollingEnabled, togglePolling } diff --git a/crates/mesh-llm-ui/src/features/logs/api/use-logs-query-retention.test.tsx b/crates/mesh-llm-ui/src/features/logs/api/use-logs-query-retention.test.tsx new file mode 100644 index 0000000000..78ec83bfa7 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/logs/api/use-logs-query-retention.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { LogsApiClient, type LogsCapability } from '@/features/logs/api/client' +import { LogRequestId } from '@/features/logs/api/ids' +import type { LogAuditEntry, LogAuditPage, LogRequest, LogsPage } from '@/features/logs/api/schemas' +import { useLogsAuditQuery } from '@/features/logs/api/use-logs-audit-query' +import { useLogsLedgerQuery } from '@/features/logs/api/use-logs-ledger-query' +import { DataModeProvider } from '@/lib/data-mode' + +type RequestResult = LogsCapability> +type AuditResult = LogsCapability + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('logs query retention', () => { + it('keeps request and operational data visible through staggered chained key changes', async () => { + const firstRequest = createDeferred(requestResult(requestFixture('10000000-0000-4000-8000-000000000001'))) + const secondRequest = createDeferred(requestResult(requestFixture('10000000-0000-4000-8000-000000000002'))) + const thirdRequest = createDeferred(requestResult(requestFixture('10000000-0000-4000-8000-000000000003'))) + const firstAudit = createDeferred(auditResult(auditFixture('audit-1', 1))) + const secondAudit = createDeferred(auditResult(auditFixture('audit-2', 2))) + const thirdAudit = createDeferred(auditResult(auditFixture('audit-3', 3))) + + const listRequests = vi + .spyOn(LogsApiClient.prototype, 'listRequests') + .mockImplementationOnce(() => firstRequest.promise) + .mockImplementationOnce(() => secondRequest.promise) + .mockImplementationOnce(() => thirdRequest.promise) + const listAudits = vi + .spyOn(LogsApiClient.prototype, 'listAudits') + .mockImplementationOnce(() => firstAudit.promise) + .mockImplementationOnce(() => secondAudit.promise) + .mockImplementationOnce(() => thirdAudit.promise) + + const firstBounds = { from: '2026-08-01T00:00:00Z', to: '2026-08-01T00:01:00Z' } + const secondBounds = { from: '2026-08-01T00:01:00Z', to: '2026-08-01T00:02:00Z' } + const thirdBounds = { from: '2026-08-01T00:02:00Z', to: '2026-08-01T00:03:00Z' } + const { rerender, result } = renderHook( + ({ requestBounds, auditBounds }) => ({ + request: useLogsLedgerQuery(requestBounds), + audit: useLogsAuditQuery(auditBounds) + }), + { + initialProps: { requestBounds: firstBounds, auditBounds: firstBounds }, + wrapper: createWrapper() + } + ) + + await waitFor(() => { + expect(listRequests).toHaveBeenCalledTimes(1) + expect(listAudits).toHaveBeenCalledTimes(1) + }) + await act(async () => { + firstRequest.resolve() + firstAudit.resolve() + }) + await waitFor(() => { + expect(visibleRequestId(result.current.request.data)).toBe('10000000-0000-4000-8000-000000000001') + expect(visibleAuditId(result.current.audit.data)).toBe('audit-1') + }) + + rerender({ requestBounds: secondBounds, auditBounds: firstBounds }) + await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(2)) + rerender({ requestBounds: secondBounds, auditBounds: secondBounds }) + await waitFor(() => expect(listAudits).toHaveBeenCalledTimes(2)) + await act(async () => { + secondRequest.resolve() + }) + await waitFor(() => { + expect(visibleRequestId(result.current.request.data)).toBe('10000000-0000-4000-8000-000000000002') + }) + + rerender({ requestBounds: thirdBounds, auditBounds: secondBounds }) + await waitFor(() => expect(listRequests).toHaveBeenCalledTimes(3)) + rerender({ requestBounds: thirdBounds, auditBounds: thirdBounds }) + await waitFor(() => expect(listAudits).toHaveBeenCalledTimes(3)) + expect({ + requestId: visibleRequestId(result.current.request.data), + auditId: visibleAuditId(result.current.audit.data) + }).toEqual({ requestId: '10000000-0000-4000-8000-000000000002', auditId: 'audit-1' }) + + await act(async () => { + thirdRequest.resolve() + }) + await waitFor(() => { + expect(visibleRequestId(result.current.request.data)).toBe('10000000-0000-4000-8000-000000000003') + expect(visibleAuditId(result.current.audit.data)).toBe('audit-1') + }) + + await act(async () => { + secondAudit.resolve() + thirdAudit.resolve() + }) + await waitFor(() => expect(visibleAuditId(result.current.audit.data)).toBe('audit-3')) + }) +}) + +function createDeferred(value: T) { + const gate = new AbortController() + const promise = new Promise((resolve) => { + gate.signal.addEventListener('abort', () => resolve(value), { once: true }) + }) + return { promise, resolve: () => gate.abort() } +} + +function requestFixture(requestId: string): LogRequest { + return { + requestId: LogRequestId.parse(requestId), + outcome: 'completed', + createdAt: '2026-08-01T00:00:00Z', + terminalAt: '2026-08-01T00:00:01Z', + route: 'chat_completions', + model: 'test-model', + provider: 'test-provider', + engine: 'test-engine', + statusCode: 200, + source: 'durable' + } +} + +function auditFixture(entryId: string, sequence: number): LogAuditEntry { + return { + entryId, + occurredAt: '2026-08-01T00:00:00Z', + source: 'runtime', + code: 'runtime_ready', + severity: 'info', + sequence + } +} + +function requestResult(item: LogRequest): RequestResult { + return { state: 'supported', value: { items: [item], nextCursor: undefined } } +} + +function auditResult(item: LogAuditEntry): AuditResult { + return { state: 'supported', value: { items: [item], nextCursor: undefined } } +} + +function visibleRequestId(data: RequestResult | undefined): string | undefined { + return data?.state === 'supported' ? data.value.items[0]?.requestId.toString() : undefined +} + +function visibleAuditId(data: AuditResult | undefined): string | undefined { + return data?.state === 'supported' ? data.value.items[0]?.entryId : undefined +} + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + return function Wrapper({ children }: { readonly children: ReactNode }) { + return ( + + + {children} + + + ) + } +} diff --git a/crates/mesh-llm-ui/src/features/logs/components/EventsOverTimeChart.test.tsx b/crates/mesh-llm-ui/src/features/logs/components/EventsOverTimeChart.test.tsx index c0d0a883aa..fb620c66bf 100644 --- a/crates/mesh-llm-ui/src/features/logs/components/EventsOverTimeChart.test.tsx +++ b/crates/mesh-llm-ui/src/features/logs/components/EventsOverTimeChart.test.tsx @@ -1,6 +1,8 @@ import '@testing-library/jest-dom/vitest' -import { act, render, renderHook, screen, within } from '@testing-library/react' +import type { ComponentProps } from 'react' +import type { BarChart, MouseHandlerDataParam } from 'recharts' +import { act, fireEvent, render, renderHook, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ChartTooltipPayloadItem } from '@/components/ui/chart' @@ -15,6 +17,39 @@ import { } from '@/features/logs/lib/log-event-ledger' import { useAdvancingChartClock } from '@/features/logs/lib/use-advancing-chart-clock' +type BarChartProps = ComponentProps + +const rechartsEventState = vi.hoisted( + (): { + click: MouseHandlerDataParam | undefined + move: MouseHandlerDataParam | undefined + } => ({ click: undefined, move: undefined }) +) + +vi.mock('recharts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + BarChart: (props: BarChartProps) => ( + <> + +

+

Loaded event volume by category and time bucket {wasAutoBucketed ? ` · Auto-bucketed to ${formatBucketInterval(effectiveIntervalMs)}` : ''} + {currentPageBucketWindow ? ( + + {' · '}Accent band marks current table page:{' '} + {formatBucketRange(currentPageBucketWindow.from, currentPageBucketWindow.to)}. + + ) : null}

+ + {loading ? ( + <> + + {onClearBucketSelection && selectedRange === 'selected' ? ( + + ) : null} setIntervalKey(value as BucketIntervalKey)} + onValueChange={(value) => setIntervalSelection({ range: rangeKey, value: value as BucketIntervalKey })} options={BUCKET_INTERVALS.map(({ value, label }) => ({ value, label }))} value={intervalKey} /> { const range = value as VolumeTimeRangeKey @@ -197,12 +295,12 @@ export function EventsOverTimeChart({
{activeCategories.length > 0 ? ( -
    +
      {activeCategories.map((category) => (