diff --git a/hyperglass/ui/components/CommunitySelect.js b/hyperglass/ui/components/CommunitySelect.js deleted file mode 100644 index 2445302..0000000 --- a/hyperglass/ui/components/CommunitySelect.js +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from 'react'; -import { useEffect } from 'react'; -import { Text } from '@chakra-ui/core'; -import { components } from 'react-select'; -import { ChakraSelect } from 'app/components'; - -export const CommunitySelect = ({ name, communities, onChange, register, unregister }) => { - const communitySelections = communities.map(c => { - return { - value: c.community, - label: c.display_name, - description: c.description, - }; - }); - const Option = ({ label, data, ...props }) => { - return ( - - {label} - - {data.description} - - - ); - }; - useEffect(() => { - register({ name }); - return () => unregister(name); - }, [name, register, unregister]); - return ( - { - onChange({ field: name, value: e.value || '' }); - }} - options={communitySelections} - components={{ Option }} - /> - ); -}; diff --git a/hyperglass/ui/components/QueryTarget.js b/hyperglass/ui/components/QueryTarget.js deleted file mode 100644 index c4b2ea9..0000000 --- a/hyperglass/ui/components/QueryTarget.js +++ /dev/null @@ -1,69 +0,0 @@ -import * as React from 'react'; -import { useEffect } from 'react'; -import { Input, useColorMode } from '@chakra-ui/core'; - -const fqdnPattern = /^(?!:\/\/)([a-zA-Z0-9-]+\.)?[a-zA-Z0-9-][a-zA-Z0-9-]+\.[a-zA-Z-]{2,6}?$/gim; - -const bg = { dark: 'whiteAlpha.100', light: 'white' }; -const color = { dark: 'whiteAlpha.800', light: 'gray.400' }; -const border = { dark: 'whiteAlpha.50', light: 'gray.100' }; -const placeholderColor = { dark: 'whiteAlpha.700', light: 'gray.600' }; - -export const QueryTarget = ({ - placeholder, - register, - unregister, - setFqdn, - name, - value, - setTarget, - resolveTarget, - displayValue, - setDisplayValue, -}) => { - const { colorMode } = useColorMode(); - - const handleBlur = () => { - if (resolveTarget && displayValue && fqdnPattern.test(displayValue)) { - setFqdn(displayValue); - } else if (resolveTarget && !displayValue) { - setFqdn(false); - } - }; - const handleChange = e => { - setDisplayValue(e.target.value); - setTarget({ field: name, value: e.target.value }); - }; - const handleKeyDown = e => { - if ([9, 13].includes(e.keyCode)) { - handleBlur(); - } - }; - useEffect(() => { - register({ name }); - return () => unregister(name); - }, [register, unregister, name]); - return ( - <> - - - - ); -}; diff --git a/hyperglass/ui/components/QueryType.js b/hyperglass/ui/components/QueryType.js deleted file mode 100644 index 105470d..0000000 --- a/hyperglass/ui/components/QueryType.js +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from 'react'; -import { ChakraSelect } from 'app/components'; - -export const QueryType = ({ queryTypes, onChange, label }) => { - const queries = queryTypes - .filter(q => q.enable === true) - .map(q => { - return { value: q.name, label: q.display_name }; - }); - return ( - onChange({ field: 'query_type', value: e.value })} - options={queries} - aria-label={label} - /> - ); -}; diff --git a/hyperglass/ui/components/QueryVrf.js b/hyperglass/ui/components/QueryVrf.js deleted file mode 100644 index 73afba0..0000000 --- a/hyperglass/ui/components/QueryVrf.js +++ /dev/null @@ -1,12 +0,0 @@ -import * as React from 'react'; -import { ChakraSelect } from 'app/components'; - -export const QueryVrf = ({ vrfs, onChange, label }) => ( - onChange({ field: 'query_vrf', value: e.value })} - /> -); diff --git a/hyperglass/ui/components/ResolvedTarget.js b/hyperglass/ui/components/ResolvedTarget.js deleted file mode 100644 index f5b9381..0000000 --- a/hyperglass/ui/components/ResolvedTarget.js +++ /dev/null @@ -1,145 +0,0 @@ -import * as React from 'react'; -import { forwardRef, useEffect } from 'react'; -import { Button, Icon, Spinner, Stack, Tag, Text, Tooltip, useColorMode } from '@chakra-ui/core'; -import useAxios from 'axios-hooks'; -import format from 'string-format'; -import { useConfig } from 'app/context'; - -format.extend(String.prototype, {}); - -const labelBg = { dark: 'secondary', light: 'secondary' }; -const labelBgSuccess = { dark: 'success', light: 'success' }; - -export const ResolvedTarget = forwardRef( - ({ fqdnTarget, setTarget, queryTarget, families, availVrfs }, ref) => { - const { colorMode } = useColorMode(); - const config = useConfig(); - const labelBgStatus = { - true: labelBgSuccess[colorMode], - false: labelBg[colorMode], - }; - const dnsUrl = config.web.dns_provider.url; - const query4 = families.includes(4); - const query6 = families.includes(6); - const params = { - 4: { - url: dnsUrl, - params: { name: fqdnTarget, type: 'A' }, - headers: { accept: 'application/dns-json' }, - crossdomain: true, - timeout: 1000, - }, - 6: { - url: dnsUrl, - params: { name: fqdnTarget, type: 'AAAA' }, - headers: { accept: 'application/dns-json' }, - crossdomain: true, - timeout: 1000, - }, - }; - - const [{ data: data4, loading: loading4, error: error4 }] = useAxios(params[4]); - - const [{ data: data6, loading: loading6, error: error6 }] = useAxios(params[6]); - - const handleOverride = overridden => { - setTarget({ field: 'query_target', value: overridden }); - }; - - const isSelected = value => { - return labelBgStatus[value === queryTarget]; - }; - - const findAnswer = data => { - return data?.Answer?.filter(answerData => answerData.type === data?.Question[0]?.type)[0] - ?.data; - }; - - useEffect(() => { - if (query6 && data6?.Answer) { - handleOverride(findAnswer(data6)); - } else if (query4 && data4?.Answer && !query6 && !data6?.Answer) { - handleOverride(findAnswer(data4)); - } else if (query4 && data4?.Answer) { - handleOverride(findAnswer(data4)); - } - }, [data4, data6]); - return ( - 1 - ? 'space-between' - : 'flex-end' - } - flexWrap="wrap"> - {loading4 || - error4 || - (query4 && findAnswer(data4) && ( - - - - - {loading4 && } - {error4 && } - {findAnswer(data4) && ( - - {findAnswer(data4)} - - )} - - ))} - {loading6 || - error6 || - (query6 && findAnswer(data6) && ( - - - - - {loading6 && } - {error6 && } - {findAnswer(data6) && ( - - {findAnswer(data6)} - - )} - - ))} - - ); - }, -); diff --git a/hyperglass/ui/components/buttons/types.ts b/hyperglass/ui/components/buttons/types.ts index a28e74b..7660523 100644 --- a/hyperglass/ui/components/buttons/types.ts +++ b/hyperglass/ui/components/buttons/types.ts @@ -15,12 +15,12 @@ export type TButtonSizeMap = { }; export interface TSubmitButton extends BoxProps { - isLoading: boolean; - isDisabled: boolean; - isActive: boolean; - isFullWidth: boolean; - size: keyof TButtonSizeMap; - loadingText: string; + isLoading?: boolean; + isDisabled?: boolean; + isActive?: boolean; + isFullWidth?: boolean; + size?: keyof TButtonSizeMap; + loadingText?: string; } export interface TRequeryButton extends ButtonProps { diff --git a/hyperglass/ui/components/countdown/countdown.tsx b/hyperglass/ui/components/countdown/countdown.tsx index a12e77f..b33969b 100644 --- a/hyperglass/ui/components/countdown/countdown.tsx +++ b/hyperglass/ui/components/countdown/countdown.tsx @@ -1,4 +1,4 @@ -import { Text } from '@chakra-ui/core'; +import { Text } from '@chakra-ui/react'; import ReactCountdown, { zeroPad } from 'react-countdown'; import { If } from '~/components'; import { useColorValue } from '~/context'; @@ -13,10 +13,10 @@ const Renderer = (props: IRenderer) => { const bg = useColorValue('black', 'white'); return ( <> - + - + {text} diff --git a/hyperglass/ui/components/form/communitySelect.tsx b/hyperglass/ui/components/form/communitySelect.tsx new file mode 100644 index 0000000..489a6b0 --- /dev/null +++ b/hyperglass/ui/components/form/communitySelect.tsx @@ -0,0 +1,56 @@ +import { useEffect, useMemo } from 'react'; +import { Text } from '@chakra-ui/react'; +import { components } from 'react-select'; +import { Select } from '~/components'; + +import type { OptionProps } from 'react-select'; +import type { TBGPCommunity, TSelectOption } from '~/types'; +import type { TCommunitySelect } from './types'; + +function buildOptions(communities: TBGPCommunity[]): TSelectOption[] { + return communities.map(c => ({ + value: c.community, + label: c.display_name, + description: c.description, + })); +} + +const Option = (props: OptionProps) => { + const { label, data } = props; + return ( + + {label} + + {data.description} + + + ); +}; + +export const CommunitySelect = (props: TCommunitySelect) => { + const { name, communities, onChange, register, unregister } = props; + + const options = useMemo(() => buildOptions(communities), [communities.length]); + + function handleChange(e: TSelectOption | TSelectOption[]): void { + if (!Array.isArray(e)) { + onChange({ field: name, value: e.value }); + } + } + + useEffect(() => { + register({ name }); + return () => unregister(name); + }, [name, register, unregister]); + + return ( + + + + ); +}; diff --git a/hyperglass/ui/components/form/queryType.tsx b/hyperglass/ui/components/form/queryType.tsx new file mode 100644 index 0000000..9a02c35 --- /dev/null +++ b/hyperglass/ui/components/form/queryType.tsx @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import { Select } from '~/components'; +import { useConfig } from '~/context'; + +import type { TQuery, TSelectOption } from '~/types'; +import type { TQuerySelectField } from './types'; + +function buildOptions(queryTypes: TQuery[]): TSelectOption[] { + return queryTypes + .filter(q => q.enable === true) + .map(q => ({ value: q.name, label: q.display_name })); +} + +export const QueryType = (props: TQuerySelectField) => { + const { onChange, label } = props; + const { queries } = useConfig(); + + const options = useMemo(() => buildOptions(queries.list), [queries.list.length]); + + function handleChange(e: TSelectOption): void { + onChange({ field: 'query_type', value: e.value }); + } + + return ( + + ); +}; diff --git a/hyperglass/ui/components/form/resolvedTarget.tsx b/hyperglass/ui/components/form/resolvedTarget.tsx new file mode 100644 index 0000000..7880c94 --- /dev/null +++ b/hyperglass/ui/components/form/resolvedTarget.tsx @@ -0,0 +1,146 @@ +import { useEffect } from 'react'; +import { Button, Icon, Spinner, Stack, Tag, Text, Tooltip } from '@chakra-ui/react'; +import { useQuery } from 'react-query'; +import { useConfig } from '~/context'; +import { useStrf } from '~/hooks'; + +import type { DnsOverHttps, ColorNames } from '~/types'; +import type { TResolvedTarget } from './types'; + +function findAnswer(data: DnsOverHttps.Response | undefined): string { + let answer = ''; + if (typeof data !== 'undefined') { + answer = data?.Answer?.filter(answerData => answerData.type === data?.Question[0]?.type)[0] + ?.data; + } + return answer; +} + +export const ResolvedTarget = (props: TResolvedTarget) => { + const { fqdnTarget, setTarget, queryTarget, families, availVrfs } = props; + const { web } = useConfig(); + + const dnsUrl = web.dns_provider.url; + const query4 = Array.from(families).includes(4); + const query6 = Array.from(families).includes(6); + + const tooltip4 = useStrf(web.text.fqdn_tooltip, { protocol: 'IPv4' }); + const tooltip6 = useStrf(web.text.fqdn_tooltip, { protocol: 'IPv6' }); + + const { data: data4, isLoading: isLoading4, isError: isError4 } = useQuery( + [fqdnTarget, 4], + dnsQuery, + ); + + const { data: data6, isLoading: isLoading6, isError: isError6 } = useQuery( + [fqdnTarget, 6], + dnsQuery, + ); + + async function dnsQuery( + target: string, + family: 4 | 6, + ): Promise { + let json; + const type = family === 4 ? 'A' : family === 6 ? 'AAAA' : ''; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1000); + const res = await fetch(`${dnsUrl}?name=${target}&type=${type}`, { + headers: { accept: 'application/dns-json' }, + signal: controller.signal, + mode: 'cors', + }); + json = await res.json(); + clearTimeout(timeout); + return json; + } + + function handleOverride(value: string): void { + setTarget({ field: 'query_target', value }); + } + + function isSelected(value: string): ColorNames { + if (value === queryTarget) { + return 'success'; + } else { + return 'secondary'; + } + } + + useEffect(() => { + if (query6 && data6?.Answer) { + handleOverride(findAnswer(data6)); + } else if (query4 && data4?.Answer && !query6 && !data6?.Answer) { + handleOverride(findAnswer(data4)); + } else if (query4 && data4?.Answer) { + handleOverride(findAnswer(data4)); + } + }, [data4, data6]); + + return ( + 1 + ? 'space-between' + : 'flex-end' + } + flexWrap="wrap"> + {isLoading4 || + isError4 || + (query4 && findAnswer(data4) && ( + + + + + {isLoading4 && } + {isError4 && } + {findAnswer(data4) && ( + + {findAnswer(data4)} + + )} + + ))} + {isLoading6 || + isError6 || + (query6 && findAnswer(data6) && ( + + + + + {isLoading6 && } + {isError6 && } + {findAnswer(data6) && ( + + {findAnswer(data6)} + + )} + + ))} + + ); +}; diff --git a/hyperglass/ui/components/form/row.tsx b/hyperglass/ui/components/form/row.tsx new file mode 100644 index 0000000..2738eb7 --- /dev/null +++ b/hyperglass/ui/components/form/row.tsx @@ -0,0 +1,15 @@ +import { Flex } from '@chakra-ui/react'; + +import { FlexProps } from '@chakra-ui/react'; + +export const FormRow = (props: FlexProps) => { + return ( + + ); +}; diff --git a/hyperglass/ui/components/form/types.ts b/hyperglass/ui/components/form/types.ts index 71f7517..740c546 100644 --- a/hyperglass/ui/components/form/types.ts +++ b/hyperglass/ui/components/form/types.ts @@ -1,20 +1,67 @@ import type { FormControlProps } from '@chakra-ui/react'; -import type { FieldError } from 'react-hook-form'; -import type { TNetwork } from '~/types'; +import type { FieldError, Control } from 'react-hook-form'; +import type { TDeviceVrf, TBGPCommunity, OnChangeArgs, Families, TFormData } from '~/types'; export interface TField extends FormControlProps { name: string; label: string; - error?: FieldError; - hiddenLabels: boolean; + errors?: FieldError | FieldError[]; + hiddenLabels?: boolean; labelAddOn?: React.ReactNode; fieldAddOn?: React.ReactNode; } -export type OnChangeArgs = { label: string; value: string | string[] }; +export type OnChange = (f: OnChangeArgs) => void; -export interface TQueryLocation { - locations: TNetwork[]; - onChange(f: OnChangeArgs | OnChangeArgs[]): void; +export interface TQuerySelectField { + onChange: OnChange; label: string; } + +export interface TQueryVrf extends TQuerySelectField { + vrfs: TDeviceVrf[]; +} + +export interface TCommunitySelect { + name: string; + onChange: OnChange; + communities: TBGPCommunity[]; + register: Control['register']; + unregister: Control['unregister']; +} + +/** + * placeholder, + register, + unregister, + setFqdn, + + name, + value, + + setTarget, + resolveTarget, + + displayValue, + setDisplayValue, + */ +export interface TQueryTarget { + name: string; + placeholder: string; + displayValue: string; + resolveTarget: boolean; + setFqdn(f: string | null): void; + setTarget(e: OnChangeArgs): void; + register: Control['register']; + value: TFormData['query_target']; + setDisplayValue(d: string): void; + unregister: Control['unregister']; +} + +export interface TResolvedTarget { + families: Families; + queryTarget: string; + availVrfs: TDeviceVrf[]; + fqdnTarget: string | null; + setTarget(e: OnChangeArgs): void; +} diff --git a/hyperglass/ui/components/header/header.tsx b/hyperglass/ui/components/header/header.tsx index 9c5aa0f..0488d72 100644 --- a/hyperglass/ui/components/header/header.tsx +++ b/hyperglass/ui/components/header/header.tsx @@ -1,11 +1,11 @@ import { Flex } from '@chakra-ui/react'; import { motion, AnimatePresence } from 'framer-motion'; -import { useColorValue, useConfig, useGlobalState, useBreakpointValue } from '~/context'; import { AnimatedDiv, Title, ResetButton, ColorModeToggle } from '~/components'; +import { useColorValue, useConfig, useGlobalState, useBreakpointValue } from '~/context'; import { useBooleanValue } from '~/hooks'; import type { ResponsiveValue } from '@chakra-ui/react'; -import type { THeader, TTitleMode } from './types'; +import type { THeader, TTitleMode, THeaderLayout } from './types'; const headerTransition = { type: 'spring', @@ -139,7 +139,10 @@ export const Header = (props: THeader) => { lg: [resetButton, title, colorModeToggle], xl: [resetButton, title, colorModeToggle], }, - ); + ) as THeaderLayout; + + const layoutBp: keyof THeaderLayout = + useBreakpointValue({ base: 'sm', md: 'md', lg: 'lg', xl: 'xl' }) ?? 'sm'; return ( { justify="space-between" flex="1 0 auto" alignItems={isSubmitting ? 'center' : 'flex-start'}> - {layout} + {layout[layoutBp]} ); diff --git a/hyperglass/ui/components/header/types.ts b/hyperglass/ui/components/header/types.ts index b43a93f..26475ff 100644 --- a/hyperglass/ui/components/header/types.ts +++ b/hyperglass/ui/components/header/types.ts @@ -7,3 +7,10 @@ export interface THeader extends FlexProps { } export type TTitleMode = IConfig['web']['text']['title_mode']; + +export type THeaderLayout = { + sm: [JSX.Element, JSX.Element, JSX.Element]; + md: [JSX.Element, JSX.Element, JSX.Element]; + lg: [JSX.Element, JSX.Element, JSX.Element]; + xl: [JSX.Element, JSX.Element, JSX.Element]; +}; diff --git a/hyperglass/ui/components/index.ts b/hyperglass/ui/components/index.ts index da81584..9f682a1 100644 --- a/hyperglass/ui/components/index.ts +++ b/hyperglass/ui/components/index.ts @@ -1,7 +1,6 @@ export * from './buttons'; export * from './card'; export * from './codeBlock'; -export * from './CommunitySelect'; export * from './countdown'; export * from './debugger'; export * from './footer'; @@ -9,17 +8,13 @@ export * from './form'; export * from './greeting'; export * from './header'; export * from './help'; -export * from './HyperglassForm'; export * from './label'; export * from './layout'; export * from './loading'; +export * from './lookingGlass'; export * from './markdown'; export * from './meta'; export * from './output'; -export * from './QueryTarget'; -export * from './QueryType'; -export * from './QueryVrf'; -export * from './ResolvedTarget'; export * from './results'; export * from './select'; export * from './table'; diff --git a/hyperglass/ui/components/loading.tsx b/hyperglass/ui/components/loading.tsx index 096fe96..52c537f 100644 --- a/hyperglass/ui/components/loading.tsx +++ b/hyperglass/ui/components/loading.tsx @@ -1,7 +1,9 @@ import { Flex, Spinner } from '@chakra-ui/react'; import { useColorValue } from '~/context'; -export const Loading: React.FC = () => { +import type { LoadableBaseOptions } from 'next/dynamic'; + +export const Loading: LoadableBaseOptions['loading'] = () => { const bg = useColorValue('white', 'black'); const color = useColorValue('black', 'white'); return ( diff --git a/hyperglass/ui/components/lookingGlass.tsx b/hyperglass/ui/components/lookingGlass.tsx new file mode 100644 index 0000000..c81b13f --- /dev/null +++ b/hyperglass/ui/components/lookingGlass.tsx @@ -0,0 +1,335 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Box, Flex } from '@chakra-ui/react'; +import { useForm } from 'react-hook-form'; +import { uniqWith } from 'lodash'; +import * as yup from 'yup'; +import { + FormRow, + QueryVrf, + FormField, + HelpModal, + QueryType, + QueryTarget, + SubmitButton, + QueryLocation, + ResolvedTarget, + CommunitySelect, +} from '~/components'; +import { useConfig, useGlobalState } from '~/context'; +import { useStrf, useGreeting } from '~/hooks'; + +import type { Families, TFormData, TDeviceVrf, TQueryTypes, OnChangeArgs } from '~/types'; + +function isString(a: any): a is string { + return typeof a === 'string'; +} + +function isQueryType(q: any): q is TQueryTypes { + let result = false; + if ( + typeof q === 'string' && + ['bgp_route', 'bgp_community', 'bgp_aspath', 'ping', 'traceroute'].includes(q) + ) { + result = true; + } + return result; +} + +export const HyperglassForm = () => { + const { web, content, devices, messages, networks, queries } = useConfig(); + + const { formData, isSubmitting } = useGlobalState(); + const [greetingAck, setGreetingAck] = useGreeting(); + + const noQueryType = useStrf(messages.no_input, { field: web.text.query_type }); + const noQueryLoc = useStrf(messages.no_input, { field: web.text.query_location }); + const noQueryTarget = useStrf(messages.no_input, { field: web.text.query_target }); + + const formSchema = yup.object().shape({ + query_location: yup.array().of(yup.string()).required(noQueryLoc), + query_type: yup.string().required(noQueryType), + query_vrf: yup.string(), + query_target: yup.string().required(noQueryTarget), + }); + + const { handleSubmit, register, unregister, setValue, errors } = useForm({ + validationSchema: formSchema, + defaultValues: { query_vrf: 'default', query_target: '' }, + }); + + const [queryLocation, setQueryLocation] = useState([]); + const [queryType, setQueryType] = useState(''); + const [queryVrf, setQueryVrf] = useState(''); + const [queryTarget, setQueryTarget] = useState(''); + const [availVrfs, setAvailVrfs] = useState([]); + const [fqdnTarget, setFqdnTarget] = useState(''); + const [displayTarget, setDisplayTarget] = useState(''); + const [families, setFamilies] = useState([]); + + function onSubmit(values: TFormData): void { + if (!greetingAck && web.greeting.required) { + window.location.reload(false); + setGreetingAck(false); + } else { + formData.set(values); + isSubmitting.set(true); + } + } + + /* + const handleLocChange = locObj => { + setQueryLocation(locObj.value); + const allVrfs = []; + const deviceVrfs = []; + locObj.value.map(loc => { + const locVrfs = []; + config.devices[loc].vrfs.map(vrf => { + locVrfs.push({ + label: vrf.display_name, + value: vrf.id, + }); + deviceVrfs.push([{ id: vrf.id, ipv4: vrf.ipv4, ipv6: vrf.ipv6 }]); + }); + allVrfs.push(locVrfs); + }); + + deviceVrfs.length !== 0 && + intersecting.length !== 0 && + deviceVrfs + .filter(v => intersecting.every(i => i.id === v.id)) + .reduce((a, b) => a.concat(b)) + .filter(v => v.id === 'default') + .map(v => { + v.ipv4 === true && ipv4++; + v.ipv6 === true && ipv6++; + }); + */ + + // function handleLocChange(locObj: TSelectOption) { + // const allVrfs = [] as TDeviceVrf[][]; + // const deviceVrfs = [] as TDeviceVrf[][]; + + // if (Array.isArray(locObj.value)) { + // setQueryLocation(locObj.value); + // for (const loc of locObj.value) { + // const locVrfs = [] as TDeviceVrf[]; + // for (const vrf of devices.filter(dev => dev.name === loc)[0].vrfs) { + // locVrfs.push(vrf); + // deviceVrfs.push([vrf]); + // } + // allVrfs.push(locVrfs); + // } + // } + + // // Use _.intersectionWith to create an array of VRFs common to all selected locations. + // const intersecting: TDeviceVrf[] = intersectionWith(...allVrfs, isEqual); + // setAvailVrfs(intersecting); + + // // If there are no intersecting VRFs, use the default VRF. + // if (intersecting.filter(i => i.id === queryVrf).length === 0 && queryVrf !== 'default') { + // setQueryVrf('default'); + // } + + // let ipv4 = 0; + // let ipv6 = 0; + + // if (deviceVrfs.length !== 0 && intersecting.length !== 0) { + // const matching = deviceVrfs + // // Select intersecting VRFs + // .filter(v => intersecting.every(i => i.id === v.id)) + // .reduce((a, b) => a.concat(b)) + // .filter(v => v.id === 'default'); + + // for (const match of matching) { + // if (match.ipv4) { + // ipv4++; + // } + // if (match.ipv6) { + // ipv6++; + // } + // } + // } + + // if (ipv4 !== 0 && ipv4 === ipv6) { + // setFamilies([4, 6]); + // } else if (ipv4 > ipv6) { + // setFamilies([4]); + // } else if (ipv4 < ipv6) { + // setFamilies([6]); + // } else { + // setFamilies([]); + // } + // } + + function handleLocChange(locations: string | string[]): void { + const allVrfs = [] as TDeviceVrf[]; + + if (Array.isArray(locations)) { + setQueryLocation(locations); + for (const loc of locations) { + for (const vrf of devices.filter(dev => dev.name === loc)[0].vrfs) { + allVrfs.push(vrf); + } + } + } + + // Use _.intersectionWith to create an array of VRFs common to all selected locations. + const intersecting = uniqWith(allVrfs, (a, b) => a.id === b.id); + setAvailVrfs(intersecting); + + // If there are no intersecting VRFs, use the default VRF. + if (intersecting.filter(i => i.id === queryVrf).length === 0 && queryVrf !== 'default') { + setQueryVrf('default'); + } + + let ipv4 = 0; + let ipv6 = 0; + + if (intersecting.length !== 0) { + for (const intersection of intersecting) { + if (intersection.ipv4) { + ipv4++; + } + if (intersection.ipv6) { + ipv6++; + } + } + } + + if (ipv4 !== 0 && ipv4 === ipv6) { + setFamilies([4, 6]); + } else if (ipv4 > ipv6) { + setFamilies([4]); + } else if (ipv4 < ipv6) { + setFamilies([6]); + } else { + setFamilies([]); + } + } + + function handleChange(e: OnChangeArgs): void { + setValue(e.field, e.value); + + if (e.field === 'query_location') { + handleLocChange(e.value); + } else if (e.field === 'query_type' && isQueryType(e.value)) { + setQueryType(e.value); + } else if (e.field === 'query_vrf' && isString(e.value)) { + setQueryVrf(e.value); + } else if (e.field === 'query_target' && isString(e.value)) { + setQueryTarget(e.value); + } + } + + const vrfContent = useMemo(() => { + if (Object.keys(content.vrf).includes(queryVrf) && queryType !== '') { + return content.vrf[queryVrf][queryType]; + } + }, [queryVrf]); + + const isFqdnQuery = useMemo(() => { + return ['bgp_route', 'ping', 'traceroute'].includes(queryType); + }, [queryType]); + + const fqdnQuery = useMemo(() => { + let result = null; + if (fqdnTarget && queryVrf === 'default' && fqdnTarget) { + result = fqdnTarget; + } + return result; + }, [queryVrf, queryType]); + + useEffect(() => { + register({ name: 'query_location' }); + register({ name: 'query_type' }); + register({ name: 'query_vrf' }); + }, [register]); + + Object.keys(errors).length >= 1 && console.error(errors); + + return ( + + + + + + }> + + + + + {availVrfs.length > 1 && ( + + + + )} + + ) + }> + {queryType === 'bgp_community' && queries.bgp_community.mode === 'select' ? ( + + ) : ( + + )} + + + + + + + + + ); +}; diff --git a/hyperglass/ui/components/select/select.tsx b/hyperglass/ui/components/select/select.tsx index 4d733a7..c0dff2e 100644 --- a/hyperglass/ui/components/select/select.tsx +++ b/hyperglass/ui/components/select/select.tsx @@ -1,6 +1,6 @@ -import { createContext, useContext, useMemo, useState } from 'react'; +import { createContext, useContext, useMemo } from 'react'; import ReactSelect from 'react-select'; -import { Box } from '@chakra-ui/react'; +import { Box, useDisclosure } from '@chakra-ui/react'; import { useColorMode } from '~/context'; import { useRSTheme, @@ -17,7 +17,8 @@ import { useIndicatorSeparatorStyle, } from './styles'; -import type { TSelect, TSelectOption, TSelectContext, TBoxAsReactSelect } from './types'; +import type { TSelectOption } from '~/types'; +import type { TSelect, TSelectContext, TBoxAsReactSelect } from './types'; const SelectContext = createContext(Object()); export const useSelectContext = () => useContext(SelectContext); @@ -26,7 +27,8 @@ const ReactSelectAsBox = (props: TBoxAsReactSelect) => { const { ctl, options, multi, onSelect, ...rest } = props; - const [isOpen, setIsOpen] = useState(false); + const { isOpen, onOpen, onClose } = useDisclosure(); + const { colorMode } = useColorMode(); const selectContext = useMemo(() => ({ colorMode, isOpen }), [colorMode, isOpen]); @@ -49,18 +51,14 @@ export const Select = (props: TSelect) => { return ( { - isOpen && setIsOpen(false); - }} - onMenuOpen={() => { - !isOpen && setIsOpen(true); - }} + onMenuClose={onClose} + onMenuOpen={onOpen} + options={options} + as={ReactSelect} + isMulti={multi} theme={rsTheme} + ref={ctl} styles={{ menuPortal, multiValue, diff --git a/hyperglass/ui/components/select/types.ts b/hyperglass/ui/components/select/types.ts index a1af0ca..8df0d7d 100644 --- a/hyperglass/ui/components/select/types.ts +++ b/hyperglass/ui/components/select/types.ts @@ -10,34 +10,24 @@ import type { PlaceholderProps, } from 'react-select'; import type { BoxProps } from '@chakra-ui/react'; -import type { ColorNames } from '~/types'; +import type { ColorNames, TSelectOption, TSelectOptionGroup } from '~/types'; export interface TSelectState { [k: string]: string[]; } -export type TSelectOption = { - label: string; - value: string; -}; - -export type TSelectOptionGroup = { - label: string; - options: TSelectOption[]; -}; - export type TOptions = Array; export type TBoxAsReactSelect = Omit & Omit; -export interface TSelect extends TBoxAsReactSelect { - options: TOptions; +export interface TSelectBase extends TBoxAsReactSelect { name: string; - required?: boolean; multi?: boolean; - onSelect?: (v: TSelectOption[]) => void; - onChange?: (c: TSelectOption | TSelectOption[]) => void; + options: TOptions; + required?: boolean; + onSelect?: (s: TSelectOption) => void; + onChange?: (c: TSelectOption) => void; colorScheme?: ColorNames; } diff --git a/hyperglass/ui/context/index.ts b/hyperglass/ui/context/index.ts index 958a443..1c748e4 100644 --- a/hyperglass/ui/context/index.ts +++ b/hyperglass/ui/context/index.ts @@ -1,3 +1,2 @@ export * from './HyperglassProvider'; -export * from './MediaProvider'; export * from './GlobalState'; diff --git a/hyperglass/ui/context/types.ts b/hyperglass/ui/context/types.ts index b581a7a..507f714 100644 --- a/hyperglass/ui/context/types.ts +++ b/hyperglass/ui/context/types.ts @@ -1,4 +1,4 @@ -import type { IConfig, IFormData } from '~/types'; +import type { IConfig, TFormData } from '~/types'; export interface THyperglassProvider { config: IConfig; @@ -7,5 +7,5 @@ export interface THyperglassProvider { export interface TGlobalState { isSubmitting: boolean; - formData: IFormData; + formData: TFormData; } diff --git a/hyperglass/ui/hooks/types.ts b/hyperglass/ui/hooks/types.ts index 6fb647b..e1352a5 100644 --- a/hyperglass/ui/hooks/types.ts +++ b/hyperglass/ui/hooks/types.ts @@ -7,4 +7,4 @@ export interface TStringTableData extends Omit { output: TStructuredResponse; } -export type TUseGreetingReturn = [boolean, () => void]; +export type TUseGreetingReturn = [boolean, (v?: boolean) => void]; diff --git a/hyperglass/ui/hooks/useGreeting.ts b/hyperglass/ui/hooks/useGreeting.ts index 2f6b07b..bd86928 100644 --- a/hyperglass/ui/hooks/useGreeting.ts +++ b/hyperglass/ui/hooks/useGreeting.ts @@ -5,11 +5,13 @@ import type { TUseGreetingReturn } from './types'; export function useGreeting(): TUseGreetingReturn { const state = useState(false); - state.attach(Persistence('plugin-persisted-data-key')); + if (typeof window !== 'undefined') { + state.attach(Persistence('hyperglass-greeting')); + } - function setAck(): void { + function setAck(v: boolean = true): void { if (!state.get()) { - state.set(true); + state.set(v); } return; } diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index 228e67d..d8c71b8 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -10,12 +10,13 @@ "build": "next build && next export -o ../hyperglass/static/ui", "start": "next start", "clean": "rimraf --no-glob ./.next ./out", + "typecheck": "tsc", "check:es:build": "es-check es5 './.next/static/**/*.js' -v", "check:es:export": "es-check es5 './out/**/*.js' -v" }, "browserslist": "> 0.25%, not dead", "dependencies": { - "@chakra-ui/react": "^1.0.1", + "@chakra-ui/react": "^1.0.3", "@emotion/react": "^11.1.1", "@emotion/styled": "^11.0.0", "@hookstate/core": "^3.0.1", @@ -26,27 +27,30 @@ "axios-hooks": "^1.9.0", "chroma-js": "^2.1.0", "dayjs": "^1.8.25", - "framer-motion": "^2.9.4", + "framer-motion": "^2.9.5", "lodash": "^4.17.15", - "next": "^9.5.4", - "react": "16.14.0", + "next": "^10.0.3", + "react": "^17.0.1", "react-countdown": "^2.2.1", - "react-dom": "16.14.0", + "react-dom": "^17.0.1", + "react-fast-compare": "^3.2.0", "react-hook-form": "^5.7", "react-markdown": "^4.3.1", + "react-query": "^2.26.4", "react-select": "^3.0.8", "react-string-replace": "^0.4.4", "react-table": "^7.6.2", "string-format": "^2.0.0", "tempy": "^0.5.0", "yarn": "^1.22.10", - "yup": "^0.28.3" + "yup": "^0.32.8" }, "devDependencies": { "@types/node": "^14.11.10", "@types/react-select": "^3.0.22", "@types/react-table": "^7.0.25", "@types/string-format": "^2.0.0", + "@types/yup": "^0.29.9", "@typescript-eslint/eslint-plugin": "^2.24.0", "@typescript-eslint/parser": "^2.24.0", "@upstatement/eslint-config": "^0.4.3", diff --git a/hyperglass/ui/pages/_app.js b/hyperglass/ui/pages/_app.tsx similarity index 56% rename from hyperglass/ui/pages/_app.js rename to hyperglass/ui/pages/_app.tsx index 513f58a..a8f849b 100644 --- a/hyperglass/ui/pages/_app.js +++ b/hyperglass/ui/pages/_app.tsx @@ -1,12 +1,22 @@ -import * as React from 'react'; import Head from 'next/head'; +import { HyperglassProvider } from '~/context'; +import { IConfig } from '~/types'; // import { useRouter } from "next/router"; -import { HyperglassProvider } from 'app/context'; // import Error from "./_error"; -const config = process.env._HYPERGLASS_CONFIG_; +import type { AppProps, AppInitialProps } from 'next/app'; -const Hyperglass = ({ Component, pageProps }) => { +type TAppProps = AppProps & AppInitialProps; + +interface TApp extends TAppProps { + appProps: { config: IConfig }; +} + +type TAppInitial = Pick; + +const App = (props: TApp) => { + const { Component, pageProps, appProps } = props; + const { config } = appProps; // const { asPath } = useRouter(); // if (asPath === "/structured") { // return ; @@ -29,4 +39,9 @@ const Hyperglass = ({ Component, pageProps }) => { ); }; -export default Hyperglass; +App.getInitialProps = async (): Promise => { + const config = (process.env._HYPERGLASS_CONFIG_ as unknown) as IConfig; + return { appProps: { config } }; +}; + +export default App; diff --git a/hyperglass/ui/pages/_document.js b/hyperglass/ui/pages/_document.tsx similarity index 87% rename from hyperglass/ui/pages/_document.js rename to hyperglass/ui/pages/_document.tsx index 4a205ac..d764e1b 100644 --- a/hyperglass/ui/pages/_document.js +++ b/hyperglass/ui/pages/_document.tsx @@ -1,8 +1,8 @@ -import React from 'react'; import Document, { Html, Head, Main, NextScript } from 'next/document'; +import type { DocumentContext } from 'next/document'; class MyDocument extends Document { - static async getInitialProps(ctx) { + static async getInitialProps(ctx: DocumentContext) { const initialProps = await Document.getInitialProps(ctx); return { ...initialProps }; } @@ -11,10 +11,10 @@ class MyDocument extends Document { return ( - - + +