[Frontend] Marc/frontend granular city/state geo proxy (#4156)

This commit is contained in:
Marc Kelechava
2025-12-01 18:25:08 -08:00
committed by GitHub
parent d0a9095b0d
commit 36c2af90b0
12 changed files with 636 additions and 76 deletions

View File

@@ -42,6 +42,7 @@
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
"cors": "^2.8.5",
"country-state-city": "^3.2.1",
"cross-spawn": "^7.0.6",
"embla-carousel-react": "^8.0.0",
"express": "^4.21.2",
@@ -5015,6 +5016,11 @@
"node": ">= 0.10"
}
},
"node_modules/country-state-city": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/country-state-city/-/country-state-city-3.2.1.tgz",
"integrity": "sha512-kxbanqMc6izjhc/EHkGPCTabSPZ2G6eG4/97akAYHJUN4stzzFEvQPZoF8oXDQ+10gM/O/yUmISCR1ZVxyb6EA=="
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",

View File

@@ -51,6 +51,7 @@
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
"cors": "^2.8.5",
"country-state-city": "^3.2.1",
"cross-spawn": "^7.0.6",
"embla-carousel-react": "^8.0.0",
"express": "^4.21.2",

View File

@@ -56,7 +56,17 @@ export const ProxyLocation = {
None: "NONE",
} as const;
export type ProxyLocation = (typeof ProxyLocation)[keyof typeof ProxyLocation];
export type LegacyProxyLocation =
(typeof ProxyLocation)[keyof typeof ProxyLocation];
export type GeoTarget = {
country: string;
subdivision?: string;
city?: string;
isISP?: boolean;
};
export type ProxyLocation = LegacyProxyLocation | GeoTarget | null;
export type ArtifactApiResponse = {
created_at: string;

View File

@@ -0,0 +1,207 @@
import * as React from "react";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@/util/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { GeoTarget } from "@/api/types";
import { formatGeoTargetCompact } from "@/util/geoData";
import {
GroupedSearchResults,
searchGeoData,
SearchResultItem,
} from "@/util/geoSearch";
import { useDebouncedCallback } from "use-debounce";
interface GeoTargetSelectorProps {
value: GeoTarget | null;
onChange: (value: GeoTarget) => void;
className?: string;
}
export function GeoTargetSelector({
value,
onChange,
className,
}: GeoTargetSelectorProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [results, setResults] = React.useState<GroupedSearchResults>({
countries: [],
subdivisions: [],
cities: [],
});
const [loading, setLoading] = React.useState(false);
const handleSearch = useDebouncedCallback(async (searchQuery: string) => {
setLoading(true);
try {
const data = await searchGeoData(searchQuery);
setResults(data);
} catch (error) {
console.error("Failed to search geo data", error);
} finally {
setLoading(false);
}
}, 300);
// Initial load of countries
React.useEffect(() => {
if (open) {
handleSearch("");
}
}, [open, handleSearch]);
const onInput = (val: string) => {
setQuery(val);
handleSearch(val);
};
const handleSelect = (item: SearchResultItem) => {
onChange(item.value);
setOpen(false);
};
const isSelected = (itemValue: GeoTarget) => {
if (!value) return false;
return (
value.country === itemValue.country &&
value.subdivision === itemValue.subdivision &&
value.city === itemValue.city
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between", className)}
>
<span className="truncate">
{value ? formatGeoTargetCompact(value) : "Select proxy location..."}
</span>
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search country, state, or city..."
value={query}
onValueChange={onInput}
/>
<CommandList>
{loading && (
<div className="py-6 text-center text-sm text-muted-foreground">
Loading...
</div>
)}
{!loading &&
results.countries.length === 0 &&
results.subdivisions.length === 0 &&
results.cities.length === 0 && (
<CommandEmpty>No location found.</CommandEmpty>
)}
{!loading && (
<>
{results.countries.length > 0 && (
<CommandGroup heading="Countries">
{results.countries.map((item) => (
<CommandItem
key={`country-${item.value.country}`}
value={JSON.stringify(item.value)}
onSelect={() => handleSelect(item)}
>
<span className="mr-2 text-lg">{item.icon}</span>
<span>{item.label}</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
isSelected(item.value)
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
)}
{results.subdivisions.length > 0 && (
<CommandGroup heading="States / Regions">
{results.subdivisions.map((item) => (
<CommandItem
key={`sub-${item.value.country}-${item.value.subdivision}`}
value={JSON.stringify(item.value)}
onSelect={() => handleSelect(item)}
>
<span className="mr-2 text-lg">{item.icon}</span>
<div className="flex flex-col">
<span>{item.label}</span>
<span className="text-xs text-muted-foreground">
{item.description}
</span>
</div>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
isSelected(item.value)
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
)}
{results.cities.length > 0 && (
<CommandGroup heading="Cities">
{results.cities.map((item) => (
<CommandItem
key={`city-${item.value.country}-${item.value.subdivision}-${item.value.city}`}
value={JSON.stringify(item.value)}
onSelect={() => handleSelect(item)}
>
<span className="mr-2 text-lg">{item.icon}</span>
<div className="flex flex-col">
<span>{item.label}</span>
<span className="text-xs text-muted-foreground">
{item.description}
</span>
</div>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
isSelected(item.value)
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
)}
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,82 +1,31 @@
import { ProxyLocation } from "@/api/types";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
geoTargetToProxyLocationInput,
proxyLocationToGeoTarget,
} from "@/util/geoData";
import { GeoTargetSelector } from "./GeoTargetSelector";
type Props = {
value: ProxyLocation | null;
value: ProxyLocation;
onChange: (value: ProxyLocation) => void;
className?: string;
};
function ProxySelector({ value, onChange, className }: Props) {
// Convert input (string enum or object) to GeoTarget for the selector
const geoTargetValue = proxyLocationToGeoTarget(value);
return (
<Select value={value ?? ""} onValueChange={onChange}>
<SelectTrigger className={className}>
<SelectValue placeholder="Proxy Location" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ProxyLocation.Residential}>Residential</SelectItem>
<SelectItem value={ProxyLocation.ResidentialISP}>
Residential ISP (US)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialAR}>
Residential (Argentina)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialAU}>
Residential (Australia)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialBR}>
Residential (Brazil)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialCA}>
Residential (Canada)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialFR}>
Residential (France)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialDE}>
Residential (Germany)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialIN}>
Residential (India)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialIE}>
Residential (Ireland)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialIT}>
Residential (Italy)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialJP}>
Residential (Japan)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialMX}>
Residential (Mexico)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialNL}>
Residential (Netherlands)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialNZ}>
Residential (New Zealand)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialZA}>
Residential (South Africa)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialES}>
Residential (Spain)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialTR}>
Residential (Turkey)
</SelectItem>
<SelectItem value={ProxyLocation.ResidentialGB}>
Residential (United Kingdom)
</SelectItem>
</SelectContent>
</Select>
<GeoTargetSelector
className={className}
value={geoTargetValue}
onChange={(newTarget) => {
// Convert back to ProxyLocation enum if possible (for simple countries)
// or keep as GeoTarget object
const newValue = geoTargetToProxyLocationInput(newTarget);
onChange(newValue);
}}
/>
);
}

