From a562c900947f4b9255a7d4e7fada6010f755ecb5 Mon Sep 17 00:00:00 2001 From: checktheroads Date: Fri, 24 Apr 2020 11:41:43 -0700 Subject: [PATCH] start adding response parsing & ui elements --- .flake8 | 6 +- hyperglass/configuration/__init__.py | 2 + hyperglass/configuration/models/web.py | 4 + hyperglass/constants.py | 15 + hyperglass/exceptions.py | 6 + hyperglass/models.py | 26 +- hyperglass/parsing/juniper.py | 25 ++ hyperglass/parsing/models/__init__.py | 1 + hyperglass/parsing/models/juniper.py | 167 +++++++++ hyperglass/parsing/models/serialized.py | 37 ++ hyperglass/ui/components/Card/CardFooter.js | 25 ++ hyperglass/ui/components/Card/CardHeader.js | 25 ++ hyperglass/ui/components/Card/index.js | 28 ++ hyperglass/ui/components/Header.js | 287 ++++++++------- .../ui/components/HyperglassProvider.js | 17 +- hyperglass/ui/components/Layout.js | 58 +-- hyperglass/ui/components/LookingGlass.js | 51 +++ hyperglass/ui/components/MediaProvider.js | 58 +-- hyperglass/ui/components/StateProvider.js | 33 ++ hyperglass/ui/components/Table/MainTable.js | 39 ++ hyperglass/ui/components/Table/TableBody.js | 24 ++ hyperglass/ui/components/Table/TableCell.js | 52 +++ hyperglass/ui/components/Table/TableHead.js | 29 ++ .../ui/components/Table/TableIconButton.js | 51 +++ hyperglass/ui/components/Table/TableRow.js | 65 ++++ .../ui/components/Table/TableSelectShow.js | 32 ++ hyperglass/ui/components/Table/index.js | 191 ++++++++++ hyperglass/ui/components/Table/makeData.js | 35 ++ hyperglass/ui/components/Table/styles.js | 70 ++++ hyperglass/ui/package.json | 3 + hyperglass/ui/pages/index.js | 8 +- hyperglass/ui/pages/structured.js | 336 ++++++++++++++++++ hyperglass/ui/theme.js | 325 +++++++---------- hyperglass/ui/util.js | 36 +- hyperglass/ui/yarn.lock | 29 ++ poetry.lock | 47 ++- pyproject.toml | 1 + 37 files changed, 1791 insertions(+), 453 deletions(-) create mode 100644 hyperglass/parsing/juniper.py create mode 100644 hyperglass/parsing/models/__init__.py create mode 100644 hyperglass/parsing/models/juniper.py create mode 100644 hyperglass/parsing/models/serialized.py create mode 100644 hyperglass/ui/components/Card/CardFooter.js create mode 100644 hyperglass/ui/components/Card/CardHeader.js create mode 100644 hyperglass/ui/components/Card/index.js create mode 100644 hyperglass/ui/components/LookingGlass.js create mode 100644 hyperglass/ui/components/StateProvider.js create mode 100644 hyperglass/ui/components/Table/MainTable.js create mode 100644 hyperglass/ui/components/Table/TableBody.js create mode 100644 hyperglass/ui/components/Table/TableCell.js create mode 100644 hyperglass/ui/components/Table/TableHead.js create mode 100644 hyperglass/ui/components/Table/TableIconButton.js create mode 100644 hyperglass/ui/components/Table/TableRow.js create mode 100644 hyperglass/ui/components/Table/TableSelectShow.js create mode 100644 hyperglass/ui/components/Table/index.js create mode 100644 hyperglass/ui/components/Table/makeData.js create mode 100644 hyperglass/ui/components/Table/styles.js create mode 100644 hyperglass/ui/pages/structured.js diff --git a/.flake8 b/.flake8 index a94e7e0..0934026 100644 --- a/.flake8 +++ b/.flake8 @@ -3,16 +3,18 @@ max-line-length=88 count=True show-source=False statistics=True -exclude=.git, __pycache__, hyperglass/api/examples/*.py, hyperglass/compat/_sshtunnel.py, hyperglass/test.py +exclude=.git, __pycache__, hyperglass/api/examples/*.py, hyperglass/compat/_sshtunnel.py, test.py filename=*.py per-file-ignores= hyperglass/main.py:E402 # Disable redefinition warning for exception handlers hyperglass/api.py:F811 # Disable classmethod warning for validator decorators + hyperglass/models.py:N805,E0213,R0903 hyperglass/api/models/*.py:N805,E0213,R0903 - hyperglass/configuration/models/*.py:N805,E0213,R0903,E501,C0301 hyperglass/api/models/response.py:E501,C0301 + hyperglass/parsing/models/*.py:N805,E0213,R0903 + hyperglass/configuration/models/*.py:N805,E0213,R0903,E501,C0301 ignore=W503,C0330,R504,D202,S403,S301 select=B, BLK, C, D, E, F, I, II, N, P, PIE, S, R, W disable-noqa=False diff --git a/hyperglass/configuration/__init__.py b/hyperglass/configuration/__init__.py index 03f3afc..c94a8dd 100644 --- a/hyperglass/configuration/__init__.py +++ b/hyperglass/configuration/__init__.py @@ -24,6 +24,7 @@ from hyperglass.constants import ( DEFAULT_TERMS, DEFAULT_DETAILS, SUPPORTED_QUERY_TYPES, + PARSED_RESPONSE_FIELDS, __version__, ) from hyperglass.exceptions import ConfigError, ConfigInvalid, ConfigMissing @@ -425,6 +426,7 @@ _frontend_params.update( "devices": frontend_devices, "networks": networks, "vrfs": vrfs, + "parsed_data_fields": PARSED_RESPONSE_FIELDS, "content": { "help_menu": content_help, "terms": content_terms, diff --git a/hyperglass/configuration/models/web.py b/hyperglass/configuration/models/web.py index 426f7b1..52fd176 100644 --- a/hyperglass/configuration/models/web.py +++ b/hyperglass/configuration/models/web.py @@ -141,6 +141,10 @@ class Text(HyperglassModel): 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 + rpki_invalid: StrictStr = "Invalid" + rpki_valid: StrictStr = "Valid" + rpki_unknown: StrictStr = "No ROAs Exist" + rpki_unverified: StrictStr = "Not Verified" @validator("title_mode") def validate_title_mode(cls, value): diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 069b915..68918bb 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -26,6 +26,21 @@ DNS_OVER_HTTPS = { "cloudflare": "https://cloudflare-dns.com/dns-query", } +PARSED_RESPONSE_FIELDS = ( + ("Active", "active", None), + ("RPKI State", "rpki_state", "center"), + ("AS Path", "as_path", "left"), + ("Next Hop", "next_hop", "left"), + ("Origin", "source_as", "right"), + ("Weight", "weight", "center"), + ("Local Preference", "local_preference", "center"), + ("MED", "med", "center"), + ("Communities", "communities", "center"), + ("Originator", "source_rid", "right"), + ("Peer", "peer_rid", "right"), + ("Age", "age", "right"), +) + CREDIT = """ Powered by [**hyperglass**](https://github.com/checktheroads/hyperglass) version \ {version}. Source code licensed \ diff --git a/hyperglass/exceptions.py b/hyperglass/exceptions.py index 2dd46ea..0b4dc60 100644 --- a/hyperglass/exceptions.py +++ b/hyperglass/exceptions.py @@ -193,3 +193,9 @@ class ResponseEmpty(_UnformattedHyperglassError): class UnsupportedDevice(_UnformattedHyperglassError): """Raised when an input NOS is not in the supported NOS list.""" + + +class ParsingError(_UnformattedHyperglassError): + """Raised when there is a problem parsing a structured response.""" + + _level = "danger" diff --git a/hyperglass/models.py b/hyperglass/models.py index bd3b680..50086b0 100644 --- a/hyperglass/models.py +++ b/hyperglass/models.py @@ -35,7 +35,14 @@ class HyperglassModel(BaseModel): Returns: {str} -- Stringified JSON. """ - return self.json(by_alias=True, exclude_unset=False, *args, **kwargs) + + export_kwargs = { + "by_alias": True, + "exclude_unset": False, + **kwargs, + } + + return self.json(*args, **export_kwargs) def export_dict(self, *args, **kwargs): """Return instance as dictionary. @@ -43,7 +50,13 @@ class HyperglassModel(BaseModel): Returns: {dict} -- Python dictionary. """ - return self.dict(by_alias=True, exclude_unset=False, *args, **kwargs) + export_kwargs = { + "by_alias": True, + "exclude_unset": False, + **kwargs, + } + + return self.dict(*args, **export_kwargs) def export_yaml(self, *args, **kwargs): """Return instance as YAML. @@ -54,7 +67,14 @@ class HyperglassModel(BaseModel): import json import yaml - return yaml.safe_dump(json.loads(self.export_json()), *args, **kwargs) + export_kwargs = { + "by_alias": kwargs.pop("by_alias", True), + "exclude_unset": kwargs.pop("by_alias", False), + } + + return yaml.safe_dump( + json.loads(self.export_json(**export_kwargs)), *args, **kwargs + ) class HyperglassModelExtra(HyperglassModel): diff --git a/hyperglass/parsing/juniper.py b/hyperglass/parsing/juniper.py new file mode 100644 index 0000000..68dae3d --- /dev/null +++ b/hyperglass/parsing/juniper.py @@ -0,0 +1,25 @@ +"""Parse Juniper XML Response to Structured Data.""" + +# Third Party +import xmltodict + +# Project +from hyperglass.log import log +from hyperglass.exceptions import ParsingError +from hyperglass.parsing.models.juniper import JuniperRoute + + +def parse_juniper(output): + """Parse a Juniper BGP XML response.""" + try: + parsed = xmltodict.parse(output)["rpc-reply"]["route-information"][ + "route-table" + ] + validated = JuniperRoute(**parsed) + return validated.serialize().export_dict() + except xmltodict.expat.ExpatError as err: + log.critical(str(err)) + raise ParsingError("Error parsing response data") + except KeyError as err: + log.critical(f"'{str(err)}' was not found in the response") + raise ParsingError("Error parsing response data") diff --git a/hyperglass/parsing/models/__init__.py b/hyperglass/parsing/models/__init__.py new file mode 100644 index 0000000..9ce8b52 --- /dev/null +++ b/hyperglass/parsing/models/__init__.py @@ -0,0 +1 @@ +"""Data models for parsed responses.""" diff --git a/hyperglass/parsing/models/juniper.py b/hyperglass/parsing/models/juniper.py new file mode 100644 index 0000000..7813076 --- /dev/null +++ b/hyperglass/parsing/models/juniper.py @@ -0,0 +1,167 @@ +"""Data Models for Parsing Juniper XML Response.""" + +# Standard Library +from typing import List + +# Third Party +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 + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +def _alias_generator(field): + return field.replace("_", "-") + + +class _JuniperBase(HyperglassModel): + class Config: + alias_generator = _alias_generator + extra = "ignore" + + +class JuniperRouteTableEntry(_JuniperBase): + """Parse Juniper rt-entry data.""" + + active_tag: StrictBool + preference: int + age: StrictInt + local_preference: int + metric: int = 0 + as_path: List[StrictInt] = [] + validation_state: StrictInt = 3 + next_hop: StrictStr + peer_rid: StrictStr + peer_as: int + source_as: int + source_rid: StrictStr + communities: List[StrictStr] + + @root_validator(pre=True) + def validate_optional_flags(cls, values): + """Flatten & rename keys prior to validation.""" + values["next-hop"] = values.pop("nh").get("to", "") + _path_attr = values.get("bgp-path-attributes", {}) + _path_attr_agg = _path_attr.get("attr-aggregator", {}).get("attr-value", {}) + values["as-path"] = _path_attr.get("attr-as-path-effective", {}).get( + "attr-value", "" + ) + values["source-as"] = _path_attr_agg.get("aggr-as-number", 0) + values["source-rid"] = _path_attr_agg.get("aggr-router-id", "") + values["peer-rid"] = values["peer-id"] + return values + + @validator("validation_state", pre=True, always=True) + def validate_rpki_state(cls, value): + """Convert string RPKI state to standard integer mapping.""" + return RPKI_STATE_MAP.get(value, 3) + + @validator("active_tag", pre=True, always=True) + def validate_active_tag(cls, value): + """Convert active-tag from string/null to boolean.""" + if value == "*": + value = True + else: + value = False + return value + + @validator("age", pre=True, always=True) + def validate_age(cls, value): + """Get age as seconds.""" + if not isinstance(value, dict): + try: + value = int(value) + except ValueError: + raise ValueError(f"Age field is in an unexpected format. Got: {value}") + else: + value = value.get("@junos:seconds", 0) + return int(value) + + @validator("as_path", pre=True, always=True) + def validate_as_path(cls, value): + """Remove origin flags from AS_PATH.""" + disallowed = ("E", "I", "?") + return [int(a) for a in value.split() if a not in disallowed] + + @validator("communities", pre=True, always=True) + def validate_communities(cls, value): + """Flatten community list.""" + return value.get("community", []) + + +class JuniperRouteTable(_JuniperBase): + """Validation model for Juniper rt data.""" + + rt_destination: StrictStr + rt_prefix_length: int + rt_entry_count: int + rt_announced_count: int + rt_entry: List[JuniperRouteTableEntry] + + @validator("rt_entry_count", pre=True, always=True) + def validate_entry_count(cls, value): + """Flatten & convert entry-count to integer.""" + return int(value.get("#text")) + + +class JuniperRoute(_JuniperBase): + """Validation model for route-table data.""" + + table_name: StrictStr + destination_count: int + total_route_count: int + active_route_count: int + hidden_route_count: int + rt: JuniperRouteTable + + def serialize(self): + """Convert the Juniper-specific fields to standard parsed data model.""" + vrf_parts = self.table_name.split(".") + if len(vrf_parts) == 2: + vrf = "default" + else: + vrf = vrf_parts[0] + + prefix = "/".join( + str(i) for i in (self.rt.rt_destination, self.rt.rt_prefix_length) + ) + + structure = { + "vrf": vrf, + "prefix": prefix, + "count": self.rt.rt_entry_count, + "winning_weight": "low", + } + + routes = [] + for route in self.rt.rt_entry: + routes.append( + { + "active": route.active_tag, + "age": route.age, + "weight": route.preference, + "med": route.metric, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": route.validation_state, + } + ) + + serialized = ParsedRoutes(routes=routes, **structure) + + log.info("Serialized Juniper response: {}", serialized) + return serialized diff --git a/hyperglass/parsing/models/serialized.py b/hyperglass/parsing/models/serialized.py new file mode 100644 index 0000000..da8b2ba --- /dev/null +++ b/hyperglass/parsing/models/serialized.py @@ -0,0 +1,37 @@ +"""Device-Agnostic Parsed Response Data Model.""" + +# Standard Library +from typing import List + +# Third Party +from pydantic import StrictInt, StrictStr, StrictBool, constr + +# Project +from hyperglass.models import HyperglassModel + + +class ParsedRouteEntry(HyperglassModel): + """Per-Route Response Model.""" + + active: StrictBool + age: StrictInt + weight: StrictInt + med: StrictInt + local_preference: StrictInt + as_path: List[StrictInt] + communities: List[StrictStr] + next_hop: StrictStr + source_as: StrictInt + source_rid: StrictStr + peer_rid: StrictStr + rpki_state: StrictInt + + +class ParsedRoutes(HyperglassModel): + """Parsed Response Model.""" + + vrf: StrictStr + prefix: StrictStr + count: StrictInt = 0 + routes: List[ParsedRouteEntry] + winning_weight: constr(regex=r"(low|high)") diff --git a/hyperglass/ui/components/Card/CardFooter.js b/hyperglass/ui/components/Card/CardFooter.js new file mode 100644 index 0000000..edf6c4e --- /dev/null +++ b/hyperglass/ui/components/Card/CardFooter.js @@ -0,0 +1,25 @@ +import * as React from "react"; +import { Flex } from "@chakra-ui/core"; + +const CardFooter = ({ children, ...props }) => { + return ( + + {children} + + ); +}; + +CardFooter.displayName = "CardFooter"; + +export default CardFooter; diff --git a/hyperglass/ui/components/Card/CardHeader.js b/hyperglass/ui/components/Card/CardHeader.js new file mode 100644 index 0000000..75627b7 --- /dev/null +++ b/hyperglass/ui/components/Card/CardHeader.js @@ -0,0 +1,25 @@ +import * as React from "react"; +import { Flex, Text, useColorMode } from "@chakra-ui/core"; + +const bg = { light: "blackAlpha.50", dark: "whiteAlpha.100" }; + +const CardHeader = ({ children, ...props }) => { + const { colorMode } = useColorMode(); + return ( + + {children} + + ); +}; + +CardHeader.displayName = "CardHeader"; + +export default CardHeader; diff --git a/hyperglass/ui/components/Card/index.js b/hyperglass/ui/components/Card/index.js new file mode 100644 index 0000000..fd99e3f --- /dev/null +++ b/hyperglass/ui/components/Card/index.js @@ -0,0 +1,28 @@ +import * as React from "react"; +import { Flex, useColorMode } from "@chakra-ui/core"; + +const bg = { light: "white", dark: "black" }; +const color = { light: "black", dark: "white" }; + +const Card = ({ onClick = () => false, children, ...props }) => { + const { colorMode } = useColorMode(); + return ( + + {children} + + ); +}; + +Card.displayName = "Card"; + +export default Card; diff --git a/hyperglass/ui/components/Header.js b/hyperglass/ui/components/Header.js index e4ab523..6c4869b 100644 --- a/hyperglass/ui/components/Header.js +++ b/hyperglass/ui/components/Header.js @@ -3,158 +3,175 @@ import { Flex, IconButton, useColorMode } from "@chakra-ui/core"; import { motion, AnimatePresence } from "framer-motion"; import ResetButton from "~/components/ResetButton"; import useMedia from "~/components/MediaProvider"; -import useConfig from "~/components/HyperglassProvider"; +import useConfig, { useHyperglassState } from "~/components/HyperglassProvider"; import Title from "~/components/Title"; const AnimatedFlex = motion.custom(Flex); const AnimatedResetButton = motion.custom(ResetButton); 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 } - } + 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 icon = { light: "moon", dark: "sun" }; const bg = { light: "white", dark: "black" }; -const colorSwitch = { dark: "Switch to light mode", light: "Switch to dark mode" }; -const headerTransition = { type: "spring", ease: "anticipate", damping: 15, stiffness: 100 }; +const colorSwitch = { + dark: "Switch to light mode", + light: "Switch to dark mode" +}; +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"] + true: ["flex-end", "flex-end", "center", "center"], + false: ["flex-start", "flex-start", "center", "center"] }; const titleHeight = { - true: null, - false: [null, "20vh", "20vh", "20vh"] + 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%", "25%", "25%"], - logo_subtitle: ["90%", "90%", "25%", "25%"], - all: ["90%", "90%", "25%", "25%"] + text_only: "100%", + logo_only: ["90%", "90%", "25%", "25%"], + logo_subtitle: ["90%", "90%", "25%", "25%"], + all: ["90%", "90%", "25%", "25%"] }; -export default ({ isSubmitting, handleFormReset, ...props }) => { - const { colorMode, toggleColorMode } = useColorMode(); - const { web } = useConfig(); - const { mediaSize } = useMedia(); - 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} - > - <IconButton - aria-label={colorSwitch[colorMode]} - variant="ghost" - color="current" - px={4} - p={null} - fontSize="20px" - onClick={toggleColorMode} - icon={icon[colorMode]} - /> - </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> - ); +export default ({ layoutRef, ...props }) => { + const { colorMode, toggleColorMode } = useColorMode(); + const { web } = useConfig(); + const { mediaSize } = useMedia(); + const { isSubmitting, resetForm } = useHyperglassState(); + const handleFormReset = () => { + resetForm(layoutRef); + }; + const resetButton = ( + <AnimatePresence key="resetButton"> + <AnimatedFlex + layoutTransition={headerTransition} + initial={{ opacity: 0, x: -50 }} + animate={{ opacity: 1, x: 0, width: "unset" }} + exit={{ opacity: 0, x: -50 }} + alignItems="center" + mb={[null, "auto"]} + ml={resetButtonMl[isSubmitting]} + display={isSubmitting ? "flex" : "none"} + > + <AnimatedResetButton + isSubmitting={isSubmitting} + onClick={handleFormReset} + /> + </AnimatedFlex> + </AnimatePresence> + ); + const title = ( + <AnimatedFlex + key="title" + px={1} + alignItems={ + isSubmitting ? "center" : ["center", "center", "flex-end", "flex-end"] + } + positionTransition={headerTransition} + initial={{ scale: 0.5 }} + animate={ + isSubmitting && web.text.title_mode === "text_only" + ? "smallText" + : isSubmitting && web.text.title_mode !== "text_only" + ? "smallLogo" + : "fullSize" + } + variants={titleVariants[mediaSize]} + justifyContent={titleJustify[isSubmitting]} + mb={[null, isSubmitting ? "auto" : null]} + mt={[null, isSubmitting ? null : "auto"]} + maxW={widthMap[web.text.title_mode]} + flex="1 0 0" + minH={titleHeight[isSubmitting]} + > + <Title isSubmitting={isSubmitting} onClick={handleFormReset} /> + </AnimatedFlex> + ); + const colorModeToggle = ( + <AnimatedFlex + layoutTransition={headerTransition} + key="colorModeToggle" + alignItems="center" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + mb={[null, "auto"]} + mr={isSubmitting ? null : 2} + > + <IconButton + aria-label={colorSwitch[colorMode]} + variant="ghost" + color="current" + px={4} + p={null} + fontSize="20px" + onClick={toggleColorMode} + icon={icon[colorMode]} + /> + </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/HyperglassProvider.js b/hyperglass/ui/components/HyperglassProvider.js index 98a352b..858ca04 100644 --- a/hyperglass/ui/components/HyperglassProvider.js +++ b/hyperglass/ui/components/HyperglassProvider.js @@ -1,7 +1,12 @@ -import React, { createContext, useContext, useMemo } from "react"; +import * as React from "react"; +import { createContext, useContext, useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { CSSReset, ThemeProvider } from "@chakra-ui/core"; -import { MediaProvider } from "~/components/MediaProvider"; +import _useMedia, { MediaProvider } from "~/components/MediaProvider"; +import { + StateProvider, + useHyperglassState as _useHyperglassState +} from "~/components/StateProvider"; import { makeTheme, defaultTheme } from "~/theme"; // Disable SSR for ColorModeProvider @@ -21,7 +26,9 @@ export const HyperglassProvider = ({ config, children }) => { <ThemeProvider theme={theme}> <ColorModeProvider value={config.web.theme.default_color_mode ?? null}> <CSSReset /> - <MediaProvider theme={theme}>{children}</MediaProvider> + <MediaProvider theme={theme}> + <StateProvider>{children}</StateProvider> + </MediaProvider> </ColorModeProvider> </ThemeProvider> </HyperglassContext.Provider> @@ -29,3 +36,7 @@ export const HyperglassProvider = ({ config, children }) => { }; export default () => useContext(HyperglassContext); + +export const useHyperglassState = _useHyperglassState; + +export const useMedia = _useMedia; diff --git a/hyperglass/ui/components/Layout.js b/hyperglass/ui/components/Layout.js index 04144fc..6a0d2e6 100644 --- a/hyperglass/ui/components/Layout.js +++ b/hyperglass/ui/components/Layout.js @@ -1,38 +1,22 @@ -import React, { useRef, useState } from "react"; -import { Flex, useColorMode, useDisclosure } from "@chakra-ui/core"; -import { motion, AnimatePresence } from "framer-motion"; -import HyperglassForm from "~/components/HyperglassForm"; -import Results from "~/components/Results"; +import * as React from "react"; +import { useRef } from "react"; +import { Flex, useColorMode } from "@chakra-ui/core"; import Header from "~/components/Header"; import Footer from "~/components/Footer"; import Greeting from "~/components/Greeting"; import Meta from "~/components/Meta"; -import useConfig from "~/components/HyperglassProvider"; +import useConfig, { useHyperglassState } from "~/components/HyperglassProvider"; import Debugger from "~/components/Debugger"; -import useSessionStorage from "~/hooks/useSessionStorage"; - -const AnimatedForm = motion.custom(HyperglassForm); const bg = { light: "white", dark: "black" }; const color = { light: "black", dark: "white" }; -const Layout = () => { +const Layout = ({ children }) => { const config = useConfig(); const { colorMode } = useColorMode(); - const [isSubmitting, setSubmitting] = useState(false); - const [formData, setFormData] = useState({}); - const [greetingAck, setGreetingAck] = useSessionStorage( - "hyperglass-greeting-ack", - false - ); + const { greetingAck, setGreetingAck } = useHyperglassState(); const containerRef = useRef(null); - const handleFormReset = () => { - containerRef.current.scrollIntoView({ behavior: "smooth", block: "start" }); - setSubmitting(false); - setFormData({}); - }; - return ( <> <Meta /> @@ -45,10 +29,7 @@ const Layout = () => { color={color[colorMode]} > <Flex px={2} flex="0 1 auto" flexDirection="column"> - <Header - isSubmitting={isSubmitting} - handleFormReset={handleFormReset} - /> + <Header layoutRef={containerRef} /> </Flex> <Flex px={2} @@ -61,30 +42,7 @@ const Layout = () => { justifyContent="start" flexDirection="column" > - {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> + {children} </Flex> <Footer /> {config.developer_mode && <Debugger />} diff --git a/hyperglass/ui/components/LookingGlass.js b/hyperglass/ui/components/LookingGlass.js new file mode 100644 index 0000000..1578918 --- /dev/null +++ b/hyperglass/ui/components/LookingGlass.js @@ -0,0 +1,51 @@ +import * as React from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import Layout from "~/components/Layout"; +import HyperglassForm from "~/components/HyperglassForm"; +import Results from "~/components/Results"; +import { useHyperglassState } from "~/components/HyperglassProvider"; + +const AnimatedForm = motion.custom(HyperglassForm); + +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> + ); +}; + +LookingGlass.displayName = "LookingGlass"; +export default LookingGlass; diff --git a/hyperglass/ui/components/MediaProvider.js b/hyperglass/ui/components/MediaProvider.js index 6dc0c51..8af5cb3 100644 --- a/hyperglass/ui/components/MediaProvider.js +++ b/hyperglass/ui/components/MediaProvider.js @@ -4,31 +4,39 @@ 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 <MediaContext.Provider value={value}>{children}</MediaContext.Provider>; + 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 ( + <MediaContext.Provider value={value}>{children}</MediaContext.Provider> + ); }; export default () => useContext(MediaContext); diff --git a/hyperglass/ui/components/StateProvider.js b/hyperglass/ui/components/StateProvider.js new file mode 100644 index 0000000..47a1d61 --- /dev/null +++ b/hyperglass/ui/components/StateProvider.js @@ -0,0 +1,33 @@ +import * as React from "react"; +import { createContext, useContext, useMemo, useState } from "react"; +import useSessionStorage from "~/hooks/useSessionStorage"; + +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 ( + <StateContext.Provider value={value}>{children}</StateContext.Provider> + ); +}; + +export const useHyperglassState = () => useContext(StateContext); diff --git a/hyperglass/ui/components/Table/MainTable.js b/hyperglass/ui/components/Table/MainTable.js new file mode 100644 index 0000000..af9b236 --- /dev/null +++ b/hyperglass/ui/components/Table/MainTable.js @@ -0,0 +1,39 @@ +/*@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" }; + +const MainTable = ({ children, ...props }) => { + const theme = useTheme(); + const { colorMode } = useColorMode(); + return ( + <Box + css={css({ + "&::-webkit-scrollbar": { height: "5px" }, + "&::-webkit-scrollbar-track": { + backgroundColor: scrollbarBg[colorMode] + }, + "&::-webkit-scrollbar-thumb": { + backgroundColor: scrollbar[colorMode] + }, + "&::-webkit-scrollbar-thumb:hover": { + backgroundColor: scrollbarHover[colorMode] + }, + + "-ms-overflow-style": { display: "none" } + })(theme)} + overflow="auto" + borderRadius="md" + boxSizing="border-box" + {...props} + > + {children} + </Box> + ); +}; + +MainTable.displayName = "MainTable"; +export default MainTable; diff --git a/hyperglass/ui/components/Table/TableBody.js b/hyperglass/ui/components/Table/TableBody.js new file mode 100644 index 0000000..28d9e68 --- /dev/null +++ b/hyperglass/ui/components/Table/TableBody.js @@ -0,0 +1,24 @@ +import * as React from "react"; +import { Box } from "@chakra-ui/core"; +import css from "@styled-system/css"; + +const TableBody = ({ children, ...props }) => { + return ( + <Box + as="tbody" + overflowY="scroll" + css={css({ + "&::-webkit-scrollbar": { display: "none" }, + "&": { "-ms-overflow-style": "none" } + })} + overflowX="hidden" + {...props} + > + {children} + </Box> + ); +}; + +TableBody.displayName = "TableBody"; + +export default TableBody; diff --git a/hyperglass/ui/components/Table/TableCell.js b/hyperglass/ui/components/Table/TableCell.js new file mode 100644 index 0000000..71cc783 --- /dev/null +++ b/hyperglass/ui/components/Table/TableCell.js @@ -0,0 +1,52 @@ +import * as React from "react"; +import { Box, useColorMode } from "@chakra-ui/core"; + +// export const TableCell = styled("div")` +// ${space}; +// ${color}; +// ${justifyContent}; +// flex: 1; +// display: flex; +// min-width: 150px; +// align-items: center; +// border-bottom-width: 1px; +// overflow: hidden; +// text-overflow: ellipsis; +// `; + +const cellBorder = { + dark: { borderLeft: "1px", borderLeftColor: "whiteAlpha.100" }, + light: { borderLeft: "1px", borderLeftColor: "blackAlpha.100" } +}; + +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 ( + <Box + as="td" + p={4} + m={0} + w="1%" + whiteSpace="nowrap" + {...borderProps} + {...props} + > + {children} + </Box> + ); +}; + +TableCell.displayName = "TableCell"; + +export default TableCell; diff --git a/hyperglass/ui/components/Table/TableHead.js b/hyperglass/ui/components/Table/TableHead.js new file mode 100644 index 0000000..1b49a7b --- /dev/null +++ b/hyperglass/ui/components/Table/TableHead.js @@ -0,0 +1,29 @@ +import * as React from "react"; +import { Box, useColorMode } from "@chakra-ui/core"; + +// export const TableHead = styled.div` +// ${space}; +// display: flex; +// flex-direction: row; +// `; + +const bg = { dark: "whiteAlpha.100", light: "blackAlpha.100" }; + +const TableHead = ({ children, ...props }) => { + const { colorMode } = useColorMode(); + return ( + <Box + as="thead" + overflowX="hidden" + overflowY="auto" + bg={bg[colorMode]} + {...props} + > + {children} + </Box> + ); +}; + +TableHead.displayName = "TableHead"; + +export default TableHead; diff --git a/hyperglass/ui/components/Table/TableIconButton.js b/hyperglass/ui/components/Table/TableIconButton.js new file mode 100644 index 0000000..91eadfa --- /dev/null +++ b/hyperglass/ui/components/Table/TableIconButton.js @@ -0,0 +1,51 @@ +import * as React from "react"; +import { IconButton } from "@chakra-ui/core"; + +// export const TableIconButton = ({ icon, onClick, isDisabled, children, variantColor, ...rest }) => { +// return ( +// <IconButton +// size="sm" +// {...rest} +// icon={icon} +// borderWidth={1} +// onClick={onClick} +// variantColor={variantColor} +// isDisabled={isDisabled} +// aria-label="Table Icon button" +// > +// {children} +// </IconButton> +// ); +// }; + +// TableIconButton.defaultProps = { +// variantColor: "gray", +// }; + +const TableIconButton = ({ + icon, + onClick, + isDisabled, + color, + children, + ...props +}) => { + return ( + <IconButton + size="sm" + icon={icon} + borderWidth={1} + onClick={onClick} + variantColor={color} + isDisabled={isDisabled} + aria-label="Table Icon Button" + {...props} + > + {children} + </IconButton> + ); +}; + +TableIconButton.displayName = "TableIconButton"; + +export default TableIconButton; diff --git a/hyperglass/ui/components/Table/TableRow.js b/hyperglass/ui/components/Table/TableRow.js new file mode 100644 index 0000000..8407793 --- /dev/null +++ b/hyperglass/ui/components/Table/TableRow.js @@ -0,0 +1,65 @@ +import * as React from "react"; +import { PseudoBox, useColorMode, useTheme } from "@chakra-ui/core"; +import { opposingColor } from "~/util"; + +// export const TableRow = styled(Flex)` +// &:hover { +// cursor: pointer; +// background-color: rgba(0, 0, 0, 0.01); +// } +// `; + +const hoverBg = { dark: "whiteAlpha.100", light: "blackAlpha.100" }; +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" }; + +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 ( + <PseudoBox + as="tr" + _hover={{ + cursor: "pointer", + backgroundColor: highlight + ? `${highlightBg}.${alphaMapHover[colorMode]}` + : hoverBg[colorMode] + }} + bg={bg} + color={color} + fontWeight={highlight ? "bold" : null} + {...borderProps} + {...props} + > + {children} + </PseudoBox> + ); +}; + +TableRow.displayName = "TableRow"; + +export default TableRow; diff --git a/hyperglass/ui/components/Table/TableSelectShow.js b/hyperglass/ui/components/Table/TableSelectShow.js new file mode 100644 index 0000000..a61040f --- /dev/null +++ b/hyperglass/ui/components/Table/TableSelectShow.js @@ -0,0 +1,32 @@ +import * as React from "react"; +import { Select } from "@chakra-ui/core"; + +{ + /* <select + value={pageSize} + onChange={e => {setPageSize(Number(e.target.value))}} +> + {[5, 10, 20, 30, 40, 50].map(pageSize => ( + <option key={pageSize} value={pageSize}> + Show {pageSize} + </option> + ))} +</select> */ +} + +const TableSelectShow = ({ value, onChange, children, ...props }) => { + return ( + <Select onChange={onChange} {...props}> + {[5, 10, 20, 30, 40, 50].map(value => ( + <option key={value} value={value}> + Show {value} + </option> + ))} + {children} + </Select> + ); +}; + +TableSelectShow.displayName = "TableSelectShow"; + +export default TableSelectShow; diff --git a/hyperglass/ui/components/Table/index.js b/hyperglass/ui/components/Table/index.js new file mode 100644 index 0000000..784c3f9 --- /dev/null +++ b/hyperglass/ui/components/Table/index.js @@ -0,0 +1,191 @@ +import * as React from "react"; +import { useMemo } from "react"; +import { Flex, Icon, Text } from "@chakra-ui/core"; +import useMedia from "~/components/MediaProvider"; +import { usePagination, useSortBy, useTable } from "react-table"; +import Card from "~/components/Card"; +import BottomSection from "~/components/Card/CardFooter"; +import TopSection from "~/components/Card/CardHeader"; +import MainTable from "./MainTable"; +import TableCell from "./TableCell"; +import TableHead from "./TableHead"; +import TableRow from "./TableRow"; +import TableBody from "./TableBody"; +import TableIconButton from "./TableIconButton"; +import TableSelectShow from "./TableSelectShow"; + +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 isTabletOrMobile = isSm ? true : isMd ? true : false; + + const defaultColumn = useMemo( + () => ({ + minWidth: 100, + width: 150, + maxWidth: 300 + }), + [] + ); + + 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 } + }, + useSortBy, + usePagination + ); + + return ( + <Card> + {!!tableHeading && <TopSection>{tableHeading}</TopSection>} + <MainTable {...getTableProps()}> + <TableHead> + {headerGroups.map(headerGroup => ( + <TableRow + key={headerGroup.id} + {...headerGroup.getHeaderGroupProps()} + > + {headerGroup.headers.map(column => ( + <TableCell + as="th" + align={column.align} + key={column.id} + {...column.getHeaderProps()} + {...column.getSortByToggleProps()} + > + <Text fontSize="sm" fontWeight="bold" display="inline-block"> + {column.render("Header")} + </Text> + {column.isSorted ? ( + column.isSortedDesc ? ( + <Icon name="chevron-down" size={4} ml={1} /> + ) : ( + <Icon name="chevron-up" size={4} ml={1} /> + ) + ) : ( + "" + )} + </TableCell> + ))} + </TableRow> + ))} + </TableHead> + <TableBody> + {page.map( + (row, key) => + prepareRow(row) || ( + <TableRow + index={key} + doStripe={striped} + doHorizontalBorders={bordersHorizontal} + onClick={() => onRowClick && onRowClick(row)} + key={key} + highlight={row.values[rowHighlightProp] ?? false} + highlightBg={rowHighlightBg} + highlightColor={rowHighlightColor} + {...row.getRowProps()} + > + {row.cells.map((cell, i) => { + return ( + <TableCell + align={cell.column.align} + cell={cell} + bordersVertical={[bordersVertical, i]} + key={cell.row.index} + {...cell.getCellProps()} + > + {cell.render(cellRender ?? "Cell")} + </TableCell> + ); + })} + </TableRow> + ) + )} + </TableBody> + </MainTable> + <BottomSection> + <Flex direction="row"> + <TableIconButton + mr={2} + onClick={() => gotoPage(0)} + isDisabled={!canPreviousPage} + icon={() => <Icon name="arrow-left" size={3} />} + /> + <TableIconButton + mr={2} + onClick={() => previousPage()} + isDisabled={!canPreviousPage} + icon={() => <Icon name="chevron-left" size={6} />} + /> + </Flex> + <Flex justifyContent="center" alignItems="center"> + <Text mr={4} whiteSpace="nowrap"> + Page{" "} + <strong> + {pageIndex + 1} of {pageOptions.length} + </strong>{" "} + </Text> + {!isTabletOrMobile && ( + <TableSelectShow + value={pageSize} + onChange={e => { + setPageSize(Number(e.target.value)); + }} + /> + )} + </Flex> + <Flex direction="row"> + <TableIconButton + ml={2} + isDisabled={!canNextPage} + onClick={() => nextPage()} + icon={() => <Icon name="chevron-right" size={6} />} + /> + <TableIconButton + ml={2} + onClick={() => gotoPage(pageCount ? pageCount - 1 : 1)} + isDisabled={!canNextPage} + icon={() => <Icon name="arrow-right" size={3} />} + /> + </Flex> + </BottomSection> + </Card> + ); +}; + +Table.displayName = "Table"; + +export default Table; diff --git a/hyperglass/ui/components/Table/makeData.js b/hyperglass/ui/components/Table/makeData.js new file mode 100644 index 0000000..d45081f --- /dev/null +++ b/hyperglass/ui/components/Table/makeData.js @@ -0,0 +1,35 @@ +import { generate } from "namor"; + +const range = (len) => { + const arr = []; + for (let i = 0; i < len; i++) { + arr.push(i); + } + return arr; +}; + +export const newPerson = () => { + const statusChance = Math.random(); + return { + name: generate({ words: 2, numbers: 0 }), + age: Math.floor(Math.random() * 30), + visits: Math.floor(Math.random() * 100), + progress: Math.floor(Math.random() * 100), + status: + statusChance > 0.66 ? "relationship" : statusChance > 0.33 ? "complicated" : "single", + }; +}; + +export default function makeData(...lens) { + const makeDataLevel = (depth = 0) => { + const len = lens[depth]; + return range(len).map((d) => { + return { + ...newPerson(), + subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined, + }; + }); + }; + + return makeDataLevel(); +} diff --git a/hyperglass/ui/components/Table/styles.js b/hyperglass/ui/components/Table/styles.js new file mode 100644 index 0000000..fa3eef5 --- /dev/null +++ b/hyperglass/ui/components/Table/styles.js @@ -0,0 +1,70 @@ +import React from "react"; +import { Flex, IconButton } from "@chakra-ui/core"; +import styled from "@emotion/styled"; +import { + color, + ColorProps, + justifyContent, + JustifyContentProps, + space, + SpaceProps, +} from "styled-system"; + +export const StyledTable = styled.div` + ${space}; + flex: 1; + width: 100%; + display: flex; + max-width: 100%; + overflow-x: auto; + border-radius: 4px; + flex-direction: column; + box-sizing: border-box; +`; + +export const TableHead = styled.div` + ${space}; + display: flex; + flex-direction: row; +`; + +export const TableCell = styled("div")` + ${space}; + ${color}; + ${justifyContent}; + flex: 1; + display: flex; + min-width: 150px; + align-items: center; + border-bottom-width: 1px; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const TableRow = styled(Flex)` + &:hover { + cursor: pointer; + background-color: rgba(0, 0, 0, 0.01); + } +`; + +export const TableIconButton = ({ icon, onClick, isDisabled, children, variantColor, ...rest }) => { + return ( + <IconButton + size="sm" + {...rest} + icon={icon} + borderWidth={1} + onClick={onClick} + variantColor={variantColor} + isDisabled={isDisabled} + aria-label="Table Icon button" + > + {children} + </IconButton> + ); +}; + +TableIconButton.defaultProps = { + variantColor: "gray", +}; diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index eac339b..5a564ba 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -18,9 +18,11 @@ "axios": "^0.19.2", "axios-hooks": "^1.9.0", "chroma-js": "^2.1.0", + "dayjs": "^1.8.25", "emotion-theming": "^10.0.27", "framer-motion": "^1.10.0", "lodash": "^4.17.15", + "namor": "^2.0.2", "next": "^9.3.1", "react": "^16.13.1", "react-countdown": "^2.2.1", @@ -30,6 +32,7 @@ "react-markdown": "^4.3.1", "react-select": "^3.0.8", "react-string-replace": "^0.4.4", + "react-table": "^7.0.4", "react-textfit": "^1.1.0", "string-format": "^2.0.0", "styled-system": "^5.1.5", diff --git a/hyperglass/ui/pages/index.js b/hyperglass/ui/pages/index.js index c19ceae..6949270 100644 --- a/hyperglass/ui/pages/index.js +++ b/hyperglass/ui/pages/index.js @@ -1,8 +1,10 @@ -import React from "react"; +import * as React from "react"; import dynamic from "next/dynamic"; import Loading from "~/components/Loading"; -const Layout = dynamic(() => import("~/components/Layout"), { loading: Loading }); +const LookingGlass = dynamic(() => import("~/components/LookingGlass"), { + loading: Loading +}); -const Index = () => <Layout />; +const Index = () => <LookingGlass />; export default Index; diff --git a/hyperglass/ui/pages/structured.js b/hyperglass/ui/pages/structured.js new file mode 100644 index 0000000..9fb7142 --- /dev/null +++ b/hyperglass/ui/pages/structured.js @@ -0,0 +1,336 @@ +import * as React from "react"; +import { + Flex, + Icon, + Popover, + PopoverArrow, + PopoverContent, + PopoverTrigger, + Text, + Tooltip, + useColorMode +} from "@chakra-ui/core"; +import dayjs from "dayjs"; +import relativeTimePlugin from "dayjs/plugin/relativeTime"; +import utcPlugin from "dayjs/plugin/utc"; +import useConfig from "~/components/HyperglassProvider"; +import Layout from "~/components/Layout"; +import Table from "~/components/Table/index"; + +dayjs.extend(relativeTimePlugin); +dayjs.extend(utcPlugin); + +const data = { + vrf: "default", + prefix: "1.1.1.0/24", + count: 4, + routes: [ + { + active: true, + age: 578857, + weight: 170, + med: 0, + local_preference: 150, + as_path: [1299, 13335], + communities: [ + "1299:35000", + "14525:0", + "14525:40", + "14525:1021", + "14525:2840", + "14525:3001", + "14525:4001", + "14525:9003" + ], + next_hop: "62.115.189.136", + source_as: 13335, + source_rid: "162.158.140.1", + peer_rid: "2.255.254.51", + rpki_state: 1 + }, + { + active: false, + age: 787213, + weight: 170, + med: 2020, + local_preference: 150, + as_path: [174, 13335], + communities: [ + "174:21001", + "174:22013", + "14525:0", + "14525:20", + "14525:1021", + "14525:2840", + "14525:3001", + "14525:4001", + "14525:9001" + ], + next_hop: "100.64.0.122", + source_as: 13335, + source_rid: "162.158.140.1", + peer_rid: "199.34.92.1", + rpki_state: 0 + }, + { + active: false, + age: 616677, + weight: 200, + med: 0, + local_preference: 150, + as_path: [6939, 13335], + communities: [ + "6939:7107", + "6939:8840", + "6939:9001", + "14525:0", + "14525:40", + "14525:1021", + "14525:2840", + "14525:3002", + "14525:4003", + "14525:9002" + ], + next_hop: "100.64.0.122", + source_as: 13335, + source_rid: "172.68.129.1", + peer_rid: "199.34.92.6", + rpki_state: 2 + }, + { + active: false, + age: 1284244, + weight: 200, + med: 25090, + local_preference: 150, + as_path: [174, 13335], + communities: [], + next_hop: "100.64.0.122", + source_as: 13335, + source_rid: "108.162.239.1", + peer_rid: "199.34.92.7", + rpki_state: 3 + } + ], + winning_weight: "low" +}; + +const hiddenCols = ["active", "source_as"]; + +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; + return { Header: header, accessor: accessor, align: align }; + }); +}; + +const longestASNLength = asPath => { + const longest = asPath.reduce((l, c) => { + const strLongest = String(l); + const strCurrent = String(c); + return strCurrent.length > strLongest.length ? strCurrent : strLongest; + }); + return longest.length; +}; + +const MonoField = ({ v, ...props }) => ( + <Text fontSize="sm" fontFamily="mono" {...props}> + {v} + </Text> +); + +const Active = ({ isActive }) => { + const { colorMode } = useColorMode(); + return ( + <Icon + name={isActive ? "check-circle" : "warning"} + color={isActiveColor[isActive][colorMode]} + /> + ); +}; + +const Age = ({ inSeconds }) => { + const now = dayjs.utc(); + const then = now.subtract(inSeconds, "seconds"); + return ( + <Tooltip + hasArrow + label={then.toString().replace("GMT", "UTC")} + placement="right" + > + <Text fontSize="sm">{now.to(then, true)}</Text> + </Tooltip> + ); +}; + +const Weight = ({ weight, winningWeight }) => { + const fixMeText = + winningWeight === "low" + ? "Lower Weight is Preferred" + : "Higher Weight is Preferred"; + return ( + <Tooltip hasArrow label={fixMeText} placement="right"> + <Text fontSize="sm" fontFamily="mono"> + {weight} + </Text> + </Tooltip> + ); +}; + +const ASPath = ({ path, active, longestASN }) => { + const { colorMode } = useColorMode(); + let paths = []; + path.map((asn, i) => { + const asnStr = String(asn); + i !== 0 && + paths.push( + <Icon + name="chevron-right" + key={`separator-${i}`} + color={arrowColor[active][colorMode]} + /> + ); + paths.push( + <Text + fontSize="sm" + as="span" + whiteSpace="pre" + fontFamily="mono" + key={`as-${asnStr}-${i}`} + > + {asnStr} + </Text> + ); + }); + return paths; +}; + +const Communities = ({ communities }) => { + const { colorMode } = useColorMode(); + let component; + communities.length === 0 + ? (component = ( + <Tooltip placement="right" hasArrow label="No Communities"> + <Icon name="question-outline" /> + </Tooltip> + )) + : (component = ( + <Popover trigger="hover" placement="right"> + <PopoverTrigger> + <Icon name="view" /> + </PopoverTrigger> + <PopoverContent + textAlign="left" + p={4} + maxW="fit-content" + color={colorMode === "dark" ? "white" : "black"} + > + <PopoverArrow /> + {communities.map(c => ( + <MonoField fontWeight="normal" v={c} key={c.replace(":", "-")} /> + ))} + </PopoverContent> + </Popover> + )); + 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 ( + <Tooltip + hasArrow + placement="right" + label={stateText[state] ?? stateText[3]} + > + <Icon + name={rpkiIcon[state]} + color={rpkiColor[active][colorMode][state]} + /> + </Tooltip> + ); +}; + +const Cell = ({ data, rawData, longestASN }) => { + hiddenCols.includes(data.column.id) && + data.setHiddenColumns(old => [...old, data.column.id]); + const component = { + active: <Active isActive={data.value} />, + age: <Age inSeconds={data.value} />, + weight: ( + <Weight weight={data.value} winningWeight={rawData.winning_weight} /> + ), + med: <MonoField v={data.value} />, + local_preference: <MonoField v={data.value} />, + as_path: ( + <ASPath + path={data.value} + active={data.row.values.active} + longestASN={longestASN} + /> + ), + communities: <Communities communities={data.value} />, + next_hop: <MonoField v={data.value} />, + source_as: <MonoField v={data.value} />, + source_rid: <MonoField v={data.value} />, + peer_rid: <MonoField v={data.value} />, + rpki_state: <RPKIState state={data.value} active={data.row.values.active} /> + }; + return component[data.column.id] ?? <> </>; +}; + +const Structured = () => { + const config = useConfig(); + const columns = makeColumns(config.parsed_data_fields); + const allASN = data.routes.map(r => r.as_path).flat(); + const asLength = longestASNLength(allASN); + + return ( + <Layout> + <Flex my={8} w="80%"> + <Table + columns={columns} + data={data.routes} + rowHighlightProp="active" + cellRender={d => ( + <Cell data={d} rawData={data} longestASN={asLength} /> + )} + bordersHorizontal + rowHighlightBg="green" + /> + </Flex> + </Layout> + ); +}; + +export default Structured; diff --git a/hyperglass/ui/theme.js b/hyperglass/ui/theme.js index 8dd4961..d6123ca 100644 --- a/hyperglass/ui/theme.js +++ b/hyperglass/ui/theme.js @@ -2,238 +2,159 @@ import { theme as chakraTheme } from "@chakra-ui/core"; import chroma from "chroma-js"; const alphaColors = color => ({ - 900: chroma(color) - .alpha(0.92) - .css(), - 800: chroma(color) - .alpha(0.8) - .css(), - 700: chroma(color) - .alpha(0.6) - .css(), - 600: chroma(color) - .alpha(0.48) - .css(), - 500: chroma(color) - .alpha(0.38) - .css(), - 400: chroma(color) - .alpha(0.24) - .css(), - 300: chroma(color) - .alpha(0.16) - .css(), - 200: chroma(color) - .alpha(0.12) - .css(), - 100: chroma(color) - .alpha(0.08) - .css(), - 50: chroma(color) - .alpha(0.04) - .css() + 900: chroma(color) + .alpha(0.92) + .css(), + 800: chroma(color) + .alpha(0.8) + .css(), + 700: chroma(color) + .alpha(0.6) + .css(), + 600: chroma(color) + .alpha(0.48) + .css(), + 500: chroma(color) + .alpha(0.38) + .css(), + 400: chroma(color) + .alpha(0.24) + .css(), + 300: chroma(color) + .alpha(0.16) + .css(), + 200: chroma(color) + .alpha(0.12) + .css(), + 100: chroma(color) + .alpha(0.08) + .css(), + 50: chroma(color) + .alpha(0.04) + .css() }); const generateColors = colorInput => { - const colorMap = {}; + const colorMap = {}; - const lightnessMap = [0.95, 0.85, 0.75, 0.65, 0.55, 0.45, 0.35, 0.25, 0.15, 0.05]; - const saturationMap = [0.32, 0.16, 0.08, 0.04, 0, 0, 0.04, 0.08, 0.16, 0.32]; + const lightnessMap = [ + 0.95, + 0.85, + 0.75, + 0.65, + 0.55, + 0.45, + 0.35, + 0.25, + 0.15, + 0.05 + ]; + const saturationMap = [0.32, 0.16, 0.08, 0.04, 0, 0, 0.04, 0.08, 0.16, 0.32]; - const validColor = chroma.valid(colorInput.trim()) ? chroma(colorInput.trim()) : chroma("#000"); + const validColor = chroma.valid(colorInput.trim()) + ? chroma(colorInput.trim()) + : chroma("#000"); - const lightnessGoal = validColor.get("hsl.l"); - const closestLightness = lightnessMap.reduce((prev, curr) => - Math.abs(curr - lightnessGoal) < Math.abs(prev - lightnessGoal) ? curr : prev - ); + const lightnessGoal = validColor.get("hsl.l"); + const closestLightness = lightnessMap.reduce((prev, curr) => + Math.abs(curr - lightnessGoal) < Math.abs(prev - lightnessGoal) + ? curr + : prev + ); - const baseColorIndex = lightnessMap.findIndex(l => l === closestLightness); + const baseColorIndex = lightnessMap.findIndex(l => l === closestLightness); - const colors = lightnessMap - .map(l => validColor.set("hsl.l", l)) - .map(color => chroma(color)) - .map((color, i) => { - const saturationDelta = saturationMap[i] - saturationMap[baseColorIndex]; - return saturationDelta >= 0 - ? color.saturate(saturationDelta) - : color.desaturate(saturationDelta * -1); - }); - - const getColorNumber = index => (index === 0 ? 50 : index * 100); - - colors.map((color, i) => { - const colorIndex = getColorNumber(i); - colorMap[colorIndex] = color.hex(); + const colors = lightnessMap + .map(l => validColor.set("hsl.l", l)) + .map(color => chroma(color)) + .map((color, i) => { + const saturationDelta = saturationMap[i] - saturationMap[baseColorIndex]; + return saturationDelta >= 0 + ? color.saturate(saturationDelta) + : color.desaturate(saturationDelta * -1); }); - return colorMap; + + const getColorNumber = index => (index === 0 ? 50 : index * 100); + + colors.map((color, i) => { + const colorIndex = getColorNumber(i); + colorMap[colorIndex] = color.hex(); + }); + return colorMap; }; -// const defaultBasePalette = { -// black: "#262626", -// white: "#f7f7f7", -// gray: "#c1c7cc", -// red: "#d84b4b", -// orange: "ff6b35", -// yellow: "#edae49", -// green: "#35b246", -// blue: "#314cb6", -// teal: "#35b299", -// cyan: "#118ab2", -// pink: "#f2607d", -// purple: "#8d30b5" -// }; - -// const defaultSwatchPalette = { -// black: defaultBasePalette.black, -// white: defaultBasePalette.white, -// gray: generateColors(defaultBasePalette.gray), -// red: generateColors(defaultBasePalette.red), -// orange: generateColors(defaultBasePalette.orange), -// yellow: generateColors(defaultBasePalette.yellow), -// green: generateColors(defaultBasePalette.green), -// blue: generateColors(defaultBasePalette.blue), -// teal: generateColors(defaultBasePalette.teal), -// cyan: generateColors(defaultBasePalette.cyan), -// pink: generateColors(defaultBasePalette.pink), -// purple: generateColors(defaultBasePalette.purple) -// }; - -// const defaultAlphaPalette = { -// blackAlpha: alphaColors(defaultBasePalette.black), -// whiteAlpha: alphaColors(defaultBasePalette.white) -// }; - -// const defaultFuncSwatchPalette = { -// primary: generateColors(defaultBasePalette.cyan), -// secondary: generateColors(defaultBasePalette.blue), -// dark: generateColors(defaultBasePalette.black), -// light: generateColors(defaultBasePalette.white), -// success: generateColors(defaultBasePalette.green), -// warning: generateColors(defaultBasePalette.yellow), -// error: generateColors(defaultBasePalette.orange), -// danger: generateColors(defaultBasePalette.red) -// }; - -// const defaultColors = { -// transparent: "transparent", -// current: "currentColor", -// ...defaultFuncSwatchPalette, -// ...defaultAlphaPalette, -// ...defaultSwatchPalette -// }; - const defaultBodyFonts = [ - // "Nunito", - "-apple-system", - "BlinkMacSystemFont", - '"Segoe UI"', - "Helvetica", - "Arial", - "sans-serif", - '"Apple Color Emoji"', - '"Segoe UI Emoji"', - '"Segoe UI Symbol"' + "-apple-system", + "BlinkMacSystemFont", + '"Segoe UI"', + "Helvetica", + "Arial", + "sans-serif", + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"' ]; const defaultMonoFonts = [ - // '"Fira Code"', - "SFMono-Regular", - "Melno", - "Monaco", - "Consolas", - '"Liberation Mono"', - '"Courier New"', - "monospace" + "SFMono-Regular", + "Melno", + "Monaco", + "Consolas", + '"Liberation Mono"', + '"Courier New"', + "monospace" ]; -// const defaultFonts = { -// body: defaultBodyFonts.join(", "), -// heading: defaultBodyFonts.join(", "), -// mono: defaultMonoFonts.join(", ") -// }; - -// const defaultTheme = { -// ...chakraTheme, -// colors: defaultColors, -// fonts: defaultFonts -// }; - const generatePalette = palette => { - const generatedPalette = {}; - Object.keys(palette).map(color => { - if (!["black", "white"].includes(color)) { - generatedPalette[color] = generateColors(palette[color]); - } else { - generatedPalette[color] = palette[color]; - generatedPalette[`${color}Alpha`] = alphaColors(palette[color]); - } - }); - return generatedPalette; + const generatedPalette = {}; + Object.keys(palette).map(color => { + if (!["black", "white"].includes(color)) { + generatedPalette[color] = generateColors(palette[color]); + } else { + generatedPalette[color] = palette[color]; + generatedPalette[`${color}Alpha`] = alphaColors(palette[color]); + } + }); + return generatedPalette; }; -// const generateFuncPalette = palette => ({ -// primary: generateColors(palette.cyan), -// secondary: generateColors(palette.blue), -// dark: generateColors(palette.black), -// light: generateColors(palette.white), -// success: generateColors(palette.green), -// warning: generateColors(palette.yellow), -// error: generateColors(palette.orange), -// danger: generateColors(palette.red) -// }); - -// const generateAlphaPalette = palette => ({ -// blackAlpha: alphaColors(palette.black), -// whiteAlpha: alphaColors(palette.white) -// }); - const formatFont = font => { - const fontList = font.split(" "); - const fontFmt = fontList.length >= 2 ? `'${fontList.join(" ")}'` : fontList.join(" "); - return fontFmt; + const fontList = font.split(" "); + const fontFmt = + fontList.length >= 2 ? `'${fontList.join(" ")}'` : fontList.join(" "); + return fontFmt; }; const importFonts = userFonts => { - const [body, mono] = [defaultBodyFonts, defaultMonoFonts]; - const bodyFmt = formatFont(userFonts.body); - const monoFmt = formatFont(userFonts.mono); - if (userFonts.body && !body.includes(bodyFmt)) { - body.unshift(bodyFmt); - } - if (userFonts.mono && !mono.includes(monoFmt)) { - mono.unshift(monoFmt); - } - return { - body: body.join(", "), - heading: body.join(", "), - mono: mono.join(", ") - }; + const [body, mono] = [defaultBodyFonts, defaultMonoFonts]; + const bodyFmt = formatFont(userFonts.body); + const monoFmt = formatFont(userFonts.mono); + if (userFonts.body && !body.includes(bodyFmt)) { + body.unshift(bodyFmt); + } + if (userFonts.mono && !mono.includes(monoFmt)) { + mono.unshift(monoFmt); + } + return { + body: body.join(", "), + heading: body.join(", "), + mono: mono.join(", ") + }; }; const importColors = (userColors = {}) => { - // const baseColors = { - // ...defaultBasePalette, - // ...userColors - // }; - - const generatedColors = generatePalette(userColors); - // const swatchColors = generatePalette(baseColors); - // const funcColors = generateFuncPalette(baseColors); - // const bwAlphaColors = generateAlphaPalette(userColors); - return { - transparent: "transparent", - current: "currentColor", - // ...swatchColors, - // ...funcColors, - ...generatedColors - // ...bwAlphaColors - }; + const generatedColors = generatePalette(userColors); + return { + transparent: "transparent", + current: "currentColor", + ...generatedColors + }; }; const makeTheme = userTheme => ({ - ...chakraTheme, - colors: importColors(userTheme.colors), - fonts: importFonts(userTheme.fonts) + ...chakraTheme, + colors: importColors(userTheme.colors), + fonts: importFonts(userTheme.fonts) }); export { makeTheme, chakraTheme as defaultTheme }; diff --git a/hyperglass/ui/util.js b/hyperglass/ui/util.js index 64dcbb6..24e9f77 100644 --- a/hyperglass/ui/util.js +++ b/hyperglass/ui/util.js @@ -1,28 +1,36 @@ import chroma from "chroma-js"; const isDark = color => { - // YIQ equation from http://24ways.org/2010/calculating-color-contrast - const rgb = chroma(color).rgb(); - const yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000; - return yiq < 128; + // YIQ equation from http://24ways.org/2010/calculating-color-contrast + const rgb = chroma(color).rgb(); + const yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000; + return yiq < 128; }; const isLight = color => isDark(color); const opposingColor = (theme, color) => { - const opposing = isDark(color) ? theme.colors.white : theme.colors.black; - return opposing; + if (color.includes(".")) { + const colorParts = color.split("."); + if (colorParts.length !== 2) { + throw Error(`Color is improperly formatted. Got '${color}'`); + } + const [colorName, colorOpacity] = colorParts; + color = theme.colors[colorName][colorOpacity]; + } + const opposing = isDark(color) ? theme.colors.white : theme.colors.black; + return opposing; }; const googleFontUrl = (fontFamily, weights = [300, 400, 700]) => { - const urlWeights = weights.join(","); - const fontName = fontFamily - .split(/, /)[0] - .trim() - .replace(/'|"/g, ""); - const urlFont = fontName.split(/ /).join("+"); - const urlBase = `https://fonts.googleapis.com/css?family=${urlFont}:${urlWeights}&display=swap`; - return urlBase; + const urlWeights = weights.join(","); + const fontName = fontFamily + .split(/, /)[0] + .trim() + .replace(/'|"/g, ""); + const urlFont = fontName.split(/ /).join("+"); + const urlBase = `https://fonts.googleapis.com/css?family=${urlFont}:${urlWeights}&display=swap`; + return urlBase; }; export { isDark, isLight, opposingColor, googleFontUrl }; diff --git a/hyperglass/ui/yarn.lock b/hyperglass/ui/yarn.lock index d153468..aae35db 100644 --- a/hyperglass/ui/yarn.lock +++ b/hyperglass/ui/yarn.lock @@ -1100,6 +1100,11 @@ resolved "https://registry.yarnpkg.com/@reach/visually-hidden/-/visually-hidden-0.1.4.tgz#0dc4ecedf523004337214187db70a46183bd945b" integrity sha512-QHbzXjflSlCvDd6vJwdwx16mSB+vUCCQMiU/wK/CgVNPibtpEiIbisyxkpZc55DyDFNUIqP91rSUsNae+ogGDQ== +"@scarf/scarf@^0.1.5": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@scarf/scarf/-/scarf-0.1.5.tgz#fc4cc88294eca336eed9a91549180346de5e6946" + integrity sha512-Fx6atDc7JM1r0WkPCDhNetVZNp+DO21q/HGlomAKBG+k8vb1B8fg8Yige4oCf1P9OWTZWm5tM5i3jlXhrSbNOg== + "@styled-system/background@^5.0.5", "@styled-system/background@^5.1.2": version "5.1.2" resolved "https://registry.yarnpkg.com/@styled-system/background/-/background-5.1.2.tgz#75c63d06b497ab372b70186c0bf608d62847a2ba" @@ -2718,6 +2723,11 @@ crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" +crypto-extra@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/crypto-extra/-/crypto-extra-1.0.1.tgz#91d9e3d8d35f9a16d548c073c31b147f34eab11e" + integrity sha512-EDJ07p6UDdyX+7GL6Bb6FqCuCHY34GuII9esE/xXZtW7tlB+A7MYT9O/c1M/ov2aGTdwvw0b3z5OcO50zYBG5Q== + css-blank-pseudo@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5" @@ -2821,6 +2831,11 @@ damerau-levenshtein@^1.0.4: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791" integrity sha512-JVrozIeElnj3QzfUIt8tB8YMluBJom4Vw9qTPpjGYQ9fYlB3D/rb6OordUxf3xeFB35LKWs0xqcO5U6ySvBtug== +dayjs@^1.8.25: + version "1.8.25" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.8.25.tgz#d09a8696cee7191bc1289e739f96626391b9c73c" + integrity sha512-Pk36juDfQQGDCgr0Lqd1kw15w3OS6xt21JaLPE3lCfsEf8KrERGwDNwvK1tRjrjqFC0uZBJncT4smZQ4F+uV5g== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -5180,6 +5195,13 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== +namor@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/namor/-/namor-2.0.2.tgz#5b1f4e63b91d1fdc8b67d5b9962edd73f67cfcbe" + integrity sha512-2LNDTv2naxXm7j0op8ngH472iL5ftTlLUmnq7Uj0kapkNxP2Oa3IyDpo4KUlHcgdQBHBSrUpJLEyUWkMqjFkMQ== + dependencies: + crypto-extra "1.0.1" + nan@^2.12.1: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" @@ -6566,6 +6588,13 @@ react-string-replace@^0.4.4: dependencies: lodash "^4.17.4" +react-table@^7.0.4: + version "7.0.4" + resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.0.4.tgz#456838661982c83c3682f156c59a41b4339a2120" + integrity sha512-Uqpj+VnUIvsNWNtNFD1z2i7OCHdlhoJtQt0DWx3XOkZnvDyI/eCghK8YBfA9mY4TW7vEgCDLaRCcREC/fmcx6Q== + dependencies: + "@scarf/scarf" "^0.1.5" + react-textfit@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/react-textfit/-/react-textfit-1.1.0.tgz#088855580f2e7aad269efc81b734bf636877d0e1" diff --git a/poetry.lock b/poetry.lock index 3267325..95fbb80 100644 --- a/poetry.lock +++ b/poetry.lock @@ -846,7 +846,7 @@ description = "Data validation and settings management using python 3.6 type hin name = "pydantic" optional = false python-versions = ">=3.6" -version = "1.4" +version = "1.5.1" [package.dependencies] [package.dependencies.dataclasses] @@ -1159,6 +1159,14 @@ version = "1.0.1" [package.extras] dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] +[[package]] +category = "main" +description = "Makes working with XML feel like you are working with JSON" +name = "xmltodict" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.12.0" + [[package]] category = "dev" description = "Backport of pathlib-compatible object wrapper for zip files" @@ -1173,7 +1181,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "7a4bbf22965a2af2a61fcc9d67126093e54986f11e9514d828db6e156534b865" +content-hash = "7b58a797269e6f4220563b69300281662b8e97f1f8bde1d7551faab23f8327da" python-versions = "^3.6" [metadata.files] @@ -1566,20 +1574,23 @@ pycparser = [ {file = "pycparser-2.19.tar.gz", hash = "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"}, ] pydantic = [ - {file = "pydantic-1.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:07911aab70f3bc52bb845ce1748569c5e70478ac977e106a150dd9d0465ebf04"}, - {file = "pydantic-1.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:012c422859bac2e03ab3151ea6624fecf0e249486be7eb8c6ee69c91740c6752"}, - {file = "pydantic-1.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:61d22d36808087d3184ed6ac0d91dd71c533b66addb02e4a9930e1e30833202f"}, - {file = "pydantic-1.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f863456d3d4bf817f2e5248553dee3974c5dc796f48e6ddb599383570f4215ac"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bbbed364376f4a0aebb9ea452ff7968b306499a9e74f4db69b28ff2cd4043a11"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e27559cedbd7f59d2375bfd6eea29a330ea1a5b0589c34d6b4e0d7bec6027bbf"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:50e4e948892a6815649ad5a9a9379ad1e5f090f17842ac206535dfaed75c6f2f"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8848b4eb458469739126e4c1a202d723dd092e087f8dbe3104371335f87ba5df"}, - {file = "pydantic-1.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:831a0265a9e3933b3d0f04d1a81bba543bafbe4119c183ff2771871db70524ab"}, - {file = "pydantic-1.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:47b8db7024ba3d46c3d4768535e1cf87b6c8cf92ccd81e76f4e1cb8ee47688b3"}, - {file = "pydantic-1.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:51f11c8bbf794a68086540da099aae4a9107447c7a9d63151edbb7d50110cf21"}, - {file = "pydantic-1.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:6100d7862371115c40be55cc4b8d766a74b1d0dbaf99dbfe72bb4bac0faf89ed"}, - {file = "pydantic-1.4-py36.py37.py38-none-any.whl", hash = "sha256:72184c1421103cca128300120f8f1185fb42a9ea73a1c9845b1c53db8c026a7d"}, - {file = "pydantic-1.4.tar.gz", hash = "sha256:f17ec336e64d4583311249fb179528e9a2c27c8a2eaf590ec6ec2c6dece7cb3f"}, + {file = "pydantic-1.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2a6904e9f18dea58f76f16b95cba6a2f20b72d787abd84ecd67ebc526e61dce6"}, + {file = "pydantic-1.5.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:da8099fca5ee339d5572cfa8af12cf0856ae993406f0b1eb9bb38c8a660e7416"}, + {file = "pydantic-1.5.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:68dece67bff2b3a5cc188258e46b49f676a722304f1c6148ae08e9291e284d98"}, + {file = "pydantic-1.5.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ab863853cb502480b118187d670f753be65ec144e1654924bec33d63bc8b3ce2"}, + {file = "pydantic-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:2007eb062ed0e57875ce8ead12760a6e44bf5836e6a1a7ea81d71eeecf3ede0f"}, + {file = "pydantic-1.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:20a15a303ce1e4d831b4e79c17a4a29cb6740b12524f5bba3ea363bff65732bc"}, + {file = "pydantic-1.5.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:473101121b1bd454c8effc9fe66d54812fdc128184d9015c5aaa0d4e58a6d338"}, + {file = "pydantic-1.5.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:9be755919258d5d168aeffbe913ed6e8bd562e018df7724b68cabdee3371e331"}, + {file = "pydantic-1.5.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:b96ce81c4b5ca62ab81181212edfd057beaa41411cd9700fbcb48a6ba6564b4e"}, + {file = "pydantic-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:93b9f265329d9827f39f0fca68f5d72cc8321881cdc519a1304fa73b9f8a75bd"}, + {file = "pydantic-1.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e2c753d355126ddd1eefeb167fa61c7037ecd30b98e7ebecdc0d1da463b4ea09"}, + {file = "pydantic-1.5.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:8433dbb87246c0f562af75d00fa80155b74e4f6924b0db6a2078a3cd2f11c6c4"}, + {file = "pydantic-1.5.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:0a1cdf24e567d42dc762d3fed399bd211a13db2e8462af9dfa93b34c41648efb"}, + {file = "pydantic-1.5.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:8be325fc9da897029ee48d1b5e40df817d97fe969f3ac3fd2434ba7e198c55d5"}, + {file = "pydantic-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:3714a4056f5bdbecf3a41e0706ec9b228c9513eee2ad884dc2c568c4dfa540e9"}, + {file = "pydantic-1.5.1-py36.py37.py38-none-any.whl", hash = "sha256:70f27d2f0268f490fe3de0a9b6fca7b7492b8fd6623f9fecd25b221ebee385e3"}, + {file = "pydantic-1.5.1.tar.gz", hash = "sha256:f0018613c7a0d19df3240c2a913849786f21b6539b9f23d85ce4067489dfacfa"}, ] pydocstyle = [ {file = "pydocstyle-5.0.2-py3-none-any.whl", hash = "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586"}, @@ -1777,6 +1788,10 @@ win32-setctime = [ {file = "win32_setctime-1.0.1-py3-none-any.whl", hash = "sha256:568fd636c68350bcc54755213fe01966fe0a6c90b386c0776425944a0382abef"}, {file = "win32_setctime-1.0.1.tar.gz", hash = "sha256:b47e5023ec7f0b4962950902b15bc56464a380d869f59d27dbf9ab423b23e8f9"}, ] +xmltodict = [ + {file = "xmltodict-0.12.0-py2.py3-none-any.whl", hash = "sha256:8bbcb45cc982f48b2ca8fe7e7827c5d792f217ecf1792626f808bf41c3b86051"}, + {file = "xmltodict-0.12.0.tar.gz", hash = "sha256:50d8c638ed7ecb88d90561beedbf720c9b4e851a9fa6c47ebd64e99d166d8a21"}, +] zipp = [ {file = "zipp-3.0.0-py3-none-any.whl", hash = "sha256:12248a63bbdf7548f89cb4c7cda4681e537031eda29c02ea29674bc6854460c2"}, {file = "zipp-3.0.0.tar.gz", hash = "sha256:7c0f8e91abc0dc07a5068f315c52cb30c66bfbc581e5b50704c8a2f6ebae794a"}, diff --git a/pyproject.toml b/pyproject.toml index 09e745d..867dabb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ uvloop = "^0.14.0" inquirer = "^2.6.3" paramiko = "^2.7.1" gunicorn = "^20.0.4" +xmltodict = "^0.12.0" [tool.poetry.dev-dependencies] bandit = "^1.6.2"