feat(dns): add DNS analyzer page with live query/response log

Add a dedicated "DNS" tab to the running pages:

- `gui::types::dns_state`: DnsState holds a bounded live log (VecDeque,
  cap 2000) of DnsEntry rows folded from the backend's DnsEvents.
- `gui::pages::dns_page`: renders a header + scrollable table
  (Time, Q/R, Domain, Type, RCODE, Answers), newest first, with an
  empty-state placeholder; mirrors the inspect_page layout/styles.
- Wire DnsState into Sniffer (field, construction, capture reset) and
  drain `msg.dns_events` into it in `refresh_data`.
- Register RunningPage::Dns (tab label, next/previous, Globe icon) and
  dispatch it in `view`; enable the previously-disabled Globe icon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Pedro 2026-06-21 18:22:57 -03:00
parent 4695544de5
commit 8f8dd02e93
8 changed files with 274 additions and 8 deletions

160
src/gui/pages/dns_page.rs Normal file
View file

@ -0,0 +1,160 @@
//! The DNS analyzer page: a live log of the DNS messages parsed from captured
//! traffic (see [`crate::networking::dns`]).
use iced::widget::scrollable::Direction;
use iced::widget::{Column, Container, Row, Scrollable, Space, Text};
use iced::{Alignment, Length, Padding};
use crate::gui::components::tab::get_pages_tabs;
use crate::gui::styles::container::ContainerType;
use crate::gui::styles::rule::RuleType;
use crate::gui::styles::scrollbar::ScrollbarType;
use crate::gui::styles::style_constants::FONT_SIZE_FOOTER;
use crate::gui::styles::text::TextType;
use crate::gui::types::dns_state::DnsEntry;
use crate::gui::types::message::Message;
use crate::gui::types::settings::Settings;
use crate::utils::formatted_strings::{clip_text, get_formatted_timestamp};
use crate::utils::types::icon::Icon;
use crate::{RunningPage, Sniffer, StyleType};
// Column widths (in pixels) for the DNS log table.
const W_TIME: f32 = 160.0;
const W_QR: f32 = 45.0;
const W_DOMAIN: f32 = 250.0;
const W_TYPE: f32 = 60.0;
const W_RCODE: f32 = 90.0;
const W_ANSWERS: f32 = 320.0;
// Maximum characters displayed per cell before clipping.
const MAX_DOMAIN_CHARS: usize = 40;
const MAX_ANSWERS_CHARS: usize = 55;
/// Builds the body of the DNS analyzer page.
pub fn dns_page(sniffer: &Sniffer) -> Container<'_, Message, StyleType> {
let Settings { language, .. } = sniffer.conf.settings;
let tabs = get_pages_tabs(RunningPage::Dns, language, sniffer.unread_notifications);
let body = Column::new()
.width(Length::Fill)
.padding(10)
.spacing(10)
.align_x(Alignment::Center)
.push(
Container::new(dns_log(sniffer))
.align_x(Alignment::Center)
.padding(Padding::new(7.0).top(10).bottom(3))
.width(947)
.height(Length::Fill)
.class(ContainerType::BorderedRound),
);
Container::new(Column::new().height(Length::Fill).push(tabs).push(body))
.height(Length::Fill)
}
/// The DNS log: a header row plus a scrollable list of entries (newest first),
/// or an empty-state placeholder when no DNS traffic has been seen yet.
fn dns_log<'a>(sniffer: &Sniffer) -> Column<'a, Message, StyleType> {
let count = sniffer.dns_state.len();
let col = Column::new()
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Start)
.push(summary_row(count))
.push(Space::new().height(4))
.push(header_row())
.push(RuleType::Standard.horizontal(5));
if sniffer.dns_state.is_empty() {
return col.push(empty_state());
}
let mut scroll = Column::new().align_x(Alignment::Start);
// Newest entries first.
for entry in sniffer.dns_state.log.iter().rev() {
scroll = scroll.push(log_row(entry));
}
col.push(
Scrollable::with_direction(scroll, Direction::Vertical(ScrollbarType::properties()))
.height(Length::Fill)
.width(Length::Fill),
)
}
fn summary_row<'a>(count: usize) -> Row<'a, Message, StyleType> {
Row::new()
.padding([0, 2])
.align_y(Alignment::Center)
.push(Text::new(format!("DNS messages captured: {count}")).class(TextType::Title))
}
fn header_row<'a>() -> Row<'a, Message, StyleType> {
let titles = [
("Time", W_TIME),
("Q/R", W_QR),
("Domain", W_DOMAIN),
("Type", W_TYPE),
("RCODE", W_RCODE),
("Answer(s)", W_ANSWERS),
];
let mut row = Row::new().padding([0, 2]).align_y(Alignment::Center);
for (title, width) in titles {
row = row.push(
Container::new(Text::new(title).class(TextType::Title))
.align_x(Alignment::Center)
.width(width),
);
}
row
}
fn log_row<'a>(entry: &DnsEntry) -> Row<'a, Message, StyleType> {
// Responses and queries are colored differently for quick scanning.
let text_type = if entry.is_response {
TextType::Incoming
} else {
TextType::Outgoing
};
let qtype = entry
.qtype
.map(|t| t.to_string())
.unwrap_or_else(|| "-".to_string());
let qr = if entry.is_response { "R" } else { "Q" };
let cells = [
(get_formatted_timestamp(entry.timestamp), W_TIME),
(qr.to_string(), W_QR),
(clip_text(&entry.domain, MAX_DOMAIN_CHARS), W_DOMAIN),
(qtype, W_TYPE),
(entry.rcode.to_string(), W_RCODE),
(clip_text(&entry.answers, MAX_ANSWERS_CHARS), W_ANSWERS),
];
let mut row = Row::new().padding([1, 2]).align_y(Alignment::Center);
for (value, width) in cells {
row = row.push(
Container::new(Text::new(value).size(FONT_SIZE_FOOTER).class(text_type))
.align_x(Alignment::Center)
.width(width),
);
}
row
}
fn empty_state<'a>() -> Column<'a, Message, StyleType> {
Column::new()
.width(Length::Fill)
.height(Length::Fill)
.padding(20)
.align_x(Alignment::Center)
.push(Space::new().height(Length::Fill))
.push(Icon::Globe.to_text().size(60))
.push(Space::new().height(15))
.push(Text::new("No DNS traffic captured yet"))
.push(Space::new().height(Length::FillPortion(2)))
}

