diff --git a/AGENTS.md b/AGENTS.md index 299409a..b4d6c09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,13 +15,18 @@ One window, not resizable. One workflow: 2. Table of IPv4 `/24` subnets. Country at import. 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. Probe all or Probe one `/24` → OK + Connect p50/p95 + TTFB p50/p95 + last - probe. 5 s timeout, 5 starts/s, 32 in flight. +3. Select `/24`s with click, Cmd/Ctrl-click, or Shift-click across the current + sorted and filtered rows; Cmd/Ctrl-A selects all filtered rows and Escape + clears selection. Selections persist through sort, filter, and pages. + Probe all, selected, or one `/24` → OK + Connect p50/p95 + TTFB + p50/p95 + last probe. 5 s timeout, 5 starts/s, 32 in flight. 4. Export `[{tags}_]{CC}_{IP}_24_{qty}.txt`. Untagged files start with the country code. No country → `XX`. -5. Filter sits with Open files, Export, and Probe all. Probe progress and - the version sit in the bottom bar. 15 subnets per page. Right-click a row - to Probe, Export, or Remove. +5. Filter sits with Open files, 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. + Remove always applies to that row. Probe is the only action verb for a run. Anything else is out of scope until asked. diff --git a/README.md b/README.md index c2f66d6..f10c257 100644 --- a/README.md +++ b/README.md @@ -69,15 +69,22 @@ usually already have it. Windows installers are unsigned. during the speed test. This sends one IP from each `/24` to that service. 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 - page. Probe progress and the app version sit in the bottom bar. - Right-click a row to Probe, Export, or Remove that subnet. -5. **Probe all**, or Probe one row. Paste one HTTPS URL the first time; it + with Open files, Export, and Probe all. The table shows 15 subnets per + page. Click a row to select it; Command-click on macOS or Ctrl-click on + Windows toggles rows, and Shift-click selects the sorted and filtered + range across pages; add Command/Ctrl to extend that range. Command/Ctrl-A + selects all filtered rows, and Escape clears the selection. Selected rows + persist while sorting and filtering. + Probe progress and the app version sit in the bottom bar. Right-click a + selected row to Probe or Export the selection, or any row to act on only + that row; Remove always removes only that row. +5. **Probe all**, selected rows, or one row. Paste one HTTPS URL the first time; it is remembered. Each proxy is probed once with a 5 second timeout. The table fills with OK, Connect, TTFB (p50 and p95), and Last probe. Those stats survive a restart. Adding proxies to a `/24` drops its last probe. Failures are omitted from those timings. -6. **Export** writes one `.txt` per subnet, source lines verbatim: +6. **Export** writes one `.txt` per selected subnet, or every subnet when none + is selected, source lines verbatim: `[{tags}_]{CC}_{IP}_24_{qty}.txt`. Example: `isp-mobile_FR_192.0.2.0_24_42.txt`. No tags means the filename starts with the country code. No country means `XX`. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 934468a..f3dcc53 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,7 +9,6 @@ use crate::import::{self, InventoryStore, TagStore}; use crate::last_target; use crate::run::{self, Metrics, Progress}; use crate::session::{SessionStore, StoredMetrics}; -use crate::split::Subnet; use crate::target::Target; const LAST_TARGET_FILE: &str = "last-target.txt"; @@ -37,7 +36,7 @@ pub fn last_target(store: State<'_, LastTarget>) -> Result, Strin #[tauri::command] pub async fn start_run( url: String, - cidr: Option, + cidrs: Option>, app: AppHandle, session: State<'_, SessionStore>, last: State<'_, LastTarget>, @@ -50,17 +49,7 @@ pub async fn start_run( if session.is_empty() { return Err("Import proxies before probing.".into()); } - match cidr { - Some(value) => { - let subnet = - Subnet::parse_cidr(&value).ok_or_else(|| "Unknown subnet.".to_string())?; - let bucket = session - .bucket(subnet) - .ok_or_else(|| "Unknown subnet.".to_string())?; - vec![bucket] - } - None => session.snapshot(), - } + session.resolve_scope(cidrs)? }; let window = app.clone(); let finished = run::probe_session(buckets, target, move |progress: Progress| { @@ -104,7 +93,7 @@ fn stored_metrics(metrics: &Metrics) -> StoredMetrics { #[tauri::command] pub fn export_dir( path: String, - cidr: Option, + cidrs: Option>, session: State<'_, SessionStore>, tags: State<'_, TagStore>, ) -> Result { @@ -113,17 +102,7 @@ pub fn export_dir( if session.is_empty() { return Err("Import proxies before exporting.".into()); } - match cidr { - Some(value) => { - let subnet = - Subnet::parse_cidr(&value).ok_or_else(|| "Unknown subnet.".to_string())?; - let bucket = session - .bucket(subnet) - .ok_or_else(|| "Unknown subnet.".to_string())?; - vec![bucket] - } - None => session.snapshot(), - } + session.resolve_scope(cidrs)? }; let store = tags.0.lock().map_err(|err| err.to_string())?; export::write_dir(std::path::Path::new(&path), &buckets, &store) @@ -146,7 +125,8 @@ pub fn remove_subnet( inventory: State<'_, InventoryStore>, tags: State<'_, TagStore>, ) -> Result, String> { - let subnet = Subnet::parse_cidr(&cidr).ok_or_else(|| "Unknown subnet.".to_string())?; + let subnet = + crate::split::Subnet::parse_cidr(&cidr).ok_or_else(|| "Unknown subnet.".to_string())?; let snapshot = { let mut session = session.0.lock().map_err(|err| err.to_string())?; let mut candidate = session.clone(); diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 7b0946a..616fae2 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Mutex; use crate::parse::ProxyLine; @@ -107,8 +107,26 @@ impl Session { self.buckets.values().cloned().collect() } - pub fn bucket(&self, subnet: Subnet) -> Option { - self.buckets.get(&subnet).cloned() + pub fn resolve_scope(&self, cidrs: Option>) -> Result, String> { + let Some(cidrs) = cidrs else { + return Ok(self.snapshot()); + }; + if cidrs.is_empty() { + return Err("Select at least one subnet.".into()); + } + let subnets = cidrs + .into_iter() + .map(|cidr| Subnet::parse_cidr(&cidr).ok_or_else(|| "Unknown subnet.".to_string())) + .collect::, _>>()?; + subnets + .into_iter() + .map(|subnet| { + self.buckets + .get(&subnet) + .cloned() + .ok_or_else(|| "Unknown subnet.".to_string()) + }) + .collect() } pub fn is_empty(&self) -> bool { @@ -300,18 +318,60 @@ mod tests { } #[test] - fn bucket_returns_one_stored_subnet() { + fn resolve_scope_returns_all_buckets() { let mut session = Session::new(); session.merge( - by_slash24(vec![proxy("192.0.2.10"), proxy("198.51.100.2")]), + by_slash24(vec![proxy("198.51.100.2"), proxy("192.0.2.10")]), + &HashMap::new(), + ); + let buckets = session.resolve_scope(None).unwrap(); + assert_eq!( + buckets + .iter() + .map(|bucket| bucket.subnet.cidr()) + .collect::>(), + vec!["192.0.2.0/24", "198.51.100.0/24"] + ); + } + + #[test] + fn resolve_scope_deduplicates_and_sorts_subnets() { + let mut session = Session::new(); + session.merge( + by_slash24(vec![proxy("198.51.100.2"), proxy("192.0.2.10")]), &HashMap::new(), ); - let one = session - .bucket(Subnet::from_host("192.0.2.10".parse().unwrap())) + let buckets = session + .resolve_scope(Some(vec![ + "198.51.100.0/24".into(), + "192.0.2.0/24".into(), + "198.51.100.0/24".into(), + ])) .unwrap(); - assert_eq!(one.proxies.len(), 1); - assert!(session - .bucket(Subnet::from_host("203.0.113.1".parse().unwrap())) - .is_none()); + assert_eq!( + buckets + .iter() + .map(|bucket| bucket.subnet.cidr()) + .collect::>(), + vec!["192.0.2.0/24", "198.51.100.0/24"] + ); + } + + #[test] + fn resolve_scope_rejects_empty_malformed_and_missing_subnets() { + let mut session = Session::new(); + session.merge(by_slash24(vec![proxy("192.0.2.10")]), &HashMap::new()); + assert!(matches!( + session.resolve_scope(Some(Vec::new())), + Err(error) if error == "Select at least one subnet." + )); + assert!(matches!( + session.resolve_scope(Some(vec!["192.0.2.10/24".into()])), + Err(error) if error == "Unknown subnet." + )); + assert!(matches!( + session.resolve_scope(Some(vec!["198.51.100.0/24".into()])), + Err(error) if error == "Unknown subnet." + )); } } diff --git a/src/lib/SubnetTable.svelte b/src/lib/SubnetTable.svelte index 13db364..41ea1be 100644 --- a/src/lib/SubnetTable.svelte +++ b/src/lib/SubnetTable.svelte @@ -27,11 +27,13 @@ let { rows, + selectedCidrs, draft, locked, filtering, pageReset, onFilterCount, + onSelectionChange, onPageChange, onAddTag, onRemoveTag, @@ -40,11 +42,13 @@ onRemoveSubnet, }: { rows: SubnetRow[]; + selectedCidrs: Set; draft: Record; locked: boolean; filtering: boolean; pageReset: number; onFilterCount: (count: number) => void; + onSelectionChange: (cidrs: Set) => void; onPageChange: (page: { label: string; canPrevious: boolean; @@ -54,8 +58,8 @@ }) => void; onAddTag: (cidr: string) => void; onRemoveTag: (cidr: string, tag: string) => void; - onProbeSubnet: (cidr: string) => void; - onExportSubnet: (cidr: string) => void; + onProbeSubnet: (cidrs: string[]) => void; + onExportSubnet: (cidrs: string[]) => void; onRemoveSubnet: (cidr: string) => void; } = $props(); @@ -204,6 +208,7 @@ table.getAllLeafColumns().filter((column) => column.getIsFiltered()).length, ); const filteredCount = $derived(table.getPrePaginatedRowModel().rows.length); + const orderedRows = $derived(table.getPrePaginatedRowModel().rows); const pagination = $derived(table.atoms.pagination.get()); const fillerCount = $derived(Math.max(0, PAGE_SIZE - visibleRows.length)); const rangeStart = $derived(filteredCount === 0 ? 0 : pagination.pageIndex * PAGE_SIZE + 1); @@ -258,13 +263,75 @@ }; }); + $effect(() => { + function selectByKeyboard(event: KeyboardEvent) { + if (menu || isInteractive(event.target) || isMenu(event.target)) { + return; + } + if (event.key === "Escape" && selectedCidrs.size > 0) { + onSelectionChange(new Set()); + rangeAnchor = null; + return; + } + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") { + event.preventDefault(); + onSelectionChange(new Set(orderedRows.map((row) => row.original.cidr))); + } + } + window.addEventListener("keydown", selectByKeyboard); + return () => window.removeEventListener("keydown", selectByKeyboard); + }); + let menu = $state<{ cidr: string; x: number; y: number } | null>(null); + let rangeAnchor = $state(null); function openMenu(event: MouseEvent, cidr: string) { event.preventDefault(); menu = { cidr, x: event.clientX, y: event.clientY }; } + function selectRow(event: MouseEvent, cidr: string) { + if (isInteractive(event.target)) { + return; + } + const additive = event.metaKey || event.ctrlKey; + if (event.shiftKey && rangeAnchor) { + const start = orderedRows.findIndex((row) => row.original.cidr === rangeAnchor); + const end = orderedRows.findIndex((row) => row.original.cidr === cidr); + if (start >= 0 && end >= 0) { + const range = orderedRows + .slice(Math.min(start, end), Math.max(start, end) + 1) + .map((row) => row.original.cidr); + onSelectionChange(additive ? new Set([...selectedCidrs, ...range]) : new Set(range)); + return; + } + } + if (additive) { + const next = new Set(selectedCidrs); + if (next.has(cidr)) { + next.delete(cidr); + } else { + next.add(cidr); + } + onSelectionChange(next); + } else { + onSelectionChange(new Set([cidr])); + } + rangeAnchor = cidr; + } + + function isInteractive(target: EventTarget | null): boolean { + return target instanceof Element && Boolean(target.closest("button, input, select, textarea, a")); + } + + function isMenu(target: EventTarget | null): boolean { + return target instanceof Element && Boolean(target.closest('[role="menu"]')); + } + + function menuScope(cidr: string): string[] { + return selectedCidrs.has(cidr) ? [...selectedCidrs] : [cidr]; + } + const numericIds = new Set([ "quantity", "ok", @@ -411,10 +478,14 @@ {/each} - + {#each visibleRows as row (row.id)} selectRow(event, row.original.cidr)} oncontextmenu={(event) => openMenu(event, row.original.cidr)} > {#each row.getAllCells() as cell (cell.id)} @@ -459,14 +530,14 @@ const cidr = menu?.cidr; menu = null; if (cidr) { - onProbeSubnet(cidr); + onProbeSubnet(menuScope(cidr)); } }} onExport={() => { const cidr = menu?.cidr; menu = null; if (cidr) { - onExportSubnet(cidr); + onExportSubnet(menuScope(cidr)); } }} onRemove={() => { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c29f481..a306ae7 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -27,7 +27,8 @@ let targetField = $state(null); let filtering = $state(false); let filterCount = $state(0); - let probeCidr = $state(null); + let selectedCidrs = $state>(new Set()); + let probeCidrs = $state(null); let pageReset = $state(0); let version = $state(""); let updating = $state(false); @@ -41,6 +42,16 @@ let noticeTimer: ReturnType | null = null; const locked = $derived(busy || running || updating); + const selectedScope = $derived(selectedCidrs.size > 0 ? [...selectedCidrs] : null); + const selectedLabel = $derived(selectedCidrs.size > 0 ? `${selectedCidrs.size} selected` : ""); + + $effect(() => { + const available = new Set(rows.map((row) => row.cidr)); + const next = new Set([...selectedCidrs].filter((cidr) => available.has(cidr))); + if (next.size !== selectedCidrs.size) { + selectedCidrs = next; + } + }); function showNotice(message: string, isError: boolean) { if (noticeTimer) { @@ -172,7 +183,7 @@ await importPaths(Array.isArray(selected) ? selected : [selected]); } - async function exportFiles(cidr?: string) { + async function exportFiles(cidrs: string[] | null = selectedScope) { if (locked || rows.length === 0) { return; } @@ -193,7 +204,7 @@ working = "Exporting…"; clearNotice(); try { - const written = await invoke("export_dir", { path, cidr: cidr ?? null }); + const written = await invoke("export_dir", { path, cidrs }); showNotice(written === 1 ? "Exported 1 file" : `Exported ${written} files`, false); } catch (error) { showNotice(String(error), true); @@ -229,13 +240,11 @@ } const probeScope = $derived.by(() => { - if (probeCidr) { - const row = rows.find((item) => item.cidr === probeCidr); - if (!row) { - return ""; - } - const n = row.quantity; - return `${row.cidr} · ${n.toLocaleString()} ${n === 1 ? "proxy" : "proxies"}`; + const cidrs = probeCidrs; + if (cidrs) { + const scoped = rows.filter((row) => cidrs.includes(row.cidr)); + const proxies = scoped.reduce((sum, row) => sum + row.quantity, 0); + return `${scoped.length.toLocaleString()} ${scoped.length === 1 ? "subnet" : "subnets"} · ${proxies.toLocaleString()} ${proxies === 1 ? "proxy" : "proxies"}`; } const n = rows.length; const proxies = rows.reduce((sum, row) => sum + row.quantity, 0); @@ -246,20 +255,20 @@ if (locked || rows.length === 0) { return; } - probeCidr = null; + probeCidrs = selectedScope; asking = true; } function cancelProbe() { asking = false; - probeCidr = null; + probeCidrs = null; } - async function probeSubnet(cidr: string) { + async function probeSubnets(cidrs: string[]) { if (locked) { return; } - probeCidr = cidr; + probeCidrs = cidrs; if (target.trim()) { await confirmProbe(); return; @@ -272,20 +281,20 @@ if (!url || locked) { return; } - const cidr = probeCidr; + const cidrs = probeCidrs; asking = false; running = true; clearNotice(); done = 0; - const scoped = cidr ? rows.filter((row) => row.cidr === cidr) : rows; + const scoped = cidrs ? rows.filter((row) => cidrs.includes(row.cidr)) : rows; total = scoped.reduce((sum, row) => sum + row.quantity, 0); etaSeconds = null; const previous = rows; rows = rows.map((row) => - (!cidr || row.cidr === cidr) && row.quantity > 0 ? { ...row, ...emptyMetrics() } : row, + (!cidrs || cidrs.includes(row.cidr)) && row.quantity > 0 ? { ...row, ...emptyMetrics() } : row, ); try { - const probed = await invoke("start_run", { url, cidr }); + const probed = await invoke("start_run", { url, cidrs }); const byCidr = new Map(probed.metrics.map((metrics) => [metrics.cidr, metrics])); rows = rows.map((row) => { const metrics = byCidr.get(row.cidr); @@ -304,7 +313,7 @@ } finally { running = false; etaSeconds = null; - probeCidr = null; + probeCidrs = null; } } @@ -462,7 +471,9 @@ {#if busy && !running} {working || "Working…"} {:else} - {rows.length} {rows.length === 1 ? "subnet" : "subnets"} + {rows.length} {rows.length === 1 ? "subnet" : "subnets"}{selectedLabel + ? ` · ${selectedLabel}` + : ""} {/if}

@@ -498,21 +509,23 @@ disabled={locked} onclick={openProbe} > - Probe all + {selectedLabel ? "Probe selected" : "Probe all"} (filterCount = count)} + onSelectionChange={(cidrs) => (selectedCidrs = cidrs)} onPageChange={(page) => (paging = page)} onAddTag={commitTag} onRemoveTag={removeTag} - onProbeSubnet={probeSubnet} + onProbeSubnet={probeSubnets} onExportSubnet={exportFiles} onRemoveSubnet={removeSubnet} />