1
0
Fork 1
mirror of https://github.com/thatmattlove/hyperglass.git synced 2026-01-17 08:48:05 +00:00

countless updates I mostly don't remember.

This commit is contained in:
thatmattlove 2023-04-13 23:05:05 -04:00
parent b94eb3006a
commit 446853e4a9
45 changed files with 3302 additions and 3549 deletions

View file

@ -5,11 +5,10 @@ repos:
- id: isort - id: isort
args: ['--profile', 'black', '--filter-files', '--check'] args: ['--profile', 'black', '--filter-files', '--check']
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.192 rev: v0.0.261
hooks: hooks:
- id: ruff - id: ruff
# Respect `exclude` and `extend-exclude` settings. # Respect `exclude` and `extend-exclude` settings.
args: ['--force-exclude']
- repo: local - repo: local
hooks: hooks:
- id: typescript - id: typescript

View file

@ -155,7 +155,6 @@ class BaseExternal:
try: try:
parsed = _json.loads(response) parsed = _json.loads(response)
except (JSONDecodeError, TypeError): except (JSONDecodeError, TypeError):
log.error("Error parsing JSON for response {}", repr(response))
parsed = {"data": response.text} parsed = {"data": response.text}
else: else:
parsed = response parsed = response

View file

@ -57,8 +57,10 @@ HyperglassConsole = Console(
) )
) )
log = _loguru_logger
class LibIntercentHandler(logging.Handler):
class LibInterceptHandler(logging.Handler):
"""Custom log handler for integrating third party library logging with hyperglass's logger.""" """Custom log handler for integrating third party library logging with hyperglass's logger."""
def emit(self, record): def emit(self, record):
@ -104,25 +106,25 @@ def setup_lib_logging(log_level: str) -> None:
See: https://pawamoy.github.io/posts/unify-logging-for-a-gunicorn-uvicorn-app/ See: https://pawamoy.github.io/posts/unify-logging-for-a-gunicorn-uvicorn-app/
""" """
intercept_handler = LibIntercentHandler() intercept_handler = LibInterceptHandler()
names = {
seen = set() name.split(".")[0]
for name in [ for name in (
*logging.root.manager.loggerDict.keys(), *logging.root.manager.loggerDict.keys(),
"gunicorn", "gunicorn",
"gunicorn.access", "gunicorn.access",
"gunicorn.error", "gunicorn.error",
"uvicorn", "uvicorn",
"uvicorn.access", "uvicorn.access",
"uvicorn.error", "uvicorn.error",
"uvicorn.asgi", "uvicorn.asgi",
"netmiko", "netmiko",
"paramiko", "paramiko",
"httpx", "httpx",
]: )
if name not in seen: }
seen.add(name.split(".")[0]) for name in names:
logging.getLogger(name).handlers = [intercept_handler] logging.getLogger(name).handlers = [intercept_handler]
def _log_patcher(record): def _log_patcher(record):
@ -165,20 +167,12 @@ def init_logger(level: str = "INFO"):
return _loguru_logger return _loguru_logger
log = init_logger()
logging.addLevelName(25, "SUCCESS")
def _log_success(self: "LoguruLogger", message: str, *a: t.Any, **kw: t.Any) -> None: def _log_success(self: "LoguruLogger", message: str, *a: t.Any, **kw: t.Any) -> None:
"""Add custom builtin logging handler for the success level.""" """Add custom builtin logging handler for the success level."""
if self.isEnabledFor(25): if self.isEnabledFor(25):
self._log(25, message, a, **kw) self._log(25, message, a, **kw)
logging.Logger.success = _log_success
def enable_file_logging( def enable_file_logging(
log_directory: "Path", log_format: "LogFormat", log_max_size: "ByteSize", debug: bool log_directory: "Path", log_format: "LogFormat", log_max_size: "ByteSize", debug: bool
) -> None: ) -> None:
@ -238,3 +232,8 @@ def enable_syslog_logging(syslog_host: str, syslog_port: int) -> None:
str(syslog_host), str(syslog_host),
str(syslog_port), str(syslog_port),
) )
# Side Effects
logging.addLevelName(25, "SUCCESS")
logging.Logger.success = _log_success

View file

@ -12,7 +12,7 @@ from gunicorn.arbiter import Arbiter # type: ignore
from gunicorn.app.base import BaseApplication # type: ignore from gunicorn.app.base import BaseApplication # type: ignore
# Local # Local
from .log import CustomGunicornLogger, log, setup_lib_logging from .log import CustomGunicornLogger, log, init_logger, setup_lib_logging
from .util import get_node_version from .util import get_node_version
from .plugins import InputPluginManager, OutputPluginManager, register_plugin, init_builtin_plugins from .plugins import InputPluginManager, OutputPluginManager, register_plugin, init_builtin_plugins
from .constants import MIN_NODE_VERSION, MIN_PYTHON_VERSION, __version__ from .constants import MIN_NODE_VERSION, MIN_PYTHON_VERSION, __version__
@ -23,10 +23,11 @@ if sys.version_info < MIN_PYTHON_VERSION:
raise RuntimeError(f"Python {pretty_version}+ is required.") raise RuntimeError(f"Python {pretty_version}+ is required.")
# Ensure the NodeJS version meets the minimum requirements. # Ensure the NodeJS version meets the minimum requirements.
node_major, _, __ = get_node_version() node_major, node_minor, node_patch = get_node_version()
if node_major < MIN_NODE_VERSION: if node_major < MIN_NODE_VERSION:
raise RuntimeError(f"NodeJS {MIN_NODE_VERSION!s}+ is required.") installed = ".".join(str(v) for v in (node_major, node_minor, node_patch))
raise RuntimeError(f"NodeJS {MIN_NODE_VERSION!s}+ is required (version {installed} installed)")
# Local # Local
@ -175,6 +176,7 @@ def run(_workers: int = None):
if _workers is not None: if _workers is not None:
workers = _workers workers = _workers
init_logger(log_level)
setup_lib_logging(log_level) setup_lib_logging(log_level)
start(log_level=log_level, workers=workers) start(log_level=log_level, workers=workers)
except Exception as error: except Exception as error:
@ -188,6 +190,8 @@ def run(_workers: int = None):
except SystemExit: except SystemExit:
# Handle Gunicorn exit. # Handle Gunicorn exit.
sys.exit(4) sys.exit(4)
except BaseException:
sys.exit(4)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -67,7 +67,7 @@ class Query(BaseModel):
def __repr__(self) -> str: def __repr__(self) -> str:
"""Represent only the query fields.""" """Represent only the query fields."""
return repr_from_attrs(self, self.__config__.fields.keys()) return repr_from_attrs(self, ("query_location", "query_type", "query_target"))
def __str__(self) -> str: def __str__(self) -> str:
"""Alias __str__ to __repr__.""" """Alias __str__ to __repr__."""

View file

@ -82,6 +82,9 @@ class Params(ParamsPublic, HyperglassModel):
schema_extra = {"level": 1} schema_extra = {"level": 1}
def __init__(self, **kw: Any) -> None:
return super().__init__(**self.convert_paths(kw))
@validator("site_description") @validator("site_description")
def validate_site_description(cls: "Params", value: str, values: Dict[str, Any]) -> str: def validate_site_description(cls: "Params", value: str, values: Dict[str, Any]) -> str:
"""Format the site descripion with the org_name field.""" """Format the site descripion with the org_name field."""

View file

