diff --git a/README.md b/README.md index 9b5b753..3d598cf 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ hyperglass is intended to make implementing a looking glass too easy not to do, - VRF support - Access List/prefix-list style query control to whitelist or blacklist query targets on a per-VRF basis - REST API with automatic, configurable OpenAPI documentation -- Modern, responsive UI built on [ReactJS](https://reactjs.org/), with [NextJS](https://nextjs.org/) & [Chakra UI](https://chakra-ui.com/) +- Modern, responsive UI built on [ReactJS](https://reactjs.org/), with [NextJS](https://nextjs.org/) & [Chakra UI](https://chakra-ui.com/), written in [TypeScript](https://www.typescriptlang.org/) - Query multiple devices simultaneously - Browser-based DNS-over-HTTPS resolution of FQDN queries diff --git a/docs/docs/ui/logo.mdx b/docs/docs/ui/logo.mdx index 786907e..34dfa46 100644 --- a/docs/docs/ui/logo.mdx +++ b/docs/docs/ui/logo.mdx @@ -13,7 +13,7 @@ description: Customize the Logo & Favicon | `light` | String | hyperglass logo | Path to logo that will be used in light mode | | `dark` | String | hyperglass logo | Path to logo that will be used in dark mode | | `favicon` | String | hyperglass icon | Path to logo that will be used as the favicon | -| `width` | Integer | `75%` | Maximum logo width as a percentage | +| `width` | String | `100%` | Maximum logo width as a percentage | | `height` | Integer | | Maximum logo height as a percentage | ### Favicon diff --git a/docs/docs/ui/text.mdx b/docs/docs/ui/text.mdx index c9eff8f..fc41528 100644 --- a/docs/docs/ui/text.mdx +++ b/docs/docs/ui/text.mdx @@ -8,19 +8,21 @@ description: Customize the text used in the web UI ## `text` -| Parameter | Type | Default | Description | -| :--------------- | :----: | :------------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cache_prefix` | String | `'Results cached for '` | Text displayed with the cache timeout countdown. | -| `cache_icon` | String | `'Cached Response from {time}'` | Text displayed when a user hovers over the lightning bolt icon, which is displayed when a response from the server was a cached response. `{time}` is replaced with the _original_ query's timestamp. | -| `completed_time` | String | `'Completed in {seconds}'` | Text displayed when a user hovers over the success icon for a query result. `{seconds}` will be replaced with 'n seconds' where n is the time a query took to complete. | -| `fqdn_tooltip` | String | `'Use {protocol}'` | Text displayed when a user hovers over the IPv4 or IPv6 button on an FQDN target resolved by DNS. `{protocol}` is replaced with the relevant IP protocol. | -| `query_location` | String | `'Location'` | Query Location (router) form label. | -| `query_target` | String | `'Target'` | Query Target (IP/hostname/community/AS Path) form label. | -| `query_type` | String | `'Query Type'` | Query Type (BGP Route, Ping, Traceroute, etc.) form label. | -| `query_vrf` | String | `'Routing Table'` | Query VRF form label. | -| `subtitle` | String | `'Network Looking Glass'` | Subtitle text. value. | -| `title` | String | `'hyperglass'` | Title text. | -| `title_mode` | String | `'text_only'` | Set the title mode. Must be text_only, logo_only, logo_subtitle, or all | +| Parameter | Type | Default | Description | +| :--------------- | :----: | :-------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cache_prefix` | String | `'Results cached for '` | Text displayed with the cache timeout countdown. | +| `cache_icon` | String | `'Cached Response from {time}'` | Text displayed when a user hovers over the lightning bolt icon, which is displayed when a response from the server was a cached response. `{time}` is replaced with the _original_ query's timestamp. | +| `completed_time` | String | `'Completed in {seconds}'` | Text displayed when a user hovers over the success icon for a query result. `{seconds}` will be replaced with 'n seconds' where n is the time a query took to complete. | +| `fqdn_message` | String | `'Your browser has resolved {fqdn} to'` | Text displayed when prompting a user to select a resolve IPv4 or IPv6 address for an FQDN query. | +| `fqdn_error` | String | `'Unable to resolve {fqdn}'` | Text displayed when an FQDN is not resolvable. | +| `fqdn_button` | String | `'Try Again'` | Button text used when an FQDN is not resolvable. | +| `query_location` | String | `'Location'` | Query Location (router) form label. | +| `query_target` | String | `'Target'` | Query Target (IP/hostname/community/AS Path) form label. | +| `query_type` | String | `'Query Type'` | Query Type (BGP Route, Ping, Traceroute, etc.) form label. | +| `query_vrf` | String | `'Routing Table'` | Query VRF form label. | +| `subtitle` | String | `'Network Looking Glass'` | Subtitle text. value. | +| `title` | String | `'hyperglass'` | Title text. | +| `title_mode` | String | `'text_only'` | Set the title mode. Must be text_only, logo_only, logo_subtitle, or all | ### Title Mode diff --git a/hyperglass/api/fake_output.py b/hyperglass/api/fake_output.py new file mode 100644 index 0000000..167c042 --- /dev/null +++ b/hyperglass/api/fake_output.py @@ -0,0 +1,164 @@ +"""Return fake, static data for development purposes.""" + +# Standard Library +from typing import Dict, Union + +PLAIN = r""" + _ _ +| |__ _ _ _ __ ___ _ __ __ _ | | __ _ ___ ___ +| '_ \ | | | || '_ \ / _ \| '__|/ _` || | / _` |/ __|/ __| +| | | || |_| || |_) || __/| | | (_| || || (_| |\__ \\__ \ +|_| |_| \__, || .__/ \___||_| \__, ||_| \__,_||___/|___/ + |___/ |_| |___/ + +""" # noqa: W291 + +ROUTES = [ + { + "prefix": "198.18.1.0/24", + "active": True, + "age": 240, + "weight": 170, + "med": 1, + "local_preference": 100, + "as_path": [1299, 174, 7018, 7922], + "communities": ["65000:1", "65000:101", "65000:102", "65000:103"], + "next_hop": "198.18.0.1", + "source_as": 7922, + "source_rid": "198.18.0.1", + "peer_rid": "198.18.0.1", + "rpki_state": 1, + }, + { + "prefix": "2001:db8:1::/64", + "active": True, + "age": 240, + "weight": 170, + "med": 1, + "local_preference": 100, + "as_path": [1299, 174, 7018, 7922], + "communities": ["65000:1", "65000:101", "65000:102", "65000:103"], + "next_hop": "2001:db8::1", + "source_as": 7922, + "source_rid": "198.18.0.1", + "peer_rid": "198.18.0.1", + "rpki_state": 1, + }, + { + "prefix": "198.18.2.0/24", + "active": False, + "age": 480, + "weight": 170, + "med": 2, + "local_preference": 100, + "as_path": [6939, 20115, 20115], + "communities": [ + "65000:2", + "65000:201", + "198.18.0.2:65000", + "198.18.0.2:650201", + ], + "next_hop": "198.18.0.2", + "source_as": 20115, + "source_rid": "198.18.0.2", + "peer_rid": "198.18.0.2", + "rpki_state": 2, + }, + { + "prefix": "2001:db8:2::/64", + "active": False, + "age": 480, + "weight": 170, + "med": 2, + "local_preference": 100, + "as_path": [6939, 20115, 20115], + "communities": [ + "65000:2", + "65000:201", + "198.18.0.2:65000", + "198.18.0.2:650201", + ], + "next_hop": "2001:db8::2", + "source_as": 20115, + "source_rid": "198.18.0.2", + "peer_rid": "198.18.0.2", + "rpki_state": 2, + }, + { + "prefix": "198.18.3.0/24", + "active": False, + "age": 480, + "weight": 170, + "med": 3, + "local_preference": 100, + "as_path": [22773], + "communities": ["65000:3"], + "next_hop": "198.18.0.3", + "source_as": 22773, + "source_rid": "198.18.0.3", + "peer_rid": "198.18.0.3", + "rpki_state": 3, + }, + { + "prefix": "2001:db8:3::/64", + "active": False, + "age": 480, + "weight": 170, + "med": 3, + "local_preference": 100, + "as_path": [22773], + "communities": ["65000:3"], + "next_hop": "2001:db8::3", + "source_as": 22773, + "source_rid": "198.18.0.3", + "peer_rid": "198.18.0.3", + "rpki_state": 3, + }, + { + "prefix": "198.18.4.0/24", + "active": False, + "age": 480, + "weight": 170, + "med": 4, + "local_preference": 100, + "as_path": [], + "communities": ["65000:4"], + "next_hop": "198.18.0.4", + "source_as": 209, + "source_rid": "198.18.0.4", + "peer_rid": "198.18.0.4", + "rpki_state": 0, + }, + { + "prefix": "2001:db8:4::/64", + "active": False, + "age": 480, + "weight": 170, + "med": 4, + "local_preference": 100, + "as_path": [], + "communities": ["65000:4"], + "next_hop": "2001:db8::4", + "source_as": 209, + "source_rid": "198.18.0.4", + "peer_rid": "198.18.0.4", + "rpki_state": 0, + }, +] + +STRUCTURED = { + "vrf": "default", + "count": len(ROUTES), + "routes": ROUTES, + "winning_weight": "high", +} + + +async def fake_output(structured: bool) -> Union[str, Dict]: + """Bypass the standard execution process and return static, fake output.""" + output = PLAIN + + if structured: + output = STRUCTURED + + return output diff --git a/hyperglass/api/routes.py b/hyperglass/api/routes.py index 08d76cf..e225390 100644 --- a/hyperglass/api/routes.py +++ b/hyperglass/api/routes.py @@ -23,6 +23,9 @@ from hyperglass.models.api import Query, EncodedRequest from hyperglass.configuration import REDIS_CONFIG, params, devices from hyperglass.execution.main import execute +# Local +from .fake_output import fake_output + APP_PATH = os.environ["hyperglass_directory"] @@ -115,9 +118,16 @@ async def query(query_data: Query, request: Request, background_tasks: Backgroun ) timestamp = query_data.timestamp - # Pass request to execution module + starttime = time.time() - cache_output = await execute(query_data) + + if params.fake_output: + # Return fake, static data for development purposes, if enabled. + cache_output = await fake_output(json_output) + else: + # Pass request to execution module + cache_output = await execute(query_data) + endtime = time.time() elapsedtime = round(endtime - starttime, 4) log.debug("Query {} took {} seconds to run.", cache_key, elapsedtime) diff --git a/hyperglass/configuration/main.py b/hyperglass/configuration/main.py index 1bf4feb..1ee5542 100644 --- a/hyperglass/configuration/main.py +++ b/hyperglass/configuration/main.py @@ -309,7 +309,12 @@ def _build_networks(): "display_name": device.display_name, "network": device.network.display_name, "vrfs": [ - {"id": vrf.name, "display_name": vrf.display_name} + { + "id": vrf.name, + "display_name": vrf.display_name, + "ipv4": True if vrf.ipv4 else False, # noqa: IF100 + "ipv6": True if vrf.ipv6 else False, # noqa: IF100 + } for vrf in device.vrfs ], } diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 88119a0..a128f13 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -4,7 +4,7 @@ from datetime import datetime __name__ = "hyperglass" -__version__ = "1.0.0-beta.64" +__version__ = "1.0.0-beta.65" __author__ = "Matt Love" __copyright__ = f"Copyright {datetime.now().year} Matthew Love" __license__ = "BSD 3-Clause Clear License" diff --git a/hyperglass/exceptions.py b/hyperglass/exceptions.py index 105c7fc..16a46d7 100644 --- a/hyperglass/exceptions.py +++ b/hyperglass/exceptions.py @@ -3,7 +3,7 @@ # Standard Library import sys import json as _json -from typing import Dict, List, Union, Optional, Sequence +from typing import Dict, List, Union, Optional # Third Party from rich.console import Console @@ -190,18 +190,13 @@ class ParsingError(_UnformattedHyperglassError): """Raised when there is a problem parsing a structured response.""" def __init__( - self, - unformatted_msg: Union[Sequence[Dict], str], - level: str = "danger", - **kwargs, + self, unformatted_msg: Union[List[Dict], str], level: str = "danger", **kwargs, ) -> None: """Format error message with keyword arguments.""" - if isinstance(unformatted_msg, Sequence): + if isinstance(unformatted_msg, list): self._message = validation_error_message(*unformatted_msg) else: self._message = unformatted_msg.format(**kwargs) self._level = level or self._level self._keywords = list(kwargs.values()) - super().__init__( - message=self._message, level=self._level, keywords=self._keywords - ) + super().__init__(self._message, level=self._level, keywords=self._keywords) diff --git a/hyperglass/execution/drivers/_common.py b/hyperglass/execution/drivers/_common.py index c37739a..14b425a 100644 --- a/hyperglass/execution/drivers/_common.py +++ b/hyperglass/execution/drivers/_common.py @@ -1,7 +1,7 @@ """Base Connection Class.""" # Standard Library -from typing import Iterable +from typing import Dict, Union, Sequence # Project from hyperglass.log import log @@ -27,8 +27,8 @@ class Connection: self.query = self._query.queries() async def parsed_response( # noqa: C901 ("too complex") - self, output: Iterable - ) -> str: + self, output: Sequence[str] + ) -> Union[str, Sequence[Dict]]: """Send output through common parsers.""" log.debug("Pre-parsed responses:\n{}", output) diff --git a/hyperglass/execution/main.py b/hyperglass/execution/main.py index 922f1d7..c1596d7 100644 --- a/hyperglass/execution/main.py +++ b/hyperglass/execution/main.py @@ -8,7 +8,7 @@ hyperglass-frr API calls, returns the output back to the front end. # Standard Library import signal -from typing import Any, Dict, Union, Callable +from typing import Any, Dict, Union, Callable, Sequence # Project from hyperglass.log import log @@ -36,7 +36,7 @@ def handle_timeout(**exc_args: Any) -> Callable: return handler -async def execute(query: Query) -> Union[str, Dict]: +async def execute(query: Query) -> Union[str, Sequence[Dict]]: """Initiate query validation and execution.""" output = params.messages.general diff --git a/hyperglass/models/api/cert_import.py b/hyperglass/models/api/cert_import.py index 739f08e..7558411 100644 --- a/hyperglass/models/api/cert_import.py +++ b/hyperglass/models/api/cert_import.py @@ -5,8 +5,8 @@ from typing import Union # Third Party from pydantic import BaseModel, StrictStr -# Project -from hyperglass.models.fields import StrictBytes +# Local +from ..fields import StrictBytes class EncodedRequest(BaseModel): diff --git a/hyperglass/models/config/params.py b/hyperglass/models/config/params.py index f046471..c6f725b 100644 --- a/hyperglass/models/config/params.py +++ b/hyperglass/models/config/params.py @@ -43,6 +43,11 @@ class Params(HyperglassModel): title="Developer Mode", description='Enable developer mode. If enabled, the hyperglass backend (Python) and frontend (React/Javascript) applications are "unlinked", so that React tools can be used for front end development. A `` convenience component is also displayed in the UI for easier UI development.', ) + fake_output: StrictBool = Field( + False, + title="Fake Output", + description="If enabled, the hyperglass backend will return static fake output for development/testing purposes.", + ) primary_asn: Union[StrictInt, StrictStr] = Field( "65001", title="Primary ASN", diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index 33cad58..8a72884 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -30,6 +30,7 @@ Percentage = constr(regex=r"^([1-9][0-9]?|100)\%$") TitleMode = constr(regex=("logo_only|text_only|logo_title|logo_subtitle|all")) ColorMode = constr(regex=r"light|dark") DOHProvider = constr(regex="|".join(DNS_OVER_HTTPS.keys())) +Title = constr(max_length=32) class Analytics(HyperglassModel): @@ -102,7 +103,7 @@ class Logo(HyperglassModel): light: FilePath = DEFAULT_IMAGES / "hyperglass-light.svg" dark: FilePath = DEFAULT_IMAGES / "hyperglass-dark.svg" favicon: FilePath = DEFAULT_IMAGES / "hyperglass-icon.svg" - width: Optional[Union[StrictInt, Percentage]] = "75%" + width: Optional[Union[StrictInt, Percentage]] = "100%" height: Optional[Union[StrictInt, Percentage]] @@ -118,13 +119,16 @@ class Text(HyperglassModel): """Validation model for params.branding.text.""" title_mode: TitleMode = "logo_only" - title: StrictStr = "hyperglass" - subtitle: StrictStr = "Network Looking Glass" + title: Title = "hyperglass" + subtitle: Title = "Network Looking Glass" query_location: StrictStr = "Location" query_type: StrictStr = "Query Type" query_target: StrictStr = "Target" query_vrf: StrictStr = "Routing Table" fqdn_tooltip: StrictStr = "Use {protocol}" # Formatted by Javascript + fqdn_message: StrictStr = "Your browser has resolved {fqdn} to" # Formatted by Javascript + fqdn_error: StrictStr = "Unable to resolve {fqdn}" # Formatted by Javascript + fqdn_error_button: StrictStr = "Try Again" cache_prefix: StrictStr = "Results cached for " cache_icon: StrictStr = "Cached from {time} UTC" # Formatted by Javascript complete_time: StrictStr = "Completed in {seconds}" # Formatted by Javascript diff --git a/hyperglass/parsing/models/__init__.py b/hyperglass/models/parsing/__init__.py similarity index 100% rename from hyperglass/parsing/models/__init__.py rename to hyperglass/models/parsing/__init__.py diff --git a/hyperglass/parsing/models/frr.py b/hyperglass/models/parsing/frr.py similarity index 96% rename from hyperglass/parsing/models/frr.py rename to hyperglass/models/parsing/frr.py index f74fb50..e9bdd21 100644 --- a/hyperglass/parsing/models/frr.py +++ b/hyperglass/models/parsing/frr.py @@ -9,8 +9,10 @@ from pydantic import StrictInt, StrictStr, StrictBool, constr, root_validator # Project from hyperglass.log import log -from hyperglass.models import HyperglassModel -from hyperglass.parsing.models.serialized import ParsedRoutes + +# Local +from ..main import HyperglassModel +from .serialized import ParsedRoutes FRRPeerType = constr(regex=r"(internal|external)") diff --git a/hyperglass/parsing/models/frr_bgp_community.json b/hyperglass/models/parsing/frr_bgp_community.json similarity index 100% rename from hyperglass/parsing/models/frr_bgp_community.json rename to hyperglass/models/parsing/frr_bgp_community.json diff --git a/hyperglass/parsing/models/frr_bgp_route.json b/hyperglass/models/parsing/frr_bgp_route.json similarity index 100% rename from hyperglass/parsing/models/frr_bgp_route.json rename to hyperglass/models/parsing/frr_bgp_route.json diff --git a/hyperglass/parsing/models/juniper.py b/hyperglass/models/parsing/juniper.py similarity index 98% rename from hyperglass/parsing/models/juniper.py rename to hyperglass/models/parsing/juniper.py index 849642e..e519fd6 100644 --- a/hyperglass/parsing/models/juniper.py +++ b/hyperglass/models/parsing/juniper.py @@ -8,8 +8,10 @@ from pydantic import StrictInt, StrictStr, StrictBool, validator, root_validator # Project from hyperglass.log import log -from hyperglass.models import HyperglassModel -from hyperglass.parsing.models.serialized import ParsedRoutes + +# Local +from ..main import HyperglassModel +from .serialized import ParsedRoutes RPKI_STATE_MAP = { "invalid": 0, diff --git a/hyperglass/parsing/models/serialized.py b/hyperglass/models/parsing/serialized.py similarity index 98% rename from hyperglass/parsing/models/serialized.py rename to hyperglass/models/parsing/serialized.py index c502d82..3ae62a4 100644 --- a/hyperglass/parsing/models/serialized.py +++ b/hyperglass/models/parsing/serialized.py @@ -8,10 +8,12 @@ from typing import List from pydantic import StrictInt, StrictStr, StrictBool, constr, validator # Project -from hyperglass.models import HyperglassModel from hyperglass.configuration import params from hyperglass.external.rpki import rpki_state +# Local +from ..main import HyperglassModel + WinningWeight = constr(regex=r"(low|high)") diff --git a/hyperglass/parsing/juniper.py b/hyperglass/parsing/juniper.py index 899c1de..820694a 100644 --- a/hyperglass/parsing/juniper.py +++ b/hyperglass/parsing/juniper.py @@ -1,6 +1,7 @@ """Parse Juniper XML Response to Structured Data.""" # Standard Library +import re from typing import Dict, Iterable # Third Party @@ -11,7 +12,34 @@ from pydantic import ValidationError from hyperglass.log import log from hyperglass.exceptions import ParsingError, ResponseEmpty from hyperglass.configuration import params -from hyperglass.parsing.models.juniper import JuniperRoute +from hyperglass.models.parsing.juniper import JuniperRoute + + +def clean_xml_output(output: str) -> str: + """Remove Juniper-specific patterns from output.""" + patterns = ( + """ + The XML response can a CLI banner appended to the end of the XML + string. For example: + + ``` + + ... + + {master} + + + + {master} + ``` + + This pattern will remove anything inside braces, including the braces. + """ + r"\{.+\}", + ) + lines = (line.strip() for line in output.split()) + scrubbed_lines = (re.sub(pat, "", line) for pat in patterns for line in lines) + return "\n".join(scrubbed_lines) def parse_juniper(output: Iterable) -> Dict: # noqa: C901 @@ -19,9 +47,10 @@ def parse_juniper(output: Iterable) -> Dict: # noqa: C901 data = {} for i, response in enumerate(output): + cleaned = clean_xml_output(response) try: parsed = xmltodict.parse( - response, force_list=("rt", "rt-entry", "community") + cleaned, force_list=("rt", "rt-entry", "community") ) log.debug("Initially Parsed Response: \n{}", parsed) @@ -49,7 +78,7 @@ def parse_juniper(output: Iterable) -> Dict: # noqa: C901 except xmltodict.expat.ExpatError as err: log.critical(str(err)) - raise ParsingError("Error parsing response data") + raise ParsingError("Error parsing response data") from err except KeyError as err: log.critical("{} was not found in the response", str(err)) diff --git a/hyperglass/ui/.gitignore b/hyperglass/ui/.gitignore index 83a4400..35328e3 100644 --- a/hyperglass/ui/.gitignore +++ b/hyperglass/ui/.gitignore @@ -1,5 +1,6 @@ .DS_Store # dev/test files +TODO.txt *.tmp* test* *.log diff --git a/hyperglass/ui/README.md b/hyperglass/ui/README.md index e28478b..f8edd2a 100644 --- a/hyperglass/ui/README.md +++ b/hyperglass/ui/README.md @@ -1,3 +1,3 @@ # hyperglass-ui -Temporary repo for the permanent [hyperglass](https://github.com/checktheroads/hyperglass) UI, written in [React](https://reactjs.org/), on [Next.js](https://nextjs.org/), with [Chakra UI](https://chakra-ui.com/). +[hyperglass](https://github.com/checktheroads/hyperglass) UI, written in [React](https://reactjs.org/), on [Next.js](https://nextjs.org/), with [Chakra UI](https://chakra-ui.com/). diff --git a/hyperglass/ui/components/BGPTable.js b/hyperglass/ui/components/BGPTable.js deleted file mode 100644 index 7cb0e61..0000000 --- a/hyperglass/ui/components/BGPTable.js +++ /dev/null @@ -1,199 +0,0 @@ -import * as React from 'react'; -import { - Flex, - Icon, - Popover, - PopoverArrow, - PopoverContent, - PopoverTrigger, - Text, - Tooltip, - useColorMode, -} from '@chakra-ui/core'; -import { MdLastPage } from '@meronex/icons/md'; -import dayjs from 'dayjs'; -import relativeTimePlugin from 'dayjs/plugin/relativeTime'; -import utcPlugin from 'dayjs/plugin/utc'; -import { useConfig } from 'app/context'; -import { Table } from 'app/components'; - -dayjs.extend(relativeTimePlugin); -dayjs.extend(utcPlugin); - -const isActiveColor = { - true: { dark: 'green.300', light: 'green.500' }, - false: { dark: 'gray.300', light: 'gray.500' }, -}; - -const arrowColor = { - true: { dark: 'blackAlpha.500', light: 'blackAlpha.500' }, - false: { dark: 'whiteAlpha.300', light: 'blackAlpha.500' }, -}; - -const rpkiIcon = ['not-allowed', 'check-circle', 'warning', 'question']; - -const rpkiColor = { - true: { - dark: ['red.500', 'green.600', 'yellow.500', 'gray.800'], - light: ['red.500', 'green.500', 'yellow.500', 'gray.600'], - }, - false: { - dark: ['red.300', 'green.300', 'yellow.300', 'gray.300'], - light: ['red.400', 'green.500', 'yellow.400', 'gray.500'], - }, -}; - -const makeColumns = fields => { - return fields.map(pair => { - const [header, accessor, align] = pair; - let columnConfig = { - Header: header, - accessor: accessor, - align: align, - hidden: false, - }; - if (align === null) { - columnConfig.hidden = true; - } - return columnConfig; - }); -}; - -const MonoField = ({ v, ...props }) => ( - - {v} - -); - -const Active = ({ isActive }) => { - const { colorMode } = useColorMode(); - return ( - - ); -}; - -const Age = ({ inSeconds }) => { - const now = dayjs.utc(); - const then = now.subtract(inSeconds, 'seconds'); - return ( - - {now.to(then, true)} - - ); -}; - -const Weight = ({ weight, winningWeight }) => { - const fixMeText = - winningWeight === 'low' ? 'Lower Weight is Preferred' : 'Higher Weight is Preferred'; - return ( - - - {weight} - - - ); -}; - -const ASPath = ({ path, active }) => { - const { colorMode } = useColorMode(); - if (path.length === 0) { - return ; - } - let paths = []; - path.map((asn, i) => { - const asnStr = String(asn); - i !== 0 && - paths.push( - , - ); - paths.push( - - {asnStr} - , - ); - }); - return paths; -}; - -const Communities = ({ communities }) => { - const { colorMode } = useColorMode(); - let component; - communities.length === 0 - ? (component = ( - - - - )) - : (component = ( - - - - - - - {communities.join('\n')} - - - )); - return component; -}; - -const RPKIState = ({ state, active }) => { - const { web } = useConfig(); - const { colorMode } = useColorMode(); - const stateText = [ - web.text.rpki_invalid, - web.text.rpki_valid, - web.text.rpki_unknown, - web.text.rpki_unverified, - ]; - return ( - - - - ); -}; - -const Cell = ({ data, rawData, longestASN }) => { - const component = { - prefix: , - active: , - age: , - weight: , - med: , - local_preference: , - as_path: , - communities: , - next_hop: , - source_as: , - source_rid: , - peer_rid: , - rpki_state: , - }; - return component[data.column.id] ?? <> ; -}; - -export const BGPTable = ({ children: data, ...props }) => { - const config = useConfig(); - const columns = makeColumns(config.parsed_data_fields); - - return ( - - } - bordersHorizontal - rowHighlightBg="green" - /> - - ); -}; diff --git a/hyperglass/ui/components/CacheTimeout.js b/hyperglass/ui/components/CacheTimeout.js deleted file mode 100644 index d27ffde..0000000 --- a/hyperglass/ui/components/CacheTimeout.js +++ /dev/null @@ -1,37 +0,0 @@ -import * as React from 'react'; -import Countdown, { zeroPad } from 'react-countdown'; -import { Text, useColorMode } from '@chakra-ui/core'; - -const bg = { dark: 'white', light: 'black' }; - -const Renderer = ({ hours, minutes, seconds, completed, props }) => { - if (completed) { - return ; - } else { - let time = [zeroPad(seconds)]; - minutes !== 0 && time.unshift(zeroPad(minutes)); - hours !== 0 && time.unshift(zeroPad(hours)); - return ( - - {props.text} - - {time.join(':')} - - - ); - } -}; - -export const CacheTimeout = ({ timeout, text }) => { - const then = timeout * 1000; - const { colorMode } = useColorMode(); - return ( - - ); -}; diff --git a/hyperglass/ui/components/Card/CardBody.js b/hyperglass/ui/components/Card/CardBody.js deleted file mode 100644 index f4447b1..0000000 --- a/hyperglass/ui/components/Card/CardBody.js +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { Flex, useColorMode } from '@chakra-ui/core'; - -const bg = { light: 'white', dark: 'original.dark' }; -const color = { light: 'original.dark', dark: 'white' }; - -export const CardBody = ({ onClick = () => false, children, ...props }) => { - const { colorMode } = useColorMode(); - return ( - - {children} - - ); -}; diff --git a/hyperglass/ui/components/Card/CardHeader.js b/hyperglass/ui/components/Card/CardHeader.js deleted file mode 100644 index 9b8f6f7..0000000 --- a/hyperglass/ui/components/Card/CardHeader.js +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from 'react'; -import { Flex, Text, useColorMode } from '@chakra-ui/core'; - -const bg = { light: 'blackAlpha.50', dark: 'whiteAlpha.100' }; - -export const CardHeader = ({ children, ...props }) => { - const { colorMode } = useColorMode(); - return ( - - {children} - - ); -}; diff --git a/hyperglass/ui/components/Card/body.tsx b/hyperglass/ui/components/Card/body.tsx new file mode 100644 index 0000000..6d0cd92 --- /dev/null +++ b/hyperglass/ui/components/Card/body.tsx @@ -0,0 +1,24 @@ +import { Flex } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; + +import type { ICardBody } from './types'; + +export const CardBody = (props: ICardBody) => { + const { onClick, ...rest } = props; + const bg = useColorValue('white', 'dark.500'); + const color = useColorValue('dark.500', 'white'); + return ( + + ); +}; diff --git a/hyperglass/ui/components/Card/CardFooter.js b/hyperglass/ui/components/Card/footer.tsx similarity index 57% rename from hyperglass/ui/components/Card/CardFooter.js rename to hyperglass/ui/components/Card/footer.tsx index 7800757..2c90d2e 100644 --- a/hyperglass/ui/components/Card/CardFooter.js +++ b/hyperglass/ui/components/Card/footer.tsx @@ -1,18 +1,18 @@ -import * as React from 'react'; -import { Flex } from '@chakra-ui/core'; +import { Flex } from '@chakra-ui/react'; -export const CardFooter = ({ children, ...props }) => ( +import type { ICardFooter } from './types'; + +export const CardFooter = (props: ICardFooter) => ( - {children} - + {...props} + /> ); diff --git a/hyperglass/ui/components/Card/header.tsx b/hyperglass/ui/components/Card/header.tsx new file mode 100644 index 0000000..4b89435 --- /dev/null +++ b/hyperglass/ui/components/Card/header.tsx @@ -0,0 +1,21 @@ +import { Flex, Text } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; + +import type { ICardHeader } from './types'; + +export const CardHeader = (props: ICardHeader) => { + const { children, ...rest } = props; + const bg = useColorValue('blackAlpha.50', 'whiteAlpha.100'); + return ( + + {children} + + ); +}; diff --git a/hyperglass/ui/components/Card/index.mjs b/hyperglass/ui/components/Card/index.mjs deleted file mode 100644 index 39dfd07..0000000 --- a/hyperglass/ui/components/Card/index.mjs +++ /dev/null @@ -1,3 +0,0 @@ -export * from './CardBody'; -export * from './CardFooter'; -export * from './CardHeader'; diff --git a/hyperglass/ui/components/Card/index.ts b/hyperglass/ui/components/Card/index.ts new file mode 100644 index 0000000..9f66b54 --- /dev/null +++ b/hyperglass/ui/components/Card/index.ts @@ -0,0 +1,3 @@ +export * from './body'; +export * from './footer'; +export * from './header'; diff --git a/hyperglass/ui/components/Card/types.ts b/hyperglass/ui/components/Card/types.ts new file mode 100644 index 0000000..9c962fb --- /dev/null +++ b/hyperglass/ui/components/Card/types.ts @@ -0,0 +1,9 @@ +import type { FlexProps } from '@chakra-ui/react'; + +export interface ICardBody extends Omit { + onClick?: () => boolean; +} + +export interface ICardFooter extends FlexProps {} + +export interface ICardHeader extends FlexProps {} diff --git a/hyperglass/ui/components/ChakraSelect.js b/hyperglass/ui/components/ChakraSelect.js deleted file mode 100644 index c7e013c..0000000 --- a/hyperglass/ui/components/ChakraSelect.js +++ /dev/null @@ -1,190 +0,0 @@ -import * as React from 'react'; -import { Text, useColorMode, useTheme } from '@chakra-ui/core'; -import Select from 'react-select'; -import { opposingColor } from 'app/util'; - -export const ChakraSelect = React.forwardRef( - ({ placeholder = 'Select...', isFullWidth, size, children, ...props }, ref) => { - const theme = useTheme(); - const { colorMode } = useColorMode(); - const sizeMap = { - lg: { height: theme.space[12] }, - md: { height: theme.space[10] }, - sm: { height: theme.space[8] }, - }; - const colorSetPrimaryBg = { - dark: theme.colors.primary[300], - light: theme.colors.primary[500], - }; - const colorSetPrimaryColor = opposingColor(theme, colorSetPrimaryBg[colorMode]); - const bg = { - dark: theme.colors.whiteAlpha[100], - light: theme.colors.white, - }; - const color = { - dark: theme.colors.whiteAlpha[800], - light: theme.colors.black, - }; - const borderFocused = theme.colors.secondary[500]; - const borderDisabled = theme.colors.whiteAlpha[100]; - const border = { - dark: theme.colors.whiteAlpha[50], - light: theme.colors.gray[100], - }; - const borderRadius = theme.space[1]; - const hoverColor = { - dark: theme.colors.whiteAlpha[200], - light: theme.colors.gray[300], - }; - const { height } = sizeMap[size]; - const optionBgActive = { - dark: theme.colors.primary[400], - light: theme.colors.primary[600], - }; - const optionBgColor = opposingColor(theme, optionBgActive[colorMode]); - const optionSelectedBg = { - dark: theme.colors.whiteAlpha[400], - light: theme.colors.blackAlpha[400], - }; - const optionSelectedColor = opposingColor(theme, optionSelectedBg[colorMode]); - const selectedDisabled = theme.colors.whiteAlpha[400]; - const placeholderColor = { - dark: theme.colors.whiteAlpha[700], - light: theme.colors.gray[600], - }; - const menuBg = { - dark: theme.colors.blackFaded[800], - light: theme.colors.whiteFaded[50], - }; - const menuColor = { - dark: theme.colors.white, - light: theme.colors.blackAlpha[800], - }; - const scrollbar = { - dark: theme.colors.whiteAlpha[300], - light: theme.colors.blackAlpha[300], - }; - const scrollbarHover = { - dark: theme.colors.whiteAlpha[400], - light: theme.colors.blackAlpha[400], - }; - const scrollbarBg = { - dark: theme.colors.whiteAlpha[50], - light: theme.colors.blackAlpha[50], - }; - return ( - - ); - }, -); diff --git a/hyperglass/ui/components/CodeBlock.js b/hyperglass/ui/components/CodeBlock.js deleted file mode 100644 index e12e242..0000000 --- a/hyperglass/ui/components/CodeBlock.js +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { Box, useColorMode } from '@chakra-ui/core'; - -export const CodeBlock = ({ children }) => { - const { colorMode } = useColorMode(); - const bg = { dark: 'gray.800', light: 'blackAlpha.100' }; - const color = { dark: 'white', light: 'black' }; - return ( - - {children} - - ); -}; diff --git a/hyperglass/ui/components/ColorModeToggle.js b/hyperglass/ui/components/ColorModeToggle.js deleted file mode 100644 index e0f377d..0000000 --- a/hyperglass/ui/components/ColorModeToggle.js +++ /dev/null @@ -1,73 +0,0 @@ -import * as React from 'react'; -import { forwardRef } from 'react'; -import { Button, useColorMode } from '@chakra-ui/core'; - -const Sun = ({ color, size = '1.5rem', ...props }) => ( - - - -); - -const Moon = ({ color, size = '1.5rem', ...props }) => ( - - - -); - -const iconMap = { dark: Moon, light: Sun }; -const outlineColor = { dark: 'primary.300', light: 'primary.600' }; - -export const ColorModeToggle = forwardRef((props, ref) => { - const { colorMode, toggleColorMode } = useColorMode(); - const Icon = iconMap[colorMode]; - - const label = `Switch to ${colorMode === 'light' ? 'dark' : 'light'} mode`; - - return ( - - ); -}); 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/CopyButton.js b/hyperglass/ui/components/CopyButton.js deleted file mode 100644 index acc7466..0000000 --- a/hyperglass/ui/components/CopyButton.js +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from 'react'; -import { Button, Icon, Tooltip, useClipboard } from '@chakra-ui/core'; - -export const CopyButton = ({ bg = 'secondary', copyValue, ...props }) => { - const { onCopy, hasCopied } = useClipboard(copyValue); - return ( - - - - ); -}; diff --git a/hyperglass/ui/components/Debugger.js b/hyperglass/ui/components/Debugger.js deleted file mode 100644 index db048ba..0000000 --- a/hyperglass/ui/components/Debugger.js +++ /dev/null @@ -1,92 +0,0 @@ -import * as React from 'react'; -import { - Button, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - Stack, - Tag, - useDisclosure, - useColorMode, - useTheme, -} from '@chakra-ui/core'; -import { useConfig, useMedia } from 'app/context'; -import { CodeBlock } from 'app/components'; - -const prettyMediaSize = { - sm: 'SMALL', - md: 'MEDIUM', - lg: 'LARGE', - xl: 'X-LARGE', -}; - -export const Debugger = () => { - const { isOpen: configOpen, onOpen: onConfigOpen, onClose: configClose } = useDisclosure(); - const { isOpen: themeOpen, onOpen: onThemeOpen, onClose: themeClose } = useDisclosure(); - const config = useConfig(); - const theme = useTheme(); - const bg = { light: 'white', dark: 'black' }; - const color = { light: 'black', dark: 'white' }; - const { colorMode } = useColorMode(); - const { mediaSize } = useMedia(); - const borderColor = { light: 'gray.100', dark: 'gray.600' }; - return ( - <> - - {colorMode.toUpperCase()} - {prettyMediaSize[mediaSize]} - - - - - - - Loaded Configuration - - - {JSON.stringify(config, null, 4)} - - - - - - - Loaded Theme - - - {JSON.stringify(theme, null, 4)} - - - - - ); -}; diff --git a/hyperglass/ui/components/Footer/Footer.js b/hyperglass/ui/components/Footer/Footer.js deleted file mode 100644 index 0d43f53..0000000 --- a/hyperglass/ui/components/Footer/Footer.js +++ /dev/null @@ -1,132 +0,0 @@ -import * as React from 'react'; -import { useState } from 'react'; -import { Flex, useColorMode } from '@chakra-ui/core'; -import { FiCode } from '@meronex/icons/fi'; -import { GoLinkExternal } from '@meronex/icons/go'; -import format from 'string-format'; -import { useConfig } from 'app/context'; -import { FooterButton } from './FooterButton'; -import { FooterContent } from './FooterContent'; - -format.extend(String.prototype, {}); - -const footerBg = { light: 'blackAlpha.50', dark: 'whiteAlpha.100' }; -const footerColor = { light: 'black', dark: 'white' }; -const contentBorder = { light: 'blackAlpha.100', dark: 'whiteAlpha.200' }; - -export const Footer = () => { - const config = useConfig(); - const { colorMode } = useColorMode(); - const [helpVisible, showHelp] = useState(false); - const [termsVisible, showTerms] = useState(false); - const [creditVisible, showCredit] = useState(false); - const handleCollapse = i => { - if (i === 'help') { - showTerms(false); - showCredit(false); - showHelp(!helpVisible); - } else if (i === 'credit') { - showTerms(false); - showHelp(false); - showCredit(!creditVisible); - } else if (i === 'terms') { - showHelp(false); - showCredit(false); - showTerms(!termsVisible); - } - }; - const extUrl = config.web.external_link.url.includes('{primary_asn}') - ? config.web.external_link.url.format({ primary_asn: config.primary_asn }) - : config.web.external_link.url || '/'; - return ( - <> - {config.web.help_menu.enable && ( - - )} - {config.web.terms.enable && ( - - )} - {config.web.credit.enable && ( - - )} - - {config.web.terms.enable && ( - handleCollapse('terms')} - aria-label={config.web.terms.title}> - {config.web.terms.title} - - )} - {config.web.help_menu.enable && ( - handleCollapse('help')} - aria-label={config.web.help_menu.title}> - {config.web.help_menu.title} - - )} - - {config.web.credit.enable && ( - handleCollapse('credit')} - aria-label="Powered by hyperglass"> - - - )} - {config.web.external_link.enable && ( - - {config.web.external_link.title} - - )} - - - ); -}; diff --git a/hyperglass/ui/components/Footer/FooterButton.js b/hyperglass/ui/components/Footer/FooterButton.js deleted file mode 100644 index 68645ab..0000000 --- a/hyperglass/ui/components/Footer/FooterButton.js +++ /dev/null @@ -1,26 +0,0 @@ -import * as React from 'react'; -import { Button, Flex } from '@chakra-ui/core'; -import { motion } from 'framer-motion'; - -const AnimatedFlex = motion.custom(Flex); - -export const FooterButton = React.forwardRef(({ onClick, side, children, ...props }, ref) => { - return ( - - - - ); -}); diff --git a/hyperglass/ui/components/Footer/FooterContent.js b/hyperglass/ui/components/Footer/FooterContent.js deleted file mode 100644 index c460995..0000000 --- a/hyperglass/ui/components/Footer/FooterContent.js +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from 'react'; -import { forwardRef } from 'react'; -import { Box, Collapse } from '@chakra-ui/core'; -import { Markdown } from 'app/components/Markdown'; - -export const FooterContent = forwardRef( - ({ isOpen = false, content, side = 'left', title, ...props }, ref) => { - return ( - - - - - - ); - }, -); diff --git a/hyperglass/ui/components/Footer/button.tsx b/hyperglass/ui/components/Footer/button.tsx new file mode 100644 index 0000000..fecd07d --- /dev/null +++ b/hyperglass/ui/components/Footer/button.tsx @@ -0,0 +1,37 @@ +import { Button, Menu, MenuButton, MenuList } from '@chakra-ui/react'; +import { Markdown } from '~/components'; +import { useColorValue, useBreakpointValue } from '~/context'; +import { useOpposingColor } from '~/hooks'; + +import type { TFooterButton } from './types'; + +export const FooterButton = (props: TFooterButton) => { + const { content, title, side, ...rest } = props; + const placement = side === 'left' ? 'top' : side === 'right' ? 'top-start' : undefined; + const bg = useColorValue('white', 'gray.900'); + const color = useOpposingColor(bg); + const size = useBreakpointValue({ base: 'xs', lg: 'sm' }); + return ( + + + {title} + + + + + + ); +}; diff --git a/hyperglass/ui/components/Footer/colorMode.tsx b/hyperglass/ui/components/Footer/colorMode.tsx new file mode 100644 index 0000000..3626ee0 --- /dev/null +++ b/hyperglass/ui/components/Footer/colorMode.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from 'react'; +import dynamic from 'next/dynamic'; +import { Button, Icon, Tooltip } from '@chakra-ui/react'; +import { If } from '~/components'; +import { useColorMode, useColorValue, useBreakpointValue } from '~/context'; +import { useOpposingColor } from '~/hooks'; + +import type { TColorModeToggle } from './types'; + +const Sun = dynamic(() => import('@meronex/icons/hi').then(i => i.HiSun)); +const Moon = dynamic(() => import('@meronex/icons/hi').then(i => i.HiMoon)); + +export const ColorModeToggle = forwardRef((props, ref) => { + const { size = '1.5rem', ...rest } = props; + const { colorMode, toggleColorMode } = useColorMode(); + + const bg = useColorValue('primary.500', 'yellow.300'); + const color = useOpposingColor(bg); + const label = useColorValue('Switch to dark mode', 'Switch to light mode'); + const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }); + + return ( + + + + ); +}); diff --git a/hyperglass/ui/components/Footer/footer.tsx b/hyperglass/ui/components/Footer/footer.tsx new file mode 100644 index 0000000..950af99 --- /dev/null +++ b/hyperglass/ui/components/Footer/footer.tsx @@ -0,0 +1,65 @@ +import dynamic from 'next/dynamic'; +import { Button, Flex, Link, Icon, HStack, useToken } from '@chakra-ui/react'; +import { If } from '~/components'; +import { useConfig, useMobile, useColorValue, useBreakpointValue } from '~/context'; +import { useStrf } from '~/hooks'; +import { FooterButton } from './button'; +import { ColorModeToggle } from './colorMode'; + +const CodeIcon = dynamic(() => import('@meronex/icons/fi').then(i => i.FiCode)); +const ExtIcon = dynamic(() => import('@meronex/icons/go').then(i => i.GoLinkExternal)); + +export const Footer = () => { + const { web, content, primary_asn } = useConfig(); + + const footerBg = useColorValue('blackAlpha.50', 'whiteAlpha.100'); + const footerColor = useColorValue('black', 'white'); + + const extUrl = useStrf(web.external_link.url, { primary_asn }) ?? '/'; + + const size = useBreakpointValue({ base: useToken('sizes', 4), lg: useToken('sizes', 6) }); + const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }); + + const isMobile = useMobile(); + + return ( + + + + + + + + + + + {!isMobile && } + + } + /> + + + + ); +}; diff --git a/hyperglass/ui/components/Footer/index.mjs b/hyperglass/ui/components/Footer/index.mjs deleted file mode 100644 index ddcc5a9..0000000 --- a/hyperglass/ui/components/Footer/index.mjs +++ /dev/null @@ -1 +0,0 @@ -export * from './Footer'; diff --git a/hyperglass/ui/components/Footer/index.ts b/hyperglass/ui/components/Footer/index.ts new file mode 100644 index 0000000..a058eae --- /dev/null +++ b/hyperglass/ui/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './footer'; diff --git a/hyperglass/ui/components/Footer/types.ts b/hyperglass/ui/components/Footer/types.ts new file mode 100644 index 0000000..ddd71bd --- /dev/null +++ b/hyperglass/ui/components/Footer/types.ts @@ -0,0 +1,15 @@ +import type { ButtonProps, MenuListProps } from '@chakra-ui/react'; + +type TFooterSide = 'left' | 'right'; + +export interface TFooterButton extends Omit { + side: TFooterSide; + title?: MenuListProps['children']; + content: string; +} + +export type TFooterItems = 'help' | 'credit' | 'terms'; + +export interface TColorModeToggle extends ButtonProps { + size?: string; +} diff --git a/hyperglass/ui/components/FormField.js b/hyperglass/ui/components/FormField.js deleted file mode 100644 index 7d1f4ad..0000000 --- a/hyperglass/ui/components/FormField.js +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from 'react'; -import { Flex, FormControl, FormLabel, FormErrorMessage, useColorMode } from '@chakra-ui/core'; - -const labelColor = { dark: 'whiteAlpha.700', light: 'blackAlpha.700' }; - -export const FormField = ({ - label, - name, - error, - hiddenLabels, - helpIcon, - targetInfo, - setTarget, - labelAddOn, - fieldAddOn, - children, - ...props -}) => { - const { colorMode } = useColorMode(); - - return ( - - - {label} - {labelAddOn || null} - - {children} - {fieldAddOn && ( - - {fieldAddOn} - - )} - - {error && error.message} - - - ); -}; diff --git a/hyperglass/ui/components/Greeting.js b/hyperglass/ui/components/Greeting.js deleted file mode 100644 index fd79296..0000000 --- a/hyperglass/ui/components/Greeting.js +++ /dev/null @@ -1,67 +0,0 @@ -import * as React from 'react'; -import { - Button, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalFooter, - ModalBody, - ModalCloseButton, - useColorMode, - useDisclosure, -} from '@chakra-ui/core'; -import { Markdown } from 'app/components'; -import { motion } from 'framer-motion'; - -const bg = { light: 'white', dark: 'black' }; -const color = { light: 'black', dark: 'white' }; - -const AnimatedModalContent = motion.custom(ModalContent); -const AnimatedModalOverlay = motion.custom(ModalOverlay); - -export const Greeting = ({ greetingConfig, content, onClickThrough }) => { - const { isOpen, onOpen, onClose } = useDisclosure(true); - const { colorMode } = useColorMode(); - - const handleClick = () => { - onClickThrough(true); - onClose(); - }; - - return ( - - - - {greetingConfig.title} - {!greetingConfig.required && } - - - - - - - - - ); -}; diff --git a/hyperglass/ui/components/Header.js b/hyperglass/ui/components/Header.js deleted file mode 100644 index 8b17353..0000000 --- a/hyperglass/ui/components/Header.js +++ /dev/null @@ -1,149 +0,0 @@ -import * as React from 'react'; -import { Flex, useColorMode } from '@chakra-ui/core'; -import { motion, AnimatePresence } from 'framer-motion'; -import { useConfig, useHyperglassState, useMedia } from 'app/context'; -import { Title, ResetButton, ColorModeToggle } from 'app/components'; - -const titleVariants = { - sm: { - fullSize: { scale: 1, marginLeft: 0 }, - smallLogo: { marginLeft: 'auto' }, - smallText: { marginLeft: 'auto' }, - }, - md: { - fullSize: { scale: 1 }, - smallLogo: { scale: 0.5 }, - smallText: { scale: 0.8 }, - }, - lg: { - fullSize: { scale: 1 }, - smallLogo: { scale: 0.5 }, - smallText: { scale: 0.8 }, - }, - xl: { - fullSize: { scale: 1 }, - smallLogo: { scale: 0.5 }, - smallText: { scale: 0.8 }, - }, -}; - -const bg = { light: 'white', dark: 'black' }; -const headerTransition = { - type: 'spring', - ease: 'anticipate', - damping: 15, - stiffness: 100, -}; -const titleJustify = { - true: ['flex-end', 'flex-end', 'center', 'center'], - false: ['flex-start', 'flex-start', 'center', 'center'], -}; -const titleHeight = { - true: null, - false: [null, '20vh', '20vh', '20vh'], -}; -const resetButtonMl = { true: [null, 2, 2, 2], false: null }; - -const widthMap = { - text_only: '100%', - logo_only: ['90%', '90%', '50%', '50%'], - logo_subtitle: ['90%', '90%', '50%', '50%'], - all: ['90%', '90%', '50%', '50%'], -}; - -export const Header = ({ layoutRef, ...props }) => { - const AnimatedFlex = motion.custom(Flex); - const AnimatedResetButton = motion.custom(ResetButton); - const { colorMode } = useColorMode(); - const { web } = useConfig(); - const { mediaSize } = useMedia(); - const { isSubmitting, resetForm } = useHyperglassState(); - const handleFormReset = () => { - resetForm(layoutRef); - }; - const resetButton = ( - - - - - - ); - const title = ( - - - </AnimatedFlex> - ); - const colorModeToggle = ( - <AnimatedFlex - layoutTransition={headerTransition} - key="colorModeToggle" - alignItems="center" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - mb={[null, 'auto']} - mr={isSubmitting ? null : 2}> - <ColorModeToggle /> - </AnimatedFlex> - ); - const layout = { - false: { - sm: [title, resetButton, colorModeToggle], - md: [resetButton, title, colorModeToggle], - lg: [resetButton, title, colorModeToggle], - xl: [resetButton, title, colorModeToggle], - }, - true: { - sm: [resetButton, colorModeToggle, title], - md: [resetButton, title, colorModeToggle], - lg: [resetButton, title, colorModeToggle], - xl: [resetButton, title, colorModeToggle], - }, - }; - return ( - <Flex - px={2} - zIndex="4" - as="header" - width="full" - flex="0 1 auto" - bg={bg[colorMode]} - color="gray.500" - {...props}> - <Flex - w="100%" - mx="auto" - pt={6} - justify="space-between" - flex="1 0 auto" - alignItems={isSubmitting ? 'center' : 'flex-start'}> - {layout[isSubmitting][mediaSize]} - </Flex> - </Flex> - ); -}; diff --git a/hyperglass/ui/components/HelpModal.js b/hyperglass/ui/components/HelpModal.js deleted file mode 100644 index 9cc6fcf..0000000 --- a/hyperglass/ui/components/HelpModal.js +++ /dev/null @@ -1,66 +0,0 @@ -import * as React from 'react'; -import { - IconButton, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - useDisclosure, - useColorMode, - useTheme, -} from '@chakra-ui/core'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Markdown } from 'app/components'; - -const AnimatedIcon = motion.custom(IconButton); - -export const HelpModal = ({ item, name }) => { - const { isOpen, onOpen, onClose } = useDisclosure(); - const { colors } = useTheme(); - const { colorMode } = useColorMode(); - const bg = { light: 'whiteFaded.50', dark: 'blackFaded.800' }; - const color = { light: 'black', dark: 'white' }; - const iconColor = { - light: colors.primary[500], - dark: colors.primary[300], - }; - return ( - <> - <AnimatePresence> - <AnimatedIcon - initial={{ opacity: 0, scale: 0.3, color: colors.gray[500] }} - animate={{ opacity: 1, scale: 1, color: iconColor[colorMode] }} - transition={{ duration: 0.2 }} - exit={{ opacity: 0, scale: 0.3 }} - variantColor="primary" - aria-label={`${name}_help`} - icon="info-outline" - variant="link" - size="sm" - h="unset" - w={3} - minW={3} - maxW={3} - h={3} - minH={3} - maxH={3} - ml={1} - mb={1} - onClick={onOpen} - /> - </AnimatePresence> - <Modal isOpen={isOpen} onClose={onClose} size="xl"> - <ModalOverlay /> - <ModalContent bg={bg[colorMode]} color={color[colorMode]} py={4} borderRadius="md"> - <ModalHeader>{item.params.title}</ModalHeader> - <ModalCloseButton /> - <ModalBody> - <Markdown content={item.content} /> - </ModalBody> - </ModalContent> - </Modal> - </> - ); -}; diff --git a/hyperglass/ui/components/HyperglassForm.js b/hyperglass/ui/components/HyperglassForm.js deleted file mode 100644 index 7f568f0..0000000 --- a/hyperglass/ui/components/HyperglassForm.js +++ /dev/null @@ -1,251 +0,0 @@ -import * as React from 'react'; -import { forwardRef, useState, useEffect } from 'react'; -import { Box, Flex } from '@chakra-ui/core'; -import { useForm } from 'react-hook-form'; -import { intersectionWith, isEqual } from 'lodash'; -import * as yup from 'yup'; -import format from 'string-format'; -import { - FormField, - HelpModal, - QueryLocation, - QueryType, - QueryTarget, - CommunitySelect, - QueryVrf, - ResolvedTarget, - SubmitButton, -} from 'app/components'; -import { useConfig } from 'app/context'; - -format.extend(String.prototype, {}); - -const formSchema = config => - yup.object().shape({ - query_location: yup - .array() - .of(yup.string()) - .required( - config.messages.no_input.format({ - field: config.web.text.query_location, - }), - ), - query_type: yup - .string() - .required(config.messages.no_input.format({ field: config.web.text.query_type })), - query_vrf: yup.string(), - query_target: yup - .string() - .required(config.messages.no_input.format({ field: config.web.text.query_target })), - }); - -const FormRow = ({ children, ...props }) => ( - <Flex - flexDirection="row" - flexWrap="wrap" - w="100%" - justifyContent={['center', 'center', 'space-between', 'space-between']} - {...props}> - {children} - </Flex> -); - -export const HyperglassForm = forwardRef( - ({ isSubmitting, setSubmitting, setFormData, greetingAck, setGreetingAck, ...props }, ref) => { - const config = useConfig(); - const { handleSubmit, register, unregister, setValue, errors } = useForm({ - validationSchema: formSchema(config), - 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([]); - const onSubmit = values => { - if (!greetingAck && config.web.greeting.required) { - window.location.reload(false); - setGreetingAck(false); - } else { - setFormData(values); - setSubmitting(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); - }); - - const intersecting = intersectionWith(...allVrfs, isEqual); - setAvailVrfs(intersecting); - !intersecting.includes(queryVrf) && queryVrf !== 'default' && setQueryVrf('default'); - - let ipv4 = 0; - let ipv6 = 0; - 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++; - }); - if (ipv4 !== 0 && ipv4 === ipv6) { - setFamilies([4, 6]); - } else if (ipv4 > ipv6) { - setFamilies([4]); - } else if (ipv4 < ipv6) { - setFamilies([6]); - } else { - setFamilies([]); - } - }; - - const handleChange = e => { - setValue(e.field, e.value); - e.field === 'query_location' - ? handleLocChange(e) - : e.field === 'query_type' - ? setQueryType(e.value) - : e.field === 'query_vrf' - ? setQueryVrf(e.value) - : e.field === 'query_target' - ? setQueryTarget(e.value) - : null; - }; - - const vrfContent = config.content.vrf[queryVrf]?.[queryType]; - - const validFqdnQueryType = - ['ping', 'traceroute', 'bgp_route'].includes(queryType) && - fqdnTarget && - queryVrf === 'default' - ? fqdnTarget - : null; - - useEffect(() => { - register({ name: 'query_location' }); - register({ name: 'query_type' }); - register({ name: 'query_vrf' }); - }, [register]); - Object.keys(errors).length >= 1 && console.error(errors); - return ( - <Box - as="form" - onSubmit={handleSubmit(onSubmit)} - maxW={['100%', '100%', '75%', '75%']} - w="100%" - p={0} - mx="auto" - my={4} - textAlign="left" - ref={ref} - {...props}> - <FormRow> - <FormField - label={config.web.text.query_location} - name="query_location" - error={errors.query_location}> - <QueryLocation - onChange={handleChange} - locations={config.networks} - label={config.web.text.query_location} - /> - </FormField> - <FormField - label={config.web.text.query_type} - name="query_type" - error={errors.query_type} - labelAddOn={vrfContent && <HelpModal item={vrfContent} name="query_type" />}> - <QueryType - onChange={handleChange} - queryTypes={config.queries.list} - label={config.web.text.query_type} - /> - </FormField> - </FormRow> - <FormRow> - {availVrfs.length > 1 && ( - <FormField label={config.web.text.query_vrf} name="query_vrf" error={errors.query_vrf}> - <QueryVrf - label={config.web.text.query_vrf} - vrfs={availVrfs} - onChange={handleChange} - /> - </FormField> - )} - <FormField - label={config.web.text.query_target} - name="query_target" - error={errors.query_target} - fieldAddOn={ - queryLocation.length !== 0 && - validFqdnQueryType && ( - <ResolvedTarget - queryTarget={queryTarget} - fqdnTarget={validFqdnQueryType} - setTarget={handleChange} - families={families} - availVrfs={availVrfs} - /> - ) - }> - {queryType === 'bgp_community' && config.queries.bgp_community.mode === 'select' ? ( - <CommunitySelect - label={config.queries.bgp_community.display_name} - name="query_target" - register={register} - unregister={unregister} - onChange={handleChange} - communities={config.queries.bgp_community.communities} - /> - ) : ( - <QueryTarget - name="query_target" - placeholder={config.web.text.query_target} - register={register} - unregister={unregister} - resolveTarget={['ping', 'traceroute', 'bgp_route'].includes(queryType)} - value={queryTarget} - setFqdn={setFqdnTarget} - setTarget={handleChange} - displayValue={displayTarget} - setDisplayValue={setDisplayTarget} - /> - )} - </FormField> - </FormRow> - <FormRow mt={0} justifyContent="flex-end"> - <Flex - w="100%" - maxW="100%" - ml="auto" - my={2} - mr={[0, 0, 2, 2]} - flexDirection="column" - flex="0 0 0"> - <SubmitButton isLoading={isSubmitting} /> - </Flex> - </FormRow> - </Box> - ); - }, -); diff --git a/hyperglass/ui/components/Label.js b/hyperglass/ui/components/Label.js deleted file mode 100644 index 73627ff..0000000 --- a/hyperglass/ui/components/Label.js +++ /dev/null @@ -1,57 +0,0 @@ -import * as React from 'react'; -import { forwardRef } from 'react'; -import { Flex, useColorMode } from '@chakra-ui/core'; - -export const Label = forwardRef( - ({ value, label, labelColor, valueBg, valueColor, ...props }, ref) => { - const { colorMode } = useColorMode(); - const _labelColor = { dark: 'whiteAlpha.700', light: 'blackAlpha.700' }; - return ( - <Flex - ref={ref} - flexWrap="nowrap" - alignItems="center" - justifyContent="flex-start" - mx={[1, 2, 2, 2]} - my={2} - {...props}> - <Flex - display="inline-flex" - justifyContent="center" - lineHeight="1.5" - px={[1, 3, 3, 3]} - whiteSpace="nowrap" - mb={2} - mr={0} - bg={valueBg || 'primary.600'} - color={valueColor || 'white'} - borderBottomLeftRadius={4} - borderTopLeftRadius={4} - borderBottomRightRadius={0} - borderTopRightRadius={0} - fontWeight="bold" - fontSize={['xs', 'sm', 'sm', 'sm']}> - {value} - </Flex> - <Flex - display="inline-flex" - justifyContent="center" - lineHeight="1.5" - px={3} - whiteSpace="nowrap" - mb={2} - ml={0} - mr={0} - boxShadow={`inset 0px 0px 0px 1px ${valueBg || 'primary.600'}`} - color={labelColor || _labelColor[colorMode]} - borderBottomRightRadius={4} - borderTopRightRadius={4} - borderBottomLeftRadius={0} - borderTopLeftRadius={0} - fontSize={['xs', 'sm', 'sm', 'sm']}> - {label} - </Flex> - </Flex> - ); - }, -); diff --git a/hyperglass/ui/components/Layout.js b/hyperglass/ui/components/Layout.js deleted file mode 100644 index 91ec11f..0000000 --- a/hyperglass/ui/components/Layout.js +++ /dev/null @@ -1,52 +0,0 @@ -import * as React from 'react'; -import { useRef } from 'react'; -import { Flex, useColorMode } from '@chakra-ui/core'; -import { useConfig, useHyperglassState } from 'app/context'; -import { Debugger, Greeting, Footer, Header } from 'app/components'; - -const bg = { light: 'white', dark: 'black' }; -const color = { light: 'black', dark: 'white' }; - -export const Layout = ({ children }) => { - const config = useConfig(); - const { colorMode } = useColorMode(); - const { greetingAck, setGreetingAck } = useHyperglassState(); - const containerRef = useRef(null); - - return ( - <> - <Flex - w="100%" - ref={containerRef} - minHeight="100vh" - bg={bg[colorMode]} - flexDirection="column" - color={color[colorMode]}> - <Flex px={2} flex="0 1 auto" flexDirection="column"> - <Header layoutRef={containerRef} /> - </Flex> - <Flex - px={2} - py={0} - w="100%" - as="main" - flex="1 1 auto" - textAlign="center" - alignItems="center" - justifyContent="start" - flexDirection="column"> - {children} - </Flex> - <Footer /> - {config.developer_mode && <Debugger />} - </Flex> - {config.web.greeting.enable && !greetingAck && ( - <Greeting - greetingConfig={config.web.greeting} - content={config.content.greeting} - onClickThrough={setGreetingAck} - /> - )} - </> - ); -}; diff --git a/hyperglass/ui/components/Loading.js b/hyperglass/ui/components/Loading.js deleted file mode 100644 index c1ad0a6..0000000 --- a/hyperglass/ui/components/Loading.js +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from 'react'; -import { Flex, Spinner, useColorMode } from '@chakra-ui/core'; - -export const Loading = () => { - const { colorMode } = useColorMode(); - const bg = { light: 'white', dark: 'black' }; - const color = { light: 'black', dark: 'white' }; - return ( - <Flex - flexDirection="column" - minHeight="100vh" - w="100%" - bg={bg[colorMode]} - color={color[colorMode]}> - <Flex - as="main" - w="100%" - flexGrow={1} - flexShrink={1} - flexBasis="auto" - alignItems="center" - justifyContent="start" - textAlign="center" - flexDirection="column" - px={2} - py={0} - mt={['50%', '50%', '50%', '25%']}> - <Spinner color="primary.500" w="6rem" h="6rem" /> - </Flex> - </Flex> - ); -}; diff --git a/hyperglass/ui/components/LookingGlass.js b/hyperglass/ui/components/LookingGlass.js deleted file mode 100644 index 7168fe4..0000000 --- a/hyperglass/ui/components/LookingGlass.js +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Layout, HyperglassForm, Results } from 'app/components'; -import { useHyperglassState } from 'app/context'; - -const AnimatedForm = motion.custom(HyperglassForm); - -export const LookingGlass = () => { - const { - isSubmitting, - setSubmitting, - formData, - setFormData, - greetingAck, - setGreetingAck, - } = useHyperglassState(); - - return ( - <Layout> - {isSubmitting && formData && ( - <Results - queryLocation={formData.query_location} - queryType={formData.query_type} - queryVrf={formData.query_vrf} - queryTarget={formData.query_target} - setSubmitting={setSubmitting} - /> - )} - <AnimatePresence> - {!isSubmitting && ( - <AnimatedForm - initial={{ opacity: 0, y: 300 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.3 }} - exit={{ opacity: 0, x: -300 }} - isSubmitting={isSubmitting} - setSubmitting={setSubmitting} - setFormData={setFormData} - greetingAck={greetingAck} - setGreetingAck={setGreetingAck} - /> - )} - </AnimatePresence> - </Layout> - ); -}; diff --git a/hyperglass/ui/components/Markdown/MDComponents.js b/hyperglass/ui/components/Markdown/MDComponents.js deleted file mode 100644 index 183d008..0000000 --- a/hyperglass/ui/components/Markdown/MDComponents.js +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from 'react'; -import { - Checkbox as ChakraCheckbox, - Divider as ChakraDivider, - Code as ChakraCode, - Heading as ChakraHeading, - Link as ChakraLink, - List as ChakraList, - ListItem as ChakraListItem, - Text as ChakraText, -} from '@chakra-ui/core'; - -import { TableCell, TableHeader, Table as ChakraTable } from './MDTable'; - -import { CodeBlock as CustomCodeBlock } from 'app/components'; - -export const Checkbox = ({ checked, children }) => ( - <ChakraCheckbox isChecked={checked}>{children}</ChakraCheckbox> -); - -export const List = ({ ordered, children }) => ( - <ChakraList as={ordered ? 'ol' : 'ul'}>{children}</ChakraList> -); - -export const ListItem = ({ checked, children }) => - checked ? ( - <Checkbox checked={checked}>{children}</Checkbox> - ) : ( - <ChakraListItem>{children}</ChakraListItem> - ); - -export const Heading = ({ level, children }) => { - const levelMap = { - 1: { as: 'h1', size: 'lg', fontWeight: 'bold' }, - 2: { as: 'h2', size: 'lg', fontWeight: 'normal' }, - 3: { as: 'h3', size: 'lg', fontWeight: 'bold' }, - 4: { as: 'h4', size: 'md', fontWeight: 'normal' }, - 5: { as: 'h5', size: 'md', fontWeight: 'bold' }, - 6: { as: 'h6', size: 'sm', fontWeight: 'bold' }, - }; - return <ChakraHeading {...levelMap[level]}>{children}</ChakraHeading>; -}; - -export const Link = ({ children, ...props }) => ( - <ChakraLink isExternal {...props}> - {children} - </ChakraLink> -); - -export const CodeBlock = ({ value }) => <CustomCodeBlock>{value}</CustomCodeBlock>; - -export const TableData = ({ isHeader, children, ...props }) => { - const Component = isHeader ? TableHeader : TableCell; - return <Component {...props}>{children}</Component>; -}; - -export const Paragraph = props => <ChakraText {...props} />; -export const InlineCode = props => <ChakraCode {...props} />; -export const Divider = props => <ChakraDivider {...props} />; -export const Table = props => <ChakraTable {...props} />; diff --git a/hyperglass/ui/components/Markdown/MDTable.js b/hyperglass/ui/components/Markdown/MDTable.js deleted file mode 100644 index 84749a0..0000000 --- a/hyperglass/ui/components/Markdown/MDTable.js +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { Box, useColorMode } from '@chakra-ui/core'; - -export const Table = props => <Box as="table" textAlign="left" mt={4} width="full" {...props} />; - -const bg = { light: 'blackAlpha.50', dark: 'whiteAlpha.50' }; - -export const TableHeader = props => { - const { colorMode } = useColorMode(); - - return <Box as="th" bg={bg[colorMode]} fontWeight="semibold" p={2} fontSize="sm" {...props} />; -}; - -export const TableCell = ({ isHeader = false, ...props }) => ( - <Box - as={isHeader ? 'th' : 'td'} - p={2} - borderTopWidth="1px" - borderColor="inherit" - fontSize="sm" - whiteSpace="normal" - {...props} - /> -); diff --git a/hyperglass/ui/components/Markdown/Markdown.js b/hyperglass/ui/components/Markdown/Markdown.tsx similarity index 55% rename from hyperglass/ui/components/Markdown/Markdown.js rename to hyperglass/ui/components/Markdown/Markdown.tsx index 63c49a9..c368d32 100644 --- a/hyperglass/ui/components/Markdown/Markdown.js +++ b/hyperglass/ui/components/Markdown/Markdown.tsx @@ -1,5 +1,3 @@ -import * as React from 'react'; -import { forwardRef } from 'react'; import ReactMarkdown from 'react-markdown'; import { List, @@ -12,9 +10,12 @@ import { InlineCode, Divider, Table, -} from './MDComponents'; +} from './elements'; -const mdComponents = { +import type { ReactMarkdownProps } from 'react-markdown'; +import type { TMarkdown } from './types'; + +const renderers = { paragraph: Paragraph, link: Link, heading: Heading, @@ -25,8 +26,8 @@ const mdComponents = { code: CodeBlock, table: Table, tableCell: TableData, -}; +} as ReactMarkdownProps['renderers']; -export const Markdown = forwardRef(({ content }, ref) => ( - <ReactMarkdown ref={ref} renderers={mdComponents} source={content} /> -)); +export const Markdown = (props: TMarkdown) => ( + <ReactMarkdown renderers={renderers} source={props.content} /> +); diff --git a/hyperglass/ui/components/Markdown/elements.tsx b/hyperglass/ui/components/Markdown/elements.tsx new file mode 100644 index 0000000..37f7e57 --- /dev/null +++ b/hyperglass/ui/components/Markdown/elements.tsx @@ -0,0 +1,87 @@ +import { + OrderedList, + UnorderedList, + Code as ChakraCode, + Link as ChakraLink, + Text as ChakraText, + Divider as ChakraDivider, + Heading as ChakraHeading, + Checkbox as ChakraCheckbox, + ListItem as ChakraListItem, +} from '@chakra-ui/react'; + +import { TD, TH, Table as ChakraTable } from './table'; + +import { CodeBlock as CustomCodeBlock, If } from '~/components'; + +import type { + BoxProps, + TextProps, + CodeProps, + LinkProps, + HeadingProps, + DividerProps, +} from '@chakra-ui/react'; +import type { TCheckbox, TList, THeading, TCodeBlock, TTableData, TListItem } from './types'; + +export const Checkbox = (props: TCheckbox) => { + const { checked, ...rest } = props; + return <ChakraCheckbox isChecked={checked} {...rest} />; +}; + +export const List = (props: TList) => { + const { ordered, ...rest } = props; + return ( + <> + <If c={ordered}> + <OrderedList {...rest} /> + </If> + <If c={!ordered}> + <UnorderedList {...rest} /> + </If> + </> + ); +}; + +export const ListItem = (props: TListItem) => { + const { checked, ...rest } = props; + return checked ? <Checkbox checked={checked} {...rest} /> : <ChakraListItem {...rest} />; +}; + +export const Heading = (props: THeading) => { + const { level, ...rest } = props; + + const levelMap = { + 1: { as: 'h1', size: 'lg', fontWeight: 'bold' }, + 2: { as: 'h2', size: 'lg', fontWeight: 'normal' }, + 3: { as: 'h3', size: 'lg', fontWeight: 'bold' }, + 4: { as: 'h4', size: 'md', fontWeight: 'normal' }, + 5: { as: 'h5', size: 'md', fontWeight: 'bold' }, + 6: { as: 'h6', size: 'sm', fontWeight: 'bold' }, + } as { [i: number]: HeadingProps }; + + return <ChakraHeading {...levelMap[level]} {...rest} />; +}; + +export const Link = (props: LinkProps) => <ChakraLink isExternal {...props} />; + +export const CodeBlock = (props: TCodeBlock) => <CustomCodeBlock>{props.value}</CustomCodeBlock>; + +export const TableData = (props: TTableData) => { + const { isHeader, ...rest } = props; + return ( + <> + <If c={isHeader}> + <TH {...rest} /> + </If> + <If c={!isHeader}> + <TD {...rest} /> + </If> + </> + ); +}; + +export const Paragraph = (props: TextProps) => <ChakraText {...props} />; +export const InlineCode = (props: CodeProps) => <ChakraCode children={props.children} />; +export const Divider = (props: DividerProps) => <ChakraDivider {...props} />; +export const Table = (props: BoxProps) => <ChakraTable {...props} />; diff --git a/hyperglass/ui/components/Markdown/index.mjs b/hyperglass/ui/components/Markdown/index.mjs deleted file mode 100644 index a5b9f6e..0000000 --- a/hyperglass/ui/components/Markdown/index.mjs +++ /dev/null @@ -1 +0,0 @@ -export * from './Markdown'; diff --git a/hyperglass/ui/components/Markdown/index.ts b/hyperglass/ui/components/Markdown/index.ts new file mode 100644 index 0000000..99334b5 --- /dev/null +++ b/hyperglass/ui/components/Markdown/index.ts @@ -0,0 +1 @@ +export * from './markdown'; diff --git a/hyperglass/ui/components/Markdown/table.tsx b/hyperglass/ui/components/Markdown/table.tsx new file mode 100644 index 0000000..d20d18c --- /dev/null +++ b/hyperglass/ui/components/Markdown/table.tsx @@ -0,0 +1,27 @@ +import { Box } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; + +import type { BoxProps } from '@chakra-ui/react'; + +export const Table = (props: BoxProps) => ( + <Box as="table" textAlign="left" mt={4} width="full" {...props} /> +); + +export const TH = (props: BoxProps) => { + const bg = useColorValue('blackAlpha.50', 'whiteAlpha.50'); + return <Box as="th" bg={bg} fontWeight="semibold" p={2} fontSize="sm" {...props} />; +}; + +export const TD = (props: BoxProps) => { + return ( + <Box + p={2} + as="td" + fontSize="sm" + whiteSpace="normal" + borderTopWidth="1px" + borderColor="inherit" + {...props} + /> + ); +}; diff --git a/hyperglass/ui/components/Markdown/types.ts b/hyperglass/ui/components/Markdown/types.ts new file mode 100644 index 0000000..f0b5b71 --- /dev/null +++ b/hyperglass/ui/components/Markdown/types.ts @@ -0,0 +1,37 @@ +import type { + BoxProps, + CheckboxProps, + HeadingProps, + ListProps, + ListItemProps, +} from '@chakra-ui/react'; + +export interface TMarkdown { + content: string; +} + +export interface TCheckbox extends CheckboxProps { + checked: boolean; +} + +export interface TListItem { + checked: boolean; + children?: React.ReactNode; +} + +export interface TList extends ListProps { + ordered: boolean; + children?: React.ReactNode; +} + +export interface THeading extends HeadingProps { + level: 1 | 2 | 3 | 4 | 5 | 6; +} + +export interface TCodeBlock { + value: React.ReactNode; +} + +export interface TTableData extends BoxProps { + isHeader: boolean; +} diff --git a/hyperglass/ui/components/Meta.js b/hyperglass/ui/components/Meta.js deleted file mode 100644 index 0e50204..0000000 --- a/hyperglass/ui/components/Meta.js +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import Head from 'next/head'; -import { useTheme } from '@chakra-ui/core'; -import { useConfig } from 'app/context'; -import { googleFontUrl } from 'app/util'; - -export const Meta = () => { - const config = useConfig(); - const theme = useTheme(); - const [location, setLocation] = useState({}); - const title = config?.site_title || 'hyperglass'; - const description = config?.site_description || 'Network Looking Glass'; - const siteName = `${title} - ${description}`; - const keywords = config?.site_keywords || [ - 'hyperglass', - 'looking glass', - 'lg', - 'peer', - 'peering', - 'ipv4', - 'ipv6', - 'transit', - 'community', - 'communities', - 'bgp', - 'routing', - 'network', - 'isp', - ]; - const language = config?.language ?? 'en'; - const primaryFont = googleFontUrl(theme.fonts.body); - const monoFont = googleFontUrl(theme.fonts.mono); - useEffect(() => { - if (typeof window !== 'undefined' && location === {}) { - setLocation(window.location); - } - }, [location]); - return ( - <Head> - <title>{title} - - - - - - - - - - - - - ); -}; diff --git a/hyperglass/ui/components/QueryLocation.js b/hyperglass/ui/components/QueryLocation.js deleted file mode 100644 index 00fffaf..0000000 --- a/hyperglass/ui/components/QueryLocation.js +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from 'react'; -import { ChakraSelect } from 'app/components'; - -const buildLocations = networks => { - const locations = []; - networks.map(net => { - const netLocations = []; - net.locations.map(loc => { - netLocations.push({ - label: loc.display_name, - value: loc.name, - group: net.display_name, - }); - }); - locations.push({ label: net.display_name, options: netLocations }); - }); - return locations; -}; - -export const QueryLocation = ({ locations, onChange, label }) => { - const options = buildLocations(locations); - const handleChange = e => { - const selected = []; - e && - e.map(sel => { - selected.push(sel.value); - }); - onChange({ field: 'query_location', value: selected }); - }; - return ( - - ); -}; 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/RequeryButton.js b/hyperglass/ui/components/RequeryButton.js deleted file mode 100644 index da0d4fb..0000000 --- a/hyperglass/ui/components/RequeryButton.js +++ /dev/null @@ -1,10 +0,0 @@ -import * as React from 'react'; -import { Button, Icon, Tooltip } from '@chakra-ui/core'; - -export const RequeryButton = ({ requery, bg = 'secondary', ...props }) => ( - - - -); diff --git a/hyperglass/ui/components/ResetButton.js b/hyperglass/ui/components/ResetButton.js deleted file mode 100644 index d862840..0000000 --- a/hyperglass/ui/components/ResetButton.js +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from 'react'; -import { Button } from '@chakra-ui/core'; -import { FiChevronLeft } from '@meronex/icons/fi'; - -export const ResetButton = React.forwardRef(({ isSubmitting, onClick }, ref) => ( - -)); 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/Result.js b/hyperglass/ui/components/Result.js deleted file mode 100644 index 3637c89..0000000 --- a/hyperglass/ui/components/Result.js +++ /dev/null @@ -1,262 +0,0 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import { forwardRef, useEffect, useState } from 'react'; -import { - AccordionItem, - AccordionHeader, - AccordionPanel, - Alert, - Box, - ButtonGroup, - css, - Flex, - Tooltip, - Text, - useColorMode, - useTheme, -} from '@chakra-ui/core'; -import { BsLightningFill } from '@meronex/icons/bs'; -import styled from '@emotion/styled'; -import useAxios from 'axios-hooks'; -import strReplace from 'react-string-replace'; -import format from 'string-format'; -import { startCase } from 'lodash'; -import { useConfig, useMedia } from 'app/context'; -import { - BGPTable, - CacheTimeout, - CopyButton, - RequeryButton, - ResultHeader, - TextOutput, -} from 'app/components'; -import { tableToString } from 'app/util'; - -format.extend(String.prototype, {}); - -const FormattedError = ({ keywords, message }) => { - const patternStr = keywords.map(kw => `(${kw})`).join('|'); - const pattern = new RegExp(patternStr, 'gi'); - let errorFmt; - try { - errorFmt = strReplace(message, pattern, match => ( - - {match} - - )); - } catch (err) { - errorFmt = {message}; - } - return {keywords.length !== 0 ? errorFmt : message}; -}; - -const AccordionHeaderWrapper = styled(Flex)` - justify-content: space-between; - &:hover { - background-color: ${props => props.hoverBg}; - } - &:focus { - box-shadow: 'outline'; - } -`; - -const statusMap = { - success: 'success', - warning: 'warning', - error: 'warning', - danger: 'error', -}; - -const color = { dark: 'white', light: 'black' }; -const scrollbar = { dark: 'whiteAlpha.300', light: 'blackAlpha.300' }; -const scrollbarHover = { dark: 'whiteAlpha.400', light: 'blackAlpha.400' }; -const scrollbarBg = { dark: 'whiteAlpha.50', light: 'blackAlpha.50' }; - -export const Result = forwardRef( - ( - { - device, - timeout, - queryLocation, - queryType, - queryVrf, - queryTarget, - index, - resultsComplete, - setComplete, - }, - ref, - ) => { - const config = useConfig(); - const theme = useTheme(); - const { isSm } = useMedia(); - const { colorMode } = useColorMode(); - let [{ data, loading, error }, refetch] = useAxios({ - url: '/api/query/', - method: 'post', - data: { - query_location: queryLocation, - query_type: queryType, - query_vrf: queryVrf, - query_target: queryTarget, - }, - timeout: timeout, - useCache: false, - }); - - const [isOpen, setOpen] = useState(false); - const [hasOverride, setOverride] = useState(false); - - const handleToggle = () => { - setOpen(!isOpen); - setOverride(true); - }; - - const errorKw = (error && error.response?.data?.keywords) || []; - - let errorMsg; - if (error && error.response?.data?.output) { - errorMsg = error.response.data.output; - } else if (error && error.message.startsWith('timeout')) { - errorMsg = config.messages.request_timeout; - } else if (error?.response?.statusText) { - errorMsg = startCase(error.response.statusText); - } else if (error && error.message) { - errorMsg = startCase(error.message); - } else { - errorMsg = config.messages.general; - } - - error && console.error(error); - - const errorLevel = - (error?.response?.data?.level && statusMap[error.response?.data?.level]) ?? 'error'; - - const structuredDataComponent = { - bgp_route: BGPTable, - bgp_aspath: BGPTable, - bgp_community: BGPTable, - ping: TextOutput, - traceroute: TextOutput, - }; - - let Output = TextOutput; - let copyValue = data?.output; - - if (data?.format === 'application/json') { - Output = structuredDataComponent[queryType]; - copyValue = tableToString(queryTarget, data, config); - } - - if (error) { - copyValue = errorMsg; - } - - useEffect(() => { - !loading && resultsComplete === null && setComplete(index); - }, [loading, resultsComplete]); - - useEffect(() => { - resultsComplete === index && !hasOverride && setOpen(true); - }, [resultsComplete, index]); - - return ( - - - - - - - - - - - - - - {!error && data && {data?.output}} - {error && ( - - - - )} - - - - - - {config.cache.show_text && data && !error && ( - <> - {!isSm && ( - - )} - - - - - - {isSm && ( - - )} - - )} - - - - - ); - }, -); diff --git a/hyperglass/ui/components/ResultHeader.js b/hyperglass/ui/components/ResultHeader.js deleted file mode 100644 index 8f9d6a3..0000000 --- a/hyperglass/ui/components/ResultHeader.js +++ /dev/null @@ -1,57 +0,0 @@ -import * as React from 'react'; -import { forwardRef } from 'react'; -import { AccordionIcon, Icon, Spinner, Stack, Text, Tooltip, useColorMode } from '@chakra-ui/core'; -import format from 'string-format'; -import { useConfig } from 'app/context'; - -format.extend(String.prototype, {}); - -const runtimeText = (runtime, text) => { - let unit; - if (runtime === 1) { - unit = 'second'; - } else { - unit = 'seconds'; - } - const fmt = text.format({ seconds: runtime }); - return `${fmt} ${unit}`; -}; - -const statusColor = { dark: 'primary.300', light: 'primary.500' }; -const warningColor = { dark: 300, light: 500 }; -const defaultStatusColor = { - dark: 'success.300', - light: 'success.500', -}; - -export const ResultHeader = forwardRef( - ({ title, loading, error, errorMsg, errorLevel, runtime }, ref) => { - const { colorMode } = useColorMode(); - const config = useConfig(); - return ( - - {loading ? ( - - ) : error ? ( - - - - ) : ( - - - - )} - {title} - - - ); - }, -); diff --git a/hyperglass/ui/components/Results.js b/hyperglass/ui/components/Results.js deleted file mode 100644 index 8586f7b..0000000 --- a/hyperglass/ui/components/Results.js +++ /dev/null @@ -1,157 +0,0 @@ -import * as React from 'react'; -import { useState } from 'react'; -import { Accordion, Box, Stack, useTheme } from '@chakra-ui/core'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Label, Result } from 'app/components'; -import { useConfig, useMedia } from 'app/context'; - -const AnimatedResult = motion.custom(Result); -const AnimatedLabel = motion.custom(Label); - -const labelInitial = { - left: { - sm: { opacity: 0, x: -100 }, - md: { opacity: 0, x: -100 }, - lg: { opacity: 0, x: -100 }, - xl: { opacity: 0, x: -100 }, - }, - center: { - sm: { opacity: 0 }, - md: { opacity: 0 }, - lg: { opacity: 0 }, - xl: { opacity: 0 }, - }, - right: { - sm: { opacity: 0, x: 100 }, - md: { opacity: 0, x: 100 }, - lg: { opacity: 0, x: 100 }, - xl: { opacity: 0, x: 100 }, - }, -}; -const labelAnimate = { - left: { - sm: { opacity: 1, x: 0 }, - md: { opacity: 1, x: 0 }, - lg: { opacity: 1, x: 0 }, - xl: { opacity: 1, x: 0 }, - }, - center: { - sm: { opacity: 1 }, - md: { opacity: 1 }, - lg: { opacity: 1 }, - xl: { opacity: 1 }, - }, - right: { - sm: { opacity: 1, x: 0 }, - md: { opacity: 1, x: 0 }, - lg: { opacity: 1, x: 0 }, - xl: { opacity: 1, x: 0 }, - }, -}; - -export const Results = ({ - queryLocation, - queryType, - queryVrf, - queryTarget, - setSubmitting, - ...props -}) => { - const config = useConfig(); - const theme = useTheme(); - const { mediaSize } = useMedia(); - const matchedVrf = - config.vrfs.filter(v => v.id === queryVrf)[0] ?? config.vrfs.filter(v => v.id === 'default')[0]; - const [resultsComplete, setComplete] = useState(null); - return ( - <> - - - - {queryLocation && ( - <> - - - - - )} - - - - - - - {queryLocation && - queryLocation.map((loc, i) => ( - - ))} - - - - - ); -}; diff --git a/hyperglass/ui/components/SubmitButton.js b/hyperglass/ui/components/SubmitButton.js deleted file mode 100644 index ccef50a..0000000 --- a/hyperglass/ui/components/SubmitButton.js +++ /dev/null @@ -1,113 +0,0 @@ -import * as React from 'react'; -import { forwardRef } from 'react'; -import { Box, PseudoBox, Spinner, useColorMode, useTheme } from '@chakra-ui/core'; -import { FiSearch } from '@meronex/icons/fi'; -import { opposingColor } from 'app/util'; - -const btnProps = { - display: 'inline-flex', - appearance: 'none', - alignItems: 'center', - justifyContent: 'center', - transition: 'all 250ms', - userSelect: 'none', - position: 'relative', - whiteSpace: 'nowrap', - verticalAlign: 'middle', - lineHeight: '1.2', - outline: 'none', - as: 'button', - type: 'submit', - borderRadius: 'md', - fontWeight: 'semibold', -}; - -const btnSizeMap = { - lg: { - height: 12, - minWidth: 12, - fontSize: 'lg', - px: 6, - }, - md: { - height: 10, - minWidth: 10, - fontSize: 'md', - px: 4, - }, - sm: { - height: 8, - minWidth: 8, - fontSize: 'sm', - px: 3, - }, - xs: { - height: 6, - minWidth: 6, - fontSize: 'xs', - px: 2, - }, -}; - -const btnBg = { dark: 'primary.300', light: 'primary.500' }; -const btnBgActive = { dark: 'primary.400', light: 'primary.600' }; -const btnBgHover = { dark: 'primary.200', light: 'primary.400' }; - -export const SubmitButton = forwardRef( - ( - { - isLoading = false, - isDisabled = false, - isActive = false, - isFullWidth = false, - size = 'lg', - loadingText, - children, - ...props - }, - ref, - ) => { - const _isDisabled = isDisabled || isLoading; - const { colorMode } = useColorMode(); - const theme = useTheme(); - const btnColor = opposingColor(theme, btnBg[colorMode]); - const btnColorActive = opposingColor(theme, btnBgActive[colorMode]); - const btnColorHover = opposingColor(theme, btnBgHover[colorMode]); - const btnSize = btnSizeMap[size]; - return ( - - {isLoading ? ( - - ) : ( - - )} - {isLoading - ? loadingText || ( - - {children} - - ) - : children} - - ); - }, -); diff --git a/hyperglass/ui/components/Table/Table.js b/hyperglass/ui/components/Table/Table.js deleted file mode 100644 index 3c5796e..0000000 --- a/hyperglass/ui/components/Table/Table.js +++ /dev/null @@ -1,189 +0,0 @@ -import * as React from 'react'; -import { useMemo } from 'react'; -import { Flex, Icon, Text } from '@chakra-ui/core'; -import { usePagination, useSortBy, useTable } from 'react-table'; -import { useMedia } from 'app/context'; -import { CardBody, CardFooter, CardHeader } from 'app/components'; -import { TableMain } from './TableMain'; -import { TableCell } from './TableCell'; -import { TableHead } from './TableHead'; -import { TableRow } from './TableRow'; -import { TableBody } from './TableBody'; -import { TableIconButton } from './TableIconButton'; -import { TableSelectShow } from './TableSelectShow'; - -export const Table = ({ - columns, - data, - tableHeading, - initialPageSize = 10, - onRowClick, - striped = false, - bordersVertical = false, - bordersHorizontal = false, - cellRender = null, - rowHighlightProp, - rowHighlightBg, - rowHighlightColor, -}) => { - const tableColumns = useMemo(() => columns, [columns]); - - const { isSm, isMd } = useMedia(); - - const defaultColumn = useMemo( - () => ({ - minWidth: 100, - width: 150, - maxWidth: 300, - }), - [], - ); - - let hiddenColumns = []; - - tableColumns.map(col => { - if (col.hidden === true) { - hiddenColumns.push(col.accessor); - } - }); - - const { - getTableProps, - headerGroups, - prepareRow, - page, - canPreviousPage, - canNextPage, - pageOptions, - pageCount, - gotoPage, - nextPage, - previousPage, - setPageSize, - state: { pageIndex, pageSize }, - } = useTable( - { - columns: tableColumns, - defaultColumn, - data, - initialState: { - pageIndex: 0, - pageSize: initialPageSize, - hiddenColumns: hiddenColumns, - }, - }, - useSortBy, - usePagination, - ); - - return ( - - {!!tableHeading && {tableHeading}} - - - {headerGroups.map(headerGroup => ( - - {headerGroup.headers.map(column => ( - - - {column.render('Header')} - - {column.isSorted ? ( - column.isSortedDesc ? ( - - ) : ( - - ) - ) : ( - '' - )} - - ))} - - ))} - - - {page.map( - (row, key) => - prepareRow(row) || ( - onRowClick && onRowClick(row)} - key={key} - highlight={row.values[rowHighlightProp] ?? false} - highlightBg={rowHighlightBg} - highlightColor={rowHighlightColor} - {...row.getRowProps()}> - {row.cells.map((cell, i) => { - return ( - - {cell.render(cellRender ?? 'Cell')} - - ); - })} - - ), - )} - - - - - gotoPage(0)} - isDisabled={!canPreviousPage} - icon={() => } - /> - previousPage()} - isDisabled={!canPreviousPage} - icon={() => } - /> - - - - Page{' '} - - {pageIndex + 1} of {pageOptions.length} - {' '} - - {!(isSm || isMd) && ( - { - setPageSize(Number(e.target.value)); - }} - /> - )} - - - nextPage()} - icon={() => } - /> - gotoPage(pageCount ? pageCount - 1 : 1)} - isDisabled={!canNextPage} - icon={() => } - /> - - - - ); -}; diff --git a/hyperglass/ui/components/Table/Table.tsx b/hyperglass/ui/components/Table/Table.tsx new file mode 100644 index 0000000..27ca08e --- /dev/null +++ b/hyperglass/ui/components/Table/Table.tsx @@ -0,0 +1,34 @@ +import { Box } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; + +import type { BoxProps } from '@chakra-ui/react'; + +export const TableMain = (props: BoxProps) => { + const scrollbar = useColorValue('blackAlpha.300', 'whiteAlpha.300'); + const scrollbarHover = useColorValue('blackAlpha.400', 'whiteAlpha.400'); + const scrollbarBg = useColorValue('blackAlpha.50', 'whiteAlpha.50'); + return ( + + ); +}; diff --git a/hyperglass/ui/components/Table/TableBody.js b/hyperglass/ui/components/Table/TableBody.js deleted file mode 100644 index 2c76d1d..0000000 --- a/hyperglass/ui/components/Table/TableBody.js +++ /dev/null @@ -1,17 +0,0 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import { Box, css } from '@chakra-ui/core'; - -export const TableBody = ({ children, ...props }) => ( - - {children} - -); diff --git a/hyperglass/ui/components/Table/TableCell.js b/hyperglass/ui/components/Table/TableCell.js deleted file mode 100644 index 15dc517..0000000 --- a/hyperglass/ui/components/Table/TableCell.js +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from 'react'; -import { Box, useColorMode } from '@chakra-ui/core'; - -const cellBorder = { - dark: { borderLeft: '1px', borderLeftColor: 'whiteAlpha.100' }, - light: { borderLeft: '1px', borderLeftColor: 'blackAlpha.100' }, -}; - -export const TableCell = ({ bordersVertical = [false, 0, 0], align, cell, children, ...props }) => { - const { colorMode } = useColorMode(); - const [doVerticalBorders, index] = bordersVertical; - let borderProps = {}; - if (doVerticalBorders && index !== 0) { - borderProps = cellBorder[colorMode]; - } - return ( - - {children} - - ); -}; diff --git a/hyperglass/ui/components/Table/TableHead.js b/hyperglass/ui/components/Table/TableHead.js deleted file mode 100644 index 6c5432b..0000000 --- a/hyperglass/ui/components/Table/TableHead.js +++ /dev/null @@ -1,13 +0,0 @@ -import * as React from 'react'; -import { Box, useColorMode } from '@chakra-ui/core'; - -const bg = { dark: 'whiteAlpha.100', light: 'blackAlpha.100' }; - -export const TableHead = ({ children, ...props }) => { - const { colorMode } = useColorMode(); - return ( - - {children} - - ); -}; diff --git a/hyperglass/ui/components/Table/TableIconButton.js b/hyperglass/ui/components/Table/TableIconButton.js deleted file mode 100644 index cd845e4..0000000 --- a/hyperglass/ui/components/Table/TableIconButton.js +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react'; -import { IconButton } from '@chakra-ui/core'; - -export const TableIconButton = ({ icon, onClick, isDisabled, color, children, ...props }) => ( - - {children} - -); diff --git a/hyperglass/ui/components/Table/TableMain.js b/hyperglass/ui/components/Table/TableMain.js deleted file mode 100644 index 4ab4c5e..0000000 --- a/hyperglass/ui/components/Table/TableMain.js +++ /dev/null @@ -1,37 +0,0 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import { Box, css, useTheme, useColorMode } from '@chakra-ui/core'; - -const scrollbar = { dark: 'whiteAlpha.300', light: 'blackAlpha.300' }; -const scrollbarHover = { dark: 'whiteAlpha.400', light: 'blackAlpha.400' }; -const scrollbarBg = { dark: 'whiteAlpha.50', light: 'blackAlpha.50' }; - -export const TableMain = ({ children, ...props }) => { - const theme = useTheme(); - const { colorMode } = useColorMode(); - return ( - - {children} - - ); -}; diff --git a/hyperglass/ui/components/Table/TableRow.js b/hyperglass/ui/components/Table/TableRow.js deleted file mode 100644 index a411e4c..0000000 --- a/hyperglass/ui/components/Table/TableRow.js +++ /dev/null @@ -1,52 +0,0 @@ -import * as React from 'react'; -import { PseudoBox, useColorMode, useTheme } from '@chakra-ui/core'; -import { opposingColor } from 'app/util'; - -const hoverBg = { dark: 'whiteAlpha.50', light: 'blackAlpha.50' }; -const bgStripe = { dark: 'whiteAlpha.50', light: 'blackAlpha.50' }; -const rowBorder = { - dark: { borderTop: '1px', borderTopColor: 'whiteAlpha.100' }, - light: { borderTop: '1px', borderTopColor: 'blackAlpha.100' }, -}; -const alphaMap = { dark: '200', light: '100' }; -const alphaMapHover = { dark: '100', light: '200' }; - -export const TableRow = ({ - highlight = false, - highlightBg = 'primary', - doStripe = false, - doHorizontalBorders = false, - index = 0, - children = false, - ...props -}) => { - const { colorMode } = useColorMode(); - const theme = useTheme(); - - let bg = null; - if (highlight) { - bg = `${highlightBg}.${alphaMap[colorMode]}`; - } else if (doStripe && index % 2 !== 0) { - bg = bgStripe[colorMode]; - } - const color = highlight ? opposingColor(theme, bg) : null; - - const borderProps = doHorizontalBorders && index !== 0 ? rowBorder[colorMode] : {}; - return ( - - {children} - - ); -}; diff --git a/hyperglass/ui/components/Table/TableSelectShow.js b/hyperglass/ui/components/Table/TableSelectShow.js deleted file mode 100644 index be6cdab..0000000 --- a/hyperglass/ui/components/Table/TableSelectShow.js +++ /dev/null @@ -1,13 +0,0 @@ -import * as React from 'react'; -import { Select } from '@chakra-ui/core'; - -export const TableSelectShow = ({ value, onChange, children, ...props }) => ( - -); diff --git a/hyperglass/ui/components/Table/body.tsx b/hyperglass/ui/components/Table/body.tsx new file mode 100644 index 0000000..f90e06d --- /dev/null +++ b/hyperglass/ui/components/Table/body.tsx @@ -0,0 +1,16 @@ +import { Box } from '@chakra-ui/react'; + +import type { BoxProps } from '@chakra-ui/react'; + +export const TableBody = (props: BoxProps) => ( + +); diff --git a/hyperglass/ui/components/Table/button.tsx b/hyperglass/ui/components/Table/button.tsx new file mode 100644 index 0000000..d86721f --- /dev/null +++ b/hyperglass/ui/components/Table/button.tsx @@ -0,0 +1,7 @@ +import { IconButton } from '@chakra-ui/react'; + +import type { TTableIconButton } from './types'; + +export const TableIconButton = (props: TTableIconButton) => ( + +); diff --git a/hyperglass/ui/components/Table/cell.tsx b/hyperglass/ui/components/Table/cell.tsx new file mode 100644 index 0000000..27e68e9 --- /dev/null +++ b/hyperglass/ui/components/Table/cell.tsx @@ -0,0 +1,28 @@ +import { Box } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; + +import type { TTableCell } from './types'; + +export const TableCell = (props: TTableCell) => { + const { bordersVertical = [false, 0], align, ...rest } = props; + const [doVerticalBorders, index] = bordersVertical; + const borderLeftColor = useColorValue('blackAlpha.100', 'whiteAlpha.100'); + + let borderProps = {}; + if (doVerticalBorders && index !== 0) { + borderProps = { borderLeft: '1px solid', borderLeftColor }; + } + + return ( + + ); +}; diff --git a/hyperglass/ui/components/Table/head.tsx b/hyperglass/ui/components/Table/head.tsx new file mode 100644 index 0000000..adee3a8 --- /dev/null +++ b/hyperglass/ui/components/Table/head.tsx @@ -0,0 +1,9 @@ +import { Box } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; + +import type { BoxProps } from '@chakra-ui/react'; + +export const TableHead = (props: BoxProps) => { + const bg = useColorValue('blackAlpha.100', 'whiteAlpha.100'); + return ; +}; diff --git a/hyperglass/ui/components/Table/index.mjs b/hyperglass/ui/components/Table/index.mjs deleted file mode 100644 index 1383bd0..0000000 --- a/hyperglass/ui/components/Table/index.mjs +++ /dev/null @@ -1,8 +0,0 @@ -export * from './Table'; -export * from './TableBody'; -export * from './TableCell'; -export * from './TableHead'; -export * from './TableIconButton'; -export * from './TableMain'; -export * from './TableRow'; -export * from './TableSelectShow'; diff --git a/hyperglass/ui/components/Table/index.ts b/hyperglass/ui/components/Table/index.ts new file mode 100644 index 0000000..7285324 --- /dev/null +++ b/hyperglass/ui/components/Table/index.ts @@ -0,0 +1,8 @@ +export * from './body'; +export * from './button'; +export * from './cell'; +export * from './head'; +export * from './main'; +export * from './main'; +export * from './pageSelect'; +export * from './row'; diff --git a/hyperglass/ui/components/Table/main.tsx b/hyperglass/ui/components/Table/main.tsx new file mode 100644 index 0000000..d94a4b8 --- /dev/null +++ b/hyperglass/ui/components/Table/main.tsx @@ -0,0 +1,202 @@ +import dynamic from 'next/dynamic'; +import { Flex, Icon, Text } from '@chakra-ui/react'; +import { usePagination, useSortBy, useTable } from 'react-table'; +import { useMobile } from '~/context'; +import { CardBody, CardFooter, CardHeader, If } from '~/components'; +import { TableMain } from './table'; +import { TableCell } from './cell'; +import { TableHead } from './head'; +import { TableRow } from './row'; +import { TableBody } from './body'; +import { TableIconButton } from './button'; +import { TableSelectShow } from './pageSelect'; + +import type { TableOptions, PluginHook } from 'react-table'; +import type { TCellRender } from '~/types'; +import type { TTable } from './types'; + +const ChevronRight = dynamic(() => + import('@meronex/icons/fa').then(i => i.FaChevronRight), +); + +const ChevronLeft = dynamic(() => + import('@meronex/icons/fa').then(i => i.FaChevronLeft), +); + +const ChevronDown = dynamic(() => + import('@meronex/icons/fa').then(i => i.FaChevronDown), +); + +const DoubleChevronRight = dynamic(() => + import('@meronex/icons/fi').then(i => i.FiChevronsRight), +); +const DoubleChevronLeft = dynamic(() => + import('@meronex/icons/fi').then(i => i.FiChevronsLeft), +); + +export function Table(props: TTable) { + const { + data, + columns, + heading, + Cell, + rowHighlightBg, + striped = false, + rowHighlightProp, + bordersVertical = false, + bordersHorizontal = false, + } = props; + + const isMobile = useMobile(); + + const defaultColumn = { + minWidth: 100, + width: 150, + maxWidth: 300, + }; + + let hiddenColumns = [] as string[]; + + for (const col of columns) { + if (col.hidden) { + hiddenColumns.push(col.accessor); + } + } + + const options = { + columns, + defaultColumn, + data, + initialState: { hiddenColumns }, + } as TableOptions; + + const plugins = [useSortBy, usePagination] as PluginHook[]; + + const instance = useTable(options, ...plugins); + + const { + page, + gotoPage, + nextPage, + pageCount, + prepareRow, + canNextPage, + pageOptions, + setPageSize, + headerGroups, + previousPage, + getTableProps, + canPreviousPage, + state: { pageIndex, pageSize }, + } = instance; + + return ( + + {heading && {heading}} + + + {headerGroups.map((headerGroup, i) => ( + + {headerGroup.headers.map(column => ( + + + {column.render('Header')} + + + + + + + + + + {''} + + ))} + + ))} + + + {page.map((row, key) => { + prepareRow(row); + return ( + + {row.cells.map((cell, i) => { + const { column, row, value } = cell as TCellRender; + return ( + + {typeof Cell !== 'undefined' ? ( + + ) : ( + cell.render('Cell') + )} + + ); + })} + + ); + })} + + + + + gotoPage(0)} + isDisabled={!canPreviousPage} + icon={} + /> + previousPage()} + isDisabled={!canPreviousPage} + icon={} + /> + + + + Page{' '} + + {pageIndex + 1} of {pageOptions.length} + {' '} + + {!isMobile && ( + { + setPageSize(Number(e.target.value)); + }} + /> + )} + + + } + /> + } + onClick={() => gotoPage(pageCount ? pageCount - 1 : 1)} + /> + + + + ); +} diff --git a/hyperglass/ui/components/Table/pageSelect.tsx b/hyperglass/ui/components/Table/pageSelect.tsx new file mode 100644 index 0000000..f58f157 --- /dev/null +++ b/hyperglass/ui/components/Table/pageSelect.tsx @@ -0,0 +1,15 @@ +import { Select } from '@chakra-ui/react'; +import { SelectProps } from '@chakra-ui/react'; + +export const TableSelectShow = (props: SelectProps) => { + const { value, ...rest } = props; + return ( + + ); +}; diff --git a/hyperglass/ui/components/Table/row.tsx b/hyperglass/ui/components/Table/row.tsx new file mode 100644 index 0000000..20e15db --- /dev/null +++ b/hyperglass/ui/components/Table/row.tsx @@ -0,0 +1,51 @@ +import { Box } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; +import { useOpposingColor } from '~/hooks'; + +import type { TTableRow } from './types'; + +export const TableRow = (props: TTableRow) => { + const { + index = 0, + doStripe = false, + highlight = false, + highlightBg = 'primary', + doHorizontalBorders = false, + ...rest + } = props; + + const alpha = useColorValue('100', '200'); + const alphaHover = useColorValue('200', '100'); + const bgStripe = useColorValue('blackAlpha.50', 'whiteAlpha.50'); + let hoverBg = useColorValue('blackAlpha.50', 'whiteAlpha.50'); + const rowBorder = useColorValue( + { borderTop: '1px', borderTopColor: 'blackAlpha.100' }, + { borderTop: '1px', borderTopColor: 'whiteAlpha.100' }, + ); + let bg; + + if (highlight) { + bg = `${String(highlightBg)}.${alpha}`; + hoverBg = `${String(highlightBg)}.${alphaHover}`; + } else if (doStripe && index % 2 !== 0) { + bg = bgStripe; + } + const defaultBg = useColorValue('white', 'black'); + const color = useOpposingColor(bg ?? defaultBg); + const borderProps = doHorizontalBorders && index !== 0 ? rowBorder : {}; + + return ( + td': { color } }} + fontWeight={highlight ? 'bold' : undefined} + _hover={{ + cursor: 'pointer', + backgroundColor: highlight ? `${String(highlightBg)}.${alphaHover}` : hoverBg, + }} + {...borderProps} + {...rest} + /> + ); +}; diff --git a/hyperglass/ui/components/Table/types.ts b/hyperglass/ui/components/Table/types.ts new file mode 100644 index 0000000..341d566 --- /dev/null +++ b/hyperglass/ui/components/Table/types.ts @@ -0,0 +1,30 @@ +import type { BoxProps, IconButtonProps } from '@chakra-ui/react'; + +import type { Theme, TColumn, TCellRender } from '~/types'; + +export interface TTable { + data: TRoute[]; + striped?: boolean; + columns: TColumn[]; + heading?: React.ReactNode; + bordersVertical?: boolean; + bordersHorizontal?: boolean; + Cell?: React.FC; + rowHighlightProp?: keyof IRoute; + rowHighlightBg?: Theme.ColorNames; +} + +export interface TTableCell extends Omit { + bordersVertical?: [boolean, number]; + align?: 'left' | 'right' | 'center'; +} + +export interface TTableRow extends BoxProps { + highlightBg?: Theme.ColorNames; + doHorizontalBorders?: boolean; + highlight?: boolean; + doStripe?: boolean; + index: number; +} + +export type TTableIconButton = Omit; diff --git a/hyperglass/ui/components/TextOutput.js b/hyperglass/ui/components/TextOutput.js deleted file mode 100644 index 43a1ba4..0000000 --- a/hyperglass/ui/components/TextOutput.js +++ /dev/null @@ -1,40 +0,0 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import { Box, css, useColorMode } from '@chakra-ui/core'; - -const bg = { dark: 'gray.800', light: 'blackAlpha.100' }; -const color = { dark: 'white', light: 'black' }; -const selectionBg = { dark: 'white', light: 'black' }; -const selectionColor = { dark: 'black', light: 'white' }; - -export const TextOutput = ({ children, ...props }) => { - const { colorMode } = useColorMode(); - - return ( - - {children - .split('\\n') - .join('\n') - .replace(/\n\n/g, '\n')} - - ); -}; diff --git a/hyperglass/ui/components/Title.js b/hyperglass/ui/components/Title.js deleted file mode 100644 index a1f29a0..0000000 --- a/hyperglass/ui/components/Title.js +++ /dev/null @@ -1,115 +0,0 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import { forwardRef } from 'react'; -import { Button, Heading, Image, Stack, useColorMode } from '@chakra-ui/core'; -import { useConfig, useMedia } from 'app/context'; - -const titleSize = { true: ['2xl', '2xl', '5xl', '5xl'], false: '2xl' }; -const titleMargin = { true: 2, false: 0 }; -const textAlignment = { false: ['right', 'center'], true: ['left', 'center'] }; -const logoName = { light: 'dark', dark: 'light' }; -const justifyMap = { - true: ['flex-end', 'center', 'center', 'center'], - false: ['flex-start', 'center', 'center', 'center'], -}; - -const logoFallback = { - light: 'https://res.cloudinary.com/hyperglass/image/upload/v1593916013/logo-dark.svg', - dark: 'https://res.cloudinary.com/hyperglass/image/upload/v1593916013/logo-light.svg', -}; - -const TitleOnly = ({ text, showSubtitle }) => ( - - {text} - -); - -const SubtitleOnly = ({ text, mediaSize, ...props }) => ( - - {text} - -); - -const TextOnly = ({ text, mediaSize, showSubtitle, ...props }) => ( - - - {showSubtitle && } - -); - -const Logo = ({ text, logo }) => { - const { colorMode } = useColorMode(); - const { width, dark_format, light_format } = logo; - const logoExt = { light: dark_format, dark: light_format }; - return ( - - ); -}; - -const LogoSubtitle = ({ text, logo, mediaSize }) => ( - <> - - - -); - -const All = ({ text, logo, mediaSize, showSubtitle }) => ( - <> - - - -); - -const modeMap = { - text_only: TextOnly, - logo_only: Logo, - logo_subtitle: LogoSubtitle, - all: All, -}; - -export const Title = forwardRef(({ onClick, isSubmitting, ...props }, ref) => { - const { web } = useConfig(); - const { mediaSize } = useMedia(); - const titleMode = web.text.title_mode; - const MatchedMode = modeMap[titleMode]; - return ( - - ); -}); diff --git a/hyperglass/ui/components/Util/If.tsx b/hyperglass/ui/components/Util/If.tsx new file mode 100644 index 0000000..9825a83 --- /dev/null +++ b/hyperglass/ui/components/Util/If.tsx @@ -0,0 +1,6 @@ +import type { TIf } from './types'; + +export const If = (props: TIf) => { + const { c, render, children, ...rest } = props; + return c ? (render ? render(rest) : children) : null; +}; diff --git a/hyperglass/ui/components/Util/animated.ts b/hyperglass/ui/components/Util/animated.ts new file mode 100644 index 0000000..99fb7dc --- /dev/null +++ b/hyperglass/ui/components/Util/animated.ts @@ -0,0 +1,8 @@ +import { chakra } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; + +export const AnimatedDiv = motion.custom(chakra.div); +export const AnimatedForm = motion.custom(chakra.form); +export const AnimatedH1 = motion.custom(chakra.h1); +export const AnimatedH3 = motion.custom(chakra.h3); +export const AnimatedButton = motion.custom(chakra.button); diff --git a/hyperglass/ui/components/Util/index.ts b/hyperglass/ui/components/Util/index.ts new file mode 100644 index 0000000..d7a1522 --- /dev/null +++ b/hyperglass/ui/components/Util/index.ts @@ -0,0 +1,2 @@ +export * from './animated'; +export * from './if'; diff --git a/hyperglass/ui/components/Util/types.ts b/hyperglass/ui/components/Util/types.ts new file mode 100644 index 0000000..dfa0b58 --- /dev/null +++ b/hyperglass/ui/components/Util/types.ts @@ -0,0 +1,5 @@ +export interface TIf { + c: boolean; + render?: (rest: any) => JSX.Element; + [k: string]: any; +} diff --git a/hyperglass/ui/components/codeBlock.tsx b/hyperglass/ui/components/codeBlock.tsx new file mode 100644 index 0000000..c95448a --- /dev/null +++ b/hyperglass/ui/components/codeBlock.tsx @@ -0,0 +1,25 @@ +import { Box } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; + +import type { BoxProps } from '@chakra-ui/react'; + +export const CodeBlock = (props: BoxProps) => { + const bg = useColorValue('blackAlpha.100', 'gray.800'); + const color = useColorValue('black', 'white'); + return ( + + ); +}; diff --git a/hyperglass/ui/components/countdown/countdown.tsx b/hyperglass/ui/components/countdown/countdown.tsx new file mode 100644 index 0000000..b33969b --- /dev/null +++ b/hyperglass/ui/components/countdown/countdown.tsx @@ -0,0 +1,41 @@ +import { Text } from '@chakra-ui/react'; +import ReactCountdown, { zeroPad } from 'react-countdown'; +import { If } from '~/components'; +import { useColorValue } from '~/context'; + +import type { ICountdown, IRenderer } from './types'; + +const Renderer = (props: IRenderer) => { + const { hours, minutes, seconds, completed, text } = props; + let time = [zeroPad(seconds)]; + minutes !== 0 && time.unshift(zeroPad(minutes)); + hours !== 0 && time.unshift(zeroPad(hours)); + const bg = useColorValue('black', 'white'); + return ( + <> + + + + + + {text} + + {time.join(':')} + + + + + ); +}; + +export const Countdown = (props: ICountdown) => { + const { timeout, text } = props; + const then = timeout * 1000; + return ( + } + /> + ); +}; diff --git a/hyperglass/ui/components/countdown/index.ts b/hyperglass/ui/components/countdown/index.ts new file mode 100644 index 0000000..c1ddcb5 --- /dev/null +++ b/hyperglass/ui/components/countdown/index.ts @@ -0,0 +1 @@ +export * from './countdown'; diff --git a/hyperglass/ui/components/countdown/types.ts b/hyperglass/ui/components/countdown/types.ts new file mode 100644 index 0000000..7828699 --- /dev/null +++ b/hyperglass/ui/components/countdown/types.ts @@ -0,0 +1,10 @@ +import type { CountdownRenderProps } from 'react-countdown'; + +export interface IRenderer extends CountdownRenderProps { + text: string; +} + +export interface ICountdown { + timeout: number; + text: string; +} diff --git a/hyperglass/ui/components/debugger.tsx b/hyperglass/ui/components/debugger.tsx new file mode 100644 index 0000000..094b0f2 --- /dev/null +++ b/hyperglass/ui/components/debugger.tsx @@ -0,0 +1,89 @@ +import { + Tag, + Modal, + HStack, + Button, + useTheme, + ModalBody, + ModalHeader, + ModalOverlay, + ModalContent, + useColorMode, + useDisclosure, + ModalCloseButton, +} from '@chakra-ui/react'; +import { useConfig, useColorValue, useBreakpointValue } from '~/context'; +import { CodeBlock } from '~/components'; +import type { UseDisclosureReturn } from '@chakra-ui/react'; + +interface TViewer extends Pick { + title: string; + children: React.ReactNode; +} + +const Viewer = (props: TViewer) => { + const { title, isOpen, onClose, children } = props; + const bg = useColorValue('white', 'black'); + const color = useColorValue('black', 'white'); + return ( + + + + {title} + + + {children} + + + + ); +}; + +export const Debugger = () => { + const { isOpen: configOpen, onOpen: onConfigOpen, onClose: configClose } = useDisclosure(); + const { isOpen: themeOpen, onOpen: onThemeOpen, onClose: themeClose } = useDisclosure(); + const { colorMode } = useColorMode(); + const config = useConfig(); + const theme = useTheme(); + const borderColor = useColorValue('gray.100', 'gray.600'); + const mediaSize = + useBreakpointValue({ base: 'SMALL', md: 'MEDIUM', lg: 'LARGE', xl: 'X-LARGE' }) ?? 'UNKNOWN'; + const tagSize = useBreakpointValue({ base: 'sm', lg: 'lg' }) ?? 'lg'; + const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }) ?? 'sm'; + return ( + <> + + + {colorMode.toUpperCase()} + + + + + {mediaSize} + + + + {JSON.stringify(config, null, 4)} + + + {JSON.stringify(theme, null, 4)} + + + ); +}; diff --git a/hyperglass/ui/components/form/field.tsx b/hyperglass/ui/components/form/field.tsx new file mode 100644 index 0000000..d0d1a47 --- /dev/null +++ b/hyperglass/ui/components/form/field.tsx @@ -0,0 +1,55 @@ +import { Flex, FormControl, FormLabel, FormErrorMessage } from '@chakra-ui/react'; +import { useFormContext } from 'react-hook-form'; +import { If } from '~/components'; +import { useColorValue } from '~/context'; +import { useBooleanValue } from '~/hooks'; + +import { TField, TFormError } from './types'; + +export const FormField = (props: TField) => { + const { name, label, children, labelAddOn, fieldAddOn, hiddenLabels = false, ...rest } = props; + const labelColor = useColorValue('blackAlpha.700', 'whiteAlpha.700'); + const errorColor = useColorValue('red.500', 'red.300'); + const opacity = useBooleanValue(hiddenLabels, 0, undefined); + + const { errors } = useFormContext(); + + const error = name in errors && (errors[name] as TFormError); + + if (error !== false) { + console.warn(`${label} Error: ${error.message}`); + } + + return ( + + + {label} + {labelAddOn} + + {children} + + + {fieldAddOn} + + + {error && error.message} + + ); +}; diff --git a/hyperglass/ui/components/form/index.ts b/hyperglass/ui/components/form/index.ts new file mode 100644 index 0000000..c315715 --- /dev/null +++ b/hyperglass/ui/components/form/index.ts @@ -0,0 +1,7 @@ +export * from './field'; +export * from './queryLocation'; +export * from './queryTarget'; +export * from './queryType'; +export * from './queryVrf'; +export * from './resolvedTarget'; +export * from './row'; diff --git a/hyperglass/ui/components/form/queryLocation.tsx b/hyperglass/ui/components/form/queryLocation.tsx new file mode 100644 index 0000000..49a8afc --- /dev/null +++ b/hyperglass/ui/components/form/queryLocation.tsx @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { Select } from '~/components'; +import { useConfig } from '~/context'; +import { useLGState, useLGMethods } from '~/hooks'; + +import type { TNetwork, TSelectOption } from '~/types'; +import type { TQuerySelectField } from './types'; + +function buildOptions(networks: TNetwork[]) { + return networks.map(net => { + const label = net.display_name; + const options = net.locations.map(loc => ({ + label: loc.display_name, + value: loc.name, + group: net.display_name, + })); + return { label, options }; + }); +} + +export const QueryLocation = (props: TQuerySelectField) => { + const { onChange, label } = props; + + const { networks } = useConfig(); + const { errors } = useFormContext(); + const { selections } = useLGState(); + const { exportState } = useLGMethods(); + + const options = useMemo(() => buildOptions(networks), [networks.length]); + + function handleChange(e: TSelectOption | TSelectOption[]): void { + if (e === null) { + e = []; + } else if (typeof e === 'string') { + e = [e]; + } + if (Array.isArray(e)) { + const value = e.map(sel => sel!.value); + onChange({ field: 'query_location', value }); + selections.queryLocation.set(e); + } + } + + return ( + + + + + + ); +}; diff --git a/hyperglass/ui/components/form/queryType.tsx b/hyperglass/ui/components/form/queryType.tsx new file mode 100644 index 0000000..85b4779 --- /dev/null +++ b/hyperglass/ui/components/form/queryType.tsx @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { Select } from '~/components'; +import { useConfig } from '~/context'; +import { useLGState, useLGMethods } from '~/hooks'; + +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 { errors } = useFormContext(); + const { selections } = useLGState(); + const { exportState } = useLGMethods(); + + const options = useMemo(() => buildOptions(queries.list), [queries.list.length]); + + function handleChange(e: TSelectOption | TSelectOption[]): void { + if (!Array.isArray(e) && e !== null) { + selections.queryType.set(e); + onChange({ field: 'query_type', value: e.value }); + } else { + selections.queryType.set(null); + } + } + + return ( + + ); +}; diff --git a/hyperglass/ui/components/form/resolvedTarget.tsx b/hyperglass/ui/components/form/resolvedTarget.tsx new file mode 100644 index 0000000..305f55c --- /dev/null +++ b/hyperglass/ui/components/form/resolvedTarget.tsx @@ -0,0 +1,137 @@ +import { useEffect, useMemo } from 'react'; +import dynamic from 'next/dynamic'; +import { Button, chakra, Icon, Stack, Text, VStack } from '@chakra-ui/react'; +import { useConfig, useColorValue } from '~/context'; +import { useStrf, useLGState, useDNSQuery } from '~/hooks'; + +const RightArrow = chakra( + dynamic(() => import('@meronex/icons/fa').then(i => i.FaArrowCircleRight)), +); + +const LeftArrow = chakra( + dynamic(() => import('@meronex/icons/fa').then(i => i.FaArrowCircleLeft)), +); + +import type { DnsOverHttps } 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 { setTarget, errorClose } = props; + const { web } = useConfig(); + const { displayTarget, isSubmitting, families, queryTarget } = useLGState(); + + const color = useColorValue('secondary.500', 'secondary.300'); + const errorColor = useColorValue('red.500', 'red.300'); + + const query4 = Array.from(families.value).includes(4); + const query6 = Array.from(families.value).includes(6); + + const tooltip4 = useStrf(web.text.fqdn_tooltip, { protocol: 'IPv4' }); + const tooltip6 = useStrf(web.text.fqdn_tooltip, { protocol: 'IPv6' }); + + const [messageStart, messageEnd] = web.text.fqdn_message.split('{fqdn}'); + const [errorStart, errorEnd] = web.text.fqdn_error.split('{fqdn}'); + + const { data: data4, isLoading: isLoading4, isError: isError4, error: error4 } = useDNSQuery( + displayTarget.value, + 4, + ); + + const { data: data6, isLoading: isLoading6, isError: isError6, error: error6 } = useDNSQuery( + displayTarget.value, + 6, + ); + + isError4 && console.error(error4); + isError6 && console.error(error6); + + const answer4 = useMemo(() => findAnswer(data4), [data4]); + const answer6 = useMemo(() => findAnswer(data6), [data6]); + + function handleOverride(value: string): void { + setTarget({ field: 'query_target', value }); + } + function selectTarget(value: string): void { + queryTarget.set(value); + isSubmitting.set(true); + } + + 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 ( + + {(answer4 || answer6) && ( + + {messageStart} + + {`${displayTarget.value}`.toLowerCase()} + + {messageEnd} + + )} + + {!isLoading4 && !isError4 && query4 && answer4 && ( + + )} + {!isLoading6 && !isError6 && query6 && answer6 && ( + + )} + {!answer4 && !answer6 && ( + <> + + {errorStart} + + {`${displayTarget.value}`.toLowerCase()} + + {errorEnd} + + + + )} + + + ); +}; 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 new file mode 100644 index 0000000..b85526b --- /dev/null +++ b/hyperglass/ui/components/form/types.ts @@ -0,0 +1,44 @@ +import type { FormControlProps } from '@chakra-ui/react'; +import type { Control } from 'react-hook-form'; +import type { TDeviceVrf, TBGPCommunity, OnChangeArgs } from '~/types'; +import type { ValidationError } from 'yup'; + +export type TFormError = Pick; + +export interface TField extends FormControlProps { + name: string; + label: string; + hiddenLabels?: boolean; + labelAddOn?: React.ReactNode; + fieldAddOn?: React.ReactNode; +} + +export type OnChange = (f: 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']; +} + +export interface TQueryTarget { + name: string; + placeholder: string; + register: Control['register']; + onChange(e: OnChangeArgs): void; +} + +export interface TResolvedTarget { + setTarget(e: OnChangeArgs): void; + errorClose(): void; +} diff --git a/hyperglass/ui/components/greeting/greeting.tsx b/hyperglass/ui/components/greeting/greeting.tsx new file mode 100644 index 0000000..5769278 --- /dev/null +++ b/hyperglass/ui/components/greeting/greeting.tsx @@ -0,0 +1,75 @@ +import { useEffect } from 'react'; +import { + Modal, + Button, + ModalBody, + ModalHeader, + ModalFooter, + ModalOverlay, + ModalContent, + ModalCloseButton, +} from '@chakra-ui/react'; +import { If, Markdown } from '~/components'; +import { useConfig, useColorValue } from '~/context'; +import { useGreeting, useOpposingColor } from '~/hooks'; + +import type { TGreeting } from './types'; + +export const Greeting = (props: TGreeting) => { + const { web, content } = useConfig(); + const { ack: greetingAck, isOpen, close, open } = useGreeting(); + + const bg = useColorValue('white', 'gray.800'); + const color = useOpposingColor(bg); + + function handleClose(ack: boolean = false): void { + if (web.greeting.required && !greetingAck.value && !ack) { + greetingAck.set(false); + } else if (web.greeting.required && !greetingAck.value && ack) { + greetingAck.set(true); + close(); + } else if (web.greeting.required && greetingAck.value) { + close(); + } else if (!web.greeting.required) { + greetingAck.set(true); + close(); + } + } + useEffect(() => { + if (!greetingAck.value && web.greeting.enable) { + isOpen.set(true); + } + }, []); + return ( + + + + {web.greeting.title} + + + + + + + + + + + + ); +}; diff --git a/hyperglass/ui/components/greeting/index.ts b/hyperglass/ui/components/greeting/index.ts new file mode 100644 index 0000000..fe2d50b --- /dev/null +++ b/hyperglass/ui/components/greeting/index.ts @@ -0,0 +1 @@ +export * from './greeting'; diff --git a/hyperglass/ui/components/greeting/types.ts b/hyperglass/ui/components/greeting/types.ts new file mode 100644 index 0000000..1568d2a --- /dev/null +++ b/hyperglass/ui/components/greeting/types.ts @@ -0,0 +1,3 @@ +import { BoxProps } from '@chakra-ui/react'; + +export interface TGreeting extends BoxProps {} diff --git a/hyperglass/ui/components/header/header.tsx b/hyperglass/ui/components/header/header.tsx new file mode 100644 index 0000000..1971b2a --- /dev/null +++ b/hyperglass/ui/components/header/header.tsx @@ -0,0 +1,51 @@ +import { useRef } from 'react'; +import { Flex, ScaleFade } from '@chakra-ui/react'; +import { useColorValue, useBreakpointValue } from '~/context'; +import { useBooleanValue, useLGState } from '~/hooks'; +import { Title } from './title'; + +import type { THeader } from './types'; + +export const Header = (props: THeader) => { + const { resetForm, ...rest } = props; + + const bg = useColorValue('white', 'black'); + + const { isSubmitting } = useLGState(); + + const titleRef = useRef({} as HTMLDivElement); + + const titleWidth = useBooleanValue( + isSubmitting.value, + { base: '75%', lg: '50%' }, + { base: '75%', lg: '75%' }, + ); + + const justify = useBreakpointValue({ base: 'flex-start', lg: 'center' }); + + return ( + + + + + </Flex> + </ScaleFade> + </Flex> + ); +}; diff --git a/hyperglass/ui/components/header/index.ts b/hyperglass/ui/components/header/index.ts new file mode 100644 index 0000000..677ca79 --- /dev/null +++ b/hyperglass/ui/components/header/index.ts @@ -0,0 +1 @@ +export * from './header'; diff --git a/hyperglass/ui/components/header/logo.tsx b/hyperglass/ui/components/header/logo.tsx new file mode 100644 index 0000000..59ae06e --- /dev/null +++ b/hyperglass/ui/components/header/logo.tsx @@ -0,0 +1,73 @@ +import { useMemo, useState } from 'react'; +import { Image, Skeleton } from '@chakra-ui/react'; +import { useColorValue, useConfig, useColorMode } from '~/context'; + +import type { TLogo } from './types'; + +/** + * Custom hook to handle loading the user's logo, errors loading the logo, and color mode changes. + */ +function useLogo(): [string, () => void] { + const { web } = useConfig(); + const { dark_format, light_format } = web.logo; + const { colorMode } = useColorMode(); + + const src = useColorValue(`/images/dark${dark_format}`, `/images/light${light_format}`); + + // Use the hyperglass logo if the user's logo can't be loaded for whatever reason. + const fallbackSrc = useColorValue( + 'https://res.cloudinary.com/hyperglass/image/upload/v1593916013/logo-dark.svg', + 'https://res.cloudinary.com/hyperglass/image/upload/v1593916013/logo-light.svg', + ); + + const [fallback, setSource] = useState<string | null>(null); + + /** + * If the user image cannot be loaded, log an error to the console and set the fallback image. + */ + function setFallback() { + console.warn(`Error loading image from '${src}'`); + setSource(fallbackSrc); + } + + // Only return the fallback image if it's been set. + return useMemo(() => [fallback ?? src, setFallback], [colorMode]); +} + +export const Logo = (props: TLogo) => { + const { web } = useConfig(); + const { width } = web.logo; + + const skeletonA = useColorValue('whiteSolid.100', 'blackSolid.800'); + const skeletonB = useColorValue('light.500', 'dark.500'); + + const [source, setFallback] = useLogo(); + + return ( + <Image + src={source} + alt={web.text.title} + onError={setFallback} + width={width ?? 'auto'} + css={{ + userDrag: 'none', + userSelect: 'none', + msUserSelect: 'none', + MozUserSelect: 'none', + WebkitUserDrag: 'none', + WebkitUserSelect: 'none', + }} + fallback={ + <Skeleton + isLoaded={false} + borderRadius="md" + endColor={skeletonB} + startColor={skeletonA} + width={{ base: 64, lg: 80 }} + height={{ base: 12, lg: 16 }} + /> + } + {...props} + /> + ); +}; diff --git a/hyperglass/ui/components/header/subtitleOnly.tsx b/hyperglass/ui/components/header/subtitleOnly.tsx new file mode 100644 index 0000000..68f7982 --- /dev/null +++ b/hyperglass/ui/components/header/subtitleOnly.tsx @@ -0,0 +1,20 @@ +import { Heading } from '@chakra-ui/react'; +import { useConfig, useBreakpointValue } from '~/context'; +import { useTitleSize } from './useTitleSize'; + +export const SubtitleOnly = () => { + const { web } = useConfig(); + const sizeSm = useTitleSize(web.text.subtitle, 'sm'); + const fontSize = useBreakpointValue({ base: sizeSm, lg: 'xl' }); + + return ( + <Heading + as="h3" + fontWeight="normal" + fontSize={fontSize} + whiteSpace="break-spaces" + textAlign={{ base: 'left', xl: 'center' }}> + {web.text.subtitle} + </Heading> + ); +}; diff --git a/hyperglass/ui/components/header/title.tsx b/hyperglass/ui/components/header/title.tsx new file mode 100644 index 0000000..51ba44d --- /dev/null +++ b/hyperglass/ui/components/header/title.tsx @@ -0,0 +1,143 @@ +import { Flex, Button, VStack } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import { If } from '~/components'; +import { useConfig, useMobile } from '~/context'; +import { useBooleanValue, useLGState, useLGMethods } from '~/hooks'; +import { Logo } from './logo'; +import { TitleOnly } from './titleOnly'; +import { SubtitleOnly } from './subtitleOnly'; + +import type { StackProps } from '@chakra-ui/react'; +import type { TTitle, TTitleWrapper, TDWrapper } from './types'; + +const AnimatedVStack = motion.custom(VStack); + +/** + * Title wrapper for mobile devices, breakpoints sm & md. + */ +const MWrapper = (props: StackProps) => <VStack spacing={1} alignItems="flex-start" {...props} />; + +/** + * Title wrapper for desktop devices, breakpoints lg & xl. + */ +const DWrapper = (props: TDWrapper) => { + const { isSubmitting } = useLGState(); + return ( + <AnimatedVStack + spacing={1} + initial="main" + alignItems="center" + animate={isSubmitting.value ? 'submitting' : 'main'} + transition={{ damping: 15, type: 'spring', stiffness: 100 }} + variants={{ submitting: { scale: 0.5 }, main: { scale: 1 } }} + {...props} + /> + ); +}; + +/** + * Universal wrapper for title sub-components, which will be different depending on the + * `title_mode` configuration variable. + */ +const TitleWrapper = (props: TDWrapper | StackProps) => { + const isMobile = useMobile(); + return ( + <> + {isMobile ? <MWrapper {...(props as StackProps)} /> : <DWrapper {...(props as TDWrapper)} />} + </> + ); +}; + +/** + * Title sub-component if `title_mode` is set to `text_only`. + */ +const TextOnly = (props: TTitleWrapper) => { + return ( + <TitleWrapper {...props}> + <TitleOnly /> + <SubtitleOnly /> + </TitleWrapper> + ); +}; + +/** + * Title sub-component if `title_mode` is set to `logo_only`. Renders only the logo. + */ +const LogoOnly = () => ( + <TitleWrapper> + <Logo /> + </TitleWrapper> +); + +/** + * Title sub-component if `title_mode` is set to `logo_subtitle`. Renders the logo with the + * subtitle underneath. + */ +const LogoSubtitle = () => ( + <TitleWrapper> + <Logo /> + <SubtitleOnly /> + </TitleWrapper> +); + +/** + * Title sub-component if `title_mode` is set to `all`. Renders the logo, title, and subtitle. + */ +const All = () => ( + <TitleWrapper> + <Logo /> + <TextOnly mt={2} /> + </TitleWrapper> +); + +/** + * Title component which renders sub-components based on the `title_mode` configuration variable. + */ +export const Title = (props: TTitle) => { + const { fontSize, ...rest } = props; + const { web } = useConfig(); + const titleMode = web.text.title_mode; + + const { isSubmitting } = useLGState(); + const { resetForm } = useLGMethods(); + + const titleHeight = useBooleanValue(isSubmitting.value, undefined, { md: '20vh' }); + + function handleClick(): void { + isSubmitting.set(false); + resetForm(); + } + + return ( + <Flex + px={0} + flexWrap="wrap" + flexDir="column" + minH={titleHeight} + justifyContent="center" + mt={[null, isSubmitting.value ? null : 'auto']} + {...rest}> + <Button + px={0} + variant="link" + flexWrap="wrap" + flexDir="column" + onClick={handleClick} + _focus={{ boxShadow: 'none' }} + _hover={{ textDecoration: 'none' }}> + <If c={titleMode === 'text_only'}> + <TextOnly /> + </If> + <If c={titleMode === 'logo_only'}> + <LogoOnly /> + </If> + <If c={titleMode === 'logo_subtitle'}> + <LogoSubtitle /> + </If> + <If c={titleMode === 'all'}> + <All /> + </If> + </Button> + </Flex> + ); +}; diff --git a/hyperglass/ui/components/header/titleOnly.tsx b/hyperglass/ui/components/header/titleOnly.tsx new file mode 100644 index 0000000..ace22f9 --- /dev/null +++ b/hyperglass/ui/components/header/titleOnly.tsx @@ -0,0 +1,18 @@ +import { Heading } from '@chakra-ui/react'; +import { useConfig } from '~/context'; +import { useBooleanValue, useLGState } from '~/hooks'; +import { useTitleSize } from './useTitleSize'; + +export const TitleOnly = () => { + const { web } = useConfig(); + const { isSubmitting } = useLGState(); + + const margin = useBooleanValue(isSubmitting.value, 0, 2); + const sizeSm = useTitleSize(web.text.title, '2xl', []); + + return ( + <Heading as="h1" mb={margin} fontSize={{ base: sizeSm, lg: '5xl' }}> + {web.text.title} + </Heading> + ); +}; diff --git a/hyperglass/ui/components/header/types.ts b/hyperglass/ui/components/header/types.ts new file mode 100644 index 0000000..fb2b53c --- /dev/null +++ b/hyperglass/ui/components/header/types.ts @@ -0,0 +1,27 @@ +import type { FlexProps, HeadingProps, ImageProps, StackProps } from '@chakra-ui/react'; +import type { MotionProps } from 'framer-motion'; + +export interface THeader extends FlexProps { + resetForm(): void; +} + +export type THeaderLayout = { + sm: [JSX.Element, JSX.Element]; + md: [JSX.Element, JSX.Element]; + lg: [JSX.Element, JSX.Element]; + xl: [JSX.Element, JSX.Element]; +}; +export type TDWrapper = Omit<StackProps, 'transition'> & MotionProps; + +export interface TTitle extends FlexProps {} + +export interface TTitleOnly extends HeadingProps {} + +export interface TLogo extends ImageProps {} + +export interface TTitleWrapper extends Partial<MotionProps & Omit<StackProps, 'transition'>> {} + +export interface THeaderCtx { + showSubtitle: boolean; + titleRef: React.MutableRefObject<HTMLHeadingElement>; +} diff --git a/hyperglass/ui/components/header/useTitleSize.ts b/hyperglass/ui/components/header/useTitleSize.ts new file mode 100644 index 0000000..cde6a05 --- /dev/null +++ b/hyperglass/ui/components/header/useTitleSize.ts @@ -0,0 +1,59 @@ +import { useMemo, useState } from 'react'; +import { useToken } from '@chakra-ui/react'; +import { useMobile } from '~/context'; + +// Mobile: +// xs: 32 +// sm: 28 +// md: 24 +// lg: 20 +// xl: 16 +// 2xl: 14 +// 3xl: 12 +// 4xl: 10 +// 5xl: 7 +type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl'; + +export function useTitleSize(title: string, defaultSize: Sizes, deps: any[] = []) { + const [size, setSize] = useState<Sizes>(defaultSize); + const realSize = useToken('fontSizes', size); + const isMobile = useMobile(); + function getSize(l: number): void { + switch (true) { + case l > 32: + setSize('xs'); + break; + case l <= 32 && l > 28: + setSize('xs'); + break; + case l <= 28 && l > 24: + setSize('sm'); + break; + case l <= 24 && l > 20: + setSize('md'); + break; + case l <= 20 && l > 16: + setSize('lg'); + break; + case l <= 16 && l > 14: + setSize('xl'); + break; + case l <= 14 && l > 12: + setSize('2xl'); + break; + case l <= 12 && l > 10: + setSize('3xl'); + break; + case l <= 10 && l > 7: + setSize('4xl'); + break; + case l <= 7: + setSize('5xl'); + break; + } + } + return useMemo(() => { + getSize(title.length); + return realSize; + }, [title, isMobile, ...deps]); +} diff --git a/hyperglass/ui/components/help/index.ts b/hyperglass/ui/components/help/index.ts new file mode 100644 index 0000000..133aa74 --- /dev/null +++ b/hyperglass/ui/components/help/index.ts @@ -0,0 +1 @@ +export * from './modal'; diff --git a/hyperglass/ui/components/help/modal.tsx b/hyperglass/ui/components/help/modal.tsx new file mode 100644 index 0000000..fe7b10c --- /dev/null +++ b/hyperglass/ui/components/help/modal.tsx @@ -0,0 +1,57 @@ +import dynamic from 'next/dynamic'; +import { + Modal, + ScaleFade, + ModalBody, + IconButton, + ModalHeader, + ModalOverlay, + ModalContent, + useDisclosure, + ModalCloseButton, +} from '@chakra-ui/react'; +import { Markdown } from '~/components'; +import { useColorValue } from '~/context'; +import { isQueryContent } from '~/types'; + +import type { THelpModal } from './types'; + +const Info = dynamic<MeronexIcon>(() => import('@meronex/icons/fi').then(i => i.FiInfo)); + +export const HelpModal = (props: THelpModal) => { + const { visible, item, name, ...rest } = props; + const { isOpen, onOpen, onClose } = useDisclosure(); + const bg = useColorValue('whiteSolid.50', 'blackSolid.800'); + const color = useColorValue('black', 'white'); + if (!isQueryContent(item)) { + return null; + } + return ( + <> + <ScaleFade in={visible} unmountOnExit> + <IconButton + mb={1} + ml={1} + minH={3} + minW={3} + size="md" + variant="link" + icon={<Info />} + onClick={onOpen} + colorScheme="blue" + aria-label={`${name}_help`} + /> + </ScaleFade> + <Modal isOpen={isOpen} onClose={onClose} size="xl" motionPreset="slideInRight"> + <ModalOverlay /> + <ModalContent bg={bg} color={color} py={4} borderRadius="md" {...rest}> + <ModalHeader>{item.params.title}</ModalHeader> + <ModalCloseButton /> + <ModalBody> + <Markdown content={item.content} /> + </ModalBody> + </ModalContent> + </Modal> + </> + ); +}; diff --git a/hyperglass/ui/components/help/types.ts b/hyperglass/ui/components/help/types.ts new file mode 100644 index 0000000..2a47dfc --- /dev/null +++ b/hyperglass/ui/components/help/types.ts @@ -0,0 +1,8 @@ +import type { ModalContentProps } from '@chakra-ui/react'; +import type { TQueryContent, TQueryFields } from '~/types'; + +export interface THelpModal extends ModalContentProps { + item: TQueryContent | null; + name: TQueryFields; + visible: boolean; +} diff --git a/hyperglass/ui/components/index.mjs b/hyperglass/ui/components/index.mjs deleted file mode 100644 index e8d3498..0000000 --- a/hyperglass/ui/components/index.mjs +++ /dev/null @@ -1,35 +0,0 @@ -export * from './BGPTable'; -export * from './CacheTimeout'; -export * from './Card'; -export * from './ChakraSelect'; -export * from './CodeBlock'; -export * from './ColorModeToggle'; -export * from './CommunitySelect'; -export * from './CopyButton'; -export * from './Debugger'; -export * from './Footer'; -export * from './FormField'; -export * from './Greeting'; -export * from './Header'; -export * from './HelpModal'; -export * from './HyperglassForm'; -export * from './Label'; -export * from './Layout'; -export * from './Loading'; -export * from './LookingGlass'; -export * from './Markdown'; -export * from './Meta'; -export * from './QueryLocation'; -export * from './QueryTarget'; -export * from './QueryType'; -export * from './QueryVrf'; -export * from './RequeryButton'; -export * from './ResetButton'; -export * from './ResolvedTarget'; -export * from './Result'; -export * from './ResultHeader'; -export * from './Results'; -export * from './SubmitButton'; -export * from './Table'; -export * from './TextOutput'; -export * from './Title'; diff --git a/hyperglass/ui/components/index.ts b/hyperglass/ui/components/index.ts new file mode 100644 index 0000000..17216b1 --- /dev/null +++ b/hyperglass/ui/components/index.ts @@ -0,0 +1,22 @@ +export * from './card'; +export * from './codeBlock'; +export * from './countdown'; +export * from './debugger'; +export * from './footer'; +export * from './form'; +export * from './greeting'; +export * from './header'; +export * from './help'; +export * from './label'; +export * from './layout'; +export * from './loading'; +export * from './lookingGlass'; +export * from './markdown'; +export * from './meta'; +export * from './output'; +export * from './path'; +export * from './results'; +export * from './select'; +export * from './submit'; +export * from './table'; +export * from './util'; diff --git a/hyperglass/ui/components/label/index.ts b/hyperglass/ui/components/label/index.ts new file mode 100644 index 0000000..301fbde --- /dev/null +++ b/hyperglass/ui/components/label/index.ts @@ -0,0 +1 @@ +export * from './label'; diff --git a/hyperglass/ui/components/label/label.tsx b/hyperglass/ui/components/label/label.tsx new file mode 100644 index 0000000..a562b6a --- /dev/null +++ b/hyperglass/ui/components/label/label.tsx @@ -0,0 +1,61 @@ +import { forwardRef } from 'react'; +import { Flex } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; +import { useOpposingColor } from '~/hooks'; + +import type { TLabel } from './types'; + +export const Label = forwardRef<HTMLDivElement, TLabel>((props, ref) => { + const { value, label, labelColor, bg = 'primary.600', valueColor, ...rest } = props; + + const valueColorAuto = useOpposingColor(bg); + const defaultLabelColor = useColorValue('blackAlpha.700', 'whiteAlpha.700'); + + return ( + <Flex + my={2} + ref={ref} + flexWrap="nowrap" + alignItems="center" + mx={{ base: 1, md: 2 }} + justifyContent="flex-start" + {...rest}> + <Flex + mb={2} + mr={0} + bg={bg} + lineHeight="1.5" + fontWeight="bold" + whiteSpace="nowrap" + display="inline-flex" + px={{ base: 1, md: 3 }} + justifyContent="center" + borderTopLeftRadius={4} + borderTopRightRadius={0} + borderBottomLeftRadius={4} + borderBottomRightRadius={0} + fontSize={{ base: 'xs', md: 'sm' }} + color={valueColor ?? valueColorAuto}> + {value} + </Flex> + <Flex + px={3} + mb={2} + ml={0} + mr={0} + lineHeight="1.5" + whiteSpace="nowrap" + display="inline-flex" + justifyContent="center" + borderTopLeftRadius={0} + borderTopRightRadius={4} + borderBottomLeftRadius={0} + borderBottomRightRadius={4} + fontSize={{ base: 'xs', md: 'sm' }} + color={labelColor ?? defaultLabelColor} + boxShadow={`inset 0px 0px 0px 1px ${bg}`}> + {label} + </Flex> + </Flex> + ); +}); diff --git a/hyperglass/ui/components/label/types.ts b/hyperglass/ui/components/label/types.ts new file mode 100644 index 0000000..2069c4c --- /dev/null +++ b/hyperglass/ui/components/label/types.ts @@ -0,0 +1,9 @@ +import { FlexProps } from '@chakra-ui/react'; + +export interface TLabel extends FlexProps { + value: string; + label: string; + bg: string; + valueColor?: string; + labelColor?: string; +} diff --git a/hyperglass/ui/components/layout/frame.tsx b/hyperglass/ui/components/layout/frame.tsx new file mode 100644 index 0000000..55a068b --- /dev/null +++ b/hyperglass/ui/components/layout/frame.tsx @@ -0,0 +1,58 @@ +import { useRef } from 'react'; +import { Flex } from '@chakra-ui/react'; +import { If, Debugger, Greeting, Footer, Header } from '~/components'; +import { useConfig, useColorValue } from '~/context'; +import { useLGState, useLGMethods } from '~/hooks'; +import { ResetButton } from './resetButton'; + +import type { TFrame } from './types'; + +export const Frame = (props: TFrame) => { + const { developer_mode } = useConfig(); + const { isSubmitting } = useLGState(); + const { resetForm } = useLGMethods(); + + const bg = useColorValue('white', 'black'); + const color = useColorValue('black', 'white'); + + const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement); + + function handleReset(): void { + containerRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + isSubmitting.set(false); + resetForm(); + } + + return ( + <> + <Flex + bg={bg} + w="100%" + id="__hyperglass" + color={color} + flexDir="column" + minHeight="100vh" + ref={containerRef}> + <Header resetForm={handleReset} /> + <Flex + px={2} + py={0} + w="100%" + as="main" + align="center" + flex="1 1 auto" + justify="start" + flexDir="column" + textAlign="center" + {...props} + /> + <Footer /> + <If c={developer_mode}> + <Debugger /> + </If> + <ResetButton developerMode={developer_mode} resetForm={handleReset} /> + </Flex> + <Greeting /> + </> + ); +}; diff --git a/hyperglass/ui/components/layout/index.ts b/hyperglass/ui/components/layout/index.ts new file mode 100644 index 0000000..aee1748 --- /dev/null +++ b/hyperglass/ui/components/layout/index.ts @@ -0,0 +1,2 @@ +export * from './frame'; +export * from './layout'; diff --git a/hyperglass/ui/components/layout/layout.tsx b/hyperglass/ui/components/layout/layout.tsx new file mode 100644 index 0000000..6810f3f --- /dev/null +++ b/hyperglass/ui/components/layout/layout.tsx @@ -0,0 +1,19 @@ +import { AnimatePresence } from 'framer-motion'; +import { LookingGlass, Results } from '~/components'; +import { useLGMethods } from '~/hooks'; +import { Frame } from './frame'; + +export const Layout: React.FC = () => { + const { formReady } = useLGMethods(); + return ( + <Frame> + {formReady() ? ( + <Results /> + ) : ( + <AnimatePresence> + <LookingGlass /> + </AnimatePresence> + )} + </Frame> + ); +}; diff --git a/hyperglass/ui/components/layout/resetButton.tsx b/hyperglass/ui/components/layout/resetButton.tsx new file mode 100644 index 0000000..59ebfe7 --- /dev/null +++ b/hyperglass/ui/components/layout/resetButton.tsx @@ -0,0 +1,39 @@ +import dynamic from 'next/dynamic'; +import { Box, Flex, Icon, IconButton, Slide } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; +import { useLGState, useOpposingColor } from '~/hooks'; + +import type { TResetButton } from './types'; + +const LeftArrow = dynamic<MeronexIcon>(() => import('@meronex/icons/fa').then(i => i.FaAngleLeft)); + +export const ResetButton = (props: TResetButton) => { + const { developerMode, resetForm, ...rest } = props; + const { isSubmitting } = useLGState(); + const bg = useColorValue('primary.500', 'primary.300'); + const color = useOpposingColor(bg); + return ( + <Slide direction="left" in={isSubmitting.value} unmountOnExit style={{ width: 'auto' }}> + <Box + bg={bg} + left={0} + zIndex={4} + bottom={24} + boxSize={12} + color={color} + position="fixed" + borderRightRadius="md" + mb={developerMode ? 14 : undefined}> + <Flex boxSize="100%" justifyContent="center" alignItems="center" {...rest}> + <IconButton + color="current" + variant="ghost" + aria-label="Reset" + onClick={resetForm} + icon={<Icon as={LeftArrow} boxSize={8} />} + /> + </Flex> + </Box> + </Slide> + ); +}; diff --git a/hyperglass/ui/components/layout/types.ts b/hyperglass/ui/components/layout/types.ts new file mode 100644 index 0000000..bf3ee75 --- /dev/null +++ b/hyperglass/ui/components/layout/types.ts @@ -0,0 +1,8 @@ +import type { FlexProps } from '@chakra-ui/react'; + +export interface TFrame extends FlexProps {} + +export interface TResetButton extends FlexProps { + developerMode: boolean; + resetForm(): void; +} diff --git a/hyperglass/ui/components/loading.tsx b/hyperglass/ui/components/loading.tsx new file mode 100644 index 0000000..a4ec0e5 --- /dev/null +++ b/hyperglass/ui/components/loading.tsx @@ -0,0 +1,24 @@ +import { Flex, Spinner } from '@chakra-ui/react'; + +import type { LoadableBaseOptions } from 'next/dynamic'; + +export const Loading: LoadableBaseOptions['loading'] = () => ( + <Flex flexDirection="column" minHeight="100vh" w="100%"> + <Flex + px={2} + py={0} + w="100%" + bg="white" + color="black" + flex="1 1 auto" + textAlign="center" + alignItems="center" + justifyContent="center" + flexDirection="column" + css={{ + '@media (prefers-color-scheme: dark)': { backgroundColor: 'black', color: 'white' }, + }}> + <Spinner color="primary.500" w="6rem" h="6rem" /> + </Flex> + </Flex> +); diff --git a/hyperglass/ui/components/lookingGlass.tsx b/hyperglass/ui/components/lookingGlass.tsx new file mode 100644 index 0000000..8c92a39 --- /dev/null +++ b/hyperglass/ui/components/lookingGlass.tsx @@ -0,0 +1,290 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { Flex } from '@chakra-ui/react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { intersectionWith } from 'lodash'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { + If, + AnimatedForm, + FormRow, + QueryVrf, + FormField, + HelpModal, + QueryType, + QueryTarget, + SubmitButton, + QueryLocation, +} from '~/components'; +import { useConfig } from '~/context'; +import { useStrf, useGreeting, useDevice, useLGState, useLGMethods } from '~/hooks'; +import { isQueryType, isQueryContent, isString } from '~/types'; + +import type { TFormData, TDeviceVrf, OnChangeArgs } from '~/types'; + +/** + * Don't set the global flag on this. + * @see https://stackoverflow.com/questions/24084926/javascript-regexp-cant-use-twice + * + * TLDR: the test() will pass the first time, but not the second. In React Strict Mode & in a dev + * environment, this will mean isFqdn will be true the first time, then false the second time, + * submitting the FQDN to hyperglass the second time. + */ +const fqdnPattern = new RegExp( + /^(?!:\/\/)([a-zA-Z0-9-]+\.)?[a-zA-Z0-9-][a-zA-Z0-9-]+\.[a-zA-Z-]{2,6}?$/im, +); + +function useIsFqdn(target: string, _type: string) { + return useCallback( + (): boolean => ['bgp_route', 'ping', 'traceroute'].includes(_type) && fqdnPattern.test(target), + [target, _type], + ); +} + +export const LookingGlass = () => { + const { web, content, messages } = useConfig(); + + const { ack, greetingReady, isOpen: greetingIsOpen } = useGreeting(); + const getDevice = useDevice(); + + 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_target: yup.string().required(noQueryTarget), + query_type: yup.string().required(noQueryType), + query_vrf: yup.string(), + }); + + const formInstance = useForm<TFormData>({ + resolver: yupResolver(formSchema), + defaultValues: { query_vrf: 'default', query_target: '', query_location: [], query_type: '' }, + }); + + const { handleSubmit, register, setValue } = formInstance; + + const { + queryVrf, + families, + queryType, + availVrfs, + btnLoading, + queryTarget, + isSubmitting, + queryLocation, + displayTarget, + } = useLGState(); + + const { resolvedOpen, resetForm } = useLGMethods(); + + const isFqdnQuery = useIsFqdn(queryTarget.value, queryType.value); + + function submitHandler() { + /** + * Before submitting a query, make sure the greeting is acknowledged if required. This should + * be handled before loading the app, but people be sneaky. + */ + if (!greetingReady()) { + resetForm(); + location.reload(); + } + + // Determine if queryTarget is an FQDN. + const isFqdn = isFqdnQuery(); + + if (greetingReady() && !isFqdn) { + return isSubmitting.set(true); + } + + if (greetingReady() && isFqdn) { + btnLoading.set(true); + return resolvedOpen(); + } else { + console.group('%cSomething went wrong', 'color:red;'); + console.table({ + 'Greeting Required': web.greeting.required, + 'Greeting Ready': greetingReady(), + 'Greeting Acknowledged': ack.value, + 'Query Target': queryTarget.value, + 'Query Type': queryType.value, + 'Is FQDN': isFqdn, + }); + console.groupEnd(); + } + } + + function handleLocChange(locations: string[]): void { + const allVrfs = [] as TDeviceVrf[][]; + + queryLocation.set(locations); + + // Create an array of each device's VRFs. + for (const loc of locations) { + const device = getDevice(loc); + allVrfs.push(device.vrfs); + } + + // Use _.intersectionWith to create an array of VRFs common to all selected locations. + const intersecting = intersectionWith( + ...allVrfs, + (a: TDeviceVrf, b: TDeviceVrf) => a.id === b.id, + ); + + availVrfs.set(intersecting); + + // If there are no intersecting VRFs, use the default VRF. + if ( + intersecting.filter(i => i.id === queryVrf.value).length === 0 && + queryVrf.value !== 'default' + ) { + queryVrf.set('default'); + } + + // Determine which address families are available in the intersecting VRFs. + let ipv4 = 0; + let ipv6 = 0; + + for (const intersection of intersecting) { + if (intersection.ipv4) { + // If IPv4 is enabled in this VRF, count it. + ipv4++; + } + if (intersection.ipv6) { + // If IPv6 is enabled in this VRF, count it. + ipv6++; + } + } + + if (ipv4 !== 0 && ipv4 === ipv6) { + /** + * If ipv4 & ipv6 are equal, this means every VRF has both IPv4 & IPv6 enabled. In that + * case, signal that both A & AAAA records should be queried if the query is an FQDN. + */ + families.set([4, 6]); + } else if (ipv4 > ipv6) { + /** + * If ipv4 is greater than ipv6, this means that IPv6 is not enabled on all VRFs, i.e. there + * are some VRFs with IPv4 enabled but IPv6 disabled. In that case, only query A records. + */ + families.set([4]); + } else if (ipv4 < ipv6) { + /** + * If ipv6 is greater than ipv4, this means that IPv4 is not enabled on all VRFs, i.e. there + * are some VRFs with IPv6 enabled but IPv4 disabled. In that case, only query AAAA records. + */ + families.set([6]); + } else { + /** + * If both ipv4 and ipv6 are 0, then both ipv4 and ipv6 are disabled, and why does that VRF + * even exist? + */ + families.set([]); + } + } + + function handleChange(e: OnChangeArgs): void { + // Signal the field & value to react-hook-form. + setValue(e.field, e.value); + + if (e.field === 'query_location' && Array.isArray(e.value)) { + handleLocChange(e.value); + } else if (e.field === 'query_type' && isQueryType(e.value)) { + queryType.set(e.value); + if (queryTarget.value !== '') { + /** + * Reset queryTarget as well, so that, for example, selecting BGP Community, and selecting + * a community, then changing the queryType to BGP Route doesn't preserve the selected + * community as the queryTarget. + */ + queryTarget.set(''); + displayTarget.set(''); + } + } else if (e.field === 'query_vrf' && isString(e.value)) { + queryVrf.set(e.value); + } else if (e.field === 'query_target' && isString(e.value)) { + queryTarget.set(e.value); + } + } + + /** + * Select the correct help content based on the selected VRF & Query Type. Also remove the icon + * if no locations are set. + */ + const vrfContent = useMemo(() => { + if (queryLocation.value.length === 0) { + return null; + } + if (Object.keys(content.vrf).includes(queryVrf.value) && queryType.value !== '') { + return content.vrf[queryVrf.value][queryType.value]; + } else { + return null; + } + }, [queryVrf.value, queryLocation.value, queryType.value]); + + useEffect(() => { + register({ name: 'query_location', required: true }); + register({ name: 'query_target', required: true }); + register({ name: 'query_type', required: true }); + register({ name: 'query_vrf' }); + }, [register]); + + return ( + <FormProvider {...formInstance}> + <AnimatedForm + p={0} + my={4} + w="100%" + mx="auto" + textAlign="left" + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3 }} + exit={{ opacity: 0, x: -300 }} + initial={{ opacity: 0, y: 300 }} + maxW={{ base: '100%', lg: '75%' }} + onSubmit={handleSubmit(submitHandler)}> + <FormRow> + <FormField name="query_location" label={web.text.query_location}> + <QueryLocation onChange={handleChange} label={web.text.query_location} /> + </FormField> + <FormField + name="query_type" + label={web.text.query_type} + labelAddOn={ + <HelpModal visible={isQueryContent(vrfContent)} item={vrfContent} name="query_type" /> + }> + <QueryType onChange={handleChange} label={web.text.query_type} /> + </FormField> + </FormRow> + <FormRow> + <If c={availVrfs.length > 1}> + <FormField label={web.text.query_vrf} name="query_vrf"> + <QueryVrf label={web.text.query_vrf} vrfs={availVrfs.value} onChange={handleChange} /> + </FormField> + </If> + <FormField name="query_target" label={web.text.query_target}> + <QueryTarget + name="query_target" + register={register} + onChange={handleChange} + placeholder={web.text.query_target} + /> + </FormField> + </FormRow> + <FormRow mt={0} justifyContent="flex-end"> + <Flex + my={2} + w="100%" + ml="auto" + maxW="100%" + flex="0 0 0" + flexDir="column" + mr={{ base: 0, lg: 2 }}> + <SubmitButton handleChange={handleChange} /> + </Flex> + </FormRow> + </AnimatedForm> + </FormProvider> + ); +}; diff --git a/hyperglass/ui/components/meta.tsx b/hyperglass/ui/components/meta.tsx new file mode 100644 index 0000000..e5b574c --- /dev/null +++ b/hyperglass/ui/components/meta.tsx @@ -0,0 +1,59 @@ +import { useEffect, useMemo, useState } from 'react'; +import Head from 'next/head'; +import { useTheme } from '@chakra-ui/react'; +import { useConfig } from '~/context'; +import { googleFontUrl } from '~/util'; + +export const Meta = () => { + const config = useConfig(); + const { fonts } = useTheme(); + const [location, setLocation] = useState('/'); + + const { + site_title: title = 'hyperglass', + site_description: description = 'Network Looking Glass', + site_keywords: keywords = [ + 'hyperglass', + 'looking glass', + 'lg', + 'peer', + 'peering', + 'ipv4', + 'ipv6', + 'transit', + 'community', + 'communities', + 'bgp', + 'routing', + 'network', + 'isp', + ], + } = useConfig(); + + const siteName = `${title} - ${description}`; + const primaryFont = useMemo(() => googleFontUrl(fonts.body), []); + const monoFont = useMemo(() => googleFontUrl(fonts.mono), []); + + useEffect(() => { + if (typeof window !== 'undefined' && location === '/') { + setLocation(window.location.href); + } + }, []); + + return ( + <Head> + <title>{title} + + + + + + + + + + + + + ); +}; diff --git a/hyperglass/ui/components/output/cell.tsx b/hyperglass/ui/components/output/cell.tsx new file mode 100644 index 0000000..db14c96 --- /dev/null +++ b/hyperglass/ui/components/output/cell.tsx @@ -0,0 +1,24 @@ +import { MonoField, Active, Weight, Age, Communities, RPKIState, ASPath } from './fields'; + +import type { TCell } from './types'; + +export const Cell = (props: TCell) => { + const { data, rawData } = props; + const cellId = data.column.id as keyof TRoute; + const component = { + med: , + age: , + prefix: , + next_hop: , + peer_rid: , + source_as: , + active: , + source_rid: , + local_preference: , + communities: , + as_path: , + rpki_state: , + weight: , + }; + return component[cellId] ?? <> ; +}; diff --git a/hyperglass/ui/components/output/fields.tsx b/hyperglass/ui/components/output/fields.tsx new file mode 100644 index 0000000..d156a99 --- /dev/null +++ b/hyperglass/ui/components/output/fields.tsx @@ -0,0 +1,178 @@ +import { forwardRef } from 'react'; +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 } from '@meronex/icons/md'; +import { RiHome2Fill as End } from '@meronex/icons/ri'; +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'; +import { If } from '~/components'; +import { useConfig, useColorValue } from '~/context'; +import { useOpposingColor } from '~/hooks'; + +import type { + TAge, + TActive, + TWeight, + TASPath, + TMonoField, + TRPKIState, + TCommunities, +} from './types'; + +dayjs.extend(relativeTimePlugin); +dayjs.extend(utcPlugin); + +export const MonoField = (props: TMonoField) => { + const { v, ...rest } = props; + return ( + + {v} + + ); +}; + +export const Active = (props: TActive) => { + const { isActive } = props; + const color = useColorValue(['gray.500', 'green.500'], ['whiteAlpha.300', 'blackAlpha.500']); + return ( + <> + + + + + + + + ); +}; + +export const Age = (props: TAge) => { + const { inSeconds, ...rest } = props; + const now = dayjs.utc(); + const then = now.subtract(inSeconds, 'second'); + return ( + + + {now.to(then, true)} + + + ); +}; + +export const Weight = (props: TWeight) => { + const { weight, winningWeight, ...rest } = props; + const fixMeText = + winningWeight === 'low' ? 'Lower Weight is Preferred' : 'Higher Weight is Preferred'; + return ( + + + {weight} + + + ); +}; + +export const ASPath = (props: TASPath) => { + const { path, active } = props; + const color = useColorValue( + // light: inactive, active + ['blackAlpha.500', 'blackAlpha.500'], + // dark: inactive, active + ['whiteAlpha.600', 'blackAlpha.700'], + ); + + if (path.length === 0) { + return ; + } + + let paths = [] as JSX.Element[]; + + path.map((asn, i) => { + const asnStr = String(asn); + i !== 0 && + paths.push( + , + ); + paths.push( + + {asnStr} + , + ); + }); + + return <>{paths}; +}; + +export const Communities = (props: TCommunities) => { + const { communities } = props; + const bg = useColorValue('white', 'gray.900'); + const color = useOpposingColor(bg); + return ( + <> + + + + + + + + + + + + {communities.join('\n')} + + + + + ); +}; + +export const RPKIState = forwardRef((props, ref) => { + const { state, active } = props; + const { web } = useConfig(); + const bg = useColorValue( + [ + ['red.400', 'green.500', 'yellow.400', 'gray.500'], + ['red.500', 'green.500', 'yellow.500', 'gray.600'], + ], + [ + ['red.300', 'green.300', 'yellow.300', 'gray.300'], + ['red.500', 'green.600', 'yellow.500', 'gray.800'], + ], + ); + const color = useOpposingColor(bg[+active][state]); + const icon = [NotAllowed, Check, Warning, Question]; + + const text = [ + web.text.rpki_invalid, + web.text.rpki_valid, + web.text.rpki_unknown, + web.text.rpki_unverified, + ]; + + return ( + + + + + + ); +}); diff --git a/hyperglass/ui/components/output/index.ts b/hyperglass/ui/components/output/index.ts new file mode 100644 index 0000000..c22d499 --- /dev/null +++ b/hyperglass/ui/components/output/index.ts @@ -0,0 +1,2 @@ +export * from './table'; +export * from './text'; diff --git a/hyperglass/ui/components/output/table.tsx b/hyperglass/ui/components/output/table.tsx new file mode 100644 index 0000000..68af8aa --- /dev/null +++ b/hyperglass/ui/components/output/table.tsx @@ -0,0 +1,45 @@ +import { Flex } from '@chakra-ui/react'; +import { useConfig } from '~/context'; +import { Table } from '~/components'; +import { Cell } from './cell'; + +import type { TColumn, TParsedDataField, TCellRender } from '~/types'; +import type { TBGPTable } from './types'; + +function makeColumns(fields: TParsedDataField[]): TColumn[] { + return fields.map(pair => { + const [header, accessor, align] = pair; + + let columnConfig = { + align, + accessor, + hidden: false, + Header: header, + } as TColumn; + + if (align === null) { + columnConfig.hidden = true; + } + + return columnConfig; + }); +} + +export const BGPTable = (props: TBGPTable) => { + const { children: data, ...rest } = props; + const { parsed_data_fields } = useConfig(); + const columns = makeColumns(parsed_data_fields); + + return ( + +
} + bordersHorizontal + rowHighlightBg="green" + /> + + ); +}; diff --git a/hyperglass/ui/components/output/text.tsx b/hyperglass/ui/components/output/text.tsx new file mode 100644 index 0000000..93a1cc4 --- /dev/null +++ b/hyperglass/ui/components/output/text.tsx @@ -0,0 +1,38 @@ +import { Box } from '@chakra-ui/react'; +import { useColorValue } from '~/context'; + +import type { TTextOutput } from './types'; + +export const TextOutput = (props: TTextOutput) => { + const { children, ...rest } = props; + + const bg = useColorValue('blackAlpha.100', 'gray.800'); + const color = useColorValue('black', 'white'); + const selectionBg = useColorValue('black', 'white'); + const selectionColor = useColorValue('white', 'black'); + + return ( + + {children.split('\\n').join('\n').replace(/\n\n/g, '\n')} + + ); +}; diff --git a/hyperglass/ui/components/output/types.ts b/hyperglass/ui/components/output/types.ts new file mode 100644 index 0000000..a492625 --- /dev/null +++ b/hyperglass/ui/components/output/types.ts @@ -0,0 +1,50 @@ +import type { BoxProps, FlexProps, TextProps } from '@chakra-ui/react'; +import type { TCellRender } from '~/types'; + +export interface TTextOutput extends Omit { + children: string; +} + +export interface TActive { + isActive: boolean; +} + +export interface TMonoField extends TextProps { + v: React.ReactNode; +} + +export interface TAge extends TextProps { + inSeconds: number; +} + +export interface TWeight extends TextProps { + weight: number; + winningWeight: 'low' | 'high'; +} + +export interface TASPath { + path: number[]; + active: boolean; +} + +export interface TCommunities { + communities: string[]; +} + +export interface TRPKIState { + state: + | 0 // Invalid + | 1 // Valid + | 2 // Unknown + | 3; // Unverified + active: boolean; +} + +export interface TCell { + data: TCellRender; + rawData: TStructuredResponse; +} + +export interface TBGPTable extends Omit { + children: TStructuredResponse; +} diff --git a/hyperglass/ui/components/path/button.tsx b/hyperglass/ui/components/path/button.tsx new file mode 100644 index 0000000..68bf21b --- /dev/null +++ b/hyperglass/ui/components/path/button.tsx @@ -0,0 +1,19 @@ +import dynamic from 'next/dynamic'; +import { Button, Icon, Tooltip } from '@chakra-ui/react'; + +import type { TPathButton } from './types'; + +const PathIcon = dynamic(() => + import('@meronex/icons/bi').then(i => i.BisNetworkChart), +); + +export const PathButton = (props: TPathButton) => { + const { onOpen } = props; + return ( + + + + ); +}; diff --git a/hyperglass/ui/components/path/chart.tsx b/hyperglass/ui/components/path/chart.tsx new file mode 100644 index 0000000..23020ab --- /dev/null +++ b/hyperglass/ui/components/path/chart.tsx @@ -0,0 +1,74 @@ +import { useMemo } from 'react'; +import { Box, Flex, SkeletonText, Badge, VStack } from '@chakra-ui/react'; +import ReactFlow from 'react-flow-renderer'; +import { Background, ReactFlowProvider } from 'react-flow-renderer'; +import { Handle, Position } from 'react-flow-renderer'; +import { useConfig, useColorValue, useColorToken, useBreakpointValue } from '~/context'; +import { useASNDetail } from '~/hooks'; +import { Controls } from './controls'; +import { buildElements } from './util'; + +import type { ReactFlowProps } from 'react-flow-renderer'; +import type { TChart, TNode, TNodeData } from './types'; + +export const Chart = (props: TChart) => { + const { data } = props; + const { primary_asn, org_name } = useConfig(); + + const dots = useColorToken('colors', 'blackAlpha.500', 'whiteAlpha.400'); + + const flowProps = useBreakpointValue>({ + base: { defaultPosition: [0, 300], defaultZoom: 0 }, + lg: { defaultPosition: [100, 300], defaultZoom: 0.7 }, + }) ?? { defaultPosition: [100, 300], defaultZoom: 0.7 }; + + const elements = useMemo(() => [...buildElements({ asn: primary_asn, name: org_name }, data)], [ + data, + ]); + + return ( + + + + + + + + + ); +}; + +const ASNode = (props: TNode) => { + const { data } = props; + const { asn, name, hasChildren, hasParents } = data; + + const color = useColorValue('black', 'white'); + const bg = useColorValue('white', 'whiteAlpha.100'); + + const { data: asnData, isError, isLoading } = useASNDetail(String(asn)); + + return ( + <> + {hasChildren && } + + + + {isLoading ? ( + + + + ) : !isError && asnData?.data?.asn.organization?.orgName ? ( + asnData.data.asn.organization.orgName + ) : ( + name + )} + + + {asn} + + + + {hasParents && } + + ); +}; diff --git a/hyperglass/ui/components/path/controls.tsx b/hyperglass/ui/components/path/controls.tsx new file mode 100644 index 0000000..5d9dd43 --- /dev/null +++ b/hyperglass/ui/components/path/controls.tsx @@ -0,0 +1,27 @@ +import dynamic from 'next/dynamic'; +import { ButtonGroup, IconButton } from '@chakra-ui/react'; +import { useZoomPanHelper } from 'react-flow-renderer'; + +const Plus = dynamic(() => import('@meronex/icons/fi').then(i => i.FiPlus)); +const Minus = dynamic(() => import('@meronex/icons/fi').then(i => i.FiMinus)); +const Square = dynamic(() => import('@meronex/icons/fi').then(i => i.FiSquare)); + +export const Controls = () => { + const { fitView, zoomIn, zoomOut } = useZoomPanHelper(); + return ( + + } onClick={() => zoomIn()} aria-label="Zoom In" /> + } onClick={() => zoomOut()} aria-label="Zoom Out" /> + } onClick={() => fitView()} aria-label="Fit Nodes" /> + + ); +}; diff --git a/hyperglass/ui/components/path/index.ts b/hyperglass/ui/components/path/index.ts new file mode 100644 index 0000000..44bb0aa --- /dev/null +++ b/hyperglass/ui/components/path/index.ts @@ -0,0 +1 @@ +export * from './path'; diff --git a/hyperglass/ui/components/path/path.tsx b/hyperglass/ui/components/path/path.tsx new file mode 100644 index 0000000..2b94267 --- /dev/null +++ b/hyperglass/ui/components/path/path.tsx @@ -0,0 +1,42 @@ +import { + Modal, + ModalBody, + ModalHeader, + ModalOverlay, + ModalContent, + useDisclosure, + Skeleton, + ModalCloseButton, +} from '@chakra-ui/react'; +import { useColorValue } from '~/context'; +import { useLGState, useLGMethods } from '~/hooks'; +import { PathButton } from './button'; +import { Chart } from './chart'; + +import type { TPath } from './types'; + +export const Path = (props: TPath) => { + const { device } = props; + const { displayTarget } = useLGState(); + const { getResponse } = useLGMethods(); + const { isOpen, onClose, onOpen } = useDisclosure(); + const response = getResponse(device); + const output = response?.output as TStructuredResponse; + const bg = useColorValue('whiteSolid.50', 'blackSolid.900'); + + return ( + <> + + + + + {`Path to ${displayTarget.value}`} + + + {response !== null ? : } + + + + + ); +}; diff --git a/hyperglass/ui/components/path/types.ts b/hyperglass/ui/components/path/types.ts new file mode 100644 index 0000000..067b065 --- /dev/null +++ b/hyperglass/ui/components/path/types.ts @@ -0,0 +1,29 @@ +import type { NodeProps } from 'react-flow-renderer'; + +export interface TChart { + data: TStructuredResponse; +} + +export interface TPath { + device: string; +} + +export interface TNode extends Omit { + data: D; +} + +export interface TNodeData { + asn: string; + name: string; + hasChildren: boolean; + hasParents?: boolean; +} + +export interface BasePath { + asn: string; + name: string; +} + +export interface TPathButton { + onOpen(): void; +} diff --git a/hyperglass/ui/components/path/util.ts b/hyperglass/ui/components/path/util.ts new file mode 100644 index 0000000..0f17b4b --- /dev/null +++ b/hyperglass/ui/components/path/util.ts @@ -0,0 +1,68 @@ +import { arrangeIntoTree } from '~/util'; + +import type { FlowElement, Elements } from 'react-flow-renderer'; +import type { PathPart } from '~/types'; +import type { BasePath } from './types'; + +function treeToElement(part: PathPart, len: number, index: number): FlowElement[] { + const x = index * 250; + const y = -(len * 10); + let elements = [ + { + id: String(part.base), + type: 'ASNode', + position: { x, y }, + data: { + asn: part.base, + name: `AS${part.base}`, + hasChildren: part.children.length !== 0, + hasParents: true, + }, + }, + ] as Elements; + + for (const child of part.children) { + let xc = index; + if (part.children.length !== 0) { + elements.push({ + id: `e${part.base}-${child.base}`, + source: String(part.base), + target: String(child.base), + }); + } else { + xc = x; + } + elements.push(...treeToElement(child, part.children.length * 12 + len, xc)); + } + return elements; +} + +export function* buildElements(base: BasePath, data: TStructuredResponse): Generator { + const { routes } = data; + // Eliminate empty AS paths & deduplicate non-empty AS paths. Length should be same as count minus empty paths. + const asPaths = routes.filter(r => r.as_path.length !== 0).map(r => [...new Set(r.as_path)]); + const asTree = arrangeIntoTree(asPaths); + const numHops = asPaths.flat().length; + const childPaths = asTree.map((a, i) => { + return treeToElement(a, asTree.length, i); + }); + + // Add the first hop at the base. + yield { + id: base.asn, + type: 'ASNode', + position: { x: 150, y: numHops * 10 }, + data: { asn: base.asn, name: base.name, hasChildren: true, hasParents: false }, + }; + + for (const [i, path] of childPaths.entries()) { + // path = Each unique path from origin + const first = path[0]; + yield { id: `e${base.asn}-${first.id}`, source: base.asn, target: first.id }; + // Add link from base to each first hop. + yield { id: `e${base.asn}-${first.id}`, source: base.asn, target: first.id }; + for (const hop of path) { + yield hop; + } + } +} diff --git a/hyperglass/ui/components/results/copyButton.tsx b/hyperglass/ui/components/results/copyButton.tsx new file mode 100644 index 0000000..4f84ee0 --- /dev/null +++ b/hyperglass/ui/components/results/copyButton.tsx @@ -0,0 +1,26 @@ +import dynamic from 'next/dynamic'; +import { Button, Icon, Tooltip, useClipboard } from '@chakra-ui/react'; + +const Copy = dynamic(() => import('@meronex/icons/fi').then(i => i.FiCopy)); +const Check = dynamic(() => import('@meronex/icons/fi').then(i => i.FiCheck)); + +import type { TCopyButton } from './types'; + +export const CopyButton = (props: TCopyButton) => { + const { copyValue, ...rest } = props; + const { onCopy, hasCopied } = useClipboard(copyValue); + return ( + + + + ); +}; diff --git a/hyperglass/ui/components/results/error.tsx b/hyperglass/ui/components/results/error.tsx new file mode 100644 index 0000000..88c6db7 --- /dev/null +++ b/hyperglass/ui/components/results/error.tsx @@ -0,0 +1,30 @@ +import { Text } from '@chakra-ui/react'; + +import type { TFormattedError } from './types'; + +type TFormatError = string | JSX.Element; + +function formatError(text: string, values: string[], regex: RegExp): TFormatError[] | TFormatError { + if (!values.length) { + return text; + } + + const parts = text.split(regex); + + return parts.reduce((prev, current, i) => { + if (!i) { + return [current]; + } + + return prev.concat( + values.includes(current) ? {current} : current, + ); + }, [] as TFormatError[]); +} + +export const FormattedError = (props: TFormattedError) => { + const { keywords, message } = props; + const pattern = new RegExp(keywords.map(kw => `(${kw})`).join('|'), 'gi'); + const things = formatError(message, keywords, pattern); + return {keywords.length !== 0 ? things : message}; +}; diff --git a/hyperglass/ui/components/results/group.tsx b/hyperglass/ui/components/results/group.tsx new file mode 100644 index 0000000..db4d3d1 --- /dev/null +++ b/hyperglass/ui/components/results/group.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from 'react'; +import { Accordion, Box, Stack, useToken } from '@chakra-ui/react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { AnimatedDiv, Label } from '~/components'; +import { useConfig, useBreakpointValue } from '~/context'; +import { useDevice, useLGState } from '~/hooks'; +import { isQueryType } from '~/types'; +import { Result } from './individual'; + +export const Results = () => { + const { queries, vrfs, web } = useConfig(); + const { queryLocation, queryTarget, queryType, queryVrf } = useLGState(); + + const getDevice = useDevice(); + const targetBg = useToken('colors', 'teal.600'); + const queryBg = useToken('colors', 'cyan.500'); + const vrfBg = useToken('colors', 'blue.500'); + + const animateLeft = useBreakpointValue({ + base: { opacity: 1, x: 0 }, + md: { opacity: 1, x: 0 }, + lg: { opacity: 1, x: 0 }, + xl: { opacity: 1, x: 0 }, + }); + + const animateCenter = useBreakpointValue({ + base: { opacity: 1 }, + md: { opacity: 1 }, + lg: { opacity: 1 }, + xl: { opacity: 1 }, + }); + + const animateRight = useBreakpointValue({ + base: { opacity: 1, x: 0 }, + md: { opacity: 1, x: 0 }, + lg: { opacity: 1, x: 0 }, + xl: { opacity: 1, x: 0 }, + }); + + const initialLeft = useBreakpointValue({ + base: { opacity: 0, x: -100 }, + md: { opacity: 0, x: -100 }, + lg: { opacity: 0, x: -100 }, + xl: { opacity: 0, x: -100 }, + }); + + const initialCenter = useBreakpointValue({ + base: { opacity: 0 }, + md: { opacity: 0 }, + lg: { opacity: 0 }, + xl: { opacity: 0 }, + }); + + const initialRight = useBreakpointValue({ + base: { opacity: 0, x: 100 }, + md: { opacity: 0, x: 100 }, + lg: { opacity: 0, x: 100 }, + xl: { opacity: 0, x: 100 }, + }); + + const [resultsComplete, setComplete] = useState([]); + + const matchedVrf = + vrfs.filter(v => v.id === queryVrf.value)[0] ?? vrfs.filter(v => v.id === 'default')[0]; + + let queryTypeLabel = ''; + if (isQueryType(queryType.value)) { + queryTypeLabel = queries[queryType.value].display_name; + } + + // Scroll to the top of the page when results load - primarily for mobile. + useEffect(() => { + if (typeof window !== 'undefined') { + window.scrollTo(0, 0); + } + }, []); + + return ( + <> + + + + {queryLocation.value && ( + <> + + + + + + + + )} + + + + + + + {queryLocation.value && + queryLocation.map((loc, i) => { + const device = getDevice(loc.value); + return ( + + ); + })} + + + + + ); +}; diff --git a/hyperglass/ui/components/results/guards.ts b/hyperglass/ui/components/results/guards.ts new file mode 100644 index 0000000..a01a1b8 --- /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 typeof error !== 'undefined' && error !== null && 'output' in error; +} diff --git a/hyperglass/ui/components/results/header.tsx b/hyperglass/ui/components/results/header.tsx new file mode 100644 index 0000000..b025eb9 --- /dev/null +++ b/hyperglass/ui/components/results/header.tsx @@ -0,0 +1,58 @@ +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'; +import { useOpposingColor, useStrf } from '~/hooks'; + +import type { TResultHeader } from './types'; + +const runtimeText = (runtime: number, text: string): string => { + let unit = 'seconds'; + if (runtime === 1) { + unit = 'second'; + } + return `${text} ${unit}`; +}; + +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`); + const defaultStatus = useColorValue('success.500', 'success.300'); + + const { web } = useConfig(); + const text = useStrf(web.text.complete_time, { seconds: runtime }, [runtime]); + const label = useMemo(() => runtimeText(runtime, text), [runtime]); + + const color = useOpposingColor(isError ? warning : defaultStatus); + + return ( + + + + {loading ? ( + + ) : ( + + )} + + + + {title} + + + ); +}; diff --git a/hyperglass/ui/components/results/index.ts b/hyperglass/ui/components/results/index.ts new file mode 100644 index 0000000..8a78f59 --- /dev/null +++ b/hyperglass/ui/components/results/index.ts @@ -0,0 +1 @@ +export * from './group'; diff --git a/hyperglass/ui/components/results/individual.tsx b/hyperglass/ui/components/results/individual.tsx new file mode 100644 index 0000000..928684a --- /dev/null +++ b/hyperglass/ui/components/results/individual.tsx @@ -0,0 +1,268 @@ +import { forwardRef, useEffect, useMemo, useState } from 'react'; +import { + Box, + Flex, + Alert, + Tooltip, + Icon, + HStack, + AccordionItem, + AccordionPanel, + AccordionButton, +} from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import { BsLightningFill } from '@meronex/icons/bs'; +import { startCase } from 'lodash'; +import { BGPTable, Countdown, TextOutput, If, Path } from '~/components'; +import { useColorValue, useConfig, useMobile } from '~/context'; +import { useStrf, useLGQuery, useLGState, useTableToString } from '~/hooks'; +import { isStructuredOutput, isStringOutput } from '~/types'; +import { isStackError, isFetchError, isLGError } from './guards'; +import { RequeryButton } from './requeryButton'; +import { CopyButton } from './copyButton'; +import { FormattedError } from './error'; +import { ResultHeader } from './header'; + +import type { TAccordionHeaderWrapper, TResult, TErrorLevels } from './types'; + +const AnimatedAccordionItem = motion.custom(AccordionItem); + +const AccordionHeaderWrapper = (props: TAccordionHeaderWrapper) => { + const { hoverBg, ...rest } = props; + return ( + + ); +}; + +export const Result = forwardRef((props, ref) => { + const { + index, + device, + queryVrf, + queryType, + queryTarget, + setComplete, + queryLocation, + resultsComplete, + } = props; + + const { web, cache, messages } = useConfig(); + + const isMobile = useMobile(); + const color = useColorValue('black', 'white'); + const scrollbar = useColorValue('blackAlpha.300', 'whiteAlpha.300'); + const scrollbarHover = useColorValue('blackAlpha.400', 'whiteAlpha.400'); + const scrollbarBg = useColorValue('blackAlpha.50', 'whiteAlpha.50'); + + const { responses } = useLGState(); + + const { data, error, isError, isLoading, refetch, isFetching, isFetchedAfterMount } = useLGQuery({ + queryLocation, + queryTarget, + queryType, + queryVrf, + }); + + const isCached = useMemo(() => data?.cached || !isFetchedAfterMount, [ + data, + isLoading, + isFetching, + ]); + + if (typeof data !== 'undefined') { + responses.merge({ [device.name]: data }); + } + + const cacheLabel = useStrf(web.text.cache_icon, { time: data?.timestamp }, [data?.timestamp]); + + const handleToggle = () => { + // Close if open. + if (resultsComplete.includes(index)) { + setComplete(p => p.filter(i => i !== index)); + } + // Open if closed. + else if (!resultsComplete.includes(index)) { + setComplete(p => [...p, index]); + } + }; + + const errorKeywords = useMemo(() => { + let kw = [] as string[]; + if (isLGError(data)) { + kw = data.keywords; + } + return kw; + }, [data]); + + let errorMsg; + + 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 (isStackError(error)) { + errorMsg = startCase(error.message); + } else { + errorMsg = messages.general; + } + + isError && console.error(error); + + const errorLevel = useMemo(() => { + const statusMap = { + success: 'success', + warning: 'warning', + error: 'warning', + danger: 'error', + } as { [k in TResponseLevel]: 'success' | 'warning' | 'error' }; + + let e: TErrorLevels = 'error'; + + if (isLGError(error)) { + const idx = error.level as TResponseLevel; + e = statusMap[idx]; + } + return e; + }, [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]); + + let copyValue = data?.output as string; + + const formatData = useTableToString(queryTarget, data, [data?.format]); + + if (data?.format === 'application/json') { + copyValue = formatData(); + } + + if (error) { + copyValue = errorMsg; + } + + // If this is the first completed result, open it. + useEffect(() => { + if (!isLoading && !isError && resultsComplete.length === 0) { + setComplete([index]); + } + }, [isLoading, isError]); + + return ( + + + + + + + {isStructuredOutput(data) && data.level === 'success' && tableComponent && ( + + )} + + + + + + + + {!isError && typeof data !== 'undefined' ? ( + <> + {isStructuredOutput(data) && data.level === 'success' && tableComponent ? ( + {data.output} + ) : isStringOutput(data) && data.level === 'success' && !tableComponent ? ( + {data.output} + ) : isStringOutput(data) && data.level !== 'success' ? ( + + + + ) : ( + + + + )} + + ) : ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + ); +}); diff --git a/hyperglass/ui/components/results/requeryButton.tsx b/hyperglass/ui/components/results/requeryButton.tsx new file mode 100644 index 0000000..3eb0c81 --- /dev/null +++ b/hyperglass/ui/components/results/requeryButton.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from 'react'; +import dynamic from 'next/dynamic'; +import { Button, Icon, Tooltip } from '@chakra-ui/react'; + +import type { TRequeryButton } from './types'; + +const Repeat = dynamic(() => import('@meronex/icons/fi').then(i => i.FiRepeat)); + +export const RequeryButton = forwardRef((props, ref) => { + const { requery, ...rest } = props; + + return ( + + + + ); +}); diff --git a/hyperglass/ui/components/results/types.ts b/hyperglass/ui/components/results/types.ts new file mode 100644 index 0000000..5559774 --- /dev/null +++ b/hyperglass/ui/components/results/types.ts @@ -0,0 +1,42 @@ +import type { ButtonProps, FlexProps } from '@chakra-ui/react'; +import type { UseQueryResult } from 'react-query'; +import type { TDevice, TQueryTypes } from '~/types'; + +export interface TResultHeader { + title: string; + loading: boolean; + isError?: boolean; + errorMsg: string; + errorLevel: 'success' | 'warning' | 'error'; + runtime: number; +} + +export interface TFormattedError { + keywords: string[]; + message: string; +} + +export interface TAccordionHeaderWrapper extends FlexProps { + hoverBg: FlexProps['bg']; +} + +export interface TResult { + index: number; + device: TDevice; + queryVrf: string; + queryTarget: string; + queryLocation: string; + queryType: TQueryTypes; + resultsComplete: number[]; + setComplete: React.Dispatch>; +} + +export type TErrorLevels = 'success' | 'warning' | 'error'; + +export interface TCopyButton extends ButtonProps { + copyValue: string; +} + +export interface TRequeryButton extends ButtonProps { + requery: UseQueryResult['refetch']; +} diff --git a/hyperglass/ui/components/select/index.ts b/hyperglass/ui/components/select/index.ts new file mode 100644 index 0000000..c739673 --- /dev/null +++ b/hyperglass/ui/components/select/index.ts @@ -0,0 +1 @@ +export * from './select'; diff --git a/hyperglass/ui/components/select/select.tsx b/hyperglass/ui/components/select/select.tsx new file mode 100644 index 0000000..9e38f0d --- /dev/null +++ b/hyperglass/ui/components/select/select.tsx @@ -0,0 +1,82 @@ +import { createContext, useContext, useMemo } from 'react'; +import ReactSelect from 'react-select'; +import { chakra, useDisclosure } from '@chakra-ui/react'; +import { useColorMode } from '~/context'; +import { + useRSTheme, + useMenuStyle, + useMenuPortal, + useOptionStyle, + useControlStyle, + useMenuListStyle, + useMultiValueStyle, + usePlaceholderStyle, + useSingleValueStyle, + useMultiValueLabelStyle, + useMultiValueRemoveStyle, + useIndicatorSeparatorStyle, +} from './styles'; + +import type { TSelectOption } from '~/types'; +import type { TSelectBase, TSelectContext, TReactSelectChakra } from './types'; + +const SelectContext = createContext(Object()); +export const useSelectContext = () => useContext(SelectContext); + +const ReactSelectChakra = chakra(ReactSelect); + +export const Select = (props: TSelectBase) => { + const { options, multi, onSelect, isError = false, ...rest } = props; + const { isOpen, onOpen, onClose } = useDisclosure(); + + const { colorMode } = useColorMode(); + + const selectContext = useMemo(() => ({ colorMode, isOpen, isError }), [ + colorMode, + isError, + isOpen, + ]); + + const defaultOnChange = (changed: TSelectOption | TSelectOption[]) => { + if (!Array.isArray(changed)) { + changed = [changed]; + } + if (typeof onSelect === 'function') { + onSelect(changed); + } + }; + + const multiValue = useMultiValueStyle({ colorMode }); + const multiValueLabel = useMultiValueLabelStyle({ colorMode }); + const multiValueRemove = useMultiValueRemoveStyle({ colorMode }); + const menuPortal = useMenuPortal({ colorMode }); + const rsTheme = useRSTheme({ colorMode }); + + return ( + + + + ); +}; diff --git a/hyperglass/ui/components/select/styles.tsx b/hyperglass/ui/components/select/styles.tsx new file mode 100644 index 0000000..062c2f3 --- /dev/null +++ b/hyperglass/ui/components/select/styles.tsx @@ -0,0 +1,206 @@ +import { useCallback, useMemo } from 'react'; +import { useToken } from '@chakra-ui/react'; +import { mergeWith } from '@chakra-ui/utils'; +import { useOpposingColor } from '~/hooks'; +import { useColorValue, useColorToken, useMobile } from '~/context'; +import { useSelectContext } from './select'; + +import type { + TControl, + TIndicator, + TMenu, + TMenuList, + TMultiValueState, + TOption, + TPlaceholder, + TStyles, + TRSTheme, + TMultiValue, +} from './types'; + +export const useControlStyle = (base: TStyles, state: TControl): TStyles => { + const { isFocused } = state; + const { colorMode, isError } = useSelectContext(); + + const minHeight = useToken('space', 12); + const borderRadius = useToken('radii', 'md'); + const color = useColorToken('colors', 'black', 'whiteAlpha.800'); + const focusBorder = useColorToken('colors', 'blue.500', 'blue.300'); + const invalidBorder = useColorToken('colors', 'red.500', 'red.300'); + const borderColor = useColorToken('colors', 'gray.100', 'whiteAlpha.50'); + const borderHover = useColorToken('colors', 'gray.300', 'whiteAlpha.400'); + const backgroundColor = useColorToken('colors', 'white', 'whiteAlpha.100'); + + const styles = { + backgroundColor, + borderRadius, + color, + minHeight, + transition: 'all 0.2s', + borderColor: isError ? invalidBorder : isFocused ? focusBorder : borderColor, + boxShadow: isError + ? `0 0 0 1px ${invalidBorder}` + : isFocused + ? `0 0 0 1px ${focusBorder}` + : undefined, + '&:hover': { borderColor: isFocused ? focusBorder : borderHover }, + '&:hover > div > span': { backgroundColor: borderHover }, + '&:focus': { borderColor: isError ? invalidBorder : focusBorder }, + '&.invalid': { borderColor: invalidBorder, boxShadow: `0 0 0 1px ${invalidBorder}` }, + }; + return useMemo(() => mergeWith({}, base, styles), [colorMode, isFocused, isError]); +}; + +export const useMenuStyle = (base: TStyles, _: TMenu): TStyles => { + const { colorMode, isOpen } = useSelectContext(); + const backgroundColor = useColorToken('colors', 'white', 'blackSolid.700'); + const borderRadius = useToken('radii', 'md'); + const styles = { borderRadius, backgroundColor }; + return useMemo(() => mergeWith({}, base, styles), [colorMode, isOpen]); +}; + +export const useMenuListStyle = (base: TStyles, state: TMenuList): TStyles => { + const { colorMode, isOpen } = useSelectContext(); + + const scrollbarTrack = useColorToken('colors', 'blackAlpha.50', 'whiteAlpha.50'); + const scrollbarThumb = useColorToken('colors', 'blackAlpha.300', 'whiteAlpha.300'); + const scrollbarThumbHover = useColorToken('colors', 'blackAlpha.400', 'whiteAlpha.400'); + + const styles = { + '&::-webkit-scrollbar': { width: '5px' }, + '&::-webkit-scrollbar-track': { backgroundColor: scrollbarTrack }, + '&::-webkit-scrollbar-thumb': { backgroundColor: scrollbarThumb }, + '&::-webkit-scrollbar-thumb:hover': { backgroundColor: scrollbarThumbHover }, + '-ms-overflow-style': { display: 'none' }, + }; + return useMemo(() => mergeWith({}, base, styles), [colorMode, isOpen]); +}; + +export const useOptionStyle = (base: TStyles, state: TOption): TStyles => { + const { isFocused, isSelected, isDisabled } = state; + const { colorMode, isOpen } = useSelectContext(); + + const fontSize = useToken('fontSizes', 'lg'); + const disabled = useToken('colors', 'whiteAlpha.400'); + const active = useColorToken('colors', 'primary.600', 'primary.400'); + const focused = useColorToken('colors', 'primary.500', 'primary.300'); + const selected = useColorToken('colors', 'blackAlpha.400', 'whiteAlpha.400'); + + const activeColor = useOpposingColor(active); + + const backgroundColor = useMemo(() => { + let bg = 'transparent'; + switch (true) { + case isDisabled: + bg = disabled; + break; + case isSelected: + bg = selected; + break; + case isFocused: + bg = focused; + break; + } + return bg; + }, [isDisabled, isFocused, isSelected]); + + const color = useOpposingColor(backgroundColor); + + const styles = { + color: backgroundColor === 'transparent' ? 'currentColor' : color, + '&:active': { backgroundColor: active, color: activeColor }, + '&:focus': { backgroundColor: active, color: activeColor }, + backgroundColor, + fontSize, + }; + + return useMemo(() => mergeWith({}, base, styles), [ + isOpen, + colorMode, + isFocused, + isDisabled, + isSelected, + ]); +}; + +export const useIndicatorSeparatorStyle = (base: TStyles, state: TIndicator): TStyles => { + const { colorMode } = useSelectContext(); + const backgroundColor = useColorToken('colors', 'whiteAlpha.700', 'gray.600'); + const styles = { backgroundColor }; + return useMemo(() => mergeWith({}, base, styles), [colorMode]); +}; + +export const usePlaceholderStyle = (base: TStyles, state: TPlaceholder): TStyles => { + const { colorMode } = useSelectContext(); + const color = useColorToken('colors', 'gray.600', 'whiteAlpha.700'); + const fontSize = useToken('fontSizes', 'lg'); + return useMemo(() => mergeWith({}, base, { color, fontSize }), [colorMode]); +}; + +export const useSingleValueStyle = (props: TStyles) => { + const { colorMode } = useSelectContext(); + + const color = useColorValue('black', 'whiteAlpha.800'); + const fontSize = useToken('fontSizes', 'lg'); + + const styles = { color, fontSize }; + return useCallback((base: TStyles, state: TMultiValueState) => mergeWith({}, base, styles), [ + color, + colorMode, + ]); +}; + +export const useMultiValueStyle = (props: TMultiValue) => { + const { colorMode } = props; + + const backgroundColor = useColorToken('colors', 'primary.500', 'primary.300'); + const color = useOpposingColor(backgroundColor); + + const styles = { backgroundColor, color }; + return useCallback((base: TStyles, state: TMultiValueState) => mergeWith({}, base, styles), [ + backgroundColor, + colorMode, + ]); +}; + +export const useMultiValueLabelStyle = (props: TMultiValue) => { + const { colorMode } = props; + + const backgroundColor = useColorToken('colors', 'primary.500', 'primary.300'); + const color = useOpposingColor(backgroundColor); + + const styles = { color }; + return useCallback((base: TStyles, state: TMultiValueState) => mergeWith({}, base, styles), [ + colorMode, + ]); +}; + +export const useMultiValueRemoveStyle = (props: TMultiValue) => { + const { colorMode } = props; + + const backgroundColor = useColorToken('colors', 'primary.500', 'primary.300'); + const color = useOpposingColor(backgroundColor); + + const styles = { + color, + '&:hover': { backgroundColor: 'inherit', color, opacity: 0.7 }, + }; + return useCallback((base: TStyles, state: TMultiValueState) => mergeWith({}, base, styles), [ + colorMode, + ]); +}; + +export const useRSTheme = (props: TMultiValue) => { + const borderRadius = useToken('radii', 'md'); + return useCallback((t: TRSTheme): TRSTheme => ({ ...t, borderRadius }), []); +}; + +export const useMenuPortal = (props: TMultiValue) => { + const isMobile = useMobile(); + const styles = { + zIndex: isMobile ? 1500 : 1, + }; + return useCallback((base: TStyles, state: TMultiValueState) => mergeWith({}, base, styles), [ + isMobile, + ]); +}; diff --git a/hyperglass/ui/components/select/types.ts b/hyperglass/ui/components/select/types.ts new file mode 100644 index 0000000..46d1393 --- /dev/null +++ b/hyperglass/ui/components/select/types.ts @@ -0,0 +1,73 @@ +import type { + Props as IReactSelect, + ControlProps, + MenuProps, + MenuListComponentProps, + OptionProps, + MultiValueProps, + IndicatorProps, + Theme as RSTheme, + PlaceholderProps, +} from 'react-select'; +import type { BoxProps } from '@chakra-ui/react'; +import type { Theme, TSelectOption, TSelectOptionMulti, TSelectOptionGroup } from '~/types'; + +export interface TSelectState { + [k: string]: string[]; +} + +export type TOptions = Array; + +export type TReactSelectChakra = Omit & + Omit; + +export interface TSelectBase extends TReactSelectChakra { + name: string; + multi?: boolean; + isError?: boolean; + options: TOptions; + required?: boolean; + onSelect?: (s: TSelectOption[]) => void; + onChange?: (c: TSelectOption | TSelectOptionMulti) => void; + colorScheme?: Theme.ColorNames; +} + +export interface TSelectContext { + colorMode: 'light' | 'dark'; + isOpen: boolean; + isError: boolean; +} + +export interface TMultiValueRemoveProps { + children: Node; + data: any; + innerProps: { + className: string; + onTouchEnd: (e: any) => void; + onClick: (e: any) => void; + onMouseDown: (e: any) => void; + }; + selectProps: any; +} + +export interface TRSTheme extends Omit { + borderRadius: string | number; +} + +export type TControl = ControlProps; + +export type TMenu = MenuProps; + +export type TMenuList = MenuListComponentProps; + +export type TOption = OptionProps; + +export type TMultiValueState = MultiValueProps; + +export type TIndicator = IndicatorProps; + +export type TPlaceholder = PlaceholderProps; + +export type TMultiValue = Pick; + +export type { Styles as TStyles } from 'react-select'; diff --git a/hyperglass/ui/components/submit/index.ts b/hyperglass/ui/components/submit/index.ts new file mode 100644 index 0000000..e9be2e3 --- /dev/null +++ b/hyperglass/ui/components/submit/index.ts @@ -0,0 +1 @@ +export * from './submit'; diff --git a/hyperglass/ui/components/submit/submit.tsx b/hyperglass/ui/components/submit/submit.tsx new file mode 100644 index 0000000..5f02bb0 --- /dev/null +++ b/hyperglass/ui/components/submit/submit.tsx @@ -0,0 +1,122 @@ +import { forwardRef } from 'react'; +import { + Modal, + Popover, + ModalBody, + IconButton, + PopoverBody, + ModalOverlay, + ModalContent, + PopoverArrow, + PopoverTrigger, + PopoverContent, + ModalCloseButton, + PopoverCloseButton, +} from '@chakra-ui/react'; +import { FiSearch } from '@meronex/icons/fi'; +import { useFormContext } from 'react-hook-form'; +import { If, ResolvedTarget } from '~/components'; +import { useMobile, useColorValue } from '~/context'; +import { useLGState, useLGMethods } from '~/hooks'; + +import type { IconButtonProps } from '@chakra-ui/react'; +import type { TSubmitButton, TRSubmitButton } from './types'; + +const SubmitIcon = forwardRef>( + (props, ref) => { + const { isLoading, ...rest } = props; + return ( + } + title="Submit Query" + colorScheme="primary" + isLoading={isLoading} + aria-label="Submit Query" + {...rest} + /> + ); + }, +); + +/** + * Mobile Submit Button + */ +const MSubmitButton = (props: TRSubmitButton) => { + const { children, isOpen, onClose, onChange } = props; + const bg = useColorValue('white', 'gray.900'); + return ( + <> + {children} + + + + + + {isOpen && } + + + + + ); +}; + +/** + * Desktop Submit Button + */ +const DSubmitButton = (props: TRSubmitButton) => { + const { children, isOpen, onClose, onChange } = props; + const bg = useColorValue('white', 'gray.900'); + return ( + + {children} + + + + + {isOpen && } + + + + ); +}; + +export const SubmitButton = (props: TSubmitButton) => { + const { handleChange } = props; + const isMobile = useMobile(); + const { resolvedIsOpen, btnLoading } = useLGState(); + const { resolvedClose, resetForm } = useLGMethods(); + + const { reset } = useFormContext(); + + function handleClose(): void { + reset(); + resetForm(); + resolvedClose(); + } + + return ( + <> + + + + + + + + + + + + ); +}; diff --git a/hyperglass/ui/components/submit/types.ts b/hyperglass/ui/components/submit/types.ts new file mode 100644 index 0000000..91ca90b --- /dev/null +++ b/hyperglass/ui/components/submit/types.ts @@ -0,0 +1,13 @@ +import type { IconButtonProps } from '@chakra-ui/react'; +import type { OnChangeArgs } from '~/types'; + +export interface TSubmitButton extends Omit { + handleChange(e: OnChangeArgs): void; +} + +export interface TRSubmitButton { + isOpen: boolean; + onClose(): void; + onChange(e: OnChangeArgs): void; + children: React.ReactNode; +} diff --git a/hyperglass/ui/context/HyperglassProvider.js b/hyperglass/ui/context/HyperglassProvider.js deleted file mode 100644 index 3bbc85c..0000000 --- a/hyperglass/ui/context/HyperglassProvider.js +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from 'react'; -import { createContext, useContext, useMemo } from 'react'; -import dynamic from 'next/dynamic'; -import { CSSReset, ThemeProvider } from '@chakra-ui/core'; -import { MediaProvider } from './MediaProvider'; -import { StateProvider } from './StateProvider'; -import { makeTheme, defaultTheme } from 'app/util'; - -// Disable SSR for ColorModeProvider -const ColorModeProvider = dynamic( - () => import('@chakra-ui/core').then(mod => mod.ColorModeProvider), - { ssr: false }, -); - -const HyperglassContext = createContext(null); - -export const HyperglassProvider = ({ config, children }) => { - const value = useMemo(() => config, [config]); - const userTheme = value && makeTheme(value.web.theme); - const theme = value ? userTheme : defaultTheme; - return ( - - - - - - {children} - - - - - ); -}; - -export const useConfig = () => useContext(HyperglassContext); diff --git a/hyperglass/ui/context/HyperglassProvider.tsx b/hyperglass/ui/context/HyperglassProvider.tsx new file mode 100644 index 0000000..547c01c --- /dev/null +++ b/hyperglass/ui/context/HyperglassProvider.tsx @@ -0,0 +1,62 @@ +import { createContext, useContext, useMemo } from 'react'; +import { + useToken, + ChakraProvider, + useColorModeValue, + useBreakpointValue, + useTheme as useChakraTheme, +} from '@chakra-ui/react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { makeTheme, defaultTheme } from '~/util'; + +import type { IConfig, Theme } from '~/types'; +import type { THyperglassProvider } from './types'; + +const HyperglassContext = createContext(Object()); + +const queryClient = new QueryClient(); + +export const HyperglassProvider = (props: THyperglassProvider) => { + const { config, children } = props; + const value = useMemo(() => config, []); + const userTheme = value && makeTheme(value.web.theme); + const theme = value ? userTheme : defaultTheme; + return ( + + + {children} + + + ); +}; + +/** + * Get the current configuration. + */ +export const useConfig = (): IConfig => useContext(HyperglassContext); + +/** + * Get the current theme object. + */ +export const useTheme = (): Theme.Full => useChakraTheme(); + +/** + * Determine if device is mobile or desktop based on Chakra UI theme breakpoints. + */ +export const useMobile = (): boolean => + useBreakpointValue({ base: true, md: true, lg: false, xl: false }) ?? true; + +/** + * Convenience function to combine Chakra UI's useToken & useColorModeValue. + */ +export const useColorToken = ( + token: keyof Theme.Full, + light: L, + dark: D, +): L | D => useColorModeValue(useToken(token, light), useToken(token, dark)); + +export { + useColorMode, + useBreakpointValue, + useColorModeValue as useColorValue, +} from '@chakra-ui/react'; diff --git a/hyperglass/ui/context/MediaProvider.js b/hyperglass/ui/context/MediaProvider.js deleted file mode 100644 index d56f258..0000000 --- a/hyperglass/ui/context/MediaProvider.js +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from 'react'; -import { createContext, useContext, useMemo } from 'react'; -import { useMediaLayout } from 'use-media'; - -const MediaContext = createContext(null); - -export const MediaProvider = ({ theme, children }) => { - const { sm, md, lg, xl } = theme.breakpoints; - const isSm = useMediaLayout({ maxWidth: md }); - const isMd = useMediaLayout({ minWidth: md, maxWidth: lg }); - const isLg = useMediaLayout({ minWidth: lg, maxWidth: xl }); - const isXl = useMediaLayout({ minWidth: xl }); - let mediaSize = false; - switch (true) { - case isSm: - mediaSize = 'sm'; - break; - case isMd: - mediaSize = 'md'; - break; - case isLg: - mediaSize = 'lg'; - break; - case isXl: - mediaSize = 'xl'; - break; - } - const value = useMemo( - () => ({ - isSm: isSm, - isMd: isMd, - isLg: isLg, - isXl: isXl, - mediaSize: mediaSize, - }), - [isSm, isMd, isLg, isXl, mediaSize], - ); - return {children}; -}; - -export const useMedia = () => useContext(MediaContext); diff --git a/hyperglass/ui/context/StateProvider.js b/hyperglass/ui/context/StateProvider.js deleted file mode 100644 index 9e41dd4..0000000 --- a/hyperglass/ui/context/StateProvider.js +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from 'react'; -import { createContext, useContext, useMemo, useState } from 'react'; -import { useSessionStorage } from 'app/hooks'; - -const StateContext = createContext(null); - -export const StateProvider = ({ children }) => { - const [isSubmitting, setSubmitting] = useState(false); - const [formData, setFormData] = useState({}); - const [greetingAck, setGreetingAck] = useSessionStorage('hyperglass-greeting-ack', false); - const resetForm = layoutRef => { - layoutRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); - setSubmitting(false); - setFormData({}); - }; - const value = useMemo(() => ({ - isSubmitting, - setSubmitting, - formData, - setFormData, - greetingAck, - setGreetingAck, - resetForm, - })); - return {children}; -}; - -export const useHyperglassState = () => useContext(StateContext); diff --git a/hyperglass/ui/context/index.mjs b/hyperglass/ui/context/index.mjs deleted file mode 100644 index 5593022..0000000 --- a/hyperglass/ui/context/index.mjs +++ /dev/null @@ -1,3 +0,0 @@ -export * from './HyperglassProvider'; -export * from './MediaProvider'; -export * from './StateProvider'; diff --git a/hyperglass/ui/context/index.ts b/hyperglass/ui/context/index.ts new file mode 100644 index 0000000..757b5cf --- /dev/null +++ b/hyperglass/ui/context/index.ts @@ -0,0 +1 @@ +export * from './HyperglassProvider'; diff --git a/hyperglass/ui/context/types.ts b/hyperglass/ui/context/types.ts new file mode 100644 index 0000000..2712f0d --- /dev/null +++ b/hyperglass/ui/context/types.ts @@ -0,0 +1,24 @@ +import type { State } from '@hookstate/core'; +import type { IConfig, TFormData } from '~/types'; + +export interface THyperglassProvider { + config: IConfig; + children: React.ReactNode; +} + +export interface TGlobalState { + isSubmitting: boolean; + formData: TFormData; +} + +interface TGlobalStateFunctions { + resetForm(): void; +} + +// export type TUseGlobalState = State & TGlobalStateFunctions; + +export interface TUseGlobalState { + isSubmitting: State; + formData: State; + resetForm(): void; +} diff --git a/hyperglass/ui/hooks/index.mjs b/hyperglass/ui/hooks/index.mjs deleted file mode 100644 index 8f7795f..0000000 --- a/hyperglass/ui/hooks/index.mjs +++ /dev/null @@ -1 +0,0 @@ -export * from './useSessionStorage'; diff --git a/hyperglass/ui/hooks/index.ts b/hyperglass/ui/hooks/index.ts new file mode 100644 index 0000000..a82290f --- /dev/null +++ b/hyperglass/ui/hooks/index.ts @@ -0,0 +1,10 @@ +export * from './useASNDetail'; +export * from './useBooleanValue'; +export * from './useDevice'; +export * from './useDNSQuery'; +export * from './useGreeting'; +export * from './useLGQuery'; +export * from './useLGState'; +export * from './useOpposingColor'; +export * from './useStrf'; +export * from './useTableToString'; diff --git a/hyperglass/ui/hooks/types.ts b/hyperglass/ui/hooks/types.ts new file mode 100644 index 0000000..853319d --- /dev/null +++ b/hyperglass/ui/hooks/types.ts @@ -0,0 +1,102 @@ +import { State } from '@hookstate/core'; +import type { QueryFunctionContext } from 'react-query'; +import type { + TDevice, + Families, + TFormQuery, + TDeviceVrf, + TQueryTypes, + TSelectOption, +} from '~/types'; + +export interface TOpposingOptions { + light?: string; + dark?: string; +} + +export type TUseGreetingReturn = { + ack: State; + isOpen: State; + open(): void; + close(): void; + greetingReady(): boolean; +}; + +export interface TUseLGQueryFn { + pageParam?: QueryFunctionContext['pageParam']; + queryKey: [string, TFormQuery]; +} + +export interface TUseASNDetailFn { + pageParam?: QueryFunctionContext['pageParam']; + queryKey: string; +} + +interface TUseDNSQueryParams { + target: string; + family: 4 | 6; +} + +export interface TUseDNSQueryFn { + pageParam?: QueryFunctionContext['pageParam']; + queryKey: [string | null, TUseDNSQueryParams]; +} + +export type TUseDevice = ( + /** + * Device's ID, e.g. the device.name field. + */ + deviceId: string, +) => TDevice; + +export interface TSelections { + queryLocation: TSelectOption[] | []; + queryType: TSelectOption | null; + queryVrf: TSelectOption | null; +} + +export interface TMethodsExtension { + getResponse(d: string): TQueryResponse | null; + resolvedClose(): void; + resolvedOpen(): void; + formReady(): boolean; + resetForm(): void; + stateExporter(o: O): O | null; +} + +export type TLGState = { + queryVrf: string; + families: Families; + queryTarget: string; + btnLoading: boolean; + isSubmitting: boolean; + displayTarget: string; + queryType: TQueryTypes; + queryLocation: string[]; + availVrfs: TDeviceVrf[]; + resolvedIsOpen: boolean; + selections: TSelections; + responses: { [d: string]: TQueryResponse }; +}; + +export type TLGStateHandlers = { + exportState(s: S): S | null; + getResponse(d: string): TQueryResponse | null; + resolvedClose(): void; + resolvedOpen(): void; + formReady(): boolean; + resetForm(): void; + stateExporter(o: O): O | null; +}; + +export type UseStrfArgs = { [k: string]: any } | string; + +export type TTableToStringFormatter = (v: any) => string; + +export type TTableToStringFormatted = { + age: (v: number) => string; + active: (v: boolean) => string; + as_path: (v: number[]) => string; + communities: (v: string[]) => string; + rpki_state: (v: number, n: TRPKIStates) => string; +}; diff --git a/hyperglass/ui/hooks/useASNDetail.ts b/hyperglass/ui/hooks/useASNDetail.ts new file mode 100644 index 0000000..a7764d2 --- /dev/null +++ b/hyperglass/ui/hooks/useASNDetail.ts @@ -0,0 +1,28 @@ +import { useQuery } from 'react-query'; + +import type { TASNQuery } from '~/types'; +import type { TUseASNDetailFn } from './types'; + +async function query(ctx: TUseASNDetailFn): Promise { + const [asn] = ctx.queryKey; + const res = await fetch('https://api.asrank.caida.org/v2/graphql', { + mode: 'cors', + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: `{ asn(asn:\"${asn}\"){ organization { orgName } } }` }), + }); + return await res.json(); +} + +/** + * Query the Caida AS Rank API to get an ASN's organization name for the AS Path component. + * @see https://api.asrank.caida.org/v2/docs + */ +export function useASNDetail(asn: string) { + return useQuery(asn, query, { + refetchOnWindowFocus: false, + refetchInterval: false, + refetchOnMount: false, + cacheTime: Infinity, + }); +} diff --git a/hyperglass/ui/hooks/useBooleanValue.ts b/hyperglass/ui/hooks/useBooleanValue.ts new file mode 100644 index 0000000..2922ce4 --- /dev/null +++ b/hyperglass/ui/hooks/useBooleanValue.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; + +/** + * Track the state of a boolean and return values based on its state. + */ +export function useBooleanValue( + status: boolean, + ifTrue: T, + ifFalse: F, +): T | F { + return useMemo(() => { + if (status) { + return ifTrue; + } else { + return ifFalse; + } + }, [status]); +} diff --git a/hyperglass/ui/hooks/useDNSQuery.ts b/hyperglass/ui/hooks/useDNSQuery.ts new file mode 100644 index 0000000..8f7d6c4 --- /dev/null +++ b/hyperglass/ui/hooks/useDNSQuery.ts @@ -0,0 +1,54 @@ +import { useQuery } from 'react-query'; +import { useConfig } from '~/context'; +import { fetchWithTimeout } from '~/util'; + +import type { DnsOverHttps } from '~/types'; +import type { TUseDNSQueryFn } from './types'; + +/** + * Perform a DNS over HTTPS query using the application/dns-json MIME type. + */ +async function dnsQuery(ctx: TUseDNSQueryFn): Promise { + const [url, { target, family }] = ctx.queryKey; + + const controller = new AbortController(); + + let json; + const type = family === 4 ? 'A' : family === 6 ? 'AAAA' : ''; + + if (url !== null) { + const res = await fetchWithTimeout( + `${url}?name=${target}&type=${type}`, + { + headers: { accept: 'application/dns-json' }, + mode: 'cors', + }, + 5000, + controller, + ); + + json = await res.json(); + } + + return json; +} + +/** + * Query the configured DNS over HTTPS provider for the provided target. If `family` is `4`, only + * an A record will be queried. If `family` is `6`, only a AAAA record will be queried. + */ +export function useDNSQuery( + /** + * Hostname for DNS query. + */ + target: string | null, + /** + * Address family, e.g. IPv4 or IPv6. + */ + family: 4 | 6, +) { + const { cache, web } = useConfig(); + return useQuery([web.dns_provider.url, { target, family }], dnsQuery, { + cacheTime: cache.timeout * 1000, + }); +} diff --git a/hyperglass/ui/hooks/useDevice.ts b/hyperglass/ui/hooks/useDevice.ts new file mode 100644 index 0000000..7fde457 --- /dev/null +++ b/hyperglass/ui/hooks/useDevice.ts @@ -0,0 +1,21 @@ +import { useCallback, useMemo } from 'react'; +import { useConfig } from '~/context'; +import { flatten } from '~/util'; + +import type { TDevice } from '~/types'; +import type { TUseDevice } from './types'; + +/** + * Get a device's configuration from the global configuration context based on its name. + */ +export function useDevice(): TUseDevice { + const { networks } = useConfig(); + + const devices = useMemo(() => flatten(networks.map(n => n.locations)), []); + + function getDevice(id: string): TDevice { + return devices.filter(dev => dev.name === id)[0]; + } + + return useCallback(getDevice, []); +} diff --git a/hyperglass/ui/hooks/useGreeting.ts b/hyperglass/ui/hooks/useGreeting.ts new file mode 100644 index 0000000..ea25777 --- /dev/null +++ b/hyperglass/ui/hooks/useGreeting.ts @@ -0,0 +1,45 @@ +import { createState, useState } from '@hookstate/core'; +import { Persistence } from '@hookstate/persistence'; +import { useConfig } from '~/context'; + +import type { TUseGreetingReturn } from './types'; + +const ackState = createState(false); +const openState = createState(false); + +/** + * Hook to manage the greeting, a.k.a. the popup at config path web.greeting. + */ +export function useGreeting(): TUseGreetingReturn { + const ack = useState(ackState); + const isOpen = useState(openState); + const { web } = useConfig(); + + if (typeof window !== 'undefined') { + ack.attach(Persistence('hyperglass-greeting')); + } + + function open() { + return isOpen.set(true); + } + function close() { + return isOpen.set(false); + } + + function greetingReady(): boolean { + if (ack.get()) { + // If the acknowledgement is already set, no further evaluation is needed. + return true; + } else if (!web.greeting.required && !ack.get()) { + // If the acknowledgement is not set, but is also not required, then pass. + return true; + } else if (web.greeting.required && !ack.get()) { + // If the acknowledgement is not set, but is required, then fail. + return false; + } else { + return false; + } + } + + return { ack, isOpen, greetingReady, open, close }; +} diff --git a/hyperglass/ui/hooks/useLGQuery.ts b/hyperglass/ui/hooks/useLGQuery.ts new file mode 100644 index 0000000..6b9dd57 --- /dev/null +++ b/hyperglass/ui/hooks/useLGQuery.ts @@ -0,0 +1,50 @@ +import { useQuery } from 'react-query'; +import { useConfig } from '~/context'; +import { fetchWithTimeout } from '~/util'; + +import type { TFormQuery } from '~/types'; +import type { TUseLGQueryFn } from './types'; + +/** + * Custom hook handle submission of a query to the hyperglass backend. + */ +export function useLGQuery(query: TFormQuery) { + const { request_timeout, cache } = useConfig(); + const controller = new AbortController(); + + async function runQuery(ctx: TUseLGQueryFn): Promise { + const [url, data] = ctx.queryKey; + const { queryLocation, queryTarget, queryType, queryVrf } = data; + 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, + { + // Invalidate react-query's cache just shy of the configured cache timeout. + cacheTime: cache.timeout * 1000 * 0.95, + // Don't refetch when window refocuses. + refetchOnWindowFocus: false, + // Don't automatically refetch query data (queries should be on-off). + refetchInterval: false, + // Don't refetch on component remount. + refetchOnMount: false, + }, + ); +} diff --git a/hyperglass/ui/hooks/useLGState.ts b/hyperglass/ui/hooks/useLGState.ts new file mode 100644 index 0000000..a7d12c8 --- /dev/null +++ b/hyperglass/ui/hooks/useLGState.ts @@ -0,0 +1,161 @@ +import { useCallback } from 'react'; +import { useState, createState } from '@hookstate/core'; +import isEqual from 'react-fast-compare'; +import { all } from '~/util'; + +import type { State, PluginStateControl, Plugin } from '@hookstate/core'; +import type { TLGState, TLGStateHandlers, TMethodsExtension } from './types'; + +const MethodsId = Symbol('Methods'); + +/** + * hookstate plugin to provide convenience functions for the useLGState hook. + */ +class MethodsInstance { + /** + * Set the DNS resolver Popover to opened. + */ + public resolvedOpen(state: State) { + state.resolvedIsOpen.set(true); + } + /** + * Set the DNS resolver Popover to closed. + */ + public resolvedClose(state: State) { + state.resolvedIsOpen.set(false); + } + /** + * Find a response based on the device ID. + */ + public getResponse(state: State, device: string): TQueryResponse | null { + if (device in state.responses) { + return state.responses[device].value; + } else { + return null; + } + } + /** + * Determine if the form is ready for submission, e.g. all fields have values and isSubmitting + * has been set to true. This ultimately controls the UI layout. + */ + public formReady(state: State): boolean { + return ( + state.isSubmitting.value && + all( + ...[ + state.queryVrf.value !== '', + state.queryType.value !== '', + state.queryTarget.value !== '', + state.queryLocation.length !== 0, + ], + ) + ); + } + /** + * Reset form values affected by the form state to their default values. + */ + public resetForm(state: State) { + state.merge({ + queryVrf: '', + families: [], + queryType: '', + responses: {}, + queryTarget: '', + queryLocation: [], + displayTarget: '', + btnLoading: false, + isSubmitting: false, + resolvedIsOpen: false, + availVrfs: [], + selections: { queryLocation: [], queryType: null, queryVrf: null }, + }); + } + public stateExporter(obj: O): O | null { + let result = null; + if (obj === null) { + return result; + } + try { + result = JSON.parse(JSON.stringify(obj)); + } catch (err) { + console.error(err.message); + } + return result; + } +} + +/** + * Plugin Initialization. + */ +function Methods(): Plugin; +/** + * Plugin Attachment. + */ +function Methods(inst: State): TMethodsExtension; +/** + * Plugin Instance. + */ +function Methods(inst?: State): Plugin | TMethodsExtension { + if (inst) { + const [instance] = inst.attach(MethodsId) as [ + MethodsInstance | Error, + PluginStateControl, + ]; + + if (instance instanceof Error) { + throw instance; + } + + return { + resetForm: () => instance.resetForm(inst), + formReady: () => instance.formReady(inst), + resolvedOpen: () => instance.resolvedOpen(inst), + resolvedClose: () => instance.resolvedClose(inst), + getResponse: device => instance.getResponse(inst, device), + stateExporter: obj => instance.stateExporter(obj), + }; + } + return { + id: MethodsId, + init: () => { + return new MethodsInstance() as {}; + }, + }; +} + +const LGState = createState({ + selections: { queryLocation: [], queryType: null, queryVrf: null }, + resolvedIsOpen: false, + isSubmitting: false, + displayTarget: '', + queryLocation: [], + btnLoading: false, + queryTarget: '', + queryType: '', + availVrfs: [], + responses: {}, + queryVrf: '', + families: [], +}); + +/** + * Global state hook for state used throughout hyperglass. + */ +export function useLGState(): State { + return useState(LGState); +} + +/** + * Plugin for useLGState() that provides convenience methods for its state. + */ +export function useLGMethods(): TLGStateHandlers { + const state = useLGState(); + state.attach(Methods); + const exporter = useCallback(Methods(state).stateExporter, [isEqual]); + return { + exportState(s) { + return exporter(s); + }, + ...Methods(state), + }; +} diff --git a/hyperglass/ui/hooks/useOpposingColor.ts b/hyperglass/ui/hooks/useOpposingColor.ts new file mode 100644 index 0000000..ade9d92 --- /dev/null +++ b/hyperglass/ui/hooks/useOpposingColor.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; +import { getColor, isLight } from '@chakra-ui/theme-tools'; +import { useTheme } from '~/context'; + +import type { TOpposingOptions } from './types'; + +/** + * Parse the color string to determine if it's a Chakra UI theme key, and determine if the + * opposing color should be black or white. + */ +export function useIsDark(color: string) { + const theme = useTheme(); + if (typeof color === 'string' && color.match(/[a-zA-Z]+\.[a-zA-Z0-9]+/g)) { + color = getColor(theme, color, color); + } + let opposingShouldBeDark = true; + try { + opposingShouldBeDark = isLight(color)(theme); + } catch (err) { + console.error(err); + } + return opposingShouldBeDark; +} + +/** + * Determine if the foreground color for `color` should be white or black. + */ +export function useOpposingColor(color: string, options?: TOpposingOptions): string { + const isBlack = useIsDark(color); + + return useMemo(() => { + if (isBlack) { + return options?.dark ?? 'black'; + } else { + return options?.light ?? 'white'; + } + }, [color]); +} diff --git a/hyperglass/ui/hooks/useSessionStorage.js b/hyperglass/ui/hooks/useSessionStorage.js deleted file mode 100644 index e3b7344..0000000 --- a/hyperglass/ui/hooks/useSessionStorage.js +++ /dev/null @@ -1,42 +0,0 @@ -/* -react-use: useSessionStorage -https://github.com/streamich/react-use/blob/master/src/useSessionStorage.ts -*/ - -import { useEffect, useState } from 'react'; - -export const useSessionStorage = (key, initialValue, raw) => { - const isClient = typeof window === 'object'; - if (!isClient) { - return [initialValue, () => {}]; - } - - const [state, setState] = useState(() => { - try { - const sessionStorageValue = sessionStorage.getItem(key); - if (typeof sessionStorageValue !== 'string') { - sessionStorage.setItem(key, raw ? String(initialValue) : JSON.stringify(initialValue)); - return initialValue; - } else { - return raw ? sessionStorageValue : JSON.parse(sessionStorageValue || 'null'); - } - } catch { - // If user is in private mode or has storage restriction - // sessionStorage can throw. JSON.parse and JSON.stringify - // cat throw, too. - return initialValue; - } - }); - - useEffect(() => { - try { - const serializedState = raw ? String(state) : JSON.stringify(state); - sessionStorage.setItem(key, serializedState); - } catch { - // If user is in private mode or has storage restriction - // sessionStorage can throw. Also JSON.stringify can throw. - } - }); - - return [state, setState]; -}; diff --git a/hyperglass/ui/hooks/useStrf.ts b/hyperglass/ui/hooks/useStrf.ts new file mode 100644 index 0000000..758e6ae --- /dev/null +++ b/hyperglass/ui/hooks/useStrf.ts @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; +import format from 'string-format'; + +import type { UseStrfArgs } from './types'; + +/** + * Format a string with variables, like Python's string.format() + */ +export function useStrf(str: string, fmt: UseStrfArgs, ...deps: any[]): string { + return useMemo(() => format(str, fmt), deps); +} diff --git a/hyperglass/ui/hooks/useTableToString.ts b/hyperglass/ui/hooks/useTableToString.ts new file mode 100644 index 0000000..7ddddd9 --- /dev/null +++ b/hyperglass/ui/hooks/useTableToString.ts @@ -0,0 +1,109 @@ +import { useCallback } from 'react'; +import dayjs from 'dayjs'; +import relativeTimePlugin from 'dayjs/plugin/relativeTime'; +import utcPlugin from 'dayjs/plugin/utc'; +import { useConfig } from '~/context'; +import { isStructuredOutput } from '~/types'; + +import type { TTableToStringFormatter, TTableToStringFormatted } from './types'; + +dayjs.extend(relativeTimePlugin); +dayjs.extend(utcPlugin); + +function formatAsPath(path: number[]): string { + return path.join(' → '); +} + +function formatCommunities(comms: string[]): string { + const commsStr = comms.map(c => ` - ${c}`); + return '\n' + commsStr.join('\n'); +} + +function formatBool(val: boolean): string { + let fmt = ''; + if (val === true) { + fmt = 'yes'; + } else if (val === false) { + fmt = 'no'; + } + return fmt; +} + +function formatTime(val: number): string { + const now = dayjs.utc(); + const then = now.subtract(val, 'second'); + const timestamp = then.toString().replace('GMT', 'UTC'); + const relative = now.to(then, true); + return `${relative} (${timestamp})`; +} + +/** + * Get a function to convert table data to string, for use in the copy button component. + */ +export function useTableToString( + target: string, + data: TQueryResponse | undefined, + ...deps: any +): () => string { + const { web, parsed_data_fields, messages } = useConfig(); + + function formatRpkiState(val: number): string { + const rpkiStates = [ + web.text.rpki_invalid, + web.text.rpki_valid, + web.text.rpki_unknown, + web.text.rpki_unverified, + ]; + return rpkiStates[val]; + } + + const tableFormatMap = { + age: formatTime, + active: formatBool, + as_path: formatAsPath, + communities: formatCommunities, + rpki_state: formatRpkiState, + }; + + function isFormatted(key: string): key is keyof TTableToStringFormatted { + return key in tableFormatMap; + } + + function getFmtFunc(accessor: keyof TRoute): TTableToStringFormatter { + if (isFormatted(accessor)) { + return tableFormatMap[accessor]; + } else { + return String; + } + } + + function doFormat(target: string, data: TQueryResponse | undefined): string { + let result = messages.no_output; + try { + 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}`); + } + } + } + } + result = tableStringParts.join('\n'); + } + return result; + } catch (err) { + console.error(err); + return `An error occurred while parsing the output: '${err.message}'`; + } + } + return useCallback(() => doFormat(target, data), deps); +} diff --git a/hyperglass/ui/jsconfig.json b/hyperglass/ui/jsconfig.json deleted file mode 100644 index b1af6af..0000000 --- a/hyperglass/ui/jsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2017", - "module": "esnext", - "baseUrl": ".", - "paths": { - "app/components": ["components/index"], - "app/components/*": ["components/*"], - "app/context": ["context/index"], - "app/context/*": ["context/*"], - "app/hooks": ["hooks/index"], - "app/hooks/*": ["hooks/*"], - "app/util": ["util/index"] - } - } -} diff --git a/hyperglass/ui/next-env.d.ts b/hyperglass/ui/next-env.d.ts new file mode 100644 index 0000000..7b7aa2c --- /dev/null +++ b/hyperglass/ui/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/hyperglass/ui/next.config.js b/hyperglass/ui/next.config.js index f4507f3..a9d6a57 100644 --- a/hyperglass/ui/next.config.js +++ b/hyperglass/ui/next.config.js @@ -1,17 +1,8 @@ -// const aliases = require("./.alias"); const envVars = require('/tmp/hyperglass.env.json'); const { configFile } = envVars; const config = require(String(configFile)); module.exports = { - // webpack(config) { - // const { alias } = config.resolve; - // config.resolve.alias = { - // ...alias, - // ...aliases - // }; - // return config; - // }, reactStrictMode: true, poweredByHeader: false, env: { diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index ed852ce..b8e4b44 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -10,82 +10,58 @@ "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/core": "^0.8", - "@emotion/core": "^10.0.28", - "@emotion/styled": "^10.0.27", + "@chakra-ui/react": "^1.1.0", + "@emotion/react": "^11.1.4", + "@emotion/styled": "^11.0.0", + "@hookform/resolvers": "^1.2.0", + "@hookstate/core": "^3.0.1", + "@hookstate/persistence": "^3.0.0", "@meronex/icons": "^4.0.0", - "@styled-system/should-forward-prop": "^5.1.5", - "axios": "^0.19.2", - "axios-hooks": "^1.9.0", - "chroma-js": "^2.1.0", + "color2k": "^1.1.1", "dayjs": "^1.8.25", - "emotion-theming": "^10.0.27", - "framer-motion": "^1.10.0", + "framer-motion": "^3.1.1", "lodash": "^4.17.15", - "next": "^9.5.4", - "react": "^16.13.1", + "next": "^10.0.4", + "react": "^17.0.1", "react-countdown": "^2.2.1", - "react-dom": "^16.13.1", - "react-hook-form": "^5.7", + "react-dom": "^17.0.1", + "react-fast-compare": "^3.2.0", + "react-flow-renderer": "^8.2.3", + "react-hook-form": "^6.13.1", "react-markdown": "^4.3.1", - "react-select": "^3.0.8", - "react-string-replace": "^0.4.4", - "react-table": "^7.0.4", + "react-query": "^3.5.6", + "react-select": "^3.1.1", + "react-table": "^7.6.2", "string-format": "^2.0.0", - "styled-system": "^5.1.5", - "tempy": "^0.5.0", - "use-media": "^1.4.0", - "yup": "^0.28.3" + "yup": "^0.32.8" }, "devDependencies": { + "@hookstate/devtools": "^3.0.0", + "@types/node": "^14.11.10", + "@types/react-select": "^3.0.28", + "@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", "@upstatement/prettier-config": "^0.3.0", "babel-eslint": "^10.1.0", - "es-check": "^5.1.0", "eslint": "^6.8.0", "eslint-config-react-app": "^5.2.0", - "eslint-import-resolver-webpack": "^0.13.0", - "eslint-plugin-import": "^2.20.1", + "eslint-import-resolver-typescript": "^2.3.0", "eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-react": "^7.19.0", "eslint-plugin-react-hooks": "^2.3.0", "express": "^4.17.1", "http-proxy-middleware": "0.20.0", - "prettier": "^1.19.1" - }, - "eslintConfig": { - "parser": "babel-eslint", - "rules": { - "max-len": [ - "error", - 100 - ], - "react/prop-types": 0, - "react/jsx-filename-extension": 0, - "react/jsx-props-no-spreading": 0, - "no-bitwise": 0, - "object-shorthand": 0, - "no-plusplus": 0, - "no-param-reassign": 0, - "no-unused-expressions": 0, - "no-nested-ternary": 0, - "no-underscore-dangle": 0, - "camelcase": 0 - }, - "extends": [ - "airbnb", - "prettier", - "prettier/react" - ], - "env": { - "browser": true - } + "prettier": "2.0", + "typescript": "^4.0.3" } } diff --git a/hyperglass/ui/pages/_app.js b/hyperglass/ui/pages/_app.js deleted file mode 100644 index 513f58a..0000000 --- a/hyperglass/ui/pages/_app.js +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from 'react'; -import Head from 'next/head'; -// import { useRouter } from "next/router"; -import { HyperglassProvider } from 'app/context'; -// import Error from "./_error"; - -const config = process.env._HYPERGLASS_CONFIG_; - -const Hyperglass = ({ Component, pageProps }) => { - // const { asPath } = useRouter(); - // if (asPath === "/structured") { - // return ; - // } - return ( - <> - - hyperglass - - - - - - - - - - - - ); -}; - -export default Hyperglass; diff --git a/hyperglass/ui/pages/_app.tsx b/hyperglass/ui/pages/_app.tsx new file mode 100644 index 0000000..3600022 --- /dev/null +++ b/hyperglass/ui/pages/_app.tsx @@ -0,0 +1,47 @@ +import Head from 'next/head'; +import { HyperglassProvider } from '~/context'; +import { IConfig } from '~/types'; + +if (process.env.NODE_ENV === 'development') { + require('@hookstate/devtools'); +} + +import type { AppProps, AppInitialProps } from 'next/app'; + +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; + + return ( + <> + + 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 ( - - + +