1
0
Fork 1
mirror of https://github.com/thatmattlove/hyperglass.git synced 2026-01-17 00:38:06 +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
args: ['--profile', 'black', '--filter-files', '--check']
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.192
rev: v0.0.261
hooks:
- id: ruff
# Respect `exclude` and `extend-exclude` settings.
args: ['--force-exclude']
- repo: local
hooks:
- id: typescript

View file

@ -155,7 +155,6 @@ class BaseExternal:
try:
parsed = _json.loads(response)
except (JSONDecodeError, TypeError):
log.error("Error parsing JSON for response {}", repr(response))
parsed = {"data": response.text}
else:
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."""
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/
"""
intercept_handler = LibIntercentHandler()
seen = set()
for name in [
*logging.root.manager.loggerDict.keys(),
"gunicorn",
"gunicorn.access",
"gunicorn.error",
"uvicorn",
"uvicorn.access",
"uvicorn.error",
"uvicorn.asgi",
"netmiko",
"paramiko",
"httpx",
]:
if name not in seen:
seen.add(name.split(".")[0])
logging.getLogger(name).handlers = [intercept_handler]
intercept_handler = LibInterceptHandler()
names = {
name.split(".")[0]
for name in (
*logging.root.manager.loggerDict.keys(),
"gunicorn",
"gunicorn.access",
"gunicorn.error",
"uvicorn",
"uvicorn.access",
"uvicorn.error",
"uvicorn.asgi",
"netmiko",
"paramiko",
"httpx",
)
}
for name in names:
logging.getLogger(name).handlers = [intercept_handler]
def _log_patcher(record):
@ -165,20 +167,12 @@ def init_logger(level: str = "INFO"):
return _loguru_logger
log = init_logger()
logging.addLevelName(25, "SUCCESS")
def _log_success(self: "LoguruLogger", message: str, *a: t.Any, **kw: t.Any) -> None:
"""Add custom builtin logging handler for the success level."""
if self.isEnabledFor(25):
self._log(25, message, a, **kw)
logging.Logger.success = _log_success
def enable_file_logging(
log_directory: "Path", log_format: "LogFormat", log_max_size: "ByteSize", debug: bool
) -> None:
@ -238,3 +232,8 @@ def enable_syslog_logging(syslog_host: str, syslog_port: int) -> None:
str(syslog_host),
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
# 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 .plugins import InputPluginManager, OutputPluginManager, register_plugin, init_builtin_plugins
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.")
# 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:
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
@ -175,6 +176,7 @@ def run(_workers: int = None):
if _workers is not None:
workers = _workers
init_logger(log_level)
setup_lib_logging(log_level)
start(log_level=log_level, workers=workers)
except Exception as error:
@ -188,6 +190,8 @@ def run(_workers: int = None):
except SystemExit:
# Handle Gunicorn exit.
sys.exit(4)
except BaseException:
sys.exit(4)
if __name__ == "__main__":

View file

