feat: add memory footprint to /stats and dashboard

Per-structure heap estimation (cache, blocklist, query log, SRTT,
overrides) with process RSS via mach_task_basic_info / sysconf.
Dashboard gets a 6th stat card and a sidebar breakdown panel with
stacked bar visualization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-30 02:05:08 +03:00
parent 8791198d10
commit e6489d4a1f
10 changed files with 391 additions and 6 deletions

View File

@@ -101,7 +101,7 @@ body {
/* Stat cards row */ /* Stat cards row */
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(6, 1fr);
gap: 1rem; gap: 1rem;
} }
.stat-card { .stat-card {
@@ -125,6 +125,8 @@ body {
.stat-card.blocked::before { background: var(--rose); } .stat-card.blocked::before { background: var(--rose); }
.stat-card.overrides::before { background: var(--violet); } .stat-card.overrides::before { background: var(--violet); }
.stat-card.uptime::before { background: var(--cyan); } .stat-card.uptime::before { background: var(--cyan); }
.stat-card.memory::before { background: var(--text-dim); }
.stat-card.memory .stat-value { color: var(--text-secondary); }
.stat-label { .stat-label {
font-size: 0.7rem; font-size: 0.7rem;
@@ -468,10 +470,74 @@ body {
display: none; display: none;
} }
/* Memory sidebar panel */
.memory-bar {
display: flex;
height: 18px;
border-radius: 4px;
overflow: hidden;
background: var(--bg-surface);
margin-bottom: 0.8rem;
}
.memory-bar-seg {
height: 100%;
min-width: 2px;
transition: width 0.6s ease;
}
.memory-bar-seg.cache { background: var(--teal); }
.memory-bar-seg.blocklist { background: var(--rose); }
.memory-bar-seg.querylog { background: var(--amber); }
.memory-bar-seg.srtt { background: var(--cyan); }
.memory-bar-seg.overrides { background: var(--violet); }
.memory-row {
display: flex;
align-items: center;
padding: 0.3rem 0;
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 0.72rem;
}
.memory-row:last-child { border-bottom: none; }
.memory-row-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
margin-right: 0.5rem;
}
.memory-row-label {
flex: 1;
color: var(--text-secondary);
}
.memory-row-size {
width: 65px;
text-align: right;
color: var(--text-primary);
font-weight: 500;
}
.memory-row-entries {
width: 90px;
text-align: right;
color: var(--text-dim);
}
.memory-rss {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
}
/* Responsive */ /* Responsive */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-grid { grid-template-columns: 1fr; } .main-grid { grid-template-columns: 1fr; }
} }
@media (max-width: 900px) {
.stats-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 700px) { @media (max-width: 700px) {
.stats-row { grid-template-columns: repeat(2, 1fr); } .stats-row { grid-template-columns: repeat(2, 1fr); }
.dashboard { padding: 1rem; } .dashboard { padding: 1rem; }
@@ -524,6 +590,11 @@ body {
<div class="stat-value" id="uptime"></div> <div class="stat-value" id="uptime"></div>
<div class="stat-sub" id="uptimeSub">&nbsp;</div> <div class="stat-sub" id="uptimeSub">&nbsp;</div>
</div> </div>
<div class="stat-card memory">
<div class="stat-label">Memory</div>
<div class="stat-value" id="memoryRss"></div>
<div class="stat-sub" id="memorySub">&nbsp;</div>
</div>
</div> </div>
<!-- Resolution paths --> <!-- Resolution paths -->
@@ -648,6 +719,17 @@ body {
</div> </div>
</div> </div>
<!-- Memory breakdown -->
<div class="panel" id="memoryPanel">
<div class="panel-header">
<span class="panel-title">Memory</span>
<span class="panel-title" id="memoryTotal" style="color: var(--text-dim)"></span>
</div>
<div class="panel-body" id="memoryBody">
<div class="empty-state">No memory data</div>
</div>
</div>
<!-- Cache entries --> <!-- Cache entries -->
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
@@ -712,6 +794,70 @@ function formatRemaining(secs) {
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m left`; return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m left`;
} }
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
const MEMORY_COMPONENTS = [
{ key: 'cache', label: 'Cache', cls: 'cache', color: 'var(--teal)' },
{ key: 'blocklist', label: 'Blocklist', cls: 'blocklist', color: 'var(--rose)' },
{ key: 'query_log', label: 'Query Log', cls: 'querylog', color: 'var(--amber)' },
{ key: 'srtt', label: 'SRTT', cls: 'srtt', color: 'var(--cyan)' },
{ key: 'overrides', label: 'Overrides', cls: 'overrides', color: 'var(--violet)' },
];
function renderMemory(mem, stats) {
if (!mem) return;
// Stat card
document.getElementById('memoryRss').textContent = formatBytes(mem.process_rss_bytes);
document.getElementById('memorySub').textContent = 'est. ' + formatBytes(mem.total_estimated_bytes);
// Entry counts from sibling stats objects (avoid duplication in memory payload)
const entryCounts = {
cache: stats.cache.entries,
blocklist: stats.blocking.domains_loaded,
query_log: mem.query_log_entries,
srtt: mem.srtt_entries,
overrides: stats.overrides.active,
};
// Sidebar panel
const total = mem.total_estimated_bytes || 1;
document.getElementById('memoryTotal').textContent = formatBytes(total);
const barSegments = MEMORY_COMPONENTS.map(c => {
const bytes = mem[c.key + '_bytes'] || 0;
const pct = ((bytes / total) * 100).toFixed(1);
return `<div class="memory-bar-seg ${c.cls}" style="width:${pct}%" title="${c.label}: ${formatBytes(bytes)} (${pct}%)"></div>`;
}).join('');
const rows = MEMORY_COMPONENTS.map(c => {
const bytes = mem[c.key + '_bytes'] || 0;
const entries = entryCounts[c.key] || 0;
return `
<div class="memory-row">
<div class="memory-row-dot" style="background:${c.color}"></div>
<span class="memory-row-label">${c.label}</span>
<span class="memory-row-size">${formatBytes(bytes)}</span>
<span class="memory-row-entries">${formatNumber(entries)} entries</span>
</div>`;
}).join('');
document.getElementById('memoryBody').innerHTML = `
<div class="memory-bar">${barSegments}</div>
${rows}
<div class="memory-rss">
<span>Process RSS</span>
<span>${formatBytes(mem.process_rss_bytes)}</span>
</div>
`;
}
const PATH_DEFS = [ const PATH_DEFS = [
{ key: 'forwarded', label: 'Forward', cls: 'forward' }, { key: 'forwarded', label: 'Forward', cls: 'forward' },
{ key: 'recursive', label: 'Recursive', cls: 'recursive' }, { key: 'recursive', label: 'Recursive', cls: 'recursive' },
@@ -960,6 +1106,7 @@ async function refresh() {
renderServices(services); renderServices(services);
renderBlockingInfo(blockingInfo); renderBlockingInfo(blockingInfo);
renderAllowlist(allowlist); renderAllowlist(allowlist);
renderMemory(stats.memory, stats);
} catch (err) { } catch (err) {
document.getElementById('statusDot').className = 'status-dot error'; document.getElementById('statusDot').className = 'status-dot error';

View File

@@ -170,6 +170,7 @@ struct StatsResponse {
overrides: OverrideStats, overrides: OverrideStats,
blocking: BlockingStatsResponse, blocking: BlockingStatsResponse,
lan: LanStatsResponse, lan: LanStatsResponse,
memory: MemoryStats,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -210,6 +211,19 @@ struct BlockingStatsResponse {
allowlist_size: usize, allowlist_size: usize,
} }
#[derive(Serialize)]
struct MemoryStats {
cache_bytes: usize,
blocklist_bytes: usize,
query_log_bytes: usize,
query_log_entries: usize,
srtt_bytes: usize,
srtt_entries: usize,
overrides_bytes: usize,
total_estimated_bytes: usize,
process_rss_bytes: usize,
}
#[derive(Serialize)] #[derive(Serialize)]
struct DiagnoseResponse { struct DiagnoseResponse {
domain: String, domain: String,
@@ -471,12 +485,29 @@ async fn query_log(
async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> { async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
let snap = ctx.stats.lock().unwrap().snapshot(); let snap = ctx.stats.lock().unwrap().snapshot();
let (cache_len, cache_max) = { let (cache_len, cache_max, cache_bytes) = {
let cache = ctx.cache.read().unwrap(); let cache = ctx.cache.read().unwrap();
(cache.len(), cache.max_entries()) (cache.len(), cache.max_entries(), cache.heap_bytes())
}; };
let override_count = ctx.overrides.read().unwrap().active_count(); let (override_count, overrides_bytes) = {
let bl_stats = ctx.blocklist.read().unwrap().stats(); let ov = ctx.overrides.read().unwrap();
(ov.active_count(), ov.heap_bytes())
};
let (bl_stats, blocklist_bytes) = {
let bl = ctx.blocklist.read().unwrap();
(bl.stats(), bl.heap_bytes())
};
let (query_log_bytes, query_log_entries) = {
let log = ctx.query_log.lock().unwrap();
(log.heap_bytes(), log.len())
};
let (srtt_bytes, srtt_entries, srtt_enabled) = {
let s = ctx.srtt.read().unwrap();
(s.heap_bytes(), s.len(), s.is_enabled())
};
let total_estimated =
cache_bytes + blocklist_bytes + query_log_bytes + srtt_bytes + overrides_bytes;
let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive { let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
"recursive (root hints)".to_string() "recursive (root hints)".to_string()
@@ -491,7 +522,7 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
config_path: ctx.config_path.clone(), config_path: ctx.config_path.clone(),
data_dir: ctx.data_dir.to_string_lossy().to_string(), data_dir: ctx.data_dir.to_string_lossy().to_string(),
dnssec: ctx.dnssec_enabled, dnssec: ctx.dnssec_enabled,
srtt: ctx.srtt.read().unwrap().is_enabled(), srtt: srtt_enabled,
queries: QueriesStats { queries: QueriesStats {
total: snap.total, total: snap.total,
forwarded: snap.forwarded, forwarded: snap.forwarded,
@@ -520,6 +551,17 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
enabled: ctx.lan_enabled, enabled: ctx.lan_enabled,
peers: ctx.lan_peers.lock().unwrap().list().len(), peers: ctx.lan_peers.lock().unwrap().list().len(),
}, },
memory: MemoryStats {
cache_bytes,
blocklist_bytes,
query_log_bytes,
query_log_entries,
srtt_bytes,
srtt_entries,
overrides_bytes,
total_estimated_bytes: total_estimated,
process_rss_bytes: crate::stats::process_rss_bytes(),
},
}) })
} }

View File

@@ -183,6 +183,20 @@ impl BlocklistStore {
self.allowlist.iter().cloned().collect() self.allowlist.iter().cloned().collect()
} }
pub fn heap_bytes(&self) -> usize {
let domains: usize = self
.domains
.iter()
.map(|d| std::mem::size_of::<String>() + d.capacity())
.sum();
let allow: usize = self
.allowlist
.iter()
.map(|d| std::mem::size_of::<String>() + d.capacity())
.sum();
domains + allow
}
pub fn stats(&self) -> BlocklistStats { pub fn stats(&self) -> BlocklistStats {
BlocklistStats { BlocklistStats {
enabled: self.is_enabled(), enabled: self.is_enabled(),

View File

@@ -142,6 +142,18 @@ impl DnsCache {
self.entry_count = 0; self.entry_count = 0;
} }
pub fn heap_bytes(&self) -> usize {
let mut total = 0;
for (domain, type_map) in &self.entries {
total += domain.capacity() + std::mem::size_of::<String>();
for entry in type_map.values() {
total += std::mem::size_of::<CacheEntry>();
total += entry.packet.heap_bytes();
}
}
total
}
pub fn remove(&mut self, domain: &str) { pub fn remove(&mut self, domain: &str) {
let domain_lower = domain.to_lowercase(); let domain_lower = domain.to_lowercase();
if let Some(type_map) = self.entries.remove(&domain_lower) { if let Some(type_map) = self.entries.remove(&domain_lower) {

View File

@@ -117,6 +117,19 @@ impl OverrideStore {
self.entries.clear(); self.entries.clear();
} }
pub fn heap_bytes(&self) -> usize {
self.entries
.iter()
.map(|(k, v)| {
k.capacity()
+ std::mem::size_of::<OverrideEntry>()
+ v.domain.capacity()
+ v.target.capacity()
+ v.record.heap_bytes()
})
.sum()
}
pub fn active_count(&self) -> usize { pub fn active_count(&self) -> usize {
self.entries.values().filter(|e| !e.is_expired()).count() self.entries.values().filter(|e| !e.is_expired()).count()
} }

View File

@@ -66,6 +66,25 @@ impl DnsPacket {
pkt pkt
} }
pub fn heap_bytes(&self) -> usize {
fn records_heap(records: &[DnsRecord]) -> usize {
records
.iter()
.map(|r| std::mem::size_of::<DnsRecord>() + r.heap_bytes())
.sum::<usize>()
}
let questions: usize = self
.questions
.iter()
.map(|q| std::mem::size_of::<DnsQuestion>() + q.name.capacity())
.sum();
questions
+ records_heap(&self.answers)
+ records_heap(&self.authorities)
+ records_heap(&self.resources)
+ self.edns.as_ref().map_or(0, |e| e.options.capacity())
}
pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket { pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket {
let mut resp = DnsPacket::new(); let mut resp = DnsPacket::new();
resp.header.id = query.header.id; resp.header.id = query.header.id;

View File

@@ -38,6 +38,21 @@ impl QueryLog {
self.entries.push_back(entry); self.entries.push_back(entry);
} }
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn heap_bytes(&self) -> usize {
self.entries
.iter()
.map(|e| std::mem::size_of::<QueryLogEntry>() + e.domain.capacity())
.sum()
}
pub fn query(&self, filter: &QueryLogFilter) -> Vec<&QueryLogEntry> { pub fn query(&self, filter: &QueryLogFilter) -> Vec<&QueryLogEntry> {
self.entries self.entries
.iter() .iter()

View File

@@ -136,6 +136,46 @@ impl DnsRecord {
} }
} }
pub fn heap_bytes(&self) -> usize {
match self {
DnsRecord::A { domain, .. } => domain.capacity(),
DnsRecord::NS { domain, host, .. } | DnsRecord::CNAME { domain, host, .. } => {
domain.capacity() + host.capacity()
}
DnsRecord::MX { domain, host, .. } => domain.capacity() + host.capacity(),
DnsRecord::AAAA { domain, .. } => domain.capacity(),
DnsRecord::DNSKEY {
domain, public_key, ..
} => domain.capacity() + public_key.capacity(),
DnsRecord::DS { domain, digest, .. } => domain.capacity() + digest.capacity(),
DnsRecord::RRSIG {
domain,
signer_name,
signature,
..
} => domain.capacity() + signer_name.capacity() + signature.capacity(),
DnsRecord::NSEC {
domain,
next_domain,
type_bitmap,
..
} => domain.capacity() + next_domain.capacity() + type_bitmap.capacity(),
DnsRecord::NSEC3 {
domain,
salt,
next_hashed_owner,
type_bitmap,
..
} => {
domain.capacity()
+ salt.capacity()
+ next_hashed_owner.capacity()
+ type_bitmap.capacity()
}
DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(),
}
}
pub fn set_ttl(&mut self, new_ttl: u32) { pub fn set_ttl(&mut self, new_ttl: u32) {
match self { match self {
DnsRecord::A { ttl, .. } DnsRecord::A { ttl, .. }

View File

@@ -100,6 +100,10 @@ impl SrttCache {
addrs.sort_by_key(|a| self.get(a.ip())); addrs.sort_by_key(|a| self.get(a.ip()));
} }
pub fn heap_bytes(&self) -> usize {
self.entries.capacity() * (std::mem::size_of::<IpAddr>() + std::mem::size_of::<SrttEntry>())
}
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.entries.len() self.entries.len()
} }

View File

@@ -1,5 +1,84 @@
use std::time::Instant; use std::time::Instant;
/// Returns the process resident set size in bytes, or 0 if unavailable.
pub fn process_rss_bytes() -> usize {
#[cfg(target_os = "macos")]
{
macos_rss()
}
#[cfg(target_os = "linux")]
{
linux_rss()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
0
}
}
#[cfg(target_os = "macos")]
fn macos_rss() -> usize {
use std::mem;
extern "C" {
fn mach_task_self() -> u32;
fn task_info(
target_task: u32,
flavor: u32,
task_info_out: *mut libc_task_basic_info,
task_info_count: *mut u32,
) -> i32;
}
#[repr(C)]
struct libc_task_basic_info {
virtual_size: u64,
resident_size: u64,
resident_size_max: u64,
user_time: [u32; 2],
system_time: [u32; 2],
policy: i32,
suspend_count: i32,
}
const MACH_TASK_BASIC_INFO: u32 = 20;
let mut info: libc_task_basic_info = unsafe { mem::zeroed() };
let mut count = (mem::size_of::<libc_task_basic_info>() / mem::size_of::<u32>()) as u32;
let kr = unsafe {
task_info(
mach_task_self(),
MACH_TASK_BASIC_INFO,
&mut info,
&mut count,
)
};
if kr == 0 {
info.resident_size as usize
} else {
0
}
}
#[cfg(target_os = "linux")]
fn linux_rss() -> usize {
extern "C" {
fn sysconf(name: i32) -> i64;
}
const SC_PAGESIZE: i32 = 30;
let page_size = unsafe { sysconf(SC_PAGESIZE) };
let page_size = if page_size > 0 {
page_size as usize
} else {
4096
};
if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") {
if let Some(rss_pages) = statm.split_whitespace().nth(1) {
if let Ok(pages) = rss_pages.parse::<usize>() {
return pages * page_size;
}
}
}
0
}
pub struct ServerStats { pub struct ServerStats {
queries_total: u64, queries_total: u64,
queries_forwarded: u64, queries_forwarded: u64,