diff --git a/hyperglass/api/__init__.py b/hyperglass/api/__init__.py index a2539ca..24ef8b9 100644 --- a/hyperglass/api/__init__.py +++ b/hyperglass/api/__init__.py @@ -18,6 +18,7 @@ from starlette.middleware.cors import CORSMiddleware from hyperglass.log import log from hyperglass.util import cpu_count from hyperglass.constants import TRANSPORT_REST, __version__ +from hyperglass.models.ui import UIParameters from hyperglass.api.events import on_startup, on_shutdown from hyperglass.api.routes import ( docs, @@ -25,8 +26,8 @@ from hyperglass.api.routes import ( query, queries, routers, - communities, ui_props, + communities, import_certificate, ) from hyperglass.exceptions import HyperglassError @@ -246,6 +247,8 @@ app.add_api_route( endpoint=ui_props, methods=["GET", "OPTIONS"], response_class=JSONResponse, + response_model=UIParameters, + response_model_by_alias=True, ) # Enable certificate import route only if a device using diff --git a/hyperglass/api/routes.py b/hyperglass/api/routes.py index 6065efd..cd2ba9a 100644 --- a/hyperglass/api/routes.py +++ b/hyperglass/api/routes.py @@ -20,7 +20,7 @@ from hyperglass.api.tasks import process_headers, import_public_key from hyperglass.constants import __version__ from hyperglass.exceptions import HyperglassError from hyperglass.models.api import Query, EncodedRequest -from hyperglass.configuration import REDIS_CONFIG, params, devices, frontend_params +from hyperglass.configuration import REDIS_CONFIG, params, devices, ui_params from hyperglass.execution.main import execute # Local @@ -266,7 +266,7 @@ async def info(): async def ui_props(): """Serve UI configration.""" - return frontend_params + return ui_params endpoints = [query, docs, routers, info, ui_props] diff --git a/hyperglass/configuration/__init__.py b/hyperglass/configuration/__init__.py index 073a8a1..aa652bc 100644 --- a/hyperglass/configuration/__init__.py +++ b/hyperglass/configuration/__init__.py @@ -10,5 +10,17 @@ from .main import ( params, devices, commands, - frontend_params, + ui_params, +) + +__all__ = ( + "URL_DEV", + "URL_PROD", + "CONFIG_PATH", + "STATIC_PATH", + "REDIS_CONFIG", + "params", + "devices", + "commands", + "ui_params", ) diff --git a/hyperglass/configuration/main.py b/hyperglass/configuration/main.py index e1ad872..7875887 100644 --- a/hyperglass/configuration/main.py +++ b/hyperglass/configuration/main.py @@ -2,7 +2,6 @@ # Standard Library import os -import json from typing import Dict, List, Generator from pathlib import Path @@ -20,6 +19,7 @@ from hyperglass.log import ( from hyperglass.util import set_app_path, set_cache_env, current_log_level from hyperglass.defaults import CREDIT from hyperglass.constants import PARSED_RESPONSE_FIELDS, __version__ +from hyperglass.models.ui import UIParameters from hyperglass.util.files import check_path from hyperglass.exceptions.private import ConfigError, ConfigMissing from hyperglass.models.config.params import Params @@ -204,35 +204,6 @@ except KeyError: pass -def _build_networks() -> List[Dict]: - """Build filtered JSON Structure of networks & devices for Jinja templates.""" - networks = [] - _networks = list(set({device.network.display_name for device in devices.objects})) - - for _network in _networks: - network_def = {"display_name": _network, "locations": []} - for device in devices.objects: - if device.network.display_name == _network: - network_def["locations"].append( - { - "_id": device._id, - "name": device.name, - "network": device.network.display_name, - "directives": [c.frontend(params) for c in device.commands], - } - ) - networks.append(network_def) - - if not networks: - raise ConfigError(message="Unable to build network to device mapping") - return networks - - -content_params = json.loads( - params.json(include={"primary_asn", "org_name", "site_title", "site_description"}) -) - - content_greeting = get_markdown( config_path=params.web.greeting, default="", @@ -242,38 +213,17 @@ content_greeting = get_markdown( content_credit = CREDIT.format(version=__version__) -networks = _build_networks() +_ui_params = params.frontend() +_ui_params["web"]["logo"]["light_format"] = params.web.logo.light.suffix +_ui_params["web"]["logo"]["dark_format"] = params.web.logo.dark.suffix -_include_fields = { - "cache": {"show_text", "timeout"}, - "debug": ..., - "developer_mode": ..., - "primary_asn": ..., - "request_timeout": ..., - "org_name": ..., - "google_analytics": ..., - "site_title": ..., - "site_description": ..., - "site_keywords": ..., - "web": ..., - "messages": ..., -} -_frontend_params = params.dict(include=_include_fields) - - -_frontend_params["web"]["logo"]["light_format"] = params.web.logo.light.suffix -_frontend_params["web"]["logo"]["dark_format"] = params.web.logo.dark.suffix - -_frontend_params.update( - { - "hyperglass_version": __version__, - "queries": {**params.queries.map, "list": params.queries.list}, - "networks": networks, - "parsed_data_fields": PARSED_RESPONSE_FIELDS, - "content": {"credit": content_credit, "greeting": content_greeting}, - } +ui_params = UIParameters( + **_ui_params, + version=__version__, + networks=devices.networks(params), + parsed_data_fields=PARSED_RESPONSE_FIELDS, + content={"credit": content_credit, "greeting": content_greeting}, ) -frontend_params = _frontend_params URL_DEV = f"http://localhost:{str(params.listen_port)}/" URL_PROD = "/api/" diff --git a/hyperglass/main.py b/hyperglass/main.py index b9e0d55..ecd48ee 100644 --- a/hyperglass/main.py +++ b/hyperglass/main.py @@ -41,7 +41,7 @@ from .configuration import ( CONFIG_PATH, REDIS_CONFIG, params, - frontend_params, + ui_params, ) from .util.frontend import build_frontend @@ -85,7 +85,7 @@ async def build_ui() -> bool: dev_mode=params.developer_mode, dev_url=URL_DEV, prod_url=URL_PROD, - params=frontend_params, + params=ui_params, app_path=CONFIG_PATH, ) return True diff --git a/hyperglass/models/commands/generic.py b/hyperglass/models/commands/generic.py index 3174fc4..b33df39 100644 --- a/hyperglass/models/commands/generic.py +++ b/hyperglass/models/commands/generic.py @@ -2,7 +2,6 @@ # Standard Library import re -import json from typing import Dict, List, Union, Literal, Optional from ipaddress import IPv4Network, IPv6Network, ip_network @@ -267,20 +266,10 @@ class Directive(HyperglassModel): } 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, + "params": params.content_params(), "content": md.read(), } diff --git a/hyperglass/models/config/cache.py b/hyperglass/models/config/cache.py index ca4499c..e5fd908 100644 --- a/hyperglass/models/config/cache.py +++ b/hyperglass/models/config/cache.py @@ -10,15 +10,20 @@ from pydantic import SecretStr, StrictInt, StrictStr, StrictBool, IPvAnyAddress from ..main import HyperglassModel -class Cache(HyperglassModel): +class CachePublic(HyperglassModel): + """Public cache parameters.""" + + timeout: StrictInt = 120 + show_text: StrictBool = True + + +class Cache(CachePublic): """Validation model for params.cache.""" host: Union[IPvAnyAddress, StrictStr] = "localhost" port: StrictInt = 6379 database: StrictInt = 1 password: Optional[SecretStr] - timeout: StrictInt = 120 - show_text: StrictBool = True class Config: """Pydantic model configuration.""" diff --git a/hyperglass/models/config/devices.py b/hyperglass/models/config/devices.py index 6fe43a4..8bd1ff1 100644 --- a/hyperglass/models/config/devices.py +++ b/hyperglass/models/config/devices.py @@ -28,6 +28,7 @@ from hyperglass.models.commands.generic import Directive from .ssl import Ssl from ..main import HyperglassModel, HyperglassModelExtra from .proxy import Proxy +from .params import Params from ..fields import SupportedDriver from .network import Network from .credential import Credential @@ -274,3 +275,23 @@ class Devices(HyperglassModelExtra): return device raise AttributeError(f"No device named '{accessor}'") + + def networks(self, params: Params) -> List[Dict[str, Any]]: + """Group devices by network.""" + names = {device.network.display_name for device in self.objects} + return [ + { + "display_name": name, + "locations": [ + { + "id": device._id, + "name": device.name, + "network": device.network.display_name, + "directives": [c.frontend(params) for c in device.commands], + } + for device in self.objects + if device.network.display_name in names + ], + } + for name in names + ] diff --git a/hyperglass/models/config/params.py b/hyperglass/models/config/params.py index dc35064..8f7ae4d 100644 --- a/hyperglass/models/config/params.py +++ b/hyperglass/models/config/params.py @@ -1,19 +1,11 @@ """Configuration validation entry point.""" # Standard Library -from typing import List, Union, Optional +from typing import Any, Dict, List, Union, Literal, Optional from ipaddress import ip_address # Third Party -from pydantic import ( - Field, - StrictInt, - StrictStr, - StrictBool, - IPvAnyAddress, - constr, - validator, -) +from pydantic import Field, StrictInt, StrictStr, StrictBool, IPvAnyAddress, validator # Local from .web import Web @@ -26,13 +18,12 @@ from .queries import Queries from .messages import Messages from .structured import Structured -Localhost = constr(regex=r"localhost") +Localhost = Literal["localhost"] -class Params(HyperglassModel): - """Validation model for all configuration variables.""" +class ParamsPublic(HyperglassModel): + """Public configuration parameters.""" - # Top Level Params debug: StrictBool = Field( False, title="Debug", @@ -43,10 +34,10 @@ class Params(HyperglassModel): title="Developer Mode", description='Enable developer mode. If enabled, the hyperglass backend (Python) and frontend (React/Javascript) applications are "unlinked", so that React tools can be used for front end development. A `` convenience component is also displayed in the UI for easier UI development.', ) - fake_output: StrictBool = Field( - False, - title="Fake Output", - description="If enabled, the hyperglass backend will return static fake output for development/testing purposes.", + request_timeout: StrictInt = Field( + 90, + title="Request Timeout", + description="Global timeout in seconds for all requests. The frontend application (UI) uses this field's exact value when submitting queries. The backend application uses this field's value, minus one second, for its own timeout handling. This is to ensure a contextual timeout error is presented to the end user in the event of a backend application timeout.", ) primary_asn: Union[StrictInt, StrictStr] = Field( "65001", @@ -58,6 +49,7 @@ class Params(HyperglassModel): title="Organization Name", description="Your organization's name. This field is used in the UI & API documentation to set fields such as `` HTML tags for SEO and the terms & conditions footer component.", ) + google_analytics: Optional[StrictStr] site_title: StrictStr = Field( "hyperglass", title="Site Title", @@ -90,10 +82,17 @@ class Params(HyperglassModel): title="Site Keywords", description='Keywords pertaining to your hyperglass site. This field is used to generate `` HTML tags, which helps tremendously with SEO.', ) - request_timeout: StrictInt = Field( - 90, - title="Request Timeout", - description="Global timeout in seconds for all requests. The frontend application (UI) uses this field's exact value when submitting queries. The backend application uses this field's value, minus one second, for its own timeout handling. This is to ensure a contextual timeout error is presented to the end user in the event of a backend application timeout.", + + +class Params(ParamsPublic, HyperglassModel): + """Validation model for all configuration variables.""" + + # Top Level Params + + fake_output: StrictBool = Field( + False, + title="Fake Output", + description="If enabled, the hyperglass backend will return static fake output for development/testing purposes.", ) listen_address: Optional[Union[IPvAnyAddress, Localhost]] = Field( None, @@ -115,7 +114,6 @@ class Params(HyperglassModel): title="Netmiko Delay Factor", description="Override the netmiko global delay factor.", ) - google_analytics: Optional[StrictStr] # Sub Level Params cache: Cache = Cache() @@ -183,3 +181,29 @@ class Params(HyperglassModel): if not isinstance(value, str): value = str(value) return value + + def content_params(self) -> Dict[str, Any]: + """Export content-specific parameters.""" + return self.dict( + include={"primary_asn", "org_name", "site_title", "site_description"} + ) + + def frontend(self) -> Dict[str, Any]: + """Export UI-specific parameters.""" + + return self.dict( + include={ + "cache": {"show_text", "timeout"}, + "debug": ..., + "developer_mode": ..., + "primary_asn": ..., + "request_timeout": ..., + "org_name": ..., + "google_analytics": ..., + "site_title": ..., + "site_description": ..., + "site_keywords": ..., + "web": ..., + "messages": ..., + } + ) diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index 34ff11d..b778a90 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -125,6 +125,13 @@ class Logo(HyperglassModel): height: Optional[Union[StrictInt, Percentage]] +class LogoPublic(Logo): + """Public logo configuration.""" + + light_format: StrictStr + dark_format: StrictStr + + class Text(HyperglassModel): """Validation model for params.branding.text.""" @@ -258,3 +265,9 @@ class Web(HyperglassModel): opengraph: OpenGraph = OpenGraph() text: Text = Text() theme: Theme = Theme() + + +class WebPublic(Web): + """Public web configuration.""" + + logo: LogoPublic diff --git a/hyperglass/models/main.py b/hyperglass/models/main.py index 7e41a21..12eaae1 100644 --- a/hyperglass/models/main.py +++ b/hyperglass/models/main.py @@ -2,12 +2,13 @@ # Standard Library import re +from typing import Type, TypeVar # Third Party from pydantic import HttpUrl, BaseModel -_WEBHOOK_TITLE = "hyperglass received a valid query with the following data" -_ICON_URL = "https://res.cloudinary.com/hyperglass/image/upload/v1593192484/icon.png" +# Project +from hyperglass.util import snake_to_camel def clean_name(_name: str) -> str: @@ -22,11 +23,14 @@ def clean_name(_name: str) -> str: return _scrubbed.lower() +AsUIModel = TypeVar("AsUIModel", bound="BaseModel") + + class HyperglassModel(BaseModel): """Base model for all hyperglass configuration models.""" class Config: - """Default Pydantic configuration. + """Pydantic model configuration. See https://pydantic-docs.helpmanual.io/usage/model_config """ @@ -38,40 +42,28 @@ class HyperglassModel(BaseModel): json_encoders = {HttpUrl: lambda v: str(v)} def export_json(self, *args, **kwargs): - """Return instance as JSON. + """Return instance as JSON.""" - Returns: - {str} -- Stringified JSON. - """ + export_kwargs = {"by_alias": True, "exclude_unset": False} - export_kwargs = { - "by_alias": True, - "exclude_unset": False, - **kwargs, - } + for key in export_kwargs.keys(): + export_kwargs.pop(key, None) - return self.json(*args, **export_kwargs) + return self.json(*args, **export_kwargs, **kwargs) def export_dict(self, *args, **kwargs): - """Return instance as dictionary. + """Return instance as dictionary.""" - Returns: - {dict} -- Python dictionary. - """ - export_kwargs = { - "by_alias": True, - "exclude_unset": False, - **kwargs, - } + export_kwargs = {"by_alias": True, "exclude_unset": False} - return self.dict(*args, **export_kwargs) + for key in export_kwargs.keys(): + export_kwargs.pop(key, None) + + return self.dict(*args, **export_kwargs, **kwargs) def export_yaml(self, *args, **kwargs): - """Return instance as YAML. + """Return instance as YAML.""" - Returns: - {str} -- Stringified YAML. - """ # Standard Library import json @@ -91,9 +83,22 @@ class HyperglassModel(BaseModel): class HyperglassModelExtra(HyperglassModel): """Model for hyperglass configuration models with dynamic fields.""" - pass - class Config: - """Default pydantic configuration.""" + """Pydantic model configuration.""" extra = "allow" + + +class HyperglassUIModel(HyperglassModel): + """Base class for UI configuration parameters.""" + + class Config: + """Pydantic model configuration.""" + + alias_generator = snake_to_camel + allow_population_by_field_name = True + + +def as_ui_model(name: str, model: Type[AsUIModel]) -> Type[AsUIModel]: + """Override a model's configuration to confirm to a UI model.""" + return type(name, (model, HyperglassUIModel), {}) diff --git a/hyperglass/models/ui.py b/hyperglass/models/ui.py new file mode 100644 index 0000000..823e961 --- /dev/null +++ b/hyperglass/models/ui.py @@ -0,0 +1,76 @@ +"""UI Configuration models.""" + +# Standard Library +from typing import Any, Dict, List, Tuple, Union, Literal, Optional + +# Third Party +from pydantic import StrictStr, StrictBool + +# Local +from .main import HyperglassUIModel, as_ui_model +from .config.web import WebPublic +from .config.cache import CachePublic +from .config.params import ParamsPublic +from .config.messages import Messages + +Alignment = Union[Literal["left"], Literal["center"], Literal["right"], None] +StructuredDataField = Tuple[str, str, Alignment] + +CacheUI = as_ui_model("CacheUI", CachePublic) +WebUI = as_ui_model("WebUI", WebPublic) +MessagesUI = as_ui_model("MessagesUI", Messages) + + +class UIDirectiveInfo(HyperglassUIModel): + """UI: Directive Info.""" + + enable: StrictBool + params: Dict[str, str] + content: StrictStr + + +class UIDirective(HyperglassUIModel): + """UI: Directive.""" + + id: StrictStr + name: StrictStr + field_type: StrictStr + groups: List[StrictStr] + description: StrictStr + info: Optional[UIDirectiveInfo] = None + options: Optional[List[Dict[str, Any]]] + + +class UILocation(HyperglassUIModel): + """UI: Location (Device).""" + + id: StrictStr + name: StrictStr + network: StrictStr + directives: List[UIDirective] = [] + + +class UINetwork(HyperglassUIModel): + """UI: Network.""" + + display_name: StrictStr + locations: List[UILocation] = [] + + +class UIContent(HyperglassUIModel): + """UI: Content.""" + + credit: StrictStr + greeting: StrictStr + + +class UIParameters(HyperglassUIModel, ParamsPublic): + """UI Configuration Parameters.""" + + cache: CacheUI + web: WebUI + messages: MessagesUI + version: StrictStr + networks: List[UINetwork] = [] + parsed_data_fields: Tuple[StructuredDataField, ...] + content: UIContent diff --git a/hyperglass/util/frontend.py b/hyperglass/util/frontend.py index e5cf382..68fa0f5 100644 --- a/hyperglass/util/frontend.py +++ b/hyperglass/util/frontend.py @@ -12,6 +12,7 @@ from pathlib import Path # Project from hyperglass.log import log +from hyperglass.models.ui import UIParameters # Local from .files import copyfiles, check_path @@ -221,7 +222,7 @@ def generate_opengraph( return True -def migrate_images(app_path: Path, params: dict): +def migrate_images(app_path: Path, params: UIParameters): """Migrate images from source code to install directory.""" images_dir = app_path / "static" / "images" favicon_dir = images_dir / "favicons" @@ -230,7 +231,7 @@ def migrate_images(app_path: Path, params: dict): dst_files = () for image in ("light", "dark", "favicon"): - src = Path(params["web"]["logo"][image]) + src: Path = getattr(params.web.logo, image) dst = images_dir / f"{image + src.suffix}" src_files += (src,) dst_files += (dst,) @@ -241,7 +242,7 @@ async def build_frontend( # noqa: C901 dev_mode: bool, dev_url: str, prod_url: str, - params: Dict, + params: UIParameters, app_path: Path, force: bool = False, timeout: int = 180, @@ -259,18 +260,6 @@ async def build_frontend( # noqa: C901 After the build is successful, the temporary file is automatically closed during garbage collection. - - Arguments: - dev_mode {bool} -- Development Mode - dev_url {str} -- Development Mode URL - prod_url {str} -- Production Mode URL - params {dict} -- Frontend Config paramters - - Raises: - RuntimeError: Raised if errors occur during build process. - - Returns: - {bool} -- True if successful """ # Standard Library import hashlib @@ -326,7 +315,7 @@ async def build_frontend( # noqa: C901 if not favicon_dir.exists(): favicon_dir.mkdir() async with Favicons( - source=params["web"]["logo"]["favicon"], + source=params.web.logo.favicon, output_directory=favicon_dir, base_url="/images/favicons/", ) as favicons: @@ -334,7 +323,7 @@ async def build_frontend( # noqa: C901 log.debug("Generated {} favicons", favicons.completed) env_vars["hyperglass"].update({"favicons": favicons.formats()}) build_data = { - "params": params, + "params": params.export_dict(), "version": __version__, "package_json": package_json, } @@ -379,11 +368,11 @@ async def build_frontend( # noqa: C901 migrate_images(app_path, params) generate_opengraph( - Path(params["web"]["opengraph"]["image"]), + params.web.opengraph.image, 1200, 630, images_dir, - params["web"]["theme"]["colors"]["black"], + params.web.theme.colors.black, ) except Exception as err: