dynamic banner width, hoist HTML escaper, cache CA, restore log path

- banner box width adapts to longest value (fixes overflow with long paths)
- hoist h() HTML escape function to script top, remove 3 local copies
- serve_ca: add Cache-Control: public, max-age=86400
- restore log path in dashboard footer alongside new config/data fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-23 12:29:18 +02:00
parent c6b35045d8
commit e7e5c173f2
3 changed files with 108 additions and 40 deletions

View File

@@ -661,6 +661,7 @@ body {
<script> <script>
const API = ''; const API = '';
const h = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
let prevTotal = null; let prevTotal = null;
let lastLogEntries = []; let lastLogEntries = [];
let prevTime = null; let prevTime = null;
@@ -1008,16 +1009,14 @@ async function checkDomain(event) {
if (result.blocked) { if (result.blocked) {
el.style.background = 'rgba(181, 68, 58, 0.1)'; el.style.background = 'rgba(181, 68, 58, 0.1)';
el.style.color = 'var(--rose)'; el.style.color = 'var(--rose)';
const hd = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); el.innerHTML = `<strong>Blocked</strong> — ${h(result.reason)}` +
el.innerHTML = `<strong>Blocked</strong> — ${hd(result.reason)}` + (result.matched_rule ? `<br>Rule: <code>${h(result.matched_rule)}</code>` : '') +
(result.matched_rule ? `<br>Rule: <code>${hd(result.matched_rule)}</code>` : '') + ` <button class="btn-delete" onclick="allowDomain('${h(domain)}')" style="color:var(--emerald);font-size:0.7rem;margin-left:0.4rem;">allow</button>`;
` <button class="btn-delete" onclick="allowDomain('${hd(domain)}')" style="color:var(--emerald);font-size:0.7rem;margin-left:0.4rem;">allow</button>`;
} else { } else {
const hd = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
el.style.background = 'rgba(82, 122, 82, 0.1)'; el.style.background = 'rgba(82, 122, 82, 0.1)';
el.style.color = 'var(--emerald)'; el.style.color = 'var(--emerald)';
el.innerHTML = `<strong>Allowed</strong> — ${hd(result.reason)}` + el.innerHTML = `<strong>Allowed</strong> — ${h(result.reason)}` +
(result.matched_rule ? `<br>Rule: <code>${hd(result.matched_rule)}</code>` : ''); (result.matched_rule ? `<br>Rule: <code>${h(result.matched_rule)}</code>` : '');
} }
} catch (err) { } catch (err) {
el.style.display = 'block'; el.style.display = 'block';
@@ -1122,7 +1121,6 @@ function renderServices(entries) {
? '<span class="lan-badge shared" title="Reachable from other devices on the network">LAN</span>' ? '<span class="lan-badge shared" title="Reachable from other devices on the network">LAN</span>'
: '<span class="lan-badge local-only" title="Bound to localhost — not reachable from other devices. Start with 0.0.0.0 to share on LAN.">local only</span>') : '<span class="lan-badge local-only" title="Bound to localhost — not reachable from other devices. Start with 0.0.0.0 to share on LAN.">local only</span>')
: ''; : '';
const h = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
const routeLines = (e.routes || []).map(r => const routeLines = (e.routes || []).map(r =>
`<div class="service-port" style="color:var(--text-dim);display:flex;align-items:center;gap:0.3rem;">` + `<div class="service-port" style="color:var(--text-dim);display:flex;align-items:center;gap:0.3rem;">` +
`<span style="display:inline-block;min-width:60px;">${h(r.path)}</span> ` + `<span style="display:inline-block;min-width:60px;">${h(r.path)}</span> ` +
@@ -1230,6 +1228,7 @@ setInterval(refresh, 2000);
Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span> Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span>
· Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span> · Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span>
· Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span> · Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span>
· Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>
· <a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener" style="color:var(--amber);text-decoration:none;">GitHub</a> · <a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener" style="color:var(--amber);text-decoration:none;">GitHub</a>
</div> </div>

View File

@@ -828,6 +828,7 @@ async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse
header::CONTENT_DISPOSITION, header::CONTENT_DISPOSITION,
"attachment; filename=\"numa-ca.pem\"", "attachment; filename=\"numa-ca.pem\"",
), ),
(header::CACHE_CONTROL, "public, max-age=86400"),
], ],
bytes, bytes,
)) ))

View File