@ -3,6 +3,7 @@
# Standard Library # Standard Library
import re import re
import typing as t import typing as t
from pathlib import Path
# Third Party # Third Party
from pydantic import StrictInt, StrictFloat from pydantic import StrictInt, StrictFloat
@ -111,3 +112,39 @@ class HttpMethod(str):
def __repr__(self): def __repr__(self):
"""Stringify custom field representation.""" """Stringify custom field representation."""
return f"HttpMethod({super().__repr__()})" return f"HttpMethod({super().__repr__()})"
class ConfigPathItem(Path):
"""Custom field type for files or directories contained within app_path."""
@classmethod
def __get_validators__(cls):
"""Pydantic custom field method."""
yield cls.validate
@classmethod
def validate(cls, value: Path) -> Path:
"""Ensure path is within app path."""
if isinstance(value, str):
value = Path(value)
if not isinstance(value, Path):
raise TypeError("Unable to convert type {} to ConfigPathItem".format(type(value)))
# Project
from hyperglass.settings import Settings
if not value.is_relative_to(Settings.app_path):
raise ValueError("{!s} must be relative to {!s}".format(value, Settings.app_path))
if Settings.container:
value = Settings.default_app_path.joinpath(
*(p for p in value.parts if p not in Settings.app_path.parts)
)
print(f"{value=}")
return value
def __repr__(self):
"""Stringify custom field representation."""
return f"ConfigPathItem({super().__repr__()})"

View file

@ -53,6 +53,39 @@ class HyperglassModel(BaseModel):
) )
return snake_to_camel(snake_field) return snake_to_camel(snake_field)
def convert_paths(self, value: t.Any):
"""Change path to relative to app_path."""
# Project
from hyperglass.settings import Settings
if isinstance(value, Path):
if Settings.container:
return str(
Settings.default_app_path.joinpath(
*(p for p in value.parts if p not in Settings.app_path.parts)
)
)
if isinstance(value, str):
path = Path(value)
if path.exists() and Settings.container:
# if path.exists():
return str(
Settings.default_app_path.joinpath(
*(p for p in path.parts if p not in Settings.app_path.parts)
)
)
if isinstance(value, t.Tuple):
return tuple(self.convert_paths(v) for v in value)
if isinstance(value, t.List):
return [self.convert_paths(v) for v in value]
if isinstance(value, t.Generator):
return (self.convert_paths(v) for v in value)
if isinstance(value, t.Dict):
return {k: self.convert_paths(v) for k, v in value.items()}
return value
def _repr_from_attrs(self, attrs: Series[str]) -> str: def _repr_from_attrs(self, attrs: Series[str]) -> str:
"""Alias to `hyperglass.util:repr_from_attrs` in the context of this model.""" """Alias to `hyperglass.util:repr_from_attrs` in the context of this model."""
return repr_from_attrs(self, attrs) return repr_from_attrs(self, attrs)

View file

@ -25,6 +25,8 @@ if t.TYPE_CHECKING:
ListenHost = t.Union[None, IPvAnyAddress, t.Literal["localhost"]] ListenHost = t.Union[None, IPvAnyAddress, t.Literal["localhost"]]
_default_app_path = Path("/etc/hyperglass")
class HyperglassSettings(BaseSettings): class HyperglassSettings(BaseSettings):
"""hyperglass system settings, required to start hyperglass.""" """hyperglass system settings, required to start hyperglass."""
@ -33,12 +35,15 @@ class HyperglassSettings(BaseSettings):
"""hyperglass system settings configuration.""" """hyperglass system settings configuration."""
env_prefix = "hyperglass_" env_prefix = "hyperglass_"
underscore_attrs_are_private = True
config_file_names: t.ClassVar[t.Tuple[str, ...]] = ("config", "devices", "directives") config_file_names: t.ClassVar[t.Tuple[str, ...]] = ("config", "devices", "directives")
default_app_path: t.ClassVar[Path] = _default_app_path
_original_app_path: DirectoryPath = _default_app_path
debug: bool = False debug: bool = False
dev_mode: bool = False dev_mode: bool = False
app_path: DirectoryPath = "/etc/hyperglass" app_path: DirectoryPath = _default_app_path
redis_host: str = "localhost" redis_host: str = "localhost"
redis_password: t.Optional[SecretStr] redis_password: t.Optional[SecretStr]
redis_db: int = 1 redis_db: int = 1
@ -46,6 +51,14 @@ class HyperglassSettings(BaseSettings):
host: IPvAnyAddress = None host: IPvAnyAddress = None
port: int = 8001 port: int = 8001
ca_cert: t.Optional[FilePath] ca_cert: t.Optional[FilePath]
container: bool = False
def __init__(self, **kwargs) -> None:
"""Create hyperglass Settings instance."""
super().__init__(**kwargs)
if self.container:
self.app_path = self.default_app_path
print(self)
def __rich_console__(self, console: "Console", options: "ConsoleOptions") -> "RenderResult": def __rich_console__(self, console: "Console", options: "ConsoleOptions") -> "RenderResult":
"""Render a Rich table representation of hyperglass settings.""" """Render a Rich table representation of hyperglass settings."""

View file

@ -1,7 +1,7 @@
"""Data models used throughout hyperglass.""" """Data models used throughout hyperglass."""
# Standard Library # Standard Library
from typing import Optional import typing as t
from datetime import datetime from datetime import datetime
# Third Party # Third Party
@ -20,12 +20,12 @@ _ICON_URL = "https://res.cloudinary.com/hyperglass/image/upload/v1593192484/icon
class WebhookHeaders(HyperglassModel): class WebhookHeaders(HyperglassModel):
"""Webhook data model.""" """Webhook data model."""
user_agent: Optional[StrictStr] user_agent: t.Optional[StrictStr]
referer: Optional[StrictStr] referer: t.Optional[StrictStr]
accept_encoding: Optional[StrictStr] accept_encoding: t.Optional[StrictStr]
accept_language: Optional[StrictStr] accept_language: t.Optional[StrictStr]
x_real_ip: Optional[StrictStr] x_real_ip: t.Optional[StrictStr]
x_forwarded_for: Optional[StrictStr] x_forwarded_for: t.Optional[StrictStr]
class Config: class Config:
"""Pydantic model config.""" """Pydantic model config."""
@ -51,9 +51,9 @@ class WebhookNetwork(HyperglassModel, extra="allow"):
class Webhook(HyperglassModel): class Webhook(HyperglassModel):
"""Webhook data model.""" """Webhook data model."""
query_location: StrictStr query_location: str
query_type: StrictStr query_type: str
query_target: StrictStr query_target: t.Union[t.List[str], str]
headers: WebhookHeaders headers: WebhookHeaders
source: StrictStr = "Unknown" source: StrictStr = "Unknown"
network: WebhookNetwork network: WebhookNetwork
@ -69,7 +69,7 @@ class Webhook(HyperglassModel):
def msteams(self): def msteams(self):
"""Format the webhook data as a Microsoft Teams card.""" """Format the webhook data as a Microsoft Teams card."""
def code(value): def code(value: t.Any):
"""Wrap argument in backticks for markdown inline code formatting.""" """Wrap argument in backticks for markdown inline code formatting."""
return f"`{str(value)}`" return f"`{str(value)}`"

View file

@ -92,7 +92,7 @@ class RedisManager:
name = self.key(key) name = self.key(key)
value: t.Optional[bytes] = self.instance.get(name) value: t.Optional[bytes] = self.instance.get(name)
if isinstance(value, bytes): if isinstance(value, bytes):
return pickle.loads(value) return pickle.loads(value) # noqa
if raise_if_none is True: if raise_if_none is True:
raise StateError("'{key}' ('{name}') does not exist in Redis store", key=key, name=name) raise StateError("'{key}' ('{name}') does not exist in Redis store", key=key, name=name)
if value_if_none is not None: if value_if_none is not None:
@ -121,7 +121,7 @@ class RedisManager:
value = self.instance.hgetall(name) value = self.instance.hgetall(name)
if isinstance(value, bytes): if isinstance(value, bytes):
return pickle.loads(value) return pickle.loads(value) # noqa
return None return None
def set_map_item(self, key: str, item: str, value: t.Any) -> None: def set_map_item(self, key: str, item: str, value: t.Any) -> None:

View file

