From 2ecfd26fb55fcffc4466c5ba128f30dc70f56159 Mon Sep 17 00:00:00 2001 From: Mathious6 <91007976+Mathious6@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:35:08 +0200 Subject: [PATCH 1/7] fix(country): retry unresolved batches across rounds --- src-tauri/src/country.rs | 168 +++++++++++++++++++++++++++++++-------- 1 file changed, 134 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/country.rs b/src-tauri/src/country.rs index 8ce3a69..397bd9a 100644 --- a/src-tauri/src/country.rs +++ b/src-tauri/src/country.rs @@ -1,4 +1,5 @@ use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fmt; use std::net::Ipv4Addr; use std::thread; use std::time::{Duration, Instant}; @@ -9,8 +10,9 @@ const ENDPOINT: &str = "https://api.country.is/"; const TIMEOUT: Duration = Duration::from_secs(8); const BATCH_SIZE: usize = 100; const REQUEST_INTERVAL: Duration = Duration::from_millis(100); -const RETRY_DELAY: Duration = Duration::from_millis(250); -const ATTEMPTS: usize = 2; +const RETRY_DELAY: Duration = Duration::from_secs(1); +const ATTEMPTS: usize = 3; +const ROUNDS: usize = 2; #[derive(serde::Deserialize)] struct Lookup { @@ -18,53 +20,91 @@ struct Lookup { country: Option, } +#[derive(Debug)] +pub enum LookupError { + Http, + Decode, +} + +impl fmt::Display for LookupError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LookupError::Http => write!(f, "country lookup request failed"), + LookupError::Decode => write!(f, "country lookup response unreadable"), + } + } +} + pub fn lookup(ips: &[Ipv4Addr]) -> HashMap { lookup_with(ips, fetch) } fn lookup_with(ips: &[Ipv4Addr], mut send: F) -> HashMap where - F: FnMut(&[Ipv4Addr]) -> Result, ()>, + F: FnMut(&[Ipv4Addr]) -> Result, LookupError>, { - let ips: Vec<_> = ips + let unique: Vec<_> = ips .iter() .copied() .collect::>() .into_iter() .collect(); - let requested: HashSet<_> = ips.iter().copied().collect(); + let requested: HashSet<_> = unique.iter().copied().collect(); let mut countries = HashMap::new(); + let mut batches: Vec<&[Ipv4Addr]> = unique.chunks(BATCH_SIZE).collect(); let mut last_request: Option = None; - - for batch in ips.chunks(BATCH_SIZE) { - let mut rows = None; - for attempt in 0..ATTEMPTS { - wait_for_request(last_request); - last_request = Some(Instant::now()); - match send(batch) { - Ok(value) => { - rows = Some(value); - break; - } - Err(()) if attempt + 1 < ATTEMPTS => thread::sleep(RETRY_DELAY), - Err(()) => {} + for round in 0..ROUNDS { + let mut unresolved: Vec<&[Ipv4Addr]> = Vec::new(); + for batch in batches { + match send_with_retries(batch, &mut send, &mut last_request) { + Some(rows) => record(&mut countries, &requested, rows), + None => unresolved.push(batch), } } - let Some(rows) = rows else { + if unresolved.is_empty() || round + 1 == ROUNDS { + break; + } + thread::sleep(RETRY_DELAY); + batches = unresolved; + } + countries +} + +fn send_with_retries( + batch: &[Ipv4Addr], + send: &mut F, + last_request: &mut Option, +) -> Option> +where + F: FnMut(&[Ipv4Addr]) -> Result, LookupError>, +{ + for attempt in 0..ATTEMPTS { + wait_for_request(*last_request); + *last_request = Some(Instant::now()); + match send(batch) { + Ok(rows) => return Some(rows), + Err(_) if attempt + 1 < ATTEMPTS => thread::sleep(RETRY_DELAY), + Err(_) => {} + } + } + None +} + +fn record( + countries: &mut HashMap, + requested: &HashSet, + rows: Vec, +) { + for row in rows { + let Ok(ip) = row.ip.parse::() else { continue; }; - for row in rows { - let Ok(ip) = row.ip.parse::() else { - continue; - }; - if requested.contains(&ip) { - if let Some(country) = normalize(row.country.as_deref()) { - countries.insert(Subnet::from_host(ip).cidr(), country); - } + if requested.contains(&ip) { + if let Some(country) = normalize(row.country.as_deref()) { + countries.insert(Subnet::from_host(ip).cidr(), country); } } } - countries } fn wait_for_request(last_request: Option) { @@ -76,14 +116,13 @@ fn wait_for_request(last_request: Option) { } } -fn fetch(ips: &[Ipv4Addr]) -> Result, ()> { +fn fetch(ips: &[Ipv4Addr]) -> Result, LookupError> { let ips: Vec<_> = ips.iter().map(Ipv4Addr::to_string).collect(); - ureq::post(ENDPOINT) + let response = ureq::post(ENDPOINT) .timeout(TIMEOUT) .send_json(ips) - .map_err(|_| ())? - .into_json() - .map_err(|_| ()) + .map_err(|_| LookupError::Http)?; + response.into_json().map_err(|_| LookupError::Decode) } fn normalize(value: Option<&str>) -> Option { @@ -173,7 +212,7 @@ mod tests { let countries = lookup_with(&[requested], |_| { attempts += 1; if attempts == 1 { - Err(()) + Err(LookupError::Http) } else { Ok(vec![Lookup { ip: requested.to_string(), @@ -185,6 +224,67 @@ mod tests { assert_eq!(countries.get("151.242.94.0/24"), Some(&"DE".into())); } + #[test] + fn lookup_retries_unresolved_batches_in_a_later_round() { + let requested: Ipv4Addr = "192.0.2.10".parse().unwrap(); + let mut calls = 0; + let countries = lookup_with(&[requested], |_| { + calls += 1; + if calls <= ATTEMPTS { + Err(LookupError::Http) + } else { + Ok(vec![Lookup { + ip: requested.to_string(), + country: Some("DE".into()), + }]) + } + }); + assert_eq!(calls, ATTEMPTS + 1); + assert_eq!(countries.get("192.0.2.0/24"), Some(&"DE".into())); + } + + #[test] + fn lookup_resends_only_unresolved_batches_in_a_later_round() { + let requested: Ipv4Addr = "192.0.2.0".parse().unwrap(); + let other: Ipv4Addr = "198.51.100.20".parse().unwrap(); + let mut all: Vec = (0..100) + .map(|offset| Ipv4Addr::new(192, 0, 2, offset)) + .collect(); + all.push(other); + let mut bulk_calls = 0; + let mut single_calls = 0; + let countries = lookup_with(&all, |batch| { + if batch.len() > 1 { + bulk_calls += 1; + if bulk_calls <= ATTEMPTS { + return Err(LookupError::Http); + } + } else { + single_calls += 1; + } + Ok(vec![Lookup { + ip: batch[0].to_string(), + country: Some("DE".into()), + }]) + }); + assert_eq!(bulk_calls, ATTEMPTS + 1); + assert_eq!(single_calls, 1); + assert_eq!(countries.get("192.0.2.0/24"), Some(&"DE".into())); + assert_eq!(countries.get("198.51.100.0/24"), Some(&"DE".into())); + } + + #[test] + fn lookup_gives_up_after_all_rounds() { + let requested: Ipv4Addr = "192.0.2.10".parse().unwrap(); + let mut calls = 0; + let countries = lookup_with(&[requested], |_| { + calls += 1; + Err::, LookupError>(LookupError::Http) + }); + assert_eq!(calls, ATTEMPTS * ROUNDS); + assert!(countries.is_empty()); + } + #[test] fn lookup_decodes_bulk_response() { let rows: Vec = From 137baacaa4977b47016c3a10de6cfc79792d843c Mon Sep 17 00:00:00 2001 From: Mathious6 <91007976+Mathious6@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:39:53 +0200 Subject: [PATCH 2/7] fix(country): refresh stored countries on demand --- src-tauri/src/commands.rs | 35 +++++++++++++++++++++++++++++++---- src-tauri/src/lib.rs | 1 + 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4875311..5cde7ba 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -113,7 +113,7 @@ pub fn export_dir( session: State<'_, SessionStore>, tags: State<'_, TagStore>, ) -> Result { - let buckets = export_buckets(&session, cidrs)?; + let buckets = scoped_buckets(&session, cidrs)?; let store = tags.0.lock().map_err(|err| err.to_string())?; export::write_dir(std::path::Path::new(&path), &buckets, &store) } @@ -125,18 +125,45 @@ pub fn export_aycd( session: State<'_, SessionStore>, tags: State<'_, TagStore>, ) -> Result { - let buckets = export_buckets(&session, cidrs)?; + let buckets = scoped_buckets(&session, cidrs)?; let store = tags.0.lock().map_err(|err| err.to_string())?; aycd::write(std::path::Path::new(&path), &buckets, &store) } -fn export_buckets( +#[tauri::command] +pub async fn refresh_countries( + cidrs: Option>, + session: State<'_, SessionStore>, + inventory: State<'_, InventoryStore>, + tags: State<'_, TagStore>, +) -> Result, String> { + let buckets = scoped_buckets(&session, cidrs)?; + let samples: Vec<_> = buckets + .iter() + .filter_map(|bucket| bucket.proxies.first().map(|proxy| proxy.host)) + .collect(); + let countries = tauri::async_runtime::spawn_blocking(move || crate::country::lookup(&samples)); + let countries = countries.await.map_err(|err| err.to_string())?; + if !countries.is_empty() { + let mut session = session.0.lock().map_err(|err| err.to_string())?; + let mut candidate = session.clone(); + candidate.record_countries(&countries); + let snapshot = candidate.snapshot(); + inventory.0.save(&snapshot)?; + *session = candidate; + } + let session = session.0.lock().map_err(|err| err.to_string())?; + let tags = tags.0.lock().map_err(|err| err.to_string())?; + Ok(import::rows_from(&session.snapshot(), &tags)) +} + +fn scoped_buckets( session: &SessionStore, cidrs: Option>, ) -> Result, String> { let session = session.0.lock().map_err(|err| err.to_string())?; if session.is_empty() { - return Err("Import proxies before exporting.".into()); + return Err("Import proxies first.".into()); } session.resolve_scope(cidrs) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8317e04..38e50ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,6 +43,7 @@ pub fn run() { import::set_tags, commands::last_target, commands::start_run, + commands::refresh_countries, commands::export_dir, commands::export_aycd, commands::session_rows, From 1cc00778d6dbdcc8f145f719fa05ac1d1c7ab20a Mon Sep 17 00:00:00 2001 From: Mathious6 <91007976+Mathious6@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:42:25 +0200 Subject: [PATCH 3/7] fix(ui): surface country refresh for missing countries --- AGENTS.md | 6 ++++-- README.md | 3 ++- src/routes/+page.svelte | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3743d5d..560171f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,9 @@ One window, not resizable. One workflow: 1. Drop a `.txt` file or a folder of `.txt` files, or Open files (`host:port:user:pass` HTTP only). 2. Table of IPv4 `/24` subnets. Country lookup batches missing imports and - refreshes every probed subnet before speed measurements. Tags persist by CIDR. + refreshes every probed subnet before speed measurements. Refresh countries + re-runs the lookup for the stored subnets and saves the result. Tags persist + by CIDR. Inventory persists locally; Open files appends. Last probe metrics persist until that `/24` gains lines. The last HTTPS target is remembered. 3. Select `/24`s with click, Cmd/Ctrl-click, or Shift-click across the current @@ -24,7 +26,7 @@ One window, not resizable. One workflow: 4. Export `[{tags}_]{CC}_{IP}_24_{qty}.txt`. Untagged files start with the country code. No country → `XX`. The Export menu also writes one AYCD JSON file for the same scope, categorized by the TXT filename stem. -5. Filter sits with Open files, Export, and Probe all. Toolbar Probe and +5. Filter sits with Open files, Refresh countries, Export, and Probe all. Toolbar Probe and Export act on selected rows when present. Probe progress and the version sit in the bottom bar. 15 subnets per page. Right-click a selected row to Probe or Export the selection; otherwise those actions apply to that row. diff --git a/README.md b/README.md index 0171198..ee5b163 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ usually already have it. Windows installers are unsigned. 3. Rows appear immediately, one per IPv4 `/24`. Country is looked up in batches from one listed IP per missing `/24` through [country.is](https://country.is). Each Probe refreshes and saves the country - for every `/24` it covers before starting the speed measurements. + for every `/24` it covers before starting the speed measurements. If a + lookup fails, **Refresh countries** re-runs the lookup and saves the result. 4. Tag subnets if you want. Tags are stored on this computer by CIDR and survive the next import. Click a column header to sort. **Filter** sits with Open files, Export, and Probe all. The table shows 15 subnets per diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 030926a..6715102 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -47,6 +47,7 @@ const locked = $derived(busy || running || updating); const selectedScope = $derived(selectedCidrs.size > 0 ? [...selectedCidrs] : null); const selectedLabel = $derived(selectedCidrs.size > 0 ? `${selectedCidrs.size} selected` : ""); + const missingCountries = $derived(rows.filter((row) => !row.country).length); $effect(() => { const available = new Set(rows.map((row) => row.cidr)); @@ -415,6 +416,30 @@ } } + async function refreshCountries() { + if (locked || rows.length === 0) { + return; + } + busy = true; + working = "Refreshing…"; + clearNotice(); + try { + const stored = await invoke("refresh_countries", { cidrs: null }); + rows = stored; + draft = Object.fromEntries(stored.map((row) => [row.cidr, draft[row.cidr] ?? ""])); + const missing = stored.filter((row) => !row.country).length; + showNotice( + missing === 0 ? "Countries refreshed" : `Countries refreshed · ${missing} unknown`, + false, + ); + } catch (error) { + showNotice(String(error), true); + } finally { + busy = false; + working = ""; + } + } + async function saveTags(cidr: string, tags: string[]) { try { const stored = await invoke("set_tags", { cidr, tags }); @@ -551,6 +576,16 @@ > Open files + {#if missingCountries > 0} + + {/if}
- {#if missingCountries > 0} - - {/if}