diff --git a/hyperglass/ui/components/Table/main.tsx b/hyperglass/ui/components/Table/main.tsx index 6613f7e..cc005ee 100644 --- a/hyperglass/ui/components/Table/main.tsx +++ b/hyperglass/ui/components/Table/main.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import dynamic from 'next/dynamic'; import { Flex, Icon, Text } from '@chakra-ui/react'; import { usePagination, useSortBy, useTable } from 'react-table'; @@ -68,18 +69,18 @@ export function Table(props: TTable) { const instance = useTable(options, ...plugins); const { - getTableProps, - headerGroups, - prepareRow, page, - canPreviousPage, - canNextPage, - pageOptions, - pageCount, gotoPage, nextPage, - previousPage, + pageCount, + prepareRow, + canNextPage, + pageOptions, setPageSize, + headerGroups, + previousPage, + getTableProps, + canPreviousPage, state: { pageIndex, pageSize }, } = instance; @@ -116,7 +117,6 @@ export function Table(props: TTable) { {page.map((row, key) => { prepareRow(row); - return ( - {cellRender ?? cell.render('Cell')} + {/* {cellRender ?? cell.render('Cell')} */} + {React.createElement(cellRender, cell)} ); })} diff --git a/hyperglass/ui/components/output/cell.tsx b/hyperglass/ui/components/output/cell.tsx index db14c96..79a057e 100644 --- a/hyperglass/ui/components/output/cell.tsx +++ b/hyperglass/ui/components/output/cell.tsx @@ -5,6 +5,10 @@ import type { TCell } from './types'; export const Cell = (props: TCell) => { const { data, rawData } = props; const cellId = data.column.id as keyof TRoute; + console.group(cellId); + console.dir(data); + console.dir(rawData); + console.groupEnd(); const component = { med: , age: , diff --git a/hyperglass/ui/components/output/fields.tsx b/hyperglass/ui/components/output/fields.tsx index 8667056..487149d 100644 --- a/hyperglass/ui/components/output/fields.tsx +++ b/hyperglass/ui/components/output/fields.tsx @@ -1,14 +1,9 @@ -import dynamic from 'next/dynamic'; -import { - Icon, - Text, - Popover, - Tooltip, - PopoverArrow, - PopoverContent, - PopoverTrigger, -} from '@chakra-ui/react'; -import { MdLastPage } from '@meronex/icons/md'; +import { Icon, Text, Box, Tooltip, Menu, MenuButton, MenuList } from '@chakra-ui/react'; +import { CgMoreO as More } from '@meronex/icons/cg'; +import { BisError as Warning } from '@meronex/icons/bi'; +import { MdNotInterested as NotAllowed, MdLastPage } from '@meronex/icons/md'; +import { BsQuestionCircleFill as Question } from '@meronex/icons/bs'; +import { FaCheckCircle as Check, FaChevronRight as ChevronRight } from '@meronex/icons/fa'; import dayjs from 'dayjs'; import relativeTimePlugin from 'dayjs/plugin/relativeTime'; import utcPlugin from 'dayjs/plugin/utc'; @@ -28,19 +23,6 @@ import type { dayjs.extend(relativeTimePlugin); dayjs.extend(utcPlugin); -const Check = dynamic(() => import('@meronex/icons/fa').then(i => i.FaCheckCircle)); -const More = dynamic(() => import('@meronex/icons/cg').then(i => i.CgMoreO)); -const NotAllowed = dynamic(() => - import('@meronex/icons/md').then(i => i.MdNotInterested), -); -const Question = dynamic(() => - import('@meronex/icons/bs').then(i => i.BsQuestionCircleFill), -); -const Warning = dynamic(() => import('@meronex/icons/bi').then(i => i.BisError)); -const ChevronRight = dynamic(() => - import('@meronex/icons/fa').then(i => i.FaChevronRight), -); - export const MonoField = (props: TMonoField) => { const { v, ...rest } = props; return ( @@ -128,22 +110,21 @@ export const Communities = (props: TCommunities) => { - - + + - - + - {communities.join('\n')} - - + + ); @@ -173,7 +154,7 @@ export const RPKIState = (props: TRPKIState) => { return ( - + ); }; diff --git a/hyperglass/ui/components/results/guards.ts b/hyperglass/ui/components/results/guards.ts new file mode 100644 index 0000000..a0458aa --- /dev/null +++ b/hyperglass/ui/components/results/guards.ts @@ -0,0 +1,11 @@ +export function isStackError(error: any): error is Error { + return error !== null && 'message' in error; +} + +export function isFetchError(error: any): error is Response { + return error !== null && 'statusText' in error; +} + +export function isLGError(error: any): error is TQueryResponse { + return error !== null && 'output' in error; +} diff --git a/hyperglass/ui/components/results/header.tsx b/hyperglass/ui/components/results/header.tsx index 1263efa..a27297a 100644 --- a/hyperglass/ui/components/results/header.tsx +++ b/hyperglass/ui/components/results/header.tsx @@ -1,5 +1,5 @@ -import { forwardRef, useMemo } from 'react'; -import { AccordionIcon, Box, Spinner, Stack, Text, Tooltip } from '@chakra-ui/react'; +import { useMemo } from 'react'; +import { AccordionIcon, Box, Spinner, HStack, Text, Tooltip } from '@chakra-ui/react'; import { BisError as Warning } from '@meronex/icons/bi'; import { FaCheckCircle as Check } from '@meronex/icons/fa'; import { useConfig, useColorValue } from '~/context'; @@ -15,8 +15,8 @@ const runtimeText = (runtime: number, text: string): string => { return `${text} ${unit}`; }; -export const ResultHeader = forwardRef((props, ref) => { - const { title, loading, error, errorMsg, errorLevel, runtime } = props; +export const ResultHeader = (props: TResultHeader) => { + const { title, loading, isError, errorMsg, errorLevel, runtime } = props; const status = useColorValue('primary.500', 'primary.300'); const warning = useColorValue(`${errorLevel}.500`, `${errorLevel}.300`); @@ -27,20 +27,27 @@ export const ResultHeader = forwardRef((props, re const label = useMemo(() => runtimeText(runtime, text), [runtime]); return ( - - {loading ? ( - - ) : error ? ( - - - - ) : ( - - - - )} + + + {loading ? ( + + ) : ( + + )} + + {title} - + ); -}); +}; diff --git a/hyperglass/ui/components/results/individual.tsx b/hyperglass/ui/components/results/individual.tsx index 26913d9..4a97674 100644 --- a/hyperglass/ui/components/results/individual.tsx +++ b/hyperglass/ui/components/results/individual.tsx @@ -10,17 +10,16 @@ import { AccordionButton, } from '@chakra-ui/react'; import { BsLightningFill } from '@meronex/icons/bs'; -import useAxios from 'axios-hooks'; import { startCase } from 'lodash'; import { BGPTable, Countdown, CopyButton, RequeryButton, TextOutput, If } from '~/components'; import { useColorValue, useConfig, useMobile } from '~/context'; -import { useStrf, useTableToString } from '~/hooks'; +import { useStrf, useLGQuery, useTableToString } from '~/hooks'; +import { isStructuredOutput, isStringOutput } from '~/types'; import { FormattedError } from './error'; import { ResultHeader } from './header'; +import { isStackError, isFetchError, isLGError } from './guards'; -import type { TAccordionHeaderWrapper, TResult } from './types'; - -type TErrorLevels = 'success' | 'warning' | 'error'; +import type { TAccordionHeaderWrapper, TResult, TErrorLevels } from './types'; const AccordionHeaderWrapper = (props: TAccordionHeaderWrapper) => { const { hoverBg, ...rest } = props; @@ -38,7 +37,6 @@ export const Result = forwardRef((props, ref) => { const { index, device, - timeout, queryVrf, queryType, queryTarget, @@ -54,20 +52,12 @@ export const Result = forwardRef((props, ref) => { const scrollbarHover = useColorValue('blackAlpha.400', 'whiteAlpha.400'); const scrollbarBg = useColorValue('blackAlpha.50', 'whiteAlpha.50'); - let [{ data, loading, error }, refetch] = useAxios( - { - url: '/api/query/', - method: 'post', - data: { - query_vrf: queryVrf, - query_type: queryType, - query_target: queryTarget, - query_location: queryLocation, - }, - timeout, - }, - { useCache: false }, - ); + const { data, error, isError, isLoading, refetch } = useLGQuery({ + queryLocation, + queryTarget, + queryType, + queryVrf, + }); const cacheLabel = useStrf(web.text.cache_icon, { time: data?.timestamp }, [data?.timestamp]); @@ -79,16 +69,23 @@ export const Result = forwardRef((props, ref) => { setOverride(true); }; - const errorKw = (error && error.response?.data?.keywords) || []; + const errorKeywords = useMemo(() => { + let kw = [] as string[]; + if (isLGError(error)) { + kw = error.keywords; + } + return kw; + }, [isError]); let errorMsg; - if (error && error.response?.data?.output) { - errorMsg = error.response.data.output; - } else if (error && error.message.startsWith('timeout')) { + + if (isLGError(error)) { + errorMsg = error.output as string; + } else if (isFetchError(error)) { + errorMsg = startCase(error.statusText); + } else if (isStackError(error) && error.message.toLowerCase().startsWith('timeout')) { errorMsg = messages.request_timeout; - } else if (error?.response?.statusText) { - errorMsg = startCase(error.response.statusText); - } else if (error && error.message) { + } else if (isStackError(error)) { errorMsg = startCase(error.message); } else { errorMsg = messages.general; @@ -96,7 +93,7 @@ export const Result = forwardRef((props, ref) => { error && console.error(error); - const getErrorLevel = (): TErrorLevels => { + const errorLevel = useMemo(() => { const statusMap = { success: 'success', warning: 'warning', @@ -106,18 +103,22 @@ export const Result = forwardRef((props, ref) => { let e: TErrorLevels = 'error'; - if (error?.response?.data?.level) { - const idx = error.response.data.level as TResponseLevel; + if (isLGError(error)) { + const idx = error.level as TResponseLevel; e = statusMap[idx]; } return e; - }; + }, [error]); - const errorLevel = useMemo(() => getErrorLevel(), [error]); + const tableComponent = useMemo(() => { + let result = false; + if (typeof queryType.match(/^bgp_\w+$/) !== null && data?.format === 'application/json') { + result = true; + } + return result; + }, [queryType, data?.format]); - const tableComponent = useMemo(() => typeof queryType.match(/^bgp_\w+$/) !== null, [queryType]); - - let copyValue = data?.output; + let copyValue = data?.output as string; const formatData = useTableToString(queryTarget, data, [data?.format]); @@ -130,18 +131,22 @@ export const Result = forwardRef((props, ref) => { } useEffect(() => { - !loading && resultsComplete === null && setComplete(index); - }, [loading, resultsComplete]); + if (isLoading && resultsComplete === null) { + setComplete(index); + } + }, [isLoading, resultsComplete]); useEffect(() => { - resultsComplete === index && !hasOverride && setOpen(true); + if (resultsComplete === index && !hasOverride) { + setOpen(true); + } }, [resultsComplete, index]); return ( ((props, ref) => { flex="1 0 auto" onClick={handleToggle}> - - + + ((props, ref) => { }}> - - - {data?.output} - - - {data?.output} - - - - {error && ( - - - + {!isError && typeof data !== 'undefined' && ( + <> + {isStructuredOutput(data) && tableComponent ? ( + {data.output} + ) : isStringOutput(data) && !tableComponent ? ( + {data.output} + ) : null} + )} + {isError && } diff --git a/hyperglass/ui/components/results/types.ts b/hyperglass/ui/components/results/types.ts index 4128984..f8819ae 100644 --- a/hyperglass/ui/components/results/types.ts +++ b/hyperglass/ui/components/results/types.ts @@ -1,10 +1,10 @@ import type { BoxProps, FlexProps } from '@chakra-ui/react'; -import type { TDevice, TQueryTypes } from '~/types'; +import type { TDevice, TQueryTypes, TFormState } from '~/types'; export interface TResultHeader { title: string; loading: boolean; - error?: Error; + isError?: boolean; errorMsg: string; errorLevel: 'success' | 'warning' | 'error'; runtime: number; @@ -22,18 +22,14 @@ export interface TAccordionHeaderWrapper extends FlexProps { export interface TResult { index: number; device: TDevice; - timeout: number; queryVrf: string; queryType: TQueryTypes; queryTarget: string; setComplete(v: number | null): void; - queryLocation: string; + queryLocation: string[]; resultsComplete: number | null; } -export interface TResults extends BoxProps { - queryType: TQueryTypes; - queryLocation: string[]; - queryTarget: string; - queryVrf: string; -} +export type TResults = TFormState & BoxProps; + +export type TErrorLevels = 'success' | 'warning' | 'error'; diff --git a/hyperglass/ui/hooks/index.ts b/hyperglass/ui/hooks/index.ts index e6b99ce..d3c8778 100644 --- a/hyperglass/ui/hooks/index.ts +++ b/hyperglass/ui/hooks/index.ts @@ -1,6 +1,7 @@ export * from './useBooleanValue'; export * from './useDevice'; export * from './useGreeting'; +export * from './useLGQuery'; export * from './useOpposingColor'; export * from './useSessionStorage'; export * from './useStrf'; diff --git a/hyperglass/ui/hooks/types.ts b/hyperglass/ui/hooks/types.ts index e1352a5..69cae3a 100644 --- a/hyperglass/ui/hooks/types.ts +++ b/hyperglass/ui/hooks/types.ts @@ -3,8 +3,4 @@ export interface TOpposingOptions { dark?: string; } -export interface TStringTableData extends Omit { - output: TStructuredResponse; -} - export type TUseGreetingReturn = [boolean, (v?: boolean) => void]; diff --git a/hyperglass/ui/hooks/useLGQuery.ts b/hyperglass/ui/hooks/useLGQuery.ts new file mode 100644 index 0000000..7c51430 --- /dev/null +++ b/hyperglass/ui/hooks/useLGQuery.ts @@ -0,0 +1,62 @@ +import { useQuery } from 'react-query'; +import { useConfig } from '~/context'; + +import type { TFormState } from '~/types'; + +/** + * Fetch Wrapper that incorporates a timeout via a passed AbortController instance. + * + * Adapted from: https://lowmess.com/blog/fetch-with-timeout + */ +export async function fetchWithTimeout( + uri: string, + options: RequestInit = {}, + timeout: number, + controller: AbortController, +): Promise { + /** + * Lets set up our `AbortController`, and create a request options object that includes the + * controller's `signal` to pass to `fetch`. + */ + const { signal = new AbortController().signal, ...allOptions } = options; + const config = { ...allOptions, signal }; + /** + * Set a timeout limit for the request using `setTimeout`. If the body of this timeout is + * reached before the request is completed, it will be cancelled. + */ + setTimeout(() => { + controller.abort(); + }, timeout); + return await fetch(uri, config); +} + +export function useLGQuery(query: TFormState) { + const { request_timeout } = useConfig(); + const controller = new AbortController(); + + async function runQuery(url: string, requestData: TFormState): Promise { + const { queryLocation, queryTarget, queryType, queryVrf } = requestData; + const res = await fetchWithTimeout( + url, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + query_location: queryLocation, + query_target: queryTarget, + query_type: queryType, + query_vrf: queryVrf, + }), + mode: 'cors', + }, + request_timeout * 1000, + controller, + ); + return await res.json(); + } + return useQuery( + ['/api/query/', query], + runQuery, + { refetchInterval: false }, + ); +} diff --git a/hyperglass/ui/hooks/useTableToString.ts b/hyperglass/ui/hooks/useTableToString.ts index 6621b75..5c3e40b 100644 --- a/hyperglass/ui/hooks/useTableToString.ts +++ b/hyperglass/ui/hooks/useTableToString.ts @@ -3,8 +3,7 @@ import dayjs from 'dayjs'; import relativeTimePlugin from 'dayjs/plugin/relativeTime'; import utcPlugin from 'dayjs/plugin/utc'; import { useConfig } from '~/context'; - -import { TStringTableData } from './types'; +import { isStructuredOutput } from '~/types'; dayjs.extend(relativeTimePlugin); dayjs.extend(utcPlugin); @@ -48,10 +47,10 @@ function formatTime(val: number): string { export function useTableToString( target: string, - data: TStringTableData, + data: TQueryResponse | undefined, ...deps: any ): () => string { - const { web, parsed_data_fields } = useConfig(); + const { web, parsed_data_fields, messages } = useConfig(); function formatRpkiState(val: number): string { const rpkiStates = [ @@ -83,26 +82,29 @@ export function useTableToString( } } - function doFormat(target: string, data: TStringTableData): string { + function doFormat(target: string, data: TQueryResponse | undefined): string { + let result = messages.no_output; try { - let tableStringParts = [`Routes For: ${target}`, `Timestamp: ${data.timestamp} UTC`]; - - data.output.routes.map(route => { - parsed_data_fields.map(field => { - const [header, accessor, align] = field; - if (align !== null) { - let value = route[accessor]; - const fmtFunc = getFmtFunc(accessor); - value = fmtFunc(value); - if (accessor === 'prefix') { - tableStringParts.push(` - ${header}: ${value}`); - } else { - tableStringParts.push(` - ${header}: ${value}`); + if (typeof data !== 'undefined' && isStructuredOutput(data)) { + let tableStringParts = [`Routes For: ${target}`, `Timestamp: ${data.timestamp} UTC`]; + for (const route of data.output.routes) { + for (const field of parsed_data_fields) { + const [header, accessor, align] = field; + if (align !== null) { + let value = route[accessor]; + const fmtFunc = getFmtFunc(accessor); + value = fmtFunc(value); + if (accessor === 'prefix') { + tableStringParts.push(` - ${header}: ${value}`); + } else { + tableStringParts.push(` - ${header}: ${value}`); + } } } - }); - }); - return tableStringParts.join('\n'); + } + result = tableStringParts.join('\n'); + } + return result; } catch (err) { console.error(err); return `An error occurred while parsing the output: '${err.message}'`; diff --git a/hyperglass/ui/types/data.ts b/hyperglass/ui/types/data.ts index 0de4efe..e33d92c 100644 --- a/hyperglass/ui/types/data.ts +++ b/hyperglass/ui/types/data.ts @@ -7,3 +7,18 @@ export interface TFormData { query_vrf: string; query_target: string; } + +export interface TFormState { + queryLocation: string[]; + queryType: TQueryTypes; + queryVrf: string; + queryTarget: string; +} + +export interface TStringTableData extends Omit { + output: TStructuredResponse; +} + +export interface TQueryResponseString extends Omit { + output: string; +} diff --git a/hyperglass/ui/types/guards.ts b/hyperglass/ui/types/guards.ts index 17ed9a2..59055ea 100644 --- a/hyperglass/ui/types/guards.ts +++ b/hyperglass/ui/types/guards.ts @@ -1,4 +1,4 @@ -import { TValidQueryTypes } from './data'; +import { TValidQueryTypes, TStringTableData, TQueryResponseString } from './data'; export function isQueryType(q: any): q is TValidQueryTypes { let result = false; @@ -14,3 +14,11 @@ export function isQueryType(q: any): q is TValidQueryTypes { export function isString(a: any): a is string { return typeof a === 'string'; } + +export function isStructuredOutput(data: any): data is TStringTableData { + return typeof data.output !== 'string'; +} + +export function isStringOutput(data: any): data is TQueryResponseString { + return typeof data.output === 'string'; +}