[Frontend] Marc/frontend granular city/state geo proxy (#4156)
This commit is contained in:
6
skyvern-frontend/package-lock.json
generated
6
skyvern-frontend/package-lock.json
generated
@@ -42,6 +42,7 @@
|
|||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"country-state-city": "^3.2.1",
|
||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "^7.0.6",
|
||||||
"embla-carousel-react": "^8.0.0",
|
"embla-carousel-react": "^8.0.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
@@ -5015,6 +5016,11 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/crelt": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"country-state-city": "^3.2.1",
|
||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "^7.0.6",
|
||||||
"embla-carousel-react": "^8.0.0",
|
"embla-carousel-react": "^8.0.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
|||||||
@@ -56,7 +56,17 @@ export const ProxyLocation = {
|
|||||||
None: "NONE",
|
None: "NONE",
|
||||||
} as const;
|
} 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 = {
|
export type ArtifactApiResponse = {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
207
skyvern-frontend/src/components/GeoTargetSelector.tsx
Normal file
207
skyvern-frontend/src/components/GeoTargetSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,82 +1,31 @@
|
|||||||
import { ProxyLocation } from "@/api/types";
|
import { ProxyLocation } from "@/api/types";
|
||||||
import {
|
import {
|
||||||
Select,
|
geoTargetToProxyLocationInput,
|
||||||
SelectContent,
|
proxyLocationToGeoTarget,
|
||||||
SelectItem,
|
} from "@/util/geoData";
|
||||||
SelectTrigger,
|
import { GeoTargetSelector } from "./GeoTargetSelector";
|
||||||
SelectValue,
|
|
||||||
} from "./ui/select";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: ProxyLocation | null;
|
value: ProxyLocation;
|
||||||
onChange: (value: ProxyLocation) => void;
|
onChange: (value: ProxyLocation) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ProxySelector({ value, onChange, className }: Props) {
|
function ProxySelector({ value, onChange, className }: Props) {
|
||||||
|
// Convert input (string enum or object) to GeoTarget for the selector
|
||||||
|
const geoTargetValue = proxyLocationToGeoTarget(value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={value ?? ""} onValueChange={onChange}>
|
<GeoTargetSelector
|
||||||
<SelectTrigger className={className}>
|
className={className}
|
||||||
<SelectValue placeholder="Proxy Location" />
|
value={geoTargetValue}
|
||||||
</SelectTrigger>
|
onChange={(newTarget) => {
|
||||||
<SelectContent>
|
// Convert back to ProxyLocation enum if possible (for simple countries)
|
||||||
<SelectItem value={ProxyLocation.Residential}>Residential</SelectItem>
|
// or keep as GeoTarget object
|
||||||
<SelectItem value={ProxyLocation.ResidentialISP}>
|
const newValue = geoTargetToProxyLocationInput(newTarget);
|
||||||
Residential ISP (US)
|
onChange(newValue);
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
|||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { ProxyLocation } from "@/api/types";
|
import { ProxyLocation } from "@/api/types";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import { getClient } from "@/api/AxiosClient";
|
import { getClient } from "@/api/AxiosClient";
|
||||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||||
import { BrowserSession } from "@/routes/workflows/types/browserSessionTypes";
|
import { BrowserSession } from "@/routes/workflows/types/browserSessionTypes";
|
||||||
|
import { ProxyLocation } from "@/api/types";
|
||||||
|
|
||||||
function useCreateBrowserSessionMutation() {
|
function useCreateBrowserSessionMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -16,7 +17,7 @@ function useCreateBrowserSessionMutation() {
|
|||||||
proxyLocation = null,
|
proxyLocation = null,
|
||||||
timeout = null,
|
timeout = null,
|
||||||
}: {
|
}: {
|
||||||
proxyLocation: string | null;
|
proxyLocation: ProxyLocation | null;
|
||||||
timeout: number | null;
|
timeout: number | null;
|
||||||
}) => {
|
}) => {
|
||||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||||
|
|||||||
@@ -15,7 +15,16 @@ const createNewTaskFormSchemaBase = z.object({
|
|||||||
totpIdentifier: z.string().or(z.null()),
|
totpIdentifier: z.string().or(z.null()),
|
||||||
cdpAddress: z.string().or(z.null()),
|
cdpAddress: z.string().or(z.null()),
|
||||||
errorCodeMapping: 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),
|
includeActionHistoryInVerification: z.boolean().or(z.null()).default(false),
|
||||||
maxScreenshotScrolls: z.number().or(z.null()).default(null),
|
maxScreenshotScrolls: z.number().or(z.null()).default(null),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ function RunWorkflowForm({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
...initialValues,
|
...initialValues,
|
||||||
webhookCallbackUrl: initialSettings.webhookCallbackUrl,
|
webhookCallbackUrl: initialSettings.webhookCallbackUrl,
|
||||||
proxyLocation: initialSettings.proxyLocation,
|
proxyLocation: initialSettings.proxyLocation ?? ProxyLocation.Residential,
|
||||||
browserSessionId: null,
|
browserSessionId: null,
|
||||||
cdpAddress: initialSettings.cdpAddress,
|
cdpAddress: initialSettings.cdpAddress,
|
||||||
maxScreenshotScrolls: initialSettings.maxScreenshotScrolls,
|
maxScreenshotScrolls: initialSettings.maxScreenshotScrolls,
|
||||||
@@ -342,7 +342,7 @@ function RunWorkflowForm({
|
|||||||
form.reset({
|
form.reset({
|
||||||
...initialValues,
|
...initialValues,
|
||||||
webhookCallbackUrl: initialSettings.webhookCallbackUrl,
|
webhookCallbackUrl: initialSettings.webhookCallbackUrl,
|
||||||
proxyLocation: initialSettings.proxyLocation,
|
proxyLocation: initialSettings.proxyLocation ?? ProxyLocation.Residential,
|
||||||
browserSessionId: null,
|
browserSessionId: null,
|
||||||
cdpAddress: initialSettings.cdpAddress,
|
cdpAddress: initialSettings.cdpAddress,
|
||||||
maxScreenshotScrolls: initialSettings.maxScreenshotScrolls,
|
maxScreenshotScrolls: initialSettings.maxScreenshotScrolls,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { RunEngine } from "@/api/types";
|
import { ProxyLocation, RunEngine } from "@/api/types";
|
||||||
import { WorkflowBlockType } from "./workflowTypes";
|
import { WorkflowBlockType } from "./workflowTypes";
|
||||||
import { WorkflowModel } from "./workflowTypes";
|
import { WorkflowModel } from "./workflowTypes";
|
||||||
|
|
||||||
export type WorkflowCreateYAMLRequest = {
|
export type WorkflowCreateYAMLRequest = {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
proxy_location?: string | null;
|
proxy_location?: ProxyLocation | null;
|
||||||
webhook_callback_url?: string | null;
|
webhook_callback_url?: string | null;
|
||||||
persist_browser_session?: boolean;
|
persist_browser_session?: boolean;
|
||||||
model?: WorkflowModel | null;
|
model?: WorkflowModel | null;
|
||||||
|
|||||||
202
skyvern-frontend/src/util/geoData.ts
Normal file
202
skyvern-frontend/src/util/geoData.ts
Normal 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();
|
||||||
|
}
|
||||||
174
skyvern-frontend/src/util/geoSearch.ts
Normal file
174
skyvern-frontend/src/util/geoSearch.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user