@ -6,8 +6,8 @@ import { useOpposingColor, useColorMode, useColorValue, useBreakpointValue } fro
import type { ButtonProps } from '@chakra-ui/react'; import type { ButtonProps } from '@chakra-ui/react';
interface ColorModeToggleProps extends ButtonProps { interface ColorModeToggleProps extends Omit<ButtonProps, 'size'> {
size?: string; size?: string | number;
} }
export const ColorModeToggle = forwardRef<HTMLButtonElement, ColorModeToggleProps>( export const ColorModeToggle = forwardRef<HTMLButtonElement, ColorModeToggleProps>(

View file

@ -48,7 +48,7 @@ export const Footer = (): JSX.Element => {
whiteSpace="nowrap" whiteSpace="nowrap"
color={footerColor} color={footerColor}
spacing={{ base: 8, lg: 6 }} spacing={{ base: 8, lg: 6 }}
d={{ base: 'inline-block', lg: 'flex' }} display={{ base: 'inline-block', lg: 'flex' }}
overflowY={{ base: 'auto', lg: 'unset' }} overflowY={{ base: 'auto', lg: 'unset' }}
justifyContent={{ base: 'center', lg: 'space-between' }} justifyContent={{ base: 'center', lg: 'space-between' }}
> >

View file

@ -44,7 +44,7 @@ export const LocationCard = (props: LocationCardProps): JSX.Element => {
} }
} }
const bg = useColorValue('white', 'blackSolid.800'); const bg = useColorValue('white', 'blackSolid.600');
const imageBorder = useColorValue('gray.600', 'whiteAlpha.800'); const imageBorder = useColorValue('gray.600', 'whiteAlpha.800');
const fg = useOpposingColor(bg); const fg = useOpposingColor(bg);
const checkedBorder = useColorValue('blue.400', 'blue.300'); const checkedBorder = useColorValue('blue.400', 'blue.300');
@ -74,35 +74,35 @@ export const LocationCard = (props: LocationCardProps): JSX.Element => {
handleChange(option); handleChange(option);
}} }}
> >
<Flex justifyContent="space-between" alignItems="center"> <>
<chakra.h2 <Flex justifyContent="space-between" alignItems="center">
color={fg} <chakra.h2
fontWeight="bold" color={fg}
mt={{ base: 2, md: 0 }} fontWeight="bold"
fontSize={{ base: 'lg', md: 'xl' }} mt={{ base: 2, md: 0 }}
> fontSize={{ base: 'lg', md: 'xl' }}
{label} >
</chakra.h2> {label}
<Avatar </chakra.h2>
color={fg} <Avatar
fit="cover" color={fg}
alt={label} name={label}
name={label} boxSize={12}
boxSize={12} rounded="full"
rounded="full" borderWidth={1}
borderWidth={1} bg="whiteAlpha.300"
bg="whiteAlpha.300" borderStyle="solid"
borderStyle="solid" borderColor={imageBorder}
borderColor={imageBorder} src={(option.data?.avatar as string) ?? undefined}
src={(option.data?.avatar as string) ?? undefined} />
/> </Flex>
</Flex>
{option?.data?.description && ( {option?.data?.description && (
<chakra.p mt={2} color={fg} opacity={0.6} fontSize="sm"> <chakra.p mt={2} color={fg} opacity={0.6} fontSize="sm">
{option.data.description as string} {option.data.description as string}
</chakra.p> </chakra.p>
)} )}
</>
</LocationCardWrapper> </LocationCardWrapper>
); );
}; };

View file

@ -1,12 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import { useConfig } from '~/context'; import { useConfig } from '~/context';
import { useTheme } from '~/hooks';
import { googleFontUrl } from '~/util';
export const Meta = (): JSX.Element => { export const Meta = (): JSX.Element => {
const config = useConfig(); const config = useConfig();
const { fonts } = useTheme();
const [location, setLocation] = useState('/'); const [location, setLocation] = useState('/');
const { const {
@ -15,8 +12,6 @@ export const Meta = (): JSX.Element => {
} = useConfig(); } = useConfig();
const siteName = `${title} - ${description}`; const siteName = `${title} - ${description}`;
const primaryFont = useMemo(() => googleFontUrl(fonts.body), [fonts.body]);
const monoFont = useMemo(() => googleFontUrl(fonts.mono), [fonts.mono]);
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined' && location === '/') { if (typeof window !== 'undefined' && location === '/') {
@ -30,12 +25,14 @@ export const Meta = (): JSX.Element => {
<meta name="url" content={location} /> <meta name="url" content={location} />
<meta name="og:title" content={title} /> <meta name="og:title" content={title} />
<meta name="og:url" content={location} /> <meta name="og:url" content={location} />
<link href={monoFont} rel="stylesheet" />
<link href={primaryFont} rel="stylesheet" />
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta property="og:image:alt" content={siteName} /> <meta property="og:image:alt" content={siteName} />
<meta name="og:description" content={description} /> <meta name="og:description" content={description} />
<meta name="hyperglass-version" content={config.version} /> <meta name="hyperglass-version" content={config.version} />
<meta
name="viewport"
content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0"
/>
</Head> </Head>
); );
}; };

View file