View File

@@ -3,6 +3,7 @@ import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { ProxyLocation } from "@/api/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {

View File

@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { BrowserSession } from "@/routes/workflows/types/browserSessionTypes";
import { ProxyLocation } from "@/api/types";
function useCreateBrowserSessionMutation() {
const queryClient = useQueryClient();
@@ -16,7 +17,7 @@ function useCreateBrowserSessionMutation() {
proxyLocation = null,
timeout = null,
}: {
proxyLocation: string | null;
proxyLocation: ProxyLocation | null;
timeout: number | null;
}) => {
const client = await getClient(credentialGetter, "sans-api-v1");

View File

@@ -15,7 +15,16 @@ const createNewTaskFormSchemaBase = z.object({
totpIdentifier: z.string().or(z.null()),
cdpAddress: z.string().or(z.null()),
errorCodeMapping: z.string().or(z.null()),
proxyLocation: z.nativeEnum(ProxyLocation).or(z.null()),
proxyLocation: z
.union([
z.nativeEnum(ProxyLocation),
z.object({
country: z.string(),
subdivision: z.string().optional(),
city: z.string().optional(),
}),
])
.nullable(),
includeActionHistoryInVerification: z.boolean().or(z.null()).default(false),
maxScreenshotScrolls: z.number().or(z.null()).default(null),
});

View File

@@ -244,7 +244,7 @@ function RunWorkflowForm({
defaultValues: {
...initialValues,
webhookCallbackUrl: initialSettings.webhookCallbackUrl,
proxyLocation: initialSettings.proxyLocation,
proxyLocation: initialSettings.proxyLocation ?? ProxyLocation.Residential,
browserSessionId: null,
cdpAddress: initialSettings.cdpAddress,
maxScreenshotScrolls: initialSettings.maxScreenshotScrolls,
@@ -342,7 +342,7 @@ function RunWorkflowForm({
form.reset({
...initialValues,
webhookCallbackUrl: initialSettings.webhookCallbackUrl,
proxyLocation: initialSettings.proxyLocation,
proxyLocation: initialSettings.proxyLocation ?? ProxyLocation.Residential,
browserSessionId: null,
cdpAddress: initialSettings.cdpAddress,
maxScreenshotScrolls: initialSettings.maxScreenshotScrolls,

View File

@@ -1,11 +1,11 @@
import { RunEngine } from "@/api/types";
import { ProxyLocation, RunEngine } from "@/api/types";
import { WorkflowBlockType } from "./workflowTypes";
import { WorkflowModel } from "./workflowTypes";
export type WorkflowCreateYAMLRequest = {
title: string;
description?: string | null;
proxy_location?: string | null;
proxy_location?: ProxyLocation | null;
webhook_callback_url?: string | null;
persist_browser_session?: boolean;
model?: WorkflowModel | null;

View File

@@ -0,0 +1,202 @@
import { GeoTarget, ProxyLocation } from "@/api/types";
export const SUPPORTED_COUNTRY_CODES = [
"US",
"AR",
"AU",
"BR",
"CA",
"DE",
"ES",
"FR",
"GB",
"IE",
"IN",
"IT",
"JP",
"MX",
"NL",
"NZ",
"TR",
"ZA",
] as const;
export type SupportedCountryCode = (typeof SUPPORTED_COUNTRY_CODES)[number];
export const COUNTRY_NAMES: Record<SupportedCountryCode, string> = {
US: "United States",
AR: "Argentina",
AU: "Australia",
BR: "Brazil",
CA: "Canada",
DE: "Germany",
ES: "Spain",
FR: "France",
GB: "United Kingdom",
IE: "Ireland",
IN: "India",
IT: "Italy",
JP: "Japan",
MX: "Mexico",
NL: "Netherlands",
NZ: "New Zealand",
TR: "Turkey",
ZA: "South Africa",
};
export const COUNTRY_FLAGS: Record<SupportedCountryCode, string> = {
US: "🇺🇸",
AR: "🇦🇷",
AU: "🇦🇺",
BR: "🇧🇷",
CA: "🇨🇦",
DE: "🇩🇪",
ES: "🇪🇸",
FR: "🇫🇷",
GB: "🇬🇧",
IE: "🇮🇪",
IN: "🇮🇳",
IT: "🇮🇹",
JP: "🇯🇵",
MX: "🇲🇽",
NL: "🇳🇱",
NZ: "🇳🇿",
TR: "🇹🇷",
ZA: "🇿🇦",
};
// Map legacy ProxyLocation to Country Code
const PROXY_LOCATION_TO_COUNTRY: Record<string, string> = {
[ProxyLocation.Residential]: "US",
[ProxyLocation.ResidentialISP]: "US",
[ProxyLocation.ResidentialAR]: "AR",
[ProxyLocation.ResidentialAU]: "AU",
[ProxyLocation.ResidentialBR]: "BR",
[ProxyLocation.ResidentialCA]: "CA",
[ProxyLocation.ResidentialDE]: "DE",
[ProxyLocation.ResidentialES]: "ES",
[ProxyLocation.ResidentialFR]: "FR",
[ProxyLocation.ResidentialGB]: "GB",
[ProxyLocation.ResidentialIE]: "IE",
[ProxyLocation.ResidentialIN]: "IN",
[ProxyLocation.ResidentialIT]: "IT",
[ProxyLocation.ResidentialJP]: "JP",
[ProxyLocation.ResidentialMX]: "MX",
[ProxyLocation.ResidentialNL]: "NL",
[ProxyLocation.ResidentialNZ]: "NZ",
[ProxyLocation.ResidentialTR]: "TR",
[ProxyLocation.ResidentialZA]: "ZA",
};
// Reverse map for round-tripping simple country selections
const COUNTRY_TO_PROXY_LOCATION: Record<string, ProxyLocation> = {
US: ProxyLocation.Residential,
AR: ProxyLocation.ResidentialAR,
AU: ProxyLocation.ResidentialAU,
BR: ProxyLocation.ResidentialBR,
CA: ProxyLocation.ResidentialCA,
DE: ProxyLocation.ResidentialDE,
ES: ProxyLocation.ResidentialES,
FR: ProxyLocation.ResidentialFR,
GB: ProxyLocation.ResidentialGB,
IE: ProxyLocation.ResidentialIE,
IN: ProxyLocation.ResidentialIN,
IT: ProxyLocation.ResidentialIT,
JP: ProxyLocation.ResidentialJP,
MX: ProxyLocation.ResidentialMX,
NL: ProxyLocation.ResidentialNL,
NZ: ProxyLocation.ResidentialNZ,
TR: ProxyLocation.ResidentialTR,
ZA: ProxyLocation.ResidentialZA,
};
export function proxyLocationToGeoTarget(
input: ProxyLocation,
): GeoTarget | null {
if (!input) return null;
// If it's already a GeoTarget object
if (typeof input === "object" && "country" in input) {
return input;
}
// If it's a legacy string
if (typeof input === "string") {
if (input === ProxyLocation.None) return null;
if (input === ProxyLocation.ResidentialISP) {
return { country: "US", isISP: true };
}
const country = PROXY_LOCATION_TO_COUNTRY[input];
if (country) {
return { country };
}
}
return null;
}
export function geoTargetToProxyLocationInput(
target: GeoTarget | null,
): ProxyLocation {
if (!target) return ProxyLocation.None;
if (target.isISP) {
return ProxyLocation.ResidentialISP;
}
// Try to map back to legacy enum if it's just a country
if (target.country && !target.subdivision && !target.city) {
const legacyLocation = COUNTRY_TO_PROXY_LOCATION[target.country];
if (legacyLocation) {
return legacyLocation;
}
}
// Otherwise return the object
return target;
}
export function formatGeoTarget(target: GeoTarget | null): string {
if (!target || !target.country) return "No Proxy";
const parts = [];
// Add Flag
if (target.country in COUNTRY_FLAGS) {
parts.push(COUNTRY_FLAGS[target.country as SupportedCountryCode]);
}
if (target.city) parts.push(target.city);
if (target.subdivision) parts.push(target.subdivision);
// Country Name
const countryName =
COUNTRY_NAMES[target.country as SupportedCountryCode] || target.country;
if (target.isISP) {
parts.push(`${countryName} (ISP)`);
} else {
parts.push(countryName);
}
return parts.join(" ");
}
export function formatGeoTargetCompact(target: GeoTarget | null): string {
if (!target || !target.country) return "No Proxy";
const parts = [];
if (target.city) parts.push(target.city);
if (target.subdivision) parts.push(target.subdivision);
const countryName =
COUNTRY_NAMES[target.country as SupportedCountryCode] || target.country;
if (target.isISP) {
parts.push(`${countryName} (ISP)`);
} else {
parts.push(countryName);
}
const text = parts.join(", ");
const flag = COUNTRY_FLAGS[target.country as SupportedCountryCode] || "";
return `${flag} ${text}`.trim();
}

View File

@@ -0,0 +1,174 @@
import { GeoTarget } from "@/api/types";
import {
COUNTRY_FLAGS,
COUNTRY_NAMES,
SUPPORTED_COUNTRY_CODES,
SupportedCountryCode,
} from "./geoData";
export type SearchResultItem = {
type: "country" | "subdivision" | "city";
label: string;
value: GeoTarget;
description?: string;
icon?: string;
};
export type GroupedSearchResults = {
countries: SearchResultItem[];
subdivisions: SearchResultItem[];
cities: SearchResultItem[];
};
let cscModule: typeof import("country-state-city") | null = null;
async function loadCsc() {
if (!cscModule) {
cscModule = await import("country-state-city");
}
return cscModule;
}
export async function searchGeoData(
query: string,
): Promise<GroupedSearchResults> {
const normalizedQuery = query.trim().toLowerCase();
const queryMatchesISP = normalizedQuery.includes("isp");
const results: GroupedSearchResults = {
countries: [],
subdivisions: [],
cities: [],
};
// 1. Countries (Always search supported countries)
// We can do this without loading the heavy lib if we use our hardcoded lists
SUPPORTED_COUNTRY_CODES.forEach((code) => {
const name = COUNTRY_NAMES[code];
const matchesCountry =
name.toLowerCase().includes(normalizedQuery) ||
code.toLowerCase().includes(normalizedQuery);
const shouldIncludeUSForISP = code === "US" && queryMatchesISP;
if (matchesCountry || shouldIncludeUSForISP) {
results.countries.push({
type: "country",
label: name,
value: { country: code },
description: code,
icon: COUNTRY_FLAGS[code],
});
}
});
if (results.countries.length > 0) {
const usIndex = results.countries.findIndex(
(item) => item.value.country === "US" && !item.value.isISP,
);
if (usIndex !== -1 || queryMatchesISP) {
const ispItem: SearchResultItem = {
type: "country",
label: "United States (ISP)",
value: { country: "US", isISP: true },
description: "US",
icon: COUNTRY_FLAGS.US,
};
const insertIndex = usIndex !== -1 ? usIndex + 1 : 0;
results.countries.splice(insertIndex, 0, ispItem);
}
}
// If query is very short, just return countries to save perf
if (normalizedQuery.length < 2) {
return results;
}
// 2. Subdivisions & Cities (Load heavy lib)
const csc = await loadCsc();
// Search Subdivisions
// We only search subdivisions of SUPPORTED countries
for (const countryCode of SUPPORTED_COUNTRY_CODES) {
const states = csc.State.getStatesOfCountry(countryCode);
for (const state of states) {
if (
state.name.toLowerCase().includes(normalizedQuery) ||
state.isoCode.toLowerCase() === normalizedQuery
) {
results.subdivisions.push({
type: "subdivision",
label: state.name,
value: { country: countryCode, subdivision: state.isoCode },
description: `${state.isoCode}, ${COUNTRY_NAMES[countryCode]}`,
icon: COUNTRY_FLAGS[countryCode],
});
}
// Limit subdivisions per country to avoid overwhelming
if (results.subdivisions.length >= 10) break;
}
}
// Search Cities
// Searching ALL cities of ALL supported countries is heavy.
// We optimize by breaking early once we have enough results.
const prefixMatches: SearchResultItem[] = [];
const partialMatches: SearchResultItem[] = [];
for (const countryCode of SUPPORTED_COUNTRY_CODES) {
const cities = csc.City.getCitiesOfCountry(countryCode) || [];
for (const city of cities) {
const nameLower = city.name.toLowerCase();
const item: SearchResultItem = {
type: "city",
label: city.name,
value: {
country: countryCode,
subdivision: city.stateCode,
city: city.name,
},
description: `${city.stateCode}, ${COUNTRY_NAMES[countryCode]}`,
icon: COUNTRY_FLAGS[countryCode],
};
if (nameLower === normalizedQuery) {
// Exact match goes to the front
prefixMatches.unshift(item);
} else if (nameLower.startsWith(normalizedQuery)) {
prefixMatches.push(item);
} else if (nameLower.includes(normalizedQuery)) {
partialMatches.push(item);
}
// Break if we have enough total cities
if (prefixMatches.length + partialMatches.length > 100) break;
}
if (prefixMatches.length + partialMatches.length > 100) break;
}
results.cities = [...prefixMatches, ...partialMatches];
// Slice to final limits
results.subdivisions = results.subdivisions.slice(0, 5);
results.cities = results.cities.slice(0, 20);
return results;
}
export async function getCountryName(code: string): Promise<string> {
if (code in COUNTRY_NAMES) {
return COUNTRY_NAMES[code as SupportedCountryCode];
}
const csc = await loadCsc();
return csc.Country.getCountryByCode(code)?.name || code;
}
export async function getSubdivisionName(
countryCode: string,
subdivisionCode: string,
): Promise<string> {
const csc = await loadCsc();
return (
csc.State.getStateByCodeAndCountry(subdivisionCode, countryCode)?.name ||
subdivisionCode
);
}