diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index 9e537f84..04732c4f 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -153,6 +153,10 @@ class Text(HyperglassModel): rpki_unknown: StrictStr = "No ROAs Exist" rpki_unverified: StrictStr = "Not Verified" no_communities: StrictStr = "No Communities" + ip_error: StrictStr = "Unable to determine IP Address" + no_ip: StrictStr = "No {protocol} Address" + ip_select: StrictStr = "Select an IP Address" + ip_button: StrictStr = "My IP" @validator("title_mode") def validate_title_mode(cls, value): diff --git a/hyperglass/ui/components/form/queryTarget.tsx b/hyperglass/ui/components/form/queryTarget.tsx index e1ebd295..75be2241 100644 --- a/hyperglass/ui/components/form/queryTarget.tsx +++ b/hyperglass/ui/components/form/queryTarget.tsx @@ -1,10 +1,12 @@ import { useMemo } from 'react'; -import { Input, Text } from '@chakra-ui/react'; +import { Input, InputGroup, InputRightElement, Text } from '@chakra-ui/react'; import { components } from 'react-select'; import { If, Select } from '~/components'; import { useColorValue } from '~/context'; import { useDirective, useFormState } from '~/hooks'; import { isSelectDirective } from '~/types'; +import { UserIP } from './userIP'; + import type { OptionProps } from 'react-select'; import type { Directive, SingleOption } from '~/types'; import type { TQueryTarget } from './types'; @@ -73,19 +75,28 @@ export const QueryTarget: React.FC = (props: TQueryTarget) => { /> - + + + + { + setTarget({ display: target }); + onChange({ field: name, value: target }); + }} + /> + + ); diff --git a/hyperglass/ui/components/form/types.ts b/hyperglass/ui/components/form/types.ts index 8882d875..e053258d 100644 --- a/hyperglass/ui/components/form/types.ts +++ b/hyperglass/ui/components/form/types.ts @@ -34,3 +34,7 @@ export interface LocationCardProps { onChange(a: 'add' | 'remove', v: SingleOption): void; hasError: boolean; } + +export interface UserIPProps { + setTarget(target: string): void; +} diff --git a/hyperglass/ui/components/form/userIP.tsx b/hyperglass/ui/components/form/userIP.tsx new file mode 100644 index 00000000..edbcea19 --- /dev/null +++ b/hyperglass/ui/components/form/userIP.tsx @@ -0,0 +1,102 @@ +import { useMemo } from 'react'; +import dynamic from 'next/dynamic'; +import { Button, chakra, Stack, Text, VStack, useDisclosure } from '@chakra-ui/react'; +import { Prompt } from '~/components'; +import { useConfig, useColorValue } from '~/context'; +import { useStrf, useWtf } from '~/hooks'; + +import type { UserIPProps } from './types'; + +const RightArrow = chakra( + dynamic(() => import('@meronex/icons/fa').then(i => i.FaArrowCircleRight)), +); + +export const UserIP = (props: UserIPProps): JSX.Element => { + const { setTarget } = props; + const { onOpen, ...disclosure } = useDisclosure(); + const strF = useStrf(); + const { web } = useConfig(); + + const errorColor = useColorValue('red.500', 'red.300'); + + const noIPv4 = strF(web.text.noIp, { protocol: 'IPv4' }); + const noIPv6 = strF(web.text.noIp, { protocol: 'IPv6' }); + + const [ipv4, ipv6, query] = useWtf(); + + const hasResult = useMemo( + () => (!ipv4.isError || !ipv6.isError) && (ipv4.data?.ip !== null || ipv6.data?.ip !== null), + [ipv4, ipv6], + ); + + const show4 = useMemo(() => !ipv4.isError && ipv4.data?.ip !== null, [ipv4]); + const show6 = useMemo(() => !ipv6.isError && ipv6.data?.ip !== null, [ipv6]); + + function handleOpen(): void { + onOpen(); + query(); + } + + return ( + + {web.text.ipButton} + + } + onOpen={handleOpen} + {...disclosure} + > + + {hasResult && ( + + {web.text.ipSelect} + + )} + + {show4 && ( + + )} + {show6 && ( + + )} + {!hasResult && ( + + {web.text.ipError} + + )} + + + + ); +}; diff --git a/hyperglass/ui/components/index.ts b/hyperglass/ui/components/index.ts index 12cd0be9..4ae1917f 100644 --- a/hyperglass/ui/components/index.ts +++ b/hyperglass/ui/components/index.ts @@ -16,6 +16,7 @@ export * from './markdown'; export * from './meta'; export * from './output'; export * from './path'; +export * from './prompt'; export * from './results'; export * from './select'; export * from './submit'; diff --git a/hyperglass/ui/components/prompt/desktop.tsx b/hyperglass/ui/components/prompt/desktop.tsx new file mode 100644 index 00000000..4816e4c7 --- /dev/null +++ b/hyperglass/ui/components/prompt/desktop.tsx @@ -0,0 +1,27 @@ +import { + Popover, + PopoverBody, + PopoverArrow, + PopoverTrigger, + PopoverContent, + PopoverCloseButton, +} from '@chakra-ui/react'; +import { useColorValue } from '~/context'; + +import type { PromptProps } from './types'; + +export const DesktopPrompt = (props: PromptProps): JSX.Element => { + const { trigger, children, ...disclosure } = props; + const bg = useColorValue('white', 'gray.900'); + + return ( + + {trigger} + + + + {children} + + + ); +}; diff --git a/hyperglass/ui/components/prompt/index.tsx b/hyperglass/ui/components/prompt/index.tsx new file mode 100644 index 00000000..d527164b --- /dev/null +++ b/hyperglass/ui/components/prompt/index.tsx @@ -0,0 +1,11 @@ +import { useMobile } from '~/context'; +import { DesktopPrompt } from './desktop'; +import { MobilePrompt } from './mobile'; + +import type { PromptProps } from './types'; + +export const Prompt = (props: PromptProps): JSX.Element => { + const isMobile = useMobile(); + + return isMobile ? : ; +}; diff --git a/hyperglass/ui/components/prompt/mobile.tsx b/hyperglass/ui/components/prompt/mobile.tsx new file mode 100644 index 00000000..afde8a8c --- /dev/null +++ b/hyperglass/ui/components/prompt/mobile.tsx @@ -0,0 +1,30 @@ +import { Modal, ModalBody, ModalOverlay, ModalContent, ModalCloseButton } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; + +import type { PromptProps } from './types'; + +export const MobilePrompt = (props: PromptProps): JSX.Element => { + const { children, trigger, ...disclosure } = props; + const bg = useColorValue('white', 'gray.900'); + return ( + <> + {trigger} + + + + + + {children} + + + + + ); +}; diff --git a/hyperglass/ui/components/prompt/types.ts b/hyperglass/ui/components/prompt/types.ts new file mode 100644 index 00000000..9c32b10d --- /dev/null +++ b/hyperglass/ui/components/prompt/types.ts @@ -0,0 +1,10 @@ +import type { UseDisclosureReturn } from '@chakra-ui/react'; + +type PromptPropsBase = React.PropsWithChildren< + Omit, 'isOpen' | 'onClose'> & + Pick +>; + +export interface PromptProps extends PromptPropsBase { + trigger?: JSX.Element; +} diff --git a/hyperglass/ui/hooks/index.ts b/hyperglass/ui/hooks/index.ts index 46aeb93d..9e516773 100644 --- a/hyperglass/ui/hooks/index.ts +++ b/hyperglass/ui/hooks/index.ts @@ -11,3 +11,4 @@ export * from './useLGQuery'; export * from './useOpposingColor'; export * from './useStrf'; export * from './useTableToString'; +export * from './useWtf'; diff --git a/hyperglass/ui/hooks/useWtf.ts b/hyperglass/ui/hooks/useWtf.ts new file mode 100644 index 00000000..04eac2ff --- /dev/null +++ b/hyperglass/ui/hooks/useWtf.ts @@ -0,0 +1,76 @@ +import { useQuery } from 'react-query'; +import { fetchWithTimeout } from '~/util'; + +import type { + QueryFunction, + QueryFunctionContext, + UseQueryOptions, + UseQueryResult, +} from 'react-query'; +import type { WtfIsMyIP } from '~/types'; + +const URL_IP4 = 'https://ipv4.json.myip.wtf'; +const URL_IP6 = 'https://ipv6.json.myip.wtf'; + +interface WtfIndividual { + ip: string; + isp: string; + location: string; + country: string; +} + +type Wtf = [UseQueryResult, UseQueryResult, () => Promise]; + +function transform(wtf: WtfIsMyIP): WtfIndividual { + const { YourFuckingIPAddress, YourFuckingISP, YourFuckingLocation, YourFuckingCountryCode } = wtf; + return { + ip: YourFuckingIPAddress, + isp: YourFuckingISP, + location: YourFuckingLocation, + country: YourFuckingCountryCode, + }; +} + +const query: QueryFunction = async (ctx: QueryFunctionContext) => { + const controller = new AbortController(); + const [url] = ctx.queryKey; + + const res = await fetchWithTimeout( + url, + { + headers: { accept: 'application/json' }, + mode: 'cors', + }, + 5000, + controller, + ); + const data = await res.json(); + return transform(data); +}; + +const common: UseQueryOptions = { + queryFn: query, + enabled: false, + refetchInterval: false, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + cacheTime: 120 * 1_000, // 2 minutes +}; + +export function useWtf(): Wtf { + const ipv4 = useQuery({ + queryKey: URL_IP4, + ...common, + }); + const ipv6 = useQuery({ + queryKey: URL_IP6, + ...common, + }); + + async function refetch(): Promise { + await ipv4.refetch(); + await ipv6.refetch(); + } + return [ipv4, ipv6, refetch]; +} diff --git a/hyperglass/ui/types/config.ts b/hyperglass/ui/types/config.ts index efe32557..1a04f18c 100644 --- a/hyperglass/ui/types/config.ts +++ b/hyperglass/ui/types/config.ts @@ -48,6 +48,10 @@ interface _Text { rpki_unknown: string; rpki_unverified: string; no_communities: string; + ip_error: string; + no_ip: string; + ip_select: string; + ip_button: string; } interface _Greeting { diff --git a/hyperglass/ui/types/index.ts b/hyperglass/ui/types/index.ts index 637512e6..8835d925 100644 --- a/hyperglass/ui/types/index.ts +++ b/hyperglass/ui/types/index.ts @@ -7,3 +7,4 @@ export * from './guards'; export * from './table'; export * from './theme'; export * from './util'; +export * from './wtfismyip'; diff --git a/hyperglass/ui/types/wtfismyip.ts b/hyperglass/ui/types/wtfismyip.ts new file mode 100644 index 00000000..4af94154 --- /dev/null +++ b/hyperglass/ui/types/wtfismyip.ts @@ -0,0 +1,13 @@ +/** + * myip.wtf response. + * + * @see https://github.com/wtfismyip/wtfismyip + * @see https://wtfismyip.com/automation + */ +export interface WtfIsMyIP { + YourFuckingIPAddress: string; + YourFuckingLocation: string; + YourFuckingISP: string; + YourFuckingTorExit: boolean; + YourFuckingCountryCode: string; +}