use std::collections::BTreeMap; use std::sync::Arc; use http_body_util::Full; use hyper::body::Bytes; use hyper::header::{self, HeaderValue}; use hyper::{Response, StatusCode}; use tokio::sync::OwnedSemaphorePermit; use crate::config::WebDebugConfig; use crate::web::trace::{StoredTraceRecord, TraceRecord, TraceRecordKind, WebTraceStore}; const MAX_PAGE_BYTES: usize = 8 * 1024 * 1024; const MAX_GROUPS: usize = 1024; // Record-detail rendering remains isolated from filtering and page layout. mod details; // Query parsing and matching remain independent from bounded HTML rendering. mod query; use details::{push_body, push_frames, push_headers, push_lifecycle}; use query::{GroupBy, StatusQuery, client_ip, parse_query, record_matches}; struct GroupSummary { count: usize, latest_seq: u64, } struct RenderedPage { html: String, _permit: OwnedSemaphorePermit, } impl AsRef<[u8]> for RenderedPage { fn as_ref(&self) -> &[u8] { self.html.as_bytes() } } /// Renders the authenticated server-side WEB debugging table. pub(super) async fn render( raw_query: Option<&str>, store: &Arc, policy: &WebDebugConfig, ) -> Response> { store.apply_policy(policy); let query = match parse_query(raw_query, policy) { Ok(query) => query, Err(error) => return html_error(StatusCode::BAD_REQUEST, "Invalid query", &error), }; let Some(render_permit) = store.try_render_permit() else { return html_error( StatusCode::SERVICE_UNAVAILABLE, "Renderer busy", "Two WEB status pages are already rendering", ); }; let now_millis = crate::web::trace::store_epoch_millis(); let since_millis = if query.record.is_none() { now_millis.saturating_sub(query.window_secs.saturating_mul(1000)) } else { 0 }; let records = store.snapshot_matching(|record| record_matches(record, &query, since_millis)); let status = store.status(); let mut html = String::with_capacity(MAX_PAGE_BYTES); push_page_start(&mut html); html.push_str("

WEB status

"); push_filter_form(&mut html, &query); html.push_str("

Store

"); summary_row(&mut html, "debug enabled", yes_no(status.policy.enabled)); summary_row(&mut html, "body capture", body_mode(&status.policy)); summary_row(&mut html, "window seconds", &query.window_secs.to_string()); summary_row( &mut html, "records", &format!("{} / {}", status.records, status.records_capacity), ); summary_row( &mut html, "bytes", &format!("{} / {}", status.used_bytes, status.bytes_capacity), ); summary_row(&mut html, "matched", &records.len().to_string()); summary_row( &mut html, "contention drops", &status.contention_drops.to_string(), ); summary_row(&mut html, "evictions", &status.evictions.to_string()); summary_row( &mut html, "byte truncations", &status.byte_truncations.to_string(), ); summary_row( &mut html, "sequence range", &format!( "{} .. {}", option_u64(status.earliest_seq), option_u64(status.latest_seq) ), ); html.push_str("
"); if !query.group_by.is_empty() { push_groups(&mut html, &records, &query.group_by); } push_records(&mut html, &records, &query); html.push_str(""); truncate_page(&mut html); retained_html_response(StatusCode::OK, html, render_permit) } fn push_page_start(html: &mut String) { html.push_str("WEB status
"); } fn push_filter_form(html: &mut String, query: &StatusQuery) { html.push_str("

Filters

"); input(html, "window_secs", &query.window_secs.to_string()); input( html, "ip", &query.ip.map(|value| value.to_string()).unwrap_or_default(), ); input( html, "session", &query .session .map(|value| value.to_string()) .unwrap_or_default(), ); input( html, "user_agent", query.user_agent.as_deref().unwrap_or_default(), ); input(html, "key", query.key.as_deref().unwrap_or_default()); input(html, "limit", &query.limit.to_string()); html.push_str("
"); } fn input(html: &mut String, name: &str, value: &str) { html.push_str(""); } fn summary_row(html: &mut String, name: &str, value: &str) { html.push_str(""); escape(html, name); html.push_str(""); escape(html, value); html.push_str(""); } fn push_groups(html: &mut String, records: &[Arc], groups: &[GroupBy]) { let mut summaries = BTreeMap::, GroupSummary>::new(); let mut overflow = 0usize; for stored in records { let values = groups .iter() .map(|group| group_value(&stored.record, *group)) .collect::>(); if let Some(summary) = summaries.get_mut(&values) { summary.count += 1; summary.latest_seq = summary.latest_seq.max(stored.record.seq); } else if summaries.len() < MAX_GROUPS { summaries.insert( values, GroupSummary { count: 1, latest_seq: stored.record.seq, }, ); } else { overflow += 1; } } let mut summaries = summaries.into_iter().collect::>(); summaries.sort_by(|(left_values, left), (right_values, right)| { right .count .cmp(&left.count) .then_with(|| left_values.cmp(right_values)) }); html.push_str("

