Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
32 changes: 6 additions & 26 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -37,7 +36,7 @@ pub fn last_target(store: State<'_, LastTarget>) -> Result<Option<String>, Strin
#[tauri::command]
pub async fn start_run(
url: String,
cidr: Option<String>,
cidrs: Option<Vec<String>>,
app: AppHandle,
session: State<'_, SessionStore>,
last: State<'_, LastTarget>,
Expand All @@ -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| {
Expand Down Expand Up @@ -104,7 +93,7 @@ fn stored_metrics(metrics: &Metrics) -> StoredMetrics {
#[tauri::command]
pub fn export_dir(
path: String,
cidr: Option<String>,
cidrs: Option<Vec<String>>,
session: State<'_, SessionStore>,
tags: State<'_, TagStore>,
) -> Result<usize, String> {
Expand All @@ -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)
Expand All @@ -146,7 +125,8 @@ pub fn remove_subnet(
inventory: State<'_, InventoryStore>,
tags: State<'_, TagStore>,
) -> Result<Vec<import::SubnetRow>, 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();
Expand Down
82 changes: 71 additions & 11 deletions src-tauri/src/session.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Mutex;

use crate::parse::ProxyLine;
Expand Down Expand Up @@ -107,8 +107,26 @@ impl Session {
self.buckets.values().cloned().collect()
}

pub fn bucket(&self, subnet: Subnet) -> Option<StoredBucket> {
self.buckets.get(&subnet).cloned()
pub fn resolve_scope(&self, cidrs: Option<Vec<String>>) -> Result<Vec<StoredBucket>, 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::<Result<BTreeSet<_>, _>>()?;
subnets
.into_iter()
.map(|subnet| {
self.buckets
.get(&subnet)
.cloned()
.ok_or_else(|| "Unknown subnet.".to_string())
})
.collect()
}

pub fn is_empty(&self) -> bool {
Expand Down Expand Up @@ -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<_>>(),
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<_>>(),
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."
));
}
}
83 changes: 77 additions & 6 deletions src/lib/SubnetTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@

let {
rows,
selectedCidrs,
draft,
locked,
filtering,
pageReset,
onFilterCount,
onSelectionChange,
onPageChange,
onAddTag,
onRemoveTag,
Expand All @@ -40,11 +42,13 @@
onRemoveSubnet,
}: {
rows: SubnetRow[];
selectedCidrs: Set<string>;
draft: Record<string, string>;
locked: boolean;
filtering: boolean;
pageReset: number;
onFilterCount: (count: number) => void;
onSelectionChange: (cidrs: Set<string>) => void;
onPageChange: (page: {
label: string;
canPrevious: boolean;
Expand All @@ -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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string | null>(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",
Expand Down Expand Up @@ -411,10 +478,14 @@
</tr>
{/each}
</thead>
<tbody>
<tbody class="select-none">
{#each visibleRows as row (row.id)}
<tr
class="group h-8 border-t border-line hover:bg-raised"
class="group h-8 border-t border-line {selectedCidrs.has(row.original.cidr)
? 'bg-accent/10 shadow-[inset_3px_0_0_var(--color-accent)] hover:bg-accent/15'
: 'hover:bg-raised'}"
aria-selected={selectedCidrs.has(row.original.cidr)}
onclick={(event) => selectRow(event, row.original.cidr)}
oncontextmenu={(event) => openMenu(event, row.original.cidr)}
>
{#each row.getAllCells() as cell (cell.id)}
Expand Down Expand Up @@ -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={() => {
Expand Down
Loading