@ -1,5 +1,5 @@
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { Text, Box, Tooltip, Menu, MenuButton, MenuList, Link } from '@chakra-ui/react'; import { Text, Box, Tooltip, Menu, MenuButton, MenuList, Link, Flex } from '@chakra-ui/react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTimePlugin from 'dayjs/plugin/relativeTime'; import relativeTimePlugin from 'dayjs/plugin/relativeTime';
import utcPlugin from 'dayjs/plugin/utc'; import utcPlugin from 'dayjs/plugin/utc';
@ -123,6 +123,7 @@ export const ASPath = (props: ASPathProps): JSX.Element => {
color={color[+active]} color={color[+active]}
boxSize={5} boxSize={5}
px={2} px={2}
display="inline-flex"
/>, />,
); );
paths.push( paths.push(
@ -132,7 +133,7 @@ export const ASPath = (props: ASPathProps): JSX.Element => {
); );
}); });
return <>{paths}</>; return <Flex>{paths}</Flex>;
}; };
export const Communities = (props: CommunitiesProps): JSX.Element => { export const Communities = (props: CommunitiesProps): JSX.Element => {

View file

@ -1,5 +1,13 @@
import { useMemo } from 'react';
import { Box, Flex, SkeletonText, Badge, VStack } from '@chakra-ui/react'; import { Box, Flex, SkeletonText, Badge, VStack } from '@chakra-ui/react';
import ReactFlow, { Background, ReactFlowProvider, Handle, Position } from 'react-flow-renderer'; import ReactFlow, {
Background,
ReactFlowProvider,
Handle,
Position,
isNode,
isEdge,
} from 'react-flow-renderer';
import { useConfig } from '~/context'; import { useConfig } from '~/context';
import { useASNDetail, useColorValue, useColorToken } from '~/hooks'; import { useASNDetail, useColorValue, useColorToken } from '~/hooks';
import { Controls } from './controls'; import { Controls } from './controls';
@ -15,7 +23,7 @@ interface NodeProps<D extends unknown> extends Omit<ReactFlowNodeProps, 'data'>
data: D; data: D;
} }
interface NodeData { export interface NodeData {
asn: string; asn: string;
name: string; name: string;
hasChildren: boolean; hasChildren: boolean;
@ -30,14 +38,18 @@ export const Chart = (props: ChartProps): JSX.Element => {
const elements = useElements({ asn: primaryAsn, name: orgName }, data); const elements = useElements({ asn: primaryAsn, name: orgName }, data);
const nodes = useMemo(() => elements.filter(isNode), [elements]);
const edges = useMemo(() => elements.filter(isEdge), [elements]);
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
<Box w="100%" h={{ base: '100vh', lg: '70vh' }} zIndex={1}> <Box w="100%" h={{ base: '100vh', lg: '70vh' }} zIndex={1}>
<ReactFlow <ReactFlow
snapToGrid snapToGrid
elements={elements} nodes={nodes}
edges={edges}
nodeTypes={{ ASNode }} nodeTypes={{ ASNode }}
onLoad={inst => setTimeout(() => inst.fitView(), 0)} onInit={inst => setTimeout(() => inst.fitView(), 0)}
> >
<Background color={dots} /> <Background color={dots} />
<Controls /> <Controls />

View file

@ -1,9 +1,9 @@
import { ButtonGroup, IconButton } from '@chakra-ui/react'; import { ButtonGroup, IconButton } from '@chakra-ui/react';
import { useZoomPanHelper } from 'react-flow-renderer'; import { useReactFlow } from 'react-flow-renderer';
import { DynamicIcon } from '~/elements'; import { DynamicIcon } from '~/elements';
export const Controls = (): JSX.Element => { export const Controls = (): JSX.Element => {
const { fitView, zoomIn, zoomOut } = useZoomPanHelper(); const { fitView, zoomIn, zoomOut } = useReactFlow();
return ( return (
<ButtonGroup <ButtonGroup
m={4} m={4}

View file

@ -2,17 +2,20 @@ import dagre from 'dagre';
import { useMemo } from 'react'; import { useMemo } from 'react';
import isEqual from 'react-fast-compare'; import isEqual from 'react-fast-compare';
import type { FlowElement } from 'react-flow-renderer'; import type { Node, Edge } from 'react-flow-renderer';
import type { NodeData } from './chart';
interface BasePath { interface BasePath {
asn: string; asn: string;
name: string; name: string;
} }
type FlowElement<T> = Node<T> | Edge<T>;
const NODE_WIDTH = 200; const NODE_WIDTH = 200;
const NODE_HEIGHT = 48; const NODE_HEIGHT = 48;
export function useElements(base: BasePath, data: StructuredResponse): FlowElement[] { export function useElements(base: BasePath, data: StructuredResponse): FlowElement<NodeData>[] {
return useMemo(() => { return useMemo(() => {
return [...buildElements(base, data)]; return [...buildElements(base, data)];
}, [base, data]); }, [base, data]);
@ -22,7 +25,10 @@ export function useElements(base: BasePath, data: StructuredResponse): FlowEleme
* Calculate the positions for each AS Path. * Calculate the positions for each AS Path.
* @see https://github.com/MrBlenny/react-flow-chart/issues/61 * @see https://github.com/MrBlenny/react-flow-chart/issues/61
*/ */
function* buildElements(base: BasePath, data: StructuredResponse): Generator<FlowElement> { function* buildElements(
base: BasePath,
data: StructuredResponse,
): Generator<FlowElement<NodeData>> {
const { routes } = data; const { routes } = data;
// Eliminate empty AS paths & deduplicate non-empty AS paths. Length should be same as count minus empty paths. // 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 asPaths = routes.filter(r => r.as_path.length !== 0).map(r => [...new Set(r.as_path)]);
@ -107,7 +113,7 @@ function* buildElements(base: BasePath, data: StructuredResponse): Generator<Flo
type: 'ASNode', type: 'ASNode',
position: { x, y }, position: { x, y },
data: { data: {
asn, asn: `${asn}`,
name: `AS${asn}`, name: `AS${asn}`,
hasChildren: idx < endIdx, hasChildren: idx < endIdx,
hasParents: true, hasParents: true,

View file

@ -3,7 +3,7 @@ import { Button, Tooltip } from '@chakra-ui/react';
import { DynamicIcon } from '~/elements'; import { DynamicIcon } from '~/elements';
import type { ButtonProps } from '@chakra-ui/react'; import type { ButtonProps } from '@chakra-ui/react';
import type { UseQueryResult } from 'react-query'; import type { UseQueryResult } from '@tanstack/react-query';
interface RequeryButtonProps extends ButtonProps { interface RequeryButtonProps extends ButtonProps {
requery: Get<UseQueryResult<QueryResponse>, 'refetch'>; requery: Get<UseQueryResult<QueryResponse>, 'refetch'>;

View file

@ -11,9 +11,13 @@ export const Option = <Opt extends SingleOption, IsMulti extends boolean>(
const tags = Array.isArray(data.tags) ? (data.tags as string[]) : []; const tags = Array.isArray(data.tags) ? (data.tags as string[]) : [];
return ( return (
<components.Option<Opt, IsMulti, GroupBase<Opt>> {...props}> <components.Option<Opt, IsMulti, GroupBase<Opt>> {...props}>
<chakra.span d={{ base: 'block', lg: 'inline' }}>{label}</chakra.span> <chakra.span display={{ base: 'block', lg: 'inline' }}>{label}</chakra.span>
{tags.length > 0 && ( {tags.length > 0 && (
<HStack d={{ base: 'flex', lg: 'inline-flex' }} ms={{ base: 0, lg: 2 }} alignItems="center"> <HStack
alignItems="center"
ms={{ base: 0, lg: 2 }}
display={{ base: 'flex', lg: 'inline-flex' }}
>
{tags.map(tag => ( {tags.map(tag => (
<Badge fontSize="xs" variant="subtle" key={tag} colorScheme="gray" textTransform="none"> <Badge fontSize="xs" variant="subtle" key={tag} colorScheme="gray" textTransform="none">
{tag} {tag}

View file

@ -75,6 +75,7 @@ export const Select = forwardRef(
isMulti={isMulti} isMulti={isMulti}
theme={rsTheme} theme={rsTheme}
components={{ Option, ...components }} components={{ Option, ...components }}
menuPortalTarget={document?.body ?? undefined}
ref={ref} ref={ref}
styles={{ styles={{
menu, menu,

View file

@ -2,7 +2,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useToken } from '@chakra-ui/react'; import { useToken } from '@chakra-ui/react';
import { mergeWith } from '@chakra-ui/utils'; import { mergeWith } from '@chakra-ui/utils';
import { merge } from 'merge-anything';
import { import {
useMobile, useMobile,
useColorValue, useColorValue,
@ -150,7 +149,6 @@ export const useIndicatorSeparatorStyle = <Opt extends SingleOption, IsMulti ext
): RSStyleFunction<'indicatorSeparator', Opt, IsMulti> => { ): RSStyleFunction<'indicatorSeparator', Opt, IsMulti> => {
const { colorMode } = props; const { colorMode } = props;
const backgroundColor = useColorToken('colors', 'gray.200', 'whiteAlpha.300'); const backgroundColor = useColorToken('colors', 'gray.200', 'whiteAlpha.300');
// const backgroundColor = useColorToken('colors', 'gray.200', 'gray.600');
const styles = { backgroundColor }; const styles = { backgroundColor };
return useCallback(base => mergeWith({}, base, styles), [colorMode]); return useCallback(base => mergeWith({}, base, styles), [colorMode]);
@ -220,7 +218,7 @@ export const useMultiValueRemoveStyle = <Opt extends SingleOption, IsMulti exten
}; };
export const useRSTheme = (): RSThemeFunction => { export const useRSTheme = (): RSThemeFunction => {
const borderRadius = useToken('radii', 'md'); const borderRadius = useToken('radii', 'md') as unknown as number;
return useCallback((t: ReactSelect.Theme): ReactSelect.Theme => ({ ...t, borderRadius }), []); return useCallback((t: ReactSelect.Theme): ReactSelect.Theme => ({ ...t, borderRadius }), []);
}; };
@ -232,8 +230,8 @@ export const useMenuPortal = <Opt extends SingleOption, IsMulti extends boolean>
> => { > => {
const isMobile = useMobile(); const isMobile = useMobile();
const styles = { const styles = {
zIndex: isMobile ? 1500 : 1, zIndex: 1500,
}; };
return useCallback(base => merge(base, styles), [isMobile]); return useCallback(base => mergeWith({}, base, styles), [isMobile]);
}; };

View file

@ -1,8 +1,11 @@
import * as ReactSelect from 'react-select'; import * as ReactSelect from 'react-select';
import type { StylesProps, StylesConfigFunction } from 'react-select/dist/declarations/src/styles'; import type { CSSObjectWithLabel } from 'react-select';
import type { StylesProps } from 'react-select/dist/declarations/src/styles';
import type { Theme, SingleOption } from '~/types'; import type { Theme, SingleOption } from '~/types';
type StylesConfigFunction<Props> = (base: CSSObjectWithLabel, props: Props) => CSSObjectWithLabel;
export type SelectOnChange< export type SelectOnChange<
Opt extends SingleOption = SingleOption, Opt extends SingleOption = SingleOption,
IsMulti extends boolean = boolean, IsMulti extends boolean = boolean,

View file

@ -99,7 +99,7 @@ export const Table = (props: TableProps): JSX.Element => {
{...column.getSortByToggleProps()} {...column.getSortByToggleProps()}
> >
<Text fontSize="sm" fontWeight="bold" display="inline-block"> <Text fontSize="sm" fontWeight="bold" display="inline-block">
{column.render('Header')} {column.render('Header') as React.ReactNode}
</Text> </Text>
<If condition={column.isSorted}> <If condition={column.isSorted}>
<Then> <Then>
@ -142,7 +142,7 @@ export const Table = (props: TableProps): JSX.Element => {
{typeof Cell !== 'undefined' ? ( {typeof Cell !== 'undefined' ? (
<Cell column={column} row={row} value={value} /> <Cell column={column} row={row} value={value} />
) : ( ) : (
cell.render('Cell') (cell.render('Cell') as React.ReactNode)
)} )}
</TableCell> </TableCell>
); );

View file

@ -1,7 +1,7 @@
import { createContext, useContext, useMemo } from 'react'; import { createContext, useContext, useMemo } from 'react';
import { ChakraProvider } from '@chakra-ui/react'; import { ChakraProvider, localStorageManager } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { makeTheme, defaultTheme } from '~/util'; import { makeTheme } from '~/util';
import type { Config } from '~/types'; import type { Config } from '~/types';
@ -16,15 +16,15 @@ const queryClient = new QueryClient();
export const HyperglassProvider = (props: HyperglassProviderProps): JSX.Element => { export const HyperglassProvider = (props: HyperglassProviderProps): JSX.Element => {
const { config, children } = props; const { config, children } = props;
const value = useMemo(() => config, [config]); const value = useMemo(() => config, []);
const userTheme = value && makeTheme(value.web.theme, value.web.theme.defaultColorMode); const theme = useMemo(() => makeTheme(value.web.theme, value.web.theme.defaultColorMode), []);
const theme = value ? userTheme : defaultTheme;
return ( return (
<ChakraProvider theme={theme}> <HyperglassContext.Provider value={value}>
<HyperglassContext.Provider value={value}> <ChakraProvider theme={theme} colorModeManager={localStorageManager} resetCSS>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</HyperglassContext.Provider> </ChakraProvider>
</ChakraProvider> </HyperglassContext.Provider>
); );
}; };

View file

@ -1,6 +1,10 @@
import { useQuery } from 'react-query'; import { useQuery } from '@tanstack/react-query';
import type { QueryFunctionContext, QueryObserverResult, QueryFunction } from 'react-query'; import type {
QueryFunctionContext,
QueryObserverResult,
QueryFunction,
} from '@tanstack/react-query';
interface ASNQuery { interface ASNQuery {
data: { data: {
@ -12,7 +16,7 @@ interface ASNQuery {
}; };
} }
const query: QueryFunction<ASNQuery, string> = async (ctx: QueryFunctionContext) => { const query: QueryFunction<ASNQuery, string[]> = async (ctx: QueryFunctionContext) => {
const asn = ctx.queryKey; const asn = ctx.queryKey;
const res = await fetch('https://api.asrank.caida.org/v2/graphql', { const res = await fetch('https://api.asrank.caida.org/v2/graphql', {
mode: 'cors', mode: 'cors',
@ -29,8 +33,8 @@ const query: QueryFunction<ASNQuery, string> = async (ctx: QueryFunctionContext)
* @see https://api.asrank.caida.org/v2/docs * @see https://api.asrank.caida.org/v2/docs
*/ */
export function useASNDetail(asn: string): QueryObserverResult<ASNQuery> { export function useASNDetail(asn: string): QueryObserverResult<ASNQuery> {
return useQuery<ASNQuery, unknown, ASNQuery, string>({ return useQuery<ASNQuery, unknown, ASNQuery, string[]>({
queryKey: asn, queryKey: [asn],
queryFn: query, queryFn: query,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchInterval: false, refetchInterval: false,

View file

@ -1,7 +1,7 @@
import 'isomorphic-fetch'; import 'isomorphic-fetch';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { renderHook } from '@testing-library/react-hooks'; import { renderHook } from '@testing-library/react-hooks';
import { QueryClientProvider, QueryClient } from 'react-query'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { HyperglassContext } from '~/context'; import { HyperglassContext } from '~/context';
import { useDNSQuery } from './use-dns-query'; import { useDNSQuery } from './use-dns-query';
@ -41,6 +41,7 @@ describe('useDNSQuery Cloudflare', () => {
const { result, waitFor } = renderHook(() => useDNSQuery('one.one.one.one', 4), { const { result, waitFor } = renderHook(() => useDNSQuery('one.one.one.one', 4), {
wrapper: CloudflareWrapper, wrapper: CloudflareWrapper,
}); });
await waitFor(() => result.current.isSuccess, { timeout: 5_000 }); await waitFor(() => result.current.isSuccess, { timeout: 5_000 });
expect(result.current.data?.Answer.map(a => a.data)).toContain('1.1.1.1'); expect(result.current.data?.Answer.map(a => a.data)).toContain('1.1.1.1');
}); });

View file

@ -1,8 +1,12 @@
import { useQuery } from 'react-query'; import { useQuery } from '@tanstack/react-query';
import { useConfig } from '~/context'; import { useConfig } from '~/context';
import { fetchWithTimeout } from '~/util'; import { fetchWithTimeout } from '~/util';
import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query'; import type {
QueryFunction,
QueryFunctionContext,
QueryObserverResult,
} from '@tanstack/react-query';
import type { DnsOverHttps } from '~/types'; import type { DnsOverHttps } from '~/types';
type DNSQueryKey = [string, { target: string | null; family: 4 | 6 }]; type DNSQueryKey = [string, { target: string | null; family: 4 | 6 }];

View file

@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from 'react-query'; import { useQuery } from '@tanstack/react-query';
import { getHyperglassConfig } from '~/util'; import { getHyperglassConfig } from '~/util';
import type { UseQueryResult } from 'react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import type { ConfigLoadError } from '~/util'; import type { ConfigLoadError } from '~/util';
import type { Config } from '~/types'; import type { Config } from '~/types';
@ -38,7 +38,7 @@ export function useHyperglassConfig(): UseHyperglassConfig {
const [initFailed, setInitFailed] = useState<boolean>(false); const [initFailed, setInitFailed] = useState<boolean>(false);
const query = useQuery<Config, ConfigLoadError>({ const query = useQuery<Config, ConfigLoadError>({
queryKey: 'hyperglass-ui-config', queryKey: ['hyperglass-ui-config'],
queryFn: getHyperglassConfig, queryFn: getHyperglassConfig,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchInterval: 10000, refetchInterval: 10000,

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { useQuery } from 'react-query'; import { useQuery } from '@tanstack/react-query';
import { useConfig } from '~/context'; import { useConfig } from '~/context';
import { fetchWithTimeout } from '~/util'; import { fetchWithTimeout } from '~/util';
@ -8,7 +8,7 @@ import type {
UseQueryOptions, UseQueryOptions,
QueryObserverResult, QueryObserverResult,
QueryFunctionContext, QueryFunctionContext,
} from 'react-query'; } from '@tanstack/react-query';
import type { FormQuery } from '~/types'; import type { FormQuery } from '~/types';
type LGQueryKey = [string, FormQuery]; type LGQueryKey = [string, FormQuery];

View file

@ -1,4 +1,4 @@
import { useQuery } from 'react-query'; import { useQuery } from '@tanstack/react-query';
import { fetchWithTimeout } from '~/util'; import { fetchWithTimeout } from '~/util';
import type { import type {
@ -6,7 +6,7 @@ import type {
UseQueryResult, UseQueryResult,
UseQueryOptions, UseQueryOptions,
QueryFunctionContext, QueryFunctionContext,
} from 'react-query'; } from '@tanstack/react-query';
interface WtfIndividual { interface WtfIndividual {
ip: string; ip: string;
@ -44,7 +44,9 @@ function transform(wtf: WtfIsMyIP): WtfIndividual {
}; };
} }
const query: QueryFunction<WtfIndividual, string> = async (ctx: QueryFunctionContext<string>) => { const query: QueryFunction<WtfIndividual, string[]> = async (
ctx: QueryFunctionContext<string[]>,
) => {
const controller = new AbortController(); const controller = new AbortController();
const [url] = ctx.queryKey; const [url] = ctx.queryKey;
@ -61,7 +63,7 @@ const query: QueryFunction<WtfIndividual, string> = async (ctx: QueryFunctionCon
return transform(data); return transform(data);
}; };
const common: UseQueryOptions<WtfIndividual, unknown, WtfIndividual, string> = { const common: UseQueryOptions<WtfIndividual, unknown, WtfIndividual, string[]> = {
queryFn: query, queryFn: query,
enabled: false, enabled: false,
refetchInterval: false, refetchInterval: false,
@ -72,12 +74,12 @@ const common: UseQueryOptions<WtfIndividual, unknown, WtfIndividual, string> = {
}; };
export function useWtf(): Wtf { export function useWtf(): Wtf {
const ipv4 = useQuery<WtfIndividual, unknown, WtfIndividual, string>({ const ipv4 = useQuery<WtfIndividual, unknown, WtfIndividual, string[]>({
queryKey: URL_IP4, queryKey: [URL_IP4],
...common, ...common,
}); });
const ipv6 = useQuery<WtfIndividual, unknown, WtfIndividual, string>({ const ipv6 = useQuery<WtfIndividual, unknown, WtfIndividual, string[]>({
queryKey: URL_IP6, queryKey: [URL_IP6],
...common, ...common,
}); });

View file

@ -7,11 +7,8 @@ const nextConfig = {
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
experimental: { swcMinify: true,
// See: https://github.com/react-hook-form/resolvers/issues/271#issuecomment-986618265 productionBrowserSourceMaps: true,
// See: https://github.com/vercel/next.js/issues/30750#issuecomment-962198711
esmExternals: false,
},
}; };
module.exports = nextConfig; module.exports = nextConfig;

View file

@ -17,31 +17,31 @@
}, },
"browserslist": "> 0.25%, not dead", "browserslist": "> 0.25%, not dead",
"dependencies": { "dependencies": {
"@chakra-ui/react": "^1.7.2", "@chakra-ui/react": "^2.5.5",
"@emotion/react": "^11.7.0", "@emotion/react": "^11.10.6",
"@emotion/styled": "^11.6.0", "@emotion/styled": "^11.10.6",
"@hookform/devtools": "^4.0.1", "@hookform/devtools": "^4.3.0",
"@hookform/resolvers": "^2.8.4", "@hookform/resolvers": "^2.9.10",
"@tanstack/react-query": "^4.22.0",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"dayjs": "^1.10.4", "dayjs": "^1.10.4",
"framer-motion": "^5.4.1", "framer-motion": "^10.11.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"merge-anything": "^4.0.1", "merge-anything": "^4.0.1",
"next": "12", "next": "12.3.4",
"palette-by-numbers": "^0.1.5", "palette-by-numbers": "^0.1.6",
"plur": "^4.0.0", "plur": "^4.0.0",
"react": "^17.0.2", "react": "^18.2.0",
"react-countdown": "^2.2.1", "react-countdown": "^2.2.1",
"react-device-detect": "^1.15.0", "react-device-detect": "^1.15.0",
"react-dom": "^17.0.2", "react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0", "react-fast-compare": "^3.2.0",
"react-flow-renderer": "^9.6.0", "react-flow-renderer": "^10.3.17",
"react-hook-form": "^7.21.0", "react-hook-form": "^7.42.1",
"react-icons": "^4.3.1", "react-icons": "^4.3.1",
"react-if": "^4.1.1", "react-if": "^4.1.4",
"react-markdown": "^5.0.3", "react-markdown": "^5.0.3",
"react-query": "^3.16.0", "react-select": "^5.7.0",
"react-select": "^5.2.1",
"react-string-replace": "^v0.5.0", "react-string-replace": "^v0.5.0",
"react-table": "^7.7.0", "react-table": "^7.7.0",
"remark-gfm": "^1.0.0", "remark-gfm": "^1.0.0",
@ -49,20 +49,16 @@
"vest": "^3.2.8", "vest": "^3.2.8",
"zustand": "^3.6.6" "zustand": "^3.6.6"
}, },
"resolutions": {
"react-select/@emotion/react": "^11.7.0",
"@hookform/devtools/@emotion/react": "^11.7.0"
},
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^5.14.1", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.2", "@testing-library/react": "^14.0.0",
"@testing-library/react-hooks": "^7.0.2", "@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^14.4.3",
"@types/dagre": "^0.7.44", "@types/dagre": "^0.7.44",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/lodash": "^4.14.177", "@types/lodash": "^4.14.177",
"@types/node": "^14.14.41", "@types/node": "^18.15.11",
"@types/react": "^17.0.3", "@types/react": "^18.0.35",
"@types/react-table": "^7.7.1", "@types/react-table": "^7.7.1",
"@types/string-format": "^2.0.0", "@types/string-format": "^2.0.0",
"@typescript-eslint/eslint-plugin": "^4.31.0", "@typescript-eslint/eslint-plugin": "^4.31.0",
@ -85,8 +81,8 @@
"jest": "^27.2.1", "jest": "^27.2.1",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"prettier-eslint": "^13.0.0", "prettier-eslint": "^13.0.0",
"react-test-renderer": "^17.0.2", "react-test-renderer": "^18.2.0",
"type-fest": "^2.3.2", "type-fest": "^3.8.0",
"typescript": "^4.4.2" "typescript": "^5.0.4"
} }
} }

View file

@ -1,4 +1,4 @@
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Switch, Case, Default } from 'react-if'; import { Switch, Case, Default } from 'react-if';
import { Meta, Layout } from '~/components'; import { Meta, Layout } from '~/components';
import { HyperglassProvider } from '~/context'; import { HyperglassProvider } from '~/context';

View file

@ -1,11 +1,16 @@
import fs from 'fs'; import fs from 'fs';
import Document, { Html, Head, Main, NextScript } from 'next/document'; import Document, { Html, Head, Main, NextScript } from 'next/document';
import { ColorModeScript } from '@chakra-ui/react';
import { CustomJavascript, CustomHtml, Favicon } from '~/elements'; import { CustomJavascript, CustomHtml, Favicon } from '~/elements';
import { getHyperglassConfig, googleFontUrl } from '~/util';
import favicons from '../favicon-formats'; import favicons from '../favicon-formats';
import type { DocumentContext, DocumentInitialProps } from 'next/document'; import type { DocumentContext, DocumentInitialProps } from 'next/document';
import type { ThemeConfig } from '~/types';
interface DocumentExtra extends DocumentInitialProps { interface DocumentExtra
extends DocumentInitialProps,
Pick<ThemeConfig, 'defaultColorMode' | 'fonts'> {
customJs: string; customJs: string;
customHtml: string; customHtml: string;
} }
@ -15,13 +20,31 @@ class MyDocument extends Document<DocumentExtra> {
const initialProps = await Document.getInitialProps(ctx); const initialProps = await Document.getInitialProps(ctx);
let customJs = '', let customJs = '',
customHtml = ''; customHtml = '';
if (fs.existsSync('custom.js')) { if (fs.existsSync('custom.js')) {
customJs = fs.readFileSync('custom.js').toString(); customJs = fs.readFileSync('custom.js').toString();
} }
if (fs.existsSync('custom.html')) { if (fs.existsSync('custom.html')) {
customHtml = fs.readFileSync('custom.html').toString(); customHtml = fs.readFileSync('custom.html').toString();
} }
return { customJs, customHtml, ...initialProps };
let fonts = { body: '', mono: '' };
let defaultColorMode: 'light' | 'dark' | null = null;
const hyperglassUrl = process.env.HYPERGLASS_URL ?? '';
const {
web: {
theme: { fonts: themeFonts, defaultColorMode: themeDefaultColorMode },
},
} = await getHyperglassConfig(hyperglassUrl);
fonts = {
body: googleFontUrl(themeFonts.body),
mono: googleFontUrl(themeFonts.mono),
};
defaultColorMode = themeDefaultColorMode;
return { customJs, customHtml, fonts, defaultColorMode, ...initialProps };
} }
render(): JSX.Element { render(): JSX.Element {
@ -35,19 +58,18 @@ class MyDocument extends Document<DocumentExtra> {
<meta name="og:image" content="/images/opengraph.jpg" /> <meta name="og:image" content="/images/opengraph.jpg" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0, minimum-scale=1.0"
/>
<link rel="dns-prefetch" href="//fonts.gstatic.com" /> <link rel="dns-prefetch" href="//fonts.gstatic.com" />
<link rel="dns-prefetch" href="//fonts.googleapis.com" /> <link rel="dns-prefetch" href="//fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="true" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link href={this.props.fonts.mono} rel="stylesheet" />
<link href={this.props.fonts.body} rel="stylesheet" />
{favicons.map((favicon, idx) => ( {favicons.map((favicon, idx) => (
<Favicon key={idx} {...favicon} /> <Favicon key={idx} {...favicon} />
))} ))}
<CustomJavascript>{this.props.customJs}</CustomJavascript> <CustomJavascript>{this.props.customJs}</CustomJavascript>
</Head> </Head>
<body> <body>
<ColorModeScript initialColorMode={this.props.defaultColorMode ?? 'system'} />
<Main /> <Main />
<CustomHtml>{this.props.customHtml}</CustomHtml> <CustomHtml>{this.props.customHtml}</CustomHtml>
<NextScript /> <NextScript />

View file

@ -1,20 +1,19 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { AnimatePresence } from 'framer-motion';
import { If, Then, Else } from 'react-if'; import { If, Then, Else } from 'react-if';
import { Loading } from '~/elements'; import { Loading } from '~/elements';
import { useView } from '~/hooks'; import { useView } from '~/hooks';
import type { NextPage } from 'next'; import type { NextPage } from 'next';
import type { AnimatePresenceProps } from 'framer-motion';
const AnimatePresence = dynamic<AnimatePresenceProps>(() => const LookingGlassForm = dynamic<Dict>(
import('framer-motion').then(i => i.AnimatePresence), () => import('~/components/looking-glass-form').then(i => i.LookingGlassForm),
{
loading: Loading,
},
); );
const LookingGlassForm = dynamic<Dict>(() => import('~/components').then(i => i.LookingGlassForm), { const Results = dynamic<Dict>(() => import('~/components/results').then(i => i.Results), {
loading: Loading,
});
const Results = dynamic<Dict>(() => import('~/components').then(i => i.Results), {
loading: Loading, loading: Loading,
}); });

View file

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES5", "target": "ESNext",
"module": "esnext", "module": "ESNext",
"downlevelIteration": true, "downlevelIteration": true,
"strict": true, "strict": true,
"baseUrl": "." /* Base directory to resolve non-absolute module names. */, "baseUrl": "." /* Base directory to resolve non-absolute module names. */,

View file

@ -21,7 +21,7 @@ export function chunkArray<A extends unknown>(array: A[], size: number): A[][] {
*/ */
export function entries<O, K extends keyof O = keyof O>(obj: O): [K, O[K]][] { export function entries<O, K extends keyof O = keyof O>(obj: O): [K, O[K]][] {
const _entries = [] as [K, O[K]][]; const _entries = [] as [K, O[K]][];
const keys = Object.keys(obj) as K[]; const keys = Object.keys(obj as Record<string, unknown>) as K[];
for (const key of keys) { for (const key of keys) {
_entries.push([key, obj[key]]); _entries.push([key, obj[key]]);
} }

View file

@ -1,3 +1,4 @@
import type { QueryFunctionContext } from '@tanstack/react-query';
import { isObject } from '~/types'; import { isObject } from '~/types';
import type { Config } from '~/types'; import type { Config } from '~/types';
@ -23,9 +24,15 @@ export class ConfigLoadError extends Error {
} }
} }
export async function getHyperglassConfig(): Promise<Config> { export async function getHyperglassConfig(url?: QueryFunctionContext | string): Promise<Config> {
let mode: RequestInit['mode']; let mode: RequestInit['mode'];
let fetchUrl = '/ui/props/';
if (typeof url === 'string') {
fetchUrl = url.replace(/(^\/)|(\/$)/g, '') + '/ui/props/';
}
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
mode = 'same-origin'; mode = 'same-origin';
} else if (process.env.NODE_ENV === 'development') { } else if (process.env.NODE_ENV === 'development') {
@ -34,7 +41,7 @@ export async function getHyperglassConfig(): Promise<Config> {
const options: RequestInit = { method: 'GET', mode, headers: { 'user-agent': 'hyperglass-ui' } }; const options: RequestInit = { method: 'GET', mode, headers: { 'user-agent': 'hyperglass-ui' } };
try { try {
const response = await fetch('/ui/props/', options); const response = await fetch(fetchUrl, options);
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw response; throw response;

View file

@ -1,9 +1,7 @@
import { extendTheme } from '@chakra-ui/react'; import { extendTheme } from '@chakra-ui/react';
import { mode } from '@chakra-ui/theme-tools';
import { generateFontFamily, generatePalette } from 'palette-by-numbers'; import { generateFontFamily, generatePalette } from 'palette-by-numbers';
import type { ChakraTheme } from '@chakra-ui/react'; import type { ChakraTheme } from '@chakra-ui/react';
import type { StyleFunctionProps } from '@chakra-ui/theme-tools';
import type { ThemeConfig, Theme } from '~/types'; import type { ThemeConfig, Theme } from '~/types';
function importFonts(userFonts: Theme.Fonts): ChakraTheme['fonts'] { function importFonts(userFonts: Theme.Fonts): ChakraTheme['fonts'] {
@ -17,41 +15,44 @@ function importFonts(userFonts: Theme.Fonts): ChakraTheme['fonts'] {
} }
function importColors(userColors: ThemeConfig['colors']): Theme.Colors { function importColors(userColors: ThemeConfig['colors']): Theme.Colors {
const generatedColors = {} as Theme.Colors; const initial: Pick<Theme.Colors, 'blackSolid' | 'whiteSolid' | 'transparent' | 'current'> = {
for (const [k, v] of Object.entries<string>(userColors)) { blackSolid: {
generatedColors[k] = generatePalette(v); 50: '#444444',
} 100: '#3c3c3c',
200: '#353535',
generatedColors.blackSolid = { 300: '#2d2d2d',
50: '#444444', 400: '#262626',
100: '#3c3c3c', 500: '#1e1e1e',
200: '#353535', 600: '#171717',
300: '#2d2d2d', 700: '#0f0f0f',
400: '#262626', 800: '#080808',
500: '#1e1e1e', 900: '#000000',
600: '#171717', },
700: '#0f0f0f', whiteSolid: {
800: '#080808', 50: '#ffffff',
900: '#000000', 100: '#f7f7f7',
}; 200: '#f0f0f0',
generatedColors.whiteSolid = { 300: '#e8e8e8',
50: '#ffffff', 400: '#e1e1e1',
100: '#f7f7f7', 500: '#d9d9d9',
200: '#f0f0f0', 600: '#d2d2d2',
300: '#e8e8e8', 700: '#cacaca',
400: '#e1e1e1', 800: '#c3c3c3',
500: '#d9d9d9', 900: '#bbbbbb',
600: '#d2d2d2', },
700: '#cacaca',
800: '#c3c3c3',
900: '#bbbbbb',
};
return {
...generatedColors,
transparent: 'transparent', transparent: 'transparent',
current: 'currentColor', current: 'currentColor',
}; };
const generatedColors = Object.entries<string>(userColors).reduce<Theme.Colors>(
(final, [k, v]) => {
final[k] = generatePalette(v);
return final;
},
initial,
);
return generatedColors;
} }
export function makeTheme( export function makeTheme(
@ -86,24 +87,34 @@ export function makeTheme(
break; break;
} }
const defaultTheme = extendTheme({ return extendTheme({
fonts, fonts,
colors, colors,
config, config,
fontWeights, fontWeights,
semanticTokens: {
colors: {
'body-bg': {
default: 'light.500',
_dark: 'dark.500',
},
'body-fg': {
default: 'dark.500',
_dark: 'light.500',
},
},
},
styles: { styles: {
global: (props: StyleFunctionProps) => ({ global: {
html: { scrollBehavior: 'smooth', height: '-webkit-fill-available' }, html: { scrollBehavior: 'smooth', height: '-webkit-fill-available' },
body: { body: {
background: mode('light.500', 'dark.500')(props), background: 'body-bg',
color: mode('black', 'white')(props), color: 'body-fg',
overflowX: 'hidden', overflowX: 'hidden',
}, },
}), },
}, },
}) as Theme.Full; }) as Theme.Full;
return defaultTheme;
} }
export function googleFontUrl(fontFamily: string, weights: number[] = [300, 400, 700]): string { export function googleFontUrl(fontFamily: string, weights: number[] = [300, 400, 700]): string {
@ -112,5 +123,3 @@ export function googleFontUrl(fontFamily: string, weights: number[] = [300, 400,
const urlFont = fontName.split(/ /).join('+'); const urlFont = fontName.split(/ /).join('+');
return `https://fonts.googleapis.com/css?family=${urlFont}:${urlWeights}&display=swap`; return `https://fonts.googleapis.com/css?family=${urlFont}:${urlWeights}&display=swap`;
} }
export { theme as defaultTheme } from '@chakra-ui/react';

3771
hyperglass/ui/yarn.lock vendored

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
"""Test file-related utilities.""" """Test file-related utilities."""
# Standard Library # Standard Library
import random
import string import string
import secrets
from pathlib import Path from pathlib import Path
# Third Party # Third Party
@ -17,6 +17,12 @@ KEY3=VALUE3
""" """
def _random_string(length: int) -> str:
alphabet = string.ascii_letters + string.digits
result = "".join(secrets.choice(alphabet) for i in range(length))
return result
def test_dotenv_to_dict_string(): def test_dotenv_to_dict_string():
result = dotenv_to_dict(ENV_TEST) result = dotenv_to_dict(ENV_TEST)
assert result.get("KEY1") == "VALUE1" assert result.get("KEY1") == "VALUE1"
@ -94,9 +100,7 @@ def test_check_path_raises(tmp_path_factory: pytest.TempPathFactory):
async def test_move_files(tmp_path_factory: pytest.TempPathFactory): async def test_move_files(tmp_path_factory: pytest.TempPathFactory):
src = tmp_path_factory.mktemp("src") src = tmp_path_factory.mktemp("src")
dst = tmp_path_factory.mktemp("dst") dst = tmp_path_factory.mktemp("dst")
filenames = ( filenames = ("".join(_random_string(8)) for _ in range(10))
"".join(random.choice(string.ascii_lowercase) for _ in range(8)) for _ in range(10)
)
files = [src / name for name in filenames] files = [src / name for name in filenames]
[f.touch() for f in files] [f.touch() for f in files]
result = await move_files(src, dst, files) result = await move_files(src, dst, files)
@ -109,9 +113,7 @@ async def test_move_files(tmp_path_factory: pytest.TempPathFactory):
async def test_move_files_raise(tmp_path_factory: pytest.TempPathFactory): async def test_move_files_raise(tmp_path_factory: pytest.TempPathFactory):
src = tmp_path_factory.mktemp("src") src = tmp_path_factory.mktemp("src")
dst = tmp_path_factory.mktemp("dst") dst = tmp_path_factory.mktemp("dst")
filenames = ( filenames = ("".join(_random_string(8)) for _ in range(10))
"".join(random.choice(string.ascii_lowercase) for _ in range(8)) for _ in range(10)
)
files = [src / name for name in filenames] files = [src / name for name in filenames]
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
await move_files(src, dst, files) await move_files(src, dst, files)
@ -120,9 +122,7 @@ async def test_move_files_raise(tmp_path_factory: pytest.TempPathFactory):
def test_copyfiles(tmp_path_factory: pytest.TempPathFactory): def test_copyfiles(tmp_path_factory: pytest.TempPathFactory):
src = tmp_path_factory.mktemp("src") src = tmp_path_factory.mktemp("src")
dst = tmp_path_factory.mktemp("dst") dst = tmp_path_factory.mktemp("dst")
filenames = [ filenames = ["".join(_random_string(8)) for _ in range(10)]
"".join(random.choice(string.ascii_lowercase) for _ in range(8)) for _ in range(10)
]
src_files = [src / name for name in filenames] src_files = [src / name for name in filenames]
dst_files = [dst / name for name in filenames] dst_files = [dst / name for name in filenames]
[f.touch() for f in src_files] [f.touch() for f in src_files]
@ -133,9 +133,7 @@ def test_copyfiles(tmp_path_factory: pytest.TempPathFactory):
def test_copyfiles_wrong_length(tmp_path_factory: pytest.TempPathFactory): def test_copyfiles_wrong_length(tmp_path_factory: pytest.TempPathFactory):
src = tmp_path_factory.mktemp("src") src = tmp_path_factory.mktemp("src")
dst = tmp_path_factory.mktemp("dst") dst = tmp_path_factory.mktemp("dst")
filenames = [ filenames = ["".join(_random_string(8)) for _ in range(10)]
"".join(random.choice(string.ascii_lowercase) for _ in range(8)) for _ in range(10)
]
dst_filenames = filenames[1:8] dst_filenames = filenames[1:8]
src_files = [src / name for name in filenames] src_files = [src / name for name in filenames]
dst_files = [dst / name for name in dst_filenames] dst_files = [dst / name for name in dst_filenames]

2421
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -63,7 +63,7 @@ pre-commit = "^2.20.0"
pytest = "^7.2.0" pytest = "^7.2.0"
pytest-asyncio = "^0.20.3" pytest-asyncio = "^0.20.3"
pytest-dependency = "^0.5.1" pytest-dependency = "^0.5.1"
ruff = "v0.0.194" ruff = "^0.0.261"
stackprinter = "^0.2.10" stackprinter = "^0.2.10"
taskipy = "^1.10.3" taskipy = "^1.10.3"
@ -128,6 +128,7 @@ ignore = [
"E731", "E731",
"D203", # Blank line before docstring. "D203", # Blank line before docstring.
"D213", # Multiline docstring summary on second line. "D213", # Multiline docstring summary on second line.
"D107", # Don't require docstrings for __init__ functions.
"D402", "D402",
"D406", "D406",
"D407", "D407",
@ -137,10 +138,14 @@ ignore = [
"N818", # Error suffix on custom exceptions. "N818", # Error suffix on custom exceptions.
"RET501", # Explicitly return None "RET501", # Explicitly return None
"B905", # zip without `strict` "B905", # zip without `strict`
"W293", # blank line contains whitespace
] ]
line-length = 100 line-length = 100
select = ["B", "C", "D", "E", "F", "I", "N", "S", "RET", "W"] select = ["B", "C", "D", "E", "F", "I", "N", "S", "RET", "W"]
[tool.ruff.pydocstyle]
convention = "pep257"
[tool.ruff.mccabe] [tool.ruff.mccabe]
max-complexity = 10 max-complexity = 10