View file

@ -1,4 +1,5 @@
pub mod connection_details_page;
pub mod dns_page;
pub mod initial_page;
pub mod inspect_page;
pub mod notifications_page;

View file

@ -1,6 +1,6 @@
use crate::gui::types::message::Message;
use crate::translations::translations::{notifications_translation, overview_translation};
use crate::translations::translations_2::inspect_translation;
use crate::translations::translations_2::{dns_translation, inspect_translation};
use crate::utils::types::icon::Icon;
use crate::{Language, StyleType};
use serde::{Deserialize, Serialize};
@ -15,13 +15,16 @@ pub enum RunningPage {
Inspect,
/// Notifications page.
Notifications,
/// DNS analyzer page.
Dns,
}
impl RunningPage {
pub const ALL: [RunningPage; 3] = [
pub const ALL: [RunningPage; 4] = [
RunningPage::Overview,
RunningPage::Inspect,
RunningPage::Notifications,
RunningPage::Dns,
];
pub fn get_tab_label(&self, language: Language) -> &str {
@ -29,6 +32,7 @@ impl RunningPage {
RunningPage::Overview => overview_translation(language),
RunningPage::Inspect => inspect_translation(language),
RunningPage::Notifications => notifications_translation(language),
RunningPage::Dns => dns_translation(language),
}
}
@ -36,15 +40,17 @@ impl RunningPage {
match self {
RunningPage::Overview => RunningPage::Inspect,
RunningPage::Inspect => RunningPage::Notifications,
RunningPage::Notifications => RunningPage::Overview,
RunningPage::Notifications => RunningPage::Dns,
RunningPage::Dns => RunningPage::Overview,
}
}
pub fn previous(self) -> Self {
match self {
RunningPage::Overview => RunningPage::Notifications,
RunningPage::Overview => RunningPage::Dns,
RunningPage::Inspect => RunningPage::Overview,
RunningPage::Notifications => RunningPage::Inspect,
RunningPage::Dns => RunningPage::Notifications,
}
}
@ -53,6 +59,7 @@ impl RunningPage {
RunningPage::Overview => Icon::Overview,
RunningPage::Inspect => Icon::Inspect,
RunningPage::Notifications => Icon::Notification,
RunningPage::Dns => Icon::Globe,
}
.to_text()
}
@ -68,7 +75,8 @@ mod tests {
#[test]
fn test_previous_running_page() {
assert_eq!(RunningPage::Overview.previous(), RunningPage::Notifications);
assert_eq!(RunningPage::Overview.previous(), RunningPage::Dns);
assert_eq!(RunningPage::Dns.previous(), RunningPage::Notifications);
assert_eq!(RunningPage::Notifications.previous(), RunningPage::Inspect);
assert_eq!(RunningPage::Inspect.previous(), RunningPage::Overview);
}
@ -77,6 +85,7 @@ mod tests {
fn test_next_running_page() {
assert_eq!(RunningPage::Overview.next(), RunningPage::Inspect);
assert_eq!(RunningPage::Inspect.next(), RunningPage::Notifications);
assert_eq!(RunningPage::Notifications.next(), RunningPage::Overview);
assert_eq!(RunningPage::Notifications.next(), RunningPage::Dns);
assert_eq!(RunningPage::Dns.next(), RunningPage::Overview);
}
}

View file

@ -6,6 +6,7 @@ use crate::gui::components::header::header;
use crate::gui::components::modal::{get_clear_all_overlay, get_exit_overlay, modal};
use crate::gui::components::types::my_modal::MyModal;
use crate::gui::pages::connection_details_page::connection_details_page;
use crate::gui::pages::dns_page::dns_page;
use crate::gui::pages::initial_page::initial_page;
use crate::gui::pages::inspect_page::inspect_page;
use crate::gui::pages::notifications_page::notifications_page;
@ -22,6 +23,7 @@ use crate::gui::styles::types::custom_palette::CustomPalette;
use crate::gui::styles::types::gradient_type::GradientType;
use crate::gui::styles::types::palette::Palette;
use crate::gui::types::conf::Conf;
use crate::gui::types::dns_state::DnsState;
use crate::gui::types::favorite::FavoriteKey;
use crate::gui::types::message::Message;
use crate::gui::types::settings::Settings;
@ -142,6 +144,8 @@ pub struct Sniffer {
pub freeze_tx: Option<tokio::sync::broadcast::Sender<()>>,
/// State of the port to program lookups
pub program_lookup: Option<ProgramLookup>,
/// State backing the DNS analyzer page
pub dns_state: DnsState,
}
impl Sniffer {
@ -189,6 +193,7 @@ impl Sniffer {
frozen: false,
freeze_tx: None,
program_lookup: None,
dns_state: DnsState::default(),
}
}
@ -391,6 +396,7 @@ impl Sniffer {
RunningPage::Overview => overview_page(self),
RunningPage::Inspect => inspect_page(self),
RunningPage::Notifications => notifications_page(self),
RunningPage::Dns => dns_page(self),
}
}
}
@ -907,6 +913,9 @@ impl Sniffer {
}
fn refresh_data(&mut self, mut msg: InfoTraffic, no_more_packets: bool) {
// Drain the DNS events accumulated during this interval into the DNS
// page state (they are not retained in the merged `info_traffic`).
self.dns_state.ingest(std::mem::take(&mut msg.dns_events));
self.info_traffic
.refresh(&mut msg, &mut self.program_lookup);
if self.info_traffic.tot_data_info.tot_data(DataRepr::Packets) == 0 {
@ -1063,6 +1072,7 @@ impl Sniffer {
self.current_capture_rx = (self.current_capture_rx.0 + 1, None);
self.info_traffic = InfoTraffic::default();
self.addresses_resolved = HashMap::new();
self.dns_state = DnsState::default();
self.logged_notifications = LoggedNotifications::default();
self.pcap_error = None;
self.traffic_chart = TrafficChart::new(style, language, self.conf.data_repr);

View file

@ -0,0 +1,79 @@
//! GUI-side state for the DNS analyzer page.
//!
//! The capture backend emits [`DnsEvent`]s; the GUI drains them each tick and
//! folds them into this state, which backs the live DNS log shown in the DNS
//! page.
use std::collections::VecDeque;
use std::net::IpAddr;
use crate::networking::dns::types::{DnsEvent, DnsRCode, DnsRecordType};
use crate::networking::types::protocol::Protocol;
use crate::utils::types::timestamp::Timestamp;
/// Maximum number of entries kept in the live log (older ones are discarded).
const MAX_LOG_ENTRIES: usize = 2000;
/// State backing the DNS page.
#[derive(Debug, Default, Clone)]
pub struct DnsState {
/// Live log of observed DNS messages, oldest first.
pub log: VecDeque<DnsEntry>,
}
impl DnsState {
/// Folds a batch of freshly parsed DNS events into the state.
pub fn ingest(&mut self, events: Vec<DnsEvent>) {
for event in events {
if self.log.len() >= MAX_LOG_ENTRIES {
self.log.pop_front();
}
self.log.push_back(DnsEntry::from(event));
}
}
/// Total number of logged DNS messages.
pub fn len(&self) -> usize {
self.log.len()
}
pub fn is_empty(&self) -> bool {
self.log.is_empty()
}
}
/// A single, display-ready row of the DNS log.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DnsEntry {
pub timestamp: Timestamp,
pub src: IpAddr,
pub dst: IpAddr,
pub transport: Protocol,
pub id: u16,
pub is_response: bool,
/// Queried domain name, or "-" if the message carried no question.
pub domain: String,
/// Queried record type, if a question was present.
pub qtype: Option<DnsRecordType>,
pub rcode: DnsRCode,
/// Comma-separated summary of the answers (empty for queries).
pub answers: String,
}
impl From<DnsEvent> for DnsEntry {
fn from(event: DnsEvent) -> Self {
let message = &event.message;
DnsEntry {
timestamp: event.timestamp,
src: event.src,
dst: event.dst,
transport: event.transport,
id: message.id,
is_response: message.is_response,
domain: message.query_name().unwrap_or("-").to_string(),
qtype: message.query_type(),
rcode: message.rcode,
answers: message.answers_summary(),
}
}
}

View file

@ -1,5 +1,6 @@
pub mod conf;
pub mod config_window;
pub mod dns_state;
pub mod export_pcap;
pub mod favorite;
pub mod filters;

View file

@ -29,6 +29,12 @@ pub fn new_version_available_translation(language: Language) -> &'static str {
}
}
/// Tab label for the DNS analyzer page. "DNS" is a universal acronym, so the
/// same label is used across all languages.
pub fn dns_translation(_language: Language) -> &'static str {
"DNS"
}
pub fn inspect_translation(language: Language) -> &'static str {
match language {
Language::EN => "Inspect",

View file

@ -26,7 +26,7 @@ pub enum Icon {
FunnelX,
FunnelStar,
GitHub,
// Globe,
Globe,
HalfSun,
Hourglass1,
Hourglass2,
@ -87,7 +87,7 @@ impl Icon {
Icon::FunnelX => ';',
Icon::FunnelStar => '6',
Icon::GitHub => 'H',
// Icon::Globe => 'c',
Icon::Globe => 'c',
Icon::HalfSun => 'K',
Icon::Hourglass1 => '1',
Icon::Hourglass2 => '2',