Groups

"); for group in groups { html.push_str(""); } html.push_str(""); for (values, summary) in summaries { html.push_str(""); for value in values { html.push_str(""); } html.push_str(""); if html.len() >= MAX_PAGE_BYTES / 2 { break; } } if overflow != 0 { html.push_str(""); } html.push_str("
"); html.push_str(group.as_str()); html.push_str("recordslatest seq
"); escape(html, &value); html.push_str(""); html.push_str(&summary.count.to_string()); html.push_str(""); html.push_str(&summary.latest_seq.to_string()); html.push_str("
Additional groups omitted: "); html.push_str(&overflow.to_string()); html.push_str("
"); } fn group_value(record: &TraceRecord, group: GroupBy) -> String { match group { GroupBy::Ip => client_ip(record).map(|value| value.to_string()), GroupBy::Session => record.identity.session_id.map(|value| value.to_string()), GroupBy::UserAgent => record.user_agent.clone(), GroupBy::Key => record.identity.key_fingerprint.clone(), } .unwrap_or_else(|| "-".to_string()) } fn push_records(html: &mut String, records: &[Arc], query: &StatusQuery) { html.push_str("

Records

"); let mut shown = 0usize; for stored in records.iter().take(query.limit) { if html.len() >= MAX_PAGE_BYTES.saturating_sub(64 * 1024) { break; } push_record(html, &stored.record); shown += 1; } if shown == 0 { html.push_str(""); } html.push_str("
seqtimekindroute/eventmethodstatusIPsessionuser / keyUser-Agentdetails
No matching records
"); if records.len() > shown && shown != 0 { let before = records[shown - 1].record.seq; html.push_str("

Next page

"); } html.push_str("
"); } fn push_record(html: &mut String, record: &TraceRecord) { html.push_str(""); html.push_str(&record.seq.to_string()); html.push_str(""); escape(html, &format_time(record.epoch_millis)); let (kind, route, method, status) = match &record.kind { TraceRecordKind::Http(http) => ( "http", http.route.as_str(), http.method.as_str(), http.status .map(|value| value.to_string()) .unwrap_or_else(|| "-".to_string()), ), TraceRecordKind::Websocket(message) => ( "websocket", message.direction.as_str(), message.message_type, message.payload_bytes.to_string(), ), TraceRecordKind::Lifecycle(event) => ( "lifecycle", event.event.as_str(), "-", event.reason.unwrap_or("-").to_string(), ), }; for value in [kind, route, method, status.as_str()] { html.push_str(""); escape(html, value); } html.push_str(""); escape( html, &client_ip(record) .map(|value| value.to_string()) .unwrap_or_else(|| "-".to_string()), ); html.push_str(""); escape(html, &option_u64(record.identity.session_id)); html.push_str(""); escape(html, record.identity.user.as_deref().unwrap_or("-")); html.push_str(" / "); escape( html, record.identity.key_fingerprint.as_deref().unwrap_or("-"), ); html.push_str(""); escape(html, record.user_agent.as_deref().unwrap_or("-")); html.push_str("
request → response"); match &record.kind { TraceRecordKind::Http(http) => { html.push_str("

"); escape(html, &http.method); html.push(' '); escape(html, &http.path); html.push_str("

"); push_headers(html, "request headers", &http.request_headers); push_body(html, "request body", http.request_body.as_ref()); push_headers(html, "response headers", &http.response_headers); push_body(html, "response body", http.response_body.as_ref()); if let Some(timings) = &http.timings { html.push_str("

timings