@ -67,7 +67,7 @@ class Query(BaseModel):
def __repr__(self) -> str:
"""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:
"""Alias __str__ to __repr__."""

View file

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

View file

@ -3,6 +3,7 @@
# Standard Library
import re
import typing as t
from pathlib import Path
# Third Party
from pydantic import StrictInt, StrictFloat
@ -111,3 +112,39 @@ class HttpMethod(str):
def __repr__(self):
"""Stringify custom field representation."""
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)
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:
"""Alias to `hyperglass.util:repr_from_attrs` in the context of this model."""
return repr_from_attrs(self, attrs)

View file

@ -25,6 +25,8 @@ if t.TYPE_CHECKING:
ListenHost = t.Union[None, IPvAnyAddress, t.Literal["localhost"]]
_default_app_path = Path("/etc/hyperglass")
class HyperglassSettings(BaseSettings):
"""hyperglass system settings, required to start hyperglass."""
@ -33,12 +35,15 @@ class HyperglassSettings(BaseSettings):
"""hyperglass system settings configuration."""
env_prefix = "hyperglass_"
underscore_attrs_are_private = True
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
dev_mode: bool = False
app_path: DirectoryPath = "/etc/hyperglass"
app_path: DirectoryPath = _default_app_path
redis_host: str = "localhost"
redis_password: t.Optional[SecretStr]
redis_db: int = 1
@ -46,6 +51,14 @@ class HyperglassSettings(BaseSettings):
host: IPvAnyAddress = None
port: int = 8001
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":
"""Render a Rich table representation of hyperglass settings."""

View file

@ -1,7 +1,7 @@
"""Data models used throughout hyperglass."""
# Standard Library
from typing import Optional
import typing as t
from datetime import datetime
# Third Party
@ -20,12 +20,12 @@ _ICON_URL = "https://res.cloudinary.com/hyperglass/image/upload/v1593192484/icon
class WebhookHeaders(HyperglassModel):
"""Webhook data model."""
user_agent: Optional[StrictStr]
referer: Optional[StrictStr]
accept_encoding: Optional[StrictStr]
accept_language: Optional[StrictStr]
x_real_ip: Optional[StrictStr]
x_forwarded_for: Optional[StrictStr]
user_agent: t.Optional[StrictStr]
referer: t.Optional[StrictStr]
accept_encoding: t.Optional[StrictStr]
accept_language: t.Optional[StrictStr]
x_real_ip: t.Optional[StrictStr]
x_forwarded_for: t.Optional[StrictStr]
class Config:
"""Pydantic model config."""
@ -51,9 +51,9 @@ class WebhookNetwork(HyperglassModel, extra="allow"):
class Webhook(HyperglassModel):
"""Webhook data model."""
query_location: StrictStr
query_type: StrictStr
query_target: StrictStr
query_location: str
query_type: str
query_target: t.Union[t.List[str], str]
headers: WebhookHeaders
source: StrictStr = "Unknown"
network: WebhookNetwork
@ -69,7 +69,7 @@ class Webhook(HyperglassModel):
def msteams(self):
"""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."""
return f"`{str(value)}`"

View file

@ -92,7 +92,7 @@ class RedisManager:
name = self.key(key)
value: t.Optional[bytes] = self.instance.get(name)
if isinstance(value, bytes):
return pickle.loads(value)
return pickle.loads(value) # noqa
if raise_if_none is True:
raise StateError("'{key}' ('{name}') does not exist in Redis store", key=key, name=name)
if value_if_none is not None:
@ -121,7 +121,7 @@ class RedisManager:
value = self.instance.hgetall(name)
if isinstance(value, bytes):
return pickle.loads(value)
return pickle.loads(value) # noqa
return 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';
interface ColorModeToggleProps extends ButtonProps {
size?: string;
interface ColorModeToggleProps extends Omit<ButtonProps, 'size'> {
size?: string | number;
}
export const ColorModeToggle = forwardRef<HTMLButtonElement, ColorModeToggleProps>(

View file

@ -48,7 +48,7 @@ export const Footer = (): JSX.Element => {
whiteSpace="nowrap"
color={footerColor}
spacing={{ base: 8, lg: 6 }}
d={{ base: 'inline-block', lg: 'flex' }}
display={{ base: 'inline-block', lg: 'flex' }}
overflowY={{ base: 'auto', lg: 'unset' }}
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 fg = useOpposingColor(bg);
const checkedBorder = useColorValue('blue.400', 'blue.300');
@ -74,35 +74,35 @@ export const LocationCard = (props: LocationCardProps): JSX.Element => {
handleChange(option);
}}
>
<Flex justifyContent="space-between" alignItems="center">
<chakra.h2
color={fg}
fontWeight="bold"
mt={{ base: 2, md: 0 }}
fontSize={{ base: 'lg', md: 'xl' }}
>
{label}
</chakra.h2>
<Avatar
color={fg}
fit="cover"
alt={label}
name={label}
boxSize={12}
rounded="full"
borderWidth={1}
bg="whiteAlpha.300"
borderStyle="solid"
borderColor={imageBorder}
src={(option.data?.avatar as string) ?? undefined}
/>
</Flex>
<>
<Flex justifyContent="space-between" alignItems="center">
<chakra.h2
color={fg}
fontWeight="bold"
mt={{ base: 2, md: 0 }}
fontSize={{ base: 'lg', md: 'xl' }}
>
{label}
</chakra.h2>
<Avatar
color={fg}
name={label}
boxSize={12}
rounded="full"
borderWidth={1}
bg="whiteAlpha.300"
borderStyle="solid"
borderColor={imageBorder}
src={(option.data?.avatar as string) ?? undefined}
/>
</Flex>
{option?.data?.description && (
<chakra.p mt={2} color={fg} opacity={0.6} fontSize="sm">
{option.data.description as string}
</chakra.p>
)}
{option?.data?.description && (
<chakra.p mt={2} color={fg} opacity={0.6} fontSize="sm">
{option.data.description as string}
</chakra.p>
)}
</>
</LocationCardWrapper>
);
};

View file

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

View file

@ -1,5 +1,5 @@
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 relativeTimePlugin from 'dayjs/plugin/relativeTime';
import utcPlugin from 'dayjs/plugin/utc';
@ -123,6 +123,7 @@ export const ASPath = (props: ASPathProps): JSX.Element => {
color={color[+active]}
boxSize={5}
px={2}
display="inline-flex"
/>,
);
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 => {

View file

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

View file

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

View file

@ -2,17 +2,20 @@ import dagre from 'dagre';
import { useMemo } from 'react';
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 {
asn: string;
name: string;
}
type FlowElement<T> = Node<T> | Edge<T>;
const NODE_WIDTH = 200;
const NODE_HEIGHT = 48;
export function useElements(base: BasePath, data: StructuredResponse): FlowElement[] {
export function useElements(base: BasePath, data: StructuredResponse): FlowElement<NodeData>[] {
return useMemo(() => {
return [...buildElements(base, data)];
}, [base, data]);
@ -22,7 +25,10 @@ export function useElements(base: BasePath, data: StructuredResponse): FlowEleme
* Calculate the positions for each AS Path.
* @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;
// 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)]);
@ -107,7 +113,7 @@ function* buildElements(base: BasePath, data: StructuredResponse): Generator<Flo
type: 'ASNode',
position: { x, y },
data: {
asn,
asn: `${asn}`,
name: `AS${asn}`,
hasChildren: idx < endIdx,
hasParents: true,

View file

@ -3,7 +3,7 @@ import { Button, Tooltip } from '@chakra-ui/react';
import { DynamicIcon } from '~/elements';
import type { ButtonProps } from '@chakra-ui/react';
import type { UseQueryResult } from 'react-query';
import type { UseQueryResult } from '@tanstack/react-query';
interface RequeryButtonProps extends ButtonProps {
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[]) : [];
return (
<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 && (
<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 => (
<Badge fontSize="xs" variant="subtle" key={tag} colorScheme="gray" textTransform="none">
{tag}

View file

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

View file

@ -2,7 +2,6 @@
import { useCallback } from 'react';
import { useToken } from '@chakra-ui/react';
import { mergeWith } from '@chakra-ui/utils';
import { merge } from 'merge-anything';
import {
useMobile,
useColorValue,
@ -150,7 +149,6 @@ export const useIndicatorSeparatorStyle = <Opt extends SingleOption, IsMulti ext
): RSStyleFunction<'indicatorSeparator', Opt, IsMulti> => {
const { colorMode } = props;
const backgroundColor = useColorToken('colors', 'gray.200', 'whiteAlpha.300');
// const backgroundColor = useColorToken('colors', 'gray.200', 'gray.600');
const styles = { backgroundColor };
return useCallback(base => mergeWith({}, base, styles), [colorMode]);
@ -220,7 +218,7 @@ export const useMultiValueRemoveStyle = <Opt extends SingleOption, IsMulti exten
};
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 }), []);
};
@ -232,8 +230,8 @@ export const useMenuPortal = <Opt extends SingleOption, IsMulti extends boolean>
> => {
const isMobile = useMobile();
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 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';
type StylesConfigFunction<Props> = (base: CSSObjectWithLabel, props: Props) => CSSObjectWithLabel;
export type SelectOnChange<
Opt extends SingleOption = SingleOption,
IsMulti extends boolean = boolean,

View file

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

View file

@ -1,7 +1,7 @@
import { createContext, useContext, useMemo } from 'react';
import { ChakraProvider } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { makeTheme, defaultTheme } from '~/util';
import { ChakraProvider, localStorageManager } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { makeTheme } from '~/util';
import type { Config } from '~/types';
@ -16,15 +16,15 @@ const queryClient = new QueryClient();
export const HyperglassProvider = (props: HyperglassProviderProps): JSX.Element => {
const { config, children } = props;
const value = useMemo(() => config, [config]);
const userTheme = value && makeTheme(value.web.theme, value.web.theme.defaultColorMode);
const theme = value ? userTheme : defaultTheme;
const value = useMemo(() => config, []);
const theme = useMemo(() => makeTheme(value.web.theme, value.web.theme.defaultColorMode), []);
return (
<ChakraProvider theme={theme}>
<HyperglassContext.Provider value={value}>
<HyperglassContext.Provider value={value}>
<ChakraProvider theme={theme} colorModeManager={localStorageManager} resetCSS>
<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 {
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 res = await fetch('https://api.asrank.caida.org/v2/graphql', {
mode: 'cors',
@ -29,8 +33,8 @@ const query: QueryFunction<ASNQuery, string> = async (ctx: QueryFunctionContext)
* @see https://api.asrank.caida.org/v2/docs
*/
export function useASNDetail(asn: string): QueryObserverResult<ASNQuery> {
return useQuery<ASNQuery, unknown, ASNQuery, string>({
queryKey: asn,
return useQuery<ASNQuery, unknown, ASNQuery, string[]>({
queryKey: [asn],
queryFn: query,
refetchOnWindowFocus: false,
refetchInterval: false,

View file

@ -1,7 +1,7 @@
import 'isomorphic-fetch';
import '@testing-library/jest-dom';
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 { useDNSQuery } from './use-dns-query';
@ -41,6 +41,7 @@ describe('useDNSQuery Cloudflare', () => {
const { result, waitFor } = renderHook(() => useDNSQuery('one.one.one.one', 4), {
wrapper: CloudflareWrapper,
});
await waitFor(() => result.current.isSuccess, { timeout: 5_000 });
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 { 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';
type DNSQueryKey = [string, { target: string | null; family: 4 | 6 }];

View file

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

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { useQuery } from '@tanstack/react-query';
import { useConfig } from '~/context';
import { fetchWithTimeout } from '~/util';
@ -8,7 +8,7 @@ import type {
UseQueryOptions,
QueryObserverResult,
QueryFunctionContext,
} from 'react-query';
} from '@tanstack/react-query';
import type { FormQuery } from '~/types';
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 type {
@ -6,7 +6,7 @@ import type {
UseQueryResult,
UseQueryOptions,
QueryFunctionContext,
} from 'react-query';
} from '@tanstack/react-query';
interface WtfIndividual {
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 [url] = ctx.queryKey;
@ -61,7 +63,7 @@ const query: QueryFunction<WtfIndividual, string> = async (ctx: QueryFunctionCon
return transform(data);
};
const common: UseQueryOptions<WtfIndividual, unknown, WtfIndividual, string> = {
const common: UseQueryOptions<WtfIndividual, unknown, WtfIndividual, string[]> = {
queryFn: query,
enabled: false,
refetchInterval: false,
@ -72,12 +74,12 @@ const common: UseQueryOptions<WtfIndividual, unknown, WtfIndividual, string> = {
};
export function useWtf(): Wtf {
const ipv4 = useQuery<WtfIndividual, unknown, WtfIndividual, string>({
queryKey: URL_IP4,
const ipv4 = useQuery<WtfIndividual, unknown, WtfIndividual, string[]>({
queryKey: [URL_IP4],
...common,
});
const ipv6 = useQuery<WtfIndividual, unknown, WtfIndividual, string>({
queryKey: URL_IP6,
const ipv6 = useQuery<WtfIndividual, unknown, WtfIndividual, string[]>({
queryKey: [URL_IP6],
...common,
});

View file

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

View file

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

View file

@ -1,11 +1,16 @@
import fs from 'fs';
import Document, { Html, Head, Main, NextScript } from 'next/document';
import { ColorModeScript } from '@chakra-ui/react';
import { CustomJavascript, CustomHtml, Favicon } from '~/elements';
import { getHyperglassConfig, googleFontUrl } from '~/util';
import favicons from '../favicon-formats';
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;
customHtml: string;
}
@ -15,13 +20,31 @@ class MyDocument extends Document<DocumentExtra> {
const initialProps = await Document.getInitialProps(ctx);
let customJs = '',
customHtml = '';
if (fs.existsSync('custom.js')) {
customJs = fs.readFileSync('custom.js').toString();
}
if (fs.existsSync('custom.html')) {
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 {
@ -35,19 +58,18 @@ class MyDocument extends Document<DocumentExtra> {
<meta name="og:image" content="/images/opengraph.jpg" />
<meta property="og:image:width" content="1200" />
<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.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) => (
<Favicon key={idx} {...favicon} />
))}
<CustomJavascript>{this.props.customJs}</CustomJavascript>
</Head>
<body>
<ColorModeScript initialColorMode={this.props.defaultColorMode ?? 'system'} />
<Main />
<CustomHtml>{this.props.customHtml}</CustomHtml>
<NextScript />

View file

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

View file

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES5",
"module": "esnext",
"target": "ESNext",
"module": "ESNext",
"downlevelIteration": true,
"strict": true,
"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]][] {
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) {
_entries.push([key, obj[key]]);
}

View file

@ -1,3 +1,4 @@
import type { QueryFunctionContext } from '@tanstack/react-query';
import { isObject } 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 fetchUrl = '/ui/props/';
if (typeof url === 'string') {
fetchUrl = url.replace(/(^\/)|(\/$)/g, '') + '/ui/props/';
}
if (process.env.NODE_ENV === 'production') {
mode = 'same-origin';
} 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' } };
try {
const response = await fetch('/ui/props/', options);
const response = await fetch(fetchUrl, options);
const data = await response.json();
if (!response.ok) {
throw response;

View file

@ -1,9 +1,7 @@
import { extendTheme } from '@chakra-ui/react';
import { mode } from '@chakra-ui/theme-tools';
import { generateFontFamily, generatePalette } from 'palette-by-numbers';
import type { ChakraTheme } from '@chakra-ui/react';
import type { StyleFunctionProps } from '@chakra-ui/theme-tools';
import type { ThemeConfig, Theme } from '~/types';
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 {
const generatedColors = {} as Theme.Colors;
for (const [k, v] of Object.entries<string>(userColors)) {
generatedColors[k] = generatePalette(v);
}
generatedColors.blackSolid = {
50: '#444444',
100: '#3c3c3c',
200: '#353535',
300: '#2d2d2d',
400: '#262626',
500: '#1e1e1e',
600: '#171717',
700: '#0f0f0f',
800: '#080808',
900: '#000000',
};
generatedColors.whiteSolid = {
50: '#ffffff',
100: '#f7f7f7',
200: '#f0f0f0',
300: '#e8e8e8',
400: '#e1e1e1',
500: '#d9d9d9',
600: '#d2d2d2',
700: '#cacaca',
800: '#c3c3c3',
900: '#bbbbbb',
};
return {
...generatedColors,
const initial: Pick<Theme.Colors, 'blackSolid' | 'whiteSolid' | 'transparent' | 'current'> = {
blackSolid: {
50: '#444444',
100: '#3c3c3c',
200: '#353535',
300: '#2d2d2d',
400: '#262626',
500: '#1e1e1e',
600: '#171717',
700: '#0f0f0f',
800: '#080808',
900: '#000000',
},
whiteSolid: {
50: '#ffffff',
100: '#f7f7f7',
200: '#f0f0f0',
300: '#e8e8e8',
400: '#e1e1e1',
500: '#d9d9d9',
600: '#d2d2d2',
700: '#cacaca',
800: '#c3c3c3',
900: '#bbbbbb',
},
transparent: 'transparent',
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(
@ -86,24 +87,34 @@ export function makeTheme(
break;
}
const defaultTheme = extendTheme({
return extendTheme({
fonts,
colors,
config,
fontWeights,
semanticTokens: {
colors: {
'body-bg': {
default: 'light.500',
_dark: 'dark.500',
},
'body-fg': {
default: 'dark.500',
_dark: 'light.500',
},
},
},
styles: {
global: (props: StyleFunctionProps) => ({
global: {
html: { scrollBehavior: 'smooth', height: '-webkit-fill-available' },
body: {
background: mode('light.500', 'dark.500')(props),
color: mode('black', 'white')(props),
background: 'body-bg',
color: 'body-fg',
overflowX: 'hidden',
},
}),
},
},
}) as Theme.Full;
return defaultTheme;
}
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('+');
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."""
# Standard Library
import random
import string
import secrets
from pathlib import Path
# 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():
result = dotenv_to_dict(ENV_TEST)
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):
src = tmp_path_factory.mktemp("src")
dst = tmp_path_factory.mktemp("dst")
filenames = (
"".join(random.choice(string.ascii_lowercase) for _ in range(8)) for _ in range(10)
)
filenames = ("".join(_random_string(8)) for _ in range(10))
files = [src / name for name in filenames]
[f.touch() for f in 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):
src = tmp_path_factory.mktemp("src")
dst = tmp_path_factory.mktemp("dst")
filenames = (
"".join(random.choice(string.ascii_lowercase) for _ in range(8)) for _ in range(10)
)
filenames = ("".join(_random_string(8)) for _ in range(10))
files = [src / name for name in filenames]
with pytest.raises(RuntimeError):
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):
src = tmp_path_factory.mktemp("src")
dst = tmp_path_factory.mktemp("dst")
filenames = [
"".join(random.choice(string.ascii_lowercase) for _ in range(8)) for _ in range(10)
]
filenames = ["".join(_random_string(8)) for _ in range(10)]
src_files = [src / name for name in filenames]
dst_files = [dst / name for name in filenames]
[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):
src = tmp_path_factory.mktemp("src")
dst = tmp_path_factory.mktemp("dst")
filenames = [
"".join(random.choice(string.ascii_lowercase) for _ in range(8)) for _ in range(10)
]
filenames = ["".join(_random_string(8)) for _ in range(10)]
dst_filenames = filenames[1:8]
src_files = [src / name for name in 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-asyncio = "^0.20.3"
pytest-dependency = "^0.5.1"
ruff = "v0.0.194"
ruff = "^0.0.261"
stackprinter = "^0.2.10"
taskipy = "^1.10.3"
@ -128,6 +128,7 @@ ignore = [
"E731",
"D203", # Blank line before docstring.
"D213", # Multiline docstring summary on second line.
"D107", # Don't require docstrings for __init__ functions.
"D402",
"D406",
"D407",
@ -137,10 +138,14 @@ ignore = [
"N818", # Error suffix on custom exceptions.
"RET501", # Explicitly return None
"B905", # zip without `strict`
"W293", # blank line contains whitespace
]
line-length = 100
select = ["B", "C", "D", "E", "F", "I", "N", "S", "RET", "W"]
[tool.ruff.pydocstyle]
convention = "pep257"
[tool.ruff.mccabe]
max-complexity = 10