mirror of
https://github.com/telemt/telemt.git
synced 2026-09-09 03:54:07 +03:00
923c79796a
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
509 lines
18 KiB
Rust
509 lines
18 KiB
Rust
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<WebTraceStore>,
|
|
policy: &WebDebugConfig,
|
|
) -> Response<Full<Bytes>> {
|
|
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("<h1>WEB status</h1>");
|
|
push_filter_form(&mut html, &query);
|
|
html.push_str("<section><h2>Store</h2><table><tbody>");
|
|
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("</tbody></table></section>");
|
|
if !query.group_by.is_empty() {
|
|
push_groups(&mut html, &records, &query.group_by);
|
|
}
|
|
push_records(&mut html, &records, &query);
|
|
html.push_str("</main></body></html>");
|
|
truncate_page(&mut html);
|
|
retained_html_response(StatusCode::OK, html, render_permit)
|
|
}
|
|
|
|
fn push_page_start(html: &mut String) {
|
|
html.push_str("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>WEB status</title><style>body{font:14px system-ui,sans-serif;margin:0;background:#f5f7fa;color:#17202a}main{max-width:1600px;margin:auto;padding:20px}h1,h2{margin:.4em 0}section{background:#fff;border:1px solid #d9e0e7;border-radius:8px;padding:12px;margin:12px 0;overflow:auto}form{display:flex;flex-wrap:wrap;gap:8px;align-items:end}label{display:grid;gap:3px}input,select,button{font:inherit;padding:5px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #d9e0e7;padding:5px;text-align:left;vertical-align:top}th{background:#edf2f7;position:sticky;top:0}code,pre{font:12px ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}details{max-width:900px}.muted{color:#657786}.bad{color:#a00}</style></head><body><main>");
|
|
}
|
|
|
|
fn push_filter_form(html: &mut String, query: &StatusQuery) {
|
|
html.push_str("<section><h2>Filters</h2><form method=\"get\" action=\"/web-status\">");
|
|
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("<label>group_by<select name=\"group_by\" multiple size=\"4\">");
|
|
for group in [
|
|
GroupBy::Ip,
|
|
GroupBy::Session,
|
|
GroupBy::UserAgent,
|
|
GroupBy::Key,
|
|
] {
|
|
html.push_str("<option value=\"");
|
|
html.push_str(group.as_str());
|
|
if query.group_by.contains(&group) {
|
|
html.push_str("\" selected>");
|
|
} else {
|
|
html.push_str("\">");
|
|
}
|
|
html.push_str(group.as_str());
|
|
html.push_str("</option>");
|
|
}
|
|
html.push_str("</select></label><button type=\"submit\">Observe</button></form></section>");
|
|
}
|
|
|
|
fn input(html: &mut String, name: &str, value: &str) {
|
|
html.push_str("<label>");
|
|
escape(html, name);
|
|
html.push_str("<input name=\"");
|
|
escape(html, name);
|
|
html.push_str("\" value=\"");
|
|
escape(html, value);
|
|
html.push_str("\"></label>");
|
|
}
|
|
|
|
fn summary_row(html: &mut String, name: &str, value: &str) {
|
|
html.push_str("<tr><th>");
|
|
escape(html, name);
|
|
html.push_str("</th><td>");
|
|
escape(html, value);
|
|
html.push_str("</td></tr>");
|
|
}
|
|
|
|
fn push_groups(html: &mut String, records: &[Arc<StoredTraceRecord>], groups: &[GroupBy]) {
|
|
let mut summaries = BTreeMap::<Vec<String>, GroupSummary>::new();
|
|
let mut overflow = 0usize;
|
|
for stored in records {
|
|
let values = groups
|
|
.iter()
|
|
.map(|group| group_value(&stored.record, *group))
|
|
.collect::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
summaries.sort_by(|(left_values, left), (right_values, right)| {
|
|
right
|
|
.count
|
|
.cmp(&left.count)
|
|
.then_with(|| left_values.cmp(right_values))
|
|
});
|
|
html.push_str("<section><h2>Groups</h2><table><thead><tr>");
|
|
for group in groups {
|
|
html.push_str("<th>");
|
|
html.push_str(group.as_str());
|
|
html.push_str("</th>");
|
|
}
|
|
html.push_str("<th>records</th><th>latest seq</th></tr></thead><tbody>");
|
|
for (values, summary) in summaries {
|
|
html.push_str("<tr>");
|
|
for value in values {
|
|
html.push_str("<td>");
|
|
escape(html, &value);
|
|
html.push_str("</td>");
|
|
}
|
|
html.push_str("<td>");
|
|
html.push_str(&summary.count.to_string());
|
|
html.push_str("</td><td>");
|
|
html.push_str(&summary.latest_seq.to_string());
|
|
html.push_str("</td></tr>");
|
|
if html.len() >= MAX_PAGE_BYTES / 2 {
|
|
break;
|
|
}
|
|
}
|
|
if overflow != 0 {
|
|
html.push_str("<tr><td colspan=\"6\" class=\"muted\">Additional groups omitted: ");
|
|
html.push_str(&overflow.to_string());
|
|
html.push_str("</td></tr>");
|
|
}
|
|
html.push_str("</tbody></table></section>");
|
|
}
|
|
|
|
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<StoredTraceRecord>], query: &StatusQuery) {
|
|
html.push_str("<section><h2>Records</h2><table><thead><tr><th>seq</th><th>time</th><th>kind</th><th>route/event</th><th>method</th><th>status</th><th>IP</th><th>session</th><th>user / key</th><th>User-Agent</th><th>details</th></tr></thead><tbody>");
|
|
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("<tr><td colspan=\"11\" class=\"muted\">No matching records</td></tr>");
|
|
}
|
|
html.push_str("</tbody></table>");
|
|
if records.len() > shown && shown != 0 {
|
|
let before = records[shown - 1].record.seq;
|
|
html.push_str("<p><a href=\"");
|
|
escape(html, &pagination_url(query, before));
|
|
html.push_str("\">Next page</a></p>");
|
|
}
|
|
html.push_str("</section>");
|
|
}
|
|
|
|
fn push_record(html: &mut String, record: &TraceRecord) {
|
|
html.push_str("<tr><td><a href=\"/web-status?record=");
|
|
html.push_str(&record.seq.to_string());
|
|
html.push_str("\">");
|
|
html.push_str(&record.seq.to_string());
|
|
html.push_str("</a></td><td>");
|
|
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("</td><td>");
|
|
escape(html, value);
|
|
}
|
|
html.push_str("</td><td>");
|
|
escape(
|
|
html,
|
|
&client_ip(record)
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "-".to_string()),
|
|
);
|
|
html.push_str("</td><td>");
|
|
escape(html, &option_u64(record.identity.session_id));
|
|
html.push_str("</td><td>");
|
|
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("</td><td>");
|
|
escape(html, record.user_agent.as_deref().unwrap_or("-"));
|
|
html.push_str("</td><td><details><summary>request → response</summary>");
|
|
match &record.kind {
|
|
TraceRecordKind::Http(http) => {
|
|
html.push_str("<p><code>");
|
|
escape(html, &http.method);
|
|
html.push(' ');
|
|
escape(html, &http.path);
|
|
html.push_str("</code></p>");
|
|
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("<h3>timings</h3><pre>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)</pre>");
|
|
}
|
|
push_frames(html, &http.frames);
|
|
}
|
|
TraceRecordKind::Websocket(message) => {
|
|
html.push_str("<pre>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</pre>");
|
|
push_body(html, "message body", message.body.as_ref());
|
|
push_frames(html, &message.frames);
|
|
}
|
|
TraceRecordKind::Lifecycle(event) => push_lifecycle(html, event),
|
|
}
|
|
html.push_str("</details></td></tr>");
|
|
}
|
|
|
|
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<u64>) -> 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<Full<Bytes>> {
|
|
let mut html = String::new();
|
|
push_page_start(&mut html);
|
|
html.push_str("<h1 class=\"bad\">");
|
|
escape(&mut html, title);
|
|
html.push_str("</h1><p>");
|
|
escape(&mut html, message);
|
|
html.push_str("</p></main></body></html>");
|
|
html_response(status, html)
|
|
}
|
|
|
|
fn html_response(status: StatusCode, html: String) -> Response<Full<Bytes>> {
|
|
html_bytes_response(status, Bytes::from(html))
|
|
}
|
|
|
|
fn retained_html_response(
|
|
status: StatusCode,
|
|
html: String,
|
|
permit: OwnedSemaphorePermit,
|
|
) -> Response<Full<Bytes>> {
|
|
html_bytes_response(
|
|
status,
|
|
Bytes::from_owner(RenderedPage {
|
|
html,
|
|
_permit: permit,
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn html_bytes_response(status: StatusCode, html: Bytes) -> Response<Full<Bytes>> {
|
|
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;
|