From cc635f2f73e5fac3f7999f27eb352d6c00c18386 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 06:15:48 +0300 Subject: [PATCH 1/5] feat(dashboard): show version in header, restructure footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #108. - Add `version` field to /stats (from CARGO_PKG_VERSION). - Show `v0.13.1` next to the Numa wordmark in the dashboard header. - Restructure the footer into two semantic rows: Row 1 (paths): Config · Data · Logs (platform-detected) Row 2 (runtime): Upstream · DNSSEC · SRTT · GitHub - Drop Mode from the footer (redundant with Upstream label). - Show only the matching-platform log path instead of both macOS and Linux unconditionally. --- site/dashboard.html | 19 ++++++++++++------- src/api.rs | 2 ++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 77018fc..de286ab 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -561,6 +561,7 @@ body {
+
DNS that governs itself
@@ -1136,16 +1137,20 @@ async function refresh() { document.getElementById('totalQueries').textContent = formatNumber(q.total); document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs); document.getElementById('uptimeSub').textContent = formatUptimeSub(stats.uptime_secs); + document.getElementById('headerVersion').textContent = stats.version ? 'v' + stats.version : ''; document.getElementById('footerUpstream').textContent = stats.upstream || ''; document.getElementById('footerConfig').textContent = stats.config_path || ''; document.getElementById('footerData').textContent = stats.data_dir || ''; - const modeEl = document.getElementById('footerMode'); - modeEl.textContent = stats.mode || '—'; - modeEl.style.color = stats.mode === 'recursive' ? 'var(--emerald)' : 'var(--amber)'; document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off'; document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)'; document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off'; document.getElementById('footerSrtt').style.color = stats.srtt ? 'var(--emerald)' : 'var(--text-dim)'; + if (!document.getElementById('footerLogs').textContent) { + const isMac = stats.data_dir && stats.data_dir.includes('/usr/local/'); + document.getElementById('footerLogs').textContent = isMac + ? '/usr/local/var/log/numa.log' + : 'journalctl -u numa -f'; + } // LAN status indicator const lanEl = document.getElementById('lanToggle'); @@ -1504,14 +1509,14 @@ refresh(); setInterval(refresh, 2000); -
+
Config: · Data: - · Upstream: - · Mode: + · Logs: +
+ Upstream: · DNSSEC: · SRTT: - · Logs: macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f · GitHub
diff --git a/src/api.rs b/src/api.rs index 17c4614..f8b2702 100644 --- a/src/api.rs +++ b/src/api.rs @@ -160,6 +160,7 @@ struct QueryLogResponse { #[derive(Serialize)] struct StatsResponse { + version: &'static str, uptime_secs: u64, upstream: String, mode: &'static str, // "recursive" or "forward" — never "auto" at runtime @@ -539,6 +540,7 @@ async fn stats(State(ctx): State>) -> Json { }; Json(StatsResponse { + version: env!("CARGO_PKG_VERSION"), uptime_secs: snap.uptime_secs, upstream, mode: ctx.upstream_mode.as_str(), From 1c5e703330bab7ca1a822246f346f79677d52863 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 06:39:29 +0300 Subject: [PATCH 2/5] =?UTF-8?q?fix(dashboard):=20collapse=20header=20on=20?= =?UTF-8?q?mobile=20(=E2=89=A4700px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide tagline, version tag, and Phone Setup on narrow viewports so the header stays single-row: logo + status dot + blocking toggle. Reduces logo font-size from 1.8rem to 1.4rem on mobile. --- site/dashboard.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/dashboard.html b/site/dashboard.html index de286ab..85b6984 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -552,7 +552,11 @@ body { @media (max-width: 700px) { .stats-row { grid-template-columns: repeat(2, 1fr); } .dashboard { padding: 1rem; } - .header { padding: 1rem; } + .header { padding: 0.8rem 1rem; } + .logo { font-size: 1.4rem; } + .tagline { display: none; } + #headerVersion { display: none; } + #phoneSetup { display: none; } } From 0118ab0f442e638274aaa68b32a3470d00fee4cf Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 13:02:25 +0300 Subject: [PATCH 3/5] feat: embed git SHA in version string via build.rs Adds a build.rs that runs `git describe --tags --always --dirty` and sets NUMA_BUILD_VERSION at compile time. A new `numa::version()` helper returns the build version, falling back to CARGO_PKG_VERSION when git is unavailable (source tarballs, Docker builds without .git). Version strings: tagged release: 0.13.1 commits ahead: 0.13.1+a87f907 uncommitted changes: 0.13.1+a87f907-dirty no git: 0.13.1 Replaces all 6 inline env!("CARGO_PKG_VERSION") call sites with the single version() function. --- build.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/api.rs | 2 +- src/health.rs | 4 ++-- src/lib.rs | 8 ++++++++ src/main.rs | 10 ++++------ 5 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 build.rs diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..e3375af --- /dev/null +++ b/build.rs @@ -0,0 +1,47 @@ +fn main() { + let git_version = std::process::Command::new("git") + .args(["describe", "--tags", "--always", "--dirty"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| { + let s = s.trim(); + let s = s.strip_prefix('v').unwrap_or(s); + // "0.13.1" → clean tag → "0.13.1" + // "0.13.1-9-ga87f907" → ahead → "0.13.1+a87f907" + // "0.13.1-9-ga87f907-dirty" → dirty → "0.13.1+a87f907-dirty" + // "a87f907" → no tags → "0.0.0+a87f907" + // "a87f907-dirty" → no tags → "0.0.0+a87f907-dirty" + if let Some((base, rest)) = s.split_once("-") { + // Could be "0.13.1-9-ga87f907[-dirty]" or "a87f907-dirty" + if base.contains('.') { + // Tagged: extract sha from "-N-gSHA[-dirty]" + let parts: Vec<&str> = rest.splitn(3, '-').collect(); + match parts.as_slice() { + [_n, sha] => format!("{}+{}", base, sha.strip_prefix('g').unwrap_or(sha)), + [_n, sha, "dirty"] => { + format!("{}+{}-dirty", base, sha.strip_prefix('g').unwrap_or(sha)) + } + _ => s.to_string(), + } + } else { + // Untagged: "sha-dirty" + format!("0.0.0+{}", s) + } + } else if s.contains('.') { + // Exact tag match: "0.13.1" + s.to_string() + } else { + // Bare sha, no tags at all + format!("0.0.0+{}", s) + } + }); + + if let Some(v) = git_version { + println!("cargo:rustc-env=NUMA_BUILD_VERSION={}", v); + } + + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/refs/tags/"); +} diff --git a/src/api.rs b/src/api.rs index f8b2702..dd1fe78 100644 --- a/src/api.rs +++ b/src/api.rs @@ -540,7 +540,7 @@ async fn stats(State(ctx): State>) -> Json { }; Json(StatsResponse { - version: env!("CARGO_PKG_VERSION"), + version: crate::version(), uptime_secs: snap.uptime_secs, upstream, mode: ctx.upstream_mode.as_str(), diff --git a/src/health.rs b/src/health.rs index e55c569..5767f4b 100644 --- a/src/health.rs +++ b/src/health.rs @@ -43,7 +43,7 @@ impl HealthMeta { #[cfg(test)] pub fn test_fixture() -> Self { HealthMeta { - version: env!("CARGO_PKG_VERSION"), + version: crate::version(), hostname: "test-host".to_string(), sni: "numa.numa".to_string(), dot_enabled: false, @@ -99,7 +99,7 @@ impl HealthMeta { } HealthMeta { - version: env!("CARGO_PKG_VERSION"), + version: crate::version(), hostname: crate::hostname(), sni: "numa.numa".to_string(), dot_enabled, diff --git a/src/lib.rs b/src/lib.rs index 8933e2a..a9d38fc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,14 @@ pub(crate) mod testutil; pub type Error = Box; pub type Result = std::result::Result; +/// Build version string. On tagged releases: `0.13.1`. On commits ahead +/// of a tag: `0.13.1+a87f907`. With uncommitted changes: `0.13.1+a87f907-dirty`. +/// Falls back to `CARGO_PKG_VERSION` when built outside a git repo (e.g. +/// from a source tarball). +pub fn version() -> &'static str { + option_env!("NUMA_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")) +} + /// Detect the machine hostname via the `hostname` command. Returns the /// full hostname (e.g., `macbook-pro.local`), or `"numa"` if the command /// fails. Call sites that need the short form (e.g., mDNS instance diff --git a/src/main.rs b/src/main.rs index bce7add..faf2e22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,7 +72,7 @@ async fn main() -> numa::Result<()> { }; } "version" | "--version" | "-V" => { - eprintln!("numa {}", env!("CARGO_PKG_VERSION")); + eprintln!("numa {}", numa::version()); return Ok(()); } "help" | "--help" | "-h" => { @@ -383,12 +383,10 @@ async fn main() -> numa::Result<()> { }; // 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") - ); + let ver = numa::version(); + let title = format!("{b}NUMA{r} {it}DNS that governs itself{r} {d}v{ver}{r}",); // 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_visible_len = 4 + 2 + 24 + 2 + 1 + ver.len() + 1; let title_pad = w.saturating_sub(title_visible_len); eprintln!("\n{o} ╔{bar_top}╗{r}"); eprint!("{o} ║{r} {title}"); From 30bb7365c9b2f0faa0e2456b4b6ac04f84109d1f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 13:18:56 +0300 Subject: [PATCH 4/5] refactor: robust git-describe parsing for pre-release tags Switch to --long flag so format is always TAG-N-gSHA[-dirty], then split from the right. Handles pre-release tags (v0.14.0-rc1) that broke the previous left-split approach. Remove ineffective directory watch on .git/refs/tags/. Trim comments. --- build.rs | 69 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/build.rs b/build.rs index e3375af..463100c 100644 --- a/build.rs +++ b/build.rs @@ -1,47 +1,48 @@ fn main() { + // --long forces "TAG-N-gSHA[-dirty]" format even on exact tag matches, + // making parsing unambiguous for pre-release tags like v0.14.0-rc1. let git_version = std::process::Command::new("git") - .args(["describe", "--tags", "--always", "--dirty"]) + .args(["describe", "--tags", "--always", "--dirty", "--long"]) .output() .ok() .filter(|o| o.status.success()) .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| { - let s = s.trim(); - let s = s.strip_prefix('v').unwrap_or(s); - // "0.13.1" → clean tag → "0.13.1" - // "0.13.1-9-ga87f907" → ahead → "0.13.1+a87f907" - // "0.13.1-9-ga87f907-dirty" → dirty → "0.13.1+a87f907-dirty" - // "a87f907" → no tags → "0.0.0+a87f907" - // "a87f907-dirty" → no tags → "0.0.0+a87f907-dirty" - if let Some((base, rest)) = s.split_once("-") { - // Could be "0.13.1-9-ga87f907[-dirty]" or "a87f907-dirty" - if base.contains('.') { - // Tagged: extract sha from "-N-gSHA[-dirty]" - let parts: Vec<&str> = rest.splitn(3, '-').collect(); - match parts.as_slice() { - [_n, sha] => format!("{}+{}", base, sha.strip_prefix('g').unwrap_or(sha)), - [_n, sha, "dirty"] => { - format!("{}+{}-dirty", base, sha.strip_prefix('g').unwrap_or(sha)) - } - _ => s.to_string(), - } - } else { - // Untagged: "sha-dirty" - format!("0.0.0+{}", s) - } - } else if s.contains('.') { - // Exact tag match: "0.13.1" - s.to_string() - } else { - // Bare sha, no tags at all - format!("0.0.0+{}", s) - } - }); + .and_then(|raw| parse_git_describe(raw.trim())); if let Some(v) = git_version { println!("cargo:rustc-env=NUMA_BUILD_VERSION={}", v); } println!("cargo:rerun-if-changed=.git/HEAD"); - println!("cargo:rerun-if-changed=.git/refs/tags/"); +} + +/// Parse `git describe --long` output into a SemVer-compatible string. +/// "v0.13.1-0-ga87f907" → "0.13.1" +/// "v0.13.1-9-ga87f907" → "0.13.1+a87f907" +/// "v0.14.0-rc1-0-ga87f907" → "0.14.0-rc1" +/// "v0.14.0-rc1-3-ga87f907-dirty" → "0.14.0-rc1+a87f907-dirty" +/// "a87f907" → "0.0.0+a87f907" +fn parse_git_describe(s: &str) -> Option { + let s = s.strip_prefix('v').unwrap_or(s); + let dirty = s.ends_with("-dirty"); + let s = s.strip_suffix("-dirty").unwrap_or(s); + + // --long format: TAG-N-gSHA. Split from the right so tags with hyphens work. + let gpos = s.rfind("-g")?; + let sha = &s[gpos + 2..]; + let rest = &s[..gpos]; + let npos = rest.rfind('-')?; + let n: u32 = rest[npos + 1..].parse().ok()?; + let tag = &rest[..npos]; + + if tag.is_empty() { + return Some(format!("0.0.0+{}", sha)); + } + + Some(match (n, dirty) { + (0, false) => tag.to_string(), + (0, true) => format!("{}+{}-dirty", tag, sha), + (_, false) => format!("{}+{}", tag, sha), + (_, true) => format!("{}+{}-dirty", tag, sha), + }) } From b69cc89d385f80ae86d5dabcb1fd9fd5cb554520 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 16 Apr 2026 15:12:00 +0300 Subject: [PATCH 5/5] fix(dashboard): skip allowlist re-render while input has focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polling refresh replaced the entire allowlist panel innerHTML every 2 seconds, destroying the input field mid-typing. Users had to paste-and-enter faster than the refresh interval — #106 reported this as text "timing out and erasing." Guard: skip renderAllowlist() when allowDomainInput has focus. --- site/dashboard.html | 1 + 1 file changed, 1 insertion(+) diff --git a/site/dashboard.html b/site/dashboard.html index 85b6984..d3b1820 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -1354,6 +1354,7 @@ function renderBlockingInfo(info) { } function renderAllowlist(entries) { + if (document.activeElement && document.activeElement.id === 'allowDomainInput') return; const el = document.getElementById('blockingAllowlist'); const count = entries.length; el.innerHTML = `