service/head accepted: 0 us\nrequest body: ");
                html.push_str(&option_u64(timings.request_body_us));
                html.push_str(" us\nresponse ready: ");
                html.push_str(&option_u64(timings.response_ready_us));
                html.push_str(" us\nresponse body consumed/polled: ");
                html.push_str(&option_u64(timings.response_body_us));
                html.push_str(" us\n(kernel flush and TCP ACK are not observed)
"); } push_frames(html, &http.frames); } TraceRecordKind::Websocket(message) => { html.push_str("
connection: ");
            html.push_str(&message.connection_id.to_string());
            html.push_str("\nlane: ");
            html.push_str(
                &message
                    .lane_id
                    .map(|value| value.to_string())
                    .unwrap_or_else(|| "-".to_string()),
            );
            html.push_str("\ndirection: ");
            html.push_str(message.direction.as_str());
            html.push_str("\nmessage: ");
            html.push_str(message.message_type);
            html.push_str("\npayload bytes: ");
            html.push_str(&message.payload_bytes.to_string());
            html.push_str("\nduration: ");
            html.push_str(&option_u64(message.duration_us));
            html.push_str(" us
"); push_body(html, "message body", message.body.as_ref()); push_frames(html, &message.frames); } TraceRecordKind::Lifecycle(event) => push_lifecycle(html, event), } html.push_str("
"); } fn pagination_url(query: &StatusQuery, before_seq: u64) -> String { let mut serializer = url::form_urlencoded::Serializer::new(String::from("/web-status?")); serializer.append_pair("window_secs", &query.window_secs.to_string()); if let Some(ip) = query.ip { serializer.append_pair("ip", &ip.to_string()); } if let Some(session) = query.session { serializer.append_pair("session", &session.to_string()); } if let Some(user_agent) = &query.user_agent { serializer.append_pair("user_agent", user_agent); } if let Some(key) = &query.key { serializer.append_pair("key", key); } for group in &query.group_by { serializer.append_pair("group_by", group.as_str()); } serializer.append_pair("limit", &query.limit.to_string()); serializer.append_pair("before_seq", &before_seq.to_string()); serializer.finish() } fn format_time(epoch_millis: u64) -> String { chrono::DateTime::from_timestamp_millis(epoch_millis as i64) .map(|value| value.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) .unwrap_or_else(|| epoch_millis.to_string()) } fn option_u64(value: Option) -> String { value .map(|value| value.to_string()) .unwrap_or_else(|| "-".to_string()) } fn body_mode(policy: &WebDebugConfig) -> &'static str { match policy.body_capture { crate::config::WebDebugBodyCapture::Off => "off", crate::config::WebDebugBodyCapture::Metadata => "metadata", crate::config::WebDebugBodyCapture::Prefix => "prefix", crate::config::WebDebugBodyCapture::Full => "full", } } fn yes_no(value: bool) -> &'static str { if value { "yes" } else { "no" } } fn escape(output: &mut String, value: &str) { for character in value.chars() { match character { '&' => output.push_str("&"), '<' => output.push_str("<"), '>' => output.push_str(">"), '"' => output.push_str("""), '\'' => output.push_str("'"), _ => output.push(character), } } } fn truncate_page(html: &mut String) { const SUFFIX: &str = "[page output truncated]"; if html.len() <= MAX_PAGE_BYTES { return; } let mut end = MAX_PAGE_BYTES.saturating_sub(SUFFIX.len()); while !html.is_char_boundary(end) { end -= 1; } html.truncate(end); html.push_str(SUFFIX); } fn html_error(status: StatusCode, title: &str, message: &str) -> Response> { let mut html = String::new(); push_page_start(&mut html); html.push_str("

"); escape(&mut html, title); html.push_str("

"); escape(&mut html, message); html.push_str("

"); html_response(status, html) } fn html_response(status: StatusCode, html: String) -> Response> { html_bytes_response(status, Bytes::from(html)) } fn retained_html_response( status: StatusCode, html: String, permit: OwnedSemaphorePermit, ) -> Response> { html_bytes_response( status, Bytes::from_owner(RenderedPage { html, _permit: permit, }), ) } fn html_bytes_response(status: StatusCode, html: Bytes) -> Response> { let mut response = Response::new(Full::new(html)); *response.status_mut() = status; response.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"), ); response .headers_mut() .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); response.headers_mut().insert( header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"), ); response.headers_mut().insert( header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"), ); response.headers_mut().insert( header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"), ); response .headers_mut() .insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); response } #[cfg(test)] #[path = "web_status/tests.rs"] mod tests;