forked from mirrors/thatmattlove-hyperglass
initial work on generic commands
This commit is contained in:
parent
7636e044b2
commit
5f036228a5
21 changed files with 488 additions and 210 deletions
|
|
@ -4,7 +4,7 @@
|
|||
import os
|
||||
import copy
|
||||
import json
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Sequence, Generator
|
||||
from pathlib import Path
|
||||
|
||||
# Third Party
|
||||
|
|
@ -25,9 +25,11 @@ from hyperglass.constants import (
|
|||
)
|
||||
from hyperglass.exceptions import ConfigError, ConfigMissing
|
||||
from hyperglass.util.files import check_path
|
||||
from hyperglass.models.commands import Commands
|
||||
|
||||
# from hyperglass.models.commands import Commands
|
||||
from hyperglass.models.commands.generic import Directive
|
||||
from hyperglass.models.config.params import Params
|
||||
from hyperglass.models.config.devices import Devices
|
||||
from hyperglass.models.config.devices import Devices, Device
|
||||
from hyperglass.configuration.defaults import (
|
||||
CREDIT,
|
||||
DEFAULT_HELP,
|
||||
|
|
@ -116,6 +118,29 @@ def _config_optional(config_path: Path) -> Dict:
|
|||
return config
|
||||
|
||||
|
||||
def _get_commands(data: Dict) -> Sequence[Directive]:
|
||||
commands = []
|
||||
for name, command in data.items():
|
||||
commands.append(Directive(id=name, **command))
|
||||
return commands
|
||||
|
||||
|
||||
def _device_commands(
|
||||
device: Dict, directives: Sequence[Directive]
|
||||
) -> Generator[Directive, None, None]:
|
||||
device_commands = device.get("commands", [])
|
||||
for directive in directives:
|
||||
if directive.id in device_commands:
|
||||
yield directive
|
||||
|
||||
|
||||
def _get_devices(data: Sequence[Dict], directives: Sequence[Directive]) -> Devices:
|
||||
for device in data:
|
||||
device_commands = list(_device_commands(device, directives))
|
||||
device["commands"] = device_commands
|
||||
return Devices(data)
|
||||
|
||||
|
||||
user_config = _config_optional(CONFIG_MAIN)
|
||||
|
||||
# Read raw debug value from config to enable debugging quickly.
|
||||
|
|
@ -136,15 +161,16 @@ elif not params.debug and log_level == "debug":
|
|||
# Map imported user commands to expected schema.
|
||||
_user_commands = _config_optional(CONFIG_COMMANDS)
|
||||
log.debug("Unvalidated commands from {}: {}", CONFIG_COMMANDS, _user_commands)
|
||||
commands = validate_config(config=_user_commands, importer=Commands.import_params)
|
||||
commands = _get_commands(_user_commands)
|
||||
|
||||
# Map imported user devices to expected schema.
|
||||
_user_devices = _config_required(CONFIG_DEVICES)
|
||||
log.debug("Unvalidated devices from {}: {}", CONFIG_DEVICES, _user_devices)
|
||||
devices = validate_config(config=_user_devices.get("routers", []), importer=Devices)
|
||||
# devices = validate_config(config=_user_devices.get("routers", []), importer=Devices)
|
||||
devices = _get_devices(_user_devices.get("routers", []), commands)
|
||||
|
||||
# Validate commands are both supported and properly mapped.
|
||||
validate_nos_commands(devices.all_nos, commands)
|
||||
# validate_nos_commands(devices.all_nos, commands)
|
||||
|
||||
# Set cache configurations to environment variables, so they can be
|
||||
# used without importing this module (Gunicorn, etc).
|
||||
|
|
@ -189,65 +215,6 @@ except KeyError:
|
|||
pass
|
||||
|
||||
|
||||
def _build_frontend_devices():
|
||||
"""Build filtered JSON structure of devices for frontend.
|
||||
|
||||
Schema:
|
||||
{
|
||||
"device.name": {
|
||||
"display_name": "device.display_name",
|
||||
"vrfs": [
|
||||
"Global",
|
||||
"vrf.display_name"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Raises:
|
||||
ConfigError: Raised if parsing/building error occurs.
|
||||
|
||||
Returns:
|
||||
{dict} -- Frontend devices
|
||||
"""
|
||||
frontend_dict = {}
|
||||
for device in devices.objects:
|
||||
if device.name in frontend_dict:
|
||||
frontend_dict[device.name].update(
|
||||
{
|
||||
"network": device.network.display_name,
|
||||
"display_name": device.display_name,
|
||||
"vrfs": [
|
||||
{
|
||||
"id": vrf.name,
|
||||
"display_name": vrf.display_name,
|
||||
"default": vrf.default,
|
||||
"ipv4": True if vrf.ipv4 else False, # noqa: IF100
|
||||
"ipv6": True if vrf.ipv6 else False, # noqa: IF100
|
||||
}
|
||||
for vrf in device.vrfs
|
||||
],
|
||||
}
|
||||
)
|
||||
elif device.name not in frontend_dict:
|
||||
frontend_dict[device.name] = {
|
||||
"network": device.network.display_name,
|
||||
"display_name": device.display_name,
|
||||
"vrfs": [
|
||||
{
|
||||
"id": vrf.name,
|
||||
"display_name": vrf.display_name,
|
||||
"default": vrf.default,
|
||||
"ipv4": True if vrf.ipv4 else False, # noqa: IF100
|
||||
"ipv6": True if vrf.ipv6 else False, # noqa: IF100
|
||||
}
|
||||
for vrf in device.vrfs
|
||||
],
|
||||
}
|
||||
if not frontend_dict:
|
||||
raise ConfigError(error_msg="Unable to build network to device mapping")
|
||||
return frontend_dict
|
||||
|
||||
|
||||
def _build_networks() -> List[Dict]:
|
||||
"""Build filtered JSON Structure of networks & devices for Jinja templates."""
|
||||
networks = []
|
||||
|
|
@ -262,6 +229,7 @@ def _build_networks() -> List[Dict]:
|
|||
"_id": device._id,
|
||||
"name": device.name,
|
||||
"network": device.network.display_name,
|
||||
"directives": [c.frontend(params) for c in device.commands],
|
||||
"vrfs": [
|
||||
{
|
||||
"_id": vrf._id,
|
||||
|
|
@ -346,7 +314,7 @@ content_terms = get_markdown(
|
|||
content_credit = CREDIT.format(version=__version__)
|
||||
|
||||
networks = _build_networks()
|
||||
frontend_devices = _build_frontend_devices()
|
||||
|
||||
_include_fields = {
|
||||
"cache": {"show_text", "timeout"},
|
||||
"debug": ...,
|
||||
|
|
|
|||
133
hyperglass/models/commands/generic.py
Normal file
133
hyperglass/models/commands/generic.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import json
|
||||
from ipaddress import IPv4Network, IPv6Network
|
||||
from typing import Optional, Sequence, Union, Dict
|
||||
from typing_extensions import Literal
|
||||
from pydantic import StrictStr, PrivateAttr, conint, validator, FilePath
|
||||
from ..main import HyperglassModel
|
||||
from ..config.params import Params
|
||||
from hyperglass.configuration.markdown import get_markdown
|
||||
|
||||
IPv4PrefixLength = conint(ge=0, le=32)
|
||||
IPv6PrefixLength = conint(ge=0, le=128)
|
||||
|
||||
|
||||
class Policy(HyperglassModel):
|
||||
network: Union[IPv4Network, IPv6Network]
|
||||
action: Literal["permit", "deny"]
|
||||
|
||||
@validator("ge", check_fields=False)
|
||||
def validate_ge(cls, value: int, values: Dict) -> int:
|
||||
"""Ensure ge is at least the size of the input prefix."""
|
||||
|
||||
network_len = values["network"].prefixlen
|
||||
|
||||
if network_len > value:
|
||||
value = network_len
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class Policy4(Policy):
|
||||
ge: IPv4PrefixLength = 0
|
||||
le: IPv4PrefixLength = 32
|
||||
|
||||
|
||||
class Policy6(Policy):
|
||||
ge: IPv6PrefixLength = 0
|
||||
le: IPv6PrefixLength = 128
|
||||
|
||||
|
||||
class Input(HyperglassModel):
|
||||
_type: PrivateAttr
|
||||
description: StrictStr
|
||||
|
||||
def is_select(self) -> bool:
|
||||
return self._type == "select"
|
||||
|
||||
def is_text(self) -> bool:
|
||||
return self._type == "text"
|
||||
|
||||
def is_ip(self) -> bool:
|
||||
return self._type == "ip"
|
||||
|
||||
|
||||
class Text(Input):
|
||||
_type: PrivateAttr = "text"
|
||||
validation: Optional[StrictStr]
|
||||
|
||||
|
||||
class IPInput(Input):
|
||||
_type: PrivateAttr = "ip"
|
||||
validation: Union[Policy4, Policy6]
|
||||
|
||||
|
||||
class Option(HyperglassModel):
|
||||
name: Optional[StrictStr]
|
||||
value: StrictStr
|
||||
|
||||
|
||||
class Select(Input):
|
||||
_type: PrivateAttr = "select"
|
||||
options: Sequence[Option]
|
||||
|
||||
|
||||
class Directive(HyperglassModel):
|
||||
id: StrictStr
|
||||
name: StrictStr
|
||||
command: Union[StrictStr, Sequence[StrictStr]]
|
||||
field: Union[Text, Select, IPInput, None]
|
||||
info: Optional[FilePath]
|
||||
attrs: Dict = {}
|
||||
groups: Sequence[
|
||||
StrictStr
|
||||
] = [] # TODO: Flesh this out. Replace VRFs, but use same logic in React to filter available commands for multi-device queries.
|
||||
|
||||
@validator("command")
|
||||
def validate_command(cls, value: Union[str, Sequence[str]]) -> Sequence[str]:
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
return value
|
||||
|
||||
def get_commands(self, target: str) -> Sequence[str]:
|
||||
return [s.format(target=target, **self.attrs) for s in self.command]
|
||||
|
||||
@property
|
||||
def field_type(self) -> Literal["text", "select", None]:
|
||||
if self.field.is_select():
|
||||
return "select"
|
||||
elif self.field.is_text() or self.field.is_ip():
|
||||
return "text"
|
||||
return None
|
||||
|
||||
def frontend(self, params: Params) -> Dict:
|
||||
|
||||
value = {
|
||||
"name": self.name,
|
||||
"field_type": self.field_type,
|
||||
"groups": self.groups,
|
||||
"description": self.field.description,
|
||||
"info": None,
|
||||
}
|
||||
|
||||
if self.info is not None:
|
||||
content_params = json.loads(
|
||||
params.json(
|
||||
include={
|
||||
"primary_asn",
|
||||
"org_name",
|
||||
"site_title",
|
||||
"site_description",
|
||||
}
|
||||
)
|
||||
)
|
||||
with self.info.open() as md:
|
||||
value["info"] = {
|
||||
"enable": True,
|
||||
"params": content_params,
|
||||
"content": md.read(),
|
||||
}
|
||||
|
||||
if self.field_type == "select":
|
||||
value["options"]: [o.export_dict() for o in self.field.options]
|
||||
|
||||
return value
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
# Standard Library
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple, Union, Optional
|
||||
from typing import Any, Dict, List, Tuple, Union, Optional, Sequence
|
||||
from pathlib import Path
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
|
||||
|
|
@ -22,6 +22,8 @@ from hyperglass.log import log
|
|||
from hyperglass.util import get_driver, validate_nos, resolve_hostname
|
||||
from hyperglass.constants import SCRAPE_HELPERS, SUPPORTED_STRUCTURED_OUTPUT
|
||||
from hyperglass.exceptions import ConfigError, UnsupportedDevice
|
||||
from hyperglass.models.commands.generic import Directive
|
||||
|
||||
|
||||
# Local
|
||||
from .ssl import Ssl
|
||||
|
|
@ -89,10 +91,8 @@ class Device(HyperglassModel):
|
|||
port: StrictInt = 22
|
||||
ssl: Optional[Ssl]
|
||||
nos: StrictStr
|
||||
commands: Optional[StrictStr]
|
||||
commands: Sequence[Directive]
|
||||
vrfs: List[Vrf] = [_default_vrf]
|
||||
display_vrfs: List[StrictStr] = []
|
||||
vrf_names: List[StrictStr] = []
|
||||
structured_output: Optional[StrictBool]
|
||||
driver: Optional[SupportedDriver]
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ class Device(HyperglassModel):
|
|||
return value
|
||||
|
||||
@root_validator(pre=True)
|
||||
def validate_nos_commands(cls, values: "Device") -> "Device":
|
||||
def validate_nos_commands(cls, values: Dict) -> Dict:
|
||||
"""Validate & rewrite NOS, set default commands."""
|
||||
|
||||
nos = values.get("nos", "")
|
||||
|
|
@ -205,7 +205,7 @@ class Device(HyperglassModel):
|
|||
if "_telnet" in inferred:
|
||||
inferred = inferred.replace("_telnet", "")
|
||||
|
||||
values["commands"] = inferred
|
||||
values["commands"] = [inferred]
|
||||
|
||||
return values
|
||||
|
||||
|
|
@ -288,7 +288,6 @@ class Devices(HyperglassModelExtra):
|
|||
_ids: List[StrictStr] = []
|
||||
hostnames: List[StrictStr] = []
|
||||
vrfs: List[StrictStr] = []
|
||||
display_vrfs: List[StrictStr] = []
|
||||
vrf_objects: List[Vrf] = []
|
||||
objects: List[Device] = []
|
||||
all_nos: List[StrictStr] = []
|
||||
|
|
@ -300,15 +299,8 @@ class Devices(HyperglassModelExtra):
|
|||
Remove unsupported characters from device names, dynamically
|
||||
set attributes for the devices class. Builds lists of common
|
||||
attributes for easy access in other modules.
|
||||
|
||||
Arguments:
|
||||
input_params {dict} -- Unvalidated router definitions
|
||||
|
||||
Returns:
|
||||
{object} -- Validated routers object
|
||||
"""
|
||||
vrfs = set()
|
||||
display_vrfs = set()
|
||||
vrf_objects = set()
|
||||
all_nos = set()
|
||||
objects = set()
|
||||
|
|
@ -328,19 +320,13 @@ class Devices(HyperglassModelExtra):
|
|||
hostnames.add(device.name)
|
||||
_ids.add(device._id)
|
||||
objects.add(device)
|
||||
all_nos.add(device.commands)
|
||||
all_nos.add(device.nos)
|
||||
|
||||
for vrf in device.vrfs:
|
||||
|
||||
# For each configured router VRF, add its name and
|
||||
# display_name to a class set (for automatic de-duping).
|
||||
vrfs.add(vrf.name)
|
||||
display_vrfs.add(vrf.display_name)
|
||||
|
||||
# Also add the names to a router-level list so each
|
||||
# router's VRFs and display VRFs can be easily accessed.
|
||||
device.display_vrfs.append(vrf.display_name)
|
||||
device.vrf_names.append(vrf.name)
|
||||
|
||||
# Add a 'default_vrf' attribute to the devices class
|
||||
# which contains the configured default VRF display name.
|
||||
|
|
@ -367,7 +353,6 @@ class Devices(HyperglassModelExtra):
|
|||
init_kwargs["hostnames"] = list(hostnames)
|
||||
init_kwargs["all_nos"] = list(all_nos)
|
||||
init_kwargs["vrfs"] = list(vrfs)
|
||||
init_kwargs["display_vrfs"] = list(vrfs)
|
||||
init_kwargs["vrf_objects"] = list(vrf_objects)
|
||||
init_kwargs["objects"] = sorted(objects, key=lambda x: x.name)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,12 +23,12 @@ interface TViewer extends Pick<UseDisclosureReturn, 'isOpen' | 'onClose'> {
|
|||
|
||||
const Viewer: React.FC<TViewer> = (props: TViewer) => {
|
||||
const { title, isOpen, onClose, children } = props;
|
||||
const bg = useColorValue('white', 'black');
|
||||
const bg = useColorValue('white', 'blackSolid.700');
|
||||
const color = useColorValue('black', 'white');
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="full" scrollBehavior="inside">
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={bg} color={color} py={4} borderRadius="md" maxW="90%">
|
||||
<ModalContent bg={bg} color={color} py={4} borderRadius="md" maxW="90%" minH="90vh">
|
||||
<ModalHeader>{title}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
|
|
|
|||
|
|
@ -24,13 +24,11 @@ export const FormField: React.FC<TField> = (props: TField) => {
|
|||
return (
|
||||
<FormControl
|
||||
mx={2}
|
||||
d="flex"
|
||||
w="100%"
|
||||
maxW="100%"
|
||||
flexDir="column"
|
||||
my={{ base: 2, lg: 4 }}
|
||||
isInvalid={error !== false}
|
||||
flex={{ base: '1 0 100%', lg: '1 0 33.33%' }}
|
||||
{...rest}
|
||||
>
|
||||
<FormLabel
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
export * from './field';
|
||||
export * from './queryLocation';
|
||||
export * from './queryTarget';
|
||||
export * from './queryType';
|
||||
export * from './queryVrf';
|
||||
export * from './resolvedTarget';
|
||||
export * from './row';
|
||||
export * from './field';
|
||||
export * from './queryVrf';
|
||||
export * from './queryType';
|
||||
export * from './queryGroup';
|
||||
export * from './queryTarget';
|
||||
export * from './queryLocation';
|
||||
export * from './resolvedTarget';
|
||||
|
|
|
|||
71
hyperglass/ui/components/form/queryGroup.tsx
Normal file
71
hyperglass/ui/components/form/queryGroup.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Select } from '~/components';
|
||||
import { useLGMethods, useLGState } from '~/hooks';
|
||||
|
||||
import type { TNetwork, TSelectOption } from '~/types';
|
||||
import type { TQueryGroup } from './types';
|
||||
|
||||
// function buildOptions(queryVrfs: TDeviceVrf[]): TSelectOption[] {
|
||||
// return queryVrfs.map(q => ({ value: q._id, label: q.display_name }));
|
||||
// }
|
||||
|
||||
type QueryGroups = Record<string, string[]>;
|
||||
|
||||
function buildOptions(networks: TNetwork[]): QueryGroups {
|
||||
const options = {} as QueryGroups;
|
||||
for (const net of networks) {
|
||||
for (const loc of net.locations) {
|
||||
for (const directive of loc.directives) {
|
||||
for (const group of directive.groups) {
|
||||
if (Object.keys(options).includes(group)) {
|
||||
options[group] = [...options[group], loc.name];
|
||||
} else {
|
||||
options[group] = [loc.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export const QueryGroup: React.FC<TQueryGroup> = (props: TQueryGroup) => {
|
||||
const { groups, onChange, label } = props;
|
||||
const { selections, availableGroups, queryLocation, queryGroup } = useLGState();
|
||||
const { exportState } = useLGMethods();
|
||||
|
||||
// const groups = useMemo(() => buildOptions(networks), []);
|
||||
// const options = useMemo<TSelectOption[]>(
|
||||
// () => Object.keys(groups).map(key => ({ label: key, value: key })),
|
||||
// [groups],
|
||||
// );
|
||||
// const options = useMemo<TSelectOption[]>(() => groups.map(g => ({ label: g, value: g })), [
|
||||
// groups,
|
||||
// ]);
|
||||
const options = useMemo<TSelectOption[]>(
|
||||
() => availableGroups.map(g => ({ label: g.value, value: g.value })),
|
||||
[availableGroups.length, queryLocation.length],
|
||||
);
|
||||
|
||||
function handleChange(e: TSelectOption | TSelectOption[]): void {
|
||||
let value = '';
|
||||
if (!Array.isArray(e) && e !== null) {
|
||||
selections.queryGroup.set(e);
|
||||
value = e.value;
|
||||
} else {
|
||||
selections.queryGroup.set(null);
|
||||
}
|
||||
onChange({ field: 'query_group', value });
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
size="lg"
|
||||
name="query_group"
|
||||
options={options}
|
||||
aria-label={label}
|
||||
onChange={handleChange}
|
||||
value={exportState(selections.queryGroup.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,34 +1,60 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { Select } from '~/components';
|
||||
import { useConfig } from '~/context';
|
||||
import { useLGState, useLGMethods } from '~/hooks';
|
||||
|
||||
import type { TQuery, TSelectOption } from '~/types';
|
||||
import type { TNetwork, TSelectOption } from '~/types';
|
||||
import type { TQuerySelectField } from './types';
|
||||
|
||||
function buildOptions(queryTypes: TQuery[]): TSelectOption[] {
|
||||
return queryTypes
|
||||
.filter(q => q.enable === true)
|
||||
.map(q => ({ value: q.name, label: q.display_name }));
|
||||
// function buildOptions(queryTypes: TQuery[]): TSelectOption[] {
|
||||
// return queryTypes
|
||||
// .filter(q => q.enable === true)
|
||||
// .map(q => ({ value: q.name, label: q.display_name }));
|
||||
// }
|
||||
|
||||
function* buildOptions(networks: TNetwork[]): Generator<TSelectOption> {
|
||||
for (const net of networks) {
|
||||
for (const loc of net.locations) {
|
||||
for (const directive of loc.directives) {
|
||||
const { name } = directive;
|
||||
yield { value: name, label: name };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const QueryType: React.FC<TQuerySelectField> = (props: TQuerySelectField) => {
|
||||
const { onChange, label } = props;
|
||||
const { queries } = useConfig();
|
||||
// const {
|
||||
// queries,
|
||||
// networks,
|
||||
// } = useConfig();
|
||||
const { errors } = useFormContext();
|
||||
const { selections } = useLGState();
|
||||
const { selections, availableTypes, queryType } = useLGState();
|
||||
const { exportState } = useLGMethods();
|
||||
|
||||
const options = useMemo(() => buildOptions(queries.list), [queries.list.length]);
|
||||
// const options = useMemo(() => buildOptions(queries.list), [queries.list.length]);
|
||||
// const options = useMemo(() => Array.from(buildOptions(networks)), []);
|
||||
// const options = useMemo(
|
||||
// () => uniqBy<TSelectOption>(Array.from(buildOptions(networks)), opt => opt?.label),
|
||||
// [],
|
||||
// );
|
||||
const options = useMemo(() => availableTypes.map(t => ({ label: t.value, value: t.value })), [
|
||||
availableTypes.length,
|
||||
]);
|
||||
|
||||
function handleChange(e: TSelectOption | TSelectOption[]): void {
|
||||
let value = '';
|
||||
if (!Array.isArray(e) && e !== null) {
|
||||
selections.queryType.set(e);
|
||||
onChange({ field: 'query_type', value: e.value });
|
||||
value = e.value;
|
||||
} else {
|
||||
selections.queryType.set(null);
|
||||
queryType.set('');
|
||||
}
|
||||
onChange({ field: 'query_type', value });
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const FormRow: React.FC<FlexProps> = (props: FlexProps) => {
|
|||
flexDir="row"
|
||||
flexWrap="wrap"
|
||||
justifyContent={{ base: 'center', lg: 'space-between' }}
|
||||
sx={{ '& > *': { display: 'flex', flex: { base: '1 0 100%', lg: '1 0 33.33%' } } }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ export interface TQueryVrf extends TQuerySelectField {
|
|||
vrfs: TDeviceVrf[];
|
||||
}
|
||||
|
||||
export interface TQueryGroup extends TQuerySelectField {
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface TCommunitySelect {
|
||||
name: string;
|
||||
onChange: OnChange;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { Flex, ScaleFade, SlideFade } from '@chakra-ui/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { intersectionWith } from 'lodash';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { vestResolver } from '@hookform/resolvers/vest';
|
||||
import vest, { test, enforce } from 'vest';
|
||||
import {
|
||||
If,
|
||||
FormRow,
|
||||
QueryVrf,
|
||||
QueryGroup,
|
||||
FormField,
|
||||
HelpModal,
|
||||
QueryType,
|
||||
|
|
@ -18,7 +19,7 @@ import {
|
|||
} from '~/components';
|
||||
import { useConfig } from '~/context';
|
||||
import { useStrf, useGreeting, useDevice, useLGState, useLGMethods } from '~/hooks';
|
||||
import { isQueryType, isQueryContent, isString } from '~/types';
|
||||
import { isString } from '~/types';
|
||||
|
||||
import type { TFormData, TDeviceVrf, OnChangeArgs } from '~/types';
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ function useIsFqdn(target: string, _type: string) {
|
|||
}
|
||||
|
||||
export const LookingGlass: React.FC = () => {
|
||||
const { web, content, messages } = useConfig();
|
||||
const { web, messages } = useConfig();
|
||||
|
||||
const { ack, greetingReady } = useGreeting();
|
||||
const getDevice = useDevice();
|
||||
|
|
@ -80,21 +81,39 @@ export const LookingGlass: React.FC = () => {
|
|||
const { handleSubmit, register, setValue, setError, clearErrors } = formInstance;
|
||||
|
||||
const {
|
||||
availableGroups,
|
||||
queryVrf,
|
||||
families,
|
||||
queryType,
|
||||
availVrfs,
|
||||
directive,
|
||||
availableTypes,
|
||||
btnLoading,
|
||||
queryGroup,
|
||||
queryTarget,
|
||||
isSubmitting,
|
||||
queryLocation,
|
||||
displayTarget,
|
||||
selections,
|
||||
} = useLGState();
|
||||
|
||||
const { resolvedOpen, resetForm } = useLGMethods();
|
||||
|
||||
const isFqdnQuery = useIsFqdn(queryTarget.value, queryType.value);
|
||||
|
||||
const selectedDirective = useMemo(() => {
|
||||
if (queryType.value === '') {
|
||||
return null;
|
||||
}
|
||||
for (const loc of queryLocation) {
|
||||
const device = getDevice(loc.value);
|
||||
for (const directive of device.directives) {
|
||||
if (directive.name === queryType.value) {
|
||||
return directive;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [queryType.value]);
|
||||
|
||||
function submitHandler() {
|
||||
/**
|
||||
* Before submitting a query, make sure the greeting is acknowledged if required. This should
|
||||
|
|
@ -133,6 +152,9 @@ export const LookingGlass: React.FC = () => {
|
|||
clearErrors('query_location');
|
||||
const allVrfs = [] as TDeviceVrf[][];
|
||||
const locationNames = [] as string[];
|
||||
const allGroups = [] as string[][];
|
||||
const allTypes = [] as string[][];
|
||||
const allDevices = [];
|
||||
|
||||
queryLocation.set(locations);
|
||||
|
||||
|
|
@ -141,66 +163,76 @@ export const LookingGlass: React.FC = () => {
|
|||
const device = getDevice(loc);
|
||||
locationNames.push(device.name);
|
||||
allVrfs.push(device.vrfs);
|
||||
allDevices.push(device);
|
||||
const groups = new Set<string>();
|
||||
for (const directive of device.directives) {
|
||||
for (const group of directive.groups) {
|
||||
groups.add(group);
|
||||
}
|
||||
}
|
||||
allGroups.push(Array.from(groups));
|
||||
}
|
||||
|
||||
// Use _.intersectionWith to create an array of VRFs common to all selected locations.
|
||||
const intersecting = intersectionWith(
|
||||
...allVrfs,
|
||||
(a: TDeviceVrf, b: TDeviceVrf) => a._id === b._id,
|
||||
);
|
||||
const intersecting = intersectionWith(...allGroups, isEqual);
|
||||
|
||||
availVrfs.set(intersecting);
|
||||
if (!intersecting.includes(queryGroup.value)) {
|
||||
queryGroup.set('');
|
||||
queryType.set('');
|
||||
directive.set(null);
|
||||
selections.merge({ queryGroup: null, queryType: null });
|
||||
}
|
||||
|
||||
for (const group of intersecting) {
|
||||
for (const device of allDevices) {
|
||||
for (const directive of device.directives) {
|
||||
if (directive.groups.includes(group)) {
|
||||
// allTypes.add(directive.name);
|
||||
allTypes.push(device.directives.map(d => d.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const intersectingTypes = intersectionWith(...allTypes, isEqual);
|
||||
|
||||
availableGroups.set(intersecting);
|
||||
availableTypes.set(intersectingTypes);
|
||||
|
||||
// If there is more than one location selected, but there are no intersecting VRFs, show an error.
|
||||
if (locations.length > 1 && intersecting.length === 0) {
|
||||
setError('query_location', {
|
||||
message: `${locationNames.join(', ')} have no VRFs in common.`,
|
||||
// message: `${locationNames.join(', ')} have no VRFs in common.`,
|
||||
message: `${locationNames.join(', ')} have no groups in common.`,
|
||||
});
|
||||
}
|
||||
// If there is only one intersecting VRF, set it as the form value so the user doesn't have to.
|
||||
else if (intersecting.length === 1) {
|
||||
queryVrf.set(intersecting[0]._id);
|
||||
// queryVrf.set(intersecting[0]._id);
|
||||
queryGroup.set(intersecting[0]);
|
||||
}
|
||||
|
||||
// Determine which address families are available in the intersecting VRFs.
|
||||
let ipv4 = 0;
|
||||
let ipv6 = 0;
|
||||
|
||||
for (const intersection of intersecting) {
|
||||
if (intersection.ipv4) {
|
||||
// If IPv4 is enabled in this VRF, count it.
|
||||
ipv4++;
|
||||
}
|
||||
if (intersection.ipv6) {
|
||||
// If IPv6 is enabled in this VRF, count it.
|
||||
ipv6++;
|
||||
if (availableGroups.length > 1 && intersectingTypes.length === 0) {
|
||||
setError('query_location', {
|
||||
message: `${locationNames.join(', ')} have no query types in common.`,
|
||||
});
|
||||
} else if (intersectingTypes.length === 1) {
|
||||
queryType.set(intersectingTypes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (ipv4 !== 0 && ipv4 === ipv6) {
|
||||
/**
|
||||
* If ipv4 & ipv6 are equal, this means every VRF has both IPv4 & IPv6 enabled. In that
|
||||
* case, signal that both A & AAAA records should be queried if the query is an FQDN.
|
||||
*/
|
||||
families.set([4, 6]);
|
||||
} else if (ipv4 > ipv6) {
|
||||
/**
|
||||
* If ipv4 is greater than ipv6, this means that IPv6 is not enabled on all VRFs, i.e. there
|
||||
* are some VRFs with IPv4 enabled but IPv6 disabled. In that case, only query A records.
|
||||
*/
|
||||
families.set([4]);
|
||||
} else if (ipv4 < ipv6) {
|
||||
/**
|
||||
* If ipv6 is greater than ipv4, this means that IPv4 is not enabled on all VRFs, i.e. there
|
||||
* are some VRFs with IPv6 enabled but IPv4 disabled. In that case, only query AAAA records.
|
||||
*/
|
||||
families.set([6]);
|
||||
} else {
|
||||
/**
|
||||
* If both ipv4 and ipv6 are 0, then both ipv4 and ipv6 are disabled, and why does that VRF
|
||||
* even exist?
|
||||
*/
|
||||
families.set([]);
|
||||
function handleGroupChange(group: string): void {
|
||||
queryGroup.set(group);
|
||||
const availTypes = new Set<string>();
|
||||
for (const loc of queryLocation) {
|
||||
const device = getDevice(loc.value);
|
||||
for (const directive of device.directives) {
|
||||
if (directive.groups.includes(group)) {
|
||||
availTypes.add(directive.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
availableTypes.set(Array.from(availTypes));
|
||||
if (availableTypes.length === 1) {
|
||||
queryType.set(availableTypes[0].value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,14 +242,12 @@ export const LookingGlass: React.FC = () => {
|
|||
|
||||
if (e.field === 'query_location' && Array.isArray(e.value)) {
|
||||
handleLocChange(e.value);
|
||||
} else if (e.field === 'query_type' && isQueryType(e.value)) {
|
||||
} else if (e.field === 'query_type' && isString(e.value)) {
|
||||
queryType.set(e.value);
|
||||
if (queryTarget.value !== '') {
|
||||
/**
|
||||
* Reset queryTarget as well, so that, for example, selecting BGP Community, and selecting
|
||||
* a community, then changing the queryType to BGP Route doesn't preserve the selected
|
||||
* community as the queryTarget.
|
||||
*/
|
||||
// Reset queryTarget as well, so that, for example, selecting BGP Community, and selecting
|
||||
// a community, then changing the queryType to BGP Route doesn't preserve the selected
|
||||
// community as the queryTarget.
|
||||
queryTarget.set('');
|
||||
displayTarget.set('');
|
||||
}
|
||||
|
|
@ -225,25 +255,21 @@ export const LookingGlass: React.FC = () => {
|
|||
queryVrf.set(e.value);
|
||||
} else if (e.field === 'query_target' && isString(e.value)) {
|
||||
queryTarget.set(e.value);
|
||||
} else if (e.field === 'query_group' && isString(e.value)) {
|
||||
// queryGroup.set(e.value);
|
||||
handleGroupChange(e.value);
|
||||
}
|
||||
console.table({
|
||||
'Query Location': queryLocation.value,
|
||||
'Query Type': queryType.value,
|
||||
'Query Group': queryGroup.value,
|
||||
'Query Target': queryTarget.value,
|
||||
'Selected Directive': selectedDirective?.name ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the correct help content based on the selected VRF & Query Type. Also remove the icon
|
||||
* if no locations are set.
|
||||
*/
|
||||
const vrfContent = useMemo(() => {
|
||||
if (queryLocation.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(content.vrf).includes(queryVrf.value) && queryType.value !== '') {
|
||||
return content.vrf[queryVrf.value][queryType.value];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, [queryVrf.value, queryLocation.value, queryType.value]);
|
||||
|
||||
useEffect(() => {
|
||||
register({ name: 'query_group', required: true });
|
||||
register({ name: 'query_location', required: true });
|
||||
register({ name: 'query_target', required: true });
|
||||
register({ name: 'query_type', required: true });
|
||||
|
|
@ -270,30 +296,44 @@ export const LookingGlass: React.FC = () => {
|
|||
<FormField name="query_location" label={web.text.query_location}>
|
||||
<QueryLocation onChange={handleChange} label={web.text.query_location} />
|
||||
</FormField>
|
||||
<If c={availableGroups.length > 1}>
|
||||
<FormField label={web.text.query_vrf} name="query_group">
|
||||
<QueryGroup
|
||||
label={web.text.query_vrf}
|
||||
groups={availableGroups.value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</FormField>
|
||||
</If>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<SlideFade offsetX={-100} in={availableTypes.length > 1} unmountOnExit>
|
||||
<FormField
|
||||
name="query_type"
|
||||
label={web.text.query_type}
|
||||
labelAddOn={
|
||||
<HelpModal visible={isQueryContent(vrfContent)} item={vrfContent} name="query_type" />
|
||||
<HelpModal
|
||||
visible={selectedDirective?.info !== null}
|
||||
item={selectedDirective?.info ?? null}
|
||||
name="query_type"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<QueryType onChange={handleChange} label={web.text.query_type} />
|
||||
</FormField>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<If c={availVrfs.length > 1}>
|
||||
<FormField label={web.text.query_vrf} name="query_vrf">
|
||||
<QueryVrf label={web.text.query_vrf} vrfs={availVrfs.value} onChange={handleChange} />
|
||||
</FormField>
|
||||
</If>
|
||||
</SlideFade>
|
||||
<SlideFade offsetX={100} in={selectedDirective !== null} unmountOnExit>
|
||||
{selectedDirective !== null && (
|
||||
<FormField name="query_target" label={web.text.query_target}>
|
||||
<QueryTarget
|
||||
name="query_target"
|
||||
register={register}
|
||||
onChange={handleChange}
|
||||
placeholder={web.text.query_target}
|
||||
placeholder={selectedDirective.description}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</SlideFade>
|
||||
</FormRow>
|
||||
<FormRow mt={0} justifyContent="flex-end">
|
||||
<Flex
|
||||
|
|
@ -305,7 +345,9 @@ export const LookingGlass: React.FC = () => {
|
|||
flexDir="column"
|
||||
mr={{ base: 0, lg: 2 }}
|
||||
>
|
||||
<ScaleFade initialScale={0.5} in={queryTarget.value !== ''}>
|
||||
<SubmitButton handleChange={handleChange} />
|
||||
</ScaleFade>
|
||||
</Flex>
|
||||
</FormRow>
|
||||
</AnimatedDiv>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Result } from './individual';
|
|||
import { Tags } from './tags';
|
||||
|
||||
export const Results: React.FC = () => {
|
||||
const { queryLocation, queryTarget, queryType, queryVrf } = useLGState();
|
||||
const { queryLocation, queryTarget, queryType, queryVrf, queryGroup } = useLGState();
|
||||
|
||||
const getDevice = useDevice();
|
||||
|
||||
|
|
@ -49,6 +49,7 @@ export const Results: React.FC = () => {
|
|||
queryLocation={loc.value}
|
||||
queryVrf={queryVrf.value}
|
||||
queryType={queryType.value}
|
||||
queryGroup={queryGroup.value}
|
||||
queryTarget={queryTarget.value}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const AccordionHeaderWrapper = chakra('div', {
|
|||
});
|
||||
|
||||
const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props: TResult, ref) => {
|
||||
const { index, device, queryVrf, queryType, queryTarget, queryLocation } = props;
|
||||
const { index, device, queryVrf, queryType, queryTarget, queryLocation, queryGroup } = props;
|
||||
|
||||
const { web, cache, messages } = useConfig();
|
||||
const { index: indices, setIndex } = useAccordionContext();
|
||||
|
|
@ -57,6 +57,7 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props:
|
|||
queryTarget,
|
||||
queryType,
|
||||
queryVrf,
|
||||
queryGroup,
|
||||
});
|
||||
|
||||
const isCached = useMemo(() => data?.cached || !isFetchedAfterMount, [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { State } from '@hookstate/core';
|
||||
import type { ButtonProps } from '@chakra-ui/react';
|
||||
import type { UseQueryResult } from 'react-query';
|
||||
import type { TDevice, TQueryTypes } from '~/types';
|
||||
import type { TDevice } from '~/types';
|
||||
|
||||
export interface TResultHeader {
|
||||
title: string;
|
||||
|
|
@ -21,9 +21,10 @@ export interface TResult {
|
|||
index: number;
|
||||
device: TDevice;
|
||||
queryVrf: string;
|
||||
queryGroup: string;
|
||||
queryTarget: string;
|
||||
queryLocation: string;
|
||||
queryType: TQueryTypes;
|
||||
queryType: string;
|
||||
}
|
||||
|
||||
export type TErrorLevels = 'success' | 'warning' | 'error';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
TDeviceVrf,
|
||||
TQueryTypes,
|
||||
TSelectOption,
|
||||
TDirective,
|
||||
} from '~/types';
|
||||
|
||||
export interface TOpposingOptions {
|
||||
|
|
@ -56,6 +57,7 @@ export interface TSelections {
|
|||
queryLocation: TSelectOption[] | [];
|
||||
queryType: TSelectOption | null;
|
||||
queryVrf: TSelectOption | null;
|
||||
queryGroup: TSelectOption | null;
|
||||
}
|
||||
|
||||
export interface TMethodsExtension {
|
||||
|
|
@ -69,14 +71,19 @@ export interface TMethodsExtension {
|
|||
|
||||
export type TLGState = {
|
||||
queryVrf: string;
|
||||
queryGroup: string;
|
||||
families: Families;
|
||||
queryTarget: string;
|
||||
btnLoading: boolean;
|
||||
isSubmitting: boolean;
|
||||
displayTarget: string;
|
||||
queryType: TQueryTypes;
|
||||
directive: TDirective | null;
|
||||
// queryType: TQueryTypes;
|
||||
queryType: string;
|
||||
queryLocation: string[];
|
||||
availVrfs: TDeviceVrf[];
|
||||
availableGroups: string[];
|
||||
availableTypes: string[];
|
||||
resolvedIsOpen: boolean;
|
||||
selections: TSelections;
|
||||
responses: { [d: string]: TQueryResponse };
|
||||
|
|
|
|||
|
|
@ -18,12 +18,14 @@ class MethodsInstance {
|
|||
public resolvedOpen(state: State<TLGState>) {
|
||||
state.resolvedIsOpen.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the DNS resolver Popover to closed.
|
||||
*/
|
||||
public resolvedClose(state: State<TLGState>) {
|
||||
state.resolvedIsOpen.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a response based on the device ID.
|
||||
*/
|
||||
|
|
@ -34,6 +36,7 @@ class MethodsInstance {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the form is ready for submission, e.g. all fields have values and isSubmitting
|
||||
* has been set to true. This ultimately controls the UI layout.
|
||||
|
|
@ -45,12 +48,14 @@ class MethodsInstance {
|
|||
...[
|
||||
state.queryVrf.value !== '',
|
||||
state.queryType.value !== '',
|
||||
state.queryGroup.value !== '',
|
||||
state.queryTarget.value !== '',
|
||||
state.queryLocation.length !== 0,
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset form values affected by the form state to their default values.
|
||||
*/
|
||||
|
|
@ -59,6 +64,7 @@ class MethodsInstance {
|
|||
queryVrf: '',
|
||||
families: [],
|
||||
queryType: '',
|
||||
queryGroup: '',
|
||||
responses: {},
|
||||
queryTarget: '',
|
||||
queryLocation: [],
|
||||
|
|
@ -67,9 +73,12 @@ class MethodsInstance {
|
|||
isSubmitting: false,
|
||||
resolvedIsOpen: false,
|
||||
availVrfs: [],
|
||||
selections: { queryLocation: [], queryType: null, queryVrf: null },
|
||||
availableGroups: [],
|
||||
availableTypes: [],
|
||||
selections: { queryLocation: [], queryType: null, queryVrf: null, queryGroup: null },
|
||||
});
|
||||
}
|
||||
|
||||
public stateExporter<O extends unknown>(obj: O): O | null {
|
||||
let result = null;
|
||||
if (obj === null) {
|
||||
|
|
@ -125,13 +134,17 @@ function Methods(inst?: State<TLGState>): Plugin | TMethodsExtension {
|
|||
}
|
||||
|
||||
const LGState = createState<TLGState>({
|
||||
selections: { queryLocation: [], queryType: null, queryVrf: null },
|
||||
selections: { queryLocation: [], queryType: null, queryVrf: null, queryGroup: null },
|
||||
resolvedIsOpen: false,
|
||||
isSubmitting: false,
|
||||
availableGroups: [],
|
||||
availableTypes: [],
|
||||
directive: null,
|
||||
displayTarget: '',
|
||||
queryLocation: [],
|
||||
btnLoading: false,
|
||||
queryTarget: '',
|
||||
queryGroup: '',
|
||||
queryType: '',
|
||||
availVrfs: [],
|
||||
responses: {},
|
||||
|
|
|
|||
|
|
@ -14,4 +14,7 @@ module.exports = {
|
|||
future: {
|
||||
webpack5: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -115,10 +115,30 @@ export interface TDeviceVrf extends TDeviceVrfBase {
|
|||
ipv6: boolean;
|
||||
}
|
||||
|
||||
interface TDirectiveBase {
|
||||
name: string;
|
||||
field_type: 'text' | 'select' | null;
|
||||
description: string;
|
||||
groups: string[];
|
||||
info: TQueryContent | null;
|
||||
}
|
||||
|
||||
interface TDirectiveOption {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TDirectiveSelect extends TDirectiveBase {
|
||||
options: TDirectiveOption[];
|
||||
}
|
||||
|
||||
export type TDirective = TDirectiveBase | TDirectiveSelect;
|
||||
|
||||
interface TDeviceBase {
|
||||
_id: string;
|
||||
name: string;
|
||||
network: string;
|
||||
directives: TDirective[];
|
||||
}
|
||||
|
||||
export interface TDevice extends TDeviceBase {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface TFormData {
|
|||
query_type: TQueryTypes;
|
||||
query_vrf: string;
|
||||
query_target: string;
|
||||
query_group: string;
|
||||
}
|
||||
|
||||
export interface TFormState {
|
||||
|
|
@ -13,6 +14,7 @@ export interface TFormState {
|
|||
queryType: TQueryTypes;
|
||||
queryVrf: string;
|
||||
queryTarget: string;
|
||||
queryGroup: string;
|
||||
}
|
||||
|
||||
export interface TFormQuery extends Omit<TFormState, 'queryLocation'> {
|
||||
|
|
|
|||
2
poetry.lock
generated
2
poetry.lock
generated
|
|
@ -1407,7 +1407,7 @@ testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake
|
|||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = ">=3.6.1,<4.0"
|
||||
content-hash = "3e44c4a83c82c220179b98b16f7b27e524c4461903bfa6f81a25781ee1514166"
|
||||
content-hash = "39564830e6fe6f4ba7253c516dd9d0dc0089e60512cd0c94ae798a4464be4505"
|
||||
|
||||
[metadata.files]
|
||||
aiocontextvars = [
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ scrapli = {extras = ["asyncssh"], version = "^2021.1.30"}
|
|||
uvicorn = {extras = ["standard"], version = "^0.13.4"}
|
||||
uvloop = "^0.14.0"
|
||||
xmltodict = "^0.12.0"
|
||||
typing-extensions = "^3.7.4"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
bandit = "^1.6.2"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue