feat: add memory footprint to /stats and dashboard (#26)
* 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> * fix: use phys_footprint on macOS to match Activity Monitor Switch from MACH_TASK_BASIC_INFO (resident_size) to TASK_VM_INFO (phys_footprint) which matches Activity Monitor's Memory column. Also: capacity-aware heap estimation, entry counts in memory payload, heap_bytes tests for all stores. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove redundant fields and fix naming in memory stats Remove duplicate entry counts from MemoryStats (already in parent StatsResponse), rename process_rss_bytes to process_memory_bytes to match macOS phys_footprint semantics, drop restating comments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #26.
This commit is contained in:
@@ -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"> </div>
|
<div class="stat-sub" id="uptimeSub"> </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"> </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,69 @@ 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_memory_bytes);
|
||||||
|
document.getElementById('memorySub').textContent = 'est. ' + formatBytes(mem.total_estimated_bytes);
|
||||||
|
|
||||||
|
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 Footprint</span>
|
||||||
|
<span>${formatBytes(mem.process_memory_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 +1105,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';
|
||||||
|
|||||||
52
src/api.rs
52
src/api.rs
@@ -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_memory_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_memory_bytes: crate::stats::process_memory_bytes(),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,15 @@ impl BlocklistStore {
|
|||||||
self.allowlist.iter().cloned().collect()
|
self.allowlist.iter().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn heap_bytes(&self) -> usize {
|
||||||
|
let per_slot_overhead = std::mem::size_of::<u64>() + std::mem::size_of::<String>() + 1;
|
||||||
|
let domains_table = self.domains.capacity() * per_slot_overhead;
|
||||||
|
let domains_heap: usize = self.domains.iter().map(|d| d.capacity()).sum();
|
||||||
|
let allow_table = self.allowlist.capacity() * per_slot_overhead;
|
||||||
|
let allow_heap: usize = self.allowlist.iter().map(|d| d.capacity()).sum();
|
||||||
|
domains_table + domains_heap + allow_table + allow_heap
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stats(&self) -> BlocklistStats {
|
pub fn stats(&self) -> BlocklistStats {
|
||||||
BlocklistStats {
|
BlocklistStats {
|
||||||
enabled: self.is_enabled(),
|
enabled: self.is_enabled(),
|
||||||
@@ -234,6 +243,23 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
|
|||||||
domains
|
domains
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heap_bytes_grows_with_domains() {
|
||||||
|
let mut store = BlocklistStore::new();
|
||||||
|
let empty = store.heap_bytes();
|
||||||
|
let domains: HashSet<String> = ["example.com", "example.org", "test.net"]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
store.swap_domains(domains, vec![]);
|
||||||
|
assert!(store.heap_bytes() > empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
|
pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
|||||||
40
src/cache.rs
40
src/cache.rs
@@ -142,6 +142,26 @@ impl DnsCache {
|
|||||||
self.entry_count = 0;
|
self.entry_count = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn heap_bytes(&self) -> usize {
|
||||||
|
let outer_slot = std::mem::size_of::<u64>()
|
||||||
|
+ std::mem::size_of::<String>()
|
||||||
|
+ std::mem::size_of::<HashMap<QueryType, CacheEntry>>()
|
||||||
|
+ 1;
|
||||||
|
let mut total = self.entries.capacity() * outer_slot;
|
||||||
|
for (domain, type_map) in &self.entries {
|
||||||
|
total += domain.capacity();
|
||||||
|
let inner_slot = std::mem::size_of::<u64>()
|
||||||
|
+ std::mem::size_of::<QueryType>()
|
||||||
|
+ std::mem::size_of::<CacheEntry>()
|
||||||
|
+ 1;
|
||||||
|
total += type_map.capacity() * inner_slot;
|
||||||
|
for entry in type_map.values() {
|
||||||
|
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) {
|
||||||
@@ -194,3 +214,23 @@ fn adjust_ttls(records: &mut [DnsRecord], new_ttl: u32) {
|
|||||||
record.set_ttl(new_ttl);
|
record.set_ttl(new_ttl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::packet::DnsPacket;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heap_bytes_grows_with_entries() {
|
||||||
|
let mut cache = DnsCache::new(100, 1, 3600);
|
||||||
|
let empty = cache.heap_bytes();
|
||||||
|
let mut pkt = DnsPacket::new();
|
||||||
|
pkt.answers.push(DnsRecord::A {
|
||||||
|
domain: "example.com".into(),
|
||||||
|
addr: "1.2.3.4".parse().unwrap(),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
cache.insert("example.com", QueryType::A, &pkt);
|
||||||
|
assert!(cache.heap_bytes() > empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,6 +117,22 @@ impl OverrideStore {
|
|||||||
self.entries.clear();
|
self.entries.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn heap_bytes(&self) -> usize {
|
||||||
|
let per_slot = std::mem::size_of::<u64>()
|
||||||
|
+ std::mem::size_of::<String>()
|
||||||
|
+ std::mem::size_of::<OverrideEntry>()
|
||||||
|
+ 1;
|
||||||
|
let table = self.entries.capacity() * per_slot;
|
||||||
|
let heap: usize = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
k.capacity() + v.domain.capacity() + v.target.capacity() + v.record.heap_bytes()
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
table + heap
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
@@ -154,3 +170,16 @@ fn parse_target(domain: &str, target: &str, ttl: u32) -> Result<(QueryType, DnsR
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heap_bytes_grows_with_entries() {
|
||||||
|
let mut store = OverrideStore::new();
|
||||||
|
let empty = store.heap_bytes();
|
||||||
|
store.insert("example.com", "1.2.3.4", 300, None).unwrap();
|
||||||
|
assert!(store.heap_bytes() > empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -591,4 +610,16 @@ mod tests {
|
|||||||
panic!("expected DNSKEY");
|
panic!("expected DNSKEY");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heap_bytes_accounts_for_records() {
|
||||||
|
let mut pkt = DnsPacket::new();
|
||||||
|
let empty = pkt.heap_bytes();
|
||||||
|
pkt.answers.push(DnsRecord::A {
|
||||||
|
domain: "example.com".into(),
|
||||||
|
addr: "1.2.3.4".parse().unwrap(),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
assert!(pkt.heap_bytes() > empty);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -77,3 +92,25 @@ pub struct QueryLogFilter {
|
|||||||
pub since: Option<SystemTime>,
|
pub since: Option<SystemTime>,
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heap_bytes_grows_with_entries() {
|
||||||
|
let mut log = QueryLog::new(100);
|
||||||
|
let empty = log.heap_bytes();
|
||||||
|
log.push(QueryLogEntry {
|
||||||
|
timestamp: SystemTime::now(),
|
||||||
|
src_addr: "127.0.0.1:1234".parse().unwrap(),
|
||||||
|
domain: "example.com".into(),
|
||||||
|
query_type: QueryType::A,
|
||||||
|
path: QueryPath::Forwarded,
|
||||||
|
rescode: ResultCode::NOERROR,
|
||||||
|
latency_us: 500,
|
||||||
|
dnssec: DnssecStatus::Indeterminate,
|
||||||
|
});
|
||||||
|
assert!(log.heap_bytes() > empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, .. }
|
||||||
@@ -650,4 +690,14 @@ mod tests {
|
|||||||
let parsed = round_trip(&rec);
|
let parsed = round_trip(&rec);
|
||||||
assert_eq!(rec, parsed);
|
assert_eq!(rec, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heap_bytes_reflects_string_capacity() {
|
||||||
|
let rec = DnsRecord::CNAME {
|
||||||
|
domain: "a]".repeat(100),
|
||||||
|
host: "b".repeat(200),
|
||||||
|
ttl: 60,
|
||||||
|
};
|
||||||
|
assert!(rec.heap_bytes() >= 300);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/srtt.rs
18
src/srtt.rs
@@ -100,6 +100,14 @@ 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 {
|
||||||
|
let per_slot = std::mem::size_of::<u64>()
|
||||||
|
+ std::mem::size_of::<IpAddr>()
|
||||||
|
+ std::mem::size_of::<SrttEntry>()
|
||||||
|
+ 1;
|
||||||
|
self.entries.capacity() * per_slot
|
||||||
|
}
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.entries.len()
|
self.entries.len()
|
||||||
}
|
}
|
||||||
@@ -303,6 +311,16 @@ mod tests {
|
|||||||
assert_eq!(addrs, vec![sock(1), sock(2)]);
|
assert_eq!(addrs, vec![sock(1), sock(2)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heap_bytes_grows_with_entries() {
|
||||||
|
let mut cache = SrttCache::new(true);
|
||||||
|
let empty = cache.heap_bytes();
|
||||||
|
for i in 1..=10u8 {
|
||||||
|
cache.record_rtt(ip(i), 100, false);
|
||||||
|
}
|
||||||
|
assert!(cache.heap_bytes() > empty);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn eviction_removes_oldest() {
|
fn eviction_removes_oldest() {
|
||||||
let mut cache = SrttCache::new(true);
|
let mut cache = SrttCache::new(true);
|
||||||
|
|||||||
87
src/stats.rs
87
src/stats.rs
@@ -1,5 +1,92 @@
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Returns the process memory footprint in bytes, or 0 if unavailable.
|
||||||
|
/// macOS: phys_footprint (matches Activity Monitor). Linux: RSS from /proc/self/statm.
|
||||||
|
pub fn process_memory_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 TaskVmInfo,
|
||||||
|
task_info_count: *mut u32,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
// Partial task_vm_info_data_t — only fields up to phys_footprint.
|
||||||
|
#[repr(C)]
|
||||||
|
struct TaskVmInfo {
|
||||||
|
virtual_size: u64,
|
||||||
|
region_count: i32,
|
||||||
|
page_size: i32,
|
||||||
|
resident_size: u64,
|
||||||
|
resident_size_peak: u64,
|
||||||
|
device: u64,
|
||||||
|
device_peak: u64,
|
||||||
|
internal: u64,
|
||||||
|
internal_peak: u64,
|
||||||
|
external: u64,
|
||||||
|
external_peak: u64,
|
||||||
|
reusable: u64,
|
||||||
|
reusable_peak: u64,
|
||||||
|
purgeable_volatile_pmap: u64,
|
||||||
|
purgeable_volatile_resident: u64,
|
||||||
|
purgeable_volatile_virtual: u64,
|
||||||
|
compressed: u64,
|
||||||
|
compressed_peak: u64,
|
||||||
|
compressed_lifetime: u64,
|
||||||
|
phys_footprint: u64,
|
||||||
|
}
|
||||||
|
const TASK_VM_INFO: u32 = 22;
|
||||||
|
let mut info: TaskVmInfo = unsafe { mem::zeroed() };
|
||||||
|
let mut count = (mem::size_of::<TaskVmInfo>() / mem::size_of::<u32>()) as u32;
|
||||||
|
let kr = unsafe { task_info(mach_task_self(), TASK_VM_INFO, &mut info, &mut count) };
|
||||||
|
if kr == 0 {
|
||||||
|
info.phys_footprint as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_rss() -> usize {
|
||||||
|
extern "C" {
|
||||||
|
fn sysconf(name: i32) -> i64;
|
||||||
|
}
|
||||||
|
const SC_PAGESIZE: i32 = 30; // x86_64 + aarch64; differs on mips (28), sparc (29)
|
||||||
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user