use anyhow::Result;
use owo_colors::{OwoColorize, Style};
use crate::api::client::{ApiClient, RequestFilter};
use crate::api::models::{InspectedRequest, RequestSummary};
use crate::output::{self, Format};
use crate::render::sanitise;
use crate::render::theme;
use crate::render::time;
pub struct RequestQuery<'a> {
pub status: Option<&'a str>,
pub method: Option<&'a str>,
pub path: Option<&'a str>,
pub follow: bool,
pub limit: usize,
}
pub async fn run(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
query: RequestQuery<'_>,
format: Format,
) -> Result<()> {
let (_, service) = client.locate(project_reference, service_reference).await?;
let filter = RequestFilter {
status: query.status,
method: query.method,
path: query.path,
limit: Some(query.limit),
};
if !query.follow {
let answer = client.requests(&service.id, filter).await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let window = &answer.body;
if window.requests.is_empty() {
println!(
"`{}` no ha recibido ninguna petición{} desde que arrancó la plataforma.",
sanitise::plain(&service.name),
if filtered(&query) {
" que coincida"
} else {
""
},
);
return Ok(());
}
let mut out = output::Lines::lock();
for request in &window.requests {
out.line(render_request(request));
}
out.finish();
println!();
println!(" {}", summary_line(&window.summary, window.capacity));
return Ok(());
}
if !format.is_json() {
println!(
" {} {} · peticiones en vivo · Ctrl-C para salir",
theme::ARROW.style(theme::accent()),
sanitise::plain(&service.name).style(theme::strong()),
);
}
client
.stream_requests(&service.id, filter, |request| {
if format.is_json() {
output::emit(&request);
} else {
println!("{}", render_request(&request));
}
})
.await
}
fn filtered(query: &RequestQuery<'_>) -> bool {
query.status.is_some() || query.method.is_some() || query.path.is_some()
}
pub fn render_request(request: &InspectedRequest) -> String {
let status = format!("{:>3}", request.status);
let duration = format!(
"{:>7}",
time::format_millis(i64::try_from(request.duration_millis).unwrap_or(i64::MAX))
);
let client = request
.client_address
.as_deref()
.map(|address| format!(" {}", sanitise::plain(address).style(theme::muted())))
.unwrap_or_default();
format!(
"{} {:<7} {} {} {}{}{}",
clock_of(&request.at).style(theme::muted()),
sanitise::plain(&request.method),
status.style(status_style(request.status)),
duration.style(theme::muted()),
sanitise::plain(&request.path),
client,
marks_of(request),
)
}
fn summary_line(summary: &RequestSummary, capacity: u32) -> String {
let millis = |value: Option<u64>| {
value.map_or_else(
|| "—".to_owned(),
|millis| time::format_millis(i64::try_from(millis).unwrap_or(i64::MAX)),
)
};
let errors = summary
.server_error_percent
.map_or_else(|| "—".to_owned(), |percent| format!("{percent} %"));
format!(
"{} {} · p50 {} · p95 {} · 5xx {}",
summary.total.style(theme::strong()),
if summary.total == 1 {
"petición".to_owned()
} else {
format!("peticiones (de las últimas {capacity})")
},
millis(summary.p50_millis),
millis(summary.p95_millis),
errors,
)
}
fn status_style(status: u16) -> Style {
match status / 100 {
2 => theme::success(),
3 => theme::info(),
4 => theme::warning(),
5 => theme::danger(),
_ => theme::muted(),
}
}
fn marks_of(request: &InspectedRequest) -> String {
let mut marks: Vec<String> = Vec::new();
if let Some(blocked) = request.blocked_by.as_deref() {
marks.push(
format!("[{}]", block_word(blocked))
.style(theme::danger())
.to_string(),
);
}
if request.cached {
marks.push("[caché]".style(theme::success()).to_string());
}
if request.woke {
marks.push("[despertó]".style(theme::accent()).to_string());
}
if request.web_socket {
marks.push("[websocket]".style(theme::info()).to_string());
}
if marks.is_empty() {
String::new()
} else {
format!(" {}", marks.join(" "))
}
}
fn block_word(blocked: &str) -> String {
match blocked {
"allowlist" => "ip no permitida".to_owned(),
"rate-limit" => "límite por ip".to_owned(),
"maintenance" => "mantenimiento".to_owned(),
"password" => "sin contraseña".to_owned(),
other => sanitise::plain(other),
}
}
fn clock_of(at: &str) -> String {
match at.split('T').nth(1) {
Some(time) if time.len() >= 8 => time[..8].to_owned(),
_ => format!("{at:<8.8}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request() -> InspectedRequest {
InspectedRequest {
sequence: 1,
at: "2026-10-16T10:42:07.120Z".to_owned(),
method: "GET".to_owned(),
path: "/api/pedidos?page=…".to_owned(),
status: 429,
duration_millis: 3,
client_address: Some("203.0.113.7".to_owned()),
user_agent: None,
cached: false,
woke: false,
web_socket: false,
blocked_by: Some("rate-limit".to_owned()),
}
}
#[test]
fn it_should_draw_a_request_on_one_line() {
let drawn = theme::silenced(|| render_request(&request()));
assert_eq!(
drawn,
"10:42:07 GET 429 3ms /api/pedidos?page=… 203.0.113.7 [límite por ip]"
);
}
#[test]
fn it_should_sum_up_what_it_drew() {
let summary = RequestSummary {
total: 42,
p50_millis: Some(12),
p95_millis: Some(230),
server_error_percent: Some(2.4),
};
assert_eq!(
theme::silenced(|| summary_line(&summary, 500)),
"42 peticiones (de las últimas 500) · p50 12ms · p95 230ms · 5xx 2.4 %"
);
}
}