initial work on generic commands

This commit is contained in:
checktheroads 2021-05-29 21:26:03 -07:00
parent 7636e044b2
commit 5f036228a5
21 changed files with 488 additions and 210 deletions

View file

@ -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": ...,

View 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

View file

@ -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)

View file

@ -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>

View file

@ -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

View file

@ -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';

View 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)}
/>
);
};

View file

@ -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 (

View file

@ -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}
/>
);

View file

@ -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;

View file

@ -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>

View file

@ -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}
/>
);

View file

@ -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, [

View file

@ -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';

View file

@ -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 };

View file

@ -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: {},

View file

@ -14,4 +14,7 @@ module.exports = {
future: {
webpack5: true,
},
typescript: {
ignoreBuildErrors: true,
},
};

View file

@ -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 {

View file

@ -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
View file

@ -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 = [

View file

@ -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"