@@ -171,46 +171,114 @@ async fn main() -> numa::Result<()> {
}); });
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
eprintln!("\n\x1b[38;2;192;98;58m ╔══════════════════════════════════════════╗\x1b[0m");
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[1;38;2;192;98;58mNUMA\x1b[0m \x1b[3;38;2;163;152;136mDNS that governs itself\x1b[0m \x1b[38;2;163;152;136mv{}\x1b[0m \x1b[38;2;192;98;58m║\x1b[0m", env!("CARGO_PKG_VERSION")); // Build banner rows, then size the box to fit the longest value
eprintln!("\x1b[38;2;192;98;58m ╠══════════════════════════════════════════╣\x1b[0m"); let api_url = format!("http://localhost:{}", api_port);
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mDNS\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", config.server.bind_addr); let proxy_label = if config.proxy.enabled {
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mAPI\x1b[0m http://localhost:{:<16}\x1b[38;2;192;98;58m║\x1b[0m", api_port); if config.proxy.tls_port > 0 {
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mDashboard\x1b[0m http://localhost:{:<16}\x1b[38;2;192;98;58m║\x1b[0m", api_port); Some(format!(
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mUpstream\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", upstream);
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mZones\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("{} records", zone_count));
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mCache\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("max {} entries", config.cache.max_entries));
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mBlocking\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
if config.blocking.enabled { format!("{} lists", config.blocking.lists.len()) } else { "disabled".to_string() });
if config.proxy.enabled {
let schemes = if config.proxy.tls_port > 0 {
format!(
"http://:{} https://:{}", "http://:{} https://:{}",
config.proxy.port, config.proxy.tls_port config.proxy.port, config.proxy.tls_port
) ))
} else { } else {
format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port) Some(format!(
"http://*.{} on :{}",
config.proxy.tld, config.proxy.port
))
}
} else {
None
}; };
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mProxy\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", schemes);
}
if config.lan.enabled {
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mLAN\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
"mDNS (_numa._tcp.local)");
}
if !ctx.forwarding_rules.is_empty() {
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mRouting\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m",
format!("{} conditional rules", ctx.forwarding_rules.len()));
}
eprintln!("\x1b[38;2;192;98;58m ╠──────────────────────────────────────────╣\x1b[0m");
let config_label = if ctx.config_found { let config_label = if ctx.config_found {
ctx.config_path.clone() ctx.config_path.clone()
} else { } else {
format!("{} (defaults)", ctx.config_path) format!("{} (defaults)", ctx.config_path)
}; };
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mConfig\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", config_label); let data_label = ctx.data_dir.display().to_string();
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mData\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", ctx.data_dir.display()); let services_label = ctx.config_dir.join("services.json").display().to_string();
eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mServices\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", ctx.config_dir.join("services.json").display());
eprintln!("\x1b[38;2;192;98;58m ╚══════════════════════════════════════════╝\x1b[0m\n"); // label (10) + value + padding (2) = inner width; minimum 40 for the title row
let val_w = [
config.server.bind_addr.len(),
api_url.len(),
upstream.to_string().len(),
config_label.len(),
data_label.len(),
services_label.len(),
]
.into_iter()
.chain(proxy_label.as_ref().map(|s| s.len()))
.max()
.unwrap_or(30);
let w = (val_w + 12).max(42); // 10 label + 2 padding, min 42 for title
let o = "\x1b[38;2;192;98;58m"; // orange
let g = "\x1b[38;2;107;124;78m"; // green
let d = "\x1b[38;2;163;152;136m"; // dim
let r = "\x1b[0m"; // reset
let b = "\x1b[1;38;2;192;98;58m"; // bold orange
let it = "\x1b[3;38;2;163;152;136m"; // italic dim
let bar_top = "".repeat(w);
let bar_mid = "".repeat(w);
let row = |label: &str, color: &str, value: &str| {
eprintln!(
"{o}{r} {color}{:<9}{r} {:<vw$}{o}{r}",
label,
value,
vw = w - 12
);
};
// Title row: center within the box
let title = format!(
"{b}NUMA{r} {it}DNS that governs itself{r} {d}v{}{r}",
env!("CARGO_PKG_VERSION")
);
// The title contains ANSI codes; visible length is ~38 chars. Pad to fill the box.
let title_visible_len = 4 + 2 + 24 + 2 + 1 + env!("CARGO_PKG_VERSION").len() + 1;
let title_pad = w.saturating_sub(title_visible_len);
eprintln!("\n{o}{bar_top}{r}");
eprint!("{o}{r} {title}");
eprintln!("{}{o}{r}", " ".repeat(title_pad));
eprintln!("{o}{bar_top}{r}");
row("DNS", g, &config.server.bind_addr);
row("API", g, &api_url);
row("Dashboard", g, &api_url);
row("Upstream", g, &upstream.to_string());
row("Zones", g, &format!("{} records", zone_count));
row(
"Cache",
g,
&format!("max {} entries", config.cache.max_entries),
);
row(
"Blocking",
g,
&if config.blocking.enabled {
format!("{} lists", config.blocking.lists.len())
} else {
"disabled".to_string()
},
);
if let Some(ref label) = proxy_label {
row("Proxy", g, label);
}
if config.lan.enabled {
row("LAN", g, "mDNS (_numa._tcp.local)");
}
if !ctx.forwarding_rules.is_empty() {
row(
"Routing",
g,
&format!("{} conditional rules", ctx.forwarding_rules.len()),
);
}
eprintln!("{o}{bar_mid}{r}");
row("Config", d, &config_label);
row("Data", d, &data_label);
row("Services", d, &services_label);
eprintln!("{o}{bar_top}{r}\n");
info!( info!(
"numa listening on {}, upstream {}, {} zone records, cache max {}, API on port {}", "numa listening on {}, upstream {}, {} zone records, cache max {}, API on port {}",