From ff56b065c8cc33ccf411df9afaf3676090902698 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:37 +1000 Subject: [PATCH 01/39] fix(api): retry transient state-initialization errors --- hyperglass/api/state.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py index f41a96d78..5dfd75ef9 100644 --- a/hyperglass/api/state.py +++ b/hyperglass/api/state.py @@ -1,27 +1,52 @@ """hyperglass state dependencies.""" # Standard Library +import asyncio import typing as t # Project -from hyperglass.state import use_state +from hyperglass.state import HyperglassState, use_state +from hyperglass.exceptions.private import StateError + + +def _is_retryable(attr: t.Optional[str]) -> bool: + """Whether a StateError for this attr is a transient (not-yet-populated) error. + + A request can arrive before the Redis-backed state is populated on startup, + which surfaces as a StateError. Referencing an attribute that does not exist + on HyperglassState is a permanent programming error and must not be retried. + """ + return attr is None or attr in ("cache", "redis") or attr in HyperglassState.properties() + + +async def _get_state_with_retry( + attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5 +) -> t.Any: + """Get hyperglass state, retrying transient startup StateErrors.""" + for attempt in range(1, max_retries + 1): + try: + return use_state(attr) + except StateError: + if not _is_retryable(attr) or attempt == max_retries: + raise + await asyncio.sleep(retry_delay) async def get_state(attr: t.Optional[str] = None): """Get hyperglass state as a FastAPI dependency.""" - return use_state(attr) + return await _get_state_with_retry(attr) async def get_params(): """Get hyperglass params as FastAPI dependency.""" - return use_state("params") + return await _get_state_with_retry("params") async def get_devices(): """Get hyperglass devices as FastAPI dependency.""" - return use_state("devices") + return await _get_state_with_retry("devices") async def get_ui_params(): """Get hyperglass ui_params as FastAPI dependency.""" - return use_state("ui_params") + return await _get_state_with_retry("ui_params") From a487f685c337f8bababe948202ffb2bcd69b6c9a Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:38 +1000 Subject: [PATCH 02/39] feat(rpki): support pluggable external RPKI backends --- .../config/structured-output.mdx | 23 +++++-- hyperglass/api/state.py | 35 +++++++++-- hyperglass/external/rpki.py | 62 +++++++++++++------ hyperglass/external/tests/test_rpki.py | 8 +-- hyperglass/models/config/structured.py | 15 +++++ hyperglass/models/data/bgp_route.py | 24 ++++--- 6 files changed, 126 insertions(+), 41 deletions(-) diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx index df936253f..c90e39137 100644 --- a/docs/pages/configuration/config/structured-output.mdx +++ b/docs/pages/configuration/config/structured-output.mdx @@ -8,13 +8,17 @@ Devices that support responding to a query with structured or easily parsable da When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods: 1. From the router's perspective -2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) +2. From an external validator — either [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) (the default) or a self-hosted [Routinator](https://routinator.docs.nlnetlabs.nl/) instance + +When `structured.rpki.mode` is `external`, the `structured.rpki.backend` field selects which validator to query. The `cloudflare` backend uses Cloudflare's GraphQL API; the `routinator` backend queries the `/validity` HTTP API of a Routinator instance at `structured.rpki.rpki_server_url`. Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user. | Parameter | Type | Default Value | Description | | :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- | -| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use Cloudflare's view (2 above). | +| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use an external validator (2 above). | +| `structured.rpki.backend` | String | cloudflare | When `mode` is `external`, the validator to use: `cloudflare` or `routinator`. | +| `structured.rpki.rpki_server_url` | String | | Base URL of the Routinator instance. **Required** when `backend` is `routinator` (e.g. `http://routinator.example.net:8323`). | | `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. | | `structured.communities.items` | List of Strings | | List of communities to match. | @@ -28,12 +32,23 @@ structured: mode: router ``` -#### Show RPKI State from a Public/External Perspective +#### Show RPKI State from a Public/External Perspective (Cloudflare) -```yaml filename="config.yaml" copy {2} +```yaml filename="config.yaml" copy {2-3} +structured: + rpki: + mode: external + backend: cloudflare +``` + +#### Validate RPKI State Against a Self-Hosted Routinator + +```yaml filename="config.yaml" copy {2-5} structured: rpki: mode: external + backend: routinator + rpki_server_url: "http://routinator.example.net:8323" ``` ### Community Filtering Examples diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py index f41a96d78..5dfd75ef9 100644 --- a/hyperglass/api/state.py +++ b/hyperglass/api/state.py @@ -1,27 +1,52 @@ """hyperglass state dependencies.""" # Standard Library +import asyncio import typing as t # Project -from hyperglass.state import use_state +from hyperglass.state import HyperglassState, use_state +from hyperglass.exceptions.private import StateError + + +def _is_retryable(attr: t.Optional[str]) -> bool: + """Whether a StateError for this attr is a transient (not-yet-populated) error. + + A request can arrive before the Redis-backed state is populated on startup, + which surfaces as a StateError. Referencing an attribute that does not exist + on HyperglassState is a permanent programming error and must not be retried. + """ + return attr is None or attr in ("cache", "redis") or attr in HyperglassState.properties() + + +async def _get_state_with_retry( + attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5 +) -> t.Any: + """Get hyperglass state, retrying transient startup StateErrors.""" + for attempt in range(1, max_retries + 1): + try: + return use_state(attr) + except StateError: + if not _is_retryable(attr) or attempt == max_retries: + raise + await asyncio.sleep(retry_delay) async def get_state(attr: t.Optional[str] = None): """Get hyperglass state as a FastAPI dependency.""" - return use_state(attr) + return await _get_state_with_retry(attr) async def get_params(): """Get hyperglass params as FastAPI dependency.""" - return use_state("params") + return await _get_state_with_retry("params") async def get_devices(): """Get hyperglass devices as FastAPI dependency.""" - return use_state("devices") + return await _get_state_with_retry("devices") async def get_ui_params(): """Get hyperglass ui_params as FastAPI dependency.""" - return use_state("ui_params") + return await _get_state_with_retry("ui_params") diff --git a/hyperglass/external/rpki.py b/hyperglass/external/rpki.py index 139ccf8fc..a5c480513 100644 --- a/hyperglass/external/rpki.py +++ b/hyperglass/external/rpki.py @@ -3,57 +3,83 @@ # Standard Library import typing as t +# Third Party +import httpx + # Project from hyperglass.log import log from hyperglass.state import use_state from hyperglass.external._base import BaseExternal if t.TYPE_CHECKING: - # Standard Library from ipaddress import IPv4Address, IPv6Address -RPKI_STATE_MAP = {"Invalid": 0, "Valid": 1, "NotFound": 2, "DEFAULT": 3} -RPKI_NAME_MAP = {v: k for k, v in RPKI_STATE_MAP.items()} +# Maps normalized (lower-case, separators stripped) backend state names to the +# integer states hyperglass uses internally. +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "notfound": 2, + "unknown": 2, + "default": 3, +} +# Canonical integer -> display name, kept stable for logging. +RPKI_NAME_MAP = {0: "Invalid", 1: "Valid", 2: "NotFound", 3: "DEFAULT"} CACHE_KEY = "hyperglass.external.rpki" -def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, str]) -> int: +def _normalize_state(value: str) -> int: + """Normalize a backend RPKI state string to an internal integer state.""" + key = str(value).strip().lower().replace("-", "").replace("_", "") + return RPKI_STATE_MAP.get(key, 3) + + +def rpki_state( + prefix: t.Union["IPv4Address", "IPv6Address", str], + asn: t.Union[int, str], + backend: str = "cloudflare", + rpki_server_url: str = "", +) -> int: """Get RPKI state and map to expected integer.""" _log = log.bind(prefix=prefix, asn=asn) _log.debug("Validating RPKI State") cache = use_state("cache") - state = 3 ro = f"{prefix!s}@{asn!s}" cached = cache.get_map(CACHE_KEY, ro) - if cached is not None: state = cached else: - ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' - query = ql.format(prefix, asn) - _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") try: - with BaseExternal(base_url="https://rpki.cloudflare.com") as client: - response = client._post("/api/graphql", data={"query": query}) - try: + if backend == "cloudflare": + ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' + query = ql.format(prefix, asn) + _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") + with BaseExternal(base_url="https://rpki.cloudflare.com") as client: + response = client._post("/api/graphql", data={"query": query}) validation_state = response["data"]["validation"]["state"] - except KeyError as missing: - _log.error("Response from Cloudflare missing key '{}': {!r}", missing, response) - validation_state = 3 + elif backend == "routinator": + url = f"{rpki_server_url.rstrip('/')}/validity" + _log.bind(url=url).debug("Routinator RPKI HTTP Query") + response = httpx.get( + url, params={"asn": str(asn), "prefix": str(prefix)}, timeout=5 + ) + response.raise_for_status() + data = response.json() + validation_state = data["validated_route"]["validity"]["state"] + else: + raise ValueError(f"Unknown RPKI backend: {backend}") - state = RPKI_STATE_MAP[validation_state] + state = _normalize_state(validation_state) cache.set_map_item(CACHE_KEY, ro, state) except Exception as err: log.error(err) - # Don't cache the state when an error produced it. state = 3 msg = "RPKI Validation State for {} via AS{} is {}".format(prefix, asn, RPKI_NAME_MAP[state]) if cached is not None: msg += " [CACHED]" - log.debug(msg) return state diff --git a/hyperglass/external/tests/test_rpki.py b/hyperglass/external/tests/test_rpki.py index 01438e717..d21d50559 100644 --- a/hyperglass/external/tests/test_rpki.py +++ b/hyperglass/external/tests/test_rpki.py @@ -19,8 +19,8 @@ def test_rpki(): result = rpki_state(prefix, asn) result_name = RPKI_NAME_MAP.get(result, "No Name") expected_name = RPKI_NAME_MAP.get(expected, "No Name") - assert result == expected, ( - "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( - prefix, asn, result, result_name, expected, expected_name - ) + assert ( + result == expected + ), "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( + prefix, asn, result, result_name, expected, expected_name ) diff --git a/hyperglass/models/config/structured.py b/hyperglass/models/config/structured.py index 4373e8187..245423c5f 100644 --- a/hyperglass/models/config/structured.py +++ b/hyperglass/models/config/structured.py @@ -3,11 +3,15 @@ # Standard Library import typing as t +# Third Party +from pydantic import model_validator + # Local from ..main import HyperglassModel StructuredCommunityMode = t.Literal["permit", "deny"] StructuredRPKIMode = t.Literal["router", "external"] +StructuredRPKIBackend = t.Literal["cloudflare", "routinator"] class StructuredCommunities(HyperglassModel): @@ -21,6 +25,17 @@ class StructuredRpki(HyperglassModel): """Control structured data response for RPKI state.""" mode: StructuredRPKIMode = "router" + backend: StructuredRPKIBackend = "cloudflare" + rpki_server_url: str = "" + + @model_validator(mode="after") + def validate_routinator_url(self) -> "StructuredRpki": + """Require a server URL when the routinator backend is selected.""" + if self.backend == "routinator" and not self.rpki_server_url: + raise ValueError( + "structured.rpki.rpki_server_url is required when backend is 'routinator'" + ) + return self class Structured(HyperglassModel): diff --git a/hyperglass/models/data/bgp_route.py b/hyperglass/models/data/bgp_route.py index a00af4e13..55381b831 100644 --- a/hyperglass/models/data/bgp_route.py +++ b/hyperglass/models/data/bgp_route.py @@ -80,10 +80,9 @@ def validate_rpki_state(cls, value, info: ValidationInfo): return value if structured.rpki.mode == "external": - # If external validation is enabled, validate the prefix - # & asn with Cloudflare's RPKI API. + # If external validation is enabled, validate the prefix & asn + # with the configured RPKI backend. as_path = info.data.get("as_path", []) - if len(as_path) == 0: # If the AS_PATH length is 0, i.e. for an internal route, # return RPKI Unknown state. @@ -91,14 +90,19 @@ def validate_rpki_state(cls, value, info: ValidationInfo): # Get last ASN in path asn = as_path[-1] - try: - net = ip_network(info.data["prefix"]) - except ValueError: - return 3 + try: + net = ip_network(info.data["prefix"]) + except ValueError: + return 3 - # Only do external RPKI lookups for global prefixes. - if net.is_global: - return rpki_state(prefix=info.data["prefix"], asn=asn) + # Only do external RPKI lookups for global prefixes. + if net.is_global: + return rpki_state( + prefix=info.data["prefix"], + asn=asn, + backend=structured.rpki.backend, + rpki_server_url=structured.rpki.rpki_server_url, + ) return value From 8738ff354c3007a64cbb53f82e07559616a7f04a Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:39 +1000 Subject: [PATCH 03/39] feat(structured): add 'name' community mode for friendly community names --- .../config/structured-output.mdx | 41 ++++++++++-- hyperglass/api/state.py | 35 +++++++++-- hyperglass/external/rpki.py | 62 +++++++++++++------ hyperglass/external/tests/test_rpki.py | 8 +-- hyperglass/models/config/structured.py | 28 ++++++++- hyperglass/models/data/bgp_route.py | 34 +++++++--- 6 files changed, 164 insertions(+), 44 deletions(-) diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx index df936253f..aded8ed8b 100644 --- a/docs/pages/configuration/config/structured-output.mdx +++ b/docs/pages/configuration/config/structured-output.mdx @@ -8,15 +8,20 @@ Devices that support responding to a query with structured or easily parsable da When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods: 1. From the router's perspective -2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) +2. From an external validator — either [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) (the default) or a self-hosted [Routinator](https://routinator.docs.nlnetlabs.nl/) instance + +When `structured.rpki.mode` is `external`, the `structured.rpki.backend` field selects which validator to query. The `cloudflare` backend uses Cloudflare's GraphQL API; the `routinator` backend queries the `/validity` HTTP API of a Routinator instance at `structured.rpki.rpki_server_url`. Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user. | Parameter | Type | Default Value | Description | | :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- | -| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use Cloudflare's view (2 above). | -| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. | -| `structured.communities.items` | List of Strings | | List of communities to match. | +| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use an external validator (2 above). | +| `structured.rpki.backend` | String | cloudflare | When `mode` is `external`, the validator to use: `cloudflare` or `routinator`. | +| `structured.rpki.rpki_server_url` | String | | Base URL of the Routinator instance. **Required** when `backend` is `routinator` (e.g. `http://routinator.example.net:8323`). | +| `structured.communities.mode` | String | deny | `deny` hides communities listed in `items`; `permit` shows _only_ those listed; `name` shows all communities and appends a friendly label to any listed in `names`. | +| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny`/`permit`). | +| `structured.communities.names` | Map of String→String | | Map of community → friendly name, used by `name` mode. At least one entry is required when `mode` is `name`. | ### RPKI Examples @@ -28,12 +33,23 @@ structured: mode: router ``` -#### Show RPKI State from a Public/External Perspective +#### Show RPKI State from a Public/External Perspective (Cloudflare) -```yaml filename="config.yaml" copy {2} +```yaml filename="config.yaml" copy {2-3} +structured: + rpki: + mode: external + backend: cloudflare +``` + +#### Validate RPKI State Against a Self-Hosted Routinator + +```yaml filename="config.yaml" copy {2-5} structured: rpki: mode: external + backend: routinator + rpki_server_url: "http://routinator.example.net:8323" ``` ### Community Filtering Examples @@ -59,3 +75,16 @@ structured: - "^65000:.*$" # permit any communities starting with 65000, but no others. - "1234:1" # permit only the 1234:1 community. ``` + +#### Show Friendly Names Alongside Communities + +With `mode: name`, all communities are shown, and any community listed in `names` has its friendly label appended (rendered as ` ()` in the UI). + +```yaml filename="config.yaml" {3-6} +structured: + communities: + mode: name + names: + "65000:100": "Customer Route" + "65000:200": "Peer Route" +``` diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py index f41a96d78..5dfd75ef9 100644 --- a/hyperglass/api/state.py +++ b/hyperglass/api/state.py @@ -1,27 +1,52 @@ """hyperglass state dependencies.""" # Standard Library +import asyncio import typing as t # Project -from hyperglass.state import use_state +from hyperglass.state import HyperglassState, use_state +from hyperglass.exceptions.private import StateError + + +def _is_retryable(attr: t.Optional[str]) -> bool: + """Whether a StateError for this attr is a transient (not-yet-populated) error. + + A request can arrive before the Redis-backed state is populated on startup, + which surfaces as a StateError. Referencing an attribute that does not exist + on HyperglassState is a permanent programming error and must not be retried. + """ + return attr is None or attr in ("cache", "redis") or attr in HyperglassState.properties() + + +async def _get_state_with_retry( + attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5 +) -> t.Any: + """Get hyperglass state, retrying transient startup StateErrors.""" + for attempt in range(1, max_retries + 1): + try: + return use_state(attr) + except StateError: + if not _is_retryable(attr) or attempt == max_retries: + raise + await asyncio.sleep(retry_delay) async def get_state(attr: t.Optional[str] = None): """Get hyperglass state as a FastAPI dependency.""" - return use_state(attr) + return await _get_state_with_retry(attr) async def get_params(): """Get hyperglass params as FastAPI dependency.""" - return use_state("params") + return await _get_state_with_retry("params") async def get_devices(): """Get hyperglass devices as FastAPI dependency.""" - return use_state("devices") + return await _get_state_with_retry("devices") async def get_ui_params(): """Get hyperglass ui_params as FastAPI dependency.""" - return use_state("ui_params") + return await _get_state_with_retry("ui_params") diff --git a/hyperglass/external/rpki.py b/hyperglass/external/rpki.py index 139ccf8fc..a5c480513 100644 --- a/hyperglass/external/rpki.py +++ b/hyperglass/external/rpki.py @@ -3,57 +3,83 @@ # Standard Library import typing as t +# Third Party +import httpx + # Project from hyperglass.log import log from hyperglass.state import use_state from hyperglass.external._base import BaseExternal if t.TYPE_CHECKING: - # Standard Library from ipaddress import IPv4Address, IPv6Address -RPKI_STATE_MAP = {"Invalid": 0, "Valid": 1, "NotFound": 2, "DEFAULT": 3} -RPKI_NAME_MAP = {v: k for k, v in RPKI_STATE_MAP.items()} +# Maps normalized (lower-case, separators stripped) backend state names to the +# integer states hyperglass uses internally. +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "notfound": 2, + "unknown": 2, + "default": 3, +} +# Canonical integer -> display name, kept stable for logging. +RPKI_NAME_MAP = {0: "Invalid", 1: "Valid", 2: "NotFound", 3: "DEFAULT"} CACHE_KEY = "hyperglass.external.rpki" -def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, str]) -> int: +def _normalize_state(value: str) -> int: + """Normalize a backend RPKI state string to an internal integer state.""" + key = str(value).strip().lower().replace("-", "").replace("_", "") + return RPKI_STATE_MAP.get(key, 3) + + +def rpki_state( + prefix: t.Union["IPv4Address", "IPv6Address", str], + asn: t.Union[int, str], + backend: str = "cloudflare", + rpki_server_url: str = "", +) -> int: """Get RPKI state and map to expected integer.""" _log = log.bind(prefix=prefix, asn=asn) _log.debug("Validating RPKI State") cache = use_state("cache") - state = 3 ro = f"{prefix!s}@{asn!s}" cached = cache.get_map(CACHE_KEY, ro) - if cached is not None: state = cached else: - ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' - query = ql.format(prefix, asn) - _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") try: - with BaseExternal(base_url="https://rpki.cloudflare.com") as client: - response = client._post("/api/graphql", data={"query": query}) - try: + if backend == "cloudflare": + ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' + query = ql.format(prefix, asn) + _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") + with BaseExternal(base_url="https://rpki.cloudflare.com") as client: + response = client._post("/api/graphql", data={"query": query}) validation_state = response["data"]["validation"]["state"] - except KeyError as missing: - _log.error("Response from Cloudflare missing key '{}': {!r}", missing, response) - validation_state = 3 + elif backend == "routinator": + url = f"{rpki_server_url.rstrip('/')}/validity" + _log.bind(url=url).debug("Routinator RPKI HTTP Query") + response = httpx.get( + url, params={"asn": str(asn), "prefix": str(prefix)}, timeout=5 + ) + response.raise_for_status() + data = response.json() + validation_state = data["validated_route"]["validity"]["state"] + else: + raise ValueError(f"Unknown RPKI backend: {backend}") - state = RPKI_STATE_MAP[validation_state] + state = _normalize_state(validation_state) cache.set_map_item(CACHE_KEY, ro, state) except Exception as err: log.error(err) - # Don't cache the state when an error produced it. state = 3 msg = "RPKI Validation State for {} via AS{} is {}".format(prefix, asn, RPKI_NAME_MAP[state]) if cached is not None: msg += " [CACHED]" - log.debug(msg) return state diff --git a/hyperglass/external/tests/test_rpki.py b/hyperglass/external/tests/test_rpki.py index 01438e717..d21d50559 100644 --- a/hyperglass/external/tests/test_rpki.py +++ b/hyperglass/external/tests/test_rpki.py @@ -19,8 +19,8 @@ def test_rpki(): result = rpki_state(prefix, asn) result_name = RPKI_NAME_MAP.get(result, "No Name") expected_name = RPKI_NAME_MAP.get(expected, "No Name") - assert result == expected, ( - "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( - prefix, asn, result, result_name, expected, expected_name - ) + assert ( + result == expected + ), "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( + prefix, asn, result, result_name, expected, expected_name ) diff --git a/hyperglass/models/config/structured.py b/hyperglass/models/config/structured.py index 4373e8187..d672ab82e 100644 --- a/hyperglass/models/config/structured.py +++ b/hyperglass/models/config/structured.py @@ -3,11 +3,15 @@ # Standard Library import typing as t +# Third Party +from pydantic import ValidationInfo, field_validator, model_validator + # Local from ..main import HyperglassModel -StructuredCommunityMode = t.Literal["permit", "deny"] +StructuredCommunityMode = t.Literal["permit", "deny", "name"] StructuredRPKIMode = t.Literal["router", "external"] +StructuredRPKIBackend = t.Literal["cloudflare", "routinator"] class StructuredCommunities(HyperglassModel): @@ -15,12 +19,34 @@ class StructuredCommunities(HyperglassModel): mode: StructuredCommunityMode = "deny" items: t.List[str] = [] + names: t.Dict[str, str] = {} + + @field_validator("names") + def validate_names(cls, value: t.Dict[str, str], info: ValidationInfo) -> t.Dict[str, str]: + """Require at least one community mapping when mode is 'name'.""" + if info.data and info.data.get("mode") == "name" and not value: + raise ValueError( + "When using mode 'name', at least one community mapping must be " + "provided in 'names'" + ) + return value class StructuredRpki(HyperglassModel): """Control structured data response for RPKI state.""" mode: StructuredRPKIMode = "router" + backend: StructuredRPKIBackend = "cloudflare" + rpki_server_url: str = "" + + @model_validator(mode="after") + def validate_routinator_url(self) -> "StructuredRpki": + """Require a server URL when the routinator backend is selected.""" + if self.backend == "routinator" and not self.rpki_server_url: + raise ValueError( + "structured.rpki.rpki_server_url is required when backend is 'routinator'" + ) + return self class Structured(HyperglassModel): diff --git a/hyperglass/models/data/bgp_route.py b/hyperglass/models/data/bgp_route.py index a00af4e13..a0a624680 100644 --- a/hyperglass/models/data/bgp_route.py +++ b/hyperglass/models/data/bgp_route.py @@ -42,6 +42,7 @@ def validate_communities(cls, value): Actions: permit: only permit matches deny: only deny matches + name: append friendly names to matching communities """ (structured := use_state("params").structured) @@ -64,6 +65,15 @@ def _deny(comm): break return valid + def _name(comm): + """Append a friendly name to a community when one is mapped.""" + if comm in structured.communities.names: + return f"{comm},{structured.communities.names[comm]}" + return comm + + if structured.communities.mode == "name": + return [_name(c) for c in value] + func_map = {"permit": _permit, "deny": _deny} func = func_map[structured.communities.mode] @@ -80,10 +90,9 @@ def validate_rpki_state(cls, value, info: ValidationInfo): return value if structured.rpki.mode == "external": - # If external validation is enabled, validate the prefix - # & asn with Cloudflare's RPKI API. + # If external validation is enabled, validate the prefix & asn + # with the configured RPKI backend. as_path = info.data.get("as_path", []) - if len(as_path) == 0: # If the AS_PATH length is 0, i.e. for an internal route, # return RPKI Unknown state. @@ -91,14 +100,19 @@ def validate_rpki_state(cls, value, info: ValidationInfo): # Get last ASN in path asn = as_path[-1] - try: - net = ip_network(info.data["prefix"]) - except ValueError: - return 3 + try: + net = ip_network(info.data["prefix"]) + except ValueError: + return 3 - # Only do external RPKI lookups for global prefixes. - if net.is_global: - return rpki_state(prefix=info.data["prefix"], asn=asn) + # Only do external RPKI lookups for global prefixes. + if net.is_global: + return rpki_state( + prefix=info.data["prefix"], + asn=asn, + backend=structured.rpki.backend, + rpki_server_url=structured.rpki.rpki_server_url, + ) return value From 8648bad368e415bc0426ee2ca1aa0cdbad38bc62 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:39 +1000 Subject: [PATCH 04/39] feat(huawei): structured BGP route output --- .../config/structured-output.mdx | 42 +- hyperglass/api/state.py | 35 +- hyperglass/constants.py | 2 +- hyperglass/defaults/directives/huawei.py | 63 ++- hyperglass/external/rpki.py | 62 ++- hyperglass/external/tests/test_rpki.py | 8 +- hyperglass/models/config/structured.py | 28 +- hyperglass/models/data/bgp_route.py | 34 +- hyperglass/models/parsing/huawei.py | 394 ++++++++++++++++++ hyperglass/plugins/_builtin/__init__.py | 2 + .../plugins/_builtin/bgp_route_huawei.py | 25 +- .../plugins/_builtin/bgp_routestr_huawei.py | 80 ++++ .../plugins/tests/test_bgp_route_huawei.py | 75 ++++ 13 files changed, 798 insertions(+), 52 deletions(-) create mode 100644 hyperglass/models/parsing/huawei.py create mode 100644 hyperglass/plugins/_builtin/bgp_routestr_huawei.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_huawei.py diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx index df936253f..89c560a64 100644 --- a/docs/pages/configuration/config/structured-output.mdx +++ b/docs/pages/configuration/config/structured-output.mdx @@ -4,19 +4,25 @@ Devices that support responding to a query with structured or easily parsable da - Arista EOS - Juniper Junos +- Huawei VRP When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods: 1. From the router's perspective -2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) +2. From an external validator — either [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) (the default) or a self-hosted [Routinator](https://routinator.docs.nlnetlabs.nl/) instance + +When `structured.rpki.mode` is `external`, the `structured.rpki.backend` field selects which validator to query. The `cloudflare` backend uses Cloudflare's GraphQL API; the `routinator` backend queries the `/validity` HTTP API of a Routinator instance at `structured.rpki.rpki_server_url`. Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user. | Parameter | Type | Default Value | Description | | :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- | -| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use Cloudflare's view (2 above). | -| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. | -| `structured.communities.items` | List of Strings | | List of communities to match. | +| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use an external validator (2 above). | +| `structured.rpki.backend` | String | cloudflare | When `mode` is `external`, the validator to use: `cloudflare` or `routinator`. | +| `structured.rpki.rpki_server_url` | String | | Base URL of the Routinator instance. **Required** when `backend` is `routinator` (e.g. `http://routinator.example.net:8323`). | +| `structured.communities.mode` | String | deny | `deny` hides communities listed in `items`; `permit` shows _only_ those listed; `name` shows all communities and appends a friendly label to any listed in `names`. | +| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny`/`permit`). | +| `structured.communities.names` | Map of String→String | | Map of community → friendly name, used by `name` mode. At least one entry is required when `mode` is `name`. | ### RPKI Examples @@ -28,12 +34,23 @@ structured: mode: router ``` -#### Show RPKI State from a Public/External Perspective +#### Show RPKI State from a Public/External Perspective (Cloudflare) -```yaml filename="config.yaml" copy {2} +```yaml filename="config.yaml" copy {2-3} +structured: + rpki: + mode: external + backend: cloudflare +``` + +#### Validate RPKI State Against a Self-Hosted Routinator + +```yaml filename="config.yaml" copy {2-5} structured: rpki: mode: external + backend: routinator + rpki_server_url: "http://routinator.example.net:8323" ``` ### Community Filtering Examples @@ -59,3 +76,16 @@ structured: - "^65000:.*$" # permit any communities starting with 65000, but no others. - "1234:1" # permit only the 1234:1 community. ``` + +#### Show Friendly Names Alongside Communities + +With `mode: name`, all communities are shown, and any community listed in `names` has its friendly label appended (rendered as ` ()` in the UI). + +```yaml filename="config.yaml" {3-6} +structured: + communities: + mode: name + names: + "65000:100": "Customer Route" + "65000:200": "Peer Route" +``` diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py index f41a96d78..5dfd75ef9 100644 --- a/hyperglass/api/state.py +++ b/hyperglass/api/state.py @@ -1,27 +1,52 @@ """hyperglass state dependencies.""" # Standard Library +import asyncio import typing as t # Project -from hyperglass.state import use_state +from hyperglass.state import HyperglassState, use_state +from hyperglass.exceptions.private import StateError + + +def _is_retryable(attr: t.Optional[str]) -> bool: + """Whether a StateError for this attr is a transient (not-yet-populated) error. + + A request can arrive before the Redis-backed state is populated on startup, + which surfaces as a StateError. Referencing an attribute that does not exist + on HyperglassState is a permanent programming error and must not be retried. + """ + return attr is None or attr in ("cache", "redis") or attr in HyperglassState.properties() + + +async def _get_state_with_retry( + attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5 +) -> t.Any: + """Get hyperglass state, retrying transient startup StateErrors.""" + for attempt in range(1, max_retries + 1): + try: + return use_state(attr) + except StateError: + if not _is_retryable(attr) or attempt == max_retries: + raise + await asyncio.sleep(retry_delay) async def get_state(attr: t.Optional[str] = None): """Get hyperglass state as a FastAPI dependency.""" - return use_state(attr) + return await _get_state_with_retry(attr) async def get_params(): """Get hyperglass params as FastAPI dependency.""" - return use_state("params") + return await _get_state_with_retry("params") async def get_devices(): """Get hyperglass devices as FastAPI dependency.""" - return use_state("devices") + return await _get_state_with_retry("devices") async def get_ui_params(): """Get hyperglass ui_params as FastAPI dependency.""" - return use_state("ui_params") + return await _get_state_with_retry("ui_params") diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 0139c7415..1886ceb68 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -19,7 +19,7 @@ TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos") -SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos") +SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos", "huawei") CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml") diff --git a/hyperglass/defaults/directives/huawei.py b/hyperglass/defaults/directives/huawei.py index f8d3ef0cf..995a42c6c 100644 --- a/hyperglass/defaults/directives/huawei.py +++ b/hyperglass/defaults/directives/huawei.py @@ -15,6 +15,9 @@ "Huawei_BGPRoute", "Huawei_Ping", "Huawei_Traceroute", + "HuaweiBGPRouteTable", + "HuaweiBGPASPathTable", + "HuaweiBGPCommunityTable", ) NAME = "Huawei VRP" @@ -36,7 +39,8 @@ ), ], field=Text(description="IP Address, Prefix, or Hostname"), - plugins=["bgp_route_huawei"], + plugins=["bgp_route_huawei", "bgp_routestr_huawei"], + table_output="__hyperglass_huawei_bgp_route_table__", platforms=PLATFORMS, ) @@ -54,6 +58,7 @@ ) ], field=Text(description="AS Path Regular Expression"), + table_output="__hyperglass_huawei_bgp_aspath_table__", platforms=PLATFORMS, ) @@ -71,6 +76,7 @@ ) ], field=Text(description="BGP Community String"), + table_output="__hyperglass_huawei_bgp_community_table__", platforms=PLATFORMS, ) @@ -111,3 +117,58 @@ field=Text(description="IP Address, Prefix, or Hostname"), platforms=PLATFORMS, ) + +# Table Output Directives + +HuaweiBGPRouteTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="display bgp routing-table {target} | no-more", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="display bgp ipv6 routing-table {target} | no-more", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +HuaweiBGPASPathTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table regular-expression "{target}"', + 'display bgp ipv6 routing-table regular-expression "{target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +HuaweiBGPCommunityTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_community_table__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table community "{target}"', + 'display bgp ipv6 routing-table community "{target}"', + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) diff --git a/hyperglass/external/rpki.py b/hyperglass/external/rpki.py index 139ccf8fc..a5c480513 100644 --- a/hyperglass/external/rpki.py +++ b/hyperglass/external/rpki.py @@ -3,57 +3,83 @@ # Standard Library import typing as t +# Third Party +import httpx + # Project from hyperglass.log import log from hyperglass.state import use_state from hyperglass.external._base import BaseExternal if t.TYPE_CHECKING: - # Standard Library from ipaddress import IPv4Address, IPv6Address -RPKI_STATE_MAP = {"Invalid": 0, "Valid": 1, "NotFound": 2, "DEFAULT": 3} -RPKI_NAME_MAP = {v: k for k, v in RPKI_STATE_MAP.items()} +# Maps normalized (lower-case, separators stripped) backend state names to the +# integer states hyperglass uses internally. +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "notfound": 2, + "unknown": 2, + "default": 3, +} +# Canonical integer -> display name, kept stable for logging. +RPKI_NAME_MAP = {0: "Invalid", 1: "Valid", 2: "NotFound", 3: "DEFAULT"} CACHE_KEY = "hyperglass.external.rpki" -def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, str]) -> int: +def _normalize_state(value: str) -> int: + """Normalize a backend RPKI state string to an internal integer state.""" + key = str(value).strip().lower().replace("-", "").replace("_", "") + return RPKI_STATE_MAP.get(key, 3) + + +def rpki_state( + prefix: t.Union["IPv4Address", "IPv6Address", str], + asn: t.Union[int, str], + backend: str = "cloudflare", + rpki_server_url: str = "", +) -> int: """Get RPKI state and map to expected integer.""" _log = log.bind(prefix=prefix, asn=asn) _log.debug("Validating RPKI State") cache = use_state("cache") - state = 3 ro = f"{prefix!s}@{asn!s}" cached = cache.get_map(CACHE_KEY, ro) - if cached is not None: state = cached else: - ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' - query = ql.format(prefix, asn) - _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") try: - with BaseExternal(base_url="https://rpki.cloudflare.com") as client: - response = client._post("/api/graphql", data={"query": query}) - try: + if backend == "cloudflare": + ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' + query = ql.format(prefix, asn) + _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") + with BaseExternal(base_url="https://rpki.cloudflare.com") as client: + response = client._post("/api/graphql", data={"query": query}) validation_state = response["data"]["validation"]["state"] - except KeyError as missing: - _log.error("Response from Cloudflare missing key '{}': {!r}", missing, response) - validation_state = 3 + elif backend == "routinator": + url = f"{rpki_server_url.rstrip('/')}/validity" + _log.bind(url=url).debug("Routinator RPKI HTTP Query") + response = httpx.get( + url, params={"asn": str(asn), "prefix": str(prefix)}, timeout=5 + ) + response.raise_for_status() + data = response.json() + validation_state = data["validated_route"]["validity"]["state"] + else: + raise ValueError(f"Unknown RPKI backend: {backend}") - state = RPKI_STATE_MAP[validation_state] + state = _normalize_state(validation_state) cache.set_map_item(CACHE_KEY, ro, state) except Exception as err: log.error(err) - # Don't cache the state when an error produced it. state = 3 msg = "RPKI Validation State for {} via AS{} is {}".format(prefix, asn, RPKI_NAME_MAP[state]) if cached is not None: msg += " [CACHED]" - log.debug(msg) return state diff --git a/hyperglass/external/tests/test_rpki.py b/hyperglass/external/tests/test_rpki.py index 01438e717..d21d50559 100644 --- a/hyperglass/external/tests/test_rpki.py +++ b/hyperglass/external/tests/test_rpki.py @@ -19,8 +19,8 @@ def test_rpki(): result = rpki_state(prefix, asn) result_name = RPKI_NAME_MAP.get(result, "No Name") expected_name = RPKI_NAME_MAP.get(expected, "No Name") - assert result == expected, ( - "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( - prefix, asn, result, result_name, expected, expected_name - ) + assert ( + result == expected + ), "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( + prefix, asn, result, result_name, expected, expected_name ) diff --git a/hyperglass/models/config/structured.py b/hyperglass/models/config/structured.py index 4373e8187..d672ab82e 100644 --- a/hyperglass/models/config/structured.py +++ b/hyperglass/models/config/structured.py @@ -3,11 +3,15 @@ # Standard Library import typing as t +# Third Party +from pydantic import ValidationInfo, field_validator, model_validator + # Local from ..main import HyperglassModel -StructuredCommunityMode = t.Literal["permit", "deny"] +StructuredCommunityMode = t.Literal["permit", "deny", "name"] StructuredRPKIMode = t.Literal["router", "external"] +StructuredRPKIBackend = t.Literal["cloudflare", "routinator"] class StructuredCommunities(HyperglassModel): @@ -15,12 +19,34 @@ class StructuredCommunities(HyperglassModel): mode: StructuredCommunityMode = "deny" items: t.List[str] = [] + names: t.Dict[str, str] = {} + + @field_validator("names") + def validate_names(cls, value: t.Dict[str, str], info: ValidationInfo) -> t.Dict[str, str]: + """Require at least one community mapping when mode is 'name'.""" + if info.data and info.data.get("mode") == "name" and not value: + raise ValueError( + "When using mode 'name', at least one community mapping must be " + "provided in 'names'" + ) + return value class StructuredRpki(HyperglassModel): """Control structured data response for RPKI state.""" mode: StructuredRPKIMode = "router" + backend: StructuredRPKIBackend = "cloudflare" + rpki_server_url: str = "" + + @model_validator(mode="after") + def validate_routinator_url(self) -> "StructuredRpki": + """Require a server URL when the routinator backend is selected.""" + if self.backend == "routinator" and not self.rpki_server_url: + raise ValueError( + "structured.rpki.rpki_server_url is required when backend is 'routinator'" + ) + return self class Structured(HyperglassModel): diff --git a/hyperglass/models/data/bgp_route.py b/hyperglass/models/data/bgp_route.py index a00af4e13..a0a624680 100644 --- a/hyperglass/models/data/bgp_route.py +++ b/hyperglass/models/data/bgp_route.py @@ -42,6 +42,7 @@ def validate_communities(cls, value): Actions: permit: only permit matches deny: only deny matches + name: append friendly names to matching communities """ (structured := use_state("params").structured) @@ -64,6 +65,15 @@ def _deny(comm): break return valid + def _name(comm): + """Append a friendly name to a community when one is mapped.""" + if comm in structured.communities.names: + return f"{comm},{structured.communities.names[comm]}" + return comm + + if structured.communities.mode == "name": + return [_name(c) for c in value] + func_map = {"permit": _permit, "deny": _deny} func = func_map[structured.communities.mode] @@ -80,10 +90,9 @@ def validate_rpki_state(cls, value, info: ValidationInfo): return value if structured.rpki.mode == "external": - # If external validation is enabled, validate the prefix - # & asn with Cloudflare's RPKI API. + # If external validation is enabled, validate the prefix & asn + # with the configured RPKI backend. as_path = info.data.get("as_path", []) - if len(as_path) == 0: # If the AS_PATH length is 0, i.e. for an internal route, # return RPKI Unknown state. @@ -91,14 +100,19 @@ def validate_rpki_state(cls, value, info: ValidationInfo): # Get last ASN in path asn = as_path[-1] - try: - net = ip_network(info.data["prefix"]) - except ValueError: - return 3 + try: + net = ip_network(info.data["prefix"]) + except ValueError: + return 3 - # Only do external RPKI lookups for global prefixes. - if net.is_global: - return rpki_state(prefix=info.data["prefix"], asn=asn) + # Only do external RPKI lookups for global prefixes. + if net.is_global: + return rpki_state( + prefix=info.data["prefix"], + asn=asn, + backend=structured.rpki.backend, + rpki_server_url=structured.rpki.rpki_server_url, + ) return value diff --git a/hyperglass/models/parsing/huawei.py b/hyperglass/models/parsing/huawei.py new file mode 100644 index 000000000..357f121b5 --- /dev/null +++ b/hyperglass/models/parsing/huawei.py @@ -0,0 +1,394 @@ +"""Parser for Huawei VRP.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import ConfigDict, field_validator, model_validator + +# Project +from hyperglass.log import log +from hyperglass.models.data.bgp_route import BGPRoute, BGPRouteTable + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +def remove_prefix(text: str, prefix: str) -> str: + """Remove prefix from text if it exists.""" + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +class HuaweiBase(HyperglassModel, extra="ignore"): + """Base Huawei model.""" + + def __init__(self, **kwargs: t.Any) -> None: + """Initialize Huawei model.""" + super().__init__(**kwargs) + + +class HuaweiPaths(HuaweiBase): + """BGP paths information.""" + + available: int = 0 + best: int = 0 + select: int = 0 + best_external: int = 0 + add_path: int = 0 + + +class HuaweiRouteEntry(HuaweiBase): + """Parse Huawei route entry data.""" + + model_config = ConfigDict(validate_assignment=False) + + prefix: str + from_addr: str = "" + duration: int = 0 + direct_out_interface: str = "" + original_next_hop: str = "" + relay_ip_next_hop: str = "" + relay_ip_out_interface: str = "" + qos: str = "" + communities: t.List[str] = [] + large_communities: t.List[str] = [] + ext_communities: t.List[str] = [] + as_path: t.List[int] = [] + origin: str = "" + metric: int = 0 # MED + local_preference: int = 100 + preference_value: int = 0 + is_valid: bool = False + is_external: bool = False + is_backup: bool = False + is_best: bool = False + is_selected: bool = False + preference: int = 0 + + @property + def next_hop(self) -> str: + """Get next hop (original or relay IP).""" + return self.original_next_hop or self.relay_ip_next_hop + + @property + def age(self) -> str: + """Get age as string.""" + return str(self.duration) + + @property + def weight(self) -> int: + """Get weight (preference).""" + return self.preference + + @property + def med(self) -> int: + """Get MED (metric).""" + return self.metric + + @property + def active(self) -> bool: + """Check if route is active (best or selected).""" + return self.is_best or self.is_selected + + @property + def all_communities(self) -> t.List[str]: + """Get all communities combined.""" + return self.communities + self.large_communities + self.ext_communities + + @property + def source_as(self) -> int: + """Get source AS from AS path (first ASN in the path).""" + return self.as_path[0] if self.as_path else 0 + + @property + def source_rid(self) -> str: + """Get source router ID.""" + return "" + + @property + def peer_rid(self) -> str: + """Get peer router ID.""" + return self.from_addr + + +def _extract_paths(line: str) -> HuaweiPaths: + """Extract paths information from line like 'Paths: 3 available, 1 best, 1 select, 0 best-external, 0 add-path'.""" + paths_data = { + "available": 0, + "best": 0, + "select": 0, + "best_external": 0, + "add_path": 0, + } + + try: + values = remove_prefix(line.strip(), "Paths:").strip().split(",") + for value in values: + parts = value.strip().split(" ") + if len(parts) >= 2: + count = int(parts[0]) + name = parts[1].replace("-", "_") # Convert best-external to best_external + if name in paths_data: + paths_data[name] = count + except (ValueError, IndexError): + log.warning(f"Failed to parse paths line: {line}") + + return HuaweiPaths(**paths_data) + + +def _extract_route_entries(lines: t.List[str]) -> t.List[HuaweiRouteEntry]: + """Extract route entries from lines.""" + routes = [] + + # Split lines into route blocks using empty lines as separators + size = len(lines) + idx_list = [idx + 1 for idx, val in enumerate(lines) if val.strip() == ""] + entries = ( + [ + lines[i:j] + for i, j in zip([0] + idx_list, idx_list + ([size] if idx_list[-1] != size else [])) + ] + if idx_list + else [lines] + ) + + for route_entry in entries: + if not route_entry: + continue + + # Initialize route data + route_data = { + "prefix": "", + "from_addr": "", + "duration": 0, + "direct_out_interface": "", + "original_next_hop": "", + "relay_ip_next_hop": "", + "relay_ip_out_interface": "", + "qos": "", + "communities": [], + "large_communities": [], + "ext_communities": [], + "as_path": [], + "origin": "", + "metric": 0, + "local_preference": 100, + "preference_value": 0, + "is_valid": False, + "is_external": False, + "is_backup": False, + "is_best": False, + "is_selected": False, + "preference": 0, + } + + for info in route_entry: + info = info.strip() + if not info: + continue + + if info.startswith("BGP routing table entry information of"): + route_data["prefix"] = remove_prefix( + info, "BGP routing table entry information of " + ).rstrip(":") + elif info.startswith("From:"): + route_data["from_addr"] = remove_prefix(info, "From: ").split(" (")[0] + elif info.startswith("Route Duration:"): + duration_str = remove_prefix(info, "Route Duration: ") + try: + # Parse format like "84d11h53m07s" + d_match = re.search(r"(\d+)d", duration_str) + h_match = re.search(r"(\d+)h", duration_str) + m_match = re.search(r"(\d+)m", duration_str) + s_match = re.search(r"(\d+)s", duration_str) + + days = int(d_match.group(1)) if d_match else 0 + hours = int(h_match.group(1)) if h_match else 0 + minutes = int(m_match.group(1)) if m_match else 0 + seconds = int(s_match.group(1)) if s_match else 0 + + route_data["duration"] = ( + days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60 + seconds + ) + except: + route_data["duration"] = 0 + elif info.startswith("Direct Out-interface:"): + route_data["direct_out_interface"] = remove_prefix(info, "Direct Out-interface: ") + elif info.startswith("Original nexthop:"): + route_data["original_next_hop"] = remove_prefix(info, "Original nexthop: ") + elif info.startswith("Relay IP Nexthop:"): + route_data["relay_ip_next_hop"] = remove_prefix(info, "Relay IP Nexthop: ") + elif info.startswith("Relay IP Out-Interface:"): + route_data["relay_ip_out_interface"] = remove_prefix( + info, "Relay IP Out-Interface: " + ) + elif info.startswith("Qos information :"): + route_data["qos"] = remove_prefix(info, "Qos information : ") + elif info.startswith("Community:"): + communities_str = remove_prefix(info, "Community: ") + if communities_str and communities_str.lower() != "none": + communities = [ + c.strip().replace("<", "").replace(">", "") + for c in communities_str.split(", ") + ] + route_data["communities"] = [c for c in communities if c] + elif info.startswith("Large-Community:"): + large_communities_str = remove_prefix(info, "Large-Community: ") + if large_communities_str and large_communities_str.lower() != "none": + large_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in large_communities_str.split(", ") + ] + route_data["large_communities"] = [c for c in large_communities if c] + elif info.startswith("Ext-Community:"): + ext_communities_str = remove_prefix(info, "Ext-Community: ") + if ext_communities_str and ext_communities_str.lower() != "none": + ext_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in ext_communities_str.split(", ") + ] + route_data["ext_communities"] = [c for c in ext_communities if c] + elif info.startswith("AS-path"): + values = info.split(",") + for v in values: + v = v.strip() + if v.startswith("AS-path"): + as_path_str = remove_prefix(v, "AS-path ") + try: + route_data["as_path"] = [ + int(a) for a in as_path_str.split() if a.isdigit() + ] + except ValueError: + route_data["as_path"] = [] + elif v.startswith("origin"): + route_data["origin"] = remove_prefix(v, "origin ") + elif v.startswith("MED"): + try: + route_data["metric"] = int(remove_prefix(v, "MED ")) + except ValueError: + route_data["metric"] = 0 + elif v.startswith("localpref"): + try: + route_data["local_preference"] = int(remove_prefix(v, "localpref ")) + except ValueError: + route_data["local_preference"] = 100 + elif v.startswith("pref-val"): + try: + route_data["preference_value"] = int(remove_prefix(v, "pref-val ")) + except ValueError: + route_data["preference_value"] = 0 + elif v.startswith("pre "): + try: + route_data["preference"] = int(remove_prefix(v, "pre ")) + except ValueError: + route_data["preference"] = 0 + elif v.strip() == "valid": + route_data["is_valid"] = True + elif v.strip() == "external": + route_data["is_external"] = True + elif v.strip() == "backup": + route_data["is_backup"] = True + elif v.strip() == "best": + route_data["is_best"] = True + elif v.strip() == "select": + route_data["is_selected"] = True + + # Only add route if we have a valid prefix + if route_data["prefix"]: + try: + route = HuaweiRouteEntry(**route_data) + routes.append(route) + except Exception as e: + log.warning( + "Failed to create route entry for prefix {}: {}", + route_data.get("prefix", "unknown"), + e, + ) + continue + + return routes + + +class HuaweiBGPRouteTable(BGPRouteTable): + """Canonical Huawei BGP Route Table.""" + + # No custom __init__ needed; inherit from BGPRouteTable (which should be a Pydantic model) + + +class HuaweiBGPTable(HuaweiBase): + """Validation model for Huawei BGP table data.""" + + local_router_id: str = "" + local_as_number: int = 0 + paths: HuaweiPaths = HuaweiPaths() + routes: t.List[HuaweiRouteEntry] = [] + + @classmethod + def parse_text(cls, text: str) -> "HuaweiBGPTable": + """Parse Huawei BGP text output.""" + _log = log.bind(parser="HuaweiBGPTable") + + instance = cls() + + lines = text.split("\n") + + # Extract general information + for line in lines: + if "BGP local router ID" in line: + instance.local_router_id = remove_prefix(line, "BGP local router ID : ").strip() + elif "Local AS number" in line: + try: + instance.local_as_number = int( + remove_prefix(line, "Local AS number : ").strip() + ) + except ValueError: + instance.local_as_number = 0 + elif line.strip().startswith("Paths:"): + instance.paths = _extract_paths(line) + + # Extract route entries + instance.routes = _extract_route_entries(lines) + + _log.debug(f"Parsed {len(instance.routes)} Huawei routes") + return instance + + def bgp_table(self) -> BGPRouteTable: + """Convert to standard BGP table format.""" + routes = [] + + for route in self.routes: + route_data = { + "prefix": route.prefix, + "active": route.active, + "age": route.age, + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.all_communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": ( + RPKI_STATE_MAP["valid"] if route.is_valid else RPKI_STATE_MAP["unknown"] + ), + } + routes.append(BGPRoute(**route_data)) + + return HuaweiBGPRouteTable( + vrf="default", + count=len(routes), + routes=routes, + winning_weight="high", + ) diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py index 5e87f5b11..0891b949a 100644 --- a/hyperglass/plugins/_builtin/__init__.py +++ b/hyperglass/plugins/_builtin/__init__.py @@ -5,6 +5,7 @@ from .remove_command import RemoveCommand from .bgp_route_arista import BGPRoutePluginArista from .bgp_route_huawei import BGPRoutePluginHuawei +from .bgp_routestr_huawei import BGPSTRRoutePluginHuawei from .bgp_route_juniper import BGPRoutePluginJuniper from .mikrotik_garbage_output import MikrotikGarbageOutput @@ -13,6 +14,7 @@ "BGPRoutePluginFrr", "BGPRoutePluginJuniper", "BGPRoutePluginHuawei", + "BGPSTRRoutePluginHuawei", "MikrotikGarbageOutput", "RemoveCommand", ) diff --git a/hyperglass/plugins/_builtin/bgp_route_huawei.py b/hyperglass/plugins/_builtin/bgp_route_huawei.py index 172d9068f..484dc9cc8 100644 --- a/hyperglass/plugins/_builtin/bgp_route_huawei.py +++ b/hyperglass/plugins/_builtin/bgp_route_huawei.py @@ -21,7 +21,10 @@ class BGPRoutePluginHuawei(InputPlugin): "huawei", "huawei_vrpv8", ) - directives: t.Sequence[str] = ("__hyperglass_huawei_bgp_route__",) + directives: t.Sequence[str] = ( + "__hyperglass_huawei_bgp_route__", + "__hyperglass_huawei_bgp_route_table__", + ) """ Huawei BGP Route Input Plugin @@ -33,15 +36,25 @@ class BGPRoutePluginHuawei(InputPlugin): def transform(self, query: "Query") -> InputPluginTransformReturn: target = query.query_target - if not target or not isinstance(target, list) or len(target) == 0: + if not target: return None - target = target[0].strip() + if isinstance(target, list): + if len(target) == 0: + return None + target = target[0].strip() + elif isinstance(target, str): + target = target.strip() + else: + return None # Check for the / in the query target if target.find("/") == -1: return target - target_network = ip_network(target) - - return f"{target_network.network_address!s} {target_network.prefixlen!s}" + try: + target_network = ip_network(target) + return f"{target_network.network_address!s} {target_network.prefixlen!s}" + except ValueError: + # Return the target unchanged if it isn't a valid network + return target diff --git a/hyperglass/plugins/_builtin/bgp_routestr_huawei.py b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py new file mode 100644 index 000000000..e42308344 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py @@ -0,0 +1,80 @@ +"""Coerce a Huawei route table in text format to a standard BGP Table structure.""" + +# Standard Library +import re +from typing import TYPE_CHECKING, List, Sequence + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +# Local +from .._output import OutputPlugin + +if TYPE_CHECKING: + # Project + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + + # Local + from .._output import OutputType + + +def parse_huawei(output: Sequence[str]) -> "OutputDataModel": + """Parse a Huawei BGP text response.""" + result = None + + _log = log.bind(plugin=BGPSTRRoutePluginHuawei.__name__) + + for response in output: + try: + # Parse the text output using the Huawei parser + validated = HuaweiBGPTable.parse_text(response) + bgp_table = validated.bgp_table() + + if result is None: + result = bgp_table + else: + result += bgp_table + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + except Exception as err: + _log.bind(error=str(err)).critical("Failed to parse Huawei BGP output") + raise ParsingError("Error parsing response data") from err + + return result + + +class BGPSTRRoutePluginHuawei(OutputPlugin): + """Coerce a Huawei route table in text format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: Sequence[str] = ("huawei",) + directives: Sequence[str] = ( + "__hyperglass_huawei_bgp_route_table__", + "__hyperglass_huawei_bgp_aspath_table__", + "__hyperglass_huawei_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse Huawei response if data is a string (and is therefore unparsed).""" + _log = log.bind(plugin=self.__class__.__name__) + _log.debug("Processing Huawei output with structured parser") + + should_process = all( + ( + isinstance(output, (list, tuple)), + query.device.platform in self.platforms, + query.device.structured_output is True, + query.device.has_directives(*self.directives), + ) + ) + if should_process: + return parse_huawei(output) + return output diff --git a/hyperglass/plugins/tests/test_bgp_route_huawei.py b/hyperglass/plugins/tests/test_bgp_route_huawei.py new file mode 100644 index 000000000..0485fad7c --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_huawei.py @@ -0,0 +1,75 @@ +"""Huawei VRP structured BGP route parsing tests.""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.config.params import Params +from hyperglass.models.data.bgp_route import BGPRouteTable +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +if t.TYPE_CHECKING: + from hyperglass.state import HyperglassState + +# Representative `display bgp routing-table ` detail output: one best/ +# selected (active) route and one valid-but-not-best route. +SAMPLE = """ + BGP local router ID : 192.0.2.1 + Local AS number : 64500 + Paths: 2 available, 1 best, 1 select + + BGP routing table entry information of 203.0.113.0/24: + From: 198.51.100.1 (192.0.2.254) + Route Duration: 0d00h05m12s + Original nexthop: 198.51.100.1 + Community: 64500:100, 64500:200 + AS-path 64500 64501, origin igp, MED 0, localpref 100, pref-val 0, valid, external, best, select, pre 255 + + BGP routing table entry information of 203.0.113.128/25: + From: 198.51.100.2 (192.0.2.253) + Route Duration: 0d00h01m00s + Original nexthop: 198.51.100.2 + AS-path 64500 64999, origin incomplete, MED 10, localpref 90, pref-val 0, valid, external, pre 255 +""" + + +@pytest.fixture +def params() -> t.Dict[str, t.Any]: + return {} + + +@pytest.fixture +def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: + """Initialize Redis-backed state with params for BGPRoute validation.""" + _state = use_state() + _params = Params(**params) + with _state.cache.pipeline() as pipeline: + pipeline.set("params", _params) + yield _state + _state.clear() + + +def test_huawei_bgp_route(state): + table = HuaweiBGPTable.parse_text(SAMPLE).bgp_table() + + assert isinstance(table, BGPRouteTable), "Parsed result is not a BGPRouteTable" + assert table.count == 2, f"Expected 2 routes, got {table.count}" + + by_prefix = {r.prefix: r for r in table.routes} + assert set(by_prefix) == {"203.0.113.0/24", "203.0.113.128/25"} + + best = by_prefix["203.0.113.0/24"] + assert best.active is True, "best+select route should be active" + assert best.as_path == [64500, 64501], f"Bad as_path: {best.as_path}" + assert best.source_as == 64500, "source_as should be first ASN in path" + assert best.next_hop == "198.51.100.1" + assert "64500:100" in best.communities + + other = by_prefix["203.0.113.128/25"] + assert other.active is False, "non-best route should not be active" + assert other.as_path == [64500, 64999] From a3cded6bb84ae639219bea56ba4f9a44fc95c64d Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:40 +1000 Subject: [PATCH 05/39] feat(table): display and dim filtered BGP routes --- .../config/structured-output.mdx | 42 +- docs/pages/configuration/config/web-ui.mdx | 3 + hyperglass/api/state.py | 35 +- hyperglass/constants.py | 2 +- hyperglass/defaults/directives/huawei.py | 63 ++- hyperglass/external/rpki.py | 62 ++- hyperglass/external/tests/test_rpki.py | 8 +- hyperglass/models/config/structured.py | 28 +- hyperglass/models/config/web.py | 1 + hyperglass/models/data/bgp_route.py | 35 +- hyperglass/models/parsing/huawei.py | 394 ++++++++++++++++++ hyperglass/plugins/_builtin/__init__.py | 2 + .../plugins/_builtin/bgp_route_huawei.py | 25 +- .../plugins/_builtin/bgp_routestr_huawei.py | 80 ++++ .../plugins/tests/test_bgp_route_huawei.py | 75 ++++ hyperglass/ui/components/table/cell.tsx | 5 +- hyperglass/ui/components/table/main.tsx | 3 + hyperglass/ui/components/table/row.tsx | 6 +- hyperglass/ui/types/globals.d.ts | 1 + 19 files changed, 816 insertions(+), 54 deletions(-) create mode 100644 hyperglass/models/parsing/huawei.py create mode 100644 hyperglass/plugins/_builtin/bgp_routestr_huawei.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_huawei.py diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx index df936253f..89c560a64 100644 --- a/docs/pages/configuration/config/structured-output.mdx +++ b/docs/pages/configuration/config/structured-output.mdx @@ -4,19 +4,25 @@ Devices that support responding to a query with structured or easily parsable da - Arista EOS - Juniper Junos +- Huawei VRP When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods: 1. From the router's perspective -2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) +2. From an external validator — either [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) (the default) or a self-hosted [Routinator](https://routinator.docs.nlnetlabs.nl/) instance + +When `structured.rpki.mode` is `external`, the `structured.rpki.backend` field selects which validator to query. The `cloudflare` backend uses Cloudflare's GraphQL API; the `routinator` backend queries the `/validity` HTTP API of a Routinator instance at `structured.rpki.rpki_server_url`. Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user. | Parameter | Type | Default Value | Description | | :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- | -| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use Cloudflare's view (2 above). | -| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. | -| `structured.communities.items` | List of Strings | | List of communities to match. | +| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use an external validator (2 above). | +| `structured.rpki.backend` | String | cloudflare | When `mode` is `external`, the validator to use: `cloudflare` or `routinator`. | +| `structured.rpki.rpki_server_url` | String | | Base URL of the Routinator instance. **Required** when `backend` is `routinator` (e.g. `http://routinator.example.net:8323`). | +| `structured.communities.mode` | String | deny | `deny` hides communities listed in `items`; `permit` shows _only_ those listed; `name` shows all communities and appends a friendly label to any listed in `names`. | +| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny`/`permit`). | +| `structured.communities.names` | Map of String→String | | Map of community → friendly name, used by `name` mode. At least one entry is required when `mode` is `name`. | ### RPKI Examples @@ -28,12 +34,23 @@ structured: mode: router ``` -#### Show RPKI State from a Public/External Perspective +#### Show RPKI State from a Public/External Perspective (Cloudflare) -```yaml filename="config.yaml" copy {2} +```yaml filename="config.yaml" copy {2-3} +structured: + rpki: + mode: external + backend: cloudflare +``` + +#### Validate RPKI State Against a Self-Hosted Routinator + +```yaml filename="config.yaml" copy {2-5} structured: rpki: mode: external + backend: routinator + rpki_server_url: "http://routinator.example.net:8323" ``` ### Community Filtering Examples @@ -59,3 +76,16 @@ structured: - "^65000:.*$" # permit any communities starting with 65000, but no others. - "1234:1" # permit only the 1234:1 community. ``` + +#### Show Friendly Names Alongside Communities + +With `mode: name`, all communities are shown, and any community listed in `names` has its friendly label appended (rendered as ` ()` in the UI). + +```yaml filename="config.yaml" {3-6} +structured: + communities: + mode: name + names: + "65000:100": "Customer Route" + "65000:200": "Peer Route" +``` diff --git a/docs/pages/configuration/config/web-ui.mdx b/docs/pages/configuration/config/web-ui.mdx index 761344769..96b5664f7 100644 --- a/docs/pages/configuration/config/web-ui.mdx +++ b/docs/pages/configuration/config/web-ui.mdx @@ -243,6 +243,7 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. | `web.theme.colors.dark` | | | `web.theme.colors.light` | | | `web.theme.colors.gray` | | +| `web.theme.colors.filtered` | | | `web.theme.colors.red` | | | `web.theme.colors.orange` | | | `web.theme.colors.yellow` | | @@ -253,6 +254,8 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. | `web.theme.colors.pink` | | | `web.theme.colors.purple` | | +The `filtered` color is used to dim BGP routes that are present but not selected/active in the structured table (for example, MikroTik routes flagged filtered, disabled, or unreachable). It defaults to the same value as `gray`. + ##### Functional Colors | Parameter | Default Value | diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py index f41a96d78..5dfd75ef9 100644 --- a/hyperglass/api/state.py +++ b/hyperglass/api/state.py @@ -1,27 +1,52 @@ """hyperglass state dependencies.""" # Standard Library +import asyncio import typing as t # Project -from hyperglass.state import use_state +from hyperglass.state import HyperglassState, use_state +from hyperglass.exceptions.private import StateError + + +def _is_retryable(attr: t.Optional[str]) -> bool: + """Whether a StateError for this attr is a transient (not-yet-populated) error. + + A request can arrive before the Redis-backed state is populated on startup, + which surfaces as a StateError. Referencing an attribute that does not exist + on HyperglassState is a permanent programming error and must not be retried. + """ + return attr is None or attr in ("cache", "redis") or attr in HyperglassState.properties() + + +async def _get_state_with_retry( + attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5 +) -> t.Any: + """Get hyperglass state, retrying transient startup StateErrors.""" + for attempt in range(1, max_retries + 1): + try: + return use_state(attr) + except StateError: + if not _is_retryable(attr) or attempt == max_retries: + raise + await asyncio.sleep(retry_delay) async def get_state(attr: t.Optional[str] = None): """Get hyperglass state as a FastAPI dependency.""" - return use_state(attr) + return await _get_state_with_retry(attr) async def get_params(): """Get hyperglass params as FastAPI dependency.""" - return use_state("params") + return await _get_state_with_retry("params") async def get_devices(): """Get hyperglass devices as FastAPI dependency.""" - return use_state("devices") + return await _get_state_with_retry("devices") async def get_ui_params(): """Get hyperglass ui_params as FastAPI dependency.""" - return use_state("ui_params") + return await _get_state_with_retry("ui_params") diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 0139c7415..1886ceb68 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -19,7 +19,7 @@ TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos") -SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos") +SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos", "huawei") CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml") diff --git a/hyperglass/defaults/directives/huawei.py b/hyperglass/defaults/directives/huawei.py index f8d3ef0cf..995a42c6c 100644 --- a/hyperglass/defaults/directives/huawei.py +++ b/hyperglass/defaults/directives/huawei.py @@ -15,6 +15,9 @@ "Huawei_BGPRoute", "Huawei_Ping", "Huawei_Traceroute", + "HuaweiBGPRouteTable", + "HuaweiBGPASPathTable", + "HuaweiBGPCommunityTable", ) NAME = "Huawei VRP" @@ -36,7 +39,8 @@ ), ], field=Text(description="IP Address, Prefix, or Hostname"), - plugins=["bgp_route_huawei"], + plugins=["bgp_route_huawei", "bgp_routestr_huawei"], + table_output="__hyperglass_huawei_bgp_route_table__", platforms=PLATFORMS, ) @@ -54,6 +58,7 @@ ) ], field=Text(description="AS Path Regular Expression"), + table_output="__hyperglass_huawei_bgp_aspath_table__", platforms=PLATFORMS, ) @@ -71,6 +76,7 @@ ) ], field=Text(description="BGP Community String"), + table_output="__hyperglass_huawei_bgp_community_table__", platforms=PLATFORMS, ) @@ -111,3 +117,58 @@ field=Text(description="IP Address, Prefix, or Hostname"), platforms=PLATFORMS, ) + +# Table Output Directives + +HuaweiBGPRouteTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="display bgp routing-table {target} | no-more", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="display bgp ipv6 routing-table {target} | no-more", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +HuaweiBGPASPathTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table regular-expression "{target}"', + 'display bgp ipv6 routing-table regular-expression "{target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +HuaweiBGPCommunityTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_community_table__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table community "{target}"', + 'display bgp ipv6 routing-table community "{target}"', + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) diff --git a/hyperglass/external/rpki.py b/hyperglass/external/rpki.py index 139ccf8fc..a5c480513 100644 --- a/hyperglass/external/rpki.py +++ b/hyperglass/external/rpki.py @@ -3,57 +3,83 @@ # Standard Library import typing as t +# Third Party +import httpx + # Project from hyperglass.log import log from hyperglass.state import use_state from hyperglass.external._base import BaseExternal if t.TYPE_CHECKING: - # Standard Library from ipaddress import IPv4Address, IPv6Address -RPKI_STATE_MAP = {"Invalid": 0, "Valid": 1, "NotFound": 2, "DEFAULT": 3} -RPKI_NAME_MAP = {v: k for k, v in RPKI_STATE_MAP.items()} +# Maps normalized (lower-case, separators stripped) backend state names to the +# integer states hyperglass uses internally. +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "notfound": 2, + "unknown": 2, + "default": 3, +} +# Canonical integer -> display name, kept stable for logging. +RPKI_NAME_MAP = {0: "Invalid", 1: "Valid", 2: "NotFound", 3: "DEFAULT"} CACHE_KEY = "hyperglass.external.rpki" -def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, str]) -> int: +def _normalize_state(value: str) -> int: + """Normalize a backend RPKI state string to an internal integer state.""" + key = str(value).strip().lower().replace("-", "").replace("_", "") + return RPKI_STATE_MAP.get(key, 3) + + +def rpki_state( + prefix: t.Union["IPv4Address", "IPv6Address", str], + asn: t.Union[int, str], + backend: str = "cloudflare", + rpki_server_url: str = "", +) -> int: """Get RPKI state and map to expected integer.""" _log = log.bind(prefix=prefix, asn=asn) _log.debug("Validating RPKI State") cache = use_state("cache") - state = 3 ro = f"{prefix!s}@{asn!s}" cached = cache.get_map(CACHE_KEY, ro) - if cached is not None: state = cached else: - ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' - query = ql.format(prefix, asn) - _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") try: - with BaseExternal(base_url="https://rpki.cloudflare.com") as client: - response = client._post("/api/graphql", data={"query": query}) - try: + if backend == "cloudflare": + ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' + query = ql.format(prefix, asn) + _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") + with BaseExternal(base_url="https://rpki.cloudflare.com") as client: + response = client._post("/api/graphql", data={"query": query}) validation_state = response["data"]["validation"]["state"] - except KeyError as missing: - _log.error("Response from Cloudflare missing key '{}': {!r}", missing, response) - validation_state = 3 + elif backend == "routinator": + url = f"{rpki_server_url.rstrip('/')}/validity" + _log.bind(url=url).debug("Routinator RPKI HTTP Query") + response = httpx.get( + url, params={"asn": str(asn), "prefix": str(prefix)}, timeout=5 + ) + response.raise_for_status() + data = response.json() + validation_state = data["validated_route"]["validity"]["state"] + else: + raise ValueError(f"Unknown RPKI backend: {backend}") - state = RPKI_STATE_MAP[validation_state] + state = _normalize_state(validation_state) cache.set_map_item(CACHE_KEY, ro, state) except Exception as err: log.error(err) - # Don't cache the state when an error produced it. state = 3 msg = "RPKI Validation State for {} via AS{} is {}".format(prefix, asn, RPKI_NAME_MAP[state]) if cached is not None: msg += " [CACHED]" - log.debug(msg) return state diff --git a/hyperglass/external/tests/test_rpki.py b/hyperglass/external/tests/test_rpki.py index 01438e717..d21d50559 100644 --- a/hyperglass/external/tests/test_rpki.py +++ b/hyperglass/external/tests/test_rpki.py @@ -19,8 +19,8 @@ def test_rpki(): result = rpki_state(prefix, asn) result_name = RPKI_NAME_MAP.get(result, "No Name") expected_name = RPKI_NAME_MAP.get(expected, "No Name") - assert result == expected, ( - "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( - prefix, asn, result, result_name, expected, expected_name - ) + assert ( + result == expected + ), "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( + prefix, asn, result, result_name, expected, expected_name ) diff --git a/hyperglass/models/config/structured.py b/hyperglass/models/config/structured.py index 4373e8187..d672ab82e 100644 --- a/hyperglass/models/config/structured.py +++ b/hyperglass/models/config/structured.py @@ -3,11 +3,15 @@ # Standard Library import typing as t +# Third Party +from pydantic import ValidationInfo, field_validator, model_validator + # Local from ..main import HyperglassModel -StructuredCommunityMode = t.Literal["permit", "deny"] +StructuredCommunityMode = t.Literal["permit", "deny", "name"] StructuredRPKIMode = t.Literal["router", "external"] +StructuredRPKIBackend = t.Literal["cloudflare", "routinator"] class StructuredCommunities(HyperglassModel): @@ -15,12 +19,34 @@ class StructuredCommunities(HyperglassModel): mode: StructuredCommunityMode = "deny" items: t.List[str] = [] + names: t.Dict[str, str] = {} + + @field_validator("names") + def validate_names(cls, value: t.Dict[str, str], info: ValidationInfo) -> t.Dict[str, str]: + """Require at least one community mapping when mode is 'name'.""" + if info.data and info.data.get("mode") == "name" and not value: + raise ValueError( + "When using mode 'name', at least one community mapping must be " + "provided in 'names'" + ) + return value class StructuredRpki(HyperglassModel): """Control structured data response for RPKI state.""" mode: StructuredRPKIMode = "router" + backend: StructuredRPKIBackend = "cloudflare" + rpki_server_url: str = "" + + @model_validator(mode="after") + def validate_routinator_url(self) -> "StructuredRpki": + """Require a server URL when the routinator backend is selected.""" + if self.backend == "routinator" and not self.rpki_server_url: + raise ValueError( + "structured.rpki.rpki_server_url is required when backend is 'routinator'" + ) + return self class Structured(HyperglassModel): diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index 7916d9e9b..f6881935c 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -137,6 +137,7 @@ class ThemeColors(HyperglassModel): dark: Color = "#010101" light: Color = "#f5f6f7" gray: Color = "#c1c7cc" + filtered: Color = "#c1c7cc" red: Color = "#d84b4b" orange: Color = "#ff6b35" yellow: Color = "#edae49" diff --git a/hyperglass/models/data/bgp_route.py b/hyperglass/models/data/bgp_route.py index a00af4e13..7eadb47ed 100644 --- a/hyperglass/models/data/bgp_route.py +++ b/hyperglass/models/data/bgp_route.py @@ -34,6 +34,7 @@ class BGPRoute(HyperglassModel): source_rid: str peer_rid: str rpki_state: int + filtered: bool = False @field_validator("communities") def validate_communities(cls, value): @@ -42,6 +43,7 @@ def validate_communities(cls, value): Actions: permit: only permit matches deny: only deny matches + name: append friendly names to matching communities """ (structured := use_state("params").structured) @@ -64,6 +66,15 @@ def _deny(comm): break return valid + def _name(comm): + """Append a friendly name to a community when one is mapped.""" + if comm in structured.communities.names: + return f"{comm},{structured.communities.names[comm]}" + return comm + + if structured.communities.mode == "name": + return [_name(c) for c in value] + func_map = {"permit": _permit, "deny": _deny} func = func_map[structured.communities.mode] @@ -80,10 +91,9 @@ def validate_rpki_state(cls, value, info: ValidationInfo): return value if structured.rpki.mode == "external": - # If external validation is enabled, validate the prefix - # & asn with Cloudflare's RPKI API. + # If external validation is enabled, validate the prefix & asn + # with the configured RPKI backend. as_path = info.data.get("as_path", []) - if len(as_path) == 0: # If the AS_PATH length is 0, i.e. for an internal route, # return RPKI Unknown state. @@ -91,14 +101,19 @@ def validate_rpki_state(cls, value, info: ValidationInfo): # Get last ASN in path asn = as_path[-1] - try: - net = ip_network(info.data["prefix"]) - except ValueError: - return 3 + try: + net = ip_network(info.data["prefix"]) + except ValueError: + return 3 - # Only do external RPKI lookups for global prefixes. - if net.is_global: - return rpki_state(prefix=info.data["prefix"], asn=asn) + # Only do external RPKI lookups for global prefixes. + if net.is_global: + return rpki_state( + prefix=info.data["prefix"], + asn=asn, + backend=structured.rpki.backend, + rpki_server_url=structured.rpki.rpki_server_url, + ) return value diff --git a/hyperglass/models/parsing/huawei.py b/hyperglass/models/parsing/huawei.py new file mode 100644 index 000000000..357f121b5 --- /dev/null +++ b/hyperglass/models/parsing/huawei.py @@ -0,0 +1,394 @@ +"""Parser for Huawei VRP.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import ConfigDict, field_validator, model_validator + +# Project +from hyperglass.log import log +from hyperglass.models.data.bgp_route import BGPRoute, BGPRouteTable + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +def remove_prefix(text: str, prefix: str) -> str: + """Remove prefix from text if it exists.""" + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +class HuaweiBase(HyperglassModel, extra="ignore"): + """Base Huawei model.""" + + def __init__(self, **kwargs: t.Any) -> None: + """Initialize Huawei model.""" + super().__init__(**kwargs) + + +class HuaweiPaths(HuaweiBase): + """BGP paths information.""" + + available: int = 0 + best: int = 0 + select: int = 0 + best_external: int = 0 + add_path: int = 0 + + +class HuaweiRouteEntry(HuaweiBase): + """Parse Huawei route entry data.""" + + model_config = ConfigDict(validate_assignment=False) + + prefix: str + from_addr: str = "" + duration: int = 0 + direct_out_interface: str = "" + original_next_hop: str = "" + relay_ip_next_hop: str = "" + relay_ip_out_interface: str = "" + qos: str = "" + communities: t.List[str] = [] + large_communities: t.List[str] = [] + ext_communities: t.List[str] = [] + as_path: t.List[int] = [] + origin: str = "" + metric: int = 0 # MED + local_preference: int = 100 + preference_value: int = 0 + is_valid: bool = False + is_external: bool = False + is_backup: bool = False + is_best: bool = False + is_selected: bool = False + preference: int = 0 + + @property + def next_hop(self) -> str: + """Get next hop (original or relay IP).""" + return self.original_next_hop or self.relay_ip_next_hop + + @property + def age(self) -> str: + """Get age as string.""" + return str(self.duration) + + @property + def weight(self) -> int: + """Get weight (preference).""" + return self.preference + + @property + def med(self) -> int: + """Get MED (metric).""" + return self.metric + + @property + def active(self) -> bool: + """Check if route is active (best or selected).""" + return self.is_best or self.is_selected + + @property + def all_communities(self) -> t.List[str]: + """Get all communities combined.""" + return self.communities + self.large_communities + self.ext_communities + + @property + def source_as(self) -> int: + """Get source AS from AS path (first ASN in the path).""" + return self.as_path[0] if self.as_path else 0 + + @property + def source_rid(self) -> str: + """Get source router ID.""" + return "" + + @property + def peer_rid(self) -> str: + """Get peer router ID.""" + return self.from_addr + + +def _extract_paths(line: str) -> HuaweiPaths: + """Extract paths information from line like 'Paths: 3 available, 1 best, 1 select, 0 best-external, 0 add-path'.""" + paths_data = { + "available": 0, + "best": 0, + "select": 0, + "best_external": 0, + "add_path": 0, + } + + try: + values = remove_prefix(line.strip(), "Paths:").strip().split(",") + for value in values: + parts = value.strip().split(" ") + if len(parts) >= 2: + count = int(parts[0]) + name = parts[1].replace("-", "_") # Convert best-external to best_external + if name in paths_data: + paths_data[name] = count + except (ValueError, IndexError): + log.warning(f"Failed to parse paths line: {line}") + + return HuaweiPaths(**paths_data) + + +def _extract_route_entries(lines: t.List[str]) -> t.List[HuaweiRouteEntry]: + """Extract route entries from lines.""" + routes = [] + + # Split lines into route blocks using empty lines as separators + size = len(lines) + idx_list = [idx + 1 for idx, val in enumerate(lines) if val.strip() == ""] + entries = ( + [ + lines[i:j] + for i, j in zip([0] + idx_list, idx_list + ([size] if idx_list[-1] != size else [])) + ] + if idx_list + else [lines] + ) + + for route_entry in entries: + if not route_entry: + continue + + # Initialize route data + route_data = { + "prefix": "", + "from_addr": "", + "duration": 0, + "direct_out_interface": "", + "original_next_hop": "", + "relay_ip_next_hop": "", + "relay_ip_out_interface": "", + "qos": "", + "communities": [], + "large_communities": [], + "ext_communities": [], + "as_path": [], + "origin": "", + "metric": 0, + "local_preference": 100, + "preference_value": 0, + "is_valid": False, + "is_external": False, + "is_backup": False, + "is_best": False, + "is_selected": False, + "preference": 0, + } + + for info in route_entry: + info = info.strip() + if not info: + continue + + if info.startswith("BGP routing table entry information of"): + route_data["prefix"] = remove_prefix( + info, "BGP routing table entry information of " + ).rstrip(":") + elif info.startswith("From:"): + route_data["from_addr"] = remove_prefix(info, "From: ").split(" (")[0] + elif info.startswith("Route Duration:"): + duration_str = remove_prefix(info, "Route Duration: ") + try: + # Parse format like "84d11h53m07s" + d_match = re.search(r"(\d+)d", duration_str) + h_match = re.search(r"(\d+)h", duration_str) + m_match = re.search(r"(\d+)m", duration_str) + s_match = re.search(r"(\d+)s", duration_str) + + days = int(d_match.group(1)) if d_match else 0 + hours = int(h_match.group(1)) if h_match else 0 + minutes = int(m_match.group(1)) if m_match else 0 + seconds = int(s_match.group(1)) if s_match else 0 + + route_data["duration"] = ( + days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60 + seconds + ) + except: + route_data["duration"] = 0 + elif info.startswith("Direct Out-interface:"): + route_data["direct_out_interface"] = remove_prefix(info, "Direct Out-interface: ") + elif info.startswith("Original nexthop:"): + route_data["original_next_hop"] = remove_prefix(info, "Original nexthop: ") + elif info.startswith("Relay IP Nexthop:"): + route_data["relay_ip_next_hop"] = remove_prefix(info, "Relay IP Nexthop: ") + elif info.startswith("Relay IP Out-Interface:"): + route_data["relay_ip_out_interface"] = remove_prefix( + info, "Relay IP Out-Interface: " + ) + elif info.startswith("Qos information :"): + route_data["qos"] = remove_prefix(info, "Qos information : ") + elif info.startswith("Community:"): + communities_str = remove_prefix(info, "Community: ") + if communities_str and communities_str.lower() != "none": + communities = [ + c.strip().replace("<", "").replace(">", "") + for c in communities_str.split(", ") + ] + route_data["communities"] = [c for c in communities if c] + elif info.startswith("Large-Community:"): + large_communities_str = remove_prefix(info, "Large-Community: ") + if large_communities_str and large_communities_str.lower() != "none": + large_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in large_communities_str.split(", ") + ] + route_data["large_communities"] = [c for c in large_communities if c] + elif info.startswith("Ext-Community:"): + ext_communities_str = remove_prefix(info, "Ext-Community: ") + if ext_communities_str and ext_communities_str.lower() != "none": + ext_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in ext_communities_str.split(", ") + ] + route_data["ext_communities"] = [c for c in ext_communities if c] + elif info.startswith("AS-path"): + values = info.split(",") + for v in values: + v = v.strip() + if v.startswith("AS-path"): + as_path_str = remove_prefix(v, "AS-path ") + try: + route_data["as_path"] = [ + int(a) for a in as_path_str.split() if a.isdigit() + ] + except ValueError: + route_data["as_path"] = [] + elif v.startswith("origin"): + route_data["origin"] = remove_prefix(v, "origin ") + elif v.startswith("MED"): + try: + route_data["metric"] = int(remove_prefix(v, "MED ")) + except ValueError: + route_data["metric"] = 0 + elif v.startswith("localpref"): + try: + route_data["local_preference"] = int(remove_prefix(v, "localpref ")) + except ValueError: + route_data["local_preference"] = 100 + elif v.startswith("pref-val"): + try: + route_data["preference_value"] = int(remove_prefix(v, "pref-val ")) + except ValueError: + route_data["preference_value"] = 0 + elif v.startswith("pre "): + try: + route_data["preference"] = int(remove_prefix(v, "pre ")) + except ValueError: + route_data["preference"] = 0 + elif v.strip() == "valid": + route_data["is_valid"] = True + elif v.strip() == "external": + route_data["is_external"] = True + elif v.strip() == "backup": + route_data["is_backup"] = True + elif v.strip() == "best": + route_data["is_best"] = True + elif v.strip() == "select": + route_data["is_selected"] = True + + # Only add route if we have a valid prefix + if route_data["prefix"]: + try: + route = HuaweiRouteEntry(**route_data) + routes.append(route) + except Exception as e: + log.warning( + "Failed to create route entry for prefix {}: {}", + route_data.get("prefix", "unknown"), + e, + ) + continue + + return routes + + +class HuaweiBGPRouteTable(BGPRouteTable): + """Canonical Huawei BGP Route Table.""" + + # No custom __init__ needed; inherit from BGPRouteTable (which should be a Pydantic model) + + +class HuaweiBGPTable(HuaweiBase): + """Validation model for Huawei BGP table data.""" + + local_router_id: str = "" + local_as_number: int = 0 + paths: HuaweiPaths = HuaweiPaths() + routes: t.List[HuaweiRouteEntry] = [] + + @classmethod + def parse_text(cls, text: str) -> "HuaweiBGPTable": + """Parse Huawei BGP text output.""" + _log = log.bind(parser="HuaweiBGPTable") + + instance = cls() + + lines = text.split("\n") + + # Extract general information + for line in lines: + if "BGP local router ID" in line: + instance.local_router_id = remove_prefix(line, "BGP local router ID : ").strip() + elif "Local AS number" in line: + try: + instance.local_as_number = int( + remove_prefix(line, "Local AS number : ").strip() + ) + except ValueError: + instance.local_as_number = 0 + elif line.strip().startswith("Paths:"): + instance.paths = _extract_paths(line) + + # Extract route entries + instance.routes = _extract_route_entries(lines) + + _log.debug(f"Parsed {len(instance.routes)} Huawei routes") + return instance + + def bgp_table(self) -> BGPRouteTable: + """Convert to standard BGP table format.""" + routes = [] + + for route in self.routes: + route_data = { + "prefix": route.prefix, + "active": route.active, + "age": route.age, + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.all_communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": ( + RPKI_STATE_MAP["valid"] if route.is_valid else RPKI_STATE_MAP["unknown"] + ), + } + routes.append(BGPRoute(**route_data)) + + return HuaweiBGPRouteTable( + vrf="default", + count=len(routes), + routes=routes, + winning_weight="high", + ) diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py index 5e87f5b11..0891b949a 100644 --- a/hyperglass/plugins/_builtin/__init__.py +++ b/hyperglass/plugins/_builtin/__init__.py @@ -5,6 +5,7 @@ from .remove_command import RemoveCommand from .bgp_route_arista import BGPRoutePluginArista from .bgp_route_huawei import BGPRoutePluginHuawei +from .bgp_routestr_huawei import BGPSTRRoutePluginHuawei from .bgp_route_juniper import BGPRoutePluginJuniper from .mikrotik_garbage_output import MikrotikGarbageOutput @@ -13,6 +14,7 @@ "BGPRoutePluginFrr", "BGPRoutePluginJuniper", "BGPRoutePluginHuawei", + "BGPSTRRoutePluginHuawei", "MikrotikGarbageOutput", "RemoveCommand", ) diff --git a/hyperglass/plugins/_builtin/bgp_route_huawei.py b/hyperglass/plugins/_builtin/bgp_route_huawei.py index 172d9068f..484dc9cc8 100644 --- a/hyperglass/plugins/_builtin/bgp_route_huawei.py +++ b/hyperglass/plugins/_builtin/bgp_route_huawei.py @@ -21,7 +21,10 @@ class BGPRoutePluginHuawei(InputPlugin): "huawei", "huawei_vrpv8", ) - directives: t.Sequence[str] = ("__hyperglass_huawei_bgp_route__",) + directives: t.Sequence[str] = ( + "__hyperglass_huawei_bgp_route__", + "__hyperglass_huawei_bgp_route_table__", + ) """ Huawei BGP Route Input Plugin @@ -33,15 +36,25 @@ class BGPRoutePluginHuawei(InputPlugin): def transform(self, query: "Query") -> InputPluginTransformReturn: target = query.query_target - if not target or not isinstance(target, list) or len(target) == 0: + if not target: return None - target = target[0].strip() + if isinstance(target, list): + if len(target) == 0: + return None + target = target[0].strip() + elif isinstance(target, str): + target = target.strip() + else: + return None # Check for the / in the query target if target.find("/") == -1: return target - target_network = ip_network(target) - - return f"{target_network.network_address!s} {target_network.prefixlen!s}" + try: + target_network = ip_network(target) + return f"{target_network.network_address!s} {target_network.prefixlen!s}" + except ValueError: + # Return the target unchanged if it isn't a valid network + return target diff --git a/hyperglass/plugins/_builtin/bgp_routestr_huawei.py b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py new file mode 100644 index 000000000..e42308344 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py @@ -0,0 +1,80 @@ +"""Coerce a Huawei route table in text format to a standard BGP Table structure.""" + +# Standard Library +import re +from typing import TYPE_CHECKING, List, Sequence + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +# Local +from .._output import OutputPlugin + +if TYPE_CHECKING: + # Project + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + + # Local + from .._output import OutputType + + +def parse_huawei(output: Sequence[str]) -> "OutputDataModel": + """Parse a Huawei BGP text response.""" + result = None + + _log = log.bind(plugin=BGPSTRRoutePluginHuawei.__name__) + + for response in output: + try: + # Parse the text output using the Huawei parser + validated = HuaweiBGPTable.parse_text(response) + bgp_table = validated.bgp_table() + + if result is None: + result = bgp_table + else: + result += bgp_table + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + except Exception as err: + _log.bind(error=str(err)).critical("Failed to parse Huawei BGP output") + raise ParsingError("Error parsing response data") from err + + return result + + +class BGPSTRRoutePluginHuawei(OutputPlugin): + """Coerce a Huawei route table in text format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: Sequence[str] = ("huawei",) + directives: Sequence[str] = ( + "__hyperglass_huawei_bgp_route_table__", + "__hyperglass_huawei_bgp_aspath_table__", + "__hyperglass_huawei_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse Huawei response if data is a string (and is therefore unparsed).""" + _log = log.bind(plugin=self.__class__.__name__) + _log.debug("Processing Huawei output with structured parser") + + should_process = all( + ( + isinstance(output, (list, tuple)), + query.device.platform in self.platforms, + query.device.structured_output is True, + query.device.has_directives(*self.directives), + ) + ) + if should_process: + return parse_huawei(output) + return output diff --git a/hyperglass/plugins/tests/test_bgp_route_huawei.py b/hyperglass/plugins/tests/test_bgp_route_huawei.py new file mode 100644 index 000000000..0485fad7c --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_huawei.py @@ -0,0 +1,75 @@ +"""Huawei VRP structured BGP route parsing tests.""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.config.params import Params +from hyperglass.models.data.bgp_route import BGPRouteTable +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +if t.TYPE_CHECKING: + from hyperglass.state import HyperglassState + +# Representative `display bgp routing-table ` detail output: one best/ +# selected (active) route and one valid-but-not-best route. +SAMPLE = """ + BGP local router ID : 192.0.2.1 + Local AS number : 64500 + Paths: 2 available, 1 best, 1 select + + BGP routing table entry information of 203.0.113.0/24: + From: 198.51.100.1 (192.0.2.254) + Route Duration: 0d00h05m12s + Original nexthop: 198.51.100.1 + Community: 64500:100, 64500:200 + AS-path 64500 64501, origin igp, MED 0, localpref 100, pref-val 0, valid, external, best, select, pre 255 + + BGP routing table entry information of 203.0.113.128/25: + From: 198.51.100.2 (192.0.2.253) + Route Duration: 0d00h01m00s + Original nexthop: 198.51.100.2 + AS-path 64500 64999, origin incomplete, MED 10, localpref 90, pref-val 0, valid, external, pre 255 +""" + + +@pytest.fixture +def params() -> t.Dict[str, t.Any]: + return {} + + +@pytest.fixture +def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: + """Initialize Redis-backed state with params for BGPRoute validation.""" + _state = use_state() + _params = Params(**params) + with _state.cache.pipeline() as pipeline: + pipeline.set("params", _params) + yield _state + _state.clear() + + +def test_huawei_bgp_route(state): + table = HuaweiBGPTable.parse_text(SAMPLE).bgp_table() + + assert isinstance(table, BGPRouteTable), "Parsed result is not a BGPRouteTable" + assert table.count == 2, f"Expected 2 routes, got {table.count}" + + by_prefix = {r.prefix: r for r in table.routes} + assert set(by_prefix) == {"203.0.113.0/24", "203.0.113.128/25"} + + best = by_prefix["203.0.113.0/24"] + assert best.active is True, "best+select route should be active" + assert best.as_path == [64500, 64501], f"Bad as_path: {best.as_path}" + assert best.source_as == 64500, "source_as should be first ASN in path" + assert best.next_hop == "198.51.100.1" + assert "64500:100" in best.communities + + other = by_prefix["203.0.113.128/25"] + assert other.active is False, "non-best route should not be active" + assert other.as_path == [64500, 64999] diff --git a/hyperglass/ui/components/table/cell.tsx b/hyperglass/ui/components/table/cell.tsx index 228576ffc..dc0f3778a 100644 --- a/hyperglass/ui/components/table/cell.tsx +++ b/hyperglass/ui/components/table/cell.tsx @@ -6,12 +6,14 @@ import type { BoxProps } from '@chakra-ui/react'; interface TableCellProps extends Omit { bordersVertical?: [boolean, number]; align?: 'left' | 'right' | 'center'; + dimText?: boolean; } export const TableCell = (props: TableCellProps): JSX.Element => { - const { bordersVertical = [false, 0], align, ...rest } = props; + const { bordersVertical = [false, 0], align, dimText = false, ...rest } = props; const [doVerticalBorders, index] = bordersVertical; const borderLeftColor = useColorValue('blackAlpha.100', 'whiteAlpha.100'); + const filteredTextColor = useColorValue('filtered.400', 'filtered.500'); let borderProps = {}; if (doVerticalBorders && index !== 0) { @@ -25,6 +27,7 @@ export const TableCell = (props: TableCellProps): JSX.Element => { w="1%" textAlign={align} whiteSpace="nowrap" + color={dimText ? filteredTextColor : undefined} {...borderProps} {...rest} /> diff --git a/hyperglass/ui/components/table/main.tsx b/hyperglass/ui/components/table/main.tsx index fd1eeef5f..4e21cb367 100644 --- a/hyperglass/ui/components/table/main.tsx +++ b/hyperglass/ui/components/table/main.tsx @@ -122,6 +122,7 @@ export const Table = (props: TableProps): JSX.Element => { {page.map((row, key) => { prepareRow(row); + const dimText = row.original.filtered; return ( { highlightBg={rowHighlightBg} doHorizontalBorders={bordersHorizontal} highlight={row.values[rowHighlightProp ?? ''] ?? false} + dimText={dimText} {...row.getRowProps()} > {row.cells.map((cell, i) => { @@ -137,6 +139,7 @@ export const Table = (props: TableProps): JSX.Element => { {typeof Cell !== 'undefined' ? ( diff --git a/hyperglass/ui/components/table/row.tsx b/hyperglass/ui/components/table/row.tsx index a0497d7e9..76b33b9d6 100644 --- a/hyperglass/ui/components/table/row.tsx +++ b/hyperglass/ui/components/table/row.tsx @@ -8,6 +8,7 @@ interface TableRowProps extends BoxProps { highlightBg?: Theme.ColorNames; doHorizontalBorders?: boolean; highlight?: boolean; + dimText?: boolean; doStripe?: boolean; index: number; } @@ -19,6 +20,7 @@ export const TableRow = (props: TableRowProps): JSX.Element => { highlight = false, highlightBg = 'primary', doHorizontalBorders = false, + dimText = false, ...rest } = props; @@ -40,12 +42,14 @@ export const TableRow = (props: TableRowProps): JSX.Element => { } const defaultBg = useColorValue('white', 'black'); const color = useOpposingColor(bg ?? defaultBg); + const filteredTextColor = useColorValue('filtered.400', 'filtered.500'); + const rowTextColor = dimText ? filteredTextColor : color; const borderProps = doHorizontalBorders && index !== 0 ? rowBorder : {}; return ( td': { color } }} + css={{ '& > td': { color: rowTextColor } }} fontWeight={highlight ? 'bold' : undefined} _hover={{ cursor: 'pointer', diff --git a/hyperglass/ui/types/globals.d.ts b/hyperglass/ui/types/globals.d.ts index f920e5998..3e1b0dc6a 100644 --- a/hyperglass/ui/types/globals.d.ts +++ b/hyperglass/ui/types/globals.d.ts @@ -30,6 +30,7 @@ export declare global { source_rid: string; peer_rid: string; rpki_state: RPKIState; + filtered: boolean; }; type RouteField = { [K in keyof Route]: Route[K] }; From 7e0c220e4c43fc6653e8898a7a4594ce03ebc655 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:41 +1000 Subject: [PATCH 06/39] feat(external): real-time IP/ASN enrichment service (replaces bgptools) --- .../config/structured-output.mdx | 48 +- docs/pages/configuration/config/web-ui.mdx | 3 + hyperglass/api/state.py | 35 +- hyperglass/api/tasks.py | 6 +- hyperglass/constants.py | 2 +- hyperglass/defaults/directives/huawei.py | 63 +- hyperglass/external/__init__.py | 8 +- hyperglass/external/bgptools.py | 178 ----- hyperglass/external/ip_enrichment.py | 655 ++++++++++++++++++ hyperglass/external/rpki.py | 62 +- hyperglass/external/tests/test_bgptools.py | 49 -- hyperglass/external/tests/test_rpki.py | 8 +- hyperglass/models/config/structured.py | 28 +- hyperglass/models/config/web.py | 1 + hyperglass/models/data/bgp_route.py | 35 +- hyperglass/models/parsing/huawei.py | 394 +++++++++++ hyperglass/plugins/_builtin/__init__.py | 2 + .../plugins/_builtin/bgp_route_huawei.py | 25 +- .../plugins/_builtin/bgp_routestr_huawei.py | 80 +++ .../plugins/tests/test_bgp_route_huawei.py | 75 ++ hyperglass/ui/components/table/cell.tsx | 5 +- hyperglass/ui/components/table/main.tsx | 3 + hyperglass/ui/components/table/row.tsx | 6 +- hyperglass/ui/types/globals.d.ts | 1 + 24 files changed, 1487 insertions(+), 285 deletions(-) delete mode 100644 hyperglass/external/bgptools.py create mode 100644 hyperglass/external/ip_enrichment.py delete mode 100644 hyperglass/external/tests/test_bgptools.py create mode 100644 hyperglass/models/parsing/huawei.py create mode 100644 hyperglass/plugins/_builtin/bgp_routestr_huawei.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_huawei.py diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx index df936253f..bf915d1b6 100644 --- a/docs/pages/configuration/config/structured-output.mdx +++ b/docs/pages/configuration/config/structured-output.mdx @@ -4,19 +4,25 @@ Devices that support responding to a query with structured or easily parsable da - Arista EOS - Juniper Junos +- Huawei VRP When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods: 1. From the router's perspective -2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) +2. From an external validator — either [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) (the default) or a self-hosted [Routinator](https://routinator.docs.nlnetlabs.nl/) instance + +When `structured.rpki.mode` is `external`, the `structured.rpki.backend` field selects which validator to query. The `cloudflare` backend uses Cloudflare's GraphQL API; the `routinator` backend queries the `/validity` HTTP API of a Routinator instance at `structured.rpki.rpki_server_url`. Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user. | Parameter | Type | Default Value | Description | | :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- | -| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use Cloudflare's view (2 above). | -| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. | -| `structured.communities.items` | List of Strings | | List of communities to match. | +| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use an external validator (2 above). | +| `structured.rpki.backend` | String | cloudflare | When `mode` is `external`, the validator to use: `cloudflare` or `routinator`. | +| `structured.rpki.rpki_server_url` | String | | Base URL of the Routinator instance. **Required** when `backend` is `routinator` (e.g. `http://routinator.example.net:8323`). | +| `structured.communities.mode` | String | deny | `deny` hides communities listed in `items`; `permit` shows _only_ those listed; `name` shows all communities and appends a friendly label to any listed in `names`. | +| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny`/`permit`). | +| `structured.communities.names` | Map of String→String | | Map of community → friendly name, used by `name` mode. At least one entry is required when `mode` is `name`. | ### RPKI Examples @@ -28,12 +34,23 @@ structured: mode: router ``` -#### Show RPKI State from a Public/External Perspective +#### Show RPKI State from a Public/External Perspective (Cloudflare) -```yaml filename="config.yaml" copy {2} +```yaml filename="config.yaml" copy {2-3} structured: rpki: mode: external + backend: cloudflare +``` + +#### Validate RPKI State Against a Self-Hosted Routinator + +```yaml filename="config.yaml" copy {2-5} +structured: + rpki: + mode: external + backend: routinator + rpki_server_url: "http://routinator.example.net:8323" ``` ### Community Filtering Examples @@ -59,3 +76,22 @@ structured: - "^65000:.*$" # permit any communities starting with 65000, but no others. - "1234:1" # permit only the 1234:1 community. ``` + +#### Show Friendly Names Alongside Communities + +With `mode: name`, all communities are shown, and any community listed in `names` has its friendly label appended (rendered as ` ()` in the UI). + +```yaml filename="config.yaml" {3-6} +structured: + communities: + mode: name + names: + "65000:100": "Customer Route" + "65000:200": "Peer Route" +``` + +## IP Enrichment + +hyperglass can enrich structured responses with ASN, organization, country, and IXP metadata resolved in real time from [bgp.tools](https://bgp.tools/). This data powers the organization names shown on AS paths and next-hops, and the per-hop detail in structured traceroute. + +Enrichment is performed by an internal service that issues bulk WHOIS lookups to bgp.tools; results are cached in-memory for the life of the process. No configuration is required to make the service available — it is enabled per-feature via the [`structured.ip_enrichment`](#ip-enrichment-configuration) settings documented below. diff --git a/docs/pages/configuration/config/web-ui.mdx b/docs/pages/configuration/config/web-ui.mdx index 761344769..96b5664f7 100644 --- a/docs/pages/configuration/config/web-ui.mdx +++ b/docs/pages/configuration/config/web-ui.mdx @@ -243,6 +243,7 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. | `web.theme.colors.dark` | | | `web.theme.colors.light` | | | `web.theme.colors.gray` | | +| `web.theme.colors.filtered` | | | `web.theme.colors.red` | | | `web.theme.colors.orange` | | | `web.theme.colors.yellow` | | @@ -253,6 +254,8 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. | `web.theme.colors.pink` | | | `web.theme.colors.purple` | | +The `filtered` color is used to dim BGP routes that are present but not selected/active in the structured table (for example, MikroTik routes flagged filtered, disabled, or unreachable). It defaults to the same value as `gray`. + ##### Functional Colors | Parameter | Default Value | diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py index f41a96d78..5dfd75ef9 100644 --- a/hyperglass/api/state.py +++ b/hyperglass/api/state.py @@ -1,27 +1,52 @@ """hyperglass state dependencies.""" # Standard Library +import asyncio import typing as t # Project -from hyperglass.state import use_state +from hyperglass.state import HyperglassState, use_state +from hyperglass.exceptions.private import StateError + + +def _is_retryable(attr: t.Optional[str]) -> bool: + """Whether a StateError for this attr is a transient (not-yet-populated) error. + + A request can arrive before the Redis-backed state is populated on startup, + which surfaces as a StateError. Referencing an attribute that does not exist + on HyperglassState is a permanent programming error and must not be retried. + """ + return attr is None or attr in ("cache", "redis") or attr in HyperglassState.properties() + + +async def _get_state_with_retry( + attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5 +) -> t.Any: + """Get hyperglass state, retrying transient startup StateErrors.""" + for attempt in range(1, max_retries + 1): + try: + return use_state(attr) + except StateError: + if not _is_retryable(attr) or attempt == max_retries: + raise + await asyncio.sleep(retry_delay) async def get_state(attr: t.Optional[str] = None): """Get hyperglass state as a FastAPI dependency.""" - return use_state(attr) + return await _get_state_with_retry(attr) async def get_params(): """Get hyperglass params as FastAPI dependency.""" - return use_state("params") + return await _get_state_with_retry("params") async def get_devices(): """Get hyperglass devices as FastAPI dependency.""" - return use_state("devices") + return await _get_state_with_retry("devices") async def get_ui_params(): """Get hyperglass ui_params as FastAPI dependency.""" - return use_state("ui_params") + return await _get_state_with_retry("ui_params") diff --git a/hyperglass/api/tasks.py b/hyperglass/api/tasks.py index 0a7d16d46..8527c53be 100644 --- a/hyperglass/api/tasks.py +++ b/hyperglass/api/tasks.py @@ -10,7 +10,7 @@ # Project from hyperglass.log import log -from hyperglass.external import Webhook, bgptools +from hyperglass.external import Webhook, network_info from hyperglass.models.api import Query if t.TYPE_CHECKING: @@ -52,7 +52,7 @@ async def send_webhook( else: host = request.client.host - network_info = await bgptools.network_info(host) + network_result = await network_info(host) async with Webhook(params.logging.http) as hook: await hook.send( @@ -60,7 +60,7 @@ async def send_webhook( **data.dict(), "headers": headers, "source": host, - "network": network_info.get(host, {}), + "network": network_result.get(host, {}), "timestamp": timestamp, } ) diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 0139c7415..1886ceb68 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -19,7 +19,7 @@ TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos") -SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos") +SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos", "huawei") CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml") diff --git a/hyperglass/defaults/directives/huawei.py b/hyperglass/defaults/directives/huawei.py index f8d3ef0cf..995a42c6c 100644 --- a/hyperglass/defaults/directives/huawei.py +++ b/hyperglass/defaults/directives/huawei.py @@ -15,6 +15,9 @@ "Huawei_BGPRoute", "Huawei_Ping", "Huawei_Traceroute", + "HuaweiBGPRouteTable", + "HuaweiBGPASPathTable", + "HuaweiBGPCommunityTable", ) NAME = "Huawei VRP" @@ -36,7 +39,8 @@ ), ], field=Text(description="IP Address, Prefix, or Hostname"), - plugins=["bgp_route_huawei"], + plugins=["bgp_route_huawei", "bgp_routestr_huawei"], + table_output="__hyperglass_huawei_bgp_route_table__", platforms=PLATFORMS, ) @@ -54,6 +58,7 @@ ) ], field=Text(description="AS Path Regular Expression"), + table_output="__hyperglass_huawei_bgp_aspath_table__", platforms=PLATFORMS, ) @@ -71,6 +76,7 @@ ) ], field=Text(description="BGP Community String"), + table_output="__hyperglass_huawei_bgp_community_table__", platforms=PLATFORMS, ) @@ -111,3 +117,58 @@ field=Text(description="IP Address, Prefix, or Hostname"), platforms=PLATFORMS, ) + +# Table Output Directives + +HuaweiBGPRouteTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="display bgp routing-table {target} | no-more", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="display bgp ipv6 routing-table {target} | no-more", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +HuaweiBGPASPathTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table regular-expression "{target}"', + 'display bgp ipv6 routing-table regular-expression "{target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +HuaweiBGPCommunityTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_community_table__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table community "{target}"', + 'display bgp ipv6 routing-table community "{target}"', + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) diff --git a/hyperglass/external/__init__.py b/hyperglass/external/__init__.py index 4d9d22e9e..827bf3314 100644 --- a/hyperglass/external/__init__.py +++ b/hyperglass/external/__init__.py @@ -5,7 +5,13 @@ from .slack import SlackHook from .generic import BaseExternal from .msteams import MSTeams -from .bgptools import network_info, network_info_sync +from .ip_enrichment import ( + network_info, + network_info_sync, + lookup_ip, + lookup_asn_name, + lookup_asn_country, +) from .webhooks import Webhook from .http_client import HTTPClient diff --git a/hyperglass/external/bgptools.py b/hyperglass/external/bgptools.py deleted file mode 100644 index f2fd7f6a9..000000000 --- a/hyperglass/external/bgptools.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Query & parse data from bgp.tools. - -- See https://bgp.tools/credits for acknowledgements and licensing. -- See https://bgp.tools/kb/api for query documentation. -""" - -# Standard Library -import re -import typing as t -import asyncio -from ipaddress import IPv4Address, IPv6Address, ip_address - -# Project -from hyperglass.log import log -from hyperglass.state import use_state - -DEFAULT_KEYS = ("asn", "ip", "prefix", "country", "rir", "allocated", "org") - -CACHE_KEY = "hyperglass.external.bgptools" - -TargetDetail = t.TypedDict( - "TargetDetail", - {"asn": str, "ip": str, "country": str, "rir": str, "allocated": str, "org": str}, -) - -TargetData = t.Dict[str, TargetDetail] - - -def default_ip_targets(*targets: str) -> t.Tuple[TargetData, t.Tuple[str, ...]]: - """Construct a mapping of default data and other data that should be queried. - - Targets in the mapping don't need to be queried and already have default values. Targets in the - query tuple should be queried. - """ - default_data = {} - query = () - for target in targets: - detail: TargetDetail = dict.fromkeys(DEFAULT_KEYS, "None") - try: - valid: t.Union[IPv4Address, IPv6Address] = ip_address(target) - - checks = ( - (valid.version == 6 and valid.is_site_local, "Site Local Address"), - (valid.is_loopback, "Loopback Address"), - (valid.is_multicast, "Multicast Address"), - (valid.is_link_local, "Link Local Address"), - (valid.is_private, "Private Address"), - ) - for exp, rir in checks: - if exp is True: - detail["rir"] = rir - break - - should_query = any((valid.is_global, valid.is_unspecified, valid.is_reserved)) - - if not should_query: - detail["ip"] = str(target) - default_data[str(target)] = detail - elif should_query: - query += (str(target),) - - except ValueError: - pass - - return default_data, query - - -def parse_whois(output: str, targets: t.List[str]) -> TargetDetail: - """Parse raw whois output from bgp.tools. - - Sample output: - AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name - 13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | 2010-07-14 | Cloudflare, Inc. - """ - - def lines(raw): - """Generate clean string values for each column.""" - for r in (r for r in raw.split("\n") if r): - fields = (re.sub(r"(\n|\r)", "", field).strip(" ") for field in r.split("|")) - yield fields - - data = {} - - for line in lines(output): - # Unpack each line's parsed values. - asn, ip, prefix, country, rir, allocated, org = line - - # Match the line to the item in the list of resources to query. - if ip in targets: - i = targets.index(ip) - data[targets[i]] = { - "asn": asn, - "ip": ip, - "prefix": prefix, - "country": country, - "rir": rir, - "allocated": allocated, - "org": org, - } - log.bind(data=data).debug("Parsed bgp.tools data") - return data - - -async def run_whois(targets: t.List[str]) -> str: - """Open raw socket to bgp.tools and execute query.""" - - # Construct bulk query - query = "\n".join(("begin", *targets, "end\n")).encode() - - # Open the socket to bgp.tools - log.debug("Opening connection to bgp.tools") - reader, writer = await asyncio.open_connection("bgp.tools", port=43) - - # Send the query - writer.write(query) - if writer.can_write_eof(): - writer.write_eof() - await writer.drain() - - # Read the response - response = b"" - while True: - data = await reader.read(128) - if data: - response += data - else: - log.debug("Closing connection to bgp.tools") - writer.close() - break - - return response.decode() - - -async def network_info(*targets: str) -> TargetData: - """Get ASN, Containing Prefix, and other info about an internet resource.""" - - default_data, query_targets = default_ip_targets(*targets) - - cache = use_state("cache") - - # Set default data structure. - query_data = {t: dict.fromkeys(DEFAULT_KEYS, "") for t in query_targets} - - # Get all cached bgp.tools data. - cached = cache.get_map(CACHE_KEY) or {} - - # Try to use cached data for each of the items in the list of - # resources. - for target in (target for target in query_targets if target in cached): - # Reassign the cached network info to the matching resource. - query_data[target] = cached[target] - log.bind(target=target).debug("Using cached network info") - - # Remove cached items from the resource list so they're not queried. - targets = [t for t in query_targets if t not in cached] - - try: - if targets: - whoisdata = await run_whois(targets) - - if whoisdata: - # If the response is not empty, parse it. - query_data.update(parse_whois(whoisdata, targets)) - - # Cache the response - for target in targets: - cache.set_map_item(CACHE_KEY, target, query_data[target]) - log.bind(target=t).debug("Cached network info") - - except Exception as err: - log.error(err) - - return {**default_data, **query_data} - - -def network_info_sync(*targets: str) -> TargetData: - """Get ASN, Containing Prefix, and other info about an internet resource.""" - return asyncio.run(network_info(*targets)) diff --git a/hyperglass/external/ip_enrichment.py b/hyperglass/external/ip_enrichment.py new file mode 100644 index 000000000..1f52609df --- /dev/null +++ b/hyperglass/external/ip_enrichment.py @@ -0,0 +1,655 @@ +"""IP enrichment: ASN and IXP lookups for hyperglass. + +Uses bgp.tools for both ASN and IXP lookups. +Provides lookup_ip, lookup_asn_name and network_info compatibility APIs. +""" + +import asyncio +import typing as t +from ipaddress import ip_address, IPv4Address, IPv6Address +import socket + +from hyperglass.log import log +from hyperglass.settings import Settings + + +class IPInfo: + """Result of IP lookup.""" + + def __init__( + self, + ip: str, + asn: t.Optional[int] = None, + asn_name: t.Optional[str] = None, + prefix: t.Optional[str] = None, + country: t.Optional[str] = None, + is_ixp: bool = False, + ixp_name: t.Optional[str] = None, + ): + self.ip = ip + self.asn = asn + self.asn_name = asn_name + self.prefix = prefix + self.country = country + self.is_ixp = is_ixp + self.ixp_name = ixp_name + + +class IPEnrichmentService: + """Simplified IP enrichment service using only BGP.TOOLS for all lookups.""" + + def __init__(self): + # Runtime caches for performance + self._ip_cache: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + self.asn_info: t.Dict[int, t.Dict[str, str]] = {} # asn -> {name, country} + + async def _query_bgp_tools_for_ip( + self, ip_str: str + ) -> t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]: + """Query bgp.tools for a single IP. + + Returns (asn_int, asn_name, prefix) or (None, None, None) on failure. + """ + # Check cache first + if ip_str in self._ip_cache: + return self._ip_cache[ip_str] + + # Use TCP WHOIS on bgp.tools:43 + def _whois_blocking( + single_ips: t.List[str], + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + out: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + host = "bgp.tools" + port = 43 + send_keys = [f"AS{q}" if q.isdigit() else q for q in single_ips] + payload = "begin\n" + "\n".join(send_keys) + "\nend\n" + try: + with socket.create_connection((host, port), timeout=10) as s: + s.settimeout(10) + s.sendall(payload.encode("utf-8")) + parts = [] + try: + while True: + chunk = s.recv(4096) + if not chunk: + break + parts.append(chunk) + except socket.timeout: + pass + + raw = b"".join(parts).decode("utf-8", errors="replace") + # Parse lines like: "13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | ... | Cloudflare, Inc." + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + if "|" in line: + cols = [c.strip() for c in line.split("|")] + try: + asn = int(cols[0]) if cols[0].isdigit() else None + except Exception: + asn = None + ipcol = cols[1] if len(cols) > 1 else None + prefix = cols[2] if len(cols) > 2 else None + org = cols[-1] if len(cols) > 0 else None + if ipcol: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + else: + # Fallback parsing + parts_line = line.split() + if len(parts_line) >= 3: + try: + asn = int(parts_line[0]) + except Exception: + asn = None + ipcol = parts_line[1] + prefix = parts_line[2] + org = " ".join(parts_line[3:]) if len(parts_line) > 3 else None + if ipcol: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + + # Map results back to original query keys + mapped: t.Dict[ + str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]] + ] = {} + for orig, sent in zip(single_ips, send_keys): + if sent in out: + mapped[orig] = out[sent] + elif orig in out: + mapped[orig] = out[orig] + else: + if orig.isdigit(): + if f"AS{orig}" in out: + mapped[orig] = out[f"AS{orig}"] + elif orig in out: + mapped[orig] = out[orig] + else: + mapped[orig] = (None, None, None) + else: + mapped[orig] = (None, None, None) + return mapped + except Exception: + for ip in single_ips: + out[ip] = (None, None, None) + return out + + loop = asyncio.get_running_loop() + resp_map = await loop.run_in_executor(None, _whois_blocking, [ip_str]) + asn, org, prefix = resp_map.get(ip_str, (None, None, None)) + # Cache result + self._ip_cache[ip_str] = (asn, org, prefix) + return (asn, org, prefix) + + async def _query_bgp_tools_bulk( + self, ips: t.List[str] + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + """Query bgp.tools for multiple IPs using a single connection. + + Returns a mapping ip -> (asn, asn_name, prefix). + """ + + def _whois_bulk_blocking( + bulk_ips: t.List[str], + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + host = "bgp.tools" + port = 43 + out: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + send_keys = [f"AS{q}" if q.isdigit() else q for q in bulk_ips] + payload = "begin\n" + "\n".join(send_keys) + "\nend\n" + try: + with socket.create_connection((host, port), timeout=15) as s: + s.settimeout(15) + s.sendall(payload.encode("utf-8")) + parts = [] + try: + while True: + chunk = s.recv(8192) + if not chunk: + break + parts.append(chunk) + if sum(len(p) for p in parts) > 512 * 1024: + break + except socket.timeout: + pass + + raw = b"".join(parts).decode("utf-8", errors="replace") + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + if "|" in line: + cols = [c.strip() for c in line.split("|")] + try: + asn = int(cols[0]) if cols[0].isdigit() else None + except Exception: + asn = None + ipcol = cols[1] if len(cols) > 1 else None + prefix = cols[2] if len(cols) > 2 else None + org = cols[-1] if len(cols) > 0 else None + # Check if this is an exchange IP (IXP) + is_ixp = any( + keyword in (org or "").lower() + for keyword in ["ix", "exchange", "peering"] + ) + if ipcol: + # If it's an IXP, mark it specially + if is_ixp: + out[ipcol] = ( + None, + org, + prefix, + ) # Use None for ASN to indicate IXP + else: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + else: + parts_line = line.split() + if len(parts_line) >= 3: + try: + asn = int(parts_line[0]) + except Exception: + asn = None + ipcol = parts_line[1] + prefix = parts_line[2] + org = " ".join(parts_line[3:]) if len(parts_line) > 3 else None + out[ipcol] = (asn, org, prefix) + + # Map results back to original query keys + mapped: t.Dict[ + str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]] + ] = {} + for orig, sent in zip(bulk_ips, send_keys): + if sent in out: + mapped[orig] = out[sent] + elif orig in out: + mapped[orig] = out[orig] + else: + if orig.isdigit(): + if f"AS{orig}" in out: + mapped[orig] = out[f"AS{orig}"] + elif orig in out: + mapped[orig] = out[orig] + else: + mapped[orig] = (None, None, None) + else: + mapped[orig] = (None, None, None) + return mapped + except Exception: + for ip in bulk_ips: + out[ip] = (None, None, None) + return out + + loop = asyncio.get_running_loop() + resp = await loop.run_in_executor(None, _whois_bulk_blocking, ips) + return resp + + async def lookup_ips_bulk(self, ips: t.List[str]) -> t.Dict[str, IPInfo]: + """Bulk lookup for multiple IPs using BGP.TOOLS.""" + results: t.Dict[str, IPInfo] = {} + + # Prepare list for query + query_ips: t.List[str] = [] + for ip in ips: + try: + target_ip = ip_address(ip) + except Exception: + results[ip] = IPInfo(ip) + continue + + # Check for private/reserved addresses + if target_ip.is_private or target_ip.is_reserved or target_ip.is_loopback: + results[ip] = IPInfo(ip, asn=0, asn_name="Private", prefix="Private Network") + continue + + query_ips.append(ip) + + # Query BGP.TOOLS in bulk for all IPs + if query_ips: + bulk = await self._query_bgp_tools_bulk(query_ips) + for ip in query_ips: + asn, asn_name, prefix = bulk.get(ip, (None, None, None)) + + # Check if this is an IXP (indicated by None ASN from our parsing) + is_ixp = asn is None and asn_name is not None + + if is_ixp: + results[ip] = IPInfo(ip, is_ixp=True, ixp_name=asn_name) + elif asn: + try: + self.asn_info[int(asn)] = {"name": asn_name or f"AS{asn}", "country": ""} + except Exception: + pass + results[ip] = IPInfo(ip, asn=asn, asn_name=asn_name, prefix=prefix) + else: + results[ip] = IPInfo(ip, asn=0, asn_name="Unknown") + + return results + + async def lookup_ip(self, ip_str: str) -> IPInfo: + """Lookup an IP address and return ASN or IXP information.""" + if Settings.debug: + log.debug(f"Looking up IP {ip_str}") + + try: + target_ip = ip_address(ip_str) + except ValueError: + if Settings.debug: + log.debug(f"Invalid IP address: {ip_str}") + return IPInfo(ip_str) + + # Check private/reserved addresses + if target_ip.is_private or target_ip.is_reserved or target_ip.is_loopback: + if Settings.debug: + log.debug(f"IP {ip_str} is in private/reserved range") + return IPInfo(ip_str, asn=0, asn_name="Private", prefix="Private Network") + + # Query bgp.tools for the IP + try: + asn, asn_name, prefix = await self._query_bgp_tools_for_ip(ip_str) + + # Check if this is an IXP (indicated by keywords in organization name) + is_ixp = asn_name and any( + keyword in asn_name.lower() for keyword in ["ix", "exchange", "peering"] + ) + + if is_ixp: + if Settings.debug: + log.debug(f"Found IXP match for {ip_str}: {asn_name}") + return IPInfo(ip_str, is_ixp=True, ixp_name=asn_name) + elif asn: + # Cache result + try: + self.asn_info[int(asn)] = {"name": asn_name or f"AS{asn}", "country": ""} + except Exception: + pass + if Settings.debug: + log.debug(f"BGP.tools lookup for {ip_str}: AS{asn} ({asn_name})") + return IPInfo(ip_str, asn=asn, asn_name=asn_name, prefix=prefix) + except Exception as e: + if Settings.debug: + log.debug(f"BGP.tools lookup failed for {ip_str}: {e}") + + # No match found + if Settings.debug: + log.debug(f"No enrichment data found for {ip_str}") + return IPInfo(ip_str, asn=0, asn_name="Unknown") + + async def lookup_asn_name(self, asn: int) -> str: + """Get the organization name for an ASN.""" + asn_data = self.asn_info.get(int(asn), {}) + return asn_data.get("name") or f"AS{asn}" + + async def lookup_asn_country(self, asn: int) -> str: + """Get the country code for an ASN, if known.""" + asn_data = self.asn_info.get(int(asn), {}) + return asn_data.get("country") or "" + + +# Global service instance +_service = IPEnrichmentService() + + +# Public API functions +async def lookup_ip(ip_address: str) -> IPInfo: + """Lookup an IP address and return ASN or IXP information.""" + return await _service.lookup_ip(ip_address) + + +async def lookup_asn_name(asn: int) -> str: + """Get the organization name for an ASN number.""" + return await _service.lookup_asn_name(asn) + + +async def lookup_asn_country(asn: int) -> str: + """Get the country code for an ASN number.""" + return await _service.lookup_asn_country(asn) + + +async def lookup_asns_bulk(asns: t.List[t.Union[str, int]]) -> t.Dict[str, t.Dict[str, str]]: + """Bulk lookup ASN organization names and countries. + + Args: + asns: List of ASN numbers (as strings like "12345" or integers) + + Returns: + Dict mapping ASN string to {"name": org_name, "country": country_code} + Example: {"12345": {"name": "Example ISP", "country": "US"}} + """ + results = {} + + # Normalize ASN list to strings and filter invalids + requested: list[str] = [] + for asn in asns: + if asn is None: + continue + if str(asn) == "IXP": + continue + try: + _ = int(asn) + requested.append(str(asn)) + except (ValueError, TypeError): + continue + + # Identify ASNs missing a human-friendly name so we can attempt a live WHOIS + missing: list[str] = [] + for asn in requested: + try: + ai = _service.asn_info.get(int(asn), {}) + name = ai.get("name") if isinstance(ai, dict) else None + if not name or name == f"AS{asn}": + missing.append(asn) + except Exception: + missing.append(asn) + + # If we have missing ASNs, try a live bgp.tools WHOIS bulk query to fetch org names + if missing and hasattr(_service, "_query_bgp_tools_bulk"): + try: + log.debug("lookup_asns_bulk: querying bgp.tools for missing ASNs: {}", missing) + queries = [f"AS{a}" for a in missing] + resp = await _service._query_bgp_tools_bulk(queries) + # resp maps query -> (asn_int, org, prefix) + for asn in missing: + q = f"AS{asn}" + entry = resp.get(q) or resp.get(str(asn)) + if entry: + _, org, _ = entry + if org: + try: + _service.asn_info[int(asn)] = {"name": org, "country": ""} + log.debug("lookup_asns_bulk: updated asn_info[{}] = {}", asn, org) + except Exception: + pass + except Exception as e: + log.debug("lookup_asns_bulk: bgp.tools lookup failed: {}", e) + + # Build final results from asn_info (may include newly-populated entries) + for asn in requested: + try: + asn_int = int(asn) + asn_data = _service.asn_info.get(asn_int, {}) + results[asn] = { + "name": asn_data.get("name", f"AS{asn}"), + "country": asn_data.get("country", ""), + } + except Exception: + results[asn] = {"name": f"AS{asn}", "country": ""} + + return results + + +async def refresh_ip_enrichment_data() -> bool: + """Manually refresh IP enrichment data.""" + log.info("Manual refresh requested") + # Since we no longer cache data, just return True to indicate the feature is available + return True + + +def get_data_status() -> dict: + """Get status information about IP enrichment data.""" + status = { + "mode": "real-time", + "data_source": "BGP.TOOLS", + "cache_enabled": False, + "last_update": "Real-time lookups via BGP.TOOLS", + "data_counts": { + "cached_ips": len(_service._ip_cache), + "cached_asns": len(_service.asn_info), + }, + } + return status + + +# Compatibility functions for existing code +TargetDetail = t.TypedDict( + "TargetDetail", + { + "asn": str, + "ip": str, + "prefix": str, + "country": str, + "rir": str, + "allocated": str, + "org": str, + }, +) + +TargetData = t.Dict[str, TargetDetail] + + +def default_ip_targets(*targets: str) -> t.Tuple[TargetData, t.List[str]]: + """Filter targets and create default data for private/special addresses.""" + _log = log.bind(source="ip_enrichment") + + default_data: TargetData = {} + query_targets: t.List[str] = [] + + for target in targets: + try: + target_ip = ip_address(target) + + # Check for special address types + special_types = [ + (target_ip.is_loopback, "Loopback Address"), + (target_ip.is_multicast, "Multicast Address"), + (target_ip.is_link_local, "Link Local Address"), + (target_ip.is_private, "Private Address"), + (target_ip.version == 6 and target_ip.is_site_local, "Site Local Address"), + ] + + is_special = False + for check, rir_type in special_types: + if check: + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": rir_type, + "allocated": "None", + "org": "None", + } + is_special = True + break + + if not is_special and (target_ip.is_global or target_ip.is_unspecified): + query_targets.append(target) + elif not is_special: + # Other non-global addresses + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Reserved Address", + "allocated": "None", + "org": "None", + } + + except ValueError: + # Invalid IP address + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Invalid Address", + "allocated": "None", + "org": "None", + } + + return default_data, query_targets + + +async def network_info(*targets: str) -> TargetData: + """Get network information using IP enrichment - compatibility function.""" + _log = log.bind(source="ip_enrichment") + + default_data, query_targets = default_ip_targets(*targets) + + if not query_targets: + _log.debug("No valid global IPs to query") + return default_data + + try: + _log.info(f"Enriching {len(query_targets)} IP addresses using bulk lookup") + + # Use the bulk lookup to query bgp.tools efficiently + query_data = {} + bulk_results = await _service.lookup_ips_bulk(query_targets) + + for target, ip_info in bulk_results.items(): + # Convert to TargetDetail format + if ip_info.is_ixp and ip_info.ixp_name: + detail: TargetDetail = { + "asn": "IXP", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "IXP", + "allocated": "None", + "org": ip_info.ixp_name, + } + elif ip_info.asn is not None and ip_info.asn != 0: + detail = { + "asn": str(ip_info.asn), + "ip": target, + "prefix": ip_info.prefix or "None", + "country": ip_info.country or "None", + "rir": "UNKNOWN", + "allocated": "None", + "org": ip_info.asn_name or "None", + } + elif ip_info.asn == 0: + detail = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Unknown", + "allocated": "None", + "org": "None", + } + else: + detail = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Unknown", + "allocated": "None", + "org": "None", + } + + query_data[target] = detail + + if ip_info.is_ixp: + _log.debug(f"Enriched {target}: IXP={ip_info.ixp_name}") + elif ip_info.asn: + _log.debug(f"Enriched {target}: AS{ip_info.asn} ({ip_info.asn_name})") + else: + _log.debug(f"No enrichment data found for {target}") + + except Exception as e: + _log.error(f"Error in network_info lookup: {e}") + # Return default data for all targets on error + query_data = {} + for target in query_targets: + query_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Error", + "allocated": "None", + "org": "None", + } + + return {**default_data, **query_data} + + +def network_info_sync(*targets: str) -> TargetData: + """Synchronous wrapper for network_info.""" + return asyncio.run(network_info(*targets)) + + +async def network_info_single(target: str) -> TargetDetail: + """Get network information for a single IP address.""" + result = await network_info(target) + return result[target] diff --git a/hyperglass/external/rpki.py b/hyperglass/external/rpki.py index 139ccf8fc..a5c480513 100644 --- a/hyperglass/external/rpki.py +++ b/hyperglass/external/rpki.py @@ -3,57 +3,83 @@ # Standard Library import typing as t +# Third Party +import httpx + # Project from hyperglass.log import log from hyperglass.state import use_state from hyperglass.external._base import BaseExternal if t.TYPE_CHECKING: - # Standard Library from ipaddress import IPv4Address, IPv6Address -RPKI_STATE_MAP = {"Invalid": 0, "Valid": 1, "NotFound": 2, "DEFAULT": 3} -RPKI_NAME_MAP = {v: k for k, v in RPKI_STATE_MAP.items()} +# Maps normalized (lower-case, separators stripped) backend state names to the +# integer states hyperglass uses internally. +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "notfound": 2, + "unknown": 2, + "default": 3, +} +# Canonical integer -> display name, kept stable for logging. +RPKI_NAME_MAP = {0: "Invalid", 1: "Valid", 2: "NotFound", 3: "DEFAULT"} CACHE_KEY = "hyperglass.external.rpki" -def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, str]) -> int: +def _normalize_state(value: str) -> int: + """Normalize a backend RPKI state string to an internal integer state.""" + key = str(value).strip().lower().replace("-", "").replace("_", "") + return RPKI_STATE_MAP.get(key, 3) + + +def rpki_state( + prefix: t.Union["IPv4Address", "IPv6Address", str], + asn: t.Union[int, str], + backend: str = "cloudflare", + rpki_server_url: str = "", +) -> int: """Get RPKI state and map to expected integer.""" _log = log.bind(prefix=prefix, asn=asn) _log.debug("Validating RPKI State") cache = use_state("cache") - state = 3 ro = f"{prefix!s}@{asn!s}" cached = cache.get_map(CACHE_KEY, ro) - if cached is not None: state = cached else: - ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' - query = ql.format(prefix, asn) - _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") try: - with BaseExternal(base_url="https://rpki.cloudflare.com") as client: - response = client._post("/api/graphql", data={"query": query}) - try: + if backend == "cloudflare": + ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' + query = ql.format(prefix, asn) + _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") + with BaseExternal(base_url="https://rpki.cloudflare.com") as client: + response = client._post("/api/graphql", data={"query": query}) validation_state = response["data"]["validation"]["state"] - except KeyError as missing: - _log.error("Response from Cloudflare missing key '{}': {!r}", missing, response) - validation_state = 3 + elif backend == "routinator": + url = f"{rpki_server_url.rstrip('/')}/validity" + _log.bind(url=url).debug("Routinator RPKI HTTP Query") + response = httpx.get( + url, params={"asn": str(asn), "prefix": str(prefix)}, timeout=5 + ) + response.raise_for_status() + data = response.json() + validation_state = data["validated_route"]["validity"]["state"] + else: + raise ValueError(f"Unknown RPKI backend: {backend}") - state = RPKI_STATE_MAP[validation_state] + state = _normalize_state(validation_state) cache.set_map_item(CACHE_KEY, ro, state) except Exception as err: log.error(err) - # Don't cache the state when an error produced it. state = 3 msg = "RPKI Validation State for {} via AS{} is {}".format(prefix, asn, RPKI_NAME_MAP[state]) if cached is not None: msg += " [CACHED]" - log.debug(msg) return state diff --git a/hyperglass/external/tests/test_bgptools.py b/hyperglass/external/tests/test_bgptools.py deleted file mode 100644 index 542c1dc28..000000000 --- a/hyperglass/external/tests/test_bgptools.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Test bgp.tools interactions.""" - -# Standard Library -import asyncio - -# Third Party -import pytest - -# Local -from ..bgptools import run_whois, parse_whois, network_info - -WHOIS_OUTPUT = """AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name -13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | 2010-07-14 | Cloudflare, Inc.""" - - -# Ignore asyncio deprecation warning about loop -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_network_info(): - checks = ( - ("192.0.2.1", {"asn": "None", "rir": "Private Address"}), - ("127.0.0.1", {"asn": "None", "rir": "Loopback Address"}), - ("fe80:dead:beef::1", {"asn": "None", "rir": "Link Local Address"}), - ("2001:db8::1", {"asn": "None", "rir": "Private Address"}), - ("1.1.1.1", {"asn": "13335", "rir": "ARIN"}), - ) - for addr, fields in checks: - info = asyncio.run(network_info(addr)) - assert addr in info - for key, expected in fields.items(): - assert info[addr][key] == expected - - -# Ignore asyncio deprecation warning about loop -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_whois(): - addr = "192.0.2.1" - response = asyncio.run(run_whois([addr])) - assert isinstance(response, str) - assert response != "" - - -def test_whois_parser(): - addr = "1.1.1.1" - result = parse_whois(WHOIS_OUTPUT, [addr]) - assert isinstance(result, dict) - assert addr in result, "Address missing" - assert result[addr]["asn"] == "13335" - assert result[addr]["rir"] == "ARIN" - assert result[addr]["org"] == "Cloudflare, Inc." diff --git a/hyperglass/external/tests/test_rpki.py b/hyperglass/external/tests/test_rpki.py index 01438e717..d21d50559 100644 --- a/hyperglass/external/tests/test_rpki.py +++ b/hyperglass/external/tests/test_rpki.py @@ -19,8 +19,8 @@ def test_rpki(): result = rpki_state(prefix, asn) result_name = RPKI_NAME_MAP.get(result, "No Name") expected_name = RPKI_NAME_MAP.get(expected, "No Name") - assert result == expected, ( - "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( - prefix, asn, result, result_name, expected, expected_name - ) + assert ( + result == expected + ), "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( + prefix, asn, result, result_name, expected, expected_name ) diff --git a/hyperglass/models/config/structured.py b/hyperglass/models/config/structured.py index 4373e8187..d672ab82e 100644 --- a/hyperglass/models/config/structured.py +++ b/hyperglass/models/config/structured.py @@ -3,11 +3,15 @@ # Standard Library import typing as t +# Third Party +from pydantic import ValidationInfo, field_validator, model_validator + # Local from ..main import HyperglassModel -StructuredCommunityMode = t.Literal["permit", "deny"] +StructuredCommunityMode = t.Literal["permit", "deny", "name"] StructuredRPKIMode = t.Literal["router", "external"] +StructuredRPKIBackend = t.Literal["cloudflare", "routinator"] class StructuredCommunities(HyperglassModel): @@ -15,12 +19,34 @@ class StructuredCommunities(HyperglassModel): mode: StructuredCommunityMode = "deny" items: t.List[str] = [] + names: t.Dict[str, str] = {} + + @field_validator("names") + def validate_names(cls, value: t.Dict[str, str], info: ValidationInfo) -> t.Dict[str, str]: + """Require at least one community mapping when mode is 'name'.""" + if info.data and info.data.get("mode") == "name" and not value: + raise ValueError( + "When using mode 'name', at least one community mapping must be " + "provided in 'names'" + ) + return value class StructuredRpki(HyperglassModel): """Control structured data response for RPKI state.""" mode: StructuredRPKIMode = "router" + backend: StructuredRPKIBackend = "cloudflare" + rpki_server_url: str = "" + + @model_validator(mode="after") + def validate_routinator_url(self) -> "StructuredRpki": + """Require a server URL when the routinator backend is selected.""" + if self.backend == "routinator" and not self.rpki_server_url: + raise ValueError( + "structured.rpki.rpki_server_url is required when backend is 'routinator'" + ) + return self class Structured(HyperglassModel): diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index 7916d9e9b..f6881935c 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -137,6 +137,7 @@ class ThemeColors(HyperglassModel): dark: Color = "#010101" light: Color = "#f5f6f7" gray: Color = "#c1c7cc" + filtered: Color = "#c1c7cc" red: Color = "#d84b4b" orange: Color = "#ff6b35" yellow: Color = "#edae49" diff --git a/hyperglass/models/data/bgp_route.py b/hyperglass/models/data/bgp_route.py index a00af4e13..7eadb47ed 100644 --- a/hyperglass/models/data/bgp_route.py +++ b/hyperglass/models/data/bgp_route.py @@ -34,6 +34,7 @@ class BGPRoute(HyperglassModel): source_rid: str peer_rid: str rpki_state: int + filtered: bool = False @field_validator("communities") def validate_communities(cls, value): @@ -42,6 +43,7 @@ def validate_communities(cls, value): Actions: permit: only permit matches deny: only deny matches + name: append friendly names to matching communities """ (structured := use_state("params").structured) @@ -64,6 +66,15 @@ def _deny(comm): break return valid + def _name(comm): + """Append a friendly name to a community when one is mapped.""" + if comm in structured.communities.names: + return f"{comm},{structured.communities.names[comm]}" + return comm + + if structured.communities.mode == "name": + return [_name(c) for c in value] + func_map = {"permit": _permit, "deny": _deny} func = func_map[structured.communities.mode] @@ -80,10 +91,9 @@ def validate_rpki_state(cls, value, info: ValidationInfo): return value if structured.rpki.mode == "external": - # If external validation is enabled, validate the prefix - # & asn with Cloudflare's RPKI API. + # If external validation is enabled, validate the prefix & asn + # with the configured RPKI backend. as_path = info.data.get("as_path", []) - if len(as_path) == 0: # If the AS_PATH length is 0, i.e. for an internal route, # return RPKI Unknown state. @@ -91,14 +101,19 @@ def validate_rpki_state(cls, value, info: ValidationInfo): # Get last ASN in path asn = as_path[-1] - try: - net = ip_network(info.data["prefix"]) - except ValueError: - return 3 + try: + net = ip_network(info.data["prefix"]) + except ValueError: + return 3 - # Only do external RPKI lookups for global prefixes. - if net.is_global: - return rpki_state(prefix=info.data["prefix"], asn=asn) + # Only do external RPKI lookups for global prefixes. + if net.is_global: + return rpki_state( + prefix=info.data["prefix"], + asn=asn, + backend=structured.rpki.backend, + rpki_server_url=structured.rpki.rpki_server_url, + ) return value diff --git a/hyperglass/models/parsing/huawei.py b/hyperglass/models/parsing/huawei.py new file mode 100644 index 000000000..357f121b5 --- /dev/null +++ b/hyperglass/models/parsing/huawei.py @@ -0,0 +1,394 @@ +"""Parser for Huawei VRP.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import ConfigDict, field_validator, model_validator + +# Project +from hyperglass.log import log +from hyperglass.models.data.bgp_route import BGPRoute, BGPRouteTable + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +def remove_prefix(text: str, prefix: str) -> str: + """Remove prefix from text if it exists.""" + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +class HuaweiBase(HyperglassModel, extra="ignore"): + """Base Huawei model.""" + + def __init__(self, **kwargs: t.Any) -> None: + """Initialize Huawei model.""" + super().__init__(**kwargs) + + +class HuaweiPaths(HuaweiBase): + """BGP paths information.""" + + available: int = 0 + best: int = 0 + select: int = 0 + best_external: int = 0 + add_path: int = 0 + + +class HuaweiRouteEntry(HuaweiBase): + """Parse Huawei route entry data.""" + + model_config = ConfigDict(validate_assignment=False) + + prefix: str + from_addr: str = "" + duration: int = 0 + direct_out_interface: str = "" + original_next_hop: str = "" + relay_ip_next_hop: str = "" + relay_ip_out_interface: str = "" + qos: str = "" + communities: t.List[str] = [] + large_communities: t.List[str] = [] + ext_communities: t.List[str] = [] + as_path: t.List[int] = [] + origin: str = "" + metric: int = 0 # MED + local_preference: int = 100 + preference_value: int = 0 + is_valid: bool = False + is_external: bool = False + is_backup: bool = False + is_best: bool = False + is_selected: bool = False + preference: int = 0 + + @property + def next_hop(self) -> str: + """Get next hop (original or relay IP).""" + return self.original_next_hop or self.relay_ip_next_hop + + @property + def age(self) -> str: + """Get age as string.""" + return str(self.duration) + + @property + def weight(self) -> int: + """Get weight (preference).""" + return self.preference + + @property + def med(self) -> int: + """Get MED (metric).""" + return self.metric + + @property + def active(self) -> bool: + """Check if route is active (best or selected).""" + return self.is_best or self.is_selected + + @property + def all_communities(self) -> t.List[str]: + """Get all communities combined.""" + return self.communities + self.large_communities + self.ext_communities + + @property + def source_as(self) -> int: + """Get source AS from AS path (first ASN in the path).""" + return self.as_path[0] if self.as_path else 0 + + @property + def source_rid(self) -> str: + """Get source router ID.""" + return "" + + @property + def peer_rid(self) -> str: + """Get peer router ID.""" + return self.from_addr + + +def _extract_paths(line: str) -> HuaweiPaths: + """Extract paths information from line like 'Paths: 3 available, 1 best, 1 select, 0 best-external, 0 add-path'.""" + paths_data = { + "available": 0, + "best": 0, + "select": 0, + "best_external": 0, + "add_path": 0, + } + + try: + values = remove_prefix(line.strip(), "Paths:").strip().split(",") + for value in values: + parts = value.strip().split(" ") + if len(parts) >= 2: + count = int(parts[0]) + name = parts[1].replace("-", "_") # Convert best-external to best_external + if name in paths_data: + paths_data[name] = count + except (ValueError, IndexError): + log.warning(f"Failed to parse paths line: {line}") + + return HuaweiPaths(**paths_data) + + +def _extract_route_entries(lines: t.List[str]) -> t.List[HuaweiRouteEntry]: + """Extract route entries from lines.""" + routes = [] + + # Split lines into route blocks using empty lines as separators + size = len(lines) + idx_list = [idx + 1 for idx, val in enumerate(lines) if val.strip() == ""] + entries = ( + [ + lines[i:j] + for i, j in zip([0] + idx_list, idx_list + ([size] if idx_list[-1] != size else [])) + ] + if idx_list + else [lines] + ) + + for route_entry in entries: + if not route_entry: + continue + + # Initialize route data + route_data = { + "prefix": "", + "from_addr": "", + "duration": 0, + "direct_out_interface": "", + "original_next_hop": "", + "relay_ip_next_hop": "", + "relay_ip_out_interface": "", + "qos": "", + "communities": [], + "large_communities": [], + "ext_communities": [], + "as_path": [], + "origin": "", + "metric": 0, + "local_preference": 100, + "preference_value": 0, + "is_valid": False, + "is_external": False, + "is_backup": False, + "is_best": False, + "is_selected": False, + "preference": 0, + } + + for info in route_entry: + info = info.strip() + if not info: + continue + + if info.startswith("BGP routing table entry information of"): + route_data["prefix"] = remove_prefix( + info, "BGP routing table entry information of " + ).rstrip(":") + elif info.startswith("From:"): + route_data["from_addr"] = remove_prefix(info, "From: ").split(" (")[0] + elif info.startswith("Route Duration:"): + duration_str = remove_prefix(info, "Route Duration: ") + try: + # Parse format like "84d11h53m07s" + d_match = re.search(r"(\d+)d", duration_str) + h_match = re.search(r"(\d+)h", duration_str) + m_match = re.search(r"(\d+)m", duration_str) + s_match = re.search(r"(\d+)s", duration_str) + + days = int(d_match.group(1)) if d_match else 0 + hours = int(h_match.group(1)) if h_match else 0 + minutes = int(m_match.group(1)) if m_match else 0 + seconds = int(s_match.group(1)) if s_match else 0 + + route_data["duration"] = ( + days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60 + seconds + ) + except: + route_data["duration"] = 0 + elif info.startswith("Direct Out-interface:"): + route_data["direct_out_interface"] = remove_prefix(info, "Direct Out-interface: ") + elif info.startswith("Original nexthop:"): + route_data["original_next_hop"] = remove_prefix(info, "Original nexthop: ") + elif info.startswith("Relay IP Nexthop:"): + route_data["relay_ip_next_hop"] = remove_prefix(info, "Relay IP Nexthop: ") + elif info.startswith("Relay IP Out-Interface:"): + route_data["relay_ip_out_interface"] = remove_prefix( + info, "Relay IP Out-Interface: " + ) + elif info.startswith("Qos information :"): + route_data["qos"] = remove_prefix(info, "Qos information : ") + elif info.startswith("Community:"): + communities_str = remove_prefix(info, "Community: ") + if communities_str and communities_str.lower() != "none": + communities = [ + c.strip().replace("<", "").replace(">", "") + for c in communities_str.split(", ") + ] + route_data["communities"] = [c for c in communities if c] + elif info.startswith("Large-Community:"): + large_communities_str = remove_prefix(info, "Large-Community: ") + if large_communities_str and large_communities_str.lower() != "none": + large_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in large_communities_str.split(", ") + ] + route_data["large_communities"] = [c for c in large_communities if c] + elif info.startswith("Ext-Community:"): + ext_communities_str = remove_prefix(info, "Ext-Community: ") + if ext_communities_str and ext_communities_str.lower() != "none": + ext_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in ext_communities_str.split(", ") + ] + route_data["ext_communities"] = [c for c in ext_communities if c] + elif info.startswith("AS-path"): + values = info.split(",") + for v in values: + v = v.strip() + if v.startswith("AS-path"): + as_path_str = remove_prefix(v, "AS-path ") + try: + route_data["as_path"] = [ + int(a) for a in as_path_str.split() if a.isdigit() + ] + except ValueError: + route_data["as_path"] = [] + elif v.startswith("origin"): + route_data["origin"] = remove_prefix(v, "origin ") + elif v.startswith("MED"): + try: + route_data["metric"] = int(remove_prefix(v, "MED ")) + except ValueError: + route_data["metric"] = 0 + elif v.startswith("localpref"): + try: + route_data["local_preference"] = int(remove_prefix(v, "localpref ")) + except ValueError: + route_data["local_preference"] = 100 + elif v.startswith("pref-val"): + try: + route_data["preference_value"] = int(remove_prefix(v, "pref-val ")) + except ValueError: + route_data["preference_value"] = 0 + elif v.startswith("pre "): + try: + route_data["preference"] = int(remove_prefix(v, "pre ")) + except ValueError: + route_data["preference"] = 0 + elif v.strip() == "valid": + route_data["is_valid"] = True + elif v.strip() == "external": + route_data["is_external"] = True + elif v.strip() == "backup": + route_data["is_backup"] = True + elif v.strip() == "best": + route_data["is_best"] = True + elif v.strip() == "select": + route_data["is_selected"] = True + + # Only add route if we have a valid prefix + if route_data["prefix"]: + try: + route = HuaweiRouteEntry(**route_data) + routes.append(route) + except Exception as e: + log.warning( + "Failed to create route entry for prefix {}: {}", + route_data.get("prefix", "unknown"), + e, + ) + continue + + return routes + + +class HuaweiBGPRouteTable(BGPRouteTable): + """Canonical Huawei BGP Route Table.""" + + # No custom __init__ needed; inherit from BGPRouteTable (which should be a Pydantic model) + + +class HuaweiBGPTable(HuaweiBase): + """Validation model for Huawei BGP table data.""" + + local_router_id: str = "" + local_as_number: int = 0 + paths: HuaweiPaths = HuaweiPaths() + routes: t.List[HuaweiRouteEntry] = [] + + @classmethod + def parse_text(cls, text: str) -> "HuaweiBGPTable": + """Parse Huawei BGP text output.""" + _log = log.bind(parser="HuaweiBGPTable") + + instance = cls() + + lines = text.split("\n") + + # Extract general information + for line in lines: + if "BGP local router ID" in line: + instance.local_router_id = remove_prefix(line, "BGP local router ID : ").strip() + elif "Local AS number" in line: + try: + instance.local_as_number = int( + remove_prefix(line, "Local AS number : ").strip() + ) + except ValueError: + instance.local_as_number = 0 + elif line.strip().startswith("Paths:"): + instance.paths = _extract_paths(line) + + # Extract route entries + instance.routes = _extract_route_entries(lines) + + _log.debug(f"Parsed {len(instance.routes)} Huawei routes") + return instance + + def bgp_table(self) -> BGPRouteTable: + """Convert to standard BGP table format.""" + routes = [] + + for route in self.routes: + route_data = { + "prefix": route.prefix, + "active": route.active, + "age": route.age, + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.all_communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": ( + RPKI_STATE_MAP["valid"] if route.is_valid else RPKI_STATE_MAP["unknown"] + ), + } + routes.append(BGPRoute(**route_data)) + + return HuaweiBGPRouteTable( + vrf="default", + count=len(routes), + routes=routes, + winning_weight="high", + ) diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py index 5e87f5b11..0891b949a 100644 --- a/hyperglass/plugins/_builtin/__init__.py +++ b/hyperglass/plugins/_builtin/__init__.py @@ -5,6 +5,7 @@ from .remove_command import RemoveCommand from .bgp_route_arista import BGPRoutePluginArista from .bgp_route_huawei import BGPRoutePluginHuawei +from .bgp_routestr_huawei import BGPSTRRoutePluginHuawei from .bgp_route_juniper import BGPRoutePluginJuniper from .mikrotik_garbage_output import MikrotikGarbageOutput @@ -13,6 +14,7 @@ "BGPRoutePluginFrr", "BGPRoutePluginJuniper", "BGPRoutePluginHuawei", + "BGPSTRRoutePluginHuawei", "MikrotikGarbageOutput", "RemoveCommand", ) diff --git a/hyperglass/plugins/_builtin/bgp_route_huawei.py b/hyperglass/plugins/_builtin/bgp_route_huawei.py index 172d9068f..484dc9cc8 100644 --- a/hyperglass/plugins/_builtin/bgp_route_huawei.py +++ b/hyperglass/plugins/_builtin/bgp_route_huawei.py @@ -21,7 +21,10 @@ class BGPRoutePluginHuawei(InputPlugin): "huawei", "huawei_vrpv8", ) - directives: t.Sequence[str] = ("__hyperglass_huawei_bgp_route__",) + directives: t.Sequence[str] = ( + "__hyperglass_huawei_bgp_route__", + "__hyperglass_huawei_bgp_route_table__", + ) """ Huawei BGP Route Input Plugin @@ -33,15 +36,25 @@ class BGPRoutePluginHuawei(InputPlugin): def transform(self, query: "Query") -> InputPluginTransformReturn: target = query.query_target - if not target or not isinstance(target, list) or len(target) == 0: + if not target: return None - target = target[0].strip() + if isinstance(target, list): + if len(target) == 0: + return None + target = target[0].strip() + elif isinstance(target, str): + target = target.strip() + else: + return None # Check for the / in the query target if target.find("/") == -1: return target - target_network = ip_network(target) - - return f"{target_network.network_address!s} {target_network.prefixlen!s}" + try: + target_network = ip_network(target) + return f"{target_network.network_address!s} {target_network.prefixlen!s}" + except ValueError: + # Return the target unchanged if it isn't a valid network + return target diff --git a/hyperglass/plugins/_builtin/bgp_routestr_huawei.py b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py new file mode 100644 index 000000000..e42308344 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py @@ -0,0 +1,80 @@ +"""Coerce a Huawei route table in text format to a standard BGP Table structure.""" + +# Standard Library +import re +from typing import TYPE_CHECKING, List, Sequence + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +# Local +from .._output import OutputPlugin + +if TYPE_CHECKING: + # Project + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + + # Local + from .._output import OutputType + + +def parse_huawei(output: Sequence[str]) -> "OutputDataModel": + """Parse a Huawei BGP text response.""" + result = None + + _log = log.bind(plugin=BGPSTRRoutePluginHuawei.__name__) + + for response in output: + try: + # Parse the text output using the Huawei parser + validated = HuaweiBGPTable.parse_text(response) + bgp_table = validated.bgp_table() + + if result is None: + result = bgp_table + else: + result += bgp_table + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + except Exception as err: + _log.bind(error=str(err)).critical("Failed to parse Huawei BGP output") + raise ParsingError("Error parsing response data") from err + + return result + + +class BGPSTRRoutePluginHuawei(OutputPlugin): + """Coerce a Huawei route table in text format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: Sequence[str] = ("huawei",) + directives: Sequence[str] = ( + "__hyperglass_huawei_bgp_route_table__", + "__hyperglass_huawei_bgp_aspath_table__", + "__hyperglass_huawei_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse Huawei response if data is a string (and is therefore unparsed).""" + _log = log.bind(plugin=self.__class__.__name__) + _log.debug("Processing Huawei output with structured parser") + + should_process = all( + ( + isinstance(output, (list, tuple)), + query.device.platform in self.platforms, + query.device.structured_output is True, + query.device.has_directives(*self.directives), + ) + ) + if should_process: + return parse_huawei(output) + return output diff --git a/hyperglass/plugins/tests/test_bgp_route_huawei.py b/hyperglass/plugins/tests/test_bgp_route_huawei.py new file mode 100644 index 000000000..0485fad7c --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_huawei.py @@ -0,0 +1,75 @@ +"""Huawei VRP structured BGP route parsing tests.""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.config.params import Params +from hyperglass.models.data.bgp_route import BGPRouteTable +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +if t.TYPE_CHECKING: + from hyperglass.state import HyperglassState + +# Representative `display bgp routing-table ` detail output: one best/ +# selected (active) route and one valid-but-not-best route. +SAMPLE = """ + BGP local router ID : 192.0.2.1 + Local AS number : 64500 + Paths: 2 available, 1 best, 1 select + + BGP routing table entry information of 203.0.113.0/24: + From: 198.51.100.1 (192.0.2.254) + Route Duration: 0d00h05m12s + Original nexthop: 198.51.100.1 + Community: 64500:100, 64500:200 + AS-path 64500 64501, origin igp, MED 0, localpref 100, pref-val 0, valid, external, best, select, pre 255 + + BGP routing table entry information of 203.0.113.128/25: + From: 198.51.100.2 (192.0.2.253) + Route Duration: 0d00h01m00s + Original nexthop: 198.51.100.2 + AS-path 64500 64999, origin incomplete, MED 10, localpref 90, pref-val 0, valid, external, pre 255 +""" + + +@pytest.fixture +def params() -> t.Dict[str, t.Any]: + return {} + + +@pytest.fixture +def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: + """Initialize Redis-backed state with params for BGPRoute validation.""" + _state = use_state() + _params = Params(**params) + with _state.cache.pipeline() as pipeline: + pipeline.set("params", _params) + yield _state + _state.clear() + + +def test_huawei_bgp_route(state): + table = HuaweiBGPTable.parse_text(SAMPLE).bgp_table() + + assert isinstance(table, BGPRouteTable), "Parsed result is not a BGPRouteTable" + assert table.count == 2, f"Expected 2 routes, got {table.count}" + + by_prefix = {r.prefix: r for r in table.routes} + assert set(by_prefix) == {"203.0.113.0/24", "203.0.113.128/25"} + + best = by_prefix["203.0.113.0/24"] + assert best.active is True, "best+select route should be active" + assert best.as_path == [64500, 64501], f"Bad as_path: {best.as_path}" + assert best.source_as == 64500, "source_as should be first ASN in path" + assert best.next_hop == "198.51.100.1" + assert "64500:100" in best.communities + + other = by_prefix["203.0.113.128/25"] + assert other.active is False, "non-best route should not be active" + assert other.as_path == [64500, 64999] diff --git a/hyperglass/ui/components/table/cell.tsx b/hyperglass/ui/components/table/cell.tsx index 228576ffc..dc0f3778a 100644 --- a/hyperglass/ui/components/table/cell.tsx +++ b/hyperglass/ui/components/table/cell.tsx @@ -6,12 +6,14 @@ import type { BoxProps } from '@chakra-ui/react'; interface TableCellProps extends Omit { bordersVertical?: [boolean, number]; align?: 'left' | 'right' | 'center'; + dimText?: boolean; } export const TableCell = (props: TableCellProps): JSX.Element => { - const { bordersVertical = [false, 0], align, ...rest } = props; + const { bordersVertical = [false, 0], align, dimText = false, ...rest } = props; const [doVerticalBorders, index] = bordersVertical; const borderLeftColor = useColorValue('blackAlpha.100', 'whiteAlpha.100'); + const filteredTextColor = useColorValue('filtered.400', 'filtered.500'); let borderProps = {}; if (doVerticalBorders && index !== 0) { @@ -25,6 +27,7 @@ export const TableCell = (props: TableCellProps): JSX.Element => { w="1%" textAlign={align} whiteSpace="nowrap" + color={dimText ? filteredTextColor : undefined} {...borderProps} {...rest} /> diff --git a/hyperglass/ui/components/table/main.tsx b/hyperglass/ui/components/table/main.tsx index fd1eeef5f..4e21cb367 100644 --- a/hyperglass/ui/components/table/main.tsx +++ b/hyperglass/ui/components/table/main.tsx @@ -122,6 +122,7 @@ export const Table = (props: TableProps): JSX.Element => { {page.map((row, key) => { prepareRow(row); + const dimText = row.original.filtered; return ( { highlightBg={rowHighlightBg} doHorizontalBorders={bordersHorizontal} highlight={row.values[rowHighlightProp ?? ''] ?? false} + dimText={dimText} {...row.getRowProps()} > {row.cells.map((cell, i) => { @@ -137,6 +139,7 @@ export const Table = (props: TableProps): JSX.Element => { {typeof Cell !== 'undefined' ? ( diff --git a/hyperglass/ui/components/table/row.tsx b/hyperglass/ui/components/table/row.tsx index a0497d7e9..76b33b9d6 100644 --- a/hyperglass/ui/components/table/row.tsx +++ b/hyperglass/ui/components/table/row.tsx @@ -8,6 +8,7 @@ interface TableRowProps extends BoxProps { highlightBg?: Theme.ColorNames; doHorizontalBorders?: boolean; highlight?: boolean; + dimText?: boolean; doStripe?: boolean; index: number; } @@ -19,6 +20,7 @@ export const TableRow = (props: TableRowProps): JSX.Element => { highlight = false, highlightBg = 'primary', doHorizontalBorders = false, + dimText = false, ...rest } = props; @@ -40,12 +42,14 @@ export const TableRow = (props: TableRowProps): JSX.Element => { } const defaultBg = useColorValue('white', 'black'); const color = useOpposingColor(bg ?? defaultBg); + const filteredTextColor = useColorValue('filtered.400', 'filtered.500'); + const rowTextColor = dimText ? filteredTextColor : color; const borderProps = doHorizontalBorders && index !== 0 ? rowBorder : {}; return ( td': { color } }} + css={{ '& > td': { color: rowTextColor } }} fontWeight={highlight ? 'bold' : undefined} _hover={{ cursor: 'pointer', diff --git a/hyperglass/ui/types/globals.d.ts b/hyperglass/ui/types/globals.d.ts index f920e5998..3e1b0dc6a 100644 --- a/hyperglass/ui/types/globals.d.ts +++ b/hyperglass/ui/types/globals.d.ts @@ -30,6 +30,7 @@ export declare global { source_rid: string; peer_rid: string; rpki_state: RPKIState; + filtered: boolean; }; type RouteField = { [K in keyof Route]: Route[K] }; From 3183b34f907d4c5a58176fbfda43c3344cdfa8c9 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:42 +1000 Subject: [PATCH 07/39] feat(mikrotik): structured BGP route output --- .../config/structured-output.mdx | 51 +- docs/pages/configuration/config/web-ui.mdx | 3 + hyperglass/api/state.py | 35 +- hyperglass/api/tasks.py | 6 +- hyperglass/constants.py | 9 +- hyperglass/defaults/directives/huawei.py | 63 +- hyperglass/defaults/directives/mikrotik.py | 66 +- hyperglass/external/__init__.py | 8 +- hyperglass/external/bgptools.py | 178 ----- hyperglass/external/ip_enrichment.py | 655 ++++++++++++++++++ hyperglass/external/rpki.py | 62 +- hyperglass/external/tests/test_bgptools.py | 49 -- hyperglass/external/tests/test_rpki.py | 8 +- hyperglass/models/config/devices.py | 8 +- hyperglass/models/config/structured.py | 28 +- hyperglass/models/config/web.py | 1 + hyperglass/models/data/bgp_route.py | 35 +- hyperglass/models/parsing/huawei.py | 394 +++++++++++ hyperglass/models/parsing/mikrotik.py | 552 +++++++++++++++ hyperglass/plugins/_builtin/__init__.py | 6 + .../plugins/_builtin/bgp_route_huawei.py | 25 +- .../plugins/_builtin/bgp_routestr_huawei.py | 80 +++ .../plugins/_builtin/bgp_routestr_mikrotik.py | 86 +++ .../_builtin/mikrotik_garbage_output.py | 293 ++++++-- .../_builtin/mikrotik_normalize_input.py | 58 ++ .../plugins/tests/test_bgp_route_huawei.py | 75 ++ .../plugins/tests/test_bgp_route_mikrotik.py | 78 +++ hyperglass/ui/components/table/cell.tsx | 5 +- hyperglass/ui/components/table/main.tsx | 3 + hyperglass/ui/components/table/row.tsx | 6 +- hyperglass/ui/types/globals.d.ts | 1 + 31 files changed, 2581 insertions(+), 346 deletions(-) delete mode 100644 hyperglass/external/bgptools.py create mode 100644 hyperglass/external/ip_enrichment.py delete mode 100644 hyperglass/external/tests/test_bgptools.py create mode 100644 hyperglass/models/parsing/huawei.py create mode 100644 hyperglass/models/parsing/mikrotik.py create mode 100644 hyperglass/plugins/_builtin/bgp_routestr_huawei.py create mode 100644 hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py create mode 100644 hyperglass/plugins/_builtin/mikrotik_normalize_input.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_huawei.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_mikrotik.py diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx index df936253f..2955b2717 100644 --- a/docs/pages/configuration/config/structured-output.mdx +++ b/docs/pages/configuration/config/structured-output.mdx @@ -4,19 +4,28 @@ Devices that support responding to a query with structured or easily parsable da - Arista EOS - Juniper Junos +- Huawei VRP +- MikroTik RouterOS / SwitchOS + +MikroTik structured BGP output (Route, AS Path, and Community queries) requires **RouterOS v7+**, as it parses the output of `routing route print detail`. RouterOS v6 uses a different route-print syntax and is not supported for structured BGP. When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods: 1. From the router's perspective -2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) +2. From an external validator — either [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) (the default) or a self-hosted [Routinator](https://routinator.docs.nlnetlabs.nl/) instance + +When `structured.rpki.mode` is `external`, the `structured.rpki.backend` field selects which validator to query. The `cloudflare` backend uses Cloudflare's GraphQL API; the `routinator` backend queries the `/validity` HTTP API of a Routinator instance at `structured.rpki.rpki_server_url`. Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user. | Parameter | Type | Default Value | Description | | :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- | -| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use Cloudflare's view (2 above). | -| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. | -| `structured.communities.items` | List of Strings | | List of communities to match. | +| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use an external validator (2 above). | +| `structured.rpki.backend` | String | cloudflare | When `mode` is `external`, the validator to use: `cloudflare` or `routinator`. | +| `structured.rpki.rpki_server_url` | String | | Base URL of the Routinator instance. **Required** when `backend` is `routinator` (e.g. `http://routinator.example.net:8323`). | +| `structured.communities.mode` | String | deny | `deny` hides communities listed in `items`; `permit` shows _only_ those listed; `name` shows all communities and appends a friendly label to any listed in `names`. | +| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny`/`permit`). | +| `structured.communities.names` | Map of String→String | | Map of community → friendly name, used by `name` mode. At least one entry is required when `mode` is `name`. | ### RPKI Examples @@ -28,12 +37,23 @@ structured: mode: router ``` -#### Show RPKI State from a Public/External Perspective +#### Show RPKI State from a Public/External Perspective (Cloudflare) -```yaml filename="config.yaml" copy {2} +```yaml filename="config.yaml" copy {2-3} +structured: + rpki: + mode: external + backend: cloudflare +``` + +#### Validate RPKI State Against a Self-Hosted Routinator + +```yaml filename="config.yaml" copy {2-5} structured: rpki: mode: external + backend: routinator + rpki_server_url: "http://routinator.example.net:8323" ``` ### Community Filtering Examples @@ -59,3 +79,22 @@ structured: - "^65000:.*$" # permit any communities starting with 65000, but no others. - "1234:1" # permit only the 1234:1 community. ``` + +#### Show Friendly Names Alongside Communities + +With `mode: name`, all communities are shown, and any community listed in `names` has its friendly label appended (rendered as ` ()` in the UI). + +```yaml filename="config.yaml" {3-6} +structured: + communities: + mode: name + names: + "65000:100": "Customer Route" + "65000:200": "Peer Route" +``` + +## IP Enrichment + +hyperglass can enrich structured responses with ASN, organization, country, and IXP metadata resolved in real time from [bgp.tools](https://bgp.tools/). This data powers the organization names shown on AS paths and next-hops, and the per-hop detail in structured traceroute. + +Enrichment is performed by an internal service that issues bulk WHOIS lookups to bgp.tools; results are cached in-memory for the life of the process. No configuration is required to make the service available — it is enabled per-feature via the [`structured.ip_enrichment`](#ip-enrichment-configuration) settings documented below. diff --git a/docs/pages/configuration/config/web-ui.mdx b/docs/pages/configuration/config/web-ui.mdx index 761344769..96b5664f7 100644 --- a/docs/pages/configuration/config/web-ui.mdx +++ b/docs/pages/configuration/config/web-ui.mdx @@ -243,6 +243,7 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. | `web.theme.colors.dark` | | | `web.theme.colors.light` | | | `web.theme.colors.gray` | | +| `web.theme.colors.filtered` | | | `web.theme.colors.red` | | | `web.theme.colors.orange` | | | `web.theme.colors.yellow` | | @@ -253,6 +254,8 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. | `web.theme.colors.pink` | | | `web.theme.colors.purple` | | +The `filtered` color is used to dim BGP routes that are present but not selected/active in the structured table (for example, MikroTik routes flagged filtered, disabled, or unreachable). It defaults to the same value as `gray`. + ##### Functional Colors | Parameter | Default Value | diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py index f41a96d78..5dfd75ef9 100644 --- a/hyperglass/api/state.py +++ b/hyperglass/api/state.py @@ -1,27 +1,52 @@ """hyperglass state dependencies.""" # Standard Library +import asyncio import typing as t # Project -from hyperglass.state import use_state +from hyperglass.state import HyperglassState, use_state +from hyperglass.exceptions.private import StateError + + +def _is_retryable(attr: t.Optional[str]) -> bool: + """Whether a StateError for this attr is a transient (not-yet-populated) error. + + A request can arrive before the Redis-backed state is populated on startup, + which surfaces as a StateError. Referencing an attribute that does not exist + on HyperglassState is a permanent programming error and must not be retried. + """ + return attr is None or attr in ("cache", "redis") or attr in HyperglassState.properties() + + +async def _get_state_with_retry( + attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5 +) -> t.Any: + """Get hyperglass state, retrying transient startup StateErrors.""" + for attempt in range(1, max_retries + 1): + try: + return use_state(attr) + except StateError: + if not _is_retryable(attr) or attempt == max_retries: + raise + await asyncio.sleep(retry_delay) async def get_state(attr: t.Optional[str] = None): """Get hyperglass state as a FastAPI dependency.""" - return use_state(attr) + return await _get_state_with_retry(attr) async def get_params(): """Get hyperglass params as FastAPI dependency.""" - return use_state("params") + return await _get_state_with_retry("params") async def get_devices(): """Get hyperglass devices as FastAPI dependency.""" - return use_state("devices") + return await _get_state_with_retry("devices") async def get_ui_params(): """Get hyperglass ui_params as FastAPI dependency.""" - return use_state("ui_params") + return await _get_state_with_retry("ui_params") diff --git a/hyperglass/api/tasks.py b/hyperglass/api/tasks.py index 0a7d16d46..8527c53be 100644 --- a/hyperglass/api/tasks.py +++ b/hyperglass/api/tasks.py @@ -10,7 +10,7 @@ # Project from hyperglass.log import log -from hyperglass.external import Webhook, bgptools +from hyperglass.external import Webhook, network_info from hyperglass.models.api import Query if t.TYPE_CHECKING: @@ -52,7 +52,7 @@ async def send_webhook( else: host = request.client.host - network_info = await bgptools.network_info(host) + network_result = await network_info(host) async with Webhook(params.logging.http) as hook: await hook.send( @@ -60,7 +60,7 @@ async def send_webhook( **data.dict(), "headers": headers, "source": host, - "network": network_info.get(host, {}), + "network": network_result.get(host, {}), "timestamp": timestamp, } ) diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 0139c7415..02723a194 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -19,7 +19,14 @@ TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos") -SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos") +SUPPORTED_STRUCTURED_OUTPUT = ( + "frr", + "juniper", + "arista_eos", + "huawei", + "mikrotik_routeros", + "mikrotik_switchos", +) CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml") diff --git a/hyperglass/defaults/directives/huawei.py b/hyperglass/defaults/directives/huawei.py index f8d3ef0cf..995a42c6c 100644 --- a/hyperglass/defaults/directives/huawei.py +++ b/hyperglass/defaults/directives/huawei.py @@ -15,6 +15,9 @@ "Huawei_BGPRoute", "Huawei_Ping", "Huawei_Traceroute", + "HuaweiBGPRouteTable", + "HuaweiBGPASPathTable", + "HuaweiBGPCommunityTable", ) NAME = "Huawei VRP" @@ -36,7 +39,8 @@ ), ], field=Text(description="IP Address, Prefix, or Hostname"), - plugins=["bgp_route_huawei"], + plugins=["bgp_route_huawei", "bgp_routestr_huawei"], + table_output="__hyperglass_huawei_bgp_route_table__", platforms=PLATFORMS, ) @@ -54,6 +58,7 @@ ) ], field=Text(description="AS Path Regular Expression"), + table_output="__hyperglass_huawei_bgp_aspath_table__", platforms=PLATFORMS, ) @@ -71,6 +76,7 @@ ) ], field=Text(description="BGP Community String"), + table_output="__hyperglass_huawei_bgp_community_table__", platforms=PLATFORMS, ) @@ -111,3 +117,58 @@ field=Text(description="IP Address, Prefix, or Hostname"), platforms=PLATFORMS, ) + +# Table Output Directives + +HuaweiBGPRouteTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="display bgp routing-table {target} | no-more", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="display bgp ipv6 routing-table {target} | no-more", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +HuaweiBGPASPathTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table regular-expression "{target}"', + 'display bgp ipv6 routing-table regular-expression "{target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +HuaweiBGPCommunityTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_community_table__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table community "{target}"', + 'display bgp ipv6 routing-table community "{target}"', + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/mikrotik.py b/hyperglass/defaults/directives/mikrotik.py index 4ea96fdff..167ac0449 100644 --- a/hyperglass/defaults/directives/mikrotik.py +++ b/hyperglass/defaults/directives/mikrotik.py @@ -15,6 +15,9 @@ "Mikrotik_BGPRoute", "Mikrotik_Ping", "Mikrotik_Traceroute", + "MikrotikBGPRouteTable", + "MikrotikBGPASPathTable", + "MikrotikBGPCommunityTable", ) NAME = "Mikrotik" @@ -27,15 +30,17 @@ RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="ip route print where {target} in dst-address", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=0.0.0.0/0", ), RuleWithIPv6( condition="::/0", action="permit", - command="ipv6 route print where {target} in dst-address", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=::/0", ), ], field=Text(description="IP Address, Prefix, or Hostname"), + plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + table_output="__hyperglass_mikrotik_bgp_route_table__", platforms=PLATFORMS, ) @@ -53,6 +58,8 @@ ) ], field=Text(description="AS Path Regular Expression"), + plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + table_output="__hyperglass_mikrotik_bgp_aspath_table__", platforms=PLATFORMS, ) @@ -70,6 +77,8 @@ ) ], field=Text(description="BGP Community String"), + plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + table_output="__hyperglass_mikrotik_bgp_community_table__", platforms=PLATFORMS, ) @@ -110,3 +119,56 @@ field=Text(description="IP Address, Prefix, or Hostname"), platforms=PLATFORMS, ) + +# Table Output Directives + +MikrotikBGPRouteTable = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=0.0.0.0/0", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=::/0", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +MikrotikBGPASPathTable = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "routing route print detail without-paging where bgp-as-path~{target}", + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +MikrotikBGPCommunityTable = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_community_table__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "routing route print detail without-paging where bgp-communities~{target}", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) diff --git a/hyperglass/external/__init__.py b/hyperglass/external/__init__.py index 4d9d22e9e..827bf3314 100644 --- a/hyperglass/external/__init__.py +++ b/hyperglass/external/__init__.py @@ -5,7 +5,13 @@ from .slack import SlackHook from .generic import BaseExternal from .msteams import MSTeams -from .bgptools import network_info, network_info_sync +from .ip_enrichment import ( + network_info, + network_info_sync, + lookup_ip, + lookup_asn_name, + lookup_asn_country, +) from .webhooks import Webhook from .http_client import HTTPClient diff --git a/hyperglass/external/bgptools.py b/hyperglass/external/bgptools.py deleted file mode 100644 index f2fd7f6a9..000000000 --- a/hyperglass/external/bgptools.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Query & parse data from bgp.tools. - -- See https://bgp.tools/credits for acknowledgements and licensing. -- See https://bgp.tools/kb/api for query documentation. -""" - -# Standard Library -import re -import typing as t -import asyncio -from ipaddress import IPv4Address, IPv6Address, ip_address - -# Project -from hyperglass.log import log -from hyperglass.state import use_state - -DEFAULT_KEYS = ("asn", "ip", "prefix", "country", "rir", "allocated", "org") - -CACHE_KEY = "hyperglass.external.bgptools" - -TargetDetail = t.TypedDict( - "TargetDetail", - {"asn": str, "ip": str, "country": str, "rir": str, "allocated": str, "org": str}, -) - -TargetData = t.Dict[str, TargetDetail] - - -def default_ip_targets(*targets: str) -> t.Tuple[TargetData, t.Tuple[str, ...]]: - """Construct a mapping of default data and other data that should be queried. - - Targets in the mapping don't need to be queried and already have default values. Targets in the - query tuple should be queried. - """ - default_data = {} - query = () - for target in targets: - detail: TargetDetail = dict.fromkeys(DEFAULT_KEYS, "None") - try: - valid: t.Union[IPv4Address, IPv6Address] = ip_address(target) - - checks = ( - (valid.version == 6 and valid.is_site_local, "Site Local Address"), - (valid.is_loopback, "Loopback Address"), - (valid.is_multicast, "Multicast Address"), - (valid.is_link_local, "Link Local Address"), - (valid.is_private, "Private Address"), - ) - for exp, rir in checks: - if exp is True: - detail["rir"] = rir - break - - should_query = any((valid.is_global, valid.is_unspecified, valid.is_reserved)) - - if not should_query: - detail["ip"] = str(target) - default_data[str(target)] = detail - elif should_query: - query += (str(target),) - - except ValueError: - pass - - return default_data, query - - -def parse_whois(output: str, targets: t.List[str]) -> TargetDetail: - """Parse raw whois output from bgp.tools. - - Sample output: - AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name - 13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | 2010-07-14 | Cloudflare, Inc. - """ - - def lines(raw): - """Generate clean string values for each column.""" - for r in (r for r in raw.split("\n") if r): - fields = (re.sub(r"(\n|\r)", "", field).strip(" ") for field in r.split("|")) - yield fields - - data = {} - - for line in lines(output): - # Unpack each line's parsed values. - asn, ip, prefix, country, rir, allocated, org = line - - # Match the line to the item in the list of resources to query. - if ip in targets: - i = targets.index(ip) - data[targets[i]] = { - "asn": asn, - "ip": ip, - "prefix": prefix, - "country": country, - "rir": rir, - "allocated": allocated, - "org": org, - } - log.bind(data=data).debug("Parsed bgp.tools data") - return data - - -async def run_whois(targets: t.List[str]) -> str: - """Open raw socket to bgp.tools and execute query.""" - - # Construct bulk query - query = "\n".join(("begin", *targets, "end\n")).encode() - - # Open the socket to bgp.tools - log.debug("Opening connection to bgp.tools") - reader, writer = await asyncio.open_connection("bgp.tools", port=43) - - # Send the query - writer.write(query) - if writer.can_write_eof(): - writer.write_eof() - await writer.drain() - - # Read the response - response = b"" - while True: - data = await reader.read(128) - if data: - response += data - else: - log.debug("Closing connection to bgp.tools") - writer.close() - break - - return response.decode() - - -async def network_info(*targets: str) -> TargetData: - """Get ASN, Containing Prefix, and other info about an internet resource.""" - - default_data, query_targets = default_ip_targets(*targets) - - cache = use_state("cache") - - # Set default data structure. - query_data = {t: dict.fromkeys(DEFAULT_KEYS, "") for t in query_targets} - - # Get all cached bgp.tools data. - cached = cache.get_map(CACHE_KEY) or {} - - # Try to use cached data for each of the items in the list of - # resources. - for target in (target for target in query_targets if target in cached): - # Reassign the cached network info to the matching resource. - query_data[target] = cached[target] - log.bind(target=target).debug("Using cached network info") - - # Remove cached items from the resource list so they're not queried. - targets = [t for t in query_targets if t not in cached] - - try: - if targets: - whoisdata = await run_whois(targets) - - if whoisdata: - # If the response is not empty, parse it. - query_data.update(parse_whois(whoisdata, targets)) - - # Cache the response - for target in targets: - cache.set_map_item(CACHE_KEY, target, query_data[target]) - log.bind(target=t).debug("Cached network info") - - except Exception as err: - log.error(err) - - return {**default_data, **query_data} - - -def network_info_sync(*targets: str) -> TargetData: - """Get ASN, Containing Prefix, and other info about an internet resource.""" - return asyncio.run(network_info(*targets)) diff --git a/hyperglass/external/ip_enrichment.py b/hyperglass/external/ip_enrichment.py new file mode 100644 index 000000000..1f52609df --- /dev/null +++ b/hyperglass/external/ip_enrichment.py @@ -0,0 +1,655 @@ +"""IP enrichment: ASN and IXP lookups for hyperglass. + +Uses bgp.tools for both ASN and IXP lookups. +Provides lookup_ip, lookup_asn_name and network_info compatibility APIs. +""" + +import asyncio +import typing as t +from ipaddress import ip_address, IPv4Address, IPv6Address +import socket + +from hyperglass.log import log +from hyperglass.settings import Settings + + +class IPInfo: + """Result of IP lookup.""" + + def __init__( + self, + ip: str, + asn: t.Optional[int] = None, + asn_name: t.Optional[str] = None, + prefix: t.Optional[str] = None, + country: t.Optional[str] = None, + is_ixp: bool = False, + ixp_name: t.Optional[str] = None, + ): + self.ip = ip + self.asn = asn + self.asn_name = asn_name + self.prefix = prefix + self.country = country + self.is_ixp = is_ixp + self.ixp_name = ixp_name + + +class IPEnrichmentService: + """Simplified IP enrichment service using only BGP.TOOLS for all lookups.""" + + def __init__(self): + # Runtime caches for performance + self._ip_cache: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + self.asn_info: t.Dict[int, t.Dict[str, str]] = {} # asn -> {name, country} + + async def _query_bgp_tools_for_ip( + self, ip_str: str + ) -> t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]: + """Query bgp.tools for a single IP. + + Returns (asn_int, asn_name, prefix) or (None, None, None) on failure. + """ + # Check cache first + if ip_str in self._ip_cache: + return self._ip_cache[ip_str] + + # Use TCP WHOIS on bgp.tools:43 + def _whois_blocking( + single_ips: t.List[str], + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + out: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + host = "bgp.tools" + port = 43 + send_keys = [f"AS{q}" if q.isdigit() else q for q in single_ips] + payload = "begin\n" + "\n".join(send_keys) + "\nend\n" + try: + with socket.create_connection((host, port), timeout=10) as s: + s.settimeout(10) + s.sendall(payload.encode("utf-8")) + parts = [] + try: + while True: + chunk = s.recv(4096) + if not chunk: + break + parts.append(chunk) + except socket.timeout: + pass + + raw = b"".join(parts).decode("utf-8", errors="replace") + # Parse lines like: "13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | ... | Cloudflare, Inc." + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + if "|" in line: + cols = [c.strip() for c in line.split("|")] + try: + asn = int(cols[0]) if cols[0].isdigit() else None + except Exception: + asn = None + ipcol = cols[1] if len(cols) > 1 else None + prefix = cols[2] if len(cols) > 2 else None + org = cols[-1] if len(cols) > 0 else None + if ipcol: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + else: + # Fallback parsing + parts_line = line.split() + if len(parts_line) >= 3: + try: + asn = int(parts_line[0]) + except Exception: + asn = None + ipcol = parts_line[1] + prefix = parts_line[2] + org = " ".join(parts_line[3:]) if len(parts_line) > 3 else None + if ipcol: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + + # Map results back to original query keys + mapped: t.Dict[ + str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]] + ] = {} + for orig, sent in zip(single_ips, send_keys): + if sent in out: + mapped[orig] = out[sent] + elif orig in out: + mapped[orig] = out[orig] + else: + if orig.isdigit(): + if f"AS{orig}" in out: + mapped[orig] = out[f"AS{orig}"] + elif orig in out: + mapped[orig] = out[orig] + else: + mapped[orig] = (None, None, None) + else: + mapped[orig] = (None, None, None) + return mapped + except Exception: + for ip in single_ips: + out[ip] = (None, None, None) + return out + + loop = asyncio.get_running_loop() + resp_map = await loop.run_in_executor(None, _whois_blocking, [ip_str]) + asn, org, prefix = resp_map.get(ip_str, (None, None, None)) + # Cache result + self._ip_cache[ip_str] = (asn, org, prefix) + return (asn, org, prefix) + + async def _query_bgp_tools_bulk( + self, ips: t.List[str] + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + """Query bgp.tools for multiple IPs using a single connection. + + Returns a mapping ip -> (asn, asn_name, prefix). + """ + + def _whois_bulk_blocking( + bulk_ips: t.List[str], + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + host = "bgp.tools" + port = 43 + out: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + send_keys = [f"AS{q}" if q.isdigit() else q for q in bulk_ips] + payload = "begin\n" + "\n".join(send_keys) + "\nend\n" + try: + with socket.create_connection((host, port), timeout=15) as s: + s.settimeout(15) + s.sendall(payload.encode("utf-8")) + parts = [] + try: + while True: + chunk = s.recv(8192) + if not chunk: + break + parts.append(chunk) + if sum(len(p) for p in parts) > 512 * 1024: + break + except socket.timeout: + pass + + raw = b"".join(parts).decode("utf-8", errors="replace") + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + if "|" in line: + cols = [c.strip() for c in line.split("|")] + try: + asn = int(cols[0]) if cols[0].isdigit() else None + except Exception: + asn = None + ipcol = cols[1] if len(cols) > 1 else None + prefix = cols[2] if len(cols) > 2 else None + org = cols[-1] if len(cols) > 0 else None + # Check if this is an exchange IP (IXP) + is_ixp = any( + keyword in (org or "").lower() + for keyword in ["ix", "exchange", "peering"] + ) + if ipcol: + # If it's an IXP, mark it specially + if is_ixp: + out[ipcol] = ( + None, + org, + prefix, + ) # Use None for ASN to indicate IXP + else: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + else: + parts_line = line.split() + if len(parts_line) >= 3: + try: + asn = int(parts_line[0]) + except Exception: + asn = None + ipcol = parts_line[1] + prefix = parts_line[2] + org = " ".join(parts_line[3:]) if len(parts_line) > 3 else None + out[ipcol] = (asn, org, prefix) + + # Map results back to original query keys + mapped: t.Dict[ + str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]] + ] = {} + for orig, sent in zip(bulk_ips, send_keys): + if sent in out: + mapped[orig] = out[sent] + elif orig in out: + mapped[orig] = out[orig] + else: + if orig.isdigit(): + if f"AS{orig}" in out: + mapped[orig] = out[f"AS{orig}"] + elif orig in out: + mapped[orig] = out[orig] + else: + mapped[orig] = (None, None, None) + else: + mapped[orig] = (None, None, None) + return mapped + except Exception: + for ip in bulk_ips: + out[ip] = (None, None, None) + return out + + loop = asyncio.get_running_loop() + resp = await loop.run_in_executor(None, _whois_bulk_blocking, ips) + return resp + + async def lookup_ips_bulk(self, ips: t.List[str]) -> t.Dict[str, IPInfo]: + """Bulk lookup for multiple IPs using BGP.TOOLS.""" + results: t.Dict[str, IPInfo] = {} + + # Prepare list for query + query_ips: t.List[str] = [] + for ip in ips: + try: + target_ip = ip_address(ip) + except Exception: + results[ip] = IPInfo(ip) + continue + + # Check for private/reserved addresses + if target_ip.is_private or target_ip.is_reserved or target_ip.is_loopback: + results[ip] = IPInfo(ip, asn=0, asn_name="Private", prefix="Private Network") + continue + + query_ips.append(ip) + + # Query BGP.TOOLS in bulk for all IPs + if query_ips: + bulk = await self._query_bgp_tools_bulk(query_ips) + for ip in query_ips: + asn, asn_name, prefix = bulk.get(ip, (None, None, None)) + + # Check if this is an IXP (indicated by None ASN from our parsing) + is_ixp = asn is None and asn_name is not None + + if is_ixp: + results[ip] = IPInfo(ip, is_ixp=True, ixp_name=asn_name) + elif asn: + try: + self.asn_info[int(asn)] = {"name": asn_name or f"AS{asn}", "country": ""} + except Exception: + pass + results[ip] = IPInfo(ip, asn=asn, asn_name=asn_name, prefix=prefix) + else: + results[ip] = IPInfo(ip, asn=0, asn_name="Unknown") + + return results + + async def lookup_ip(self, ip_str: str) -> IPInfo: + """Lookup an IP address and return ASN or IXP information.""" + if Settings.debug: + log.debug(f"Looking up IP {ip_str}") + + try: + target_ip = ip_address(ip_str) + except ValueError: + if Settings.debug: + log.debug(f"Invalid IP address: {ip_str}") + return IPInfo(ip_str) + + # Check private/reserved addresses + if target_ip.is_private or target_ip.is_reserved or target_ip.is_loopback: + if Settings.debug: + log.debug(f"IP {ip_str} is in private/reserved range") + return IPInfo(ip_str, asn=0, asn_name="Private", prefix="Private Network") + + # Query bgp.tools for the IP + try: + asn, asn_name, prefix = await self._query_bgp_tools_for_ip(ip_str) + + # Check if this is an IXP (indicated by keywords in organization name) + is_ixp = asn_name and any( + keyword in asn_name.lower() for keyword in ["ix", "exchange", "peering"] + ) + + if is_ixp: + if Settings.debug: + log.debug(f"Found IXP match for {ip_str}: {asn_name}") + return IPInfo(ip_str, is_ixp=True, ixp_name=asn_name) + elif asn: + # Cache result + try: + self.asn_info[int(asn)] = {"name": asn_name or f"AS{asn}", "country": ""} + except Exception: + pass + if Settings.debug: + log.debug(f"BGP.tools lookup for {ip_str}: AS{asn} ({asn_name})") + return IPInfo(ip_str, asn=asn, asn_name=asn_name, prefix=prefix) + except Exception as e: + if Settings.debug: + log.debug(f"BGP.tools lookup failed for {ip_str}: {e}") + + # No match found + if Settings.debug: + log.debug(f"No enrichment data found for {ip_str}") + return IPInfo(ip_str, asn=0, asn_name="Unknown") + + async def lookup_asn_name(self, asn: int) -> str: + """Get the organization name for an ASN.""" + asn_data = self.asn_info.get(int(asn), {}) + return asn_data.get("name") or f"AS{asn}" + + async def lookup_asn_country(self, asn: int) -> str: + """Get the country code for an ASN, if known.""" + asn_data = self.asn_info.get(int(asn), {}) + return asn_data.get("country") or "" + + +# Global service instance +_service = IPEnrichmentService() + + +# Public API functions +async def lookup_ip(ip_address: str) -> IPInfo: + """Lookup an IP address and return ASN or IXP information.""" + return await _service.lookup_ip(ip_address) + + +async def lookup_asn_name(asn: int) -> str: + """Get the organization name for an ASN number.""" + return await _service.lookup_asn_name(asn) + + +async def lookup_asn_country(asn: int) -> str: + """Get the country code for an ASN number.""" + return await _service.lookup_asn_country(asn) + + +async def lookup_asns_bulk(asns: t.List[t.Union[str, int]]) -> t.Dict[str, t.Dict[str, str]]: + """Bulk lookup ASN organization names and countries. + + Args: + asns: List of ASN numbers (as strings like "12345" or integers) + + Returns: + Dict mapping ASN string to {"name": org_name, "country": country_code} + Example: {"12345": {"name": "Example ISP", "country": "US"}} + """ + results = {} + + # Normalize ASN list to strings and filter invalids + requested: list[str] = [] + for asn in asns: + if asn is None: + continue + if str(asn) == "IXP": + continue + try: + _ = int(asn) + requested.append(str(asn)) + except (ValueError, TypeError): + continue + + # Identify ASNs missing a human-friendly name so we can attempt a live WHOIS + missing: list[str] = [] + for asn in requested: + try: + ai = _service.asn_info.get(int(asn), {}) + name = ai.get("name") if isinstance(ai, dict) else None + if not name or name == f"AS{asn}": + missing.append(asn) + except Exception: + missing.append(asn) + + # If we have missing ASNs, try a live bgp.tools WHOIS bulk query to fetch org names + if missing and hasattr(_service, "_query_bgp_tools_bulk"): + try: + log.debug("lookup_asns_bulk: querying bgp.tools for missing ASNs: {}", missing) + queries = [f"AS{a}" for a in missing] + resp = await _service._query_bgp_tools_bulk(queries) + # resp maps query -> (asn_int, org, prefix) + for asn in missing: + q = f"AS{asn}" + entry = resp.get(q) or resp.get(str(asn)) + if entry: + _, org, _ = entry + if org: + try: + _service.asn_info[int(asn)] = {"name": org, "country": ""} + log.debug("lookup_asns_bulk: updated asn_info[{}] = {}", asn, org) + except Exception: + pass + except Exception as e: + log.debug("lookup_asns_bulk: bgp.tools lookup failed: {}", e) + + # Build final results from asn_info (may include newly-populated entries) + for asn in requested: + try: + asn_int = int(asn) + asn_data = _service.asn_info.get(asn_int, {}) + results[asn] = { + "name": asn_data.get("name", f"AS{asn}"), + "country": asn_data.get("country", ""), + } + except Exception: + results[asn] = {"name": f"AS{asn}", "country": ""} + + return results + + +async def refresh_ip_enrichment_data() -> bool: + """Manually refresh IP enrichment data.""" + log.info("Manual refresh requested") + # Since we no longer cache data, just return True to indicate the feature is available + return True + + +def get_data_status() -> dict: + """Get status information about IP enrichment data.""" + status = { + "mode": "real-time", + "data_source": "BGP.TOOLS", + "cache_enabled": False, + "last_update": "Real-time lookups via BGP.TOOLS", + "data_counts": { + "cached_ips": len(_service._ip_cache), + "cached_asns": len(_service.asn_info), + }, + } + return status + + +# Compatibility functions for existing code +TargetDetail = t.TypedDict( + "TargetDetail", + { + "asn": str, + "ip": str, + "prefix": str, + "country": str, + "rir": str, + "allocated": str, + "org": str, + }, +) + +TargetData = t.Dict[str, TargetDetail] + + +def default_ip_targets(*targets: str) -> t.Tuple[TargetData, t.List[str]]: + """Filter targets and create default data for private/special addresses.""" + _log = log.bind(source="ip_enrichment") + + default_data: TargetData = {} + query_targets: t.List[str] = [] + + for target in targets: + try: + target_ip = ip_address(target) + + # Check for special address types + special_types = [ + (target_ip.is_loopback, "Loopback Address"), + (target_ip.is_multicast, "Multicast Address"), + (target_ip.is_link_local, "Link Local Address"), + (target_ip.is_private, "Private Address"), + (target_ip.version == 6 and target_ip.is_site_local, "Site Local Address"), + ] + + is_special = False + for check, rir_type in special_types: + if check: + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": rir_type, + "allocated": "None", + "org": "None", + } + is_special = True + break + + if not is_special and (target_ip.is_global or target_ip.is_unspecified): + query_targets.append(target) + elif not is_special: + # Other non-global addresses + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Reserved Address", + "allocated": "None", + "org": "None", + } + + except ValueError: + # Invalid IP address + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Invalid Address", + "allocated": "None", + "org": "None", + } + + return default_data, query_targets + + +async def network_info(*targets: str) -> TargetData: + """Get network information using IP enrichment - compatibility function.""" + _log = log.bind(source="ip_enrichment") + + default_data, query_targets = default_ip_targets(*targets) + + if not query_targets: + _log.debug("No valid global IPs to query") + return default_data + + try: + _log.info(f"Enriching {len(query_targets)} IP addresses using bulk lookup") + + # Use the bulk lookup to query bgp.tools efficiently + query_data = {} + bulk_results = await _service.lookup_ips_bulk(query_targets) + + for target, ip_info in bulk_results.items(): + # Convert to TargetDetail format + if ip_info.is_ixp and ip_info.ixp_name: + detail: TargetDetail = { + "asn": "IXP", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "IXP", + "allocated": "None", + "org": ip_info.ixp_name, + } + elif ip_info.asn is not None and ip_info.asn != 0: + detail = { + "asn": str(ip_info.asn), + "ip": target, + "prefix": ip_info.prefix or "None", + "country": ip_info.country or "None", + "rir": "UNKNOWN", + "allocated": "None", + "org": ip_info.asn_name or "None", + } + elif ip_info.asn == 0: + detail = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Unknown", + "allocated": "None", + "org": "None", + } + else: + detail = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Unknown", + "allocated": "None", + "org": "None", + } + + query_data[target] = detail + + if ip_info.is_ixp: + _log.debug(f"Enriched {target}: IXP={ip_info.ixp_name}") + elif ip_info.asn: + _log.debug(f"Enriched {target}: AS{ip_info.asn} ({ip_info.asn_name})") + else: + _log.debug(f"No enrichment data found for {target}") + + except Exception as e: + _log.error(f"Error in network_info lookup: {e}") + # Return default data for all targets on error + query_data = {} + for target in query_targets: + query_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Error", + "allocated": "None", + "org": "None", + } + + return {**default_data, **query_data} + + +def network_info_sync(*targets: str) -> TargetData: + """Synchronous wrapper for network_info.""" + return asyncio.run(network_info(*targets)) + + +async def network_info_single(target: str) -> TargetDetail: + """Get network information for a single IP address.""" + result = await network_info(target) + return result[target] diff --git a/hyperglass/external/rpki.py b/hyperglass/external/rpki.py index 139ccf8fc..a5c480513 100644 --- a/hyperglass/external/rpki.py +++ b/hyperglass/external/rpki.py @@ -3,57 +3,83 @@ # Standard Library import typing as t +# Third Party +import httpx + # Project from hyperglass.log import log from hyperglass.state import use_state from hyperglass.external._base import BaseExternal if t.TYPE_CHECKING: - # Standard Library from ipaddress import IPv4Address, IPv6Address -RPKI_STATE_MAP = {"Invalid": 0, "Valid": 1, "NotFound": 2, "DEFAULT": 3} -RPKI_NAME_MAP = {v: k for k, v in RPKI_STATE_MAP.items()} +# Maps normalized (lower-case, separators stripped) backend state names to the +# integer states hyperglass uses internally. +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "notfound": 2, + "unknown": 2, + "default": 3, +} +# Canonical integer -> display name, kept stable for logging. +RPKI_NAME_MAP = {0: "Invalid", 1: "Valid", 2: "NotFound", 3: "DEFAULT"} CACHE_KEY = "hyperglass.external.rpki" -def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, str]) -> int: +def _normalize_state(value: str) -> int: + """Normalize a backend RPKI state string to an internal integer state.""" + key = str(value).strip().lower().replace("-", "").replace("_", "") + return RPKI_STATE_MAP.get(key, 3) + + +def rpki_state( + prefix: t.Union["IPv4Address", "IPv6Address", str], + asn: t.Union[int, str], + backend: str = "cloudflare", + rpki_server_url: str = "", +) -> int: """Get RPKI state and map to expected integer.""" _log = log.bind(prefix=prefix, asn=asn) _log.debug("Validating RPKI State") cache = use_state("cache") - state = 3 ro = f"{prefix!s}@{asn!s}" cached = cache.get_map(CACHE_KEY, ro) - if cached is not None: state = cached else: - ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' - query = ql.format(prefix, asn) - _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") try: - with BaseExternal(base_url="https://rpki.cloudflare.com") as client: - response = client._post("/api/graphql", data={"query": query}) - try: + if backend == "cloudflare": + ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' + query = ql.format(prefix, asn) + _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") + with BaseExternal(base_url="https://rpki.cloudflare.com") as client: + response = client._post("/api/graphql", data={"query": query}) validation_state = response["data"]["validation"]["state"] - except KeyError as missing: - _log.error("Response from Cloudflare missing key '{}': {!r}", missing, response) - validation_state = 3 + elif backend == "routinator": + url = f"{rpki_server_url.rstrip('/')}/validity" + _log.bind(url=url).debug("Routinator RPKI HTTP Query") + response = httpx.get( + url, params={"asn": str(asn), "prefix": str(prefix)}, timeout=5 + ) + response.raise_for_status() + data = response.json() + validation_state = data["validated_route"]["validity"]["state"] + else: + raise ValueError(f"Unknown RPKI backend: {backend}") - state = RPKI_STATE_MAP[validation_state] + state = _normalize_state(validation_state) cache.set_map_item(CACHE_KEY, ro, state) except Exception as err: log.error(err) - # Don't cache the state when an error produced it. state = 3 msg = "RPKI Validation State for {} via AS{} is {}".format(prefix, asn, RPKI_NAME_MAP[state]) if cached is not None: msg += " [CACHED]" - log.debug(msg) return state diff --git a/hyperglass/external/tests/test_bgptools.py b/hyperglass/external/tests/test_bgptools.py deleted file mode 100644 index 542c1dc28..000000000 --- a/hyperglass/external/tests/test_bgptools.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Test bgp.tools interactions.""" - -# Standard Library -import asyncio - -# Third Party -import pytest - -# Local -from ..bgptools import run_whois, parse_whois, network_info - -WHOIS_OUTPUT = """AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name -13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | 2010-07-14 | Cloudflare, Inc.""" - - -# Ignore asyncio deprecation warning about loop -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_network_info(): - checks = ( - ("192.0.2.1", {"asn": "None", "rir": "Private Address"}), - ("127.0.0.1", {"asn": "None", "rir": "Loopback Address"}), - ("fe80:dead:beef::1", {"asn": "None", "rir": "Link Local Address"}), - ("2001:db8::1", {"asn": "None", "rir": "Private Address"}), - ("1.1.1.1", {"asn": "13335", "rir": "ARIN"}), - ) - for addr, fields in checks: - info = asyncio.run(network_info(addr)) - assert addr in info - for key, expected in fields.items(): - assert info[addr][key] == expected - - -# Ignore asyncio deprecation warning about loop -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_whois(): - addr = "192.0.2.1" - response = asyncio.run(run_whois([addr])) - assert isinstance(response, str) - assert response != "" - - -def test_whois_parser(): - addr = "1.1.1.1" - result = parse_whois(WHOIS_OUTPUT, [addr]) - assert isinstance(result, dict) - assert addr in result, "Address missing" - assert result[addr]["asn"] == "13335" - assert result[addr]["rir"] == "ARIN" - assert result[addr]["org"] == "Cloudflare, Inc." diff --git a/hyperglass/external/tests/test_rpki.py b/hyperglass/external/tests/test_rpki.py index 01438e717..d21d50559 100644 --- a/hyperglass/external/tests/test_rpki.py +++ b/hyperglass/external/tests/test_rpki.py @@ -19,8 +19,8 @@ def test_rpki(): result = rpki_state(prefix, asn) result_name = RPKI_NAME_MAP.get(result, "No Name") expected_name = RPKI_NAME_MAP.get(expected, "No Name") - assert result == expected, ( - "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( - prefix, asn, result, result_name, expected, expected_name - ) + assert ( + result == expected + ), "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( + prefix, asn, result, result_name, expected, expected_name ) diff --git a/hyperglass/models/config/devices.py b/hyperglass/models/config/devices.py index 70cac21ee..b633541fd 100644 --- a/hyperglass/models/config/devices.py +++ b/hyperglass/models/config/devices.py @@ -247,10 +247,10 @@ def validate_structured_output(cls, value: bool, info: ValidationInfo) -> bool: if value is True: if info.data.get("platform") not in SUPPORTED_STRUCTURED_OUTPUT: raise ConfigError( - "The 'structured_output' field is set to 'true' on device '{}' with " - + "platform '{}', which does not support structured output", - info.data.get("name"), - info.data.get("platform"), + "The 'structured_output' field is set to 'true' on device '{d}' with " + "platform '{p}', which does not support structured output", + d=info.data.get("name"), + p=info.data.get("platform"), ) return value if value is None and info.data.get("platform") in SUPPORTED_STRUCTURED_OUTPUT: diff --git a/hyperglass/models/config/structured.py b/hyperglass/models/config/structured.py index 4373e8187..d672ab82e 100644 --- a/hyperglass/models/config/structured.py +++ b/hyperglass/models/config/structured.py @@ -3,11 +3,15 @@ # Standard Library import typing as t +# Third Party +from pydantic import ValidationInfo, field_validator, model_validator + # Local from ..main import HyperglassModel -StructuredCommunityMode = t.Literal["permit", "deny"] +StructuredCommunityMode = t.Literal["permit", "deny", "name"] StructuredRPKIMode = t.Literal["router", "external"] +StructuredRPKIBackend = t.Literal["cloudflare", "routinator"] class StructuredCommunities(HyperglassModel): @@ -15,12 +19,34 @@ class StructuredCommunities(HyperglassModel): mode: StructuredCommunityMode = "deny" items: t.List[str] = [] + names: t.Dict[str, str] = {} + + @field_validator("names") + def validate_names(cls, value: t.Dict[str, str], info: ValidationInfo) -> t.Dict[str, str]: + """Require at least one community mapping when mode is 'name'.""" + if info.data and info.data.get("mode") == "name" and not value: + raise ValueError( + "When using mode 'name', at least one community mapping must be " + "provided in 'names'" + ) + return value class StructuredRpki(HyperglassModel): """Control structured data response for RPKI state.""" mode: StructuredRPKIMode = "router" + backend: StructuredRPKIBackend = "cloudflare" + rpki_server_url: str = "" + + @model_validator(mode="after") + def validate_routinator_url(self) -> "StructuredRpki": + """Require a server URL when the routinator backend is selected.""" + if self.backend == "routinator" and not self.rpki_server_url: + raise ValueError( + "structured.rpki.rpki_server_url is required when backend is 'routinator'" + ) + return self class Structured(HyperglassModel): diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index 7916d9e9b..f6881935c 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -137,6 +137,7 @@ class ThemeColors(HyperglassModel): dark: Color = "#010101" light: Color = "#f5f6f7" gray: Color = "#c1c7cc" + filtered: Color = "#c1c7cc" red: Color = "#d84b4b" orange: Color = "#ff6b35" yellow: Color = "#edae49" diff --git a/hyperglass/models/data/bgp_route.py b/hyperglass/models/data/bgp_route.py index a00af4e13..7eadb47ed 100644 --- a/hyperglass/models/data/bgp_route.py +++ b/hyperglass/models/data/bgp_route.py @@ -34,6 +34,7 @@ class BGPRoute(HyperglassModel): source_rid: str peer_rid: str rpki_state: int + filtered: bool = False @field_validator("communities") def validate_communities(cls, value): @@ -42,6 +43,7 @@ def validate_communities(cls, value): Actions: permit: only permit matches deny: only deny matches + name: append friendly names to matching communities """ (structured := use_state("params").structured) @@ -64,6 +66,15 @@ def _deny(comm): break return valid + def _name(comm): + """Append a friendly name to a community when one is mapped.""" + if comm in structured.communities.names: + return f"{comm},{structured.communities.names[comm]}" + return comm + + if structured.communities.mode == "name": + return [_name(c) for c in value] + func_map = {"permit": _permit, "deny": _deny} func = func_map[structured.communities.mode] @@ -80,10 +91,9 @@ def validate_rpki_state(cls, value, info: ValidationInfo): return value if structured.rpki.mode == "external": - # If external validation is enabled, validate the prefix - # & asn with Cloudflare's RPKI API. + # If external validation is enabled, validate the prefix & asn + # with the configured RPKI backend. as_path = info.data.get("as_path", []) - if len(as_path) == 0: # If the AS_PATH length is 0, i.e. for an internal route, # return RPKI Unknown state. @@ -91,14 +101,19 @@ def validate_rpki_state(cls, value, info: ValidationInfo): # Get last ASN in path asn = as_path[-1] - try: - net = ip_network(info.data["prefix"]) - except ValueError: - return 3 + try: + net = ip_network(info.data["prefix"]) + except ValueError: + return 3 - # Only do external RPKI lookups for global prefixes. - if net.is_global: - return rpki_state(prefix=info.data["prefix"], asn=asn) + # Only do external RPKI lookups for global prefixes. + if net.is_global: + return rpki_state( + prefix=info.data["prefix"], + asn=asn, + backend=structured.rpki.backend, + rpki_server_url=structured.rpki.rpki_server_url, + ) return value diff --git a/hyperglass/models/parsing/huawei.py b/hyperglass/models/parsing/huawei.py new file mode 100644 index 000000000..357f121b5 --- /dev/null +++ b/hyperglass/models/parsing/huawei.py @@ -0,0 +1,394 @@ +"""Parser for Huawei VRP.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import ConfigDict, field_validator, model_validator + +# Project +from hyperglass.log import log +from hyperglass.models.data.bgp_route import BGPRoute, BGPRouteTable + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +def remove_prefix(text: str, prefix: str) -> str: + """Remove prefix from text if it exists.""" + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +class HuaweiBase(HyperglassModel, extra="ignore"): + """Base Huawei model.""" + + def __init__(self, **kwargs: t.Any) -> None: + """Initialize Huawei model.""" + super().__init__(**kwargs) + + +class HuaweiPaths(HuaweiBase): + """BGP paths information.""" + + available: int = 0 + best: int = 0 + select: int = 0 + best_external: int = 0 + add_path: int = 0 + + +class HuaweiRouteEntry(HuaweiBase): + """Parse Huawei route entry data.""" + + model_config = ConfigDict(validate_assignment=False) + + prefix: str + from_addr: str = "" + duration: int = 0 + direct_out_interface: str = "" + original_next_hop: str = "" + relay_ip_next_hop: str = "" + relay_ip_out_interface: str = "" + qos: str = "" + communities: t.List[str] = [] + large_communities: t.List[str] = [] + ext_communities: t.List[str] = [] + as_path: t.List[int] = [] + origin: str = "" + metric: int = 0 # MED + local_preference: int = 100 + preference_value: int = 0 + is_valid: bool = False + is_external: bool = False + is_backup: bool = False + is_best: bool = False + is_selected: bool = False + preference: int = 0 + + @property + def next_hop(self) -> str: + """Get next hop (original or relay IP).""" + return self.original_next_hop or self.relay_ip_next_hop + + @property + def age(self) -> str: + """Get age as string.""" + return str(self.duration) + + @property + def weight(self) -> int: + """Get weight (preference).""" + return self.preference + + @property + def med(self) -> int: + """Get MED (metric).""" + return self.metric + + @property + def active(self) -> bool: + """Check if route is active (best or selected).""" + return self.is_best or self.is_selected + + @property + def all_communities(self) -> t.List[str]: + """Get all communities combined.""" + return self.communities + self.large_communities + self.ext_communities + + @property + def source_as(self) -> int: + """Get source AS from AS path (first ASN in the path).""" + return self.as_path[0] if self.as_path else 0 + + @property + def source_rid(self) -> str: + """Get source router ID.""" + return "" + + @property + def peer_rid(self) -> str: + """Get peer router ID.""" + return self.from_addr + + +def _extract_paths(line: str) -> HuaweiPaths: + """Extract paths information from line like 'Paths: 3 available, 1 best, 1 select, 0 best-external, 0 add-path'.""" + paths_data = { + "available": 0, + "best": 0, + "select": 0, + "best_external": 0, + "add_path": 0, + } + + try: + values = remove_prefix(line.strip(), "Paths:").strip().split(",") + for value in values: + parts = value.strip().split(" ") + if len(parts) >= 2: + count = int(parts[0]) + name = parts[1].replace("-", "_") # Convert best-external to best_external + if name in paths_data: + paths_data[name] = count + except (ValueError, IndexError): + log.warning(f"Failed to parse paths line: {line}") + + return HuaweiPaths(**paths_data) + + +def _extract_route_entries(lines: t.List[str]) -> t.List[HuaweiRouteEntry]: + """Extract route entries from lines.""" + routes = [] + + # Split lines into route blocks using empty lines as separators + size = len(lines) + idx_list = [idx + 1 for idx, val in enumerate(lines) if val.strip() == ""] + entries = ( + [ + lines[i:j] + for i, j in zip([0] + idx_list, idx_list + ([size] if idx_list[-1] != size else [])) + ] + if idx_list + else [lines] + ) + + for route_entry in entries: + if not route_entry: + continue + + # Initialize route data + route_data = { + "prefix": "", + "from_addr": "", + "duration": 0, + "direct_out_interface": "", + "original_next_hop": "", + "relay_ip_next_hop": "", + "relay_ip_out_interface": "", + "qos": "", + "communities": [], + "large_communities": [], + "ext_communities": [], + "as_path": [], + "origin": "", + "metric": 0, + "local_preference": 100, + "preference_value": 0, + "is_valid": False, + "is_external": False, + "is_backup": False, + "is_best": False, + "is_selected": False, + "preference": 0, + } + + for info in route_entry: + info = info.strip() + if not info: + continue + + if info.startswith("BGP routing table entry information of"): + route_data["prefix"] = remove_prefix( + info, "BGP routing table entry information of " + ).rstrip(":") + elif info.startswith("From:"): + route_data["from_addr"] = remove_prefix(info, "From: ").split(" (")[0] + elif info.startswith("Route Duration:"): + duration_str = remove_prefix(info, "Route Duration: ") + try: + # Parse format like "84d11h53m07s" + d_match = re.search(r"(\d+)d", duration_str) + h_match = re.search(r"(\d+)h", duration_str) + m_match = re.search(r"(\d+)m", duration_str) + s_match = re.search(r"(\d+)s", duration_str) + + days = int(d_match.group(1)) if d_match else 0 + hours = int(h_match.group(1)) if h_match else 0 + minutes = int(m_match.group(1)) if m_match else 0 + seconds = int(s_match.group(1)) if s_match else 0 + + route_data["duration"] = ( + days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60 + seconds + ) + except: + route_data["duration"] = 0 + elif info.startswith("Direct Out-interface:"): + route_data["direct_out_interface"] = remove_prefix(info, "Direct Out-interface: ") + elif info.startswith("Original nexthop:"): + route_data["original_next_hop"] = remove_prefix(info, "Original nexthop: ") + elif info.startswith("Relay IP Nexthop:"): + route_data["relay_ip_next_hop"] = remove_prefix(info, "Relay IP Nexthop: ") + elif info.startswith("Relay IP Out-Interface:"): + route_data["relay_ip_out_interface"] = remove_prefix( + info, "Relay IP Out-Interface: " + ) + elif info.startswith("Qos information :"): + route_data["qos"] = remove_prefix(info, "Qos information : ") + elif info.startswith("Community:"): + communities_str = remove_prefix(info, "Community: ") + if communities_str and communities_str.lower() != "none": + communities = [ + c.strip().replace("<", "").replace(">", "") + for c in communities_str.split(", ") + ] + route_data["communities"] = [c for c in communities if c] + elif info.startswith("Large-Community:"): + large_communities_str = remove_prefix(info, "Large-Community: ") + if large_communities_str and large_communities_str.lower() != "none": + large_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in large_communities_str.split(", ") + ] + route_data["large_communities"] = [c for c in large_communities if c] + elif info.startswith("Ext-Community:"): + ext_communities_str = remove_prefix(info, "Ext-Community: ") + if ext_communities_str and ext_communities_str.lower() != "none": + ext_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in ext_communities_str.split(", ") + ] + route_data["ext_communities"] = [c for c in ext_communities if c] + elif info.startswith("AS-path"): + values = info.split(",") + for v in values: + v = v.strip() + if v.startswith("AS-path"): + as_path_str = remove_prefix(v, "AS-path ") + try: + route_data["as_path"] = [ + int(a) for a in as_path_str.split() if a.isdigit() + ] + except ValueError: + route_data["as_path"] = [] + elif v.startswith("origin"): + route_data["origin"] = remove_prefix(v, "origin ") + elif v.startswith("MED"): + try: + route_data["metric"] = int(remove_prefix(v, "MED ")) + except ValueError: + route_data["metric"] = 0 + elif v.startswith("localpref"): + try: + route_data["local_preference"] = int(remove_prefix(v, "localpref ")) + except ValueError: + route_data["local_preference"] = 100 + elif v.startswith("pref-val"): + try: + route_data["preference_value"] = int(remove_prefix(v, "pref-val ")) + except ValueError: + route_data["preference_value"] = 0 + elif v.startswith("pre "): + try: + route_data["preference"] = int(remove_prefix(v, "pre ")) + except ValueError: + route_data["preference"] = 0 + elif v.strip() == "valid": + route_data["is_valid"] = True + elif v.strip() == "external": + route_data["is_external"] = True + elif v.strip() == "backup": + route_data["is_backup"] = True + elif v.strip() == "best": + route_data["is_best"] = True + elif v.strip() == "select": + route_data["is_selected"] = True + + # Only add route if we have a valid prefix + if route_data["prefix"]: + try: + route = HuaweiRouteEntry(**route_data) + routes.append(route) + except Exception as e: + log.warning( + "Failed to create route entry for prefix {}: {}", + route_data.get("prefix", "unknown"), + e, + ) + continue + + return routes + + +class HuaweiBGPRouteTable(BGPRouteTable): + """Canonical Huawei BGP Route Table.""" + + # No custom __init__ needed; inherit from BGPRouteTable (which should be a Pydantic model) + + +class HuaweiBGPTable(HuaweiBase): + """Validation model for Huawei BGP table data.""" + + local_router_id: str = "" + local_as_number: int = 0 + paths: HuaweiPaths = HuaweiPaths() + routes: t.List[HuaweiRouteEntry] = [] + + @classmethod + def parse_text(cls, text: str) -> "HuaweiBGPTable": + """Parse Huawei BGP text output.""" + _log = log.bind(parser="HuaweiBGPTable") + + instance = cls() + + lines = text.split("\n") + + # Extract general information + for line in lines: + if "BGP local router ID" in line: + instance.local_router_id = remove_prefix(line, "BGP local router ID : ").strip() + elif "Local AS number" in line: + try: + instance.local_as_number = int( + remove_prefix(line, "Local AS number : ").strip() + ) + except ValueError: + instance.local_as_number = 0 + elif line.strip().startswith("Paths:"): + instance.paths = _extract_paths(line) + + # Extract route entries + instance.routes = _extract_route_entries(lines) + + _log.debug(f"Parsed {len(instance.routes)} Huawei routes") + return instance + + def bgp_table(self) -> BGPRouteTable: + """Convert to standard BGP table format.""" + routes = [] + + for route in self.routes: + route_data = { + "prefix": route.prefix, + "active": route.active, + "age": route.age, + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.all_communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": ( + RPKI_STATE_MAP["valid"] if route.is_valid else RPKI_STATE_MAP["unknown"] + ), + } + routes.append(BGPRoute(**route_data)) + + return HuaweiBGPRouteTable( + vrf="default", + count=len(routes), + routes=routes, + winning_weight="high", + ) diff --git a/hyperglass/models/parsing/mikrotik.py b/hyperglass/models/parsing/mikrotik.py new file mode 100644 index 000000000..ae8980578 --- /dev/null +++ b/hyperglass/models/parsing/mikrotik.py @@ -0,0 +1,552 @@ +"""Parser for MikroTik RouterOS (ROS v6/v7) – structured in Huawei style.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import ConfigDict + +# Project +from hyperglass.log import log +from hyperglass.settings import Settings +from hyperglass.models.data.bgp_route import BGPRoute, BGPRouteTable # Add BGPRoute import + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +def remove_prefix(text: str, prefix: str) -> str: + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +# Regex to find key=value pairs. The key can contain dots and hyphens. +# The value can be quoted or a single word. +TOKEN_RE = re.compile(r'([a-zA-Z0-9_.-]+)=(".*?"|\S+)') + +# Regex to find flags at the beginning of a line (e.g., "Ab dst-address=..."). +# Include filtered/disabled/unreachable flags so those routes split into their own entries. +FLAGS_RE = re.compile(r"^\s*([DXUuIAFfcmsroivmyH\+b]+)\s+") + + +class MikrotikBase(HyperglassModel, extra="ignore"): + """Base model for MikroTik parsing models (ignores unknown fields).""" + + +class MikrotikPaths(MikrotikBase): + available: int = 0 + best: int = 0 + select: int = 0 + best_external: int = 0 + add_path: int = 0 + + +class MikrotikRouteEntry(MikrotikBase): + """MikroTik Route Entry.""" + + model_config = ConfigDict(validate_assignment=False) + + prefix: str + gateway: str = "" + distance: int = 0 + scope: int = 0 + target_scope: int = 0 + as_path: t.List[int] = [] + communities: t.List[str] = [] + large_communities: t.List[str] = [] + ext_communities: t.List[str] = [] + local_preference: int = 100 + metric: int = 0 # MED + origin: str = "" + belongs_to: str = "" # BGP session identifier + is_active: bool = False + is_filtered: bool = False + rpki_state: int = RPKI_STATE_MAP.get("unknown", 2) + + @property + def next_hop(self) -> str: + return self.gateway + + @property + def age(self) -> int: + # MikroTik output does not provide route age, returning -1 to indicate unavailable. + return -1 + + @property + def weight(self) -> int: + return self.distance + + @property + def med(self) -> int: + return self.metric + + @property + def active(self) -> bool: + return self.is_active + + @property + def all_communities(self) -> t.List[str]: + return self.communities + self.large_communities + self.ext_communities + + @property + def source_as(self) -> int: + return self.as_path[-1] if self.as_path else 0 + + @property + def source_rid(self) -> str: + """Extract the IP/IPv6 address from belongs-to field. + + Examples: + belongs-to="bgp-IP-196.60.96.109" -> "196.60.96.109" + belongs-to="bgp-IPv6-2001:43f8:6d0::11:141" -> "2001:43f8:6d0::11:141" + """ + if not self.belongs_to: + return "" + + # Remove bgp-IP- or bgp-IPv6- prefix + if self.belongs_to.startswith("bgp-IP-"): + return self.belongs_to[7:] # len("bgp-IP-") = 7 + elif self.belongs_to.startswith("bgp-IPv6-"): + return self.belongs_to[9:] # len("bgp-IPv6-") = 9 + + return "" + + @property + def peer_rid(self) -> str: + return self.gateway + + +def _extract_paths(lines: t.List[str]) -> MikrotikPaths: + """Simple count based on lines with dst/dst-address and preferred 'A' flag.""" + available = 0 + best = 0 + for raw in lines: + if ("dst-address=" in raw) or (" dst=" in f" {raw} "): + available += 1 + m = FLAGS_RE.match(raw) + if m: + flags = set(m.group(1)) + if "A" in flags: + best += 1 + return MikrotikPaths(available=available, best=best, select=best) + + +def _process_kv(route: dict, key: str, val: str): + """Process a key-value pair and update the route dictionary.""" + # Normalize quoted values + if val.startswith('"') and val.endswith('"'): + val = val[1:-1] + + if key in ("dst-address", "dst"): + route["prefix"] = val + elif key in ("gateway", "nexthop"): + # Extract only the IP from gateway (e.g., 168.254.0.2%vlan-2000) + route["gateway"] = val.split("%")[0] + elif key == "distance": + route["distance"] = int(val) if val.isdigit() else route.get("distance", 0) + elif key == "scope": + route["scope"] = int(val) if val.isdigit() else route.get("scope", 0) + elif key in ("target-scope", "target_scope"): + route["target_scope"] = int(val) if val.isdigit() else route.get("target_scope", 0) + + # v7 keys (with dot) + elif key in (".as-path", "as-path", "bgp-as-path"): + if val and val.lower() != "none": + # Find all numbers in the as-path string + nums = re.findall(r"\b\d{1,10}\b", val) + route["as_path"] = [int(n) for n in nums if 1 <= int(n) <= 4294967295] + elif key in (".origin", "origin", "bgp-origin"): + route["origin"] = val + elif key in (".med", "med", "bgp-med"): + route["metric"] = int(val) if val.isdigit() else 0 + elif key in (".local-pref", "local-pref", "bgp-local-pref"): + route["local_preference"] = int(val) if val.isdigit() else 100 + elif key in (".communities", "communities", "bgp-communities"): + if val and val.lower() != "none": + route["communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key in (".large-communities", "large-communities", "bgp-large-communities"): + if val and val.lower() != "none": + route["large_communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key == "bgp-ext-communities": + if val and val.lower() != "none": + route["ext_communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key == "rpki": + clean_val = val.strip().strip('"').lower() + route["rpki_state"] = RPKI_STATE_MAP.get(clean_val, 2) + elif key == "belongs-to": + route["belongs_to"] = val + + +def _extract_route_entries(lines: t.List[str]) -> t.List[MikrotikRouteEntry]: + """Extract route entries from a list of lines.""" + routes: t.List[MikrotikRouteEntry] = [] + current_route_lines = [] + + for line in lines: + stripped_line = line.strip() + # A new route entry starts with flags or is a continuation line. + # An empty line signifies the end of the previous block. + if not stripped_line and current_route_lines: + # Process the completed route block + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + current_route_lines = [] + elif stripped_line: + # Check if this line is the start of a new entry + if FLAGS_RE.match(stripped_line) and current_route_lines: + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + current_route_lines = [stripped_line] + else: + current_route_lines.append(stripped_line) + + # Process any remaining lines + if current_route_lines: + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + + return routes + + +def _parse_route_block(block: t.List[str]) -> t.Optional[MikrotikRouteEntry]: + """Parse a single route block and return a MikrotikRouteEntry.""" + if not block: + return None + + full_block_text = " ".join(block) + if "dst-address=" not in full_block_text and " dst=" not in f" {full_block_text} ": + return None + + flags = "" + rd = { + "prefix": "", + "gateway": "", + "distance": 20, + "scope": 30, + "target_scope": 10, + "as_path": [], + "communities": [], + "large_communities": [], + "ext_communities": [], + "local_preference": 100, + "metric": 0, + "origin": "", + "belongs_to": "", + "is_active": False, + "is_filtered": False, + "rpki_state": RPKI_STATE_MAP.get("unknown", 2), + } + + # Interpret flags in the first line: + # - "A" => active (preferred) + # - otherwise not active + m = FLAGS_RE.match(block[0]) + if m: + flags = m.group(1) + flag_set = set(flags) + is_active = "A" in flag_set + is_filtered = bool(flag_set & {"F", "f", "U", "u", "X", "x"}) + + if is_active: + rd["is_active"] = True + rd["is_filtered"] = False + else: + rd["is_filtered"] = is_filtered + + # Find all key=value tokens in the entire block + for k, v in TOKEN_RE.findall(full_block_text): + _process_kv(rd, k, v) + + if rd["prefix"]: + try: + if Settings.debug: + log.bind(parser="MikrotikBGPTable").debug( + "Parsed MikroTik route entry: " + "prefix={prefix} flags={flags} active={active} filtered={filtered} " + "next_hop={next_hop} as_path={as_path} rpki_state={rpki_state}", + prefix=rd["prefix"], + flags=flags or None, + active=rd["is_active"], + filtered=rd["is_filtered"], + next_hop=rd["gateway"] or None, + as_path=rd["as_path"], + rpki_state=rd["rpki_state"], + ) + return MikrotikRouteEntry(**rd) + except Exception as e: + log.warning(f"Failed to create MikroTik route entry ({rd.get('prefix','?')}: {e}") + return None + + +class MikrotikBGPRouteTable(BGPRouteTable): + """Canonical MikroTik BGP Route Table.""" + + # No custom __init__ needed; inherit from BGPRouteTable (which should be a Pydantic model) + + +class MikrotikBGPTable(MikrotikBase): + """MikroTik BGP Table in canonical format.""" + + local_router_id: str = "" + local_as_number: int = 0 + paths: MikrotikPaths = MikrotikPaths() + routes: t.List[MikrotikRouteEntry] = [] + + @classmethod + def parse_text(cls, text: str) -> "MikrotikBGPTable": + _log = log.bind(parser="MikrotikBGPTable") + inst = cls() + + lines = text.splitlines() + if not lines: + return inst + + # Filter out command echoes and header lines + lines = [ln for ln in lines if not ln.strip().startswith((">", "Flags:", "[", "#"))] + + inst.paths = _extract_paths(lines) + inst.routes = _extract_route_entries(lines) + + _log.debug(f"Parsed {len(inst.routes)} MikroTik routes") + return inst + + def bgp_table(self) -> BGPRouteTable: + routes = [] + for route in self.routes: + route_data = { + "prefix": route.prefix, + "active": route.active, + "age": route.age, + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.all_communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": route.rpki_state, + "filtered": route.is_filtered, + } + # Instantiate BGPRoute to trigger validation (including external RPKI) + routes.append(BGPRoute(**route_data)) + return MikrotikBGPRouteTable( + vrf="default", + count=len(routes), + routes=routes, + winning_weight="low", + ) + + +class MikrotikTracerouteTable(MikrotikBase): + """MikroTik Traceroute Table.""" + + target: str + source: str + hops: t.List["MikrotikTracerouteHop"] = [] + max_hops: int = 30 + packet_size: int = 60 + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> "MikrotikTracerouteTable": + """Parse a cleaned MikroTik traceroute table. + + The input is expected to be a single, clean traceroute table that has already + been processed by the garbage cleaner to remove paging artifacts and duplicates. + + Expected format: + ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS + 197.157.67.233 0% 3 0.4ms 0.2 0.1 0.4 0.1 + 100% 3 timeout + 41.78.188.153 0% 3 210.8ms 210.8 210.8 210.9 0 + """ + _log = log.bind(parser="MikrotikTracerouteTable") + + _log.debug( + "Parsing cleaned MikroTik traceroute table", + target=target, + source=source, + lines=len(text.splitlines()), + ) + + lines = text.strip().split("\n") + hops = [] + hop_number = 1 + + # Find the table header to start parsing from + header_found = False + data_start_index = 0 + + for i, line in enumerate(lines): + if "ADDRESS" in line and "LOSS" in line and "SENT" in line: + header_found = True + data_start_index = i + 1 + break + + if not header_found: + _log.warning("No traceroute table header found in cleaned output") + return MikrotikTracerouteTable(target=target, source=source, hops=[]) + + # Parse data rows + for i in range(data_start_index, len(lines)): + line = lines[i].strip() + + # Skip empty lines + if not line: + continue + + # Stop at any remaining paging markers (shouldn't happen with cleaned input) + if "-- [Q quit|C-z pause]" in line: + break + + try: + # Parse data line + parts = line.split() + if len(parts) < 3: + continue + + # Check if this is a timeout line (starts with percentage) + if parts[0].endswith("%"): + # Timeout hop: "100% 3 timeout" + ip_address = None + loss_pct = int(parts[0].rstrip("%")) + sent_count = int(parts[1]) + last_rtt = None + avg_rtt = None + best_rtt = None + worst_rtt = None + else: + # Normal hop: "197.157.67.233 0% 3 0.4ms 0.2 0.1 0.4 0.1" + ip_address = parts[0] + if len(parts) < 7: + continue + + loss_pct = int(parts[1].rstrip("%")) + sent_count = int(parts[2]) + + # Parse RTT values + def parse_rtt(rtt_str: str) -> t.Optional[float]: + if rtt_str in ("timeout", "-", "0ms", "*"): + return None + rtt_clean = re.sub(r"ms$", "", rtt_str) + try: + return float(rtt_clean) + except ValueError: + return None + + last_rtt = parse_rtt(parts[3]) + avg_rtt = parse_rtt(parts[4]) + best_rtt = parse_rtt(parts[5]) + worst_rtt = parse_rtt(parts[6]) + + hop = MikrotikTracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + hostname=None, # MikroTik doesn't do reverse DNS by default + loss_pct=loss_pct, + sent_count=sent_count, + last_rtt=last_rtt, + avg_rtt=avg_rtt, + best_rtt=best_rtt, + worst_rtt=worst_rtt, + ) + + hops.append(hop) + hop_number += 1 + + except (ValueError, IndexError) as e: + _log.debug("Failed to parse traceroute data line", line=line, error=str(e)) + continue + + result = MikrotikTracerouteTable(target=target, source=source, hops=hops) + _log.info("Parsed cleaned traceroute table", hops=len(hops)) + return result + + def traceroute_result(self): + """Convert to TracerouteResult format.""" + from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop + from hyperglass.log import log + + _log = log.bind(parser="MikrotikTracerouteTable") + + converted_hops = [] + for hop in self.hops: + # Handle truncated IP addresses + ip_address = hop.ip_address + display_ip = None + + if hop.ip_address and hop.ip_address.endswith("..."): + # For truncated IPs, store for display but set ip_address to None for validation + display_ip = hop.ip_address + ip_address = None + + created_hop = TracerouteHop( + hop_number=hop.hop_number, + ip_address=ip_address, # None for truncated IPs + display_ip=display_ip, # Truncated IP for display + hostname=hop.hostname, + # Set RTT values to ensure avg_rtt property returns MikroTik's AVG value + # Since avg_rtt = (rtt1 + rtt2 + rtt3) / 3, we set all to the MikroTik AVG + rtt1=hop.avg_rtt, # Set to AVG so computed average is correct + rtt2=hop.avg_rtt, # Set to AVG so computed average is correct + rtt3=hop.avg_rtt, # Set to AVG so computed average is correct + # MikroTik-specific statistics (preserve original values) + loss_pct=hop.loss_pct, + sent_count=hop.sent_count, + last_rtt=hop.last_rtt, # Preserve LAST value + best_rtt=hop.best_rtt, # Preserve BEST value + worst_rtt=hop.worst_rtt, # Preserve WORST value + # BGP enrichment fields will be populated by enrichment plugin + # For truncated IPs, these will remain None/empty + asn=None, + org=None, + prefix=None, + country=None, + rir=None, + allocated=None, + ) + + converted_hops.append(created_hop) + + return TracerouteResult( + target=self.target, + source=self.source, + hops=converted_hops, + max_hops=self.max_hops, + packet_size=self.packet_size, + raw_output=None, # Will be set by the plugin + ) + + +class MikrotikTracerouteHop(MikrotikBase): + """Individual MikroTik traceroute hop.""" + + hop_number: int + ip_address: t.Optional[str] = None + hostname: t.Optional[str] = None + + # MikroTik-specific statistics + loss_pct: t.Optional[int] = None + sent_count: t.Optional[int] = None + last_rtt: t.Optional[float] = None + avg_rtt: t.Optional[float] = None + best_rtt: t.Optional[float] = None + worst_rtt: t.Optional[float] = None + + @property + def is_timeout(self) -> bool: + """Check if this hop is a timeout.""" + return self.ip_address is None or self.loss_pct == 100 diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py index 5e87f5b11..cbc1eb314 100644 --- a/hyperglass/plugins/_builtin/__init__.py +++ b/hyperglass/plugins/_builtin/__init__.py @@ -5,14 +5,20 @@ from .remove_command import RemoveCommand from .bgp_route_arista import BGPRoutePluginArista from .bgp_route_huawei import BGPRoutePluginHuawei +from .bgp_routestr_huawei import BGPSTRRoutePluginHuawei from .bgp_route_juniper import BGPRoutePluginJuniper from .mikrotik_garbage_output import MikrotikGarbageOutput +from .mikrotik_normalize_input import MikrotikTargetNormalizerInput +from .bgp_routestr_mikrotik import BGPSTRRoutePluginMikrotik __all__ = ( "BGPRoutePluginArista", "BGPRoutePluginFrr", "BGPRoutePluginJuniper", "BGPRoutePluginHuawei", + "BGPSTRRoutePluginHuawei", "MikrotikGarbageOutput", + "MikrotikTargetNormalizerInput", + "BGPSTRRoutePluginMikrotik", "RemoveCommand", ) diff --git a/hyperglass/plugins/_builtin/bgp_route_huawei.py b/hyperglass/plugins/_builtin/bgp_route_huawei.py index 172d9068f..484dc9cc8 100644 --- a/hyperglass/plugins/_builtin/bgp_route_huawei.py +++ b/hyperglass/plugins/_builtin/bgp_route_huawei.py @@ -21,7 +21,10 @@ class BGPRoutePluginHuawei(InputPlugin): "huawei", "huawei_vrpv8", ) - directives: t.Sequence[str] = ("__hyperglass_huawei_bgp_route__",) + directives: t.Sequence[str] = ( + "__hyperglass_huawei_bgp_route__", + "__hyperglass_huawei_bgp_route_table__", + ) """ Huawei BGP Route Input Plugin @@ -33,15 +36,25 @@ class BGPRoutePluginHuawei(InputPlugin): def transform(self, query: "Query") -> InputPluginTransformReturn: target = query.query_target - if not target or not isinstance(target, list) or len(target) == 0: + if not target: return None - target = target[0].strip() + if isinstance(target, list): + if len(target) == 0: + return None + target = target[0].strip() + elif isinstance(target, str): + target = target.strip() + else: + return None # Check for the / in the query target if target.find("/") == -1: return target - target_network = ip_network(target) - - return f"{target_network.network_address!s} {target_network.prefixlen!s}" + try: + target_network = ip_network(target) + return f"{target_network.network_address!s} {target_network.prefixlen!s}" + except ValueError: + # Return the target unchanged if it isn't a valid network + return target diff --git a/hyperglass/plugins/_builtin/bgp_routestr_huawei.py b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py new file mode 100644 index 000000000..e42308344 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py @@ -0,0 +1,80 @@ +"""Coerce a Huawei route table in text format to a standard BGP Table structure.""" + +# Standard Library +import re +from typing import TYPE_CHECKING, List, Sequence + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +# Local +from .._output import OutputPlugin + +if TYPE_CHECKING: + # Project + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + + # Local + from .._output import OutputType + + +def parse_huawei(output: Sequence[str]) -> "OutputDataModel": + """Parse a Huawei BGP text response.""" + result = None + + _log = log.bind(plugin=BGPSTRRoutePluginHuawei.__name__) + + for response in output: + try: + # Parse the text output using the Huawei parser + validated = HuaweiBGPTable.parse_text(response) + bgp_table = validated.bgp_table() + + if result is None: + result = bgp_table + else: + result += bgp_table + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + except Exception as err: + _log.bind(error=str(err)).critical("Failed to parse Huawei BGP output") + raise ParsingError("Error parsing response data") from err + + return result + + +class BGPSTRRoutePluginHuawei(OutputPlugin): + """Coerce a Huawei route table in text format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: Sequence[str] = ("huawei",) + directives: Sequence[str] = ( + "__hyperglass_huawei_bgp_route_table__", + "__hyperglass_huawei_bgp_aspath_table__", + "__hyperglass_huawei_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse Huawei response if data is a string (and is therefore unparsed).""" + _log = log.bind(plugin=self.__class__.__name__) + _log.debug("Processing Huawei output with structured parser") + + should_process = all( + ( + isinstance(output, (list, tuple)), + query.device.platform in self.platforms, + query.device.structured_output is True, + query.device.has_directives(*self.directives), + ) + ) + if should_process: + return parse_huawei(output) + return output diff --git a/hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py b/hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py new file mode 100644 index 000000000..ad08c56e5 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py @@ -0,0 +1,86 @@ +"""Coerce a MikroTik route table in text format to a standard BGP Table structure.""" + +# Standard Library +from typing import TYPE_CHECKING, Sequence, Union, List + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.mikrotik import MikrotikBGPTable + +# Local +from .._output import OutputPlugin + +if TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + from .._output import OutputType + + +def _normalize_output(output: Union[str, Sequence[str]]) -> List[str]: + """Ensure the output is a list of strings.""" + if isinstance(output, str): + return [output] + return list(output) + + +def parse_mikrotik(output: Union[str, Sequence[str]]) -> "OutputDataModel": + """Parse a MikroTik BGP text response.""" + result = None + out_list = _normalize_output(output) + + _log = log.bind(plugin=BGPSTRRoutePluginMikrotik.__name__) + combined_output = "\n".join(out_list) + _log.debug(f"Combined output length: {len(combined_output)}") + + try: + # Pass the entire combined output to the parser at once + validated = MikrotikBGPTable.parse_text(combined_output) + result = validated.bgp_table() + _log.debug(f"Successfully parsed {len(validated.routes)} routes") + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + except Exception as err: + _log.bind(error=str(err)).critical("Failed to parse MikroTik BGP output") + raise ParsingError("Error parsing response data") from err + + return result + + +class BGPSTRRoutePluginMikrotik(OutputPlugin): + """Coerce a MikroTik route table in text format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + + directives: Sequence[str] = ( + "__hyperglass_mikrotik_bgp_route_table__", + "__hyperglass_mikrotik_bgp_aspath_table__", + "__hyperglass_mikrotik_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse MikroTik response if data is text (and is therefore unparsed).""" + _log = log.bind(plugin=self.__class__.__name__) + _log.debug("Processing MikroTik output with structured parser") + + is_text = isinstance(output, (list, tuple, str)) + has_platform = query.device.platform in self.platforms + wants_structured = bool(getattr(query.device, "structured_output", False)) + has_directives = query.device.has_directives(*self.directives) + + should_process = all((is_text, has_platform, wants_structured, has_directives)) + if not should_process: + _log.debug( + "Skipping structured parser: " + f"is_text={is_text}, platform={query.device.platform!r} (o@k={has_platform}), " + f"structured_output={wants_structured}, has_directives={has_directives}" + ) + return output + + return parse_mikrotik(output) diff --git a/hyperglass/plugins/_builtin/mikrotik_garbage_output.py b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py index 8718604c1..4bb64be24 100644 --- a/hyperglass/plugins/_builtin/mikrotik_garbage_output.py +++ b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py @@ -8,6 +8,7 @@ from pydantic import PrivateAttr # Project +from hyperglass.log import log from hyperglass.types import Series # Local @@ -19,64 +20,246 @@ class MikrotikGarbageOutput(OutputPlugin): - """Parse Mikrotik output to remove garbage.""" + """Parse Mikrotik output to remove garbage before structured parsing.""" _hyperglass_builtin: bool = PrivateAttr(True) - platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos") - directives: t.Sequence[str] = ( - "__hyperglass_mikrotik_bgp_aspath__", - "__hyperglass_mikrotik_bgp_community__", - "__hyperglass_mikrotik_bgp_route__", - "__hyperglass_mikrotik_ping__", - "__hyperglass_mikrotik_traceroute__", - ) + platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + # Only apply to MikroTik platforms, not all devices + common: bool = False - def process(self, *, output: OutputType, query: "Query") -> Series[str]: - """Parse Mikrotik output to remove garbage.""" + def _clean_traceroute_output(self, raw_output: str) -> str: + """Clean MikroTik traceroute output specifically. + + Important: Traceroute hops are sequential - each line represents a unique hop position. + We should NOT deduplicate by IP address as the same IP can appear at different hops. + Order matters for traceroute results. + + However, we can aggregate consecutive timeout lines at the END of the traceroute + to avoid showing 10+ meaningless timeout entries. + """ + if not raw_output or not raw_output.strip(): + return "" + + lines = raw_output.splitlines() + # Remove command echoes and paging, keep only header markers and data lines + # We'll split the output into discrete tables (each table begins at a header) + tables: t.List[t.List[str]] = [] + current_table: t.List[str] = [] + header_line: t.Optional[str] = None + + for line in lines: + stripped = line.strip() + + # Skip empty lines and interactive paging prompts + if ( + not stripped + or "-- [Q quit|C-z pause]" in stripped + or "-- [Q quit|D dump|C-z pause]" in stripped + ): + continue + + # Skip command echo lines + if "tool traceroute" in stripped: + continue + + # If this is a header line, start a new table + if "ADDRESS" in stripped and "LOSS" in stripped and "SENT" in stripped: + header_line = line + # If we were collecting a table, push it + if current_table: + tables.append(current_table) + current_table = [] + # Start collecting after header + continue + + # Collect data lines (will be associated with the most recent header) + if header_line is not None: + current_table.append(line) + + # Push the last collected table if any + if current_table: + tables.append(current_table) - result = () + # If we didn't find any header/data, return cleaned minimal output + if not tables: + # Fallback to previous behavior: remove prompts and flags + filtered_lines: t.List[str] = [] + in_flags_section = False + for line in lines: + stripped_line = line.strip() + if stripped_line.startswith("@") and stripped_line.endswith("] >"): + continue + if "[Q quit|D dump|C-z pause]" in stripped_line: + continue + if stripped_line.startswith("Flags:"): + in_flags_section = True + continue + if in_flags_section: + if "=" in stripped_line: + in_flags_section = False + else: + continue + filtered_lines.append(line) + return "\n".join(filtered_lines) - for each_output in output: - if len(each_output) != 0: - if each_output.split()[-1] in ("DISTANCE", "STATUS"): - # Mikrotik shows the columns with no rows if there is no data. - # Rather than send back an empty table, send back an empty - # response which is handled with a warning message. - each_output = "" + # Aggregate tables by hop index. For each hop position, pick the row with the + # highest SENT count. If SENT ties, prefer non-timeout rows and the later table. + processed_lines: t.List[str] = [] + + # Regex to extract LOSS% and SENT count following it: e.g. '0% 3' + sent_re = re.compile(r"(\d+)%\s+(\d+)\b") + + max_rows = max(len(t) for t in tables) + + for i in range(max_rows): + best_row = None + best_sent = -1 + best_is_timeout = True + best_table_index = -1 + + for ti, table in enumerate(tables): + if i >= len(table): + continue + row = table[i] + m = sent_re.search(row) + if m: + try: + sent = int(m.group(2)) + except Exception: + sent = 0 else: - remove_lines = () - all_lines = each_output.splitlines() - # Starting index for rows (after the column row). - start = 1 - # Extract the column row. - column_line = " ".join(all_lines[0].split()) - - for i, line in enumerate(all_lines[1:]): - # Remove all the newline characters (which differ line to - # line) for comparison purposes. - normalized = " ".join(line.split()) - - # Remove ansii characters that aren't caught by Netmiko. - normalized = re.sub(r"\\x1b\[\S{2}\s", "", normalized) - - if column_line in normalized: - # Mikrotik often re-inserts the column row in the output, - # effectively 'starting over'. In that case, re-assign - # the column row and starting index to that point. - column_line = re.sub(r"\[\S{2}\s", "", line) - start = i + 2 - - if "[Q quit|D dump|C-z pause]" in normalized: - # Remove Mikrotik's unhelpful helpers from the output. - remove_lines += (i + 1,) - - # Combine the column row and the data rows from the starting - # index onward. - lines = [column_line, *all_lines[start:]] - - # Remove any lines marked for removal and re-join with a single - # newline character. - lines = [line for idx, line in enumerate(lines) if idx not in remove_lines] - result += ("\n".join(lines),) - - return result + sent = 0 + + is_timeout = "timeout" in row.lower() or ( + "100%" in row and "timeout" in row.lower() + ) + + # Prefer higher SENT, then prefer non-timeout, then later table (higher ti) + pick = False + if sent > best_sent: + pick = True + elif sent == best_sent: + if best_is_timeout and not is_timeout: + pick = True + elif (best_is_timeout == is_timeout) and ti > best_table_index: + pick = True + + if pick: + best_row = row + best_sent = sent + best_is_timeout = is_timeout + best_table_index = ti + + if best_row is not None: + processed_lines.append(best_row) + + # Collapse excessive trailing timeouts into an aggregation line + trailing_timeouts = 0 + for line in reversed(processed_lines): + if ("timeout" in line.lower()) or ( + sent_re.search(line) and sent_re.search(line).group(1) == "100" + ): + trailing_timeouts += 1 + else: + break + + if trailing_timeouts > 3: + non_trailing = len(processed_lines) - trailing_timeouts + # Keep first 2 of trailing timeouts and aggregate the rest + aggregated = ( + processed_lines[:non_trailing] + processed_lines[non_trailing : non_trailing + 2] + ) + remaining = trailing_timeouts - 2 + aggregated.append( + f" ... ({remaining} more timeout hops)" + ) + processed_lines = aggregated + + # Prepend header line if we have one + header_to_use = ( + header_line + or "ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS" + ) + cleaned = [header_to_use] + processed_lines + return "\n".join(cleaned) + + def process(self, *, output: OutputType, query: "Query") -> Series[str]: + """ + Clean raw output from a MikroTik device. + This plugin removes command echoes, prompts, flag legends, and interactive help text. + """ + + # If output is already processed/structured (not raw strings), pass it through unchanged + if not isinstance(output, (tuple, list)): + return output + + # Check if the tuple/list contains non-string objects (structured data) + if output and not isinstance(output[0], str): + return output + + cleaned_outputs = [] + + for raw_output in output: + # Handle non-string outputs (already processed by other plugins) - double check + if not isinstance(raw_output, str): + cleaned_outputs.append(raw_output) + continue + + # Nothing to do if the output is already empty. + if not raw_output or not raw_output.strip(): + cleaned_outputs.append("") + continue + + # Check if this is traceroute output and handle it specially + if ( + "tool traceroute" in raw_output + or ("ADDRESS" in raw_output and "LOSS" in raw_output and "SENT" in raw_output) + or "-- [Q quit|C-z pause]" in raw_output + ): + cleaned_output = self._clean_traceroute_output(raw_output) + cleaned_outputs.append(cleaned_output) + continue + + # Original logic for other outputs (BGP routes, etc.) + lines = raw_output.splitlines() + filtered_lines = [] + in_flags_section = False + + for line in lines: + stripped_line = line.strip() + + # Skip prompts and command echoes + if stripped_line.startswith("@") and stripped_line.endswith("] >"): + continue + + # Skip the interactive help line + if "[Q quit|D dump|C-z pause]" in stripped_line: + continue + + # Begin detecting the Flags section + if stripped_line.startswith("Flags:"): + in_flags_section = True + continue # Skip the "Flags:" line itself + + # Within the flags section, check whether the line is still part of it + if in_flags_section: + if "=" in stripped_line: + in_flags_section = False + else: + continue # Skip the flag-legend lines + + filtered_lines.append(line) + + # Join the cleaned lines back into a single string + cleaned_output = "\n".join(filtered_lines) + cleaned_outputs.append(cleaned_output) + + # Minimal debug logging: log number of cleaned blocks and if any aggregation occurred + if len(output) > 0: + log.debug(f"MikrotikGarbageOutput processed {len(output)} output blocks.") + # If any aggregation line was added, log that event + for cleaned in cleaned_outputs: + if "... (" in cleaned: + log.debug("Aggregated excessive trailing timeout hops in traceroute output.") + break + return tuple(cleaned_outputs) diff --git a/hyperglass/plugins/_builtin/mikrotik_normalize_input.py b/hyperglass/plugins/_builtin/mikrotik_normalize_input.py new file mode 100644 index 000000000..d7bf556e0 --- /dev/null +++ b/hyperglass/plugins/_builtin/mikrotik_normalize_input.py @@ -0,0 +1,58 @@ +# Standard Library +from ipaddress import ip_network, ip_address +import typing as t + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.log import log + +# REMOVIDA a importação direta de Query para evitar o erro circular +# from hyperglass.models.api.query import Query + +# Local +from .._input import InputPlugin, InputPluginValidationReturn + + +class MikrotikTargetNormalizerInput(InputPlugin): + """ + InputPlugin to normalize a target IP address to its network prefix. + This ensures that queries for different IPs within the same subnet + resolve to the same cache key. + """ + + _hyperglass_builtin: bool = PrivateAttr(False) + name: str = "mikrotik_normalizer" + platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + + def validate(self, query: t.Any) -> InputPluginValidationReturn: + """Take the query object and modify the target if it's a BGP Route query.""" + + # Acessamos os atributos normalmente, pois sabemos que o objeto 'query' os terá. + if query.query_type != "bgp_route": + return True, None + + try: + target_ip = ip_address(query.target) + + if target_ip.version == 4: + prefix_len = 24 + else: + prefix_len = 48 + + network = ip_network(f"{str(target_ip)}/{prefix_len}", strict=False) + + normalized_target = str(network.with_prefixlen) + + if query.target != normalized_target: + log.debug( + f"Normalizing target '{query.target}' to network prefix " + f"'{normalized_target}' for MikroTik cache key." + ) + query.target = normalized_target + + except ValueError: + pass + + return True, None diff --git a/hyperglass/plugins/tests/test_bgp_route_huawei.py b/hyperglass/plugins/tests/test_bgp_route_huawei.py new file mode 100644 index 000000000..0485fad7c --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_huawei.py @@ -0,0 +1,75 @@ +"""Huawei VRP structured BGP route parsing tests.""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.config.params import Params +from hyperglass.models.data.bgp_route import BGPRouteTable +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +if t.TYPE_CHECKING: + from hyperglass.state import HyperglassState + +# Representative `display bgp routing-table ` detail output: one best/ +# selected (active) route and one valid-but-not-best route. +SAMPLE = """ + BGP local router ID : 192.0.2.1 + Local AS number : 64500 + Paths: 2 available, 1 best, 1 select + + BGP routing table entry information of 203.0.113.0/24: + From: 198.51.100.1 (192.0.2.254) + Route Duration: 0d00h05m12s + Original nexthop: 198.51.100.1 + Community: 64500:100, 64500:200 + AS-path 64500 64501, origin igp, MED 0, localpref 100, pref-val 0, valid, external, best, select, pre 255 + + BGP routing table entry information of 203.0.113.128/25: + From: 198.51.100.2 (192.0.2.253) + Route Duration: 0d00h01m00s + Original nexthop: 198.51.100.2 + AS-path 64500 64999, origin incomplete, MED 10, localpref 90, pref-val 0, valid, external, pre 255 +""" + + +@pytest.fixture +def params() -> t.Dict[str, t.Any]: + return {} + + +@pytest.fixture +def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: + """Initialize Redis-backed state with params for BGPRoute validation.""" + _state = use_state() + _params = Params(**params) + with _state.cache.pipeline() as pipeline: + pipeline.set("params", _params) + yield _state + _state.clear() + + +def test_huawei_bgp_route(state): + table = HuaweiBGPTable.parse_text(SAMPLE).bgp_table() + + assert isinstance(table, BGPRouteTable), "Parsed result is not a BGPRouteTable" + assert table.count == 2, f"Expected 2 routes, got {table.count}" + + by_prefix = {r.prefix: r for r in table.routes} + assert set(by_prefix) == {"203.0.113.0/24", "203.0.113.128/25"} + + best = by_prefix["203.0.113.0/24"] + assert best.active is True, "best+select route should be active" + assert best.as_path == [64500, 64501], f"Bad as_path: {best.as_path}" + assert best.source_as == 64500, "source_as should be first ASN in path" + assert best.next_hop == "198.51.100.1" + assert "64500:100" in best.communities + + other = by_prefix["203.0.113.128/25"] + assert other.active is False, "non-best route should not be active" + assert other.as_path == [64500, 64999] diff --git a/hyperglass/plugins/tests/test_bgp_route_mikrotik.py b/hyperglass/plugins/tests/test_bgp_route_mikrotik.py new file mode 100644 index 000000000..d24518e17 --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_mikrotik.py @@ -0,0 +1,78 @@ +"""MikroTik structured BGP route parsing tests. + +The sample below is a trimmed, real `routing route print detail` capture from a +RouterOS 7.20.6 device (CCR2216), reduced to one active and one filtered route. +Long community lists are kept on a single line, matching what the device emits +to the looking-glass SSH driver (no terminal-width wrapping). +""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.config.params import Params +from hyperglass.models.data.bgp_route import BGPRouteTable +from hyperglass.models.parsing.mikrotik import MikrotikBGPTable + +if t.TYPE_CHECKING: + from hyperglass.state import HyperglassState + +SAMPLE = """Flags: X - disabled, F - filtered, U - unreachable, A - active; +c - connect, s - static, r - rip, b - bgp, n - bgp-net, o - ospf, i - isis, d - dhcp, v - vpn, m - modem, a - ldp-address, l - ldp-mapping, g - slaac, y - bgp-mpls-vpn, e - evpn; +H - hw-offloaded; + - ecmp, B - blackhole + Ab afi=ip contribution=active dst-address=1.0.0.0/24 routing-table=main pref-src=192.0.2.1 gateway=196.60.8.198 + immediate-gw=196.60.8.198%[sfp28-01] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-196.60.8.198" + rpki=valid bgp.session=NAP-JHB-CloudFlare-v4-1 .as-path="13335" .communities=13335:10045 .large-communities=328964:2000:0,328964:2001:0 .local-pref=250 .med=0 .origin=igp + + Fb afi=ip contribution=filtered dst-address=5.101.88.0/24 routing-table=main pref-src=192.0.2.1 gateway=192.0.2.2 + immediate-gw=192.0.2.2%[sfp28-10] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-192.0.2.2" + rpki=invalid bgp.session=TRANSIT-ANGOLA-v4-1 .as-path="37468,41095,51601,50113,207569" .communities=37468:14100,37468:2000 .med=0 .origin=incomplete +""" + + +@pytest.fixture +def params() -> t.Dict[str, t.Any]: + return {} + + +@pytest.fixture +def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: + """Initialize Redis-backed state with params for BGPRoute validation.""" + _state = use_state() + _params = Params(**params) + with _state.cache.pipeline() as pipeline: + pipeline.set("params", _params) + yield _state + _state.clear() + + +def test_mikrotik_bgp_route(state): + table = MikrotikBGPTable.parse_text(SAMPLE).bgp_table() + + assert isinstance(table, BGPRouteTable), "Parsed result is not a BGPRouteTable" + assert table.count == 2, f"Expected 2 routes, got {table.count}" + + by_prefix = {r.prefix: r for r in table.routes} + assert set(by_prefix) == {"1.0.0.0/24", "5.101.88.0/24"} + + # Active route (flag "A"): RPKI valid (state 1), single-ASN path. + active = by_prefix["1.0.0.0/24"] + assert active.active is True + assert active.filtered is False + assert active.as_path == [13335] + assert active.next_hop == "196.60.8.198" + assert "13335:10045" in active.communities + assert active.rpki_state == 1 # valid + + # Filtered route (flag "F" => filtered per the RouterOS flags legend): RPKI + # invalid (state 0), multi-ASN path. + filtered = by_prefix["5.101.88.0/24"] + assert filtered.active is False + assert filtered.filtered is True, "F-flagged route should be marked filtered" + assert filtered.as_path == [37468, 41095, 51601, 50113, 207569] + assert filtered.rpki_state == 0 # invalid diff --git a/hyperglass/ui/components/table/cell.tsx b/hyperglass/ui/components/table/cell.tsx index 228576ffc..dc0f3778a 100644 --- a/hyperglass/ui/components/table/cell.tsx +++ b/hyperglass/ui/components/table/cell.tsx @@ -6,12 +6,14 @@ import type { BoxProps } from '@chakra-ui/react'; interface TableCellProps extends Omit { bordersVertical?: [boolean, number]; align?: 'left' | 'right' | 'center'; + dimText?: boolean; } export const TableCell = (props: TableCellProps): JSX.Element => { - const { bordersVertical = [false, 0], align, ...rest } = props; + const { bordersVertical = [false, 0], align, dimText = false, ...rest } = props; const [doVerticalBorders, index] = bordersVertical; const borderLeftColor = useColorValue('blackAlpha.100', 'whiteAlpha.100'); + const filteredTextColor = useColorValue('filtered.400', 'filtered.500'); let borderProps = {}; if (doVerticalBorders && index !== 0) { @@ -25,6 +27,7 @@ export const TableCell = (props: TableCellProps): JSX.Element => { w="1%" textAlign={align} whiteSpace="nowrap" + color={dimText ? filteredTextColor : undefined} {...borderProps} {...rest} /> diff --git a/hyperglass/ui/components/table/main.tsx b/hyperglass/ui/components/table/main.tsx index fd1eeef5f..4e21cb367 100644 --- a/hyperglass/ui/components/table/main.tsx +++ b/hyperglass/ui/components/table/main.tsx @@ -122,6 +122,7 @@ export const Table = (props: TableProps): JSX.Element => { {page.map((row, key) => { prepareRow(row); + const dimText = row.original.filtered; return ( { highlightBg={rowHighlightBg} doHorizontalBorders={bordersHorizontal} highlight={row.values[rowHighlightProp ?? ''] ?? false} + dimText={dimText} {...row.getRowProps()} > {row.cells.map((cell, i) => { @@ -137,6 +139,7 @@ export const Table = (props: TableProps): JSX.Element => { {typeof Cell !== 'undefined' ? ( diff --git a/hyperglass/ui/components/table/row.tsx b/hyperglass/ui/components/table/row.tsx index a0497d7e9..76b33b9d6 100644 --- a/hyperglass/ui/components/table/row.tsx +++ b/hyperglass/ui/components/table/row.tsx @@ -8,6 +8,7 @@ interface TableRowProps extends BoxProps { highlightBg?: Theme.ColorNames; doHorizontalBorders?: boolean; highlight?: boolean; + dimText?: boolean; doStripe?: boolean; index: number; } @@ -19,6 +20,7 @@ export const TableRow = (props: TableRowProps): JSX.Element => { highlight = false, highlightBg = 'primary', doHorizontalBorders = false, + dimText = false, ...rest } = props; @@ -40,12 +42,14 @@ export const TableRow = (props: TableRowProps): JSX.Element => { } const defaultBg = useColorValue('white', 'black'); const color = useOpposingColor(bg ?? defaultBg); + const filteredTextColor = useColorValue('filtered.400', 'filtered.500'); + const rowTextColor = dimText ? filteredTextColor : color; const borderProps = doHorizontalBorders && index !== 0 ? rowBorder : {}; return ( td': { color } }} + css={{ '& > td': { color: rowTextColor } }} fontWeight={highlight ? 'bold' : undefined} _hover={{ cursor: 'pointer', diff --git a/hyperglass/ui/types/globals.d.ts b/hyperglass/ui/types/globals.d.ts index f920e5998..3e1b0dc6a 100644 --- a/hyperglass/ui/types/globals.d.ts +++ b/hyperglass/ui/types/globals.d.ts @@ -30,6 +30,7 @@ export declare global { source_rid: string; peer_rid: string; rpki_state: RPKIState; + filtered: boolean; }; type RouteField = { [K in keyof Route]: Route[K] }; From 41223187a36e60a992ce55a052596e8365a0f117 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:43 +1000 Subject: [PATCH 08/39] test(frr): anonymize example IP in parser comment (RFC 5737) --- .../config/structured-output.mdx | 75 +- docs/pages/configuration/config/web-ui.mdx | 3 + hyperglass/api/state.py | 35 +- hyperglass/api/tasks.py | 6 +- hyperglass/constants.py | 9 +- hyperglass/defaults/directives/frr.py | 4 +- hyperglass/defaults/directives/huawei.py | 67 +- hyperglass/defaults/directives/mikrotik.py | 70 +- hyperglass/external/__init__.py | 8 +- hyperglass/external/bgptools.py | 178 ----- hyperglass/external/ip_enrichment.py | 655 ++++++++++++++++++ hyperglass/external/rpki.py | 62 +- hyperglass/external/tests/test_bgptools.py | 49 -- hyperglass/external/tests/test_rpki.py | 8 +- hyperglass/models/api/query.py | 44 +- hyperglass/models/config/devices.py | 8 +- hyperglass/models/config/params.py | 12 +- hyperglass/models/config/structured.py | 35 +- hyperglass/models/config/web.py | 1 + hyperglass/models/data/__init__.py | 7 +- hyperglass/models/data/bgp_route.py | 35 +- hyperglass/models/data/traceroute.py | 252 +++++++ hyperglass/models/parsing/huawei.py | 394 +++++++++++ hyperglass/models/parsing/mikrotik.py | 561 +++++++++++++++ hyperglass/models/parsing/traceroute.py | 235 +++++++ hyperglass/plugins/_builtin/__init__.py | 12 + .../plugins/_builtin/bgp_route_huawei.py | 25 +- .../plugins/_builtin/bgp_routestr_huawei.py | 80 +++ .../plugins/_builtin/bgp_routestr_mikrotik.py | 86 +++ .../_builtin/mikrotik_garbage_output.py | 293 ++++++-- .../_builtin/mikrotik_normalize_input.py | 58 ++ .../plugins/_builtin/trace_route_frr.py | 345 +++++++++ .../plugins/_builtin/trace_route_huawei.py | 278 ++++++++ .../plugins/_builtin/trace_route_mikrotik.py | 165 +++++ .../plugins/tests/test_bgp_route_huawei.py | 75 ++ .../plugins/tests/test_bgp_route_mikrotik.py | 78 +++ .../plugins/tests/test_traceroute_frr.py | 46 ++ .../plugins/tests/test_traceroute_huawei.py | 40 ++ .../plugins/tests/test_traceroute_mikrotik.py | 153 ++++ .../ui/components/looking-glass-form.tsx | 7 +- hyperglass/ui/components/output/cell.tsx | 21 +- hyperglass/ui/components/output/fields.tsx | 109 ++- hyperglass/ui/components/output/index.ts | 1 + .../ui/components/output/traceroute-cell.tsx | 51 ++ .../components/output/traceroute-fields.tsx | 188 +++++ .../ui/components/output/traceroute-table.tsx | 38 + hyperglass/ui/components/path/chart.tsx | 18 +- hyperglass/ui/components/path/path.tsx | 47 +- hyperglass/ui/components/path/use-elements.ts | 110 ++- hyperglass/ui/components/query-type.tsx | 32 +- .../ui/components/results/individual.tsx | 31 +- hyperglass/ui/components/table/cell.tsx | 5 +- hyperglass/ui/components/table/main.tsx | 26 +- hyperglass/ui/components/table/row.tsx | 6 +- hyperglass/ui/hooks/use-asn-detail.ts | 39 +- hyperglass/ui/hooks/use-table-to-string.ts | 182 ++++- hyperglass/ui/types/globals.d.ts | 50 ++ hyperglass/ui/types/guards.ts | 8 + hyperglass/ui/types/table.ts | 13 + 59 files changed, 5064 insertions(+), 465 deletions(-) delete mode 100644 hyperglass/external/bgptools.py create mode 100644 hyperglass/external/ip_enrichment.py delete mode 100644 hyperglass/external/tests/test_bgptools.py create mode 100644 hyperglass/models/data/traceroute.py create mode 100644 hyperglass/models/parsing/huawei.py create mode 100644 hyperglass/models/parsing/mikrotik.py create mode 100644 hyperglass/models/parsing/traceroute.py create mode 100644 hyperglass/plugins/_builtin/bgp_routestr_huawei.py create mode 100644 hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py create mode 100644 hyperglass/plugins/_builtin/mikrotik_normalize_input.py create mode 100644 hyperglass/plugins/_builtin/trace_route_frr.py create mode 100644 hyperglass/plugins/_builtin/trace_route_huawei.py create mode 100644 hyperglass/plugins/_builtin/trace_route_mikrotik.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_huawei.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_mikrotik.py create mode 100644 hyperglass/plugins/tests/test_traceroute_frr.py create mode 100644 hyperglass/plugins/tests/test_traceroute_huawei.py create mode 100644 hyperglass/plugins/tests/test_traceroute_mikrotik.py create mode 100644 hyperglass/ui/components/output/traceroute-cell.tsx create mode 100644 hyperglass/ui/components/output/traceroute-fields.tsx create mode 100644 hyperglass/ui/components/output/traceroute-table.tsx diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx index df936253f..5a94b5bf1 100644 --- a/docs/pages/configuration/config/structured-output.mdx +++ b/docs/pages/configuration/config/structured-output.mdx @@ -4,19 +4,28 @@ Devices that support responding to a query with structured or easily parsable da - Arista EOS - Juniper Junos +- Huawei VRP +- MikroTik RouterOS / SwitchOS + +MikroTik structured BGP output (Route, AS Path, and Community queries) requires **RouterOS v7+**, as it parses the output of `routing route print detail`. RouterOS v6 uses a different route-print syntax and is not supported for structured BGP. When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods: 1. From the router's perspective -2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) +2. From an external validator — either [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) (the default) or a self-hosted [Routinator](https://routinator.docs.nlnetlabs.nl/) instance + +When `structured.rpki.mode` is `external`, the `structured.rpki.backend` field selects which validator to query. The `cloudflare` backend uses Cloudflare's GraphQL API; the `routinator` backend queries the `/validity` HTTP API of a Routinator instance at `structured.rpki.rpki_server_url`. Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user. | Parameter | Type | Default Value | Description | | :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- | -| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use Cloudflare's view (2 above). | -| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. | -| `structured.communities.items` | List of Strings | | List of communities to match. | +| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use an external validator (2 above). | +| `structured.rpki.backend` | String | cloudflare | When `mode` is `external`, the validator to use: `cloudflare` or `routinator`. | +| `structured.rpki.rpki_server_url` | String | | Base URL of the Routinator instance. **Required** when `backend` is `routinator` (e.g. `http://routinator.example.net:8323`). | +| `structured.communities.mode` | String | deny | `deny` hides communities listed in `items`; `permit` shows _only_ those listed; `name` shows all communities and appends a friendly label to any listed in `names`. | +| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny`/`permit`). | +| `structured.communities.names` | Map of String→String | | Map of community → friendly name, used by `name` mode. At least one entry is required when `mode` is `name`. | ### RPKI Examples @@ -28,12 +37,23 @@ structured: mode: router ``` -#### Show RPKI State from a Public/External Perspective +#### Show RPKI State from a Public/External Perspective (Cloudflare) -```yaml filename="config.yaml" copy {2} +```yaml filename="config.yaml" copy {2-3} structured: rpki: mode: external + backend: cloudflare +``` + +#### Validate RPKI State Against a Self-Hosted Routinator + +```yaml filename="config.yaml" copy {2-5} +structured: + rpki: + mode: external + backend: routinator + rpki_server_url: "http://routinator.example.net:8323" ``` ### Community Filtering Examples @@ -59,3 +79,46 @@ structured: - "^65000:.*$" # permit any communities starting with 65000, but no others. - "1234:1" # permit only the 1234:1 community. ``` + +#### Show Friendly Names Alongside Communities + +With `mode: name`, all communities are shown, and any community listed in `names` has its friendly label appended (rendered as ` ()` in the UI). + +```yaml filename="config.yaml" {3-6} +structured: + communities: + mode: name + names: + "65000:100": "Customer Route" + "65000:200": "Peer Route" +``` + +## Structured Traceroute + +In addition to BGP routes, hyperglass can parse traceroute output into a structured hop table (and JSON via the REST API). Each hop shows the hop number, IP address, resolved hostname (when available), and per-probe round-trip times; MikroTik hops also include loss and probe counts. When [IP enrichment](#ip-enrichment) is enabled, each hop is annotated with its ASN, organization, country, and IXP membership. + +Structured traceroute is supported on the following platforms: + +- FRRouting +- Huawei VRP +- MikroTik RouterOS / SwitchOS + +Structured output (for both BGP routes and traceroute) is controlled per query type. When a `structured:` block is present in the device's configuration and the device has `structured_output: true`, structured output is enabled by default. The two flags below let you disable it for a specific query type without removing the `structured:` block: + +| Parameter | Type | Default Value | Description | +| :--------------------------------- | :------ | :------------ | :------------------------------------------------------------------------------------------- | +| `structured.enable_for_traceroute` | Boolean | (enabled) | Set to `false` to return raw (unstructured) traceroute output even when structured is available. | +| `structured.enable_for_bgp_route` | Boolean | (enabled) | Set to `false` to return raw (unstructured) BGP route output even when structured is available. | + +### Disable Structured Traceroute (keep structured BGP) + +```yaml filename="config.yaml" copy {2} +structured: + enable_for_traceroute: false +``` + +## IP Enrichment + +hyperglass can enrich structured responses with ASN, organization, country, and IXP metadata resolved in real time from [bgp.tools](https://bgp.tools/). This data powers the organization names shown on AS paths and next-hops, and the per-hop detail in structured traceroute. + +Enrichment is performed by an internal service that issues bulk WHOIS lookups to bgp.tools; results are cached in-memory for the life of the process. No configuration is required to make the service available — it is enabled per-feature via the [`structured.ip_enrichment`](#ip-enrichment-configuration) settings documented below. diff --git a/docs/pages/configuration/config/web-ui.mdx b/docs/pages/configuration/config/web-ui.mdx index 761344769..96b5664f7 100644 --- a/docs/pages/configuration/config/web-ui.mdx +++ b/docs/pages/configuration/config/web-ui.mdx @@ -243,6 +243,7 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. | `web.theme.colors.dark` | | | `web.theme.colors.light` | | | `web.theme.colors.gray` | | +| `web.theme.colors.filtered` | | | `web.theme.colors.red` | | | `web.theme.colors.orange` | | | `web.theme.colors.yellow` | | @@ -253,6 +254,8 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. | `web.theme.colors.pink` | | | `web.theme.colors.purple` | | +The `filtered` color is used to dim BGP routes that are present but not selected/active in the structured table (for example, MikroTik routes flagged filtered, disabled, or unreachable). It defaults to the same value as `gray`. + ##### Functional Colors | Parameter | Default Value | diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py index f41a96d78..5dfd75ef9 100644 --- a/hyperglass/api/state.py +++ b/hyperglass/api/state.py @@ -1,27 +1,52 @@ """hyperglass state dependencies.""" # Standard Library +import asyncio import typing as t # Project -from hyperglass.state import use_state +from hyperglass.state import HyperglassState, use_state +from hyperglass.exceptions.private import StateError + + +def _is_retryable(attr: t.Optional[str]) -> bool: + """Whether a StateError for this attr is a transient (not-yet-populated) error. + + A request can arrive before the Redis-backed state is populated on startup, + which surfaces as a StateError. Referencing an attribute that does not exist + on HyperglassState is a permanent programming error and must not be retried. + """ + return attr is None or attr in ("cache", "redis") or attr in HyperglassState.properties() + + +async def _get_state_with_retry( + attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5 +) -> t.Any: + """Get hyperglass state, retrying transient startup StateErrors.""" + for attempt in range(1, max_retries + 1): + try: + return use_state(attr) + except StateError: + if not _is_retryable(attr) or attempt == max_retries: + raise + await asyncio.sleep(retry_delay) async def get_state(attr: t.Optional[str] = None): """Get hyperglass state as a FastAPI dependency.""" - return use_state(attr) + return await _get_state_with_retry(attr) async def get_params(): """Get hyperglass params as FastAPI dependency.""" - return use_state("params") + return await _get_state_with_retry("params") async def get_devices(): """Get hyperglass devices as FastAPI dependency.""" - return use_state("devices") + return await _get_state_with_retry("devices") async def get_ui_params(): """Get hyperglass ui_params as FastAPI dependency.""" - return use_state("ui_params") + return await _get_state_with_retry("ui_params") diff --git a/hyperglass/api/tasks.py b/hyperglass/api/tasks.py index 0a7d16d46..8527c53be 100644 --- a/hyperglass/api/tasks.py +++ b/hyperglass/api/tasks.py @@ -10,7 +10,7 @@ # Project from hyperglass.log import log -from hyperglass.external import Webhook, bgptools +from hyperglass.external import Webhook, network_info from hyperglass.models.api import Query if t.TYPE_CHECKING: @@ -52,7 +52,7 @@ async def send_webhook( else: host = request.client.host - network_info = await bgptools.network_info(host) + network_result = await network_info(host) async with Webhook(params.logging.http) as hook: await hook.send( @@ -60,7 +60,7 @@ async def send_webhook( **data.dict(), "headers": headers, "source": host, - "network": network_info.get(host, {}), + "network": network_result.get(host, {}), "timestamp": timestamp, } ) diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 0139c7415..02723a194 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -19,7 +19,14 @@ TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos") -SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos") +SUPPORTED_STRUCTURED_OUTPUT = ( + "frr", + "juniper", + "arista_eos", + "huawei", + "mikrotik_routeros", + "mikrotik_switchos", +) CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml") diff --git a/hyperglass/defaults/directives/frr.py b/hyperglass/defaults/directives/frr.py index eb6baafd9..3d7fb6960 100644 --- a/hyperglass/defaults/directives/frr.py +++ b/hyperglass/defaults/directives/frr.py @@ -101,12 +101,12 @@ RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="traceroute -4 -w 1 -q 1 -s {source4} {target}", + command="traceroute -4 -I -n -q 1 -w 1 -s {source4} {target}", ), RuleWithIPv6( condition="::/0", action="permit", - command="traceroute -6 -w 1 -q 1 -s {source6} {target}", + command="traceroute -6 -I -n -q 1 -w 1 -s {source6} {target}", ), ], field=Text(description="IP Address, Prefix, or Hostname"), diff --git a/hyperglass/defaults/directives/huawei.py b/hyperglass/defaults/directives/huawei.py index f8d3ef0cf..6cb3f2ad5 100644 --- a/hyperglass/defaults/directives/huawei.py +++ b/hyperglass/defaults/directives/huawei.py @@ -15,6 +15,9 @@ "Huawei_BGPRoute", "Huawei_Ping", "Huawei_Traceroute", + "HuaweiBGPRouteTable", + "HuaweiBGPASPathTable", + "HuaweiBGPCommunityTable", ) NAME = "Huawei VRP" @@ -36,7 +39,8 @@ ), ], field=Text(description="IP Address, Prefix, or Hostname"), - plugins=["bgp_route_huawei"], + plugins=["bgp_route_huawei", "bgp_routestr_huawei"], + table_output="__hyperglass_huawei_bgp_route_table__", platforms=PLATFORMS, ) @@ -54,6 +58,7 @@ ) ], field=Text(description="AS Path Regular Expression"), + table_output="__hyperglass_huawei_bgp_aspath_table__", platforms=PLATFORMS, ) @@ -71,6 +76,7 @@ ) ], field=Text(description="BGP Community String"), + table_output="__hyperglass_huawei_bgp_community_table__", platforms=PLATFORMS, ) @@ -100,14 +106,69 @@ RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="tracert -q 2 -f 1 -a {source4} {target}", + command="tracert -w 500 -q 1 -f 1 -a {source4} {target}", ), RuleWithIPv6( condition="::/0", action="permit", - command="tracert -q 2 -f 1 -a {source6} {target}", + command="tracert ipv6 -w 500 -q 1 -f 1 -a {source6} {target}", ), ], field=Text(description="IP Address, Prefix, or Hostname"), platforms=PLATFORMS, ) + +# Table Output Directives + +HuaweiBGPRouteTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="display bgp routing-table {target} | no-more", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="display bgp ipv6 routing-table {target} | no-more", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +HuaweiBGPASPathTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table regular-expression "{target}"', + 'display bgp ipv6 routing-table regular-expression "{target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +HuaweiBGPCommunityTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_community_table__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table community "{target}"', + 'display bgp ipv6 routing-table community "{target}"', + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/mikrotik.py b/hyperglass/defaults/directives/mikrotik.py index 4ea96fdff..35469ecdb 100644 --- a/hyperglass/defaults/directives/mikrotik.py +++ b/hyperglass/defaults/directives/mikrotik.py @@ -15,6 +15,9 @@ "Mikrotik_BGPRoute", "Mikrotik_Ping", "Mikrotik_Traceroute", + "MikrotikBGPRouteTable", + "MikrotikBGPASPathTable", + "MikrotikBGPCommunityTable", ) NAME = "Mikrotik" @@ -27,15 +30,17 @@ RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="ip route print where {target} in dst-address", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=0.0.0.0/0", ), RuleWithIPv6( condition="::/0", action="permit", - command="ipv6 route print where {target} in dst-address", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=::/0", ), ], field=Text(description="IP Address, Prefix, or Hostname"), + plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + table_output="__hyperglass_mikrotik_bgp_route_table__", platforms=PLATFORMS, ) @@ -53,6 +58,8 @@ ) ], field=Text(description="AS Path Regular Expression"), + plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + table_output="__hyperglass_mikrotik_bgp_aspath_table__", platforms=PLATFORMS, ) @@ -70,6 +77,8 @@ ) ], field=Text(description="BGP Community String"), + plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + table_output="__hyperglass_mikrotik_bgp_community_table__", platforms=PLATFORMS, ) @@ -99,14 +108,67 @@ RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="tool traceroute src-address={source4} timeout=1 duration=5 count=1 {target}", + command="tool traceroute src-address={source4} timeout=1 duration=30 count=3 {target}", ), RuleWithIPv6( condition="::/0", action="permit", - command="tool traceroute src-address={source6} timeout=1 duration=5 count=1 {target}", + command="tool traceroute src-address={source6} timeout=1 duration=30 count=3 {target}", ), ], field=Text(description="IP Address, Prefix, or Hostname"), platforms=PLATFORMS, ) + +# Table Output Directives + +MikrotikBGPRouteTable = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=0.0.0.0/0", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=::/0", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +MikrotikBGPASPathTable = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "routing route print detail without-paging where bgp-as-path~{target}", + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +MikrotikBGPCommunityTable = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_community_table__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "routing route print detail without-paging where bgp-communities~{target}", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) diff --git a/hyperglass/external/__init__.py b/hyperglass/external/__init__.py index 4d9d22e9e..827bf3314 100644 --- a/hyperglass/external/__init__.py +++ b/hyperglass/external/__init__.py @@ -5,7 +5,13 @@ from .slack import SlackHook from .generic import BaseExternal from .msteams import MSTeams -from .bgptools import network_info, network_info_sync +from .ip_enrichment import ( + network_info, + network_info_sync, + lookup_ip, + lookup_asn_name, + lookup_asn_country, +) from .webhooks import Webhook from .http_client import HTTPClient diff --git a/hyperglass/external/bgptools.py b/hyperglass/external/bgptools.py deleted file mode 100644 index f2fd7f6a9..000000000 --- a/hyperglass/external/bgptools.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Query & parse data from bgp.tools. - -- See https://bgp.tools/credits for acknowledgements and licensing. -- See https://bgp.tools/kb/api for query documentation. -""" - -# Standard Library -import re -import typing as t -import asyncio -from ipaddress import IPv4Address, IPv6Address, ip_address - -# Project -from hyperglass.log import log -from hyperglass.state import use_state - -DEFAULT_KEYS = ("asn", "ip", "prefix", "country", "rir", "allocated", "org") - -CACHE_KEY = "hyperglass.external.bgptools" - -TargetDetail = t.TypedDict( - "TargetDetail", - {"asn": str, "ip": str, "country": str, "rir": str, "allocated": str, "org": str}, -) - -TargetData = t.Dict[str, TargetDetail] - - -def default_ip_targets(*targets: str) -> t.Tuple[TargetData, t.Tuple[str, ...]]: - """Construct a mapping of default data and other data that should be queried. - - Targets in the mapping don't need to be queried and already have default values. Targets in the - query tuple should be queried. - """ - default_data = {} - query = () - for target in targets: - detail: TargetDetail = dict.fromkeys(DEFAULT_KEYS, "None") - try: - valid: t.Union[IPv4Address, IPv6Address] = ip_address(target) - - checks = ( - (valid.version == 6 and valid.is_site_local, "Site Local Address"), - (valid.is_loopback, "Loopback Address"), - (valid.is_multicast, "Multicast Address"), - (valid.is_link_local, "Link Local Address"), - (valid.is_private, "Private Address"), - ) - for exp, rir in checks: - if exp is True: - detail["rir"] = rir - break - - should_query = any((valid.is_global, valid.is_unspecified, valid.is_reserved)) - - if not should_query: - detail["ip"] = str(target) - default_data[str(target)] = detail - elif should_query: - query += (str(target),) - - except ValueError: - pass - - return default_data, query - - -def parse_whois(output: str, targets: t.List[str]) -> TargetDetail: - """Parse raw whois output from bgp.tools. - - Sample output: - AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name - 13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | 2010-07-14 | Cloudflare, Inc. - """ - - def lines(raw): - """Generate clean string values for each column.""" - for r in (r for r in raw.split("\n") if r): - fields = (re.sub(r"(\n|\r)", "", field).strip(" ") for field in r.split("|")) - yield fields - - data = {} - - for line in lines(output): - # Unpack each line's parsed values. - asn, ip, prefix, country, rir, allocated, org = line - - # Match the line to the item in the list of resources to query. - if ip in targets: - i = targets.index(ip) - data[targets[i]] = { - "asn": asn, - "ip": ip, - "prefix": prefix, - "country": country, - "rir": rir, - "allocated": allocated, - "org": org, - } - log.bind(data=data).debug("Parsed bgp.tools data") - return data - - -async def run_whois(targets: t.List[str]) -> str: - """Open raw socket to bgp.tools and execute query.""" - - # Construct bulk query - query = "\n".join(("begin", *targets, "end\n")).encode() - - # Open the socket to bgp.tools - log.debug("Opening connection to bgp.tools") - reader, writer = await asyncio.open_connection("bgp.tools", port=43) - - # Send the query - writer.write(query) - if writer.can_write_eof(): - writer.write_eof() - await writer.drain() - - # Read the response - response = b"" - while True: - data = await reader.read(128) - if data: - response += data - else: - log.debug("Closing connection to bgp.tools") - writer.close() - break - - return response.decode() - - -async def network_info(*targets: str) -> TargetData: - """Get ASN, Containing Prefix, and other info about an internet resource.""" - - default_data, query_targets = default_ip_targets(*targets) - - cache = use_state("cache") - - # Set default data structure. - query_data = {t: dict.fromkeys(DEFAULT_KEYS, "") for t in query_targets} - - # Get all cached bgp.tools data. - cached = cache.get_map(CACHE_KEY) or {} - - # Try to use cached data for each of the items in the list of - # resources. - for target in (target for target in query_targets if target in cached): - # Reassign the cached network info to the matching resource. - query_data[target] = cached[target] - log.bind(target=target).debug("Using cached network info") - - # Remove cached items from the resource list so they're not queried. - targets = [t for t in query_targets if t not in cached] - - try: - if targets: - whoisdata = await run_whois(targets) - - if whoisdata: - # If the response is not empty, parse it. - query_data.update(parse_whois(whoisdata, targets)) - - # Cache the response - for target in targets: - cache.set_map_item(CACHE_KEY, target, query_data[target]) - log.bind(target=t).debug("Cached network info") - - except Exception as err: - log.error(err) - - return {**default_data, **query_data} - - -def network_info_sync(*targets: str) -> TargetData: - """Get ASN, Containing Prefix, and other info about an internet resource.""" - return asyncio.run(network_info(*targets)) diff --git a/hyperglass/external/ip_enrichment.py b/hyperglass/external/ip_enrichment.py new file mode 100644 index 000000000..1f52609df --- /dev/null +++ b/hyperglass/external/ip_enrichment.py @@ -0,0 +1,655 @@ +"""IP enrichment: ASN and IXP lookups for hyperglass. + +Uses bgp.tools for both ASN and IXP lookups. +Provides lookup_ip, lookup_asn_name and network_info compatibility APIs. +""" + +import asyncio +import typing as t +from ipaddress import ip_address, IPv4Address, IPv6Address +import socket + +from hyperglass.log import log +from hyperglass.settings import Settings + + +class IPInfo: + """Result of IP lookup.""" + + def __init__( + self, + ip: str, + asn: t.Optional[int] = None, + asn_name: t.Optional[str] = None, + prefix: t.Optional[str] = None, + country: t.Optional[str] = None, + is_ixp: bool = False, + ixp_name: t.Optional[str] = None, + ): + self.ip = ip + self.asn = asn + self.asn_name = asn_name + self.prefix = prefix + self.country = country + self.is_ixp = is_ixp + self.ixp_name = ixp_name + + +class IPEnrichmentService: + """Simplified IP enrichment service using only BGP.TOOLS for all lookups.""" + + def __init__(self): + # Runtime caches for performance + self._ip_cache: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + self.asn_info: t.Dict[int, t.Dict[str, str]] = {} # asn -> {name, country} + + async def _query_bgp_tools_for_ip( + self, ip_str: str + ) -> t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]: + """Query bgp.tools for a single IP. + + Returns (asn_int, asn_name, prefix) or (None, None, None) on failure. + """ + # Check cache first + if ip_str in self._ip_cache: + return self._ip_cache[ip_str] + + # Use TCP WHOIS on bgp.tools:43 + def _whois_blocking( + single_ips: t.List[str], + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + out: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + host = "bgp.tools" + port = 43 + send_keys = [f"AS{q}" if q.isdigit() else q for q in single_ips] + payload = "begin\n" + "\n".join(send_keys) + "\nend\n" + try: + with socket.create_connection((host, port), timeout=10) as s: + s.settimeout(10) + s.sendall(payload.encode("utf-8")) + parts = [] + try: + while True: + chunk = s.recv(4096) + if not chunk: + break + parts.append(chunk) + except socket.timeout: + pass + + raw = b"".join(parts).decode("utf-8", errors="replace") + # Parse lines like: "13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | ... | Cloudflare, Inc." + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + if "|" in line: + cols = [c.strip() for c in line.split("|")] + try: + asn = int(cols[0]) if cols[0].isdigit() else None + except Exception: + asn = None + ipcol = cols[1] if len(cols) > 1 else None + prefix = cols[2] if len(cols) > 2 else None + org = cols[-1] if len(cols) > 0 else None + if ipcol: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + else: + # Fallback parsing + parts_line = line.split() + if len(parts_line) >= 3: + try: + asn = int(parts_line[0]) + except Exception: + asn = None + ipcol = parts_line[1] + prefix = parts_line[2] + org = " ".join(parts_line[3:]) if len(parts_line) > 3 else None + if ipcol: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + + # Map results back to original query keys + mapped: t.Dict[ + str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]] + ] = {} + for orig, sent in zip(single_ips, send_keys): + if sent in out: + mapped[orig] = out[sent] + elif orig in out: + mapped[orig] = out[orig] + else: + if orig.isdigit(): + if f"AS{orig}" in out: + mapped[orig] = out[f"AS{orig}"] + elif orig in out: + mapped[orig] = out[orig] + else: + mapped[orig] = (None, None, None) + else: + mapped[orig] = (None, None, None) + return mapped + except Exception: + for ip in single_ips: + out[ip] = (None, None, None) + return out + + loop = asyncio.get_running_loop() + resp_map = await loop.run_in_executor(None, _whois_blocking, [ip_str]) + asn, org, prefix = resp_map.get(ip_str, (None, None, None)) + # Cache result + self._ip_cache[ip_str] = (asn, org, prefix) + return (asn, org, prefix) + + async def _query_bgp_tools_bulk( + self, ips: t.List[str] + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + """Query bgp.tools for multiple IPs using a single connection. + + Returns a mapping ip -> (asn, asn_name, prefix). + """ + + def _whois_bulk_blocking( + bulk_ips: t.List[str], + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + host = "bgp.tools" + port = 43 + out: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + send_keys = [f"AS{q}" if q.isdigit() else q for q in bulk_ips] + payload = "begin\n" + "\n".join(send_keys) + "\nend\n" + try: + with socket.create_connection((host, port), timeout=15) as s: + s.settimeout(15) + s.sendall(payload.encode("utf-8")) + parts = [] + try: + while True: + chunk = s.recv(8192) + if not chunk: + break + parts.append(chunk) + if sum(len(p) for p in parts) > 512 * 1024: + break + except socket.timeout: + pass + + raw = b"".join(parts).decode("utf-8", errors="replace") + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + if "|" in line: + cols = [c.strip() for c in line.split("|")] + try: + asn = int(cols[0]) if cols[0].isdigit() else None + except Exception: + asn = None + ipcol = cols[1] if len(cols) > 1 else None + prefix = cols[2] if len(cols) > 2 else None + org = cols[-1] if len(cols) > 0 else None + # Check if this is an exchange IP (IXP) + is_ixp = any( + keyword in (org or "").lower() + for keyword in ["ix", "exchange", "peering"] + ) + if ipcol: + # If it's an IXP, mark it specially + if is_ixp: + out[ipcol] = ( + None, + org, + prefix, + ) # Use None for ASN to indicate IXP + else: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + else: + parts_line = line.split() + if len(parts_line) >= 3: + try: + asn = int(parts_line[0]) + except Exception: + asn = None + ipcol = parts_line[1] + prefix = parts_line[2] + org = " ".join(parts_line[3:]) if len(parts_line) > 3 else None + out[ipcol] = (asn, org, prefix) + + # Map results back to original query keys + mapped: t.Dict[ + str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]] + ] = {} + for orig, sent in zip(bulk_ips, send_keys): + if sent in out: + mapped[orig] = out[sent] + elif orig in out: + mapped[orig] = out[orig] + else: + if orig.isdigit(): + if f"AS{orig}" in out: + mapped[orig] = out[f"AS{orig}"] + elif orig in out: + mapped[orig] = out[orig] + else: + mapped[orig] = (None, None, None) + else: + mapped[orig] = (None, None, None) + return mapped + except Exception: + for ip in bulk_ips: + out[ip] = (None, None, None) + return out + + loop = asyncio.get_running_loop() + resp = await loop.run_in_executor(None, _whois_bulk_blocking, ips) + return resp + + async def lookup_ips_bulk(self, ips: t.List[str]) -> t.Dict[str, IPInfo]: + """Bulk lookup for multiple IPs using BGP.TOOLS.""" + results: t.Dict[str, IPInfo] = {} + + # Prepare list for query + query_ips: t.List[str] = [] + for ip in ips: + try: + target_ip = ip_address(ip) + except Exception: + results[ip] = IPInfo(ip) + continue + + # Check for private/reserved addresses + if target_ip.is_private or target_ip.is_reserved or target_ip.is_loopback: + results[ip] = IPInfo(ip, asn=0, asn_name="Private", prefix="Private Network") + continue + + query_ips.append(ip) + + # Query BGP.TOOLS in bulk for all IPs + if query_ips: + bulk = await self._query_bgp_tools_bulk(query_ips) + for ip in query_ips: + asn, asn_name, prefix = bulk.get(ip, (None, None, None)) + + # Check if this is an IXP (indicated by None ASN from our parsing) + is_ixp = asn is None and asn_name is not None + + if is_ixp: + results[ip] = IPInfo(ip, is_ixp=True, ixp_name=asn_name) + elif asn: + try: + self.asn_info[int(asn)] = {"name": asn_name or f"AS{asn}", "country": ""} + except Exception: + pass + results[ip] = IPInfo(ip, asn=asn, asn_name=asn_name, prefix=prefix) + else: + results[ip] = IPInfo(ip, asn=0, asn_name="Unknown") + + return results + + async def lookup_ip(self, ip_str: str) -> IPInfo: + """Lookup an IP address and return ASN or IXP information.""" + if Settings.debug: + log.debug(f"Looking up IP {ip_str}") + + try: + target_ip = ip_address(ip_str) + except ValueError: + if Settings.debug: + log.debug(f"Invalid IP address: {ip_str}") + return IPInfo(ip_str) + + # Check private/reserved addresses + if target_ip.is_private or target_ip.is_reserved or target_ip.is_loopback: + if Settings.debug: + log.debug(f"IP {ip_str} is in private/reserved range") + return IPInfo(ip_str, asn=0, asn_name="Private", prefix="Private Network") + + # Query bgp.tools for the IP + try: + asn, asn_name, prefix = await self._query_bgp_tools_for_ip(ip_str) + + # Check if this is an IXP (indicated by keywords in organization name) + is_ixp = asn_name and any( + keyword in asn_name.lower() for keyword in ["ix", "exchange", "peering"] + ) + + if is_ixp: + if Settings.debug: + log.debug(f"Found IXP match for {ip_str}: {asn_name}") + return IPInfo(ip_str, is_ixp=True, ixp_name=asn_name) + elif asn: + # Cache result + try: + self.asn_info[int(asn)] = {"name": asn_name or f"AS{asn}", "country": ""} + except Exception: + pass + if Settings.debug: + log.debug(f"BGP.tools lookup for {ip_str}: AS{asn} ({asn_name})") + return IPInfo(ip_str, asn=asn, asn_name=asn_name, prefix=prefix) + except Exception as e: + if Settings.debug: + log.debug(f"BGP.tools lookup failed for {ip_str}: {e}") + + # No match found + if Settings.debug: + log.debug(f"No enrichment data found for {ip_str}") + return IPInfo(ip_str, asn=0, asn_name="Unknown") + + async def lookup_asn_name(self, asn: int) -> str: + """Get the organization name for an ASN.""" + asn_data = self.asn_info.get(int(asn), {}) + return asn_data.get("name") or f"AS{asn}" + + async def lookup_asn_country(self, asn: int) -> str: + """Get the country code for an ASN, if known.""" + asn_data = self.asn_info.get(int(asn), {}) + return asn_data.get("country") or "" + + +# Global service instance +_service = IPEnrichmentService() + + +# Public API functions +async def lookup_ip(ip_address: str) -> IPInfo: + """Lookup an IP address and return ASN or IXP information.""" + return await _service.lookup_ip(ip_address) + + +async def lookup_asn_name(asn: int) -> str: + """Get the organization name for an ASN number.""" + return await _service.lookup_asn_name(asn) + + +async def lookup_asn_country(asn: int) -> str: + """Get the country code for an ASN number.""" + return await _service.lookup_asn_country(asn) + + +async def lookup_asns_bulk(asns: t.List[t.Union[str, int]]) -> t.Dict[str, t.Dict[str, str]]: + """Bulk lookup ASN organization names and countries. + + Args: + asns: List of ASN numbers (as strings like "12345" or integers) + + Returns: + Dict mapping ASN string to {"name": org_name, "country": country_code} + Example: {"12345": {"name": "Example ISP", "country": "US"}} + """ + results = {} + + # Normalize ASN list to strings and filter invalids + requested: list[str] = [] + for asn in asns: + if asn is None: + continue + if str(asn) == "IXP": + continue + try: + _ = int(asn) + requested.append(str(asn)) + except (ValueError, TypeError): + continue + + # Identify ASNs missing a human-friendly name so we can attempt a live WHOIS + missing: list[str] = [] + for asn in requested: + try: + ai = _service.asn_info.get(int(asn), {}) + name = ai.get("name") if isinstance(ai, dict) else None + if not name or name == f"AS{asn}": + missing.append(asn) + except Exception: + missing.append(asn) + + # If we have missing ASNs, try a live bgp.tools WHOIS bulk query to fetch org names + if missing and hasattr(_service, "_query_bgp_tools_bulk"): + try: + log.debug("lookup_asns_bulk: querying bgp.tools for missing ASNs: {}", missing) + queries = [f"AS{a}" for a in missing] + resp = await _service._query_bgp_tools_bulk(queries) + # resp maps query -> (asn_int, org, prefix) + for asn in missing: + q = f"AS{asn}" + entry = resp.get(q) or resp.get(str(asn)) + if entry: + _, org, _ = entry + if org: + try: + _service.asn_info[int(asn)] = {"name": org, "country": ""} + log.debug("lookup_asns_bulk: updated asn_info[{}] = {}", asn, org) + except Exception: + pass + except Exception as e: + log.debug("lookup_asns_bulk: bgp.tools lookup failed: {}", e) + + # Build final results from asn_info (may include newly-populated entries) + for asn in requested: + try: + asn_int = int(asn) + asn_data = _service.asn_info.get(asn_int, {}) + results[asn] = { + "name": asn_data.get("name", f"AS{asn}"), + "country": asn_data.get("country", ""), + } + except Exception: + results[asn] = {"name": f"AS{asn}", "country": ""} + + return results + + +async def refresh_ip_enrichment_data() -> bool: + """Manually refresh IP enrichment data.""" + log.info("Manual refresh requested") + # Since we no longer cache data, just return True to indicate the feature is available + return True + + +def get_data_status() -> dict: + """Get status information about IP enrichment data.""" + status = { + "mode": "real-time", + "data_source": "BGP.TOOLS", + "cache_enabled": False, + "last_update": "Real-time lookups via BGP.TOOLS", + "data_counts": { + "cached_ips": len(_service._ip_cache), + "cached_asns": len(_service.asn_info), + }, + } + return status + + +# Compatibility functions for existing code +TargetDetail = t.TypedDict( + "TargetDetail", + { + "asn": str, + "ip": str, + "prefix": str, + "country": str, + "rir": str, + "allocated": str, + "org": str, + }, +) + +TargetData = t.Dict[str, TargetDetail] + + +def default_ip_targets(*targets: str) -> t.Tuple[TargetData, t.List[str]]: + """Filter targets and create default data for private/special addresses.""" + _log = log.bind(source="ip_enrichment") + + default_data: TargetData = {} + query_targets: t.List[str] = [] + + for target in targets: + try: + target_ip = ip_address(target) + + # Check for special address types + special_types = [ + (target_ip.is_loopback, "Loopback Address"), + (target_ip.is_multicast, "Multicast Address"), + (target_ip.is_link_local, "Link Local Address"), + (target_ip.is_private, "Private Address"), + (target_ip.version == 6 and target_ip.is_site_local, "Site Local Address"), + ] + + is_special = False + for check, rir_type in special_types: + if check: + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": rir_type, + "allocated": "None", + "org": "None", + } + is_special = True + break + + if not is_special and (target_ip.is_global or target_ip.is_unspecified): + query_targets.append(target) + elif not is_special: + # Other non-global addresses + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Reserved Address", + "allocated": "None", + "org": "None", + } + + except ValueError: + # Invalid IP address + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Invalid Address", + "allocated": "None", + "org": "None", + } + + return default_data, query_targets + + +async def network_info(*targets: str) -> TargetData: + """Get network information using IP enrichment - compatibility function.""" + _log = log.bind(source="ip_enrichment") + + default_data, query_targets = default_ip_targets(*targets) + + if not query_targets: + _log.debug("No valid global IPs to query") + return default_data + + try: + _log.info(f"Enriching {len(query_targets)} IP addresses using bulk lookup") + + # Use the bulk lookup to query bgp.tools efficiently + query_data = {} + bulk_results = await _service.lookup_ips_bulk(query_targets) + + for target, ip_info in bulk_results.items(): + # Convert to TargetDetail format + if ip_info.is_ixp and ip_info.ixp_name: + detail: TargetDetail = { + "asn": "IXP", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "IXP", + "allocated": "None", + "org": ip_info.ixp_name, + } + elif ip_info.asn is not None and ip_info.asn != 0: + detail = { + "asn": str(ip_info.asn), + "ip": target, + "prefix": ip_info.prefix or "None", + "country": ip_info.country or "None", + "rir": "UNKNOWN", + "allocated": "None", + "org": ip_info.asn_name or "None", + } + elif ip_info.asn == 0: + detail = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Unknown", + "allocated": "None", + "org": "None", + } + else: + detail = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Unknown", + "allocated": "None", + "org": "None", + } + + query_data[target] = detail + + if ip_info.is_ixp: + _log.debug(f"Enriched {target}: IXP={ip_info.ixp_name}") + elif ip_info.asn: + _log.debug(f"Enriched {target}: AS{ip_info.asn} ({ip_info.asn_name})") + else: + _log.debug(f"No enrichment data found for {target}") + + except Exception as e: + _log.error(f"Error in network_info lookup: {e}") + # Return default data for all targets on error + query_data = {} + for target in query_targets: + query_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Error", + "allocated": "None", + "org": "None", + } + + return {**default_data, **query_data} + + +def network_info_sync(*targets: str) -> TargetData: + """Synchronous wrapper for network_info.""" + return asyncio.run(network_info(*targets)) + + +async def network_info_single(target: str) -> TargetDetail: + """Get network information for a single IP address.""" + result = await network_info(target) + return result[target] diff --git a/hyperglass/external/rpki.py b/hyperglass/external/rpki.py index 139ccf8fc..a5c480513 100644 --- a/hyperglass/external/rpki.py +++ b/hyperglass/external/rpki.py @@ -3,57 +3,83 @@ # Standard Library import typing as t +# Third Party +import httpx + # Project from hyperglass.log import log from hyperglass.state import use_state from hyperglass.external._base import BaseExternal if t.TYPE_CHECKING: - # Standard Library from ipaddress import IPv4Address, IPv6Address -RPKI_STATE_MAP = {"Invalid": 0, "Valid": 1, "NotFound": 2, "DEFAULT": 3} -RPKI_NAME_MAP = {v: k for k, v in RPKI_STATE_MAP.items()} +# Maps normalized (lower-case, separators stripped) backend state names to the +# integer states hyperglass uses internally. +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "notfound": 2, + "unknown": 2, + "default": 3, +} +# Canonical integer -> display name, kept stable for logging. +RPKI_NAME_MAP = {0: "Invalid", 1: "Valid", 2: "NotFound", 3: "DEFAULT"} CACHE_KEY = "hyperglass.external.rpki" -def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, str]) -> int: +def _normalize_state(value: str) -> int: + """Normalize a backend RPKI state string to an internal integer state.""" + key = str(value).strip().lower().replace("-", "").replace("_", "") + return RPKI_STATE_MAP.get(key, 3) + + +def rpki_state( + prefix: t.Union["IPv4Address", "IPv6Address", str], + asn: t.Union[int, str], + backend: str = "cloudflare", + rpki_server_url: str = "", +) -> int: """Get RPKI state and map to expected integer.""" _log = log.bind(prefix=prefix, asn=asn) _log.debug("Validating RPKI State") cache = use_state("cache") - state = 3 ro = f"{prefix!s}@{asn!s}" cached = cache.get_map(CACHE_KEY, ro) - if cached is not None: state = cached else: - ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' - query = ql.format(prefix, asn) - _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") try: - with BaseExternal(base_url="https://rpki.cloudflare.com") as client: - response = client._post("/api/graphql", data={"query": query}) - try: + if backend == "cloudflare": + ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' + query = ql.format(prefix, asn) + _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") + with BaseExternal(base_url="https://rpki.cloudflare.com") as client: + response = client._post("/api/graphql", data={"query": query}) validation_state = response["data"]["validation"]["state"] - except KeyError as missing: - _log.error("Response from Cloudflare missing key '{}': {!r}", missing, response) - validation_state = 3 + elif backend == "routinator": + url = f"{rpki_server_url.rstrip('/')}/validity" + _log.bind(url=url).debug("Routinator RPKI HTTP Query") + response = httpx.get( + url, params={"asn": str(asn), "prefix": str(prefix)}, timeout=5 + ) + response.raise_for_status() + data = response.json() + validation_state = data["validated_route"]["validity"]["state"] + else: + raise ValueError(f"Unknown RPKI backend: {backend}") - state = RPKI_STATE_MAP[validation_state] + state = _normalize_state(validation_state) cache.set_map_item(CACHE_KEY, ro, state) except Exception as err: log.error(err) - # Don't cache the state when an error produced it. state = 3 msg = "RPKI Validation State for {} via AS{} is {}".format(prefix, asn, RPKI_NAME_MAP[state]) if cached is not None: msg += " [CACHED]" - log.debug(msg) return state diff --git a/hyperglass/external/tests/test_bgptools.py b/hyperglass/external/tests/test_bgptools.py deleted file mode 100644 index 542c1dc28..000000000 --- a/hyperglass/external/tests/test_bgptools.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Test bgp.tools interactions.""" - -# Standard Library -import asyncio - -# Third Party -import pytest - -# Local -from ..bgptools import run_whois, parse_whois, network_info - -WHOIS_OUTPUT = """AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name -13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | 2010-07-14 | Cloudflare, Inc.""" - - -# Ignore asyncio deprecation warning about loop -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_network_info(): - checks = ( - ("192.0.2.1", {"asn": "None", "rir": "Private Address"}), - ("127.0.0.1", {"asn": "None", "rir": "Loopback Address"}), - ("fe80:dead:beef::1", {"asn": "None", "rir": "Link Local Address"}), - ("2001:db8::1", {"asn": "None", "rir": "Private Address"}), - ("1.1.1.1", {"asn": "13335", "rir": "ARIN"}), - ) - for addr, fields in checks: - info = asyncio.run(network_info(addr)) - assert addr in info - for key, expected in fields.items(): - assert info[addr][key] == expected - - -# Ignore asyncio deprecation warning about loop -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_whois(): - addr = "192.0.2.1" - response = asyncio.run(run_whois([addr])) - assert isinstance(response, str) - assert response != "" - - -def test_whois_parser(): - addr = "1.1.1.1" - result = parse_whois(WHOIS_OUTPUT, [addr]) - assert isinstance(result, dict) - assert addr in result, "Address missing" - assert result[addr]["asn"] == "13335" - assert result[addr]["rir"] == "ARIN" - assert result[addr]["org"] == "Cloudflare, Inc." diff --git a/hyperglass/external/tests/test_rpki.py b/hyperglass/external/tests/test_rpki.py index 01438e717..d21d50559 100644 --- a/hyperglass/external/tests/test_rpki.py +++ b/hyperglass/external/tests/test_rpki.py @@ -19,8 +19,8 @@ def test_rpki(): result = rpki_state(prefix, asn) result_name = RPKI_NAME_MAP.get(result, "No Name") expected_name = RPKI_NAME_MAP.get(expected, "No Name") - assert result == expected, ( - "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( - prefix, asn, result, result_name, expected, expected_name - ) + assert ( + result == expected + ), "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( + prefix, asn, result, result_name, expected, expected_name ) diff --git a/hyperglass/models/api/query.py b/hyperglass/models/api/query.py index a8791bc06..f37b494d6 100644 --- a/hyperglass/models/api/query.py +++ b/hyperglass/models/api/query.py @@ -123,7 +123,49 @@ def dict(self) -> t.Dict[str, t.Union[t.List[str], str]]: @property def device(self) -> Device: """Get this query's device object by query_location.""" - return self._state.devices[self.query_location] + # Return a proxy around the device so we can override + # structured_output per-request without mutating global state. + device = self._state.devices[self.query_location] + + # Determine effective structured_output based on global params + try: + params = use_state("params") + except Exception: + params = None + + # Decide which top-level structured enable flag to consult + feature_flag_name = None + if getattr(self, "query_type", None) == "traceroute": + feature_flag_name = "enable_for_traceroute" + elif getattr(self, "query_type", None) in ("bgp_route", "bgp_routestr"): + feature_flag_name = "enable_for_bgp_route" + + effective_structured = bool(getattr(device, "structured_output", False)) + + if params is None or not getattr(params, "structured", None): + # Global structured block absent => structured disabled + effective_structured = False + else: + # If structured is present, default is enabled; allow opt-out + if feature_flag_name is not None: + if getattr(params.structured, feature_flag_name, None) is False: + effective_structured = False + + class _DeviceProxy: + """Tiny proxy object that delegates to the real device but + overrides structured_output.""" + + def __init__(self, real, structured_value: bool) -> None: + self._real = real + self.structured_output = structured_value + + def __getattr__(self, name: str): + return getattr(self._real, name) + + def __repr__(self) -> str: # pragma: no cover - trivial + return repr(self._real) + + return _DeviceProxy(device, effective_structured) @field_validator("query_location") def validate_query_location(cls, value): diff --git a/hyperglass/models/config/devices.py b/hyperglass/models/config/devices.py index 70cac21ee..b633541fd 100644 --- a/hyperglass/models/config/devices.py +++ b/hyperglass/models/config/devices.py @@ -247,10 +247,10 @@ def validate_structured_output(cls, value: bool, info: ValidationInfo) -> bool: if value is True: if info.data.get("platform") not in SUPPORTED_STRUCTURED_OUTPUT: raise ConfigError( - "The 'structured_output' field is set to 'true' on device '{}' with " - + "platform '{}', which does not support structured output", - info.data.get("name"), - info.data.get("platform"), + "The 'structured_output' field is set to 'true' on device '{d}' with " + "platform '{p}', which does not support structured output", + d=info.data.get("name"), + p=info.data.get("platform"), ) return value if value is None and info.data.get("platform") in SUPPORTED_STRUCTURED_OUTPUT: diff --git a/hyperglass/models/config/params.py b/hyperglass/models/config/params.py index 1e35ba2b0..6fabf0bc9 100644 --- a/hyperglass/models/config/params.py +++ b/hyperglass/models/config/params.py @@ -83,12 +83,12 @@ class Params(ParamsPublic, HyperglassModel): plugins: t.List[str] = [] # Sub Level Params - cache: Cache = Cache() - docs: Docs = Docs() - logging: Logging = Logging() - messages: Messages = Messages() - structured: Structured = Structured() - web: Web = Web() + cache: Cache = Field(default_factory=Cache) + docs: Docs = Field(default_factory=Docs) + logging: Logging = Field(default_factory=Logging) + messages: Messages = Field(default_factory=Messages) + structured: Structured = Field(default_factory=Structured) + web: Web = Field(default_factory=Web) def __init__(self, **kw: t.Any) -> None: return super().__init__(**self.convert_paths(kw)) diff --git a/hyperglass/models/config/structured.py b/hyperglass/models/config/structured.py index 4373e8187..70464bbee 100644 --- a/hyperglass/models/config/structured.py +++ b/hyperglass/models/config/structured.py @@ -3,11 +3,15 @@ # Standard Library import typing as t +# Third Party +from pydantic import ValidationInfo, field_validator, model_validator + # Local from ..main import HyperglassModel -StructuredCommunityMode = t.Literal["permit", "deny"] +StructuredCommunityMode = t.Literal["permit", "deny", "name"] StructuredRPKIMode = t.Literal["router", "external"] +StructuredRPKIBackend = t.Literal["cloudflare", "routinator"] class StructuredCommunities(HyperglassModel): @@ -15,12 +19,34 @@ class StructuredCommunities(HyperglassModel): mode: StructuredCommunityMode = "deny" items: t.List[str] = [] + names: t.Dict[str, str] = {} + + @field_validator("names") + def validate_names(cls, value: t.Dict[str, str], info: ValidationInfo) -> t.Dict[str, str]: + """Require at least one community mapping when mode is 'name'.""" + if info.data and info.data.get("mode") == "name" and not value: + raise ValueError( + "When using mode 'name', at least one community mapping must be " + "provided in 'names'" + ) + return value class StructuredRpki(HyperglassModel): """Control structured data response for RPKI state.""" mode: StructuredRPKIMode = "router" + backend: StructuredRPKIBackend = "cloudflare" + rpki_server_url: str = "" + + @model_validator(mode="after") + def validate_routinator_url(self) -> "StructuredRpki": + """Require a server URL when the routinator backend is selected.""" + if self.backend == "routinator" and not self.rpki_server_url: + raise ValueError( + "structured.rpki.rpki_server_url is required when backend is 'routinator'" + ) + return self class Structured(HyperglassModel): @@ -28,3 +54,10 @@ class Structured(HyperglassModel): communities: StructuredCommunities = StructuredCommunities() rpki: StructuredRpki = StructuredRpki() + + # Top-level structured enable/disable flags. If `structured:` is present in + # the user's config and these are not set (None), the structured table + # output is considered enabled by default. Setting them to False disables + # the structured table output even when a `structured:` block exists. + enable_for_traceroute: t.Optional[bool] = None + enable_for_bgp_route: t.Optional[bool] = None diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index 7916d9e9b..f6881935c 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -137,6 +137,7 @@ class ThemeColors(HyperglassModel): dark: Color = "#010101" light: Color = "#f5f6f7" gray: Color = "#c1c7cc" + filtered: Color = "#c1c7cc" red: Color = "#d84b4b" orange: Color = "#ff6b35" yellow: Color = "#edae49" diff --git a/hyperglass/models/data/__init__.py b/hyperglass/models/data/__init__.py index 02d8f276d..9e0a0be52 100644 --- a/hyperglass/models/data/__init__.py +++ b/hyperglass/models/data/__init__.py @@ -4,11 +4,14 @@ from typing import Union # Local -from .bgp_route import BGPRouteTable +from .bgp_route import BGPRoute, BGPRouteTable +from .traceroute import TracerouteResult -OutputDataModel = Union[BGPRouteTable] +OutputDataModel = Union[BGPRouteTable, TracerouteResult] __all__ = ( + "BGPRoute", "BGPRouteTable", + "TracerouteResult", "OutputDataModel", ) diff --git a/hyperglass/models/data/bgp_route.py b/hyperglass/models/data/bgp_route.py index a00af4e13..7eadb47ed 100644 --- a/hyperglass/models/data/bgp_route.py +++ b/hyperglass/models/data/bgp_route.py @@ -34,6 +34,7 @@ class BGPRoute(HyperglassModel): source_rid: str peer_rid: str rpki_state: int + filtered: bool = False @field_validator("communities") def validate_communities(cls, value): @@ -42,6 +43,7 @@ def validate_communities(cls, value): Actions: permit: only permit matches deny: only deny matches + name: append friendly names to matching communities """ (structured := use_state("params").structured) @@ -64,6 +66,15 @@ def _deny(comm): break return valid + def _name(comm): + """Append a friendly name to a community when one is mapped.""" + if comm in structured.communities.names: + return f"{comm},{structured.communities.names[comm]}" + return comm + + if structured.communities.mode == "name": + return [_name(c) for c in value] + func_map = {"permit": _permit, "deny": _deny} func = func_map[structured.communities.mode] @@ -80,10 +91,9 @@ def validate_rpki_state(cls, value, info: ValidationInfo): return value if structured.rpki.mode == "external": - # If external validation is enabled, validate the prefix - # & asn with Cloudflare's RPKI API. + # If external validation is enabled, validate the prefix & asn + # with the configured RPKI backend. as_path = info.data.get("as_path", []) - if len(as_path) == 0: # If the AS_PATH length is 0, i.e. for an internal route, # return RPKI Unknown state. @@ -91,14 +101,19 @@ def validate_rpki_state(cls, value, info: ValidationInfo): # Get last ASN in path asn = as_path[-1] - try: - net = ip_network(info.data["prefix"]) - except ValueError: - return 3 + try: + net = ip_network(info.data["prefix"]) + except ValueError: + return 3 - # Only do external RPKI lookups for global prefixes. - if net.is_global: - return rpki_state(prefix=info.data["prefix"], asn=asn) + # Only do external RPKI lookups for global prefixes. + if net.is_global: + return rpki_state( + prefix=info.data["prefix"], + asn=asn, + backend=structured.rpki.backend, + rpki_server_url=structured.rpki.rpki_server_url, + ) return value diff --git a/hyperglass/models/data/traceroute.py b/hyperglass/models/data/traceroute.py new file mode 100644 index 000000000..e2d929b35 --- /dev/null +++ b/hyperglass/models/data/traceroute.py @@ -0,0 +1,252 @@ +"""Traceroute Data Models.""" + +# Standard Library +import typing as t +from ipaddress import ip_address, AddressValueError + +# Third Party +from pydantic import field_validator, computed_field + +# Project +from hyperglass.external.ip_enrichment import TargetDetail + +# Local +from ..main import HyperglassModel + + +class TracerouteHop(HyperglassModel): + """Individual hop in a traceroute.""" + + hop_number: int + ip_address: t.Optional[str] = None + display_ip: t.Optional[str] = None # For truncated IPs that can't be validated + hostname: t.Optional[str] = None + rtt1: t.Optional[float] = None + rtt2: t.Optional[float] = None + rtt3: t.Optional[float] = None + + # MikroTik-specific statistics + loss_pct: t.Optional[int] = None + sent_count: t.Optional[int] = None + last_rtt: t.Optional[float] = None + best_rtt: t.Optional[float] = None + worst_rtt: t.Optional[float] = None + + # IP enrichment data + asn: t.Optional[str] = None + org: t.Optional[str] = None + prefix: t.Optional[str] = None + country: t.Optional[str] = None + rir: t.Optional[str] = None + allocated: t.Optional[str] = None + + @field_validator("ip_address") + def validate_ip_address(cls, value): + """Validate IP address format.""" + if value is not None: + # Handle truncated addresses (MikroTik sometimes truncates long IPv6 addresses with ...) + if value.endswith("...") or value.endswith(".."): + return None # Invalid for BGP enrichment but kept in display_ip + try: + ip_address(value) + except AddressValueError: + return None + return value + + @property + def ip_display(self) -> t.Optional[str]: + """Get the IP address for display purposes (may be truncated).""" + return self.display_ip or self.ip_address + + @computed_field + @property + def avg_rtt(self) -> t.Optional[float]: + """Calculate average RTT from available measurements.""" + rtts = [rtt for rtt in [self.rtt1, self.rtt2, self.rtt3] if rtt is not None] + return sum(rtts) / len(rtts) if rtts else None + + @property + def is_timeout(self) -> bool: + """Check if this hop is a timeout (no IP and no RTTs).""" + return self.ip_address is None and all( + rtt is None for rtt in [self.rtt1, self.rtt2, self.rtt3] + ) + + @property + def asn_display(self) -> str: + """Display ASN - just the number, no AS prefix.""" + if self.asn and self.asn != "None": + if self.asn == "IXP": + # For IXPs, show "IXP" with org if available + if self.org and self.org != "None": + return f"IXP ({self.org})" + return "IXP" + else: + # For ASNs, show just the number with org if available + if self.org and self.org != "None": + return f"{self.asn} ({self.org})" + return self.asn + return "Unknown" + + +class TracerouteResult(HyperglassModel): + """Complete traceroute result.""" + + target: str + source: str + hops: t.List[TracerouteHop] + max_hops: int = 30 + packet_size: int = 60 + raw_output: t.Optional[str] = ( + None # Store cleaned/processed output for "Copy Raw" functionality (not original raw router output) + ) + asn_organizations: t.Dict[str, t.Dict[str, str]] = {} # ASN -> {name, country} + + @property + def hop_count(self) -> int: + """Total number of hops.""" + return len(self.hops) + + @property + def unique_asns(self) -> t.List[str]: + """List of unique ASNs encountered in the path.""" + asns = set() + for hop in self.hops: + if hop.asn and hop.asn != "None": + asns.add(hop.asn) + return sorted(list(asns)) + + @property + def as_path_summary(self) -> str: + """Summary of AS path traversed.""" + as_path = [] + current_asn = None + + for hop in self.hops: + if hop.asn and hop.asn not in ["None", None] and hop.asn != current_asn: + current_asn = hop.asn + # hop.asn is now just number ("12345") or "IXP" - display as-is + as_path.append(hop.asn) + + return " -> ".join(as_path) if as_path else "Unknown" + + @property + def as_path_detailed(self) -> str: + """Detailed AS path with organization names.""" + as_path = [] + current_asn = None + current_org = None + + for hop in self.hops: + if hop.asn and hop.asn not in ["None", None] and hop.asn != current_asn: + current_asn = hop.asn # Just number ("12345") or "IXP" + current_org = hop.org + + # Format with org name if we have it + if current_org and current_org not in ["None", None]: + if current_asn == "IXP": + as_path.append(f"IXP ({current_org})") + else: + as_path.append(f"{current_asn} ({current_org})") + else: + as_path.append(current_asn) + + return " -> ".join(as_path) if as_path else "Unknown" + + @property + def as_path_data(self) -> t.List[t.Dict[str, t.Union[str, None]]]: + """AS path data as structured list for frontend visualization.""" + as_path_data = [] + current_asn = None + current_org = None + + for hop in self.hops: + if hop.asn and hop.asn not in ["None", None] and hop.asn != current_asn: + current_asn = hop.asn # Just number ("12345") or "IXP" + current_org = hop.org + + as_path_data.append( + { + "asn": current_asn, + "org": current_org if current_org and current_org != "None" else None, + } + ) + + return as_path_data + + async def enrich_with_ip_enrichment(self): + """Enrich traceroute hops with IP enrichment data.""" + from hyperglass.external.ip_enrichment import network_info + + # Extract all IP addresses that need enrichment + ips_to_lookup = [] + for hop in self.hops: + if hop.ip_address and hop.asn is None: # Only lookup if not already enriched + ips_to_lookup.append(hop.ip_address) + + if not ips_to_lookup: + return + + # Bulk lookup IP information + network_data = await network_info(*ips_to_lookup) + + # Enrich hops with the retrieved data + for hop in self.hops: + if hop.ip_address in network_data: + data: TargetDetail = network_data[hop.ip_address] + # ASN field is now just number string ("12345") or "IXP" + asn_value = data.get("asn") + if asn_value and asn_value != "None": + hop.asn = asn_value # Store as-is: "12345" or "IXP" + else: + hop.asn = None + + hop.org = data.get("org") if data.get("org") != "None" else None + hop.prefix = data.get("prefix") if data.get("prefix") != "None" else None + hop.country = data.get("country") if data.get("country") != "None" else None + hop.rir = data.get("rir") if data.get("rir") != "None" else None + hop.allocated = data.get("allocated") if data.get("allocated") != "None" else None + + async def enrich_asn_organizations(self): + """Enrich ASN organization names using bulk ASN lookup.""" + from hyperglass.external.ip_enrichment import lookup_asns_bulk + from hyperglass.log import log + + _log = log.bind(source="traceroute_asn_enrichment") + + # Collect all unique ASNs that need organization info + asns_to_lookup = [] + for hop in self.hops: + if hop.asn and hop.asn != "None" and hop.asn != "IXP": + asns_to_lookup.append(hop.asn) + _log.debug(f"Hop {hop.hop_number}: ASN={hop.asn}, current org='{hop.org}'") + + if not asns_to_lookup: + _log.debug("No ASNs to lookup") + return + + # Remove duplicates while preserving order + unique_asns = list(dict.fromkeys(asns_to_lookup)) + _log.info(f"Looking up organizations for {len(unique_asns)} unique ASNs: {unique_asns}") + + # Bulk lookup ASN organization data + asn_data = await lookup_asns_bulk(unique_asns) + _log.debug(f"Got ASN data: {asn_data}") + + # Apply the organization data to hops + for hop in self.hops: + if hop.asn and hop.asn in asn_data: + data = asn_data[hop.asn] + org_name = data.get("name") if data.get("name") != f"AS{hop.asn}" else None + + _log.debug( + f"Hop {hop.hop_number} ASN {hop.asn}: setting org='{org_name}' (was '{hop.org}')" + ) + + # Always update org from ASN data (more accurate than IP-based org) + hop.org = org_name + if not hop.country: # Only set country if not already set + hop.country = data.get("country") or None + + # Store the ASN organization mapping for frontend path visualization + self.asn_organizations = asn_data diff --git a/hyperglass/models/parsing/huawei.py b/hyperglass/models/parsing/huawei.py new file mode 100644 index 000000000..357f121b5 --- /dev/null +++ b/hyperglass/models/parsing/huawei.py @@ -0,0 +1,394 @@ +"""Parser for Huawei VRP.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import ConfigDict, field_validator, model_validator + +# Project +from hyperglass.log import log +from hyperglass.models.data.bgp_route import BGPRoute, BGPRouteTable + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +def remove_prefix(text: str, prefix: str) -> str: + """Remove prefix from text if it exists.""" + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +class HuaweiBase(HyperglassModel, extra="ignore"): + """Base Huawei model.""" + + def __init__(self, **kwargs: t.Any) -> None: + """Initialize Huawei model.""" + super().__init__(**kwargs) + + +class HuaweiPaths(HuaweiBase): + """BGP paths information.""" + + available: int = 0 + best: int = 0 + select: int = 0 + best_external: int = 0 + add_path: int = 0 + + +class HuaweiRouteEntry(HuaweiBase): + """Parse Huawei route entry data.""" + + model_config = ConfigDict(validate_assignment=False) + + prefix: str + from_addr: str = "" + duration: int = 0 + direct_out_interface: str = "" + original_next_hop: str = "" + relay_ip_next_hop: str = "" + relay_ip_out_interface: str = "" + qos: str = "" + communities: t.List[str] = [] + large_communities: t.List[str] = [] + ext_communities: t.List[str] = [] + as_path: t.List[int] = [] + origin: str = "" + metric: int = 0 # MED + local_preference: int = 100 + preference_value: int = 0 + is_valid: bool = False + is_external: bool = False + is_backup: bool = False + is_best: bool = False + is_selected: bool = False + preference: int = 0 + + @property + def next_hop(self) -> str: + """Get next hop (original or relay IP).""" + return self.original_next_hop or self.relay_ip_next_hop + + @property + def age(self) -> str: + """Get age as string.""" + return str(self.duration) + + @property + def weight(self) -> int: + """Get weight (preference).""" + return self.preference + + @property + def med(self) -> int: + """Get MED (metric).""" + return self.metric + + @property + def active(self) -> bool: + """Check if route is active (best or selected).""" + return self.is_best or self.is_selected + + @property + def all_communities(self) -> t.List[str]: + """Get all communities combined.""" + return self.communities + self.large_communities + self.ext_communities + + @property + def source_as(self) -> int: + """Get source AS from AS path (first ASN in the path).""" + return self.as_path[0] if self.as_path else 0 + + @property + def source_rid(self) -> str: + """Get source router ID.""" + return "" + + @property + def peer_rid(self) -> str: + """Get peer router ID.""" + return self.from_addr + + +def _extract_paths(line: str) -> HuaweiPaths: + """Extract paths information from line like 'Paths: 3 available, 1 best, 1 select, 0 best-external, 0 add-path'.""" + paths_data = { + "available": 0, + "best": 0, + "select": 0, + "best_external": 0, + "add_path": 0, + } + + try: + values = remove_prefix(line.strip(), "Paths:").strip().split(",") + for value in values: + parts = value.strip().split(" ") + if len(parts) >= 2: + count = int(parts[0]) + name = parts[1].replace("-", "_") # Convert best-external to best_external + if name in paths_data: + paths_data[name] = count + except (ValueError, IndexError): + log.warning(f"Failed to parse paths line: {line}") + + return HuaweiPaths(**paths_data) + + +def _extract_route_entries(lines: t.List[str]) -> t.List[HuaweiRouteEntry]: + """Extract route entries from lines.""" + routes = [] + + # Split lines into route blocks using empty lines as separators + size = len(lines) + idx_list = [idx + 1 for idx, val in enumerate(lines) if val.strip() == ""] + entries = ( + [ + lines[i:j] + for i, j in zip([0] + idx_list, idx_list + ([size] if idx_list[-1] != size else [])) + ] + if idx_list + else [lines] + ) + + for route_entry in entries: + if not route_entry: + continue + + # Initialize route data + route_data = { + "prefix": "", + "from_addr": "", + "duration": 0, + "direct_out_interface": "", + "original_next_hop": "", + "relay_ip_next_hop": "", + "relay_ip_out_interface": "", + "qos": "", + "communities": [], + "large_communities": [], + "ext_communities": [], + "as_path": [], + "origin": "", + "metric": 0, + "local_preference": 100, + "preference_value": 0, + "is_valid": False, + "is_external": False, + "is_backup": False, + "is_best": False, + "is_selected": False, + "preference": 0, + } + + for info in route_entry: + info = info.strip() + if not info: + continue + + if info.startswith("BGP routing table entry information of"): + route_data["prefix"] = remove_prefix( + info, "BGP routing table entry information of " + ).rstrip(":") + elif info.startswith("From:"): + route_data["from_addr"] = remove_prefix(info, "From: ").split(" (")[0] + elif info.startswith("Route Duration:"): + duration_str = remove_prefix(info, "Route Duration: ") + try: + # Parse format like "84d11h53m07s" + d_match = re.search(r"(\d+)d", duration_str) + h_match = re.search(r"(\d+)h", duration_str) + m_match = re.search(r"(\d+)m", duration_str) + s_match = re.search(r"(\d+)s", duration_str) + + days = int(d_match.group(1)) if d_match else 0 + hours = int(h_match.group(1)) if h_match else 0 + minutes = int(m_match.group(1)) if m_match else 0 + seconds = int(s_match.group(1)) if s_match else 0 + + route_data["duration"] = ( + days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60 + seconds + ) + except: + route_data["duration"] = 0 + elif info.startswith("Direct Out-interface:"): + route_data["direct_out_interface"] = remove_prefix(info, "Direct Out-interface: ") + elif info.startswith("Original nexthop:"): + route_data["original_next_hop"] = remove_prefix(info, "Original nexthop: ") + elif info.startswith("Relay IP Nexthop:"): + route_data["relay_ip_next_hop"] = remove_prefix(info, "Relay IP Nexthop: ") + elif info.startswith("Relay IP Out-Interface:"): + route_data["relay_ip_out_interface"] = remove_prefix( + info, "Relay IP Out-Interface: " + ) + elif info.startswith("Qos information :"): + route_data["qos"] = remove_prefix(info, "Qos information : ") + elif info.startswith("Community:"): + communities_str = remove_prefix(info, "Community: ") + if communities_str and communities_str.lower() != "none": + communities = [ + c.strip().replace("<", "").replace(">", "") + for c in communities_str.split(", ") + ] + route_data["communities"] = [c for c in communities if c] + elif info.startswith("Large-Community:"): + large_communities_str = remove_prefix(info, "Large-Community: ") + if large_communities_str and large_communities_str.lower() != "none": + large_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in large_communities_str.split(", ") + ] + route_data["large_communities"] = [c for c in large_communities if c] + elif info.startswith("Ext-Community:"): + ext_communities_str = remove_prefix(info, "Ext-Community: ") + if ext_communities_str and ext_communities_str.lower() != "none": + ext_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in ext_communities_str.split(", ") + ] + route_data["ext_communities"] = [c for c in ext_communities if c] + elif info.startswith("AS-path"): + values = info.split(",") + for v in values: + v = v.strip() + if v.startswith("AS-path"): + as_path_str = remove_prefix(v, "AS-path ") + try: + route_data["as_path"] = [ + int(a) for a in as_path_str.split() if a.isdigit() + ] + except ValueError: + route_data["as_path"] = [] + elif v.startswith("origin"): + route_data["origin"] = remove_prefix(v, "origin ") + elif v.startswith("MED"): + try: + route_data["metric"] = int(remove_prefix(v, "MED ")) + except ValueError: + route_data["metric"] = 0 + elif v.startswith("localpref"): + try: + route_data["local_preference"] = int(remove_prefix(v, "localpref ")) + except ValueError: + route_data["local_preference"] = 100 + elif v.startswith("pref-val"): + try: + route_data["preference_value"] = int(remove_prefix(v, "pref-val ")) + except ValueError: + route_data["preference_value"] = 0 + elif v.startswith("pre "): + try: + route_data["preference"] = int(remove_prefix(v, "pre ")) + except ValueError: + route_data["preference"] = 0 + elif v.strip() == "valid": + route_data["is_valid"] = True + elif v.strip() == "external": + route_data["is_external"] = True + elif v.strip() == "backup": + route_data["is_backup"] = True + elif v.strip() == "best": + route_data["is_best"] = True + elif v.strip() == "select": + route_data["is_selected"] = True + + # Only add route if we have a valid prefix + if route_data["prefix"]: + try: + route = HuaweiRouteEntry(**route_data) + routes.append(route) + except Exception as e: + log.warning( + "Failed to create route entry for prefix {}: {}", + route_data.get("prefix", "unknown"), + e, + ) + continue + + return routes + + +class HuaweiBGPRouteTable(BGPRouteTable): + """Canonical Huawei BGP Route Table.""" + + # No custom __init__ needed; inherit from BGPRouteTable (which should be a Pydantic model) + + +class HuaweiBGPTable(HuaweiBase): + """Validation model for Huawei BGP table data.""" + + local_router_id: str = "" + local_as_number: int = 0 + paths: HuaweiPaths = HuaweiPaths() + routes: t.List[HuaweiRouteEntry] = [] + + @classmethod + def parse_text(cls, text: str) -> "HuaweiBGPTable": + """Parse Huawei BGP text output.""" + _log = log.bind(parser="HuaweiBGPTable") + + instance = cls() + + lines = text.split("\n") + + # Extract general information + for line in lines: + if "BGP local router ID" in line: + instance.local_router_id = remove_prefix(line, "BGP local router ID : ").strip() + elif "Local AS number" in line: + try: + instance.local_as_number = int( + remove_prefix(line, "Local AS number : ").strip() + ) + except ValueError: + instance.local_as_number = 0 + elif line.strip().startswith("Paths:"): + instance.paths = _extract_paths(line) + + # Extract route entries + instance.routes = _extract_route_entries(lines) + + _log.debug(f"Parsed {len(instance.routes)} Huawei routes") + return instance + + def bgp_table(self) -> BGPRouteTable: + """Convert to standard BGP table format.""" + routes = [] + + for route in self.routes: + route_data = { + "prefix": route.prefix, + "active": route.active, + "age": route.age, + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.all_communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": ( + RPKI_STATE_MAP["valid"] if route.is_valid else RPKI_STATE_MAP["unknown"] + ), + } + routes.append(BGPRoute(**route_data)) + + return HuaweiBGPRouteTable( + vrf="default", + count=len(routes), + routes=routes, + winning_weight="high", + ) diff --git a/hyperglass/models/parsing/mikrotik.py b/hyperglass/models/parsing/mikrotik.py new file mode 100644 index 000000000..1aea80f7d --- /dev/null +++ b/hyperglass/models/parsing/mikrotik.py @@ -0,0 +1,561 @@ +"""Parser for MikroTik RouterOS (ROS v6/v7) – structured in Huawei style.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import ConfigDict + +# Project +from hyperglass.log import log +from hyperglass.settings import Settings +from hyperglass.models.data.bgp_route import BGPRoute, BGPRouteTable # Add BGPRoute import + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +def remove_prefix(text: str, prefix: str) -> str: + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +# Regex to find key=value pairs. The key can contain dots and hyphens. +# The value can be quoted or a single word. +TOKEN_RE = re.compile(r'([a-zA-Z0-9_.-]+)=(".*?"|\S+)') + +# Regex to find flags at the beginning of a line (e.g., "Ab dst-address=..."). +# Include filtered/disabled/unreachable flags so those routes split into their own entries. +FLAGS_RE = re.compile(r"^\s*([DXUuIAFfcmsroivmyH\+b]+)\s+") + + +class MikrotikBase(HyperglassModel, extra="ignore"): + """Base model for MikroTik parsing models (ignores unknown fields).""" + + +class MikrotikPaths(MikrotikBase): + available: int = 0 + best: int = 0 + select: int = 0 + best_external: int = 0 + add_path: int = 0 + + +class MikrotikRouteEntry(MikrotikBase): + """MikroTik Route Entry.""" + + model_config = ConfigDict(validate_assignment=False) + + prefix: str + gateway: str = "" + distance: int = 0 + scope: int = 0 + target_scope: int = 0 + as_path: t.List[int] = [] + communities: t.List[str] = [] + large_communities: t.List[str] = [] + ext_communities: t.List[str] = [] + local_preference: int = 100 + metric: int = 0 # MED + origin: str = "" + belongs_to: str = "" # BGP session identifier + is_active: bool = False + is_filtered: bool = False + rpki_state: int = RPKI_STATE_MAP.get("unknown", 2) + + @property + def next_hop(self) -> str: + return self.gateway + + @property + def age(self) -> int: + # MikroTik output does not provide route age, returning -1 to indicate unavailable. + return -1 + + @property + def weight(self) -> int: + return self.distance + + @property + def med(self) -> int: + return self.metric + + @property + def active(self) -> bool: + return self.is_active + + @property + def all_communities(self) -> t.List[str]: + return self.communities + self.large_communities + self.ext_communities + + @property + def source_as(self) -> int: + return self.as_path[-1] if self.as_path else 0 + + @property + def source_rid(self) -> str: + """Extract the IP/IPv6 address from belongs-to field. + + Examples: + belongs-to="bgp-IP-196.60.96.109" -> "196.60.96.109" + belongs-to="bgp-IPv6-2001:43f8:6d0::11:141" -> "2001:43f8:6d0::11:141" + """ + if not self.belongs_to: + return "" + + # Remove bgp-IP- or bgp-IPv6- prefix + if self.belongs_to.startswith("bgp-IP-"): + return self.belongs_to[7:] # len("bgp-IP-") = 7 + elif self.belongs_to.startswith("bgp-IPv6-"): + return self.belongs_to[9:] # len("bgp-IPv6-") = 9 + + return "" + + @property + def peer_rid(self) -> str: + return self.gateway + + +def _extract_paths(lines: t.List[str]) -> MikrotikPaths: + """Simple count based on lines with dst/dst-address and preferred 'A' flag.""" + available = 0 + best = 0 + for raw in lines: + if ("dst-address=" in raw) or (" dst=" in f" {raw} "): + available += 1 + m = FLAGS_RE.match(raw) + if m: + flags = set(m.group(1)) + if "A" in flags: + best += 1 + return MikrotikPaths(available=available, best=best, select=best) + + +def _process_kv(route: dict, key: str, val: str): + """Process a key-value pair and update the route dictionary.""" + # Normalize quoted values + if val.startswith('"') and val.endswith('"'): + val = val[1:-1] + + if key in ("dst-address", "dst"): + route["prefix"] = val + elif key in ("gateway", "nexthop"): + # Extract only the IP from gateway (e.g., 168.254.0.2%vlan-2000) + route["gateway"] = val.split("%")[0] + elif key == "distance": + route["distance"] = int(val) if val.isdigit() else route.get("distance", 0) + elif key == "scope": + route["scope"] = int(val) if val.isdigit() else route.get("scope", 0) + elif key in ("target-scope", "target_scope"): + route["target_scope"] = int(val) if val.isdigit() else route.get("target_scope", 0) + + # v7 keys (with dot) + elif key in (".as-path", "as-path", "bgp-as-path"): + if val and val.lower() != "none": + # Find all numbers in the as-path string + nums = re.findall(r"\b\d{1,10}\b", val) + route["as_path"] = [int(n) for n in nums if 1 <= int(n) <= 4294967295] + elif key in (".origin", "origin", "bgp-origin"): + route["origin"] = val + elif key in (".med", "med", "bgp-med"): + route["metric"] = int(val) if val.isdigit() else 0 + elif key in (".local-pref", "local-pref", "bgp-local-pref"): + route["local_preference"] = int(val) if val.isdigit() else 100 + elif key in (".communities", "communities", "bgp-communities"): + if val and val.lower() != "none": + route["communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key in (".large-communities", "large-communities", "bgp-large-communities"): + if val and val.lower() != "none": + route["large_communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key == "bgp-ext-communities": + if val and val.lower() != "none": + route["ext_communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key == "rpki": + clean_val = val.strip().strip('"').lower() + route["rpki_state"] = RPKI_STATE_MAP.get(clean_val, 2) + elif key == "belongs-to": + route["belongs_to"] = val + + +def _extract_route_entries(lines: t.List[str]) -> t.List[MikrotikRouteEntry]: + """Extract route entries from a list of lines.""" + routes: t.List[MikrotikRouteEntry] = [] + current_route_lines = [] + + for line in lines: + stripped_line = line.strip() + # A new route entry starts with flags or is a continuation line. + # An empty line signifies the end of the previous block. + if not stripped_line and current_route_lines: + # Process the completed route block + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + current_route_lines = [] + elif stripped_line: + # Check if this line is the start of a new entry + if FLAGS_RE.match(stripped_line) and current_route_lines: + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + current_route_lines = [stripped_line] + else: + current_route_lines.append(stripped_line) + + # Process any remaining lines + if current_route_lines: + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + + return routes + + +def _parse_route_block(block: t.List[str]) -> t.Optional[MikrotikRouteEntry]: + """Parse a single route block and return a MikrotikRouteEntry.""" + if not block: + return None + + full_block_text = " ".join(block) + if "dst-address=" not in full_block_text and " dst=" not in f" {full_block_text} ": + return None + + flags = "" + rd = { + "prefix": "", + "gateway": "", + "distance": 20, + "scope": 30, + "target_scope": 10, + "as_path": [], + "communities": [], + "large_communities": [], + "ext_communities": [], + "local_preference": 100, + "metric": 0, + "origin": "", + "belongs_to": "", + "is_active": False, + "is_filtered": False, + "rpki_state": RPKI_STATE_MAP.get("unknown", 2), + } + + # Interpret flags in the first line: + # - "A" => active (preferred) + # - otherwise not active + m = FLAGS_RE.match(block[0]) + if m: + flags = m.group(1) + flag_set = set(flags) + is_active = "A" in flag_set + is_filtered = bool(flag_set & {"F", "f", "U", "u", "X", "x"}) + + if is_active: + rd["is_active"] = True + rd["is_filtered"] = False + else: + rd["is_filtered"] = is_filtered + + # Find all key=value tokens in the entire block + for k, v in TOKEN_RE.findall(full_block_text): + _process_kv(rd, k, v) + + if rd["prefix"]: + try: + if Settings.debug: + log.bind(parser="MikrotikBGPTable").debug( + "Parsed MikroTik route entry: " + "prefix={prefix} flags={flags} active={active} filtered={filtered} " + "next_hop={next_hop} as_path={as_path} rpki_state={rpki_state}", + prefix=rd["prefix"], + flags=flags or None, + active=rd["is_active"], + filtered=rd["is_filtered"], + next_hop=rd["gateway"] or None, + as_path=rd["as_path"], + rpki_state=rd["rpki_state"], + ) + return MikrotikRouteEntry(**rd) + except Exception as e: + log.warning(f"Failed to create MikroTik route entry ({rd.get('prefix','?')}: {e}") + return None + + +class MikrotikBGPRouteTable(BGPRouteTable): + """Canonical MikroTik BGP Route Table.""" + + # No custom __init__ needed; inherit from BGPRouteTable (which should be a Pydantic model) + + +class MikrotikBGPTable(MikrotikBase): + """MikroTik BGP Table in canonical format.""" + + local_router_id: str = "" + local_as_number: int = 0 + paths: MikrotikPaths = MikrotikPaths() + routes: t.List[MikrotikRouteEntry] = [] + + @classmethod + def parse_text(cls, text: str) -> "MikrotikBGPTable": + _log = log.bind(parser="MikrotikBGPTable") + inst = cls() + + lines = text.splitlines() + if not lines: + return inst + + # Filter out command echoes and header lines + lines = [ln for ln in lines if not ln.strip().startswith((">", "Flags:", "[", "#"))] + + inst.paths = _extract_paths(lines) + inst.routes = _extract_route_entries(lines) + + _log.debug(f"Parsed {len(inst.routes)} MikroTik routes") + return inst + + def bgp_table(self) -> BGPRouteTable: + routes = [] + for route in self.routes: + route_data = { + "prefix": route.prefix, + "active": route.active, + "age": route.age, + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.all_communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": route.rpki_state, + "filtered": route.is_filtered, + } + # Instantiate BGPRoute to trigger validation (including external RPKI) + routes.append(BGPRoute(**route_data)) + return MikrotikBGPRouteTable( + vrf="default", + count=len(routes), + routes=routes, + winning_weight="low", + ) + + +class MikrotikTracerouteTable(MikrotikBase): + """MikroTik Traceroute Table.""" + + target: str + source: str + hops: t.List["MikrotikTracerouteHop"] = [] + max_hops: int = 30 + packet_size: int = 60 + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> "MikrotikTracerouteTable": + """Parse a cleaned MikroTik traceroute table. + + The input is expected to be a single, clean traceroute table that has already + been processed by the garbage cleaner to remove paging artifacts and duplicates. + + Expected format: + ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS + 197.157.67.233 0% 3 0.4ms 0.2 0.1 0.4 0.1 + 100% 3 timeout + 41.78.188.153 0% 3 210.8ms 210.8 210.8 210.9 0 + """ + _log = log.bind(parser="MikrotikTracerouteTable") + + _log.debug( + "Parsing cleaned MikroTik traceroute table", + target=target, + source=source, + lines=len(text.splitlines()), + ) + + lines = text.strip().split("\n") + hops = [] + hop_number = 1 + + # Find the table header to start parsing from + header_found = False + data_start_index = 0 + has_index_column = False + + for i, line in enumerate(lines): + if "ADDRESS" in line and "LOSS" in line and "SENT" in line: + header_found = True + data_start_index = i + 1 + # Some RouterOS versions prefix each row with a hop-index column, + # so the header begins with "#" (e.g. "# ADDRESS LOSS SENT ..."). + # Detect it here so the data rows can drop that leading column. + header_tokens = line.split() + has_index_column = bool(header_tokens) and header_tokens[0] == "#" + break + + if not header_found: + _log.warning("No traceroute table header found in cleaned output") + return MikrotikTracerouteTable(target=target, source=source, hops=[]) + + # Parse data rows + for i in range(data_start_index, len(lines)): + line = lines[i].strip() + + # Skip empty lines + if not line: + continue + + # Stop at any remaining paging markers (shouldn't happen with cleaned input) + if "-- [Q quit|C-z pause]" in line: + break + + try: + # Parse data line + parts = line.split() + # Drop the leading hop-index column when present (e.g. "0 1.2.3.4 ..."). + if has_index_column and parts and re.fullmatch(r"\d+", parts[0]): + parts = parts[1:] + if len(parts) < 3: + continue + + # Check if this is a timeout line (starts with percentage) + if parts[0].endswith("%"): + # Timeout hop: "100% 3 timeout" + ip_address = None + loss_pct = int(parts[0].rstrip("%")) + sent_count = int(parts[1]) + last_rtt = None + avg_rtt = None + best_rtt = None + worst_rtt = None + else: + # Normal hop: "197.157.67.233 0% 3 0.4ms 0.2 0.1 0.4 0.1" + ip_address = parts[0] + if len(parts) < 7: + continue + + loss_pct = int(parts[1].rstrip("%")) + sent_count = int(parts[2]) + + # Parse RTT values + def parse_rtt(rtt_str: str) -> t.Optional[float]: + if rtt_str in ("timeout", "-", "0ms", "*"): + return None + rtt_clean = re.sub(r"ms$", "", rtt_str) + try: + return float(rtt_clean) + except ValueError: + return None + + last_rtt = parse_rtt(parts[3]) + avg_rtt = parse_rtt(parts[4]) + best_rtt = parse_rtt(parts[5]) + worst_rtt = parse_rtt(parts[6]) + + hop = MikrotikTracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + hostname=None, # MikroTik doesn't do reverse DNS by default + loss_pct=loss_pct, + sent_count=sent_count, + last_rtt=last_rtt, + avg_rtt=avg_rtt, + best_rtt=best_rtt, + worst_rtt=worst_rtt, + ) + + hops.append(hop) + hop_number += 1 + + except (ValueError, IndexError) as e: + _log.debug("Failed to parse traceroute data line", line=line, error=str(e)) + continue + + result = MikrotikTracerouteTable(target=target, source=source, hops=hops) + _log.info("Parsed cleaned traceroute table", hops=len(hops)) + return result + + def traceroute_result(self): + """Convert to TracerouteResult format.""" + from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop + from hyperglass.log import log + + _log = log.bind(parser="MikrotikTracerouteTable") + + converted_hops = [] + for hop in self.hops: + # Handle truncated IP addresses + ip_address = hop.ip_address + display_ip = None + + if hop.ip_address and hop.ip_address.endswith("..."): + # For truncated IPs, store for display but set ip_address to None for validation + display_ip = hop.ip_address + ip_address = None + + created_hop = TracerouteHop( + hop_number=hop.hop_number, + ip_address=ip_address, # None for truncated IPs + display_ip=display_ip, # Truncated IP for display + hostname=hop.hostname, + # Set RTT values to ensure avg_rtt property returns MikroTik's AVG value + # Since avg_rtt = (rtt1 + rtt2 + rtt3) / 3, we set all to the MikroTik AVG + rtt1=hop.avg_rtt, # Set to AVG so computed average is correct + rtt2=hop.avg_rtt, # Set to AVG so computed average is correct + rtt3=hop.avg_rtt, # Set to AVG so computed average is correct + # MikroTik-specific statistics (preserve original values) + loss_pct=hop.loss_pct, + sent_count=hop.sent_count, + last_rtt=hop.last_rtt, # Preserve LAST value + best_rtt=hop.best_rtt, # Preserve BEST value + worst_rtt=hop.worst_rtt, # Preserve WORST value + # BGP enrichment fields will be populated by enrichment plugin + # For truncated IPs, these will remain None/empty + asn=None, + org=None, + prefix=None, + country=None, + rir=None, + allocated=None, + ) + + converted_hops.append(created_hop) + + return TracerouteResult( + target=self.target, + source=self.source, + hops=converted_hops, + max_hops=self.max_hops, + packet_size=self.packet_size, + raw_output=None, # Will be set by the plugin + ) + + +class MikrotikTracerouteHop(MikrotikBase): + """Individual MikroTik traceroute hop.""" + + hop_number: int + ip_address: t.Optional[str] = None + hostname: t.Optional[str] = None + + # MikroTik-specific statistics + loss_pct: t.Optional[int] = None + sent_count: t.Optional[int] = None + last_rtt: t.Optional[float] = None + avg_rtt: t.Optional[float] = None + best_rtt: t.Optional[float] = None + worst_rtt: t.Optional[float] = None + + @property + def is_timeout(self) -> bool: + """Check if this hop is a timeout.""" + return self.ip_address is None or self.loss_pct == 100 diff --git a/hyperglass/models/parsing/traceroute.py b/hyperglass/models/parsing/traceroute.py new file mode 100644 index 000000000..ec001cb5c --- /dev/null +++ b/hyperglass/models/parsing/traceroute.py @@ -0,0 +1,235 @@ +"""Example traceroute parsing module.""" + +# Standard Library +import re +import typing as t + +# Project +from hyperglass.log import log +from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop + +# Local +from ..main import HyperglassModel + + +class TracerouteParser(HyperglassModel): + """Base traceroute parser.""" + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> TracerouteResult: + """Parse traceroute text output into structured data.""" + _log = log.bind(parser="TracerouteParser") + + hops = [] + lines = text.strip().split("\n") + + # Common traceroute pattern: hop number, IP/hostname, RTT values + hop_pattern = re.compile(r"^\s*(\d+)\s+(?:(\S+)\s+\(([^)]+)\)|(\S+))\s+(.+)$") + + # RTT pattern to extract timing values + rtt_pattern = re.compile(r"(\d+(?:\.\d+)?)\s*ms") + + for line in lines: + line = line.strip() + if not line or line.startswith("traceroute"): + continue + + # Handle timeout lines (* * *) + if "*" in line and re.search(r"\d+\s+\*", line): + hop_match = re.match(r"^\s*(\d+)\s+\*", line) + if hop_match: + hop_number = int(hop_match.group(1)) + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=None, + hostname=None, + rtt1=None, + rtt2=None, + rtt3=None, + ) + ) + continue + + # Parse normal hop lines + hop_match = hop_pattern.match(line) + if hop_match: + hop_number = int(hop_match.group(1)) + + # Extract hostname and IP + if hop_match.group(2) and hop_match.group(3): + # Format: hostname (ip) + hostname = hop_match.group(2) + ip_address = hop_match.group(3) + else: + # Format: ip or hostname only + hostname = None + ip_address = hop_match.group(4) + + # Extract RTT values + rtt_text = hop_match.group(5) + rtts = rtt_pattern.findall(rtt_text) + + # Pad with None if less than 3 RTT values + while len(rtts) < 3: + rtts.append(None) + + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + hostname=hostname, + rtt1=float(rtts[0]) if rtts[0] else None, + rtt2=float(rtts[1]) if rtts[1] else None, + rtt3=float(rtts[2]) if rtts[2] else None, + ) + ) + + result = TracerouteResult(target=target, source=source, hops=hops) + + _log.info(f"Parsed {len(hops)} hops from traceroute output") + return result + + +class CiscoTracerouteParser(TracerouteParser): + """Cisco-specific traceroute parser.""" + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> TracerouteResult: + """Parse Cisco traceroute output.""" + _log = log.bind(parser="CiscoTracerouteParser") + + # Cisco traceroute often has format like: + # Type escape sequence to abort. + # Tracing the route to 8.8.8.8 (8.8.8.8) + # 1 192.168.1.1 4 msec 8 msec 4 msec + + lines = text.strip().split("\n") + hops = [] + + for line in lines: + line = line.strip() + if not line or "escape sequence" in line.lower() or "tracing" in line.lower(): + continue + + # Cisco format: hop_number ip_or_hostname rtt1 msec rtt2 msec rtt3 msec + cisco_pattern = re.compile( + r"^\s*(\d+)\s+(\S+)\s+(\d+(?:\.\d+)?)\s*msec\s+(\d+(?:\.\d+)?)\s*msec\s+(\d+(?:\.\d+)?)\s*msec" + ) + + match = cisco_pattern.match(line) + if match: + hop_number = int(match.group(1)) + ip_or_hostname = match.group(2) + rtt1 = float(match.group(3)) + rtt2 = float(match.group(4)) + rtt3 = float(match.group(5)) + + # Try to determine if it's an IP or hostname + import ipaddress + + try: + ipaddress.ip_address(ip_or_hostname) + ip_address = ip_or_hostname + hostname = None + except ValueError: + ip_address = None + hostname = ip_or_hostname + + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + hostname=hostname, + rtt1=rtt1, + rtt2=rtt2, + rtt3=rtt3, + ) + ) + + result = TracerouteResult(target=target, source=source, hops=hops) + + _log.info(f"Parsed {len(hops)} hops from Cisco traceroute output") + return result + + +class JuniperTracerouteParser(TracerouteParser): + """Juniper-specific traceroute parser.""" + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> TracerouteResult: + """Parse Juniper traceroute output.""" + _log = log.bind(parser="JuniperTracerouteParser") + + # Juniper format often like: + # traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets + # 1 192.168.1.1 (192.168.1.1) 1.234 ms 1.456 ms 1.678 ms + + lines = text.strip().split("\n") + hops = [] + + for line in lines: + line = line.strip() + if not line or line.startswith("traceroute to"): + continue + + # Juniper format often has hostname (ip) followed by RTTs + juniper_pattern = re.compile( + r"^\s*(\d+)\s+(?:(\S+)\s+\(([^)]+)\)|(\S+))\s+([\d\.\s\*ms]+)$" + ) + + match = juniper_pattern.match(line) + if match: + hop_number = int(match.group(1)) + + if match.group(2) and match.group(3): + hostname = match.group(2) + ip_address = match.group(3) + else: + hostname = None + ip_address = match.group(4) + + # Extract RTT values + rtt_text = match.group(5) + rtts = re.findall(r"(\d+(?:\.\d+)?)\s*ms", rtt_text) + + # Handle * for timeouts + if "*" in rtt_text: + timeout_count = rtt_text.count("*") + rtts.extend([None] * timeout_count) + + # Ensure we have exactly 3 RTT values + while len(rtts) < 3: + rtts.append(None) + + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + hostname=hostname, + rtt1=float(rtts[0]) if rtts[0] else None, + rtt2=float(rtts[1]) if rtts[1] else None, + rtt3=float(rtts[2]) if rtts[2] else None, + ) + ) + + result = TracerouteResult(target=target, source=source, hops=hops) + + _log.info(f"Parsed {len(hops)} hops from Juniper traceroute output") + return result + + +# Parser mapping by platform +TRACEROUTE_PARSERS = { + "cisco_ios": CiscoTracerouteParser, + "cisco_nxos": CiscoTracerouteParser, + "cisco_xr": CiscoTracerouteParser, + "juniper": JuniperTracerouteParser, + "juniper_junos": JuniperTracerouteParser, + "generic": TracerouteParser, # Fallback +} + + +def get_traceroute_parser(platform: str) -> t.Type[TracerouteParser]: + """Get the appropriate traceroute parser for a platform.""" + return TRACEROUTE_PARSERS.get(platform, TracerouteParser) diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py index 5e87f5b11..0c9970cab 100644 --- a/hyperglass/plugins/_builtin/__init__.py +++ b/hyperglass/plugins/_builtin/__init__.py @@ -5,14 +5,26 @@ from .remove_command import RemoveCommand from .bgp_route_arista import BGPRoutePluginArista from .bgp_route_huawei import BGPRoutePluginHuawei +from .bgp_routestr_huawei import BGPSTRRoutePluginHuawei from .bgp_route_juniper import BGPRoutePluginJuniper from .mikrotik_garbage_output import MikrotikGarbageOutput +from .mikrotik_normalize_input import MikrotikTargetNormalizerInput +from .bgp_routestr_mikrotik import BGPSTRRoutePluginMikrotik +from .trace_route_frr import TraceroutePluginFrr +from .trace_route_huawei import TraceroutePluginHuawei +from .trace_route_mikrotik import TraceroutePluginMikrotik __all__ = ( "BGPRoutePluginArista", "BGPRoutePluginFrr", "BGPRoutePluginJuniper", "BGPRoutePluginHuawei", + "BGPSTRRoutePluginHuawei", "MikrotikGarbageOutput", + "MikrotikTargetNormalizerInput", + "BGPSTRRoutePluginMikrotik", + "TraceroutePluginFrr", + "TraceroutePluginHuawei", + "TraceroutePluginMikrotik", "RemoveCommand", ) diff --git a/hyperglass/plugins/_builtin/bgp_route_huawei.py b/hyperglass/plugins/_builtin/bgp_route_huawei.py index 172d9068f..484dc9cc8 100644 --- a/hyperglass/plugins/_builtin/bgp_route_huawei.py +++ b/hyperglass/plugins/_builtin/bgp_route_huawei.py @@ -21,7 +21,10 @@ class BGPRoutePluginHuawei(InputPlugin): "huawei", "huawei_vrpv8", ) - directives: t.Sequence[str] = ("__hyperglass_huawei_bgp_route__",) + directives: t.Sequence[str] = ( + "__hyperglass_huawei_bgp_route__", + "__hyperglass_huawei_bgp_route_table__", + ) """ Huawei BGP Route Input Plugin @@ -33,15 +36,25 @@ class BGPRoutePluginHuawei(InputPlugin): def transform(self, query: "Query") -> InputPluginTransformReturn: target = query.query_target - if not target or not isinstance(target, list) or len(target) == 0: + if not target: return None - target = target[0].strip() + if isinstance(target, list): + if len(target) == 0: + return None + target = target[0].strip() + elif isinstance(target, str): + target = target.strip() + else: + return None # Check for the / in the query target if target.find("/") == -1: return target - target_network = ip_network(target) - - return f"{target_network.network_address!s} {target_network.prefixlen!s}" + try: + target_network = ip_network(target) + return f"{target_network.network_address!s} {target_network.prefixlen!s}" + except ValueError: + # Return the target unchanged if it isn't a valid network + return target diff --git a/hyperglass/plugins/_builtin/bgp_routestr_huawei.py b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py new file mode 100644 index 000000000..e42308344 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py @@ -0,0 +1,80 @@ +"""Coerce a Huawei route table in text format to a standard BGP Table structure.""" + +# Standard Library +import re +from typing import TYPE_CHECKING, List, Sequence + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +# Local +from .._output import OutputPlugin + +if TYPE_CHECKING: + # Project + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + + # Local + from .._output import OutputType + + +def parse_huawei(output: Sequence[str]) -> "OutputDataModel": + """Parse a Huawei BGP text response.""" + result = None + + _log = log.bind(plugin=BGPSTRRoutePluginHuawei.__name__) + + for response in output: + try: + # Parse the text output using the Huawei parser + validated = HuaweiBGPTable.parse_text(response) + bgp_table = validated.bgp_table() + + if result is None: + result = bgp_table + else: + result += bgp_table + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + except Exception as err: + _log.bind(error=str(err)).critical("Failed to parse Huawei BGP output") + raise ParsingError("Error parsing response data") from err + + return result + + +class BGPSTRRoutePluginHuawei(OutputPlugin): + """Coerce a Huawei route table in text format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: Sequence[str] = ("huawei",) + directives: Sequence[str] = ( + "__hyperglass_huawei_bgp_route_table__", + "__hyperglass_huawei_bgp_aspath_table__", + "__hyperglass_huawei_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse Huawei response if data is a string (and is therefore unparsed).""" + _log = log.bind(plugin=self.__class__.__name__) + _log.debug("Processing Huawei output with structured parser") + + should_process = all( + ( + isinstance(output, (list, tuple)), + query.device.platform in self.platforms, + query.device.structured_output is True, + query.device.has_directives(*self.directives), + ) + ) + if should_process: + return parse_huawei(output) + return output diff --git a/hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py b/hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py new file mode 100644 index 000000000..ad08c56e5 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py @@ -0,0 +1,86 @@ +"""Coerce a MikroTik route table in text format to a standard BGP Table structure.""" + +# Standard Library +from typing import TYPE_CHECKING, Sequence, Union, List + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.mikrotik import MikrotikBGPTable + +# Local +from .._output import OutputPlugin + +if TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + from .._output import OutputType + + +def _normalize_output(output: Union[str, Sequence[str]]) -> List[str]: + """Ensure the output is a list of strings.""" + if isinstance(output, str): + return [output] + return list(output) + + +def parse_mikrotik(output: Union[str, Sequence[str]]) -> "OutputDataModel": + """Parse a MikroTik BGP text response.""" + result = None + out_list = _normalize_output(output) + + _log = log.bind(plugin=BGPSTRRoutePluginMikrotik.__name__) + combined_output = "\n".join(out_list) + _log.debug(f"Combined output length: {len(combined_output)}") + + try: + # Pass the entire combined output to the parser at once + validated = MikrotikBGPTable.parse_text(combined_output) + result = validated.bgp_table() + _log.debug(f"Successfully parsed {len(validated.routes)} routes") + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + except Exception as err: + _log.bind(error=str(err)).critical("Failed to parse MikroTik BGP output") + raise ParsingError("Error parsing response data") from err + + return result + + +class BGPSTRRoutePluginMikrotik(OutputPlugin): + """Coerce a MikroTik route table in text format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + + directives: Sequence[str] = ( + "__hyperglass_mikrotik_bgp_route_table__", + "__hyperglass_mikrotik_bgp_aspath_table__", + "__hyperglass_mikrotik_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse MikroTik response if data is text (and is therefore unparsed).""" + _log = log.bind(plugin=self.__class__.__name__) + _log.debug("Processing MikroTik output with structured parser") + + is_text = isinstance(output, (list, tuple, str)) + has_platform = query.device.platform in self.platforms + wants_structured = bool(getattr(query.device, "structured_output", False)) + has_directives = query.device.has_directives(*self.directives) + + should_process = all((is_text, has_platform, wants_structured, has_directives)) + if not should_process: + _log.debug( + "Skipping structured parser: " + f"is_text={is_text}, platform={query.device.platform!r} (o@k={has_platform}), " + f"structured_output={wants_structured}, has_directives={has_directives}" + ) + return output + + return parse_mikrotik(output) diff --git a/hyperglass/plugins/_builtin/mikrotik_garbage_output.py b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py index 8718604c1..4bb64be24 100644 --- a/hyperglass/plugins/_builtin/mikrotik_garbage_output.py +++ b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py @@ -8,6 +8,7 @@ from pydantic import PrivateAttr # Project +from hyperglass.log import log from hyperglass.types import Series # Local @@ -19,64 +20,246 @@ class MikrotikGarbageOutput(OutputPlugin): - """Parse Mikrotik output to remove garbage.""" + """Parse Mikrotik output to remove garbage before structured parsing.""" _hyperglass_builtin: bool = PrivateAttr(True) - platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos") - directives: t.Sequence[str] = ( - "__hyperglass_mikrotik_bgp_aspath__", - "__hyperglass_mikrotik_bgp_community__", - "__hyperglass_mikrotik_bgp_route__", - "__hyperglass_mikrotik_ping__", - "__hyperglass_mikrotik_traceroute__", - ) + platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + # Only apply to MikroTik platforms, not all devices + common: bool = False - def process(self, *, output: OutputType, query: "Query") -> Series[str]: - """Parse Mikrotik output to remove garbage.""" + def _clean_traceroute_output(self, raw_output: str) -> str: + """Clean MikroTik traceroute output specifically. + + Important: Traceroute hops are sequential - each line represents a unique hop position. + We should NOT deduplicate by IP address as the same IP can appear at different hops. + Order matters for traceroute results. + + However, we can aggregate consecutive timeout lines at the END of the traceroute + to avoid showing 10+ meaningless timeout entries. + """ + if not raw_output or not raw_output.strip(): + return "" + + lines = raw_output.splitlines() + # Remove command echoes and paging, keep only header markers and data lines + # We'll split the output into discrete tables (each table begins at a header) + tables: t.List[t.List[str]] = [] + current_table: t.List[str] = [] + header_line: t.Optional[str] = None + + for line in lines: + stripped = line.strip() + + # Skip empty lines and interactive paging prompts + if ( + not stripped + or "-- [Q quit|C-z pause]" in stripped + or "-- [Q quit|D dump|C-z pause]" in stripped + ): + continue + + # Skip command echo lines + if "tool traceroute" in stripped: + continue + + # If this is a header line, start a new table + if "ADDRESS" in stripped and "LOSS" in stripped and "SENT" in stripped: + header_line = line + # If we were collecting a table, push it + if current_table: + tables.append(current_table) + current_table = [] + # Start collecting after header + continue + + # Collect data lines (will be associated with the most recent header) + if header_line is not None: + current_table.append(line) + + # Push the last collected table if any + if current_table: + tables.append(current_table) - result = () + # If we didn't find any header/data, return cleaned minimal output + if not tables: + # Fallback to previous behavior: remove prompts and flags + filtered_lines: t.List[str] = [] + in_flags_section = False + for line in lines: + stripped_line = line.strip() + if stripped_line.startswith("@") and stripped_line.endswith("] >"): + continue + if "[Q quit|D dump|C-z pause]" in stripped_line: + continue + if stripped_line.startswith("Flags:"): + in_flags_section = True + continue + if in_flags_section: + if "=" in stripped_line: + in_flags_section = False + else: + continue + filtered_lines.append(line) + return "\n".join(filtered_lines) - for each_output in output: - if len(each_output) != 0: - if each_output.split()[-1] in ("DISTANCE", "STATUS"): - # Mikrotik shows the columns with no rows if there is no data. - # Rather than send back an empty table, send back an empty - # response which is handled with a warning message. - each_output = "" + # Aggregate tables by hop index. For each hop position, pick the row with the + # highest SENT count. If SENT ties, prefer non-timeout rows and the later table. + processed_lines: t.List[str] = [] + + # Regex to extract LOSS% and SENT count following it: e.g. '0% 3' + sent_re = re.compile(r"(\d+)%\s+(\d+)\b") + + max_rows = max(len(t) for t in tables) + + for i in range(max_rows): + best_row = None + best_sent = -1 + best_is_timeout = True + best_table_index = -1 + + for ti, table in enumerate(tables): + if i >= len(table): + continue + row = table[i] + m = sent_re.search(row) + if m: + try: + sent = int(m.group(2)) + except Exception: + sent = 0 else: - remove_lines = () - all_lines = each_output.splitlines() - # Starting index for rows (after the column row). - start = 1 - # Extract the column row. - column_line = " ".join(all_lines[0].split()) - - for i, line in enumerate(all_lines[1:]): - # Remove all the newline characters (which differ line to - # line) for comparison purposes. - normalized = " ".join(line.split()) - - # Remove ansii characters that aren't caught by Netmiko. - normalized = re.sub(r"\\x1b\[\S{2}\s", "", normalized) - - if column_line in normalized: - # Mikrotik often re-inserts the column row in the output, - # effectively 'starting over'. In that case, re-assign - # the column row and starting index to that point. - column_line = re.sub(r"\[\S{2}\s", "", line) - start = i + 2 - - if "[Q quit|D dump|C-z pause]" in normalized: - # Remove Mikrotik's unhelpful helpers from the output. - remove_lines += (i + 1,) - - # Combine the column row and the data rows from the starting - # index onward. - lines = [column_line, *all_lines[start:]] - - # Remove any lines marked for removal and re-join with a single - # newline character. - lines = [line for idx, line in enumerate(lines) if idx not in remove_lines] - result += ("\n".join(lines),) - - return result + sent = 0 + + is_timeout = "timeout" in row.lower() or ( + "100%" in row and "timeout" in row.lower() + ) + + # Prefer higher SENT, then prefer non-timeout, then later table (higher ti) + pick = False + if sent > best_sent: + pick = True + elif sent == best_sent: + if best_is_timeout and not is_timeout: + pick = True + elif (best_is_timeout == is_timeout) and ti > best_table_index: + pick = True + + if pick: + best_row = row + best_sent = sent + best_is_timeout = is_timeout + best_table_index = ti + + if best_row is not None: + processed_lines.append(best_row) + + # Collapse excessive trailing timeouts into an aggregation line + trailing_timeouts = 0 + for line in reversed(processed_lines): + if ("timeout" in line.lower()) or ( + sent_re.search(line) and sent_re.search(line).group(1) == "100" + ): + trailing_timeouts += 1 + else: + break + + if trailing_timeouts > 3: + non_trailing = len(processed_lines) - trailing_timeouts + # Keep first 2 of trailing timeouts and aggregate the rest + aggregated = ( + processed_lines[:non_trailing] + processed_lines[non_trailing : non_trailing + 2] + ) + remaining = trailing_timeouts - 2 + aggregated.append( + f" ... ({remaining} more timeout hops)" + ) + processed_lines = aggregated + + # Prepend header line if we have one + header_to_use = ( + header_line + or "ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS" + ) + cleaned = [header_to_use] + processed_lines + return "\n".join(cleaned) + + def process(self, *, output: OutputType, query: "Query") -> Series[str]: + """ + Clean raw output from a MikroTik device. + This plugin removes command echoes, prompts, flag legends, and interactive help text. + """ + + # If output is already processed/structured (not raw strings), pass it through unchanged + if not isinstance(output, (tuple, list)): + return output + + # Check if the tuple/list contains non-string objects (structured data) + if output and not isinstance(output[0], str): + return output + + cleaned_outputs = [] + + for raw_output in output: + # Handle non-string outputs (already processed by other plugins) - double check + if not isinstance(raw_output, str): + cleaned_outputs.append(raw_output) + continue + + # Nothing to do if the output is already empty. + if not raw_output or not raw_output.strip(): + cleaned_outputs.append("") + continue + + # Check if this is traceroute output and handle it specially + if ( + "tool traceroute" in raw_output + or ("ADDRESS" in raw_output and "LOSS" in raw_output and "SENT" in raw_output) + or "-- [Q quit|C-z pause]" in raw_output + ): + cleaned_output = self._clean_traceroute_output(raw_output) + cleaned_outputs.append(cleaned_output) + continue + + # Original logic for other outputs (BGP routes, etc.) + lines = raw_output.splitlines() + filtered_lines = [] + in_flags_section = False + + for line in lines: + stripped_line = line.strip() + + # Skip prompts and command echoes + if stripped_line.startswith("@") and stripped_line.endswith("] >"): + continue + + # Skip the interactive help line + if "[Q quit|D dump|C-z pause]" in stripped_line: + continue + + # Begin detecting the Flags section + if stripped_line.startswith("Flags:"): + in_flags_section = True + continue # Skip the "Flags:" line itself + + # Within the flags section, check whether the line is still part of it + if in_flags_section: + if "=" in stripped_line: + in_flags_section = False + else: + continue # Skip the flag-legend lines + + filtered_lines.append(line) + + # Join the cleaned lines back into a single string + cleaned_output = "\n".join(filtered_lines) + cleaned_outputs.append(cleaned_output) + + # Minimal debug logging: log number of cleaned blocks and if any aggregation occurred + if len(output) > 0: + log.debug(f"MikrotikGarbageOutput processed {len(output)} output blocks.") + # If any aggregation line was added, log that event + for cleaned in cleaned_outputs: + if "... (" in cleaned: + log.debug("Aggregated excessive trailing timeout hops in traceroute output.") + break + return tuple(cleaned_outputs) diff --git a/hyperglass/plugins/_builtin/mikrotik_normalize_input.py b/hyperglass/plugins/_builtin/mikrotik_normalize_input.py new file mode 100644 index 000000000..d7bf556e0 --- /dev/null +++ b/hyperglass/plugins/_builtin/mikrotik_normalize_input.py @@ -0,0 +1,58 @@ +# Standard Library +from ipaddress import ip_network, ip_address +import typing as t + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.log import log + +# REMOVIDA a importação direta de Query para evitar o erro circular +# from hyperglass.models.api.query import Query + +# Local +from .._input import InputPlugin, InputPluginValidationReturn + + +class MikrotikTargetNormalizerInput(InputPlugin): + """ + InputPlugin to normalize a target IP address to its network prefix. + This ensures that queries for different IPs within the same subnet + resolve to the same cache key. + """ + + _hyperglass_builtin: bool = PrivateAttr(False) + name: str = "mikrotik_normalizer" + platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + + def validate(self, query: t.Any) -> InputPluginValidationReturn: + """Take the query object and modify the target if it's a BGP Route query.""" + + # Acessamos os atributos normalmente, pois sabemos que o objeto 'query' os terá. + if query.query_type != "bgp_route": + return True, None + + try: + target_ip = ip_address(query.target) + + if target_ip.version == 4: + prefix_len = 24 + else: + prefix_len = 48 + + network = ip_network(f"{str(target_ip)}/{prefix_len}", strict=False) + + normalized_target = str(network.with_prefixlen) + + if query.target != normalized_target: + log.debug( + f"Normalizing target '{query.target}' to network prefix " + f"'{normalized_target}' for MikroTik cache key." + ) + query.target = normalized_target + + except ValueError: + pass + + return True, None diff --git a/hyperglass/plugins/_builtin/trace_route_frr.py b/hyperglass/plugins/_builtin/trace_route_frr.py new file mode 100644 index 000000000..6d17dd100 --- /dev/null +++ b/hyperglass/plugins/_builtin/trace_route_frr.py @@ -0,0 +1,345 @@ +"""Parse FRR traceroute output to structured data.""" + +# Standard Library +import re +import typing as t +from ipaddress import ip_address as validate_ip + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop +from hyperglass.state import use_state + +# Local +from .._output import OutputPlugin + +if t.TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + from .._output import OutputType + + +def _normalize_output(output: t.Union[str, t.Sequence[str]]) -> t.List[str]: + """Ensure the output is a list of strings.""" + if isinstance(output, str): + return [output] + return list(output) + + +def parse_frr_traceroute( + output: t.Union[str, t.Sequence[str]], target: str, source: str +) -> "OutputDataModel": + """Parse an FRR traceroute text response.""" + result = None + out_list = _normalize_output(output) + + _log = log.bind(plugin=TraceroutePluginFrr.__name__) + combined_output = "\n".join(out_list) + + # DEBUG: Log the raw output we're about to parse + _log.debug(f"=== FRR TRACEROUTE PLUGIN RAW INPUT ===") + _log.debug(f"Target: {target}, Source: {source}") + _log.debug(f"Output pieces: {len(out_list)}") + _log.debug(f"Combined output length: {len(combined_output)}") + _log.debug(f"First 500 chars: {repr(combined_output[:500])}") + _log.debug(f"=== END PLUGIN RAW INPUT ===") + + try: + result = FrrTracerouteTable.parse_text(combined_output, target, source) + except Exception as exc: + _log.error(f"Failed to parse FRR traceroute: {exc}") + raise ParsingError(f"Failed to parse FRR traceroute output: {exc}") from exc + + _log.debug(f"=== FINAL STRUCTURED TRACEROUTE RESULT ===") + _log.debug(f"Successfully parsed {len(result.hops)} traceroute hops") + _log.debug(f"Target: {target}, Source: {source}") + for hop in result.hops: + _log.debug(f"Hop {hop.hop_number}: {hop.ip_address or '*'} - RTT: {hop.rtt1 or 'timeout'}") + _log.debug(f"Raw output length: {len(combined_output)} characters") + _log.debug(f"=== END STRUCTURED RESULT ===") + + return result + + +class FrrTracerouteTable(TracerouteResult): + """FRR traceroute table parser.""" + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> TracerouteResult: + """Parse FRR traceroute text output into structured data.""" + _log = log.bind(parser="FrrTracerouteTable") + + _log.debug(f"=== RAW FRR TRACEROUTE INPUT ===") + _log.debug(f"Target: {target}, Source: {source}") + _log.debug(f"Raw text length: {len(text)} characters") + _log.debug(f"Raw text:\n{repr(text)}") + _log.debug(f"=== END RAW INPUT ===") + + hops = [] + + # Parse the traceroute output line by line + lines = text.strip().split("\n") + _log.debug(f"Split into {len(lines)} lines") + + # Patterns for different line formats + # Pattern for regular hop: " 1 203.0.113.1 0.221 ms" + simple_pattern = re.compile(r"^\s*(\d+)\s+([\d\.:a-fA-F]+)\s+([\d.]+)\s+ms") + + # Pattern for timeout hop: " 9 *" + timeout_pattern = re.compile(r"^\s*(\d+)\s+\*\s*$") + + # Pattern for hop with hostname: " 2 hostname.example.com (192.168.1.1) 15.234 ms" + hostname_pattern = re.compile(r"^\s*(\d+)\s+(\S+)\s+\(([\d\.:a-fA-F]+)\)\s+([\d.]+)\s+ms") + + # Pattern for multiple RTTs: " 3 192.168.1.1 15.234 ms 16.123 ms 14.567 ms" + multi_rtt_pattern = re.compile( + r"^\s*(\d+)\s+([\d\.:a-fA-F]+)\s+([\d.]+)\s+ms(?:\s+([\d.]+)\s+ms)?(?:\s+([\d.]+)\s+ms)?" + ) + + # Pattern for partial timeout: " 7 port-channel4.core4.mrs1.he.net (184.105.81.30) 132.624 ms 132.589 ms *" + partial_timeout_pattern = re.compile( + r"^\s*(\d+)\s+(\S+)\s+\(([\d\.:a-fA-F]+)\)\s+(?:([\d.]+)\s+ms\s+)?(?:([\d.]+)\s+ms\s+)?\*" + ) + + for i, line in enumerate(lines): + line = line.strip() + if not line: + continue + + _log.debug(f"Line {i:2}: {repr(line)}") + + # Skip header lines + if "traceroute to" in line.lower() or "hops max" in line.lower(): + _log.debug(f"Line {i:2}: SKIPPING HEADER") + continue + + hop_number = None + ip_address = None + hostname = None + rtt1 = None + rtt2 = None + rtt3 = None + + # Try to match timeout hop first + timeout_match = timeout_pattern.match(line) + if timeout_match: + hop_number = int(timeout_match.group(1)) + _log.debug(f"Line {i:2}: TIMEOUT HOP - {hop_number}") + + # Try to match partial timeout + elif partial_timeout_pattern.match(line): + partial_match = partial_timeout_pattern.match(line) + hop_number = int(partial_match.group(1)) + hostname = partial_match.group(2) + ip_address = partial_match.group(3) + rtt1 = float(partial_match.group(4)) if partial_match.group(4) else None + rtt2 = float(partial_match.group(5)) if partial_match.group(5) else None + _log.debug( + f"Line {i:2}: PARTIAL TIMEOUT HOP - {hop_number}: {hostname} ({ip_address}) {rtt1} {rtt2} *" + ) + + # Try to match hostname pattern + elif hostname_pattern.match(line): + hostname_match = hostname_pattern.match(line) + hop_number = int(hostname_match.group(1)) + hostname = hostname_match.group(2) + ip_address = hostname_match.group(3) + rtt1 = float(hostname_match.group(4)) + _log.debug( + f"Line {i:2}: HOSTNAME HOP - {hop_number}: {hostname} ({ip_address}) {rtt1} ms" + ) + + # Try to match multiple RTT pattern + elif multi_rtt_pattern.match(line): + multi_match = multi_rtt_pattern.match(line) + hop_number = int(multi_match.group(1)) + ip_address = multi_match.group(2) + rtt1 = float(multi_match.group(3)) + rtt2 = float(multi_match.group(4)) if multi_match.group(4) else None + rtt3 = float(multi_match.group(5)) if multi_match.group(5) else None + _log.debug( + f"Line {i:2}: MULTI RTT HOP - {hop_number}: {ip_address} {rtt1} {rtt2} {rtt3}" + ) + + # Try to match simple pattern + elif simple_pattern.match(line): + simple_match = simple_pattern.match(line) + hop_number = int(simple_match.group(1)) + ip_address = simple_match.group(2) + rtt1 = float(simple_match.group(3)) + _log.debug(f"Line {i:2}: SIMPLE IP HOP - {hop_number}: {ip_address} {rtt1} ms") + + else: + _log.debug(f"Line {i:2}: NO MATCH - skipping") + continue + + # Create hop object if we have valid data + if hop_number is not None: + try: + # Validate IP address if present + if ip_address: + validate_ip(ip_address) + except ValueError: + # If IP validation fails, treat as hostname + if not hostname: + hostname = ip_address + ip_address = None + + hop = TracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + display_ip=None, + hostname=hostname, + rtt1=rtt1, + rtt2=rtt2, + rtt3=rtt3, + sent_count=None, + last_rtt=rtt1, + best_rtt=( + min(filter(None, [rtt1, rtt2, rtt3])) if any([rtt1, rtt2, rtt3]) else None + ), + worst_rtt=( + max(filter(None, [rtt1, rtt2, rtt3])) if any([rtt1, rtt2, rtt3]) else None + ), + loss_pct=None, + # BGP enrichment fields (will be populated by enrichment plugin) + asn=None, + org=None, + prefix=None, + country=None, + rir=None, + allocated=None, + ) + hops.append(hop) + + # Clean up hops - remove duplicates and sort by hop number + _log.debug(f"Before cleanup: {len(hops)} hops") + + # Group hops by hop number and merge data + hop_dict = {} + for hop in hops: + if hop.hop_number in hop_dict: + # Merge data for same hop number + existing = hop_dict[hop.hop_number] + # Keep the first non-None value for each field + hop_dict[hop.hop_number] = TracerouteHop( + hop_number=hop.hop_number, + ip_address=existing.ip_address or hop.ip_address, + display_ip=existing.display_ip or hop.display_ip, + hostname=existing.hostname or hop.hostname, + rtt1=existing.rtt1 or hop.rtt1, + rtt2=existing.rtt2 or hop.rtt2, + rtt3=existing.rtt3 or hop.rtt3, + sent_count=existing.sent_count or hop.sent_count, + last_rtt=existing.last_rtt or hop.last_rtt, + best_rtt=existing.best_rtt or hop.best_rtt, + worst_rtt=existing.worst_rtt or hop.worst_rtt, + loss_pct=existing.loss_pct or hop.loss_pct, + asn=existing.asn or hop.asn, + org=existing.org or hop.org, + prefix=existing.prefix or hop.prefix, + country=existing.country or hop.country, + rir=existing.rir or hop.rir, + allocated=existing.allocated or hop.allocated, + ) + else: + hop_dict[hop.hop_number] = hop + + # Convert back to sorted list + final_hops = [hop_dict[hop_num] for hop_num in sorted(hop_dict.keys())] + _log.debug(f"After cleanup: {len(final_hops)} hops") + + # Debug final hop list + for hop in final_hops: + hostname_display = hop.hostname or "no-hostname" + _log.debug( + f"Final hop {hop.hop_number}: {hop.ip_address} ({hostname_display}) - RTTs: {hop.rtt1}/{hop.rtt2}/{hop.rtt3}" + ) + + _log.info(f"Parsed {len(final_hops)} hops from FRR traceroute") + + # Extract packet information from traceroute output + max_hops = 30 # Default + packet_size = 60 # FRR default + + # Try to extract from header line + for line in lines: + if "hops max" in line.lower(): + try: + # Look for pattern like "30 hops max, 60 byte packets" + match = re.search(r"(\d+)\s+hops\s+max.*?(\d+)\s+byte", line) + if match: + max_hops = int(match.group(1)) + packet_size = int(match.group(2)) + break + except (ValueError, AttributeError): + pass + + return TracerouteResult( + target=target, + source=source, + hops=final_hops, + max_hops=max_hops, + packet_size=packet_size, + raw_output=text, + asn_organizations={}, + ) + + +class TraceroutePluginFrr(OutputPlugin): + """Parse FRR traceroute output.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ("frr",) + directives: t.Sequence[str] = ("__hyperglass_frr_traceroute__",) + common: bool = False + + def process(self, output: "OutputType", query: "Query") -> "OutputType": + """Process FRR traceroute output.""" + # Extract target and source with fallbacks + target = str(query.query_target) if query.query_target else "unknown" + source = "unknown" + + if hasattr(query, "device") and query.device: + source = getattr(query.device, "display_name", None) or getattr( + query.device, "name", "unknown" + ) + + # Logging + _log = log.bind(plugin="TraceroutePluginFrr") + _log.info(f"Processing Traceroute for {target} from {source}") + + device = getattr(query, "device", None) + if device is not None: + if not getattr(device, "structured_output", False): + return output + try: + _params = use_state("params") + except Exception: + _params = None + if ( + _params + and getattr(_params, "structured", None) + and getattr(_params.structured, "enable_for_traceroute", None) is False + ): + return output + else: + try: + params = use_state("params") + except Exception: + params = None + if not (params and getattr(params, "structured", None)): + return output + if getattr(params.structured, "enable_for_traceroute", None) is False: + return output + + # Parse traceroute output + return parse_frr_traceroute( + output=output, + target=target, + source=source, + ) diff --git a/hyperglass/plugins/_builtin/trace_route_huawei.py b/hyperglass/plugins/_builtin/trace_route_huawei.py new file mode 100644 index 000000000..549e4eb04 --- /dev/null +++ b/hyperglass/plugins/_builtin/trace_route_huawei.py @@ -0,0 +1,278 @@ +"""Parse Huawei traceroute output to structured data.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop +from hyperglass.state import use_state + +# Local +from .._output import OutputPlugin + +if t.TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + from .._output import OutputType + + +def _normalize_output(output: t.Union[str, t.Sequence[str]]) -> t.List[str]: + """Ensure the output is a list of strings.""" + if isinstance(output, str): + return [output] + return list(output) + + +def parse_huawei_traceroute( + output: t.Union[str, t.Sequence[str]], target: str, source: str +) -> "OutputDataModel": + """Parse a Huawei traceroute text response.""" + result = None + out_list = _normalize_output(output) + + _log = log.bind(plugin=TraceroutePluginHuawei.__name__) + combined_output = "\n".join(out_list) + + # DEBUG: Log the raw output we're about to parse + _log.debug(f"=== HUAWEI TRACEROUTE PLUGIN RAW INPUT ===") + _log.debug(f"Target: {target}, Source: {source}") + _log.debug(f"Output pieces: {len(out_list)}") + _log.debug(f"Combined output length: {len(combined_output)}") + _log.debug(f"First 500 chars: {repr(combined_output[:500])}") + _log.debug(f"=== END PLUGIN RAW INPUT ===") + + try: + result = HuaweiTracerouteTable.parse_text(combined_output, target, source) + except Exception as exc: + _log.error(f"Failed to parse Huawei traceroute: {exc}") + raise ParsingError(f"Failed to parse Huawei traceroute output: {exc}") from exc + + _log.debug(f"=== FINAL STRUCTURED TRACEROUTE RESULT ===") + _log.debug(f"Successfully parsed {len(result.hops)} traceroute hops") + _log.debug(f"Target: {target}, Source: {source}") + for hop in result.hops: + _log.debug(f"Hop {hop.hop_number}: {hop.ip_address or '*'} - RTT: {hop.rtt1 or 'timeout'}") + _log.debug(f"Raw output length: {len(combined_output)} characters") + _log.debug(f"=== END STRUCTURED RESULT ===") + + return result + + +class HuaweiTracerouteTable(TracerouteResult): + """Huawei traceroute table parser.""" + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> TracerouteResult: + """Parse Huawei traceroute text output into structured data.""" + _log = log.bind(parser="HuaweiTracerouteTable") + + _log.debug(f"=== RAW HUAWEI TRACEROUTE INPUT ===") + _log.debug(f"Target: {target}, Source: {source}") + _log.debug(f"Raw text length: {len(text)} characters") + _log.debug(f"Raw text:\n{repr(text)}") + _log.debug(f"=== END RAW INPUT ===") + + hops = [] + lines = text.strip().split("\n") + + _log.debug(f"Split into {len(lines)} lines") + + # Pattern for normal hop: "1 172.24.165.197 1 ms" + hop_pattern = re.compile(r"^\s*(\d+)\s+(\S+)\s+(\d+(?:\.\d+)?)\s*ms\s*$") + + # Pattern for timeout hop: "3 *" + timeout_pattern = re.compile(r"^\s*(\d+)\s+\*\s*$") + + for i, line in enumerate(lines): + line = line.strip() + _log.debug(f"Line {i:2d}: {repr(line)}") + + if not line: + continue + + # Skip header lines + if ( + "traceroute to" in line.lower() + or "max hops" in line.lower() + or "press CTRL_C" in line.lower() + ): + _log.debug(f"Line {i:2d}: SKIPPING HEADER") + continue + + # Try to match normal hop + hop_match = hop_pattern.match(line) + if hop_match: + hop_number = int(hop_match.group(1)) + ip_address = hop_match.group(2) + rtt = float(hop_match.group(3)) + + _log.debug(f"Line {i:2d}: NORMAL HOP - {hop_number}: {ip_address} {rtt}ms") + + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + display_ip=None, # Huawei doesn't truncate IPs like MikroTik + hostname=None, # Will be populated by IP enrichment + rtt1=rtt, + rtt2=None, # Huawei shows only one RTT per line + rtt3=None, + # MikroTik-specific statistics (populate for consistency) + sent_count=1, # Huawei sends 1 ping per hop + last_rtt=rtt, # Same as the only RTT + best_rtt=rtt, # Same as the only RTT + worst_rtt=rtt, # Same as the only RTT + loss_pct=0, # No loss if we got a response + # BGP enrichment fields (will be populated by enrichment plugin) + asn=None, + org=None, + prefix=None, + country=None, + rir=None, + allocated=None, + ) + ) + continue + + # Try to match timeout hop + timeout_match = timeout_pattern.match(line) + if timeout_match: + hop_number = int(timeout_match.group(1)) + + _log.debug(f"Line {i:2d}: TIMEOUT HOP - {hop_number}: *") + + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=None, + display_ip=None, + hostname=None, + rtt1=None, + rtt2=None, + rtt3=None, + # MikroTik-specific statistics for timeout + sent_count=1, # Still sent 1 ping, just timed out + last_rtt=None, + best_rtt=None, + worst_rtt=None, + loss_pct=100, # 100% loss for timeout + # BGP enrichment fields (all None for timeout) + asn=None, + org=None, + prefix=None, + country=None, + rir=None, + allocated=None, + ) + ) + continue + + _log.debug(f"Line {i:2d}: UNMATCHED - skipping") + + _log.debug(f"Before cleanup: {len(hops)} hops") + + # Clean up consecutive timeout hops at the end + # Keep only the first few timeouts, remove excessive trailing timeouts + if len(hops) > 5: + # Find the last non-timeout hop + last_real_hop = -1 + for i in range(len(hops) - 1, -1, -1): + if not hops[i].is_timeout: + last_real_hop = i + break + + if last_real_hop >= 0: + # Keep at most 3 timeout hops after the last real hop + max_timeouts = 3 + timeout_count = 0 + cleaned_hops = hops[: last_real_hop + 1] # Keep all hops up to last real hop + + for hop in hops[last_real_hop + 1 :]: + if hop.is_timeout: + timeout_count += 1 + if timeout_count <= max_timeouts: + cleaned_hops.append(hop) + else: + _log.debug(f"Removing excessive timeout hop {hop.hop_number}") + else: + # If we find another real hop after timeouts, keep it + cleaned_hops.append(hop) + timeout_count = 0 + + hops = cleaned_hops + + _log.debug(f"After cleanup: {len(hops)} hops") + + for hop in hops: + if hop.is_timeout: + _log.debug(f"Final hop {hop.hop_number}: * (timeout)") + else: + _log.debug(f"Final hop {hop.hop_number}: {hop.ip_address} - {hop.rtt1}ms") + + _log.info(f"Parsed {len(hops)} hops from Huawei traceroute") + + return TracerouteResult( + target=target, + source=source, + hops=hops, + max_hops=64, # Default for Huawei + packet_size=40, # From the header in sample output + raw_output=text, + asn_organizations={}, + ) + + +class TraceroutePluginHuawei(OutputPlugin): + """Parse Huawei traceroute output.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ("huawei", "huawei_vrpv8") + directives: t.Sequence[str] = ("__hyperglass_huawei_traceroute__",) + common: bool = False + + def process(self, output: "OutputType", query: "Query") -> "OutputType": + """Process Huawei traceroute output.""" + # Extract target and source with fallbacks + target = str(query.query_target) if query.query_target else "unknown" + source = "unknown" + + if hasattr(query, "device") and query.device: + source = getattr(query.device, "display_name", None) or getattr( + query.device, "name", "unknown" + ) + + device = getattr(query, "device", None) + if device is not None: + if not getattr(device, "structured_output", False): + return output + try: + _params = use_state("params") + except Exception: + _params = None + if ( + _params + and getattr(_params, "structured", None) + and getattr(_params.structured, "enable_for_traceroute", None) is False + ): + return output + else: + try: + params = use_state("params") + except Exception: + params = None + if not (params and getattr(params, "structured", None)): + return output + if getattr(params.structured, "enable_for_traceroute", None) is False: + return output + + return parse_huawei_traceroute( + output=output, + target=target, + source=source, + ) diff --git a/hyperglass/plugins/_builtin/trace_route_mikrotik.py b/hyperglass/plugins/_builtin/trace_route_mikrotik.py new file mode 100644 index 000000000..7df6c184a --- /dev/null +++ b/hyperglass/plugins/_builtin/trace_route_mikrotik.py @@ -0,0 +1,165 @@ +"""Parse MikroTik traceroute output to structured data.""" + +# Standard Library +import typing as t + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log, log as _log +from hyperglass.settings import Settings +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.mikrotik import MikrotikTracerouteTable +from hyperglass.state import use_state + +# Local +from .._output import OutputPlugin + +if t.TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + from .._output import OutputType + + +def _normalize_output(output: t.Union[str, t.Sequence[str]]) -> t.List[str]: + """Ensure the output is a list of strings.""" + if isinstance(output, str): + return [output] + return list(output) + + +def _clean_traceroute_only( + output: t.Union[str, t.Sequence[str]], query: "Query" +) -> t.Union[str, t.Tuple[str, ...]]: + """Clean traceroute output using MikrotikGarbageOutput plugin.""" + from .mikrotik_garbage_output import MikrotikGarbageOutput + + out_list = _normalize_output(output) + cleaner = MikrotikGarbageOutput() + + cleaned_list: t.List[str] = [] + for piece in out_list: + try: + cleaned_piece = cleaner._clean_traceroute_output(piece) + except Exception: + cleaned_piece = piece + cleaned_list.append(cleaned_piece) + + if isinstance(output, str): + return cleaned_list[0] if cleaned_list else "" + return tuple(cleaned_list) + + +def parse_mikrotik_traceroute( + output: t.Union[str, t.Sequence[str]], target: str, source: str +) -> "OutputDataModel": + """Parse a cleaned MikroTik traceroute text response.""" + out_list = _normalize_output(output) + _log = log.bind(plugin=TraceroutePluginMikrotik.__name__) + combined_output = "\n".join(out_list) + + if Settings.debug: + _log.debug( + "Parsing cleaned traceroute input", + target=target, + source=source, + pieces=len(out_list), + combined_len=len(combined_output), + ) + + try: + validated = MikrotikTracerouteTable.parse_text(combined_output, target, source) + result = validated.traceroute_result() + result.raw_output = combined_output + + if Settings.debug: + _log.debug( + "Parsed traceroute result", + hops=len(validated.hops), + target=result.target, + source=result.source, + ) + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + except Exception as err: + _log.bind(error=str(err)).critical("Failed to parse MikroTik traceroute output") + raise ParsingError("Error parsing traceroute response data") from err + + return result + + +class TraceroutePluginMikrotik(OutputPlugin): + """Convert MikroTik traceroute output to structured format.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + directives: t.Sequence[str] = ("__hyperglass_mikrotik_traceroute__",) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputDataModel": + """Process the MikroTik traceroute output.""" + target = getattr(query, "target", "unknown") + source = getattr(query, "source", "unknown") + + if hasattr(query, "query_target") and query.query_target: + target = str(query.query_target) + + if hasattr(query, "device") and query.device: + source = getattr(query.device, "name", source) + + _log = log.bind(plugin=TraceroutePluginMikrotik.__name__) + + # Log raw router output only when debug is enabled + if Settings.debug: + try: + if isinstance(output, (tuple, list)): + try: + combined_raw = "\n".join(output) + except Exception: + combined_raw = repr(output) + else: + combined_raw = output if isinstance(output, str) else repr(output) + + _log.debug("Router raw output:\n{}", combined_raw) + except Exception: + _log.exception("Failed to log router raw output") + + try: + params = use_state("params") + except Exception: + params = None + + device = getattr(query, "device", None) + + # Check if structured output is enabled + if device is None: + if Settings.debug: + _log.debug("No device found, using cleanup-only mode") + return _clean_traceroute_only(output, query) + + if params is None: + if Settings.debug: + _log.debug("No params found, using cleanup-only mode") + return _clean_traceroute_only(output, query) + + if not getattr(params, "structured", None): + if Settings.debug: + _log.debug("Structured output not configured, using cleanup-only mode") + return _clean_traceroute_only(output, query) + + if getattr(params.structured, "enable_for_traceroute", None) is False: + if Settings.debug: + _log.debug("Structured output disabled for traceroute, using cleanup-only mode") + return _clean_traceroute_only(output, query) + + if Settings.debug: + _log.debug("Processing traceroute with structured output enabled") + + # Clean the output first using garbage cleaner before parsing + cleaned_output = _clean_traceroute_only(output, query) + if Settings.debug: + _log.debug("Applied garbage cleaning before structured parsing") + + return parse_mikrotik_traceroute(cleaned_output, target, source) diff --git a/hyperglass/plugins/tests/test_bgp_route_huawei.py b/hyperglass/plugins/tests/test_bgp_route_huawei.py new file mode 100644 index 000000000..0485fad7c --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_huawei.py @@ -0,0 +1,75 @@ +"""Huawei VRP structured BGP route parsing tests.""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.config.params import Params +from hyperglass.models.data.bgp_route import BGPRouteTable +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +if t.TYPE_CHECKING: + from hyperglass.state import HyperglassState + +# Representative `display bgp routing-table ` detail output: one best/ +# selected (active) route and one valid-but-not-best route. +SAMPLE = """ + BGP local router ID : 192.0.2.1 + Local AS number : 64500 + Paths: 2 available, 1 best, 1 select + + BGP routing table entry information of 203.0.113.0/24: + From: 198.51.100.1 (192.0.2.254) + Route Duration: 0d00h05m12s + Original nexthop: 198.51.100.1 + Community: 64500:100, 64500:200 + AS-path 64500 64501, origin igp, MED 0, localpref 100, pref-val 0, valid, external, best, select, pre 255 + + BGP routing table entry information of 203.0.113.128/25: + From: 198.51.100.2 (192.0.2.253) + Route Duration: 0d00h01m00s + Original nexthop: 198.51.100.2 + AS-path 64500 64999, origin incomplete, MED 10, localpref 90, pref-val 0, valid, external, pre 255 +""" + + +@pytest.fixture +def params() -> t.Dict[str, t.Any]: + return {} + + +@pytest.fixture +def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: + """Initialize Redis-backed state with params for BGPRoute validation.""" + _state = use_state() + _params = Params(**params) + with _state.cache.pipeline() as pipeline: + pipeline.set("params", _params) + yield _state + _state.clear() + + +def test_huawei_bgp_route(state): + table = HuaweiBGPTable.parse_text(SAMPLE).bgp_table() + + assert isinstance(table, BGPRouteTable), "Parsed result is not a BGPRouteTable" + assert table.count == 2, f"Expected 2 routes, got {table.count}" + + by_prefix = {r.prefix: r for r in table.routes} + assert set(by_prefix) == {"203.0.113.0/24", "203.0.113.128/25"} + + best = by_prefix["203.0.113.0/24"] + assert best.active is True, "best+select route should be active" + assert best.as_path == [64500, 64501], f"Bad as_path: {best.as_path}" + assert best.source_as == 64500, "source_as should be first ASN in path" + assert best.next_hop == "198.51.100.1" + assert "64500:100" in best.communities + + other = by_prefix["203.0.113.128/25"] + assert other.active is False, "non-best route should not be active" + assert other.as_path == [64500, 64999] diff --git a/hyperglass/plugins/tests/test_bgp_route_mikrotik.py b/hyperglass/plugins/tests/test_bgp_route_mikrotik.py new file mode 100644 index 000000000..d24518e17 --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_mikrotik.py @@ -0,0 +1,78 @@ +"""MikroTik structured BGP route parsing tests. + +The sample below is a trimmed, real `routing route print detail` capture from a +RouterOS 7.20.6 device (CCR2216), reduced to one active and one filtered route. +Long community lists are kept on a single line, matching what the device emits +to the looking-glass SSH driver (no terminal-width wrapping). +""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.config.params import Params +from hyperglass.models.data.bgp_route import BGPRouteTable +from hyperglass.models.parsing.mikrotik import MikrotikBGPTable + +if t.TYPE_CHECKING: + from hyperglass.state import HyperglassState + +SAMPLE = """Flags: X - disabled, F - filtered, U - unreachable, A - active; +c - connect, s - static, r - rip, b - bgp, n - bgp-net, o - ospf, i - isis, d - dhcp, v - vpn, m - modem, a - ldp-address, l - ldp-mapping, g - slaac, y - bgp-mpls-vpn, e - evpn; +H - hw-offloaded; + - ecmp, B - blackhole + Ab afi=ip contribution=active dst-address=1.0.0.0/24 routing-table=main pref-src=192.0.2.1 gateway=196.60.8.198 + immediate-gw=196.60.8.198%[sfp28-01] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-196.60.8.198" + rpki=valid bgp.session=NAP-JHB-CloudFlare-v4-1 .as-path="13335" .communities=13335:10045 .large-communities=328964:2000:0,328964:2001:0 .local-pref=250 .med=0 .origin=igp + + Fb afi=ip contribution=filtered dst-address=5.101.88.0/24 routing-table=main pref-src=192.0.2.1 gateway=192.0.2.2 + immediate-gw=192.0.2.2%[sfp28-10] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-192.0.2.2" + rpki=invalid bgp.session=TRANSIT-ANGOLA-v4-1 .as-path="37468,41095,51601,50113,207569" .communities=37468:14100,37468:2000 .med=0 .origin=incomplete +""" + + +@pytest.fixture +def params() -> t.Dict[str, t.Any]: + return {} + + +@pytest.fixture +def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: + """Initialize Redis-backed state with params for BGPRoute validation.""" + _state = use_state() + _params = Params(**params) + with _state.cache.pipeline() as pipeline: + pipeline.set("params", _params) + yield _state + _state.clear() + + +def test_mikrotik_bgp_route(state): + table = MikrotikBGPTable.parse_text(SAMPLE).bgp_table() + + assert isinstance(table, BGPRouteTable), "Parsed result is not a BGPRouteTable" + assert table.count == 2, f"Expected 2 routes, got {table.count}" + + by_prefix = {r.prefix: r for r in table.routes} + assert set(by_prefix) == {"1.0.0.0/24", "5.101.88.0/24"} + + # Active route (flag "A"): RPKI valid (state 1), single-ASN path. + active = by_prefix["1.0.0.0/24"] + assert active.active is True + assert active.filtered is False + assert active.as_path == [13335] + assert active.next_hop == "196.60.8.198" + assert "13335:10045" in active.communities + assert active.rpki_state == 1 # valid + + # Filtered route (flag "F" => filtered per the RouterOS flags legend): RPKI + # invalid (state 0), multi-ASN path. + filtered = by_prefix["5.101.88.0/24"] + assert filtered.active is False + assert filtered.filtered is True, "F-flagged route should be marked filtered" + assert filtered.as_path == [37468, 41095, 51601, 50113, 207569] + assert filtered.rpki_state == 0 # invalid diff --git a/hyperglass/plugins/tests/test_traceroute_frr.py b/hyperglass/plugins/tests/test_traceroute_frr.py new file mode 100644 index 000000000..0eea6beb8 --- /dev/null +++ b/hyperglass/plugins/tests/test_traceroute_frr.py @@ -0,0 +1,46 @@ +"""FRR structured traceroute parsing tests.""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.models.data.traceroute import TracerouteResult +from hyperglass.plugins._builtin.trace_route_frr import FrrTracerouteTable + +# Standard Linux `traceroute` output as emitted by FRR hosts: a plain hop, a +# hop with hostname + IP, a full timeout, and a multi-probe hop. +SAMPLE = """traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets + 1 198.51.100.1 0.221 ms + 2 router.example.net (203.0.113.1) 15.234 ms + 3 * + 4 8.8.8.8 20.001 ms 19.502 ms 21.110 ms +""" + + +def test_frr_traceroute(): + result = FrrTracerouteTable.parse_text(SAMPLE, target="8.8.8.8", source="192.0.2.1") + + assert isinstance(result, TracerouteResult) + assert result.target == "8.8.8.8" + assert len(result.hops) == 4, f"Expected 4 hops, got {len(result.hops)}" + + by_hop = {h.hop_number: h for h in result.hops} + assert by_hop[1].ip_address == "198.51.100.1" + assert by_hop[1].rtt1 == pytest.approx(0.221) + + # Hop 2 carries a resolved hostname alongside the IP. + assert by_hop[2].ip_address == "203.0.113.1" + assert by_hop[2].hostname == "router.example.net" + + # Hop 3 is a full timeout (no IP, no RTTs). + assert by_hop[3].ip_address is None + assert by_hop[3].rtt1 is None + + # Hop 4 reports three probes. + assert by_hop[4].ip_address == "8.8.8.8" + assert by_hop[4].rtt1 == pytest.approx(20.001) + assert by_hop[4].rtt3 == pytest.approx(21.110) diff --git a/hyperglass/plugins/tests/test_traceroute_huawei.py b/hyperglass/plugins/tests/test_traceroute_huawei.py new file mode 100644 index 000000000..af6de9394 --- /dev/null +++ b/hyperglass/plugins/tests/test_traceroute_huawei.py @@ -0,0 +1,40 @@ +"""Huawei VRP structured traceroute parsing tests.""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.models.data.traceroute import TracerouteResult +from hyperglass.plugins._builtin.trace_route_huawei import HuaweiTracerouteTable + +# Huawei `tracert -q 1` output: one RTT per hop, a timeout hop, then the target. +SAMPLE = """ traceroute to 8.8.8.8, max hops 30 ,press CTRL_C to break + 1 198.51.100.1 1 ms + 2 203.0.113.1 5 ms + 3 * + 4 8.8.8.8 21 ms +""" + + +def test_huawei_traceroute(): + result = HuaweiTracerouteTable.parse_text(SAMPLE, target="8.8.8.8", source="192.0.2.1") + + assert isinstance(result, TracerouteResult) + assert result.target == "8.8.8.8" + assert len(result.hops) == 4, f"Expected 4 hops, got {len(result.hops)}" + + by_hop = {h.hop_number: h for h in result.hops} + assert by_hop[1].ip_address == "198.51.100.1" + assert by_hop[1].rtt1 == pytest.approx(1.0) + assert by_hop[2].ip_address == "203.0.113.1" + + # Hop 3 is a timeout (no IP / no RTT). + assert by_hop[3].ip_address is None + assert by_hop[3].rtt1 is None + + assert by_hop[4].ip_address == "8.8.8.8" + assert by_hop[4].rtt1 == pytest.approx(21.0) diff --git a/hyperglass/plugins/tests/test_traceroute_mikrotik.py b/hyperglass/plugins/tests/test_traceroute_mikrotik.py new file mode 100644 index 000000000..2fa792cc9 --- /dev/null +++ b/hyperglass/plugins/tests/test_traceroute_mikrotik.py @@ -0,0 +1,153 @@ +"""MikroTik structured traceroute parsing tests. + +MikroTik's `/tool traceroute` redraws its table in place; the netmiko-based +looking-glass driver captures the final accumulated table (one row per hop, +SENT = total probes, with AVG/BEST/WORST filled in). + +Real RouterOS devices emit two distinct table layouts, both of which must parse: + +1. No index column, with a trailing STATUS column: + ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS + 198.51.100.7 0% 3 0.2ms 0.2 0.1 0.2 0 + +2. Leading hop-index column ("#") and no STATUS column: + # ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV + 0 192.0.2.1 0% 3 15.8ms 15.8 15.8 15.9 0 + +The index column previously shifted every field and dropped all hops; the +parser now detects and strips it. The fixtures below preserve the exact shape +of real captures (hop counts, timeout rows, both layouts), but all addresses +are RFC 5737 documentation ranges (192.0.2.0/24, 198.51.100.0/24, +203.0.113.0/24) rather than real network data. + +The parser only populates fields it can read from the table (IP, loss, sent, +RTTs). ASN/IXP/org enrichment is applied later by a separate plugin, so these +parser tests assert only parser-produced fields. +""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.models.data.traceroute import TracerouteResult +from hyperglass.plugins._builtin.trace_route_mikrotik import parse_mikrotik_traceroute + +# --------------------------------------------------------------------------- +# Fixtures: (raw table, target, source, expected hops) +# Each expected hop is (ip_address, loss_pct, best_rtt, worst_rtt). +# Addresses are RFC 5737 documentation ranges (anonymized). +# --------------------------------------------------------------------------- + +# Indexed layout ("#" column, no STATUS), including a timeout hop. +INDEXED_5HOP = ( + """ # ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV + 0 192.0.2.1 0% 3 15.8ms 15.8 15.8 15.9 0 + 1 100% 3 timeout + 2 198.51.100.7 0% 3 17.4ms 17.4 17.4 17.5 0 + 3 198.51.100.8 0% 3 16.4ms 16.4 16.4 16.4 0 + 4 203.0.113.8 0% 3 16.3ms 16.3 16.3 16.3 0 +""", + "203.0.113.8", + "router-a", + [ + ("192.0.2.1", 0, 15.8, 15.9), + (None, 100, None, None), + ("198.51.100.7", 0, 17.4, 17.5), + ("198.51.100.8", 0, 16.4, 16.4), + ("203.0.113.8", 0, 16.3, 16.3), + ], +) + +# Indexed layout, short 2-hop trace. +INDEXED_2HOP = ( + """ # ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV + 0 192.0.2.20 0% 3 4.4ms 7.6 0.7 17.7 7.3 + 1 203.0.113.1 0% 3 0.8ms 0.8 0.8 0.8 0 +""", + "203.0.113.1", + "router-a", + [ + ("192.0.2.20", 0, 0.7, 17.7), + ("203.0.113.1", 0, 0.8, 0.8), + ], +) + +# Non-indexed layout with a trailing STATUS column. +STATUS_COL_4HOP = ( + """ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS +192.0.2.10 0% 3 0.2ms 0.2 0.1 0.2 0 +198.51.100.7 0% 3 1.6ms 1.5 1.4 1.6 0.1 +198.51.100.8 0% 3 0.5ms 0.5 0.5 0.5 0 +203.0.113.8 0% 3 1ms 1 1 1 0 +""", + "203.0.113.8", + "router-b", + [ + ("192.0.2.10", 0, 0.1, 0.2), + ("198.51.100.7", 0, 1.4, 1.6), + ("198.51.100.8", 0, 0.5, 0.5), + ("203.0.113.8", 0, 1.0, 1.0), + ], +) + +STATUS_COL_5HOP = ( + """ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS +192.0.2.1 0% 3 0.7ms 0.7 0.7 0.7 0 +192.0.2.10 0% 3 0.9ms 0.9 0.8 0.9 0 +198.51.100.9 0% 3 2.5ms 2.5 2.4 2.6 0.1 +198.51.100.10 0% 3 2.3ms 2.2 2.1 2.3 0.1 +203.0.113.8 0% 3 1.3ms 1.3 1.3 1.3 0 +""", + "203.0.113.8", + "router-c", + [ + ("192.0.2.1", 0, 0.7, 0.7), + ("192.0.2.10", 0, 0.8, 0.9), + ("198.51.100.9", 0, 2.4, 2.6), + ("198.51.100.10", 0, 2.1, 2.3), + ("203.0.113.8", 0, 1.3, 1.3), + ], +) + +CASES = { + "indexed-5hop": INDEXED_5HOP, + "indexed-2hop": INDEXED_2HOP, + "status-col-4hop": STATUS_COL_4HOP, + "status-col-5hop": STATUS_COL_5HOP, +} + + +@pytest.mark.parametrize("case", CASES.values(), ids=list(CASES.keys())) +def test_mikrotik_traceroute(case: t.Tuple) -> None: + raw, target, source, expected = case + result = parse_mikrotik_traceroute(raw, target=target, source=source) + + assert isinstance(result, TracerouteResult), "Expected a TracerouteResult" + assert len(result.hops) == len(expected), ( + f"Expected {len(expected)} hops, got {len(result.hops)}" + ) + + for i, (hop, (ip, loss, best, worst)) in enumerate(zip(result.hops, expected), start=1): + assert hop.hop_number == i, f"Hop {i}: wrong hop_number {hop.hop_number}" + assert hop.ip_address == ip, f"Hop {i}: expected ip {ip!r}, got {hop.ip_address!r}" + assert hop.loss_pct == loss, f"Hop {i}: expected loss {loss}, got {hop.loss_pct}" + # BEST/WORST are preserved verbatim (AVG is a computed float and not + # safe for exact comparison). + assert hop.best_rtt == best, f"Hop {i}: expected best {best}, got {hop.best_rtt}" + assert hop.worst_rtt == worst, f"Hop {i}: expected worst {worst}, got {hop.worst_rtt}" + + # The final hop is always the queried target. + assert result.hops[-1].ip_address == target + + +def test_mikrotik_traceroute_timeout_hop() -> None: + """A 100%-loss (timeout) row yields a hop with no IP/RTT but loss recorded.""" + result = parse_mikrotik_traceroute(*INDEXED_5HOP[:3]) + timeout = result.hops[1] + assert timeout.ip_address is None + assert timeout.loss_pct == 100 + assert timeout.last_rtt is None and timeout.avg_rtt is None diff --git a/hyperglass/ui/components/looking-glass-form.tsx b/hyperglass/ui/components/looking-glass-form.tsx index 2da46338a..776bfdd79 100644 --- a/hyperglass/ui/components/looking-glass-form.tsx +++ b/hyperglass/ui/components/looking-glass-form.tsx @@ -87,7 +87,7 @@ export const LookingGlassForm = (): JSX.Element => { return tmp; }, [form.queryType, form.queryLocation, getDirective]); - function submitHandler(): void { + async function submitHandler(): Promise { if (process.env.NODE_ENV === 'development') { console.table({ 'Query Location': form.queryLocation.toString(), @@ -97,6 +97,11 @@ export const LookingGlassForm = (): JSX.Element => { }); } + // Note: IP enrichment refresh is now handled server-side on query + // submission when enabled. Removing client-side best-effort refresh + // to centralize refresh logic and avoid redundant requests from many + // clients. + // Before submitting a query, make sure the greeting is acknowledged if required. This should // be handled before loading the app, but people be sneaky. if (!greetingReady) { diff --git a/hyperglass/ui/components/output/cell.tsx b/hyperglass/ui/components/output/cell.tsx index 6bf12b706..79073c85d 100644 --- a/hyperglass/ui/components/output/cell.tsx +++ b/hyperglass/ui/components/output/cell.tsx @@ -1,4 +1,4 @@ -import { MonoField, Active, Weight, Age, Communities, RPKIState, ASPath } from './fields'; +import { MonoField, Active, Weight, Age, Communities, RPKIState, ASPath, HideableField, NextHop } from './fields'; import type { CellRenderProps } from '~/types'; @@ -14,14 +14,27 @@ export const Cell = (props: CellProps): JSX.Element => { med: , age: , prefix: , - next_hop: , + next_hop: ( + + ), peer_rid: , source_as: , active: , - source_rid: , + source_rid: , local_preference: , communities: , - as_path: , + as_path: ( + + ), rpki_state: , weight: , }; diff --git a/hyperglass/ui/components/output/fields.tsx b/hyperglass/ui/components/output/fields.tsx index a24857a17..23c686667 100644 --- a/hyperglass/ui/components/output/fields.tsx +++ b/hyperglass/ui/components/output/fields.tsx @@ -30,6 +30,15 @@ interface WeightProps extends TextProps { interface ASPathProps { path: number[]; active: boolean; + // Optional ASN -> org map populated when enrichment is enabled + asnOrgs?: Record; +} + +interface NextHopProps extends TextProps { + ip: string; + asn?: string | null; + org?: string | null; + country?: string | null; } interface CommunitiesProps { @@ -50,6 +59,16 @@ dayjs.extend(utcPlugin); export const MonoField = (props: MonoFieldProps): JSX.Element => { const { v, ...rest } = props; + + // Handle empty or undefined values, but not zero values + if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) { + return ( + + N/A + + ); + } + return ( {v} @@ -74,6 +93,12 @@ export const Active = (props: ActiveProps): JSX.Element => { export const Age = (props: AgeProps): JSX.Element => { const { inSeconds, ...rest } = props; + + // Handle case where age is not available (e.g., MikroTik) - hide the field entirely + if (inSeconds === -1) { + return <>; + } + const now = dayjs.utc(); const then = now.subtract(inSeconds, 'second'); return ( @@ -99,7 +124,7 @@ export const Weight = (props: WeightProps): JSX.Element => { }; export const ASPath = (props: ASPathProps): JSX.Element => { - const { path, active } = props; + const { path, active, asnOrgs = {} } = props; const color = useColorValue( // light: inactive, active ['blackAlpha.500', 'blackAlpha.500'], @@ -121,6 +146,8 @@ export const ASPath = (props: ASPathProps): JSX.Element => { path.map((asn, i) => { const asnStr = String(asn); + const orgName = asnOrgs?.[asnStr]?.name; + const tooltipLabel = orgName ? `${asnStr} - ${orgName}` : `AS${asnStr}`; i !== 0 && paths.push( { ); paths.push( // biome-ignore lint/suspicious/noArrayIndexKey: index makes sense in this case. - - {asnStr} - , + + + {asnStr} + + , ); }); return {paths}; }; +export const NextHop = (props: NextHopProps): JSX.Element => { + const { ip, asn, org, country, ...rest } = props; + + // Build tooltip label with ASN - ORG format (strip AS prefix from asn to match AS path format) + const hasEnrichment = asn || org; + let tooltipLabel = ''; + + if (hasEnrichment) { + const parts = []; + if (asn) { + // Remove "AS" prefix if present to match AS path tooltip format + const asnNumber = asn.startsWith('AS') ? asn.slice(2) : asn; + parts.push(asnNumber); + } + if (org) parts.push(org); + tooltipLabel = parts.join(' - '); + } + + if (!hasEnrichment) { + // No enrichment data, just show the IP + return ( + + {ip} + + ); + } + + // Show tooltip with ASN - ORG format + return ( + + + {ip} + + + ); +}; + export const Communities = (props: CommunitiesProps): JSX.Element => { const { communities } = props; const { web } = useConfig(); const bg = useColorValue('white', 'gray.900'); const color = useOpposingColor(bg); + + // Parse communities to separate code and name if present + const parsedCommunities = communities.map(community => { + if (community.includes(',')) { + const [code, name] = community.split(',', 2); + return { code, name, display: `${code} - ${name}` }; + } + return { code: community, name: null, display: community }; + }); + return ( @@ -175,7 +257,9 @@ export const Communities = (props: CommunitiesProps): JSX.Element => { fontWeight="normal" whiteSpace="pre-wrap" > - {communities.join('\n')} + {parsedCommunities.map(({ display }, index) => ( + {display} + ))} @@ -231,3 +315,18 @@ const _RPKIState: React.ForwardRefRenderFunction }; export const RPKIState = forwardRef(_RPKIState); + +export const HideableField = (props: MonoFieldProps): JSX.Element => { + const { v, ...rest } = props; + + // Hide the field entirely if value is empty or undefined + if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) { + return <>; + } + + return ( + + {v} + + ); +}; diff --git a/hyperglass/ui/components/output/index.ts b/hyperglass/ui/components/output/index.ts index 6e28bab53..b7220bcf8 100644 --- a/hyperglass/ui/components/output/index.ts +++ b/hyperglass/ui/components/output/index.ts @@ -1,2 +1,3 @@ export * from './bgp-table'; export * from './text-output'; +export * from './traceroute-table'; diff --git a/hyperglass/ui/components/output/traceroute-cell.tsx b/hyperglass/ui/components/output/traceroute-cell.tsx new file mode 100644 index 000000000..7c1356027 --- /dev/null +++ b/hyperglass/ui/components/output/traceroute-cell.tsx @@ -0,0 +1,51 @@ +import { MonoField, ASNField, LatencyField, LossField, HostnameField } from './traceroute-fields'; + +import type { TracerouteCellRenderProps } from '~/types'; + +interface TracerouteCellProps { + data: TracerouteCellRenderProps; + rawData: TracerouteResult; +} + +export const TracerouteCell = (props: TracerouteCellProps): JSX.Element => { + const { data, rawData } = props; + const cellId = data.column.id as keyof TracerouteHop; + + // For IP address field, prefer display_ip if available (for truncated IPv6) + const getIPValue = () => { + if (cellId === 'ip_address') { + const hop = data.row?.original as TracerouteHop | undefined; + if (hop && hop.display_ip) { + return hop.display_ip; + } + if (hop && hop.ip_address) { + return hop.ip_address; + } + } + return data.value; + }; + + const component = { + hop_number: , + ip_address: , + display_ip: , // For truncated IPv6 display + hostname: , + loss_pct: , + sent_count: , + last_rtt: , + avg_rtt: , + best_rtt: , + worst_rtt: , + asn: , + org: null, // Hidden, displayed as part of ASN + prefix: , + country: , + rir: , + allocated: , + rtt1: null, // Not displayed directly in table + rtt2: null, // Not displayed directly in table + rtt3: null, // Not displayed directly in table + }; + + return component[cellId] ?? ; +}; \ No newline at end of file diff --git a/hyperglass/ui/components/output/traceroute-fields.tsx b/hyperglass/ui/components/output/traceroute-fields.tsx new file mode 100644 index 000000000..491e971d3 --- /dev/null +++ b/hyperglass/ui/components/output/traceroute-fields.tsx @@ -0,0 +1,188 @@ +import { Text, Tooltip, Badge, Link } from '@chakra-ui/react'; +import { Else, If, Then } from 'react-if'; +import { useColorValue } from '~/hooks'; + +import type { TextProps } from '@chakra-ui/react'; + +interface MonoFieldProps extends TextProps { + v: React.ReactNode; +} + +interface ASNFieldProps extends TextProps { + asn: string | null; + org: string | null; +} + +interface HostnameFieldProps extends TextProps { + hostname: string | null; +} + +interface LatencyFieldProps extends TextProps { + rtt: number | null; +} + +interface LossFieldProps extends TextProps { + loss: number | null; +} + +export const MonoField = (props: MonoFieldProps): JSX.Element => { + const { v, ...rest } = props; + + // Handle empty, null, undefined values and timeout indicators + if (v === null || v === undefined || (typeof v === 'string' && (v.trim() === '' || v === 'None'))) { + return ( + + — + + ); + } + + return ( + + {v} + + ); +}; + +export const ASNField = (props: ASNFieldProps): JSX.Element => { + const { asn, org, ...rest } = props; + + if (!asn || asn === 'None' || asn === 'null') { + return ( + + — + + ); + } + + // Display ASN. If this hop is an IXP (asn === 'IXP') and we have the + // IXP name in `org`, show the IXP name instead of the literal "IXP" so + // the visualiser renders a friendly label. Keep the tooltip labeled as + // "IXP - " for clarity. + let asnDisplay = asn; // default: "12345" or "IXP" + // For table display we want IXPs to appear as the literal "IXP". + if (asn === 'IXP') { + asnDisplay = 'IXP'; + } + const tooltipLabel = org && org !== 'None' + ? (asn === 'IXP' ? `IXP - ${org}` : `${asnDisplay} - ${org}`) + : asnDisplay; + + const isNumericAsn = /^\d+$/.test(asnDisplay); + const content = isNumericAsn ? ( + + {asnDisplay} + + ) : ( + + {asnDisplay} + + ); + + return ( + + {content} + + ); +}; + +export const HostnameField = (props: HostnameFieldProps): JSX.Element => { + const { hostname, ...rest } = props; + + if (!hostname || hostname === 'None' || hostname === 'null') { + return ( + + — + + ); + } + + return ( + + + {hostname} + + + ); +}; + +export const LatencyField = (props: LatencyFieldProps): JSX.Element => { + const { rtt, ...rest } = props; + + if (rtt === null || rtt === undefined) { + return ( + + * + + ); + } + + // Color-code latency: green < 50ms, yellow < 200ms, red >= 200ms + const getLatencyColor = (latency: number) => { + if (latency < 50) return 'green.500'; + if (latency < 200) return 'yellow.500'; + return 'red.500'; + }; + + return ( + + {rtt.toFixed(1)}ms + + ); +}; + +export const LossField = (props: LossFieldProps): JSX.Element => { + const { loss, ...rest } = props; + + if (loss === null || loss === undefined) { + return ( + + — + + ); + } + + // Color-code loss: green = 0%, yellow < 50%, red >= 50% + const getLossColor = (lossPercent: number) => { + if (lossPercent === 0) return 'green.500'; + if (lossPercent < 50) return 'yellow.500'; + return 'red.500'; + }; + + const bgColor = useColorValue( + loss === 0 ? 'green.50' : loss < 50 ? 'yellow.50' : 'red.50', + loss === 0 ? 'green.900' : loss < 50 ? 'yellow.900' : 'red.900' + ); + + return ( + + {loss}% + + ); +}; \ No newline at end of file diff --git a/hyperglass/ui/components/output/traceroute-table.tsx b/hyperglass/ui/components/output/traceroute-table.tsx new file mode 100644 index 000000000..90a94648f --- /dev/null +++ b/hyperglass/ui/components/output/traceroute-table.tsx @@ -0,0 +1,38 @@ +import { Flex } from '@chakra-ui/react'; +import { Table } from '~/components'; +import { TracerouteCell } from './traceroute-cell'; + +import type { FlexProps } from '@chakra-ui/react'; +import type { TracerouteTableColumn, TracerouteCellRenderProps } from '~/types'; + +type TracerouteTableProps = Swap; + +// Column definition for the traceroute table using BGP table structure +// Format: "Hop | IP | HostName (reverse dns) | ASN | Loss | Sent | Last | AVG | BEST | Worst" +const tracerouteColumns: TracerouteTableColumn[] = [ + { Header: 'Hop', accessor: 'hop_number', align: 'center', hidden: false }, + { Header: 'IP Address', accessor: 'ip_address', align: 'left', hidden: false }, + { Header: 'Hostname', accessor: 'hostname', align: 'left', hidden: false }, + { Header: 'ASN', accessor: 'asn', align: 'center', hidden: false }, + { Header: 'Loss', accessor: 'loss_pct', align: 'center', hidden: false }, + { Header: 'Sent', accessor: 'sent_count', align: 'center', hidden: false }, + { Header: 'Last', accessor: 'last_rtt', align: 'right', hidden: false }, + { Header: 'AVG', accessor: 'avg_rtt', align: 'right', hidden: false }, + { Header: 'Best', accessor: 'best_rtt', align: 'right', hidden: false }, + { Header: 'Worst', accessor: 'worst_rtt', align: 'right', hidden: false }, +]; + +export const TracerouteTable = (props: TracerouteTableProps): JSX.Element => { + const { children: data, ...rest } = props; + + return ( + + + columns={tracerouteColumns as any} + bordersHorizontal + data={data.hops} + Cell={(d: TracerouteCellRenderProps) => } + /> + + ); +}; \ No newline at end of file diff --git a/hyperglass/ui/components/path/chart.tsx b/hyperglass/ui/components/path/chart.tsx index e5e95f3fc..a839f18cd 100644 --- a/hyperglass/ui/components/path/chart.tsx +++ b/hyperglass/ui/components/path/chart.tsx @@ -1,4 +1,4 @@ -import { Badge, Box, Flex, SkeletonText, VStack } from '@chakra-ui/react'; +import { Badge, Box, Flex, VStack } from '@chakra-ui/react'; import { useMemo } from 'react'; import ReactFlow, { Background, @@ -9,14 +9,14 @@ import ReactFlow, { isEdge, } from 'reactflow'; import { useConfig } from '~/context'; -import { useASNDetail, useColorToken, useColorValue } from '~/hooks'; +import { useColorToken, useColorValue } from '~/hooks'; import { Controls } from './controls'; import { useElements } from './use-elements'; import type { NodeProps as ReactFlowNodeProps } from 'reactflow'; interface ChartProps { - data: StructuredResponse; + data: AllStructuredResponses; } interface NodeProps extends Omit { @@ -70,23 +70,13 @@ const ASNode = (props: NodeProps): JSX.Element => { const color = useColorValue('black', 'white'); const bg = useColorValue('white', 'whiteAlpha.200'); - const { data: asnData, isError, isLoading } = useASNDetail(String(asn)); - return ( <> {hasChildren && } - {isLoading ? ( - - - - ) : !isError && asnData?.data?.asn.organization?.orgName ? ( - asnData.data.asn.organization.orgName - ) : ( - name - )} + {name} {asn} diff --git a/hyperglass/ui/components/path/path.tsx b/hyperglass/ui/components/path/path.tsx index 0fd3d7425..0bd249481 100644 --- a/hyperglass/ui/components/path/path.tsx +++ b/hyperglass/ui/components/path/path.tsx @@ -23,12 +23,55 @@ export const Path = (props: PathProps): JSX.Element => { const getResponse = useFormState(s => s.response); const { isOpen, onClose, onOpen } = useDisclosure(); const response = getResponse(device); - const output = response?.output as StructuredResponse; + const output = response?.output as AllStructuredResponses; const bg = useColorValue('light.50', 'dark.900'); const centered = useBreakpointValue({ base: false, lg: true }) ?? true; + const addResponse = useFormState(s => s.addResponse); return ( <> - + { + // When opening the AS path modal, attempt on-demand ASN enrichment + // if the response does not already contain ASN organization data. + try { + onOpen(); + if (!response) return; + const out = response.output as any; + const asnOrgs = out?.asn_organizations || {}; + if (Object.keys(asnOrgs).length > 0) return; + + // Collect unique ASNs from the output depending on type + let asns: string[] = []; + if (out?.routes) { + const all = out.routes.flatMap((r: any) => r.as_path || []); + asns = Array.from(new Set(all.map((a: any) => String(a)))); + } else if (out?.hops) { + const all = out.hops.map((h: any) => h.asn).filter(Boolean); + asns = Array.from(new Set(all.map((a: any) => String(a)))); + } + + if (asns.length === 0) return; + + const resp = await fetch('/api/aspath/enrich', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ as_path: asns }), + }); + if (!resp.ok) return; + const j = await resp.json(); + if (j?.success && j.asn_organizations) { + // Merge ASN orgs into the stored response and update state + out.asn_organizations = { ...(out.asn_organizations || {}), ...j.asn_organizations }; + addResponse(device, { ...response, output: out }); + } + } catch (e) { + // Ignore enrichment failures + // eslint-disable-next-line no-console + console.debug('AS path enrichment failed', e); + onOpen(); + } + }} + /> = Node | Edge; const NODE_WIDTH = 128; const NODE_HEIGHT = 48; -export function useElements(base: BasePath, data: StructuredResponse): FlowElement[] { +export function useElements(base: BasePath, data: AllStructuredResponses): FlowElement[] { return useMemo(() => { return [...buildElements(base, data)]; }, [base, data]); } +/** + * Check if data contains BGP routes + */ +function isBGPData(data: AllStructuredResponses): data is BGPStructuredOutput { + return 'routes' in data && Array.isArray(data.routes); +} + +/** + * Check if data contains traceroute hops + */ +function isTracerouteData(data: AllStructuredResponses): data is TracerouteStructuredOutput { + return 'hops' in data && Array.isArray(data.hops); +} + /** * Calculate the positions for each AS Path. * @see https://github.com/MrBlenny/react-flow-chart/issues/61 */ function* buildElements( base: BasePath, - data: StructuredResponse, + data: AllStructuredResponses, ): Generator> { - const { routes } = data; - // Eliminate empty AS paths & deduplicate non-empty AS paths. Length should be same as count minus empty paths. - const asPaths = routes.filter(r => r.as_path.length !== 0).map(r => [...new Set(r.as_path)]); + let asPaths: string[][] = []; + let asnOrgs: Record = {}; + // For traceroute data we may have IXPs represented as asn === 'IXP' with + // the IXP name stored per-hop in hop.org. Collect per-path org arrays so + // nodes for IXPs can show the proper IXP name instead of the generic + // "IXP" label. + const pathGroupOrgs: Record> = {}; + + if (isBGPData(data)) { + // Handle BGP routes with AS paths + const { routes } = data; + asPaths = routes + .filter(r => r.as_path.length !== 0) + .map(r => { + const uniqueAsns = [...new Set(r.as_path.map(asn => String(asn)))]; + // Remove the base ASN if it's the first hop to avoid duplication + return uniqueAsns[0] === base.asn ? uniqueAsns.slice(1) : uniqueAsns; + }) + .filter(path => path.length > 0); // Remove empty paths + + // Get ASN organization mapping if available + asnOrgs = (data as any).asn_organizations || {}; + + // Debug: Log BGP ASN organization data + if (Object.keys(asnOrgs).length > 0) { + console.debug('BGP ASN organizations loaded:', asnOrgs); + } else { + console.warn('BGP ASN organizations not found or empty'); + } + } else if (isTracerouteData(data)) { + // Handle traceroute hops - build AS path from hop ASNs + const hopAsns: string[] = []; + const hopOrgs: Array = []; + let currentAsn = ''; + + for (const hop of data.hops) { + if (hop.asn && hop.asn !== 'None' && hop.asn !== currentAsn) { + currentAsn = hop.asn; + hopAsns.push(hop.asn); + hopOrgs.push(hop.org ?? undefined); + } + } + + if (hopAsns.length > 0) { + // Remove the base ASN if it's the first hop to avoid duplication + const removeBase = hopAsns[0] === base.asn; + const filteredAsns = removeBase ? hopAsns.slice(1) : hopAsns; + const filteredOrgs = removeBase ? hopOrgs.slice(1) : hopOrgs; + if (filteredAsns.length > 0) { + asPaths = [filteredAsns]; + pathGroupOrgs[0] = filteredOrgs; + } + } + + // Get ASN organization mapping if available + asnOrgs = (data as any).asn_organizations || {}; + + // Debug: Log traceroute ASN organization data + if (Object.keys(asnOrgs).length > 0) { + console.debug('Traceroute ASN organizations loaded:', asnOrgs); + } else { + console.warn('Traceroute ASN organizations not found or empty'); + } + } + + if (asPaths.length === 0) { + return; + } const totalPaths = asPaths.length - 1; @@ -95,7 +174,12 @@ function* buildElements( id: base.asn, type: 'ASNode', position: { x, y }, - data: { asn: base.asn, name: base.name, hasChildren: true, hasParents: false }, + data: { + asn: base.asn, + name: asnOrgs[base.asn]?.name || base.name, + hasChildren: true, + hasParents: false + }, }; for (const [groupIdx, pathGroup] of asPaths.entries()) { @@ -108,13 +192,25 @@ function* buildElements( const y = g.node(node).y - NODE_HEIGHT * (idx * 6); // Get each ASN's positions. + // Determine display name for this node. Prefer ASN org mapping, but + // for traceroute IXPs prefer the per-hop IXP name if present. + let nodeName = asnOrgs[asn]?.name || (asn === '0' ? 'Private/Unknown' : `AS${asn}`); + if (asn === 'IXP') { + const ixpName = pathGroupOrgs[groupIdx]?.[idx]; + if (ixpName && ixpName !== 'None') { + nodeName = ixpName; + } else { + nodeName = 'IXP'; + } + } + yield { id: node, type: 'ASNode', position: { x, y }, data: { asn: `${asn}`, - name: `AS${asn}`, + name: nodeName, hasChildren: idx < endIdx, hasParents: true, }, diff --git a/hyperglass/ui/components/query-type.tsx b/hyperglass/ui/components/query-type.tsx index 7b9ac73b9..a323644f7 100644 --- a/hyperglass/ui/components/query-type.tsx +++ b/hyperglass/ui/components/query-type.tsx @@ -127,21 +127,23 @@ const MenuList = (props: MenuListProps): JSX.Element = } return ( - - handleClick('') })}> - None - - {filtered.groups.map(value => { - return ( - handleClick(value) })} - > - {value} - - ); - })} - + {filtered.groups.length > 0 && ( + + handleClick('') })}> + None + + {filtered.groups.map(value => { + return ( + handleClick(value) })} + > + {value} + + ); + })} + + )} {children} ); diff --git a/hyperglass/ui/components/results/individual.tsx b/hyperglass/ui/components/results/individual.tsx index a316dd33c..d6591ae72 100644 --- a/hyperglass/ui/components/results/individual.tsx +++ b/hyperglass/ui/components/results/individual.tsx @@ -16,7 +16,7 @@ import startCase from 'lodash/startCase'; import { forwardRef, memo, useEffect, useMemo, useState } from 'react'; import isEqual from 'react-fast-compare'; import { Else, If, Then } from 'react-if'; -import { BGPTable, Path, TextOutput } from '~/components'; +import { BGPTable, TracerouteTable, Path, TextOutput } from '~/components'; import { useConfig } from '~/context'; import { Countdown, DynamicIcon } from '~/elements'; import { @@ -28,7 +28,7 @@ import { useStrf, useTableToString, } from '~/hooks'; -import { isStringOutput, isStructuredOutput } from '~/types'; +import { isStringOutput, isStructuredOutput, isBGPStructuredOutput, isTracerouteStructuredOutput } from '~/types'; import { CopyButton } from './copy-button'; import { FormattedError } from './formatted-error'; import { isFetchError, isLGError, isLGOutputOrError, isStackError } from './guards'; @@ -87,7 +87,7 @@ const _Result: React.ForwardRefRenderFunction = ( _setErrorLevel(e); }; - const { data, error, isLoading, refetch, isFetchedAfterMount } = useLGQuery( + const { data, error, isLoading, isFetching, refetch, isFetchedAfterMount } = useLGQuery( { queryLocation, queryTarget: form.queryTarget, queryType: form.queryType }, { onSuccess(data) { @@ -108,6 +108,7 @@ const _Result: React.ForwardRefRenderFunction = ( }, ); + const isQuerying = isLoading || isFetching; const isError = useMemo(() => isLGOutputOrError(data), [data, error]); const isCached = useMemo(() => data?.cached || !isFetchedAfterMount, [data, isFetchedAfterMount]); @@ -153,9 +154,15 @@ const _Result: React.ForwardRefRenderFunction = ( let copyValue = data?.output as string; + // Always create formatData hook for both BGP and Traceroute outputs const formatData = useTableToString(form.queryTarget, data, [data?.format]); + const isBGPData = isBGPStructuredOutput(data); + const isTracerouteData = isTracerouteStructuredOutput(data); - if (data?.format === 'application/json') { + if (data?.format === 'application/json' && isBGPData) { + copyValue = formatData(); + } else if (data?.format === 'application/json' && isTracerouteData) { + // For structured traceroute, use formatted table output for copy functionality copyValue = formatData(); } @@ -166,13 +173,13 @@ const _Result: React.ForwardRefRenderFunction = ( // Signal to the group that this result is done loading. useEffect(() => { // Only set the index if it's not already set and the query is finished loading. - if (Array.isArray(indices) && indices.length === 0 && !isLoading) { + if (Array.isArray(indices) && indices.length === 0 && !isQuerying) { // Only set the index if the response has data or an error. if (data || isError) { setIndex([index]); } } - }, [data, index, indices, isLoading, isError, setIndex]); + }, [data, index, indices, isQuerying, isError, setIndex]); if (device === null) { const id = `toast-queryLocation-${index}-${queryLocation}`; @@ -192,7 +199,7 @@ const _Result: React.ForwardRefRenderFunction = ( = ( = ( {isStructuredOutput(data) && data.level === 'success' && tableComponent && ( )} - - + + = ( - {isStructuredOutput(data) && data.level === 'success' && tableComponent ? ( + {isBGPStructuredOutput(data) && data.level === 'success' && tableComponent ? ( {data.output} + ) : isTracerouteStructuredOutput(data) && data.level === 'success' && tableComponent ? ( + {data.output} ) : isStringOutput(data) && data.level === 'success' && !tableComponent ? ( {data.output} ) : isStringOutput(data) && data.level !== 'success' ? ( diff --git a/hyperglass/ui/components/table/cell.tsx b/hyperglass/ui/components/table/cell.tsx index 228576ffc..dc0f3778a 100644 --- a/hyperglass/ui/components/table/cell.tsx +++ b/hyperglass/ui/components/table/cell.tsx @@ -6,12 +6,14 @@ import type { BoxProps } from '@chakra-ui/react'; interface TableCellProps extends Omit { bordersVertical?: [boolean, number]; align?: 'left' | 'right' | 'center'; + dimText?: boolean; } export const TableCell = (props: TableCellProps): JSX.Element => { - const { bordersVertical = [false, 0], align, ...rest } = props; + const { bordersVertical = [false, 0], align, dimText = false, ...rest } = props; const [doVerticalBorders, index] = bordersVertical; const borderLeftColor = useColorValue('blackAlpha.100', 'whiteAlpha.100'); + const filteredTextColor = useColorValue('filtered.400', 'filtered.500'); let borderProps = {}; if (doVerticalBorders && index !== 0) { @@ -25,6 +27,7 @@ export const TableCell = (props: TableCellProps): JSX.Element => { w="1%" textAlign={align} whiteSpace="nowrap" + color={dimText ? filteredTextColor : undefined} {...borderProps} {...rest} /> diff --git a/hyperglass/ui/components/table/main.tsx b/hyperglass/ui/components/table/main.tsx index fd1eeef5f..5e6383529 100644 --- a/hyperglass/ui/components/table/main.tsx +++ b/hyperglass/ui/components/table/main.tsx @@ -16,19 +16,19 @@ import { PageSelect } from './page-select'; import type { TableOptions, PluginHook } from 'react-table'; import type { Theme, TableColumn, CellRenderProps } from '~/types'; -interface TableProps { - data: Route[]; +interface TableProps { + data: T[]; striped?: boolean; - columns: TableColumn[]; + columns: TableColumn[] | any[]; // Allow more flexible column types heading?: React.ReactNode; bordersVertical?: boolean; bordersHorizontal?: boolean; - Cell?: React.FC; - rowHighlightProp?: keyof Route; + Cell?: React.FC; // More flexible cell render props + rowHighlightProp?: keyof T; rowHighlightBg?: Theme.ColorNames; } -export const Table = (props: TableProps): JSX.Element => { +export const Table = (props: TableProps): JSX.Element => { const { data, columns, @@ -61,12 +61,15 @@ export const Table = (props: TableProps): JSX.Element => { columns, defaultColumn, data, - initialState: { hiddenColumns }, - } as TableOptions; + initialState: { + hiddenColumns, + pageSize: 50 // Default to 50 rows instead of default 10 + }, + } as TableOptions; - const plugins = [useSortBy, usePagination] as PluginHook[]; + const plugins = [useSortBy, usePagination] as PluginHook[]; - const instance = useTable(options, ...plugins); + const instance = useTable(options, ...plugins); const { page, @@ -122,6 +125,7 @@ export const Table = (props: TableProps): JSX.Element => { {page.map((row, key) => { prepareRow(row); + const dimText = Boolean((row.original as { filtered?: boolean }).filtered); return ( { highlightBg={rowHighlightBg} doHorizontalBorders={bordersHorizontal} highlight={row.values[rowHighlightProp ?? ''] ?? false} + dimText={dimText} {...row.getRowProps()} > {row.cells.map((cell, i) => { @@ -137,6 +142,7 @@ export const Table = (props: TableProps): JSX.Element => { {typeof Cell !== 'undefined' ? ( diff --git a/hyperglass/ui/components/table/row.tsx b/hyperglass/ui/components/table/row.tsx index a0497d7e9..76b33b9d6 100644 --- a/hyperglass/ui/components/table/row.tsx +++ b/hyperglass/ui/components/table/row.tsx @@ -8,6 +8,7 @@ interface TableRowProps extends BoxProps { highlightBg?: Theme.ColorNames; doHorizontalBorders?: boolean; highlight?: boolean; + dimText?: boolean; doStripe?: boolean; index: number; } @@ -19,6 +20,7 @@ export const TableRow = (props: TableRowProps): JSX.Element => { highlight = false, highlightBg = 'primary', doHorizontalBorders = false, + dimText = false, ...rest } = props; @@ -40,12 +42,14 @@ export const TableRow = (props: TableRowProps): JSX.Element => { } const defaultBg = useColorValue('white', 'black'); const color = useOpposingColor(bg ?? defaultBg); + const filteredTextColor = useColorValue('filtered.400', 'filtered.500'); + const rowTextColor = dimText ? filteredTextColor : color; const borderProps = doHorizontalBorders && index !== 0 ? rowBorder : {}; return ( td': { color } }} + css={{ '& > td': { color: rowTextColor } }} fontWeight={highlight ? 'bold' : undefined} _hover={{ cursor: 'pointer', diff --git a/hyperglass/ui/hooks/use-asn-detail.ts b/hyperglass/ui/hooks/use-asn-detail.ts index 57f1e63b5..ab21ba111 100644 --- a/hyperglass/ui/hooks/use-asn-detail.ts +++ b/hyperglass/ui/hooks/use-asn-detail.ts @@ -16,29 +16,38 @@ interface ASNQuery { }; } -const query: QueryFunction = async (ctx: QueryFunctionContext) => { - const asn = ctx.queryKey; - const res = await fetch('https://api.asrank.caida.org/v2/graphql', { - mode: 'cors', - method: 'POST', - headers: { 'content-type': 'application/json' }, - /* eslint no-useless-escape: 0 */ - body: JSON.stringify({ query: `{ asn(asn:\"${asn}\"){ organization { orgName } } }` }), - }); - return await res.json(); -}; +// Disabled - we now get org names from our own IP enrichment system +// const query: QueryFunction = async (ctx: QueryFunctionContext) => { +// const asn = ctx.queryKey; +// const res = await fetch('https://api.asrank.caida.org/v2/graphql', { +// mode: 'cors', +// method: 'POST', +// headers: { 'content-type': 'application/json' }, +// /* eslint no-useless-escape: 0 */ +// body: JSON.stringify({ query: `{ asn(asn:\"${asn}\"){ organization { orgName } } }` }), +// }); +// return await res.json(); +// }; /** - * Query the Caida AS Rank API to get an ASN's organization name for the AS Path component. - * @see https://api.asrank.caida.org/v2/docs + * Stub function - we no longer need external CAIDA calls since we have ASN org data + * from our IP enrichment system. This hook is kept for compatibility but returns empty data. + * @deprecated Use as_path_data from traceroute response instead of external CAIDA calls */ export function useASNDetail(asn: string): QueryObserverResult { return useQuery({ queryKey: [asn], - queryFn: query, + queryFn: async () => ({ + data: { + asn: { + organization: null, // No external fetch - org data comes from IP enrichment + } + } + }), refetchOnWindowFocus: false, - refetchInterval: false, + refetchInterval: false, refetchOnMount: false, cacheTime: Infinity, + enabled: false, // Disable the query entirely }); } diff --git a/hyperglass/ui/hooks/use-table-to-string.ts b/hyperglass/ui/hooks/use-table-to-string.ts index acd2449ca..c1bcbfc35 100644 --- a/hyperglass/ui/hooks/use-table-to-string.ts +++ b/hyperglass/ui/hooks/use-table-to-string.ts @@ -3,7 +3,7 @@ import dayjs from 'dayjs'; import relativeTimePlugin from 'dayjs/plugin/relativeTime'; import utcPlugin from 'dayjs/plugin/utc'; import { useConfig } from '~/context'; -import { isStructuredOutput } from '~/types'; +import { isStructuredOutput, isBGPStructuredOutput, isTracerouteStructuredOutput } from '~/types'; type TableToStringFormatter = | ((v: string) => string) @@ -23,13 +23,17 @@ interface TableToStringFormatted { dayjs.extend(relativeTimePlugin); dayjs.extend(utcPlugin); -function formatAsPath(path: number[]): string { - return path.join(' → '); +function formatAsPath(path: number[], enrichments?: Record): string { + return path.map(asn => { + const asnStr = String(asn); + const org = enrichments?.[asnStr]?.name; + return org ? `${asn} (${org})` : String(asn); + }).join(' → '); } function formatCommunities(comms: string[]): string { const commsStr = comms.map(c => ` - ${c}`); - return `\n ${commsStr.join('\n')}`; + return `\n${commsStr.join('\n')}`; } function formatBool(val: boolean): string { @@ -93,26 +97,164 @@ export function useTableToString( let result = messages.noOutput; try { if (typeof data !== 'undefined' && isStructuredOutput(data)) { - const tableStringParts = [ - `Routes For: ${target.join(', ')}`, - `Timestamp: ${data.timestamp} UTC`, - ]; - for (const route of data.output.routes) { - for (const field of parsedDataFields) { - const [header, accessor, align] = field; - if (align !== null) { - let value = route[accessor]; - const fmtFunc = getFmtFunc(accessor) as (v: typeof value) => string; - value = fmtFunc(value); - if (accessor === 'prefix') { - tableStringParts.push(` - ${header}: ${value}`); - } else { - tableStringParts.push(` - ${header}: ${value}`); + + // Handle BGP data + if (isBGPStructuredOutput(data)) { + // Check if this is BGP data with routes + if (!('routes' in data.output) || !Array.isArray(data.output.routes)) { + return messages.noOutput; // Not BGP data, return early + } + + const tableStringParts = [ + `Routes For: ${target.join(', ')}`, + `Timestamp: ${data.timestamp} UTC`, + ]; + + // Get enrichment data if available + const enrichments = (data.output as any).asn_organizations || {}; + + for (const route of data.output.routes) { + for (const field of parsedDataFields) { + const [header, accessor, align] = field; + if (align !== null) { + let value = route[accessor]; + + // Handle fields that should be hidden when empty/not available + if ((accessor === 'source_rid' || accessor === 'age') && + (value === null || value === undefined || + (typeof value === 'string' && value.trim() === '') || + (accessor === 'age' && value === -1))) { + continue; // Skip this field entirely + } + + // Special handling for AS path with enrichment + if (accessor === 'as_path') { + value = formatAsPath(value as number[], enrichments); + } + // Special handling for next_hop with enrichment + else if (accessor === 'next_hop') { + const nextHopOrg = (route as any).next_hop_org; + const nextHopAsn = (route as any).next_hop_asn; + if (nextHopOrg || nextHopAsn) { + const asnPart = nextHopAsn ? nextHopAsn.replace(/^AS/, '') : ''; + const enrichmentPart = [asnPart, nextHopOrg].filter(Boolean).join(' '); + value = enrichmentPart ? `${value} (${enrichmentPart})` : value; + } + } else { + const fmtFunc = getFmtFunc(accessor) as (v: typeof value) => string; + value = fmtFunc(value); + } + + if (accessor === 'prefix') { + let statusSuffix = ''; + if (route.active) { + statusSuffix = ' (active path)'; + } else if (route.filtered) { + statusSuffix = ' (filtered)'; + } else { + statusSuffix = ' (candidate)'; + } + tableStringParts.push(` - ${header}: ${value}${statusSuffix}`); + } else { + tableStringParts.push(` - ${header}: ${value}`); + } } } } + result = tableStringParts.join('\n'); + } + + // Handle Traceroute data + else if (isTracerouteStructuredOutput(data)) { + if (!('hops' in data.output) || !Array.isArray(data.output.hops)) { + return messages.noOutput; // Not traceroute data, return early + } + + const formatRTT = (rtt: number | null | undefined): string => { + if (rtt === null || rtt === undefined) return '*'; + return `${rtt.toFixed(1)}ms`; + }; + + const formatIP = (hop: any): string => { + if (hop.display_ip) return hop.display_ip; // For truncated IPv6 + if (hop.ip_address) return hop.ip_address; + return '*'; + }; + + const formatASN = (hop: any): string => { + if (hop.asn) return `AS${hop.asn}`; + return '*'; + }; + + const formatHostname = (hop: any): string => { + if (hop.hostname && hop.hostname !== 'None' && hop.hostname !== 'null') return hop.hostname; + return '*'; + }; + + // Create a nicely formatted text table with proper column alignment + const header = `Traceroute to ${data.output.target} from ${data.output.source}`; + const timestamp = `Timestamp: ${data.timestamp} UTC`; + const separator = '=' .repeat(header.length); + + // Calculate optimal column widths by examining all data + const columnWidths = { + hop: Math.max(3, ...data.output.hops.map(h => h.hop_number.toString().length)), + ip: Math.max(10, ...data.output.hops.map(h => formatIP(h).length)), + hostname: Math.max(8, ...data.output.hops.map(h => formatHostname(h).length)), + asn: Math.max(3, ...data.output.hops.map(h => formatASN(h).length)), + loss: 4, // "100%" is max + sent: Math.max(4, ...data.output.hops.map(h => (h.sent_count || 0).toString().length)), + last: Math.max(4, ...data.output.hops.map(h => formatRTT(h.last_rtt).length)), + avg: Math.max(3, ...data.output.hops.map(h => formatRTT(h.avg_rtt).length)), + best: Math.max(4, ...data.output.hops.map(h => formatRTT(h.best_rtt).length)), + worst: Math.max(5, ...data.output.hops.map(h => formatRTT(h.worst_rtt).length)), + }; + + // Create header row with proper spacing + const headerRow = [ + 'Hop'.padEnd(columnWidths.hop), + 'IP Address'.padEnd(columnWidths.ip), + 'Hostname'.padEnd(columnWidths.hostname), + 'ASN'.padEnd(columnWidths.asn), + 'Loss'.padEnd(columnWidths.loss), + 'Sent'.padEnd(columnWidths.sent), + 'Last'.padEnd(columnWidths.last), + 'AVG'.padEnd(columnWidths.avg), + 'Best'.padEnd(columnWidths.best), + 'Worst' + ].join(' '); + + const totalWidth = headerRow.length; + + const tableLines = [ + header, + timestamp, + separator, + '', + headerRow, + '-'.repeat(totalWidth), + ]; + + // Format data rows with consistent column widths + for (const hop of data.output.hops) { + const row = [ + hop.hop_number.toString().padEnd(columnWidths.hop), + formatIP(hop).padEnd(columnWidths.ip), + formatHostname(hop).padEnd(columnWidths.hostname), + formatASN(hop).padEnd(columnWidths.asn), + `${hop.loss_pct || 0}%`.padEnd(columnWidths.loss), + (hop.sent_count || 0).toString().padEnd(columnWidths.sent), + formatRTT(hop.last_rtt).padEnd(columnWidths.last), + formatRTT(hop.avg_rtt).padEnd(columnWidths.avg), + formatRTT(hop.best_rtt).padEnd(columnWidths.best), + formatRTT(hop.worst_rtt) + ].join(' '); + + tableLines.push(row); + } + + result = tableLines.join('\n'); } - result = tableStringParts.join('\n'); } return result; } catch (err) { diff --git a/hyperglass/ui/types/globals.d.ts b/hyperglass/ui/types/globals.d.ts index f920e5998..b96f98c9f 100644 --- a/hyperglass/ui/types/globals.d.ts +++ b/hyperglass/ui/types/globals.d.ts @@ -30,17 +30,67 @@ export declare global { source_rid: string; peer_rid: string; rpki_state: RPKIState; + filtered: boolean; + }; + + type TracerouteHop = { + hop_number: number; + ip_address: string | null; + display_ip: string | null; + hostname: string | null; + rtt1: number | null; + rtt2: number | null; + rtt3: number | null; + loss_pct: number | null; + sent_count: number | null; + last_rtt: number | null; + avg_rtt: number | null; + best_rtt: number | null; + worst_rtt: number | null; + asn: string | null; + org: string | null; + prefix: string | null; + country: string | null; + rir: string | null; + allocated: string | null; + }; + + type TracerouteResult = { + target: string; + source: string; + hops: TracerouteHop[]; + max_hops: number; + packet_size: number; + raw_output: string | null; }; type RouteField = { [K in keyof Route]: Route[K] }; + + type TracerouteHopField = { [K in keyof TracerouteHop]: TracerouteHop[K] }; type StructuredResponse = { vrf: string; count: number; routes: Route[]; winning_weight: 'high' | 'low'; + asn_organizations?: Record; }; + type TracerouteStructuredOutput = { + vrf: string; + target: string; + source: string; + hops: TracerouteHop[]; + max_hops: number; + packet_size: number; + raw_output: string | null; + asn_organizations?: Record; + }; + + type BGPStructuredOutput = StructuredResponse; + + type AllStructuredResponses = BGPStructuredOutput | TracerouteStructuredOutput; + type QueryResponse = { random: string; cached: boolean; diff --git a/hyperglass/ui/types/guards.ts b/hyperglass/ui/types/guards.ts index 3e587f690..227b7a87f 100644 --- a/hyperglass/ui/types/guards.ts +++ b/hyperglass/ui/types/guards.ts @@ -19,6 +19,14 @@ export function isStructuredOutput(data: unknown): data is StringTableData { return isObject(data) && 'output' in data; } +export function isBGPStructuredOutput(data: unknown): data is StringTableData & { output: StructuredResponse } { + return isStructuredOutput(data) && isObject((data as StringTableData).output) && 'routes' in (data as StringTableData).output; +} + +export function isTracerouteStructuredOutput(data: unknown): data is StringTableData & { output: TracerouteResult } { + return isStructuredOutput(data) && isObject((data as StringTableData).output) && 'hops' in (data as StringTableData).output; +} + export function isStringOutput(data: unknown): data is StringQueryResponse { return ( isObject(data) && 'output' in data && typeof (data as { output: unknown }).output === 'string' diff --git a/hyperglass/ui/types/table.ts b/hyperglass/ui/types/table.ts index fe20e0b18..2687f7363 100644 --- a/hyperglass/ui/types/table.ts +++ b/hyperglass/ui/types/table.ts @@ -7,8 +7,21 @@ export interface TableColumn { hidden: boolean; } +export interface TracerouteTableColumn { + Header: string; + accessor: keyof TracerouteHop; + align: string; + hidden: boolean; +} + export type CellRenderProps = { column: CellProps['column']; row: CellProps['row']; value: CellProps['value']; }; + +export type TracerouteCellRenderProps = { + column: CellProps['column']; + row: CellProps['row']; + value: CellProps['value']; +}; From 55bcaff8bfc54c04ee3a7f0ba8eb9497589b2d74 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:44 +1000 Subject: [PATCH 09/39] feat(enrichment): BGP next-hop and AS-path IP enrichment --- .../config/structured-output.mdx | 100 ++- docs/pages/configuration/config/web-ui.mdx | 3 + hyperglass/api/__init__.py | 10 +- hyperglass/api/routes.py | 29 + hyperglass/api/state.py | 35 +- hyperglass/api/tasks.py | 6 +- hyperglass/constants.py | 9 +- hyperglass/defaults/directives/frr.py | 4 +- hyperglass/defaults/directives/huawei.py | 67 +- hyperglass/defaults/directives/mikrotik.py | 70 +- hyperglass/external/__init__.py | 8 +- hyperglass/external/bgptools.py | 178 ----- hyperglass/external/ip_enrichment.py | 655 ++++++++++++++++++ hyperglass/external/rpki.py | 62 +- hyperglass/external/tests/test_bgptools.py | 49 -- hyperglass/external/tests/test_rpki.py | 8 +- hyperglass/models/api/query.py | 44 +- hyperglass/models/config/devices.py | 8 +- hyperglass/models/config/params.py | 12 +- hyperglass/models/config/structured.py | 47 +- hyperglass/models/config/web.py | 1 + hyperglass/models/data/__init__.py | 7 +- hyperglass/models/data/bgp_route.py | 108 ++- hyperglass/models/data/traceroute.py | 252 +++++++ hyperglass/models/parsing/huawei.py | 394 +++++++++++ hyperglass/models/parsing/mikrotik.py | 561 +++++++++++++++ hyperglass/models/parsing/traceroute.py | 235 +++++++ hyperglass/plugins/_builtin/__init__.py | 16 + .../plugins/_builtin/bgp_route_enrichment.py | 97 +++ .../plugins/_builtin/bgp_route_huawei.py | 25 +- .../plugins/_builtin/bgp_routestr_huawei.py | 80 +++ .../plugins/_builtin/bgp_routestr_mikrotik.py | 86 +++ .../_builtin/mikrotik_garbage_output.py | 293 ++++++-- .../_builtin/mikrotik_normalize_input.py | 58 ++ .../plugins/_builtin/trace_route_frr.py | 345 +++++++++ .../plugins/_builtin/trace_route_huawei.py | 278 ++++++++ .../plugins/_builtin/trace_route_mikrotik.py | 165 +++++ .../_builtin/traceroute_ip_enrichment.py | 210 ++++++ .../plugins/tests/test_bgp_route_huawei.py | 75 ++ .../plugins/tests/test_bgp_route_mikrotik.py | 78 +++ .../plugins/tests/test_traceroute_frr.py | 46 ++ .../plugins/tests/test_traceroute_huawei.py | 40 ++ .../plugins/tests/test_traceroute_mikrotik.py | 153 ++++ .../ui/components/looking-glass-form.tsx | 7 +- hyperglass/ui/components/output/cell.tsx | 21 +- hyperglass/ui/components/output/fields.tsx | 109 ++- hyperglass/ui/components/output/index.ts | 1 + .../ui/components/output/traceroute-cell.tsx | 51 ++ .../components/output/traceroute-fields.tsx | 188 +++++ .../ui/components/output/traceroute-table.tsx | 38 + hyperglass/ui/components/path/chart.tsx | 18 +- hyperglass/ui/components/path/path.tsx | 47 +- hyperglass/ui/components/path/use-elements.ts | 110 ++- hyperglass/ui/components/query-type.tsx | 32 +- .../ui/components/results/individual.tsx | 31 +- hyperglass/ui/components/table/cell.tsx | 5 +- hyperglass/ui/components/table/main.tsx | 26 +- hyperglass/ui/components/table/row.tsx | 6 +- hyperglass/ui/hooks/use-asn-detail.ts | 39 +- hyperglass/ui/hooks/use-table-to-string.ts | 182 ++++- hyperglass/ui/types/globals.d.ts | 50 ++ hyperglass/ui/types/guards.ts | 8 + hyperglass/ui/types/table.ts | 13 + 63 files changed, 5523 insertions(+), 466 deletions(-) delete mode 100644 hyperglass/external/bgptools.py create mode 100644 hyperglass/external/ip_enrichment.py delete mode 100644 hyperglass/external/tests/test_bgptools.py create mode 100644 hyperglass/models/data/traceroute.py create mode 100644 hyperglass/models/parsing/huawei.py create mode 100644 hyperglass/models/parsing/mikrotik.py create mode 100644 hyperglass/models/parsing/traceroute.py create mode 100644 hyperglass/plugins/_builtin/bgp_route_enrichment.py create mode 100644 hyperglass/plugins/_builtin/bgp_routestr_huawei.py create mode 100644 hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py create mode 100644 hyperglass/plugins/_builtin/mikrotik_normalize_input.py create mode 100644 hyperglass/plugins/_builtin/trace_route_frr.py create mode 100644 hyperglass/plugins/_builtin/trace_route_huawei.py create mode 100644 hyperglass/plugins/_builtin/trace_route_mikrotik.py create mode 100644 hyperglass/plugins/_builtin/traceroute_ip_enrichment.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_huawei.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_mikrotik.py create mode 100644 hyperglass/plugins/tests/test_traceroute_frr.py create mode 100644 hyperglass/plugins/tests/test_traceroute_huawei.py create mode 100644 hyperglass/plugins/tests/test_traceroute_mikrotik.py create mode 100644 hyperglass/ui/components/output/traceroute-cell.tsx create mode 100644 hyperglass/ui/components/output/traceroute-fields.tsx create mode 100644 hyperglass/ui/components/output/traceroute-table.tsx diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx index df936253f..99b5791ac 100644 --- a/docs/pages/configuration/config/structured-output.mdx +++ b/docs/pages/configuration/config/structured-output.mdx @@ -4,19 +4,28 @@ Devices that support responding to a query with structured or easily parsable da - Arista EOS - Juniper Junos +- Huawei VRP +- MikroTik RouterOS / SwitchOS + +MikroTik structured BGP output (Route, AS Path, and Community queries) requires **RouterOS v7+**, as it parses the output of `routing route print detail`. RouterOS v6 uses a different route-print syntax and is not supported for structured BGP. When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods: 1. From the router's perspective -2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) +2. From an external validator — either [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) (the default) or a self-hosted [Routinator](https://routinator.docs.nlnetlabs.nl/) instance + +When `structured.rpki.mode` is `external`, the `structured.rpki.backend` field selects which validator to query. The `cloudflare` backend uses Cloudflare's GraphQL API; the `routinator` backend queries the `/validity` HTTP API of a Routinator instance at `structured.rpki.rpki_server_url`. Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user. | Parameter | Type | Default Value | Description | | :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- | -| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use Cloudflare's view (2 above). | -| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. | -| `structured.communities.items` | List of Strings | | List of communities to match. | +| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use an external validator (2 above). | +| `structured.rpki.backend` | String | cloudflare | When `mode` is `external`, the validator to use: `cloudflare` or `routinator`. | +| `structured.rpki.rpki_server_url` | String | | Base URL of the Routinator instance. **Required** when `backend` is `routinator` (e.g. `http://routinator.example.net:8323`). | +| `structured.communities.mode` | String | deny | `deny` hides communities listed in `items`; `permit` shows _only_ those listed; `name` shows all communities and appends a friendly label to any listed in `names`. | +| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny`/`permit`). | +| `structured.communities.names` | Map of String→String | | Map of community → friendly name, used by `name` mode. At least one entry is required when `mode` is `name`. | ### RPKI Examples @@ -28,12 +37,23 @@ structured: mode: router ``` -#### Show RPKI State from a Public/External Perspective +#### Show RPKI State from a Public/External Perspective (Cloudflare) -```yaml filename="config.yaml" copy {2} +```yaml filename="config.yaml" copy {2-3} +structured: + rpki: + mode: external + backend: cloudflare +``` + +#### Validate RPKI State Against a Self-Hosted Routinator + +```yaml filename="config.yaml" copy {2-5} structured: rpki: mode: external + backend: routinator + rpki_server_url: "http://routinator.example.net:8323" ``` ### Community Filtering Examples @@ -59,3 +79,71 @@ structured: - "^65000:.*$" # permit any communities starting with 65000, but no others. - "1234:1" # permit only the 1234:1 community. ``` + +#### Show Friendly Names Alongside Communities + +With `mode: name`, all communities are shown, and any community listed in `names` has its friendly label appended (rendered as ` ()` in the UI). + +```yaml filename="config.yaml" {3-6} +structured: + communities: + mode: name + names: + "65000:100": "Customer Route" + "65000:200": "Peer Route" +``` + +## Structured Traceroute + +In addition to BGP routes, hyperglass can parse traceroute output into a structured hop table (and JSON via the REST API). Each hop shows the hop number, IP address, resolved hostname (when available), and per-probe round-trip times; MikroTik hops also include loss and probe counts. When [IP enrichment](#ip-enrichment) is enabled, each hop is annotated with its ASN, organization, country, and IXP membership. + +Structured traceroute is supported on the following platforms: + +- FRRouting +- Huawei VRP +- MikroTik RouterOS / SwitchOS + +Structured output (for both BGP routes and traceroute) is controlled per query type. When a `structured:` block is present in the device's configuration and the device has `structured_output: true`, structured output is enabled by default. The two flags below let you disable it for a specific query type without removing the `structured:` block: + +| Parameter | Type | Default Value | Description | +| :--------------------------------- | :------ | :------------ | :------------------------------------------------------------------------------------------- | +| `structured.enable_for_traceroute` | Boolean | (enabled) | Set to `false` to return raw (unstructured) traceroute output even when structured is available. | +| `structured.enable_for_bgp_route` | Boolean | (enabled) | Set to `false` to return raw (unstructured) BGP route output even when structured is available. | + +### Disable Structured Traceroute (keep structured BGP) + +```yaml filename="config.yaml" copy {2} +structured: + enable_for_traceroute: false +``` + +## IP Enrichment + +hyperglass can enrich structured responses with ASN, organization, country, and IXP metadata resolved in real time from [bgp.tools](https://bgp.tools/). This data powers the organization names shown on AS paths and next-hops, and the per-hop detail in structured traceroute. + +Enrichment is performed by an internal service that issues bulk WHOIS lookups to bgp.tools; results are cached in-memory for the life of the process. No configuration is required to make the service available — it is enabled per-feature via the [`structured.ip_enrichment`](#ip-enrichment-configuration) settings documented below. + +### IP Enrichment Configuration + +| Parameter | Type | Default Value | Description | +| :--------------------------------------- | :------ | :------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `structured.ip_enrichment.enrich_traceroute` | Boolean | `false` | Resolve ASN, organization, country, and IXP details for each traceroute hop. | +| `structured.ip_enrichment.enrich_bgproute` | Boolean | `false` | When `true`, BGP next-hop and AS-path organizations are resolved eagerly as part of the query response. When `false`, they are resolved lazily on demand when the AS Path view is opened (via the `/api/aspath/enrich` endpoint). | + +When BGP routes are enriched, AS-path entries link to `https://bgp.tools/as/` and display the organization name on hover, and next-hop IPs show their ASN and organization. + +### Enable Traceroute Enrichment + +```yaml filename="config.yaml" copy {3} +structured: + ip_enrichment: + enrich_traceroute: true +``` + +### Eagerly Enrich BGP Routes + +```yaml filename="config.yaml" copy {3} +structured: + ip_enrichment: + enrich_bgproute: true +``` diff --git a/docs/pages/configuration/config/web-ui.mdx b/docs/pages/configuration/config/web-ui.mdx index 761344769..96b5664f7 100644 --- a/docs/pages/configuration/config/web-ui.mdx +++ b/docs/pages/configuration/config/web-ui.mdx @@ -243,6 +243,7 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. | `web.theme.colors.dark` | | | `web.theme.colors.light` | | | `web.theme.colors.gray` | | +| `web.theme.colors.filtered` | | | `web.theme.colors.red` | | | `web.theme.colors.orange` | | | `web.theme.colors.yellow` | | @@ -253,6 +254,8 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. | `web.theme.colors.pink` | | | `web.theme.colors.purple` | | +The `filtered` color is used to dim BGP routes that are present but not selected/active in the structured table (for example, MikroTik routes flagged filtered, disabled, or unreachable). It defaults to the same value as `gray`. + ##### Functional Colors | Parameter | Default Value | diff --git a/hyperglass/api/__init__.py b/hyperglass/api/__init__.py index 65458f962..74cf9dbba 100644 --- a/hyperglass/api/__init__.py +++ b/hyperglass/api/__init__.py @@ -16,7 +16,14 @@ # Local from .events import check_redis -from .routes import info, query, device, devices, queries +from .routes import ( + info, + query, + device, + devices, + queries, + aspath_enrich, +) from .middleware import COMPRESSION_CONFIG, create_cors_config from .error_handlers import app_handler, http_handler, default_handler, validation_handler @@ -42,6 +49,7 @@ queries, info, query, + aspath_enrich, ] if not STATE.settings.disable_ui: diff --git a/hyperglass/api/routes.py b/hyperglass/api/routes.py index 9e17c9a69..36f7f3f4f 100644 --- a/hyperglass/api/routes.py +++ b/hyperglass/api/routes.py @@ -34,9 +34,38 @@ "queries", "info", "query", + "aspath_enrich", ) +# Cap the number of ASNs accepted per enrichment request to bound outbound lookups. +_MAX_ASPATH_ENRICH = 64 + + +@post("/api/aspath/enrich") +async def aspath_enrich(data: dict) -> dict: + """Enrich a list of ASNs with organization names on demand. + + Expected JSON payload: { "as_path": [123, 456, ...] } + """ + as_path = data.get("as_path", []) if isinstance(data, dict) else [] + # Accept only numeric ASNs and bound the request size. + asns = [str(a) for a in as_path if str(a).isdigit()][:_MAX_ASPATH_ENRICH] + if not asns: + return {"success": False, "error": "No valid as_path provided"} + + from hyperglass.external.ip_enrichment import lookup_asns_bulk + + try: + results = await lookup_asns_bulk(asns) + except Exception: + # Log server-side; don't leak internal error detail to the client. + log.bind(as_path=asns).error("AS path enrichment lookup failed") + return {"success": False, "error": "Enrichment lookup failed"} + + return {"success": True, "asn_organizations": results} + + @get("/api/devices/{id:str}", dependencies={"devices": Provide(get_devices)}) async def device(devices: Devices, id: str) -> APIDevice: """Retrieve a device by ID.""" diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py index f41a96d78..5dfd75ef9 100644 --- a/hyperglass/api/state.py +++ b/hyperglass/api/state.py @@ -1,27 +1,52 @@ """hyperglass state dependencies.""" # Standard Library +import asyncio import typing as t # Project -from hyperglass.state import use_state +from hyperglass.state import HyperglassState, use_state +from hyperglass.exceptions.private import StateError + + +def _is_retryable(attr: t.Optional[str]) -> bool: + """Whether a StateError for this attr is a transient (not-yet-populated) error. + + A request can arrive before the Redis-backed state is populated on startup, + which surfaces as a StateError. Referencing an attribute that does not exist + on HyperglassState is a permanent programming error and must not be retried. + """ + return attr is None or attr in ("cache", "redis") or attr in HyperglassState.properties() + + +async def _get_state_with_retry( + attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5 +) -> t.Any: + """Get hyperglass state, retrying transient startup StateErrors.""" + for attempt in range(1, max_retries + 1): + try: + return use_state(attr) + except StateError: + if not _is_retryable(attr) or attempt == max_retries: + raise + await asyncio.sleep(retry_delay) async def get_state(attr: t.Optional[str] = None): """Get hyperglass state as a FastAPI dependency.""" - return use_state(attr) + return await _get_state_with_retry(attr) async def get_params(): """Get hyperglass params as FastAPI dependency.""" - return use_state("params") + return await _get_state_with_retry("params") async def get_devices(): """Get hyperglass devices as FastAPI dependency.""" - return use_state("devices") + return await _get_state_with_retry("devices") async def get_ui_params(): """Get hyperglass ui_params as FastAPI dependency.""" - return use_state("ui_params") + return await _get_state_with_retry("ui_params") diff --git a/hyperglass/api/tasks.py b/hyperglass/api/tasks.py index 0a7d16d46..8527c53be 100644 --- a/hyperglass/api/tasks.py +++ b/hyperglass/api/tasks.py @@ -10,7 +10,7 @@ # Project from hyperglass.log import log -from hyperglass.external import Webhook, bgptools +from hyperglass.external import Webhook, network_info from hyperglass.models.api import Query if t.TYPE_CHECKING: @@ -52,7 +52,7 @@ async def send_webhook( else: host = request.client.host - network_info = await bgptools.network_info(host) + network_result = await network_info(host) async with Webhook(params.logging.http) as hook: await hook.send( @@ -60,7 +60,7 @@ async def send_webhook( **data.dict(), "headers": headers, "source": host, - "network": network_info.get(host, {}), + "network": network_result.get(host, {}), "timestamp": timestamp, } ) diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 0139c7415..02723a194 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -19,7 +19,14 @@ TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos") -SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos") +SUPPORTED_STRUCTURED_OUTPUT = ( + "frr", + "juniper", + "arista_eos", + "huawei", + "mikrotik_routeros", + "mikrotik_switchos", +) CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml") diff --git a/hyperglass/defaults/directives/frr.py b/hyperglass/defaults/directives/frr.py index eb6baafd9..3d7fb6960 100644 --- a/hyperglass/defaults/directives/frr.py +++ b/hyperglass/defaults/directives/frr.py @@ -101,12 +101,12 @@ RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="traceroute -4 -w 1 -q 1 -s {source4} {target}", + command="traceroute -4 -I -n -q 1 -w 1 -s {source4} {target}", ), RuleWithIPv6( condition="::/0", action="permit", - command="traceroute -6 -w 1 -q 1 -s {source6} {target}", + command="traceroute -6 -I -n -q 1 -w 1 -s {source6} {target}", ), ], field=Text(description="IP Address, Prefix, or Hostname"), diff --git a/hyperglass/defaults/directives/huawei.py b/hyperglass/defaults/directives/huawei.py index f8d3ef0cf..6cb3f2ad5 100644 --- a/hyperglass/defaults/directives/huawei.py +++ b/hyperglass/defaults/directives/huawei.py @@ -15,6 +15,9 @@ "Huawei_BGPRoute", "Huawei_Ping", "Huawei_Traceroute", + "HuaweiBGPRouteTable", + "HuaweiBGPASPathTable", + "HuaweiBGPCommunityTable", ) NAME = "Huawei VRP" @@ -36,7 +39,8 @@ ), ], field=Text(description="IP Address, Prefix, or Hostname"), - plugins=["bgp_route_huawei"], + plugins=["bgp_route_huawei", "bgp_routestr_huawei"], + table_output="__hyperglass_huawei_bgp_route_table__", platforms=PLATFORMS, ) @@ -54,6 +58,7 @@ ) ], field=Text(description="AS Path Regular Expression"), + table_output="__hyperglass_huawei_bgp_aspath_table__", platforms=PLATFORMS, ) @@ -71,6 +76,7 @@ ) ], field=Text(description="BGP Community String"), + table_output="__hyperglass_huawei_bgp_community_table__", platforms=PLATFORMS, ) @@ -100,14 +106,69 @@ RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="tracert -q 2 -f 1 -a {source4} {target}", + command="tracert -w 500 -q 1 -f 1 -a {source4} {target}", ), RuleWithIPv6( condition="::/0", action="permit", - command="tracert -q 2 -f 1 -a {source6} {target}", + command="tracert ipv6 -w 500 -q 1 -f 1 -a {source6} {target}", ), ], field=Text(description="IP Address, Prefix, or Hostname"), platforms=PLATFORMS, ) + +# Table Output Directives + +HuaweiBGPRouteTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="display bgp routing-table {target} | no-more", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="display bgp ipv6 routing-table {target} | no-more", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +HuaweiBGPASPathTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table regular-expression "{target}"', + 'display bgp ipv6 routing-table regular-expression "{target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +HuaweiBGPCommunityTable = BuiltinDirective( + id="__hyperglass_huawei_bgp_community_table__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'display bgp routing-table community "{target}"', + 'display bgp ipv6 routing-table community "{target}"', + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/mikrotik.py b/hyperglass/defaults/directives/mikrotik.py index 4ea96fdff..35469ecdb 100644 --- a/hyperglass/defaults/directives/mikrotik.py +++ b/hyperglass/defaults/directives/mikrotik.py @@ -15,6 +15,9 @@ "Mikrotik_BGPRoute", "Mikrotik_Ping", "Mikrotik_Traceroute", + "MikrotikBGPRouteTable", + "MikrotikBGPASPathTable", + "MikrotikBGPCommunityTable", ) NAME = "Mikrotik" @@ -27,15 +30,17 @@ RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="ip route print where {target} in dst-address", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=0.0.0.0/0", ), RuleWithIPv6( condition="::/0", action="permit", - command="ipv6 route print where {target} in dst-address", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=::/0", ), ], field=Text(description="IP Address, Prefix, or Hostname"), + plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + table_output="__hyperglass_mikrotik_bgp_route_table__", platforms=PLATFORMS, ) @@ -53,6 +58,8 @@ ) ], field=Text(description="AS Path Regular Expression"), + plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + table_output="__hyperglass_mikrotik_bgp_aspath_table__", platforms=PLATFORMS, ) @@ -70,6 +77,8 @@ ) ], field=Text(description="BGP Community String"), + plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + table_output="__hyperglass_mikrotik_bgp_community_table__", platforms=PLATFORMS, ) @@ -99,14 +108,67 @@ RuleWithIPv4( condition="0.0.0.0/0", action="permit", - command="tool traceroute src-address={source4} timeout=1 duration=5 count=1 {target}", + command="tool traceroute src-address={source4} timeout=1 duration=30 count=3 {target}", ), RuleWithIPv6( condition="::/0", action="permit", - command="tool traceroute src-address={source6} timeout=1 duration=5 count=1 {target}", + command="tool traceroute src-address={source6} timeout=1 duration=30 count=3 {target}", ), ], field=Text(description="IP Address, Prefix, or Hostname"), platforms=PLATFORMS, ) + +# Table Output Directives + +MikrotikBGPRouteTable = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=0.0.0.0/0", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="routing route print detail without-paging where {target} in dst-address bgp and dst-address !=::/0", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +MikrotikBGPASPathTable = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "routing route print detail without-paging where bgp-as-path~{target}", + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +MikrotikBGPCommunityTable = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_community_table__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "routing route print detail without-paging where bgp-communities~{target}", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) diff --git a/hyperglass/external/__init__.py b/hyperglass/external/__init__.py index 4d9d22e9e..827bf3314 100644 --- a/hyperglass/external/__init__.py +++ b/hyperglass/external/__init__.py @@ -5,7 +5,13 @@ from .slack import SlackHook from .generic import BaseExternal from .msteams import MSTeams -from .bgptools import network_info, network_info_sync +from .ip_enrichment import ( + network_info, + network_info_sync, + lookup_ip, + lookup_asn_name, + lookup_asn_country, +) from .webhooks import Webhook from .http_client import HTTPClient diff --git a/hyperglass/external/bgptools.py b/hyperglass/external/bgptools.py deleted file mode 100644 index f2fd7f6a9..000000000 --- a/hyperglass/external/bgptools.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Query & parse data from bgp.tools. - -- See https://bgp.tools/credits for acknowledgements and licensing. -- See https://bgp.tools/kb/api for query documentation. -""" - -# Standard Library -import re -import typing as t -import asyncio -from ipaddress import IPv4Address, IPv6Address, ip_address - -# Project -from hyperglass.log import log -from hyperglass.state import use_state - -DEFAULT_KEYS = ("asn", "ip", "prefix", "country", "rir", "allocated", "org") - -CACHE_KEY = "hyperglass.external.bgptools" - -TargetDetail = t.TypedDict( - "TargetDetail", - {"asn": str, "ip": str, "country": str, "rir": str, "allocated": str, "org": str}, -) - -TargetData = t.Dict[str, TargetDetail] - - -def default_ip_targets(*targets: str) -> t.Tuple[TargetData, t.Tuple[str, ...]]: - """Construct a mapping of default data and other data that should be queried. - - Targets in the mapping don't need to be queried and already have default values. Targets in the - query tuple should be queried. - """ - default_data = {} - query = () - for target in targets: - detail: TargetDetail = dict.fromkeys(DEFAULT_KEYS, "None") - try: - valid: t.Union[IPv4Address, IPv6Address] = ip_address(target) - - checks = ( - (valid.version == 6 and valid.is_site_local, "Site Local Address"), - (valid.is_loopback, "Loopback Address"), - (valid.is_multicast, "Multicast Address"), - (valid.is_link_local, "Link Local Address"), - (valid.is_private, "Private Address"), - ) - for exp, rir in checks: - if exp is True: - detail["rir"] = rir - break - - should_query = any((valid.is_global, valid.is_unspecified, valid.is_reserved)) - - if not should_query: - detail["ip"] = str(target) - default_data[str(target)] = detail - elif should_query: - query += (str(target),) - - except ValueError: - pass - - return default_data, query - - -def parse_whois(output: str, targets: t.List[str]) -> TargetDetail: - """Parse raw whois output from bgp.tools. - - Sample output: - AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name - 13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | 2010-07-14 | Cloudflare, Inc. - """ - - def lines(raw): - """Generate clean string values for each column.""" - for r in (r for r in raw.split("\n") if r): - fields = (re.sub(r"(\n|\r)", "", field).strip(" ") for field in r.split("|")) - yield fields - - data = {} - - for line in lines(output): - # Unpack each line's parsed values. - asn, ip, prefix, country, rir, allocated, org = line - - # Match the line to the item in the list of resources to query. - if ip in targets: - i = targets.index(ip) - data[targets[i]] = { - "asn": asn, - "ip": ip, - "prefix": prefix, - "country": country, - "rir": rir, - "allocated": allocated, - "org": org, - } - log.bind(data=data).debug("Parsed bgp.tools data") - return data - - -async def run_whois(targets: t.List[str]) -> str: - """Open raw socket to bgp.tools and execute query.""" - - # Construct bulk query - query = "\n".join(("begin", *targets, "end\n")).encode() - - # Open the socket to bgp.tools - log.debug("Opening connection to bgp.tools") - reader, writer = await asyncio.open_connection("bgp.tools", port=43) - - # Send the query - writer.write(query) - if writer.can_write_eof(): - writer.write_eof() - await writer.drain() - - # Read the response - response = b"" - while True: - data = await reader.read(128) - if data: - response += data - else: - log.debug("Closing connection to bgp.tools") - writer.close() - break - - return response.decode() - - -async def network_info(*targets: str) -> TargetData: - """Get ASN, Containing Prefix, and other info about an internet resource.""" - - default_data, query_targets = default_ip_targets(*targets) - - cache = use_state("cache") - - # Set default data structure. - query_data = {t: dict.fromkeys(DEFAULT_KEYS, "") for t in query_targets} - - # Get all cached bgp.tools data. - cached = cache.get_map(CACHE_KEY) or {} - - # Try to use cached data for each of the items in the list of - # resources. - for target in (target for target in query_targets if target in cached): - # Reassign the cached network info to the matching resource. - query_data[target] = cached[target] - log.bind(target=target).debug("Using cached network info") - - # Remove cached items from the resource list so they're not queried. - targets = [t for t in query_targets if t not in cached] - - try: - if targets: - whoisdata = await run_whois(targets) - - if whoisdata: - # If the response is not empty, parse it. - query_data.update(parse_whois(whoisdata, targets)) - - # Cache the response - for target in targets: - cache.set_map_item(CACHE_KEY, target, query_data[target]) - log.bind(target=t).debug("Cached network info") - - except Exception as err: - log.error(err) - - return {**default_data, **query_data} - - -def network_info_sync(*targets: str) -> TargetData: - """Get ASN, Containing Prefix, and other info about an internet resource.""" - return asyncio.run(network_info(*targets)) diff --git a/hyperglass/external/ip_enrichment.py b/hyperglass/external/ip_enrichment.py new file mode 100644 index 000000000..1f52609df --- /dev/null +++ b/hyperglass/external/ip_enrichment.py @@ -0,0 +1,655 @@ +"""IP enrichment: ASN and IXP lookups for hyperglass. + +Uses bgp.tools for both ASN and IXP lookups. +Provides lookup_ip, lookup_asn_name and network_info compatibility APIs. +""" + +import asyncio +import typing as t +from ipaddress import ip_address, IPv4Address, IPv6Address +import socket + +from hyperglass.log import log +from hyperglass.settings import Settings + + +class IPInfo: + """Result of IP lookup.""" + + def __init__( + self, + ip: str, + asn: t.Optional[int] = None, + asn_name: t.Optional[str] = None, + prefix: t.Optional[str] = None, + country: t.Optional[str] = None, + is_ixp: bool = False, + ixp_name: t.Optional[str] = None, + ): + self.ip = ip + self.asn = asn + self.asn_name = asn_name + self.prefix = prefix + self.country = country + self.is_ixp = is_ixp + self.ixp_name = ixp_name + + +class IPEnrichmentService: + """Simplified IP enrichment service using only BGP.TOOLS for all lookups.""" + + def __init__(self): + # Runtime caches for performance + self._ip_cache: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + self.asn_info: t.Dict[int, t.Dict[str, str]] = {} # asn -> {name, country} + + async def _query_bgp_tools_for_ip( + self, ip_str: str + ) -> t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]: + """Query bgp.tools for a single IP. + + Returns (asn_int, asn_name, prefix) or (None, None, None) on failure. + """ + # Check cache first + if ip_str in self._ip_cache: + return self._ip_cache[ip_str] + + # Use TCP WHOIS on bgp.tools:43 + def _whois_blocking( + single_ips: t.List[str], + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + out: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + host = "bgp.tools" + port = 43 + send_keys = [f"AS{q}" if q.isdigit() else q for q in single_ips] + payload = "begin\n" + "\n".join(send_keys) + "\nend\n" + try: + with socket.create_connection((host, port), timeout=10) as s: + s.settimeout(10) + s.sendall(payload.encode("utf-8")) + parts = [] + try: + while True: + chunk = s.recv(4096) + if not chunk: + break + parts.append(chunk) + except socket.timeout: + pass + + raw = b"".join(parts).decode("utf-8", errors="replace") + # Parse lines like: "13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | ... | Cloudflare, Inc." + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + if "|" in line: + cols = [c.strip() for c in line.split("|")] + try: + asn = int(cols[0]) if cols[0].isdigit() else None + except Exception: + asn = None + ipcol = cols[1] if len(cols) > 1 else None + prefix = cols[2] if len(cols) > 2 else None + org = cols[-1] if len(cols) > 0 else None + if ipcol: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + else: + # Fallback parsing + parts_line = line.split() + if len(parts_line) >= 3: + try: + asn = int(parts_line[0]) + except Exception: + asn = None + ipcol = parts_line[1] + prefix = parts_line[2] + org = " ".join(parts_line[3:]) if len(parts_line) > 3 else None + if ipcol: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + + # Map results back to original query keys + mapped: t.Dict[ + str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]] + ] = {} + for orig, sent in zip(single_ips, send_keys): + if sent in out: + mapped[orig] = out[sent] + elif orig in out: + mapped[orig] = out[orig] + else: + if orig.isdigit(): + if f"AS{orig}" in out: + mapped[orig] = out[f"AS{orig}"] + elif orig in out: + mapped[orig] = out[orig] + else: + mapped[orig] = (None, None, None) + else: + mapped[orig] = (None, None, None) + return mapped + except Exception: + for ip in single_ips: + out[ip] = (None, None, None) + return out + + loop = asyncio.get_running_loop() + resp_map = await loop.run_in_executor(None, _whois_blocking, [ip_str]) + asn, org, prefix = resp_map.get(ip_str, (None, None, None)) + # Cache result + self._ip_cache[ip_str] = (asn, org, prefix) + return (asn, org, prefix) + + async def _query_bgp_tools_bulk( + self, ips: t.List[str] + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + """Query bgp.tools for multiple IPs using a single connection. + + Returns a mapping ip -> (asn, asn_name, prefix). + """ + + def _whois_bulk_blocking( + bulk_ips: t.List[str], + ) -> t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]]: + host = "bgp.tools" + port = 43 + out: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} + send_keys = [f"AS{q}" if q.isdigit() else q for q in bulk_ips] + payload = "begin\n" + "\n".join(send_keys) + "\nend\n" + try: + with socket.create_connection((host, port), timeout=15) as s: + s.settimeout(15) + s.sendall(payload.encode("utf-8")) + parts = [] + try: + while True: + chunk = s.recv(8192) + if not chunk: + break + parts.append(chunk) + if sum(len(p) for p in parts) > 512 * 1024: + break + except socket.timeout: + pass + + raw = b"".join(parts).decode("utf-8", errors="replace") + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + if "|" in line: + cols = [c.strip() for c in line.split("|")] + try: + asn = int(cols[0]) if cols[0].isdigit() else None + except Exception: + asn = None + ipcol = cols[1] if len(cols) > 1 else None + prefix = cols[2] if len(cols) > 2 else None + org = cols[-1] if len(cols) > 0 else None + # Check if this is an exchange IP (IXP) + is_ixp = any( + keyword in (org or "").lower() + for keyword in ["ix", "exchange", "peering"] + ) + if ipcol: + # If it's an IXP, mark it specially + if is_ixp: + out[ipcol] = ( + None, + org, + prefix, + ) # Use None for ASN to indicate IXP + else: + out[ipcol] = (asn, org, prefix) + else: + if asn is not None: + out_key1 = f"AS{asn}" + out_key2 = str(asn) + out[out_key1] = (asn, org, prefix) + out[out_key2] = (asn, org, prefix) + else: + parts_line = line.split() + if len(parts_line) >= 3: + try: + asn = int(parts_line[0]) + except Exception: + asn = None + ipcol = parts_line[1] + prefix = parts_line[2] + org = " ".join(parts_line[3:]) if len(parts_line) > 3 else None + out[ipcol] = (asn, org, prefix) + + # Map results back to original query keys + mapped: t.Dict[ + str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]] + ] = {} + for orig, sent in zip(bulk_ips, send_keys): + if sent in out: + mapped[orig] = out[sent] + elif orig in out: + mapped[orig] = out[orig] + else: + if orig.isdigit(): + if f"AS{orig}" in out: + mapped[orig] = out[f"AS{orig}"] + elif orig in out: + mapped[orig] = out[orig] + else: + mapped[orig] = (None, None, None) + else: + mapped[orig] = (None, None, None) + return mapped + except Exception: + for ip in bulk_ips: + out[ip] = (None, None, None) + return out + + loop = asyncio.get_running_loop() + resp = await loop.run_in_executor(None, _whois_bulk_blocking, ips) + return resp + + async def lookup_ips_bulk(self, ips: t.List[str]) -> t.Dict[str, IPInfo]: + """Bulk lookup for multiple IPs using BGP.TOOLS.""" + results: t.Dict[str, IPInfo] = {} + + # Prepare list for query + query_ips: t.List[str] = [] + for ip in ips: + try: + target_ip = ip_address(ip) + except Exception: + results[ip] = IPInfo(ip) + continue + + # Check for private/reserved addresses + if target_ip.is_private or target_ip.is_reserved or target_ip.is_loopback: + results[ip] = IPInfo(ip, asn=0, asn_name="Private", prefix="Private Network") + continue + + query_ips.append(ip) + + # Query BGP.TOOLS in bulk for all IPs + if query_ips: + bulk = await self._query_bgp_tools_bulk(query_ips) + for ip in query_ips: + asn, asn_name, prefix = bulk.get(ip, (None, None, None)) + + # Check if this is an IXP (indicated by None ASN from our parsing) + is_ixp = asn is None and asn_name is not None + + if is_ixp: + results[ip] = IPInfo(ip, is_ixp=True, ixp_name=asn_name) + elif asn: + try: + self.asn_info[int(asn)] = {"name": asn_name or f"AS{asn}", "country": ""} + except Exception: + pass + results[ip] = IPInfo(ip, asn=asn, asn_name=asn_name, prefix=prefix) + else: + results[ip] = IPInfo(ip, asn=0, asn_name="Unknown") + + return results + + async def lookup_ip(self, ip_str: str) -> IPInfo: + """Lookup an IP address and return ASN or IXP information.""" + if Settings.debug: + log.debug(f"Looking up IP {ip_str}") + + try: + target_ip = ip_address(ip_str) + except ValueError: + if Settings.debug: + log.debug(f"Invalid IP address: {ip_str}") + return IPInfo(ip_str) + + # Check private/reserved addresses + if target_ip.is_private or target_ip.is_reserved or target_ip.is_loopback: + if Settings.debug: + log.debug(f"IP {ip_str} is in private/reserved range") + return IPInfo(ip_str, asn=0, asn_name="Private", prefix="Private Network") + + # Query bgp.tools for the IP + try: + asn, asn_name, prefix = await self._query_bgp_tools_for_ip(ip_str) + + # Check if this is an IXP (indicated by keywords in organization name) + is_ixp = asn_name and any( + keyword in asn_name.lower() for keyword in ["ix", "exchange", "peering"] + ) + + if is_ixp: + if Settings.debug: + log.debug(f"Found IXP match for {ip_str}: {asn_name}") + return IPInfo(ip_str, is_ixp=True, ixp_name=asn_name) + elif asn: + # Cache result + try: + self.asn_info[int(asn)] = {"name": asn_name or f"AS{asn}", "country": ""} + except Exception: + pass + if Settings.debug: + log.debug(f"BGP.tools lookup for {ip_str}: AS{asn} ({asn_name})") + return IPInfo(ip_str, asn=asn, asn_name=asn_name, prefix=prefix) + except Exception as e: + if Settings.debug: + log.debug(f"BGP.tools lookup failed for {ip_str}: {e}") + + # No match found + if Settings.debug: + log.debug(f"No enrichment data found for {ip_str}") + return IPInfo(ip_str, asn=0, asn_name="Unknown") + + async def lookup_asn_name(self, asn: int) -> str: + """Get the organization name for an ASN.""" + asn_data = self.asn_info.get(int(asn), {}) + return asn_data.get("name") or f"AS{asn}" + + async def lookup_asn_country(self, asn: int) -> str: + """Get the country code for an ASN, if known.""" + asn_data = self.asn_info.get(int(asn), {}) + return asn_data.get("country") or "" + + +# Global service instance +_service = IPEnrichmentService() + + +# Public API functions +async def lookup_ip(ip_address: str) -> IPInfo: + """Lookup an IP address and return ASN or IXP information.""" + return await _service.lookup_ip(ip_address) + + +async def lookup_asn_name(asn: int) -> str: + """Get the organization name for an ASN number.""" + return await _service.lookup_asn_name(asn) + + +async def lookup_asn_country(asn: int) -> str: + """Get the country code for an ASN number.""" + return await _service.lookup_asn_country(asn) + + +async def lookup_asns_bulk(asns: t.List[t.Union[str, int]]) -> t.Dict[str, t.Dict[str, str]]: + """Bulk lookup ASN organization names and countries. + + Args: + asns: List of ASN numbers (as strings like "12345" or integers) + + Returns: + Dict mapping ASN string to {"name": org_name, "country": country_code} + Example: {"12345": {"name": "Example ISP", "country": "US"}} + """ + results = {} + + # Normalize ASN list to strings and filter invalids + requested: list[str] = [] + for asn in asns: + if asn is None: + continue + if str(asn) == "IXP": + continue + try: + _ = int(asn) + requested.append(str(asn)) + except (ValueError, TypeError): + continue + + # Identify ASNs missing a human-friendly name so we can attempt a live WHOIS + missing: list[str] = [] + for asn in requested: + try: + ai = _service.asn_info.get(int(asn), {}) + name = ai.get("name") if isinstance(ai, dict) else None + if not name or name == f"AS{asn}": + missing.append(asn) + except Exception: + missing.append(asn) + + # If we have missing ASNs, try a live bgp.tools WHOIS bulk query to fetch org names + if missing and hasattr(_service, "_query_bgp_tools_bulk"): + try: + log.debug("lookup_asns_bulk: querying bgp.tools for missing ASNs: {}", missing) + queries = [f"AS{a}" for a in missing] + resp = await _service._query_bgp_tools_bulk(queries) + # resp maps query -> (asn_int, org, prefix) + for asn in missing: + q = f"AS{asn}" + entry = resp.get(q) or resp.get(str(asn)) + if entry: + _, org, _ = entry + if org: + try: + _service.asn_info[int(asn)] = {"name": org, "country": ""} + log.debug("lookup_asns_bulk: updated asn_info[{}] = {}", asn, org) + except Exception: + pass + except Exception as e: + log.debug("lookup_asns_bulk: bgp.tools lookup failed: {}", e) + + # Build final results from asn_info (may include newly-populated entries) + for asn in requested: + try: + asn_int = int(asn) + asn_data = _service.asn_info.get(asn_int, {}) + results[asn] = { + "name": asn_data.get("name", f"AS{asn}"), + "country": asn_data.get("country", ""), + } + except Exception: + results[asn] = {"name": f"AS{asn}", "country": ""} + + return results + + +async def refresh_ip_enrichment_data() -> bool: + """Manually refresh IP enrichment data.""" + log.info("Manual refresh requested") + # Since we no longer cache data, just return True to indicate the feature is available + return True + + +def get_data_status() -> dict: + """Get status information about IP enrichment data.""" + status = { + "mode": "real-time", + "data_source": "BGP.TOOLS", + "cache_enabled": False, + "last_update": "Real-time lookups via BGP.TOOLS", + "data_counts": { + "cached_ips": len(_service._ip_cache), + "cached_asns": len(_service.asn_info), + }, + } + return status + + +# Compatibility functions for existing code +TargetDetail = t.TypedDict( + "TargetDetail", + { + "asn": str, + "ip": str, + "prefix": str, + "country": str, + "rir": str, + "allocated": str, + "org": str, + }, +) + +TargetData = t.Dict[str, TargetDetail] + + +def default_ip_targets(*targets: str) -> t.Tuple[TargetData, t.List[str]]: + """Filter targets and create default data for private/special addresses.""" + _log = log.bind(source="ip_enrichment") + + default_data: TargetData = {} + query_targets: t.List[str] = [] + + for target in targets: + try: + target_ip = ip_address(target) + + # Check for special address types + special_types = [ + (target_ip.is_loopback, "Loopback Address"), + (target_ip.is_multicast, "Multicast Address"), + (target_ip.is_link_local, "Link Local Address"), + (target_ip.is_private, "Private Address"), + (target_ip.version == 6 and target_ip.is_site_local, "Site Local Address"), + ] + + is_special = False + for check, rir_type in special_types: + if check: + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": rir_type, + "allocated": "None", + "org": "None", + } + is_special = True + break + + if not is_special and (target_ip.is_global or target_ip.is_unspecified): + query_targets.append(target) + elif not is_special: + # Other non-global addresses + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Reserved Address", + "allocated": "None", + "org": "None", + } + + except ValueError: + # Invalid IP address + default_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Invalid Address", + "allocated": "None", + "org": "None", + } + + return default_data, query_targets + + +async def network_info(*targets: str) -> TargetData: + """Get network information using IP enrichment - compatibility function.""" + _log = log.bind(source="ip_enrichment") + + default_data, query_targets = default_ip_targets(*targets) + + if not query_targets: + _log.debug("No valid global IPs to query") + return default_data + + try: + _log.info(f"Enriching {len(query_targets)} IP addresses using bulk lookup") + + # Use the bulk lookup to query bgp.tools efficiently + query_data = {} + bulk_results = await _service.lookup_ips_bulk(query_targets) + + for target, ip_info in bulk_results.items(): + # Convert to TargetDetail format + if ip_info.is_ixp and ip_info.ixp_name: + detail: TargetDetail = { + "asn": "IXP", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "IXP", + "allocated": "None", + "org": ip_info.ixp_name, + } + elif ip_info.asn is not None and ip_info.asn != 0: + detail = { + "asn": str(ip_info.asn), + "ip": target, + "prefix": ip_info.prefix or "None", + "country": ip_info.country or "None", + "rir": "UNKNOWN", + "allocated": "None", + "org": ip_info.asn_name or "None", + } + elif ip_info.asn == 0: + detail = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Unknown", + "allocated": "None", + "org": "None", + } + else: + detail = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Unknown", + "allocated": "None", + "org": "None", + } + + query_data[target] = detail + + if ip_info.is_ixp: + _log.debug(f"Enriched {target}: IXP={ip_info.ixp_name}") + elif ip_info.asn: + _log.debug(f"Enriched {target}: AS{ip_info.asn} ({ip_info.asn_name})") + else: + _log.debug(f"No enrichment data found for {target}") + + except Exception as e: + _log.error(f"Error in network_info lookup: {e}") + # Return default data for all targets on error + query_data = {} + for target in query_targets: + query_data[target] = { + "asn": "None", + "ip": target, + "prefix": "None", + "country": "None", + "rir": "Error", + "allocated": "None", + "org": "None", + } + + return {**default_data, **query_data} + + +def network_info_sync(*targets: str) -> TargetData: + """Synchronous wrapper for network_info.""" + return asyncio.run(network_info(*targets)) + + +async def network_info_single(target: str) -> TargetDetail: + """Get network information for a single IP address.""" + result = await network_info(target) + return result[target] diff --git a/hyperglass/external/rpki.py b/hyperglass/external/rpki.py index 139ccf8fc..a5c480513 100644 --- a/hyperglass/external/rpki.py +++ b/hyperglass/external/rpki.py @@ -3,57 +3,83 @@ # Standard Library import typing as t +# Third Party +import httpx + # Project from hyperglass.log import log from hyperglass.state import use_state from hyperglass.external._base import BaseExternal if t.TYPE_CHECKING: - # Standard Library from ipaddress import IPv4Address, IPv6Address -RPKI_STATE_MAP = {"Invalid": 0, "Valid": 1, "NotFound": 2, "DEFAULT": 3} -RPKI_NAME_MAP = {v: k for k, v in RPKI_STATE_MAP.items()} +# Maps normalized (lower-case, separators stripped) backend state names to the +# integer states hyperglass uses internally. +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "notfound": 2, + "unknown": 2, + "default": 3, +} +# Canonical integer -> display name, kept stable for logging. +RPKI_NAME_MAP = {0: "Invalid", 1: "Valid", 2: "NotFound", 3: "DEFAULT"} CACHE_KEY = "hyperglass.external.rpki" -def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, str]) -> int: +def _normalize_state(value: str) -> int: + """Normalize a backend RPKI state string to an internal integer state.""" + key = str(value).strip().lower().replace("-", "").replace("_", "") + return RPKI_STATE_MAP.get(key, 3) + + +def rpki_state( + prefix: t.Union["IPv4Address", "IPv6Address", str], + asn: t.Union[int, str], + backend: str = "cloudflare", + rpki_server_url: str = "", +) -> int: """Get RPKI state and map to expected integer.""" _log = log.bind(prefix=prefix, asn=asn) _log.debug("Validating RPKI State") cache = use_state("cache") - state = 3 ro = f"{prefix!s}@{asn!s}" cached = cache.get_map(CACHE_KEY, ro) - if cached is not None: state = cached else: - ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' - query = ql.format(prefix, asn) - _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") try: - with BaseExternal(base_url="https://rpki.cloudflare.com") as client: - response = client._post("/api/graphql", data={"query": query}) - try: + if backend == "cloudflare": + ql = 'query GetValidation {{ validation(prefix: "{}", asn: {}) {{ state }} }}' + query = ql.format(prefix, asn) + _log.bind(query=query).debug("Cloudflare RPKI GraphQL Query") + with BaseExternal(base_url="https://rpki.cloudflare.com") as client: + response = client._post("/api/graphql", data={"query": query}) validation_state = response["data"]["validation"]["state"] - except KeyError as missing: - _log.error("Response from Cloudflare missing key '{}': {!r}", missing, response) - validation_state = 3 + elif backend == "routinator": + url = f"{rpki_server_url.rstrip('/')}/validity" + _log.bind(url=url).debug("Routinator RPKI HTTP Query") + response = httpx.get( + url, params={"asn": str(asn), "prefix": str(prefix)}, timeout=5 + ) + response.raise_for_status() + data = response.json() + validation_state = data["validated_route"]["validity"]["state"] + else: + raise ValueError(f"Unknown RPKI backend: {backend}") - state = RPKI_STATE_MAP[validation_state] + state = _normalize_state(validation_state) cache.set_map_item(CACHE_KEY, ro, state) except Exception as err: log.error(err) - # Don't cache the state when an error produced it. state = 3 msg = "RPKI Validation State for {} via AS{} is {}".format(prefix, asn, RPKI_NAME_MAP[state]) if cached is not None: msg += " [CACHED]" - log.debug(msg) return state diff --git a/hyperglass/external/tests/test_bgptools.py b/hyperglass/external/tests/test_bgptools.py deleted file mode 100644 index 542c1dc28..000000000 --- a/hyperglass/external/tests/test_bgptools.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Test bgp.tools interactions.""" - -# Standard Library -import asyncio - -# Third Party -import pytest - -# Local -from ..bgptools import run_whois, parse_whois, network_info - -WHOIS_OUTPUT = """AS | IP | BGP Prefix | CC | Registry | Allocated | AS Name -13335 | 1.1.1.1 | 1.1.1.0/24 | US | ARIN | 2010-07-14 | Cloudflare, Inc.""" - - -# Ignore asyncio deprecation warning about loop -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_network_info(): - checks = ( - ("192.0.2.1", {"asn": "None", "rir": "Private Address"}), - ("127.0.0.1", {"asn": "None", "rir": "Loopback Address"}), - ("fe80:dead:beef::1", {"asn": "None", "rir": "Link Local Address"}), - ("2001:db8::1", {"asn": "None", "rir": "Private Address"}), - ("1.1.1.1", {"asn": "13335", "rir": "ARIN"}), - ) - for addr, fields in checks: - info = asyncio.run(network_info(addr)) - assert addr in info - for key, expected in fields.items(): - assert info[addr][key] == expected - - -# Ignore asyncio deprecation warning about loop -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_whois(): - addr = "192.0.2.1" - response = asyncio.run(run_whois([addr])) - assert isinstance(response, str) - assert response != "" - - -def test_whois_parser(): - addr = "1.1.1.1" - result = parse_whois(WHOIS_OUTPUT, [addr]) - assert isinstance(result, dict) - assert addr in result, "Address missing" - assert result[addr]["asn"] == "13335" - assert result[addr]["rir"] == "ARIN" - assert result[addr]["org"] == "Cloudflare, Inc." diff --git a/hyperglass/external/tests/test_rpki.py b/hyperglass/external/tests/test_rpki.py index 01438e717..d21d50559 100644 --- a/hyperglass/external/tests/test_rpki.py +++ b/hyperglass/external/tests/test_rpki.py @@ -19,8 +19,8 @@ def test_rpki(): result = rpki_state(prefix, asn) result_name = RPKI_NAME_MAP.get(result, "No Name") expected_name = RPKI_NAME_MAP.get(expected, "No Name") - assert result == expected, ( - "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( - prefix, asn, result, result_name, expected, expected_name - ) + assert ( + result == expected + ), "RPKI State for '{}' via AS{!s} '{}' ({}) instead of '{}' ({})".format( + prefix, asn, result, result_name, expected, expected_name ) diff --git a/hyperglass/models/api/query.py b/hyperglass/models/api/query.py index a8791bc06..f37b494d6 100644 --- a/hyperglass/models/api/query.py +++ b/hyperglass/models/api/query.py @@ -123,7 +123,49 @@ def dict(self) -> t.Dict[str, t.Union[t.List[str], str]]: @property def device(self) -> Device: """Get this query's device object by query_location.""" - return self._state.devices[self.query_location] + # Return a proxy around the device so we can override + # structured_output per-request without mutating global state. + device = self._state.devices[self.query_location] + + # Determine effective structured_output based on global params + try: + params = use_state("params") + except Exception: + params = None + + # Decide which top-level structured enable flag to consult + feature_flag_name = None + if getattr(self, "query_type", None) == "traceroute": + feature_flag_name = "enable_for_traceroute" + elif getattr(self, "query_type", None) in ("bgp_route", "bgp_routestr"): + feature_flag_name = "enable_for_bgp_route" + + effective_structured = bool(getattr(device, "structured_output", False)) + + if params is None or not getattr(params, "structured", None): + # Global structured block absent => structured disabled + effective_structured = False + else: + # If structured is present, default is enabled; allow opt-out + if feature_flag_name is not None: + if getattr(params.structured, feature_flag_name, None) is False: + effective_structured = False + + class _DeviceProxy: + """Tiny proxy object that delegates to the real device but + overrides structured_output.""" + + def __init__(self, real, structured_value: bool) -> None: + self._real = real + self.structured_output = structured_value + + def __getattr__(self, name: str): + return getattr(self._real, name) + + def __repr__(self) -> str: # pragma: no cover - trivial + return repr(self._real) + + return _DeviceProxy(device, effective_structured) @field_validator("query_location") def validate_query_location(cls, value): diff --git a/hyperglass/models/config/devices.py b/hyperglass/models/config/devices.py index 70cac21ee..b633541fd 100644 --- a/hyperglass/models/config/devices.py +++ b/hyperglass/models/config/devices.py @@ -247,10 +247,10 @@ def validate_structured_output(cls, value: bool, info: ValidationInfo) -> bool: if value is True: if info.data.get("platform") not in SUPPORTED_STRUCTURED_OUTPUT: raise ConfigError( - "The 'structured_output' field is set to 'true' on device '{}' with " - + "platform '{}', which does not support structured output", - info.data.get("name"), - info.data.get("platform"), + "The 'structured_output' field is set to 'true' on device '{d}' with " + "platform '{p}', which does not support structured output", + d=info.data.get("name"), + p=info.data.get("platform"), ) return value if value is None and info.data.get("platform") in SUPPORTED_STRUCTURED_OUTPUT: diff --git a/hyperglass/models/config/params.py b/hyperglass/models/config/params.py index 1e35ba2b0..6fabf0bc9 100644 --- a/hyperglass/models/config/params.py +++ b/hyperglass/models/config/params.py @@ -83,12 +83,12 @@ class Params(ParamsPublic, HyperglassModel): plugins: t.List[str] = [] # Sub Level Params - cache: Cache = Cache() - docs: Docs = Docs() - logging: Logging = Logging() - messages: Messages = Messages() - structured: Structured = Structured() - web: Web = Web() + cache: Cache = Field(default_factory=Cache) + docs: Docs = Field(default_factory=Docs) + logging: Logging = Field(default_factory=Logging) + messages: Messages = Field(default_factory=Messages) + structured: Structured = Field(default_factory=Structured) + web: Web = Field(default_factory=Web) def __init__(self, **kw: t.Any) -> None: return super().__init__(**self.convert_paths(kw)) diff --git a/hyperglass/models/config/structured.py b/hyperglass/models/config/structured.py index 4373e8187..692b800d2 100644 --- a/hyperglass/models/config/structured.py +++ b/hyperglass/models/config/structured.py @@ -3,11 +3,15 @@ # Standard Library import typing as t +# Third Party +from pydantic import ValidationInfo, field_validator, model_validator + # Local from ..main import HyperglassModel -StructuredCommunityMode = t.Literal["permit", "deny"] +StructuredCommunityMode = t.Literal["permit", "deny", "name"] StructuredRPKIMode = t.Literal["router", "external"] +StructuredRPKIBackend = t.Literal["cloudflare", "routinator"] class StructuredCommunities(HyperglassModel): @@ -15,12 +19,45 @@ class StructuredCommunities(HyperglassModel): mode: StructuredCommunityMode = "deny" items: t.List[str] = [] + names: t.Dict[str, str] = {} + + @field_validator("names") + def validate_names(cls, value: t.Dict[str, str], info: ValidationInfo) -> t.Dict[str, str]: + """Require at least one community mapping when mode is 'name'.""" + if info.data and info.data.get("mode") == "name" and not value: + raise ValueError( + "When using mode 'name', at least one community mapping must be " + "provided in 'names'" + ) + return value class StructuredRpki(HyperglassModel): """Control structured data response for RPKI state.""" mode: StructuredRPKIMode = "router" + backend: StructuredRPKIBackend = "cloudflare" + rpki_server_url: str = "" + + @model_validator(mode="after") + def validate_routinator_url(self) -> "StructuredRpki": + """Require a server URL when the routinator backend is selected.""" + if self.backend == "routinator" and not self.rpki_server_url: + raise ValueError( + "structured.rpki.rpki_server_url is required when backend is 'routinator'" + ) + return self + + +class StructuredIpEnrichment(HyperglassModel): + """Control IP enrichment for structured data responses using real-time BGP.tools lookups.""" + + enrich_traceroute: bool = False + """Enable ASN/org/IP enrichment for traceroute hops using real-time BGP.tools data.""" + + enrich_bgproute: bool = False + """Enable early ASN/org enrichment for BGP routes. When true, enrichment happens immediately + on query response. When false, enrichment is lazy-loaded when viewing the AS Path.""" class Structured(HyperglassModel): @@ -28,3 +65,11 @@ class Structured(HyperglassModel): communities: StructuredCommunities = StructuredCommunities() rpki: StructuredRpki = StructuredRpki() + ip_enrichment: StructuredIpEnrichment = StructuredIpEnrichment() + + # Top-level structured enable/disable flags. If `structured:` is present in + # the user's config and these are not set (None), the structured table + # output is considered enabled by default. Setting them to False disables + # the structured table output even when a `structured:` block exists. + enable_for_traceroute: t.Optional[bool] = None + enable_for_bgp_route: t.Optional[bool] = None diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index 7916d9e9b..f6881935c 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -137,6 +137,7 @@ class ThemeColors(HyperglassModel): dark: Color = "#010101" light: Color = "#f5f6f7" gray: Color = "#c1c7cc" + filtered: Color = "#c1c7cc" red: Color = "#d84b4b" orange: Color = "#ff6b35" yellow: Color = "#edae49" diff --git a/hyperglass/models/data/__init__.py b/hyperglass/models/data/__init__.py index 02d8f276d..9e0a0be52 100644 --- a/hyperglass/models/data/__init__.py +++ b/hyperglass/models/data/__init__.py @@ -4,11 +4,14 @@ from typing import Union # Local -from .bgp_route import BGPRouteTable +from .bgp_route import BGPRoute, BGPRouteTable +from .traceroute import TracerouteResult -OutputDataModel = Union[BGPRouteTable] +OutputDataModel = Union[BGPRouteTable, TracerouteResult] __all__ = ( + "BGPRoute", "BGPRouteTable", + "TracerouteResult", "OutputDataModel", ) diff --git a/hyperglass/models/data/bgp_route.py b/hyperglass/models/data/bgp_route.py index a00af4e13..b8a67275e 100644 --- a/hyperglass/models/data/bgp_route.py +++ b/hyperglass/models/data/bgp_route.py @@ -11,6 +11,7 @@ # Project from hyperglass.state import use_state from hyperglass.external.rpki import rpki_state +from hyperglass.external.ip_enrichment import TargetDetail # Local from ..main import HyperglassModel @@ -34,6 +35,12 @@ class BGPRoute(HyperglassModel): source_rid: str peer_rid: str rpki_state: int + filtered: bool = False + + # IP enrichment data (optional) + next_hop_asn: t.Optional[str] = None + next_hop_org: t.Optional[str] = None + next_hop_country: t.Optional[str] = None @field_validator("communities") def validate_communities(cls, value): @@ -42,6 +49,7 @@ def validate_communities(cls, value): Actions: permit: only permit matches deny: only deny matches + name: append friendly names to matching communities """ (structured := use_state("params").structured) @@ -64,6 +72,15 @@ def _deny(comm): break return valid + def _name(comm): + """Append a friendly name to a community when one is mapped.""" + if comm in structured.communities.names: + return f"{comm},{structured.communities.names[comm]}" + return comm + + if structured.communities.mode == "name": + return [_name(c) for c in value] + func_map = {"permit": _permit, "deny": _deny} func = func_map[structured.communities.mode] @@ -80,10 +97,9 @@ def validate_rpki_state(cls, value, info: ValidationInfo): return value if structured.rpki.mode == "external": - # If external validation is enabled, validate the prefix - # & asn with Cloudflare's RPKI API. + # If external validation is enabled, validate the prefix & asn + # with the configured RPKI backend. as_path = info.data.get("as_path", []) - if len(as_path) == 0: # If the AS_PATH length is 0, i.e. for an internal route, # return RPKI Unknown state. @@ -91,17 +107,29 @@ def validate_rpki_state(cls, value, info: ValidationInfo): # Get last ASN in path asn = as_path[-1] - try: - net = ip_network(info.data["prefix"]) - except ValueError: - return 3 + try: + net = ip_network(info.data["prefix"]) + except ValueError: + return 3 - # Only do external RPKI lookups for global prefixes. - if net.is_global: - return rpki_state(prefix=info.data["prefix"], asn=asn) + # Only do external RPKI lookups for global prefixes. + if net.is_global: + return rpki_state( + prefix=info.data["prefix"], + asn=asn, + backend=structured.rpki.backend, + rpki_server_url=structured.rpki.rpki_server_url, + ) return value + @property + def as_path_summary(self) -> str: + """Summary of AS path.""" + if not self.as_path: + return "Unknown" + return " -> ".join([f"AS{asn}" for asn in self.as_path]) + class BGPRouteTable(HyperglassModel): """Post-parsed BGP route table.""" @@ -110,6 +138,7 @@ class BGPRouteTable(HyperglassModel): count: int = 0 routes: t.List[BGPRoute] winning_weight: WinningWeight + asn_organizations: t.Dict[str, t.Dict[str, str]] = {} # ASN -> {name, country} def __init__(self, **kwargs): """Sort routes by prefix after validation.""" @@ -122,3 +151,62 @@ def __add__(self: "BGPRouteTable", other: "BGPRouteTable") -> "BGPRouteTable": self.routes = sorted([*self.routes, *other.routes], key=lambda r: r.prefix) self.count = len(self.routes) return self + + async def enrich_with_ip_enrichment(self): + """Enrich BGP routes with next-hop information from IP enrichment.""" + from hyperglass.external.ip_enrichment import network_info + + # Extract unique next-hop IPs that need enrichment + next_hops_to_lookup = set() + for route in self.routes: + if route.next_hop and not route.next_hop_asn: # Only lookup if not already enriched + next_hops_to_lookup.add(route.next_hop) + + if not next_hops_to_lookup: + return + + # Bulk lookup next-hop information + network_data = await network_info(*list(next_hops_to_lookup)) + + # Enrich routes with the retrieved data + for route in self.routes: + if route.next_hop in network_data: + data: TargetDetail = network_data[route.next_hop] + # Handle ASN formatting + asn_raw = data.get("asn") + if asn_raw and asn_raw != "None": + route.next_hop_asn = f"AS{asn_raw}" + else: + route.next_hop_asn = None + + route.next_hop_org = data.get("org") if data.get("org") != "None" else None + route.next_hop_country = ( + data.get("country") if data.get("country") != "None" else None + ) + + async def enrich_as_path_organizations(self): + """Enrich AS path ASNs with organization names using bulk lookup.""" + from hyperglass.external.ip_enrichment import lookup_asns_bulk + from hyperglass.log import log + + _log = log.bind(source="bgp_asn_enrichment") + + # Collect all unique ASNs from AS paths + all_asns = set() + for route in self.routes: + all_asns.update(route.as_path) + + if not all_asns: + _log.debug("No AS paths found to enrich") + return + + # Convert to strings and bulk lookup + asn_strings = [str(asn) for asn in all_asns] + + try: + asn_data = await lookup_asns_bulk(asn_strings) + # Store the ASN organization mapping for use by frontend + self.asn_organizations = asn_data + except Exception as e: + _log.error(f"Failed to lookup ASN organizations: {e}") + self.asn_organizations = {} diff --git a/hyperglass/models/data/traceroute.py b/hyperglass/models/data/traceroute.py new file mode 100644 index 000000000..e2d929b35 --- /dev/null +++ b/hyperglass/models/data/traceroute.py @@ -0,0 +1,252 @@ +"""Traceroute Data Models.""" + +# Standard Library +import typing as t +from ipaddress import ip_address, AddressValueError + +# Third Party +from pydantic import field_validator, computed_field + +# Project +from hyperglass.external.ip_enrichment import TargetDetail + +# Local +from ..main import HyperglassModel + + +class TracerouteHop(HyperglassModel): + """Individual hop in a traceroute.""" + + hop_number: int + ip_address: t.Optional[str] = None + display_ip: t.Optional[str] = None # For truncated IPs that can't be validated + hostname: t.Optional[str] = None + rtt1: t.Optional[float] = None + rtt2: t.Optional[float] = None + rtt3: t.Optional[float] = None + + # MikroTik-specific statistics + loss_pct: t.Optional[int] = None + sent_count: t.Optional[int] = None + last_rtt: t.Optional[float] = None + best_rtt: t.Optional[float] = None + worst_rtt: t.Optional[float] = None + + # IP enrichment data + asn: t.Optional[str] = None + org: t.Optional[str] = None + prefix: t.Optional[str] = None + country: t.Optional[str] = None + rir: t.Optional[str] = None + allocated: t.Optional[str] = None + + @field_validator("ip_address") + def validate_ip_address(cls, value): + """Validate IP address format.""" + if value is not None: + # Handle truncated addresses (MikroTik sometimes truncates long IPv6 addresses with ...) + if value.endswith("...") or value.endswith(".."): + return None # Invalid for BGP enrichment but kept in display_ip + try: + ip_address(value) + except AddressValueError: + return None + return value + + @property + def ip_display(self) -> t.Optional[str]: + """Get the IP address for display purposes (may be truncated).""" + return self.display_ip or self.ip_address + + @computed_field + @property + def avg_rtt(self) -> t.Optional[float]: + """Calculate average RTT from available measurements.""" + rtts = [rtt for rtt in [self.rtt1, self.rtt2, self.rtt3] if rtt is not None] + return sum(rtts) / len(rtts) if rtts else None + + @property + def is_timeout(self) -> bool: + """Check if this hop is a timeout (no IP and no RTTs).""" + return self.ip_address is None and all( + rtt is None for rtt in [self.rtt1, self.rtt2, self.rtt3] + ) + + @property + def asn_display(self) -> str: + """Display ASN - just the number, no AS prefix.""" + if self.asn and self.asn != "None": + if self.asn == "IXP": + # For IXPs, show "IXP" with org if available + if self.org and self.org != "None": + return f"IXP ({self.org})" + return "IXP" + else: + # For ASNs, show just the number with org if available + if self.org and self.org != "None": + return f"{self.asn} ({self.org})" + return self.asn + return "Unknown" + + +class TracerouteResult(HyperglassModel): + """Complete traceroute result.""" + + target: str + source: str + hops: t.List[TracerouteHop] + max_hops: int = 30 + packet_size: int = 60 + raw_output: t.Optional[str] = ( + None # Store cleaned/processed output for "Copy Raw" functionality (not original raw router output) + ) + asn_organizations: t.Dict[str, t.Dict[str, str]] = {} # ASN -> {name, country} + + @property + def hop_count(self) -> int: + """Total number of hops.""" + return len(self.hops) + + @property + def unique_asns(self) -> t.List[str]: + """List of unique ASNs encountered in the path.""" + asns = set() + for hop in self.hops: + if hop.asn and hop.asn != "None": + asns.add(hop.asn) + return sorted(list(asns)) + + @property + def as_path_summary(self) -> str: + """Summary of AS path traversed.""" + as_path = [] + current_asn = None + + for hop in self.hops: + if hop.asn and hop.asn not in ["None", None] and hop.asn != current_asn: + current_asn = hop.asn + # hop.asn is now just number ("12345") or "IXP" - display as-is + as_path.append(hop.asn) + + return " -> ".join(as_path) if as_path else "Unknown" + + @property + def as_path_detailed(self) -> str: + """Detailed AS path with organization names.""" + as_path = [] + current_asn = None + current_org = None + + for hop in self.hops: + if hop.asn and hop.asn not in ["None", None] and hop.asn != current_asn: + current_asn = hop.asn # Just number ("12345") or "IXP" + current_org = hop.org + + # Format with org name if we have it + if current_org and current_org not in ["None", None]: + if current_asn == "IXP": + as_path.append(f"IXP ({current_org})") + else: + as_path.append(f"{current_asn} ({current_org})") + else: + as_path.append(current_asn) + + return " -> ".join(as_path) if as_path else "Unknown" + + @property + def as_path_data(self) -> t.List[t.Dict[str, t.Union[str, None]]]: + """AS path data as structured list for frontend visualization.""" + as_path_data = [] + current_asn = None + current_org = None + + for hop in self.hops: + if hop.asn and hop.asn not in ["None", None] and hop.asn != current_asn: + current_asn = hop.asn # Just number ("12345") or "IXP" + current_org = hop.org + + as_path_data.append( + { + "asn": current_asn, + "org": current_org if current_org and current_org != "None" else None, + } + ) + + return as_path_data + + async def enrich_with_ip_enrichment(self): + """Enrich traceroute hops with IP enrichment data.""" + from hyperglass.external.ip_enrichment import network_info + + # Extract all IP addresses that need enrichment + ips_to_lookup = [] + for hop in self.hops: + if hop.ip_address and hop.asn is None: # Only lookup if not already enriched + ips_to_lookup.append(hop.ip_address) + + if not ips_to_lookup: + return + + # Bulk lookup IP information + network_data = await network_info(*ips_to_lookup) + + # Enrich hops with the retrieved data + for hop in self.hops: + if hop.ip_address in network_data: + data: TargetDetail = network_data[hop.ip_address] + # ASN field is now just number string ("12345") or "IXP" + asn_value = data.get("asn") + if asn_value and asn_value != "None": + hop.asn = asn_value # Store as-is: "12345" or "IXP" + else: + hop.asn = None + + hop.org = data.get("org") if data.get("org") != "None" else None + hop.prefix = data.get("prefix") if data.get("prefix") != "None" else None + hop.country = data.get("country") if data.get("country") != "None" else None + hop.rir = data.get("rir") if data.get("rir") != "None" else None + hop.allocated = data.get("allocated") if data.get("allocated") != "None" else None + + async def enrich_asn_organizations(self): + """Enrich ASN organization names using bulk ASN lookup.""" + from hyperglass.external.ip_enrichment import lookup_asns_bulk + from hyperglass.log import log + + _log = log.bind(source="traceroute_asn_enrichment") + + # Collect all unique ASNs that need organization info + asns_to_lookup = [] + for hop in self.hops: + if hop.asn and hop.asn != "None" and hop.asn != "IXP": + asns_to_lookup.append(hop.asn) + _log.debug(f"Hop {hop.hop_number}: ASN={hop.asn}, current org='{hop.org}'") + + if not asns_to_lookup: + _log.debug("No ASNs to lookup") + return + + # Remove duplicates while preserving order + unique_asns = list(dict.fromkeys(asns_to_lookup)) + _log.info(f"Looking up organizations for {len(unique_asns)} unique ASNs: {unique_asns}") + + # Bulk lookup ASN organization data + asn_data = await lookup_asns_bulk(unique_asns) + _log.debug(f"Got ASN data: {asn_data}") + + # Apply the organization data to hops + for hop in self.hops: + if hop.asn and hop.asn in asn_data: + data = asn_data[hop.asn] + org_name = data.get("name") if data.get("name") != f"AS{hop.asn}" else None + + _log.debug( + f"Hop {hop.hop_number} ASN {hop.asn}: setting org='{org_name}' (was '{hop.org}')" + ) + + # Always update org from ASN data (more accurate than IP-based org) + hop.org = org_name + if not hop.country: # Only set country if not already set + hop.country = data.get("country") or None + + # Store the ASN organization mapping for frontend path visualization + self.asn_organizations = asn_data diff --git a/hyperglass/models/parsing/huawei.py b/hyperglass/models/parsing/huawei.py new file mode 100644 index 000000000..357f121b5 --- /dev/null +++ b/hyperglass/models/parsing/huawei.py @@ -0,0 +1,394 @@ +"""Parser for Huawei VRP.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import ConfigDict, field_validator, model_validator + +# Project +from hyperglass.log import log +from hyperglass.models.data.bgp_route import BGPRoute, BGPRouteTable + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +def remove_prefix(text: str, prefix: str) -> str: + """Remove prefix from text if it exists.""" + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +class HuaweiBase(HyperglassModel, extra="ignore"): + """Base Huawei model.""" + + def __init__(self, **kwargs: t.Any) -> None: + """Initialize Huawei model.""" + super().__init__(**kwargs) + + +class HuaweiPaths(HuaweiBase): + """BGP paths information.""" + + available: int = 0 + best: int = 0 + select: int = 0 + best_external: int = 0 + add_path: int = 0 + + +class HuaweiRouteEntry(HuaweiBase): + """Parse Huawei route entry data.""" + + model_config = ConfigDict(validate_assignment=False) + + prefix: str + from_addr: str = "" + duration: int = 0 + direct_out_interface: str = "" + original_next_hop: str = "" + relay_ip_next_hop: str = "" + relay_ip_out_interface: str = "" + qos: str = "" + communities: t.List[str] = [] + large_communities: t.List[str] = [] + ext_communities: t.List[str] = [] + as_path: t.List[int] = [] + origin: str = "" + metric: int = 0 # MED + local_preference: int = 100 + preference_value: int = 0 + is_valid: bool = False + is_external: bool = False + is_backup: bool = False + is_best: bool = False + is_selected: bool = False + preference: int = 0 + + @property + def next_hop(self) -> str: + """Get next hop (original or relay IP).""" + return self.original_next_hop or self.relay_ip_next_hop + + @property + def age(self) -> str: + """Get age as string.""" + return str(self.duration) + + @property + def weight(self) -> int: + """Get weight (preference).""" + return self.preference + + @property + def med(self) -> int: + """Get MED (metric).""" + return self.metric + + @property + def active(self) -> bool: + """Check if route is active (best or selected).""" + return self.is_best or self.is_selected + + @property + def all_communities(self) -> t.List[str]: + """Get all communities combined.""" + return self.communities + self.large_communities + self.ext_communities + + @property + def source_as(self) -> int: + """Get source AS from AS path (first ASN in the path).""" + return self.as_path[0] if self.as_path else 0 + + @property + def source_rid(self) -> str: + """Get source router ID.""" + return "" + + @property + def peer_rid(self) -> str: + """Get peer router ID.""" + return self.from_addr + + +def _extract_paths(line: str) -> HuaweiPaths: + """Extract paths information from line like 'Paths: 3 available, 1 best, 1 select, 0 best-external, 0 add-path'.""" + paths_data = { + "available": 0, + "best": 0, + "select": 0, + "best_external": 0, + "add_path": 0, + } + + try: + values = remove_prefix(line.strip(), "Paths:").strip().split(",") + for value in values: + parts = value.strip().split(" ") + if len(parts) >= 2: + count = int(parts[0]) + name = parts[1].replace("-", "_") # Convert best-external to best_external + if name in paths_data: + paths_data[name] = count + except (ValueError, IndexError): + log.warning(f"Failed to parse paths line: {line}") + + return HuaweiPaths(**paths_data) + + +def _extract_route_entries(lines: t.List[str]) -> t.List[HuaweiRouteEntry]: + """Extract route entries from lines.""" + routes = [] + + # Split lines into route blocks using empty lines as separators + size = len(lines) + idx_list = [idx + 1 for idx, val in enumerate(lines) if val.strip() == ""] + entries = ( + [ + lines[i:j] + for i, j in zip([0] + idx_list, idx_list + ([size] if idx_list[-1] != size else [])) + ] + if idx_list + else [lines] + ) + + for route_entry in entries: + if not route_entry: + continue + + # Initialize route data + route_data = { + "prefix": "", + "from_addr": "", + "duration": 0, + "direct_out_interface": "", + "original_next_hop": "", + "relay_ip_next_hop": "", + "relay_ip_out_interface": "", + "qos": "", + "communities": [], + "large_communities": [], + "ext_communities": [], + "as_path": [], + "origin": "", + "metric": 0, + "local_preference": 100, + "preference_value": 0, + "is_valid": False, + "is_external": False, + "is_backup": False, + "is_best": False, + "is_selected": False, + "preference": 0, + } + + for info in route_entry: + info = info.strip() + if not info: + continue + + if info.startswith("BGP routing table entry information of"): + route_data["prefix"] = remove_prefix( + info, "BGP routing table entry information of " + ).rstrip(":") + elif info.startswith("From:"): + route_data["from_addr"] = remove_prefix(info, "From: ").split(" (")[0] + elif info.startswith("Route Duration:"): + duration_str = remove_prefix(info, "Route Duration: ") + try: + # Parse format like "84d11h53m07s" + d_match = re.search(r"(\d+)d", duration_str) + h_match = re.search(r"(\d+)h", duration_str) + m_match = re.search(r"(\d+)m", duration_str) + s_match = re.search(r"(\d+)s", duration_str) + + days = int(d_match.group(1)) if d_match else 0 + hours = int(h_match.group(1)) if h_match else 0 + minutes = int(m_match.group(1)) if m_match else 0 + seconds = int(s_match.group(1)) if s_match else 0 + + route_data["duration"] = ( + days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60 + seconds + ) + except: + route_data["duration"] = 0 + elif info.startswith("Direct Out-interface:"): + route_data["direct_out_interface"] = remove_prefix(info, "Direct Out-interface: ") + elif info.startswith("Original nexthop:"): + route_data["original_next_hop"] = remove_prefix(info, "Original nexthop: ") + elif info.startswith("Relay IP Nexthop:"): + route_data["relay_ip_next_hop"] = remove_prefix(info, "Relay IP Nexthop: ") + elif info.startswith("Relay IP Out-Interface:"): + route_data["relay_ip_out_interface"] = remove_prefix( + info, "Relay IP Out-Interface: " + ) + elif info.startswith("Qos information :"): + route_data["qos"] = remove_prefix(info, "Qos information : ") + elif info.startswith("Community:"): + communities_str = remove_prefix(info, "Community: ") + if communities_str and communities_str.lower() != "none": + communities = [ + c.strip().replace("<", "").replace(">", "") + for c in communities_str.split(", ") + ] + route_data["communities"] = [c for c in communities if c] + elif info.startswith("Large-Community:"): + large_communities_str = remove_prefix(info, "Large-Community: ") + if large_communities_str and large_communities_str.lower() != "none": + large_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in large_communities_str.split(", ") + ] + route_data["large_communities"] = [c for c in large_communities if c] + elif info.startswith("Ext-Community:"): + ext_communities_str = remove_prefix(info, "Ext-Community: ") + if ext_communities_str and ext_communities_str.lower() != "none": + ext_communities = [ + c.strip().replace("<", "").replace(">", "") + for c in ext_communities_str.split(", ") + ] + route_data["ext_communities"] = [c for c in ext_communities if c] + elif info.startswith("AS-path"): + values = info.split(",") + for v in values: + v = v.strip() + if v.startswith("AS-path"): + as_path_str = remove_prefix(v, "AS-path ") + try: + route_data["as_path"] = [ + int(a) for a in as_path_str.split() if a.isdigit() + ] + except ValueError: + route_data["as_path"] = [] + elif v.startswith("origin"): + route_data["origin"] = remove_prefix(v, "origin ") + elif v.startswith("MED"): + try: + route_data["metric"] = int(remove_prefix(v, "MED ")) + except ValueError: + route_data["metric"] = 0 + elif v.startswith("localpref"): + try: + route_data["local_preference"] = int(remove_prefix(v, "localpref ")) + except ValueError: + route_data["local_preference"] = 100 + elif v.startswith("pref-val"): + try: + route_data["preference_value"] = int(remove_prefix(v, "pref-val ")) + except ValueError: + route_data["preference_value"] = 0 + elif v.startswith("pre "): + try: + route_data["preference"] = int(remove_prefix(v, "pre ")) + except ValueError: + route_data["preference"] = 0 + elif v.strip() == "valid": + route_data["is_valid"] = True + elif v.strip() == "external": + route_data["is_external"] = True + elif v.strip() == "backup": + route_data["is_backup"] = True + elif v.strip() == "best": + route_data["is_best"] = True + elif v.strip() == "select": + route_data["is_selected"] = True + + # Only add route if we have a valid prefix + if route_data["prefix"]: + try: + route = HuaweiRouteEntry(**route_data) + routes.append(route) + except Exception as e: + log.warning( + "Failed to create route entry for prefix {}: {}", + route_data.get("prefix", "unknown"), + e, + ) + continue + + return routes + + +class HuaweiBGPRouteTable(BGPRouteTable): + """Canonical Huawei BGP Route Table.""" + + # No custom __init__ needed; inherit from BGPRouteTable (which should be a Pydantic model) + + +class HuaweiBGPTable(HuaweiBase): + """Validation model for Huawei BGP table data.""" + + local_router_id: str = "" + local_as_number: int = 0 + paths: HuaweiPaths = HuaweiPaths() + routes: t.List[HuaweiRouteEntry] = [] + + @classmethod + def parse_text(cls, text: str) -> "HuaweiBGPTable": + """Parse Huawei BGP text output.""" + _log = log.bind(parser="HuaweiBGPTable") + + instance = cls() + + lines = text.split("\n") + + # Extract general information + for line in lines: + if "BGP local router ID" in line: + instance.local_router_id = remove_prefix(line, "BGP local router ID : ").strip() + elif "Local AS number" in line: + try: + instance.local_as_number = int( + remove_prefix(line, "Local AS number : ").strip() + ) + except ValueError: + instance.local_as_number = 0 + elif line.strip().startswith("Paths:"): + instance.paths = _extract_paths(line) + + # Extract route entries + instance.routes = _extract_route_entries(lines) + + _log.debug(f"Parsed {len(instance.routes)} Huawei routes") + return instance + + def bgp_table(self) -> BGPRouteTable: + """Convert to standard BGP table format.""" + routes = [] + + for route in self.routes: + route_data = { + "prefix": route.prefix, + "active": route.active, + "age": route.age, + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.all_communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": ( + RPKI_STATE_MAP["valid"] if route.is_valid else RPKI_STATE_MAP["unknown"] + ), + } + routes.append(BGPRoute(**route_data)) + + return HuaweiBGPRouteTable( + vrf="default", + count=len(routes), + routes=routes, + winning_weight="high", + ) diff --git a/hyperglass/models/parsing/mikrotik.py b/hyperglass/models/parsing/mikrotik.py new file mode 100644 index 000000000..1aea80f7d --- /dev/null +++ b/hyperglass/models/parsing/mikrotik.py @@ -0,0 +1,561 @@ +"""Parser for MikroTik RouterOS (ROS v6/v7) – structured in Huawei style.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import ConfigDict + +# Project +from hyperglass.log import log +from hyperglass.settings import Settings +from hyperglass.models.data.bgp_route import BGPRoute, BGPRouteTable # Add BGPRoute import + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +def remove_prefix(text: str, prefix: str) -> str: + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +# Regex to find key=value pairs. The key can contain dots and hyphens. +# The value can be quoted or a single word. +TOKEN_RE = re.compile(r'([a-zA-Z0-9_.-]+)=(".*?"|\S+)') + +# Regex to find flags at the beginning of a line (e.g., "Ab dst-address=..."). +# Include filtered/disabled/unreachable flags so those routes split into their own entries. +FLAGS_RE = re.compile(r"^\s*([DXUuIAFfcmsroivmyH\+b]+)\s+") + + +class MikrotikBase(HyperglassModel, extra="ignore"): + """Base model for MikroTik parsing models (ignores unknown fields).""" + + +class MikrotikPaths(MikrotikBase): + available: int = 0 + best: int = 0 + select: int = 0 + best_external: int = 0 + add_path: int = 0 + + +class MikrotikRouteEntry(MikrotikBase): + """MikroTik Route Entry.""" + + model_config = ConfigDict(validate_assignment=False) + + prefix: str + gateway: str = "" + distance: int = 0 + scope: int = 0 + target_scope: int = 0 + as_path: t.List[int] = [] + communities: t.List[str] = [] + large_communities: t.List[str] = [] + ext_communities: t.List[str] = [] + local_preference: int = 100 + metric: int = 0 # MED + origin: str = "" + belongs_to: str = "" # BGP session identifier + is_active: bool = False + is_filtered: bool = False + rpki_state: int = RPKI_STATE_MAP.get("unknown", 2) + + @property + def next_hop(self) -> str: + return self.gateway + + @property + def age(self) -> int: + # MikroTik output does not provide route age, returning -1 to indicate unavailable. + return -1 + + @property + def weight(self) -> int: + return self.distance + + @property + def med(self) -> int: + return self.metric + + @property + def active(self) -> bool: + return self.is_active + + @property + def all_communities(self) -> t.List[str]: + return self.communities + self.large_communities + self.ext_communities + + @property + def source_as(self) -> int: + return self.as_path[-1] if self.as_path else 0 + + @property + def source_rid(self) -> str: + """Extract the IP/IPv6 address from belongs-to field. + + Examples: + belongs-to="bgp-IP-196.60.96.109" -> "196.60.96.109" + belongs-to="bgp-IPv6-2001:43f8:6d0::11:141" -> "2001:43f8:6d0::11:141" + """ + if not self.belongs_to: + return "" + + # Remove bgp-IP- or bgp-IPv6- prefix + if self.belongs_to.startswith("bgp-IP-"): + return self.belongs_to[7:] # len("bgp-IP-") = 7 + elif self.belongs_to.startswith("bgp-IPv6-"): + return self.belongs_to[9:] # len("bgp-IPv6-") = 9 + + return "" + + @property + def peer_rid(self) -> str: + return self.gateway + + +def _extract_paths(lines: t.List[str]) -> MikrotikPaths: + """Simple count based on lines with dst/dst-address and preferred 'A' flag.""" + available = 0 + best = 0 + for raw in lines: + if ("dst-address=" in raw) or (" dst=" in f" {raw} "): + available += 1 + m = FLAGS_RE.match(raw) + if m: + flags = set(m.group(1)) + if "A" in flags: + best += 1 + return MikrotikPaths(available=available, best=best, select=best) + + +def _process_kv(route: dict, key: str, val: str): + """Process a key-value pair and update the route dictionary.""" + # Normalize quoted values + if val.startswith('"') and val.endswith('"'): + val = val[1:-1] + + if key in ("dst-address", "dst"): + route["prefix"] = val + elif key in ("gateway", "nexthop"): + # Extract only the IP from gateway (e.g., 168.254.0.2%vlan-2000) + route["gateway"] = val.split("%")[0] + elif key == "distance": + route["distance"] = int(val) if val.isdigit() else route.get("distance", 0) + elif key == "scope": + route["scope"] = int(val) if val.isdigit() else route.get("scope", 0) + elif key in ("target-scope", "target_scope"): + route["target_scope"] = int(val) if val.isdigit() else route.get("target_scope", 0) + + # v7 keys (with dot) + elif key in (".as-path", "as-path", "bgp-as-path"): + if val and val.lower() != "none": + # Find all numbers in the as-path string + nums = re.findall(r"\b\d{1,10}\b", val) + route["as_path"] = [int(n) for n in nums if 1 <= int(n) <= 4294967295] + elif key in (".origin", "origin", "bgp-origin"): + route["origin"] = val + elif key in (".med", "med", "bgp-med"): + route["metric"] = int(val) if val.isdigit() else 0 + elif key in (".local-pref", "local-pref", "bgp-local-pref"): + route["local_preference"] = int(val) if val.isdigit() else 100 + elif key in (".communities", "communities", "bgp-communities"): + if val and val.lower() != "none": + route["communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key in (".large-communities", "large-communities", "bgp-large-communities"): + if val and val.lower() != "none": + route["large_communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key == "bgp-ext-communities": + if val and val.lower() != "none": + route["ext_communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key == "rpki": + clean_val = val.strip().strip('"').lower() + route["rpki_state"] = RPKI_STATE_MAP.get(clean_val, 2) + elif key == "belongs-to": + route["belongs_to"] = val + + +def _extract_route_entries(lines: t.List[str]) -> t.List[MikrotikRouteEntry]: + """Extract route entries from a list of lines.""" + routes: t.List[MikrotikRouteEntry] = [] + current_route_lines = [] + + for line in lines: + stripped_line = line.strip() + # A new route entry starts with flags or is a continuation line. + # An empty line signifies the end of the previous block. + if not stripped_line and current_route_lines: + # Process the completed route block + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + current_route_lines = [] + elif stripped_line: + # Check if this line is the start of a new entry + if FLAGS_RE.match(stripped_line) and current_route_lines: + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + current_route_lines = [stripped_line] + else: + current_route_lines.append(stripped_line) + + # Process any remaining lines + if current_route_lines: + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + + return routes + + +def _parse_route_block(block: t.List[str]) -> t.Optional[MikrotikRouteEntry]: + """Parse a single route block and return a MikrotikRouteEntry.""" + if not block: + return None + + full_block_text = " ".join(block) + if "dst-address=" not in full_block_text and " dst=" not in f" {full_block_text} ": + return None + + flags = "" + rd = { + "prefix": "", + "gateway": "", + "distance": 20, + "scope": 30, + "target_scope": 10, + "as_path": [], + "communities": [], + "large_communities": [], + "ext_communities": [], + "local_preference": 100, + "metric": 0, + "origin": "", + "belongs_to": "", + "is_active": False, + "is_filtered": False, + "rpki_state": RPKI_STATE_MAP.get("unknown", 2), + } + + # Interpret flags in the first line: + # - "A" => active (preferred) + # - otherwise not active + m = FLAGS_RE.match(block[0]) + if m: + flags = m.group(1) + flag_set = set(flags) + is_active = "A" in flag_set + is_filtered = bool(flag_set & {"F", "f", "U", "u", "X", "x"}) + + if is_active: + rd["is_active"] = True + rd["is_filtered"] = False + else: + rd["is_filtered"] = is_filtered + + # Find all key=value tokens in the entire block + for k, v in TOKEN_RE.findall(full_block_text): + _process_kv(rd, k, v) + + if rd["prefix"]: + try: + if Settings.debug: + log.bind(parser="MikrotikBGPTable").debug( + "Parsed MikroTik route entry: " + "prefix={prefix} flags={flags} active={active} filtered={filtered} " + "next_hop={next_hop} as_path={as_path} rpki_state={rpki_state}", + prefix=rd["prefix"], + flags=flags or None, + active=rd["is_active"], + filtered=rd["is_filtered"], + next_hop=rd["gateway"] or None, + as_path=rd["as_path"], + rpki_state=rd["rpki_state"], + ) + return MikrotikRouteEntry(**rd) + except Exception as e: + log.warning(f"Failed to create MikroTik route entry ({rd.get('prefix','?')}: {e}") + return None + + +class MikrotikBGPRouteTable(BGPRouteTable): + """Canonical MikroTik BGP Route Table.""" + + # No custom __init__ needed; inherit from BGPRouteTable (which should be a Pydantic model) + + +class MikrotikBGPTable(MikrotikBase): + """MikroTik BGP Table in canonical format.""" + + local_router_id: str = "" + local_as_number: int = 0 + paths: MikrotikPaths = MikrotikPaths() + routes: t.List[MikrotikRouteEntry] = [] + + @classmethod + def parse_text(cls, text: str) -> "MikrotikBGPTable": + _log = log.bind(parser="MikrotikBGPTable") + inst = cls() + + lines = text.splitlines() + if not lines: + return inst + + # Filter out command echoes and header lines + lines = [ln for ln in lines if not ln.strip().startswith((">", "Flags:", "[", "#"))] + + inst.paths = _extract_paths(lines) + inst.routes = _extract_route_entries(lines) + + _log.debug(f"Parsed {len(inst.routes)} MikroTik routes") + return inst + + def bgp_table(self) -> BGPRouteTable: + routes = [] + for route in self.routes: + route_data = { + "prefix": route.prefix, + "active": route.active, + "age": route.age, + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.all_communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": route.rpki_state, + "filtered": route.is_filtered, + } + # Instantiate BGPRoute to trigger validation (including external RPKI) + routes.append(BGPRoute(**route_data)) + return MikrotikBGPRouteTable( + vrf="default", + count=len(routes), + routes=routes, + winning_weight="low", + ) + + +class MikrotikTracerouteTable(MikrotikBase): + """MikroTik Traceroute Table.""" + + target: str + source: str + hops: t.List["MikrotikTracerouteHop"] = [] + max_hops: int = 30 + packet_size: int = 60 + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> "MikrotikTracerouteTable": + """Parse a cleaned MikroTik traceroute table. + + The input is expected to be a single, clean traceroute table that has already + been processed by the garbage cleaner to remove paging artifacts and duplicates. + + Expected format: + ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS + 197.157.67.233 0% 3 0.4ms 0.2 0.1 0.4 0.1 + 100% 3 timeout + 41.78.188.153 0% 3 210.8ms 210.8 210.8 210.9 0 + """ + _log = log.bind(parser="MikrotikTracerouteTable") + + _log.debug( + "Parsing cleaned MikroTik traceroute table", + target=target, + source=source, + lines=len(text.splitlines()), + ) + + lines = text.strip().split("\n") + hops = [] + hop_number = 1 + + # Find the table header to start parsing from + header_found = False + data_start_index = 0 + has_index_column = False + + for i, line in enumerate(lines): + if "ADDRESS" in line and "LOSS" in line and "SENT" in line: + header_found = True + data_start_index = i + 1 + # Some RouterOS versions prefix each row with a hop-index column, + # so the header begins with "#" (e.g. "# ADDRESS LOSS SENT ..."). + # Detect it here so the data rows can drop that leading column. + header_tokens = line.split() + has_index_column = bool(header_tokens) and header_tokens[0] == "#" + break + + if not header_found: + _log.warning("No traceroute table header found in cleaned output") + return MikrotikTracerouteTable(target=target, source=source, hops=[]) + + # Parse data rows + for i in range(data_start_index, len(lines)): + line = lines[i].strip() + + # Skip empty lines + if not line: + continue + + # Stop at any remaining paging markers (shouldn't happen with cleaned input) + if "-- [Q quit|C-z pause]" in line: + break + + try: + # Parse data line + parts = line.split() + # Drop the leading hop-index column when present (e.g. "0 1.2.3.4 ..."). + if has_index_column and parts and re.fullmatch(r"\d+", parts[0]): + parts = parts[1:] + if len(parts) < 3: + continue + + # Check if this is a timeout line (starts with percentage) + if parts[0].endswith("%"): + # Timeout hop: "100% 3 timeout" + ip_address = None + loss_pct = int(parts[0].rstrip("%")) + sent_count = int(parts[1]) + last_rtt = None + avg_rtt = None + best_rtt = None + worst_rtt = None + else: + # Normal hop: "197.157.67.233 0% 3 0.4ms 0.2 0.1 0.4 0.1" + ip_address = parts[0] + if len(parts) < 7: + continue + + loss_pct = int(parts[1].rstrip("%")) + sent_count = int(parts[2]) + + # Parse RTT values + def parse_rtt(rtt_str: str) -> t.Optional[float]: + if rtt_str in ("timeout", "-", "0ms", "*"): + return None + rtt_clean = re.sub(r"ms$", "", rtt_str) + try: + return float(rtt_clean) + except ValueError: + return None + + last_rtt = parse_rtt(parts[3]) + avg_rtt = parse_rtt(parts[4]) + best_rtt = parse_rtt(parts[5]) + worst_rtt = parse_rtt(parts[6]) + + hop = MikrotikTracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + hostname=None, # MikroTik doesn't do reverse DNS by default + loss_pct=loss_pct, + sent_count=sent_count, + last_rtt=last_rtt, + avg_rtt=avg_rtt, + best_rtt=best_rtt, + worst_rtt=worst_rtt, + ) + + hops.append(hop) + hop_number += 1 + + except (ValueError, IndexError) as e: + _log.debug("Failed to parse traceroute data line", line=line, error=str(e)) + continue + + result = MikrotikTracerouteTable(target=target, source=source, hops=hops) + _log.info("Parsed cleaned traceroute table", hops=len(hops)) + return result + + def traceroute_result(self): + """Convert to TracerouteResult format.""" + from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop + from hyperglass.log import log + + _log = log.bind(parser="MikrotikTracerouteTable") + + converted_hops = [] + for hop in self.hops: + # Handle truncated IP addresses + ip_address = hop.ip_address + display_ip = None + + if hop.ip_address and hop.ip_address.endswith("..."): + # For truncated IPs, store for display but set ip_address to None for validation + display_ip = hop.ip_address + ip_address = None + + created_hop = TracerouteHop( + hop_number=hop.hop_number, + ip_address=ip_address, # None for truncated IPs + display_ip=display_ip, # Truncated IP for display + hostname=hop.hostname, + # Set RTT values to ensure avg_rtt property returns MikroTik's AVG value + # Since avg_rtt = (rtt1 + rtt2 + rtt3) / 3, we set all to the MikroTik AVG + rtt1=hop.avg_rtt, # Set to AVG so computed average is correct + rtt2=hop.avg_rtt, # Set to AVG so computed average is correct + rtt3=hop.avg_rtt, # Set to AVG so computed average is correct + # MikroTik-specific statistics (preserve original values) + loss_pct=hop.loss_pct, + sent_count=hop.sent_count, + last_rtt=hop.last_rtt, # Preserve LAST value + best_rtt=hop.best_rtt, # Preserve BEST value + worst_rtt=hop.worst_rtt, # Preserve WORST value + # BGP enrichment fields will be populated by enrichment plugin + # For truncated IPs, these will remain None/empty + asn=None, + org=None, + prefix=None, + country=None, + rir=None, + allocated=None, + ) + + converted_hops.append(created_hop) + + return TracerouteResult( + target=self.target, + source=self.source, + hops=converted_hops, + max_hops=self.max_hops, + packet_size=self.packet_size, + raw_output=None, # Will be set by the plugin + ) + + +class MikrotikTracerouteHop(MikrotikBase): + """Individual MikroTik traceroute hop.""" + + hop_number: int + ip_address: t.Optional[str] = None + hostname: t.Optional[str] = None + + # MikroTik-specific statistics + loss_pct: t.Optional[int] = None + sent_count: t.Optional[int] = None + last_rtt: t.Optional[float] = None + avg_rtt: t.Optional[float] = None + best_rtt: t.Optional[float] = None + worst_rtt: t.Optional[float] = None + + @property + def is_timeout(self) -> bool: + """Check if this hop is a timeout.""" + return self.ip_address is None or self.loss_pct == 100 diff --git a/hyperglass/models/parsing/traceroute.py b/hyperglass/models/parsing/traceroute.py new file mode 100644 index 000000000..ec001cb5c --- /dev/null +++ b/hyperglass/models/parsing/traceroute.py @@ -0,0 +1,235 @@ +"""Example traceroute parsing module.""" + +# Standard Library +import re +import typing as t + +# Project +from hyperglass.log import log +from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop + +# Local +from ..main import HyperglassModel + + +class TracerouteParser(HyperglassModel): + """Base traceroute parser.""" + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> TracerouteResult: + """Parse traceroute text output into structured data.""" + _log = log.bind(parser="TracerouteParser") + + hops = [] + lines = text.strip().split("\n") + + # Common traceroute pattern: hop number, IP/hostname, RTT values + hop_pattern = re.compile(r"^\s*(\d+)\s+(?:(\S+)\s+\(([^)]+)\)|(\S+))\s+(.+)$") + + # RTT pattern to extract timing values + rtt_pattern = re.compile(r"(\d+(?:\.\d+)?)\s*ms") + + for line in lines: + line = line.strip() + if not line or line.startswith("traceroute"): + continue + + # Handle timeout lines (* * *) + if "*" in line and re.search(r"\d+\s+\*", line): + hop_match = re.match(r"^\s*(\d+)\s+\*", line) + if hop_match: + hop_number = int(hop_match.group(1)) + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=None, + hostname=None, + rtt1=None, + rtt2=None, + rtt3=None, + ) + ) + continue + + # Parse normal hop lines + hop_match = hop_pattern.match(line) + if hop_match: + hop_number = int(hop_match.group(1)) + + # Extract hostname and IP + if hop_match.group(2) and hop_match.group(3): + # Format: hostname (ip) + hostname = hop_match.group(2) + ip_address = hop_match.group(3) + else: + # Format: ip or hostname only + hostname = None + ip_address = hop_match.group(4) + + # Extract RTT values + rtt_text = hop_match.group(5) + rtts = rtt_pattern.findall(rtt_text) + + # Pad with None if less than 3 RTT values + while len(rtts) < 3: + rtts.append(None) + + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + hostname=hostname, + rtt1=float(rtts[0]) if rtts[0] else None, + rtt2=float(rtts[1]) if rtts[1] else None, + rtt3=float(rtts[2]) if rtts[2] else None, + ) + ) + + result = TracerouteResult(target=target, source=source, hops=hops) + + _log.info(f"Parsed {len(hops)} hops from traceroute output") + return result + + +class CiscoTracerouteParser(TracerouteParser): + """Cisco-specific traceroute parser.""" + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> TracerouteResult: + """Parse Cisco traceroute output.""" + _log = log.bind(parser="CiscoTracerouteParser") + + # Cisco traceroute often has format like: + # Type escape sequence to abort. + # Tracing the route to 8.8.8.8 (8.8.8.8) + # 1 192.168.1.1 4 msec 8 msec 4 msec + + lines = text.strip().split("\n") + hops = [] + + for line in lines: + line = line.strip() + if not line or "escape sequence" in line.lower() or "tracing" in line.lower(): + continue + + # Cisco format: hop_number ip_or_hostname rtt1 msec rtt2 msec rtt3 msec + cisco_pattern = re.compile( + r"^\s*(\d+)\s+(\S+)\s+(\d+(?:\.\d+)?)\s*msec\s+(\d+(?:\.\d+)?)\s*msec\s+(\d+(?:\.\d+)?)\s*msec" + ) + + match = cisco_pattern.match(line) + if match: + hop_number = int(match.group(1)) + ip_or_hostname = match.group(2) + rtt1 = float(match.group(3)) + rtt2 = float(match.group(4)) + rtt3 = float(match.group(5)) + + # Try to determine if it's an IP or hostname + import ipaddress + + try: + ipaddress.ip_address(ip_or_hostname) + ip_address = ip_or_hostname + hostname = None + except ValueError: + ip_address = None + hostname = ip_or_hostname + + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + hostname=hostname, + rtt1=rtt1, + rtt2=rtt2, + rtt3=rtt3, + ) + ) + + result = TracerouteResult(target=target, source=source, hops=hops) + + _log.info(f"Parsed {len(hops)} hops from Cisco traceroute output") + return result + + +class JuniperTracerouteParser(TracerouteParser): + """Juniper-specific traceroute parser.""" + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> TracerouteResult: + """Parse Juniper traceroute output.""" + _log = log.bind(parser="JuniperTracerouteParser") + + # Juniper format often like: + # traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets + # 1 192.168.1.1 (192.168.1.1) 1.234 ms 1.456 ms 1.678 ms + + lines = text.strip().split("\n") + hops = [] + + for line in lines: + line = line.strip() + if not line or line.startswith("traceroute to"): + continue + + # Juniper format often has hostname (ip) followed by RTTs + juniper_pattern = re.compile( + r"^\s*(\d+)\s+(?:(\S+)\s+\(([^)]+)\)|(\S+))\s+([\d\.\s\*ms]+)$" + ) + + match = juniper_pattern.match(line) + if match: + hop_number = int(match.group(1)) + + if match.group(2) and match.group(3): + hostname = match.group(2) + ip_address = match.group(3) + else: + hostname = None + ip_address = match.group(4) + + # Extract RTT values + rtt_text = match.group(5) + rtts = re.findall(r"(\d+(?:\.\d+)?)\s*ms", rtt_text) + + # Handle * for timeouts + if "*" in rtt_text: + timeout_count = rtt_text.count("*") + rtts.extend([None] * timeout_count) + + # Ensure we have exactly 3 RTT values + while len(rtts) < 3: + rtts.append(None) + + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + hostname=hostname, + rtt1=float(rtts[0]) if rtts[0] else None, + rtt2=float(rtts[1]) if rtts[1] else None, + rtt3=float(rtts[2]) if rtts[2] else None, + ) + ) + + result = TracerouteResult(target=target, source=source, hops=hops) + + _log.info(f"Parsed {len(hops)} hops from Juniper traceroute output") + return result + + +# Parser mapping by platform +TRACEROUTE_PARSERS = { + "cisco_ios": CiscoTracerouteParser, + "cisco_nxos": CiscoTracerouteParser, + "cisco_xr": CiscoTracerouteParser, + "juniper": JuniperTracerouteParser, + "juniper_junos": JuniperTracerouteParser, + "generic": TracerouteParser, # Fallback +} + + +def get_traceroute_parser(platform: str) -> t.Type[TracerouteParser]: + """Get the appropriate traceroute parser for a platform.""" + return TRACEROUTE_PARSERS.get(platform, TracerouteParser) diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py index 5e87f5b11..75a78ee9f 100644 --- a/hyperglass/plugins/_builtin/__init__.py +++ b/hyperglass/plugins/_builtin/__init__.py @@ -5,14 +5,30 @@ from .remove_command import RemoveCommand from .bgp_route_arista import BGPRoutePluginArista from .bgp_route_huawei import BGPRoutePluginHuawei +from .bgp_routestr_huawei import BGPSTRRoutePluginHuawei from .bgp_route_juniper import BGPRoutePluginJuniper from .mikrotik_garbage_output import MikrotikGarbageOutput +from .mikrotik_normalize_input import MikrotikTargetNormalizerInput +from .bgp_routestr_mikrotik import BGPSTRRoutePluginMikrotik +from .trace_route_frr import TraceroutePluginFrr +from .trace_route_huawei import TraceroutePluginHuawei +from .trace_route_mikrotik import TraceroutePluginMikrotik +from .traceroute_ip_enrichment import ZTracerouteIpEnrichment +from .bgp_route_enrichment import ZBgpRouteEnrichment __all__ = ( "BGPRoutePluginArista", "BGPRoutePluginFrr", "BGPRoutePluginJuniper", "BGPRoutePluginHuawei", + "BGPSTRRoutePluginHuawei", "MikrotikGarbageOutput", + "MikrotikTargetNormalizerInput", + "BGPSTRRoutePluginMikrotik", + "TraceroutePluginFrr", + "TraceroutePluginHuawei", + "TraceroutePluginMikrotik", + "ZTracerouteIpEnrichment", + "ZBgpRouteEnrichment", "RemoveCommand", ) diff --git a/hyperglass/plugins/_builtin/bgp_route_enrichment.py b/hyperglass/plugins/_builtin/bgp_route_enrichment.py new file mode 100644 index 000000000..c1dbcdb6f --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_route_enrichment.py @@ -0,0 +1,97 @@ +"""ASN organization enrichment for structured BGP route data.""" + +# Standard Library +import typing as t + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.log import log +from hyperglass.plugins._output import OutputPlugin +from hyperglass.models.data.bgp_route import BGPRouteTable + +if t.TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + + +class ZBgpRouteEnrichment(OutputPlugin): + """Enrich structured BGP route output with ASN organization data.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ( + "mikrotik_routeros", + "mikrotik_switchos", + "mikrotik", + "cisco_ios", + "juniper_junos", + "huawei", + "huawei_vrpv8", + "arista_eos", + "bird", + ) + directives: t.Sequence[str] = ("bgp_route", "MikroTik_BGPRoute") + common: bool = True + + def process(self, *, output: "OutputDataModel", query: "Query") -> "OutputDataModel": + """Enrich structured BGP route data with ASN organization information.""" + + if not isinstance(output, BGPRouteTable): + return output + + _log = log.bind(plugin=self.__class__.__name__) + + # Check if BGP route enrichment is enabled in config + try: + from hyperglass.state import use_state + from hyperglass.settings import Settings + + params = use_state("params") + # If structured config missing or BGP route enrichment disabled, skip + if ( + not getattr(params, "structured", None) + or not params.structured.ip_enrichment.enrich_bgproute + or getattr(params.structured, "enable_for_bgp_route", None) is False + ): + if Settings.debug: + _log.debug("ASN enrichment for BGP routes disabled in configuration") + return output + + _log.info(f"Starting ASN and next-hop enrichment for {len(output.routes)} BGP routes") + + # Run enrichment in event loop + import asyncio + + async def enrich_all(): + """Run both AS path and next-hop enrichment.""" + await output.enrich_as_path_organizations() + await output.enrich_with_ip_enrichment() + + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # We're inside an event loop; create task + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(lambda: asyncio.run(enrich_all())) + future.result() + else: + # No loop running, we can use asyncio.run directly + asyncio.run(enrich_all()) + except RuntimeError: + # Fallback if we can't determine loop state + asyncio.run(enrich_all()) + + enriched_asn_count = len(output.asn_organizations) + enriched_nexthop_count = sum(1 for r in output.routes if r.next_hop_org) + _log.info( + f"Enriched {enriched_asn_count} unique ASNs and {enriched_nexthop_count} next-hops with organization data" + ) + + except Exception as e: + _log.error(f"BGP route enrichment failed: {e}") + # Don't fail the entire request if enrichment fails + + return output diff --git a/hyperglass/plugins/_builtin/bgp_route_huawei.py b/hyperglass/plugins/_builtin/bgp_route_huawei.py index 172d9068f..484dc9cc8 100644 --- a/hyperglass/plugins/_builtin/bgp_route_huawei.py +++ b/hyperglass/plugins/_builtin/bgp_route_huawei.py @@ -21,7 +21,10 @@ class BGPRoutePluginHuawei(InputPlugin): "huawei", "huawei_vrpv8", ) - directives: t.Sequence[str] = ("__hyperglass_huawei_bgp_route__",) + directives: t.Sequence[str] = ( + "__hyperglass_huawei_bgp_route__", + "__hyperglass_huawei_bgp_route_table__", + ) """ Huawei BGP Route Input Plugin @@ -33,15 +36,25 @@ class BGPRoutePluginHuawei(InputPlugin): def transform(self, query: "Query") -> InputPluginTransformReturn: target = query.query_target - if not target or not isinstance(target, list) or len(target) == 0: + if not target: return None - target = target[0].strip() + if isinstance(target, list): + if len(target) == 0: + return None + target = target[0].strip() + elif isinstance(target, str): + target = target.strip() + else: + return None # Check for the / in the query target if target.find("/") == -1: return target - target_network = ip_network(target) - - return f"{target_network.network_address!s} {target_network.prefixlen!s}" + try: + target_network = ip_network(target) + return f"{target_network.network_address!s} {target_network.prefixlen!s}" + except ValueError: + # Return the target unchanged if it isn't a valid network + return target diff --git a/hyperglass/plugins/_builtin/bgp_routestr_huawei.py b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py new file mode 100644 index 000000000..e42308344 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_routestr_huawei.py @@ -0,0 +1,80 @@ +"""Coerce a Huawei route table in text format to a standard BGP Table structure.""" + +# Standard Library +import re +from typing import TYPE_CHECKING, List, Sequence + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +# Local +from .._output import OutputPlugin + +if TYPE_CHECKING: + # Project + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + + # Local + from .._output import OutputType + + +def parse_huawei(output: Sequence[str]) -> "OutputDataModel": + """Parse a Huawei BGP text response.""" + result = None + + _log = log.bind(plugin=BGPSTRRoutePluginHuawei.__name__) + + for response in output: + try: + # Parse the text output using the Huawei parser + validated = HuaweiBGPTable.parse_text(response) + bgp_table = validated.bgp_table() + + if result is None: + result = bgp_table + else: + result += bgp_table + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + except Exception as err: + _log.bind(error=str(err)).critical("Failed to parse Huawei BGP output") + raise ParsingError("Error parsing response data") from err + + return result + + +class BGPSTRRoutePluginHuawei(OutputPlugin): + """Coerce a Huawei route table in text format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: Sequence[str] = ("huawei",) + directives: Sequence[str] = ( + "__hyperglass_huawei_bgp_route_table__", + "__hyperglass_huawei_bgp_aspath_table__", + "__hyperglass_huawei_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse Huawei response if data is a string (and is therefore unparsed).""" + _log = log.bind(plugin=self.__class__.__name__) + _log.debug("Processing Huawei output with structured parser") + + should_process = all( + ( + isinstance(output, (list, tuple)), + query.device.platform in self.platforms, + query.device.structured_output is True, + query.device.has_directives(*self.directives), + ) + ) + if should_process: + return parse_huawei(output) + return output diff --git a/hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py b/hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py new file mode 100644 index 000000000..ad08c56e5 --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_routestr_mikrotik.py @@ -0,0 +1,86 @@ +"""Coerce a MikroTik route table in text format to a standard BGP Table structure.""" + +# Standard Library +from typing import TYPE_CHECKING, Sequence, Union, List + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.mikrotik import MikrotikBGPTable + +# Local +from .._output import OutputPlugin + +if TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + from .._output import OutputType + + +def _normalize_output(output: Union[str, Sequence[str]]) -> List[str]: + """Ensure the output is a list of strings.""" + if isinstance(output, str): + return [output] + return list(output) + + +def parse_mikrotik(output: Union[str, Sequence[str]]) -> "OutputDataModel": + """Parse a MikroTik BGP text response.""" + result = None + out_list = _normalize_output(output) + + _log = log.bind(plugin=BGPSTRRoutePluginMikrotik.__name__) + combined_output = "\n".join(out_list) + _log.debug(f"Combined output length: {len(combined_output)}") + + try: + # Pass the entire combined output to the parser at once + validated = MikrotikBGPTable.parse_text(combined_output) + result = validated.bgp_table() + _log.debug(f"Successfully parsed {len(validated.routes)} routes") + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + except Exception as err: + _log.bind(error=str(err)).critical("Failed to parse MikroTik BGP output") + raise ParsingError("Error parsing response data") from err + + return result + + +class BGPSTRRoutePluginMikrotik(OutputPlugin): + """Coerce a MikroTik route table in text format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + + directives: Sequence[str] = ( + "__hyperglass_mikrotik_bgp_route_table__", + "__hyperglass_mikrotik_bgp_aspath_table__", + "__hyperglass_mikrotik_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse MikroTik response if data is text (and is therefore unparsed).""" + _log = log.bind(plugin=self.__class__.__name__) + _log.debug("Processing MikroTik output with structured parser") + + is_text = isinstance(output, (list, tuple, str)) + has_platform = query.device.platform in self.platforms + wants_structured = bool(getattr(query.device, "structured_output", False)) + has_directives = query.device.has_directives(*self.directives) + + should_process = all((is_text, has_platform, wants_structured, has_directives)) + if not should_process: + _log.debug( + "Skipping structured parser: " + f"is_text={is_text}, platform={query.device.platform!r} (o@k={has_platform}), " + f"structured_output={wants_structured}, has_directives={has_directives}" + ) + return output + + return parse_mikrotik(output) diff --git a/hyperglass/plugins/_builtin/mikrotik_garbage_output.py b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py index 8718604c1..4bb64be24 100644 --- a/hyperglass/plugins/_builtin/mikrotik_garbage_output.py +++ b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py @@ -8,6 +8,7 @@ from pydantic import PrivateAttr # Project +from hyperglass.log import log from hyperglass.types import Series # Local @@ -19,64 +20,246 @@ class MikrotikGarbageOutput(OutputPlugin): - """Parse Mikrotik output to remove garbage.""" + """Parse Mikrotik output to remove garbage before structured parsing.""" _hyperglass_builtin: bool = PrivateAttr(True) - platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos") - directives: t.Sequence[str] = ( - "__hyperglass_mikrotik_bgp_aspath__", - "__hyperglass_mikrotik_bgp_community__", - "__hyperglass_mikrotik_bgp_route__", - "__hyperglass_mikrotik_ping__", - "__hyperglass_mikrotik_traceroute__", - ) + platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + # Only apply to MikroTik platforms, not all devices + common: bool = False - def process(self, *, output: OutputType, query: "Query") -> Series[str]: - """Parse Mikrotik output to remove garbage.""" + def _clean_traceroute_output(self, raw_output: str) -> str: + """Clean MikroTik traceroute output specifically. + + Important: Traceroute hops are sequential - each line represents a unique hop position. + We should NOT deduplicate by IP address as the same IP can appear at different hops. + Order matters for traceroute results. + + However, we can aggregate consecutive timeout lines at the END of the traceroute + to avoid showing 10+ meaningless timeout entries. + """ + if not raw_output or not raw_output.strip(): + return "" + + lines = raw_output.splitlines() + # Remove command echoes and paging, keep only header markers and data lines + # We'll split the output into discrete tables (each table begins at a header) + tables: t.List[t.List[str]] = [] + current_table: t.List[str] = [] + header_line: t.Optional[str] = None + + for line in lines: + stripped = line.strip() + + # Skip empty lines and interactive paging prompts + if ( + not stripped + or "-- [Q quit|C-z pause]" in stripped + or "-- [Q quit|D dump|C-z pause]" in stripped + ): + continue + + # Skip command echo lines + if "tool traceroute" in stripped: + continue + + # If this is a header line, start a new table + if "ADDRESS" in stripped and "LOSS" in stripped and "SENT" in stripped: + header_line = line + # If we were collecting a table, push it + if current_table: + tables.append(current_table) + current_table = [] + # Start collecting after header + continue + + # Collect data lines (will be associated with the most recent header) + if header_line is not None: + current_table.append(line) + + # Push the last collected table if any + if current_table: + tables.append(current_table) - result = () + # If we didn't find any header/data, return cleaned minimal output + if not tables: + # Fallback to previous behavior: remove prompts and flags + filtered_lines: t.List[str] = [] + in_flags_section = False + for line in lines: + stripped_line = line.strip() + if stripped_line.startswith("@") and stripped_line.endswith("] >"): + continue + if "[Q quit|D dump|C-z pause]" in stripped_line: + continue + if stripped_line.startswith("Flags:"): + in_flags_section = True + continue + if in_flags_section: + if "=" in stripped_line: + in_flags_section = False + else: + continue + filtered_lines.append(line) + return "\n".join(filtered_lines) - for each_output in output: - if len(each_output) != 0: - if each_output.split()[-1] in ("DISTANCE", "STATUS"): - # Mikrotik shows the columns with no rows if there is no data. - # Rather than send back an empty table, send back an empty - # response which is handled with a warning message. - each_output = "" + # Aggregate tables by hop index. For each hop position, pick the row with the + # highest SENT count. If SENT ties, prefer non-timeout rows and the later table. + processed_lines: t.List[str] = [] + + # Regex to extract LOSS% and SENT count following it: e.g. '0% 3' + sent_re = re.compile(r"(\d+)%\s+(\d+)\b") + + max_rows = max(len(t) for t in tables) + + for i in range(max_rows): + best_row = None + best_sent = -1 + best_is_timeout = True + best_table_index = -1 + + for ti, table in enumerate(tables): + if i >= len(table): + continue + row = table[i] + m = sent_re.search(row) + if m: + try: + sent = int(m.group(2)) + except Exception: + sent = 0 else: - remove_lines = () - all_lines = each_output.splitlines() - # Starting index for rows (after the column row). - start = 1 - # Extract the column row. - column_line = " ".join(all_lines[0].split()) - - for i, line in enumerate(all_lines[1:]): - # Remove all the newline characters (which differ line to - # line) for comparison purposes. - normalized = " ".join(line.split()) - - # Remove ansii characters that aren't caught by Netmiko. - normalized = re.sub(r"\\x1b\[\S{2}\s", "", normalized) - - if column_line in normalized: - # Mikrotik often re-inserts the column row in the output, - # effectively 'starting over'. In that case, re-assign - # the column row and starting index to that point. - column_line = re.sub(r"\[\S{2}\s", "", line) - start = i + 2 - - if "[Q quit|D dump|C-z pause]" in normalized: - # Remove Mikrotik's unhelpful helpers from the output. - remove_lines += (i + 1,) - - # Combine the column row and the data rows from the starting - # index onward. - lines = [column_line, *all_lines[start:]] - - # Remove any lines marked for removal and re-join with a single - # newline character. - lines = [line for idx, line in enumerate(lines) if idx not in remove_lines] - result += ("\n".join(lines),) - - return result + sent = 0 + + is_timeout = "timeout" in row.lower() or ( + "100%" in row and "timeout" in row.lower() + ) + + # Prefer higher SENT, then prefer non-timeout, then later table (higher ti) + pick = False + if sent > best_sent: + pick = True + elif sent == best_sent: + if best_is_timeout and not is_timeout: + pick = True + elif (best_is_timeout == is_timeout) and ti > best_table_index: + pick = True + + if pick: + best_row = row + best_sent = sent + best_is_timeout = is_timeout + best_table_index = ti + + if best_row is not None: + processed_lines.append(best_row) + + # Collapse excessive trailing timeouts into an aggregation line + trailing_timeouts = 0 + for line in reversed(processed_lines): + if ("timeout" in line.lower()) or ( + sent_re.search(line) and sent_re.search(line).group(1) == "100" + ): + trailing_timeouts += 1 + else: + break + + if trailing_timeouts > 3: + non_trailing = len(processed_lines) - trailing_timeouts + # Keep first 2 of trailing timeouts and aggregate the rest + aggregated = ( + processed_lines[:non_trailing] + processed_lines[non_trailing : non_trailing + 2] + ) + remaining = trailing_timeouts - 2 + aggregated.append( + f" ... ({remaining} more timeout hops)" + ) + processed_lines = aggregated + + # Prepend header line if we have one + header_to_use = ( + header_line + or "ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS" + ) + cleaned = [header_to_use] + processed_lines + return "\n".join(cleaned) + + def process(self, *, output: OutputType, query: "Query") -> Series[str]: + """ + Clean raw output from a MikroTik device. + This plugin removes command echoes, prompts, flag legends, and interactive help text. + """ + + # If output is already processed/structured (not raw strings), pass it through unchanged + if not isinstance(output, (tuple, list)): + return output + + # Check if the tuple/list contains non-string objects (structured data) + if output and not isinstance(output[0], str): + return output + + cleaned_outputs = [] + + for raw_output in output: + # Handle non-string outputs (already processed by other plugins) - double check + if not isinstance(raw_output, str): + cleaned_outputs.append(raw_output) + continue + + # Nothing to do if the output is already empty. + if not raw_output or not raw_output.strip(): + cleaned_outputs.append("") + continue + + # Check if this is traceroute output and handle it specially + if ( + "tool traceroute" in raw_output + or ("ADDRESS" in raw_output and "LOSS" in raw_output and "SENT" in raw_output) + or "-- [Q quit|C-z pause]" in raw_output + ): + cleaned_output = self._clean_traceroute_output(raw_output) + cleaned_outputs.append(cleaned_output) + continue + + # Original logic for other outputs (BGP routes, etc.) + lines = raw_output.splitlines() + filtered_lines = [] + in_flags_section = False + + for line in lines: + stripped_line = line.strip() + + # Skip prompts and command echoes + if stripped_line.startswith("@") and stripped_line.endswith("] >"): + continue + + # Skip the interactive help line + if "[Q quit|D dump|C-z pause]" in stripped_line: + continue + + # Begin detecting the Flags section + if stripped_line.startswith("Flags:"): + in_flags_section = True + continue # Skip the "Flags:" line itself + + # Within the flags section, check whether the line is still part of it + if in_flags_section: + if "=" in stripped_line: + in_flags_section = False + else: + continue # Skip the flag-legend lines + + filtered_lines.append(line) + + # Join the cleaned lines back into a single string + cleaned_output = "\n".join(filtered_lines) + cleaned_outputs.append(cleaned_output) + + # Minimal debug logging: log number of cleaned blocks and if any aggregation occurred + if len(output) > 0: + log.debug(f"MikrotikGarbageOutput processed {len(output)} output blocks.") + # If any aggregation line was added, log that event + for cleaned in cleaned_outputs: + if "... (" in cleaned: + log.debug("Aggregated excessive trailing timeout hops in traceroute output.") + break + return tuple(cleaned_outputs) diff --git a/hyperglass/plugins/_builtin/mikrotik_normalize_input.py b/hyperglass/plugins/_builtin/mikrotik_normalize_input.py new file mode 100644 index 000000000..d7bf556e0 --- /dev/null +++ b/hyperglass/plugins/_builtin/mikrotik_normalize_input.py @@ -0,0 +1,58 @@ +# Standard Library +from ipaddress import ip_network, ip_address +import typing as t + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.log import log + +# REMOVIDA a importação direta de Query para evitar o erro circular +# from hyperglass.models.api.query import Query + +# Local +from .._input import InputPlugin, InputPluginValidationReturn + + +class MikrotikTargetNormalizerInput(InputPlugin): + """ + InputPlugin to normalize a target IP address to its network prefix. + This ensures that queries for different IPs within the same subnet + resolve to the same cache key. + """ + + _hyperglass_builtin: bool = PrivateAttr(False) + name: str = "mikrotik_normalizer" + platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + + def validate(self, query: t.Any) -> InputPluginValidationReturn: + """Take the query object and modify the target if it's a BGP Route query.""" + + # Acessamos os atributos normalmente, pois sabemos que o objeto 'query' os terá. + if query.query_type != "bgp_route": + return True, None + + try: + target_ip = ip_address(query.target) + + if target_ip.version == 4: + prefix_len = 24 + else: + prefix_len = 48 + + network = ip_network(f"{str(target_ip)}/{prefix_len}", strict=False) + + normalized_target = str(network.with_prefixlen) + + if query.target != normalized_target: + log.debug( + f"Normalizing target '{query.target}' to network prefix " + f"'{normalized_target}' for MikroTik cache key." + ) + query.target = normalized_target + + except ValueError: + pass + + return True, None diff --git a/hyperglass/plugins/_builtin/trace_route_frr.py b/hyperglass/plugins/_builtin/trace_route_frr.py new file mode 100644 index 000000000..6d17dd100 --- /dev/null +++ b/hyperglass/plugins/_builtin/trace_route_frr.py @@ -0,0 +1,345 @@ +"""Parse FRR traceroute output to structured data.""" + +# Standard Library +import re +import typing as t +from ipaddress import ip_address as validate_ip + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop +from hyperglass.state import use_state + +# Local +from .._output import OutputPlugin + +if t.TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + from .._output import OutputType + + +def _normalize_output(output: t.Union[str, t.Sequence[str]]) -> t.List[str]: + """Ensure the output is a list of strings.""" + if isinstance(output, str): + return [output] + return list(output) + + +def parse_frr_traceroute( + output: t.Union[str, t.Sequence[str]], target: str, source: str +) -> "OutputDataModel": + """Parse an FRR traceroute text response.""" + result = None + out_list = _normalize_output(output) + + _log = log.bind(plugin=TraceroutePluginFrr.__name__) + combined_output = "\n".join(out_list) + + # DEBUG: Log the raw output we're about to parse + _log.debug(f"=== FRR TRACEROUTE PLUGIN RAW INPUT ===") + _log.debug(f"Target: {target}, Source: {source}") + _log.debug(f"Output pieces: {len(out_list)}") + _log.debug(f"Combined output length: {len(combined_output)}") + _log.debug(f"First 500 chars: {repr(combined_output[:500])}") + _log.debug(f"=== END PLUGIN RAW INPUT ===") + + try: + result = FrrTracerouteTable.parse_text(combined_output, target, source) + except Exception as exc: + _log.error(f"Failed to parse FRR traceroute: {exc}") + raise ParsingError(f"Failed to parse FRR traceroute output: {exc}") from exc + + _log.debug(f"=== FINAL STRUCTURED TRACEROUTE RESULT ===") + _log.debug(f"Successfully parsed {len(result.hops)} traceroute hops") + _log.debug(f"Target: {target}, Source: {source}") + for hop in result.hops: + _log.debug(f"Hop {hop.hop_number}: {hop.ip_address or '*'} - RTT: {hop.rtt1 or 'timeout'}") + _log.debug(f"Raw output length: {len(combined_output)} characters") + _log.debug(f"=== END STRUCTURED RESULT ===") + + return result + + +class FrrTracerouteTable(TracerouteResult): + """FRR traceroute table parser.""" + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> TracerouteResult: + """Parse FRR traceroute text output into structured data.""" + _log = log.bind(parser="FrrTracerouteTable") + + _log.debug(f"=== RAW FRR TRACEROUTE INPUT ===") + _log.debug(f"Target: {target}, Source: {source}") + _log.debug(f"Raw text length: {len(text)} characters") + _log.debug(f"Raw text:\n{repr(text)}") + _log.debug(f"=== END RAW INPUT ===") + + hops = [] + + # Parse the traceroute output line by line + lines = text.strip().split("\n") + _log.debug(f"Split into {len(lines)} lines") + + # Patterns for different line formats + # Pattern for regular hop: " 1 203.0.113.1 0.221 ms" + simple_pattern = re.compile(r"^\s*(\d+)\s+([\d\.:a-fA-F]+)\s+([\d.]+)\s+ms") + + # Pattern for timeout hop: " 9 *" + timeout_pattern = re.compile(r"^\s*(\d+)\s+\*\s*$") + + # Pattern for hop with hostname: " 2 hostname.example.com (192.168.1.1) 15.234 ms" + hostname_pattern = re.compile(r"^\s*(\d+)\s+(\S+)\s+\(([\d\.:a-fA-F]+)\)\s+([\d.]+)\s+ms") + + # Pattern for multiple RTTs: " 3 192.168.1.1 15.234 ms 16.123 ms 14.567 ms" + multi_rtt_pattern = re.compile( + r"^\s*(\d+)\s+([\d\.:a-fA-F]+)\s+([\d.]+)\s+ms(?:\s+([\d.]+)\s+ms)?(?:\s+([\d.]+)\s+ms)?" + ) + + # Pattern for partial timeout: " 7 port-channel4.core4.mrs1.he.net (184.105.81.30) 132.624 ms 132.589 ms *" + partial_timeout_pattern = re.compile( + r"^\s*(\d+)\s+(\S+)\s+\(([\d\.:a-fA-F]+)\)\s+(?:([\d.]+)\s+ms\s+)?(?:([\d.]+)\s+ms\s+)?\*" + ) + + for i, line in enumerate(lines): + line = line.strip() + if not line: + continue + + _log.debug(f"Line {i:2}: {repr(line)}") + + # Skip header lines + if "traceroute to" in line.lower() or "hops max" in line.lower(): + _log.debug(f"Line {i:2}: SKIPPING HEADER") + continue + + hop_number = None + ip_address = None + hostname = None + rtt1 = None + rtt2 = None + rtt3 = None + + # Try to match timeout hop first + timeout_match = timeout_pattern.match(line) + if timeout_match: + hop_number = int(timeout_match.group(1)) + _log.debug(f"Line {i:2}: TIMEOUT HOP - {hop_number}") + + # Try to match partial timeout + elif partial_timeout_pattern.match(line): + partial_match = partial_timeout_pattern.match(line) + hop_number = int(partial_match.group(1)) + hostname = partial_match.group(2) + ip_address = partial_match.group(3) + rtt1 = float(partial_match.group(4)) if partial_match.group(4) else None + rtt2 = float(partial_match.group(5)) if partial_match.group(5) else None + _log.debug( + f"Line {i:2}: PARTIAL TIMEOUT HOP - {hop_number}: {hostname} ({ip_address}) {rtt1} {rtt2} *" + ) + + # Try to match hostname pattern + elif hostname_pattern.match(line): + hostname_match = hostname_pattern.match(line) + hop_number = int(hostname_match.group(1)) + hostname = hostname_match.group(2) + ip_address = hostname_match.group(3) + rtt1 = float(hostname_match.group(4)) + _log.debug( + f"Line {i:2}: HOSTNAME HOP - {hop_number}: {hostname} ({ip_address}) {rtt1} ms" + ) + + # Try to match multiple RTT pattern + elif multi_rtt_pattern.match(line): + multi_match = multi_rtt_pattern.match(line) + hop_number = int(multi_match.group(1)) + ip_address = multi_match.group(2) + rtt1 = float(multi_match.group(3)) + rtt2 = float(multi_match.group(4)) if multi_match.group(4) else None + rtt3 = float(multi_match.group(5)) if multi_match.group(5) else None + _log.debug( + f"Line {i:2}: MULTI RTT HOP - {hop_number}: {ip_address} {rtt1} {rtt2} {rtt3}" + ) + + # Try to match simple pattern + elif simple_pattern.match(line): + simple_match = simple_pattern.match(line) + hop_number = int(simple_match.group(1)) + ip_address = simple_match.group(2) + rtt1 = float(simple_match.group(3)) + _log.debug(f"Line {i:2}: SIMPLE IP HOP - {hop_number}: {ip_address} {rtt1} ms") + + else: + _log.debug(f"Line {i:2}: NO MATCH - skipping") + continue + + # Create hop object if we have valid data + if hop_number is not None: + try: + # Validate IP address if present + if ip_address: + validate_ip(ip_address) + except ValueError: + # If IP validation fails, treat as hostname + if not hostname: + hostname = ip_address + ip_address = None + + hop = TracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + display_ip=None, + hostname=hostname, + rtt1=rtt1, + rtt2=rtt2, + rtt3=rtt3, + sent_count=None, + last_rtt=rtt1, + best_rtt=( + min(filter(None, [rtt1, rtt2, rtt3])) if any([rtt1, rtt2, rtt3]) else None + ), + worst_rtt=( + max(filter(None, [rtt1, rtt2, rtt3])) if any([rtt1, rtt2, rtt3]) else None + ), + loss_pct=None, + # BGP enrichment fields (will be populated by enrichment plugin) + asn=None, + org=None, + prefix=None, + country=None, + rir=None, + allocated=None, + ) + hops.append(hop) + + # Clean up hops - remove duplicates and sort by hop number + _log.debug(f"Before cleanup: {len(hops)} hops") + + # Group hops by hop number and merge data + hop_dict = {} + for hop in hops: + if hop.hop_number in hop_dict: + # Merge data for same hop number + existing = hop_dict[hop.hop_number] + # Keep the first non-None value for each field + hop_dict[hop.hop_number] = TracerouteHop( + hop_number=hop.hop_number, + ip_address=existing.ip_address or hop.ip_address, + display_ip=existing.display_ip or hop.display_ip, + hostname=existing.hostname or hop.hostname, + rtt1=existing.rtt1 or hop.rtt1, + rtt2=existing.rtt2 or hop.rtt2, + rtt3=existing.rtt3 or hop.rtt3, + sent_count=existing.sent_count or hop.sent_count, + last_rtt=existing.last_rtt or hop.last_rtt, + best_rtt=existing.best_rtt or hop.best_rtt, + worst_rtt=existing.worst_rtt or hop.worst_rtt, + loss_pct=existing.loss_pct or hop.loss_pct, + asn=existing.asn or hop.asn, + org=existing.org or hop.org, + prefix=existing.prefix or hop.prefix, + country=existing.country or hop.country, + rir=existing.rir or hop.rir, + allocated=existing.allocated or hop.allocated, + ) + else: + hop_dict[hop.hop_number] = hop + + # Convert back to sorted list + final_hops = [hop_dict[hop_num] for hop_num in sorted(hop_dict.keys())] + _log.debug(f"After cleanup: {len(final_hops)} hops") + + # Debug final hop list + for hop in final_hops: + hostname_display = hop.hostname or "no-hostname" + _log.debug( + f"Final hop {hop.hop_number}: {hop.ip_address} ({hostname_display}) - RTTs: {hop.rtt1}/{hop.rtt2}/{hop.rtt3}" + ) + + _log.info(f"Parsed {len(final_hops)} hops from FRR traceroute") + + # Extract packet information from traceroute output + max_hops = 30 # Default + packet_size = 60 # FRR default + + # Try to extract from header line + for line in lines: + if "hops max" in line.lower(): + try: + # Look for pattern like "30 hops max, 60 byte packets" + match = re.search(r"(\d+)\s+hops\s+max.*?(\d+)\s+byte", line) + if match: + max_hops = int(match.group(1)) + packet_size = int(match.group(2)) + break + except (ValueError, AttributeError): + pass + + return TracerouteResult( + target=target, + source=source, + hops=final_hops, + max_hops=max_hops, + packet_size=packet_size, + raw_output=text, + asn_organizations={}, + ) + + +class TraceroutePluginFrr(OutputPlugin): + """Parse FRR traceroute output.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ("frr",) + directives: t.Sequence[str] = ("__hyperglass_frr_traceroute__",) + common: bool = False + + def process(self, output: "OutputType", query: "Query") -> "OutputType": + """Process FRR traceroute output.""" + # Extract target and source with fallbacks + target = str(query.query_target) if query.query_target else "unknown" + source = "unknown" + + if hasattr(query, "device") and query.device: + source = getattr(query.device, "display_name", None) or getattr( + query.device, "name", "unknown" + ) + + # Logging + _log = log.bind(plugin="TraceroutePluginFrr") + _log.info(f"Processing Traceroute for {target} from {source}") + + device = getattr(query, "device", None) + if device is not None: + if not getattr(device, "structured_output", False): + return output + try: + _params = use_state("params") + except Exception: + _params = None + if ( + _params + and getattr(_params, "structured", None) + and getattr(_params.structured, "enable_for_traceroute", None) is False + ): + return output + else: + try: + params = use_state("params") + except Exception: + params = None + if not (params and getattr(params, "structured", None)): + return output + if getattr(params.structured, "enable_for_traceroute", None) is False: + return output + + # Parse traceroute output + return parse_frr_traceroute( + output=output, + target=target, + source=source, + ) diff --git a/hyperglass/plugins/_builtin/trace_route_huawei.py b/hyperglass/plugins/_builtin/trace_route_huawei.py new file mode 100644 index 000000000..549e4eb04 --- /dev/null +++ b/hyperglass/plugins/_builtin/trace_route_huawei.py @@ -0,0 +1,278 @@ +"""Parse Huawei traceroute output to structured data.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop +from hyperglass.state import use_state + +# Local +from .._output import OutputPlugin + +if t.TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + from .._output import OutputType + + +def _normalize_output(output: t.Union[str, t.Sequence[str]]) -> t.List[str]: + """Ensure the output is a list of strings.""" + if isinstance(output, str): + return [output] + return list(output) + + +def parse_huawei_traceroute( + output: t.Union[str, t.Sequence[str]], target: str, source: str +) -> "OutputDataModel": + """Parse a Huawei traceroute text response.""" + result = None + out_list = _normalize_output(output) + + _log = log.bind(plugin=TraceroutePluginHuawei.__name__) + combined_output = "\n".join(out_list) + + # DEBUG: Log the raw output we're about to parse + _log.debug(f"=== HUAWEI TRACEROUTE PLUGIN RAW INPUT ===") + _log.debug(f"Target: {target}, Source: {source}") + _log.debug(f"Output pieces: {len(out_list)}") + _log.debug(f"Combined output length: {len(combined_output)}") + _log.debug(f"First 500 chars: {repr(combined_output[:500])}") + _log.debug(f"=== END PLUGIN RAW INPUT ===") + + try: + result = HuaweiTracerouteTable.parse_text(combined_output, target, source) + except Exception as exc: + _log.error(f"Failed to parse Huawei traceroute: {exc}") + raise ParsingError(f"Failed to parse Huawei traceroute output: {exc}") from exc + + _log.debug(f"=== FINAL STRUCTURED TRACEROUTE RESULT ===") + _log.debug(f"Successfully parsed {len(result.hops)} traceroute hops") + _log.debug(f"Target: {target}, Source: {source}") + for hop in result.hops: + _log.debug(f"Hop {hop.hop_number}: {hop.ip_address or '*'} - RTT: {hop.rtt1 or 'timeout'}") + _log.debug(f"Raw output length: {len(combined_output)} characters") + _log.debug(f"=== END STRUCTURED RESULT ===") + + return result + + +class HuaweiTracerouteTable(TracerouteResult): + """Huawei traceroute table parser.""" + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> TracerouteResult: + """Parse Huawei traceroute text output into structured data.""" + _log = log.bind(parser="HuaweiTracerouteTable") + + _log.debug(f"=== RAW HUAWEI TRACEROUTE INPUT ===") + _log.debug(f"Target: {target}, Source: {source}") + _log.debug(f"Raw text length: {len(text)} characters") + _log.debug(f"Raw text:\n{repr(text)}") + _log.debug(f"=== END RAW INPUT ===") + + hops = [] + lines = text.strip().split("\n") + + _log.debug(f"Split into {len(lines)} lines") + + # Pattern for normal hop: "1 172.24.165.197 1 ms" + hop_pattern = re.compile(r"^\s*(\d+)\s+(\S+)\s+(\d+(?:\.\d+)?)\s*ms\s*$") + + # Pattern for timeout hop: "3 *" + timeout_pattern = re.compile(r"^\s*(\d+)\s+\*\s*$") + + for i, line in enumerate(lines): + line = line.strip() + _log.debug(f"Line {i:2d}: {repr(line)}") + + if not line: + continue + + # Skip header lines + if ( + "traceroute to" in line.lower() + or "max hops" in line.lower() + or "press CTRL_C" in line.lower() + ): + _log.debug(f"Line {i:2d}: SKIPPING HEADER") + continue + + # Try to match normal hop + hop_match = hop_pattern.match(line) + if hop_match: + hop_number = int(hop_match.group(1)) + ip_address = hop_match.group(2) + rtt = float(hop_match.group(3)) + + _log.debug(f"Line {i:2d}: NORMAL HOP - {hop_number}: {ip_address} {rtt}ms") + + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + display_ip=None, # Huawei doesn't truncate IPs like MikroTik + hostname=None, # Will be populated by IP enrichment + rtt1=rtt, + rtt2=None, # Huawei shows only one RTT per line + rtt3=None, + # MikroTik-specific statistics (populate for consistency) + sent_count=1, # Huawei sends 1 ping per hop + last_rtt=rtt, # Same as the only RTT + best_rtt=rtt, # Same as the only RTT + worst_rtt=rtt, # Same as the only RTT + loss_pct=0, # No loss if we got a response + # BGP enrichment fields (will be populated by enrichment plugin) + asn=None, + org=None, + prefix=None, + country=None, + rir=None, + allocated=None, + ) + ) + continue + + # Try to match timeout hop + timeout_match = timeout_pattern.match(line) + if timeout_match: + hop_number = int(timeout_match.group(1)) + + _log.debug(f"Line {i:2d}: TIMEOUT HOP - {hop_number}: *") + + hops.append( + TracerouteHop( + hop_number=hop_number, + ip_address=None, + display_ip=None, + hostname=None, + rtt1=None, + rtt2=None, + rtt3=None, + # MikroTik-specific statistics for timeout + sent_count=1, # Still sent 1 ping, just timed out + last_rtt=None, + best_rtt=None, + worst_rtt=None, + loss_pct=100, # 100% loss for timeout + # BGP enrichment fields (all None for timeout) + asn=None, + org=None, + prefix=None, + country=None, + rir=None, + allocated=None, + ) + ) + continue + + _log.debug(f"Line {i:2d}: UNMATCHED - skipping") + + _log.debug(f"Before cleanup: {len(hops)} hops") + + # Clean up consecutive timeout hops at the end + # Keep only the first few timeouts, remove excessive trailing timeouts + if len(hops) > 5: + # Find the last non-timeout hop + last_real_hop = -1 + for i in range(len(hops) - 1, -1, -1): + if not hops[i].is_timeout: + last_real_hop = i + break + + if last_real_hop >= 0: + # Keep at most 3 timeout hops after the last real hop + max_timeouts = 3 + timeout_count = 0 + cleaned_hops = hops[: last_real_hop + 1] # Keep all hops up to last real hop + + for hop in hops[last_real_hop + 1 :]: + if hop.is_timeout: + timeout_count += 1 + if timeout_count <= max_timeouts: + cleaned_hops.append(hop) + else: + _log.debug(f"Removing excessive timeout hop {hop.hop_number}") + else: + # If we find another real hop after timeouts, keep it + cleaned_hops.append(hop) + timeout_count = 0 + + hops = cleaned_hops + + _log.debug(f"After cleanup: {len(hops)} hops") + + for hop in hops: + if hop.is_timeout: + _log.debug(f"Final hop {hop.hop_number}: * (timeout)") + else: + _log.debug(f"Final hop {hop.hop_number}: {hop.ip_address} - {hop.rtt1}ms") + + _log.info(f"Parsed {len(hops)} hops from Huawei traceroute") + + return TracerouteResult( + target=target, + source=source, + hops=hops, + max_hops=64, # Default for Huawei + packet_size=40, # From the header in sample output + raw_output=text, + asn_organizations={}, + ) + + +class TraceroutePluginHuawei(OutputPlugin): + """Parse Huawei traceroute output.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ("huawei", "huawei_vrpv8") + directives: t.Sequence[str] = ("__hyperglass_huawei_traceroute__",) + common: bool = False + + def process(self, output: "OutputType", query: "Query") -> "OutputType": + """Process Huawei traceroute output.""" + # Extract target and source with fallbacks + target = str(query.query_target) if query.query_target else "unknown" + source = "unknown" + + if hasattr(query, "device") and query.device: + source = getattr(query.device, "display_name", None) or getattr( + query.device, "name", "unknown" + ) + + device = getattr(query, "device", None) + if device is not None: + if not getattr(device, "structured_output", False): + return output + try: + _params = use_state("params") + except Exception: + _params = None + if ( + _params + and getattr(_params, "structured", None) + and getattr(_params.structured, "enable_for_traceroute", None) is False + ): + return output + else: + try: + params = use_state("params") + except Exception: + params = None + if not (params and getattr(params, "structured", None)): + return output + if getattr(params.structured, "enable_for_traceroute", None) is False: + return output + + return parse_huawei_traceroute( + output=output, + target=target, + source=source, + ) diff --git a/hyperglass/plugins/_builtin/trace_route_mikrotik.py b/hyperglass/plugins/_builtin/trace_route_mikrotik.py new file mode 100644 index 000000000..7df6c184a --- /dev/null +++ b/hyperglass/plugins/_builtin/trace_route_mikrotik.py @@ -0,0 +1,165 @@ +"""Parse MikroTik traceroute output to structured data.""" + +# Standard Library +import typing as t + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log, log as _log +from hyperglass.settings import Settings +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.mikrotik import MikrotikTracerouteTable +from hyperglass.state import use_state + +# Local +from .._output import OutputPlugin + +if t.TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + from .._output import OutputType + + +def _normalize_output(output: t.Union[str, t.Sequence[str]]) -> t.List[str]: + """Ensure the output is a list of strings.""" + if isinstance(output, str): + return [output] + return list(output) + + +def _clean_traceroute_only( + output: t.Union[str, t.Sequence[str]], query: "Query" +) -> t.Union[str, t.Tuple[str, ...]]: + """Clean traceroute output using MikrotikGarbageOutput plugin.""" + from .mikrotik_garbage_output import MikrotikGarbageOutput + + out_list = _normalize_output(output) + cleaner = MikrotikGarbageOutput() + + cleaned_list: t.List[str] = [] + for piece in out_list: + try: + cleaned_piece = cleaner._clean_traceroute_output(piece) + except Exception: + cleaned_piece = piece + cleaned_list.append(cleaned_piece) + + if isinstance(output, str): + return cleaned_list[0] if cleaned_list else "" + return tuple(cleaned_list) + + +def parse_mikrotik_traceroute( + output: t.Union[str, t.Sequence[str]], target: str, source: str +) -> "OutputDataModel": + """Parse a cleaned MikroTik traceroute text response.""" + out_list = _normalize_output(output) + _log = log.bind(plugin=TraceroutePluginMikrotik.__name__) + combined_output = "\n".join(out_list) + + if Settings.debug: + _log.debug( + "Parsing cleaned traceroute input", + target=target, + source=source, + pieces=len(out_list), + combined_len=len(combined_output), + ) + + try: + validated = MikrotikTracerouteTable.parse_text(combined_output, target, source) + result = validated.traceroute_result() + result.raw_output = combined_output + + if Settings.debug: + _log.debug( + "Parsed traceroute result", + hops=len(validated.hops), + target=result.target, + source=result.source, + ) + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + except Exception as err: + _log.bind(error=str(err)).critical("Failed to parse MikroTik traceroute output") + raise ParsingError("Error parsing traceroute response data") from err + + return result + + +class TraceroutePluginMikrotik(OutputPlugin): + """Convert MikroTik traceroute output to structured format.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + directives: t.Sequence[str] = ("__hyperglass_mikrotik_traceroute__",) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputDataModel": + """Process the MikroTik traceroute output.""" + target = getattr(query, "target", "unknown") + source = getattr(query, "source", "unknown") + + if hasattr(query, "query_target") and query.query_target: + target = str(query.query_target) + + if hasattr(query, "device") and query.device: + source = getattr(query.device, "name", source) + + _log = log.bind(plugin=TraceroutePluginMikrotik.__name__) + + # Log raw router output only when debug is enabled + if Settings.debug: + try: + if isinstance(output, (tuple, list)): + try: + combined_raw = "\n".join(output) + except Exception: + combined_raw = repr(output) + else: + combined_raw = output if isinstance(output, str) else repr(output) + + _log.debug("Router raw output:\n{}", combined_raw) + except Exception: + _log.exception("Failed to log router raw output") + + try: + params = use_state("params") + except Exception: + params = None + + device = getattr(query, "device", None) + + # Check if structured output is enabled + if device is None: + if Settings.debug: + _log.debug("No device found, using cleanup-only mode") + return _clean_traceroute_only(output, query) + + if params is None: + if Settings.debug: + _log.debug("No params found, using cleanup-only mode") + return _clean_traceroute_only(output, query) + + if not getattr(params, "structured", None): + if Settings.debug: + _log.debug("Structured output not configured, using cleanup-only mode") + return _clean_traceroute_only(output, query) + + if getattr(params.structured, "enable_for_traceroute", None) is False: + if Settings.debug: + _log.debug("Structured output disabled for traceroute, using cleanup-only mode") + return _clean_traceroute_only(output, query) + + if Settings.debug: + _log.debug("Processing traceroute with structured output enabled") + + # Clean the output first using garbage cleaner before parsing + cleaned_output = _clean_traceroute_only(output, query) + if Settings.debug: + _log.debug("Applied garbage cleaning before structured parsing") + + return parse_mikrotik_traceroute(cleaned_output, target, source) diff --git a/hyperglass/plugins/_builtin/traceroute_ip_enrichment.py b/hyperglass/plugins/_builtin/traceroute_ip_enrichment.py new file mode 100644 index 000000000..fb260b36b --- /dev/null +++ b/hyperglass/plugins/_builtin/traceroute_ip_enrichment.py @@ -0,0 +1,210 @@ +"""IP enrichment for structured traceroute data.""" + +# Standard Library +import asyncio +import socket +import typing as t + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.log import log +from hyperglass.plugins._output import OutputPlugin +from hyperglass.models.data.traceroute import TracerouteResult + +if t.TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + + +class ZTracerouteIpEnrichment(OutputPlugin): + """Enrich structured traceroute output with IP enrichment ASN/organization data and reverse DNS.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ( + "mikrotik_routeros", + "mikrotik_switchos", + "mikrotik", + "cisco_ios", + "juniper_junos", + "huawei", + "huawei_vrpv8", + ) + directives: t.Sequence[str] = ("traceroute", "MikroTik_Traceroute") + common: bool = True + + def _reverse_dns_lookup(self, ip: str) -> t.Optional[str]: + """Perform reverse DNS lookup for an IP address.""" + from hyperglass.settings import Settings + + try: + hostname = socket.gethostbyaddr(ip)[0] + if Settings.debug: + log.debug(f"Reverse DNS for {ip}: {hostname}") + return hostname + except (socket.herror, socket.gaierror, socket.timeout) as e: + if Settings.debug: + log.debug(f"Reverse DNS lookup failed for {ip}: {e}") + return None + + async def _reverse_dns_lookup_async(self, ip: str) -> t.Optional[str]: + """Async wrapper around synchronous reverse DNS lookup using a thread. + + Uses asyncio.to_thread to avoid blocking the event loop and allows + multiple lookups to be scheduled concurrently. + """ + try: + return await asyncio.to_thread(self._reverse_dns_lookup, ip) + except Exception as e: + from hyperglass.settings import Settings + + if Settings.debug: + log.debug(f"Reverse DNS async lookup error for {ip}: {e}") + return None + + async def _enrich_async(self, output: TracerouteResult) -> None: + """Async helper to enrich traceroute data. + + This performs IP enrichment (ASN lookups), ASN organization lookups, + and then runs reverse DNS lookups concurrently for hops missing hostnames. + """ + # First enrich with IP information (ASN numbers) + await output.enrich_with_ip_enrichment() + + # Then enrich ASN numbers with organization names + await output.enrich_asn_organizations() + + # Concurrent reverse DNS for hops missing hostnames + ips_to_lookup: list[str] = [ + hop.ip_address for hop in output.hops if hop.ip_address and hop.hostname is None + ] + if not ips_to_lookup: + return + + # Schedule lookups concurrently + tasks = [asyncio.create_task(self._reverse_dns_lookup_async(ip)) for ip in ips_to_lookup] + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Apply results back to hops in order + idx = 0 + for hop in output.hops: + if hop.ip_address and hop.hostname is None: + res = results[idx] + idx += 1 + if isinstance(res, Exception): + from hyperglass.settings import Settings + + if Settings.debug: + log.debug(f"Reverse DNS lookup raised for {hop.ip_address}: {res}") + else: + hop.hostname = res + + def process(self, *, output: "OutputDataModel", query: "Query") -> "OutputDataModel": + """Enrich structured traceroute data with IP enrichment and reverse DNS information.""" + + if not isinstance(output, TracerouteResult): + return output + + _log = log.bind(plugin=self.__class__.__name__) + + # Import Settings for debug gating + from hyperglass.settings import Settings + + _log.info( + f"Starting IP enrichment for {len(output.hops)} traceroute hops" + ) # Check if IP enrichment is enabled in config + try: + from hyperglass.state import use_state + + params = use_state("params") + # If structured config missing or traceroute enrichment disabled, skip + # IP enrichment but still perform reverse DNS lookups. + if ( + not getattr(params, "structured", None) + or not params.structured.ip_enrichment.enrich_traceroute + or getattr(params.structured, "enable_for_traceroute", None) is False + ): + if Settings.debug: + _log.debug("IP enrichment for traceroute disabled in configuration") + # Still do reverse DNS if enrichment is disabled + # Perform concurrent reverse DNS lookups for hops needing hostnames + ips = [ + hop.ip_address for hop in output.hops if hop.ip_address and hop.hostname is None + ] + if ips: + try: + # Run lookups in an event loop + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # We're inside an event loop; run tasks via asyncio.run in thread + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit( + lambda: asyncio.run( + asyncio.gather( + *[self._reverse_dns_lookup_async(ip) for ip in ips] + ) + ) + ) + results = future.result() + else: + results = loop.run_until_complete( + asyncio.gather( + *[self._reverse_dns_lookup_async(ip) for ip in ips] + ) + ) + except RuntimeError: + results = asyncio.run( + asyncio.gather(*[self._reverse_dns_lookup_async(ip) for ip in ips]) + ) + + # Apply results + idx = 0 + for hop in output.hops: + if hop.ip_address and hop.hostname is None: + res = results[idx] + idx += 1 + if not isinstance(res, Exception): + hop.hostname = res + except Exception as e: + if Settings.debug: + _log.debug( + f"Concurrent reverse DNS failed (fallback to sequential): {e}" + ) + for hop in output.hops: + if hop.ip_address and hop.hostname is None: + hop.hostname = self._reverse_dns_lookup(hop.ip_address) + return output + except Exception as e: + if Settings.debug: + _log.debug(f"Could not check IP enrichment config: {e}") + + # Use the built-in enrichment method from TracerouteResult + try: + # Run async enrichment in sync context + loop = None + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # If we're already in an event loop, create a new task + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(asyncio.run, self._enrich_async(output)) + future.result() + else: + loop.run_until_complete(self._enrich_async(output)) + except RuntimeError: + # No event loop, create one + asyncio.run(self._enrich_async(output)) + _log.info("IP enrichment completed successfully") + except Exception as e: + _log.error(f"IP enrichment failed: {e}") + + # Reverse DNS lookups already handled in _enrich_async for missing hostnames + + _log.info(f"Completed enrichment for traceroute to {output.target}") + return output diff --git a/hyperglass/plugins/tests/test_bgp_route_huawei.py b/hyperglass/plugins/tests/test_bgp_route_huawei.py new file mode 100644 index 000000000..0485fad7c --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_huawei.py @@ -0,0 +1,75 @@ +"""Huawei VRP structured BGP route parsing tests.""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.config.params import Params +from hyperglass.models.data.bgp_route import BGPRouteTable +from hyperglass.models.parsing.huawei import HuaweiBGPTable + +if t.TYPE_CHECKING: + from hyperglass.state import HyperglassState + +# Representative `display bgp routing-table ` detail output: one best/ +# selected (active) route and one valid-but-not-best route. +SAMPLE = """ + BGP local router ID : 192.0.2.1 + Local AS number : 64500 + Paths: 2 available, 1 best, 1 select + + BGP routing table entry information of 203.0.113.0/24: + From: 198.51.100.1 (192.0.2.254) + Route Duration: 0d00h05m12s + Original nexthop: 198.51.100.1 + Community: 64500:100, 64500:200 + AS-path 64500 64501, origin igp, MED 0, localpref 100, pref-val 0, valid, external, best, select, pre 255 + + BGP routing table entry information of 203.0.113.128/25: + From: 198.51.100.2 (192.0.2.253) + Route Duration: 0d00h01m00s + Original nexthop: 198.51.100.2 + AS-path 64500 64999, origin incomplete, MED 10, localpref 90, pref-val 0, valid, external, pre 255 +""" + + +@pytest.fixture +def params() -> t.Dict[str, t.Any]: + return {} + + +@pytest.fixture +def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: + """Initialize Redis-backed state with params for BGPRoute validation.""" + _state = use_state() + _params = Params(**params) + with _state.cache.pipeline() as pipeline: + pipeline.set("params", _params) + yield _state + _state.clear() + + +def test_huawei_bgp_route(state): + table = HuaweiBGPTable.parse_text(SAMPLE).bgp_table() + + assert isinstance(table, BGPRouteTable), "Parsed result is not a BGPRouteTable" + assert table.count == 2, f"Expected 2 routes, got {table.count}" + + by_prefix = {r.prefix: r for r in table.routes} + assert set(by_prefix) == {"203.0.113.0/24", "203.0.113.128/25"} + + best = by_prefix["203.0.113.0/24"] + assert best.active is True, "best+select route should be active" + assert best.as_path == [64500, 64501], f"Bad as_path: {best.as_path}" + assert best.source_as == 64500, "source_as should be first ASN in path" + assert best.next_hop == "198.51.100.1" + assert "64500:100" in best.communities + + other = by_prefix["203.0.113.128/25"] + assert other.active is False, "non-best route should not be active" + assert other.as_path == [64500, 64999] diff --git a/hyperglass/plugins/tests/test_bgp_route_mikrotik.py b/hyperglass/plugins/tests/test_bgp_route_mikrotik.py new file mode 100644 index 000000000..d24518e17 --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_mikrotik.py @@ -0,0 +1,78 @@ +"""MikroTik structured BGP route parsing tests. + +The sample below is a trimmed, real `routing route print detail` capture from a +RouterOS 7.20.6 device (CCR2216), reduced to one active and one filtered route. +Long community lists are kept on a single line, matching what the device emits +to the looking-glass SSH driver (no terminal-width wrapping). +""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.config.params import Params +from hyperglass.models.data.bgp_route import BGPRouteTable +from hyperglass.models.parsing.mikrotik import MikrotikBGPTable + +if t.TYPE_CHECKING: + from hyperglass.state import HyperglassState + +SAMPLE = """Flags: X - disabled, F - filtered, U - unreachable, A - active; +c - connect, s - static, r - rip, b - bgp, n - bgp-net, o - ospf, i - isis, d - dhcp, v - vpn, m - modem, a - ldp-address, l - ldp-mapping, g - slaac, y - bgp-mpls-vpn, e - evpn; +H - hw-offloaded; + - ecmp, B - blackhole + Ab afi=ip contribution=active dst-address=1.0.0.0/24 routing-table=main pref-src=192.0.2.1 gateway=196.60.8.198 + immediate-gw=196.60.8.198%[sfp28-01] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-196.60.8.198" + rpki=valid bgp.session=NAP-JHB-CloudFlare-v4-1 .as-path="13335" .communities=13335:10045 .large-communities=328964:2000:0,328964:2001:0 .local-pref=250 .med=0 .origin=igp + + Fb afi=ip contribution=filtered dst-address=5.101.88.0/24 routing-table=main pref-src=192.0.2.1 gateway=192.0.2.2 + immediate-gw=192.0.2.2%[sfp28-10] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-192.0.2.2" + rpki=invalid bgp.session=TRANSIT-ANGOLA-v4-1 .as-path="37468,41095,51601,50113,207569" .communities=37468:14100,37468:2000 .med=0 .origin=incomplete +""" + + +@pytest.fixture +def params() -> t.Dict[str, t.Any]: + return {} + + +@pytest.fixture +def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: + """Initialize Redis-backed state with params for BGPRoute validation.""" + _state = use_state() + _params = Params(**params) + with _state.cache.pipeline() as pipeline: + pipeline.set("params", _params) + yield _state + _state.clear() + + +def test_mikrotik_bgp_route(state): + table = MikrotikBGPTable.parse_text(SAMPLE).bgp_table() + + assert isinstance(table, BGPRouteTable), "Parsed result is not a BGPRouteTable" + assert table.count == 2, f"Expected 2 routes, got {table.count}" + + by_prefix = {r.prefix: r for r in table.routes} + assert set(by_prefix) == {"1.0.0.0/24", "5.101.88.0/24"} + + # Active route (flag "A"): RPKI valid (state 1), single-ASN path. + active = by_prefix["1.0.0.0/24"] + assert active.active is True + assert active.filtered is False + assert active.as_path == [13335] + assert active.next_hop == "196.60.8.198" + assert "13335:10045" in active.communities + assert active.rpki_state == 1 # valid + + # Filtered route (flag "F" => filtered per the RouterOS flags legend): RPKI + # invalid (state 0), multi-ASN path. + filtered = by_prefix["5.101.88.0/24"] + assert filtered.active is False + assert filtered.filtered is True, "F-flagged route should be marked filtered" + assert filtered.as_path == [37468, 41095, 51601, 50113, 207569] + assert filtered.rpki_state == 0 # invalid diff --git a/hyperglass/plugins/tests/test_traceroute_frr.py b/hyperglass/plugins/tests/test_traceroute_frr.py new file mode 100644 index 000000000..0eea6beb8 --- /dev/null +++ b/hyperglass/plugins/tests/test_traceroute_frr.py @@ -0,0 +1,46 @@ +"""FRR structured traceroute parsing tests.""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.models.data.traceroute import TracerouteResult +from hyperglass.plugins._builtin.trace_route_frr import FrrTracerouteTable + +# Standard Linux `traceroute` output as emitted by FRR hosts: a plain hop, a +# hop with hostname + IP, a full timeout, and a multi-probe hop. +SAMPLE = """traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets + 1 198.51.100.1 0.221 ms + 2 router.example.net (203.0.113.1) 15.234 ms + 3 * + 4 8.8.8.8 20.001 ms 19.502 ms 21.110 ms +""" + + +def test_frr_traceroute(): + result = FrrTracerouteTable.parse_text(SAMPLE, target="8.8.8.8", source="192.0.2.1") + + assert isinstance(result, TracerouteResult) + assert result.target == "8.8.8.8" + assert len(result.hops) == 4, f"Expected 4 hops, got {len(result.hops)}" + + by_hop = {h.hop_number: h for h in result.hops} + assert by_hop[1].ip_address == "198.51.100.1" + assert by_hop[1].rtt1 == pytest.approx(0.221) + + # Hop 2 carries a resolved hostname alongside the IP. + assert by_hop[2].ip_address == "203.0.113.1" + assert by_hop[2].hostname == "router.example.net" + + # Hop 3 is a full timeout (no IP, no RTTs). + assert by_hop[3].ip_address is None + assert by_hop[3].rtt1 is None + + # Hop 4 reports three probes. + assert by_hop[4].ip_address == "8.8.8.8" + assert by_hop[4].rtt1 == pytest.approx(20.001) + assert by_hop[4].rtt3 == pytest.approx(21.110) diff --git a/hyperglass/plugins/tests/test_traceroute_huawei.py b/hyperglass/plugins/tests/test_traceroute_huawei.py new file mode 100644 index 000000000..af6de9394 --- /dev/null +++ b/hyperglass/plugins/tests/test_traceroute_huawei.py @@ -0,0 +1,40 @@ +"""Huawei VRP structured traceroute parsing tests.""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.models.data.traceroute import TracerouteResult +from hyperglass.plugins._builtin.trace_route_huawei import HuaweiTracerouteTable + +# Huawei `tracert -q 1` output: one RTT per hop, a timeout hop, then the target. +SAMPLE = """ traceroute to 8.8.8.8, max hops 30 ,press CTRL_C to break + 1 198.51.100.1 1 ms + 2 203.0.113.1 5 ms + 3 * + 4 8.8.8.8 21 ms +""" + + +def test_huawei_traceroute(): + result = HuaweiTracerouteTable.parse_text(SAMPLE, target="8.8.8.8", source="192.0.2.1") + + assert isinstance(result, TracerouteResult) + assert result.target == "8.8.8.8" + assert len(result.hops) == 4, f"Expected 4 hops, got {len(result.hops)}" + + by_hop = {h.hop_number: h for h in result.hops} + assert by_hop[1].ip_address == "198.51.100.1" + assert by_hop[1].rtt1 == pytest.approx(1.0) + assert by_hop[2].ip_address == "203.0.113.1" + + # Hop 3 is a timeout (no IP / no RTT). + assert by_hop[3].ip_address is None + assert by_hop[3].rtt1 is None + + assert by_hop[4].ip_address == "8.8.8.8" + assert by_hop[4].rtt1 == pytest.approx(21.0) diff --git a/hyperglass/plugins/tests/test_traceroute_mikrotik.py b/hyperglass/plugins/tests/test_traceroute_mikrotik.py new file mode 100644 index 000000000..2fa792cc9 --- /dev/null +++ b/hyperglass/plugins/tests/test_traceroute_mikrotik.py @@ -0,0 +1,153 @@ +"""MikroTik structured traceroute parsing tests. + +MikroTik's `/tool traceroute` redraws its table in place; the netmiko-based +looking-glass driver captures the final accumulated table (one row per hop, +SENT = total probes, with AVG/BEST/WORST filled in). + +Real RouterOS devices emit two distinct table layouts, both of which must parse: + +1. No index column, with a trailing STATUS column: + ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS + 198.51.100.7 0% 3 0.2ms 0.2 0.1 0.2 0 + +2. Leading hop-index column ("#") and no STATUS column: + # ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV + 0 192.0.2.1 0% 3 15.8ms 15.8 15.8 15.9 0 + +The index column previously shifted every field and dropped all hops; the +parser now detects and strips it. The fixtures below preserve the exact shape +of real captures (hop counts, timeout rows, both layouts), but all addresses +are RFC 5737 documentation ranges (192.0.2.0/24, 198.51.100.0/24, +203.0.113.0/24) rather than real network data. + +The parser only populates fields it can read from the table (IP, loss, sent, +RTTs). ASN/IXP/org enrichment is applied later by a separate plugin, so these +parser tests assert only parser-produced fields. +""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.models.data.traceroute import TracerouteResult +from hyperglass.plugins._builtin.trace_route_mikrotik import parse_mikrotik_traceroute + +# --------------------------------------------------------------------------- +# Fixtures: (raw table, target, source, expected hops) +# Each expected hop is (ip_address, loss_pct, best_rtt, worst_rtt). +# Addresses are RFC 5737 documentation ranges (anonymized). +# --------------------------------------------------------------------------- + +# Indexed layout ("#" column, no STATUS), including a timeout hop. +INDEXED_5HOP = ( + """ # ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV + 0 192.0.2.1 0% 3 15.8ms 15.8 15.8 15.9 0 + 1 100% 3 timeout + 2 198.51.100.7 0% 3 17.4ms 17.4 17.4 17.5 0 + 3 198.51.100.8 0% 3 16.4ms 16.4 16.4 16.4 0 + 4 203.0.113.8 0% 3 16.3ms 16.3 16.3 16.3 0 +""", + "203.0.113.8", + "router-a", + [ + ("192.0.2.1", 0, 15.8, 15.9), + (None, 100, None, None), + ("198.51.100.7", 0, 17.4, 17.5), + ("198.51.100.8", 0, 16.4, 16.4), + ("203.0.113.8", 0, 16.3, 16.3), + ], +) + +# Indexed layout, short 2-hop trace. +INDEXED_2HOP = ( + """ # ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV + 0 192.0.2.20 0% 3 4.4ms 7.6 0.7 17.7 7.3 + 1 203.0.113.1 0% 3 0.8ms 0.8 0.8 0.8 0 +""", + "203.0.113.1", + "router-a", + [ + ("192.0.2.20", 0, 0.7, 17.7), + ("203.0.113.1", 0, 0.8, 0.8), + ], +) + +# Non-indexed layout with a trailing STATUS column. +STATUS_COL_4HOP = ( + """ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS +192.0.2.10 0% 3 0.2ms 0.2 0.1 0.2 0 +198.51.100.7 0% 3 1.6ms 1.5 1.4 1.6 0.1 +198.51.100.8 0% 3 0.5ms 0.5 0.5 0.5 0 +203.0.113.8 0% 3 1ms 1 1 1 0 +""", + "203.0.113.8", + "router-b", + [ + ("192.0.2.10", 0, 0.1, 0.2), + ("198.51.100.7", 0, 1.4, 1.6), + ("198.51.100.8", 0, 0.5, 0.5), + ("203.0.113.8", 0, 1.0, 1.0), + ], +) + +STATUS_COL_5HOP = ( + """ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS +192.0.2.1 0% 3 0.7ms 0.7 0.7 0.7 0 +192.0.2.10 0% 3 0.9ms 0.9 0.8 0.9 0 +198.51.100.9 0% 3 2.5ms 2.5 2.4 2.6 0.1 +198.51.100.10 0% 3 2.3ms 2.2 2.1 2.3 0.1 +203.0.113.8 0% 3 1.3ms 1.3 1.3 1.3 0 +""", + "203.0.113.8", + "router-c", + [ + ("192.0.2.1", 0, 0.7, 0.7), + ("192.0.2.10", 0, 0.8, 0.9), + ("198.51.100.9", 0, 2.4, 2.6), + ("198.51.100.10", 0, 2.1, 2.3), + ("203.0.113.8", 0, 1.3, 1.3), + ], +) + +CASES = { + "indexed-5hop": INDEXED_5HOP, + "indexed-2hop": INDEXED_2HOP, + "status-col-4hop": STATUS_COL_4HOP, + "status-col-5hop": STATUS_COL_5HOP, +} + + +@pytest.mark.parametrize("case", CASES.values(), ids=list(CASES.keys())) +def test_mikrotik_traceroute(case: t.Tuple) -> None: + raw, target, source, expected = case + result = parse_mikrotik_traceroute(raw, target=target, source=source) + + assert isinstance(result, TracerouteResult), "Expected a TracerouteResult" + assert len(result.hops) == len(expected), ( + f"Expected {len(expected)} hops, got {len(result.hops)}" + ) + + for i, (hop, (ip, loss, best, worst)) in enumerate(zip(result.hops, expected), start=1): + assert hop.hop_number == i, f"Hop {i}: wrong hop_number {hop.hop_number}" + assert hop.ip_address == ip, f"Hop {i}: expected ip {ip!r}, got {hop.ip_address!r}" + assert hop.loss_pct == loss, f"Hop {i}: expected loss {loss}, got {hop.loss_pct}" + # BEST/WORST are preserved verbatim (AVG is a computed float and not + # safe for exact comparison). + assert hop.best_rtt == best, f"Hop {i}: expected best {best}, got {hop.best_rtt}" + assert hop.worst_rtt == worst, f"Hop {i}: expected worst {worst}, got {hop.worst_rtt}" + + # The final hop is always the queried target. + assert result.hops[-1].ip_address == target + + +def test_mikrotik_traceroute_timeout_hop() -> None: + """A 100%-loss (timeout) row yields a hop with no IP/RTT but loss recorded.""" + result = parse_mikrotik_traceroute(*INDEXED_5HOP[:3]) + timeout = result.hops[1] + assert timeout.ip_address is None + assert timeout.loss_pct == 100 + assert timeout.last_rtt is None and timeout.avg_rtt is None diff --git a/hyperglass/ui/components/looking-glass-form.tsx b/hyperglass/ui/components/looking-glass-form.tsx index 2da46338a..776bfdd79 100644 --- a/hyperglass/ui/components/looking-glass-form.tsx +++ b/hyperglass/ui/components/looking-glass-form.tsx @@ -87,7 +87,7 @@ export const LookingGlassForm = (): JSX.Element => { return tmp; }, [form.queryType, form.queryLocation, getDirective]); - function submitHandler(): void { + async function submitHandler(): Promise { if (process.env.NODE_ENV === 'development') { console.table({ 'Query Location': form.queryLocation.toString(), @@ -97,6 +97,11 @@ export const LookingGlassForm = (): JSX.Element => { }); } + // Note: IP enrichment refresh is now handled server-side on query + // submission when enabled. Removing client-side best-effort refresh + // to centralize refresh logic and avoid redundant requests from many + // clients. + // Before submitting a query, make sure the greeting is acknowledged if required. This should // be handled before loading the app, but people be sneaky. if (!greetingReady) { diff --git a/hyperglass/ui/components/output/cell.tsx b/hyperglass/ui/components/output/cell.tsx index 6bf12b706..79073c85d 100644 --- a/hyperglass/ui/components/output/cell.tsx +++ b/hyperglass/ui/components/output/cell.tsx @@ -1,4 +1,4 @@ -import { MonoField, Active, Weight, Age, Communities, RPKIState, ASPath } from './fields'; +import { MonoField, Active, Weight, Age, Communities, RPKIState, ASPath, HideableField, NextHop } from './fields'; import type { CellRenderProps } from '~/types'; @@ -14,14 +14,27 @@ export const Cell = (props: CellProps): JSX.Element => { med: , age: , prefix: , - next_hop: , + next_hop: ( + + ), peer_rid: , source_as: , active: , - source_rid: , + source_rid: , local_preference: , communities: , - as_path: , + as_path: ( + + ), rpki_state: , weight: , }; diff --git a/hyperglass/ui/components/output/fields.tsx b/hyperglass/ui/components/output/fields.tsx index a24857a17..23c686667 100644 --- a/hyperglass/ui/components/output/fields.tsx +++ b/hyperglass/ui/components/output/fields.tsx @@ -30,6 +30,15 @@ interface WeightProps extends TextProps { interface ASPathProps { path: number[]; active: boolean; + // Optional ASN -> org map populated when enrichment is enabled + asnOrgs?: Record; +} + +interface NextHopProps extends TextProps { + ip: string; + asn?: string | null; + org?: string | null; + country?: string | null; } interface CommunitiesProps { @@ -50,6 +59,16 @@ dayjs.extend(utcPlugin); export const MonoField = (props: MonoFieldProps): JSX.Element => { const { v, ...rest } = props; + + // Handle empty or undefined values, but not zero values + if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) { + return ( + + N/A + + ); + } + return ( {v} @@ -74,6 +93,12 @@ export const Active = (props: ActiveProps): JSX.Element => { export const Age = (props: AgeProps): JSX.Element => { const { inSeconds, ...rest } = props; + + // Handle case where age is not available (e.g., MikroTik) - hide the field entirely + if (inSeconds === -1) { + return <>; + } + const now = dayjs.utc(); const then = now.subtract(inSeconds, 'second'); return ( @@ -99,7 +124,7 @@ export const Weight = (props: WeightProps): JSX.Element => { }; export const ASPath = (props: ASPathProps): JSX.Element => { - const { path, active } = props; + const { path, active, asnOrgs = {} } = props; const color = useColorValue( // light: inactive, active ['blackAlpha.500', 'blackAlpha.500'], @@ -121,6 +146,8 @@ export const ASPath = (props: ASPathProps): JSX.Element => { path.map((asn, i) => { const asnStr = String(asn); + const orgName = asnOrgs?.[asnStr]?.name; + const tooltipLabel = orgName ? `${asnStr} - ${orgName}` : `AS${asnStr}`; i !== 0 && paths.push( { ); paths.push( // biome-ignore lint/suspicious/noArrayIndexKey: index makes sense in this case. - - {asnStr} - , + + + {asnStr} + + , ); }); return {paths}; }; +export const NextHop = (props: NextHopProps): JSX.Element => { + const { ip, asn, org, country, ...rest } = props; + + // Build tooltip label with ASN - ORG format (strip AS prefix from asn to match AS path format) + const hasEnrichment = asn || org; + let tooltipLabel = ''; + + if (hasEnrichment) { + const parts = []; + if (asn) { + // Remove "AS" prefix if present to match AS path tooltip format + const asnNumber = asn.startsWith('AS') ? asn.slice(2) : asn; + parts.push(asnNumber); + } + if (org) parts.push(org); + tooltipLabel = parts.join(' - '); + } + + if (!hasEnrichment) { + // No enrichment data, just show the IP + return ( + + {ip} + + ); + } + + // Show tooltip with ASN - ORG format + return ( + + + {ip} + + + ); +}; + export const Communities = (props: CommunitiesProps): JSX.Element => { const { communities } = props; const { web } = useConfig(); const bg = useColorValue('white', 'gray.900'); const color = useOpposingColor(bg); + + // Parse communities to separate code and name if present + const parsedCommunities = communities.map(community => { + if (community.includes(',')) { + const [code, name] = community.split(',', 2); + return { code, name, display: `${code} - ${name}` }; + } + return { code: community, name: null, display: community }; + }); + return ( @@ -175,7 +257,9 @@ export const Communities = (props: CommunitiesProps): JSX.Element => { fontWeight="normal" whiteSpace="pre-wrap" > - {communities.join('\n')} + {parsedCommunities.map(({ display }, index) => ( + {display} + ))} @@ -231,3 +315,18 @@ const _RPKIState: React.ForwardRefRenderFunction }; export const RPKIState = forwardRef(_RPKIState); + +export const HideableField = (props: MonoFieldProps): JSX.Element => { + const { v, ...rest } = props; + + // Hide the field entirely if value is empty or undefined + if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) { + return <>; + } + + return ( + + {v} + + ); +}; diff --git a/hyperglass/ui/components/output/index.ts b/hyperglass/ui/components/output/index.ts index 6e28bab53..b7220bcf8 100644 --- a/hyperglass/ui/components/output/index.ts +++ b/hyperglass/ui/components/output/index.ts @@ -1,2 +1,3 @@ export * from './bgp-table'; export * from './text-output'; +export * from './traceroute-table'; diff --git a/hyperglass/ui/components/output/traceroute-cell.tsx b/hyperglass/ui/components/output/traceroute-cell.tsx new file mode 100644 index 000000000..7c1356027 --- /dev/null +++ b/hyperglass/ui/components/output/traceroute-cell.tsx @@ -0,0 +1,51 @@ +import { MonoField, ASNField, LatencyField, LossField, HostnameField } from './traceroute-fields'; + +import type { TracerouteCellRenderProps } from '~/types'; + +interface TracerouteCellProps { + data: TracerouteCellRenderProps; + rawData: TracerouteResult; +} + +export const TracerouteCell = (props: TracerouteCellProps): JSX.Element => { + const { data, rawData } = props; + const cellId = data.column.id as keyof TracerouteHop; + + // For IP address field, prefer display_ip if available (for truncated IPv6) + const getIPValue = () => { + if (cellId === 'ip_address') { + const hop = data.row?.original as TracerouteHop | undefined; + if (hop && hop.display_ip) { + return hop.display_ip; + } + if (hop && hop.ip_address) { + return hop.ip_address; + } + } + return data.value; + }; + + const component = { + hop_number: , + ip_address: , + display_ip: , // For truncated IPv6 display + hostname: , + loss_pct: , + sent_count: , + last_rtt: , + avg_rtt: , + best_rtt: , + worst_rtt: , + asn: , + org: null, // Hidden, displayed as part of ASN + prefix: , + country: , + rir: , + allocated: , + rtt1: null, // Not displayed directly in table + rtt2: null, // Not displayed directly in table + rtt3: null, // Not displayed directly in table + }; + + return component[cellId] ?? ; +}; \ No newline at end of file diff --git a/hyperglass/ui/components/output/traceroute-fields.tsx b/hyperglass/ui/components/output/traceroute-fields.tsx new file mode 100644 index 000000000..491e971d3 --- /dev/null +++ b/hyperglass/ui/components/output/traceroute-fields.tsx @@ -0,0 +1,188 @@ +import { Text, Tooltip, Badge, Link } from '@chakra-ui/react'; +import { Else, If, Then } from 'react-if'; +import { useColorValue } from '~/hooks'; + +import type { TextProps } from '@chakra-ui/react'; + +interface MonoFieldProps extends TextProps { + v: React.ReactNode; +} + +interface ASNFieldProps extends TextProps { + asn: string | null; + org: string | null; +} + +interface HostnameFieldProps extends TextProps { + hostname: string | null; +} + +interface LatencyFieldProps extends TextProps { + rtt: number | null; +} + +interface LossFieldProps extends TextProps { + loss: number | null; +} + +export const MonoField = (props: MonoFieldProps): JSX.Element => { + const { v, ...rest } = props; + + // Handle empty, null, undefined values and timeout indicators + if (v === null || v === undefined || (typeof v === 'string' && (v.trim() === '' || v === 'None'))) { + return ( + + — + + ); + } + + return ( + + {v} + + ); +}; + +export const ASNField = (props: ASNFieldProps): JSX.Element => { + const { asn, org, ...rest } = props; + + if (!asn || asn === 'None' || asn === 'null') { + return ( + + — + + ); + } + + // Display ASN. If this hop is an IXP (asn === 'IXP') and we have the + // IXP name in `org`, show the IXP name instead of the literal "IXP" so + // the visualiser renders a friendly label. Keep the tooltip labeled as + // "IXP - " for clarity. + let asnDisplay = asn; // default: "12345" or "IXP" + // For table display we want IXPs to appear as the literal "IXP". + if (asn === 'IXP') { + asnDisplay = 'IXP'; + } + const tooltipLabel = org && org !== 'None' + ? (asn === 'IXP' ? `IXP - ${org}` : `${asnDisplay} - ${org}`) + : asnDisplay; + + const isNumericAsn = /^\d+$/.test(asnDisplay); + const content = isNumericAsn ? ( + + {asnDisplay} + + ) : ( + + {asnDisplay} + + ); + + return ( + + {content} + + ); +}; + +export const HostnameField = (props: HostnameFieldProps): JSX.Element => { + const { hostname, ...rest } = props; + + if (!hostname || hostname === 'None' || hostname === 'null') { + return ( + + — + + ); + } + + return ( + + + {hostname} + + + ); +}; + +export const LatencyField = (props: LatencyFieldProps): JSX.Element => { + const { rtt, ...rest } = props; + + if (rtt === null || rtt === undefined) { + return ( + + * + + ); + } + + // Color-code latency: green < 50ms, yellow < 200ms, red >= 200ms + const getLatencyColor = (latency: number) => { + if (latency < 50) return 'green.500'; + if (latency < 200) return 'yellow.500'; + return 'red.500'; + }; + + return ( + + {rtt.toFixed(1)}ms + + ); +}; + +export const LossField = (props: LossFieldProps): JSX.Element => { + const { loss, ...rest } = props; + + if (loss === null || loss === undefined) { + return ( + + — + + ); + } + + // Color-code loss: green = 0%, yellow < 50%, red >= 50% + const getLossColor = (lossPercent: number) => { + if (lossPercent === 0) return 'green.500'; + if (lossPercent < 50) return 'yellow.500'; + return 'red.500'; + }; + + const bgColor = useColorValue( + loss === 0 ? 'green.50' : loss < 50 ? 'yellow.50' : 'red.50', + loss === 0 ? 'green.900' : loss < 50 ? 'yellow.900' : 'red.900' + ); + + return ( + + {loss}% + + ); +}; \ No newline at end of file diff --git a/hyperglass/ui/components/output/traceroute-table.tsx b/hyperglass/ui/components/output/traceroute-table.tsx new file mode 100644 index 000000000..90a94648f --- /dev/null +++ b/hyperglass/ui/components/output/traceroute-table.tsx @@ -0,0 +1,38 @@ +import { Flex } from '@chakra-ui/react'; +import { Table } from '~/components'; +import { TracerouteCell } from './traceroute-cell'; + +import type { FlexProps } from '@chakra-ui/react'; +import type { TracerouteTableColumn, TracerouteCellRenderProps } from '~/types'; + +type TracerouteTableProps = Swap; + +// Column definition for the traceroute table using BGP table structure +// Format: "Hop | IP | HostName (reverse dns) | ASN | Loss | Sent | Last | AVG | BEST | Worst" +const tracerouteColumns: TracerouteTableColumn[] = [ + { Header: 'Hop', accessor: 'hop_number', align: 'center', hidden: false }, + { Header: 'IP Address', accessor: 'ip_address', align: 'left', hidden: false }, + { Header: 'Hostname', accessor: 'hostname', align: 'left', hidden: false }, + { Header: 'ASN', accessor: 'asn', align: 'center', hidden: false }, + { Header: 'Loss', accessor: 'loss_pct', align: 'center', hidden: false }, + { Header: 'Sent', accessor: 'sent_count', align: 'center', hidden: false }, + { Header: 'Last', accessor: 'last_rtt', align: 'right', hidden: false }, + { Header: 'AVG', accessor: 'avg_rtt', align: 'right', hidden: false }, + { Header: 'Best', accessor: 'best_rtt', align: 'right', hidden: false }, + { Header: 'Worst', accessor: 'worst_rtt', align: 'right', hidden: false }, +]; + +export const TracerouteTable = (props: TracerouteTableProps): JSX.Element => { + const { children: data, ...rest } = props; + + return ( + + + columns={tracerouteColumns as any} + bordersHorizontal + data={data.hops} + Cell={(d: TracerouteCellRenderProps) => } + /> + + ); +}; \ No newline at end of file diff --git a/hyperglass/ui/components/path/chart.tsx b/hyperglass/ui/components/path/chart.tsx index e5e95f3fc..a839f18cd 100644 --- a/hyperglass/ui/components/path/chart.tsx +++ b/hyperglass/ui/components/path/chart.tsx @@ -1,4 +1,4 @@ -import { Badge, Box, Flex, SkeletonText, VStack } from '@chakra-ui/react'; +import { Badge, Box, Flex, VStack } from '@chakra-ui/react'; import { useMemo } from 'react'; import ReactFlow, { Background, @@ -9,14 +9,14 @@ import ReactFlow, { isEdge, } from 'reactflow'; import { useConfig } from '~/context'; -import { useASNDetail, useColorToken, useColorValue } from '~/hooks'; +import { useColorToken, useColorValue } from '~/hooks'; import { Controls } from './controls'; import { useElements } from './use-elements'; import type { NodeProps as ReactFlowNodeProps } from 'reactflow'; interface ChartProps { - data: StructuredResponse; + data: AllStructuredResponses; } interface NodeProps extends Omit { @@ -70,23 +70,13 @@ const ASNode = (props: NodeProps): JSX.Element => { const color = useColorValue('black', 'white'); const bg = useColorValue('white', 'whiteAlpha.200'); - const { data: asnData, isError, isLoading } = useASNDetail(String(asn)); - return ( <> {hasChildren && } - {isLoading ? ( - - - - ) : !isError && asnData?.data?.asn.organization?.orgName ? ( - asnData.data.asn.organization.orgName - ) : ( - name - )} + {name} {asn} diff --git a/hyperglass/ui/components/path/path.tsx b/hyperglass/ui/components/path/path.tsx index 0fd3d7425..0bd249481 100644 --- a/hyperglass/ui/components/path/path.tsx +++ b/hyperglass/ui/components/path/path.tsx @@ -23,12 +23,55 @@ export const Path = (props: PathProps): JSX.Element => { const getResponse = useFormState(s => s.response); const { isOpen, onClose, onOpen } = useDisclosure(); const response = getResponse(device); - const output = response?.output as StructuredResponse; + const output = response?.output as AllStructuredResponses; const bg = useColorValue('light.50', 'dark.900'); const centered = useBreakpointValue({ base: false, lg: true }) ?? true; + const addResponse = useFormState(s => s.addResponse); return ( <> - + { + // When opening the AS path modal, attempt on-demand ASN enrichment + // if the response does not already contain ASN organization data. + try { + onOpen(); + if (!response) return; + const out = response.output as any; + const asnOrgs = out?.asn_organizations || {}; + if (Object.keys(asnOrgs).length > 0) return; + + // Collect unique ASNs from the output depending on type + let asns: string[] = []; + if (out?.routes) { + const all = out.routes.flatMap((r: any) => r.as_path || []); + asns = Array.from(new Set(all.map((a: any) => String(a)))); + } else if (out?.hops) { + const all = out.hops.map((h: any) => h.asn).filter(Boolean); + asns = Array.from(new Set(all.map((a: any) => String(a)))); + } + + if (asns.length === 0) return; + + const resp = await fetch('/api/aspath/enrich', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ as_path: asns }), + }); + if (!resp.ok) return; + const j = await resp.json(); + if (j?.success && j.asn_organizations) { + // Merge ASN orgs into the stored response and update state + out.asn_organizations = { ...(out.asn_organizations || {}), ...j.asn_organizations }; + addResponse(device, { ...response, output: out }); + } + } catch (e) { + // Ignore enrichment failures + // eslint-disable-next-line no-console + console.debug('AS path enrichment failed', e); + onOpen(); + } + }} + /> = Node | Edge; const NODE_WIDTH = 128; const NODE_HEIGHT = 48; -export function useElements(base: BasePath, data: StructuredResponse): FlowElement[] { +export function useElements(base: BasePath, data: AllStructuredResponses): FlowElement[] { return useMemo(() => { return [...buildElements(base, data)]; }, [base, data]); } +/** + * Check if data contains BGP routes + */ +function isBGPData(data: AllStructuredResponses): data is BGPStructuredOutput { + return 'routes' in data && Array.isArray(data.routes); +} + +/** + * Check if data contains traceroute hops + */ +function isTracerouteData(data: AllStructuredResponses): data is TracerouteStructuredOutput { + return 'hops' in data && Array.isArray(data.hops); +} + /** * Calculate the positions for each AS Path. * @see https://github.com/MrBlenny/react-flow-chart/issues/61 */ function* buildElements( base: BasePath, - data: StructuredResponse, + data: AllStructuredResponses, ): Generator> { - const { routes } = data; - // Eliminate empty AS paths & deduplicate non-empty AS paths. Length should be same as count minus empty paths. - const asPaths = routes.filter(r => r.as_path.length !== 0).map(r => [...new Set(r.as_path)]); + let asPaths: string[][] = []; + let asnOrgs: Record = {}; + // For traceroute data we may have IXPs represented as asn === 'IXP' with + // the IXP name stored per-hop in hop.org. Collect per-path org arrays so + // nodes for IXPs can show the proper IXP name instead of the generic + // "IXP" label. + const pathGroupOrgs: Record> = {}; + + if (isBGPData(data)) { + // Handle BGP routes with AS paths + const { routes } = data; + asPaths = routes + .filter(r => r.as_path.length !== 0) + .map(r => { + const uniqueAsns = [...new Set(r.as_path.map(asn => String(asn)))]; + // Remove the base ASN if it's the first hop to avoid duplication + return uniqueAsns[0] === base.asn ? uniqueAsns.slice(1) : uniqueAsns; + }) + .filter(path => path.length > 0); // Remove empty paths + + // Get ASN organization mapping if available + asnOrgs = (data as any).asn_organizations || {}; + + // Debug: Log BGP ASN organization data + if (Object.keys(asnOrgs).length > 0) { + console.debug('BGP ASN organizations loaded:', asnOrgs); + } else { + console.warn('BGP ASN organizations not found or empty'); + } + } else if (isTracerouteData(data)) { + // Handle traceroute hops - build AS path from hop ASNs + const hopAsns: string[] = []; + const hopOrgs: Array = []; + let currentAsn = ''; + + for (const hop of data.hops) { + if (hop.asn && hop.asn !== 'None' && hop.asn !== currentAsn) { + currentAsn = hop.asn; + hopAsns.push(hop.asn); + hopOrgs.push(hop.org ?? undefined); + } + } + + if (hopAsns.length > 0) { + // Remove the base ASN if it's the first hop to avoid duplication + const removeBase = hopAsns[0] === base.asn; + const filteredAsns = removeBase ? hopAsns.slice(1) : hopAsns; + const filteredOrgs = removeBase ? hopOrgs.slice(1) : hopOrgs; + if (filteredAsns.length > 0) { + asPaths = [filteredAsns]; + pathGroupOrgs[0] = filteredOrgs; + } + } + + // Get ASN organization mapping if available + asnOrgs = (data as any).asn_organizations || {}; + + // Debug: Log traceroute ASN organization data + if (Object.keys(asnOrgs).length > 0) { + console.debug('Traceroute ASN organizations loaded:', asnOrgs); + } else { + console.warn('Traceroute ASN organizations not found or empty'); + } + } + + if (asPaths.length === 0) { + return; + } const totalPaths = asPaths.length - 1; @@ -95,7 +174,12 @@ function* buildElements( id: base.asn, type: 'ASNode', position: { x, y }, - data: { asn: base.asn, name: base.name, hasChildren: true, hasParents: false }, + data: { + asn: base.asn, + name: asnOrgs[base.asn]?.name || base.name, + hasChildren: true, + hasParents: false + }, }; for (const [groupIdx, pathGroup] of asPaths.entries()) { @@ -108,13 +192,25 @@ function* buildElements( const y = g.node(node).y - NODE_HEIGHT * (idx * 6); // Get each ASN's positions. + // Determine display name for this node. Prefer ASN org mapping, but + // for traceroute IXPs prefer the per-hop IXP name if present. + let nodeName = asnOrgs[asn]?.name || (asn === '0' ? 'Private/Unknown' : `AS${asn}`); + if (asn === 'IXP') { + const ixpName = pathGroupOrgs[groupIdx]?.[idx]; + if (ixpName && ixpName !== 'None') { + nodeName = ixpName; + } else { + nodeName = 'IXP'; + } + } + yield { id: node, type: 'ASNode', position: { x, y }, data: { asn: `${asn}`, - name: `AS${asn}`, + name: nodeName, hasChildren: idx < endIdx, hasParents: true, }, diff --git a/hyperglass/ui/components/query-type.tsx b/hyperglass/ui/components/query-type.tsx index 7b9ac73b9..a323644f7 100644 --- a/hyperglass/ui/components/query-type.tsx +++ b/hyperglass/ui/components/query-type.tsx @@ -127,21 +127,23 @@ const MenuList = (props: MenuListProps): JSX.Element = } return ( - - handleClick('') })}> - None - - {filtered.groups.map(value => { - return ( - handleClick(value) })} - > - {value} - - ); - })} - + {filtered.groups.length > 0 && ( + + handleClick('') })}> + None + + {filtered.groups.map(value => { + return ( + handleClick(value) })} + > + {value} + + ); + })} + + )} {children} ); diff --git a/hyperglass/ui/components/results/individual.tsx b/hyperglass/ui/components/results/individual.tsx index a316dd33c..d6591ae72 100644 --- a/hyperglass/ui/components/results/individual.tsx +++ b/hyperglass/ui/components/results/individual.tsx @@ -16,7 +16,7 @@ import startCase from 'lodash/startCase'; import { forwardRef, memo, useEffect, useMemo, useState } from 'react'; import isEqual from 'react-fast-compare'; import { Else, If, Then } from 'react-if'; -import { BGPTable, Path, TextOutput } from '~/components'; +import { BGPTable, TracerouteTable, Path, TextOutput } from '~/components'; import { useConfig } from '~/context'; import { Countdown, DynamicIcon } from '~/elements'; import { @@ -28,7 +28,7 @@ import { useStrf, useTableToString, } from '~/hooks'; -import { isStringOutput, isStructuredOutput } from '~/types'; +import { isStringOutput, isStructuredOutput, isBGPStructuredOutput, isTracerouteStructuredOutput } from '~/types'; import { CopyButton } from './copy-button'; import { FormattedError } from './formatted-error'; import { isFetchError, isLGError, isLGOutputOrError, isStackError } from './guards'; @@ -87,7 +87,7 @@ const _Result: React.ForwardRefRenderFunction = ( _setErrorLevel(e); }; - const { data, error, isLoading, refetch, isFetchedAfterMount } = useLGQuery( + const { data, error, isLoading, isFetching, refetch, isFetchedAfterMount } = useLGQuery( { queryLocation, queryTarget: form.queryTarget, queryType: form.queryType }, { onSuccess(data) { @@ -108,6 +108,7 @@ const _Result: React.ForwardRefRenderFunction = ( }, ); + const isQuerying = isLoading || isFetching; const isError = useMemo(() => isLGOutputOrError(data), [data, error]); const isCached = useMemo(() => data?.cached || !isFetchedAfterMount, [data, isFetchedAfterMount]); @@ -153,9 +154,15 @@ const _Result: React.ForwardRefRenderFunction = ( let copyValue = data?.output as string; + // Always create formatData hook for both BGP and Traceroute outputs const formatData = useTableToString(form.queryTarget, data, [data?.format]); + const isBGPData = isBGPStructuredOutput(data); + const isTracerouteData = isTracerouteStructuredOutput(data); - if (data?.format === 'application/json') { + if (data?.format === 'application/json' && isBGPData) { + copyValue = formatData(); + } else if (data?.format === 'application/json' && isTracerouteData) { + // For structured traceroute, use formatted table output for copy functionality copyValue = formatData(); } @@ -166,13 +173,13 @@ const _Result: React.ForwardRefRenderFunction = ( // Signal to the group that this result is done loading. useEffect(() => { // Only set the index if it's not already set and the query is finished loading. - if (Array.isArray(indices) && indices.length === 0 && !isLoading) { + if (Array.isArray(indices) && indices.length === 0 && !isQuerying) { // Only set the index if the response has data or an error. if (data || isError) { setIndex([index]); } } - }, [data, index, indices, isLoading, isError, setIndex]); + }, [data, index, indices, isQuerying, isError, setIndex]); if (device === null) { const id = `toast-queryLocation-${index}-${queryLocation}`; @@ -192,7 +199,7 @@ const _Result: React.ForwardRefRenderFunction = ( = ( = ( {isStructuredOutput(data) && data.level === 'success' && tableComponent && ( )} - - + + = ( - {isStructuredOutput(data) && data.level === 'success' && tableComponent ? ( + {isBGPStructuredOutput(data) && data.level === 'success' && tableComponent ? ( {data.output} + ) : isTracerouteStructuredOutput(data) && data.level === 'success' && tableComponent ? ( + {data.output} ) : isStringOutput(data) && data.level === 'success' && !tableComponent ? ( {data.output} ) : isStringOutput(data) && data.level !== 'success' ? ( diff --git a/hyperglass/ui/components/table/cell.tsx b/hyperglass/ui/components/table/cell.tsx index 228576ffc..dc0f3778a 100644 --- a/hyperglass/ui/components/table/cell.tsx +++ b/hyperglass/ui/components/table/cell.tsx @@ -6,12 +6,14 @@ import type { BoxProps } from '@chakra-ui/react'; interface TableCellProps extends Omit { bordersVertical?: [boolean, number]; align?: 'left' | 'right' | 'center'; + dimText?: boolean; } export const TableCell = (props: TableCellProps): JSX.Element => { - const { bordersVertical = [false, 0], align, ...rest } = props; + const { bordersVertical = [false, 0], align, dimText = false, ...rest } = props; const [doVerticalBorders, index] = bordersVertical; const borderLeftColor = useColorValue('blackAlpha.100', 'whiteAlpha.100'); + const filteredTextColor = useColorValue('filtered.400', 'filtered.500'); let borderProps = {}; if (doVerticalBorders && index !== 0) { @@ -25,6 +27,7 @@ export const TableCell = (props: TableCellProps): JSX.Element => { w="1%" textAlign={align} whiteSpace="nowrap" + color={dimText ? filteredTextColor : undefined} {...borderProps} {...rest} /> diff --git a/hyperglass/ui/components/table/main.tsx b/hyperglass/ui/components/table/main.tsx index fd1eeef5f..5e6383529 100644 --- a/hyperglass/ui/components/table/main.tsx +++ b/hyperglass/ui/components/table/main.tsx @@ -16,19 +16,19 @@ import { PageSelect } from './page-select'; import type { TableOptions, PluginHook } from 'react-table'; import type { Theme, TableColumn, CellRenderProps } from '~/types'; -interface TableProps { - data: Route[]; +interface TableProps { + data: T[]; striped?: boolean; - columns: TableColumn[]; + columns: TableColumn[] | any[]; // Allow more flexible column types heading?: React.ReactNode; bordersVertical?: boolean; bordersHorizontal?: boolean; - Cell?: React.FC; - rowHighlightProp?: keyof Route; + Cell?: React.FC; // More flexible cell render props + rowHighlightProp?: keyof T; rowHighlightBg?: Theme.ColorNames; } -export const Table = (props: TableProps): JSX.Element => { +export const Table = (props: TableProps): JSX.Element => { const { data, columns, @@ -61,12 +61,15 @@ export const Table = (props: TableProps): JSX.Element => { columns, defaultColumn, data, - initialState: { hiddenColumns }, - } as TableOptions; + initialState: { + hiddenColumns, + pageSize: 50 // Default to 50 rows instead of default 10 + }, + } as TableOptions; - const plugins = [useSortBy, usePagination] as PluginHook[]; + const plugins = [useSortBy, usePagination] as PluginHook[]; - const instance = useTable(options, ...plugins); + const instance = useTable(options, ...plugins); const { page, @@ -122,6 +125,7 @@ export const Table = (props: TableProps): JSX.Element => { {page.map((row, key) => { prepareRow(row); + const dimText = Boolean((row.original as { filtered?: boolean }).filtered); return ( { highlightBg={rowHighlightBg} doHorizontalBorders={bordersHorizontal} highlight={row.values[rowHighlightProp ?? ''] ?? false} + dimText={dimText} {...row.getRowProps()} > {row.cells.map((cell, i) => { @@ -137,6 +142,7 @@ export const Table = (props: TableProps): JSX.Element => { {typeof Cell !== 'undefined' ? ( diff --git a/hyperglass/ui/components/table/row.tsx b/hyperglass/ui/components/table/row.tsx index a0497d7e9..76b33b9d6 100644 --- a/hyperglass/ui/components/table/row.tsx +++ b/hyperglass/ui/components/table/row.tsx @@ -8,6 +8,7 @@ interface TableRowProps extends BoxProps { highlightBg?: Theme.ColorNames; doHorizontalBorders?: boolean; highlight?: boolean; + dimText?: boolean; doStripe?: boolean; index: number; } @@ -19,6 +20,7 @@ export const TableRow = (props: TableRowProps): JSX.Element => { highlight = false, highlightBg = 'primary', doHorizontalBorders = false, + dimText = false, ...rest } = props; @@ -40,12 +42,14 @@ export const TableRow = (props: TableRowProps): JSX.Element => { } const defaultBg = useColorValue('white', 'black'); const color = useOpposingColor(bg ?? defaultBg); + const filteredTextColor = useColorValue('filtered.400', 'filtered.500'); + const rowTextColor = dimText ? filteredTextColor : color; const borderProps = doHorizontalBorders && index !== 0 ? rowBorder : {}; return ( td': { color } }} + css={{ '& > td': { color: rowTextColor } }} fontWeight={highlight ? 'bold' : undefined} _hover={{ cursor: 'pointer', diff --git a/hyperglass/ui/hooks/use-asn-detail.ts b/hyperglass/ui/hooks/use-asn-detail.ts index 57f1e63b5..ab21ba111 100644 --- a/hyperglass/ui/hooks/use-asn-detail.ts +++ b/hyperglass/ui/hooks/use-asn-detail.ts @@ -16,29 +16,38 @@ interface ASNQuery { }; } -const query: QueryFunction = async (ctx: QueryFunctionContext) => { - const asn = ctx.queryKey; - const res = await fetch('https://api.asrank.caida.org/v2/graphql', { - mode: 'cors', - method: 'POST', - headers: { 'content-type': 'application/json' }, - /* eslint no-useless-escape: 0 */ - body: JSON.stringify({ query: `{ asn(asn:\"${asn}\"){ organization { orgName } } }` }), - }); - return await res.json(); -}; +// Disabled - we now get org names from our own IP enrichment system +// const query: QueryFunction = async (ctx: QueryFunctionContext) => { +// const asn = ctx.queryKey; +// const res = await fetch('https://api.asrank.caida.org/v2/graphql', { +// mode: 'cors', +// method: 'POST', +// headers: { 'content-type': 'application/json' }, +// /* eslint no-useless-escape: 0 */ +// body: JSON.stringify({ query: `{ asn(asn:\"${asn}\"){ organization { orgName } } }` }), +// }); +// return await res.json(); +// }; /** - * Query the Caida AS Rank API to get an ASN's organization name for the AS Path component. - * @see https://api.asrank.caida.org/v2/docs + * Stub function - we no longer need external CAIDA calls since we have ASN org data + * from our IP enrichment system. This hook is kept for compatibility but returns empty data. + * @deprecated Use as_path_data from traceroute response instead of external CAIDA calls */ export function useASNDetail(asn: string): QueryObserverResult { return useQuery({ queryKey: [asn], - queryFn: query, + queryFn: async () => ({ + data: { + asn: { + organization: null, // No external fetch - org data comes from IP enrichment + } + } + }), refetchOnWindowFocus: false, - refetchInterval: false, + refetchInterval: false, refetchOnMount: false, cacheTime: Infinity, + enabled: false, // Disable the query entirely }); } diff --git a/hyperglass/ui/hooks/use-table-to-string.ts b/hyperglass/ui/hooks/use-table-to-string.ts index acd2449ca..c1bcbfc35 100644 --- a/hyperglass/ui/hooks/use-table-to-string.ts +++ b/hyperglass/ui/hooks/use-table-to-string.ts @@ -3,7 +3,7 @@ import dayjs from 'dayjs'; import relativeTimePlugin from 'dayjs/plugin/relativeTime'; import utcPlugin from 'dayjs/plugin/utc'; import { useConfig } from '~/context'; -import { isStructuredOutput } from '~/types'; +import { isStructuredOutput, isBGPStructuredOutput, isTracerouteStructuredOutput } from '~/types'; type TableToStringFormatter = | ((v: string) => string) @@ -23,13 +23,17 @@ interface TableToStringFormatted { dayjs.extend(relativeTimePlugin); dayjs.extend(utcPlugin); -function formatAsPath(path: number[]): string { - return path.join(' → '); +function formatAsPath(path: number[], enrichments?: Record): string { + return path.map(asn => { + const asnStr = String(asn); + const org = enrichments?.[asnStr]?.name; + return org ? `${asn} (${org})` : String(asn); + }).join(' → '); } function formatCommunities(comms: string[]): string { const commsStr = comms.map(c => ` - ${c}`); - return `\n ${commsStr.join('\n')}`; + return `\n${commsStr.join('\n')}`; } function formatBool(val: boolean): string { @@ -93,26 +97,164 @@ export function useTableToString( let result = messages.noOutput; try { if (typeof data !== 'undefined' && isStructuredOutput(data)) { - const tableStringParts = [ - `Routes For: ${target.join(', ')}`, - `Timestamp: ${data.timestamp} UTC`, - ]; - for (const route of data.output.routes) { - for (const field of parsedDataFields) { - const [header, accessor, align] = field; - if (align !== null) { - let value = route[accessor]; - const fmtFunc = getFmtFunc(accessor) as (v: typeof value) => string; - value = fmtFunc(value); - if (accessor === 'prefix') { - tableStringParts.push(` - ${header}: ${value}`); - } else { - tableStringParts.push(` - ${header}: ${value}`); + + // Handle BGP data + if (isBGPStructuredOutput(data)) { + // Check if this is BGP data with routes + if (!('routes' in data.output) || !Array.isArray(data.output.routes)) { + return messages.noOutput; // Not BGP data, return early + } + + const tableStringParts = [ + `Routes For: ${target.join(', ')}`, + `Timestamp: ${data.timestamp} UTC`, + ]; + + // Get enrichment data if available + const enrichments = (data.output as any).asn_organizations || {}; + + for (const route of data.output.routes) { + for (const field of parsedDataFields) { + const [header, accessor, align] = field; + if (align !== null) { + let value = route[accessor]; + + // Handle fields that should be hidden when empty/not available + if ((accessor === 'source_rid' || accessor === 'age') && + (value === null || value === undefined || + (typeof value === 'string' && value.trim() === '') || + (accessor === 'age' && value === -1))) { + continue; // Skip this field entirely + } + + // Special handling for AS path with enrichment + if (accessor === 'as_path') { + value = formatAsPath(value as number[], enrichments); + } + // Special handling for next_hop with enrichment + else if (accessor === 'next_hop') { + const nextHopOrg = (route as any).next_hop_org; + const nextHopAsn = (route as any).next_hop_asn; + if (nextHopOrg || nextHopAsn) { + const asnPart = nextHopAsn ? nextHopAsn.replace(/^AS/, '') : ''; + const enrichmentPart = [asnPart, nextHopOrg].filter(Boolean).join(' '); + value = enrichmentPart ? `${value} (${enrichmentPart})` : value; + } + } else { + const fmtFunc = getFmtFunc(accessor) as (v: typeof value) => string; + value = fmtFunc(value); + } + + if (accessor === 'prefix') { + let statusSuffix = ''; + if (route.active) { + statusSuffix = ' (active path)'; + } else if (route.filtered) { + statusSuffix = ' (filtered)'; + } else { + statusSuffix = ' (candidate)'; + } + tableStringParts.push(` - ${header}: ${value}${statusSuffix}`); + } else { + tableStringParts.push(` - ${header}: ${value}`); + } } } } + result = tableStringParts.join('\n'); + } + + // Handle Traceroute data + else if (isTracerouteStructuredOutput(data)) { + if (!('hops' in data.output) || !Array.isArray(data.output.hops)) { + return messages.noOutput; // Not traceroute data, return early + } + + const formatRTT = (rtt: number | null | undefined): string => { + if (rtt === null || rtt === undefined) return '*'; + return `${rtt.toFixed(1)}ms`; + }; + + const formatIP = (hop: any): string => { + if (hop.display_ip) return hop.display_ip; // For truncated IPv6 + if (hop.ip_address) return hop.ip_address; + return '*'; + }; + + const formatASN = (hop: any): string => { + if (hop.asn) return `AS${hop.asn}`; + return '*'; + }; + + const formatHostname = (hop: any): string => { + if (hop.hostname && hop.hostname !== 'None' && hop.hostname !== 'null') return hop.hostname; + return '*'; + }; + + // Create a nicely formatted text table with proper column alignment + const header = `Traceroute to ${data.output.target} from ${data.output.source}`; + const timestamp = `Timestamp: ${data.timestamp} UTC`; + const separator = '=' .repeat(header.length); + + // Calculate optimal column widths by examining all data + const columnWidths = { + hop: Math.max(3, ...data.output.hops.map(h => h.hop_number.toString().length)), + ip: Math.max(10, ...data.output.hops.map(h => formatIP(h).length)), + hostname: Math.max(8, ...data.output.hops.map(h => formatHostname(h).length)), + asn: Math.max(3, ...data.output.hops.map(h => formatASN(h).length)), + loss: 4, // "100%" is max + sent: Math.max(4, ...data.output.hops.map(h => (h.sent_count || 0).toString().length)), + last: Math.max(4, ...data.output.hops.map(h => formatRTT(h.last_rtt).length)), + avg: Math.max(3, ...data.output.hops.map(h => formatRTT(h.avg_rtt).length)), + best: Math.max(4, ...data.output.hops.map(h => formatRTT(h.best_rtt).length)), + worst: Math.max(5, ...data.output.hops.map(h => formatRTT(h.worst_rtt).length)), + }; + + // Create header row with proper spacing + const headerRow = [ + 'Hop'.padEnd(columnWidths.hop), + 'IP Address'.padEnd(columnWidths.ip), + 'Hostname'.padEnd(columnWidths.hostname), + 'ASN'.padEnd(columnWidths.asn), + 'Loss'.padEnd(columnWidths.loss), + 'Sent'.padEnd(columnWidths.sent), + 'Last'.padEnd(columnWidths.last), + 'AVG'.padEnd(columnWidths.avg), + 'Best'.padEnd(columnWidths.best), + 'Worst' + ].join(' '); + + const totalWidth = headerRow.length; + + const tableLines = [ + header, + timestamp, + separator, + '', + headerRow, + '-'.repeat(totalWidth), + ]; + + // Format data rows with consistent column widths + for (const hop of data.output.hops) { + const row = [ + hop.hop_number.toString().padEnd(columnWidths.hop), + formatIP(hop).padEnd(columnWidths.ip), + formatHostname(hop).padEnd(columnWidths.hostname), + formatASN(hop).padEnd(columnWidths.asn), + `${hop.loss_pct || 0}%`.padEnd(columnWidths.loss), + (hop.sent_count || 0).toString().padEnd(columnWidths.sent), + formatRTT(hop.last_rtt).padEnd(columnWidths.last), + formatRTT(hop.avg_rtt).padEnd(columnWidths.avg), + formatRTT(hop.best_rtt).padEnd(columnWidths.best), + formatRTT(hop.worst_rtt) + ].join(' '); + + tableLines.push(row); + } + + result = tableLines.join('\n'); } - result = tableStringParts.join('\n'); } return result; } catch (err) { diff --git a/hyperglass/ui/types/globals.d.ts b/hyperglass/ui/types/globals.d.ts index f920e5998..b96f98c9f 100644 --- a/hyperglass/ui/types/globals.d.ts +++ b/hyperglass/ui/types/globals.d.ts @@ -30,17 +30,67 @@ export declare global { source_rid: string; peer_rid: string; rpki_state: RPKIState; + filtered: boolean; + }; + + type TracerouteHop = { + hop_number: number; + ip_address: string | null; + display_ip: string | null; + hostname: string | null; + rtt1: number | null; + rtt2: number | null; + rtt3: number | null; + loss_pct: number | null; + sent_count: number | null; + last_rtt: number | null; + avg_rtt: number | null; + best_rtt: number | null; + worst_rtt: number | null; + asn: string | null; + org: string | null; + prefix: string | null; + country: string | null; + rir: string | null; + allocated: string | null; + }; + + type TracerouteResult = { + target: string; + source: string; + hops: TracerouteHop[]; + max_hops: number; + packet_size: number; + raw_output: string | null; }; type RouteField = { [K in keyof Route]: Route[K] }; + + type TracerouteHopField = { [K in keyof TracerouteHop]: TracerouteHop[K] }; type StructuredResponse = { vrf: string; count: number; routes: Route[]; winning_weight: 'high' | 'low'; + asn_organizations?: Record; }; + type TracerouteStructuredOutput = { + vrf: string; + target: string; + source: string; + hops: TracerouteHop[]; + max_hops: number; + packet_size: number; + raw_output: string | null; + asn_organizations?: Record; + }; + + type BGPStructuredOutput = StructuredResponse; + + type AllStructuredResponses = BGPStructuredOutput | TracerouteStructuredOutput; + type QueryResponse = { random: string; cached: boolean; diff --git a/hyperglass/ui/types/guards.ts b/hyperglass/ui/types/guards.ts index 3e587f690..227b7a87f 100644 --- a/hyperglass/ui/types/guards.ts +++ b/hyperglass/ui/types/guards.ts @@ -19,6 +19,14 @@ export function isStructuredOutput(data: unknown): data is StringTableData { return isObject(data) && 'output' in data; } +export function isBGPStructuredOutput(data: unknown): data is StringTableData & { output: StructuredResponse } { + return isStructuredOutput(data) && isObject((data as StringTableData).output) && 'routes' in (data as StringTableData).output; +} + +export function isTracerouteStructuredOutput(data: unknown): data is StringTableData & { output: TracerouteResult } { + return isStructuredOutput(data) && isObject((data as StringTableData).output) && 'hops' in (data as StringTableData).output; +} + export function isStringOutput(data: unknown): data is StringQueryResponse { return ( isObject(data) && 'output' in data && typeof (data as { output: unknown }).output === 'string' diff --git a/hyperglass/ui/types/table.ts b/hyperglass/ui/types/table.ts index fe20e0b18..2687f7363 100644 --- a/hyperglass/ui/types/table.ts +++ b/hyperglass/ui/types/table.ts @@ -7,8 +7,21 @@ export interface TableColumn { hidden: boolean; } +export interface TracerouteTableColumn { + Header: string; + accessor: keyof TracerouteHop; + align: string; + hidden: boolean; +} + export type CellRenderProps = { column: CellProps['column']; row: CellProps['row']; value: CellProps['value']; }; + +export type TracerouteCellRenderProps = { + column: CellProps['column']; + row: CellProps['row']; + value: CellProps['value']; +}; From 5fe81608ce7133a06ec30f4bdeb3f61ab7e67670 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:45 +1000 Subject: [PATCH 10/39] fix(exceptions): redact device address from connection error messages --- hyperglass/exceptions/public.py | 76 +++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/hyperglass/exceptions/public.py b/hyperglass/exceptions/public.py index 912e63ba5..bd4864d7f 100644 --- a/hyperglass/exceptions/public.py +++ b/hyperglass/exceptions/public.py @@ -20,32 +20,92 @@ class ScrapeError( """Raised when an SSH driver error occurs.""" def __init__(self, *, error: BaseException, device: "Device"): - """Initialize parent error.""" - super().__init__(error=str(error), device=device.name, proxy=device.proxy) + """Initialize parent error with sanitized error message.""" + # Sanitize error message to remove sensitive information like IP/port + error_str = str(error) + + # Remove device settings line that contains IP:port + if "Device settings:" in error_str: + lines = error_str.split("\n") + sanitized_lines = [ + line for line in lines if not line.strip().startswith("Device settings:") + ] + error_str = "\n".join(sanitized_lines).strip() + + # If the error is empty after sanitization, provide a generic message + if not error_str or error_str.isspace(): + error_str = "Connection error occurred" + + super().__init__(error=error_str, device=device.name, proxy=device.proxy) class AuthError(PublicHyperglassError, template="authentication_error", level="danger"): """Raised when authentication to a device fails.""" def __init__(self, *, error: BaseException, device: "Device"): - """Initialize parent error.""" - super().__init__(error=str(error), device=device.name, proxy=device.proxy) + """Initialize parent error with sanitized error message.""" + # Sanitize error message to remove sensitive information like IP/port + error_str = str(error) + + # Remove device settings line that contains IP:port + if "Device settings:" in error_str: + lines = error_str.split("\n") + sanitized_lines = [ + line for line in lines if not line.strip().startswith("Device settings:") + ] + error_str = "\n".join(sanitized_lines).strip() + + # If the error is empty after sanitization, provide a generic message + if not error_str or error_str.isspace(): + error_str = "Authentication failed" + + super().__init__(error=error_str, device=device.name, proxy=device.proxy) class RestError(PublicHyperglassError, template="connection_error", level="danger"): """Raised upon a rest API client error.""" def __init__(self, *, error: BaseException, device: "Device"): - """Initialize parent error.""" - super().__init__(error=str(error), device=device.name) + """Initialize parent error with sanitized error message.""" + # Sanitize error message to remove sensitive information like IP/port + error_str = str(error) + + # Remove device settings line that contains IP:port + if "Device settings:" in error_str: + lines = error_str.split("\n") + sanitized_lines = [ + line for line in lines if not line.strip().startswith("Device settings:") + ] + error_str = "\n".join(sanitized_lines).strip() + + # If the error is empty after sanitization, provide a generic message + if not error_str or error_str.isspace(): + error_str = "REST API connection error" + + super().__init__(error=error_str, device=device.name) class DeviceTimeout(PublicHyperglassError, template="request_timeout", level="danger"): """Raised when the connection to a device times out.""" def __init__(self, *, error: BaseException, device: "Device"): - """Initialize parent error.""" - super().__init__(error=str(error), device=device.name, proxy=device.proxy) + """Initialize parent error with sanitized error message.""" + # Sanitize error message to remove sensitive information like IP/port + error_str = str(error) + + # Remove device settings line that contains IP:port + if "Device settings:" in error_str: + lines = error_str.split("\n") + sanitized_lines = [ + line for line in lines if not line.strip().startswith("Device settings:") + ] + error_str = "\n".join(sanitized_lines).strip() + + # If the error is empty after sanitization, provide a generic message + if not error_str or error_str.isspace(): + error_str = "Connection timed out" + + super().__init__(error=error_str, device=device.name, proxy=device.proxy) class InvalidQuery(PublicHyperglassError, template="request_timeout"): From 8e0eb695160b5bd3f7a699e7dc1aa2a21c72d6a0 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:45 +1000 Subject: [PATCH 11/39] fix(driver): run netmiko off the event loop and use send_command_timing --- hyperglass/execution/drivers/ssh_netmiko.py | 37 +++++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/hyperglass/execution/drivers/ssh_netmiko.py b/hyperglass/execution/drivers/ssh_netmiko.py index e96f68cb7..52fc588c1 100644 --- a/hyperglass/execution/drivers/ssh_netmiko.py +++ b/hyperglass/execution/drivers/ssh_netmiko.py @@ -83,22 +83,37 @@ async def collect(self, host: str = None, port: int = None) -> Iterable: # private key password. driver_kwargs["passphrase"] = self.device.credential.password.get_secret_value() - try: - nm_connect_direct = ConnectHandler(**driver_kwargs) + # Run blocking netmiko operations in thread executor to prevent blocking the event loop + import asyncio + import functools + + def _netmiko_connect(): + """Execute blocking netmiko operations in a separate thread.""" + try: + nm_connect_direct = ConnectHandler(**driver_kwargs) - responses = () + responses = () - for query in self.query: - raw = nm_connect_direct.send_command(query, **send_args) - responses += (raw,) + for query in self.query: + raw = nm_connect_direct.send_command_timing(query, **send_args) + responses += (raw,) - nm_connect_direct.disconnect() + nm_connect_direct.disconnect() + return responses - except NetMikoTimeoutException as scrape_error: - raise DeviceTimeout(error=scrape_error, device=self.device) from scrape_error + except NetMikoTimeoutException as scrape_error: + raise DeviceTimeout(error=scrape_error, device=self.device) from scrape_error - except NetMikoAuthenticationException as auth_error: - raise AuthError(error=auth_error, device=self.device) from auth_error + except NetMikoAuthenticationException as auth_error: + raise AuthError(error=auth_error, device=self.device) from auth_error + + try: + # Execute blocking netmiko operations in thread pool + loop = asyncio.get_event_loop() + responses = await loop.run_in_executor(None, _netmiko_connect) + except (DeviceTimeout, AuthError): + # Re-raise our custom exceptions as-is + raise if not responses: raise ResponseEmpty(query=self.query_data) From 054d3ff8379eacd806bf1a82ba631b032449bb30 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:08:46 +1000 Subject: [PATCH 12/39] fix(security): patch fixable HIGH CVEs flagged by the trivy gate --- Dockerfile | 17 +- hyperglass/frontend/__init__.py | 4 +- hyperglass/ui/next-env.d.ts | 3 +- hyperglass/ui/next.config.js | 1 - hyperglass/ui/package.json | 8 +- hyperglass/ui/pnpm-lock.yaml | 624 +++++++--- hyperglass/ui/pnpm-workspace.yaml | 20 +- poetry.lock | 1890 ----------------------------- pyproject.toml | 69 +- requirements-dev.lock | 240 ++-- requirements.lock | 173 +-- 11 files changed, 775 insertions(+), 2274 deletions(-) delete mode 100644 poetry.lock diff --git a/Dockerfile b/Dockerfile index 55442c43e..f952cc398 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12.3-alpine as base +FROM python:3.13-alpine AS base WORKDIR /opt/hyperglass ENV HYPERGLASS_APP_PATH=/etc/hyperglass ENV HYPERGLASS_HOST=0.0.0.0 @@ -12,13 +12,20 @@ COPY . . FROM base as ui WORKDIR /opt/hyperglass/hyperglass/ui -RUN apk add build-base pkgconfig cairo-dev nodejs npm -RUN npm install -g pnpm -RUN pnpm install -P +RUN apk add build-base pkgconfig cairo-dev nodejs npm \ + && npm install -g npm@10.9.3 pnpm@11.6.0 \ + && npm --prefix /usr/lib/node_modules/npm install glob@11.1.0 \ + && pnpm install -P FROM ui as hyperglass WORKDIR /opt/hyperglass -RUN pip3 install -e . +# Install the pinned dependency set from the lockfile (the single source of +# truth), then the package itself without re-resolving deps. --no-deps is +# required: the lock is a complete closure and pins pillow past favicons' +# declared cap (resolved via [tool.uv] override-dependencies), so letting pip +# re-resolve would fail on favicons' pillow<11 metadata. +RUN pip3 install --no-deps -r requirements.lock \ + && pip3 install -e . --no-deps EXPOSE ${HYPERGLASS_PORT} CMD ["python3", "-m", "hyperglass.console", "start"] diff --git a/hyperglass/frontend/__init__.py b/hyperglass/frontend/__init__.py index bbd7acead..0ff4fd537 100644 --- a/hyperglass/frontend/__init__.py +++ b/hyperglass/frontend/__init__.py @@ -59,14 +59,14 @@ async def read_package_json() -> t.Dict[str, t.Any]: return package_json -async def node_initial(timeout: int = 180, dev_mode: bool = False) -> str: +async def node_initial(timeout: int = 600, dev_mode: bool = False) -> str: """Initialize node_modules.""" ui_path = Path(__file__).parent.parent / "ui" env_timeout = get_ui_build_timeout() - if env_timeout is not None and env_timeout > timeout: + if env_timeout is not None: timeout = env_timeout proc = await asyncio.create_subprocess_shell( diff --git a/hyperglass/ui/next-env.d.ts b/hyperglass/ui/next-env.d.ts index 4f11a03dc..254b73c16 100644 --- a/hyperglass/ui/next-env.d.ts +++ b/hyperglass/ui/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/hyperglass/ui/next.config.js b/hyperglass/ui/next.config.js index 0c259b9a5..8baa6ecaa 100644 --- a/hyperglass/ui/next.config.js +++ b/hyperglass/ui/next.config.js @@ -7,7 +7,6 @@ const nextConfig = { typescript: { ignoreBuildErrors: true, }, - swcMinify: true, productionBrowserSourceMaps: true, }; diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index b50ba478f..89be03e6f 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -1,5 +1,5 @@ { - "version": "2.0.4", + "version": "2.1.2", "name": "ui", "description": "UI for hyperglass, the modern network looking glass", "author": "Matt Love", @@ -12,7 +12,7 @@ "typecheck": "tsc --noEmit", "format": "biome format --write .", "format:check": "biome format .", - "build": "export NODE_OPTIONS=--openssl-legacy-provider; next build && next export --no-lint -o ../hyperglass/static/ui", + "build": "next build && rm -rf ../hyperglass/static/ui && cp -r out ../hyperglass/static/ui", "test": "vitest --run" }, "browserslist": "> 0.25%, not dead", @@ -29,9 +29,9 @@ "dagre": "^0.8.5", "dayjs": "^1.10.4", "framer-motion": "^11.2.10", - "lodash": "^4.17.21", + "lodash": "^4.18.0", "merge-anything": "^4.0.1", - "next": "13.5.6", + "next": "15.5.19", "palette-by-numbers": "^0.1.6", "plur": "^4.0.0", "react": "^18.2.0", diff --git a/hyperglass/ui/pnpm-lock.yaml b/hyperglass/ui/pnpm-lock.yaml index f987d8d61..07cafb1f5 100644 --- a/hyperglass/ui/pnpm-lock.yaml +++ b/hyperglass/ui/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + ua-parser-js: ^0.7.36 + '@babel/runtime': 7.26.10 + '@babel/helpers': 7.26.10 + importers: .: @@ -45,14 +50,14 @@ importers: specifier: ^11.2.10 version: 11.2.10(@emotion/is-prop-valid@1.2.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) lodash: - specifier: ^4.17.21 - version: 4.17.21 + specifier: ^4.18.0 + version: 4.18.1 merge-anything: specifier: ^4.0.1 version: 4.0.1 next: - specifier: 13.5.6 - version: 13.5.6(@babel/core@7.23.9)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + specifier: 15.5.19 + version: 15.5.19(@babel/core@7.23.9)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) palette-by-numbers: specifier: ^0.1.6 version: 0.1.6 @@ -116,7 +121,7 @@ importers: version: 1.5.3 '@testing-library/jest-dom': specifier: ^6.4.2 - version: 6.4.2(vitest@1.3.1(@types/node@20.11.20)(@vitest/ui@1.3.1)(jsdom@24.0.0)) + version: 6.4.2(vitest@1.3.1) '@testing-library/react': specifier: ^14.2.1 version: 14.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -231,6 +236,10 @@ packages: resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.23.5': resolution: {integrity: sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==} engines: {node: '>=6.9.0'} @@ -285,16 +294,24 @@ packages: resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.22.20': resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.23.5': resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.23.9': - resolution: {integrity: sha512-87ICKgU5t5SzOT7sBMfCOZQ2rHjRU+Pcb9BoILMYz600W6DkVRLFBPwQ18gwUVvggqXivaUakpnxWQGbpywbBQ==} + '@babel/helpers@7.26.10': + resolution: {integrity: sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==} engines: {node: '>=6.9.0'} '@babel/highlight@7.23.4': @@ -306,6 +323,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.23.3': resolution: {integrity: sha512-qXRvbeKDSfwnlJnanVRp0SfuWE5DQhwQr5xtLBzp56Wabyo+4CMosF6Kfp+eOD/4FYpql64XVJ2W0pVLlJZxOQ==} engines: {node: '>=6.9.0'} @@ -318,18 +340,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.21.0': - resolution: {integrity: sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==} - engines: {node: '>=6.9.0'} - - '@babel/runtime@7.23.9': - resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==} + '@babel/runtime@7.26.10': + resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==} engines: {node: '>=6.9.0'} '@babel/template@7.23.9': resolution: {integrity: sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==} engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.23.9': resolution: {integrity: sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==} engines: {node: '>=6.9.0'} @@ -338,6 +360,10 @@ packages: resolution: {integrity: sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + '@biomejs/biome@1.5.3': resolution: {integrity: sha512-yvZCa/g3akwTaAQ7PCwPWDCkZs3Qa5ONg/fgOUT9e6wAWsPftCjLQFPXBeGxPK30yZSSpgEmRCfpGTmVbUjGgg==} engines: {node: '>=14.*'} @@ -867,6 +893,9 @@ packages: '@chakra-ui/system': '>=2.0.0' react: '>=18' + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emotion/babel-plugin@11.11.0': resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==} @@ -1120,6 +1149,143 @@ packages: '@humanwhocodes/object-schema@2.0.2': resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1142,59 +1308,53 @@ packages: '@jridgewell/trace-mapping@0.3.22': resolution: {integrity: sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==} - '@next/env@13.5.6': - resolution: {integrity: sha512-Yac/bV5sBGkkEXmAX5FWPS9Mmo2rthrOPRQQNfycJPkjUAUclomCPH7QFVCDQ4Mp2k2K1SSM6m0zrxYrOwtFQw==} + '@next/env@15.5.19': + resolution: {integrity: sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==} - '@next/swc-darwin-arm64@13.5.6': - resolution: {integrity: sha512-5nvXMzKtZfvcu4BhtV0KH1oGv4XEW+B+jOfmBdpFI3C7FrB/MfujRpWYSBBO64+qbW8pkZiSyQv9eiwnn5VIQA==} + '@next/swc-darwin-arm64@15.5.19': + resolution: {integrity: sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@13.5.6': - resolution: {integrity: sha512-6cgBfxg98oOCSr4BckWjLLgiVwlL3vlLj8hXg2b+nDgm4bC/qVXXLfpLB9FHdoDu4057hzywbxKvmYGmi7yUzA==} + '@next/swc-darwin-x64@15.5.19': + resolution: {integrity: sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@13.5.6': - resolution: {integrity: sha512-txagBbj1e1w47YQjcKgSU4rRVQ7uF29YpnlHV5xuVUsgCUf2FmyfJ3CPjZUvpIeXCJAoMCFAoGnbtX86BK7+sg==} + '@next/swc-linux-arm64-gnu@15.5.19': + resolution: {integrity: sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@13.5.6': - resolution: {integrity: sha512-cGd+H8amifT86ZldVJtAKDxUqeFyLWW+v2NlBULnLAdWsiuuN8TuhVBt8ZNpCqcAuoruoSWynvMWixTFcroq+Q==} + '@next/swc-linux-arm64-musl@15.5.19': + resolution: {integrity: sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@13.5.6': - resolution: {integrity: sha512-Mc2b4xiIWKXIhBy2NBTwOxGD3nHLmq4keFk+d4/WL5fMsB8XdJRdtUlL87SqVCTSaf1BRuQQf1HvXZcy+rq3Nw==} + '@next/swc-linux-x64-gnu@15.5.19': + resolution: {integrity: sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@13.5.6': - resolution: {integrity: sha512-CFHvP9Qz98NruJiUnCe61O6GveKKHpJLloXbDSWRhqhkJdZD2zU5hG+gtVJR//tyW897izuHpM6Gtf6+sNgJPQ==} + '@next/swc-linux-x64-musl@15.5.19': + resolution: {integrity: sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@13.5.6': - resolution: {integrity: sha512-aFv1ejfkbS7PUa1qVPwzDHjQWQtknzAZWGTKYIAaS4NMtBlk3VyA6AYn593pqNanlicewqyl2jUhQAaFV/qXsg==} + '@next/swc-win32-arm64-msvc@15.5.19': + resolution: {integrity: sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-ia32-msvc@13.5.6': - resolution: {integrity: sha512-XqqpHgEIlBHvzwG8sp/JXMFkLAfGLqkbVsyN+/Ih1mR8INb6YCc2x/Mbwi6hsAgUnqQztz8cvEbHJUbSl7RHDg==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@next/swc-win32-x64-msvc@13.5.6': - resolution: {integrity: sha512-Cqfe1YmOS7k+5mGu92nl5ULkzpKuxJrP3+4AEuPmrpFZ3BHxTY3TnHmU1On3bFmFFs6FbTcdF58CCUProGpIGQ==} + '@next/swc-win32-x64-msvc@15.5.19': + resolution: {integrity: sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1325,8 +1485,8 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@swc/helpers@0.5.2': - resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} '@tanstack/query-core@4.22.0': resolution: {integrity: sha512-OeLyBKBQoT265f5G9biReijeP8mBxNFwY7ZUu1dKL+YzqpG5q5z7J/N1eT8aWyKuhyDTiUHuKm5l+oIVzbtrjw==} @@ -1856,10 +2016,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -2089,6 +2245,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} @@ -2470,9 +2630,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} @@ -2869,8 +3026,8 @@ packages: lodash.mergewith@4.6.2: resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} loglevel-colored-level-prefix@1.0.0: resolution: {integrity: sha512-u45Wcxxc+SdAlh4yeF/uKlC1SPUPCy0gullSNKXod5I4bmifzk+Q4lSLExNEVn19tGaJipbZ4V4jbFn79/6mVA==} @@ -3025,18 +3182,24 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@13.5.6: - resolution: {integrity: sha512-Y2wTcTbO4WwEsVb4A8VSnOsG1I9ok+h74q0ZdxkwM3EODqrs4pasq7O0iUxbcS9VtWMicG7f3+HAj0r1+NtKSw==} - engines: {node: '>=16.14.0'} + next@15.5.19: + resolution: {integrity: sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 - react: ^18.2.0 - react-dom: ^18.2.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 sass: ^1.3.0 peerDependenciesMeta: '@opentelemetry/api': optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true sass: optional: true @@ -3170,6 +3333,9 @@ packages: picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.0: resolution: {integrity: sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==} engines: {node: '>=8.6'} @@ -3416,9 +3582,6 @@ packages: resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==} engines: {node: '>= 0.4'} - regenerator-runtime@0.13.11: - resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} - regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} @@ -3510,6 +3673,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + set-function-length@1.2.1: resolution: {integrity: sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==} engines: {node: '>= 0.4'} @@ -3518,6 +3686,10 @@ packages: resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} engines: {node: '>= 0.4'} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3562,10 +3734,6 @@ packages: resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==} engines: {node: '>= 0.4'} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - string-format@2.0.0: resolution: {integrity: sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==} @@ -3609,13 +3777,13 @@ packages: strip-literal@2.0.0: resolution: {integrity: sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==} - styled-jsx@5.1.1: - resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} peerDependencies: '@babel/core': '*' babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' peerDependenciesMeta: '@babel/core': optional: true @@ -3719,6 +3887,9 @@ packages: tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3756,8 +3927,9 @@ packages: engines: {node: '>=14.17'} hasBin: true - ua-parser-js@0.7.23: - resolution: {integrity: sha512-m4hvMLxgGHXG3O3fQVAyyAQpZzDOvwnhOTjYz5Xmr7r/+LpkNy3vJXdVRWgd1TkAb7NGROZuSy96CrlNVjA7KA==} + ua-parser-js@0.7.41: + resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} + hasBin: true ufo@1.4.0: resolution: {integrity: sha512-Hhy+BhRBleFjpJ2vchUNN40qgkh0366FWJGqVLYBHev0vpHTrXSA0ryT+74UiW6KWsldNurQMKGqCm1M2zBciQ==} @@ -3939,10 +4111,6 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - watchpack@2.4.0: - resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} - engines: {node: '>=10.13.0'} - webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4083,6 +4251,12 @@ snapshots: '@babel/highlight': 7.23.4 chalk: 2.4.2 + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.23.5': {} '@babel/core@7.23.9': @@ -4092,7 +4266,7 @@ snapshots: '@babel/generator': 7.23.6 '@babel/helper-compilation-targets': 7.23.6 '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.9) - '@babel/helpers': 7.23.9 + '@babel/helpers': 7.26.10 '@babel/parser': 7.23.9 '@babel/template': 7.23.9 '@babel/traverse': 7.23.9 @@ -4156,17 +4330,18 @@ snapshots: '@babel/helper-string-parser@7.23.4': {} + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-validator-identifier@7.22.20': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.23.5': {} - '@babel/helpers@7.23.9': + '@babel/helpers@7.26.10': dependencies: - '@babel/template': 7.23.9 - '@babel/traverse': 7.23.9 - '@babel/types': 7.23.9 - transitivePeerDependencies: - - supports-color + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 '@babel/highlight@7.23.4': dependencies: @@ -4178,6 +4353,10 @@ snapshots: dependencies: '@babel/types': 7.23.9 + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + '@babel/plugin-transform-react-jsx-self@7.23.3(@babel/core@7.23.9)': dependencies: '@babel/core': 7.23.9 @@ -4188,11 +4367,7 @@ snapshots: '@babel/core': 7.23.9 '@babel/helper-plugin-utils': 7.22.5 - '@babel/runtime@7.21.0': - dependencies: - regenerator-runtime: 0.13.11 - - '@babel/runtime@7.23.9': + '@babel/runtime@7.26.10': dependencies: regenerator-runtime: 0.14.1 @@ -4202,6 +4377,12 @@ snapshots: '@babel/parser': 7.23.9 '@babel/types': 7.23.9 + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@babel/traverse@7.23.9': dependencies: '@babel/code-frame': 7.23.5 @@ -4223,6 +4404,11 @@ snapshots: '@babel/helper-validator-identifier': 7.22.20 to-fast-properties: 2.0.0 + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@biomejs/biome@1.5.3': optionalDependencies: '@biomejs/cli-darwin-arm64': 1.5.3 @@ -4966,10 +5152,15 @@ snapshots: '@chakra-ui/system': 2.6.2(@emotion/react@11.11.4(@types/react@18.2.60)(react@18.2.0))(@emotion/styled@11.11.0(@emotion/react@11.11.4(@types/react@18.2.60)(react@18.2.0))(@types/react@18.2.60)(react@18.2.0))(react@18.2.0) react: 18.2.0 + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.6.2 + optional: true + '@emotion/babel-plugin@11.11.0': dependencies: '@babel/helper-module-imports': 7.22.15 - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 '@emotion/hash': 0.9.1 '@emotion/memoize': 0.8.1 '@emotion/serialize': 1.1.3 @@ -5008,7 +5199,7 @@ snapshots: '@emotion/react@11.11.4(@types/react@18.2.60)(react@18.2.0)': dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 '@emotion/babel-plugin': 11.11.0 '@emotion/cache': 11.11.0 '@emotion/serialize': 1.1.3 @@ -5034,7 +5225,7 @@ snapshots: '@emotion/styled@11.11.0(@emotion/react@11.11.4(@types/react@18.2.60)(react@18.2.0))(@types/react@18.2.60)(react@18.2.0)': dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 '@emotion/babel-plugin': 11.11.0 '@emotion/is-prop-valid': 1.2.2 '@emotion/react': 11.11.4(@types/react@18.2.60)(react@18.2.0) @@ -5163,7 +5354,7 @@ snapshots: '@emotion/styled': 11.11.0(@emotion/react@11.11.4(@types/react@18.2.60)(react@18.2.0))(@types/react@18.2.60)(react@18.2.0) '@types/lodash': 4.14.177 little-state-machine: 4.1.0(react@18.2.0) - lodash: 4.17.21 + lodash: 4.18.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) react-simple-animate: 3.3.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -5188,6 +5379,103 @@ snapshots: '@humanwhocodes/object-schema@2.0.2': {} + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.8 @@ -5209,33 +5497,30 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 - '@next/env@13.5.6': {} + '@next/env@15.5.19': {} - '@next/swc-darwin-arm64@13.5.6': + '@next/swc-darwin-arm64@15.5.19': optional: true - '@next/swc-darwin-x64@13.5.6': + '@next/swc-darwin-x64@15.5.19': optional: true - '@next/swc-linux-arm64-gnu@13.5.6': + '@next/swc-linux-arm64-gnu@15.5.19': optional: true - '@next/swc-linux-arm64-musl@13.5.6': + '@next/swc-linux-arm64-musl@15.5.19': optional: true - '@next/swc-linux-x64-gnu@13.5.6': + '@next/swc-linux-x64-gnu@15.5.19': optional: true - '@next/swc-linux-x64-musl@13.5.6': + '@next/swc-linux-x64-musl@15.5.19': optional: true - '@next/swc-win32-arm64-msvc@13.5.6': + '@next/swc-win32-arm64-msvc@15.5.19': optional: true - '@next/swc-win32-ia32-msvc@13.5.6': - optional: true - - '@next/swc-win32-x64-msvc@13.5.6': + '@next/swc-win32-x64-msvc@15.5.19': optional: true '@nodelib/fs.scandir@2.1.5': @@ -5375,9 +5660,9 @@ snapshots: '@sinclair/typebox@0.27.8': {} - '@swc/helpers@0.5.2': + '@swc/helpers@0.5.15': dependencies: - tslib: 2.6.2 + tslib: 2.8.1 '@tanstack/query-core@4.22.0': {} @@ -5392,7 +5677,7 @@ snapshots: '@testing-library/dom@9.3.4': dependencies: '@babel/code-frame': 7.23.5 - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 '@types/aria-query': 5.0.4 aria-query: 5.1.3 chalk: 4.1.2 @@ -5400,22 +5685,22 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.4.2(vitest@1.3.1(@types/node@20.11.20)(@vitest/ui@1.3.1)(jsdom@24.0.0))': + '@testing-library/jest-dom@6.4.2(vitest@1.3.1)': dependencies: '@adobe/css-tools': 4.3.3 - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 aria-query: 5.3.0 chalk: 3.0.0 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 - lodash: 4.17.21 + lodash: 4.18.1 redent: 3.0.0 optionalDependencies: vitest: 1.3.1(@types/node@20.11.20)(@vitest/ui@1.3.1)(jsdom@24.0.0) '@testing-library/react-hooks@8.0.1(@types/react@18.2.60)(react-dom@18.2.0(react@18.2.0))(react-test-renderer@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 react: 18.2.0 react-error-boundary: 3.1.4(react@18.2.0) optionalDependencies: @@ -5425,7 +5710,7 @@ snapshots: '@testing-library/react@14.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 '@testing-library/dom': 9.3.4 '@types/react-dom': 18.0.11 react: 18.2.0 @@ -5984,7 +6269,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 cosmiconfig: 7.1.0 resolve: 1.22.8 @@ -6012,10 +6297,6 @@ snapshots: node-releases: 2.0.14 update-browserslist-db: 1.0.13(browserslist@4.23.0) - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - cac@6.7.14: {} call-bind@1.0.2: @@ -6190,7 +6471,7 @@ snapshots: dagre@0.8.5: dependencies: graphlib: 2.1.8 - lodash: 4.17.21 + lodash: 4.18.1 damerau-levenshtein@1.0.8: {} @@ -6258,6 +6539,9 @@ snapshots: dequal@2.0.3: {} + detect-libc@2.1.2: + optional: true + detect-node-es@1.1.0: {} diff-sequences@29.6.3: {} @@ -6282,7 +6566,7 @@ snapshots: dom-helpers@5.2.0: dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.26.10 csstype: 3.1.1 dom-serializer@1.1.0: @@ -6467,7 +6751,7 @@ snapshots: debug: 4.3.4 enhanced-resolve: 5.15.1 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 @@ -6479,7 +6763,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -6500,7 +6784,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) hasown: 2.0.1 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -6519,12 +6803,12 @@ snapshots: eslint-plugin-json@3.1.0: dependencies: - lodash: 4.17.21 + lodash: 4.18.1 vscode-json-languageservice: 4.1.7 eslint-plugin-jsx-a11y@6.8.0(eslint@8.57.0): dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 aria-query: 5.3.0 array-includes: 3.1.7 array.prototype.flatmap: 1.3.2 @@ -6795,8 +7079,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regexp@0.4.1: {} - glob@7.1.7: dependencies: fs.realpath: 1.0.0 @@ -6835,7 +7117,7 @@ snapshots: graphlib@2.1.8: dependencies: - lodash: 4.17.21 + lodash: 4.18.1 harmony-reflect@1.6.2: {} @@ -7205,7 +7487,7 @@ snapshots: lodash.mergewith@4.6.2: {} - lodash@4.17.21: {} + lodash@4.18.1: {} loglevel-colored-level-prefix@1.0.0: dependencies: @@ -7405,27 +7687,25 @@ snapshots: natural-compare@1.4.0: {} - next@13.5.6(@babel/core@7.23.9)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + next@15.5.19(@babel/core@7.23.9)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - '@next/env': 13.5.6 - '@swc/helpers': 0.5.2 - busboy: 1.6.0 + '@next/env': 15.5.19 + '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001587 postcss: 8.4.31 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.1.1(@babel/core@7.23.9)(react@18.2.0) - watchpack: 2.4.0 + styled-jsx: 5.1.6(@babel/core@7.23.9)(react@18.2.0) optionalDependencies: - '@next/swc-darwin-arm64': 13.5.6 - '@next/swc-darwin-x64': 13.5.6 - '@next/swc-linux-arm64-gnu': 13.5.6 - '@next/swc-linux-arm64-musl': 13.5.6 - '@next/swc-linux-x64-gnu': 13.5.6 - '@next/swc-linux-x64-musl': 13.5.6 - '@next/swc-win32-arm64-msvc': 13.5.6 - '@next/swc-win32-ia32-msvc': 13.5.6 - '@next/swc-win32-x64-msvc': 13.5.6 + '@next/swc-darwin-arm64': 15.5.19 + '@next/swc-darwin-x64': 15.5.19 + '@next/swc-linux-arm64-gnu': 15.5.19 + '@next/swc-linux-arm64-musl': 15.5.19 + '@next/swc-linux-x64-gnu': 15.5.19 + '@next/swc-linux-x64-musl': 15.5.19 + '@next/swc-win32-arm64-msvc': 15.5.19 + '@next/swc-win32-x64-msvc': 15.5.19 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -7575,6 +7855,8 @@ snapshots: picocolors@1.0.0: {} + picocolors@1.1.1: {} + picomatch@2.3.0: {} pkg-types@1.0.3: @@ -7592,7 +7874,7 @@ snapshots: postcss@8.4.31: dependencies: nanoid: 3.3.7 - picocolors: 1.0.0 + picocolors: 1.1.1 source-map-js: 1.0.2 postcss@8.4.35: @@ -7664,7 +7946,7 @@ snapshots: react-clientside-effect@1.2.6(react@18.2.0): dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 react: 18.2.0 react-countdown@2.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): @@ -7677,7 +7959,7 @@ snapshots: dependencies: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - ua-parser-js: 0.7.23 + ua-parser-js: 0.7.41 react-dom@18.2.0(react@18.2.0): dependencies: @@ -7687,7 +7969,7 @@ snapshots: react-error-boundary@3.1.4(react@18.2.0): dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 react: 18.2.0 react-fast-compare@3.2.1: {} @@ -7696,7 +7978,7 @@ snapshots: react-focus-lock@2.11.2(@types/react@18.2.60)(react@18.2.0): dependencies: - '@babel/runtime': 7.23.9 + '@babel/runtime': 7.26.10 focus-lock: 1.3.3 prop-types: 15.8.1 react: 18.2.0 @@ -7765,7 +8047,7 @@ snapshots: react-select@5.7.0(@types/react@18.2.60)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.26.10 '@emotion/cache': 11.10.5 '@emotion/react': 11.11.4(@types/react@18.2.60)(react@18.2.0) '@floating-ui/dom': 1.1.0 @@ -7814,7 +8096,7 @@ snapshots: react-transition-group@4.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.26.10 dom-helpers: 5.2.0 loose-envify: 1.4.0 prop-types: 15.7.2 @@ -7854,8 +8136,6 @@ snapshots: globalthis: 1.0.3 which-builtin-type: 1.1.3 - regenerator-runtime@0.13.11: {} - regenerator-runtime@0.14.1: {} regexp.prototype.flags@1.5.2: @@ -7968,6 +8248,9 @@ snapshots: dependencies: lru-cache: 6.0.0 + semver@7.8.4: + optional: true + set-function-length@1.2.1: dependencies: define-data-property: 1.1.4 @@ -7984,6 +8267,38 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -8020,8 +8335,6 @@ snapshots: dependencies: internal-slot: 1.0.7 - streamsearch@1.1.0: {} - string-format@2.0.0: {} string.prototype.matchall@4.0.10: @@ -8076,7 +8389,7 @@ snapshots: dependencies: js-tokens: 8.0.3 - styled-jsx@5.1.1(@babel/core@7.23.9)(react@18.2.0): + styled-jsx@5.1.6(@babel/core@7.23.9)(react@18.2.0): dependencies: client-only: 0.0.1 react: 18.2.0 @@ -8160,6 +8473,8 @@ snapshots: tslib@2.6.2: {} + tslib@2.8.1: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -8204,7 +8519,7 @@ snapshots: typescript@5.3.3: {} - ua-parser-js@0.7.23: {} + ua-parser-js@0.7.41: {} ufo@1.4.0: {} @@ -8252,7 +8567,7 @@ snapshots: dependencies: browserslist: 4.23.0 escalade: 3.1.2 - picocolors: 1.0.0 + picocolors: 1.1.1 uri-js@4.4.0: dependencies: @@ -8272,7 +8587,7 @@ snapshots: use-deep-compare-effect@1.8.1(react@18.2.0): dependencies: - '@babel/runtime': 7.21.0 + '@babel/runtime': 7.26.10 dequal: 2.0.3 react: 18.2.0 @@ -8395,7 +8710,7 @@ snapshots: eslint-visitor-keys: 3.4.3 espree: 9.6.1 esquery: 1.5.0 - lodash: 4.17.21 + lodash: 4.18.1 semver: 7.6.0 transitivePeerDependencies: - supports-color @@ -8404,11 +8719,6 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - watchpack@2.4.0: - dependencies: - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.8 - webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: {} diff --git a/hyperglass/ui/pnpm-workspace.yaml b/hyperglass/ui/pnpm-workspace.yaml index c70f14379..1aa9d9d30 100644 --- a/hyperglass/ui/pnpm-workspace.yaml +++ b/hyperglass/ui/pnpm-workspace.yaml @@ -1,5 +1,17 @@ -onlyBuiltDependencies: - - '@biomejs/biome' - - esbuild +# Build-script approvals (pnpm 11 `allowBuilds`). sharp is an optional native +# dep pulled in by Next 15; the static export build does not use next/image, so +# its build script is intentionally skipped (avoids ERR_PNPM_IGNORED_BUILDS). +allowBuilds: + '@biomejs/biome': true + esbuild: true + sharp: false +# Migrated from package.json `pnpm.overrides` (pnpm 11 no longer reads that key). +# Security pins for transitive deps: +# ua-parser-js >=0.7.36 (ReDoS CVEs in older 0.7.x) +# @babel/runtime, @babel/helpers pinned to a patched release +overrides: + ua-parser-js: '^0.7.36' + '@babel/runtime': '7.26.10' + '@babel/helpers': '7.26.10' packages: - - . \ No newline at end of file + - . diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index e9f76878f..000000000 --- a/poetry.lock +++ /dev/null @@ -1,1890 +0,0 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. - -[[package]] -name = "aiofiles" -version = "23.1.0" -description = "File support for asyncio." -category = "main" -optional = false -python-versions = ">=3.7,<4.0" -files = [ - {file = "aiofiles-23.1.0-py3-none-any.whl", hash = "sha256:9312414ae06472eb6f1d163f555e466a23aed1c8f60c30cccf7121dba2e53eb2"}, - {file = "aiofiles-23.1.0.tar.gz", hash = "sha256:edd247df9a19e0db16534d4baaf536d6609a43e1de5401d7a4c1c148753a1635"}, -] - -[[package]] -name = "anyio" -version = "3.6.2" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "main" -optional = false -python-versions = ">=3.6.2" -files = [ - {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, - {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, -] - -[package.dependencies] -idna = ">=2.8" -sniffio = ">=1.1" - -[package.extras] -doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] -trio = ["trio (>=0.16,<0.22)"] - -[[package]] -name = "async-timeout" -version = "4.0.2" -description = "Timeout context manager for asyncio programs" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, - {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, -] - -[[package]] -name = "attrs" -version = "22.1.0" -description = "Classes Without Boilerplate" -category = "dev" -optional = false -python-versions = ">=3.5" -files = [ - {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, - {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, -] - -[package.extras] -dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] -docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] -tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] -tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] - -[[package]] -name = "bandit" -version = "1.7.4" -description = "Security oriented static analyser for python code." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "bandit-1.7.4-py3-none-any.whl", hash = "sha256:412d3f259dab4077d0e7f0c11f50f650cc7d10db905d98f6520a95a18049658a"}, - {file = "bandit-1.7.4.tar.gz", hash = "sha256:2d63a8c573417bae338962d4b9b06fbc6080f74ecd955a092849e1e65c717bd2"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -GitPython = ">=1.0.1" -PyYAML = ">=5.3.1" -stevedore = ">=1.20.0" - -[package.extras] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml"] -toml = ["toml"] -yaml = ["PyYAML"] - -[[package]] -name = "bcrypt" -version = "4.0.1" -description = "Modern password hashing for your software and your servers" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3"}, - {file = "bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2"}, - {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535"}, - {file = "bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e"}, - {file = "bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab"}, - {file = "bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9"}, - {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4fa8b2ca74381bb5442c089350f09a3f17797829d958fad058d6e44d9eb83c"}, - {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:67a97e1c405b24f19d08890e7ae0c4f7ce1e56a712a016746c8b2d7732d65d4b"}, - {file = "bcrypt-4.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b3b85202d95dd568efcb35b53936c5e3b3600c7cdcc6115ba461df3a8e89f38d"}, - {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbb03eec97496166b704ed663a53680ab57c5084b2fc98ef23291987b525cb7d"}, - {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:5ad4d32a28b80c5fa6671ccfb43676e8c1cc232887759d1cd7b6f56ea4355215"}, - {file = "bcrypt-4.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b57adba8a1444faf784394de3436233728a1ecaeb6e07e8c22c8848f179b893c"}, - {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b2cea8a9ed3d55b4491887ceadb0106acf7c6387699fca771af56b1cdeeda"}, - {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:2b3ac11cf45161628f1f3733263e63194f22664bf4d0c0f3ab34099c02134665"}, - {file = "bcrypt-4.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3100851841186c25f127731b9fa11909ab7b1df6fc4b9f8353f4f1fd952fbf71"}, - {file = "bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd"}, -] - -[package.extras] -tests = ["pytest (>=3.2.1,!=3.3.0)"] -typecheck = ["mypy"] - -[[package]] -name = "black" -version = "22.12.0" -description = "The uncompromising code formatter." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, - {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, - {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, - {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, - {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, - {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, - {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, - {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, - {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, - {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, - {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, - {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -pathspec = ">=0.9.0" -platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} -typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - -[[package]] -name = "certifi" -version = "2022.12.7" -description = "Python package for providing Mozilla's CA Bundle." -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, -] - -[[package]] -name = "cffi" -version = "1.15.1" -description = "Foreign Function Interface for Python calling C code." -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, - {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, - {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, - {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, - {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, - {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, - {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, - {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, - {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, - {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, - {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, - {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, - {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, - {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, - {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, - {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, - {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, - {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, - {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, -] - -[package.dependencies] -pycparser = "*" - -[[package]] -name = "cfgv" -version = "3.3.1" -description = "Validate configuration and produce human readable error messages." -category = "dev" -optional = false -python-versions = ">=3.6.1" -files = [ - {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, - {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, -] - -[[package]] -name = "click" -version = "8.1.3" -description = "Composable command line interface toolkit" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -category = "main" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "cryptography" -version = "38.0.4" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "cryptography-38.0.4-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:2fa36a7b2cc0998a3a4d5af26ccb6273f3df133d61da2ba13b3286261e7efb70"}, - {file = "cryptography-38.0.4-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:1f13ddda26a04c06eb57119caf27a524ccae20533729f4b1e4a69b54e07035eb"}, - {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:2ec2a8714dd005949d4019195d72abed84198d877112abb5a27740e217e0ea8d"}, - {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50a1494ed0c3f5b4d07650a68cd6ca62efe8b596ce743a5c94403e6f11bf06c1"}, - {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a10498349d4c8eab7357a8f9aa3463791292845b79597ad1b98a543686fb1ec8"}, - {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:10652dd7282de17990b88679cb82f832752c4e8237f0c714be518044269415db"}, - {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bfe6472507986613dc6cc00b3d492b2f7564b02b3b3682d25ca7f40fa3fd321b"}, - {file = "cryptography-38.0.4-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce127dd0a6a0811c251a6cddd014d292728484e530d80e872ad9806cfb1c5b3c"}, - {file = "cryptography-38.0.4-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:53049f3379ef05182864d13bb9686657659407148f901f3f1eee57a733fb4b00"}, - {file = "cryptography-38.0.4-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:8a4b2bdb68a447fadebfd7d24855758fe2d6fecc7fed0b78d190b1af39a8e3b0"}, - {file = "cryptography-38.0.4-cp36-abi3-win32.whl", hash = "sha256:1d7e632804a248103b60b16fb145e8df0bc60eed790ece0d12efe8cd3f3e7744"}, - {file = "cryptography-38.0.4-cp36-abi3-win_amd64.whl", hash = "sha256:8e45653fb97eb2f20b8c96f9cd2b3a0654d742b47d638cf2897afbd97f80fa6d"}, - {file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca57eb3ddaccd1112c18fc80abe41db443cc2e9dcb1917078e02dfa010a4f353"}, - {file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:c9e0d79ee4c56d841bd4ac6e7697c8ff3c8d6da67379057f29e66acffcd1e9a7"}, - {file = "cryptography-38.0.4-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0e70da4bdff7601b0ef48e6348339e490ebfb0cbe638e083c9c41fb49f00c8bd"}, - {file = "cryptography-38.0.4-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:998cd19189d8a747b226d24c0207fdaa1e6658a1d3f2494541cb9dfbf7dcb6d2"}, - {file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67461b5ebca2e4c2ab991733f8ab637a7265bb582f07c7c88914b5afb88cb95b"}, - {file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4eb85075437f0b1fd8cd66c688469a0c4119e0ba855e3fef86691971b887caf6"}, - {file = "cryptography-38.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3178d46f363d4549b9a76264f41c6948752183b3f587666aff0555ac50fd7876"}, - {file = "cryptography-38.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:6391e59ebe7c62d9902c24a4d8bcbc79a68e7c4ab65863536127c8a9cd94043b"}, - {file = "cryptography-38.0.4-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:78e47e28ddc4ace41dd38c42e6feecfdadf9c3be2af389abbfeef1ff06822285"}, - {file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fb481682873035600b5502f0015b664abc26466153fab5c6bc92c1ea69d478b"}, - {file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:4367da5705922cf7070462e964f66e4ac24162e22ab0a2e9d31f1b270dd78083"}, - {file = "cryptography-38.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b4cad0cea995af760f82820ab4ca54e5471fc782f70a007f31531957f43e9dee"}, - {file = "cryptography-38.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:80ca53981ceeb3241998443c4964a387771588c4e4a5d92735a493af868294f9"}, - {file = "cryptography-38.0.4.tar.gz", hash = "sha256:175c1a818b87c9ac80bb7377f5520b7f31b3ef2a0004e2420319beadedb67290"}, -] - -[package.dependencies] -cffi = ">=1.12" - -[package.extras] -docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] -docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] -pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] -sdist = ["setuptools-rust (>=0.11.4)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"] - -[[package]] -name = "cssselect2" -version = "0.7.0" -description = "CSS selectors for Python ElementTree" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "cssselect2-0.7.0-py3-none-any.whl", hash = "sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969"}, - {file = "cssselect2-0.7.0.tar.gz", hash = "sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a"}, -] - -[package.dependencies] -tinycss2 = "*" -webencodings = "*" - -[package.extras] -doc = ["sphinx", "sphinx_rtd_theme"] -test = ["flake8", "isort", "pytest"] - -[[package]] -name = "distlib" -version = "0.3.6" -description = "Distribution utilities" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, - {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, -] - -[[package]] -name = "distro" -version = "1.8.0" -description = "Distro - an OS platform information API" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "distro-1.8.0-py3-none-any.whl", hash = "sha256:99522ca3e365cac527b44bde033f64c6945d90eb9f769703caaec52b09bbd3ff"}, - {file = "distro-1.8.0.tar.gz", hash = "sha256:02e111d1dc6a50abb8eed6bf31c3e48ed8b0830d1ea2a1b78c61765c2513fdd8"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.0.4" -description = "Backport of PEP 654 (exception groups)" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"}, - {file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "fastapi" -version = "0.95.1" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "fastapi-0.95.1-py3-none-any.whl", hash = "sha256:a870d443e5405982e1667dfe372663abf10754f246866056336d7f01c21dab07"}, - {file = "fastapi-0.95.1.tar.gz", hash = "sha256:9569f0a381f8a457ec479d90fa01005cfddaae07546eb1f3fa035bc4797ae7d5"}, -] - -[package.dependencies] -pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" -starlette = ">=0.26.1,<0.27.0" - -[package.extras] -all = ["email-validator (>=1.1.1)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -dev = ["pre-commit (>=2.17.0,<3.0.0)", "ruff (==0.0.138)", "uvicorn[standard] (>=0.12.0,<0.21.0)"] -doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer-cli (>=0.0.13,<0.0.14)", "typer[all] (>=0.6.1,<0.8.0)"] -test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==23.1.0)", "coverage[toml] (>=6.5.0,<8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.7)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.138)", "sqlalchemy (>=1.3.18,<1.4.43)", "types-orjson (==3.6.2)", "types-ujson (==5.7.0.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] - -[[package]] -name = "favicons" -version = "0.2.0" -description = "Favicon generator for Python 3 with strongly typed sync & async APIs, CLI, & HTML generation." -category = "main" -optional = false -python-versions = ">=3.8.1,<4.0" -files = [ - {file = "favicons-0.2.0-py3-none-any.whl", hash = "sha256:f60b3184028db9580a2411c98eafafcc915e9fcec871617de8892f6faeb805ec"}, - {file = "favicons-0.2.0.tar.gz", hash = "sha256:84039ed1638cd07527c1673780afeebd6fe7673e38d8b56c53ad4506a469e9b7"}, -] - -[package.dependencies] -pillow = ">=9.5.0,<10.0.0" -rich = ">=13.3.4,<14.0.0" -svglib = ">=1.5.1,<2.0.0" -typer = ">=0.7.0,<0.8.0" - -[[package]] -name = "filelock" -version = "3.8.2" -description = "A platform independent file lock." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "filelock-3.8.2-py3-none-any.whl", hash = "sha256:8df285554452285f79c035efb0c861eb33a4bcfa5b7a137016e32e6a90f9792c"}, - {file = "filelock-3.8.2.tar.gz", hash = "sha256:7565f628ea56bfcd8e54e42bdc55da899c85c1abfe1b5bcfd147e9188cebb3b2"}, -] - -[package.extras] -docs = ["furo (>=2022.9.29)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] -testing = ["covdefaults (>=2.2.2)", "coverage (>=6.5)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-timeout (>=2.1)"] - -[[package]] -name = "flake8" -version = "6.0.0" -description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, - {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.10.0,<2.11.0" -pyflakes = ">=3.0.0,<3.1.0" - -[[package]] -name = "future" -version = "0.18.2" -description = "Clean single-source support for Python 3 and 2" -category = "main" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, -] - -[[package]] -name = "gitdb" -version = "4.0.10" -description = "Git Object Database" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "gitdb-4.0.10-py3-none-any.whl", hash = "sha256:c286cf298426064079ed96a9e4a9d39e7f3e9bf15ba60701e95f5492f28415c7"}, - {file = "gitdb-4.0.10.tar.gz", hash = "sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a"}, -] - -[package.dependencies] -smmap = ">=3.0.1,<6" - -[[package]] -name = "gitpython" -version = "3.1.29" -description = "GitPython is a python library used to interact with Git repositories" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "GitPython-3.1.29-py3-none-any.whl", hash = "sha256:41eea0deec2deea139b459ac03656f0dd28fc4a3387240ec1d3c259a2c47850f"}, - {file = "GitPython-3.1.29.tar.gz", hash = "sha256:cc36bfc4a3f913e66805a28e84703e419d9c264c1077e537b54f0e1af85dbefd"}, -] - -[package.dependencies] -gitdb = ">=4.0.1,<5" - -[[package]] -name = "gunicorn" -version = "20.1.0" -description = "WSGI HTTP Server for UNIX" -category = "main" -optional = false -python-versions = ">=3.5" -files = [ - {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, - {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, -] - -[package.dependencies] -setuptools = ">=3.0" - -[package.extras] -eventlet = ["eventlet (>=0.24.1)"] -gevent = ["gevent (>=1.4.0)"] -setproctitle = ["setproctitle"] -tornado = ["tornado (>=0.2)"] - -[[package]] -name = "h11" -version = "0.12.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, - {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, -] - -[[package]] -name = "httpcore" -version = "0.15.0" -description = "A minimal low-level HTTP client." -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "httpcore-0.15.0-py3-none-any.whl", hash = "sha256:1105b8b73c025f23ff7c36468e4432226cbb959176eab66864b8e31c4ee27fa6"}, - {file = "httpcore-0.15.0.tar.gz", hash = "sha256:18b68ab86a3ccf3e7dc0f43598eaddcf472b602aba29f9aa6ab85fe2ada3980b"}, -] - -[package.dependencies] -anyio = ">=3.0.0,<4.0.0" -certifi = "*" -h11 = ">=0.11,<0.13" -sniffio = ">=1.0.0,<2.0.0" - -[package.extras] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] - -[[package]] -name = "httpx" -version = "0.24.0" -description = "The next generation HTTP client." -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "httpx-0.24.0-py3-none-any.whl", hash = "sha256:447556b50c1921c351ea54b4fe79d91b724ed2b027462ab9a329465d147d5a4e"}, - {file = "httpx-0.24.0.tar.gz", hash = "sha256:507d676fc3e26110d41df7d35ebd8b3b8585052450f4097401c9be59d928c63e"}, -] - -[package.dependencies] -certifi = "*" -httpcore = ">=0.15.0,<0.18.0" -idna = "*" -sniffio = "*" - -[package.extras] -brotli = ["brotli", "brotlicffi"] -cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] - -[[package]] -name = "identify" -version = "2.5.9" -description = "File identification library for Python" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "identify-2.5.9-py2.py3-none-any.whl", hash = "sha256:a390fb696e164dbddb047a0db26e57972ae52fbd037ae68797e5ae2f4492485d"}, - {file = "identify-2.5.9.tar.gz", hash = "sha256:906036344ca769539610436e40a684e170c3648b552194980bb7b617a8daeb9f"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "idna" -version = "3.4" -description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" -optional = false -python-versions = ">=3.5" -files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, -] - -[[package]] -name = "iniconfig" -version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, -] - -[[package]] -name = "isort" -version = "5.10.1" -description = "A Python utility / library to sort Python imports." -category = "dev" -optional = false -python-versions = ">=3.6.1,<4.0" -files = [ - {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, - {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, -] - -[package.extras] -colors = ["colorama (>=0.4.3,<0.5.0)"] -pipfile-deprecated-finder = ["pipreqs", "requirementslib"] -plugins = ["setuptools"] -requirements-deprecated-finder = ["pip-api", "pipreqs"] - -[[package]] -name = "loguru" -version = "0.7.0" -description = "Python logging made (stupidly) simple" -category = "main" -optional = false -python-versions = ">=3.5" -files = [ - {file = "loguru-0.7.0-py3-none-any.whl", hash = "sha256:b93aa30099fa6860d4727f1b81f8718e965bb96253fa190fab2077aaad6d15d3"}, - {file = "loguru-0.7.0.tar.gz", hash = "sha256:1612053ced6ae84d7959dd7d5e431a0532642237ec21f7fd83ac73fe539e03e1"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} -win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} - -[package.extras] -dev = ["Sphinx (==5.3.0)", "colorama (==0.4.5)", "colorama (==0.4.6)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v0.990)", "pre-commit (==3.2.1)", "pytest (==6.1.2)", "pytest (==7.2.1)", "pytest-cov (==2.12.1)", "pytest-cov (==4.0.0)", "pytest-mypy-plugins (==1.10.1)", "pytest-mypy-plugins (==1.9.3)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.2.0)", "tox (==3.27.1)", "tox (==4.4.6)"] - -[[package]] -name = "lxml" -version = "4.9.1" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" -files = [ - {file = "lxml-4.9.1-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:98cafc618614d72b02185ac583c6f7796202062c41d2eeecdf07820bad3295ed"}, - {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c62e8dd9754b7debda0c5ba59d34509c4688f853588d75b53c3791983faa96fc"}, - {file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:21fb3d24ab430fc538a96e9fbb9b150029914805d551deeac7d7822f64631dfc"}, - {file = "lxml-4.9.1-cp27-cp27m-win32.whl", hash = "sha256:86e92728ef3fc842c50a5cb1d5ba2bc66db7da08a7af53fb3da79e202d1b2cd3"}, - {file = "lxml-4.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4cfbe42c686f33944e12f45a27d25a492cc0e43e1dc1da5d6a87cbcaf2e95627"}, - {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dad7b164905d3e534883281c050180afcf1e230c3d4a54e8038aa5cfcf312b84"}, - {file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a614e4afed58c14254e67862456d212c4dcceebab2eaa44d627c2ca04bf86837"}, - {file = "lxml-4.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f9ced82717c7ec65a67667bb05865ffe38af0e835cdd78728f1209c8fffe0cad"}, - {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:d9fc0bf3ff86c17348dfc5d322f627d78273eba545db865c3cd14b3f19e57fa5"}, - {file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e5f66bdf0976ec667fc4594d2812a00b07ed14d1b44259d19a41ae3fff99f2b8"}, - {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fe17d10b97fdf58155f858606bddb4e037b805a60ae023c009f760d8361a4eb8"}, - {file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8caf4d16b31961e964c62194ea3e26a0e9561cdf72eecb1781458b67ec83423d"}, - {file = "lxml-4.9.1-cp310-cp310-win32.whl", hash = "sha256:4780677767dd52b99f0af1f123bc2c22873d30b474aa0e2fc3fe5e02217687c7"}, - {file = "lxml-4.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:b122a188cd292c4d2fcd78d04f863b789ef43aa129b233d7c9004de08693728b"}, - {file = "lxml-4.9.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:be9eb06489bc975c38706902cbc6888f39e946b81383abc2838d186f0e8b6a9d"}, - {file = "lxml-4.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f1be258c4d3dc609e654a1dc59d37b17d7fef05df912c01fc2e15eb43a9735f3"}, - {file = "lxml-4.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:927a9dd016d6033bc12e0bf5dee1dde140235fc8d0d51099353c76081c03dc29"}, - {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9232b09f5efee6a495a99ae6824881940d6447debe272ea400c02e3b68aad85d"}, - {file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:04da965dfebb5dac2619cb90fcf93efdb35b3c6994fea58a157a834f2f94b318"}, - {file = "lxml-4.9.1-cp35-cp35m-win32.whl", hash = "sha256:4d5bae0a37af799207140652a700f21a85946f107a199bcb06720b13a4f1f0b7"}, - {file = "lxml-4.9.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4878e667ebabe9b65e785ac8da4d48886fe81193a84bbe49f12acff8f7a383a4"}, - {file = "lxml-4.9.1-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:1355755b62c28950f9ce123c7a41460ed9743c699905cbe664a5bcc5c9c7c7fb"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:bcaa1c495ce623966d9fc8a187da80082334236a2a1c7e141763ffaf7a405067"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6eafc048ea3f1b3c136c71a86db393be36b5b3d9c87b1c25204e7d397cee9536"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:13c90064b224e10c14dcdf8086688d3f0e612db53766e7478d7754703295c7c8"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206a51077773c6c5d2ce1991327cda719063a47adc02bd703c56a662cdb6c58b"}, - {file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e8f0c9d65da595cfe91713bc1222af9ecabd37971762cb830dea2fc3b3bb2acf"}, - {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8f0a4d179c9a941eb80c3a63cdb495e539e064f8054230844dcf2fcb812b71d3"}, - {file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:830c88747dce8a3e7525defa68afd742b4580df6aa2fdd6f0855481e3994d391"}, - {file = "lxml-4.9.1-cp36-cp36m-win32.whl", hash = "sha256:1e1cf47774373777936c5aabad489fef7b1c087dcd1f426b621fda9dcc12994e"}, - {file = "lxml-4.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:5974895115737a74a00b321e339b9c3f45c20275d226398ae79ac008d908bff7"}, - {file = "lxml-4.9.1-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:1423631e3d51008871299525b541413c9b6c6423593e89f9c4cfbe8460afc0a2"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:2aaf6a0a6465d39b5ca69688fce82d20088c1838534982996ec46633dc7ad6cc"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:9f36de4cd0c262dd9927886cc2305aa3f2210db437aa4fed3fb4940b8bf4592c"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ae06c1e4bc60ee076292e582a7512f304abdf6c70db59b56745cca1684f875a4"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:57e4d637258703d14171b54203fd6822fda218c6c2658a7d30816b10995f29f3"}, - {file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6d279033bf614953c3fc4a0aa9ac33a21e8044ca72d4fa8b9273fe75359d5cca"}, - {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a60f90bba4c37962cbf210f0188ecca87daafdf60271f4c6948606e4dabf8785"}, - {file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6ca2264f341dd81e41f3fffecec6e446aa2121e0b8d026fb5130e02de1402785"}, - {file = "lxml-4.9.1-cp37-cp37m-win32.whl", hash = "sha256:27e590352c76156f50f538dbcebd1925317a0f70540f7dc8c97d2931c595783a"}, - {file = "lxml-4.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:eea5d6443b093e1545ad0210e6cf27f920482bfcf5c77cdc8596aec73523bb7e"}, - {file = "lxml-4.9.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f05251bbc2145349b8d0b77c0d4e5f3b228418807b1ee27cefb11f69ed3d233b"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:487c8e61d7acc50b8be82bda8c8d21d20e133c3cbf41bd8ad7eb1aaeb3f07c97"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d1a92d8e90b286d491e5626af53afef2ba04da33e82e30744795c71880eaa21"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:b570da8cd0012f4af9fa76a5635cd31f707473e65a5a335b186069d5c7121ff2"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ef87fca280fb15342726bd5f980f6faf8b84a5287fcc2d4962ea8af88b35130"}, - {file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:93e414e3206779ef41e5ff2448067213febf260ba747fc65389a3ddaa3fb8715"}, - {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6653071f4f9bac46fbc30f3c7838b0e9063ee335908c5d61fb7a4a86c8fd2036"}, - {file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:32a73c53783becdb7eaf75a2a1525ea8e49379fb7248c3eeefb9412123536387"}, - {file = "lxml-4.9.1-cp38-cp38-win32.whl", hash = "sha256:1a7c59c6ffd6ef5db362b798f350e24ab2cfa5700d53ac6681918f314a4d3b94"}, - {file = "lxml-4.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:1436cf0063bba7888e43f1ba8d58824f085410ea2025befe81150aceb123e345"}, - {file = "lxml-4.9.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:4beea0f31491bc086991b97517b9683e5cfb369205dac0148ef685ac12a20a67"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:41fb58868b816c202e8881fd0f179a4644ce6e7cbbb248ef0283a34b73ec73bb"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd34f6d1810d9354dc7e35158aa6cc33456be7706df4420819af6ed966e85448"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:edffbe3c510d8f4bf8640e02ca019e48a9b72357318383ca60e3330c23aaffc7"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d949f53ad4fc7cf02c44d6678e7ff05ec5f5552b235b9e136bd52e9bf730b91"}, - {file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:079b68f197c796e42aa80b1f739f058dcee796dc725cc9a1be0cdb08fc45b000"}, - {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9c3a88d20e4fe4a2a4a84bf439a5ac9c9aba400b85244c63a1ab7088f85d9d25"}, - {file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4e285b5f2bf321fc0857b491b5028c5f276ec0c873b985d58d7748ece1d770dd"}, - {file = "lxml-4.9.1-cp39-cp39-win32.whl", hash = "sha256:ef72013e20dd5ba86a8ae1aed7f56f31d3374189aa8b433e7b12ad182c0d2dfb"}, - {file = "lxml-4.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:10d2017f9150248563bb579cd0d07c61c58da85c922b780060dcc9a3aa9f432d"}, - {file = "lxml-4.9.1-pp37-pypy37_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538747a9d7827ce3e16a8fdd201a99e661c7dee3c96c885d8ecba3c35d1032c"}, - {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0645e934e940107e2fdbe7c5b6fb8ec6232444260752598bc4d09511bd056c0b"}, - {file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6daa662aba22ef3258934105be2dd9afa5bb45748f4f702a3b39a5bf53a1f4dc"}, - {file = "lxml-4.9.1-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:603a464c2e67d8a546ddaa206d98e3246e5db05594b97db844c2f0a1af37cf5b"}, - {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c4b2e0559b68455c085fb0f6178e9752c4be3bba104d6e881eb5573b399d1eb2"}, - {file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0f3f0059891d3254c7b5fb935330d6db38d6519ecd238ca4fce93c234b4a0f73"}, - {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c852b1530083a620cb0de5f3cd6826f19862bafeaf77586f1aef326e49d95f0c"}, - {file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:287605bede6bd36e930577c5925fcea17cb30453d96a7b4c63c14a257118dbb9"}, - {file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"}, -] - -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html5 = ["html5lib"] -htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=0.29.7)"] - -[[package]] -name = "markdown-it-py" -version = "2.2.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, - {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mslex" -version = "0.3.0" -description = "shlex for windows" -category = "dev" -optional = false -python-versions = ">=3.5" -files = [ - {file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"}, - {file = "mslex-0.3.0.tar.gz", hash = "sha256:4a1ac3f25025cad78ad2fe499dd16d42759f7a3801645399cce5c404415daa97"}, -] - -[[package]] -name = "mypy-extensions" -version = "0.4.3" -description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, - {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, -] - -[[package]] -name = "netmiko" -version = "4.1.2" -description = "Multi-vendor library to simplify legacy CLI connections to network devices" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "netmiko-4.1.2-py3-none-any.whl", hash = "sha256:ee1e88ecbd07f619b0bc1e90648f82a64a0adee5968c3068621bbdadbfec5c03"}, - {file = "netmiko-4.1.2.tar.gz", hash = "sha256:f5ede2a28670d3dfd3470061468665f80f9b4906ed20e6b0fb4d9e1c9be67afc"}, -] - -[package.dependencies] -ntc-templates = ">=2.0.0" -paramiko = ">=2.7.2" -pyserial = "*" -pyyaml = ">=5.3" -scp = ">=0.13.3" -setuptools = ">=38.4.0" -tenacity = "*" -textfsm = "1.1.2" - -[[package]] -name = "nodeenv" -version = "1.7.0" -description = "Node.js virtual environment builder" -category = "dev" -optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" -files = [ - {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, - {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, -] - -[package.dependencies] -setuptools = "*" - -[[package]] -name = "ntc-templates" -version = "3.1.0" -description = "TextFSM Templates for Network Devices, and Python wrapper for TextFSM's CliTable." -category = "main" -optional = false -python-versions = ">=3.6,<4.0" -files = [ - {file = "ntc_templates-3.1.0-py3-none-any.whl", hash = "sha256:e59fbc485a132e0d3eecb6f53fc8e85426cec099ef39678cdc20eec8dccaced6"}, - {file = "ntc_templates-3.1.0.tar.gz", hash = "sha256:7231e4227d46d1a04a03a34e3478b23acee7869942cce62b6722b2c925c2f809"}, -] - -[package.dependencies] -textfsm = ">=1.1.0,<2.0.0" - -[[package]] -name = "packaging" -version = "22.0" -description = "Core utilities for Python packages" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "packaging-22.0-py3-none-any.whl", hash = "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"}, - {file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"}, -] - -[[package]] -name = "paramiko" -version = "3.1.0" -description = "SSH2 protocol library" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "paramiko-3.1.0-py3-none-any.whl", hash = "sha256:f0caa660e797d9cd10db6fc6ae81e2c9b2767af75c3180fcd0e46158cd368d7f"}, - {file = "paramiko-3.1.0.tar.gz", hash = "sha256:6950faca6819acd3219d4ae694a23c7a87ee38d084f70c1724b0c0dbb8b75769"}, -] - -[package.dependencies] -bcrypt = ">=3.2" -cryptography = ">=3.3" -pynacl = ">=1.5" - -[package.extras] -all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] -gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] -invoke = ["invoke (>=2.0)"] - -[[package]] -name = "pathspec" -version = "0.10.3" -description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pathspec-0.10.3-py3-none-any.whl", hash = "sha256:3c95343af8b756205e2aba76e843ba9520a24dd84f68c22b9f93251507509dd6"}, - {file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"}, -] - -[[package]] -name = "pbr" -version = "5.11.0" -description = "Python Build Reasonableness" -category = "dev" -optional = false -python-versions = ">=2.6" -files = [ - {file = "pbr-5.11.0-py2.py3-none-any.whl", hash = "sha256:db2317ff07c84c4c63648c9064a79fe9d9f5c7ce85a9099d4b6258b3db83225a"}, - {file = "pbr-5.11.0.tar.gz", hash = "sha256:b97bc6695b2aff02144133c2e7399d5885223d42b7912ffaec2ca3898e673bfe"}, -] - -[[package]] -name = "pep8-naming" -version = "0.13.2" -description = "Check PEP-8 naming conventions, plugin for flake8" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pep8-naming-0.13.2.tar.gz", hash = "sha256:93eef62f525fd12a6f8c98f4dcc17fa70baae2f37fa1f73bec00e3e44392fa48"}, - {file = "pep8_naming-0.13.2-py3-none-any.whl", hash = "sha256:59e29e55c478db69cffbe14ab24b5bd2cd615c0413edf790d47d3fb7ba9a4e23"}, -] - -[package.dependencies] -flake8 = ">=3.9.1" - -[[package]] -name = "pillow" -version = "9.5.0" -description = "Python Imaging Library (Fork)" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "Pillow-9.5.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:ace6ca218308447b9077c14ea4ef381ba0b67ee78d64046b3f19cf4e1139ad16"}, - {file = "Pillow-9.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3d403753c9d5adc04d4694d35cf0391f0f3d57c8e0030aac09d7678fa8030aa"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ba1b81ee69573fe7124881762bb4cd2e4b6ed9dd28c9c60a632902fe8db8b38"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe7e1c262d3392afcf5071df9afa574544f28eac825284596ac6db56e6d11062"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f36397bf3f7d7c6a3abdea815ecf6fd14e7fcd4418ab24bae01008d8d8ca15e"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:252a03f1bdddce077eff2354c3861bf437c892fb1832f75ce813ee94347aa9b5"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:85ec677246533e27770b0de5cf0f9d6e4ec0c212a1f89dfc941b64b21226009d"}, - {file = "Pillow-9.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b416f03d37d27290cb93597335a2f85ed446731200705b22bb927405320de903"}, - {file = "Pillow-9.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1781a624c229cb35a2ac31cc4a77e28cafc8900733a864870c49bfeedacd106a"}, - {file = "Pillow-9.5.0-cp310-cp310-win32.whl", hash = "sha256:8507eda3cd0608a1f94f58c64817e83ec12fa93a9436938b191b80d9e4c0fc44"}, - {file = "Pillow-9.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:d3c6b54e304c60c4181da1c9dadf83e4a54fd266a99c70ba646a9baa626819eb"}, - {file = "Pillow-9.5.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:7ec6f6ce99dab90b52da21cf0dc519e21095e332ff3b399a357c187b1a5eee32"}, - {file = "Pillow-9.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:560737e70cb9c6255d6dcba3de6578a9e2ec4b573659943a5e7e4af13f298f5c"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96e88745a55b88a7c64fa49bceff363a1a27d9a64e04019c2281049444a571e3"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9c206c29b46cfd343ea7cdfe1232443072bbb270d6a46f59c259460db76779a"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfcc2c53c06f2ccb8976fb5c71d448bdd0a07d26d8e07e321c103416444c7ad1"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a0f9bb6c80e6efcde93ffc51256d5cfb2155ff8f78292f074f60f9e70b942d99"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8d935f924bbab8f0a9a28404422da8af4904e36d5c33fc6f677e4c4485515625"}, - {file = "Pillow-9.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fed1e1cf6a42577953abbe8e6cf2fe2f566daebde7c34724ec8803c4c0cda579"}, - {file = "Pillow-9.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c1170d6b195555644f0616fd6ed929dfcf6333b8675fcca044ae5ab110ded296"}, - {file = "Pillow-9.5.0-cp311-cp311-win32.whl", hash = "sha256:54f7102ad31a3de5666827526e248c3530b3a33539dbda27c6843d19d72644ec"}, - {file = "Pillow-9.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfa4561277f677ecf651e2b22dc43e8f5368b74a25a8f7d1d4a3a243e573f2d4"}, - {file = "Pillow-9.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:965e4a05ef364e7b973dd17fc765f42233415974d773e82144c9bbaaaea5d089"}, - {file = "Pillow-9.5.0-cp312-cp312-win32.whl", hash = "sha256:22baf0c3cf0c7f26e82d6e1adf118027afb325e703922c8dfc1d5d0156bb2eeb"}, - {file = "Pillow-9.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:432b975c009cf649420615388561c0ce7cc31ce9b2e374db659ee4f7d57a1f8b"}, - {file = "Pillow-9.5.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:5d4ebf8e1db4441a55c509c4baa7a0587a0210f7cd25fcfe74dbbce7a4bd1906"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:375f6e5ee9620a271acb6820b3d1e94ffa8e741c0601db4c0c4d3cb0a9c224bf"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99eb6cafb6ba90e436684e08dad8be1637efb71c4f2180ee6b8f940739406e78"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfaaf10b6172697b9bceb9a3bd7b951819d1ca339a5ef294d1f1ac6d7f63270"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:763782b2e03e45e2c77d7779875f4432e25121ef002a41829d8868700d119392"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:35f6e77122a0c0762268216315bf239cf52b88865bba522999dc38f1c52b9b47"}, - {file = "Pillow-9.5.0-cp37-cp37m-win32.whl", hash = "sha256:aca1c196f407ec7cf04dcbb15d19a43c507a81f7ffc45b690899d6a76ac9fda7"}, - {file = "Pillow-9.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322724c0032af6692456cd6ed554bb85f8149214d97398bb80613b04e33769f6"}, - {file = "Pillow-9.5.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:a0aa9417994d91301056f3d0038af1199eb7adc86e646a36b9e050b06f526597"}, - {file = "Pillow-9.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f8286396b351785801a976b1e85ea88e937712ee2c3ac653710a4a57a8da5d9c"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c830a02caeb789633863b466b9de10c015bded434deb3ec87c768e53752ad22a"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbd359831c1657d69bb81f0db962905ee05e5e9451913b18b831febfe0519082"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8fc330c3370a81bbf3f88557097d1ea26cd8b019d6433aa59f71195f5ddebbf"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:7002d0797a3e4193c7cdee3198d7c14f92c0836d6b4a3f3046a64bd1ce8df2bf"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:229e2c79c00e85989a34b5981a2b67aa079fd08c903f0aaead522a1d68d79e51"}, - {file = "Pillow-9.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9adf58f5d64e474bed00d69bcd86ec4bcaa4123bfa70a65ce72e424bfb88ed96"}, - {file = "Pillow-9.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:662da1f3f89a302cc22faa9f14a262c2e3951f9dbc9617609a47521c69dd9f8f"}, - {file = "Pillow-9.5.0-cp38-cp38-win32.whl", hash = "sha256:6608ff3bf781eee0cd14d0901a2b9cc3d3834516532e3bd673a0a204dc8615fc"}, - {file = "Pillow-9.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:e49eb4e95ff6fd7c0c402508894b1ef0e01b99a44320ba7d8ecbabefddcc5569"}, - {file = "Pillow-9.5.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:482877592e927fd263028c105b36272398e3e1be3269efda09f6ba21fd83ec66"}, - {file = "Pillow-9.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3ded42b9ad70e5f1754fb7c2e2d6465a9c842e41d178f262e08b8c85ed8a1d8e"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c446d2245ba29820d405315083d55299a796695d747efceb5717a8b450324115"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aca1152d93dcc27dc55395604dcfc55bed5f25ef4c98716a928bacba90d33a3"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:608488bdcbdb4ba7837461442b90ea6f3079397ddc968c31265c1e056964f1ef"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:60037a8db8750e474af7ffc9faa9b5859e6c6d0a50e55c45576bf28be7419705"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:07999f5834bdc404c442146942a2ecadd1cb6292f5229f4ed3b31e0a108746b1"}, - {file = "Pillow-9.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a127ae76092974abfbfa38ca2d12cbeddcdeac0fb71f9627cc1135bedaf9d51a"}, - {file = "Pillow-9.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:489f8389261e5ed43ac8ff7b453162af39c3e8abd730af8363587ba64bb2e865"}, - {file = "Pillow-9.5.0-cp39-cp39-win32.whl", hash = "sha256:9b1af95c3a967bf1da94f253e56b6286b50af23392a886720f563c547e48e964"}, - {file = "Pillow-9.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:77165c4a5e7d5a284f10a6efaa39a0ae8ba839da344f20b111d62cc932fa4e5d"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:833b86a98e0ede388fa29363159c9b1a294b0905b5128baf01db683672f230f5"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaf305d6d40bd9632198c766fb64f0c1a83ca5b667f16c1e79e1661ab5060140"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0852ddb76d85f127c135b6dd1f0bb88dbb9ee990d2cd9aa9e28526c93e794fba"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:91ec6fe47b5eb5a9968c79ad9ed78c342b1f97a091677ba0e012701add857829"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cb841572862f629b99725ebaec3287fc6d275be9b14443ea746c1dd325053cbd"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:c380b27d041209b849ed246b111b7c166ba36d7933ec6e41175fd15ab9eb1572"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c9af5a3b406a50e313467e3565fc99929717f780164fe6fbb7704edba0cebbe"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5671583eab84af046a397d6d0ba25343c00cd50bce03787948e0fff01d4fd9b1"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:84a6f19ce086c1bf894644b43cd129702f781ba5751ca8572f08aa40ef0ab7b7"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1e7723bd90ef94eda669a3c2c19d549874dd5badaeefabefd26053304abe5799"}, - {file = "Pillow-9.5.0.tar.gz", hash = "sha256:bf548479d336726d7a0eceb6e767e179fbde37833ae42794602631a070d630f1"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] - -[[package]] -name = "platformdirs" -version = "2.6.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "platformdirs-2.6.0-py3-none-any.whl", hash = "sha256:1a89a12377800c81983db6be069ec068eee989748799b946cce2a6e80dcc54ca"}, - {file = "platformdirs-2.6.0.tar.gz", hash = "sha256:b46ffafa316e6b83b47489d240ce17173f123a9b9c83282141c3daf26ad9ac2e"}, -] - -[package.extras] -docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.4)"] -test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] - -[[package]] -name = "pluggy" -version = "1.0.0" -description = "plugin and hook calling mechanisms for python" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pre-commit" -version = "2.20.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pre_commit-2.20.0-py2.py3-none-any.whl", hash = "sha256:51a5ba7c480ae8072ecdb6933df22d2f812dc897d5fe848778116129a681aac7"}, - {file = "pre_commit-2.20.0.tar.gz", hash = "sha256:a978dac7bc9ec0bcee55c18a277d553b0f419d259dadb4b9418ff2d00eb43959"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -toml = "*" -virtualenv = ">=20.0.8" - -[[package]] -name = "psutil" -version = "5.9.4" -description = "Cross-platform lib for process and system monitoring in Python." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "psutil-5.9.4-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c1ca331af862803a42677c120aff8a814a804e09832f166f226bfd22b56feee8"}, - {file = "psutil-5.9.4-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:68908971daf802203f3d37e78d3f8831b6d1014864d7a85937941bb35f09aefe"}, - {file = "psutil-5.9.4-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:3ff89f9b835100a825b14c2808a106b6fdcc4b15483141482a12c725e7f78549"}, - {file = "psutil-5.9.4-cp27-cp27m-win32.whl", hash = "sha256:852dd5d9f8a47169fe62fd4a971aa07859476c2ba22c2254d4a1baa4e10b95ad"}, - {file = "psutil-5.9.4-cp27-cp27m-win_amd64.whl", hash = "sha256:9120cd39dca5c5e1c54b59a41d205023d436799b1c8c4d3ff71af18535728e94"}, - {file = "psutil-5.9.4-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6b92c532979bafc2df23ddc785ed116fced1f492ad90a6830cf24f4d1ea27d24"}, - {file = "psutil-5.9.4-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:efeae04f9516907be44904cc7ce08defb6b665128992a56957abc9b61dca94b7"}, - {file = "psutil-5.9.4-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:54d5b184728298f2ca8567bf83c422b706200bcbbfafdc06718264f9393cfeb7"}, - {file = "psutil-5.9.4-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16653106f3b59386ffe10e0bad3bb6299e169d5327d3f187614b1cb8f24cf2e1"}, - {file = "psutil-5.9.4-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54c0d3d8e0078b7666984e11b12b88af2db11d11249a8ac8920dd5ef68a66e08"}, - {file = "psutil-5.9.4-cp36-abi3-win32.whl", hash = "sha256:149555f59a69b33f056ba1c4eb22bb7bf24332ce631c44a319cec09f876aaeff"}, - {file = "psutil-5.9.4-cp36-abi3-win_amd64.whl", hash = "sha256:fd8522436a6ada7b4aad6638662966de0d61d241cb821239b2ae7013d41a43d4"}, - {file = "psutil-5.9.4-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:6001c809253a29599bc0dfd5179d9f8a5779f9dffea1da0f13c53ee568115e1e"}, - {file = "psutil-5.9.4.tar.gz", hash = "sha256:3d7f9739eb435d4b1338944abe23f49584bde5395f27487d2ee25ad9a8774a62"}, -] - -[package.extras] -test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] - -[[package]] -name = "py-cpuinfo" -version = "9.0.0" -description = "Get CPU info with pure Python" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690"}, - {file = "py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5"}, -] - -[[package]] -name = "pycodestyle" -version = "2.10.0" -description = "Python style guide checker" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, - {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, -] - -[[package]] -name = "pycparser" -version = "2.21" -description = "C parser in Python" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, - {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, -] - -[[package]] -name = "pydantic" -version = "1.10.7" -description = "Data validation and settings management using python type hints" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pydantic-1.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e79e999e539872e903767c417c897e729e015872040e56b96e67968c3b918b2d"}, - {file = "pydantic-1.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:01aea3a42c13f2602b7ecbbea484a98169fb568ebd9e247593ea05f01b884b2e"}, - {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:516f1ed9bc2406a0467dd777afc636c7091d71f214d5e413d64fef45174cfc7a"}, - {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae150a63564929c675d7f2303008d88426a0add46efd76c3fc797cd71cb1b46f"}, - {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ecbbc51391248116c0a055899e6c3e7ffbb11fb5e2a4cd6f2d0b93272118a209"}, - {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4a2b50e2b03d5776e7f21af73e2070e1b5c0d0df255a827e7c632962f8315af"}, - {file = "pydantic-1.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:a7cd2251439988b413cb0a985c4ed82b6c6aac382dbaff53ae03c4b23a70e80a"}, - {file = "pydantic-1.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:68792151e174a4aa9e9fc1b4e653e65a354a2fa0fed169f7b3d09902ad2cb6f1"}, - {file = "pydantic-1.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe2507b8ef209da71b6fb5f4e597b50c5a34b78d7e857c4f8f3115effaef5fe"}, - {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a86d8c8db68086f1e30a530f7d5f83eb0685e632e411dbbcf2d5c0150e8dcd"}, - {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75ae19d2a3dbb146b6f324031c24f8a3f52ff5d6a9f22f0683694b3afcb16fb"}, - {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:464855a7ff7f2cc2cf537ecc421291b9132aa9c79aef44e917ad711b4a93163b"}, - {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:193924c563fae6ddcb71d3f06fa153866423ac1b793a47936656e806b64e24ca"}, - {file = "pydantic-1.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:b4a849d10f211389502059c33332e91327bc154acc1845f375a99eca3afa802d"}, - {file = "pydantic-1.10.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cc1dde4e50a5fc1336ee0581c1612215bc64ed6d28d2c7c6f25d2fe3e7c3e918"}, - {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0cfe895a504c060e5d36b287ee696e2fdad02d89e0d895f83037245218a87fe"}, - {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:670bb4683ad1e48b0ecb06f0cfe2178dcf74ff27921cdf1606e527d2617a81ee"}, - {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:950ce33857841f9a337ce07ddf46bc84e1c4946d2a3bba18f8280297157a3fd1"}, - {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c15582f9055fbc1bfe50266a19771bbbef33dd28c45e78afbe1996fd70966c2a"}, - {file = "pydantic-1.10.7-cp37-cp37m-win_amd64.whl", hash = "sha256:82dffb306dd20bd5268fd6379bc4bfe75242a9c2b79fec58e1041fbbdb1f7914"}, - {file = "pydantic-1.10.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c7f51861d73e8b9ddcb9916ae7ac39fb52761d9ea0df41128e81e2ba42886cd"}, - {file = "pydantic-1.10.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6434b49c0b03a51021ade5c4daa7d70c98f7a79e95b551201fff682fc1661245"}, - {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d34ab766fa056df49013bb6e79921a0265204c071984e75a09cbceacbbdd5d"}, - {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:701daea9ffe9d26f97b52f1d157e0d4121644f0fcf80b443248434958fd03dc3"}, - {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf135c46099ff3f919d2150a948ce94b9ce545598ef2c6c7bf55dca98a304b52"}, - {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0f85904f73161817b80781cc150f8b906d521fa11e3cdabae19a581c3606209"}, - {file = "pydantic-1.10.7-cp38-cp38-win_amd64.whl", hash = "sha256:9f6f0fd68d73257ad6685419478c5aece46432f4bdd8d32c7345f1986496171e"}, - {file = "pydantic-1.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c230c0d8a322276d6e7b88c3f7ce885f9ed16e0910354510e0bae84d54991143"}, - {file = "pydantic-1.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:976cae77ba6a49d80f461fd8bba183ff7ba79f44aa5cfa82f1346b5626542f8e"}, - {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d45fc99d64af9aaf7e308054a0067fdcd87ffe974f2442312372dfa66e1001d"}, - {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2a5ebb48958754d386195fe9e9c5106f11275867051bf017a8059410e9abf1f"}, - {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:abfb7d4a7cd5cc4e1d1887c43503a7c5dd608eadf8bc615413fc498d3e4645cd"}, - {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:80b1fab4deb08a8292d15e43a6edccdffa5377a36a4597bb545b93e79c5ff0a5"}, - {file = "pydantic-1.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:d71e69699498b020ea198468e2480a2f1e7433e32a3a99760058c6520e2bea7e"}, - {file = "pydantic-1.10.7-py3-none-any.whl", hash = "sha256:0cd181f1d0b1d00e2b705f1bf1ac7799a2d938cce3376b8007df62b29be3c2c6"}, - {file = "pydantic-1.10.7.tar.gz", hash = "sha256:cfc83c0678b6ba51b0532bea66860617c4cd4251ecf76e9846fa5a9f3454e97e"}, -] - -[package.dependencies] -typing-extensions = ">=4.2.0" - -[package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] - -[[package]] -name = "pyflakes" -version = "3.0.1" -description = "passive checker of Python programs" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, - {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, -] - -[[package]] -name = "pygments" -version = "2.13.0" -description = "Pygments is a syntax highlighting package written in Python." -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, - {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, -] - -[package.extras] -plugins = ["importlib-metadata"] - -[[package]] -name = "pyjwt" -version = "2.6.0" -description = "JSON Web Token implementation in Python" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "PyJWT-2.6.0-py3-none-any.whl", hash = "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14"}, - {file = "PyJWT-2.6.0.tar.gz", hash = "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd"}, -] - -[package.extras] -crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] - -[[package]] -name = "pynacl" -version = "1.5.0" -description = "Python binding to the Networking and Cryptography (NaCl) library" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, - {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, -] - -[package.dependencies] -cffi = ">=1.4.1" - -[package.extras] -docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] -tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] - -[[package]] -name = "pyserial" -version = "3.5" -description = "Python Serial Port Extension" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"}, - {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"}, -] - -[package.extras] -cp2110 = ["hidapi"] - -[[package]] -name = "pytest" -version = "7.2.0" -description = "pytest: simple powerful testing with Python" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, - {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, -] - -[package.dependencies] -attrs = ">=19.2.0" -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "0.20.3" -description = "Pytest support for asyncio" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"}, - {file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"}, -] - -[package.dependencies] -pytest = ">=6.1.0" - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] -testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] - -[[package]] -name = "pytest-dependency" -version = "0.5.1" -description = "Manage dependencies of tests" -category = "dev" -optional = false -python-versions = "*" -files = [ - {file = "pytest-dependency-0.5.1.tar.gz", hash = "sha256:c2a892906192663f85030a6ab91304e508e546cddfe557d692d61ec57a1d946b"}, -] - -[package.dependencies] -pytest = ">=3.6.0" - -[[package]] -name = "pyyaml" -version = "6.0" -description = "YAML parser and emitter for Python" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, - {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, - {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, - {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, - {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, - {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, - {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, - {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, - {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, - {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, - {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, - {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, - {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, - {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, - {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, - {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, - {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, - {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, -] - -[[package]] -name = "redis" -version = "4.5.4" -description = "Python client for Redis database and key-value store" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "redis-4.5.4-py3-none-any.whl", hash = "sha256:2c19e6767c474f2e85167909061d525ed65bea9301c0770bb151e041b7ac89a2"}, - {file = "redis-4.5.4.tar.gz", hash = "sha256:73ec35da4da267d6847e47f68730fdd5f62e2ca69e3ef5885c6a78a9374c3893"}, -] - -[package.dependencies] -async-timeout = {version = ">=4.0.2", markers = "python_version <= \"3.11.2\""} - -[package.extras] -hiredis = ["hiredis (>=1.0.0)"] -ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] - -[[package]] -name = "reportlab" -version = "3.6.9" -description = "The Reportlab Toolkit" -category = "main" -optional = false -python-versions = ">=3.7, <4" -files = [ - {file = "reportlab-3.6.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4ba8eebfa4383e4680d6e7e6dba9c45c1fe19bbc0a754db4d84823f1a9511e56"}, - {file = "reportlab-3.6.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37dda88dbe16dd3f4f9039464637cce66e462c0b95e5763dbd45ac5799136d3a"}, - {file = "reportlab-3.6.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10681d89a0ca37bb4036283fb8c0efac9ac1b22265dbdf350bda0448be33e00c"}, - {file = "reportlab-3.6.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cebd0b28a0e875a9ce789514700f80659269ecf2a8fcef0aa10b8ae52b40474a"}, - {file = "reportlab-3.6.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ec84055cf2c83783958b74eadf0e577eb0cd9088c8b5d536e9ddc0f4a9f8c70"}, - {file = "reportlab-3.6.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f74627cafecf3924741ab8b0690a19df4214eb56b1cfce2dc74a15c9744034"}, - {file = "reportlab-3.6.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2c2fd861f10b2cd49ccf29a31da9ad5c3b95aa437804e4fd0351ed4eb695f74"}, - {file = "reportlab-3.6.9-cp310-cp310-win32.whl", hash = "sha256:e492e87886423192af1fafde23907bcd9d2fdccfc22f67e18aa5c73db3a380a3"}, - {file = "reportlab-3.6.9-cp310-cp310-win_amd64.whl", hash = "sha256:d1bf9455aff37beb421a4447d89d6dd77bb46f677c0bab4eb0272cdb79faad2f"}, - {file = "reportlab-3.6.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0a7f2b7232c3ffb451b649d55c51a6dd0c8104ad7bbcfe355addf7619705e7fa"}, - {file = "reportlab-3.6.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1967dbc9930917d75c39784712a137d432dbc2e5ca9e132a2453319c2619ccff"}, - {file = "reportlab-3.6.9-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32a5c5cd9625a40feec956f460355b4813bc3187c4f8dc9efd9f1a7f8f854e34"}, - {file = "reportlab-3.6.9-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cb82b6d14ad4bd915acacc8f114c6a7bab8b9b1503cabb930e433ebd320f90c"}, - {file = "reportlab-3.6.9-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e767cf4507ca8eed7dde8511f0889b0f19f160a2bdf9ef07742b2aaeceed9f2"}, - {file = "reportlab-3.6.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a114761ad3ba6e0cdfacf14a8fb2cb8f5713b115ca1f0c17f3cd638d0a5b4bd"}, - {file = "reportlab-3.6.9-cp37-cp37m-win32.whl", hash = "sha256:bbaab798991863952c593c0459dcb82e0aade837675593310e13cba2ce7fb45a"}, - {file = "reportlab-3.6.9-cp37-cp37m-win_amd64.whl", hash = "sha256:ab1ffe4ec7be99ad348791116d436610afdc7a9a02a968997f31eaa62eaadad8"}, - {file = "reportlab-3.6.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:496f42840604255ce06777bc129048b3bab966213bbac4f07fbe4ceb6a2e0482"}, - {file = "reportlab-3.6.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a441afdfe31870b964bccde042d7172ed3c0077f519bbf3ed7d9d34c406b6b91"}, - {file = "reportlab-3.6.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fbe23ac870adf90544d2014c572dba6ec4d772afad6505bb91f171ddad12839"}, - {file = "reportlab-3.6.9-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de724c78f4eb1363b1195dce85a2a8806e7509b69ac5c842a714d942ea534d63"}, - {file = "reportlab-3.6.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:713574da534b6ce73d884f1574c35a565e438af4888fcc75e752f1de02e356a7"}, - {file = "reportlab-3.6.9-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:193671445b4885128d8800d3e416eb2fa4fd89bafae08cc9889c0752fe5ad8c2"}, - {file = "reportlab-3.6.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff0e014a3a3fe286c642ef51213c41684a156b9ed293ef205e8890bc1dbbfdc7"}, - {file = "reportlab-3.6.9-cp38-cp38-win32.whl", hash = "sha256:23f5aed2d212096f2fe95d56f868d63f839a08bf7e389237e644d93981274222"}, - {file = "reportlab-3.6.9-cp38-cp38-win_amd64.whl", hash = "sha256:09b2ca175129a34292399fc4c6a8b1739f6c5946368fcaa6f931d69385b2f720"}, - {file = "reportlab-3.6.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cb21666fc9edec9716553bfcfe0c30d1bbbe2731910a96f07ec65652974e5f83"}, - {file = "reportlab-3.6.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d927bf802bf53c1b5a3878a22e9be310900877984e7c436a3a99bdd19cfec4c3"}, - {file = "reportlab-3.6.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce3a3aad287c8532f62223f5720b5504e31abe3dce52a27bd2a25f508c0d846e"}, - {file = "reportlab-3.6.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9a5f63bc381c0f945402ef4c1bccc74a8eed28f6be6596704b1db7d82ec89fe"}, - {file = "reportlab-3.6.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50f8e30f5410efc69b0217261b1f21912888da392a4549e79c7aaaac85f01bfa"}, - {file = "reportlab-3.6.9-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15294435f786968bcdf1a7a67bcc23a136470b6ea26919497f5c76ff0f653041"}, - {file = "reportlab-3.6.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9b5e9115363545a727d8ebe7e4b94f7cf6f26113261a269d50d88b8db4eb726"}, - {file = "reportlab-3.6.9-cp39-cp39-win32.whl", hash = "sha256:e1fc1b1f5d9d1c2e18b5e60602dfa7854b2330ba0efc312ef605abf588abea9c"}, - {file = "reportlab-3.6.9-cp39-cp39-win_amd64.whl", hash = "sha256:92a6613af9877e3ad2a1c5a16a122514a4f9f8d9b91b1f22e7fa0fa796617b36"}, - {file = "reportlab-3.6.9.tar.gz", hash = "sha256:5d0cc3682456ad213150f6dbffe7d47eab737d809e517c316103376be548fb84"}, -] - -[package.dependencies] -pillow = ">=4.0.0" - -[package.extras] -rlpycairo = ["rlPyCairo (>=0.0.5)"] - -[[package]] -name = "rich" -version = "13.3.4" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -category = "main" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "rich-13.3.4-py3-none-any.whl", hash = "sha256:22b74cae0278fd5086ff44144d3813be1cedc9115bdfabbfefd86400cb88b20a"}, - {file = "rich-13.3.4.tar.gz", hash = "sha256:b5d573e13605423ec80bdd0cd5f8541f7844a0e71a13f74cf454ccb2f490708b"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0,<3.0.0" -pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "ruff" -version = "0.0.261" -description = "An extremely fast Python linter, written in Rust." -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "ruff-0.0.261-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:6624a966c4a21110cee6780333e2216522a831364896f3d98f13120936eff40a"}, - {file = "ruff-0.0.261-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:2dba68a9e558ab33e6dd5d280af798a2d9d3c80c913ad9c8b8e97d7b287f1cc9"}, - {file = "ruff-0.0.261-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dbd0cee5a81b0785dc0feeb2640c1e31abe93f0d77c5233507ac59731a626f1"}, - {file = "ruff-0.0.261-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:581e64fa1518df495ca890a605ee65065101a86db56b6858f848bade69fc6489"}, - {file = "ruff-0.0.261-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc970f6ece0b4950e419f0252895ee42e9e8e5689c6494d18f5dc2c6ebb7f798"}, - {file = "ruff-0.0.261-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8fa98e747e0fe185d65a40b0ea13f55c492f3b5f9a032a1097e82edaddb9e52e"}, - {file = "ruff-0.0.261-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f268d52a71bf410aa45c232870c17049df322a7d20e871cfe622c9fc784aab7b"}, - {file = "ruff-0.0.261-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1293acc64eba16a11109678dc4743df08c207ed2edbeaf38b3e10eb2597321b"}, - {file = "ruff-0.0.261-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d95596e2f4cafead19a6d1ec0b86f8fda45ba66fe934de3956d71146a87959b3"}, - {file = "ruff-0.0.261-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4bcec45abdf65c1328a269cf6cc193f7ff85b777fa2865c64cf2c96b80148a2c"}, - {file = "ruff-0.0.261-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6c5f397ec0af42a434ad4b6f86565027406c5d0d0ebeea0d5b3f90c4bf55bc82"}, - {file = "ruff-0.0.261-py3-none-musllinux_1_2_i686.whl", hash = "sha256:39abd02342cec0c131b2ddcaace08b2eae9700cab3ca7dba64ae5fd4f4881bd0"}, - {file = "ruff-0.0.261-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:aaa4f52a6e513f8daa450dac4859e80390d947052f592f0d8e796baab24df2fc"}, - {file = "ruff-0.0.261-py3-none-win32.whl", hash = "sha256:daff64b4e86e42ce69e6367d63aab9562fc213cd4db0e146859df8abc283dba0"}, - {file = "ruff-0.0.261-py3-none-win_amd64.whl", hash = "sha256:0fbc689c23609edda36169c8708bb91bab111d8f44cb4a88330541757770ab30"}, - {file = "ruff-0.0.261-py3-none-win_arm64.whl", hash = "sha256:d2eddc60ae75fc87f8bb8fd6e8d5339cf884cd6de81e82a50287424309c187ba"}, - {file = "ruff-0.0.261.tar.gz", hash = "sha256:c1c715b0d1e18f9c509d7c411ca61da3543a4aa459325b1b1e52b8301d65c6d2"}, -] - -[[package]] -name = "scp" -version = "0.14.4" -description = "scp module for paramiko" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "scp-0.14.4-py2.py3-none-any.whl", hash = "sha256:29ddaafbfba60793a8a779694c97d8c150d365668a4ef67616c515b80a69ef2f"}, - {file = "scp-0.14.4.tar.gz", hash = "sha256:54699b92cb68ae34b5928c48a888eab9722a212502cba89aa795bd56597505bd"}, -] - -[package.dependencies] -paramiko = "*" - -[[package]] -name = "setuptools" -version = "65.6.3" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "setuptools-65.6.3-py3-none-any.whl", hash = "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54"}, - {file = "setuptools-65.6.3.tar.gz", hash = "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "smmap" -version = "5.0.0" -description = "A pure Python implementation of a sliding window memory map manager" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, - {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, -] - -[[package]] -name = "sniffio" -version = "1.3.0" -description = "Sniff out which async library your code is running under" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, - {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, -] - -[[package]] -name = "stackprinter" -version = "0.2.10" -description = "Debug-friendly stack traces, with variable values and semantic highlighting" -category = "dev" -optional = false -python-versions = ">=3.4" -files = [ - {file = "stackprinter-0.2.10-py3-none-any.whl", hash = "sha256:496e6cd058e7dd6f41e0c67e044f79a894297bec9fb80493a4fd094fac1e4677"}, - {file = "stackprinter-0.2.10.tar.gz", hash = "sha256:99d1ea6b91ffad96b28241edd7bcf071752b0cf694cab58d2335df5353acd086"}, -] - -[[package]] -name = "starlette" -version = "0.26.1" -description = "The little ASGI library that shines." -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "starlette-0.26.1-py3-none-any.whl", hash = "sha256:e87fce5d7cbdde34b76f0ac69013fd9d190d581d80681493016666e6f96c6d5e"}, - {file = "starlette-0.26.1.tar.gz", hash = "sha256:41da799057ea8620e4667a3e69a5b1923ebd32b1819c8fa75634bbe8d8bea9bd"}, -] - -[package.dependencies] -anyio = ">=3.4.0,<5" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} - -[package.extras] -full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] - -[[package]] -name = "stevedore" -version = "4.1.1" -description = "Manage dynamic plugins for Python applications" -category = "dev" -optional = false -python-versions = ">=3.8" -files = [ - {file = "stevedore-4.1.1-py3-none-any.whl", hash = "sha256:aa6436565c069b2946fe4ebff07f5041e0c8bf18c7376dd29edf80cf7d524e4e"}, - {file = "stevedore-4.1.1.tar.gz", hash = "sha256:7f8aeb6e3f90f96832c301bff21a7eb5eefbe894c88c506483d355565d88cc1a"}, -] - -[package.dependencies] -pbr = ">=2.0.0,<2.1.0 || >2.1.0" - -[[package]] -name = "svglib" -version = "1.5.1" -description = "A pure-Python library for reading and converting SVG" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "svglib-1.5.1.tar.gz", hash = "sha256:3ae765d3a9409ee60c0fb4d24c2deb6a80617aa927054f5bcd7fc98f0695e587"}, -] - -[package.dependencies] -cssselect2 = ">=0.2.0" -lxml = "*" -reportlab = "*" -tinycss2 = ">=0.6.0" - -[[package]] -name = "taskipy" -version = "1.10.3" -description = "tasks runner for python projects" -category = "dev" -optional = false -python-versions = ">=3.6,<4.0" -files = [ - {file = "taskipy-1.10.3-py3-none-any.whl", hash = "sha256:4c0070ca53868d97989f7ab5c6f237525d52ee184f9b967576e8fe427ed9d0b8"}, - {file = "taskipy-1.10.3.tar.gz", hash = "sha256:112beaf21e3d5569950b99162a1de003fa885fabee9e450757a6b874be914877"}, -] - -[package.dependencies] -colorama = ">=0.4.4,<0.5.0" -mslex = {version = ">=0.3.0,<0.4.0", markers = "sys_platform == \"win32\""} -psutil = ">=5.7.2,<6.0.0" -tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version >= \"3.7\" and python_version < \"4.0\""} - -[[package]] -name = "tenacity" -version = "8.1.0" -description = "Retry code until it succeeds" -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "tenacity-8.1.0-py3-none-any.whl", hash = "sha256:35525cd47f82830069f0d6b73f7eb83bc5b73ee2fff0437952cedf98b27653ac"}, - {file = "tenacity-8.1.0.tar.gz", hash = "sha256:e48c437fdf9340f5666b92cd7990e96bc5fc955e1298baf4a907e3972067a445"}, -] - -[package.extras] -doc = ["reno", "sphinx", "tornado (>=4.5)"] - -[[package]] -name = "textfsm" -version = "1.1.2" -description = "Python module for parsing semi-structured text into python tables." -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "textfsm-1.1.2-py2.py3-none-any.whl", hash = "sha256:f3d4e9bd4344935a08e6844e53d6220e2e4fb7e465bee51fa909152ed6bab406"}, - {file = "textfsm-1.1.2.tar.gz", hash = "sha256:85a450b441aff04b1cac726bdb36f35534a5b196cca08c8bc14fddd879c4255c"}, -] - -[package.dependencies] -future = "*" -six = "*" - -[[package]] -name = "tinycss2" -version = "1.2.1" -description = "A tiny CSS parser" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tinycss2-1.2.1-py3-none-any.whl", hash = "sha256:2b80a96d41e7c3914b8cda8bc7f705a4d9c49275616e886103dd839dfc847847"}, - {file = "tinycss2-1.2.1.tar.gz", hash = "sha256:8cff3a8f066c2ec677c06dbc7b45619804a6938478d9d73c284b29d14ecb0627"}, -] - -[package.dependencies] -webencodings = ">=0.4" - -[package.extras] -doc = ["sphinx", "sphinx_rtd_theme"] -test = ["flake8", "isort", "pytest"] - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -category = "dev" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "typer" -version = "0.7.0" -description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -category = "main" -optional = false -python-versions = ">=3.6" -files = [ - {file = "typer-0.7.0-py3-none-any.whl", hash = "sha256:b5e704f4e48ec263de1c0b3a2387cd405a13767d2f907f44c1a08cbad96f606d"}, - {file = "typer-0.7.0.tar.gz", hash = "sha256:ff797846578a9f2a201b53442aedeb543319466870fbe1c701eab66dd7681165"}, -] - -[package.dependencies] -click = ">=7.1.1,<9.0.0" - -[package.extras] -all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"] -dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"] -doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] -test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"] - -[[package]] -name = "typing-extensions" -version = "4.5.0" -description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, - {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, -] - -[[package]] -name = "uvicorn" -version = "0.21.1" -description = "The lightning-fast ASGI server." -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "uvicorn-0.21.1-py3-none-any.whl", hash = "sha256:e47cac98a6da10cd41e6fd036d472c6f58ede6c5dbee3dbee3ef7a100ed97742"}, - {file = "uvicorn-0.21.1.tar.gz", hash = "sha256:0fac9cb342ba099e0d582966005f3fdba5b0290579fed4a6266dc702ca7bb032"}, -] - -[package.dependencies] -click = ">=7.0" -h11 = ">=0.8" - -[package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] - -[[package]] -name = "uvloop" -version = "0.17.0" -description = "Fast implementation of asyncio event loop on top of libuv" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "uvloop-0.17.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce9f61938d7155f79d3cb2ffa663147d4a76d16e08f65e2c66b77bd41b356718"}, - {file = "uvloop-0.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:68532f4349fd3900b839f588972b3392ee56042e440dd5873dfbbcd2cc67617c"}, - {file = "uvloop-0.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0949caf774b9fcefc7c5756bacbbbd3fc4c05a6b7eebc7c7ad6f825b23998d6d"}, - {file = "uvloop-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff3d00b70ce95adce264462c930fbaecb29718ba6563db354608f37e49e09024"}, - {file = "uvloop-0.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a5abddb3558d3f0a78949c750644a67be31e47936042d4f6c888dd6f3c95f4aa"}, - {file = "uvloop-0.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8efcadc5a0003d3a6e887ccc1fb44dec25594f117a94e3127954c05cf144d811"}, - {file = "uvloop-0.17.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3378eb62c63bf336ae2070599e49089005771cc651c8769aaad72d1bd9385a7c"}, - {file = "uvloop-0.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6aafa5a78b9e62493539456f8b646f85abc7093dd997f4976bb105537cf2635e"}, - {file = "uvloop-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c686a47d57ca910a2572fddfe9912819880b8765e2f01dc0dd12a9bf8573e539"}, - {file = "uvloop-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:864e1197139d651a76c81757db5eb199db8866e13acb0dfe96e6fc5d1cf45fc4"}, - {file = "uvloop-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2a6149e1defac0faf505406259561bc14b034cdf1d4711a3ddcdfbaa8d825a05"}, - {file = "uvloop-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6708f30db9117f115eadc4f125c2a10c1a50d711461699a0cbfaa45b9a78e376"}, - {file = "uvloop-0.17.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:23609ca361a7fc587031429fa25ad2ed7242941adec948f9d10c045bfecab06b"}, - {file = "uvloop-0.17.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2deae0b0fb00a6af41fe60a675cec079615b01d68beb4cc7b722424406b126a8"}, - {file = "uvloop-0.17.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45cea33b208971e87a31c17622e4b440cac231766ec11e5d22c76fab3bf9df62"}, - {file = "uvloop-0.17.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9b09e0f0ac29eee0451d71798878eae5a4e6a91aa275e114037b27f7db72702d"}, - {file = "uvloop-0.17.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dbbaf9da2ee98ee2531e0c780455f2841e4675ff580ecf93fe5c48fe733b5667"}, - {file = "uvloop-0.17.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a4aee22ece20958888eedbad20e4dbb03c37533e010fb824161b4f05e641f738"}, - {file = "uvloop-0.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:307958f9fc5c8bb01fad752d1345168c0abc5d62c1b72a4a8c6c06f042b45b20"}, - {file = "uvloop-0.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ebeeec6a6641d0adb2ea71dcfb76017602ee2bfd8213e3fcc18d8f699c5104f"}, - {file = "uvloop-0.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1436c8673c1563422213ac6907789ecb2b070f5939b9cbff9ef7113f2b531595"}, - {file = "uvloop-0.17.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8887d675a64cfc59f4ecd34382e5b4f0ef4ae1da37ed665adba0c2badf0d6578"}, - {file = "uvloop-0.17.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3db8de10ed684995a7f34a001f15b374c230f7655ae840964d51496e2f8a8474"}, - {file = "uvloop-0.17.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7d37dccc7ae63e61f7b96ee2e19c40f153ba6ce730d8ba4d3b4e9738c1dccc1b"}, - {file = "uvloop-0.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cbbe908fda687e39afd6ea2a2f14c2c3e43f2ca88e3a11964b297822358d0e6c"}, - {file = "uvloop-0.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d97672dc709fa4447ab83276f344a165075fd9f366a97b712bdd3fee05efae8"}, - {file = "uvloop-0.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1e507c9ee39c61bfddd79714e4f85900656db1aec4d40c6de55648e85c2799c"}, - {file = "uvloop-0.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c092a2c1e736086d59ac8e41f9c98f26bbf9b9222a76f21af9dfe949b99b2eb9"}, - {file = "uvloop-0.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:30babd84706115626ea78ea5dbc7dd8d0d01a2e9f9b306d24ca4ed5796c66ded"}, - {file = "uvloop-0.17.0.tar.gz", hash = "sha256:0ddf6baf9cf11a1a22c71487f39f15b2cf78eb5bde7e5b45fbb99e8a9d91b9e1"}, -] - -[package.extras] -dev = ["Cython (>=0.29.32,<0.30.0)", "Sphinx (>=4.1.2,<4.2.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=22.0.0,<22.1.0)", "pycodestyle (>=2.7.0,<2.8.0)", "pytest (>=3.6.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] -docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] -test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=22.0.0,<22.1.0)", "pycodestyle (>=2.7.0,<2.8.0)"] - -[[package]] -name = "virtualenv" -version = "20.17.1" -description = "Virtual Python Environment builder" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "virtualenv-20.17.1-py3-none-any.whl", hash = "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4"}, - {file = "virtualenv-20.17.1.tar.gz", hash = "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"}, -] - -[package.dependencies] -distlib = ">=0.3.6,<1" -filelock = ">=3.4.1,<4" -platformdirs = ">=2.4,<3" - -[package.extras] -docs = ["proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-argparse (>=0.3.2)", "sphinx-rtd-theme (>=1)", "towncrier (>=22.8)"] -testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"] - -[[package]] -name = "webencodings" -version = "0.5.1" -description = "Character encoding aliases for legacy web content" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, - {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, -] - -[[package]] -name = "win32-setctime" -version = "1.1.0" -description = "A small Python utility to set file creation time on Windows" -category = "main" -optional = false -python-versions = ">=3.5" -files = [ - {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, - {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, -] - -[package.extras] -dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] - -[[package]] -name = "xmltodict" -version = "0.13.0" -description = "Makes working with XML feel like you are working with JSON" -category = "main" -optional = false -python-versions = ">=3.4" -files = [ - {file = "xmltodict-0.13.0-py2.py3-none-any.whl", hash = "sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852"}, - {file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"}, -] - -[metadata] -lock-version = "2.0" -python-versions = ">=3.8.1,<4.0" -content-hash = "b371cf3dcbbfa4c598d5b44086903176d446f4a308a3a6727062ecc2d798e46e" diff --git a/pyproject.toml b/pyproject.toml index 384d6dac0..8f998c4bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,37 +1,39 @@ [project] name = "hyperglass" -version = "2.0.4" +version = "2.1.2" description = "hyperglass is the modern network looking glass that tries to make the internet better." authors = [ { name = "thatmattlove", email = "matt@hyperglass.dev" } ] dependencies = [ - "Pillow==10.2.0", - "PyJWT==2.6.0", - "PyYAML>=6.0", - "aiofiles>=23.2.1", - "distro==1.8.0", + "Pillow>=12.2.0", + "PyJWT>=2.12.0", + "PyYAML>=6.0.2", + "aiofiles>=24.1.0", + "distro==1.9.0", "favicons==0.2.2", - "httpx==0.24.0", - "loguru>=0.7.2", - "netmiko==4.1.2", - "paramiko==3.4.0", - "psutil==5.9.4", + "httpx==0.28.1", + "loguru>=0.7.3", + "netmiko==4.6.0", + "paramiko==4.0.0", + "psutil==7.1.0", "py-cpuinfo==9.0.0", - "pydantic>=2.6.3", - "redis==4.5.4", - "rich>=13.7.0", + "pydantic>=2.11.9", + "redis==6.4.0", + "rich<=14.0.0", "typer>=0.9.0", - "uvicorn==0.21.1", - "uvloop>=0.17.0", - "xmltodict==0.13.0", + "uvicorn==0.37.0", + "uvloop>=0.21.0", + "xmltodict==1.0.2", "toml>=0.10.2", - "pydantic-settings>=2.2.1", - "pydantic-extra-types>=2.6.0", - "litestar[standard,brotli]>=2.7.1", + "pydantic-settings==2.11.0", + "pydantic-extra-types>=2.10.5", + "litestar[standard,brotli]>=2.22.0", + "click<=8.1.7", + "requests>=2.26.0", ] readme = "README.md" -requires-python = ">= 3.11" +requires-python = ">= 3.13,<4.0" [project.scripts] hyperglass = "hyperglass.console:run" @@ -56,6 +58,31 @@ dev-dependencies = [ "taskipy>=1.12.2", ] +[tool.uv] +# Security floors for transitive dependencies. These are pulled in indirectly +# (litestar / paramiko / httpx) with no conflicting upper bound, so a constraint +# is enough to force the patched releases without otherwise reshaping the tree: +# brotli CVE-2025-6176 (DoS via decompression bomb) +# cryptography CVE-2026-26007 (SECT-curve subgroup attack) — keep within 46.x +# lxml CVE-2026-41066 (info disclosure via untrusted XML) +# multipart CVE-2026-28356 (DoS via crafted multipart headers) +constraint-dependencies = [ + "brotli>=1.2.0", + "cryptography>=46.0.5,<47", + "lxml>=6.1.0", + "multipart>=1.3.1", +] +# Overrides for dependencies whose declared bounds conflict with a version we +# need and have verified is safe: +# - favicons 0.2.2 (latest release) caps pillow<11.0.0, holding us on a Pillow +# line with HIGH CVEs (CVE-2026-25990 / -40192 / -42311). favicons is the +# only Pillow consumer and its favicon generation is verified on Pillow 12.x. +# - taskipy (dev only) caps psutil<7, but hyperglass pins psutil 7.1.0. +override-dependencies = [ + "pillow>=12.2.0", + "psutil==7.1.0", +] + [tool.hatch.metadata] allow-direct-references = true diff --git a/requirements-dev.lock b/requirements-dev.lock index e2c192391..7c53b691d 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -10,257 +10,277 @@ # universal: false -e file:. -aiofiles==23.2.1 +aiofiles==24.1.0 # via hyperglass -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.3.0 - # via httpcore +anyio==4.11.0 + # via httpx # via litestar # via watchfiles -async-timeout==4.0.3 - # via redis -bandit==1.7.7 -bcrypt==4.1.2 +bandit==1.9.4 +bcrypt==5.0.0 # via paramiko -black==24.2.0 -brotli==1.1.0 +black==26.5.1 +brotli==1.2.0 + # via -c (workspace) # via litestar -certifi==2024.2.2 +certifi==2025.8.3 # via httpcore # via httpx -cffi==1.16.0 + # via requests +cffi==2.0.0 # via cryptography # via pynacl -cfgv==3.4.0 +cfgv==3.5.0 # via pre-commit -chardet==5.2.0 +charset-normalizer==3.4.3 # via reportlab + # via requests click==8.1.7 # via black + # via hyperglass # via litestar # via rich-click # via typer # via uvicorn colorama==0.4.6 # via taskipy -cryptography==42.0.3 +cryptography==46.0.7 + # via -c (workspace) # via paramiko -cssselect2==0.7.0 +cssselect2==0.8.0 # via svglib -distlib==0.3.8 +distlib==0.4.3 # via virtualenv -distro==1.8.0 +distro==1.9.0 # via hyperglass -editorconfig==0.12.4 +editorconfig==0.17.1 # via jsbeautifier -faker==24.4.0 +faker==37.8.0 # via polyfactory -fast-query-parsers==1.0.3 - # via litestar favicons==0.2.2 # via hyperglass -filelock==3.13.1 +filelock==3.29.4 + # via python-discovery # via virtualenv -flake8==7.0.0 +flake8==7.3.0 # via pep8-naming -freetype-py==2.4.0 +freetype-py==2.5.1 # via rlpycairo -future==0.18.3 - # via textfsm -h11==0.14.0 +h11==0.16.0 # via httpcore # via uvicorn -httpcore==0.17.3 +httpcore==1.0.9 # via httpx -httptools==0.6.1 +httptools==0.6.4 # via uvicorn -httpx==0.24.0 +httpx==0.28.1 # via hyperglass # via litestar -identify==2.5.35 +identify==2.6.19 # via pre-commit -idna==3.6 +idna==3.10 # via anyio # via httpx -iniconfig==2.0.0 + # via requests +iniconfig==2.3.0 # via pytest -isort==5.13.2 -jinja2==3.1.3 +invoke==2.2.0 + # via paramiko +isort==8.0.1 +jinja2==3.1.6 # via litestar -jsbeautifier==1.15.1 +jsbeautifier==1.15.4 # via litestar -litestar==2.7.1 +litestar==2.24.0 # via hyperglass -loguru==0.7.2 +litestar-htmx==0.5.0 + # via litestar +loguru==0.7.3 # via hyperglass -lxml==5.1.0 +lxml==6.1.1 + # via -c (workspace) # via svglib -markdown-it-py==3.0.0 +markdown-it-py==4.0.0 # via rich -markupsafe==2.1.5 +markupsafe==3.0.2 # via jinja2 mccabe==0.7.0 # via flake8 mdurl==0.1.2 # via markdown-it-py -msgspec==0.18.6 +msgspec==0.19.0 # via litestar -multidict==6.0.5 +multidict==6.6.4 # via litestar -mypy-extensions==1.0.0 +multipart==1.3.1 + # via -c (workspace) + # via litestar +mypy-extensions==1.1.0 # via black -netmiko==4.1.2 +netmiko==4.6.0 # via hyperglass -nodeenv==1.8.0 +nodeenv==1.10.0 # via pre-commit -ntc-templates==4.3.0 +ntc-templates==8.1.0 # via netmiko -packaging==23.2 +packaging==26.2 # via black # via pytest -paramiko==3.4.0 +paramiko==4.0.0 # via hyperglass # via netmiko # via scp -pathspec==0.12.1 +pathspec==1.1.1 # via black -pbr==6.0.0 - # via stevedore -pep8-naming==0.13.3 -pillow==10.2.0 +pep8-naming==0.15.1 +pillow==12.2.0 + # via --override (workspace) # via favicons # via hyperglass # via reportlab -platformdirs==4.2.0 +platformdirs==4.10.0 # via black + # via python-discovery # via virtualenv -pluggy==1.4.0 +pluggy==1.6.0 # via pytest -polyfactory==2.15.0 +polyfactory==2.22.2 # via litestar -pre-commit==3.6.2 -psutil==5.9.4 +pre-commit==4.6.0 +psutil==7.1.0 + # via --override (workspace) # via hyperglass # via taskipy py-cpuinfo==9.0.0 # via hyperglass -pycairo==1.26.0 +pycairo==1.28.0 # via rlpycairo -pycodestyle==2.11.1 +pycodestyle==2.14.0 # via flake8 -pycparser==2.21 +pycparser==2.23 # via cffi -pydantic==2.6.3 +pydantic==2.11.9 # via hyperglass # via pydantic-extra-types # via pydantic-settings -pydantic-core==2.16.3 +pydantic-core==2.33.2 # via pydantic -pydantic-extra-types==2.6.0 +pydantic-extra-types==2.10.5 # via hyperglass -pydantic-settings==2.2.1 +pydantic-settings==2.11.0 # via hyperglass -pyflakes==3.2.0 +pyflakes==3.4.0 # via flake8 -pygments==2.17.2 +pygments==2.19.2 + # via pytest # via rich -pyjwt==2.6.0 +pyjwt==2.13.0 # via hyperglass -pynacl==1.5.0 +pynacl==1.6.0 # via paramiko pyserial==3.5 # via netmiko -pytest==8.0.1 +pytest==9.1.0 # via pytest-asyncio # via pytest-dependency -pytest-asyncio==0.23.5 -pytest-dependency==0.6.0 -python-dateutil==2.9.0.post0 - # via faker -python-dotenv==1.0.1 +pytest-asyncio==1.4.0 +pytest-dependency==0.6.1 +python-discovery==1.4.2 + # via virtualenv +python-dotenv==1.1.1 # via pydantic-settings # via uvicorn -pyyaml==6.0.1 +pytokens==0.4.1 + # via black +pyyaml==6.0.3 # via bandit # via hyperglass # via litestar # via netmiko # via pre-commit # via uvicorn -redis==4.5.4 +redis==6.4.0 # via hyperglass -reportlab==4.1.0 +reportlab==4.4.4 # via favicons # via svglib -rich==13.7.0 +requests==2.34.2 + # via hyperglass +rich==13.9.4 # via bandit # via favicons # via hyperglass # via litestar + # via netmiko # via rich-click -rich-click==1.7.4 +rich-click==1.9.1 # via litestar rlpycairo==0.3.0 # via favicons -ruff==0.2.2 -scp==0.14.5 +ruamel-yaml==0.18.15 # via netmiko -setuptools==69.1.0 +ruamel-yaml-clib==0.2.14 + # via ruamel-yaml +ruff==0.15.17 +scp==0.15.0 # via netmiko - # via nodeenv +setuptools==82.0.1 # via pytest-dependency -six==1.16.0 +six==1.17.0 # via jsbeautifier - # via python-dateutil - # via textfsm -sniffio==1.3.0 +sniffio==1.3.1 # via anyio - # via httpcore - # via httpx -stackprinter==0.2.11 -stevedore==5.1.0 + # via litestar +stackprinter==0.2.13 +stevedore==5.8.0 # via bandit svglib==1.5.1 # via favicons -taskipy==1.12.2 -tenacity==8.2.3 - # via netmiko -textfsm==1.1.2 +taskipy==1.14.1 +textfsm==2.1.0 # via netmiko # via ntc-templates -tinycss2==1.2.1 +tinycss2==1.4.0 # via cssselect2 # via svglib toml==0.10.2 # via hyperglass -tomli==2.0.1 +tomli==2.4.1 # via taskipy -typer==0.9.0 +typer==0.9.4 # via favicons # via hyperglass -typing-extensions==4.9.0 +typing-extensions==4.15.0 # via litestar # via polyfactory # via pydantic # via pydantic-core - # via rich-click + # via pydantic-extra-types # via typer -uvicorn==0.21.1 + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic + # via pydantic-settings +tzdata==2025.2 + # via faker +urllib3==2.7.0 + # via requests +uvicorn==0.37.0 # via hyperglass # via litestar -uvloop==0.18.0 +uvloop==0.21.0 # via hyperglass - # via litestar # via uvicorn -virtualenv==20.25.0 +virtualenv==21.5.0 # via pre-commit -watchfiles==0.21.0 +watchfiles==1.1.0 # via uvicorn webencodings==0.5.1 # via cssselect2 # via tinycss2 -websockets==12.0 +websockets==15.0.1 # via uvicorn -xmltodict==0.13.0 +xmltodict==1.0.2 # via hyperglass diff --git a/requirements.lock b/requirements.lock index b15170923..e3bb70014 100644 --- a/requirements.lock +++ b/requirements.lock @@ -10,195 +10,210 @@ # universal: false -e file:. -aiofiles==23.2.1 +aiofiles==24.1.0 # via hyperglass -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.3.0 - # via httpcore +anyio==4.11.0 + # via httpx # via litestar # via watchfiles -async-timeout==4.0.3 - # via redis -bcrypt==4.1.2 +bcrypt==5.0.0 # via paramiko -brotli==1.1.0 +brotli==1.2.0 + # via -c (workspace) # via litestar -certifi==2024.2.2 +certifi==2025.8.3 # via httpcore # via httpx -cffi==1.16.0 + # via requests +cffi==2.0.0 # via cryptography # via pynacl -chardet==5.2.0 +charset-normalizer==3.4.3 # via reportlab + # via requests click==8.1.7 + # via hyperglass # via litestar # via rich-click # via typer # via uvicorn -cryptography==42.0.3 +cryptography==46.0.7 + # via -c (workspace) # via paramiko -cssselect2==0.7.0 +cssselect2==0.8.0 # via svglib -distro==1.8.0 +distro==1.9.0 # via hyperglass -editorconfig==0.12.4 +editorconfig==0.17.1 # via jsbeautifier -faker==24.4.0 +faker==37.8.0 # via polyfactory -fast-query-parsers==1.0.3 - # via litestar favicons==0.2.2 # via hyperglass -freetype-py==2.4.0 +freetype-py==2.5.1 # via rlpycairo -future==0.18.3 - # via textfsm -h11==0.14.0 +h11==0.16.0 # via httpcore # via uvicorn -httpcore==0.17.3 +httpcore==1.0.9 # via httpx -httptools==0.6.1 +httptools==0.6.4 # via uvicorn -httpx==0.24.0 +httpx==0.28.1 # via hyperglass # via litestar -idna==3.6 +idna==3.10 # via anyio # via httpx -jinja2==3.1.3 + # via requests +invoke==2.2.0 + # via paramiko +jinja2==3.1.6 # via litestar -jsbeautifier==1.15.1 +jsbeautifier==1.15.4 # via litestar -litestar==2.7.1 +litestar==2.24.0 # via hyperglass -loguru==0.7.2 +litestar-htmx==0.5.0 + # via litestar +loguru==0.7.3 # via hyperglass -lxml==5.1.0 +lxml==6.1.1 + # via -c (workspace) # via svglib -markdown-it-py==3.0.0 +markdown-it-py==4.0.0 # via rich -markupsafe==2.1.5 +markupsafe==3.0.2 # via jinja2 mdurl==0.1.2 # via markdown-it-py -msgspec==0.18.6 +msgspec==0.19.0 + # via litestar +multidict==6.6.4 # via litestar -multidict==6.0.5 +multipart==1.3.1 + # via -c (workspace) # via litestar -netmiko==4.1.2 +netmiko==4.6.0 # via hyperglass -ntc-templates==4.3.0 +ntc-templates==8.1.0 # via netmiko -paramiko==3.4.0 +paramiko==4.0.0 # via hyperglass # via netmiko # via scp -pillow==10.2.0 +pillow==12.2.0 + # via --override (workspace) # via favicons # via hyperglass # via reportlab -polyfactory==2.15.0 +polyfactory==2.22.2 # via litestar -psutil==5.9.4 +psutil==7.1.0 + # via --override (workspace) # via hyperglass py-cpuinfo==9.0.0 # via hyperglass -pycairo==1.26.0 +pycairo==1.28.0 # via rlpycairo -pycparser==2.21 +pycparser==2.23 # via cffi -pydantic==2.6.3 +pydantic==2.11.9 # via hyperglass # via pydantic-extra-types # via pydantic-settings -pydantic-core==2.16.3 +pydantic-core==2.33.2 # via pydantic -pydantic-extra-types==2.6.0 +pydantic-extra-types==2.10.5 # via hyperglass -pydantic-settings==2.2.1 +pydantic-settings==2.11.0 # via hyperglass -pygments==2.17.2 +pygments==2.19.2 # via rich -pyjwt==2.6.0 +pyjwt==2.13.0 # via hyperglass -pynacl==1.5.0 +pynacl==1.6.0 # via paramiko pyserial==3.5 # via netmiko -python-dateutil==2.9.0.post0 - # via faker -python-dotenv==1.0.1 +python-dotenv==1.1.1 # via pydantic-settings # via uvicorn -pyyaml==6.0.1 +pyyaml==6.0.3 # via hyperglass # via litestar # via netmiko # via uvicorn -redis==4.5.4 +redis==6.4.0 # via hyperglass -reportlab==4.1.0 +reportlab==4.4.4 # via favicons # via svglib -rich==13.7.0 +requests==2.34.2 + # via hyperglass +rich==13.9.4 # via favicons # via hyperglass # via litestar + # via netmiko # via rich-click -rich-click==1.7.4 +rich-click==1.9.1 # via litestar rlpycairo==0.3.0 # via favicons -scp==0.14.5 +ruamel-yaml==0.18.15 # via netmiko -setuptools==69.1.0 +ruamel-yaml-clib==0.2.14 + # via ruamel-yaml +scp==0.15.0 # via netmiko -six==1.16.0 +six==1.17.0 # via jsbeautifier - # via python-dateutil - # via textfsm -sniffio==1.3.0 +sniffio==1.3.1 # via anyio - # via httpcore - # via httpx + # via litestar svglib==1.5.1 # via favicons -tenacity==8.2.3 - # via netmiko -textfsm==1.1.2 +textfsm==2.1.0 # via netmiko # via ntc-templates -tinycss2==1.2.1 +tinycss2==1.4.0 # via cssselect2 # via svglib toml==0.10.2 # via hyperglass -typer==0.9.0 +typer==0.9.4 # via favicons # via hyperglass -typing-extensions==4.9.0 +typing-extensions==4.15.0 # via litestar # via polyfactory # via pydantic # via pydantic-core - # via rich-click + # via pydantic-extra-types # via typer -uvicorn==0.21.1 + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic + # via pydantic-settings +tzdata==2025.2 + # via faker +urllib3==2.7.0 + # via requests +uvicorn==0.37.0 # via hyperglass # via litestar -uvloop==0.18.0 +uvloop==0.21.0 # via hyperglass - # via litestar # via uvicorn -watchfiles==0.21.0 +watchfiles==1.1.0 # via uvicorn webencodings==0.5.1 # via cssselect2 # via tinycss2 -websockets==12.0 +websockets==15.0.1 # via uvicorn -xmltodict==0.13.0 +xmltodict==1.0.2 # via hyperglass From 1b086b93ed6671b53d7380711ee7067d8356c4be Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Sun, 14 Jun 2026 21:09:39 +1000 Subject: [PATCH 13/39] docs: comprehensive documentation --- README.md | 9 + docs/pages/_meta.tsx | 1 + docs/pages/configuration/config/_meta.tsx | 4 + .../configuration/config/complete-config.mdx | 801 ++++++++++++++++++ .../config/structured-output.mdx | 218 ++++- docs/pages/configuration/config/web-ui.mdx | 1 + docs/pages/configuration/examples/_meta.tsx | 4 + .../configuration/examples/quick-start.mdx | 453 ++++++++++ docs/pages/installation/manual.mdx | 2 +- docs/pages/installation/upgrading.mdx | 2 +- docs/pages/user-guide.mdx | 362 ++++++++ 11 files changed, 1846 insertions(+), 11 deletions(-) create mode 100644 docs/pages/configuration/config/complete-config.mdx create mode 100644 docs/pages/configuration/examples/quick-start.mdx create mode 100644 docs/pages/user-guide.mdx diff --git a/README.md b/README.md index 869c03fe0..b88ac840b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ hyperglass is intended to make implementing a looking glass too easy not to do, ## Features - BGP Route, BGP Community, BGP AS Path, Ping, & Traceroute, or [add your own commands](https://hyperglass.dev/configuration/directives). +- **Structured data output** with rich metadata for supported platforms +- **Enhanced traceroute** with ASN information, organization names, country codes, and IXP detection - Full IPv6 support - Customizable everything: features, theme, UI/API text, error messages, commands - Built-in support for: @@ -43,12 +45,17 @@ hyperglass is intended to make implementing a looking glass too easy not to do, - OpenBGPD - TNSR - VyOS +- **Structured BGP Route support** for: Arista EOS, FRRouting, Huawei VRP, Juniper Junos, MikroTik RouterOS +- **Structured Traceroute support** for: Arista EOS, FRRouting, Huawei VRP, Juniper Junos, MikroTik RouterOS/SwitchOS - Configurable support for any other [supported platform](https://hyperglass.dev/platforms) - Optionally access devices via an SSH proxy/jump server - Access-list/prefix-list style query control to whitelist or blacklist query targets - REST API with automatic, configurable OpenAPI documentation - Modern, responsive UI built on [ReactJS](https://reactjs.org/), with [NextJS](https://nextjs.org/) & [Chakra UI](https://chakra-ui.com/), written in [TypeScript](https://www.typescriptlang.org/) +- **AS path visualization** with interactive flow charts showing organization names +- **Real-time IP enrichment** using BGP.tools for maximum accuracy and up-to-date data - Query multiple devices simultaneously +- **Concurrent processing** with non-blocking operations for improved performance - Browser-based DNS-over-HTTPS resolution of FQDN queries *To request support for a specific platform, please [submit a Github Issue](https://github.com/thatmattlove/hyperglass/issues/new) with the **feature** label.* @@ -76,5 +83,7 @@ hyperglass is built entirely on open-source software. Here are some of the aweso - [Litestar](https://litestar.dev) - [Pydantic](https://docs.pydantic.dev/latest/) - [Chakra UI](https://chakra-ui.com/) +- [React Flow](https://reactflow.dev/) - AS path visualization +- [BGP.tools](https://bgp.tools/) - Real-time IP enrichment and IXP data [![GitHub](https://img.shields.io/github/license/thatmattlove/hyperglass?color=330036&style=for-the-badge)](https://github.com/thatmattlove/hyperglass/blob/main/LICENSE) diff --git a/docs/pages/_meta.tsx b/docs/pages/_meta.tsx index 78bc398e6..40849e0a0 100644 --- a/docs/pages/_meta.tsx +++ b/docs/pages/_meta.tsx @@ -3,6 +3,7 @@ export default { "---": { type: "separator", }, + "user-guide": "User Guide", installation: "Installation", configuration: "Configuration", platforms: "Platforms", diff --git a/docs/pages/configuration/config/_meta.tsx b/docs/pages/configuration/config/_meta.tsx index c91b46169..f0fd71350 100644 --- a/docs/pages/configuration/config/_meta.tsx +++ b/docs/pages/configuration/config/_meta.tsx @@ -1,4 +1,8 @@ export default { + "complete-config": "Complete Configuration Reference", + "---": { + type: "separator", + }, "api-docs": "API Docs", caching: "Caching", logging: "Logging & Webhooks", diff --git a/docs/pages/configuration/config/complete-config.mdx b/docs/pages/configuration/config/complete-config.mdx new file mode 100644 index 000000000..9406b1061 --- /dev/null +++ b/docs/pages/configuration/config/complete-config.mdx @@ -0,0 +1,801 @@ +--- +title: Complete Configuration Reference +description: Comprehensive documentation for all config.yaml parameters with examples +--- + +import { Callout } from 'nextra/components' +import { DocsButton } from '~/components/docs-button' + +# Complete Configuration Reference + +This is a comprehensive reference for all available configuration options in hyperglass. The configuration is written in YAML format and saved as `config.yaml` in your hyperglass configuration directory. + +## Top-Level Parameters + +These parameters are configured at the root level of your `config.yaml` file. + +### Basic Site Information + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `site_title` | String | `hyperglass` | The name of your hyperglass site. Used in HTML `` tags and API documentation. | +| `org_name` | String | `Beloved Hyperglass User` | Your organization's name. Used in UI, API documentation, and HTML meta tags for SEO. | +| `site_description` | String | `{org_name} Network Looking Glass` | Short description of your site. Used for HTML meta description tag. `{org_name}` is automatically replaced. | +| `primary_asn` | String/Integer | `65001` | Your network's primary ASN. Used for defaults like subtitle and PeeringDB URL. | + +#### Example: Basic Site Configuration +```yaml filename="config.yaml" +site_title: "ACME Networks Looking Glass" +org_name: "ACME Networks Inc." +site_description: "Network diagnostic tools for ACME Networks AS65000" +primary_asn: 65000 +``` + +### System Parameters + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `request_timeout` | Integer | `90` | Global timeout in seconds for all requests. Frontend uses this exact value; backend uses value minus 1 second. | +| `fake_output` | Boolean | `false` | Enable fake static output for development/testing purposes. | +| `cors_origins` | Array | `[]` | List of allowed CORS hosts. By default, no CORS hosts are allowed. | +| `plugins` | Array | `[]` | List of paths to custom plugin files. | + +#### Example: System Configuration +```yaml filename="config.yaml" +request_timeout: 120 +fake_output: false # Set to true for testing +cors_origins: + - "https://network-tools.example.com" + - "https://monitoring.example.com" +plugins: + - "/etc/hyperglass/plugins/custom_validation.py" +``` + +--- + +## Cache Configuration + +Controls how hyperglass caches query responses to improve performance. + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `cache.timeout` | Integer | `120` | Cache timeout in seconds. How long responses are cached. | +| `cache.show_text` | Boolean | `true` | Whether to show cache timeout countdown to users. | + +#### Example: Cache Configuration +```yaml filename="config.yaml" +cache: + timeout: 300 # Cache for 5 minutes + show_text: true # Show "Results cached for X seconds" to users +``` + +--- + +## API Documentation Configuration + +Controls the automatically generated API documentation available at `/api/docs`. + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `docs.enable` | Boolean | `true` | Enable or disable API documentation. | +| `docs.base_url` | String | `https://lg.example.net` | Base URL used in API request samples. | +| `docs.path` | String | `/api/docs` | HTTP path where API documentation is accessible. | +| `docs.title` | String | `{site_title} API Documentation` | API documentation title. `{site_title}` is automatically replaced. | +| `docs.description` | String | `` | Description appearing below the API documentation title. | +| `docs.mode` | String | `redoc` | Documentation UI style: `redoc` or `swagger`. | + +### API Endpoint Documentation + +Each API endpoint's documentation can be customized: + +| Parameter | Type | Description | +| :-------- | :--- | :---------- | +| `docs.query.title` | String | Title for the query submission endpoint | +| `docs.query.description` | String | Description for the query submission endpoint | +| `docs.query.summary` | String | Summary text for the query submission endpoint | +| `docs.devices.title` | String | Title for the devices list endpoint | +| `docs.devices.description` | String | Description for the devices list endpoint | +| `docs.devices.summary` | String | Summary text for the devices list endpoint | +| `docs.queries.title` | String | Title for the supported queries endpoint | +| `docs.queries.description` | String | Description for the supported queries endpoint | +| `docs.queries.summary` | String | Summary text for the supported queries endpoint | +| `docs.info.title` | String | Title for the system information endpoint | +| `docs.info.description` | String | Description for the system information endpoint | +| `docs.info.summary` | String | Summary text for the system information endpoint | + +#### Example: API Documentation Configuration +```yaml filename="config.yaml" +docs: + enable: true + base_url: "https://lg.acmenetworks.com" + path: "/api/docs" + title: "ACME Networks Looking Glass API" + description: "RESTful API for network diagnostic queries" + mode: "redoc" + + # Customize individual endpoints + query: + title: "Execute Network Query" + description: "Submit traceroute, ping, or BGP lookup requests" + summary: "Network Diagnostics" + + devices: + title: "Network Locations" + description: "Available routers and their capabilities" + summary: "Router List" +``` + +--- + +## Logging & Webhooks Configuration + +Controls log output, file logging, syslog, and HTTP webhooks for query notifications. + +### File Logging + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `logging.directory` | Path | `/tmp` | Directory where log files are stored. | +| `logging.format` | String | `text` | Log format: `text` or `json`. | +| `logging.max_size` | Size | `50MB` | Maximum log file size before rotation. | + +### Syslog + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `logging.syslog.enable` | Boolean | `true` | Enable syslog logging. | +| `logging.syslog.host` | String | | Syslog server hostname or IP address. | +| `logging.syslog.port` | Integer | `514` | Syslog server port. | + +### HTTP Webhooks + +Send query notifications to external systems (Slack, Teams, custom endpoints). + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `logging.http.enable` | Boolean | `true` | Enable HTTP webhook logging. | +| `logging.http.provider` | String | `generic` | Webhook provider: `slack`, `msteams`, or `generic`. | +| `logging.http.host` | URL | | Webhook endpoint URL. | +| `logging.http.verify_ssl` | Boolean | `true` | Verify SSL certificates for HTTPS webhooks. | +| `logging.http.timeout` | Number | `5.0` | Webhook request timeout in seconds. | +| `logging.http.headers` | Object | `{}` | Custom HTTP headers to send with webhooks. | +| `logging.http.params` | Object | `{}` | Custom URL parameters to send with webhooks. | + +#### Authentication for HTTP Webhooks + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `logging.http.authentication.mode` | String | `basic` | Authentication mode: `basic` or `api_key`. | +| `logging.http.authentication.username` | String | | Username for basic authentication. | +| `logging.http.authentication.password` | String | | Password for basic auth or API key value. | +| `logging.http.authentication.header` | String | `x-api-key` | Header name for API key authentication. | + +#### Example: Complete Logging Configuration +```yaml filename="config.yaml" +logging: + directory: "/var/log/hyperglass" + format: "json" + max_size: "100MB" + + # Send logs to syslog server + syslog: + enable: true + host: "syslog.example.com" + port: 514 + + # Send query notifications to Slack + http: + enable: true + provider: "slack" + host: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + timeout: 10.0 + verify_ssl: true +``` + +#### Example: Custom Webhook with API Key +```yaml filename="config.yaml" +logging: + http: + enable: true + provider: "generic" + host: "https://monitoring.example.com/webhook" + authentication: + mode: "api_key" + password: "your-secret-api-key" + header: "Authorization" + headers: + "Content-Type": "application/json" + "User-Agent": "hyperglass-webhook" +``` + +--- + +## Messages Configuration + +Customize error messages and user-facing text shown in the UI and API responses. + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `messages.no_input` | String | `{field} must be specified.` | Shown when required field is missing. `{field}` shows field name. | +| `messages.target_not_allowed` | String | `{target} is not allowed.` | Shown when query target is denied by rules. `{target}` shows denied target. | +| `messages.feature_not_enabled` | String | `{feature} is not enabled.` | Shown when disabled query type is requested. `{feature}` shows feature name. | +| `messages.invalid_input` | String | `{target} is not valid.` | Shown for invalid query target values. `{target}` shows invalid input. | +| `messages.invalid_query` | String | `{target} is not a valid {query_type} target.` | Shown for query type/target mismatches. | +| `messages.invalid_field` | String | `{input} is an invalid {field}.` | Shown for invalid field values. | +| `messages.general` | String | `Something went wrong.` | Generic error message for unexpected errors. | +| `messages.not_found` | String | `{type} '{name}' not found.` | Shown when configuration object doesn't exist. | +| `messages.request_timeout` | String | `Request timed out.` | Shown when request exceeds timeout limit. | +| `messages.connection_error` | String | `Error connecting to {device_name}: {error}` | Shown for device connection failures. | +| `messages.authentication_error` | String | `Authentication error occurred.` | Shown for device authentication failures. | +| `messages.no_response` | String | `No response.` | Shown when no output can be read from device. | +| `messages.no_output` | String | `The query completed, but no matching results were found.` | Shown for successful queries with empty results. | + +#### Example: Custom Messages +```yaml filename="config.yaml" +messages: + no_input: "Please specify a {field} for your query." + target_not_allowed: "Sorry, {target} is not permitted by our policy." + request_timeout: "Your request took too long and was cancelled." + connection_error: "Unable to reach {device_name}: {error}" + no_output: "Query executed successfully but returned no results." +``` + +--- + +## Structured Output Configuration + +Controls how hyperglass processes and displays structured data from network devices. + +### BGP Communities + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `structured.communities.mode` | String | `deny` | How to handle communities: `permit`, `deny`, or `name`. | +| `structured.communities.items` | Array | `[]` | List of community strings to permit/deny. | +| `structured.communities.names` | Object | `{}` | Map community values to human-readable names. | + +### RPKI Validation + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `structured.rpki.mode` | String | `router` | RPKI validation source: `router` or `external`. | +| `structured.rpki.backend` | String | `cloudflare` | External RPKI validation backend to use. | +| `structured.rpki.rpki_server_url` | String | `` | Custom RPKI validation server URL. | + +#### Example: Structured Output Configuration +```yaml filename="config.yaml" +structured: + communities: + mode: "name" # Show human-readable names + names: + "65000:100": "Customer Routes" + "65000:200": "Peer Routes" + "65000:300": "Upstream Routes" + "65000:999": "Blackhole" + + rpki: + mode: "external" # Use external RPKI validation + backend: "cloudflare" # Use Cloudflare's RPKI API +``` + +--- + +## Web UI Configuration + +Extensive customization options for the web interface appearance and behavior. + +### Basic Web Settings + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.location_display_mode` | String | `auto` | Location selector style: `auto`, `dropdown`, or `gallery`. | +| `web.custom_javascript` | Path | | Path to custom JavaScript file to load. | +| `web.custom_html` | Path | | Path to custom HTML content to display. | + +### DNS over HTTPS + +Controls hostname resolution in the browser using DNS-over-HTTPS. + +<Callout type="info"> +When users enter hostnames (like `example.com`) instead of IP addresses, hyperglass uses DNS-over-HTTPS to resolve them to IP addresses directly in the user's browser. This provides privacy and security benefits over traditional DNS queries. +</Callout> + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.dns_provider.name` | String | `cloudflare` | DNS provider: `cloudflare`, `google`, or `custom`. | +| `web.dns_provider.url` | String | Auto-detected | Custom DNS-over-HTTPS endpoint URL. | + +#### Example: DNS Configuration +```yaml filename="config.yaml" +web: + dns_provider: + name: "cloudflare" # Privacy-focused option + +# Or use a custom DNS resolver: +web: + dns_provider: + name: "custom" + url: "https://dns.example.com/dns-query" +``` + +### Logo Configuration + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.logo.light` | Path | Built-in logo | Logo image for light theme. | +| `web.logo.dark` | Path | Built-in logo | Logo image for dark theme. | +| `web.logo.favicon` | Path | Built-in icon | Favicon/icon image. | +| `web.logo.width` | String | `50%` | Logo width (percentage or pixels). | +| `web.logo.height` | String | | Logo height (percentage or pixels). | + +#### Example: Custom Logo +```yaml filename="config.yaml" +web: + logo: + light: "/etc/hyperglass/images/company-logo-light.svg" + dark: "/etc/hyperglass/images/company-logo-dark.svg" + favicon: "/etc/hyperglass/images/company-icon.svg" + width: "200px" + height: "60px" +``` + +### Greeting Modal + +Show a welcome message or terms of service when users first visit. + +<Callout type="info"> +The greeting modal is perfect for displaying terms of service, usage policies, or welcome messages. When `required` is set to `true`, users cannot use the looking glass until they acknowledge the greeting. +</Callout> + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.greeting.enable` | Boolean | `false` | Enable the greeting modal. | +| `web.greeting.file` | Path | | Path to markdown/text file with greeting content. | +| `web.greeting.title` | String | `Welcome` | Greeting modal title. | +| `web.greeting.button` | String | `Continue` | Greeting acknowledgment button text. | +| `web.greeting.required` | Boolean | `false` | Whether users must acknowledge before using the looking glass. | + +#### Example: Terms of Service Greeting +```yaml filename="config.yaml" +web: + greeting: + enable: true + title: "Terms of Service" + file: "/etc/hyperglass/terms-of-service.md" + button: "I Agree" + required: true # Users must agree to proceed +``` + +### Text Customization + +Customize almost all text displayed in the user interface. + +#### Basic Interface Text + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.text.title` | String | `hyperglass` | Main title text. | +| `web.text.subtitle` | String | `Network Looking Glass` | Subtitle text. | +| `web.text.query_location` | String | `Location` | Label for location/router selection field. | +| `web.text.query_type` | String | `Query Type` | Label for query type selection field. | +| `web.text.query_target` | String | `Target` | Label for target input field. | + +#### Title Display Mode + +| Parameter | Value | Description | +| :-------- | :---- | :---------- | +| `web.text.title_mode` | `logo_only` | Show only logo, hide title and subtitle. | +| | `text_only` | Show only title and subtitle, hide logo. | +| | `logo_subtitle` | Show logo and subtitle, hide title. | +| | `all` | Show logo, title, and subtitle. | + +#### Hostname Resolution Text + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.text.fqdn_tooltip` | String | `Use {protocol}` | Tooltip for hostname resolution buttons. `{protocol}` shows IPv4/IPv6. | +| `web.text.fqdn_message` | String | `Your browser has resolved {fqdn} to` | Message shown during hostname resolution. | +| `web.text.fqdn_error` | String | `Unable to resolve {fqdn}` | Error message for failed hostname resolution. | +| `web.text.fqdn_error_button` | String | `Try Again` | Button text for retrying hostname resolution. | + +#### "My IP" Feature Text + +<Callout type="info"> +The "My IP" button automatically detects the user's public IP address and populates it as the query target. This is useful for users who want to test routing or connectivity to their own location. +</Callout> + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.text.ip_button` | String | `My IP` | Text for the IP detection button. | +| `web.text.ip_select` | String | `Select an IP Address` | Prompt for IP address selection. | +| `web.text.ip_error` | String | `Unable to determine IP Address` | Error when IP detection fails. | +| `web.text.no_ip` | String | `No {protocol} Address` | Message when specific IP version unavailable. | + +#### Results and Status Text + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.text.cache_prefix` | String | `Results cached for ` | Prefix for cache countdown timer. | +| `web.text.cache_icon` | String | `Cached from {time} UTC` | Tooltip for cached result indicator. | +| `web.text.complete_time` | String | `Completed in {seconds}` | Tooltip showing query completion time. | + +#### BGP/RPKI Status Text + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.text.rpki_valid` | String | `Valid` | Text for RPKI valid routes. | +| `web.text.rpki_invalid` | String | `Invalid` | Text for RPKI invalid routes. | +| `web.text.rpki_unknown` | String | `No ROAs Exist` | Text for RPKI unknown routes. | +| `web.text.rpki_unverified` | String | `Not Verified` | Text for RPKI unverified routes. | +| `web.text.no_communities` | String | `No Communities` | Text when route has no BGP communities. | + +#### Example: Complete Text Customization +```yaml filename="config.yaml" +web: + text: + title_mode: "all" + title: "Network Operations Center" + subtitle: "AS65000 Looking Glass" + + # Customize form labels + query_location: "Router Location" + query_type: "Diagnostic Type" + query_target: "Destination" + + # Customize "My IP" feature + ip_button: "Detect My IP" + ip_select: "Choose your IP address" + + # Customize hostname resolution + fqdn_message: "We found these IP addresses for {fqdn}" + fqdn_error: "Could not resolve hostname {fqdn}" + + # Customize status indicators + cache_prefix: "Cached result expires in " + rpki_valid: "✓ Valid ROA" + rpki_invalid: "✗ Invalid ROA" +``` + +### Footer Links + +Add custom links to the footer for documentation, policies, or external resources. + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.links[].title` | String | | Link text displayed to users. | +| `web.links[].url` | URL | | Destination URL. Can use `{primary_asn}` placeholder. | +| `web.links[].show_icon` | Boolean | `true` | Show external link icon. | +| `web.links[].side` | String | `left` | Footer side: `left` or `right`. | +| `web.links[].order` | Integer | `0` | Display order (lower numbers first). | + +#### Example: Footer Links +```yaml filename="config.yaml" +web: + links: + # Default PeeringDB link (uses primary_asn automatically) + - title: "PeeringDB" + url: "https://www.peeringdb.com/asn/{primary_asn}" + side: "right" + order: 1 + + # Company website + - title: "Our Website" + url: "https://acmenetworks.com" + show_icon: false + side: "left" + order: 0 + + # Network status page + - title: "Network Status" + url: "https://status.acmenetworks.com" + side: "right" + order: 2 +``` + +### Footer Menus + +Add dropdown menus in the footer for longer content like terms of service or help documentation. + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.menus[].title` | String | | Menu title shown to users. | +| `web.menus[].content` | String | | Menu content (markdown supported) or path to file. | +| `web.menus[].side` | String | `left` | Footer side: `left` or `right`. | +| `web.menus[].order` | Integer | `0` | Display order (lower numbers first). | + +<Callout type="info"> +For the `content` field, you can either provide the content directly in YAML (for short text) or specify a path to a markdown file (for longer content). Hyperglass automatically detects file paths and loads the content. +</Callout> + +#### Example: Footer Menus +```yaml filename="config.yaml" +web: + menus: + # Load terms from external file + - title: "Terms of Service" + content: "/etc/hyperglass/legal/terms.md" + side: "left" + order: 0 + + # Inline help content + - title: "Help" + content: | + ## How to use this looking glass + + 1. Select a router location + 2. Choose your query type (ping, traceroute, etc.) + 3. Enter an IP address or hostname + 4. Click Submit + + For technical support, email network@example.com + side: "right" + order: 0 +``` + +### Theme Customization + +Customize colors, fonts, and appearance of the web interface. + +#### Colors + +You can customize both functional and palette colors: + +**Palette Colors:** +```yaml filename="config.yaml" +web: + theme: + colors: + # Basic colors + black: "#000000" + white: "#ffffff" + dark: "#010101" + light: "#f5f6f7" + gray: "#c1c7cc" + filtered: "#c1c7cc" + + # Brand colors + red: "#d84b4b" + orange: "#ff6b35" + yellow: "#edae49" + green: "#35b246" + blue: "#314cb6" + teal: "#35b299" + cyan: "#118ab2" + pink: "#f2607d" + purple: "#8d30b5" +``` + +**Functional Colors:** +```yaml filename="config.yaml" +web: + theme: + colors: + # Main brand colors + primary: "#118ab2" # Main interactive elements + secondary: "#35b299" # Secondary elements + + # Status colors + success: "#35b246" # Success states + warning: "#edae49" # Warning states + error: "#d84b4b" # Error states + danger: "#d84b4b" # Danger states +``` + +#### Fonts + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.theme.fonts.body` | String | `Nunito` | Font family for body text. | +| `web.theme.fonts.mono` | String | `Fira Code` | Font family for monospace text (IPs, commands, etc.). | + +#### Default Color Mode + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.theme.default_color_mode` | String | | Default theme: `light` or `dark`. If not set, uses system preference. | + +#### Example: Complete Theme Customization +```yaml filename="config.yaml" +web: + theme: + default_color_mode: "dark" + + colors: + # Corporate brand colors + primary: "#1e3a8a" # Corporate blue + secondary: "#059669" # Corporate green + + # Custom palette + blue: "#1e40af" + green: "#047857" + red: "#dc2626" + + fonts: + body: "Inter" # Modern sans-serif + mono: "JetBrains Mono" # Developer-friendly monospace +``` + +### Output Highlighting + +Highlight specific patterns in query results for better visibility. + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.highlight[].pattern` | String | | Regular expression pattern to match. | +| `web.highlight[].label` | String | | Optional label for the highlight rule. | +| `web.highlight[].color` | String | `primary` | Theme color to use for highlighting. | + +#### Example: Output Highlighting +```yaml filename="config.yaml" +web: + highlight: + # Highlight your own ASN in BGP paths + - pattern: "65000" + label: "Our ASN" + color: "primary" + + # Highlight private IP ranges + - pattern: "192\.168\.\d+\.\d+" + label: "RFC 1918" + color: "warning" + + # Highlight packet loss + - pattern: "\d+% loss" + label: "Packet Loss" + color: "error" +``` + +### Developer Credit + +| Parameter | Type | Default | Description | +| :-------- | :--- | :------ | :---------- | +| `web.credit.enable` | Boolean | `true` | Show hyperglass developer credit in footer. | + +<Callout emoji="❤️" type="info"> +**Note from the Developer** + +If your organization's policy allows, please consider keeping the developer credit enabled. The goal of this project is to get more networks using looking glasses to make all our lives easier. Since it's primarily other network operators who will use your looking glass, it helps them know where they can get their own. +</Callout> + +--- + +## Complete Example Configuration + +Here's a comprehensive example showing how all these options work together: + +```yaml filename="config.yaml" +# Basic site information +site_title: "ACME Networks Looking Glass" +org_name: "ACME Networks Inc." +site_description: "Network diagnostic tools for ACME Networks AS65000" +primary_asn: 65000 + +# System settings +request_timeout: 120 +fake_output: false +cors_origins: + - "https://network-tools.acmenetworks.com" + +# Performance settings +cache: + timeout: 300 + show_text: true + +# API documentation +docs: + enable: true + base_url: "https://lg.acmenetworks.com" + title: "ACME Networks Looking Glass API" + description: "RESTful API for network diagnostic queries" + +# Logging and notifications +logging: + directory: "/var/log/hyperglass" + format: "json" + max_size: "100MB" + + # Send notifications to Slack + http: + enable: true + provider: "slack" + host: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + +# Custom error messages +messages: + no_input: "Please specify a {field} for your query." + target_not_allowed: "Sorry, {target} is not permitted by our policy." + request_timeout: "Your request took too long and was cancelled." + +# Structured data processing +structured: + communities: + mode: "name" + names: + "65000:100": "Customer Routes" + "65000:200": "Peer Routes" + "65000:999": "Blackhole" + rpki: + mode: "external" + backend: "cloudflare" + +# Web interface customization +web: + # DNS resolution + dns_provider: + name: "cloudflare" + + # Custom branding + logo: + light: "/etc/hyperglass/images/company-logo-light.svg" + dark: "/etc/hyperglass/images/company-logo-dark.svg" + width: "200px" + + # Welcome message + greeting: + enable: true + title: "Terms of Service" + file: "/etc/hyperglass/legal/terms.md" + required: true + + # Interface text + text: + title_mode: "all" + title: "Network Operations Center" + subtitle: "AS65000 Looking Glass" + query_location: "Router Location" + ip_button: "Detect My IP" + + # Footer links + links: + - title: "PeeringDB" + url: "https://www.peeringdb.com/asn/{primary_asn}" + side: "right" + - title: "Network Status" + url: "https://status.acmenetworks.com" + side: "right" + + # Footer menus + menus: + - title: "Help" + content: "/etc/hyperglass/help/user-guide.md" + side: "left" + + # Theme customization + theme: + default_color_mode: "dark" + colors: + primary: "#1e3a8a" + secondary: "#059669" + fonts: + body: "Inter" + mono: "JetBrains Mono" + + # Highlight patterns + highlight: + - pattern: "65000" + label: "Our ASN" + color: "primary" + - pattern: "\d+% loss" + label: "Packet Loss" + color: "error" + + # Developer credit + credit: + enable: true +``` + +--- + +## Next Steps + +<DocsButton href="/configuration/devices"> +**Configure Devices** - Set up routers and network devices +</DocsButton> + +<DocsButton href="/configuration/directives"> +**Configure Directives** - Define available query types +</DocsButton> + +<DocsButton href="/configuration/examples"> +**See Examples** - View real-world configuration examples +</DocsButton> diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx index df936253f..c49a513b9 100644 --- a/docs/pages/configuration/config/structured-output.mdx +++ b/docs/pages/configuration/config/structured-output.mdx @@ -1,22 +1,41 @@ +import { Callout } from "nextra/components"; + ## Structured Devices that support responding to a query with structured or easily parsable data can have their response data placed into an easier to read table (or JSON, when using the REST API). Currently, the following platforms have structured data supported in hyperglass: - Arista EOS +- FRRouting +- Huawei VRP - Juniper Junos +- Mikrotik RouterOS/SwitchOS When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods: -1. From the router's perspective -2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) +1. **From the router's perspective** - Uses the RPKI validation state as determined by the router itself +2. **From an external RPKI validation service** - Queries an external service to determine RPKI state independently + +For external validation, hyperglass supports two backends: +- **Cloudflare**: Uses [Cloudflare's public RPKI service](https://rpki.cloudflare.com/) via GraphQL API +- **Routinator**: Connects to your own [Routinator](https://github.com/NLnetLabs/routinator) RPKI validator instance Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user. -| Parameter | Type | Default Value | Description | -| :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- | -| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use Cloudflare's view (2 above). | -| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. | -| `structured.communities.items` | List of Strings | | List of communities to match. | +For devices with structured traceroute support (FRRouting, Huawei VRP, and MikroTik RouterOS), hyperglass can enhance the output with IP enrichment data including ASN information, organization names, country codes, and IXP detection using real-time lookups from BGP.tools. + +| Parameter | Type | Default Value | Description | +| :-------------------------------- | :-------------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------- | +| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state, or `external` to use an external validation service. | +| `structured.rpki.backend` | String | cloudflare | When using `external` mode, choose `cloudflare` or `routinator` as the validation backend. | +| `structured.rpki.rpki_server_url` | String | | When using `routinator` backend, specify the base URL of your Routinator server (e.g., `http://rpki.example.com:3323`). | +| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed, `permit` to _only_ permit communities listed, or `name` to append friendly names. | +| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny` and `permit` modes). | +| `structured.communities.names` | Dict | | Dictionary mapping BGP community codes to friendly names (used by `name` mode). | +| `structured.ip_enrichment.cache_timeout` | Integer | 604800 | **DEPRECATED:** No longer used with real-time BGP.tools lookups. Kept for backward compatibility only. | +| `structured.ip_enrichment.enrich_traceroute`| Boolean | false | When `structured:` is present, enable IP enrichment of traceroute hops (ASN, org, IXP). This must be true for enrichment to run. | +| `structured.ip_enrichment.enrich_bgproute` | Boolean | false | When `structured:` is present, enable early ASN/org enrichment for BGP routes. When true, enrichment happens immediately on query response. When false, enrichment is lazy-loaded when viewing AS Path. | +| `structured.enable_for_traceroute`| Boolean | (when structured present) true | When `structured:` is present this controls whether the structured traceroute table output is shown. Set to false to force raw router output. | +| `structured.enable_for_bgp_route`| Boolean | (when structured present) true | When `structured:` is present this controls whether the structured BGP route table output is shown. Set to false to force raw router output. | ### RPKI Examples @@ -28,14 +47,31 @@ structured: mode: router ``` -#### Show RPKI State from a Public/External Perspective +#### Show RPKI State from Cloudflare's Public Service -```yaml filename="config.yaml" copy {2} +```yaml filename="config.yaml" copy {2-4} +structured: + rpki: + mode: external + backend: cloudflare +``` + +#### Show RPKI State from a Custom Routinator Server + +```yaml filename="config.yaml" copy {2-5} structured: rpki: mode: external + backend: routinator + rpki_server_url: "http://rpki.example.com:8080" ``` +<Callout type="info" emoji="ℹ️"> + **Routinator URL Format** + + The `rpki_server_url` should be the base URL of your Routinator HTTP web API endpoint. This is typically different from the RTR port (3323). The URL should not include the `/validity` path as hyperglass will append this automatically. +</Callout> + ### Community Filtering Examples #### Deny Listed Communities by Regex pattern @@ -59,3 +95,167 @@ structured: - "^65000:.*$" # permit any communities starting with 65000, but no others. - "1234:1" # permit only the 1234:1 community. ``` + +#### Append Friendly Names to Communities + +```yaml filename="config.yaml" {2-10} +structured: + communities: + mode: name + names: + "65000:1000": "Upstream Any" + "65000:1001": "Upstream A (all locations)" + "65000:1101": "Upstream A Location 1" + "65000:1201": "Upstream A Location 2" + "65000:1002": "Upstream B (all locations)" + "65000:1102": "Upstream B Location 1" + "65000:2000": "IXP Any" +``` + +### IP Enrichment Examples + +<Callout type="info" emoji="ℹ️"> + **IP Enrichment Requirements** + + IP enrichment is currently supported for traceroute outputs on supported platforms. + + The system uses real-time lookups from BGP.tools for maximum accuracy and reliability. +</Callout> + +#### Enable IP Enrichment for Traceroute + +```yaml filename="config.yaml" copy {2-4} +structured: + # Ensure `structured:` exists to enable structured output. By default the + # structured table output is enabled when this block is present. To disable + # the structured traceroute table, set `structured.enable_for_traceroute: false`. + ip_enrichment: + enrich_traceroute: true +``` + +#### Enable IP Enrichment + +```yaml filename="config.yaml" copy {2-4} +structured: + ip_enrichment: + enrich_traceroute: true + # cache_timeout is deprecated - real-time lookups are used +``` + +#### Enable IP Enrichment for Traceroute + +```yaml filename="config.yaml" copy {2-3} +structured: + ip_enrichment: + enrich_traceroute: true +``` + +<Callout type="warning" emoji="⚠️"> + **Performance Considerations** + + - Initial cache loading may take 30-60 seconds on first startup + - Data is cached locally using pickle format for ultra-fast subsequent loads + - Cache files are stored in `/etc/hyperglass/ip_enrichment/` + - Minimum cache timeout is 24 hours (86400 seconds) to prevent excessive API usage +</Callout> + +### Structured Traceroute Configuration + +<Callout type="info" emoji="ℹ️"> + **Structured Traceroute Support** + + Structured traceroute with rich metadata is available for: + - **FRRouting**: Parses Unix-style traceroute output with load balancing and multi-path support + - **Huawei VRP**: Parses Unix-style traceroute output + - **MikroTik RouterOS/SwitchOS**: Parses multi-table format with statistics + + When IP enrichment is enabled, traceroute hops are enhanced with ASN numbers, organization names, country codes, prefixes, and IXP detection. +</Callout> + +#### Complete Structured Traceroute Setup + +```yaml filename="config.yaml" copy {2-12} +structured: + rpki: + mode: external + backend: routinator + rpki_server_url: "https://rpki.example.com" + communities: + mode: name + names: + "65000:1000": "Transit Routes" + "65000:2000": "Peer Routes" + ip_enrichment: + enrich_traceroute: true + # cache_timeout is deprecated - real-time lookups are used +``` + +#### Structured Traceroute with Cloudflare RPKI + +```yaml filename="config.yaml" copy {2-9} +structured: + rpki: + mode: external + backend: cloudflare + ip_enrichment: + enrich_traceroute: true +``` + +#### Minimal Structured Traceroute (No IP Enrichment) + +```yaml filename="config.yaml" copy {2-4} +structured: + ip_enrichment: + enrich_traceroute: false # Traceroute will show basic hop info without ASN/org data + enrich_bgproute: true # BGP routes will include ASN/org enrichment on query response +``` + +<Callout type="warning" emoji="⚠️"> + **IP Enrichment Dependency** + + Without IP enrichment enabled: + - Traceroute hops will only show IP addresses and RTT values + - BGP next-hop IPs will not show ASN/organization tooltips + - No ASN organization names in copied BGP route text + - AS path visualization will be limited or unavailable + - IXP detection will not function + + For the full structured traceroute and BGP route experience with rich metadata, enable the appropriate enrichment settings. +</Callout> + +### BGP Route Enrichment + +BGP route enrichment provides two modes for ASN and organization lookups: + +#### Early Enrichment (On-Query) +When `enrich_bgproute: true`, enrichment happens immediately during query execution: +- ✅ ASN names appear instantly in AS path entries +- ✅ Next-hop IP tooltips show organization information +- ✅ Copied text includes enriched data: `37468 (Organization) → 12956 (Organization)` +- ⚠️ Adds a small delay to query execution for bulk lookups + +```yaml filename="config.yaml" copy {4} +structured: + ip_enrichment: + enrich_bgproute: true +``` + +#### Lazy Enrichment (On-Demand) +When `enrich_bgproute: false` (default), enrichment happens only when needed: +- ✅ Faster initial query response +- ✅ Organization names load when clicking "View AS Path" button or hovering +- ✅ Reduces unnecessary lookups for partial queries +- ⚠️ Organization names not included in copied text by default + +```yaml filename="config.yaml" copy {4} +structured: + ip_enrichment: + enrich_bgproute: false +``` + +#### Next-Hop Enrichment + +Regardless of enrichment mode, next-hop IPs for BGP routes are always enriched with: +- Peer ASN (if available) +- Peer organization name (if available) +- Hovering over a next-hop IP shows: `192.0.2.1 (ASN Organization)` diff --git a/docs/pages/configuration/config/web-ui.mdx b/docs/pages/configuration/config/web-ui.mdx index 761344769..636f0d6a1 100644 --- a/docs/pages/configuration/config/web-ui.mdx +++ b/docs/pages/configuration/config/web-ui.mdx @@ -243,6 +243,7 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. | `web.theme.colors.dark` | <Color hex="#010101"/> | | `web.theme.colors.light` | <Color hex="#f5f6f7"/> | | `web.theme.colors.gray` | <Color hex="#c1c7cc"/> | +| `web.theme.colors.filtered` | <Color hex="#c1c7cc"/> | | `web.theme.colors.red` | <Color hex="#d84b4b"/> | | `web.theme.colors.orange` | <Color hex="#ff6b35"/> | | `web.theme.colors.yellow` | <Color hex="#edae49"/> | diff --git a/docs/pages/configuration/examples/_meta.tsx b/docs/pages/configuration/examples/_meta.tsx index d4151247d..e36b95dde 100644 --- a/docs/pages/configuration/examples/_meta.tsx +++ b/docs/pages/configuration/examples/_meta.tsx @@ -1,4 +1,8 @@ export default { + "quick-start": "Quick Start Examples", + "---": { + type: "separator", + }, "basic-configuration": "Basic Configuration", "add-your-own-command": "Add Your Own Command", "customize-the-ui": "Customize the UI", diff --git a/docs/pages/configuration/examples/quick-start.mdx b/docs/pages/configuration/examples/quick-start.mdx new file mode 100644 index 000000000..8506e7e9b --- /dev/null +++ b/docs/pages/configuration/examples/quick-start.mdx @@ -0,0 +1,453 @@ +--- +title: Quick Start Configuration Examples +description: Common configuration patterns and examples to get started quickly +--- + +import { Callout } from 'nextra/components' + +# Quick Start Configuration Examples + +Here are the most common configuration patterns to get you started with hyperglass quickly. For complete documentation, see the [Complete Configuration Reference](/configuration/config/complete-config). + +## Minimal Configuration + +The absolute minimum configuration needed: + +```yaml filename="config.yaml" +site_title: "My Network Looking Glass" +org_name: "My Company" +primary_asn: 65000 +``` + +## Basic Corporate Setup + +A typical corporate setup with branding and terms of service: + +```yaml filename="config.yaml" +# Basic info +site_title: "ACME Networks Looking Glass" +org_name: "ACME Networks Inc." +primary_asn: 65000 +site_description: "Network diagnostic tools for ACME Networks" + +# Performance +request_timeout: 120 +cache: + timeout: 300 # 5 minutes + +# Web interface +web: + # Custom branding + logo: + light: "/etc/hyperglass/images/company-logo-light.svg" + dark: "/etc/hyperglass/images/company-logo-dark.svg" + + # Welcome message with terms + greeting: + enable: true + title: "Terms of Service" + file: "/etc/hyperglass/terms.md" + required: true + button: "I Agree" + + # Interface customization + text: + title: "Network Operations Center" + subtitle: "AS65000 Looking Glass" + + # Footer links + links: + - title: "Company Website" + url: "https://acmenetworks.com" + show_icon: false + side: "left" + - title: "Network Status" + url: "https://status.acmenetworks.com" + side: "right" +``` + +## Privacy-Focused Setup + +Emphasizing user privacy and security: + +```yaml filename="config.yaml" +site_title: "Privacy-First Looking Glass" +org_name: "SecureNet" +primary_asn: 65001 + +web: + # Use privacy-focused DNS + dns_provider: + name: "cloudflare" # More privacy-focused than Google + + # Informative text about privacy + text: + ip_button: "Detect My IP (Private)" + fqdn_message: "Securely resolved {fqdn} to" + + # Privacy-focused greeting + greeting: + enable: true + title: "Your Privacy Matters" + content: | + ## Privacy Notice + + - DNS queries use encrypted DNS-over-HTTPS + - Your IP detection happens in your browser + - We don't log sensitive query details + - All connections use HTTPS encryption + required: false + button: "I Understand" + +# Minimal logging +logging: + format: "text" + # No HTTP webhooks for privacy +``` + +## High-Performance Setup + +Optimized for performance with extended caching: + +```yaml filename="config.yaml" +site_title: "High-Performance Looking Glass" +org_name: "FastNet ISP" +primary_asn: 65002 + +# Performance optimizations +request_timeout: 60 # Faster timeouts +cache: + timeout: 600 # Cache for 10 minutes + show_text: true # Show cache status + +web: + # Optimized text + text: + cache_prefix: "Cached result expires in " + complete_time: "⚡ Completed in {seconds}" + + # Performance theme + theme: + colors: + primary: "#059669" # Fast green + secondary: "#0ea5e9" # Speed blue + default_color_mode: "dark" # Easier on eyes + +# Structured output for faster parsing +structured: + rpki: + mode: "external" + backend: "cloudflare" # Fast RPKI validation +``` + +## ISP/Service Provider Setup + +Configuration for internet service providers: + +```yaml filename="config.yaml" +site_title: "GlobalNet Looking Glass" +org_name: "GlobalNet Internet Services" +primary_asn: 64500 +site_description: "Network diagnostics for GlobalNet AS64500 customers and peers" + +web: + # Professional branding + logo: + light: "/etc/hyperglass/branding/globalnet-light.svg" + dark: "/etc/hyperglass/branding/globalnet-dark.svg" + width: "250px" + + # Comprehensive footer + links: + - title: "PeeringDB" + url: "https://www.peeringdb.com/asn/{primary_asn}" + side: "right" + order: 1 + - title: "Peering Policy" + url: "https://globalnet.com/peering" + side: "right" + order: 2 + - title: "Customer Portal" + url: "https://portal.globalnet.com" + side: "left" + order: 1 + - title: "Network Status" + url: "https://status.globalnet.com" + side: "left" + order: 2 + + menus: + - title: "Peering Information" + content: | + ## Peering with GlobalNet + + - **ASN**: AS64500 + - **IPv4 Prefix**: 203.0.113.0/24 + - **IPv6 Prefix**: 2001:db8::/32 + - **NOC**: noc@globalnet.com + - **Peering**: peering@globalnet.com + + We maintain an open peering policy and welcome new peers. + side: "right" + + # ISP-focused text + text: + title: "Network Operations Center" + subtitle: "Peering & Transit Services" + query_location: "Point of Presence" + +# BGP-focused structured output +structured: + communities: + mode: "name" + names: + "64500:100": "Customer Routes" + "64500:200": "Peer Routes" + "64500:300": "Transit Routes" + "64500:400": "Internal Routes" + "64500:666": "Blackhole" + "64500:777": "No Export" + rpki: + mode: "external" + backend: "cloudflare" + +# Comprehensive API docs for automation +docs: + enable: true + title: "GlobalNet Looking Glass API" + description: "Programmatic access to network diagnostic tools" + base_url: "https://lg.globalnet.com" +``` + +## Enterprise Internal Setup + +For internal corporate networks: + +```yaml filename="config.yaml" +site_title: "ACME Internal Network Tools" +org_name: "ACME Corporation" +primary_asn: 65000 + +web: + # Internal-focused interface + text: + title_mode: "all" + title: "IT Network Diagnostics" + subtitle: "Internal Use Only" + query_location: "Network Segment" + ip_button: "My Internal IP" + + # Corporate theme + theme: + colors: + primary: "#1e40af" # Corporate blue + secondary: "#059669" # Corporate green + fonts: + body: "Inter" + mono: "JetBrains Mono" + + # Internal policies + greeting: + enable: true + title: "IT Policy Reminder" + content: | + ## Network Diagnostic Tools + + These tools are for authorized IT personnel only. + + - Use responsibly and only for legitimate troubleshooting + - Do not test external targets without approval + - Report any suspicious network activity + - Contact IT Security for questions: security@acme.com + required: true + button: "I Acknowledge" + + links: + - title: "IT Helpdesk" + url: "https://helpdesk.acme.local" + side: "left" + - title: "Network Documentation" + url: "https://wiki.acme.local/network" + side: "left" + +# Detailed logging for internal audit +logging: + directory: "/var/log/hyperglass" + format: "json" + max_size: "200MB" + + # Log to internal SIEM + http: + enable: true + provider: "generic" + host: "https://siem.acme.local/api/logs" + authentication: + mode: "api_key" + password: "your-siem-api-key" + header: "X-API-Key" +``` + +## Development/Testing Setup + +For development and testing environments: + +```yaml filename="config.yaml" +site_title: "Development Looking Glass" +org_name: "DevNet Testing" +primary_asn: 65999 + +# Development settings +fake_output: true # Use fake data for testing +request_timeout: 30 # Shorter timeouts for dev +cors_origins: # Allow local development + - "http://localhost:3000" + - "http://127.0.0.1:3000" + +cache: + timeout: 30 # Short cache for testing + show_text: true + +web: + # Development theme + theme: + default_color_mode: "dark" + colors: + primary: "#f59e0b" # Warning amber for dev + secondary: "#8b5cf6" # Purple for distinction + + # Development notices + text: + title: "🔧 Development Environment" + subtitle: "Testing & Development Only" + + greeting: + enable: true + title: "Development Environment" + content: | + ## ⚠️ Development Environment + + This is a development/testing instance of hyperglass. + + - Data may be fake or simulated + - Configuration changes frequently + - Not suitable for production use + - Contact developers: dev@example.com + required: false + button: "Continue to Testing" + +# Extensive logging for debugging +logging: + format: "json" + directory: "/tmp/hyperglass-dev" + +# Enable all structured features for testing +structured: + communities: + mode: "name" + names: + "65999:1": "Test Community 1" + "65999:2": "Test Community 2" + rpki: + mode: "external" + backend: "cloudflare" + +# Full API documentation for testing +docs: + enable: true + title: "Development API Docs" + description: "API documentation for testing and development" +``` + +## Monitoring Integration + +With comprehensive monitoring and alerting: + +```yaml filename="config.yaml" +site_title: "MonitorNet Looking Glass" +org_name: "MonitorNet Services" +primary_asn: 65100 + +# Monitoring-optimized settings +cache: + timeout: 180 # 3 minutes for monitoring + show_text: true + +logging: + directory: "/var/log/hyperglass" + format: "json" # Structured logs for monitoring + max_size: "500MB" + + # Multiple monitoring integrations + http: + enable: true + provider: "slack" + host: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + timeout: 5.0 + + # Custom headers for monitoring context + headers: + "X-Environment": "production" + "X-Service": "hyperglass" + +web: + # Monitoring-friendly highlighting + highlight: + - pattern: "100% loss" + label: "Total Packet Loss" + color: "error" + - pattern: "[5-9][0-9]% loss" + label: "High Packet Loss" + color: "warning" + - pattern: "timeout" + label: "Timeout" + color: "error" + - pattern: "65100" + label: "Our Network" + color: "primary" + + text: + complete_time: "⏱ Query time: {seconds}" + cache_prefix: "📊 Cached for " + rpki_invalid: "❌ RPKI Invalid" + rpki_valid: "✅ RPKI Valid" + +# Detailed structured output for monitoring +structured: + communities: + mode: "name" + names: + "65100:911": "🚨 Emergency Routes" + "65100:999": "🚫 Blackhole" + rpki: + mode: "external" + backend: "cloudflare" +``` + +--- + +<Callout type="info"> +**Next Steps**: Once you have a basic configuration working, see the [Complete Configuration Reference](/configuration/config/complete-config) for all available options and detailed examples. +</Callout> + +## Configuration File Location + +Place your `config.yaml` file in the hyperglass configuration directory: + +- **Docker**: Mount to `/etc/hyperglass/config.yaml` +- **System Install**: `/etc/hyperglass/config.yaml` +- **Development**: `./config.yaml` in your project directory + +## Validation + +After creating your configuration, validate it with: + +```bash +# Check configuration syntax +hyperglass --validate-config + +# Test with fake output +hyperglass --fake-output + +# Full startup test +hyperglass --debug +``` \ No newline at end of file diff --git a/docs/pages/installation/manual.mdx b/docs/pages/installation/manual.mdx index 4c3cefc81..186fecb32 100644 --- a/docs/pages/installation/manual.mdx +++ b/docs/pages/installation/manual.mdx @@ -26,7 +26,7 @@ Once these dependencies are installed, install hyperglass via PyPI: git clone https://github.com/thatmattlove/hyperglass --depth=1 cd hyperglass # optional - switch to the latest stable release -# git switch -c v2.0.4 v2.0.4 +# git switch -c v2.1.0 v2.1.0 pip3 install -e . ``` diff --git a/docs/pages/installation/upgrading.mdx b/docs/pages/installation/upgrading.mdx index f70da5edc..c77828f82 100644 --- a/docs/pages/installation/upgrading.mdx +++ b/docs/pages/installation/upgrading.mdx @@ -5,7 +5,7 @@ cd /opt/hyperglass docker compose down docker compose rm -f git fetch -git checkout v2.0.4 +git checkout v2.1.0 docker compose build docker compose up ``` diff --git a/docs/pages/user-guide.mdx b/docs/pages/user-guide.mdx new file mode 100644 index 000000000..f753432ac --- /dev/null +++ b/docs/pages/user-guide.mdx @@ -0,0 +1,362 @@ +--- +title: User Guide +description: How to use the hyperglass web interface +--- + +import { Callout } from 'nextra/components' + +# User Guide + +This guide explains how to use the hyperglass web interface from an end-user perspective. If you're looking to configure hyperglass as an administrator, see the [Configuration Guide](/configuration). + +## Getting Started + +The hyperglass web interface provides an easy-to-use form for performing network diagnostics. Here's how to use it: + +### 1. Select a Location + +Choose which router/location you want to query from using the location selector. This determines which network device will execute your diagnostic command. + +- **Dropdown Mode**: Select from a list of available locations +- **Gallery Mode**: Choose from a visual grid of location cards (used when there are 5 or fewer locations) + +### 2. Choose Query Type + +Select the type of network diagnostic you want to perform: + +- **Ping**: Test basic connectivity and measure round-trip time +- **Traceroute**: Show the network path to a destination +- **BGP Route**: Look up BGP routing information for an IP prefix +- **BGP Community**: Find routes with specific BGP communities +- **BGP AS Path**: Find routes traversing specific ASNs + +### 3. Enter Your Target + +Specify what you want to query. This can be: + +- **IP Address**: `8.8.8.8` or `2001:4860:4860::8888` +- **Hostname**: `google.com` or `www.example.com` +- **IP Prefix**: `8.8.8.0/24` (for BGP queries) +- **AS Number**: `AS15169` (for AS path queries) + +## Using Hostnames (FQDN Resolution) + +<Callout type="info"> +When you enter a hostname like `google.com` instead of an IP address, hyperglass automatically resolves it to IP addresses using secure DNS-over-HTTPS. This happens directly in your browser for privacy and security. +</Callout> + +### How Hostname Resolution Works + +1. **Enter a hostname**: Type something like `google.com` in the target field +2. **Detection**: hyperglass detects this is a hostname (not an IP address) +3. **DNS Resolution**: A modal opens showing "Resolving hostname..." +4. **Choose IP**: Select from available IPv4 and/or IPv6 addresses +5. **Submit Query**: The selected IP address is used for your diagnostic query + +### Hostname Resolution Features + +- **IPv4 and IPv6**: Get both A and AAAA records when available +- **Privacy-Focused**: Uses DNS-over-HTTPS (encrypted) instead of plain DNS +- **Multiple Providers**: Supports Cloudflare DNS, Google DNS, or custom resolvers +- **Browser-Based**: Resolution happens in your browser, not on the server + +### Example: Resolving a Hostname + +``` +1. Enter "google.com" as your target +2. Click Submit +3. See resolution modal: + ┌─────────────────────────────────────┐ + │ Your browser has resolved google.com to │ + │ │ + │ [→] 142.250.191.14 (IPv4) │ + │ [→] 2607:f8b0:4004:c1b::71 (IPv6) │ + │ │ + └─────────────────────────────────────┘ +4. Click your preferred IP address +5. Query executes using the selected IP +``` + +## Using "My IP" Feature + +<Callout type="info"> +The "My IP" button automatically detects your public IP address and uses it as the query target. This is useful for testing connectivity or routing to your current location. +</Callout> + +### How "My IP" Works + +1. **Click the "My IP" button** (located next to the target input field) +2. **IP Detection**: A modal opens showing "Detecting your IP address..." +3. **Choose Version**: Select your IPv4 or IPv6 address (or both if available) +4. **Auto-Population**: Your selected IP is automatically entered as the target + +### When to Use "My IP" + +- **Connectivity Testing**: Check if the network can reach your location +- **Routing Analysis**: See what path traffic takes to reach you +- **Latency Testing**: Measure round-trip time to your connection +- **Troubleshooting**: Diagnose connectivity issues from your current network + +### Example: Using My IP + +``` +1. Click the "My IP" button +2. See detection modal: + ┌─────────────────────────────────────┐ + │ Select an IP Address │ + │ │ + │ [→] 203.0.113.45 (IPv4) │ + │ [→] 2001:db8::1 (IPv6) │ + │ │ + └─────────────────────────────────────┘ +3. Click your preferred IP version +4. IP is automatically entered as target +5. Submit your query as normal +``` + +## Query Types Explained + +### Ping + +Tests basic connectivity and measures round-trip time. + +**Use Cases:** +- Check if a host is reachable +- Measure network latency +- Basic connectivity troubleshooting + +**Sample Output:** +``` +PING google.com (142.250.191.14): 56 data bytes +64 bytes from 142.250.191.14: icmp_seq=0 ttl=58 time=12.345 ms +64 bytes from 142.250.191.14: icmp_seq=1 ttl=58 time=11.678 ms + +--- google.com ping statistics --- +2 packets transmitted, 2 packets received, 0.0% packet loss +round-trip min/avg/max/stddev = 11.678/12.012/12.345/0.334 ms +``` + +### Traceroute + +Shows the network path packets take to reach a destination. + +**Use Cases:** +- Identify network routing paths +- Find where connectivity breaks +- Analyze routing decisions +- Network troubleshooting + +**Sample Output:** +``` +traceroute to google.com (142.250.191.14), 30 hops max, 60 byte packets + 1 gateway.local (192.168.1.1) 1.234 ms 1.456 ms 1.789 ms + 2 isp-router.net (203.0.113.1) 5.123 ms 5.234 ms 5.345 ms + 3 backbone.net (198.51.100.1) 12.34 ms 12.45 ms 12.56 ms + 4 google-peer.net (142.250.191.14) 15.67 ms 15.78 ms 15.89 ms +``` + +### BGP Route + +Look up BGP routing information for IP prefixes. + +**Use Cases:** +- Check BGP route advertisements +- Verify RPKI validation status +- Analyze AS paths and communities +- Route troubleshooting + +**Sample Output:** +``` +BGP routing table entry for 8.8.8.0/24 +Paths: 1 available + 15169 + 192.0.2.1 from 192.0.2.1 + Origin IGP, metric 0, valid, external, best + Community: 65000:100 65000:200 + RPKI validation state: Valid + Last update: 2 hours ago +``` + +### BGP Community + +Find routes with specific BGP communities. + +**Use Cases:** +- Filter routes by community values +- Analyze traffic engineering policies +- Debug community-based routing + +### BGP AS Path + +Find routes traversing specific Autonomous Systems. + +**Use Cases:** +- Analyze internet routing paths +- Check AS-level connectivity +- Investigate routing policies + +## Understanding Results + +### Status Indicators + +Results include several status indicators: + +- **✓ Success**: Query completed successfully +- **⚠ Warning**: Query completed with warnings +- **✗ Error**: Query failed +- **🕒 Cached**: Result was served from cache +- **⏱ Time**: Shows how long the query took + +### Cache Information + +<Callout type="info"> +To improve performance, hyperglass caches query results. Cached results are marked with a clock icon and show when they expire. Fresh queries are automatically run when cache expires. +</Callout> + +When you see cached results: +- **Cache Timer**: Shows "Results cached for X seconds" +- **Cache Icon**: Hover to see when the original query was run +- **Auto-Refresh**: New query runs automatically when cache expires + +### RPKI Validation + +For BGP queries, routes show RPKI validation status: + +- **Valid**: Route has valid ROA (Route Origin Authorization) +- **Invalid**: Route conflicts with ROA +- **Unknown**: No ROA exists for this route +- **Not Verified**: RPKI validation not performed + +## Common Use Cases + +### Network Troubleshooting + +**Scenario**: Website seems slow from your location + +1. Use **"My IP"** feature to get your IP address +2. Run **ping** to measure basic connectivity and latency +3. Run **traceroute** to see the network path +4. Check **BGP route** to verify optimal routing + +### Connectivity Testing + +**Scenario**: Check if a server is reachable + +1. Enter the server's **hostname or IP address** +2. Select multiple **router locations** to test from different points +3. Use **ping** for basic reachability +4. Use **traceroute** if ping fails to find where connectivity breaks + +### Routing Analysis + +**Scenario**: Analyze how traffic reaches a destination + +1. Enter the **destination IP or prefix** +2. Use **BGP route** to see routing table entries +3. Check **AS path** to understand routing decisions +4. Verify **RPKI validation** for security + +### Multi-Location Testing + +**Scenario**: Test connectivity from multiple network locations + +1. Choose your **target** (IP, hostname, or prefix) +2. Select **multiple locations** using Ctrl+Click (or Cmd+Click on Mac) +3. Submit query to test from **all selected locations simultaneously** +4. Compare results to identify location-specific issues + +## Tips and Best Practices + +### Efficient Querying + +- **Use hostnames** when you don't know IP addresses - hyperglass will resolve them +- **Try "My IP"** for connectivity testing from your location +- **Select multiple locations** to get a broader view of network behavior +- **Check cached results** first - they may already have the information you need + +### Interpreting Results + +- **Compare multiple locations** to identify regional issues +- **Look at RPKI status** for BGP routes to verify security +- **Check AS paths** to understand routing decisions +- **Use traceroute** when ping shows connectivity issues + +### Privacy and Security + +- **DNS resolution** uses encrypted DNS-over-HTTPS for privacy +- **No logging** of sensitive query details in most configurations +- **RPKI validation** helps verify route authenticity +- **Rate limiting** prevents abuse while allowing legitimate use + +## Troubleshooting + +### Common Issues + +**"Unable to resolve hostname"** +- Check spelling of the hostname +- Try a different DNS provider in browser settings +- Use an IP address instead + +**"Request timed out"** +- Target may be unreachable or filtering responses +- Try a different query type +- Check from multiple locations + +**"No output"** +- Query completed but returned no results +- Target may not exist in routing tables +- Try a broader search (e.g., larger prefix) + +**"Target not allowed"** +- Query target is blocked by policy +- Contact administrator if you believe this is an error +- Try a different target + +### Getting Help + +If you encounter issues: + +1. **Check multiple locations** - issue may be location-specific +2. **Try different query types** - some may work when others don't +3. **Contact network administrators** - they can review logs and policies +4. **Report persistent issues** - help improve the service + +--- + +## Advanced Features + +### Keyboard Shortcuts + +- **Enter**: Submit query +- **Ctrl/Cmd + Click**: Select multiple locations +- **Tab**: Navigate between form fields +- **Escape**: Close modals/popups + +### URL Parameters + +You can bookmark or share queries using URL parameters: + +``` +https://lg.example.com/?location=router1&type=ping&target=8.8.8.8 +``` + +Parameters: +- `location`: Router location ID +- `type`: Query type (ping, traceroute, bgp_route, etc.) +- `target`: Query target (IP, hostname, prefix, etc.) + +### API Access + +For programmatic access, use the REST API: + +```bash +curl -X POST https://lg.example.com/api/query \ + -H "Content-Type: application/json" \ + -d '{ + "query_location": "router1", + "query_type": "ping", + "query_target": "8.8.8.8" + }' +``` + +See the [API Documentation](/api/docs) for complete details. \ No newline at end of file From 9189b98c0a013e581c834a9a21414d3d2de4e671 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Sun, 14 Jun 2026 21:09:59 +1000 Subject: [PATCH 14/39] feat(mikrotik): traceroute table parsing + structured-output fixes --- .../config/structured-output.mdx | 218 ++++++- hyperglass/constants.py | 9 +- hyperglass/models/config/devices.py | 8 +- hyperglass/models/parsing/mikrotik.py | 561 ++++++++++++++++++ hyperglass/plugins/_builtin/__init__.py | 16 + .../_builtin/mikrotik_garbage_output.py | 293 +++++++-- .../_builtin/mikrotik_normalize_input.py | 58 ++ .../plugins/_builtin/trace_route_frr.py | 345 +++++++++++ .../plugins/tests/test_bgp_route_mikrotik.py | 78 +++ .../plugins/tests/test_traceroute_mikrotik.py | 153 +++++ 10 files changed, 1670 insertions(+), 69 deletions(-) create mode 100644 hyperglass/models/parsing/mikrotik.py create mode 100644 hyperglass/plugins/_builtin/mikrotik_normalize_input.py create mode 100644 hyperglass/plugins/_builtin/trace_route_frr.py create mode 100644 hyperglass/plugins/tests/test_bgp_route_mikrotik.py create mode 100644 hyperglass/plugins/tests/test_traceroute_mikrotik.py diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx index df936253f..c49a513b9 100644 --- a/docs/pages/configuration/config/structured-output.mdx +++ b/docs/pages/configuration/config/structured-output.mdx @@ -1,22 +1,41 @@ +import { Callout } from "nextra/components"; + ## Structured Devices that support responding to a query with structured or easily parsable data can have their response data placed into an easier to read table (or JSON, when using the REST API). Currently, the following platforms have structured data supported in hyperglass: - Arista EOS +- FRRouting +- Huawei VRP - Juniper Junos +- Mikrotik RouterOS/SwitchOS When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods: -1. From the router's perspective -2. From the perspective of [Cloudflare's RPKI Service](https://rpki.cloudflare.com/) +1. **From the router's perspective** - Uses the RPKI validation state as determined by the router itself +2. **From an external RPKI validation service** - Queries an external service to determine RPKI state independently + +For external validation, hyperglass supports two backends: +- **Cloudflare**: Uses [Cloudflare's public RPKI service](https://rpki.cloudflare.com/) via GraphQL API +- **Routinator**: Connects to your own [Routinator](https://github.com/NLnetLabs/routinator) RPKI validator instance Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user. -| Parameter | Type | Default Value | Description | -| :----------------------------- | :-------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------- | -| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state (1 above), or `external` to use Cloudflare's view (2 above). | -| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed in `structured.communities.items`, or `permit` to _only_ permit communities listed. | -| `structured.communities.items` | List of Strings | | List of communities to match. | +For devices with structured traceroute support (FRRouting, Huawei VRP, and MikroTik RouterOS), hyperglass can enhance the output with IP enrichment data including ASN information, organization names, country codes, and IXP detection using real-time lookups from BGP.tools. + +| Parameter | Type | Default Value | Description | +| :-------------------------------- | :-------------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------- | +| `structured.rpki.mode` | String | router | Use `router` to use the router's view of the RPKI state, or `external` to use an external validation service. | +| `structured.rpki.backend` | String | cloudflare | When using `external` mode, choose `cloudflare` or `routinator` as the validation backend. | +| `structured.rpki.rpki_server_url` | String | | When using `routinator` backend, specify the base URL of your Routinator server (e.g., `http://rpki.example.com:3323`). | +| `structured.communities.mode` | String | deny | Use `deny` to deny any communities listed, `permit` to _only_ permit communities listed, or `name` to append friendly names. | +| `structured.communities.items` | List of Strings | | List of communities to match (used by `deny` and `permit` modes). | +| `structured.communities.names` | Dict | | Dictionary mapping BGP community codes to friendly names (used by `name` mode). | +| `structured.ip_enrichment.cache_timeout` | Integer | 604800 | **DEPRECATED:** No longer used with real-time BGP.tools lookups. Kept for backward compatibility only. | +| `structured.ip_enrichment.enrich_traceroute`| Boolean | false | When `structured:` is present, enable IP enrichment of traceroute hops (ASN, org, IXP). This must be true for enrichment to run. | +| `structured.ip_enrichment.enrich_bgproute` | Boolean | false | When `structured:` is present, enable early ASN/org enrichment for BGP routes. When true, enrichment happens immediately on query response. When false, enrichment is lazy-loaded when viewing AS Path. | +| `structured.enable_for_traceroute`| Boolean | (when structured present) true | When `structured:` is present this controls whether the structured traceroute table output is shown. Set to false to force raw router output. | +| `structured.enable_for_bgp_route`| Boolean | (when structured present) true | When `structured:` is present this controls whether the structured BGP route table output is shown. Set to false to force raw router output. | ### RPKI Examples @@ -28,14 +47,31 @@ structured: mode: router ``` -#### Show RPKI State from a Public/External Perspective +#### Show RPKI State from Cloudflare's Public Service -```yaml filename="config.yaml" copy {2} +```yaml filename="config.yaml" copy {2-4} +structured: + rpki: + mode: external + backend: cloudflare +``` + +#### Show RPKI State from a Custom Routinator Server + +```yaml filename="config.yaml" copy {2-5} structured: rpki: mode: external + backend: routinator + rpki_server_url: "http://rpki.example.com:8080" ``` +<Callout type="info" emoji="ℹ️"> + **Routinator URL Format** + + The `rpki_server_url` should be the base URL of your Routinator HTTP web API endpoint. This is typically different from the RTR port (3323). The URL should not include the `/validity` path as hyperglass will append this automatically. +</Callout> + ### Community Filtering Examples #### Deny Listed Communities by Regex pattern @@ -59,3 +95,167 @@ structured: - "^65000:.*$" # permit any communities starting with 65000, but no others. - "1234:1" # permit only the 1234:1 community. ``` + +#### Append Friendly Names to Communities + +```yaml filename="config.yaml" {2-10} +structured: + communities: + mode: name + names: + "65000:1000": "Upstream Any" + "65000:1001": "Upstream A (all locations)" + "65000:1101": "Upstream A Location 1" + "65000:1201": "Upstream A Location 2" + "65000:1002": "Upstream B (all locations)" + "65000:1102": "Upstream B Location 1" + "65000:2000": "IXP Any" +``` + +### IP Enrichment Examples + +<Callout type="info" emoji="ℹ️"> + **IP Enrichment Requirements** + + IP enrichment is currently supported for traceroute outputs on supported platforms. + + The system uses real-time lookups from BGP.tools for maximum accuracy and reliability. +</Callout> + +#### Enable IP Enrichment for Traceroute + +```yaml filename="config.yaml" copy {2-4} +structured: + # Ensure `structured:` exists to enable structured output. By default the + # structured table output is enabled when this block is present. To disable + # the structured traceroute table, set `structured.enable_for_traceroute: false`. + ip_enrichment: + enrich_traceroute: true +``` + +#### Enable IP Enrichment + +```yaml filename="config.yaml" copy {2-4} +structured: + ip_enrichment: + enrich_traceroute: true + # cache_timeout is deprecated - real-time lookups are used +``` + +#### Enable IP Enrichment for Traceroute + +```yaml filename="config.yaml" copy {2-3} +structured: + ip_enrichment: + enrich_traceroute: true +``` + +<Callout type="warning" emoji="⚠️"> + **Performance Considerations** + + - Initial cache loading may take 30-60 seconds on first startup + - Data is cached locally using pickle format for ultra-fast subsequent loads + - Cache files are stored in `/etc/hyperglass/ip_enrichment/` + - Minimum cache timeout is 24 hours (86400 seconds) to prevent excessive API usage +</Callout> + +### Structured Traceroute Configuration + +<Callout type="info" emoji="ℹ️"> + **Structured Traceroute Support** + + Structured traceroute with rich metadata is available for: + - **FRRouting**: Parses Unix-style traceroute output with load balancing and multi-path support + - **Huawei VRP**: Parses Unix-style traceroute output + - **MikroTik RouterOS/SwitchOS**: Parses multi-table format with statistics + + When IP enrichment is enabled, traceroute hops are enhanced with ASN numbers, organization names, country codes, prefixes, and IXP detection. +</Callout> + +#### Complete Structured Traceroute Setup + +```yaml filename="config.yaml" copy {2-12} +structured: + rpki: + mode: external + backend: routinator + rpki_server_url: "https://rpki.example.com" + communities: + mode: name + names: + "65000:1000": "Transit Routes" + "65000:2000": "Peer Routes" + ip_enrichment: + enrich_traceroute: true + # cache_timeout is deprecated - real-time lookups are used +``` + +#### Structured Traceroute with Cloudflare RPKI + +```yaml filename="config.yaml" copy {2-9} +structured: + rpki: + mode: external + backend: cloudflare + ip_enrichment: + enrich_traceroute: true +``` + +#### Minimal Structured Traceroute (No IP Enrichment) + +```yaml filename="config.yaml" copy {2-4} +structured: + ip_enrichment: + enrich_traceroute: false # Traceroute will show basic hop info without ASN/org data + enrich_bgproute: true # BGP routes will include ASN/org enrichment on query response +``` + +<Callout type="warning" emoji="⚠️"> + **IP Enrichment Dependency** + + Without IP enrichment enabled: + - Traceroute hops will only show IP addresses and RTT values + - BGP next-hop IPs will not show ASN/organization tooltips + - No ASN organization names in copied BGP route text + - AS path visualization will be limited or unavailable + - IXP detection will not function + + For the full structured traceroute and BGP route experience with rich metadata, enable the appropriate enrichment settings. +</Callout> + +### BGP Route Enrichment + +BGP route enrichment provides two modes for ASN and organization lookups: + +#### Early Enrichment (On-Query) +When `enrich_bgproute: true`, enrichment happens immediately during query execution: +- ✅ ASN names appear instantly in AS path entries +- ✅ Next-hop IP tooltips show organization information +- ✅ Copied text includes enriched data: `37468 (Organization) → 12956 (Organization)` +- ⚠️ Adds a small delay to query execution for bulk lookups + +```yaml filename="config.yaml" copy {4} +structured: + ip_enrichment: + enrich_bgproute: true +``` + +#### Lazy Enrichment (On-Demand) +When `enrich_bgproute: false` (default), enrichment happens only when needed: +- ✅ Faster initial query response +- ✅ Organization names load when clicking "View AS Path" button or hovering +- ✅ Reduces unnecessary lookups for partial queries +- ⚠️ Organization names not included in copied text by default + +```yaml filename="config.yaml" copy {4} +structured: + ip_enrichment: + enrich_bgproute: false +``` + +#### Next-Hop Enrichment + +Regardless of enrichment mode, next-hop IPs for BGP routes are always enriched with: +- Peer ASN (if available) +- Peer organization name (if available) +- Hovering over a next-hop IP shows: `192.0.2.1 (ASN Organization)` diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 0139c7415..02723a194 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -19,7 +19,14 @@ TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos") -SUPPORTED_STRUCTURED_OUTPUT = ("frr", "juniper", "arista_eos") +SUPPORTED_STRUCTURED_OUTPUT = ( + "frr", + "juniper", + "arista_eos", + "huawei", + "mikrotik_routeros", + "mikrotik_switchos", +) CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml") diff --git a/hyperglass/models/config/devices.py b/hyperglass/models/config/devices.py index 70cac21ee..b633541fd 100644 --- a/hyperglass/models/config/devices.py +++ b/hyperglass/models/config/devices.py @@ -247,10 +247,10 @@ def validate_structured_output(cls, value: bool, info: ValidationInfo) -> bool: if value is True: if info.data.get("platform") not in SUPPORTED_STRUCTURED_OUTPUT: raise ConfigError( - "The 'structured_output' field is set to 'true' on device '{}' with " - + "platform '{}', which does not support structured output", - info.data.get("name"), - info.data.get("platform"), + "The 'structured_output' field is set to 'true' on device '{d}' with " + "platform '{p}', which does not support structured output", + d=info.data.get("name"), + p=info.data.get("platform"), ) return value if value is None and info.data.get("platform") in SUPPORTED_STRUCTURED_OUTPUT: diff --git a/hyperglass/models/parsing/mikrotik.py b/hyperglass/models/parsing/mikrotik.py new file mode 100644 index 000000000..1aea80f7d --- /dev/null +++ b/hyperglass/models/parsing/mikrotik.py @@ -0,0 +1,561 @@ +"""Parser for MikroTik RouterOS (ROS v6/v7) – structured in Huawei style.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import ConfigDict + +# Project +from hyperglass.log import log +from hyperglass.settings import Settings +from hyperglass.models.data.bgp_route import BGPRoute, BGPRouteTable # Add BGPRoute import + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +def remove_prefix(text: str, prefix: str) -> str: + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +# Regex to find key=value pairs. The key can contain dots and hyphens. +# The value can be quoted or a single word. +TOKEN_RE = re.compile(r'([a-zA-Z0-9_.-]+)=(".*?"|\S+)') + +# Regex to find flags at the beginning of a line (e.g., "Ab dst-address=..."). +# Include filtered/disabled/unreachable flags so those routes split into their own entries. +FLAGS_RE = re.compile(r"^\s*([DXUuIAFfcmsroivmyH\+b]+)\s+") + + +class MikrotikBase(HyperglassModel, extra="ignore"): + """Base model for MikroTik parsing models (ignores unknown fields).""" + + +class MikrotikPaths(MikrotikBase): + available: int = 0 + best: int = 0 + select: int = 0 + best_external: int = 0 + add_path: int = 0 + + +class MikrotikRouteEntry(MikrotikBase): + """MikroTik Route Entry.""" + + model_config = ConfigDict(validate_assignment=False) + + prefix: str + gateway: str = "" + distance: int = 0 + scope: int = 0 + target_scope: int = 0 + as_path: t.List[int] = [] + communities: t.List[str] = [] + large_communities: t.List[str] = [] + ext_communities: t.List[str] = [] + local_preference: int = 100 + metric: int = 0 # MED + origin: str = "" + belongs_to: str = "" # BGP session identifier + is_active: bool = False + is_filtered: bool = False + rpki_state: int = RPKI_STATE_MAP.get("unknown", 2) + + @property + def next_hop(self) -> str: + return self.gateway + + @property + def age(self) -> int: + # MikroTik output does not provide route age, returning -1 to indicate unavailable. + return -1 + + @property + def weight(self) -> int: + return self.distance + + @property + def med(self) -> int: + return self.metric + + @property + def active(self) -> bool: + return self.is_active + + @property + def all_communities(self) -> t.List[str]: + return self.communities + self.large_communities + self.ext_communities + + @property + def source_as(self) -> int: + return self.as_path[-1] if self.as_path else 0 + + @property + def source_rid(self) -> str: + """Extract the IP/IPv6 address from belongs-to field. + + Examples: + belongs-to="bgp-IP-196.60.96.109" -> "196.60.96.109" + belongs-to="bgp-IPv6-2001:43f8:6d0::11:141" -> "2001:43f8:6d0::11:141" + """ + if not self.belongs_to: + return "" + + # Remove bgp-IP- or bgp-IPv6- prefix + if self.belongs_to.startswith("bgp-IP-"): + return self.belongs_to[7:] # len("bgp-IP-") = 7 + elif self.belongs_to.startswith("bgp-IPv6-"): + return self.belongs_to[9:] # len("bgp-IPv6-") = 9 + + return "" + + @property + def peer_rid(self) -> str: + return self.gateway + + +def _extract_paths(lines: t.List[str]) -> MikrotikPaths: + """Simple count based on lines with dst/dst-address and preferred 'A' flag.""" + available = 0 + best = 0 + for raw in lines: + if ("dst-address=" in raw) or (" dst=" in f" {raw} "): + available += 1 + m = FLAGS_RE.match(raw) + if m: + flags = set(m.group(1)) + if "A" in flags: + best += 1 + return MikrotikPaths(available=available, best=best, select=best) + + +def _process_kv(route: dict, key: str, val: str): + """Process a key-value pair and update the route dictionary.""" + # Normalize quoted values + if val.startswith('"') and val.endswith('"'): + val = val[1:-1] + + if key in ("dst-address", "dst"): + route["prefix"] = val + elif key in ("gateway", "nexthop"): + # Extract only the IP from gateway (e.g., 168.254.0.2%vlan-2000) + route["gateway"] = val.split("%")[0] + elif key == "distance": + route["distance"] = int(val) if val.isdigit() else route.get("distance", 0) + elif key == "scope": + route["scope"] = int(val) if val.isdigit() else route.get("scope", 0) + elif key in ("target-scope", "target_scope"): + route["target_scope"] = int(val) if val.isdigit() else route.get("target_scope", 0) + + # v7 keys (with dot) + elif key in (".as-path", "as-path", "bgp-as-path"): + if val and val.lower() != "none": + # Find all numbers in the as-path string + nums = re.findall(r"\b\d{1,10}\b", val) + route["as_path"] = [int(n) for n in nums if 1 <= int(n) <= 4294967295] + elif key in (".origin", "origin", "bgp-origin"): + route["origin"] = val + elif key in (".med", "med", "bgp-med"): + route["metric"] = int(val) if val.isdigit() else 0 + elif key in (".local-pref", "local-pref", "bgp-local-pref"): + route["local_preference"] = int(val) if val.isdigit() else 100 + elif key in (".communities", "communities", "bgp-communities"): + if val and val.lower() != "none": + route["communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key in (".large-communities", "large-communities", "bgp-large-communities"): + if val and val.lower() != "none": + route["large_communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key == "bgp-ext-communities": + if val and val.lower() != "none": + route["ext_communities"] = [c.strip() for c in val.split(",") if c.strip()] + elif key == "rpki": + clean_val = val.strip().strip('"').lower() + route["rpki_state"] = RPKI_STATE_MAP.get(clean_val, 2) + elif key == "belongs-to": + route["belongs_to"] = val + + +def _extract_route_entries(lines: t.List[str]) -> t.List[MikrotikRouteEntry]: + """Extract route entries from a list of lines.""" + routes: t.List[MikrotikRouteEntry] = [] + current_route_lines = [] + + for line in lines: + stripped_line = line.strip() + # A new route entry starts with flags or is a continuation line. + # An empty line signifies the end of the previous block. + if not stripped_line and current_route_lines: + # Process the completed route block + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + current_route_lines = [] + elif stripped_line: + # Check if this line is the start of a new entry + if FLAGS_RE.match(stripped_line) and current_route_lines: + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + current_route_lines = [stripped_line] + else: + current_route_lines.append(stripped_line) + + # Process any remaining lines + if current_route_lines: + route_data = _parse_route_block(current_route_lines) + if route_data: + routes.append(route_data) + + return routes + + +def _parse_route_block(block: t.List[str]) -> t.Optional[MikrotikRouteEntry]: + """Parse a single route block and return a MikrotikRouteEntry.""" + if not block: + return None + + full_block_text = " ".join(block) + if "dst-address=" not in full_block_text and " dst=" not in f" {full_block_text} ": + return None + + flags = "" + rd = { + "prefix": "", + "gateway": "", + "distance": 20, + "scope": 30, + "target_scope": 10, + "as_path": [], + "communities": [], + "large_communities": [], + "ext_communities": [], + "local_preference": 100, + "metric": 0, + "origin": "", + "belongs_to": "", + "is_active": False, + "is_filtered": False, + "rpki_state": RPKI_STATE_MAP.get("unknown", 2), + } + + # Interpret flags in the first line: + # - "A" => active (preferred) + # - otherwise not active + m = FLAGS_RE.match(block[0]) + if m: + flags = m.group(1) + flag_set = set(flags) + is_active = "A" in flag_set + is_filtered = bool(flag_set & {"F", "f", "U", "u", "X", "x"}) + + if is_active: + rd["is_active"] = True + rd["is_filtered"] = False + else: + rd["is_filtered"] = is_filtered + + # Find all key=value tokens in the entire block + for k, v in TOKEN_RE.findall(full_block_text): + _process_kv(rd, k, v) + + if rd["prefix"]: + try: + if Settings.debug: + log.bind(parser="MikrotikBGPTable").debug( + "Parsed MikroTik route entry: " + "prefix={prefix} flags={flags} active={active} filtered={filtered} " + "next_hop={next_hop} as_path={as_path} rpki_state={rpki_state}", + prefix=rd["prefix"], + flags=flags or None, + active=rd["is_active"], + filtered=rd["is_filtered"], + next_hop=rd["gateway"] or None, + as_path=rd["as_path"], + rpki_state=rd["rpki_state"], + ) + return MikrotikRouteEntry(**rd) + except Exception as e: + log.warning(f"Failed to create MikroTik route entry ({rd.get('prefix','?')}: {e}") + return None + + +class MikrotikBGPRouteTable(BGPRouteTable): + """Canonical MikroTik BGP Route Table.""" + + # No custom __init__ needed; inherit from BGPRouteTable (which should be a Pydantic model) + + +class MikrotikBGPTable(MikrotikBase): + """MikroTik BGP Table in canonical format.""" + + local_router_id: str = "" + local_as_number: int = 0 + paths: MikrotikPaths = MikrotikPaths() + routes: t.List[MikrotikRouteEntry] = [] + + @classmethod + def parse_text(cls, text: str) -> "MikrotikBGPTable": + _log = log.bind(parser="MikrotikBGPTable") + inst = cls() + + lines = text.splitlines() + if not lines: + return inst + + # Filter out command echoes and header lines + lines = [ln for ln in lines if not ln.strip().startswith((">", "Flags:", "[", "#"))] + + inst.paths = _extract_paths(lines) + inst.routes = _extract_route_entries(lines) + + _log.debug(f"Parsed {len(inst.routes)} MikroTik routes") + return inst + + def bgp_table(self) -> BGPRouteTable: + routes = [] + for route in self.routes: + route_data = { + "prefix": route.prefix, + "active": route.active, + "age": route.age, + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.all_communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": route.rpki_state, + "filtered": route.is_filtered, + } + # Instantiate BGPRoute to trigger validation (including external RPKI) + routes.append(BGPRoute(**route_data)) + return MikrotikBGPRouteTable( + vrf="default", + count=len(routes), + routes=routes, + winning_weight="low", + ) + + +class MikrotikTracerouteTable(MikrotikBase): + """MikroTik Traceroute Table.""" + + target: str + source: str + hops: t.List["MikrotikTracerouteHop"] = [] + max_hops: int = 30 + packet_size: int = 60 + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> "MikrotikTracerouteTable": + """Parse a cleaned MikroTik traceroute table. + + The input is expected to be a single, clean traceroute table that has already + been processed by the garbage cleaner to remove paging artifacts and duplicates. + + Expected format: + ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS + 197.157.67.233 0% 3 0.4ms 0.2 0.1 0.4 0.1 + 100% 3 timeout + 41.78.188.153 0% 3 210.8ms 210.8 210.8 210.9 0 + """ + _log = log.bind(parser="MikrotikTracerouteTable") + + _log.debug( + "Parsing cleaned MikroTik traceroute table", + target=target, + source=source, + lines=len(text.splitlines()), + ) + + lines = text.strip().split("\n") + hops = [] + hop_number = 1 + + # Find the table header to start parsing from + header_found = False + data_start_index = 0 + has_index_column = False + + for i, line in enumerate(lines): + if "ADDRESS" in line and "LOSS" in line and "SENT" in line: + header_found = True + data_start_index = i + 1 + # Some RouterOS versions prefix each row with a hop-index column, + # so the header begins with "#" (e.g. "# ADDRESS LOSS SENT ..."). + # Detect it here so the data rows can drop that leading column. + header_tokens = line.split() + has_index_column = bool(header_tokens) and header_tokens[0] == "#" + break + + if not header_found: + _log.warning("No traceroute table header found in cleaned output") + return MikrotikTracerouteTable(target=target, source=source, hops=[]) + + # Parse data rows + for i in range(data_start_index, len(lines)): + line = lines[i].strip() + + # Skip empty lines + if not line: + continue + + # Stop at any remaining paging markers (shouldn't happen with cleaned input) + if "-- [Q quit|C-z pause]" in line: + break + + try: + # Parse data line + parts = line.split() + # Drop the leading hop-index column when present (e.g. "0 1.2.3.4 ..."). + if has_index_column and parts and re.fullmatch(r"\d+", parts[0]): + parts = parts[1:] + if len(parts) < 3: + continue + + # Check if this is a timeout line (starts with percentage) + if parts[0].endswith("%"): + # Timeout hop: "100% 3 timeout" + ip_address = None + loss_pct = int(parts[0].rstrip("%")) + sent_count = int(parts[1]) + last_rtt = None + avg_rtt = None + best_rtt = None + worst_rtt = None + else: + # Normal hop: "197.157.67.233 0% 3 0.4ms 0.2 0.1 0.4 0.1" + ip_address = parts[0] + if len(parts) < 7: + continue + + loss_pct = int(parts[1].rstrip("%")) + sent_count = int(parts[2]) + + # Parse RTT values + def parse_rtt(rtt_str: str) -> t.Optional[float]: + if rtt_str in ("timeout", "-", "0ms", "*"): + return None + rtt_clean = re.sub(r"ms$", "", rtt_str) + try: + return float(rtt_clean) + except ValueError: + return None + + last_rtt = parse_rtt(parts[3]) + avg_rtt = parse_rtt(parts[4]) + best_rtt = parse_rtt(parts[5]) + worst_rtt = parse_rtt(parts[6]) + + hop = MikrotikTracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + hostname=None, # MikroTik doesn't do reverse DNS by default + loss_pct=loss_pct, + sent_count=sent_count, + last_rtt=last_rtt, + avg_rtt=avg_rtt, + best_rtt=best_rtt, + worst_rtt=worst_rtt, + ) + + hops.append(hop) + hop_number += 1 + + except (ValueError, IndexError) as e: + _log.debug("Failed to parse traceroute data line", line=line, error=str(e)) + continue + + result = MikrotikTracerouteTable(target=target, source=source, hops=hops) + _log.info("Parsed cleaned traceroute table", hops=len(hops)) + return result + + def traceroute_result(self): + """Convert to TracerouteResult format.""" + from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop + from hyperglass.log import log + + _log = log.bind(parser="MikrotikTracerouteTable") + + converted_hops = [] + for hop in self.hops: + # Handle truncated IP addresses + ip_address = hop.ip_address + display_ip = None + + if hop.ip_address and hop.ip_address.endswith("..."): + # For truncated IPs, store for display but set ip_address to None for validation + display_ip = hop.ip_address + ip_address = None + + created_hop = TracerouteHop( + hop_number=hop.hop_number, + ip_address=ip_address, # None for truncated IPs + display_ip=display_ip, # Truncated IP for display + hostname=hop.hostname, + # Set RTT values to ensure avg_rtt property returns MikroTik's AVG value + # Since avg_rtt = (rtt1 + rtt2 + rtt3) / 3, we set all to the MikroTik AVG + rtt1=hop.avg_rtt, # Set to AVG so computed average is correct + rtt2=hop.avg_rtt, # Set to AVG so computed average is correct + rtt3=hop.avg_rtt, # Set to AVG so computed average is correct + # MikroTik-specific statistics (preserve original values) + loss_pct=hop.loss_pct, + sent_count=hop.sent_count, + last_rtt=hop.last_rtt, # Preserve LAST value + best_rtt=hop.best_rtt, # Preserve BEST value + worst_rtt=hop.worst_rtt, # Preserve WORST value + # BGP enrichment fields will be populated by enrichment plugin + # For truncated IPs, these will remain None/empty + asn=None, + org=None, + prefix=None, + country=None, + rir=None, + allocated=None, + ) + + converted_hops.append(created_hop) + + return TracerouteResult( + target=self.target, + source=self.source, + hops=converted_hops, + max_hops=self.max_hops, + packet_size=self.packet_size, + raw_output=None, # Will be set by the plugin + ) + + +class MikrotikTracerouteHop(MikrotikBase): + """Individual MikroTik traceroute hop.""" + + hop_number: int + ip_address: t.Optional[str] = None + hostname: t.Optional[str] = None + + # MikroTik-specific statistics + loss_pct: t.Optional[int] = None + sent_count: t.Optional[int] = None + last_rtt: t.Optional[float] = None + avg_rtt: t.Optional[float] = None + best_rtt: t.Optional[float] = None + worst_rtt: t.Optional[float] = None + + @property + def is_timeout(self) -> bool: + """Check if this hop is a timeout.""" + return self.ip_address is None or self.loss_pct == 100 diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py index 5e87f5b11..75a78ee9f 100644 --- a/hyperglass/plugins/_builtin/__init__.py +++ b/hyperglass/plugins/_builtin/__init__.py @@ -5,14 +5,30 @@ from .remove_command import RemoveCommand from .bgp_route_arista import BGPRoutePluginArista from .bgp_route_huawei import BGPRoutePluginHuawei +from .bgp_routestr_huawei import BGPSTRRoutePluginHuawei from .bgp_route_juniper import BGPRoutePluginJuniper from .mikrotik_garbage_output import MikrotikGarbageOutput +from .mikrotik_normalize_input import MikrotikTargetNormalizerInput +from .bgp_routestr_mikrotik import BGPSTRRoutePluginMikrotik +from .trace_route_frr import TraceroutePluginFrr +from .trace_route_huawei import TraceroutePluginHuawei +from .trace_route_mikrotik import TraceroutePluginMikrotik +from .traceroute_ip_enrichment import ZTracerouteIpEnrichment +from .bgp_route_enrichment import ZBgpRouteEnrichment __all__ = ( "BGPRoutePluginArista", "BGPRoutePluginFrr", "BGPRoutePluginJuniper", "BGPRoutePluginHuawei", + "BGPSTRRoutePluginHuawei", "MikrotikGarbageOutput", + "MikrotikTargetNormalizerInput", + "BGPSTRRoutePluginMikrotik", + "TraceroutePluginFrr", + "TraceroutePluginHuawei", + "TraceroutePluginMikrotik", + "ZTracerouteIpEnrichment", + "ZBgpRouteEnrichment", "RemoveCommand", ) diff --git a/hyperglass/plugins/_builtin/mikrotik_garbage_output.py b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py index 8718604c1..4bb64be24 100644 --- a/hyperglass/plugins/_builtin/mikrotik_garbage_output.py +++ b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py @@ -8,6 +8,7 @@ from pydantic import PrivateAttr # Project +from hyperglass.log import log from hyperglass.types import Series # Local @@ -19,64 +20,246 @@ class MikrotikGarbageOutput(OutputPlugin): - """Parse Mikrotik output to remove garbage.""" + """Parse Mikrotik output to remove garbage before structured parsing.""" _hyperglass_builtin: bool = PrivateAttr(True) - platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos") - directives: t.Sequence[str] = ( - "__hyperglass_mikrotik_bgp_aspath__", - "__hyperglass_mikrotik_bgp_community__", - "__hyperglass_mikrotik_bgp_route__", - "__hyperglass_mikrotik_ping__", - "__hyperglass_mikrotik_traceroute__", - ) + platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + # Only apply to MikroTik platforms, not all devices + common: bool = False - def process(self, *, output: OutputType, query: "Query") -> Series[str]: - """Parse Mikrotik output to remove garbage.""" + def _clean_traceroute_output(self, raw_output: str) -> str: + """Clean MikroTik traceroute output specifically. + + Important: Traceroute hops are sequential - each line represents a unique hop position. + We should NOT deduplicate by IP address as the same IP can appear at different hops. + Order matters for traceroute results. + + However, we can aggregate consecutive timeout lines at the END of the traceroute + to avoid showing 10+ meaningless timeout entries. + """ + if not raw_output or not raw_output.strip(): + return "" + + lines = raw_output.splitlines() + # Remove command echoes and paging, keep only header markers and data lines + # We'll split the output into discrete tables (each table begins at a header) + tables: t.List[t.List[str]] = [] + current_table: t.List[str] = [] + header_line: t.Optional[str] = None + + for line in lines: + stripped = line.strip() + + # Skip empty lines and interactive paging prompts + if ( + not stripped + or "-- [Q quit|C-z pause]" in stripped + or "-- [Q quit|D dump|C-z pause]" in stripped + ): + continue + + # Skip command echo lines + if "tool traceroute" in stripped: + continue + + # If this is a header line, start a new table + if "ADDRESS" in stripped and "LOSS" in stripped and "SENT" in stripped: + header_line = line + # If we were collecting a table, push it + if current_table: + tables.append(current_table) + current_table = [] + # Start collecting after header + continue + + # Collect data lines (will be associated with the most recent header) + if header_line is not None: + current_table.append(line) + + # Push the last collected table if any + if current_table: + tables.append(current_table) - result = () + # If we didn't find any header/data, return cleaned minimal output + if not tables: + # Fallback to previous behavior: remove prompts and flags + filtered_lines: t.List[str] = [] + in_flags_section = False + for line in lines: + stripped_line = line.strip() + if stripped_line.startswith("@") and stripped_line.endswith("] >"): + continue + if "[Q quit|D dump|C-z pause]" in stripped_line: + continue + if stripped_line.startswith("Flags:"): + in_flags_section = True + continue + if in_flags_section: + if "=" in stripped_line: + in_flags_section = False + else: + continue + filtered_lines.append(line) + return "\n".join(filtered_lines) - for each_output in output: - if len(each_output) != 0: - if each_output.split()[-1] in ("DISTANCE", "STATUS"): - # Mikrotik shows the columns with no rows if there is no data. - # Rather than send back an empty table, send back an empty - # response which is handled with a warning message. - each_output = "" + # Aggregate tables by hop index. For each hop position, pick the row with the + # highest SENT count. If SENT ties, prefer non-timeout rows and the later table. + processed_lines: t.List[str] = [] + + # Regex to extract LOSS% and SENT count following it: e.g. '0% 3' + sent_re = re.compile(r"(\d+)%\s+(\d+)\b") + + max_rows = max(len(t) for t in tables) + + for i in range(max_rows): + best_row = None + best_sent = -1 + best_is_timeout = True + best_table_index = -1 + + for ti, table in enumerate(tables): + if i >= len(table): + continue + row = table[i] + m = sent_re.search(row) + if m: + try: + sent = int(m.group(2)) + except Exception: + sent = 0 else: - remove_lines = () - all_lines = each_output.splitlines() - # Starting index for rows (after the column row). - start = 1 - # Extract the column row. - column_line = " ".join(all_lines[0].split()) - - for i, line in enumerate(all_lines[1:]): - # Remove all the newline characters (which differ line to - # line) for comparison purposes. - normalized = " ".join(line.split()) - - # Remove ansii characters that aren't caught by Netmiko. - normalized = re.sub(r"\\x1b\[\S{2}\s", "", normalized) - - if column_line in normalized: - # Mikrotik often re-inserts the column row in the output, - # effectively 'starting over'. In that case, re-assign - # the column row and starting index to that point. - column_line = re.sub(r"\[\S{2}\s", "", line) - start = i + 2 - - if "[Q quit|D dump|C-z pause]" in normalized: - # Remove Mikrotik's unhelpful helpers from the output. - remove_lines += (i + 1,) - - # Combine the column row and the data rows from the starting - # index onward. - lines = [column_line, *all_lines[start:]] - - # Remove any lines marked for removal and re-join with a single - # newline character. - lines = [line for idx, line in enumerate(lines) if idx not in remove_lines] - result += ("\n".join(lines),) - - return result + sent = 0 + + is_timeout = "timeout" in row.lower() or ( + "100%" in row and "timeout" in row.lower() + ) + + # Prefer higher SENT, then prefer non-timeout, then later table (higher ti) + pick = False + if sent > best_sent: + pick = True + elif sent == best_sent: + if best_is_timeout and not is_timeout: + pick = True + elif (best_is_timeout == is_timeout) and ti > best_table_index: + pick = True + + if pick: + best_row = row + best_sent = sent + best_is_timeout = is_timeout + best_table_index = ti + + if best_row is not None: + processed_lines.append(best_row) + + # Collapse excessive trailing timeouts into an aggregation line + trailing_timeouts = 0 + for line in reversed(processed_lines): + if ("timeout" in line.lower()) or ( + sent_re.search(line) and sent_re.search(line).group(1) == "100" + ): + trailing_timeouts += 1 + else: + break + + if trailing_timeouts > 3: + non_trailing = len(processed_lines) - trailing_timeouts + # Keep first 2 of trailing timeouts and aggregate the rest + aggregated = ( + processed_lines[:non_trailing] + processed_lines[non_trailing : non_trailing + 2] + ) + remaining = trailing_timeouts - 2 + aggregated.append( + f" ... ({remaining} more timeout hops)" + ) + processed_lines = aggregated + + # Prepend header line if we have one + header_to_use = ( + header_line + or "ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS" + ) + cleaned = [header_to_use] + processed_lines + return "\n".join(cleaned) + + def process(self, *, output: OutputType, query: "Query") -> Series[str]: + """ + Clean raw output from a MikroTik device. + This plugin removes command echoes, prompts, flag legends, and interactive help text. + """ + + # If output is already processed/structured (not raw strings), pass it through unchanged + if not isinstance(output, (tuple, list)): + return output + + # Check if the tuple/list contains non-string objects (structured data) + if output and not isinstance(output[0], str): + return output + + cleaned_outputs = [] + + for raw_output in output: + # Handle non-string outputs (already processed by other plugins) - double check + if not isinstance(raw_output, str): + cleaned_outputs.append(raw_output) + continue + + # Nothing to do if the output is already empty. + if not raw_output or not raw_output.strip(): + cleaned_outputs.append("") + continue + + # Check if this is traceroute output and handle it specially + if ( + "tool traceroute" in raw_output + or ("ADDRESS" in raw_output and "LOSS" in raw_output and "SENT" in raw_output) + or "-- [Q quit|C-z pause]" in raw_output + ): + cleaned_output = self._clean_traceroute_output(raw_output) + cleaned_outputs.append(cleaned_output) + continue + + # Original logic for other outputs (BGP routes, etc.) + lines = raw_output.splitlines() + filtered_lines = [] + in_flags_section = False + + for line in lines: + stripped_line = line.strip() + + # Skip prompts and command echoes + if stripped_line.startswith("@") and stripped_line.endswith("] >"): + continue + + # Skip the interactive help line + if "[Q quit|D dump|C-z pause]" in stripped_line: + continue + + # Begin detecting the Flags section + if stripped_line.startswith("Flags:"): + in_flags_section = True + continue # Skip the "Flags:" line itself + + # Within the flags section, check whether the line is still part of it + if in_flags_section: + if "=" in stripped_line: + in_flags_section = False + else: + continue # Skip the flag-legend lines + + filtered_lines.append(line) + + # Join the cleaned lines back into a single string + cleaned_output = "\n".join(filtered_lines) + cleaned_outputs.append(cleaned_output) + + # Minimal debug logging: log number of cleaned blocks and if any aggregation occurred + if len(output) > 0: + log.debug(f"MikrotikGarbageOutput processed {len(output)} output blocks.") + # If any aggregation line was added, log that event + for cleaned in cleaned_outputs: + if "... (" in cleaned: + log.debug("Aggregated excessive trailing timeout hops in traceroute output.") + break + return tuple(cleaned_outputs) diff --git a/hyperglass/plugins/_builtin/mikrotik_normalize_input.py b/hyperglass/plugins/_builtin/mikrotik_normalize_input.py new file mode 100644 index 000000000..d7bf556e0 --- /dev/null +++ b/hyperglass/plugins/_builtin/mikrotik_normalize_input.py @@ -0,0 +1,58 @@ +# Standard Library +from ipaddress import ip_network, ip_address +import typing as t + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.log import log + +# REMOVIDA a importação direta de Query para evitar o erro circular +# from hyperglass.models.api.query import Query + +# Local +from .._input import InputPlugin, InputPluginValidationReturn + + +class MikrotikTargetNormalizerInput(InputPlugin): + """ + InputPlugin to normalize a target IP address to its network prefix. + This ensures that queries for different IPs within the same subnet + resolve to the same cache key. + """ + + _hyperglass_builtin: bool = PrivateAttr(False) + name: str = "mikrotik_normalizer" + platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") + + def validate(self, query: t.Any) -> InputPluginValidationReturn: + """Take the query object and modify the target if it's a BGP Route query.""" + + # Acessamos os atributos normalmente, pois sabemos que o objeto 'query' os terá. + if query.query_type != "bgp_route": + return True, None + + try: + target_ip = ip_address(query.target) + + if target_ip.version == 4: + prefix_len = 24 + else: + prefix_len = 48 + + network = ip_network(f"{str(target_ip)}/{prefix_len}", strict=False) + + normalized_target = str(network.with_prefixlen) + + if query.target != normalized_target: + log.debug( + f"Normalizing target '{query.target}' to network prefix " + f"'{normalized_target}' for MikroTik cache key." + ) + query.target = normalized_target + + except ValueError: + pass + + return True, None diff --git a/hyperglass/plugins/_builtin/trace_route_frr.py b/hyperglass/plugins/_builtin/trace_route_frr.py new file mode 100644 index 000000000..6d17dd100 --- /dev/null +++ b/hyperglass/plugins/_builtin/trace_route_frr.py @@ -0,0 +1,345 @@ +"""Parse FRR traceroute output to structured data.""" + +# Standard Library +import re +import typing as t +from ipaddress import ip_address as validate_ip + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop +from hyperglass.state import use_state + +# Local +from .._output import OutputPlugin + +if t.TYPE_CHECKING: + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + from .._output import OutputType + + +def _normalize_output(output: t.Union[str, t.Sequence[str]]) -> t.List[str]: + """Ensure the output is a list of strings.""" + if isinstance(output, str): + return [output] + return list(output) + + +def parse_frr_traceroute( + output: t.Union[str, t.Sequence[str]], target: str, source: str +) -> "OutputDataModel": + """Parse an FRR traceroute text response.""" + result = None + out_list = _normalize_output(output) + + _log = log.bind(plugin=TraceroutePluginFrr.__name__) + combined_output = "\n".join(out_list) + + # DEBUG: Log the raw output we're about to parse + _log.debug(f"=== FRR TRACEROUTE PLUGIN RAW INPUT ===") + _log.debug(f"Target: {target}, Source: {source}") + _log.debug(f"Output pieces: {len(out_list)}") + _log.debug(f"Combined output length: {len(combined_output)}") + _log.debug(f"First 500 chars: {repr(combined_output[:500])}") + _log.debug(f"=== END PLUGIN RAW INPUT ===") + + try: + result = FrrTracerouteTable.parse_text(combined_output, target, source) + except Exception as exc: + _log.error(f"Failed to parse FRR traceroute: {exc}") + raise ParsingError(f"Failed to parse FRR traceroute output: {exc}") from exc + + _log.debug(f"=== FINAL STRUCTURED TRACEROUTE RESULT ===") + _log.debug(f"Successfully parsed {len(result.hops)} traceroute hops") + _log.debug(f"Target: {target}, Source: {source}") + for hop in result.hops: + _log.debug(f"Hop {hop.hop_number}: {hop.ip_address or '*'} - RTT: {hop.rtt1 or 'timeout'}") + _log.debug(f"Raw output length: {len(combined_output)} characters") + _log.debug(f"=== END STRUCTURED RESULT ===") + + return result + + +class FrrTracerouteTable(TracerouteResult): + """FRR traceroute table parser.""" + + @classmethod + def parse_text(cls, text: str, target: str, source: str) -> TracerouteResult: + """Parse FRR traceroute text output into structured data.""" + _log = log.bind(parser="FrrTracerouteTable") + + _log.debug(f"=== RAW FRR TRACEROUTE INPUT ===") + _log.debug(f"Target: {target}, Source: {source}") + _log.debug(f"Raw text length: {len(text)} characters") + _log.debug(f"Raw text:\n{repr(text)}") + _log.debug(f"=== END RAW INPUT ===") + + hops = [] + + # Parse the traceroute output line by line + lines = text.strip().split("\n") + _log.debug(f"Split into {len(lines)} lines") + + # Patterns for different line formats + # Pattern for regular hop: " 1 203.0.113.1 0.221 ms" + simple_pattern = re.compile(r"^\s*(\d+)\s+([\d\.:a-fA-F]+)\s+([\d.]+)\s+ms") + + # Pattern for timeout hop: " 9 *" + timeout_pattern = re.compile(r"^\s*(\d+)\s+\*\s*$") + + # Pattern for hop with hostname: " 2 hostname.example.com (192.168.1.1) 15.234 ms" + hostname_pattern = re.compile(r"^\s*(\d+)\s+(\S+)\s+\(([\d\.:a-fA-F]+)\)\s+([\d.]+)\s+ms") + + # Pattern for multiple RTTs: " 3 192.168.1.1 15.234 ms 16.123 ms 14.567 ms" + multi_rtt_pattern = re.compile( + r"^\s*(\d+)\s+([\d\.:a-fA-F]+)\s+([\d.]+)\s+ms(?:\s+([\d.]+)\s+ms)?(?:\s+([\d.]+)\s+ms)?" + ) + + # Pattern for partial timeout: " 7 port-channel4.core4.mrs1.he.net (184.105.81.30) 132.624 ms 132.589 ms *" + partial_timeout_pattern = re.compile( + r"^\s*(\d+)\s+(\S+)\s+\(([\d\.:a-fA-F]+)\)\s+(?:([\d.]+)\s+ms\s+)?(?:([\d.]+)\s+ms\s+)?\*" + ) + + for i, line in enumerate(lines): + line = line.strip() + if not line: + continue + + _log.debug(f"Line {i:2}: {repr(line)}") + + # Skip header lines + if "traceroute to" in line.lower() or "hops max" in line.lower(): + _log.debug(f"Line {i:2}: SKIPPING HEADER") + continue + + hop_number = None + ip_address = None + hostname = None + rtt1 = None + rtt2 = None + rtt3 = None + + # Try to match timeout hop first + timeout_match = timeout_pattern.match(line) + if timeout_match: + hop_number = int(timeout_match.group(1)) + _log.debug(f"Line {i:2}: TIMEOUT HOP - {hop_number}") + + # Try to match partial timeout + elif partial_timeout_pattern.match(line): + partial_match = partial_timeout_pattern.match(line) + hop_number = int(partial_match.group(1)) + hostname = partial_match.group(2) + ip_address = partial_match.group(3) + rtt1 = float(partial_match.group(4)) if partial_match.group(4) else None + rtt2 = float(partial_match.group(5)) if partial_match.group(5) else None + _log.debug( + f"Line {i:2}: PARTIAL TIMEOUT HOP - {hop_number}: {hostname} ({ip_address}) {rtt1} {rtt2} *" + ) + + # Try to match hostname pattern + elif hostname_pattern.match(line): + hostname_match = hostname_pattern.match(line) + hop_number = int(hostname_match.group(1)) + hostname = hostname_match.group(2) + ip_address = hostname_match.group(3) + rtt1 = float(hostname_match.group(4)) + _log.debug( + f"Line {i:2}: HOSTNAME HOP - {hop_number}: {hostname} ({ip_address}) {rtt1} ms" + ) + + # Try to match multiple RTT pattern + elif multi_rtt_pattern.match(line): + multi_match = multi_rtt_pattern.match(line) + hop_number = int(multi_match.group(1)) + ip_address = multi_match.group(2) + rtt1 = float(multi_match.group(3)) + rtt2 = float(multi_match.group(4)) if multi_match.group(4) else None + rtt3 = float(multi_match.group(5)) if multi_match.group(5) else None + _log.debug( + f"Line {i:2}: MULTI RTT HOP - {hop_number}: {ip_address} {rtt1} {rtt2} {rtt3}" + ) + + # Try to match simple pattern + elif simple_pattern.match(line): + simple_match = simple_pattern.match(line) + hop_number = int(simple_match.group(1)) + ip_address = simple_match.group(2) + rtt1 = float(simple_match.group(3)) + _log.debug(f"Line {i:2}: SIMPLE IP HOP - {hop_number}: {ip_address} {rtt1} ms") + + else: + _log.debug(f"Line {i:2}: NO MATCH - skipping") + continue + + # Create hop object if we have valid data + if hop_number is not None: + try: + # Validate IP address if present + if ip_address: + validate_ip(ip_address) + except ValueError: + # If IP validation fails, treat as hostname + if not hostname: + hostname = ip_address + ip_address = None + + hop = TracerouteHop( + hop_number=hop_number, + ip_address=ip_address, + display_ip=None, + hostname=hostname, + rtt1=rtt1, + rtt2=rtt2, + rtt3=rtt3, + sent_count=None, + last_rtt=rtt1, + best_rtt=( + min(filter(None, [rtt1, rtt2, rtt3])) if any([rtt1, rtt2, rtt3]) else None + ), + worst_rtt=( + max(filter(None, [rtt1, rtt2, rtt3])) if any([rtt1, rtt2, rtt3]) else None + ), + loss_pct=None, + # BGP enrichment fields (will be populated by enrichment plugin) + asn=None, + org=None, + prefix=None, + country=None, + rir=None, + allocated=None, + ) + hops.append(hop) + + # Clean up hops - remove duplicates and sort by hop number + _log.debug(f"Before cleanup: {len(hops)} hops") + + # Group hops by hop number and merge data + hop_dict = {} + for hop in hops: + if hop.hop_number in hop_dict: + # Merge data for same hop number + existing = hop_dict[hop.hop_number] + # Keep the first non-None value for each field + hop_dict[hop.hop_number] = TracerouteHop( + hop_number=hop.hop_number, + ip_address=existing.ip_address or hop.ip_address, + display_ip=existing.display_ip or hop.display_ip, + hostname=existing.hostname or hop.hostname, + rtt1=existing.rtt1 or hop.rtt1, + rtt2=existing.rtt2 or hop.rtt2, + rtt3=existing.rtt3 or hop.rtt3, + sent_count=existing.sent_count or hop.sent_count, + last_rtt=existing.last_rtt or hop.last_rtt, + best_rtt=existing.best_rtt or hop.best_rtt, + worst_rtt=existing.worst_rtt or hop.worst_rtt, + loss_pct=existing.loss_pct or hop.loss_pct, + asn=existing.asn or hop.asn, + org=existing.org or hop.org, + prefix=existing.prefix or hop.prefix, + country=existing.country or hop.country, + rir=existing.rir or hop.rir, + allocated=existing.allocated or hop.allocated, + ) + else: + hop_dict[hop.hop_number] = hop + + # Convert back to sorted list + final_hops = [hop_dict[hop_num] for hop_num in sorted(hop_dict.keys())] + _log.debug(f"After cleanup: {len(final_hops)} hops") + + # Debug final hop list + for hop in final_hops: + hostname_display = hop.hostname or "no-hostname" + _log.debug( + f"Final hop {hop.hop_number}: {hop.ip_address} ({hostname_display}) - RTTs: {hop.rtt1}/{hop.rtt2}/{hop.rtt3}" + ) + + _log.info(f"Parsed {len(final_hops)} hops from FRR traceroute") + + # Extract packet information from traceroute output + max_hops = 30 # Default + packet_size = 60 # FRR default + + # Try to extract from header line + for line in lines: + if "hops max" in line.lower(): + try: + # Look for pattern like "30 hops max, 60 byte packets" + match = re.search(r"(\d+)\s+hops\s+max.*?(\d+)\s+byte", line) + if match: + max_hops = int(match.group(1)) + packet_size = int(match.group(2)) + break + except (ValueError, AttributeError): + pass + + return TracerouteResult( + target=target, + source=source, + hops=final_hops, + max_hops=max_hops, + packet_size=packet_size, + raw_output=text, + asn_organizations={}, + ) + + +class TraceroutePluginFrr(OutputPlugin): + """Parse FRR traceroute output.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ("frr",) + directives: t.Sequence[str] = ("__hyperglass_frr_traceroute__",) + common: bool = False + + def process(self, output: "OutputType", query: "Query") -> "OutputType": + """Process FRR traceroute output.""" + # Extract target and source with fallbacks + target = str(query.query_target) if query.query_target else "unknown" + source = "unknown" + + if hasattr(query, "device") and query.device: + source = getattr(query.device, "display_name", None) or getattr( + query.device, "name", "unknown" + ) + + # Logging + _log = log.bind(plugin="TraceroutePluginFrr") + _log.info(f"Processing Traceroute for {target} from {source}") + + device = getattr(query, "device", None) + if device is not None: + if not getattr(device, "structured_output", False): + return output + try: + _params = use_state("params") + except Exception: + _params = None + if ( + _params + and getattr(_params, "structured", None) + and getattr(_params.structured, "enable_for_traceroute", None) is False + ): + return output + else: + try: + params = use_state("params") + except Exception: + params = None + if not (params and getattr(params, "structured", None)): + return output + if getattr(params.structured, "enable_for_traceroute", None) is False: + return output + + # Parse traceroute output + return parse_frr_traceroute( + output=output, + target=target, + source=source, + ) diff --git a/hyperglass/plugins/tests/test_bgp_route_mikrotik.py b/hyperglass/plugins/tests/test_bgp_route_mikrotik.py new file mode 100644 index 000000000..d24518e17 --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_mikrotik.py @@ -0,0 +1,78 @@ +"""MikroTik structured BGP route parsing tests. + +The sample below is a trimmed, real `routing route print detail` capture from a +RouterOS 7.20.6 device (CCR2216), reduced to one active and one filtered route. +Long community lists are kept on a single line, matching what the device emits +to the looking-glass SSH driver (no terminal-width wrapping). +""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.config.params import Params +from hyperglass.models.data.bgp_route import BGPRouteTable +from hyperglass.models.parsing.mikrotik import MikrotikBGPTable + +if t.TYPE_CHECKING: + from hyperglass.state import HyperglassState + +SAMPLE = """Flags: X - disabled, F - filtered, U - unreachable, A - active; +c - connect, s - static, r - rip, b - bgp, n - bgp-net, o - ospf, i - isis, d - dhcp, v - vpn, m - modem, a - ldp-address, l - ldp-mapping, g - slaac, y - bgp-mpls-vpn, e - evpn; +H - hw-offloaded; + - ecmp, B - blackhole + Ab afi=ip contribution=active dst-address=1.0.0.0/24 routing-table=main pref-src=192.0.2.1 gateway=196.60.8.198 + immediate-gw=196.60.8.198%[sfp28-01] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-196.60.8.198" + rpki=valid bgp.session=NAP-JHB-CloudFlare-v4-1 .as-path="13335" .communities=13335:10045 .large-communities=328964:2000:0,328964:2001:0 .local-pref=250 .med=0 .origin=igp + + Fb afi=ip contribution=filtered dst-address=5.101.88.0/24 routing-table=main pref-src=192.0.2.1 gateway=192.0.2.2 + immediate-gw=192.0.2.2%[sfp28-10] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-192.0.2.2" + rpki=invalid bgp.session=TRANSIT-ANGOLA-v4-1 .as-path="37468,41095,51601,50113,207569" .communities=37468:14100,37468:2000 .med=0 .origin=incomplete +""" + + +@pytest.fixture +def params() -> t.Dict[str, t.Any]: + return {} + + +@pytest.fixture +def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: + """Initialize Redis-backed state with params for BGPRoute validation.""" + _state = use_state() + _params = Params(**params) + with _state.cache.pipeline() as pipeline: + pipeline.set("params", _params) + yield _state + _state.clear() + + +def test_mikrotik_bgp_route(state): + table = MikrotikBGPTable.parse_text(SAMPLE).bgp_table() + + assert isinstance(table, BGPRouteTable), "Parsed result is not a BGPRouteTable" + assert table.count == 2, f"Expected 2 routes, got {table.count}" + + by_prefix = {r.prefix: r for r in table.routes} + assert set(by_prefix) == {"1.0.0.0/24", "5.101.88.0/24"} + + # Active route (flag "A"): RPKI valid (state 1), single-ASN path. + active = by_prefix["1.0.0.0/24"] + assert active.active is True + assert active.filtered is False + assert active.as_path == [13335] + assert active.next_hop == "196.60.8.198" + assert "13335:10045" in active.communities + assert active.rpki_state == 1 # valid + + # Filtered route (flag "F" => filtered per the RouterOS flags legend): RPKI + # invalid (state 0), multi-ASN path. + filtered = by_prefix["5.101.88.0/24"] + assert filtered.active is False + assert filtered.filtered is True, "F-flagged route should be marked filtered" + assert filtered.as_path == [37468, 41095, 51601, 50113, 207569] + assert filtered.rpki_state == 0 # invalid diff --git a/hyperglass/plugins/tests/test_traceroute_mikrotik.py b/hyperglass/plugins/tests/test_traceroute_mikrotik.py new file mode 100644 index 000000000..2fa792cc9 --- /dev/null +++ b/hyperglass/plugins/tests/test_traceroute_mikrotik.py @@ -0,0 +1,153 @@ +"""MikroTik structured traceroute parsing tests. + +MikroTik's `/tool traceroute` redraws its table in place; the netmiko-based +looking-glass driver captures the final accumulated table (one row per hop, +SENT = total probes, with AVG/BEST/WORST filled in). + +Real RouterOS devices emit two distinct table layouts, both of which must parse: + +1. No index column, with a trailing STATUS column: + ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS + 198.51.100.7 0% 3 0.2ms 0.2 0.1 0.2 0 + +2. Leading hop-index column ("#") and no STATUS column: + # ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV + 0 192.0.2.1 0% 3 15.8ms 15.8 15.8 15.9 0 + +The index column previously shifted every field and dropped all hops; the +parser now detects and strips it. The fixtures below preserve the exact shape +of real captures (hop counts, timeout rows, both layouts), but all addresses +are RFC 5737 documentation ranges (192.0.2.0/24, 198.51.100.0/24, +203.0.113.0/24) rather than real network data. + +The parser only populates fields it can read from the table (IP, loss, sent, +RTTs). ASN/IXP/org enrichment is applied later by a separate plugin, so these +parser tests assert only parser-produced fields. +""" + +# flake8: noqa +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.models.data.traceroute import TracerouteResult +from hyperglass.plugins._builtin.trace_route_mikrotik import parse_mikrotik_traceroute + +# --------------------------------------------------------------------------- +# Fixtures: (raw table, target, source, expected hops) +# Each expected hop is (ip_address, loss_pct, best_rtt, worst_rtt). +# Addresses are RFC 5737 documentation ranges (anonymized). +# --------------------------------------------------------------------------- + +# Indexed layout ("#" column, no STATUS), including a timeout hop. +INDEXED_5HOP = ( + """ # ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV + 0 192.0.2.1 0% 3 15.8ms 15.8 15.8 15.9 0 + 1 100% 3 timeout + 2 198.51.100.7 0% 3 17.4ms 17.4 17.4 17.5 0 + 3 198.51.100.8 0% 3 16.4ms 16.4 16.4 16.4 0 + 4 203.0.113.8 0% 3 16.3ms 16.3 16.3 16.3 0 +""", + "203.0.113.8", + "router-a", + [ + ("192.0.2.1", 0, 15.8, 15.9), + (None, 100, None, None), + ("198.51.100.7", 0, 17.4, 17.5), + ("198.51.100.8", 0, 16.4, 16.4), + ("203.0.113.8", 0, 16.3, 16.3), + ], +) + +# Indexed layout, short 2-hop trace. +INDEXED_2HOP = ( + """ # ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV + 0 192.0.2.20 0% 3 4.4ms 7.6 0.7 17.7 7.3 + 1 203.0.113.1 0% 3 0.8ms 0.8 0.8 0.8 0 +""", + "203.0.113.1", + "router-a", + [ + ("192.0.2.20", 0, 0.7, 17.7), + ("203.0.113.1", 0, 0.8, 0.8), + ], +) + +# Non-indexed layout with a trailing STATUS column. +STATUS_COL_4HOP = ( + """ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS +192.0.2.10 0% 3 0.2ms 0.2 0.1 0.2 0 +198.51.100.7 0% 3 1.6ms 1.5 1.4 1.6 0.1 +198.51.100.8 0% 3 0.5ms 0.5 0.5 0.5 0 +203.0.113.8 0% 3 1ms 1 1 1 0 +""", + "203.0.113.8", + "router-b", + [ + ("192.0.2.10", 0, 0.1, 0.2), + ("198.51.100.7", 0, 1.4, 1.6), + ("198.51.100.8", 0, 0.5, 0.5), + ("203.0.113.8", 0, 1.0, 1.0), + ], +) + +STATUS_COL_5HOP = ( + """ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS +192.0.2.1 0% 3 0.7ms 0.7 0.7 0.7 0 +192.0.2.10 0% 3 0.9ms 0.9 0.8 0.9 0 +198.51.100.9 0% 3 2.5ms 2.5 2.4 2.6 0.1 +198.51.100.10 0% 3 2.3ms 2.2 2.1 2.3 0.1 +203.0.113.8 0% 3 1.3ms 1.3 1.3 1.3 0 +""", + "203.0.113.8", + "router-c", + [ + ("192.0.2.1", 0, 0.7, 0.7), + ("192.0.2.10", 0, 0.8, 0.9), + ("198.51.100.9", 0, 2.4, 2.6), + ("198.51.100.10", 0, 2.1, 2.3), + ("203.0.113.8", 0, 1.3, 1.3), + ], +) + +CASES = { + "indexed-5hop": INDEXED_5HOP, + "indexed-2hop": INDEXED_2HOP, + "status-col-4hop": STATUS_COL_4HOP, + "status-col-5hop": STATUS_COL_5HOP, +} + + +@pytest.mark.parametrize("case", CASES.values(), ids=list(CASES.keys())) +def test_mikrotik_traceroute(case: t.Tuple) -> None: + raw, target, source, expected = case + result = parse_mikrotik_traceroute(raw, target=target, source=source) + + assert isinstance(result, TracerouteResult), "Expected a TracerouteResult" + assert len(result.hops) == len(expected), ( + f"Expected {len(expected)} hops, got {len(result.hops)}" + ) + + for i, (hop, (ip, loss, best, worst)) in enumerate(zip(result.hops, expected), start=1): + assert hop.hop_number == i, f"Hop {i}: wrong hop_number {hop.hop_number}" + assert hop.ip_address == ip, f"Hop {i}: expected ip {ip!r}, got {hop.ip_address!r}" + assert hop.loss_pct == loss, f"Hop {i}: expected loss {loss}, got {hop.loss_pct}" + # BEST/WORST are preserved verbatim (AVG is a computed float and not + # safe for exact comparison). + assert hop.best_rtt == best, f"Hop {i}: expected best {best}, got {hop.best_rtt}" + assert hop.worst_rtt == worst, f"Hop {i}: expected worst {worst}, got {hop.worst_rtt}" + + # The final hop is always the queried target. + assert result.hops[-1].ip_address == target + + +def test_mikrotik_traceroute_timeout_hop() -> None: + """A 100%-loss (timeout) row yields a hop with no IP/RTT but loss recorded.""" + result = parse_mikrotik_traceroute(*INDEXED_5HOP[:3]) + timeout = result.hops[1] + assert timeout.ip_address is None + assert timeout.loss_pct == 100 + assert timeout.last_rtt is None and timeout.avg_rtt is None From 251fc9091b1a238b603ef833b4eb00943d096cc1 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Wed, 1 Jul 2026 16:26:22 +1000 Subject: [PATCH 15/39] fix(mikrotik): support RouterOS 7.21 wrapped/commented BGP output RouterOS 7.21 emits BGP route detail differently over the looking-glass SSH driver, which broke the structured parser: - Active/best routes learned from a commented peer put the flag column and the ";;; comment" on a line of their own (afi=ip wraps to the next line), so the "A" flag was lost and no best route was highlighted. Derive active/filtered from the always-present "contribution=" field instead, falling back to the flag column for ROS v6. - Long ".communities" lists wrap across terminal-width lines; everything after the wrap was silently dropped. De-wrap comma-separated lists before tokenizing. - The garbage cleaner stripped any legend-looking line without "=", which also ate commented route flag lines. Match the "<char> - " legend shape instead. - Parse the v7 dotted ".ext-communities" key. Also restore the empty-BGP-table retry (4 attempts, 10s delay, MikroTik BGP queries only) that re-runs when RouterOS answers before its routing table has finished assembling. Test fixtures use RFC 5737 (IPv4) documentation addresses and RFC 5398 example ASNs only; no real network data. --- hyperglass/api/routes.py | 56 ++++++++- hyperglass/models/parsing/mikrotik.py | 60 +++++---- .../_builtin/mikrotik_garbage_output.py | 35 +++--- .../plugins/tests/test_bgp_route_mikrotik.py | 119 ++++++++++++++---- 4 files changed, 206 insertions(+), 64 deletions(-) diff --git a/hyperglass/api/routes.py b/hyperglass/api/routes.py index 36f7f3f4f..b84a0fabc 100644 --- a/hyperglass/api/routes.py +++ b/hyperglass/api/routes.py @@ -3,6 +3,7 @@ # Standard Library import json import time +import asyncio import typing as t from datetime import UTC, datetime @@ -37,6 +38,57 @@ "aspath_enrich", ) +# MikroTik occasionally answers a BGP query before its routing table has finished +# assembling, returning an empty route table on the first attempt. For MikroTik +# BGP queries only, retry a few times (with a delay) while the result is empty. +_MIKROTIK_PLATFORMS = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") +_MIKROTIK_BGP_MAX_ATTEMPTS = 4 +_MIKROTIK_BGP_RETRY_DELAY = 10 # seconds + + +def _is_empty_bgp_table(output: t.Any) -> bool: + """True if output is a structured BGP table containing no routes.""" + if not is_type(output, OutputDataModel): + return False + try: + raw = json.loads(output.export_json()) + except Exception: + return False + return ( + isinstance(raw, dict) + and raw.get("count", None) == 0 + and not raw.get("routes") + ) + + +async def _execute_query(data: "Query") -> t.Any: + """Execute a query, retrying MikroTik BGP queries that return an empty table.""" + directive_name = getattr(getattr(data, "directive", None), "name", "") or "" + is_bgp_query = "bgp_" in data.query_type or "bgp" in directive_name.lower() + is_mikrotik_bgp = data.device.platform in _MIKROTIK_PLATFORMS and is_bgp_query + max_attempts = _MIKROTIK_BGP_MAX_ATTEMPTS if is_mikrotik_bgp else 1 + + _log = log.bind(directive=data.query_type, device=data.device.name) + if is_mikrotik_bgp: + _log.bind(max_attempts=max_attempts, retry_delay=_MIKROTIK_BGP_RETRY_DELAY).debug( + "MikroTik BGP empty-table retry enabled" + ) + + output = None + for attempt in range(1, max_attempts + 1): + if attempt > 1: + _log.bind(attempt=attempt, max_attempts=max_attempts).warning( + "MikroTik returned empty BGP table - retrying after {}s", _MIKROTIK_BGP_RETRY_DELAY + ) + await asyncio.sleep(_MIKROTIK_BGP_RETRY_DELAY) + + output = await execute(data) + + if not (is_mikrotik_bgp and attempt < max_attempts and _is_empty_bgp_table(output)): + break + + return output + # Cap the number of ASNs accepted per enrichment request to bound outbound lookups. _MAX_ASPATH_ENRICH = 64 @@ -136,8 +188,8 @@ async def query(_state: HyperglassState, request: Request, data: Query) -> Query structured=data.device.structured_output or False, ) else: - # Pass request to execution module - output = await execute(data) + # Pass request to execution module (retries empty MikroTik BGP tables) + output = await _execute_query(data) endtime = time.time() elapsedtime = round(endtime - starttime, 4) diff --git a/hyperglass/models/parsing/mikrotik.py b/hyperglass/models/parsing/mikrotik.py index 1aea80f7d..4b0c578b5 100644 --- a/hyperglass/models/parsing/mikrotik.py +++ b/hyperglass/models/parsing/mikrotik.py @@ -126,16 +126,21 @@ def peer_rid(self) -> str: def _extract_paths(lines: t.List[str]) -> MikrotikPaths: - """Simple count based on lines with dst/dst-address and preferred 'A' flag.""" + """Count available/best paths. + + Prefers the RouterOS v7 ``contribution=active`` marker (which sits on the same + line as ``dst-address``) and falls back to the legacy ``A`` flag for ROS v6. + """ available = 0 best = 0 for raw in lines: if ("dst-address=" in raw) or (" dst=" in f" {raw} "): available += 1 - m = FLAGS_RE.match(raw) - if m: - flags = set(m.group(1)) - if "A" in flags: + if "contribution=active" in raw: + best += 1 + elif "contribution=" not in raw: + m = FLAGS_RE.match(raw) + if m and "A" in set(m.group(1)): best += 1 return MikrotikPaths(available=available, best=best, select=best) @@ -176,7 +181,7 @@ def _process_kv(route: dict, key: str, val: str): elif key in (".large-communities", "large-communities", "bgp-large-communities"): if val and val.lower() != "none": route["large_communities"] = [c.strip() for c in val.split(",") if c.strip()] - elif key == "bgp-ext-communities": + elif key in (".ext-communities", "ext-communities", "bgp-ext-communities"): if val and val.lower() != "none": route["ext_communities"] = [c.strip() for c in val.split(",") if c.strip()] elif key == "rpki": @@ -184,6 +189,11 @@ def _process_kv(route: dict, key: str, val: str): route["rpki_state"] = RPKI_STATE_MAP.get(clean_val, 2) elif key == "belongs-to": route["belongs_to"] = val + elif key == "contribution": + # ROS v7 selection state: active | candidate | filtered. This is the + # authoritative active/filtered signal (see _parse_route_block); the + # legacy flag column is only used as a fallback for ROS v6. + route["contribution"] = val.strip().lower() def _extract_route_entries(lines: t.List[str]) -> t.List[MikrotikRouteEntry]: @@ -229,7 +239,13 @@ def _parse_route_block(block: t.List[str]) -> t.Optional[MikrotikRouteEntry]: if "dst-address=" not in full_block_text and " dst=" not in f" {full_block_text} ": return None - flags = "" + # RouterOS wraps long output at the terminal width, splitting unquoted + # comma-separated lists (.communities / .large-communities) across physical + # lines. Joining with a space above turns the wrap into ", " inside the list; + # collapse it back so the whole list is captured as a single token. RouterOS + # never emits ", " within a route value otherwise, so this is safe. + full_block_text = re.sub(r",\s+", ",", full_block_text) + rd = { "prefix": "", "gateway": "", @@ -249,25 +265,27 @@ def _parse_route_block(block: t.List[str]) -> t.Optional[MikrotikRouteEntry]: "rpki_state": RPKI_STATE_MAP.get("unknown", 2), } - # Interpret flags in the first line: - # - "A" => active (preferred) - # - otherwise not active + # Find all key=value tokens in the entire block + for k, v in TOKEN_RE.findall(full_block_text): + _process_kv(rd, k, v) + + # Determine active/filtered state. Prefer the ROS v7 "contribution" marker, + # which always appears in the key=value data (robust to comment lines and to + # the flag column being wrapped/stripped). Fall back to the legacy flag + # column ("A" active; F/U/X filtered) for ROS v6, which has no contribution. + contribution = rd.pop("contribution", "") m = FLAGS_RE.match(block[0]) - if m: - flags = m.group(1) + flags = m.group(1) if m else "" + if contribution: + rd["is_active"] = contribution == "active" + rd["is_filtered"] = contribution == "filtered" + elif flags: flag_set = set(flags) - is_active = "A" in flag_set - is_filtered = bool(flag_set & {"F", "f", "U", "u", "X", "x"}) - - if is_active: + if "A" in flag_set: rd["is_active"] = True rd["is_filtered"] = False else: - rd["is_filtered"] = is_filtered - - # Find all key=value tokens in the entire block - for k, v in TOKEN_RE.findall(full_block_text): - _process_kv(rd, k, v) + rd["is_filtered"] = bool(flag_set & {"F", "f", "U", "u", "X", "x"}) if rd["prefix"]: try: diff --git a/hyperglass/plugins/_builtin/mikrotik_garbage_output.py b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py index 4bb64be24..ced46f191 100644 --- a/hyperglass/plugins/_builtin/mikrotik_garbage_output.py +++ b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py @@ -19,6 +19,13 @@ from hyperglass.models.api.query import Query +# Matches a RouterOS flag-legend line, e.g. "c - connect, s - static" or +# "H - hw-offloaded; + - ecmp". Deliberately anchored to "<char> - " so it never +# matches a route line such as "b ;;; comment" or "Ab afi=ip ..." (whose flag +# column is followed by whitespace, not " - "). +LEGEND_LINE_RE = re.compile(r"^[A-Za-z+]\s-\s") + + class MikrotikGarbageOutput(OutputPlugin): """Parse Mikrotik output to remove garbage before structured parsing.""" @@ -84,21 +91,14 @@ def _clean_traceroute_output(self, raw_output: str) -> str: if not tables: # Fallback to previous behavior: remove prompts and flags filtered_lines: t.List[str] = [] - in_flags_section = False for line in lines: stripped_line = line.strip() if stripped_line.startswith("@") and stripped_line.endswith("] >"): continue if "[Q quit|D dump|C-z pause]" in stripped_line: continue - if stripped_line.startswith("Flags:"): - in_flags_section = True + if stripped_line.startswith("Flags:") or LEGEND_LINE_RE.match(stripped_line): continue - if in_flags_section: - if "=" in stripped_line: - in_flags_section = False - else: - continue filtered_lines.append(line) return "\n".join(filtered_lines) @@ -223,7 +223,6 @@ def process(self, *, output: OutputType, query: "Query") -> Series[str]: # Original logic for other outputs (BGP routes, etc.) lines = raw_output.splitlines() filtered_lines = [] - in_flags_section = False for line in lines: stripped_line = line.strip() @@ -236,17 +235,13 @@ def process(self, *, output: OutputType, query: "Query") -> Series[str]: if "[Q quit|D dump|C-z pause]" in stripped_line: continue - # Begin detecting the Flags section - if stripped_line.startswith("Flags:"): - in_flags_section = True - continue # Skip the "Flags:" line itself - - # Within the flags section, check whether the line is still part of it - if in_flags_section: - if "=" in stripped_line: - in_flags_section = False - else: - continue # Skip the flag-legend lines + # Skip the flag legend: the "Flags:" header and its continuation + # lines ("c - connect, ...", "H - hw-offloaded; + - ecmp, ..."). + # Matching the "<char> - " shape (rather than "no '=' present") + # ensures a commented route line like "b ;;; peer-name" — which + # also has no "=" — is preserved instead of being dropped. + if stripped_line.startswith("Flags:") or LEGEND_LINE_RE.match(stripped_line): + continue filtered_lines.append(line) diff --git a/hyperglass/plugins/tests/test_bgp_route_mikrotik.py b/hyperglass/plugins/tests/test_bgp_route_mikrotik.py index d24518e17..976e317be 100644 --- a/hyperglass/plugins/tests/test_bgp_route_mikrotik.py +++ b/hyperglass/plugins/tests/test_bgp_route_mikrotik.py @@ -1,9 +1,11 @@ """MikroTik structured BGP route parsing tests. -The sample below is a trimmed, real `routing route print detail` capture from a -RouterOS 7.20.6 device (CCR2216), reduced to one active and one filtered route. -Long community lists are kept on a single line, matching what the device emits -to the looking-glass SSH driver (no terminal-width wrapping). +The samples below are synthetic captures shaped exactly like RouterOS +`routing route print detail` output, but scrubbed of any real network data: all +addresses use the RFC 5737 (IPv4) documentation ranges, autonomous-system +numbers use the RFC 5398 documentation range (64496-64511), and peer/interface +names are generic. Only the *structure* of the output is real, which is all the +parser cares about. """ # flake8: noqa @@ -22,16 +24,18 @@ if t.TYPE_CHECKING: from hyperglass.state import HyperglassState +# One active and one filtered route, each emitted on a single (unwrapped) line, +# as a wide terminal would produce. SAMPLE = """Flags: X - disabled, F - filtered, U - unreachable, A - active; c - connect, s - static, r - rip, b - bgp, n - bgp-net, o - ospf, i - isis, d - dhcp, v - vpn, m - modem, a - ldp-address, l - ldp-mapping, g - slaac, y - bgp-mpls-vpn, e - evpn; H - hw-offloaded; + - ecmp, B - blackhole - Ab afi=ip contribution=active dst-address=1.0.0.0/24 routing-table=main pref-src=192.0.2.1 gateway=196.60.8.198 - immediate-gw=196.60.8.198%[sfp28-01] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-196.60.8.198" - rpki=valid bgp.session=NAP-JHB-CloudFlare-v4-1 .as-path="13335" .communities=13335:10045 .large-communities=328964:2000:0,328964:2001:0 .local-pref=250 .med=0 .origin=igp + Ab afi=ip contribution=active dst-address=203.0.113.0/24 routing-table=main pref-src=192.0.2.1 gateway=198.51.100.1 + immediate-gw=198.51.100.1%[ether1] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-198.51.100.1" + rpki=valid bgp.session=peer-ix-1 .as-path="64496" .communities=64496:10045 .large-communities=64500:2000:0,64500:2001:0 .local-pref=250 .med=0 .origin=igp - Fb afi=ip contribution=filtered dst-address=5.101.88.0/24 routing-table=main pref-src=192.0.2.1 gateway=192.0.2.2 - immediate-gw=192.0.2.2%[sfp28-10] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-192.0.2.2" - rpki=invalid bgp.session=TRANSIT-ANGOLA-v4-1 .as-path="37468,41095,51601,50113,207569" .communities=37468:14100,37468:2000 .med=0 .origin=incomplete + Fb afi=ip contribution=filtered dst-address=198.51.100.0/24 routing-table=main pref-src=192.0.2.1 gateway=192.0.2.2 + immediate-gw=192.0.2.2%[ether2] distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-192.0.2.2" + rpki=invalid bgp.session=transit-1 .as-path="64497,64498,64499,64500,64501" .communities=64497:14100,64497:2000 .med=0 .origin=incomplete """ @@ -58,21 +62,94 @@ def test_mikrotik_bgp_route(state): assert table.count == 2, f"Expected 2 routes, got {table.count}" by_prefix = {r.prefix: r for r in table.routes} - assert set(by_prefix) == {"1.0.0.0/24", "5.101.88.0/24"} + assert set(by_prefix) == {"203.0.113.0/24", "198.51.100.0/24"} - # Active route (flag "A"): RPKI valid (state 1), single-ASN path. - active = by_prefix["1.0.0.0/24"] + # Active route (contribution=active): RPKI valid (state 1), single-ASN path. + active = by_prefix["203.0.113.0/24"] assert active.active is True assert active.filtered is False - assert active.as_path == [13335] - assert active.next_hop == "196.60.8.198" - assert "13335:10045" in active.communities + assert active.as_path == [64496] + assert active.next_hop == "198.51.100.1" + assert "64496:10045" in active.communities assert active.rpki_state == 1 # valid - # Filtered route (flag "F" => filtered per the RouterOS flags legend): RPKI - # invalid (state 0), multi-ASN path. - filtered = by_prefix["5.101.88.0/24"] + # Filtered route (contribution=filtered): RPKI invalid (state 0), multi-ASN path. + filtered = by_prefix["198.51.100.0/24"] assert filtered.active is False - assert filtered.filtered is True, "F-flagged route should be marked filtered" - assert filtered.as_path == [37468, 41095, 51601, 50113, 207569] + assert filtered.filtered is True, "filtered route should be marked filtered" + assert filtered.as_path == [64497, 64498, 64499, 64500, 64501] assert filtered.rpki_state == 0 # invalid + + +# Wrapped/commented capture as RouterOS 7.21 emits it over the looking-glass SSH +# driver. Two properties that broke the legacy parser are exercised here: +# 1. The *active* route is learned from a commented peer, so RouterOS emits the +# flag column ("Ab") and the ";;; comment" on a line of their own, with +# "afi=ip" wrapped to the next line. Active state must come from +# "contribution=active", not the (now-separated) flag column. +# 2. A long ".communities" list is wrapped across two terminal-width lines; all +# members must survive (no truncation at the wrap point). +# 3. An unquoted interface description containing spaces ("vlan10 CORE LINK") +# must not swallow the following key=value tokens. +WRAPPED_SAMPLE = """Flags: X - disabled, F - filtered, U - unreachable, A - active; +c - connect, s - static, r - rip, b - bgp, n - bgp-net, o - ospf, i - isis, d - dhcp, v - vpn, m - modem, a - ldp-address, l - ldp-mapping, g - slaac, y - bgp-mpls-vpn, e - evpn; +H - hw-offloaded; + - ecmp, B - blackhole + Ab ;;; peer-ix-1 + afi=ip + contribution=active dst-address=203.0.113.0/24 routing-table=main + pref-src=192.0.2.1 gateway=198.51.100.1 + immediate-gw=198.51.100.1%[ether1] IX PEERING + distance=20 scope=40 target-scope=10 belongs-to="bgp-IP-198.51.100.1" + rpki=valid bgp.session=peer-ix-1 + .aggregator="64496:192.0.2.1" .as-path="64496" + .communities=64496:100,64496:200,64496:300,64497:100,64497:200, + 64497:300,64498:100,64498:200,64498:300 + .large-communities=64500:2000:0,64500:2001:0 .local-pref=250 .origin=igp + debug.fwp-ptr=0x00000000 + + b afi=ip contribution=candidate dst-address=203.0.113.0/24 routing-table=main + pref-src=192.0.2.1 gateway=192.0.2.10 + immediate-gw=192.0.2.10%vlan10 CORE LINK distance=200 scope=40 + target-scope=30 belongs-to="bgp-IP-192.0.2.10" + bgp.session=core-edge-1 .as-path="64496" .communities=64496:400 + .local-pref=250 .origin=igp + debug.fwp-ptr=0x00000001 +""" + + +def test_mikrotik_bgp_route_wrapped_commented(state): + """Regression: RouterOS 7.21 wrapped output with a commented active route.""" + parsed = MikrotikBGPTable.parse_text(WRAPPED_SAMPLE) + + # Exactly one active (best) route must be detected, via contribution=active, + # even though its flag column sits on a separate ";;; comment" line. + active_entries = [r for r in parsed.routes if r.is_active] + assert len(active_entries) == 1, "commented active route must still be detected" + active = active_entries[0] + assert active.gateway == "198.51.100.1" + + # The wrapped 9-member community list must be captured in full, not truncated + # at the terminal-width wrap point. + assert active.communities == [ + "64496:100", + "64496:200", + "64496:300", + "64497:100", + "64497:200", + "64497:300", + "64498:100", + "64498:200", + "64498:300", + ] + + # The candidate route's unquoted interface description ("vlan10 CORE LINK") + # must not have swallowed the following key=value tokens. + candidate = [r for r in parsed.routes if not r.is_active][0] + assert candidate.gateway == "192.0.2.10" + assert candidate.local_preference == 250 + assert candidate.communities == ["64496:400"] + + # The full conversion to the canonical BGP table must still succeed. + table = parsed.bgp_table() + assert isinstance(table, BGPRouteTable) + assert table.count == 2, f"Expected 2 routes, got {table.count}" From 50ed24f268653d6552aaf7846d6f5b73bf8beaff Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Thu, 2 Jul 2026 12:09:47 +1000 Subject: [PATCH 16/39] feat(logging): opt-in JSONL query-sample logging (raw + parsed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `logging.samples` config block that, when enabled, appends one JSON object per query to a JSONL file capturing the request, the raw device output as hyperglass received it, and the parsed/structured result — including failures (empty responses, timeouts, parse errors) with the error and runtime. Motivation: parser issues (e.g. RouterOS version differences) were hard to debug because we had no record of the exact device output the app received. The JSONL stream is intended to be tailed by a log shipper (Grafana Alloy) into Loki, and to build a corpus of real raw+parsed samples for parser regression fixtures. - models/config/logging.py: QuerySamples model (enable, path, include_raw, include_parsed, max_size) wired into Logging.samples; disabled by default. - util/query_samples.py: best-effort JSONL writer with size-based rotation and raw/parsed normalization. Writing never affects the query. - execution/main.py: capture at the point where request, raw output and parsed result coexist, on both success and failure; driven per-query from params so it is multi-worker safe and near-zero cost when disabled. - tests: disabled-noop, success, failure + include flags, custom path/append, rotation. Default output: <logging.directory>/query_samples.jsonl. Note raw output can contain internal addresses/communities/interface names. --- hyperglass/execution/main.py | 90 +++++++--- hyperglass/models/config/logging.py | 23 ++- hyperglass/util/query_samples.py | 177 ++++++++++++++++++++ hyperglass/util/tests/test_query_samples.py | 90 ++++++++++ 4 files changed, 352 insertions(+), 28 deletions(-) create mode 100644 hyperglass/util/query_samples.py create mode 100644 hyperglass/util/tests/test_query_samples.py diff --git a/hyperglass/execution/main.py b/hyperglass/execution/main.py index 08c9483e6..9a77b5d40 100644 --- a/hyperglass/execution/main.py +++ b/hyperglass/execution/main.py @@ -7,6 +7,7 @@ """ # Standard Library +import time import signal from typing import TYPE_CHECKING, Any, Dict, Union, Callable @@ -14,6 +15,7 @@ from hyperglass.log import log from hyperglass.state import use_state from hyperglass.util.typing import is_series +from hyperglass.util.query_samples import log_query_sample from hyperglass.exceptions.public import DeviceTimeout, ResponseEmpty if TYPE_CHECKING: @@ -59,32 +61,66 @@ async def execute(query: "Query") -> Union["OutputDataModel", str]: ) signal.alarm(params.request_timeout - 1) - if query.device.proxy: - proxy = driver.setup_proxy() - with proxy() as tunnel: - response = await driver.collect(tunnel.local_bind_host, tunnel.local_bind_port) - else: - response = await driver.collect() - - output = await driver.response(response) - - if is_series(output): - if len(output) == 0: - raise ResponseEmpty(query=query) - output = "\n\n".join(output) - - elif isinstance(output, str): - # If the output is a string (not structured) and is empty, - # produce an error. - if output == "" or output == "\n": - raise ResponseEmpty(query=query) - - elif isinstance(output, Dict): - # If the output an empty dict, responses have data, produce an - # error. - if not output: - raise ResponseEmpty(query=query) - - signal.alarm(0) + # Capture the raw device output and parsed result (and failures) as JSONL + # samples when logging.samples is enabled. Best-effort; never affects the query. + samples = params.logging.samples + started = time.monotonic() + response = None + output = None + + try: + if query.device.proxy: + proxy = driver.setup_proxy() + with proxy() as tunnel: + response = await driver.collect(tunnel.local_bind_host, tunnel.local_bind_port) + else: + response = await driver.collect() + + output = await driver.response(response) + + if is_series(output): + if len(output) == 0: + raise ResponseEmpty(query=query) + output = "\n\n".join(output) + + elif isinstance(output, str): + # If the output is a string (not structured) and is empty, + # produce an error. + if output == "" or output == "\n": + raise ResponseEmpty(query=query) + + elif isinstance(output, Dict): + # If the output an empty dict, responses have data, produce an + # error. + if not output: + raise ResponseEmpty(query=query) + + except Exception as error: + if samples.enable: + log_query_sample( + query, + samples, + params.logging.directory, + raw=response, + parsed=output, + error=error, + runtime=round(time.monotonic() - started, 4), + structured=output is not None and not isinstance(output, str), + ) + raise + finally: + signal.alarm(0) + + if samples.enable: + log_query_sample( + query, + samples, + params.logging.directory, + raw=response, + parsed=output, + error=None, + runtime=round(time.monotonic() - started, 4), + structured=output is not None and not isinstance(output, str), + ) return output diff --git a/hyperglass/models/config/logging.py b/hyperglass/models/config/logging.py index 3604dd0a9..aef18f53f 100644 --- a/hyperglass/models/config/logging.py +++ b/hyperglass/models/config/logging.py @@ -5,7 +5,7 @@ from pathlib import Path # Third Party -from pydantic import ByteSize, SecretStr, AnyHttpUrl, DirectoryPath, field_validator +from pydantic import Field, ByteSize, SecretStr, AnyHttpUrl, DirectoryPath, field_validator # Project from hyperglass.constants import __version__ @@ -78,6 +78,26 @@ def __init__(self, **kwargs): dumped["auth"] = self.authentication.basic() +class QuerySamples(HyperglassModel): + """Structured request/response capture (raw + parsed) as JSON lines. + + When enabled, every query appends one JSON object to a JSONL file capturing + the request, the raw device output, and the parsed result (and failures). + Intended for shipping to Loki via Grafana Alloy and for building parser + regression fixtures. Disabled by default. + + Note: raw output can contain internal addresses/communities/interface names. + """ + + enable: bool = False + # Output file. Defaults to "<logging.directory>/hyperglass_query_log.jsonl". + path: t.Optional[Path] = None + include_raw: bool = True + include_parsed: bool = True + # Size-based rotation threshold; a single "<name>.1" backup is kept. + max_size: ByteSize = "50MB" + + class Logging(HyperglassModel): """Validation model for logging configuration.""" @@ -86,3 +106,4 @@ class Logging(HyperglassModel): max_size: ByteSize = "50MB" syslog: t.Optional[Syslog] = None http: t.Optional[Http] = None + samples: QuerySamples = Field(default_factory=QuerySamples) diff --git a/hyperglass/util/query_samples.py b/hyperglass/util/query_samples.py new file mode 100644 index 000000000..3f8ee69f2 --- /dev/null +++ b/hyperglass/util/query_samples.py @@ -0,0 +1,177 @@ +"""Structured request/response sample logging. + +Appends one JSON object per line (JSONL) capturing each query's request, the raw +device output as hyperglass received it, and the parsed/structured result — plus +failures (empty responses, timeouts, parse errors). Intended to be tailed by a +log shipper (e.g. Grafana Alloy) into Loki, and to build a corpus of real +raw+parsed samples for parser regression fixtures. + +Enabled via ``logging.samples`` in the hyperglass config; disabled by default. +Writing is best-effort and must never affect the query itself. +""" + +# Standard Library +import json +import typing as t +import threading +from pathlib import Path +from datetime import datetime, timezone + +# Project +from hyperglass.log import log + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.api import Query + from hyperglass.models.config.logging import QuerySamples + +# Guards the check-size-then-rotate step within a process. Not needed for the +# appends themselves — see _emit for why concurrent writes are already safe. +_write_lock = threading.Lock() + +DEFAULT_FILENAME = "hyperglass_query_log.jsonl" + + +def _resolve_path(cfg: "QuerySamples", directory: Path) -> Path: + """Determine the JSONL output path (explicit path, else <directory>/<default>).""" + if cfg.path is not None: + return Path(cfg.path) + return Path(directory) / DEFAULT_FILENAME + + +def _rotate_if_needed(path: Path, max_size: int) -> None: + """Size-based rotation: when the file reaches max_size, move it to '<name>.1'. + + A single backup is kept; a log shipper following the live file re-opens on + rotation. Best-effort — rotation failures never block a write. + """ + try: + if max_size and path.exists() and path.stat().st_size >= max_size: + backup = path.with_name(path.name + ".1") + if backup.exists(): + backup.unlink() + path.rename(backup) + except Exception: # noqa: BLE001 - rotation is best-effort + pass + + +def _normalize_raw(raw: t.Any) -> t.Union[t.List[str], str, None]: + """Represent raw device output as a list of strings (per command) or a string.""" + if raw is None: + return None + if isinstance(raw, (list, tuple)): + return [r if isinstance(r, str) else str(r) for r in raw] + return raw if isinstance(raw, str) else str(raw) + + +def _normalize_parsed(parsed: t.Any) -> t.Any: + """Represent parsed output as JSON-friendly data (dict/list) or a string.""" + if parsed is None: + return None + # Structured hyperglass models expose export_dict(). + export = getattr(parsed, "export_dict", None) + if callable(export): + try: + return export() + except Exception: # noqa: BLE001 + pass + export_json = getattr(parsed, "export_json", None) + if callable(export_json): + try: + return json.loads(export_json()) + except Exception: # noqa: BLE001 + pass + if isinstance(parsed, (dict, list, str, int, float, bool)): + return parsed + return str(parsed) + + +def build_sample( + query: "Query", + cfg: "QuerySamples", + *, + raw: t.Any, + parsed: t.Any, + error: t.Optional[BaseException], + runtime: t.Optional[float], + structured: bool, +) -> t.Dict[str, t.Any]: + """Assemble the JSON record for one query, honoring include_raw/include_parsed.""" + device = query.device + record: t.Dict[str, t.Any] = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "device": { + "id": getattr(device, "id", None), + "name": getattr(device, "name", None), + "platform": getattr(device, "platform", None), + }, + "query": { + "type": query.query_type, + "target": query.query_target, + "location": query.query_location, + }, + "structured": structured, + "runtime_s": runtime, + "error": None + if error is None + else {"type": type(error).__name__, "message": str(error)}, + } + if cfg.include_raw: + record["raw"] = _normalize_raw(raw) + if cfg.include_parsed: + record["parsed"] = _normalize_parsed(parsed) + return record + + +def log_query_sample( + query: "Query", + cfg: "QuerySamples", + directory: Path, + *, + raw: t.Any = None, + parsed: t.Any = None, + error: t.Optional[BaseException] = None, + runtime: t.Optional[float] = None, + structured: bool = False, +) -> None: + """Append one JSONL sample record. Best-effort; never raises to the caller.""" + if not cfg.enable: + return + try: + path = _resolve_path(cfg, directory) + record = build_sample( + query, + cfg, + raw=raw, + parsed=parsed, + error=error, + runtime=runtime, + structured=structured, + ) + line = json.dumps(record, default=str, ensure_ascii=False) + _emit(path, line + "\n", int(cfg.max_size)) + except Exception as err: # noqa: BLE001 - sample logging must not break queries + log.bind(error=str(err)).debug("Failed to write query sample") + + +def _emit(path: Path, line: str, max_size: int) -> None: + """Rotate-if-needed, then append one line. No lock files, no cross-process locks. + + Safe under multiple uvicorn workers because: + + * Each record is written to a file opened in append mode (``O_APPEND``), which + the Linux kernel guarantees is atomic — concurrent workers cannot interleave + or overwrite each other's lines, so no lock is needed for the writes. + * The in-process ``threading.Lock`` only guards the check-size-then-rotate + step against other async tasks in the same worker. + * The one thing not serialized across *processes* is the rotation rename. At a + looking-glass query rate against a large ``max_size`` that collision is + vanishingly rare, and its only effect would be clobbering a redundant ``.1`` + backup whose lines a shipper (Alloy) has already delivered to Loki. This is + an intentional trade to avoid lock-file machinery in a best-effort logger. + """ + path.parent.mkdir(parents=True, exist_ok=True) + with _write_lock: + _rotate_if_needed(path, max_size) + with path.open("a", encoding="utf-8") as handle: + handle.write(line) diff --git a/hyperglass/util/tests/test_query_samples.py b/hyperglass/util/tests/test_query_samples.py new file mode 100644 index 000000000..a30498558 --- /dev/null +++ b/hyperglass/util/tests/test_query_samples.py @@ -0,0 +1,90 @@ +"""Tests for JSONL query-sample logging.""" + +# flake8: noqa +# Standard Library +import json +from types import SimpleNamespace + +# Project +from hyperglass.models.config.logging import QuerySamples +from hyperglass.util.query_samples import log_query_sample + + +def _fake_query(): + device = SimpleNamespace(id="dev1", name="Dev One", platform="mikrotik_routeros") + return SimpleNamespace( + device=device, + query_type="bgp_route", + query_target="1.0.0.0/24", + query_location="dev1", + ) + + +def test_disabled_writes_nothing(tmp_path): + cfg = QuerySamples(enable=False) + log_query_sample(_fake_query(), cfg, tmp_path, raw=["x"], parsed={"a": 1}) + assert not (tmp_path / "hyperglass_query_log.jsonl").exists() + + +def test_success_record(tmp_path): + cfg = QuerySamples(enable=True) + log_query_sample( + _fake_query(), + cfg, + tmp_path, + raw=["ADDRESS ... first line"], + parsed={"count": 2, "routes": []}, + runtime=1.5, + structured=True, + ) + f = tmp_path / "hyperglass_query_log.jsonl" + assert f.exists() + rec = json.loads(f.read_text().strip()) + assert rec["query"] == {"type": "bgp_route", "target": "1.0.0.0/24", "location": "dev1"} + assert rec["device"]["platform"] == "mikrotik_routeros" + assert rec["raw"] == ["ADDRESS ... first line"] + assert rec["parsed"] == {"count": 2, "routes": []} + assert rec["structured"] is True + assert rec["error"] is None + assert rec["runtime_s"] == 1.5 + assert "timestamp" in rec + + +def test_failure_record_and_include_flags(tmp_path): + # include_parsed=False must omit the parsed key entirely; error captured. + cfg = QuerySamples(enable=True, include_raw=True, include_parsed=False) + log_query_sample( + _fake_query(), + cfg, + tmp_path, + raw="raw device text", + parsed={"should": "be omitted"}, + error=ValueError("empty response"), + runtime=0.2, + ) + rec = json.loads((tmp_path / "hyperglass_query_log.jsonl").read_text().strip()) + assert rec["error"]["type"] == "ValueError" + assert "empty response" in rec["error"]["message"] + assert rec["raw"] == "raw device text" + assert "parsed" not in rec + + +def test_custom_path_and_appends(tmp_path): + p = tmp_path / "nested" / "custom.jsonl" + cfg = QuerySamples(enable=True, path=p) + q = _fake_query() + log_query_sample(q, cfg, tmp_path, raw=["a"], parsed=None) + log_query_sample(q, cfg, tmp_path, raw=["b"], parsed=None) + lines = p.read_text().strip().splitlines() + assert len(lines) == 2 + assert [json.loads(x)["raw"] for x in lines] == [["a"], ["b"]] + + +def test_rotation_keeps_single_backup(tmp_path): + cfg = QuerySamples(enable=True, max_size="200B") + q = _fake_query() + for _ in range(20): + log_query_sample(q, cfg, tmp_path, raw=["x" * 40], parsed=None) + live = tmp_path / "hyperglass_query_log.jsonl" + backup = tmp_path / "hyperglass_query_log.jsonl.1" + assert live.exists() and backup.exists() From 2c08c15869992adff47700579eb398651a7eda70 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Thu, 2 Jul 2026 12:53:24 +1000 Subject: [PATCH 17/39] fix(server): honor HYPERGLASS_WORKERS for web worker count The web-worker count was hardcoded to 2x CPU cores when debug was off (via an unused `workers` property and duplicate inline logic in run()), so a 24-core host span 48 uvicorn workers and the documented HYPERGLASS_WORKERS env var was silently ignored. Add a real `workers` settings field (HYPERGLASS_WORKERS) and a `worker_count` property that honors it (clamped to >= 1), falling back to 1 in debug mode or 2x CPU cores otherwise. run() now uses Settings.worker_count. --- hyperglass/main.py | 9 +----- hyperglass/models/system.py | 11 +++++-- .../models/tests/test_system_workers.py | 31 +++++++++++++++++++ 3 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 hyperglass/models/tests/test_system_workers.py diff --git a/hyperglass/main.py b/hyperglass/main.py index 51d21da73..32ab94da3 100644 --- a/hyperglass/main.py +++ b/hyperglass/main.py @@ -28,7 +28,6 @@ # Local -from .util import cpu_count from .state import use_state from .settings import Settings @@ -151,13 +150,7 @@ def run(workers: int = None): host=state.params.logging.syslog.host, port=state.params.logging.syslog.port, ) - _workers = workers - - if workers is None: - if Settings.debug: - _workers = 1 - else: - _workers = cpu_count(2) + _workers = workers if workers is not None else Settings.worker_count log.bind( version=__version__, diff --git a/hyperglass/models/system.py b/hyperglass/models/system.py index 049452631..d1490ca01 100644 --- a/hyperglass/models/system.py +++ b/hyperglass/models/system.py @@ -50,6 +50,7 @@ class HyperglassSettings(BaseSettings): port: int = 8001 ca_cert: t.Optional[FilePath] = None container: bool = False + workers: t.Optional[int] = None def __init__(self, **kwargs) -> None: """Create hyperglass Settings instance.""" @@ -136,8 +137,14 @@ def log_level(self: "HyperglassSettings") -> str: return "WARNING" @property - def workers(self: "HyperglassSettings") -> int: - """Get worker count, inferred from debug mode.""" + def worker_count(self: "HyperglassSettings") -> int: + """Number of web-server workers. + + Honors an explicit ``HYPERGLASS_WORKERS`` (the ``workers`` field, clamped + to at least 1) when set; otherwise 1 in debug mode, else 2x CPU cores. + """ + if self.workers is not None: + return max(1, self.workers) if self.debug: return 1 return cpu_count(2) diff --git a/hyperglass/models/tests/test_system_workers.py b/hyperglass/models/tests/test_system_workers.py new file mode 100644 index 000000000..71ebf0b09 --- /dev/null +++ b/hyperglass/models/tests/test_system_workers.py @@ -0,0 +1,31 @@ +"""Tests for HyperglassSettings.worker_count.""" + +# flake8: noqa +# Project +from hyperglass.util import cpu_count +from hyperglass.models.system import HyperglassSettings + + +def _settings(tmp_path, **kwargs): + return HyperglassSettings(app_path=tmp_path, **kwargs) + + +def test_explicit_workers_honored(tmp_path): + assert _settings(tmp_path, debug=False, workers=4).worker_count == 4 + + +def test_workers_clamped_to_at_least_one(tmp_path): + assert _settings(tmp_path, debug=False, workers=0).worker_count == 1 + + +def test_debug_defaults_to_one(tmp_path): + assert _settings(tmp_path, debug=True).worker_count == 1 + + +def test_unset_non_debug_uses_cpu_default(tmp_path): + assert _settings(tmp_path, debug=False).worker_count == cpu_count(2) + + +def test_explicit_workers_overrides_debug(tmp_path): + # An explicit worker count wins even in debug mode. + assert _settings(tmp_path, debug=True, workers=3).worker_count == 3 From c188a9c3ee217ced5a81b1a60db14809a275e183 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Thu, 2 Jul 2026 14:31:43 +1000 Subject: [PATCH 18/39] fix(netmiko): widen MikroTik send_command_timing last_read to avoid empty reads MikroTik can take a couple seconds to start emitting output for larger queries (the command executes before any bytes return). netmiko send_command_timing's default last_read (2.0s) elapses during that initial silence and returns an empty string, so hyperglass parses 0 routes even though the device would return a full table. It is latency-sensitive: it shows up for devices tens of ms away (e.g. a Cape Town router queried from a Johannesburg node) while nearby devices squeak through, and the empty-table retry cannot help since every attempt hits the same window. Set last_read=5.0 for mikrotik_routeros/switchos so the read waits for output to begin. Empirically last_read>=4.0 reliably returns the full output where 2.0 returns empty; trade-off is ~5s added per MikroTik query (last_read is also the trailing quiet-wait). send_command (prompt-based) was tried and rejected: RouterOS prompt detection truncates output through the driver, which is why send_command_timing is used. --- hyperglass/execution/drivers/ssh_netmiko.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hyperglass/execution/drivers/ssh_netmiko.py b/hyperglass/execution/drivers/ssh_netmiko.py index 52fc588c1..e42620e9a 100644 --- a/hyperglass/execution/drivers/ssh_netmiko.py +++ b/hyperglass/execution/drivers/ssh_netmiko.py @@ -29,7 +29,10 @@ "mikrotik_switchos": {"global_cmd_verify": False}, } -netmiko_device_send_args = {} +netmiko_device_send_args = { + "mikrotik_routeros": {"last_read": 5.0}, + "mikrotik_switchos": {"last_read": 5.0}, +} class NetmikoConnection(SSHConnection): From 9a0a1adfef34d0aa7d40498375f4821261d9ecc2 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Thu, 6 Aug 2026 09:51:15 +1000 Subject: [PATCH 19/39] fix(state): rebuild Redis-backed state when it is lost at runtime All runtime configuration (params, directives, devices, ui_params) and the plugin registry were written to Redis in exactly one place, run() at startup, and never rewritten. Redis is deliberately cache-only, so anything that emptied it while hyperglass was running left the process with no state and no way back: every query raised StateError: 'devices' ('hyperglass.state.devices') does not exist until hyperglass was restarted. A replaced Redis container does exactly this, and `docker compose up -d` recreates Redis on its own whenever that image changes, without touching hyperglass. The retry in api/state.py caught the error but nothing repopulated state, so it only turned a fast failure into a 2.5s slow one. Rebuild in place instead. rebuild_state() re-runs init_user_config() and register_all_plugins() under a Redis SET NX EX lock, so one process does the work and the others wait for it rather than all re-reading and re-validating the configuration, with a cooldown so a configuration that cannot be loaded is not re-validated on every request. It deliberately does not call state.clear(): flushing is correct once at startup, but mid-flight it would wipe state other workers are using. Two things this got wrong on the first attempt, both found by testing against a real deployment rather than a fixture: The recovery belongs on the HyperglassState properties, not on use_state(). The request path does not go through use_state: models/api/query.py reads self._state.devices and the query route reads _state.params straight off a state instance. use_state also memoizes, so a worker that has already read devices never asks Redis again, which keeps /api/devices returning 200 with the store empty and hides the failure. The plugin registry lives in the same store and init_user_config() does not write it, so recovering configuration alone brought hyperglass back with no output plugins: queries returned raw text instead of structured results, silently. register_all_plugins() moves from main.py into hyperglass.plugins so recovery can call it, and _wait_for_rebuild waits for the lock to be released as well as the keys to appear, because plugins are registered after the configuration is written. Also here, since they share a cause: clear() now scans and deletes only hyperglass.state.* instead of flushing the database, which was deleting keys belonging to anything else sharing it; init_user_config() writes a generation UUID last, so its presence means state is complete and a rebuilt store is distinguishable from the original in the logs; and the compose healthcheck points at /api/info, which reads state, rather than the UI root, which does not and so reported healthy through a total query outage. Verified against a live deployment: destroy the Redis container and its data volume, leave hyperglass running, and a BGP route query still returns structured output with the store repopulated. --- compose.yaml | 13 ++ hyperglass/api/state.py | 6 +- hyperglass/configuration/__init__.py | 4 + hyperglass/main.py | 28 +--- hyperglass/plugins/__init__.py | 3 +- hyperglass/plugins/main.py | 30 ++++ hyperglass/state/hooks.py | 7 +- hyperglass/state/recovery.py | 131 +++++++++++++++++ hyperglass/state/store.py | 57 +++++++- hyperglass/state/tests/test_recovery.py | 185 ++++++++++++++++++++++++ 10 files changed, 430 insertions(+), 34 deletions(-) create mode 100644 hyperglass/state/recovery.py create mode 100644 hyperglass/state/tests/test_recovery.py diff --git a/compose.yaml b/compose.yaml index 74a6ff6d1..9ccc0ba27 100644 --- a/compose.yaml +++ b/compose.yaml @@ -19,3 +19,16 @@ services: - "${HYPERGLASS_PORT-8001}:${HYPERGLASS_PORT-8001}" volumes: - ${HYPERGLASS_APP_PATH-/etc/hyperglass}:/etc/hyperglass + healthcheck: + # /api/info reads Redis-backed state. The UI root does not, so it keeps + # returning 200 through a total query outage. + test: + [ + "CMD-SHELL", + "wget -qO- http://127.0.0.1:${HYPERGLASS_PORT-8001}/api/info >/dev/null || exit 1", + ] + interval: 30s + timeout: 10s + retries: 3 + # The UI is built on boot, which takes a couple of minutes. + start_period: 180s diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py index 5dfd75ef9..ddbace57b 100644 --- a/hyperglass/api/state.py +++ b/hyperglass/api/state.py @@ -22,7 +22,11 @@ def _is_retryable(attr: t.Optional[str]) -> bool: async def _get_state_with_retry( attr: t.Optional[str] = None, max_retries: int = 5, retry_delay: float = 0.5 ) -> t.Any: - """Get hyperglass state, retrying transient startup StateErrors.""" + """Get hyperglass state, retrying transient startup StateErrors. + + State that goes missing *after* startup is repopulated by `use_state` itself; these + retries only cover the window where another process is still writing it. + """ for attempt in range(1, max_retries + 1): try: return use_state(attr) diff --git a/hyperglass/configuration/__init__.py b/hyperglass/configuration/__init__.py index 4d425159a..0596fb404 100644 --- a/hyperglass/configuration/__init__.py +++ b/hyperglass/configuration/__init__.py @@ -2,6 +2,7 @@ # Standard Library import typing as t +from uuid import uuid4 # Project from hyperglass.state import use_state @@ -43,3 +44,6 @@ def init_user_config( with state.cache.pipeline() as pipeline: pipeline.set("devices", _devices) pipeline.set("ui_params", ui_params) + # Written last, so its presence means state is complete. Changes on every load, + # so a lost and rebuilt store is distinguishable from the original. + pipeline.set("generation", str(uuid4())) diff --git a/hyperglass/main.py b/hyperglass/main.py index 32ab94da3..4a0576c97 100644 --- a/hyperglass/main.py +++ b/hyperglass/main.py @@ -52,31 +52,6 @@ async def build_ui() -> bool: return True -def register_all_plugins() -> None: - """Validate and register configured plugins.""" - - # Local - from .plugins import register_plugin, init_builtin_plugins - - state = use_state() - - # Register built-in plugins. - init_builtin_plugins() - - failures = () - - # Register external directive-based plugins (defined in directives). - for plugin_file, directives in state.devices.directive_plugins().items(): - failures += register_plugin(plugin_file, directives=directives) - - # Register external global/common plugins (defined in config). - for plugin_file in state.params.common_plugins(): - failures += register_plugin(plugin_file, common=True) - - for failure in failures: - log.bind(plugin=failure).warning("Invalid hyperglass plugin") - - def unregister_all_plugins() -> None: """Unregister all plugins.""" # Local @@ -88,6 +63,8 @@ def unregister_all_plugins() -> None: def start(*, log_level: t.Union[str, int], workers: int) -> None: """Start hyperglass via ASGI server.""" + # Local + from .plugins import register_all_plugins register_all_plugins() @@ -154,6 +131,7 @@ def run(workers: int = None): log.bind( version=__version__, + state_generation=state.generation, listening=f"http://{Settings.bind()}", app_path=f"{Settings.app_path.absolute()!s}", container=Settings.container, diff --git a/hyperglass/plugins/__init__.py b/hyperglass/plugins/__init__.py index 52933a104..dc4f62cff 100644 --- a/hyperglass/plugins/__init__.py +++ b/hyperglass/plugins/__init__.py @@ -1,7 +1,7 @@ """hyperglass Plugins.""" # Local -from .main import register_plugin, init_builtin_plugins +from .main import register_plugin, init_builtin_plugins, register_all_plugins from ._input import InputPlugin, InputPluginValidationReturn from ._output import OutputType, OutputPlugin from ._manager import InputPluginManager, OutputPluginManager @@ -15,4 +15,5 @@ "OutputPluginManager", "OutputType", "register_plugin", + "register_all_plugins", ) diff --git a/hyperglass/plugins/main.py b/hyperglass/plugins/main.py index abc83fb6e..7848a4f9e 100644 --- a/hyperglass/plugins/main.py +++ b/hyperglass/plugins/main.py @@ -67,3 +67,33 @@ def register_plugin(plugin_file: Path, **kwargs) -> t.Tuple[str, ...]: results = _register_from_module(module, ref=plugin_file.stem, **kwargs) return results raise FileNotFoundError(str(plugin_file)) + + +def register_all_plugins() -> None: + """Validate and register all built-in and configured plugins. + + The registry lives in Redis alongside the rest of the state, so it is lost with + everything else if the store is emptied. Registering is idempotent, which is what + lets state recovery call this as well as startup. + """ + # Project + from hyperglass.log import log + from hyperglass.state import use_state + + state = use_state() + + # Register built-in plugins. + init_builtin_plugins() + + failures = () + + # Register external directive-based plugins (defined in directives). + for plugin_file, directives in state.devices.directive_plugins().items(): + failures += register_plugin(plugin_file, directives=directives) + + # Register external global/common plugins (defined in config). + for plugin_file in state.params.common_plugins(): + failures += register_plugin(plugin_file, common=True) + + for failure in failures: + log.bind(plugin=failure).warning("Invalid hyperglass plugin") diff --git a/hyperglass/state/hooks.py b/hyperglass/state/hooks.py index 107655329..0953d0d6b 100644 --- a/hyperglass/state/hooks.py +++ b/hyperglass/state/hooks.py @@ -73,5 +73,10 @@ def use_state(attr=None) -> "HyperglassState": def use_state(attr: t.Optional[str] = None) -> "HyperglassState": - """Access global hyperglass state.""" + """Access global hyperglass state. + + State lost from Redis after startup is rebuilt by the `HyperglassState` properties + themselves, so it is recovered whether it is reached from here or read directly off + a state instance. + """ return _use_state(attr) diff --git a/hyperglass/state/recovery.py b/hyperglass/state/recovery.py new file mode 100644 index 000000000..8f1a01499 --- /dev/null +++ b/hyperglass/state/recovery.py @@ -0,0 +1,131 @@ +"""Recover Redis-backed state that is lost while hyperglass is running. + +All runtime configuration (`params`, `directives`, `devices`, `ui_params`) is written to +Redis exactly once, by `hyperglass.main.run` at startup. Redis is intentionally +ephemeral - the reference deployment runs it with no RDB and no AOF - so anything that +empties the store leaves a running hyperglass with no state and no way to get it back: +every state-touching request raises `StateError` until the process is restarted. A +replaced Redis container does exactly this, and `docker compose up -d` will replace +Redis without touching hyperglass. + +This module repopulates the store in place instead. One process does the work, holding a +Redis lock so multiple uvicorn workers do not each re-read and re-validate the +configuration; the rest wait for the winner to finish. +""" + +# Standard Library +import time +import typing as t +from uuid import uuid4 + +# Project +from hyperglass.log import log + +if t.TYPE_CHECKING: + # Local + from .redis import RedisManager + +__all__ = ("rebuild_state",) + +# Keys that must all be present for state to be usable. +REQUIRED_KEYS = ("params", "directives", "devices", "ui_params") + +# Lock key, held for the duration of a rebuild by the process performing it. +LOCK_KEY = ("rebuild", "lock") + +# Expiry of the lock, so a process that dies mid-rebuild cannot block recovery forever. +LOCK_TIMEOUT = 60 + +# How long to wait for another process's rebuild before giving up. +WAIT_TIMEOUT = 30.0 + +# How often to check whether another process's rebuild has completed. +POLL_INTERVAL = 0.25 + +# How long to fail fast after a failed rebuild. Without this, a configuration that +# cannot be loaded would be re-read and re-validated on every single request. +FAILURE_COOLDOWN = 30.0 + +_last_failure: float = 0.0 + + +def _initialize() -> None: + """Write user configuration and the plugin registry to Redis. + + Imported lazily because both of these import `hyperglass.state`. + """ + # Project + from hyperglass.plugins import register_all_plugins + from hyperglass.configuration import init_user_config + + # Deliberately *not* preceded by `state.clear()`. `run()` flushes before + # initializing, which is correct once at startup, but mid-flight it would wipe + # state that other workers are reading. + init_user_config() + + # The plugin registry lives in the same store, so it is lost with the rest of it. + # Without this, hyperglass recovers but silently stops parsing device output into + # structured results. + register_all_plugins() + + +def is_populated(cache: "RedisManager") -> bool: + """Determine whether all required state keys exist in Redis.""" + keys = [cache.key(key) for key in REQUIRED_KEYS] + return cache.instance.exists(*keys) == len(keys) + + +def _wait_for_rebuild(cache: "RedisManager") -> bool: + """Wait for another process to finish rebuilding state. + + Waits for the lock to be released as well as the keys to appear, because the + configuration is written before the plugin registry. Returning on the keys alone + would let a waiter serve requests in the window where hyperglass has its + configuration back but not its output plugins. + """ + lock = cache.key(LOCK_KEY) + deadline = time.monotonic() + WAIT_TIMEOUT + while time.monotonic() < deadline: + time.sleep(POLL_INTERVAL) + if not cache.instance.exists(lock) and is_populated(cache): + return True + log.bind(timeout=WAIT_TIMEOUT).error("Timed out waiting for hyperglass state rebuild") + return False + + +def rebuild_state(cache: "RedisManager") -> bool: + """Repopulate configuration state in Redis after it has been lost. + + Returns `True` if state is populated by the time this returns, whether this process + rebuilt it or another one did. Blocks for the duration of the rebuild. + """ + global _last_failure # noqa: PLW0603 + + if is_populated(cache): + # State came back between the failed read and now, or the miss was for + # something this function does not manage. + return True + + if time.monotonic() - _last_failure < FAILURE_COOLDOWN: + return False + + lock = cache.key(LOCK_KEY) + token = str(uuid4()).encode() + + if not cache.instance.set(lock, token, nx=True, ex=LOCK_TIMEOUT): + return _wait_for_rebuild(cache) + + try: + log.bind(keys=REQUIRED_KEYS).warning("hyperglass state is missing from Redis, rebuilding") + _initialize() + except Exception as error: + _last_failure = time.monotonic() + log.bind(error=str(error)).critical("Failed to rebuild hyperglass state") + return False + finally: + if cache.instance.get(lock) == token: + cache.instance.delete(lock) + + _last_failure = 0.0 + log.bind(generation=cache.get("generation")).warning("Rebuilt hyperglass state") + return True diff --git a/hyperglass/state/store.py b/hyperglass/state/store.py index 9414adc0c..29a59e328 100644 --- a/hyperglass/state/store.py +++ b/hyperglass/state/store.py @@ -3,8 +3,12 @@ # Standard Library import typing as t +# Project +from hyperglass.exceptions.private import StateError + # Local from .manager import StateManager +from .recovery import rebuild_state if t.TYPE_CHECKING: # Project @@ -46,8 +50,40 @@ def add_directive(self, *directives: t.Union["Directive", t.Dict[str, t.Any]]) - self.redis.set("directives", current) def clear(self) -> None: - """Delete all cache keys.""" - self.redis.instance.flushdb(asynchronous=True) + """Delete all hyperglass cache keys. + + Scoped to the state namespace rather than flushing the database, so hyperglass + does not delete keys belonging to anything else sharing the Redis database. + Note that two hyperglass instances sharing a database still share this + namespace and will clear each other on start; give each its own + `HYPERGLASS_REDIS_DB`. + """ + batch = [] + for key in self.redis.instance.scan_iter(match=f"{self.redis.namespace}.*", count=500): + batch.append(key) + if len(batch) == 500: + self.redis.instance.delete(*batch) + batch = [] + if batch: + self.redis.instance.delete(*batch) + + def _required(self, key: str) -> t.Any: + """Read a value that must exist, rebuilding state if it has been lost. + + Redis is cache-only, so a flush or a replaced Redis container empties the store + while hyperglass is running and leaves every one of these reads failing until + the process is restarted. Rebuild and read again instead. + + This sits on the property rather than on `use_state`, because the properties are + read directly off a state instance on the hot paths (`Query.device` and the query + route both do it), and those reads never pass through `use_state`. + """ + try: + return self.redis.get(key, raise_if_none=True) + except StateError: + if not rebuild_state(self.redis): + raise + return self.redis.get(key, raise_if_none=True) @property def cache(self) -> "RedisManager": @@ -57,22 +93,31 @@ def cache(self) -> "RedisManager": @property def params(self) -> "Params": """Get hyperglass configuration parameters (`hyperglass.yaml`).""" - return self.redis.get("params", raise_if_none=True) + return self._required("params") @property def devices(self) -> "Devices": """Get hyperglass devices (`devices.yaml`).""" - return self.redis.get("devices", raise_if_none=True) + return self._required("devices") @property def ui_params(self) -> "UIParameters": """UI parameters, built from params.""" - return self.redis.get("ui_params", raise_if_none=True) + return self._required("ui_params") @property def directives(self) -> "Directives": """All directives.""" - return self.redis.get("directives", raise_if_none=True) + return self._required("directives") + + @property + def generation(self) -> t.Optional[str]: + """Identifier for the current population of state. + + Written every time configuration is loaded into Redis, so a consumer can tell a + rebuilt store from the one it started with. `None` means state has been lost. + """ + return self.redis.get("generation", raise_if_none=False) def plugins(self, _type: str) -> t.List[PluginT]: """Get plugins by type.""" diff --git a/hyperglass/state/tests/test_recovery.py b/hyperglass/state/tests/test_recovery.py new file mode 100644 index 000000000..112c87bf2 --- /dev/null +++ b/hyperglass/state/tests/test_recovery.py @@ -0,0 +1,185 @@ +"""Test state recovery.""" + +# Standard Library +import typing as t +from fnmatch import fnmatch + +# Third Party +import pytest + +# Project +from hyperglass.exceptions.private import StateError + +# Local +from .. import recovery +from ..redis import RedisManager +from ..store import HyperglassState +from ..recovery import rebuild_state + +NAMESPACE = "hyperglass.state" +STATE_KEYS = tuple(f"{NAMESPACE}.{key}" for key in recovery.REQUIRED_KEYS) +LOCK = f"{NAMESPACE}.rebuild.lock" + + +class FakeRedis: + """Minimal stand-in for a Redis client.""" + + data: t.Dict[str, t.Any] + + def __init__(self, **data: t.Any) -> None: + self.data = dict(data) + + def exists(self, *names: str) -> int: + return sum(1 for name in names if name in self.data) + + def get(self, name: str) -> t.Any: + return self.data.get(name) + + def set(self, name: str, value: t.Any, nx: bool = False, ex: int = None) -> t.Optional[bool]: + if nx and name in self.data: + return None + self.data[name] = value + return True + + def delete(self, *names: str) -> None: + for name in names: + self.data.pop(name, None) + + def scan_iter(self, match: str, count: int = None) -> t.Generator[str, None, None]: + yield from [key for key in list(self.data) if fnmatch(key, match)] + + +def cache_with(*keys: str) -> RedisManager: + """Build a cache manager containing `keys`.""" + return RedisManager(instance=FakeRedis(**{key: b"" for key in keys}), namespace=NAMESPACE) + + +def state_with(cache: RedisManager) -> HyperglassState: + """Build a state instance around `cache`, without opening a Redis connection.""" + state = HyperglassState.__new__(HyperglassState) + state.redis = cache + return state + + +@pytest.fixture(autouse=True) +def no_cooldown(): + """Ensure a failure in one test does not suppress the rebuild in the next.""" + recovery._last_failure = 0.0 + yield + recovery._last_failure = 0.0 + + +@pytest.fixture +def instant_sleep(monkeypatch): + """Remove the poll delay so waiting tests do not take real time.""" + monkeypatch.setattr(recovery.time, "sleep", lambda _: None) + + +def test_populated_state_is_not_rebuilt(monkeypatch): + """State that is present must not be re-initialized.""" + calls = [] + monkeypatch.setattr(recovery, "_initialize", lambda: calls.append(True)) + cache = cache_with(*STATE_KEYS) + + assert rebuild_state(cache) is True + assert calls == [] + + +def test_partial_state_is_rebuilt(monkeypatch): + """State missing any required key is rebuilt, and the lock is released.""" + cache = cache_with(STATE_KEYS[0]) + + def initialize(): + for key in STATE_KEYS: + cache.instance.data[key] = b"" + + monkeypatch.setattr(recovery, "_initialize", initialize) + + assert rebuild_state(cache) is True + assert cache.instance.exists(*STATE_KEYS) == len(STATE_KEYS) + assert LOCK not in cache.instance.data + + +def test_waits_for_another_process(monkeypatch): + """A process that loses the lock waits instead of rebuilding in parallel.""" + calls = [] + monkeypatch.setattr(recovery, "_initialize", lambda: calls.append(True)) + cache = cache_with(LOCK) + + def sleep(_): + for key in STATE_KEYS: + cache.instance.data[key] = b"" + cache.instance.delete(LOCK) # the winner finished and released the lock + + monkeypatch.setattr(recovery.time, "sleep", sleep) + + assert rebuild_state(cache) is True + assert calls == [], "rebuilt state while another process held the lock" + + +def test_wait_times_out(monkeypatch, instant_sleep): + """Waiting on a lock holder that never finishes eventually gives up.""" + monkeypatch.setattr(recovery, "WAIT_TIMEOUT", 0.01) + cache = cache_with(LOCK) + + assert rebuild_state(cache) is False + + +def test_failure_backs_off(monkeypatch): + """A failed rebuild is not retried on every request.""" + calls = [] + + def initialize(): + calls.append(True) + raise RuntimeError("bad config") + + monkeypatch.setattr(recovery, "_initialize", initialize) + cache = cache_with() + + assert rebuild_state(cache) is False + assert LOCK not in cache.instance.data, "lock left behind after a failed rebuild" + + assert rebuild_state(cache) is False + assert len(calls) == 1, "configuration was re-read during the failure cooldown" + + +def test_property_read_rebuilds_lost_state(monkeypatch): + """Reading a state property directly must recover, not just `use_state`. + + `Query.device` and the query route read `state.devices` and `state.params` off an + instance on every request, so this is the path that fails in production. + """ + cache = cache_with() + state = state_with(cache) + + def initialize(): + cache.instance.data.update({key: b"" for key in STATE_KEYS}) + cache.set("devices", ["recovered"]) + + monkeypatch.setattr(recovery, "_initialize", initialize) + + assert state.devices == ["recovered"] + + +def test_property_read_raises_when_rebuild_fails(monkeypatch): + """A rebuild that cannot repopulate state must surface the original error.""" + + def initialize(): + raise RuntimeError("bad config") + + monkeypatch.setattr(recovery, "_initialize", initialize) + state = state_with(cache_with()) + + with pytest.raises(StateError): + _ = state.devices + + +def test_clear_is_scoped_to_hyperglass(): + """Clearing state must not delete keys belonging to other applications.""" + cache = cache_with(*STATE_KEYS, "someotherapp.session.1") + state = state_with(cache) + + state.clear() + + assert cache.instance.exists(*STATE_KEYS) == 0 + assert "someotherapp.session.1" in cache.instance.data From 1930119ae38edc18863cc53ece3d02802cc5ebe0 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Thu, 6 Aug 2026 09:50:49 +1000 Subject: [PATCH 20/39] fix(docker): drop the npm glob patch that breaks the image build `docker compose build` failed in the UI stage. The line npm --prefix /usr/lib/node_modules/npm install glob@11.1.0 installs into npm's own directory, which makes npm reconcile npm's package.json. That file lists @npmcli/docs as a devDependency, and it is an unpublished workspace stub, so the install ends in a 404 and the stage exits 1. --omit=dev does not avoid it. The patch is also pointless now. It was added to lift glob past a CVE, but the npm that Alpine ships already bundles glob 13.x, so the line would have downgraded it. It targets /usr/lib/node_modules/npm (Alpine's copy) while the npm on PATH after this RUN is the one under /usr/local, so it was not patching the npm actually in use either. --- Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f952cc398..4c5126773 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,9 +12,13 @@ COPY . . FROM base as ui WORKDIR /opt/hyperglass/hyperglass/ui +# The glob patch that used to sit here is gone. It ran `npm --prefix +# /usr/lib/node_modules/npm install glob@11.1.0`, which fails the build: installing +# into npm's own tree makes npm reconcile npm's package.json, whose devDependencies +# include the unpublished `@npmcli/docs`, so the install 404s. It is also no longer +# needed, since the npm shipped by alpine bundles glob 13.x already. RUN apk add build-base pkgconfig cairo-dev nodejs npm \ && npm install -g npm@10.9.3 pnpm@11.6.0 \ - && npm --prefix /usr/lib/node_modules/npm install glob@11.1.0 \ && pnpm install -P FROM ui as hyperglass From 03083e56eed7656b982524245047145280f01a72 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Thu, 6 Aug 2026 09:50:48 +1000 Subject: [PATCH 21/39] chore(version): re-base the fork's version line at 3.0.0 The version was declared in three places and all three disagreed: constants.py said 2.0.4 (upstream's last release), pyproject.toml and ui/package.json said 2.1.2, and the install docs told you to check out v2.1.0. None of them described what this fork actually is. Set all of them to 3.0.0 and record the upstream base separately as __upstream_version__, alongside __upstream_url__ and __fork_url__. The fork's version line is its own and is not comparable to upstream's; the major bump reflects the raised Python floor (3.13), the removal of hyperglass.external.bgptools, and changed default directive commands for FRRouting, Huawei and MikroTik. version.py (rye run upgrade) matches only __version__, so it will not touch the new upstream constants. It does not know about manual.mdx or those constants, which stay manual. manual.mdx also cloned thatmattlove/hyperglass and then asked you to switch to a v3.0.0 tag, which will only ever exist on the fork. It now clones the fork. --- .github/ISSUE_TEMPLATE/1-feature-request.yaml | 2 +- .github/ISSUE_TEMPLATE/2-bug-report.yaml | 2 +- docs/pages/installation/manual.mdx | 4 ++-- docs/pages/installation/upgrading.mdx | 2 +- hyperglass/constants.py | 9 ++++++++- hyperglass/ui/package.json | 2 +- pyproject.toml | 2 +- 7 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/1-feature-request.yaml b/.github/ISSUE_TEMPLATE/1-feature-request.yaml index ae9a76b3a..d302edfb6 100644 --- a/.github/ISSUE_TEMPLATE/1-feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/1-feature-request.yaml @@ -16,7 +16,7 @@ body: attributes: label: Version description: What version of hyperglass are you currently running? - placeholder: v2.0.4 + placeholder: v3.0.0 validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.yaml b/.github/ISSUE_TEMPLATE/2-bug-report.yaml index fa8824f9b..2e97ceb34 100644 --- a/.github/ISSUE_TEMPLATE/2-bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/2-bug-report.yaml @@ -20,7 +20,7 @@ body: attributes: label: Version description: What version of hyperglass are you currently running? - placeholder: v2.0.4 + placeholder: v3.0.0 validations: required: true - type: textarea diff --git a/docs/pages/installation/manual.mdx b/docs/pages/installation/manual.mdx index 186fecb32..c5aefe61c 100644 --- a/docs/pages/installation/manual.mdx +++ b/docs/pages/installation/manual.mdx @@ -23,10 +23,10 @@ To install hyperglass manually, you'll need to install the following dependencie Once these dependencies are installed, install hyperglass via PyPI: ```shell copy -git clone https://github.com/thatmattlove/hyperglass --depth=1 +git clone https://github.com/WilhelmZA/hyperglass --depth=1 cd hyperglass # optional - switch to the latest stable release -# git switch -c v2.1.0 v2.1.0 +# git switch -c v3.0.0 v3.0.0 pip3 install -e . ``` diff --git a/docs/pages/installation/upgrading.mdx b/docs/pages/installation/upgrading.mdx index c77828f82..d4b42eda3 100644 --- a/docs/pages/installation/upgrading.mdx +++ b/docs/pages/installation/upgrading.mdx @@ -5,7 +5,7 @@ cd /opt/hyperglass docker compose down docker compose rm -f git fetch -git checkout v2.1.0 +git checkout v3.0.0 docker compose build docker compose up ``` diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 02723a194..c624fdff6 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -4,11 +4,18 @@ from datetime import datetime __name__ = "hyperglass" -__version__ = "2.0.4" +__version__ = "3.0.0" __author__ = "Matt Love" __copyright__ = f"Copyright {datetime.now().year} Matthew Love" __license__ = "BSD 3-Clause Clear License" +# This is a fork of hyperglass. `__version__` above is the fork's own version line, which +# starts at 3.0.0 and is independent of upstream. `__upstream_version__` is the upstream +# release the fork is based on. +__upstream_version__ = "2.0.4" +__upstream_url__ = "https://github.com/thatmattlove/hyperglass" +__fork_url__ = "https://github.com/WilhelmZA/hyperglass" + METADATA = (__name__, __version__, __author__, __copyright__, __license__) MIN_PYTHON_VERSION = (3, 8) diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index 89be03e6f..d330fc61f 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -1,5 +1,5 @@ { - "version": "2.1.2", + "version": "3.0.0", "name": "ui", "description": "UI for hyperglass, the modern network looking glass", "author": "Matt Love", diff --git a/pyproject.toml b/pyproject.toml index 8f998c4bb..c13b4652c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hyperglass" -version = "2.1.2" +version = "3.0.0" description = "hyperglass is the modern network looking glass that tries to make the internet better." authors = [ { name = "thatmattlove", email = "matt@hyperglass.dev" } From 76899d7ed72ceb4a69f871b22278df705e15676b Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Thu, 6 Aug 2026 09:50:49 +1000 Subject: [PATCH 22/39] feat(ui): identify the deployment as a fork in the footer credit The footer credit read "Powered by hyperglass version X", which gave a version number with no indication of whose it was. Now that the fork has its own version line, that is actively misleading: 3.0.0 is not an upstream release. The credit now names the fork, its version, the upstream version it is based on, and links to both repositories. Upstream's authorship and the BSD 3-Clause Clear licence line are unchanged. No frontend code changed. The credit was already built server-side from the CREDIT template and delivered through ui_params, so the strings stay in constants.py rather than moving into a component. --- hyperglass/configuration/validate.py | 15 +++++++++++++-- hyperglass/defaults/_strings.py | 5 ++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/hyperglass/configuration/validate.py b/hyperglass/configuration/validate.py index 3accb7e16..a9cf96780 100644 --- a/hyperglass/configuration/validate.py +++ b/hyperglass/configuration/validate.py @@ -113,14 +113,25 @@ def init_ui_params(*, params: "Params", devices: "Devices") -> "UIParameters": # Project from hyperglass.defaults import CREDIT - from hyperglass.constants import PARSED_RESPONSE_FIELDS, __version__ + from hyperglass.constants import ( + PARSED_RESPONSE_FIELDS, + __fork_url__, + __upstream_url__, + __upstream_version__, + __version__, + ) content_greeting = get_markdown( config=params.web.greeting, default="", params={"title": params.web.greeting.title}, ) - content_credit = CREDIT.format(version=__version__) + content_credit = CREDIT.format( + version=__version__, + upstream_version=__upstream_version__, + upstream_url=__upstream_url__, + fork_url=__fork_url__, + ) _ui_params = params.frontend() _ui_params["web"]["logo"]["light_format"] = params.web.logo.light.suffix diff --git a/hyperglass/defaults/_strings.py b/hyperglass/defaults/_strings.py index e1ae1456c..0b646cecb 100644 --- a/hyperglass/defaults/_strings.py +++ b/hyperglass/defaults/_strings.py @@ -1,8 +1,11 @@ """Constant store for large default values.""" CREDIT = """ -Powered by [**hyperglass**](https://hyperglass.dev) version {version}. \ +Powered by [**hyperglass**](https://hyperglass.dev). \ Source code licensed [_BSD 3-Clause Clear_](https://hyperglass.dev/license/). + +Running [a fork of hyperglass]({fork_url}) version {version}, \ +based on [upstream]({upstream_url}) version {upstream_version}. """ DEFAULT_TERMS = """ From b9a8730cf95351e30cfd7288ec78a13e79ee9655 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Thu, 6 Aug 2026 09:51:16 +1000 Subject: [PATCH 23/39] docs(changelog): document the fork's changes as 3.0.0 The changelog still described upstream's releases and carried an [Unreleased] section of upstream issues, so nothing in it reflected the work done in this fork since 2.0.4. Rewrite the header to say plainly that this file documents the fork, and add a 3.0.0 section covering the changes made on top of upstream: structured BGP output for MikroTik and Huawei, structured traceroute for FRRouting, Huawei and MikroTik, IP and ASN enrichment, the AS-path enrichment endpoint and tooltips, pluggable RPKI backends, community names, filtered-route display, query-sample logging, the worker count fix, error sanitization, the dependency modernization, and the state, clear() and build fixes from this series. Upstream's 2.0.4 and earlier sections are kept verbatim below as inherited history. The ten entries from [Unreleased] were each checked against the tree rather than copied: all are present, because the fork's base is upstream main rather than the 2.0.4 tag, so they sit in an "Inherited from upstream" block with their original contributor credits. #292 is marked superseded, since the MikroTik BGP directives were rewritten onto RouterOS 7 syntax that keeps supernet matching by other means. Two branches are deliberately absent because they are not merged: experimental/query-sample-logging and experimental/arista-juniper-traceroute. --- CHANGELOG.md | 89 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be4445dc9..a58365399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,28 +1,87 @@ # Changelog -All notable changes to this project will be documented in this file. +This is the changelog for [WilhelmZA/hyperglass](https://github.com/WilhelmZA/hyperglass), a **fork** of [thatmattlove/hyperglass](https://github.com/thatmattlove/hyperglass). It documents the fork's changes, not upstream's. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The fork's own version line starts at **3.0.0**. It is based on **upstream hyperglass 2.0.4**, plus the upstream commits that had landed on upstream `main` after 2.0.4 but were never released by upstream. Upstream's version numbering is unrelated to this one; upstream's own history continues at [thatmattlove/hyperglass/blob/main/CHANGELOG.md](https://github.com/thatmattlove/hyperglass/blob/main/CHANGELOG.md). -## [Unreleased] +Everything from [2.0.4](#204---2024-06-30) downward is inherited upstream history, kept verbatim for reference. -### Fixed -- [#280](https://github.com/thatmattlove/hyperglass/issues/280): Fix: `condition: None` caused error in directive @Jimmy01240397 -- [#306](https://github.com/thatmattlove/hyperglass/issues/306): Fix: allow integer values in ext_community_list_raw field for Arista BGP - @cooperwinser -- [#311](https://github.com/thatmattlove/hyperglass/issues/311): Fix: device and directive errors. -- [#315](https://github.com/thatmattlove/hyperglass/issues/315): Impossibile to use command "BGP Route" with Huawei NetEngine 8000 -- [#315](https://github.com/thatmattlove/hyperglass/issues/187): Error in bgp_router query on Huawei -- [#325](https://github.com/thatmattlove/hyperglass/pull/325): Fix code block padding in the documentation - @jagardaniel -- [#332](https://github.com/thatmattlove/hyperglass/pull/332): Fix custom proxy port support in SSH proxy tunnels @jessiebryan +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -### Updated +## 3.0.0 - 2026-08-06 -- [#245](https://github.com/thatmattlove/hyperglass/issues/245): v2.0.0 Vyos version platforms - Moved to latest LTS command set. - @ServerForge -- [#292](https://github.com/thatmattlove/hyperglass/pull/292): Updates Mikrotik BGP route command so supernets are selected as well as exact matches. - @GrandArcher +First release of the fork. The major version bump reflects a raised Python floor (3.13), the removal of the `hyperglass.external.bgptools` module, and changed default directive commands for FRRouting, Huawei and MikroTik. ### Added -- [#304](https://github.com/thatmattlove/hyperglass/pull/304): Add FRR structured output for BGP Routes - @chriswiggins +- Structured BGP route output for **MikroTik RouterOS 7+** (`mikrotik_routeros`, `mikrotik_switchos`). New parser `hyperglass/models/parsing/mikrotik.py` (`MikrotikBGPTable`, `MikrotikRouteEntry`, `MikrotikPaths`), output plugin `bgp_routestr_mikrotik`, and three table directives `__hyperglass_mikrotik_bgp_route_table__`, `__hyperglass_mikrotik_bgp_aspath_table__` and `__hyperglass_mikrotik_bgp_community_table__`. Parses `routing route print detail`, including RPKI state, large and extended communities, and active/filtered status. +- Structured BGP route output for **Huawei VRP**. New parser `hyperglass/models/parsing/huawei.py` (`HuaweiBGPTable`, `HuaweiRouteEntry`, `HuaweiPaths`), output plugin `bgp_routestr_huawei`, and the table directives `__hyperglass_huawei_bgp_route_table__`, `__hyperglass_huawei_bgp_aspath_table__` and `__hyperglass_huawei_bgp_community_table__`. +- `SUPPORTED_STRUCTURED_OUTPUT` extended with `huawei`, `mikrotik_routeros` and `mikrotik_switchos`, so `structured_output: true` is now valid on those platforms. +- **Structured traceroute output** for FRRouting, Huawei VRP and MikroTik. New data models `TracerouteResult` and `TracerouteHop` (`hyperglass/models/data/traceroute.py`), a shared parser base in `hyperglass/models/parsing/traceroute.py`, and the output plugins `TraceroutePluginFrr`, `TraceroutePluginHuawei` and `TraceroutePluginMikrotik`. `OutputDataModel` is now `Union[BGPRouteTable, TracerouteResult]`. +- New UI components for structured traceroute: `TracerouteTable`, `TracerouteCell` and the field renderers `MonoField`, `ASNField`, `HostnameField`, `LatencyField` and `LossField` (Hop / IP Address / Hostname / ASN / Loss / Sent / Last / AVG / Best / Worst). Latency is colour-graded and packet loss renders as a badge. `results/individual.tsx` picks the BGP or traceroute table via the new `isBGPStructuredOutput` / `isTracerouteStructuredOutput` type guards. +- New config keys `structured.enable_for_traceroute` and `structured.enable_for_bgp_route` (both `Optional[bool]`, unset means enabled when a `structured` block exists). `Query.device` now returns a per-request device proxy that applies these without mutating global device state. +- **IP and ASN enrichment.** New config block `structured.ip_enrichment` with `enrich_traceroute` and `enrich_bgproute` (both default `false`). Two builtin output plugins, `bgp_route_enrichment` and `traceroute_ip_enrichment`, add ASN, organisation, country, IXP identity and reverse DNS to results. Enrichment failures are swallowed so they cannot fail a query. +- BGP next-hop enrichment: new API fields `BGPRoute.next_hop_asn`, `BGPRoute.next_hop_org` and `BGPRoute.next_hop_country`, plus `BGPRouteTable.asn_organizations` and the async helpers `BGPRouteTable.enrich_with_ip_enrichment()` and `BGPRouteTable.enrich_as_path_organizations()`. The UI renders a `NextHop` field showing ASN, organisation and country on hover. +- AS-path enrichment endpoint `POST /api/aspath/enrich`, which takes `{"as_path": [...]}` (numeric ASNs only, capped at 64) and returns `{"success": true, "asn_organizations": {...}}`. Errors return generic strings rather than internal detail. The AS-path modal calls it lazily when a response has no enrichment data attached. +- AS-path tooltips: every ASN in an AS path renders as a tooltipped link to `https://bgp.tools/as/<asn>` labelled with the organisation name. The AS-path flow chart builds its graph from either BGP AS paths or traceroute hop ASNs, collapsing repeated ASNs and labelling IXP hops by IXP name. +- **Pluggable external RPKI backends.** New config keys `structured.rpki.backend` (`cloudflare` or `routinator`) and `structured.rpki.rpki_server_url`. A validator requires the URL when the backend is `routinator`. Routinator is queried at `<url>/validity`; RPKI state names are normalised so `NotFound`, `not-found` and `unknown` all map correctly. +- **Friendly BGP community names.** New `structured.communities.mode: name` and a `structured.communities.names` map of community to label. In `name` mode no communities are filtered and matched communities render as `<community> - <name>`. +- **Filtered BGP routes are now shown rather than dropped.** New API field `BGPRoute.filtered` and new theme colour `web.theme.colors.filtered` (default `#c1c7cc`). Filtered rows are dimmed in the results table via a new `dimText` prop on `TableRow` and `TableCell`. +- **Opt-in query-sample logging.** New config block `logging.samples` with `enable` (default `false`), `path`, `include_raw`, `include_parsed` and `max_size` (default 50MB). Writes one JSON object per line to `hyperglass_query_log.jsonl` under the logging directory, capturing device, query, runtime, error and optionally raw and parsed output, on both the success and failure paths. Appends are `O_APPEND` so multiple workers are safe, and the file is size-rotated with a single `.1` backup. Raw output can contain internal addressing, so this is off by default. +- New documentation pages: `user-guide.mdx` (end-user guide to locations, query types and FQDN resolution), `configuration/examples/quick-start.mdx` (eight named recipe configurations) and `configuration/config/complete-config.mdx` (key-by-key reference for every configuration option). `structured-output.mdx` expanded to cover every new `structured.*` key with worked examples, and the README rewritten to describe the fork's capabilities. +- Tests for the new parsers and utilities: `test_bgp_route_huawei.py`, `test_bgp_route_mikrotik.py`, `test_traceroute_frr.py`, `test_traceroute_huawei.py`, `test_traceroute_mikrotik.py`, `test_query_samples.py` and `test_system_workers.py`. + +### Changed + +- **Breaking:** `hyperglass.external.bgptools` is removed and replaced by `hyperglass.external.ip_enrichment`. The new module does real-time bulk WHOIS against `bgp.tools:43` off the event loop, with in-process caching, private and reserved address short-circuiting, and IXP detection. `network_info()` and `network_info_sync()` are kept as compatible shims; `lookup_ip()`, `lookup_asn_name()`, `lookup_asn_country()` and `lookup_asns_bulk()` are new. Anything importing `hyperglass.external.bgptools` directly must be updated. +- **Breaking:** minimum Python raised from 3.11 to 3.13, and the Docker base image moved from `python:3.12.3-alpine` to `python:3.13-alpine`. +- Dependencies modernised: httpx 0.28.1, netmiko 4.6.0, paramiko 4.0.0, pydantic 2.11.9+, pydantic-settings 2.11.0, litestar 2.22.0+, redis 6.4.0, uvicorn 0.37.0, psutil 7.1.0, Pillow 12.2.0+, xmltodict 1.0.2, distro 1.9.0. `poetry.lock` is dropped in favour of the uv lockfiles `requirements.lock` and `requirements-dev.lock`, and the Docker build installs from the lockfile with `--no-deps`. +- Frontend upgraded to Next.js 15. The UI build no longer uses `next export` or `NODE_OPTIONS=--openssl-legacy-provider`; it runs `next build` and copies `out/` into `hyperglass/static/ui`. pnpm workspace configuration migrated to pnpm 11 `allowBuilds`. +- **Default directive commands changed.** MikroTik BGP route queries now use `routing route print detail without-paging where {target} in dst-address bgp and dst-address !=0.0.0.0/0` (RouterOS 7 syntax) instead of `ip route print`; MikroTik traceroute uses `duration=30 count=3` instead of `duration=5 count=1` so loss and RTT statistics are produced; FRRouting traceroute adds `-I -n`; Huawei traceroute uses `tracert -w 500 -q 1 -f 1 -a {source} {target}` with a proper `tracert ipv6` variant. +- netmiko sessions now run in a thread executor instead of inline in the coroutine, so a slow device no longer blocks every other concurrent query. Per-query reads switched from `send_command` to `send_command_timing`, because RouterOS prompt detection truncates output. +- `MikrotikGarbageOutput` is no longer a `common` plugin applied to every device, its hard-coded directive list is gone, and it gained traceroute-aware cleaning that splits repeated RouterOS tables, strips paging prompts and command echoes, and aggregates per-hop rows by highest sent count. +- New input plugin `mikrotik_normalize_input` rewrites a MikroTik BGP route target to its containing /24 or /48, so queries within one subnet share a cache entry. +- The browser no longer calls CAIDA's `api.asrank.caida.org` for ASN organisation names; `useASNDetail` is stubbed and deprecated, and organisation names come from server-side enrichment instead. This removes the last third-party request made from the client. +- Results table page size raised from 10 to 50, and the results accordion, copy and requery controls now track `isFetching` as well as `isLoading`. +- `hyperglass/models/config/params.py` sub-models switched to `Field(default_factory=...)`, so the `cache`, `docs`, `logging`, `messages`, `structured` and `web` blocks no longer share mutable class-level instances. +- The UI build timeout default was raised from 180 to 600 seconds, and `HYPERGLASS_UI_BUILD_TIMEOUT` is now always honoured rather than only when it exceeds the default. +- The credit shown in the footer now identifies this deployment as a fork, giving the fork version, the upstream version it is based on, and a link to the fork repository. +- The group filter row on the query form is hidden when there are no groups to filter by. +- `HyperglassState.clear()` scans and deletes only keys under the `hyperglass.state` namespace instead of flushing the Redis database, so hyperglass no longer deletes keys belonging to anything else sharing that database. Two hyperglass instances sharing one database still share the namespace and will clear each other on start; give each its own `HYPERGLASS_REDIS_DB`. +- The Docker Compose healthcheck queries `/api/info`, which reads Redis-backed state, rather than the UI root, which does not and so reported healthy through a total query outage. `start_period` is raised to 180s to cover the boot-time UI build. Note that Docker Compose does not restart a container for being unhealthy; the healthcheck reports the outage, the in-app rebuild resolves it. + +### Fixed + +- `HYPERGLASS_WORKERS` is now read. `HyperglassSettings` gained a real `workers` field and the old `workers` property was renamed `worker_count`, returning the explicit value (clamped to at least 1) when set, 1 in debug mode, and twice the CPU count otherwise. A 24-core host no longer unconditionally starts 48 workers. +- Requests arriving before Redis-backed state is populated at startup no longer return 500. State lookups retry up to five times at half-second intervals, and only for retryable attributes, so a genuine attribute error still raises immediately. +- **Configuration state lost from Redis while hyperglass is running is now rebuilt in place.** All runtime configuration was written to Redis once, by `run()` at startup, and never rewritten. Redis is deliberately cache-only, so anything that emptied it (a `FLUSHDB`, or a replaced Redis container, which Docker Compose recreates on its own whenever that image changes) left every state-touching request raising `StateError` until hyperglass was restarted. The `HyperglassState` properties now re-run `init_user_config()` and re-register the plugins under a Redis lock, so one process rebuilds and the others wait for it rather than all re-reading the configuration, with a cooldown so a configuration that cannot be loaded is not re-validated on every request. The recovery sits on the properties rather than on `use_state`, because `Query.device` and the query route read `state.devices` and `state.params` directly off a state instance on every request and never pass through `use_state`; that is the path that fails in production. The plugin registry lives in the same store and is restored with it, otherwise hyperglass comes back but silently stops parsing device output into structured results. +- `init_user_config()` writes a `generation` UUID last, so its presence means state is complete, and a rebuilt store is distinguishable from the original. Exposed as `HyperglassState.generation` and logged at startup. +- The Docker image builds again. The UI stage patched npm's bundled `glob` with `npm --prefix /usr/lib/node_modules/npm install glob@11.1.0`, which cannot succeed: installing into npm's own tree makes npm reconcile npm's `package.json`, whose devDependencies include the unpublished `@npmcli/docs`, so the install returns 404. The patch is also no longer needed, because the npm shipped by the Alpine base image bundles glob 13.x. +- MikroTik RouterOS 7.21 BGP output parses correctly. Active and filtered status is derived from the `contribution` field with the legacy flag column as a RouterOS 6 fallback, terminal-wrapped community lists are de-wrapped before tokenising, `.ext-communities` is parsed, and the flags legend is matched precisely so commented route lines are no longer swallowed. +- Empty MikroTik BGP tables are retried up to four times at ten-second intervals, guarded so that only MikroTik BGP queries with a zero-route structured result retry. +- MikroTik queries no longer return empty. netmiko's `last_read` window is widened to 5 seconds for `mikrotik_routeros` and `mikrotik_switchos`, because RouterOS can take a couple of seconds to begin emitting output for large queries and the default 2-second window returned nothing. This adds roughly 5 seconds to every MikroTik query. +- External RPKI validation no longer runs when `structured.rpki.mode` is `router`. The prefix parse and the external lookup were previously executed in both modes. +- MikroTik traceroute output is aggregated per hop by highest sent count rather than deduplicated by IP, so repeated RouterOS tables no longer produce duplicate or partial hops. + +### Security + +- Device addressing is no longer leaked to end users. `ScrapeError`, `AuthError`, `RestError` and `DeviceTimeout` strip netmiko's `Device settings:` line, which contained the backend device host and port, from the error text returned by the API and shown in the UI, substituting a generic message when nothing is left. +- Fixable HIGH CVEs flagged by the container scan are patched by floor and override constraints in a new `[tool.uv]` block: brotli 1.2.0+, cryptography 46.0.5+, lxml 6.1.0+, multipart 1.3.1+, Pillow 12.2.0+ (overriding the `pillow<11` cap declared by `favicons`) and psutil 7.1.0 (overriding the `psutil<7` cap declared by `taskipy`). +- Frontend overrides pin `ua-parser-js` to 0.7.36+ for the ReDoS advisory and `@babel/runtime` and `@babel/helpers` to 7.26.10. + +### Inherited from upstream + +These landed on upstream `main` after 2.0.4 but were never released by upstream. They are present in this fork's code and ship as part of 3.0.0. Credit is upstream's. + +- [#280](https://github.com/thatmattlove/hyperglass/issues/280): Fixed `condition: None` causing an error in a directive - @Jimmy01240397 +- [#306](https://github.com/thatmattlove/hyperglass/issues/306): Allow integer values in the `ext_community_list_raw` field for Arista BGP - @cooperwinser +- [#311](https://github.com/thatmattlove/hyperglass/issues/311): Fixed device and directive field validation errors +- [#315](https://github.com/thatmattlove/hyperglass/issues/315) and [#187](https://github.com/thatmattlove/hyperglass/issues/187): Fixed the BGP Route query on Huawei NetEngine 8000, via the Huawei BGP route input plugin +- [#325](https://github.com/thatmattlove/hyperglass/pull/325): Fixed code block padding in the documentation - @jagardaniel +- [#332](https://github.com/thatmattlove/hyperglass/pull/332): Fixed custom proxy port support in SSH proxy tunnels - @jessiebryan +- [#245](https://github.com/thatmattlove/hyperglass/issues/245): VyOS platforms moved to the latest LTS command set - @ServerForge +- [#304](https://github.com/thatmattlove/hyperglass/pull/304): FRRouting structured output for BGP routes - @chriswiggins +- [#292](https://github.com/thatmattlove/hyperglass/pull/292): MikroTik BGP route command updated so supernets are selected as well as exact matches - @GrandArcher. Superseded in this fork: the MikroTik BGP route directives were rewritten onto the RouterOS 7 `routing route print detail` syntax, which keeps the supernet behaviour through `{target} in dst-address`. ## 2.0.4 - 2024-06-30 From f18d2dc4b8f3697993b0fae9424ce3ac3c3490b5 Mon Sep 17 00:00:00 2001 From: WilhelmZA <82225237+WilhelmZA@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:30:45 +1000 Subject: [PATCH 24/39] release: hyperglass 3.1.0 security and platform update Merge the 3.1.0 security, dependency, Docker, documentation and UI platform release. Validation completed with 70 Python tests, 39 UI tests, a 33-route docs export, a successful Docker build, a clean HIGH/CRITICAL Trivy scan, and a live /mnt/AppData HTTP 200 check. --- .dockerignore | 11 +- .github/ISSUE_TEMPLATE/1-feature-request.yaml | 2 +- .github/ISSUE_TEMPLATE/2-bug-report.yaml | 2 +- CHANGELOG.md | 42 +- Dockerfile | 28 +- docs/.gitignore | 2 +- docs/app/[[...mdxPath]]/page.jsx | 15 + docs/app/layout.tsx | 44 + docs/app/not-found.tsx | 3 + docs/components/color.tsx | 2 + docs/components/platforms.tsx | 20 +- docs/{pages/_meta.tsx => content/_meta.js} | 11 +- .../configuration/_meta.js} | 0 .../configuration/config.mdx | 0 .../configuration/config/_meta.js} | 0 .../configuration/config/api-docs.mdx | 0 .../configuration/config/caching.mdx | 0 .../configuration/config/complete-config.mdx | 0 .../configuration/config/logging.mdx | 0 .../configuration/config/messages.mdx | 0 .../config/structured-output.mdx | 0 .../configuration/config/web-ui.mdx | 0 .../configuration/devices.mdx | 0 .../configuration/devices/_meta.js} | 0 .../configuration/devices/credentials.mdx | 0 .../configuration/devices/http-device.mdx | 0 .../configuration/devices/ssh-proxy.mdx | 0 .../configuration/directives.mdx | 0 .../configuration/examples/_meta.js} | 0 .../examples/add-your-own-command.mdx | 0 .../examples/basic-configuration.mdx | 0 .../examples/customize-the-ui.mdx | 0 .../configuration/examples/quick-start.mdx | 0 .../configuration/overview.mdx | 2 +- docs/{pages => content}/index.mdx | 0 docs/{pages => content}/installation.mdx | 0 .../installation/_meta.js} | 0 .../installation/docker.mdx | 0 .../installation/environment-variables.mdx | 0 .../installation/manual.mdx | 0 .../installation/reverse-proxy.mdx | 0 .../installation/upgrading.mdx | 2 +- docs/{pages => content}/license.mdx | 0 docs/{pages => content}/platforms.mdx | 0 docs/{pages => content}/plugins.mdx | 0 docs/{pages => content}/user-guide.mdx | 0 docs/mdx-components.js | 10 + docs/next-env.d.ts | 3 +- docs/next.config.mjs | 14 +- docs/package.json | 13 +- docs/patches/nextra-theme-docs@4.6.1.patch | 12 + docs/pnpm-lock.yaml | 2275 +++++++++++------ docs/pnpm-workspace.yaml | 15 + docs/theme.config.tsx | 144 -- docs/tsconfig.json | 65 +- hyperglass/constants.py | 2 +- hyperglass/ui/components/output/cell.tsx | 20 +- hyperglass/ui/components/output/fields.tsx | 30 +- .../ui/components/output/traceroute-cell.tsx | 10 +- .../components/output/traceroute-fields.tsx | 65 +- .../ui/components/output/traceroute-table.tsx | 4 +- hyperglass/ui/components/path/chart.tsx | 4 +- hyperglass/ui/components/path/path.tsx | 17 +- hyperglass/ui/components/path/use-elements.ts | 26 +- .../ui/components/results/individual.tsx | 11 +- hyperglass/ui/components/table/main.tsx | 18 +- hyperglass/ui/hooks/use-asn-detail.ts | 6 +- hyperglass/ui/hooks/use-dns-query.test.tsx | 46 +- hyperglass/ui/hooks/use-table-to-string.ts | 97 +- hyperglass/ui/package.json | 4 +- hyperglass/ui/pnpm-lock.yaml | 836 +++--- hyperglass/ui/pnpm-workspace.yaml | 3 + hyperglass/ui/types/globals.d.ts | 7 +- hyperglass/ui/types/guards.ts | 23 +- hyperglass/ui/types/table.ts | 18 +- hyperglass/ui/util/common.ts | 2 +- pyproject.toml | 10 +- requirements-dev.lock | 4 +- requirements.lock | 4 +- version.py | 2 +- 80 files changed, 2400 insertions(+), 1606 deletions(-) create mode 100644 docs/app/[[...mdxPath]]/page.jsx create mode 100644 docs/app/layout.tsx create mode 100644 docs/app/not-found.tsx rename docs/{pages/_meta.tsx => content/_meta.js} (97%) rename docs/{pages/configuration/_meta.tsx => content/configuration/_meta.js} (100%) rename docs/{pages => content}/configuration/config.mdx (100%) rename docs/{pages/configuration/config/_meta.tsx => content/configuration/config/_meta.js} (100%) rename docs/{pages => content}/configuration/config/api-docs.mdx (100%) rename docs/{pages => content}/configuration/config/caching.mdx (100%) rename docs/{pages => content}/configuration/config/complete-config.mdx (100%) rename docs/{pages => content}/configuration/config/logging.mdx (100%) rename docs/{pages => content}/configuration/config/messages.mdx (100%) rename docs/{pages => content}/configuration/config/structured-output.mdx (100%) rename docs/{pages => content}/configuration/config/web-ui.mdx (100%) rename docs/{pages => content}/configuration/devices.mdx (100%) rename docs/{pages/configuration/devices/_meta.tsx => content/configuration/devices/_meta.js} (100%) rename docs/{pages => content}/configuration/devices/credentials.mdx (100%) rename docs/{pages => content}/configuration/devices/http-device.mdx (100%) rename docs/{pages => content}/configuration/devices/ssh-proxy.mdx (100%) rename docs/{pages => content}/configuration/directives.mdx (100%) rename docs/{pages/configuration/examples/_meta.tsx => content/configuration/examples/_meta.js} (100%) rename docs/{pages => content}/configuration/examples/add-your-own-command.mdx (100%) rename docs/{pages => content}/configuration/examples/basic-configuration.mdx (100%) rename docs/{pages => content}/configuration/examples/customize-the-ui.mdx (100%) rename docs/{pages => content}/configuration/examples/quick-start.mdx (100%) rename docs/{pages => content}/configuration/overview.mdx (97%) rename docs/{pages => content}/index.mdx (100%) rename docs/{pages => content}/installation.mdx (100%) rename docs/{pages/installation/_meta.tsx => content/installation/_meta.js} (100%) rename docs/{pages => content}/installation/docker.mdx (100%) rename docs/{pages => content}/installation/environment-variables.mdx (100%) rename docs/{pages => content}/installation/manual.mdx (100%) rename docs/{pages => content}/installation/reverse-proxy.mdx (100%) rename docs/{pages => content}/installation/upgrading.mdx (87%) rename docs/{pages => content}/license.mdx (100%) rename docs/{pages => content}/platforms.mdx (100%) rename docs/{pages => content}/plugins.mdx (100%) rename docs/{pages => content}/user-guide.mdx (100%) create mode 100644 docs/mdx-components.js create mode 100644 docs/patches/nextra-theme-docs@4.6.1.patch create mode 100644 docs/pnpm-workspace.yaml delete mode 100644 docs/theme.config.tsx diff --git a/.dockerignore b/.dockerignore index 1f8e52810..a1a2cc8d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,16 @@ hyperglass/hyperglass/static -**/node_modules -**/node_modules/.pnpm +**/node_modules/ +**/node_modules/** +**/.next/ +**/.next/** +**/out/ +**/out/** +**/tsconfig.tsbuildinfo .DS_Store hyperglass/ui/.env* hyperglass.json custom.*[js, html] -.next +.next/ out/ fonts/ __pycache__ diff --git a/.github/ISSUE_TEMPLATE/1-feature-request.yaml b/.github/ISSUE_TEMPLATE/1-feature-request.yaml index d302edfb6..0639c689b 100644 --- a/.github/ISSUE_TEMPLATE/1-feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/1-feature-request.yaml @@ -16,7 +16,7 @@ body: attributes: label: Version description: What version of hyperglass are you currently running? - placeholder: v3.0.0 + placeholder: v3.1.0 validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.yaml b/.github/ISSUE_TEMPLATE/2-bug-report.yaml index 2e97ceb34..4d9650d97 100644 --- a/.github/ISSUE_TEMPLATE/2-bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/2-bug-report.yaml @@ -20,7 +20,7 @@ body: attributes: label: Version description: What version of hyperglass are you currently running? - placeholder: v3.0.0 + placeholder: v3.1.0 validations: required: true - type: textarea diff --git a/CHANGELOG.md b/CHANGELOG.md index a58365399..4bbe3eda0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,52 @@ This is the changelog for [WilhelmZA/hyperglass](https://github.com/WilhelmZA/hyperglass), a **fork** of [thatmattlove/hyperglass](https://github.com/thatmattlove/hyperglass). It documents the fork's changes, not upstream's. -The fork's own version line starts at **3.0.0**. It is based on **upstream hyperglass 2.0.4**, plus the upstream commits that had landed on upstream `main` after 2.0.4 but were never released by upstream. Upstream's version numbering is unrelated to this one; upstream's own history continues at [thatmattlove/hyperglass/blob/main/CHANGELOG.md](https://github.com/thatmattlove/hyperglass/blob/main/CHANGELOG.md). +The fork's own version line starts at **3.0.0** and is currently at **3.1.0**. It is based on **upstream hyperglass 2.0.4**, plus the upstream commits that had landed on upstream `main` after 2.0.4 but were never released by upstream. Upstream's version numbering is unrelated to this one; upstream's own history continues at [thatmattlove/hyperglass/blob/main/CHANGELOG.md](https://github.com/thatmattlove/hyperglass/blob/main/CHANGELOG.md). Everything from [2.0.4](#204---2024-06-30) downward is inherited upstream history, kept verbatim for reference. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 3.1.0 - 2026-08-25 + +Security and platform hardening release covering the dependency estate, container build, documentation platform and frontend validation. + +### Added + +- Nextra 4 documentation application using Next.js 15, React 19, the App Router and the migrated `docs/content` route tree, exporting all 33 documentation routes. +- A compatibility patch for `nextra-theme-docs@4.6.1` so the Nextra layout schema accepts the React 19 layout configuration. +- Deterministic DNS-over-HTTPS UI tests that validate provider URLs, query types, headers and parsed IPv4 and IPv6 answers without depending on external network availability. + +### Changed + +- The Python package, runtime metadata, UI package, upgrade guide and issue templates now identify the release as `3.1.0`. +- The version helper now targets the migrated `docs/content/installation/upgrading.mdx` path. +- Structured-output TypeScript models now include optional BGP next-hop enrichment fields and correctly distinguish BGP and traceroute responses. +- UI table components now use generic column and cell types instead of permissive `any` fallbacks. +- The frontend timeout helper now uses the caller-provided `AbortController` signal. +- The Docker build installs the pinned dependency closure with the patched npm and pnpm toolchain, then removes build-only package tooling from the final image. + +### Security + +- Raised vulnerable dependency floors and lockfile overrides for the fixable HIGH CVEs identified by the container scan, including Brotli, cryptography, lxml, multipart, Pillow and psutil. +- Updated frontend dependency overrides for the affected `ua-parser-js`, `@babel/runtime` and `@babel/helpers` packages. +- Final Trivy validation reports zero HIGH or CRITICAL vulnerabilities in the Alpine OS, Python packages, UI packages and docs package. + +### Fixed + +- Resolved strict UI type errors in route enrichment, structured-output narrowing, traceroute rendering and generic table usage. +- Cleared all UI Biome lint and formatting diagnostics. +- Removed live DNS test flakiness caused by external provider availability and cross-realm `AbortSignal` objects in jsdom. + +### Validation + +- Python test suite: 70 passed. +- UI test suite: 39 passed. +- UI TypeScript, lint and formatting checks: passed. +- Documentation typecheck and production export: passed for 33 routes. +- Docker image build: passed. +- `/mnt/AppData` compose validation: application reached HTTP 200 after its runtime UI build. + ## 3.0.0 - 2026-08-06 First release of the fork. The major version bump reflects a raised Python floor (3.13), the removal of the `hyperglass.external.bgptools` module, and changed default directive commands for FRRouting, Huawei and MikroTik. diff --git a/Dockerfile b/Dockerfile index 4c5126773..13f6bff98 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,26 +10,40 @@ ENV HYPEGLASS_DISABLE_UI=true ENV HYPERGLASS_CONTAINER=true COPY . . -FROM base as ui +FROM base AS ui WORKDIR /opt/hyperglass/hyperglass/ui # The glob patch that used to sit here is gone. It ran `npm --prefix # /usr/lib/node_modules/npm install glob@11.1.0`, which fails the build: installing # into npm's own tree makes npm reconcile npm's package.json, whose devDependencies # include the unpublished `@npmcli/docs`, so the install 404s. It is also no longer # needed, since the npm shipped by alpine bundles glob 13.x already. -RUN apk add build-base pkgconfig cairo-dev nodejs npm \ - && npm install -g npm@10.9.3 pnpm@11.6.0 \ - && pnpm install -P +# npm's bundled tar release is patched in isolation because npm cannot +# reconcile its own package manifest. pnpm is pinned to a release with a fixed +# bundled tar dependency. +RUN apk add --no-cache build-base pkgconfig cairo-dev nodejs npm \ + && npm install -g npm@12.0.2 pnpm@11.23.0 \ + && rm -rf /tmp/tar-patch \ + && mkdir -p /tmp/tar-patch \ + && npm --prefix /tmp/tar-patch install --omit=dev --ignore-scripts \ + tar@7.5.21 brace-expansion@5.0.9 ip-address@10.3.1 \ + && cp -a /tmp/tar-patch/node_modules/. /usr/local/lib/node_modules/npm/node_modules/ \ + && rm -rf /tmp/tar-patch \ + && pnpm install -P \ + && apk del npm -FROM ui as hyperglass +FROM ui AS hyperglass WORKDIR /opt/hyperglass # Install the pinned dependency set from the lockfile (the single source of # truth), then the package itself without re-resolving deps. --no-deps is # required: the lock is a complete closure and pins pillow past favicons' # declared cap (resolved via [tool.uv] override-dependencies), so letting pip # re-resolve would fail on favicons' pillow<11 metadata. -RUN pip3 install --no-deps -r requirements.lock \ - && pip3 install -e . --no-deps +RUN pip3 install --no-cache-dir --no-deps -r requirements.lock \ + && pip3 install --no-cache-dir -e . --no-deps \ + && rm -rf /root/.cache/pip \ + /usr/local/lib/python3.13/site-packages/pip \ + /usr/local/lib/python3.13/site-packages/pip-*.dist-info \ + /usr/local/bin/pip /usr/local/bin/pip3 EXPOSE ${HYPERGLASS_PORT} CMD ["python3", "-m", "hyperglass.console", "start"] diff --git a/docs/.gitignore b/docs/.gitignore index 8ade9b27b..756a90a69 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -7,4 +7,4 @@ node_modules/ fonts/ .next out -pages/changelog.mdx +content/changelog.mdx diff --git a/docs/app/[[...mdxPath]]/page.jsx b/docs/app/[[...mdxPath]]/page.jsx new file mode 100644 index 000000000..422fe696a --- /dev/null +++ b/docs/app/[[...mdxPath]]/page.jsx @@ -0,0 +1,15 @@ +import { generateStaticParamsFor, importPage } from "nextra/pages"; + +export const generateStaticParams = generateStaticParamsFor("mdxPath"); + +export async function generateMetadata(props) { + const params = await props.params; + const { metadata } = await importPage(params.mdxPath); + return metadata; +} + +export default async function Page(props) { + const params = await props.params; + const { default: MDXContent } = await importPage(params.mdxPath); + return <MDXContent {...props} params={params} />; +} diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx new file mode 100644 index 000000000..dcfeaf7f1 --- /dev/null +++ b/docs/app/layout.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import { Layout } from "nextra-theme-docs"; +import { getPageMap } from "nextra/page-map"; +import "nextra-theme-docs/style.css"; + +const noIndexFollow = process.env.CF_PAGES_BRANCH !== "main"; + +export const metadata: Metadata = { + metadataBase: new URL("https://hyperglass.dev"), + title: { + default: "hyperglass Documentation", + template: "%s | hyperglass", + }, + description: "hyperglass Documentation", + openGraph: { + type: "website", + siteName: "hyperglass", + images: ["/opengraph.jpg"], + }, + robots: { + index: !noIndexFollow, + follow: !noIndexFollow, + }, +}; + +type RootLayoutProps = Readonly<{ children: ReactNode }>; + +export default async function RootLayout({ children }: RootLayoutProps) { + const pageMap = await getPageMap(); + + return ( + <html lang="en" dir="ltr" suppressHydrationWarning> + <body> + <Layout + docsRepositoryBase="https://github.com/thatmattlove/hyperglass/tree/main/docs" + pageMap={pageMap} + > + {children} + </Layout> + </body> + </html> + ); +} diff --git a/docs/app/not-found.tsx b/docs/app/not-found.tsx new file mode 100644 index 000000000..257968f51 --- /dev/null +++ b/docs/app/not-found.tsx @@ -0,0 +1,3 @@ +export default function NotFound() { + return <h1>Page not found</h1>; +} diff --git a/docs/components/color.tsx b/docs/components/color.tsx index 0234cba37..2da258c4c 100644 --- a/docs/components/color.tsx +++ b/docs/components/color.tsx @@ -1,3 +1,5 @@ +"use client"; + type ColorProps = { hex: string; noText?: boolean; diff --git a/docs/components/platforms.tsx b/docs/components/platforms.tsx index dda7d708a..6cabcf560 100644 --- a/docs/components/platforms.tsx +++ b/docs/components/platforms.tsx @@ -1,4 +1,4 @@ -import { Code, Table, Td, Th, Tr } from "nextra/components"; +import { Code, Table } from "nextra/components"; import platforms from "~/platforms.json"; import { NotSupported } from "./not-supported-icon"; import { Supported } from "./supported-icon"; @@ -23,21 +23,21 @@ export const SupportedPlatforms = () => ( export const PlatformTable = () => ( <Table> <tbody> - <Tr> - <Th>Platform Keys</Th> - <Th>Natively Supported</Th> - </Tr> + <tr> + <th>Platform Keys</th> + <th>Natively Supported</th> + </tr> {platforms.map((spec) => ( - <Tr key={spec.keys.join("--")}> - <Td> + <tr key={spec.keys.join("--")}> + <td> {spec.keys.map((key) => ( <Code className="nx-mx-2" key={key}> {key} </Code> ))} - </Td> - <Td align="center">{spec.native ? <Supported /> : <NotSupported />}</Td> - </Tr> + </td> + <td align="center">{spec.native ? <Supported /> : <NotSupported />}</td> + </tr> ))} </tbody> </Table> diff --git a/docs/pages/_meta.tsx b/docs/content/_meta.js similarity index 97% rename from docs/pages/_meta.tsx rename to docs/content/_meta.js index 40849e0a0..0233a7c8e 100644 --- a/docs/pages/_meta.tsx +++ b/docs/content/_meta.js @@ -8,6 +8,11 @@ export default { configuration: "Configuration", platforms: "Platforms", plugins: "Plugins", + demo: { + title: "Demo", + type: "page", + href: "https://demo.hyperglass.dev", + }, documentation: { title: "Documentation", type: "menu", @@ -34,10 +39,4 @@ export default { }, }, }, - demo: { - title: "Demo", - type: "page", - href: "https://demo.hyperglass.dev", - newWindow: true, - }, }; diff --git a/docs/pages/configuration/_meta.tsx b/docs/content/configuration/_meta.js similarity index 100% rename from docs/pages/configuration/_meta.tsx rename to docs/content/configuration/_meta.js diff --git a/docs/pages/configuration/config.mdx b/docs/content/configuration/config.mdx similarity index 100% rename from docs/pages/configuration/config.mdx rename to docs/content/configuration/config.mdx diff --git a/docs/pages/configuration/config/_meta.tsx b/docs/content/configuration/config/_meta.js similarity index 100% rename from docs/pages/configuration/config/_meta.tsx rename to docs/content/configuration/config/_meta.js diff --git a/docs/pages/configuration/config/api-docs.mdx b/docs/content/configuration/config/api-docs.mdx similarity index 100% rename from docs/pages/configuration/config/api-docs.mdx rename to docs/content/configuration/config/api-docs.mdx diff --git a/docs/pages/configuration/config/caching.mdx b/docs/content/configuration/config/caching.mdx similarity index 100% rename from docs/pages/configuration/config/caching.mdx rename to docs/content/configuration/config/caching.mdx diff --git a/docs/pages/configuration/config/complete-config.mdx b/docs/content/configuration/config/complete-config.mdx similarity index 100% rename from docs/pages/configuration/config/complete-config.mdx rename to docs/content/configuration/config/complete-config.mdx diff --git a/docs/pages/configuration/config/logging.mdx b/docs/content/configuration/config/logging.mdx similarity index 100% rename from docs/pages/configuration/config/logging.mdx rename to docs/content/configuration/config/logging.mdx diff --git a/docs/pages/configuration/config/messages.mdx b/docs/content/configuration/config/messages.mdx similarity index 100% rename from docs/pages/configuration/config/messages.mdx rename to docs/content/configuration/config/messages.mdx diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/content/configuration/config/structured-output.mdx similarity index 100% rename from docs/pages/configuration/config/structured-output.mdx rename to docs/content/configuration/config/structured-output.mdx diff --git a/docs/pages/configuration/config/web-ui.mdx b/docs/content/configuration/config/web-ui.mdx similarity index 100% rename from docs/pages/configuration/config/web-ui.mdx rename to docs/content/configuration/config/web-ui.mdx diff --git a/docs/pages/configuration/devices.mdx b/docs/content/configuration/devices.mdx similarity index 100% rename from docs/pages/configuration/devices.mdx rename to docs/content/configuration/devices.mdx diff --git a/docs/pages/configuration/devices/_meta.tsx b/docs/content/configuration/devices/_meta.js similarity index 100% rename from docs/pages/configuration/devices/_meta.tsx rename to docs/content/configuration/devices/_meta.js diff --git a/docs/pages/configuration/devices/credentials.mdx b/docs/content/configuration/devices/credentials.mdx similarity index 100% rename from docs/pages/configuration/devices/credentials.mdx rename to docs/content/configuration/devices/credentials.mdx diff --git a/docs/pages/configuration/devices/http-device.mdx b/docs/content/configuration/devices/http-device.mdx similarity index 100% rename from docs/pages/configuration/devices/http-device.mdx rename to docs/content/configuration/devices/http-device.mdx diff --git a/docs/pages/configuration/devices/ssh-proxy.mdx b/docs/content/configuration/devices/ssh-proxy.mdx similarity index 100% rename from docs/pages/configuration/devices/ssh-proxy.mdx rename to docs/content/configuration/devices/ssh-proxy.mdx diff --git a/docs/pages/configuration/directives.mdx b/docs/content/configuration/directives.mdx similarity index 100% rename from docs/pages/configuration/directives.mdx rename to docs/content/configuration/directives.mdx diff --git a/docs/pages/configuration/examples/_meta.tsx b/docs/content/configuration/examples/_meta.js similarity index 100% rename from docs/pages/configuration/examples/_meta.tsx rename to docs/content/configuration/examples/_meta.js diff --git a/docs/pages/configuration/examples/add-your-own-command.mdx b/docs/content/configuration/examples/add-your-own-command.mdx similarity index 100% rename from docs/pages/configuration/examples/add-your-own-command.mdx rename to docs/content/configuration/examples/add-your-own-command.mdx diff --git a/docs/pages/configuration/examples/basic-configuration.mdx b/docs/content/configuration/examples/basic-configuration.mdx similarity index 100% rename from docs/pages/configuration/examples/basic-configuration.mdx rename to docs/content/configuration/examples/basic-configuration.mdx diff --git a/docs/pages/configuration/examples/customize-the-ui.mdx b/docs/content/configuration/examples/customize-the-ui.mdx similarity index 100% rename from docs/pages/configuration/examples/customize-the-ui.mdx rename to docs/content/configuration/examples/customize-the-ui.mdx diff --git a/docs/pages/configuration/examples/quick-start.mdx b/docs/content/configuration/examples/quick-start.mdx similarity index 100% rename from docs/pages/configuration/examples/quick-start.mdx rename to docs/content/configuration/examples/quick-start.mdx diff --git a/docs/pages/configuration/overview.mdx b/docs/content/configuration/overview.mdx similarity index 97% rename from docs/pages/configuration/overview.mdx rename to docs/content/configuration/overview.mdx index c90c4dcf1..0e172360e 100644 --- a/docs/pages/configuration/overview.mdx +++ b/docs/content/configuration/overview.mdx @@ -1,4 +1,4 @@ -import { Code, Table, Td, Th, Tr, Callout } from "nextra/components"; +import { Callout } from "nextra/components"; import { SupportedPlatforms } from "~/components/platforms"; Once you've gotten started with a basic configuration, you'll probably want to customize the look and feel of hyperglass by changing the logo or color scheme. Fortunately, there are _a lot_ ways to customize hyperglass. diff --git a/docs/pages/index.mdx b/docs/content/index.mdx similarity index 100% rename from docs/pages/index.mdx rename to docs/content/index.mdx diff --git a/docs/pages/installation.mdx b/docs/content/installation.mdx similarity index 100% rename from docs/pages/installation.mdx rename to docs/content/installation.mdx diff --git a/docs/pages/installation/_meta.tsx b/docs/content/installation/_meta.js similarity index 100% rename from docs/pages/installation/_meta.tsx rename to docs/content/installation/_meta.js diff --git a/docs/pages/installation/docker.mdx b/docs/content/installation/docker.mdx similarity index 100% rename from docs/pages/installation/docker.mdx rename to docs/content/installation/docker.mdx diff --git a/docs/pages/installation/environment-variables.mdx b/docs/content/installation/environment-variables.mdx similarity index 100% rename from docs/pages/installation/environment-variables.mdx rename to docs/content/installation/environment-variables.mdx diff --git a/docs/pages/installation/manual.mdx b/docs/content/installation/manual.mdx similarity index 100% rename from docs/pages/installation/manual.mdx rename to docs/content/installation/manual.mdx diff --git a/docs/pages/installation/reverse-proxy.mdx b/docs/content/installation/reverse-proxy.mdx similarity index 100% rename from docs/pages/installation/reverse-proxy.mdx rename to docs/content/installation/reverse-proxy.mdx diff --git a/docs/pages/installation/upgrading.mdx b/docs/content/installation/upgrading.mdx similarity index 87% rename from docs/pages/installation/upgrading.mdx rename to docs/content/installation/upgrading.mdx index d4b42eda3..30100053e 100644 --- a/docs/pages/installation/upgrading.mdx +++ b/docs/content/installation/upgrading.mdx @@ -5,7 +5,7 @@ cd /opt/hyperglass docker compose down docker compose rm -f git fetch -git checkout v3.0.0 +git checkout v3.1.0 docker compose build docker compose up ``` diff --git a/docs/pages/license.mdx b/docs/content/license.mdx similarity index 100% rename from docs/pages/license.mdx rename to docs/content/license.mdx diff --git a/docs/pages/platforms.mdx b/docs/content/platforms.mdx similarity index 100% rename from docs/pages/platforms.mdx rename to docs/content/platforms.mdx diff --git a/docs/pages/plugins.mdx b/docs/content/plugins.mdx similarity index 100% rename from docs/pages/plugins.mdx rename to docs/content/plugins.mdx diff --git a/docs/pages/user-guide.mdx b/docs/content/user-guide.mdx similarity index 100% rename from docs/pages/user-guide.mdx rename to docs/content/user-guide.mdx diff --git a/docs/mdx-components.js b/docs/mdx-components.js new file mode 100644 index 000000000..edcba2cc7 --- /dev/null +++ b/docs/mdx-components.js @@ -0,0 +1,10 @@ +import { useMDXComponents as getDocsMDXComponents } from "nextra-theme-docs"; + +const docsComponents = getDocsMDXComponents(); + +export function useMDXComponents(components) { + return { + ...docsComponents, + ...components, + }; +} diff --git a/docs/next-env.d.ts b/docs/next-env.d.ts index 4f11a03dc..830fb594c 100644 --- a/docs/next-env.d.ts +++ b/docs/next-env.d.ts @@ -1,5 +1,6 @@ /// <reference types="next" /> /// <reference types="next/image-types/global" /> +/// <reference path="./.next/types/routes.d.ts" /> // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/docs/next.config.mjs b/docs/next.config.mjs index 9223adc8d..75646eac2 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -1,27 +1,19 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import withNextra from "nextra"; +import nextra from "nextra"; function copyChangelog() { const dir = path.dirname(fileURLToPath(import.meta.url)); const src = path.resolve(dir, "..", "CHANGELOG.md"); const data = fs.readFileSync(src); const replaced = data.toString().replace("# Changelog\n\n", ""); - const dst = path.resolve(dir, "pages", "changelog.mdx"); + const dst = path.resolve(dir, "content", "changelog.mdx"); fs.writeFileSync(dst, replaced); } copyChangelog(); -/** - * @type {import('nextra').NextraConfig} - */ -const nextraConfig = { - theme: "nextra-theme-docs", - themeConfig: "./theme.config.tsx", -}; - /** * @type {import('next').NextConfig} */ @@ -32,4 +24,4 @@ const config = { output: "export", }; -export default withNextra(nextraConfig)(config); +export default nextra({})(config); diff --git a/docs/package.json b/docs/package.json index a63a8e993..533e32cc5 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,6 +4,7 @@ "description": "hyperglass documentation", "private": true, "scripts": { + "build": "next build", "dev": "next dev", "start": "next start", "typecheck": "tsc --noEmit" @@ -11,14 +12,16 @@ "author": "thatmattlove <matt@hyperglass.dev>", "license": "BSD-3-Clause-Clear", "dependencies": { - "next": "^14.1.1", - "nextra": "3.0.0-alpha.24", - "nextra-theme-docs": "3.0.0-alpha.24", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "next": "15.5.21", + "nextra": "4.6.1", + "nextra-theme-docs": "4.6.1", + "react": "19.1.0", + "react-dom": "19.1.0" }, "devDependencies": { "@types/node": "^20.11.24", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", "typescript": "^5.3.3" } } diff --git a/docs/patches/nextra-theme-docs@4.6.1.patch b/docs/patches/nextra-theme-docs@4.6.1.patch new file mode 100644 index 000000000..9198e35d9 --- /dev/null +++ b/docs/patches/nextra-theme-docs@4.6.1.patch @@ -0,0 +1,12 @@ +diff --git a/dist/schemas.js b/dist/schemas.js +index 82d2d01..a0ae44e 100644 +--- a/dist/schemas.js ++++ b/dist/schemas.js +@@ -65,7 +65,6 @@ const LayoutPropsSchema = z.strictObject({ + banner: reactNode.optional().meta({ + description: "Rendered [`<Banner>` component](/docs/built-ins/banner). E.g. `<Banner {...bannerProps} />`" + }), +- children: reactNode, + copyPageButton: z.boolean().default(true).meta({ + description: "Hide/show copy page content button." + }), diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 9585e0df9..3236fdf2e 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -4,29 +4,48 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + cross-spawn: 7.0.6 + dompurify: 3.1.3 + js-yaml: 3.15.1 + lodash: 4.18.0 + lodash-es: 4.18.0 + mermaid: 10.9.3 + nanoid: 3.3.18 + postcss: 8.5.18 + +patchedDependencies: + nextra-theme-docs@4.6.1: 5e1f3c09c82663f461daba8ce038f5bd36b3f2d3e08e5edbe1a1fc301578fa80 + importers: .: dependencies: next: - specifier: ^14.1.1 - version: 14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + specifier: 15.5.21 + version: 15.5.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nextra: - specifier: 3.0.0-alpha.24 - version: 3.0.0-alpha.24(@types/react@18.0.26)(next@14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.3.3) + specifier: 4.6.1 + version: 4.6.1(next@15.5.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(supports-color@4.5.0)(typescript@5.3.3) nextra-theme-docs: - specifier: 3.0.0-alpha.24 - version: 3.0.0-alpha.24(next@14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nextra@3.0.0-alpha.24(@types/react@18.0.26)(next@14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.3.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + specifier: 4.6.1 + version: 4.6.1(patch_hash=5e1f3c09c82663f461daba8ce038f5bd36b3f2d3e08e5edbe1a1fc301578fa80)(@types/react@19.2.18)(next@15.5.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nextra@4.6.1(next@15.5.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(supports-color@4.5.0)(typescript@5.3.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) react: - specifier: ^18.2.0 - version: 18.2.0 + specifier: 19.1.0 + version: 19.1.0 react-dom: - specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) + specifier: 19.1.0 + version: 19.1.0(react@19.1.0) devDependencies: '@types/node': specifier: ^20.11.24 version: 20.11.24 + '@types/react': + specifier: ^19.1.8 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.1.6 + version: 19.2.5(@types/react@19.2.18) typescript: specifier: ^5.3.3 version: 5.3.3 @@ -36,22 +55,205 @@ packages: '@braintree/sanitize-url@6.0.4': resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} - '@headlessui/react@1.7.18': - resolution: {integrity: sha512-4i5DOrzwN4qSgNsL4Si61VMkUcWbcSKueUV7sFhpHzQcSShdlHENE5+QBntMSRvHt8NyoFO2AGG8si9lq+w4zQ==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.26.28': + resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@formatjs/intl-localematcher@0.6.2': + resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + + '@headlessui/react@2.2.10': + resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==} engines: {node: '>=10'} peerDependencies: - react: ^16 || ^17 || ^18 - react-dom: ^16 || ^17 || ^18 + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@internationalized/date@3.12.3': + resolution: {integrity: sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==} + + '@internationalized/number@3.6.7': + resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} + + '@internationalized/string@3.2.10': + resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==} '@mdx-js/mdx@3.0.1': resolution: {integrity: sha512-eIQ4QTrOWyL3LWEe/bu6Taqzq2HQvHcyTMaOrI95P2/LmJE7AsfPfgJGuFLPVqBUE1BC1rik3VIhU+s9u72arA==} - '@mdx-js/react@3.0.1': - resolution: {integrity: sha512-9ZrPIU4MGf6et1m1ov3zKf+q9+deetI51zprKB1D/z3NOb+rUxxtEl3mCjW5wTGh6VhRdwPueh1oRzi6ezkA8A==} - peerDependencies: - '@types/react': '>=16' - react: '>=16' - '@napi-rs/simple-git-android-arm-eabi@0.1.16': resolution: {integrity: sha512-dbrCL0Pl5KZG7x7tXdtVsA5CO6At5ohDX3myf5xIYn9kN4jDFxsocl8bNt6Vb/hZQoJd8fI+k5VlJt+rFhbdVw==} engines: {node: '>= 10'} @@ -87,24 +289,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/simple-git-linux-arm64-musl@0.1.16': resolution: {integrity: sha512-I57Ph0F0Yn2KW93ep+V1EzKhACqX0x49vvSiapqIsdDA2PifdEWLc1LJarBolmK7NKoPqKmf6lAKKO9lhiZzkg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/simple-git-linux-x64-gnu@0.1.16': resolution: {integrity: sha512-AZYYFY2V7hlcQASPEOWyOa3e1skzTct9QPzz0LiDM3f/hCFY/wBaU2M6NC5iG3d2Kr38heuyFS/+JqxLm5WaKA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/simple-git-linux-x64-musl@0.1.16': resolution: {integrity: sha512-9TyMcYSBJwjT8jwjY9m24BZbu7ozyWTjsmYBYNtK3B0Um1Ov6jthSNneLVvouQ6x+k3Ow+00TiFh6bvmT00r8g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/simple-git-win32-arm64-msvc@0.1.16': resolution: {integrity: sha512-uslJ1WuAHCYJWui6xjsyT47SjX6KOHDtClmNO8hqKz1pmDSNY7AjyUY8HxvD1lK9bDnWwc4JYhikS9cxCqHybw==} @@ -122,91 +328,144 @@ packages: resolution: {integrity: sha512-C5wRPw9waqL2jk3jEDeJv+f7ScuO3N0a39HVdyFLkwKxHH4Sya4ZbzZsu2JLi6eEqe7RuHipHL6mC7B2OfYZZw==} engines: {node: '>= 10'} - '@next/env@14.1.1': - resolution: {integrity: sha512-7CnQyD5G8shHxQIIg3c7/pSeYFeMhsNbpU/bmvH7ZnDql7mNRgg8O2JZrhrc/soFnfBnKP4/xXNiiSIPn2w8gA==} + '@next/env@15.5.21': + resolution: {integrity: sha512-hjJI/GfrjWHgNguRIBzItjRRu0m3Nrz17GhxsjuHfjIvg9hyg3239REd2dpI+bpMTFuVrVprHzEQ19m++cDtbw==} - '@next/swc-darwin-arm64@14.1.1': - resolution: {integrity: sha512-yDjSFKQKTIjyT7cFv+DqQfW5jsD+tVxXTckSe1KIouKk75t1qZmj/mV3wzdmFb0XHVGtyRjDMulfVG8uCKemOQ==} + '@next/swc-darwin-arm64@15.5.21': + resolution: {integrity: sha512-ZfjqPEdi6TRC/fWx7UDbwb1fbVgyh2uD5tVTRKIDZDlYM+UNuE/LafDG2fwuAoZilADpABh46OY/F5qf9JjqLQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@14.1.1': - resolution: {integrity: sha512-KCQmBL0CmFmN8D64FHIZVD9I4ugQsDBBEJKiblXGgwn7wBCSe8N4Dx47sdzl4JAg39IkSN5NNrr8AniXLMb3aw==} + '@next/swc-darwin-x64@15.5.21': + resolution: {integrity: sha512-TlCf1NpxgQLzTrexuev75xwmNCJMd1/qkJpTVP1GRRcih93hlIBn1P72hkh8T0gnRFr6BmWksQtbyG3jT6jnww==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@14.1.1': - resolution: {integrity: sha512-YDQfbWyW0JMKhJf/T4eyFr4b3tceTorQ5w2n7I0mNVTFOvu6CGEzfwT3RSAQGTi/FFMTFcuspPec/7dFHuP7Eg==} + '@next/swc-linux-arm64-gnu@15.5.21': + resolution: {integrity: sha512-LXRsq1p+HvHSi7ygwNcSEEcK0zuo5jS75ZlqFHtOH+LF7qntXAJVJxah+1Pi/GyBm7EpkwU7m4EgbvIKrMqm9A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] - '@next/swc-linux-arm64-musl@14.1.1': - resolution: {integrity: sha512-fiuN/OG6sNGRN/bRFxRvV5LyzLB8gaL8cbDH5o3mEiVwfcMzyE5T//ilMmaTrnA8HLMS6hoz4cHOu6Qcp9vxgQ==} + '@next/swc-linux-arm64-musl@15.5.21': + resolution: {integrity: sha512-hyGixhFxpDKjqoev6l4KlcRBlt9AXWrGhDZwmwg49sMJM5tnKQPSi+SEj9+e5n+l/bthRGZUdh59GKIs6lQPRw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] - '@next/swc-linux-x64-gnu@14.1.1': - resolution: {integrity: sha512-rv6AAdEXoezjbdfp3ouMuVqeLjE1Bin0AuE6qxE6V9g3Giz5/R3xpocHoAi7CufRR+lnkuUjRBn05SYJ83oKNQ==} + '@next/swc-linux-x64-gnu@15.5.21': + resolution: {integrity: sha512-qfE+YfOba6S2+13e8qn1/UozDVNZ2clBlrs8UtDoax4s8ediu6sq93z66OEHUYlb69Tffh5JTNkgtsAKiSuugg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] - '@next/swc-linux-x64-musl@14.1.1': - resolution: {integrity: sha512-YAZLGsaNeChSrpz/G7MxO3TIBLaMN8QWMr3X8bt6rCvKovwU7GqQlDu99WdvF33kI8ZahvcdbFsy4jAFzFX7og==} + '@next/swc-linux-x64-musl@15.5.21': + resolution: {integrity: sha512-BXLGG+EvIwp/Rrgl6HY8sqvD6BOUOIRz8/naDbeLNX7mlA5H2XRcL6MW/0IGnJISfj5BA9gNhFyJj5yOoiIDJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] - '@next/swc-win32-arm64-msvc@14.1.1': - resolution: {integrity: sha512-1L4mUYPBMvVDMZg1inUYyPvFSduot0g73hgfD9CODgbr4xiTYe0VOMTZzaRqYJYBA9mana0x4eaAaypmWo1r5A==} + '@next/swc-win32-arm64-msvc@15.5.21': + resolution: {integrity: sha512-tNGNOlT0Wn7E4IMsSnufjXN/l2L2/AGdLLpa2vzS89SYCBuihgLn3ngLsIrvndAnWo9nAkus+4gZHTI/Ijx9HA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-ia32-msvc@14.1.1': - resolution: {integrity: sha512-jvIE9tsuj9vpbbXlR5YxrghRfMuG0Qm/nZ/1KDHc+y6FpnZ/apsgh+G6t15vefU0zp3WSpTMIdXRUsNl/7RSuw==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@next/swc-win32-x64-msvc@14.1.1': - resolution: {integrity: sha512-S6K6EHDU5+1KrBDLko7/c1MNy/Ya73pIAmvKeFwsF4RmBFJSO7/7YeD4FnZ4iBdzE69PpQ4sOMU9ORKeNuxe8A==} + '@next/swc-win32-x64-msvc@15.5.21': + resolution: {integrity: sha512-DmIdWmC9p4rdNIiQqo8ap0+Cnj6kKtTZnuSCxoYydSc8sgpDgAg9wFhxplunak9imLV0pTvc5WVCOHwm5eHLtQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@popperjs/core@2.11.8': - resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@react-aria/focus@3.22.1': + resolution: {integrity: sha512-CPxtkyrBi/HYY5P3lE/57sQ6qfa0lN8E55TOm89H0kNGv0lKt+/0zP7lWERzBjRr5IxBVrQX4gFEowBN52LPaA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/interactions@3.28.1': + resolution: {integrity: sha512-Bqb+HrD5I5MHS2SKBhISYqo2SW8Y2dfzgF/Y1lIJq7xqLxheo9vzxPGEHhz+XzkgGfoqEJx8A6a3C7uiqS3HWA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-types/shared@3.36.1': + resolution: {integrity: sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/twoslash@3.23.0': + resolution: {integrity: sha512-pNaLJWMA3LU7PhT8tm9OQBZ1epy0jmdgeJzntBtr1EVXLbHxGzTj3mnf9vOdcl84l96qnlJXkJ/NGXZYBpXl5g==} + peerDependencies: + typescript: '>=5.5.0' + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@shikijs/core@1.6.1': - resolution: {integrity: sha512-CqYyepN4SnBopaoXYwng4NO8riB5ask/LTCkhOFq+GNGtr2X+aKeD767eYdqYukeixEUvv4bXdyTYVaogj7KBw==} + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@shikijs/twoslash@1.6.1': - resolution: {integrity: sha512-VluGZXQ97sDFyxneOzsPkEHK06A6C1SRDh+kSM9AZAkzHorZaGxF4awgA3rh2K0oZnR94NZzfhq8GtERm38EEQ==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@swc/helpers@0.5.2': - resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} + '@swc/helpers@0.5.5': + resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} - '@tanstack/react-virtual@3.1.3': - resolution: {integrity: sha512-YCzcbF/Ws/uZ0q3Z6fagH+JVhx4JLvbSflgldMgLsuvB8aXjZLLb3HvrEVxY480F9wFlBiXlvQxOyXb5ENPrNA==} + '@tanstack/react-virtual@3.14.10': + resolution: {integrity: sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/virtual-core@3.1.3': - resolution: {integrity: sha512-Y5B4EYyv1j9V8LzeAoOVeTg0LI7Fo5InYKgAjkY1Pu9GjtUwX/EKxNcU7ng3sKr99WEf+bPTcktAeybyMOYo+g==} + '@tanstack/virtual-core@3.17.8': + resolution: {integrity: sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==} - '@theguild/remark-mermaid@0.0.5': - resolution: {integrity: sha512-e+ZIyJkEv9jabI4m7q29wZtZv+2iwPGsXJ2d46Zi7e+QcFudiyuqhLhHG/3gX3ZEB+hxTch+fpItyMS8jwbIcw==} + '@theguild/remark-mermaid@0.3.0': + resolution: {integrity: sha512-Fy1J4FSj8totuHsHFpaeWyWRaRSIvpzGTRoEfnNJc1JmLV9uV70sYE3zcT+Jj5Yw20Xq4iCsiT+3Ho49BBZcBQ==} peerDependencies: - react: ^18.2.0 + react: ^18.2.0 || ^19.0.0 - '@theguild/remark-npm2yarn@0.3.0': - resolution: {integrity: sha512-Fofw+9airYgjBd9G6PiHHCrptjyUybQ50JH9/5o9LCH54kggJ7stpCofzHjICB8L7VQbQ1Gwu23P/3CMVY1R4Q==} + '@theguild/remark-npm2yarn@0.3.3': + resolution: {integrity: sha512-ma6DvR03gdbvwqfKx1omqhg9May/VYGdMHvTzB4VuxkyS7KzfZ/lzrj43hmcsggpMje0x7SADA/pcMph0ejRnA==} + + '@ts-morph/common@0.28.1': + resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} @@ -247,20 +506,19 @@ packages: '@types/ms@0.7.31': resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} - '@types/nlcst@1.0.4': - resolution: {integrity: sha512-ABoYdNQ/kBSsLvZAekMhIPMQ3YUZvavStpKYs7BjLLuKVmIMA0LUgZ7b54zzuWJRbHF80v1cNf4r90Vd6eMQDg==} + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} '@types/node@20.11.24': resolution: {integrity: sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==} - '@types/prop-types@15.7.5': - resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} - - '@types/react@18.0.26': - resolution: {integrity: sha512-hCR3PJQsAIXyxhTNSiDFY//LhnMZWpNNr5etoCqx/iUfGc5gXWtQR2Phl908jVR6uPXacojQWTg4qRpkxTuGug==} + '@types/react-dom@19.2.5': + resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} + peerDependencies: + '@types/react': ^19.2.0 - '@types/scheduler@0.16.2': - resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} '@types/unist@2.0.6': resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} @@ -268,11 +526,14 @@ packages: '@types/unist@3.0.2': resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} - '@typescript/vfs@1.5.0': - resolution: {integrity: sha512-AJS307bPgbsZZ9ggCT3wwpg3VbTKMFNHfaY/uF0ahSkYYrPF2dSSKDNIDIQAHm9qJqbLvCsSJH7yN4Vs/CsMMg==} + '@typescript/vfs@1.6.4': + resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==} + peerDependencies: + typescript: '*' '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -284,18 +545,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - arch@2.2.0: - resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} - - arg@1.0.0: - resolution: {integrity: sha512-Wk7TEzl1KqvTGs/uyhmHO/3XLd3t1UeU4IstvPXVzGPM522cTjqjNZ99esCkcL52sjqjo8e8CTBcWhkxvGzoAw==} - - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} array-iterate@2.0.1: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} @@ -307,14 +562,22 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - better-react-mathjax@2.0.3: - resolution: {integrity: sha512-wfifT8GFOKb1TWm2+E50I6DJpLZ5kLbch283Lu043EJtwSv0XvZDjr4YfR4d2MjAhqP6SH4VjjrKgbX8R00oCQ==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + better-react-mathjax@2.3.0: + resolution: {integrity: sha512-K0ceQC+jQmB+NLDogO5HCpqmYf18AU2FxDbLdduYgkHYWZApFggkHE4dIaXCV1NqeoscESYXXo1GSkY6fA295w==} peerDependencies: react: '>=16.8' - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} caniuse-lite@1.0.30001591: resolution: {integrity: sha512-PCzRMei/vXjJyL5mJtzNiUCKP59dm8Apqc3PH8gJkMnMXZGox93RbE76jHsmLwmIo6/3nsYIpJtx0O7u5PqFuQ==} @@ -322,9 +585,9 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chalk@2.3.0: - resolution: {integrity: sha512-Az5zJR2CBujap2rqXGaJKaPHyJ0IrUimvYNX+ncCy8PJP4ltOGTrHUIo097ZaL2zMeKYpiCdqDvS6zdrTFok3Q==} - engines: {node: '>=4'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -341,23 +604,20 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - clipboardy@1.2.2: - resolution: {integrity: sha512-16KrBOV7bHmHdxcQiCvfUFYVFyEah4FI8vYT1Fr7CGSA4G+xBWMEfUEQJS1hxeHGtI9ju1Bzs9uXSbj5HZKArw==} - engines: {node: '>=4'} + clipboardy@4.0.0: + resolution: {integrity: sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==} + engines: {node: '>=18'} clsx@2.1.0: resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==} engines: {node: '>=6'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -376,14 +636,19 @@ packages: compute-scroll-into-view@3.1.0: resolution: {integrity: sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} - cross-spawn@5.1.0: - resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} - csstype@3.1.1: - resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} @@ -539,8 +804,8 @@ packages: dayjs@1.11.10: resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} - debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -558,6 +823,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -565,8 +834,8 @@ packages: resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==} engines: {node: '>=0.3.1'} - dompurify@3.0.9: - resolution: {integrity: sha512-uyb4NDIvQ3hRn6NiC+SIFaP4mJ/MdXlvtunaqK9Bn6dD3RuB/1S/gasEjDHD8eiaqdSael2vBv+hOs7Y+jhYOQ==} + dompurify@3.1.3: + resolution: {integrity: sha512-5sOWYSNPaxz6o2MUPvtyxTTqR4D3L77pr5rUQoWgD5ROQtVIZQgJkXbo1DLlK3vj11YGw5+LnF4SYti4gZmwng==} elkjs@0.9.2: resolution: {integrity: sha512-2Y/RaA1pdgSHpY0YG4TYuYCD2wh97CRvu22eLG3Kz0pgQ/6KbIFTxsTnDc4MH/6hFlg2L/9qXrDMG0nMjP63iw==} @@ -575,10 +844,6 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -587,32 +852,20 @@ packages: resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} engines: {node: '>=6'} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} estree-util-build-jsx@3.0.1: resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} - estree-util-is-identifier-name@2.0.1: - resolution: {integrity: sha512-rxZj1GkQhY4x1j/CSnybK9cGuMFQYFPLq0iNyopqf14aOVLFtMv7Esika+ObJWPWiOHuMOAHz3YkWoLYYRnzWQ==} - estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} estree-util-to-js@2.0.0: resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} - estree-util-value-to-estree@1.3.0: - resolution: {integrity: sha512-Y+ughcF9jSUJvncXwqRageavjrNPAI+1M/L3BI3PyLp1nmgYTGUXU6t5z1Y7OWuThoDdhPME07bQU+d5LxdJqw==} - engines: {node: '>=12.0.0'} - - estree-util-value-to-estree@3.1.1: - resolution: {integrity: sha512-5mvUrF2suuv5f5cGDnDphIy4/gW86z82kl5qG6mM9z04SEQI4FB5Apmaw/TGEf3l55nLtMs5s51dmhUzvAHQCA==} + estree-util-value-to-estree@3.5.0: + resolution: {integrity: sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ==} estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} @@ -620,43 +873,50 @@ packages: estree-walker@3.0.1: resolution: {integrity: sha512-woY0RUD87WzMBUiZLx8NsYr23N5BKsOMZHhu2hoNRVh6NXGfoiT1KOL8G3UHlJAnEDGmfa5ubNA/AacfG+Kb0g==} - execa@0.8.0: - resolution: {integrity: sha512-zDWS+Rb1E8BlqqhALSt9kUhss8Qq4nN3iof3gsOdyINksElaPyNBtKUMTR62qhvgVWR0CqCX7sdnKe4MnUbFEA==} - engines: {node: '>=4'} - - extend-shallow@2.0.1: - resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} - engines: {node: '>=0.10.0'} + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} - flexsearch@0.7.43: - resolution: {integrity: sha512-c5o/+Um8aqCSOXGcZoqZOm+NqtVwNsvVpWv6lfmSclU954O3wvQKxxK8zj74fPaSJbXpSLTs4PRhh+wnoCXnKg==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true - focus-visible@5.2.0: - resolution: {integrity: sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - get-stream@3.0.0: - resolution: {integrity: sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==} - engines: {node: '>=4'} + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - gray-matter@4.0.3: - resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} - engines: {node: '>=6.0'} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} has-flag@2.0.0: resolution: {integrity: sha512-P+1n3MnwjR/Epg9BBo1KT8qbye2g2Ou4sFumihwt6I4tsUX7jnLcX4BTOSKg/B1ZrIYMN9FcEnG4x5a7NB8Eng==} @@ -686,6 +946,9 @@ packages: hast-util-to-estree@3.1.0: resolution: {integrity: sha512-lfX5g6hqVh9kjS/B9E2gSkvHH4SZNiQFiqWS0x9fENzEl+8W12RqdRxX6d/Cwxi30tPQs3bIO+aolQJNp1bIyw==} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.0: resolution: {integrity: sha512-H/y0+IWPdsLLS738P8tDnrQ8Z+dj12zQQ6WC11TIM21C8WFVoIxcqWXf2H3hiTVZjF1AWqoimGwrTWecWrnmRQ==} @@ -710,6 +973,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -727,32 +994,39 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - intersection-observer@0.12.2: - resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} - is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - is-buffer@2.0.5: - resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} - engines: {node: '>=4'} - is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - is-extendable@0.1.1: - resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - is-plain-obj@3.0.0: - resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} - engines: {node: '>=10'} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} @@ -761,31 +1035,28 @@ packages: is-reference@3.0.0: resolution: {integrity: sha512-Eo1W3wUoHWoCoVM4GVl/a+K0IgiqE5aIo4kJABFyMum1ZORlPkC+UC357sSQUL5w5QCE5kCC9upl75b7+7CY/Q==} - is-stream@1.1.0: - resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} - engines: {node: '>=0.10.0'} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + is64bit@2.0.0: + resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==} + engines: {node: '>=18'} - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} - hasBin: true + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - katex@0.16.9: - resolution: {integrity: sha512-fsSYjWS0EEOwvy81j3vRA8TEAhQhKiqO+FQaKWp0m39qwOzHVBgAUBIXWj1pB+O2W3fIpNa6Y9KSKCVbfPhyAQ==} + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} hasBin: true khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - kind-of@6.0.3: - resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} - engines: {node: '>=0.10.0'} - kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -793,22 +1064,17 @@ packages: layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} - lodash-es@4.17.21: - resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash-es@4.18.0: + resolution: {integrity: sha512-koAgswPPA+UTaPN64Etp+PGP+WT6oqOS2NMi5yDkMaiGw9qY4VxQbQF0mtKMyr4BlTznWyzePV5UpECTJQmSUA==} + deprecated: Bad release. Please use lodash-es@4.17.23 instead. - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.18.0: + resolution: {integrity: sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA==} + deprecated: Bad release. Please use lodash@4.17.21 instead. longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - lru-cache@4.1.5: - resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} - markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -818,6 +1084,7 @@ packages: mathjax-full@3.2.2: resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==} + deprecated: Version 4 replaces this package with the scoped package @mathjax/src mdast-util-find-and-replace@3.0.1: resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} @@ -867,8 +1134,8 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - mdast-util-to-hast@13.1.0: - resolution: {integrity: sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} mdast-util-to-markdown@2.1.0: resolution: {integrity: sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==} @@ -879,8 +1146,15 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - mermaid@10.8.0: - resolution: {integrity: sha512-9CzfSreRjdDJxX796+jW4zjEq0DVw5xVF0nWsqff8OTbrt+ml0TZ5PyYUjjUZJa2NYxYJZZXewEquxGiM8qZEA==} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + mermaid@10.9.3: + resolution: {integrity: sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==} mhchemparser@4.2.1: resolution: {integrity: sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==} @@ -1059,6 +1333,18 @@ packages: micromark@4.0.0: resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + mj-context-menu@0.6.1: resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==} @@ -1066,78 +1352,90 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.7: - resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - next-themes@0.3.0: - resolution: {integrity: sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: - react: ^16.8 || ^17 || ^18 - react-dom: ^16.8 || ^17 || ^18 + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@14.1.1: - resolution: {integrity: sha512-McrGJqlGSHeaz2yTRPkEucxQKe5Zq7uPwyeHNmJaZNY4wx9E9QdxmTp310agFRoMuIYgQrCrT3petg13fSVOww==} - engines: {node: '>=18.17.0'} + next@15.5.21: + resolution: {integrity: sha512-/TsdBtkWLhkl+NVL3Uqws2UphNd6IPzOtzSk1fHaf+0P7GQKLZDUytyhns/Ykbzdy9+YRjwG7ONvrHaaTDdFqQ==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 - react: ^18.2.0 - react-dom: ^18.2.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 sass: ^1.3.0 peerDependenciesMeta: '@opentelemetry/api': optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true sass: optional: true - nextra-theme-docs@3.0.0-alpha.24: - resolution: {integrity: sha512-AUuv3o1xYzGGvUP/lxxf7i++Zyycy4bH22l6T8uDVez2n0j/RDbzqmSIdL4Jh4POUd0fyxEG+BUomKXKyj3L1g==} + nextra-theme-docs@4.6.1: + resolution: {integrity: sha512-u5Hh8erVcGOXO1FVrwYBgrEjyzdYQY0k/iAhLd8RofKp+Bru3fyLy9V9W34mfJ0KHKHjv/ldlDTlb4KlL4eIuQ==} peerDependencies: - next: '>=13' - nextra: 3.0.0-alpha.24 - react: '>=16.13.1' - react-dom: '>=16.13.1' + next: '>=14' + nextra: 4.6.1 + react: '>=18' + react-dom: '>=18' - nextra@3.0.0-alpha.24: - resolution: {integrity: sha512-KScl/DQG68JBGy6gfOFNdVcrlc24YvcegGnYwUXvpCoHx2YcBUSp6FE7LeaX/C/mI4PPIMmTJuEWIjGH9DXeCQ==} + nextra@4.6.1: + resolution: {integrity: sha512-yz5WMJFZ5c58y14a6Rmwt+SJUYDdIgzWSxwtnpD4XAJTq3mbOqOg3VTaJqLiJjwRSxoFRHNA1yAhnhbvbw9zSg==} engines: {node: '>=18'} peerDependencies: - next: '>=13' - react: '>=16.13.1' - react-dom: '>=16.13.1' + next: '>=14' + react: '>=18' + react-dom: '>=18' - nlcst-to-string@3.1.1: - resolution: {integrity: sha512-63mVyqaqt0cmn2VcI2aH6kxe1rLAmSROqHMA0i4qqg1tidkfExgpb0FGMikMCn86mw5dFtBtEANfmSSK7TjNHw==} + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} non-layered-tidy-tree-layout@2.0.2: resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} - npm-run-path@2.0.2: - resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} - engines: {node: '>=4'} + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - npm-to-yarn@2.2.1: - resolution: {integrity: sha512-O/j/ROyX0KGLG7O6Ieut/seQ0oiTpHF2tXAcFbpdTLQFiaNtkyTXXocM1fwpaa60dg1qpWj0nHlbNhx6qwuENQ==} + npm-to-yarn@3.2.0: + resolution: {integrity: sha512-K1HmQeZT2HrjpsR6KgqbN2FAXL2NrJJmNUSD9ck7HGTVu1JKXox8n9SB+tjbU8m8JGLF4OscrroPepew/L7/Xw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - p-finally@1.0.0: - resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} - engines: {node: '>=4'} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} - p-limit@4.0.0: - resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} parse-entities@4.0.0: resolution: {integrity: sha512-5nk9Fn03x3rEhGaX1FU6IDwG/k+GxLXlFAkgrbM1asuAFl3BhdQWvASaIsmwWypRNcZKHPYnIuOSfIWEyEQnPQ==} - parse-latin@5.0.1: - resolution: {integrity: sha512-b/K8ExXaWC9t34kKeDV8kGXBkXZ1HCSAZRYE7HR14eA1GlXX5L8iWhs8USJNhQU9q5ci413jCKF0gOyovvyRBg==} + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} parse-numeric-range@1.3.0: resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} @@ -1145,49 +1443,98 @@ packages: parse5@7.1.2: resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} - path-key@2.0.1: - resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} - engines: {node: '>=4'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} periscopic@3.0.4: resolution: {integrity: sha512-SFx68DxCv0Iyo6APZuw/AKewkkThGwssmU0QWtTlvov3VAtPX+QJ4CadwSaz8nrT5jPIuxdvJWB4PnD2KNDxQg==} - picocolors@1.0.0: - resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.18: + resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==} engines: {node: ^10 || ^12 || >=14} property-information@6.2.0: resolution: {integrity: sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg==} - pseudomap@1.0.2: - resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-aria@3.51.0: + resolution: {integrity: sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-compiler-runtime@19.1.0-rc.3: + resolution: {integrity: sha512-Cssogys2XZu6SqxRdX2xd8cQAf57BBvFbLEBlIa77161lninbKUn/EqbecCe7W3eqDQfg3rIoOwzExzgCh7h/g==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental + + react-dom@19.1.0: + resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + peerDependencies: + react: ^19.1.0 + + react-medium-image-zoom@5.4.9: + resolution: {integrity: sha512-czBdTbJI6JEbqeRIT5M8D3j7TrffBfo00U0P0lzzd19usj8PiR1yPH7PK/vQlwBE/Sv9YFdJGN6XauJPi4jRyg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom@18.2.0: - resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + react-stately@3.49.0: + resolution: {integrity: sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==} peerDependencies: - react: ^18.2.0 + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - react@18.2.0: - resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + react@19.1.0: + resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} reading-time@1.5.0: resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + rehype-katex@7.0.0: resolution: {integrity: sha512-h8FPkGE00r2XKU+/acgqwWUlyzve1IiOKwsEkg4pDL3k48PiE0Pt+/uLtVHDVkN1yA4iurZN6UES8ivHVEQV6Q==} rehype-parse@9.0.0: resolution: {integrity: sha512-WG7nfvmWWkCR++KEkZevZb/uw41E8TsH4DsY9UxsTbIXCVGbAs4S+r8FrQ+OtH5EEQAs+5UxKC42VinkmpA1Yw==} - rehype-pretty-code@0.13.0: - resolution: {integrity: sha512-+22dz1StXlF7dlMyOySNaVxgcGhMI4BCxq0JxJJPWYGiKsI6cu5jyuIKGHXHvH18D8sv1rdKtvsY9UEfN3++SQ==} + rehype-pretty-code@0.14.1: + resolution: {integrity: sha512-IpG4OL0iYlbx78muVldsK86hdfNoht0z63AP7sekQNW2QOTmjxB7RbTO+rhIYNGRljgHxgVZoPwUl6bIC9SbjA==} engines: {node: '>=18'} peerDependencies: - shiki: ^1.0.0 + shiki: ^1.0.0 || ^2.0.0 || ^3.0.0 rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} @@ -1207,34 +1554,41 @@ packages: remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} - remark-reading-time@2.0.1: - resolution: {integrity: sha512-fy4BKy9SRhtYbEHvp6AItbRTnrhiDGbqLQTSYVbQPGuRCncU1ubSsh9p/W5QZSxtYcUXv8KGL0xBgPLyNJA1xw==} + remark-reading-time@2.1.0: + resolution: {integrity: sha512-gBsJbQv87TUq4dRMSOgIX6P60Tk9ke8c29KsL7bccmsv2m9AycDfVu3ghRtrNpHLZU3TE5P/vImGOMSPzYU8rA==} remark-rehype@11.1.0: resolution: {integrity: sha512-z3tJrAs2kIs1AqIIy6pzHmAHlF1hWQ+OdY4/hv+Wxe35EhyLKcajL33iUEn3ScxtFox9nUvRufR/Zre8Q08H/g==} - remark-smartypants@2.1.0: - resolution: {integrity: sha512-qoF6Vz3BjU2tP6OfZqHOvCU0ACmu/6jhGaINSQRI9mM7wCxNQTKB3JUAN4SVoN2ybElEDTxBIABRep7e569iJw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + remark-smartypants@3.0.3: + resolution: {integrity: sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA==} + engines: {node: '>=16.0.0'} remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - retext-latin@3.1.0: - resolution: {integrity: sha512-5MrD1tuebzO8ppsja5eEu+ZbBeUNCjoEarn70tkXOS7Bdsdf6tNahsv2bY0Z8VooFF6cw7/6S+d3yI/TMlMVVQ==} + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} - retext-smartypants@5.2.0: - resolution: {integrity: sha512-Do8oM+SsjrbzT2UNIKgheP0hgUQTDDQYyZaIY3kfq0pdFzoPk+ZClYJ+OERNXveog4xf1pZL4PfRxNoVL7a/jw==} + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} - retext-stringify@3.1.0: - resolution: {integrity: sha512-767TLOaoXFXyOnjx/EggXlb37ZD2u4P1n0GJqVdpipqACsQP+20W+BNpMYrlJkq7hxffnFk+jc6mAK9qrbuB8w==} + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} - retext@8.1.0: - resolution: {integrity: sha512-N9/Kq7YTn6ZpzfiGW45WfEGJqFf1IM1q8OsRa1CGzIebCJBNCANDRmOrholiDRGKo/We7ofKR4SEvcGAWEMD3Q==} + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -1245,36 +1599,45 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.23.0: - resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} - section-matter@1.0.0: - resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} - engines: {node: '>=4'} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true - shebang-command@1.2.0: - resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} - engines: {node: '>=0.10.0'} + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - shebang-regex@1.0.0: - resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} - engines: {node: '>=0.10.0'} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} - shiki@1.6.1: - resolution: {integrity: sha512-1Pu/A1rtsG6HZvQm4W0NExQ45e02og+rPog7PDaFDiMumZgOYnZIu4JtGQeAIfMwdbKSjJQoCUr79vDLKUUxWA==} + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} slash@5.1.0: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} - source-map-js@1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} source-map@0.7.4: @@ -1288,23 +1651,12 @@ packages: resolution: {integrity: sha512-sJrL3/wHzNwJRLBdf6CjJWIlxC04iYKkyXvYSVsWVOiC2DSkHmxsqOhEeMsBA9XK+CHuNcsdkbFDnoUfAsmp9g==} hasBin: true - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - stringify-entities@4.0.3: resolution: {integrity: sha512-BP9nNHMhhfcMbiuQKCqMjhDP5yBCAxsPu4pHFFzJ6Alo9dZgY4VLDPutXqIjpRiMoKdp7Av85Gr73Q5uH9k7+g==} - strip-bom-string@1.0.0: - resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} - engines: {node: '>=0.10.0'} - - strip-eof@1.0.0: - resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} - engines: {node: '>=0.10.0'} + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} style-to-object@0.4.4: resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} @@ -1312,13 +1664,13 @@ packages: style-to-object@1.0.6: resolution: {integrity: sha512-khxq+Qm3xEyZfKd/y9L3oIWQimxuc4STrQKtQn8aSDRHb8mFgpukgX1hdzfrMEW6JCjyJ8p89x+IUMVnCBI1PA==} - styled-jsx@5.1.1: - resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} peerDependencies: '@babel/core': '*' babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' peerDependenciesMeta: '@babel/core': optional: true @@ -1332,13 +1684,24 @@ packages: resolution: {integrity: sha512-ycQR/UbvI9xIlEdQT1TQqwoXtEldExbCEAJgRo5YXlmSKjv6ThHnP9/vwGa1gr19Gfw+LkFd7KqYMhzrRC5JYw==} engines: {node: '>=4'} - title@3.5.3: - resolution: {integrity: sha512-20JyowYglSEeCvZv3EZ0nZ046vLarO37prvV0mbtQV7C8DJPGgN967r8SJkqd3XK3K3lD3/Iyfp3avjfil8Q2Q==} + system-architecture@0.1.0: + resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} + engines: {node: '>=18'} + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + title@4.0.1: + resolution: {integrity: sha512-xRnPkJx9nvE5MF6LkB5e8QJjE2FW8269wTu/LQdf7zZqBgPly0QJPf/CWAo7srj5so4yXfoLEdCFgurlpi47zg==} hasBin: true - titleize@1.0.0: - resolution: {integrity: sha512-TARUb7z1pGvlLxgPk++7wJ6aycXF3GJ0sNSBTAsTuJrQG5QuZlkUQP+zl+nbjAh4gMX9yDw9ZYklMd7vAfJKEw==} - engines: {node: '>=0.10.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -1350,16 +1713,19 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} - tslib@2.4.1: - resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} + ts-morph@27.0.2: + resolution: {integrity: sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - twoslash-protocol@0.2.6: - resolution: {integrity: sha512-8NbJlYeRdBcCTQ7ui7pdRPC1NL16aOnoYNz06oBW+W0qWNuiQXHgE8UeNvbA038aDd6ZPuuD5WedsBIESocB4g==} + twoslash-protocol@0.3.9: + resolution: {integrity: sha512-9/iwp+CXOnjFMPQuPL5PkuRbZnDoNpBvtJCLs9t8kDYkL3YHujbvnHfZA1i5fApDftVEdBw+T/4F+dH5kIzpYQ==} - twoslash@0.2.6: - resolution: {integrity: sha512-DcAKIyXMB6xNs+SOw/oF8GvUr/cfJSqznngVXYbAUIVfTW3M8vWSEoCaz/RgSD+M6vwtK8DJ4/FmYBF5MN8BGw==} + twoslash@0.3.9: + resolution: {integrity: sha512-rDclk+OtzuTX+tnea7DYLCkqGQ3eP0IyfD+kzUJ7t46X/NzlaxwrhecmEBNuSCuEn3V+n1PhcjUUQQ7gUJzX5Q==} peerDependencies: - typescript: '*' + typescript: ^5.5.0 || ^6.0.0 typescript@5.3.3: resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} @@ -1369,26 +1735,17 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - unherit@3.0.1: - resolution: {integrity: sha512-akOOQ/Yln8a2sgcLj4U0Jmx0R5jpIg2IUyRrWOzmEbjBtGzBdHtSeFKgoEcoH4KYIG/Pb8GQ/BwtYm0GCq1Sqg==} - - unified@10.1.2: - resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} - - unified@11.0.4: - resolution: {integrity: sha512-apMPnyLjAX+ty4OrNap7yumyVAMlKx5IWU2wlzzUdYJO9A8f1p9m/gywF/GM2ZDFcjQPrx59Mc90KwmxsoklxQ==} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} - unist-util-is@5.1.1: - resolution: {integrity: sha512-F5CZ68eYzuSvJjGhCLPL3cYx45IxkqXSetCcRgUXtbcm50X2L9oOWQlfUfDdAf+6Pd27YDblBfdtmsThXmwpbQ==} - unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} - unist-util-modify-children@3.1.1: - resolution: {integrity: sha512-yXi4Lm+TG5VG+qvokP6tpnk+r1EPwyYL04JWDxLvgvPV40jANh7nm3udk65OOWquvbMDe+PL9+LmkxDpTv/7BA==} + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} unist-util-position-from-estree@2.0.0: resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} @@ -1408,29 +1765,23 @@ packages: unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - unist-util-visit-children@2.0.2: - resolution: {integrity: sha512-+LWpMFqyUwLGpsQxpumsQ9o9DG2VGLFrpz+rpVXYIEdPy57GSy5HioC0g3bg/8WP9oCLlapQtklOzQ8uLS496Q==} - - unist-util-visit-parents@4.1.1: - resolution: {integrity: sha512-1xAFJXAKpnnJl8G7K5KgU7FY55y3GcLIXqkzUj5QF/QVP7biUm0K0O2oqVkYsdjzJKifYeWn9+o6piAK2hGSHw==} - - unist-util-visit-parents@5.1.1: - resolution: {integrity: sha512-gks4baapT/kNRaWxuGkl5BIhoanZo7sC/cUT/JToSRNL1dYoXRFl75d++NkjYk4TAu2uv2Px+l8guMajogeuiw==} + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} unist-util-visit-parents@6.0.1: resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} - unist-util-visit@3.1.0: - resolution: {integrity: sha512-Szoh+R/Ll68QWAyQyZZpQzZQm2UPbxibDvaY8Xc9SUtYgPsDzx5AWSk++UUt2hJuow8mvwR+rG+LQLw+KsuAKA==} - - unist-util-visit@4.1.1: - resolution: {integrity: sha512-n9KN3WV9k4h1DxYR1LoajgN93wpEi/7ZplVe02IoB4gH5ctI1AaF2670BLHQYbwj+pY83gFtyeySFiyMHJklrg==} - unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uvu@0.5.6: @@ -1441,15 +1792,9 @@ packages: vfile-location@5.0.2: resolution: {integrity: sha512-NXPYyxyBSH7zB5U6+3uDdd6Nybz6o6/od9rk8bp9H8GR3L+cm/fC0uUTbqBmUTnMCUDslAGBOIKNfvvb+gGlDg==} - vfile-message@3.1.3: - resolution: {integrity: sha512-0yaU+rj2gKAyEk12ffdSbBfjnnj+b1zqTBv3OQCTn8yEB02bsPizwdBPrLJjHnK+cU9EMMcUnNv938XcZIkmdA==} - vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} - vfile@5.3.6: - resolution: {integrity: sha512-ADBsmerdGBs2WYckrLBEmuETSPyTD4TuLxTrw0DvjirxW1ra4ZwkbzG8ndsv3Q57smvHxo677MHaQrY9yxH8cA==} - vfile@6.0.1: resolution: {integrity: sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==} @@ -1459,53 +1804,205 @@ packages: web-worker@1.3.0: resolution: {integrity: sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==} - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} - hasBin: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wicked-good-xpath@1.3.0: + resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==} + + xmldom-sre@0.1.31: + resolution: {integrity: sha512-f9s+fUkX04BxQf+7mMWAp5zk61pciie+fFLC9hX9UVvCeJQfNHRHXpeo5MPcR0EUf57PYLdt+ZO4f3Ipk2oZUw==} + engines: {node: '>=0.1'} + + yaml@2.4.2: + resolution: {integrity: sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==} + engines: {node: '>= 14'} + hasBin: true + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand@5.0.15: + resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@braintree/sanitize-url@6.0.4': {} + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@floating-ui/react@0.26.28(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@floating-ui/utils': 0.2.12 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tabbable: 6.5.0 + + '@floating-ui/utils@0.2.12': {} + + '@formatjs/intl-localematcher@0.6.2': + dependencies: + tslib: 2.8.1 + + '@headlessui/react@2.2.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react': 0.26.28(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@react-aria/focus': 3.22.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@react-aria/interactions': 3.28.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-virtual': 3.14.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + use-sync-external-store: 1.6.0(react@19.1.0) + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true - wicked-good-xpath@1.3.0: - resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==} + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true - xmldom-sre@0.1.31: - resolution: {integrity: sha512-f9s+fUkX04BxQf+7mMWAp5zk61pciie+fFLC9hX9UVvCeJQfNHRHXpeo5MPcR0EUf57PYLdt+ZO4f3Ipk2oZUw==} - engines: {node: '>=0.1'} + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true - yallist@2.1.2: - resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true - yaml@2.4.2: - resolution: {integrity: sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==} - engines: {node: '>= 14'} - hasBin: true + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true - yocto-queue@1.0.0: - resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} - engines: {node: '>=12.20'} + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true - zod-validation-error@1.5.0: - resolution: {integrity: sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw==} - engines: {node: '>=16.0.0'} - peerDependencies: - zod: ^3.18.0 + '@img/sharp-win32-arm64@0.34.5': + optional: true - zod@3.22.4: - resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + '@img/sharp-win32-ia32@0.34.5': + optional: true - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + '@img/sharp-win32-x64@0.34.5': + optional: true -snapshots: + '@internationalized/date@3.12.3': + dependencies: + '@swc/helpers': 0.5.5 - '@braintree/sanitize-url@6.0.4': {} + '@internationalized/number@3.6.7': + dependencies: + '@swc/helpers': 0.5.5 - '@headlessui/react@1.7.18(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@internationalized/string@3.2.10': dependencies: - '@tanstack/react-virtual': 3.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - client-only: 0.0.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@swc/helpers': 0.5.5 - '@mdx-js/mdx@3.0.1': + '@mdx-js/mdx@3.0.1(supports-color@4.5.0)': dependencies: '@types/estree': 1.0.0 '@types/estree-jsx': 1.0.0 @@ -1517,15 +2014,15 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-to-js: 2.0.0 estree-walker: 3.0.1 - hast-util-to-estree: 3.1.0 - hast-util-to-jsx-runtime: 2.3.0 + hast-util-to-estree: 3.1.0(supports-color@4.5.0) + hast-util-to-jsx-runtime: 2.3.0(supports-color@4.5.0) markdown-extensions: 2.0.0 periscopic: 3.0.4 - remark-mdx: 3.0.1 - remark-parse: 11.0.0 + remark-mdx: 3.0.1(supports-color@4.5.0) + remark-parse: 11.0.0(supports-color@4.5.0) remark-rehype: 11.1.0 source-map: 0.7.4 - unified: 11.0.4 + unified: 11.0.5 unist-util-position-from-estree: 2.0.0 unist-util-stringify-position: 4.0.0 unist-util-visit: 5.0.0 @@ -1533,12 +2030,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@mdx-js/react@3.0.1(@types/react@18.0.26)(react@18.2.0)': - dependencies: - '@types/mdx': 2.0.3 - '@types/react': 18.0.26 - react: 18.2.0 - '@napi-rs/simple-git-android-arm-eabi@0.1.16': optional: true @@ -1586,72 +2077,143 @@ snapshots: '@napi-rs/simple-git-win32-arm64-msvc': 0.1.16 '@napi-rs/simple-git-win32-x64-msvc': 0.1.16 - '@next/env@14.1.1': {} + '@next/env@15.5.21': {} - '@next/swc-darwin-arm64@14.1.1': + '@next/swc-darwin-arm64@15.5.21': optional: true - '@next/swc-darwin-x64@14.1.1': + '@next/swc-darwin-x64@15.5.21': optional: true - '@next/swc-linux-arm64-gnu@14.1.1': + '@next/swc-linux-arm64-gnu@15.5.21': optional: true - '@next/swc-linux-arm64-musl@14.1.1': + '@next/swc-linux-arm64-musl@15.5.21': optional: true - '@next/swc-linux-x64-gnu@14.1.1': + '@next/swc-linux-x64-gnu@15.5.21': optional: true - '@next/swc-linux-x64-musl@14.1.1': + '@next/swc-linux-x64-musl@15.5.21': optional: true - '@next/swc-win32-arm64-msvc@14.1.1': + '@next/swc-win32-arm64-msvc@15.5.21': optional: true - '@next/swc-win32-ia32-msvc@14.1.1': + '@next/swc-win32-x64-msvc@15.5.21': optional: true - '@next/swc-win32-x64-msvc@14.1.1': - optional: true + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@react-aria/focus@3.22.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@swc/helpers': 0.5.5 + react: 19.1.0 + react-aria: 3.51.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-dom: 19.1.0(react@19.1.0) + + '@react-aria/interactions@3.28.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@react-types/shared': 3.36.1(react@19.1.0) + '@swc/helpers': 0.5.5 + react: 19.1.0 + react-aria: 3.51.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-dom: 19.1.0(react@19.1.0) - '@popperjs/core@2.11.8': {} + '@react-types/shared@3.36.1(react@19.1.0)': + dependencies: + react: 19.1.0 + + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/core@1.6.1': {} + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 - '@shikijs/twoslash@1.6.1(typescript@5.3.3)': + '@shikijs/twoslash@3.23.0(supports-color@4.5.0)(typescript@5.3.3)': dependencies: - '@shikijs/core': 1.6.1 - twoslash: 0.2.6(typescript@5.3.3) + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + twoslash: 0.3.9(supports-color@4.5.0)(typescript@5.3.3) + typescript: 5.3.3 transitivePeerDependencies: - supports-color - - typescript - '@swc/helpers@0.5.2': + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@swc/helpers@0.5.5': dependencies: - tslib: 2.4.1 + '@swc/counter': 0.1.3 + tslib: 2.8.1 - '@tanstack/react-virtual@3.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@tanstack/react-virtual@3.14.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@tanstack/virtual-core': 3.1.3 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@tanstack/virtual-core': 3.17.8 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) - '@tanstack/virtual-core@3.1.3': {} + '@tanstack/virtual-core@3.17.8': {} - '@theguild/remark-mermaid@0.0.5(react@18.2.0)': + '@theguild/remark-mermaid@0.3.0(react@19.1.0)(supports-color@4.5.0)': dependencies: - mermaid: 10.8.0 - react: 18.2.0 + mermaid: 10.9.3(supports-color@4.5.0) + react: 19.1.0 unist-util-visit: 5.0.0 transitivePeerDependencies: - supports-color - '@theguild/remark-npm2yarn@0.3.0': + '@theguild/remark-npm2yarn@0.3.3': dependencies: - npm-to-yarn: 2.2.1 + npm-to-yarn: 3.2.0 unist-util-visit: 5.0.0 + '@ts-morph/common@0.28.1': + dependencies: + minimatch: 10.2.6 + path-browserify: 1.0.1 + tinyglobby: 0.2.17 + '@types/acorn@4.0.6': dependencies: '@types/estree': 1.0.0 @@ -1692,31 +2254,30 @@ snapshots: '@types/ms@0.7.31': {} - '@types/nlcst@1.0.4': + '@types/nlcst@2.0.3': dependencies: - '@types/unist': 2.0.6 + '@types/unist': 3.0.2 '@types/node@20.11.24': dependencies: undici-types: 5.26.5 - '@types/prop-types@15.7.5': {} - - '@types/react@18.0.26': + '@types/react-dom@19.2.5(@types/react@19.2.18)': dependencies: - '@types/prop-types': 15.7.5 - '@types/scheduler': 0.16.2 - csstype: 3.1.1 + '@types/react': 19.2.18 - '@types/scheduler@0.16.2': {} + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 '@types/unist@2.0.6': {} '@types/unist@3.0.2': {} - '@typescript/vfs@1.5.0': + '@typescript/vfs@1.6.4(supports-color@4.5.0)(typescript@5.3.3)': dependencies: - debug: 4.3.4 + debug: 4.4.3(supports-color@4.5.0) + typescript: 5.3.3 transitivePeerDependencies: - supports-color @@ -1728,17 +2289,11 @@ snapshots: acorn@8.8.1: {} - ansi-styles@3.2.1: - dependencies: - color-convert: 1.9.3 - - arch@2.2.0: {} - - arg@1.0.0: {} + arg@5.0.2: {} - argparse@1.0.10: + aria-hidden@1.2.6: dependencies: - sprintf-js: 1.0.3 + tslib: 2.8.1 array-iterate@2.0.1: {} @@ -1746,24 +2301,26 @@ snapshots: bail@2.0.2: {} - better-react-mathjax@2.0.3(react@18.2.0): + balanced-match@4.0.4: {} + + better-react-mathjax@2.3.0(react@19.1.0): dependencies: mathjax-full: 3.2.2 - react: 18.2.0 + react: 19.1.0 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 - busboy@1.6.0: + braces@3.0.3: dependencies: - streamsearch: 1.1.0 + fill-range: 7.1.1 caniuse-lite@1.0.30001591: {} ccount@2.0.1: {} - chalk@2.3.0: - dependencies: - ansi-styles: 3.2.1 - escape-string-regexp: 1.0.5 - supports-color: 4.5.0 + chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -1775,20 +2332,17 @@ snapshots: client-only@0.0.1: {} - clipboardy@1.2.2: + clipboardy@4.0.0: dependencies: - arch: 2.2.0 - execa: 0.8.0 + execa: 8.0.1 + is-wsl: 3.1.1 + is64bit: 2.0.0 clsx@2.1.0: {} - collapse-white-space@2.1.0: {} - - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 + code-block-writer@13.0.3: {} - color-name@1.1.3: {} + collapse-white-space@2.1.0: {} comma-separated-tokens@2.0.3: {} @@ -1800,17 +2354,19 @@ snapshots: compute-scroll-into-view@3.1.0: {} + content-type@2.1.0: {} + cose-base@1.0.3: dependencies: layout-base: 1.0.2 - cross-spawn@5.1.0: + cross-spawn@7.0.6: dependencies: - lru-cache: 4.1.5 - shebang-command: 1.2.0 - which: 1.3.1 + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 - csstype@3.1.1: {} + csstype@3.2.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.28.1): dependencies: @@ -1820,7 +2376,7 @@ snapshots: cytoscape@3.28.1: dependencies: heap: 0.2.7 - lodash: 4.17.21 + lodash: 4.18.0 d3-array@2.12.1: dependencies: @@ -1992,13 +2548,15 @@ snapshots: dagre-d3-es@7.0.10: dependencies: d3: 7.8.5 - lodash-es: 4.17.21 + lodash-es: 4.18.0 dayjs@1.11.10: {} - debug@4.3.4: + debug@4.4.3(supports-color@4.5.0): dependencies: - ms: 2.1.2 + ms: 2.1.3 + optionalDependencies: + supports-color: 4.5.0 decode-named-character-reference@1.0.2: dependencies: @@ -2010,26 +2568,25 @@ snapshots: dequal@2.0.3: {} + detect-libc@2.1.2: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 diff@5.1.0: {} - dompurify@3.0.9: {} + dompurify@3.1.3: {} elkjs@0.9.2: {} entities@4.5.0: {} - escape-string-regexp@1.0.5: {} - escape-string-regexp@5.0.0: {} esm@3.2.25: {} - esprima@4.0.1: {} - estree-util-attach-comments@3.0.0: dependencies: '@types/estree': 1.0.0 @@ -2041,8 +2598,6 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-walker: 3.0.1 - estree-util-is-identifier-name@2.0.1: {} - estree-util-is-identifier-name@3.0.0: {} estree-util-to-js@2.0.0: @@ -2051,14 +2606,9 @@ snapshots: astring: 1.8.3 source-map: 0.7.4 - estree-util-value-to-estree@1.3.0: - dependencies: - is-plain-obj: 3.0.0 - - estree-util-value-to-estree@3.1.1: + estree-util-value-to-estree@3.5.0: dependencies: '@types/estree': 1.0.0 - is-plain-obj: 4.1.0 estree-util-visit@2.0.0: dependencies: @@ -2067,46 +2617,56 @@ snapshots: estree-walker@3.0.1: {} - execa@0.8.0: + execa@8.0.1: dependencies: - cross-spawn: 5.1.0 - get-stream: 3.0.0 - is-stream: 1.1.0 - npm-run-path: 2.0.2 - p-finally: 1.0.0 - signal-exit: 3.0.7 - strip-eof: 1.0.0 + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + extend@3.0.2: {} - extend-shallow@2.0.1: + fast-glob@3.3.3: dependencies: - is-extendable: 0.1.1 + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 - extend@3.0.2: {} + fastq@1.20.1: + dependencies: + reusify: 1.1.0 fault@2.0.1: dependencies: format: 0.2.2 - flexsearch@0.7.43: {} + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 - focus-visible@5.2.0: {} + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 format@0.2.2: {} - get-stream@3.0.0: {} + get-stream@8.0.1: {} github-slugger@2.0.0: {} - graceful-fs@4.2.11: {} - - gray-matter@4.0.3: + glob-parent@5.1.2: dependencies: - js-yaml: 3.14.1 - kind-of: 6.0.3 - section-matter: 1.0.0 - strip-bom-string: 1.0.0 + is-glob: 4.0.3 - has-flag@2.0.0: {} + has-flag@2.0.0: + optional: true hast-util-from-dom@5.0.0: dependencies: @@ -2157,7 +2717,7 @@ snapshots: hast-util-from-parse5: 8.0.1 hast-util-to-parse5: 8.0.0 html-void-elements: 3.0.0 - mdast-util-to-hast: 13.1.0 + mdast-util-to-hast: 13.2.1 parse5: 7.1.2 unist-util-position: 5.0.0 unist-util-visit: 5.0.0 @@ -2165,7 +2725,7 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 - hast-util-to-estree@3.1.0: + hast-util-to-estree@3.1.0(supports-color@4.5.0): dependencies: '@types/estree': 1.0.0 '@types/estree-jsx': 1.0.0 @@ -2175,9 +2735,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.0 - mdast-util-mdx-jsx: 3.1.2 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.0(supports-color@4.5.0) + mdast-util-mdx-jsx: 3.1.2(supports-color@4.5.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@4.5.0) property-information: 6.2.0 space-separated-tokens: 2.0.2 style-to-object: 0.4.4 @@ -2186,7 +2746,21 @@ snapshots: transitivePeerDependencies: - supports-color - hast-util-to-jsx-runtime@2.3.0: + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.2 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.3 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.0(supports-color@4.5.0): dependencies: '@types/estree': 1.0.0 '@types/hast': 3.0.4 @@ -2195,9 +2769,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.0 - mdast-util-mdx-jsx: 3.1.2 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.0(supports-color@4.5.0) + mdast-util-mdx-jsx: 3.1.2(supports-color@4.5.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@4.5.0) property-information: 6.2.0 space-separated-tokens: 2.0.2 style-to-object: 1.0.6 @@ -2243,6 +2817,8 @@ snapshots: html-void-elements@3.0.0: {} + human-signals@5.0.0: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -2255,8 +2831,6 @@ snapshots: internmap@2.0.3: {} - intersection-observer@0.12.2: {} - is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -2264,15 +2838,23 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 - is-buffer@2.0.5: {} - is-decimal@2.0.1: {} - is-extendable@0.1.1: {} + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 is-hexadecimal@2.0.1: {} - is-plain-obj@3.0.0: {} + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -2280,44 +2862,34 @@ snapshots: dependencies: '@types/estree': 1.0.0 - is-stream@1.1.0: {} + is-stream@3.0.0: {} - isexe@2.0.0: {} - - js-tokens@4.0.0: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 - js-yaml@3.14.1: + is64bit@2.0.0: dependencies: - argparse: 1.0.10 - esprima: 4.0.1 + system-architecture: 0.1.0 + + isexe@2.0.0: {} - katex@0.16.9: + katex@0.16.47: dependencies: commander: 8.3.0 khroma@2.1.0: {} - kind-of@6.0.3: {} - kleur@4.1.5: {} layout-base@1.0.2: {} - lodash-es@4.17.21: {} + lodash-es@4.18.0: {} - lodash@4.17.21: {} + lodash@4.18.0: {} longest-streak@3.1.0: {} - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - lru-cache@4.1.5: - dependencies: - pseudomap: 1.0.2 - yallist: 2.1.2 - markdown-extensions@2.0.0: {} markdown-table@3.0.3: {} @@ -2336,13 +2908,13 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 - mdast-util-from-markdown@1.3.1: + mdast-util-from-markdown@1.3.1(supports-color@4.5.0): dependencies: '@types/mdast': 3.0.10 '@types/unist': 2.0.6 decode-named-character-reference: 1.0.2 mdast-util-to-string: 3.1.0 - micromark: 3.1.0 + micromark: 3.1.0(supports-color@4.5.0) micromark-util-decode-numeric-character-reference: 1.0.0 micromark-util-decode-string: 1.0.2 micromark-util-normalize-identifier: 1.0.0 @@ -2353,14 +2925,14 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-from-markdown@2.0.1: + mdast-util-from-markdown@2.0.1(supports-color@4.5.0): dependencies: '@types/mdast': 4.0.3 '@types/unist': 3.0.2 decode-named-character-reference: 1.0.2 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.0 + micromark: 4.0.0(supports-color@4.5.0) micromark-util-decode-numeric-character-reference: 2.0.1 micromark-util-decode-string: 2.0.0 micromark-util-normalize-identifier: 2.0.0 @@ -2370,12 +2942,12 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-frontmatter@2.0.1: + mdast-util-frontmatter@2.0.1(supports-color@4.5.0): dependencies: '@types/mdast': 4.0.3 devlop: 1.1.0 escape-string-regexp: 5.0.0 - mdast-util-from-markdown: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) mdast-util-to-markdown: 2.1.0 micromark-extension-frontmatter: 2.0.0 transitivePeerDependencies: @@ -2389,79 +2961,79 @@ snapshots: mdast-util-find-and-replace: 3.0.1 micromark-util-character: 2.1.0 - mdast-util-gfm-footnote@2.0.0: + mdast-util-gfm-footnote@2.0.0(supports-color@4.5.0): dependencies: '@types/mdast': 4.0.3 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) mdast-util-to-markdown: 2.1.0 micromark-util-normalize-identifier: 2.0.0 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0: + mdast-util-gfm-strikethrough@2.0.0(supports-color@4.5.0): dependencies: '@types/mdast': 4.0.3 - mdast-util-from-markdown: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) mdast-util-to-markdown: 2.1.0 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0: + mdast-util-gfm-table@2.0.0(supports-color@4.5.0): dependencies: '@types/mdast': 4.0.3 devlop: 1.1.0 markdown-table: 3.0.3 - mdast-util-from-markdown: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) mdast-util-to-markdown: 2.1.0 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0: + mdast-util-gfm-task-list-item@2.0.0(supports-color@4.5.0): dependencies: '@types/mdast': 4.0.3 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) mdast-util-to-markdown: 2.1.0 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.0.0: + mdast-util-gfm@3.0.0(supports-color@4.5.0): dependencies: - mdast-util-from-markdown: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) mdast-util-gfm-autolink-literal: 2.0.0 - mdast-util-gfm-footnote: 2.0.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-gfm-footnote: 2.0.0(supports-color@4.5.0) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@4.5.0) + mdast-util-gfm-table: 2.0.0(supports-color@4.5.0) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@4.5.0) mdast-util-to-markdown: 2.1.0 transitivePeerDependencies: - supports-color - mdast-util-math@3.0.0: + mdast-util-math@3.0.0(supports-color@4.5.0): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.3 devlop: 1.1.0 longest-streak: 3.1.0 - mdast-util-from-markdown: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) mdast-util-to-markdown: 2.1.0 unist-util-remove-position: 5.0.0 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.0: + mdast-util-mdx-expression@2.0.0(supports-color@4.5.0): dependencies: '@types/estree-jsx': 1.0.0 '@types/hast': 3.0.4 '@types/mdast': 4.0.3 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) mdast-util-to-markdown: 2.1.0 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.1.2: + mdast-util-mdx-jsx@3.1.2(supports-color@4.5.0): dependencies: '@types/estree-jsx': 1.0.0 '@types/hast': 3.0.4 @@ -2469,7 +3041,7 @@ snapshots: '@types/unist': 3.0.2 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) mdast-util-to-markdown: 2.1.0 parse-entities: 4.0.0 stringify-entities: 4.0.3 @@ -2479,23 +3051,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0: + mdast-util-mdx@3.0.0(supports-color@4.5.0): dependencies: - mdast-util-from-markdown: 2.0.1 - mdast-util-mdx-expression: 2.0.0 - mdast-util-mdx-jsx: 3.1.2 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) + mdast-util-mdx-expression: 2.0.0(supports-color@4.5.0) + mdast-util-mdx-jsx: 3.1.2(supports-color@4.5.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@4.5.0) mdast-util-to-markdown: 2.1.0 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1: + mdast-util-mdxjs-esm@2.0.1(supports-color@4.5.0): dependencies: '@types/estree-jsx': 1.0.0 '@types/hast': 3.0.4 '@types/mdast': 4.0.3 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) mdast-util-to-markdown: 2.1.0 transitivePeerDependencies: - supports-color @@ -2505,7 +3077,7 @@ snapshots: '@types/mdast': 4.0.3 unist-util-is: 6.0.0 - mdast-util-to-hast@13.1.0: + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.3 @@ -2534,7 +3106,11 @@ snapshots: dependencies: '@types/mdast': 4.0.3 - mermaid@10.8.0: + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + mermaid@10.9.3(supports-color@4.5.0): dependencies: '@braintree/sanitize-url': 6.0.4 '@types/d3-scale': 4.0.8 @@ -2545,11 +3121,12 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.10 dayjs: 1.11.10 - dompurify: 3.0.9 + dompurify: 3.1.3 elkjs: 0.9.2 + katex: 0.16.47 khroma: 2.1.0 - lodash-es: 4.17.21 - mdast-util-from-markdown: 1.3.1 + lodash-es: 4.18.0 + mdast-util-from-markdown: 1.3.1(supports-color@4.5.0) non-layered-tidy-tree-layout: 2.0.2 stylis: 4.3.1 ts-dedent: 2.2.0 @@ -2667,7 +3244,7 @@ snapshots: dependencies: '@types/katex': 0.16.7 devlop: 1.1.0 - katex: 0.16.9 + katex: 0.16.47 micromark-factory-space: 2.0.0 micromark-util-character: 2.1.0 micromark-util-symbol: 2.0.0 @@ -2931,10 +3508,10 @@ snapshots: micromark-util-types@2.0.0: {} - micromark@3.1.0: + micromark@3.1.0(supports-color@4.5.0): dependencies: '@types/debug': 4.1.7 - debug: 4.3.4 + debug: 4.4.3(supports-color@4.5.0) decode-named-character-reference: 1.0.2 micromark-core-commonmark: 1.0.6 micromark-factory-space: 1.0.0 @@ -2953,10 +3530,10 @@ snapshots: transitivePeerDependencies: - supports-color - micromark@4.0.0: + micromark@4.0.0(supports-color@4.5.0): dependencies: '@types/debug': 4.1.7 - debug: 4.3.4 + debug: 4.4.3(supports-color@4.5.0) decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.1 @@ -2975,121 +3552,146 @@ snapshots: transitivePeerDependencies: - supports-color + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mimic-fn@4.0.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + mj-context-menu@0.6.1: {} mri@1.2.0: {} - ms@2.1.2: {} + ms@2.1.3: {} - nanoid@3.3.7: {} + nanoid@3.3.18: {} - next-themes@0.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + negotiator@1.1.0: dependencies: - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + content-type: 2.1.0 - next@14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@next/env': 14.1.1 - '@swc/helpers': 0.5.2 - busboy: 1.6.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + next@15.5.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@next/env': 15.5.21 + '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001591 - graceful-fs: 4.2.11 - postcss: 8.4.31 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.1.1(react@18.2.0) + postcss: 8.5.18 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styled-jsx: 5.1.6(react@19.1.0) optionalDependencies: - '@next/swc-darwin-arm64': 14.1.1 - '@next/swc-darwin-x64': 14.1.1 - '@next/swc-linux-arm64-gnu': 14.1.1 - '@next/swc-linux-arm64-musl': 14.1.1 - '@next/swc-linux-x64-gnu': 14.1.1 - '@next/swc-linux-x64-musl': 14.1.1 - '@next/swc-win32-arm64-msvc': 14.1.1 - '@next/swc-win32-ia32-msvc': 14.1.1 - '@next/swc-win32-x64-msvc': 14.1.1 + '@next/swc-darwin-arm64': 15.5.21 + '@next/swc-darwin-x64': 15.5.21 + '@next/swc-linux-arm64-gnu': 15.5.21 + '@next/swc-linux-arm64-musl': 15.5.21 + '@next/swc-linux-x64-gnu': 15.5.21 + '@next/swc-linux-x64-musl': 15.5.21 + '@next/swc-win32-arm64-msvc': 15.5.21 + '@next/swc-win32-x64-msvc': 15.5.21 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - nextra-theme-docs@3.0.0-alpha.24(next@14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(nextra@3.0.0-alpha.24(@types/react@18.0.26)(next@14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.3.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + nextra-theme-docs@4.6.1(patch_hash=5e1f3c09c82663f461daba8ce038f5bd36b3f2d3e08e5edbe1a1fc301578fa80)(@types/react@19.2.18)(next@15.5.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nextra@4.6.1(next@15.5.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(supports-color@4.5.0)(typescript@5.3.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)): dependencies: - '@headlessui/react': 1.7.18(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@popperjs/core': 2.11.8 + '@headlessui/react': 2.2.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) clsx: 2.1.0 - escape-string-regexp: 5.0.0 - flexsearch: 0.7.43 - focus-visible: 5.2.0 - intersection-observer: 0.12.2 - next: 14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - next-themes: 0.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - nextra: 3.0.0-alpha.24(@types/react@18.0.26)(next@14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.3.3) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + next: 15.5.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next-themes: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + nextra: 4.6.1(next@15.5.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(supports-color@4.5.0)(typescript@5.3.3) + react: 19.1.0 + react-compiler-runtime: 19.1.0-rc.3(react@19.1.0) + react-dom: 19.1.0(react@19.1.0) scroll-into-view-if-needed: 3.1.0 - zod: 3.22.4 + zod: 4.4.3 + zustand: 5.0.15(@types/react@19.2.18)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) + transitivePeerDependencies: + - '@types/react' + - immer + - use-sync-external-store - nextra@3.0.0-alpha.24(@types/react@18.0.26)(next@14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.3.3): + nextra@4.6.1(next@15.5.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(supports-color@4.5.0)(typescript@5.3.3): dependencies: - '@headlessui/react': 1.7.18(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@mdx-js/mdx': 3.0.1 - '@mdx-js/react': 3.0.1(@types/react@18.0.26)(react@18.2.0) + '@formatjs/intl-localematcher': 0.6.2 + '@headlessui/react': 2.2.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@mdx-js/mdx': 3.0.1(supports-color@4.5.0) '@napi-rs/simple-git': 0.1.16 - '@shikijs/twoslash': 1.6.1(typescript@5.3.3) - '@theguild/remark-mermaid': 0.0.5(react@18.2.0) - '@theguild/remark-npm2yarn': 0.3.0 - better-react-mathjax: 2.0.3(react@18.2.0) + '@shikijs/twoslash': 3.23.0(supports-color@4.5.0)(typescript@5.3.3) + '@theguild/remark-mermaid': 0.3.0(react@19.1.0)(supports-color@4.5.0) + '@theguild/remark-npm2yarn': 0.3.3 + better-react-mathjax: 2.3.0(react@19.1.0) clsx: 2.1.0 estree-util-to-js: 2.0.0 - estree-util-value-to-estree: 3.1.1 + estree-util-value-to-estree: 3.5.0 + fast-glob: 3.3.3 github-slugger: 2.0.0 - graceful-fs: 4.2.11 - gray-matter: 4.0.3 - hast-util-to-estree: 3.1.0 - katex: 0.16.9 - next: 14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - p-limit: 4.0.0 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + hast-util-to-estree: 3.1.0(supports-color@4.5.0) + katex: 0.16.47 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) + mdast-util-gfm: 3.0.0(supports-color@4.5.0) + mdast-util-to-hast: 13.2.1 + negotiator: 1.1.0 + next: 15.5.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-compiler-runtime: 19.1.0-rc.3(react@19.1.0) + react-dom: 19.1.0(react@19.1.0) + react-medium-image-zoom: 5.4.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0) rehype-katex: 7.0.0 - rehype-pretty-code: 0.13.0(shiki@1.6.1) + rehype-pretty-code: 0.14.1(shiki@3.23.0) rehype-raw: 7.0.0 - remark-frontmatter: 5.0.0 - remark-gfm: 4.0.0 - remark-math: 6.0.0 - remark-reading-time: 2.0.1 - remark-smartypants: 2.1.0 - shiki: 1.6.1 + remark-frontmatter: 5.0.0(supports-color@4.5.0) + remark-gfm: 4.0.0(supports-color@4.5.0) + remark-math: 6.0.0(supports-color@4.5.0) + remark-reading-time: 2.1.0 + remark-smartypants: 3.0.3 + server-only: 0.0.1 + shiki: 3.23.0 slash: 5.1.0 - title: 3.5.3 + title: 4.0.1 + ts-morph: 27.0.2 unist-util-remove: 4.0.0 unist-util-visit: 5.0.0 + unist-util-visit-children: 3.0.0 yaml: 2.4.2 - zod: 3.22.4 - zod-validation-error: 1.5.0(zod@3.22.4) + zod: 4.4.3 transitivePeerDependencies: - - '@types/react' - supports-color - typescript - nlcst-to-string@3.1.1: + nlcst-to-string@4.0.0: dependencies: - '@types/nlcst': 1.0.4 + '@types/nlcst': 2.0.3 non-layered-tidy-tree-layout@2.0.2: {} - npm-run-path@2.0.2: + npm-run-path@5.3.0: dependencies: - path-key: 2.0.1 + path-key: 4.0.0 - npm-to-yarn@2.2.1: {} + npm-to-yarn@3.2.0: {} + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 - p-finally@1.0.0: {} + oniguruma-parser@0.12.2: {} - p-limit@4.0.0: + oniguruma-to-es@4.3.6: dependencies: - yocto-queue: 1.0.0 + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 parse-entities@4.0.0: dependencies: @@ -3102,11 +3704,14 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 - parse-latin@5.0.1: + parse-latin@7.0.0: dependencies: - nlcst-to-string: 3.1.1 - unist-util-modify-children: 3.1.1 - unist-util-visit-children: 2.0.2 + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.2 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.1 parse-numeric-range@1.3.0: {} @@ -3114,44 +3719,94 @@ snapshots: dependencies: entities: 4.5.0 - path-key@2.0.1: {} + path-browserify@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} periscopic@3.0.4: dependencies: estree-walker: 3.0.1 is-reference: 3.0.0 - picocolors@1.0.0: {} + picocolors@1.1.1: {} - postcss@8.4.31: + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + postcss@8.5.18: dependencies: - nanoid: 3.3.7 - picocolors: 1.0.0 - source-map-js: 1.0.2 + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 property-information@6.2.0: {} - pseudomap@1.0.2: {} + property-information@7.2.0: {} + + queue-microtask@1.2.3: {} + + react-aria@3.51.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@internationalized/date': 3.12.3 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@19.1.0) + '@swc/helpers': 0.5.5 + aria-hidden: 1.2.6 + clsx: 2.1.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-stately: 3.49.0(react@19.1.0) + use-sync-external-store: 1.6.0(react@19.1.0) + + react-compiler-runtime@19.1.0-rc.3(react@19.1.0): + dependencies: + react: 19.1.0 - react-dom@18.2.0(react@18.2.0): + react-dom@19.1.0(react@19.1.0): dependencies: - loose-envify: 1.4.0 - react: 18.2.0 - scheduler: 0.23.0 + react: 19.1.0 + scheduler: 0.26.0 - react@18.2.0: + react-medium-image-zoom@5.4.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - loose-envify: 1.4.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + react-stately@3.49.0(react@19.1.0): + dependencies: + '@internationalized/date': 3.12.3 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@19.1.0) + '@swc/helpers': 0.5.5 + react: 19.1.0 + use-sync-external-store: 1.6.0(react@19.1.0) + + react@19.1.0: {} reading-time@1.5.0: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + rehype-katex@7.0.0: dependencies: '@types/hast': 3.0.4 '@types/katex': 0.16.7 hast-util-from-html-isomorphic: 2.0.0 hast-util-to-text: 4.0.0 - katex: 0.16.9 + katex: 0.16.47 unist-util-visit-parents: 6.0.1 vfile: 6.0.1 @@ -3159,16 +3814,16 @@ snapshots: dependencies: '@types/hast': 3.0.4 hast-util-from-html: 2.0.1 - unified: 11.0.4 + unified: 11.0.5 - rehype-pretty-code@0.13.0(shiki@1.6.1): + rehype-pretty-code@0.14.1(shiki@3.23.0): dependencies: '@types/hast': 3.0.4 hast-util-to-string: 3.0.0 parse-numeric-range: 1.3.0 rehype-parse: 9.0.0 - shiki: 1.6.1 - unified: 11.0.4 + shiki: 3.23.0 + unified: 11.0.5 unist-util-visit: 5.0.0 rehype-raw@7.0.0: @@ -3177,107 +3832,112 @@ snapshots: hast-util-raw: 9.0.2 vfile: 6.0.1 - remark-frontmatter@5.0.0: + remark-frontmatter@5.0.0(supports-color@4.5.0): dependencies: '@types/mdast': 4.0.3 - mdast-util-frontmatter: 2.0.1 + mdast-util-frontmatter: 2.0.1(supports-color@4.5.0) micromark-extension-frontmatter: 2.0.0 - unified: 11.0.4 + unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-gfm@4.0.0: + remark-gfm@4.0.0(supports-color@4.5.0): dependencies: '@types/mdast': 4.0.3 - mdast-util-gfm: 3.0.0 + mdast-util-gfm: 3.0.0(supports-color@4.5.0) micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@4.5.0) remark-stringify: 11.0.0 - unified: 11.0.4 + unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-math@6.0.0: + remark-math@6.0.0(supports-color@4.5.0): dependencies: '@types/mdast': 4.0.3 - mdast-util-math: 3.0.0 + mdast-util-math: 3.0.0(supports-color@4.5.0) micromark-extension-math: 3.0.0 - unified: 11.0.4 + unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.0.1: + remark-mdx@3.0.1(supports-color@4.5.0): dependencies: - mdast-util-mdx: 3.0.0 + mdast-util-mdx: 3.0.0(supports-color@4.5.0) micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0: + remark-parse@11.0.0(supports-color@4.5.0): dependencies: '@types/mdast': 4.0.3 - mdast-util-from-markdown: 2.0.1 + mdast-util-from-markdown: 2.0.1(supports-color@4.5.0) micromark-util-types: 2.0.0 - unified: 11.0.4 + unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-reading-time@2.0.1: + remark-reading-time@2.1.0: dependencies: - estree-util-is-identifier-name: 2.0.1 - estree-util-value-to-estree: 1.3.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-value-to-estree: 3.5.0 reading-time: 1.5.0 - unist-util-visit: 3.1.0 + unist-util-visit: 5.0.0 remark-rehype@11.1.0: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.3 - mdast-util-to-hast: 13.1.0 - unified: 11.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 vfile: 6.0.1 - remark-smartypants@2.1.0: + remark-smartypants@3.0.3: dependencies: - retext: 8.1.0 - retext-smartypants: 5.2.0 + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 unist-util-visit: 5.0.0 remark-stringify@11.0.0: dependencies: '@types/mdast': 4.0.3 mdast-util-to-markdown: 2.1.0 - unified: 11.0.4 + unified: 11.0.5 - retext-latin@3.1.0: + retext-latin@4.0.0: dependencies: - '@types/nlcst': 1.0.4 - parse-latin: 5.0.1 - unherit: 3.0.1 - unified: 10.1.2 + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 - retext-smartypants@5.2.0: + retext-smartypants@6.2.0: dependencies: - '@types/nlcst': 1.0.4 - nlcst-to-string: 3.1.1 - unified: 10.1.2 - unist-util-visit: 4.1.1 + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.0.0 - retext-stringify@3.1.0: + retext-stringify@4.0.0: dependencies: - '@types/nlcst': 1.0.4 - nlcst-to-string: 3.1.1 - unified: 10.1.2 + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 - retext@8.1.0: + retext@9.0.0: dependencies: - '@types/nlcst': 1.0.4 - retext-latin: 3.1.0 - retext-stringify: 3.1.0 - unified: 10.1.2 + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + + reusify@1.1.0: {} robust-predicates@3.0.2: {} + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + rw@1.3.3: {} sade@1.8.1: @@ -3286,34 +3946,71 @@ snapshots: safer-buffer@2.1.2: {} - scheduler@0.23.0: - dependencies: - loose-envify: 1.4.0 + scheduler@0.26.0: {} scroll-into-view-if-needed@3.1.0: dependencies: compute-scroll-into-view: 3.1.0 - section-matter@1.0.0: + semver@7.8.5: + optional: true + + server-only@0.0.1: {} + + sharp@0.34.5: dependencies: - extend-shallow: 2.0.1 - kind-of: 6.0.3 + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true - shebang-command@1.2.0: + shebang-command@2.0.0: dependencies: - shebang-regex: 1.0.0 + shebang-regex: 3.0.0 - shebang-regex@1.0.0: {} + shebang-regex@3.0.0: {} - shiki@1.6.1: + shiki@3.23.0: dependencies: - '@shikijs/core': 1.6.1 + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 - signal-exit@3.0.7: {} + signal-exit@4.1.0: {} slash@5.1.0: {} - source-map-js@1.0.2: {} + source-map-js@1.2.1: {} source-map@0.7.4: {} @@ -3325,18 +4022,12 @@ snapshots: wicked-good-xpath: 1.3.0 xmldom-sre: 0.1.31 - sprintf-js@1.0.3: {} - - streamsearch@1.1.0: {} - stringify-entities@4.0.3: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 - strip-bom-string@1.0.0: {} - - strip-eof@1.0.0: {} + strip-final-newline@3.0.0: {} style-to-object@0.4.4: dependencies: @@ -3346,25 +4037,36 @@ snapshots: dependencies: inline-style-parser: 0.2.3 - styled-jsx@5.1.1(react@18.2.0): + styled-jsx@5.1.6(react@19.1.0): dependencies: client-only: 0.0.1 - react: 18.2.0 + react: 19.1.0 stylis@4.3.1: {} supports-color@4.5.0: dependencies: has-flag: 2.0.0 + optional: true + + system-architecture@0.1.0: {} + + tabbable@6.5.0: {} - title@3.5.3: + tinyglobby@0.2.17: dependencies: - arg: 1.0.0 - chalk: 2.3.0 - clipboardy: 1.2.2 - titleize: 1.0.0 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 - titleize@1.0.0: {} + title@4.0.1: + dependencies: + arg: 5.0.2 + chalk: 5.6.2 + clipboardy: 4.0.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 trim-lines@3.0.1: {} @@ -3372,14 +4074,19 @@ snapshots: ts-dedent@2.2.0: {} - tslib@2.4.1: {} + ts-morph@27.0.2: + dependencies: + '@ts-morph/common': 0.28.1 + code-block-writer: 13.0.3 + + tslib@2.8.1: {} - twoslash-protocol@0.2.6: {} + twoslash-protocol@0.3.9: {} - twoslash@0.2.6(typescript@5.3.3): + twoslash@0.3.9(supports-color@4.5.0)(typescript@5.3.3): dependencies: - '@typescript/vfs': 1.5.0 - twoslash-protocol: 0.2.6 + '@typescript/vfs': 1.6.4(supports-color@4.5.0)(typescript@5.3.3) + twoslash-protocol: 0.3.9 typescript: 5.3.3 transitivePeerDependencies: - supports-color @@ -3388,19 +4095,7 @@ snapshots: undici-types@5.26.5: {} - unherit@3.0.1: {} - - unified@10.1.2: - dependencies: - '@types/unist': 2.0.6 - bail: 2.0.2 - extend: 3.0.2 - is-buffer: 2.0.5 - is-plain-obj: 4.1.0 - trough: 2.1.0 - vfile: 5.3.6 - - unified@11.0.4: + unified@11.0.5: dependencies: '@types/unist': 3.0.2 bail: 2.0.2 @@ -3415,15 +4110,13 @@ snapshots: '@types/unist': 3.0.2 unist-util-is: 6.0.0 - unist-util-is@5.1.1: {} - unist-util-is@6.0.0: dependencies: '@types/unist': 3.0.2 - unist-util-modify-children@3.1.1: + unist-util-modify-children@4.0.0: dependencies: - '@types/unist': 2.0.6 + '@types/unist': 3.0.2 array-iterate: 2.0.1 unist-util-position-from-estree@2.0.0: @@ -3453,43 +4146,25 @@ snapshots: dependencies: '@types/unist': 3.0.2 - unist-util-visit-children@2.0.2: - dependencies: - '@types/unist': 2.0.6 - - unist-util-visit-parents@4.1.1: - dependencies: - '@types/unist': 2.0.6 - unist-util-is: 5.1.1 - - unist-util-visit-parents@5.1.1: + unist-util-visit-children@3.0.0: dependencies: - '@types/unist': 2.0.6 - unist-util-is: 5.1.1 + '@types/unist': 3.0.2 unist-util-visit-parents@6.0.1: dependencies: '@types/unist': 3.0.2 unist-util-is: 6.0.0 - unist-util-visit@3.1.0: - dependencies: - '@types/unist': 2.0.6 - unist-util-is: 5.1.1 - unist-util-visit-parents: 4.1.1 - - unist-util-visit@4.1.1: - dependencies: - '@types/unist': 2.0.6 - unist-util-is: 5.1.1 - unist-util-visit-parents: 5.1.1 - unist-util-visit@5.0.0: dependencies: '@types/unist': 3.0.2 unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + use-sync-external-store@1.6.0(react@19.1.0): + dependencies: + react: 19.1.0 + uuid@9.0.1: {} uvu@0.5.6: @@ -3504,23 +4179,11 @@ snapshots: '@types/unist': 3.0.2 vfile: 6.0.1 - vfile-message@3.1.3: - dependencies: - '@types/unist': 2.0.6 - unist-util-stringify-position: 3.0.2 - vfile-message@4.0.2: dependencies: '@types/unist': 3.0.2 unist-util-stringify-position: 4.0.0 - vfile@5.3.6: - dependencies: - '@types/unist': 2.0.6 - is-buffer: 2.0.5 - unist-util-stringify-position: 3.0.2 - vfile-message: 3.1.3 - vfile@6.0.1: dependencies: '@types/unist': 3.0.2 @@ -3531,7 +4194,7 @@ snapshots: web-worker@1.3.0: {} - which@1.3.1: + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3539,16 +4202,14 @@ snapshots: xmldom-sre@0.1.31: {} - yallist@2.1.2: {} - yaml@2.4.2: {} - yocto-queue@1.0.0: {} - - zod-validation-error@1.5.0(zod@3.22.4): - dependencies: - zod: 3.22.4 + zod@4.4.3: {} - zod@3.22.4: {} + zustand@5.0.15(@types/react@19.2.18)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)): + optionalDependencies: + '@types/react': 19.2.18 + react: 19.1.0 + use-sync-external-store: 1.6.0(react@19.1.0) zwitch@2.0.4: {} diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml new file mode 100644 index 000000000..0977eedf8 --- /dev/null +++ b/docs/pnpm-workspace.yaml @@ -0,0 +1,15 @@ +allowBuilds: + sharp: false +overrides: + cross-spawn: '7.0.6' + dompurify: '3.1.3' + js-yaml: '3.15.1' + lodash: '4.18.0' + lodash-es: '4.18.0' + mermaid: '10.9.3' + nanoid: '3.3.18' + postcss: '8.5.18' +packages: + - . +patchedDependencies: + nextra-theme-docs@4.6.1: patches/nextra-theme-docs@4.6.1.patch diff --git a/docs/theme.config.tsx b/docs/theme.config.tsx deleted file mode 100644 index 97d17d9c1..000000000 --- a/docs/theme.config.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { useRouter } from "next/router"; -import { type DocsThemeConfig, useConfig } from "nextra-theme-docs"; -import "nextra-theme-docs/style.css"; -import faviconFormats from "./favicon-formats"; -import styles from "./global.module.css"; - -const NO_INDEX_FOLLOW = process.env.CF_PAGES_BRANCH !== "main"; - -const config: DocsThemeConfig = { - logo: ( - <span className={styles.logo}> - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2759.9 544.87" width={200}> - <title>hyperglass - - - - - - - - - - - - - - - - - - - - - - ), - head: () => { - const { asPath, locale, defaultLocale } = useRouter(); - const { frontMatter } = useConfig(); - const url = `https://hyperglass.dev${ - defaultLocale === locale ? asPath : `/${locale}${asPath}` - }`; - let title = frontMatter.title || "hyperglass"; - if (title !== "hyperglass") { - title = `${title} | hyperglass`; - } - const description = frontMatter.description || "hyperglass Documentation"; - const index = NO_INDEX_FOLLOW ? "noindex, nofollow" : "index, follow"; - const favicons = faviconFormats.map((fmt) => { - const { image_format, dimensions, prefix, rel } = fmt; - const [w, h] = dimensions; - const href = `/img/${prefix}-${w}x${h}.${image_format}`; - return { rel: rel ?? "", href, type: `image/${image_format}` }; - }); - - return ( - - {title} - - - - - - - - - - - - - - {favicons.map((props) => ( - - ))} - - ); - }, - docsRepositoryBase: "https://github.com/thatmattlove/hyperglass/tree/main/docs", - banner: { - dismissible: true, - content: "🎉 hyperglass 2.0 is here! This documentation is still in development, though.", - }, - feedback: { content: null }, - footer: { content: `© ${new Date().getFullYear()} hyperglass` }, - editLink: { component: null }, - chat: { - link: "https://netdev.chat/", - icon: ( - - NetDev Chat - - - ), - }, - project: { - link: "https://github.com/thatmattlove/hyperglass", - }, -}; - -export default config; diff --git a/docs/tsconfig.json b/docs/tsconfig.json index 4fe9021ba..e8001127c 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -1,26 +1,45 @@ { - "compilerOptions": { - "target": "ESNext", - "module": "esnext", - "downlevelIteration": true, - "strict": true, - "baseUrl": ".", - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "noEmit": true, - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "paths": { - "~/*": ["./*"] - } + "compilerOptions": { + "target": "ESNext", + "module": "esnext", + "downlevelIteration": true, + "strict": true, + "baseUrl": ".", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "noEmit": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "paths": { + "~/*": [ + "./*" + ] }, - - "exclude": ["node_modules", ".next"], - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.mjs"] + "plugins": [ + { + "name": "next" + } + ] + }, + "exclude": [ + "node_modules", + ".next" + ], + "include": [ + "**/*.ts", + "**/*.tsx", + "next-env.d.ts", + "next.config.mjs", + ".next/types/**/*.ts" + ] } diff --git a/hyperglass/constants.py b/hyperglass/constants.py index c624fdff6..965a16693 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -4,7 +4,7 @@ from datetime import datetime __name__ = "hyperglass" -__version__ = "3.0.0" +__version__ = "3.1.0" __author__ = "Matt Love" __copyright__ = f"Copyright {datetime.now().year} Matthew Love" __license__ = "BSD 3-Clause Clear License" diff --git a/hyperglass/ui/components/output/cell.tsx b/hyperglass/ui/components/output/cell.tsx index 79073c85d..8e5c30972 100644 --- a/hyperglass/ui/components/output/cell.tsx +++ b/hyperglass/ui/components/output/cell.tsx @@ -1,4 +1,14 @@ -import { MonoField, Active, Weight, Age, Communities, RPKIState, ASPath, HideableField, NextHop } from './fields'; +import { + MonoField, + Active, + Weight, + Age, + Communities, + RPKIState, + ASPath, + HideableField, + NextHop, +} from './fields'; import type { CellRenderProps } from '~/types'; @@ -7,10 +17,10 @@ interface CellProps { rawData: StructuredResponse; } -export const Cell = (props: CellProps): JSX.Element => { +export const Cell = (props: CellProps): JSX.Element | null => { const { data, rawData } = props; const cellId = data.column.id as keyof Route; - const component = { + const component: Partial> = { med: , age: , prefix: , @@ -32,11 +42,11 @@ export const Cell = (props: CellProps): JSX.Element => { ), rpki_state: , weight: , }; - return component[cellId] ?? ''; + return component[cellId] ?? null; }; diff --git a/hyperglass/ui/components/output/fields.tsx b/hyperglass/ui/components/output/fields.tsx index 23c686667..787289339 100644 --- a/hyperglass/ui/components/output/fields.tsx +++ b/hyperglass/ui/components/output/fields.tsx @@ -59,7 +59,7 @@ dayjs.extend(utcPlugin); export const MonoField = (props: MonoFieldProps): JSX.Element => { const { v, ...rest } = props; - + // Handle empty or undefined values, but not zero values if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) { return ( @@ -68,7 +68,7 @@ export const MonoField = (props: MonoFieldProps): JSX.Element => { ); } - + return ( {v} @@ -93,12 +93,12 @@ export const Active = (props: ActiveProps): JSX.Element => { export const Age = (props: AgeProps): JSX.Element => { const { inSeconds, ...rest } = props; - + // Handle case where age is not available (e.g., MikroTik) - hide the field entirely if (inSeconds === -1) { return <>; } - + const now = dayjs.utc(); const then = now.subtract(inSeconds, 'second'); return ( @@ -181,11 +181,11 @@ export const ASPath = (props: ASPathProps): JSX.Element => { export const NextHop = (props: NextHopProps): JSX.Element => { const { ip, asn, org, country, ...rest } = props; - + // Build tooltip label with ASN - ORG format (strip AS prefix from asn to match AS path format) const hasEnrichment = asn || org; let tooltipLabel = ''; - + if (hasEnrichment) { const parts = []; if (asn) { @@ -196,7 +196,7 @@ export const NextHop = (props: NextHopProps): JSX.Element => { if (org) parts.push(org); tooltipLabel = parts.join(' - '); } - + if (!hasEnrichment) { // No enrichment data, just show the IP return ( @@ -205,7 +205,7 @@ export const NextHop = (props: NextHopProps): JSX.Element => { ); } - + // Show tooltip with ASN - ORG format return ( @@ -221,7 +221,7 @@ export const Communities = (props: CommunitiesProps): JSX.Element => { const { web } = useConfig(); const bg = useColorValue('white', 'gray.900'); const color = useOpposingColor(bg); - + // Parse communities to separate code and name if present const parsedCommunities = communities.map(community => { if (community.includes(',')) { @@ -230,7 +230,7 @@ export const Communities = (props: CommunitiesProps): JSX.Element => { } return { code: community, name: null, display: community }; }); - + return ( @@ -257,8 +257,10 @@ export const Communities = (props: CommunitiesProps): JSX.Element => { fontWeight="normal" whiteSpace="pre-wrap" > - {parsedCommunities.map(({ display }, index) => ( - {display} + {parsedCommunities.map(({ code, display }) => ( + + {display} + ))} @@ -318,12 +320,12 @@ export const RPKIState = forwardRef(_RPKIState); export const HideableField = (props: MonoFieldProps): JSX.Element => { const { v, ...rest } = props; - + // Hide the field entirely if value is empty or undefined if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) { return <>; } - + return ( {v} diff --git a/hyperglass/ui/components/output/traceroute-cell.tsx b/hyperglass/ui/components/output/traceroute-cell.tsx index 7c1356027..73d0e5a95 100644 --- a/hyperglass/ui/components/output/traceroute-cell.tsx +++ b/hyperglass/ui/components/output/traceroute-cell.tsx @@ -15,16 +15,16 @@ export const TracerouteCell = (props: TracerouteCellProps): JSX.Element => { const getIPValue = () => { if (cellId === 'ip_address') { const hop = data.row?.original as TracerouteHop | undefined; - if (hop && hop.display_ip) { + if (hop?.display_ip) { return hop.display_ip; } - if (hop && hop.ip_address) { + if (hop?.ip_address) { return hop.ip_address; } } return data.value; }; - + const component = { hop_number: , ip_address: , @@ -46,6 +46,6 @@ export const TracerouteCell = (props: TracerouteCellProps): JSX.Element => { rtt2: null, // Not displayed directly in table rtt3: null, // Not displayed directly in table }; - + return component[cellId] ?? ; -}; \ No newline at end of file +}; diff --git a/hyperglass/ui/components/output/traceroute-fields.tsx b/hyperglass/ui/components/output/traceroute-fields.tsx index 491e971d3..396582563 100644 --- a/hyperglass/ui/components/output/traceroute-fields.tsx +++ b/hyperglass/ui/components/output/traceroute-fields.tsx @@ -27,16 +27,20 @@ interface LossFieldProps extends TextProps { export const MonoField = (props: MonoFieldProps): JSX.Element => { const { v, ...rest } = props; - + // Handle empty, null, undefined values and timeout indicators - if (v === null || v === undefined || (typeof v === 'string' && (v.trim() === '' || v === 'None'))) { + if ( + v === null || + v === undefined || + (typeof v === 'string' && (v.trim() === '' || v === 'None')) + ) { return ( — ); } - + return ( {v} @@ -46,7 +50,7 @@ export const MonoField = (props: MonoFieldProps): JSX.Element => { export const ASNField = (props: ASNFieldProps): JSX.Element => { const { asn, org, ...rest } = props; - + if (!asn || asn === 'None' || asn === 'null') { return ( @@ -54,7 +58,7 @@ export const ASNField = (props: ASNFieldProps): JSX.Element => { ); } - + // Display ASN. If this hop is an IXP (asn === 'IXP') and we have the // IXP name in `org`, show the IXP name instead of the literal "IXP" so // the visualiser renders a friendly label. Keep the tooltip labeled as @@ -64,10 +68,13 @@ export const ASNField = (props: ASNFieldProps): JSX.Element => { if (asn === 'IXP') { asnDisplay = 'IXP'; } - const tooltipLabel = org && org !== 'None' - ? (asn === 'IXP' ? `IXP - ${org}` : `${asnDisplay} - ${org}`) - : asnDisplay; - + const tooltipLabel = + org && org !== 'None' + ? asn === 'IXP' + ? `IXP - ${org}` + : `${asnDisplay} - ${org}` + : asnDisplay; + const isNumericAsn = /^\d+$/.test(asnDisplay); const content = isNumericAsn ? ( { fontSize="sm" fontFamily="mono" cursor="pointer" - {...rest} > {asnDisplay} @@ -95,7 +101,7 @@ export const ASNField = (props: ASNFieldProps): JSX.Element => { export const HostnameField = (props: HostnameFieldProps): JSX.Element => { const { hostname, ...rest } = props; - + if (!hostname || hostname === 'None' || hostname === 'null') { return ( @@ -103,17 +109,10 @@ export const HostnameField = (props: HostnameFieldProps): JSX.Element => { ); } - + return ( - + {hostname} @@ -122,7 +121,7 @@ export const HostnameField = (props: HostnameFieldProps): JSX.Element => { export const LatencyField = (props: LatencyFieldProps): JSX.Element => { const { rtt, ...rest } = props; - + if (rtt === null || rtt === undefined) { return ( @@ -130,22 +129,16 @@ export const LatencyField = (props: LatencyFieldProps): JSX.Element => { ); } - + // Color-code latency: green < 50ms, yellow < 200ms, red >= 200ms const getLatencyColor = (latency: number) => { if (latency < 50) return 'green.500'; if (latency < 200) return 'yellow.500'; return 'red.500'; }; - + return ( - + {rtt.toFixed(1)}ms ); @@ -153,7 +146,7 @@ export const LatencyField = (props: LatencyFieldProps): JSX.Element => { export const LossField = (props: LossFieldProps): JSX.Element => { const { loss, ...rest } = props; - + if (loss === null || loss === undefined) { return ( @@ -161,19 +154,19 @@ export const LossField = (props: LossFieldProps): JSX.Element => { ); } - + // Color-code loss: green = 0%, yellow < 50%, red >= 50% const getLossColor = (lossPercent: number) => { if (lossPercent === 0) return 'green.500'; if (lossPercent < 50) return 'yellow.500'; return 'red.500'; }; - + const bgColor = useColorValue( loss === 0 ? 'green.50' : loss < 50 ? 'yellow.50' : 'red.50', - loss === 0 ? 'green.900' : loss < 50 ? 'yellow.900' : 'red.900' + loss === 0 ? 'green.900' : loss < 50 ? 'yellow.900' : 'red.900', ); - + return ( { {loss}% ); -}; \ No newline at end of file +}; diff --git a/hyperglass/ui/components/output/traceroute-table.tsx b/hyperglass/ui/components/output/traceroute-table.tsx index 90a94648f..af6aa0aef 100644 --- a/hyperglass/ui/components/output/traceroute-table.tsx +++ b/hyperglass/ui/components/output/traceroute-table.tsx @@ -28,11 +28,11 @@ export const TracerouteTable = (props: TracerouteTableProps): JSX.Element => { return ( - columns={tracerouteColumns as any} + columns={tracerouteColumns} bordersHorizontal data={data.hops} Cell={(d: TracerouteCellRenderProps) => } /> ); -}; \ No newline at end of file +}; diff --git a/hyperglass/ui/components/path/chart.tsx b/hyperglass/ui/components/path/chart.tsx index a839f18cd..1768a873e 100644 --- a/hyperglass/ui/components/path/chart.tsx +++ b/hyperglass/ui/components/path/chart.tsx @@ -75,9 +75,7 @@ const ASNode = (props: NodeProps): JSX.Element => { {hasChildren && } - - {name} - + {name} {asn} diff --git a/hyperglass/ui/components/path/path.tsx b/hyperglass/ui/components/path/path.tsx index 0bd249481..9bbb05b11 100644 --- a/hyperglass/ui/components/path/path.tsx +++ b/hyperglass/ui/components/path/path.tsx @@ -36,18 +36,19 @@ export const Path = (props: PathProps): JSX.Element => { try { onOpen(); if (!response) return; - const out = response.output as any; - const asnOrgs = out?.asn_organizations || {}; + const out = response.output; + if (typeof out === 'string') return; + const asnOrgs = out.asn_organizations || {}; if (Object.keys(asnOrgs).length > 0) return; // Collect unique ASNs from the output depending on type let asns: string[] = []; - if (out?.routes) { - const all = out.routes.flatMap((r: any) => r.as_path || []); - asns = Array.from(new Set(all.map((a: any) => String(a)))); - } else if (out?.hops) { - const all = out.hops.map((h: any) => h.asn).filter(Boolean); - asns = Array.from(new Set(all.map((a: any) => String(a)))); + if ('routes' in out) { + const all = out.routes.flatMap(route => route.as_path); + asns = Array.from(new Set(all.map(asn => String(asn)))); + } else if ('hops' in out) { + const all = out.hops.map(hop => hop.asn).filter((asn): asn is string => Boolean(asn)); + asns = Array.from(new Set(all.map(asn => String(asn)))); } if (asns.length === 0) return; diff --git a/hyperglass/ui/components/path/use-elements.ts b/hyperglass/ui/components/path/use-elements.ts index 6f77d95ec..ef7d69e64 100644 --- a/hyperglass/ui/components/path/use-elements.ts +++ b/hyperglass/ui/components/path/use-elements.ts @@ -44,7 +44,7 @@ function* buildElements( data: AllStructuredResponses, ): Generator> { let asPaths: string[][] = []; - let asnOrgs: Record = {}; + let asnOrgs: Record = {}; // For traceroute data we may have IXPs represented as asn === 'IXP' with // the IXP name stored per-hop in hop.org. Collect per-path org arrays so // nodes for IXPs can show the proper IXP name instead of the generic @@ -62,10 +62,10 @@ function* buildElements( return uniqueAsns[0] === base.asn ? uniqueAsns.slice(1) : uniqueAsns; }) .filter(path => path.length > 0); // Remove empty paths - + // Get ASN organization mapping if available - asnOrgs = (data as any).asn_organizations || {}; - + asnOrgs = data.asn_organizations || {}; + // Debug: Log BGP ASN organization data if (Object.keys(asnOrgs).length > 0) { console.debug('BGP ASN organizations loaded:', asnOrgs); @@ -82,7 +82,7 @@ function* buildElements( if (hop.asn && hop.asn !== 'None' && hop.asn !== currentAsn) { currentAsn = hop.asn; hopAsns.push(hop.asn); - hopOrgs.push(hop.org ?? undefined); + hopOrgs.push(hop.org ?? undefined); } } @@ -96,10 +96,10 @@ function* buildElements( pathGroupOrgs[0] = filteredOrgs; } } - + // Get ASN organization mapping if available - asnOrgs = (data as any).asn_organizations || {}; - + asnOrgs = data.asn_organizations || {}; + // Debug: Log traceroute ASN organization data if (Object.keys(asnOrgs).length > 0) { console.debug('Traceroute ASN organizations loaded:', asnOrgs); @@ -174,11 +174,11 @@ function* buildElements( id: base.asn, type: 'ASNode', position: { x, y }, - data: { - asn: base.asn, - name: asnOrgs[base.asn]?.name || base.name, - hasChildren: true, - hasParents: false + data: { + asn: base.asn, + name: asnOrgs[base.asn]?.name || base.name, + hasChildren: true, + hasParents: false, }, }; diff --git a/hyperglass/ui/components/results/individual.tsx b/hyperglass/ui/components/results/individual.tsx index d6591ae72..9d026c2bb 100644 --- a/hyperglass/ui/components/results/individual.tsx +++ b/hyperglass/ui/components/results/individual.tsx @@ -28,7 +28,12 @@ import { useStrf, useTableToString, } from '~/hooks'; -import { isStringOutput, isStructuredOutput, isBGPStructuredOutput, isTracerouteStructuredOutput } from '~/types'; +import { + isStringOutput, + isStructuredOutput, + isBGPStructuredOutput, + isTracerouteStructuredOutput, +} from '~/types'; import { CopyButton } from './copy-button'; import { FormattedError } from './formatted-error'; import { isFetchError, isLGError, isLGOutputOrError, isStackError } from './guards'; @@ -253,7 +258,9 @@ const _Result: React.ForwardRefRenderFunction = ( {isBGPStructuredOutput(data) && data.level === 'success' && tableComponent ? ( {data.output} - ) : isTracerouteStructuredOutput(data) && data.level === 'success' && tableComponent ? ( + ) : isTracerouteStructuredOutput(data) && + data.level === 'success' && + tableComponent ? ( {data.output} ) : isStringOutput(data) && data.level === 'success' && !tableComponent ? ( {data.output} diff --git a/hyperglass/ui/components/table/main.tsx b/hyperglass/ui/components/table/main.tsx index 5e6383529..d76c0edd8 100644 --- a/hyperglass/ui/components/table/main.tsx +++ b/hyperglass/ui/components/table/main.tsx @@ -16,19 +16,19 @@ import { PageSelect } from './page-select'; import type { TableOptions, PluginHook } from 'react-table'; import type { Theme, TableColumn, CellRenderProps } from '~/types'; -interface TableProps { +interface TableProps { data: T[]; striped?: boolean; - columns: TableColumn[] | any[]; // Allow more flexible column types + columns: TableColumn[]; heading?: React.ReactNode; bordersVertical?: boolean; bordersHorizontal?: boolean; - Cell?: React.FC; // More flexible cell render props + Cell?: React.FC>; rowHighlightProp?: keyof T; rowHighlightBg?: Theme.ColorNames; } -export const Table = (props: TableProps): JSX.Element => { +export const Table = (props: TableProps): JSX.Element => { const { data, columns, @@ -53,7 +53,7 @@ export const Table = (props: TableProps): JSX.Element => { for (const col of columns) { if (col.hidden) { - hiddenColumns.push(col.accessor); + hiddenColumns.push(String(col.accessor)); } } @@ -61,9 +61,9 @@ export const Table = (props: TableProps): JSX.Element => { columns, defaultColumn, data, - initialState: { + initialState: { hiddenColumns, - pageSize: 50 // Default to 50 rows instead of default 10 + pageSize: 50, // Default to 50 rows instead of default 10 }, } as TableOptions; @@ -132,12 +132,12 @@ export const Table = (props: TableProps): JSX.Element => { doStripe={striped} highlightBg={rowHighlightBg} doHorizontalBorders={bordersHorizontal} - highlight={row.values[rowHighlightProp ?? ''] ?? false} + highlight={rowHighlightProp ? Boolean(row.values[String(rowHighlightProp)]) : false} dimText={dimText} {...row.getRowProps()} > {row.cells.map((cell, i) => { - const { column, row, value } = cell as CellRenderProps; + const { column, row, value } = cell as unknown as CellRenderProps; return ( { data: { asn: { organization: null, // No external fetch - org data comes from IP enrichment - } - } + }, + }, }), refetchOnWindowFocus: false, - refetchInterval: false, + refetchInterval: false, refetchOnMount: false, cacheTime: Infinity, enabled: false, // Disable the query entirely diff --git a/hyperglass/ui/hooks/use-dns-query.test.tsx b/hyperglass/ui/hooks/use-dns-query.test.tsx index 403aad802..4843d5aa4 100644 --- a/hyperglass/ui/hooks/use-dns-query.test.tsx +++ b/hyperglass/ui/hooks/use-dns-query.test.tsx @@ -1,5 +1,4 @@ -import 'isomorphic-fetch'; -import { expect, describe, it } from 'vitest'; +import { afterEach, beforeEach, expect, describe, it, vi } from 'vitest'; import '@testing-library/jest-dom'; import { renderHook } from '@testing-library/react-hooks'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; @@ -8,6 +7,23 @@ import { useDNSQuery } from './use-dns-query'; import type { Config } from '~/types'; +const fetchMock = vi.fn(async (input: RequestInfo | URL): Promise => { + const requestUrl = new URL(String(input)); + const address = requestUrl.searchParams.get('type') === 'A' ? '1.1.1.1' : '2606:4700:4700::1111'; + return { + json: async () => ({ Answer: [{ data: address }] }), + } as Response; +}); + +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + fetchMock.mockClear(); +}); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, cacheTime: Infinity } }, }); @@ -38,38 +54,56 @@ const GoogleWrapper = (props: React.PropsWithChildren>) => { }; describe('useDNSQuery Cloudflare', () => { - it('Test Cloudflare DNS over HTTPS (IPv4)', async () => { + it('queries the configured Cloudflare provider for IPv4', async () => { const { result, waitFor } = renderHook(() => useDNSQuery('one.one.one.one', 4), { wrapper: CloudflareWrapper, }); await waitFor(() => result.current.isSuccess, { timeout: 5_000 }); expect(result.current.data?.Answer.map(a => a.data)).toContain('1.1.1.1'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('https://cloudflare-dns.com/dns-query?name=one.one.one.one&type=A'), + expect.objectContaining({ headers: { accept: 'application/dns-json' } }), + ); }); - it('Test Cloudflare DNS over HTTPS (IPv6)', async () => { + it('queries the configured Cloudflare provider for IPv6', async () => { const { result, waitFor } = renderHook(() => useDNSQuery('one.one.one.one', 6), { wrapper: CloudflareWrapper, }); await waitFor(() => result.current.isSuccess, { timeout: 5_000 }); expect(result.current.data?.Answer.map(a => a.data)).toContain('2606:4700:4700::1111'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + 'https://cloudflare-dns.com/dns-query?name=one.one.one.one&type=AAAA', + ), + expect.objectContaining({ headers: { accept: 'application/dns-json' } }), + ); }); }); describe('useDNSQuery Google', () => { - it('Test Google DNS over HTTPS (IPv4)', async () => { + it('queries the configured Google provider for IPv4', async () => { const { result, waitFor } = renderHook(() => useDNSQuery('one.one.one.one', 4), { wrapper: GoogleWrapper, }); await waitFor(() => result.current.isSuccess, { timeout: 5_000 }); expect(result.current.data?.Answer.map(a => a.data)).toContain('1.1.1.1'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('https://dns.google/resolve?name=one.one.one.one&type=A'), + expect.objectContaining({ headers: { accept: 'application/dns-json' } }), + ); }); - it('Test Google DNS over HTTPS (IPv6)', async () => { + it('queries the configured Google provider for IPv6', async () => { const { result, waitFor } = renderHook(() => useDNSQuery('one.one.one.one', 6), { wrapper: GoogleWrapper, }); await waitFor(() => result.current.isSuccess, { timeout: 5_000 }); expect(result.current.data?.Answer.map(a => a.data)).toContain('2606:4700:4700::1111'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('https://dns.google/resolve?name=one.one.one.one&type=AAAA'), + expect.objectContaining({ headers: { accept: 'application/dns-json' } }), + ); }); }); diff --git a/hyperglass/ui/hooks/use-table-to-string.ts b/hyperglass/ui/hooks/use-table-to-string.ts index c1bcbfc35..a0f85a378 100644 --- a/hyperglass/ui/hooks/use-table-to-string.ts +++ b/hyperglass/ui/hooks/use-table-to-string.ts @@ -23,12 +23,17 @@ interface TableToStringFormatted { dayjs.extend(relativeTimePlugin); dayjs.extend(utcPlugin); -function formatAsPath(path: number[], enrichments?: Record): string { - return path.map(asn => { - const asnStr = String(asn); - const org = enrichments?.[asnStr]?.name; - return org ? `${asn} (${org})` : String(asn); - }).join(' → '); +function formatAsPath( + path: number[], + enrichments?: Record, +): string { + return path + .map(asn => { + const asnStr = String(asn); + const org = enrichments?.[asnStr]?.name; + return org ? `${asn} (${org})` : String(asn); + }) + .join(' → '); } function formatCommunities(comms: string[]): string { @@ -97,44 +102,46 @@ export function useTableToString( let result = messages.noOutput; try { if (typeof data !== 'undefined' && isStructuredOutput(data)) { - // Handle BGP data if (isBGPStructuredOutput(data)) { // Check if this is BGP data with routes if (!('routes' in data.output) || !Array.isArray(data.output.routes)) { return messages.noOutput; // Not BGP data, return early } - + const tableStringParts = [ `Routes For: ${target.join(', ')}`, `Timestamp: ${data.timestamp} UTC`, ]; - + // Get enrichment data if available - const enrichments = (data.output as any).asn_organizations || {}; - + const enrichments = data.output.asn_organizations || {}; + for (const route of data.output.routes) { for (const field of parsedDataFields) { const [header, accessor, align] = field; if (align !== null) { let value = route[accessor]; - + // Handle fields that should be hidden when empty/not available - if ((accessor === 'source_rid' || accessor === 'age') && - (value === null || value === undefined || - (typeof value === 'string' && value.trim() === '') || - (accessor === 'age' && value === -1))) { + if ( + (accessor === 'source_rid' || accessor === 'age') && + (value === null || + value === undefined || + (typeof value === 'string' && value.trim() === '') || + (accessor === 'age' && value === -1)) + ) { continue; // Skip this field entirely } - + // Special handling for AS path with enrichment if (accessor === 'as_path') { value = formatAsPath(value as number[], enrichments); } // Special handling for next_hop with enrichment else if (accessor === 'next_hop') { - const nextHopOrg = (route as any).next_hop_org; - const nextHopAsn = (route as any).next_hop_asn; + const nextHopOrg = route.next_hop_org; + const nextHopAsn = route.next_hop_asn; if (nextHopOrg || nextHopAsn) { const asnPart = nextHopAsn ? nextHopAsn.replace(/^AS/, '') : ''; const enrichmentPart = [asnPart, nextHopOrg].filter(Boolean).join(' '); @@ -144,7 +151,7 @@ export function useTableToString( const fmtFunc = getFmtFunc(accessor) as (v: typeof value) => string; value = fmtFunc(value); } - + if (accessor === 'prefix') { let statusSuffix = ''; if (route.active) { @@ -163,39 +170,40 @@ export function useTableToString( } result = tableStringParts.join('\n'); } - + // Handle Traceroute data else if (isTracerouteStructuredOutput(data)) { if (!('hops' in data.output) || !Array.isArray(data.output.hops)) { return messages.noOutput; // Not traceroute data, return early } - + const formatRTT = (rtt: number | null | undefined): string => { if (rtt === null || rtt === undefined) return '*'; return `${rtt.toFixed(1)}ms`; }; - - const formatIP = (hop: any): string => { + + const formatIP = (hop: TracerouteHop): string => { if (hop.display_ip) return hop.display_ip; // For truncated IPv6 if (hop.ip_address) return hop.ip_address; return '*'; }; - - const formatASN = (hop: any): string => { + + const formatASN = (hop: TracerouteHop): string => { if (hop.asn) return `AS${hop.asn}`; return '*'; }; - - const formatHostname = (hop: any): string => { - if (hop.hostname && hop.hostname !== 'None' && hop.hostname !== 'null') return hop.hostname; + + const formatHostname = (hop: TracerouteHop): string => { + if (hop.hostname && hop.hostname !== 'None' && hop.hostname !== 'null') + return hop.hostname; return '*'; }; - + // Create a nicely formatted text table with proper column alignment const header = `Traceroute to ${data.output.target} from ${data.output.source}`; const timestamp = `Timestamp: ${data.timestamp} UTC`; - const separator = '=' .repeat(header.length); - + const separator = '='.repeat(header.length); + // Calculate optimal column widths by examining all data const columnWidths = { hop: Math.max(3, ...data.output.hops.map(h => h.hop_number.toString().length)), @@ -209,7 +217,7 @@ export function useTableToString( best: Math.max(4, ...data.output.hops.map(h => formatRTT(h.best_rtt).length)), worst: Math.max(5, ...data.output.hops.map(h => formatRTT(h.worst_rtt).length)), }; - + // Create header row with proper spacing const headerRow = [ 'Hop'.padEnd(columnWidths.hop), @@ -221,20 +229,13 @@ export function useTableToString( 'Last'.padEnd(columnWidths.last), 'AVG'.padEnd(columnWidths.avg), 'Best'.padEnd(columnWidths.best), - 'Worst' + 'Worst', ].join(' '); - + const totalWidth = headerRow.length; - - const tableLines = [ - header, - timestamp, - separator, - '', - headerRow, - '-'.repeat(totalWidth), - ]; - + + const tableLines = [header, timestamp, separator, '', headerRow, '-'.repeat(totalWidth)]; + // Format data rows with consistent column widths for (const hop of data.output.hops) { const row = [ @@ -247,12 +248,12 @@ export function useTableToString( formatRTT(hop.last_rtt).padEnd(columnWidths.last), formatRTT(hop.avg_rtt).padEnd(columnWidths.avg), formatRTT(hop.best_rtt).padEnd(columnWidths.best), - formatRTT(hop.worst_rtt) + formatRTT(hop.worst_rtt), ].join(' '); - + tableLines.push(row); } - + result = tableLines.join('\n'); } } diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index d330fc61f..8e7921c01 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -1,5 +1,5 @@ { - "version": "3.0.0", + "version": "3.1.0", "name": "ui", "description": "UI for hyperglass, the modern network looking glass", "author": "Matt Love", @@ -31,7 +31,7 @@ "framer-motion": "^11.2.10", "lodash": "^4.18.0", "merge-anything": "^4.0.1", - "next": "15.5.19", + "next": "15.5.21", "palette-by-numbers": "^0.1.6", "plur": "^4.0.0", "react": "^18.2.0", diff --git a/hyperglass/ui/pnpm-lock.yaml b/hyperglass/ui/pnpm-lock.yaml index 07cafb1f5..ce6566a72 100644 --- a/hyperglass/ui/pnpm-lock.yaml +++ b/hyperglass/ui/pnpm-lock.yaml @@ -8,6 +8,9 @@ overrides: ua-parser-js: ^0.7.36 '@babel/runtime': 7.26.10 '@babel/helpers': 7.26.10 + nanoid: 3.3.18 + postcss: 8.5.18 + sharp: 0.35.0 importers: @@ -56,8 +59,8 @@ importers: specifier: ^4.0.1 version: 4.0.1 next: - specifier: 15.5.19 - version: 15.5.19(@babel/core@7.23.9)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + specifier: 15.5.21 + version: 15.5.21(@babel/core@7.23.9(supports-color@7.2.0))(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) palette-by-numbers: specifier: ^0.1.6 version: 0.1.6 @@ -90,7 +93,7 @@ importers: version: 4.1.4(react@18.2.0) react-markdown: specifier: ^5.0.3 - version: 5.0.3(@types/node@20.11.20)(@types/react@18.2.60)(react@18.2.0) + version: 5.0.3(@types/node@20.11.20)(@types/react@18.2.60)(react@18.2.0)(supports-color@7.2.0) react-select: specifier: ^5.7.0 version: 5.7.0(@types/react@18.2.60)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -105,7 +108,7 @@ importers: version: 11.10.4(@types/react@18.2.60)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) remark-gfm: specifier: ^1.0.0 - version: 1.0.0 + version: 1.0.0(supports-color@7.2.0) string-format: specifier: ^2.0.0 version: 2.0.0 @@ -151,46 +154,46 @@ importers: version: 2.0.0 '@typescript-eslint/eslint-plugin': specifier: ^7.1.0 - version: 7.1.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3) + version: 7.1.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3))(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3) '@typescript-eslint/parser': specifier: ^7.1.0 - version: 7.1.0(eslint@8.57.0)(typescript@5.3.3) + version: 7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3) '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.2.1(vite@5.1.4(@types/node@20.11.20)) + version: 4.2.1(supports-color@7.2.0)(vite@5.1.4(@types/node@20.11.20)) '@vitest/ui': specifier: ^1.3.1 version: 1.3.1(vitest@1.3.1) babel-eslint: specifier: ^10.1.0 - version: 10.1.0(eslint@8.57.0) + version: 10.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0) eslint: specifier: ^8.57.0 - version: 8.57.0 + version: 8.57.0(supports-color@7.2.0) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.0(eslint@8.57.0) + version: 9.1.0(eslint@8.57.0(supports-color@7.2.0)) eslint-import-resolver-typescript: specifier: ^3.6.1 - version: 3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0) + version: 3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-plugin-import@2.29.1)(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-import: specifier: ^2.29.1 - version: 2.29.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + version: 2.29.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0) eslint-plugin-json: specifier: ^3.1.0 version: 3.1.0 eslint-plugin-jsx-a11y: specifier: ^6.8.0 - version: 6.8.0(eslint@8.57.0) + version: 6.8.0(eslint@8.57.0(supports-color@7.2.0)) eslint-plugin-prettier: specifier: ^5.1.3 - version: 5.1.3(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.2.5) + version: 5.1.3(eslint-config-prettier@9.1.0(eslint@8.57.0(supports-color@7.2.0)))(eslint@8.57.0(supports-color@7.2.0))(prettier@3.2.5) eslint-plugin-react: specifier: ^7.33.2 - version: 7.33.2(eslint@8.57.0) + version: 7.33.2(eslint@8.57.0(supports-color@7.2.0)) eslint-plugin-react-hooks: specifier: ^4.6.0 - version: 4.6.0(eslint@8.57.0) + version: 4.6.0(eslint@8.57.0(supports-color@7.2.0)) identity-obj-proxy: specifier: ^3.0.0 version: 3.0.0 @@ -199,13 +202,13 @@ importers: version: 3.0.0 jsdom: specifier: ^24.0.0 - version: 24.0.0 + version: 24.0.0(supports-color@7.2.0) prettier: specifier: ^3.2.5 version: 3.2.5 prettier-eslint: specifier: ^16.3.0 - version: 16.3.0 + version: 16.3.0(supports-color@7.2.0) react-test-renderer: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) @@ -217,7 +220,7 @@ importers: version: 5.3.3 vitest: specifier: ^1.3.1 - version: 1.3.1(@types/node@20.11.20)(@vitest/ui@1.3.1)(jsdom@24.0.0) + version: 1.3.1(@types/node@20.11.20)(@vitest/ui@1.3.1)(jsdom@24.0.0(supports-color@7.2.0))(supports-color@7.2.0) packages: @@ -344,10 +347,6 @@ packages: resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==} engines: {node: '>=6.9.0'} - '@babel/template@7.23.9': - resolution: {integrity: sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==} - engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -386,24 +385,28 @@ packages: engines: {node: '>=14.*'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@1.5.3': resolution: {integrity: sha512-cupBQv0sNF1OKqBfx7EDWMSsKwRrBUZfjXawT4s6hKV6ALq7p0QzWlxr/sDmbKMLOaLQtw2Qgu/77N9rm+f9Rg==} engines: {node: '>=14.*'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@1.5.3': resolution: {integrity: sha512-UUHiAnlDqr2Y/LpvshBFhUYMWkl2/Jn+bi3U6jKuav0qWbbBKU/ByHgR4+NBxpKBYoCtWxhnmatfH1bpPIuZMw==} engines: {node: '>=14.*'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@1.5.3': resolution: {integrity: sha512-YQrSArQvcv4FYsk7Q91Yv4uuu5F8hJyORVcv3zsjCLGkjIjx2RhjYLpTL733SNL7v33GmOlZY0eFR1ko38tuUw==} engines: {node: '>=14.*'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@1.5.3': resolution: {integrity: sha512-HxatYH7vf/kX9nrD+pDYuV2GI9GV8EFo6cfKkahAecTuZLPxryHx1WEfJthp5eNsE0+09STGkKIKjirP0ufaZA==} @@ -1141,6 +1144,7 @@ packages: '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -1148,141 +1152,167 @@ packages: '@humanwhocodes/object-schema@2.0.2': resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + deprecated: Use @eslint/object-schema instead '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.0': + resolution: {integrity: sha512-ZgaYEwaj+lx/5n4W8GmZ2IYz0PQHjN5eqRcfijWGB+2Aq7ZInZGa0qJyAn6DEtyLuWHRSrmWOqT9q3qqTBvmUQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.0': + resolution: {integrity: sha512-c1z9LFpKB0slQW3RchwBE8iSVzGp70TNjUUO9k4BZwwW4HH7JBGHeIy4b+kk4n/kcBASb9evKCE3/7Slmslgiw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.0': + resolution: {integrity: sha512-Li2KTev0H90kEtnJHkI9xQojXt1AqWmFBMXiPw5kqd1jQgP7gi5HVK/qC5Rmh/59NuAwUuPzzPITmX22NomYYQ==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.0': + resolution: {integrity: sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.0': + resolution: {integrity: sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.0': + resolution: {integrity: sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==} cpu: [arm64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.0': + resolution: {integrity: sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==} cpu: [arm] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.0': + resolution: {integrity: sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==} cpu: [ppc64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.0': + resolution: {integrity: sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.0': + resolution: {integrity: sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==} cpu: [s390x] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.0': + resolution: {integrity: sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==} cpu: [x64] os: [linux] + libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.0': + resolution: {integrity: sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==} cpu: [arm64] os: [linux] + libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.0': + resolution: {integrity: sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==} cpu: [x64] os: [linux] + libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.0': + resolution: {integrity: sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.0': + resolution: {integrity: sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] + libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.0': + resolution: {integrity: sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] + libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.0': + resolution: {integrity: sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] + libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.0': + resolution: {integrity: sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] + libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.0': + resolution: {integrity: sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.0': + resolution: {integrity: sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.0': + resolution: {integrity: sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.0': + resolution: {integrity: sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.0': + resolution: {integrity: sha512-t+kie1TOyaDM6Dho+f+y0VqIUNhYQaKCUahuZVi0E0frgdiaOaPsDxDW3wfKacUdaNBCnK/ZDBMg33ydvHj8uA==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.0': + resolution: {integrity: sha512-M5eKxug0dabbaWgFKvPa3odNs2OpaP+81NASfGKkt4GcYXpNhSu7CaeYxWkLNV6vHmUp4hnCxnxrUyhUJhXbKA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.0': + resolution: {integrity: sha512-z0+pZ03QCDvdVN0Ez9IX/yjWC19ikMlXrmdYMwYNLTh2BLPx3hXWPvyqWfquZ0BTO9O6GVOjIVoTcyyacMnWlQ==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.0': + resolution: {integrity: sha512-feNnlz5ZHKr0MY1LPHvZQyJeBkbo4ctsn0D8FvA53VTw5TC63rfEL2UrWbkSBR19htSE7Mw78xYVwdJqoMWVHw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -1308,53 +1338,57 @@ packages: '@jridgewell/trace-mapping@0.3.22': resolution: {integrity: sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==} - '@next/env@15.5.19': - resolution: {integrity: sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==} + '@next/env@15.5.21': + resolution: {integrity: sha512-hjJI/GfrjWHgNguRIBzItjRRu0m3Nrz17GhxsjuHfjIvg9hyg3239REd2dpI+bpMTFuVrVprHzEQ19m++cDtbw==} - '@next/swc-darwin-arm64@15.5.19': - resolution: {integrity: sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==} + '@next/swc-darwin-arm64@15.5.21': + resolution: {integrity: sha512-ZfjqPEdi6TRC/fWx7UDbwb1fbVgyh2uD5tVTRKIDZDlYM+UNuE/LafDG2fwuAoZilADpABh46OY/F5qf9JjqLQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.19': - resolution: {integrity: sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==} + '@next/swc-darwin-x64@15.5.21': + resolution: {integrity: sha512-TlCf1NpxgQLzTrexuev75xwmNCJMd1/qkJpTVP1GRRcih93hlIBn1P72hkh8T0gnRFr6BmWksQtbyG3jT6jnww==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.19': - resolution: {integrity: sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==} + '@next/swc-linux-arm64-gnu@15.5.21': + resolution: {integrity: sha512-LXRsq1p+HvHSi7ygwNcSEEcK0zuo5jS75ZlqFHtOH+LF7qntXAJVJxah+1Pi/GyBm7EpkwU7m4EgbvIKrMqm9A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] - '@next/swc-linux-arm64-musl@15.5.19': - resolution: {integrity: sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==} + '@next/swc-linux-arm64-musl@15.5.21': + resolution: {integrity: sha512-hyGixhFxpDKjqoev6l4KlcRBlt9AXWrGhDZwmwg49sMJM5tnKQPSi+SEj9+e5n+l/bthRGZUdh59GKIs6lQPRw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] - '@next/swc-linux-x64-gnu@15.5.19': - resolution: {integrity: sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==} + '@next/swc-linux-x64-gnu@15.5.21': + resolution: {integrity: sha512-qfE+YfOba6S2+13e8qn1/UozDVNZ2clBlrs8UtDoax4s8ediu6sq93z66OEHUYlb69Tffh5JTNkgtsAKiSuugg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] - '@next/swc-linux-x64-musl@15.5.19': - resolution: {integrity: sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==} + '@next/swc-linux-x64-musl@15.5.21': + resolution: {integrity: sha512-BXLGG+EvIwp/Rrgl6HY8sqvD6BOUOIRz8/naDbeLNX7mlA5H2XRcL6MW/0IGnJISfj5BA9gNhFyJj5yOoiIDJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] - '@next/swc-win32-arm64-msvc@15.5.19': - resolution: {integrity: sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==} + '@next/swc-win32-arm64-msvc@15.5.21': + resolution: {integrity: sha512-tNGNOlT0Wn7E4IMsSnufjXN/l2L2/AGdLLpa2vzS89SYCBuihgLn3ngLsIrvndAnWo9nAkus+4gZHTI/Ijx9HA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.19': - resolution: {integrity: sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==} + '@next/swc-win32-x64-msvc@15.5.21': + resolution: {integrity: sha512-DmIdWmC9p4rdNIiQqo8ap0+Cnj6kKtTZnuSCxoYydSc8sgpDgAg9wFhxplunak9imLV0pTvc5WVCOHwm5eHLtQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1446,26 +1480,31 @@ packages: resolution: {integrity: sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.12.0': resolution: {integrity: sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.12.0': resolution: {integrity: sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.12.0': resolution: {integrity: sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.12.0': resolution: {integrity: sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.12.0': resolution: {integrity: sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw==} @@ -1828,6 +1867,7 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-react@4.2.1': resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} @@ -2461,6 +2501,7 @@ packages: eslint@8.57.0: resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@9.6.1: @@ -2632,6 +2673,7 @@ packages: glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -2764,6 +2806,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3174,16 +3217,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.7: - resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@15.5.19: - resolution: {integrity: sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==} + next@15.5.21: + resolution: {integrity: sha512-/TsdBtkWLhkl+NVL3Uqws2UphNd6IPzOtzSk1fHaf+0P7GQKLZDUytyhns/Ykbzdy9+YRjwG7ONvrHaaTDdFqQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -3351,12 +3394,8 @@ packages: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.4.35: - resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==} + postcss@8.5.18: + resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -3630,6 +3669,7 @@ packages: rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rollup@4.12.0: @@ -3686,9 +3726,9 @@ packages: resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} engines: {node: '>= 0.4'} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.0: + resolution: {integrity: sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==} + engines: {node: '>=20.9.0'} shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -3716,8 +3756,8 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - source-map-js@1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} source-map@0.5.7: @@ -4016,6 +4056,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true vest@3.2.8: @@ -4121,6 +4162,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.2: resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==} @@ -4259,20 +4301,20 @@ snapshots: '@babel/compat-data@7.23.5': {} - '@babel/core@7.23.9': + '@babel/core@7.23.9(supports-color@7.2.0)': dependencies: '@ampproject/remapping': 2.2.1 - '@babel/code-frame': 7.23.5 + '@babel/code-frame': 7.27.1 '@babel/generator': 7.23.6 '@babel/helper-compilation-targets': 7.23.6 - '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.9) + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.9(supports-color@7.2.0)) '@babel/helpers': 7.26.10 - '@babel/parser': 7.23.9 - '@babel/template': 7.23.9 - '@babel/traverse': 7.23.9 - '@babel/types': 7.23.9 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.23.9(supports-color@7.2.0) + '@babel/types': 7.28.5 convert-source-map: 2.0.0 - debug: 4.3.4 + debug: 4.3.4(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4281,7 +4323,7 @@ snapshots: '@babel/generator@7.23.6': dependencies: - '@babel/types': 7.23.9 + '@babel/types': 7.28.5 '@jridgewell/gen-mapping': 0.3.3 '@jridgewell/trace-mapping': 0.3.22 jsesc: 2.5.2 @@ -4298,35 +4340,35 @@ snapshots: '@babel/helper-function-name@7.23.0': dependencies: - '@babel/template': 7.23.9 - '@babel/types': 7.23.9 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 '@babel/helper-hoist-variables@7.22.5': dependencies: - '@babel/types': 7.23.9 + '@babel/types': 7.28.5 '@babel/helper-module-imports@7.22.15': dependencies: - '@babel/types': 7.23.9 + '@babel/types': 7.28.5 - '@babel/helper-module-transforms@7.23.3(@babel/core@7.23.9)': + '@babel/helper-module-transforms@7.23.3(@babel/core@7.23.9(supports-color@7.2.0))': dependencies: - '@babel/core': 7.23.9 + '@babel/core': 7.23.9(supports-color@7.2.0) '@babel/helper-environment-visitor': 7.22.20 '@babel/helper-module-imports': 7.22.15 '@babel/helper-simple-access': 7.22.5 '@babel/helper-split-export-declaration': 7.22.6 - '@babel/helper-validator-identifier': 7.22.20 + '@babel/helper-validator-identifier': 7.28.5 '@babel/helper-plugin-utils@7.22.5': {} '@babel/helper-simple-access@7.22.5': dependencies: - '@babel/types': 7.23.9 + '@babel/types': 7.28.5 '@babel/helper-split-export-declaration@7.22.6': dependencies: - '@babel/types': 7.23.9 + '@babel/types': 7.28.5 '@babel/helper-string-parser@7.23.4': {} @@ -4357,43 +4399,37 @@ snapshots: dependencies: '@babel/types': 7.28.5 - '@babel/plugin-transform-react-jsx-self@7.23.3(@babel/core@7.23.9)': + '@babel/plugin-transform-react-jsx-self@7.23.3(@babel/core@7.23.9(supports-color@7.2.0))': dependencies: - '@babel/core': 7.23.9 + '@babel/core': 7.23.9(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.22.5 - '@babel/plugin-transform-react-jsx-source@7.23.3(@babel/core@7.23.9)': + '@babel/plugin-transform-react-jsx-source@7.23.3(@babel/core@7.23.9(supports-color@7.2.0))': dependencies: - '@babel/core': 7.23.9 + '@babel/core': 7.23.9(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.22.5 '@babel/runtime@7.26.10': dependencies: regenerator-runtime: 0.14.1 - '@babel/template@7.23.9': - dependencies: - '@babel/code-frame': 7.23.5 - '@babel/parser': 7.23.9 - '@babel/types': 7.23.9 - '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 '@babel/parser': 7.28.5 '@babel/types': 7.28.5 - '@babel/traverse@7.23.9': + '@babel/traverse@7.23.9(supports-color@7.2.0)': dependencies: - '@babel/code-frame': 7.23.5 + '@babel/code-frame': 7.27.1 '@babel/generator': 7.23.6 '@babel/helper-environment-visitor': 7.22.20 '@babel/helper-function-name': 7.23.0 '@babel/helper-hoist-variables': 7.22.5 '@babel/helper-split-export-declaration': 7.22.6 - '@babel/parser': 7.23.9 - '@babel/types': 7.23.9 - debug: 4.3.4 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + debug: 4.3.4(supports-color@7.2.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5154,7 +5190,7 @@ snapshots: '@emnapi/runtime@1.11.1': dependencies: - tslib: 2.6.2 + tslib: 2.8.1 optional: true '@emotion/babel-plugin@11.11.0': @@ -5319,17 +5355,17 @@ snapshots: '@esbuild/win32-x64@0.19.12': optional: true - '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0(supports-color@7.2.0))': dependencies: - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.10.0': {} - '@eslint/eslintrc@2.1.4': + '@eslint/eslintrc@2.1.4(supports-color@7.2.0)': dependencies: ajv: 6.12.6 - debug: 4.3.4 + debug: 4.3.4(supports-color@7.2.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.1 @@ -5367,10 +5403,10 @@ snapshots: dependencies: react-hook-form: 7.42.1(react@18.2.0) - '@humanwhocodes/config-array@0.11.14': + '@humanwhocodes/config-array@0.11.14(supports-color@7.2.0)': dependencies: '@humanwhocodes/object-schema': 2.0.2 - debug: 4.3.4 + debug: 4.3.4(supports-color@7.2.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -5382,98 +5418,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.0': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.0 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.0': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.0 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.0': + dependencies: + '@img/sharp-wasm32': 0.35.0 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.3.0': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.0': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.0': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.0': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.0': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.0': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.0': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.0': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.0': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-x64@1.3.0': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-linux-arm64@0.35.0': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.0 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.0': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.0 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.0': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.0 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.0': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.0 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.0': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.0 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.0': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.0 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.0': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.0 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.0': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.0 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-wasm32@0.35.0': dependencies: '@emnapi/runtime': 1.11.1 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.0': + dependencies: + '@img/sharp-wasm32': 0.35.0 + optional: true + + '@img/sharp-win32-arm64@0.35.0': optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-ia32@0.35.0': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-x64@0.35.0': optional: true '@jest/schemas@29.6.3': @@ -5497,30 +5543,30 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 - '@next/env@15.5.19': {} + '@next/env@15.5.21': {} - '@next/swc-darwin-arm64@15.5.19': + '@next/swc-darwin-arm64@15.5.21': optional: true - '@next/swc-darwin-x64@15.5.19': + '@next/swc-darwin-x64@15.5.21': optional: true - '@next/swc-linux-arm64-gnu@15.5.19': + '@next/swc-linux-arm64-gnu@15.5.21': optional: true - '@next/swc-linux-arm64-musl@15.5.19': + '@next/swc-linux-arm64-musl@15.5.21': optional: true - '@next/swc-linux-x64-gnu@15.5.19': + '@next/swc-linux-x64-gnu@15.5.21': optional: true - '@next/swc-linux-x64-musl@15.5.19': + '@next/swc-linux-x64-musl@15.5.21': optional: true - '@next/swc-win32-arm64-msvc@15.5.19': + '@next/swc-win32-arm64-msvc@15.5.21': optional: true - '@next/swc-win32-x64-msvc@15.5.19': + '@next/swc-win32-x64-msvc@15.5.21': optional: true '@nodelib/fs.scandir@2.1.5': @@ -5676,7 +5722,7 @@ snapshots: '@testing-library/dom@9.3.4': dependencies: - '@babel/code-frame': 7.23.5 + '@babel/code-frame': 7.27.1 '@babel/runtime': 7.26.10 '@types/aria-query': 5.0.4 aria-query: 5.1.3 @@ -5696,7 +5742,7 @@ snapshots: lodash: 4.18.1 redent: 3.0.0 optionalDependencies: - vitest: 1.3.1(@types/node@20.11.20)(@vitest/ui@1.3.1)(jsdom@24.0.0) + vitest: 1.3.1(@types/node@20.11.20)(@vitest/ui@1.3.1)(jsdom@24.0.0(supports-color@7.2.0))(supports-color@7.2.0) '@testing-library/react-hooks@8.0.1(@types/react@18.2.60)(react-dom@18.2.0(react@18.2.0))(react-test-renderer@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: @@ -5931,16 +5977,16 @@ snapshots: '@types/unist@2.0.3': {} - '@typescript-eslint/eslint-plugin@7.1.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint@8.57.0)(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@7.1.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3))(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 7.1.0(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/parser': 7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3) '@typescript-eslint/scope-manager': 7.1.0 - '@typescript-eslint/type-utils': 7.1.0(eslint@8.57.0)(typescript@5.3.3) - '@typescript-eslint/utils': 7.1.0(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/type-utils': 7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3) + '@typescript-eslint/utils': 7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.1.0 - debug: 4.3.4 - eslint: 8.57.0 + debug: 4.3.4(supports-color@7.2.0) + eslint: 8.57.0(supports-color@7.2.0) graphemer: 1.4.0 ignore: 5.3.1 natural-compare: 1.4.0 @@ -5951,27 +5997,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.3.3)': + '@typescript-eslint/parser@6.21.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + '@typescript-eslint/typescript-estree': 6.21.0(supports-color@7.2.0)(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.4 - eslint: 8.57.0 + debug: 4.3.4(supports-color@7.2.0) + eslint: 8.57.0(supports-color@7.2.0) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3)': + '@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 7.1.0 '@typescript-eslint/types': 7.1.0 - '@typescript-eslint/typescript-estree': 7.1.0(typescript@5.3.3) + '@typescript-eslint/typescript-estree': 7.1.0(supports-color@7.2.0)(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.1.0 - debug: 4.3.4 - eslint: 8.57.0 + debug: 4.3.4(supports-color@7.2.0) + eslint: 8.57.0(supports-color@7.2.0) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -5987,12 +6033,12 @@ snapshots: '@typescript-eslint/types': 7.1.0 '@typescript-eslint/visitor-keys': 7.1.0 - '@typescript-eslint/type-utils@7.1.0(eslint@8.57.0)(typescript@5.3.3)': + '@typescript-eslint/type-utils@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3)': dependencies: - '@typescript-eslint/typescript-estree': 7.1.0(typescript@5.3.3) - '@typescript-eslint/utils': 7.1.0(eslint@8.57.0)(typescript@5.3.3) - debug: 4.3.4 - eslint: 8.57.0 + '@typescript-eslint/typescript-estree': 7.1.0(supports-color@7.2.0)(typescript@5.3.3) + '@typescript-eslint/utils': 7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3) + debug: 4.3.4(supports-color@7.2.0) + eslint: 8.57.0(supports-color@7.2.0) ts-api-utils: 1.2.1(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -6003,11 +6049,11 @@ snapshots: '@typescript-eslint/types@7.1.0': {} - '@typescript-eslint/typescript-estree@6.21.0(typescript@5.3.3)': + '@typescript-eslint/typescript-estree@6.21.0(supports-color@7.2.0)(typescript@5.3.3)': dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.4 + debug: 4.3.4(supports-color@7.2.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -6018,11 +6064,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@7.1.0(typescript@5.3.3)': + '@typescript-eslint/typescript-estree@7.1.0(supports-color@7.2.0)(typescript@5.3.3)': dependencies: '@typescript-eslint/types': 7.1.0 '@typescript-eslint/visitor-keys': 7.1.0 - debug: 4.3.4 + debug: 4.3.4(supports-color@7.2.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -6033,15 +6079,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.1.0(eslint@8.57.0)(typescript@5.3.3)': + '@typescript-eslint/utils@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0(supports-color@7.2.0)) '@types/json-schema': 7.0.15 '@types/semver': 7.5.8 '@typescript-eslint/scope-manager': 7.1.0 '@typescript-eslint/types': 7.1.0 - '@typescript-eslint/typescript-estree': 7.1.0(typescript@5.3.3) - eslint: 8.57.0 + '@typescript-eslint/typescript-estree': 7.1.0(supports-color@7.2.0)(typescript@5.3.3) + eslint: 8.57.0(supports-color@7.2.0) semver: 7.6.0 transitivePeerDependencies: - supports-color @@ -6059,11 +6105,11 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-react@4.2.1(vite@5.1.4(@types/node@20.11.20))': + '@vitejs/plugin-react@4.2.1(supports-color@7.2.0)(vite@5.1.4(@types/node@20.11.20))': dependencies: - '@babel/core': 7.23.9 - '@babel/plugin-transform-react-jsx-self': 7.23.3(@babel/core@7.23.9) - '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.9) + '@babel/core': 7.23.9(supports-color@7.2.0) + '@babel/plugin-transform-react-jsx-self': 7.23.3(@babel/core@7.23.9(supports-color@7.2.0)) + '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.9(supports-color@7.2.0)) '@types/babel__core': 7.20.5 react-refresh: 0.14.0 vite: 5.1.4(@types/node@20.11.20) @@ -6101,7 +6147,7 @@ snapshots: pathe: 1.1.2 picocolors: 1.0.0 sirv: 2.0.4 - vitest: 1.3.1(@types/node@20.11.20)(@vitest/ui@1.3.1)(jsdom@24.0.0) + vitest: 1.3.1(@types/node@20.11.20)(@vitest/ui@1.3.1)(jsdom@24.0.0(supports-color@7.2.0))(supports-color@7.2.0) '@vitest/utils@1.3.1': dependencies: @@ -6126,9 +6172,9 @@ snapshots: acorn@8.11.3: {} - agent-base@7.1.0: + agent-base@7.1.0(supports-color@7.2.0): dependencies: - debug: 4.3.4 + debug: 4.3.4(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -6159,7 +6205,7 @@ snapshots: aria-hidden@1.2.3: dependencies: - tslib: 2.6.2 + tslib: 2.8.1 aria-query@5.1.3: dependencies: @@ -6255,13 +6301,13 @@ snapshots: dependencies: dequal: 2.0.3 - babel-eslint@10.1.0(eslint@8.57.0): + babel-eslint@10.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0): dependencies: '@babel/code-frame': 7.23.5 '@babel/parser': 7.23.9 - '@babel/traverse': 7.23.9 + '@babel/traverse': 7.23.9(supports-color@7.2.0) '@babel/types': 7.23.9 - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) eslint-visitor-keys: 1.3.0 resolve: 1.22.1 transitivePeerDependencies: @@ -6482,13 +6528,17 @@ snapshots: dayjs@1.10.4: {} - debug@3.2.7: + debug@3.2.7(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 - debug@4.3.4: + debug@4.3.4(supports-color@7.2.0): dependencies: ms: 2.1.2 + optionalDependencies: + supports-color: 7.2.0 decimal.js@10.4.3: {} @@ -6734,25 +6784,25 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@9.1.0(eslint@8.57.0): + eslint-config-prettier@9.1.0(eslint@8.57.0(supports-color@7.2.0)): dependencies: - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) - eslint-import-resolver-node@0.3.9: + eslint-import-resolver-node@0.3.9(supports-color@7.2.0): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@7.2.0) is-core-module: 2.13.1 resolve: 1.22.8 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-plugin-import@2.29.1)(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - debug: 4.3.4 + debug: 4.3.4(supports-color@7.2.0) enhanced-resolve: 5.15.1 - eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint: 8.57.0(supports-color@7.2.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 is-core-module: 2.11.0 @@ -6763,28 +6813,28 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@7.2.0) optionalDependencies: - '@typescript-eslint/parser': 7.1.0(eslint@8.57.0)(typescript@5.3.3) - eslint: 8.57.0 - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0) + '@typescript-eslint/parser': 7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3) + eslint: 8.57.0(supports-color@7.2.0) + eslint-import-resolver-node: 0.3.9(supports-color@7.2.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-plugin-import@2.29.1)(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0): dependencies: array-includes: 3.1.7 array.prototype.findlastindex: 1.2.4 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 - debug: 3.2.7 + debug: 3.2.7(supports-color@7.2.0) doctrine: 2.1.0 - eslint: 8.57.0 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint: 8.57.0(supports-color@7.2.0) + eslint-import-resolver-node: 0.3.9(supports-color@7.2.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0) hasown: 2.0.1 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -6795,7 +6845,7 @@ snapshots: semver: 6.3.1 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 7.1.0(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/parser': 7.1.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -6806,7 +6856,7 @@ snapshots: lodash: 4.18.1 vscode-json-languageservice: 4.1.7 - eslint-plugin-jsx-a11y@6.8.0(eslint@8.57.0): + eslint-plugin-jsx-a11y@6.8.0(eslint@8.57.0(supports-color@7.2.0)): dependencies: '@babel/runtime': 7.26.10 aria-query: 5.3.0 @@ -6818,7 +6868,7 @@ snapshots: damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 es-iterator-helpers: 1.0.17 - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) hasown: 2.0.1 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -6826,27 +6876,27 @@ snapshots: object.entries: 1.1.7 object.fromentries: 2.0.7 - eslint-plugin-prettier@5.1.3(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.2.5): + eslint-plugin-prettier@5.1.3(eslint-config-prettier@9.1.0(eslint@8.57.0(supports-color@7.2.0)))(eslint@8.57.0(supports-color@7.2.0))(prettier@3.2.5): dependencies: - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) prettier: 3.2.5 prettier-linter-helpers: 1.0.0 synckit: 0.8.8 optionalDependencies: - eslint-config-prettier: 9.1.0(eslint@8.57.0) + eslint-config-prettier: 9.1.0(eslint@8.57.0(supports-color@7.2.0)) - eslint-plugin-react-hooks@4.6.0(eslint@8.57.0): + eslint-plugin-react-hooks@4.6.0(eslint@8.57.0(supports-color@7.2.0)): dependencies: - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) - eslint-plugin-react@7.33.2(eslint@8.57.0): + eslint-plugin-react@7.33.2(eslint@8.57.0(supports-color@7.2.0)): dependencies: array-includes: 3.1.7 array.prototype.flatmap: 1.3.2 array.prototype.tosorted: 1.1.3 doctrine: 2.1.0 es-iterator-helpers: 1.0.17 - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) estraverse: 5.3.0 jsx-ast-utils: 3.2.0 minimatch: 3.1.2 @@ -6868,20 +6918,20 @@ snapshots: eslint-visitor-keys@3.4.3: {} - eslint@8.57.0: + eslint@8.57.0(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0(supports-color@7.2.0)) '@eslint-community/regexpp': 4.10.0 - '@eslint/eslintrc': 2.1.4 + '@eslint/eslintrc': 2.1.4(supports-color@7.2.0) '@eslint/js': 8.57.0 - '@humanwhocodes/config-array': 0.11.14 + '@humanwhocodes/config-array': 0.11.14(supports-color@7.2.0) '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.2.0 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4 + debug: 4.3.4(supports-color@7.2.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -6997,7 +7047,7 @@ snapshots: focus-lock@1.3.3: dependencies: - tslib: 2.6.2 + tslib: 2.8.1 for-each@0.3.3: dependencies: @@ -7185,17 +7235,17 @@ snapshots: domutils: 2.4.2 entities: 2.1.0 - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: - agent-base: 7.1.0 - debug: 4.3.4 + agent-base: 7.1.0(supports-color@7.2.0) + debug: 4.3.4(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.4: + https-proxy-agent@7.0.4(supports-color@7.2.0): dependencies: - agent-base: 7.1.0 - debug: 4.3.4 + agent-base: 7.1.0(supports-color@7.2.0) + debug: 4.3.4(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -7272,7 +7322,7 @@ snapshots: is-boolean-object@1.1.0: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 is-buffer@2.0.5: {} @@ -7288,7 +7338,7 @@ snapshots: is-date-object@1.0.5: dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 is-decimal@1.0.4: {} @@ -7328,14 +7378,14 @@ snapshots: is-regex@1.1.4: dependencies: - call-bind: 1.0.2 - has-tostringtag: 1.0.0 + call-bind: 1.0.7 + has-tostringtag: 1.0.2 is-set@2.0.2: {} is-shared-array-buffer@1.0.2: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 is-stream@3.0.0: {} @@ -7367,8 +7417,8 @@ snapshots: is-weakset@2.0.2: dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.0 + call-bind: 1.0.7 + get-intrinsic: 1.2.4 is-what@3.14.1: {} @@ -7397,15 +7447,15 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@24.0.0: + jsdom@24.0.0(supports-color@7.2.0): dependencies: cssstyle: 4.0.1 data-urls: 5.0.0 decimal.js: 10.4.3 form-data: 4.0.0 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.4 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.4(supports-color@7.2.0) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.7 parse5: 7.1.2 @@ -7534,21 +7584,21 @@ snapshots: unist-util-is: 4.0.4 unist-util-visit-parents: 3.1.1 - mdast-util-from-markdown@0.8.4: + mdast-util-from-markdown@0.8.4(supports-color@7.2.0): dependencies: '@types/mdast': 3.0.3 mdast-util-to-string: 2.0.0 - micromark: 2.11.4 + micromark: 2.11.4(supports-color@7.2.0) parse-entities: 2.0.0 unist-util-stringify-position: 2.0.3 transitivePeerDependencies: - supports-color - mdast-util-gfm-autolink-literal@0.1.3: + mdast-util-gfm-autolink-literal@0.1.3(supports-color@7.2.0): dependencies: ccount: 1.1.0 mdast-util-find-and-replace: 1.1.1 - micromark: 2.11.4 + micromark: 2.11.4(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -7565,9 +7615,9 @@ snapshots: dependencies: mdast-util-to-markdown: 0.6.5 - mdast-util-gfm@0.1.2: + mdast-util-gfm@0.1.2(supports-color@7.2.0): dependencies: - mdast-util-gfm-autolink-literal: 0.1.3 + mdast-util-gfm-autolink-literal: 0.1.3(supports-color@7.2.0) mdast-util-gfm-strikethrough: 0.2.3 mdast-util-gfm-table: 0.1.6 mdast-util-gfm-task-list-item: 0.1.6 @@ -7597,46 +7647,46 @@ snapshots: merge2@1.4.1: {} - micromark-extension-gfm-autolink-literal@0.5.7: + micromark-extension-gfm-autolink-literal@0.5.7(supports-color@7.2.0): dependencies: - micromark: 2.11.4 + micromark: 2.11.4(supports-color@7.2.0) transitivePeerDependencies: - supports-color - micromark-extension-gfm-strikethrough@0.6.5: + micromark-extension-gfm-strikethrough@0.6.5(supports-color@7.2.0): dependencies: - micromark: 2.11.4 + micromark: 2.11.4(supports-color@7.2.0) transitivePeerDependencies: - supports-color - micromark-extension-gfm-table@0.4.3: + micromark-extension-gfm-table@0.4.3(supports-color@7.2.0): dependencies: - micromark: 2.11.4 + micromark: 2.11.4(supports-color@7.2.0) transitivePeerDependencies: - supports-color micromark-extension-gfm-tagfilter@0.3.0: {} - micromark-extension-gfm-task-list-item@0.3.3: + micromark-extension-gfm-task-list-item@0.3.3(supports-color@7.2.0): dependencies: - micromark: 2.11.4 + micromark: 2.11.4(supports-color@7.2.0) transitivePeerDependencies: - supports-color - micromark-extension-gfm@0.3.3: + micromark-extension-gfm@0.3.3(supports-color@7.2.0): dependencies: - micromark: 2.11.4 - micromark-extension-gfm-autolink-literal: 0.5.7 - micromark-extension-gfm-strikethrough: 0.6.5 - micromark-extension-gfm-table: 0.4.3 + micromark: 2.11.4(supports-color@7.2.0) + micromark-extension-gfm-autolink-literal: 0.5.7(supports-color@7.2.0) + micromark-extension-gfm-strikethrough: 0.6.5(supports-color@7.2.0) + micromark-extension-gfm-table: 0.4.3(supports-color@7.2.0) micromark-extension-gfm-tagfilter: 0.3.0 - micromark-extension-gfm-task-list-item: 0.3.3 + micromark-extension-gfm-task-list-item: 0.3.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - micromark@2.11.4: + micromark@2.11.4(supports-color@7.2.0): dependencies: - debug: 4.3.4 + debug: 4.3.4(supports-color@7.2.0) parse-entities: 2.0.0 transitivePeerDependencies: - supports-color @@ -7683,29 +7733,29 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.7: {} + nanoid@3.3.18: {} natural-compare@1.4.0: {} - next@15.5.19(@babel/core@7.23.9)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + next@15.5.21(@babel/core@7.23.9(supports-color@7.2.0))(babel-plugin-macros@3.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - '@next/env': 15.5.19 + '@next/env': 15.5.21 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001587 - postcss: 8.4.31 + postcss: 8.5.18 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.1.6(@babel/core@7.23.9)(react@18.2.0) + styled-jsx: 5.1.6(@babel/core@7.23.9(supports-color@7.2.0))(babel-plugin-macros@3.1.0)(react@18.2.0) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.19 - '@next/swc-darwin-x64': 15.5.19 - '@next/swc-linux-arm64-gnu': 15.5.19 - '@next/swc-linux-arm64-musl': 15.5.19 - '@next/swc-linux-x64-gnu': 15.5.19 - '@next/swc-linux-x64-musl': 15.5.19 - '@next/swc-win32-arm64-msvc': 15.5.19 - '@next/swc-win32-x64-msvc': 15.5.19 - sharp: 0.34.5 + '@next/swc-darwin-arm64': 15.5.21 + '@next/swc-darwin-x64': 15.5.21 + '@next/swc-linux-arm64-gnu': 15.5.21 + '@next/swc-linux-arm64-musl': 15.5.21 + '@next/swc-linux-x64-gnu': 15.5.21 + '@next/swc-linux-x64-musl': 15.5.21 + '@next/swc-win32-arm64-msvc': 15.5.21 + '@next/swc-win32-x64-msvc': 15.5.21 + sharp: 0.35.0 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -7828,7 +7878,7 @@ snapshots: parse-json@5.1.0: dependencies: - '@babel/code-frame': 7.23.5 + '@babel/code-frame': 7.27.1 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.1.6 @@ -7871,26 +7921,20 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss@8.4.31: + postcss@8.5.18: dependencies: - nanoid: 3.3.7 + nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.0.2 - - postcss@8.4.35: - dependencies: - nanoid: 3.3.7 - picocolors: 1.0.0 - source-map-js: 1.0.2 + source-map-js: 1.2.1 prelude-ls@1.2.1: {} - prettier-eslint@16.3.0: + prettier-eslint@16.3.0(supports-color@7.2.0): dependencies: - '@typescript-eslint/parser': 6.21.0(eslint@8.57.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.3.3) common-tags: 1.8.0 dlv: 1.1.3 - eslint: 8.57.0 + eslint: 8.57.0(supports-color@7.2.0) indent-string: 4.0.0 lodash.merge: 4.6.2 loglevel-colored-level-prefix: 1.0.0 @@ -7898,7 +7942,7 @@ snapshots: pretty-format: 29.7.0 require-relative: 0.8.7 typescript: 5.3.3 - vue-eslint-parser: 9.4.2(eslint@8.57.0) + vue-eslint-parser: 9.4.2(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -8006,7 +8050,7 @@ snapshots: react-is@18.2.0: {} - react-markdown@5.0.3(@types/node@20.11.20)(@types/react@18.2.60)(react@18.2.0): + react-markdown@5.0.3(@types/node@20.11.20)(@types/react@18.2.60)(react@18.2.0)(supports-color@7.2.0): dependencies: '@types/mdast': 3.0.3 '@types/react': 18.2.60 @@ -8016,7 +8060,7 @@ snapshots: prop-types: 15.7.2 react: 18.2.0 react-is: 16.13.1 - remark-parse: 9.0.0 + remark-parse: 9.0.0(supports-color@7.2.0) unified: 9.2.0 unist-util-visit: 2.0.3 xtend: 4.0.2 @@ -8030,7 +8074,7 @@ snapshots: dependencies: react: 18.2.0 react-style-singleton: 2.2.1(@types/react@18.2.60)(react@18.2.0) - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.60 @@ -8039,7 +8083,7 @@ snapshots: react: 18.2.0 react-remove-scroll-bar: 2.3.4(@types/react@18.2.60)(react@18.2.0) react-style-singleton: 2.2.1(@types/react@18.2.60)(react@18.2.0) - tslib: 2.6.2 + tslib: 2.8.1 use-callback-ref: 1.3.0(@types/react@18.2.60)(react@18.2.0) use-sidecar: 1.1.2(@types/react@18.2.60)(react@18.2.0) optionalDependencies: @@ -8079,7 +8123,7 @@ snapshots: get-nonce: 1.0.1 invariant: 2.2.4 react: 18.2.0 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.60 @@ -8145,16 +8189,16 @@ snapshots: es-errors: 1.3.0 set-function-name: 2.0.2 - remark-gfm@1.0.0: + remark-gfm@1.0.0(supports-color@7.2.0): dependencies: - mdast-util-gfm: 0.1.2 - micromark-extension-gfm: 0.3.3 + mdast-util-gfm: 0.1.2(supports-color@7.2.0) + micromark-extension-gfm: 0.3.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - remark-parse@9.0.0: + remark-parse@9.0.0(supports-color@7.2.0): dependencies: - mdast-util-from-markdown: 0.8.4 + mdast-util-from-markdown: 0.8.4(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -8267,36 +8311,37 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 - sharp@0.34.5: + sharp@0.35.0: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.4 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.0 + '@img/sharp-darwin-x64': 0.35.0 + '@img/sharp-freebsd-wasm32': 0.35.0 + '@img/sharp-libvips-darwin-arm64': 1.3.0 + '@img/sharp-libvips-darwin-x64': 1.3.0 + '@img/sharp-libvips-linux-arm': 1.3.0 + '@img/sharp-libvips-linux-arm64': 1.3.0 + '@img/sharp-libvips-linux-ppc64': 1.3.0 + '@img/sharp-libvips-linux-riscv64': 1.3.0 + '@img/sharp-libvips-linux-s390x': 1.3.0 + '@img/sharp-libvips-linux-x64': 1.3.0 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.0 + '@img/sharp-libvips-linuxmusl-x64': 1.3.0 + '@img/sharp-linux-arm': 0.35.0 + '@img/sharp-linux-arm64': 0.35.0 + '@img/sharp-linux-ppc64': 0.35.0 + '@img/sharp-linux-riscv64': 0.35.0 + '@img/sharp-linux-s390x': 0.35.0 + '@img/sharp-linux-x64': 0.35.0 + '@img/sharp-linuxmusl-arm64': 0.35.0 + '@img/sharp-linuxmusl-x64': 0.35.0 + '@img/sharp-webcontainers-wasm32': 0.35.0 + '@img/sharp-win32-arm64': 0.35.0 + '@img/sharp-win32-ia32': 0.35.0 + '@img/sharp-win32-x64': 0.35.0 optional: true shebang-command@2.0.0: @@ -8323,7 +8368,7 @@ snapshots: slash@3.0.0: {} - source-map-js@1.0.2: {} + source-map-js@1.2.1: {} source-map@0.5.7: {} @@ -8389,12 +8434,13 @@ snapshots: dependencies: js-tokens: 8.0.3 - styled-jsx@5.1.6(@babel/core@7.23.9)(react@18.2.0): + styled-jsx@5.1.6(@babel/core@7.23.9(supports-color@7.2.0))(babel-plugin-macros@3.1.0)(react@18.2.0): dependencies: client-only: 0.0.1 react: 18.2.0 optionalDependencies: - '@babel/core': 7.23.9 + '@babel/core': 7.23.9(supports-color@7.2.0) + babel-plugin-macros: 3.1.0 stylis@4.1.3: {} @@ -8581,7 +8627,7 @@ snapshots: use-callback-ref@1.3.0(@types/react@18.2.60)(react@18.2.0): dependencies: react: 18.2.0 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.60 @@ -8601,7 +8647,7 @@ snapshots: dependencies: detect-node-es: 1.1.0 react: 18.2.0 - tslib: 2.6.2 + tslib: 2.8.1 optionalDependencies: '@types/react': 18.2.60 @@ -8625,10 +8671,10 @@ snapshots: unist-util-stringify-position: 2.0.3 vfile-message: 2.0.4 - vite-node@1.3.1(@types/node@20.11.20): + vite-node@1.3.1(@types/node@20.11.20)(supports-color@7.2.0): dependencies: cac: 6.7.14 - debug: 4.3.4 + debug: 4.3.4(supports-color@7.2.0) pathe: 1.1.2 picocolors: 1.0.0 vite: 5.1.4(@types/node@20.11.20) @@ -8645,13 +8691,13 @@ snapshots: vite@5.1.4(@types/node@20.11.20): dependencies: esbuild: 0.19.12 - postcss: 8.4.35 + postcss: 8.5.18 rollup: 4.12.0 optionalDependencies: '@types/node': 20.11.20 fsevents: 2.3.3 - vitest@1.3.1(@types/node@20.11.20)(@vitest/ui@1.3.1)(jsdom@24.0.0): + vitest@1.3.1(@types/node@20.11.20)(@vitest/ui@1.3.1)(jsdom@24.0.0(supports-color@7.2.0))(supports-color@7.2.0): dependencies: '@vitest/expect': 1.3.1 '@vitest/runner': 1.3.1 @@ -8660,7 +8706,7 @@ snapshots: '@vitest/utils': 1.3.1 acorn-walk: 8.3.2 chai: 4.4.1 - debug: 4.3.4 + debug: 4.3.4(supports-color@7.2.0) execa: 8.0.1 local-pkg: 0.5.0 magic-string: 0.30.7 @@ -8671,12 +8717,12 @@ snapshots: tinybench: 2.6.0 tinypool: 0.8.2 vite: 5.1.4(@types/node@20.11.20) - vite-node: 1.3.1(@types/node@20.11.20) + vite-node: 1.3.1(@types/node@20.11.20)(supports-color@7.2.0) why-is-node-running: 2.2.2 optionalDependencies: '@types/node': 20.11.20 '@vitest/ui': 1.3.1(vitest@1.3.1) - jsdom: 24.0.0 + jsdom: 24.0.0(supports-color@7.2.0) transitivePeerDependencies: - less - lightningcss @@ -8702,10 +8748,10 @@ snapshots: vscode-uri@3.0.2: {} - vue-eslint-parser@9.4.2(eslint@8.57.0): + vue-eslint-parser@9.4.2(eslint@8.57.0(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - debug: 4.3.4 - eslint: 8.57.0 + debug: 4.3.4(supports-color@7.2.0) + eslint: 8.57.0(supports-color@7.2.0) eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 espree: 9.6.1 diff --git a/hyperglass/ui/pnpm-workspace.yaml b/hyperglass/ui/pnpm-workspace.yaml index 1aa9d9d30..f5289a433 100644 --- a/hyperglass/ui/pnpm-workspace.yaml +++ b/hyperglass/ui/pnpm-workspace.yaml @@ -13,5 +13,8 @@ overrides: ua-parser-js: '^0.7.36' '@babel/runtime': '7.26.10' '@babel/helpers': '7.26.10' + nanoid: '3.3.18' + postcss: '8.5.18' + sharp: '0.35.0' packages: - . diff --git a/hyperglass/ui/types/globals.d.ts b/hyperglass/ui/types/globals.d.ts index b96f98c9f..5d48a0e55 100644 --- a/hyperglass/ui/types/globals.d.ts +++ b/hyperglass/ui/types/globals.d.ts @@ -31,6 +31,9 @@ export declare global { peer_rid: string; rpki_state: RPKIState; filtered: boolean; + next_hop_asn?: string | null; + next_hop_org?: string | null; + next_hop_country?: string | null; }; type TracerouteHop = { @@ -65,7 +68,7 @@ export declare global { }; type RouteField = { [K in keyof Route]: Route[K] }; - + type TracerouteHopField = { [K in keyof TracerouteHop]: TracerouteHop[K] }; type StructuredResponse = { @@ -98,7 +101,7 @@ export declare global { level: ResponseLevel; timestamp: string; keywords: string[]; - output: string | StructuredResponse; + output: string | AllStructuredResponses; format: 'text/plain' | 'application/json'; }; diff --git a/hyperglass/ui/types/guards.ts b/hyperglass/ui/types/guards.ts index 227b7a87f..aabef251c 100644 --- a/hyperglass/ui/types/guards.ts +++ b/hyperglass/ui/types/guards.ts @@ -1,4 +1,4 @@ -import type { FormData, StringTableData, StringQueryResponse } from './data'; +import type { FormData, StringQueryResponse } from './data'; import type { DirectiveSelect, Directive, Link, Menu } from './config'; export function isString(a: unknown): a is string { @@ -15,16 +15,25 @@ export function isObject( return typeof obj === 'object' && obj !== null && !Array.isArray(obj); } -export function isStructuredOutput(data: unknown): data is StringTableData { - return isObject(data) && 'output' in data; +export function isStructuredOutput( + data: unknown, +): data is QueryResponse & { output: AllStructuredResponses } { + if (!isObject(data) || !('output' in data) || !isObject(data.output)) { + return false; + } + return 'routes' in data.output || 'hops' in data.output; } -export function isBGPStructuredOutput(data: unknown): data is StringTableData & { output: StructuredResponse } { - return isStructuredOutput(data) && isObject((data as StringTableData).output) && 'routes' in (data as StringTableData).output; +export function isBGPStructuredOutput( + data: unknown, +): data is QueryResponse & { output: StructuredResponse } { + return isStructuredOutput(data) && 'routes' in data.output; } -export function isTracerouteStructuredOutput(data: unknown): data is StringTableData & { output: TracerouteResult } { - return isStructuredOutput(data) && isObject((data as StringTableData).output) && 'hops' in (data as StringTableData).output; +export function isTracerouteStructuredOutput( + data: unknown, +): data is QueryResponse & { output: TracerouteResult } { + return isStructuredOutput(data) && 'hops' in data.output; } export function isStringOutput(data: unknown): data is StringQueryResponse { diff --git a/hyperglass/ui/types/table.ts b/hyperglass/ui/types/table.ts index 2687f7363..24fcb166d 100644 --- a/hyperglass/ui/types/table.ts +++ b/hyperglass/ui/types/table.ts @@ -1,8 +1,8 @@ import type { CellProps } from 'react-table'; -export interface TableColumn { +export interface TableColumn { Header: string; - accessor: keyof Route; + accessor: keyof T; align: string; hidden: boolean; } @@ -14,14 +14,10 @@ export interface TracerouteTableColumn { hidden: boolean; } -export type CellRenderProps = { - column: CellProps['column']; - row: CellProps['row']; - value: CellProps['value']; +export type CellRenderProps = { + column: CellProps['column']; + row: CellProps['row']; + value: CellProps['value']; }; -export type TracerouteCellRenderProps = { - column: CellProps['column']; - row: CellProps['row']; - value: CellProps['value']; -}; +export type TracerouteCellRenderProps = CellRenderProps; diff --git a/hyperglass/ui/util/common.ts b/hyperglass/ui/util/common.ts index 0bbec3bb8..76155676c 100644 --- a/hyperglass/ui/util/common.ts +++ b/hyperglass/ui/util/common.ts @@ -44,7 +44,7 @@ export async function fetchWithTimeout( * Lets set up our `AbortController`, and create a request options object that includes the * controller's `signal` to pass to `fetch`. */ - const { signal = new AbortController().signal, ...allOptions } = options; + const { signal = controller.signal, ...allOptions } = options; const config = { ...allOptions, signal }; /** * Set a timeout limit for the request using `setTimeout`. If the body of this timeout is diff --git a/pyproject.toml b/pyproject.toml index c13b4652c..4c3f36138 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "hyperglass" -version = "3.0.0" +version = "3.1.0" description = "hyperglass is the modern network looking glass that tries to make the internet better." authors = [ { name = "thatmattlove", email = "matt@hyperglass.dev" } ] dependencies = [ - "Pillow>=12.2.0", + "Pillow>=12.3.0", "PyJWT>=2.12.0", "PyYAML>=6.0.2", "aiofiles>=24.1.0", @@ -63,12 +63,12 @@ dev-dependencies = [ # (litestar / paramiko / httpx) with no conflicting upper bound, so a constraint # is enough to force the patched releases without otherwise reshaping the tree: # brotli CVE-2025-6176 (DoS via decompression bomb) -# cryptography CVE-2026-26007 (SECT-curve subgroup attack) — keep within 46.x +# cryptography CVE-2026-26007 (SECT-curve subgroup attack) # lxml CVE-2026-41066 (info disclosure via untrusted XML) # multipart CVE-2026-28356 (DoS via crafted multipart headers) constraint-dependencies = [ "brotli>=1.2.0", - "cryptography>=46.0.5,<47", + "cryptography>=50.0.0,<51", "lxml>=6.1.0", "multipart>=1.3.1", ] @@ -79,7 +79,7 @@ constraint-dependencies = [ # only Pillow consumer and its favicon generation is verified on Pillow 12.x. # - taskipy (dev only) caps psutil<7, but hyperglass pins psutil 7.1.0. override-dependencies = [ - "pillow>=12.2.0", + "pillow>=12.3.0", "psutil==7.1.0", ] diff --git a/requirements-dev.lock b/requirements-dev.lock index 7c53b691d..ce8abda7b 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -46,7 +46,7 @@ click==8.1.7 # via uvicorn colorama==0.4.6 # via taskipy -cryptography==46.0.7 +cryptography==50.0.0 # via -c (workspace) # via paramiko cssselect2==0.8.0 @@ -135,7 +135,7 @@ paramiko==4.0.0 pathspec==1.1.1 # via black pep8-naming==0.15.1 -pillow==12.2.0 +pillow==12.3.0 # via --override (workspace) # via favicons # via hyperglass diff --git a/requirements.lock b/requirements.lock index e3bb70014..51a431402 100644 --- a/requirements.lock +++ b/requirements.lock @@ -39,7 +39,7 @@ click==8.1.7 # via rich-click # via typer # via uvicorn -cryptography==46.0.7 +cryptography==50.0.0 # via -c (workspace) # via paramiko cssselect2==0.8.0 @@ -104,7 +104,7 @@ paramiko==4.0.0 # via hyperglass # via netmiko # via scp -pillow==12.2.0 +pillow==12.3.0 # via --override (workspace) # via favicons # via hyperglass diff --git a/version.py b/version.py index 29fea924c..bbd412d5c 100755 --- a/version.py +++ b/version.py @@ -18,7 +18,7 @@ CONSTANTS = Path(__file__).parent / "hyperglass" / "constants.py" CONSTANT_PATTERN = re.compile(r"^__version__\s\=\s\"(.+)\"$") -UPGRADE_DOC = Path(__file__).parent / "docs" / "pages" / "installation" / "upgrading.mdx" +UPGRADE_DOC = Path(__file__).parent / "docs" / "content" / "installation" / "upgrading.mdx" UPGRADE_DOC_PATTERN = re.compile(r"^git\scheckout\sv(.+)$") UPGRADE_GH_FEATURE = Path(__file__).parent / ".github" / "ISSUE_TEMPLATE" / "1-feature-request.yaml" From a2ad21b9b13a164e3b04f34b9fa95719e9c2c4bd Mon Sep 17 00:00:00 2001 From: WilhelmZA <82225237+WilhelmZA@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:01:50 +1000 Subject: [PATCH 25/39] ci: test and publish Docker releases (#7) * fix(security): patch runtime dependencies and image tooling * wip(docs): checkpoint Nextra 4 migration * fix(docs): patch Nextra layout schema * fix(ui): restore strict types and deterministic tests * release: prepare hyperglass 3.1.0 * ci: test and publish Docker releases * ci: create application directory before tests --- .github/workflows/backend.yml | 76 ---------------- .github/workflows/ci.yml | 156 +++++++++++++++++++++++++++++++++ .github/workflows/frontend.yml | 57 ------------ 3 files changed, 156 insertions(+), 133 deletions(-) delete mode 100644 .github/workflows/backend.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/frontend.yml diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml deleted file mode 100644 index 63e496596..000000000 --- a/.github/workflows/backend.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Backend Testing -on: - push: - paths: - - hyperglass/** - - .github/** - pull_request: - -jobs: - backend: - name: Backend Tests - strategy: - fail-fast: false - matrix: - node-version: [20.x] - pnpm-version: [9] - redis-version: [latest] - python-version: ["3.11", "3.12"] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} - - steps: - - name: Git Checkout - uses: actions/checkout@v3 - - - name: Install system pachages - run: | - sudo apt-get update - sudo apt-get install -y libcairo2-dev pkg-config python3-dev - - - name: Install Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Rye - uses: sksat/setup-rye@v0.23.1 - - - name: Install Node - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Install PNPM - uses: pnpm/action-setup@v3 - with: - version: ${{ matrix.pnpm-version }} - - - name: Start Redis - uses: supercharge/redis-github-action@1.7.0 - with: - redis-version: ${{ matrix.redis-version }} - - - name: Prepare - run: | - mkdir -p "$HOME/hyperglass" - echo "HYPERGLASS_APP_PATH=$HOME/hyperglass" >> $GITHUB_ENV - echo "HYPERGLASS_HOST=127.0.0.1" >> $GITHUB_ENV - echo "HYPERGLASS_PORT=8001" >> $GITHUB_ENV - - - name: Install - run: rye sync - - - name: Activate virtualenv - run: | - . .venv/bin/activate - echo PATH=$PATH >> $GITHUB_ENV - - - name: Lint (Rye) - run: rye lint - - - name: Tests (PyTest) - run: pytest hyperglass --ignore hyperglass/plugins/external - - - name: Run hyperglass - run: ".tests/ga-backend-app.sh" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..a8f7f6e71 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,156 @@ +name: CI and Docker Release + +on: + push: + branches: + - main + tags: + - "v*.*.*" + pull_request: + workflow_dispatch: + inputs: + publish: + description: Publish the image to Docker Hub + required: true + default: false + type: boolean + version: + description: Image version when publishing manually, without the leading v + required: false + type: string + +permissions: + contents: read + +env: + DOCKERHUB_REPOSITORY: wilhelmza/hyperglass + +jobs: + backend: + name: Backend tests + runs-on: ubuntu-latest + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + HYPERGLASS_APP_PATH: ${{ github.workspace }}/.hyperglass + HYPERGLASS_HOST: 127.0.0.1 + HYPERGLASS_PORT: 8001 + HYPERGLASS_REDIS_HOST: 127.0.0.1 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcairo2-dev pkg-config python3-dev + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Install Python dependencies + run: uv pip install --system -r requirements-dev.lock + + - name: Prepare application directory + run: mkdir -p "$HYPERGLASS_APP_PATH" + + - name: Test Python + run: pytest hyperglass --ignore hyperglass/plugins/external + + frontend: + name: Frontend tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: hyperglass/ui + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.23.0 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Create empty hyperglass.json + run: printf '{}\n' > hyperglass.json + + - name: Check formatting + run: pnpm run format:check + + - name: Lint TypeScript + run: pnpm run lint + + - name: Check TypeScript types + run: pnpm run typecheck + + - name: Test TypeScript + run: pnpm run test + + docker: + name: Build and publish Docker image + needs: + - backend + - frontend + if: ${{ always() && needs.backend.result == 'success' && needs.frontend.result == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Validate manual release version + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + run: test -n "${{ inputs.version }}" + + - name: Log in to Docker Hub + if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish) }} + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: docker.io/${{ env.DOCKERHUB_REPOSITORY }} + tags: | + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' && inputs.publish && inputs.version != '' }} + type=raw,value=latest + + - name: Build and publish Docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + push: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish) }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml deleted file mode 100644 index e97859f86..000000000 --- a/.github/workflows/frontend.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Frontend Testing -on: - push: - paths: - - hyperglass/ui/** - - .github/** - pull_request: - -jobs: - frontend: - name: Frontend Tests - strategy: - fail-fast: false - matrix: - node-version: [20.x, 21.x, 22.x] - pnpm-version: [9, 10] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} - env: - working-directory: ./hyperglass/ui - steps: - - name: Git Checkout - uses: actions/checkout@v3 - - - name: Install Node - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Install PNPM - uses: pnpm/action-setup@v3 - with: - version: ${{ matrix.pnpm-version }} - - - name: Install Dependencies - working-directory: ${{ env.working-directory }} - run: pnpm install - - - name: Create empty hyperglass.json - working-directory: ${{ env.working-directory }} - run: echo '{}' > hyperglass.json - - - name: Formatting - working-directory: ${{ env.working-directory }} - run: pnpm run format:check - - - name: Lint - working-directory: ${{ env.working-directory }} - run: pnpm run lint - - - name: Check Types - working-directory: ${{ env.working-directory }} - run: pnpm run typecheck - - - name: Tests - working-directory: ${{ env.working-directory }} - run: pnpm run test From fdda4581eba725b1fd3811259e20bf05bff968ca Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt Date: Wed, 26 Aug 2026 11:00:53 +1000 Subject: [PATCH 26/39] Rebrand project as Ultraglass --- .github/ISSUE_TEMPLATE/1-feature-request.yaml | 8 +- .github/ISSUE_TEMPLATE/2-bug-report.yaml | 18 +- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 24 +- CODE_OF_CONDUCT.md | 37 +- CONTRIBUTING.md | 59 ++- README.md | 113 +++--- docs/app/layout.tsx | 25 +- docs/content/_meta.js | 5 - docs/content/configuration/config.mdx | 12 +- .../content/configuration/config/api-docs.mdx | 4 +- docs/content/configuration/config/caching.mdx | 2 +- .../configuration/config/complete-config.mdx | 14 +- docs/content/configuration/config/logging.mdx | 18 +- .../content/configuration/config/messages.mdx | 10 +- .../config/structured-output.mdx | 12 +- docs/content/configuration/config/web-ui.mdx | 28 +- docs/content/configuration/devices.mdx | 2 +- .../configuration/devices/credentials.mdx | 2 +- .../configuration/devices/http-device.mdx | 6 +- .../configuration/devices/ssh-proxy.mdx | 4 +- .../examples/add-your-own-command.mdx | 2 +- .../examples/basic-configuration.mdx | 12 +- .../examples/customize-the-ui.mdx | 2 +- .../configuration/examples/quick-start.mdx | 8 +- docs/content/configuration/overview.mdx | 6 +- docs/content/index.mdx | 45 ++- docs/content/installation.mdx | 14 +- docs/content/installation/_meta.js | 2 +- docs/content/installation/docker.mdx | 22 +- .../installation/environment-variables.mdx | 14 +- docs/content/installation/manual.mdx | 28 +- docs/content/installation/reverse-proxy.mdx | 6 +- docs/content/installation/upgrading.mdx | 4 +- docs/content/platforms.mdx | 6 +- docs/content/plugins.mdx | 6 +- docs/content/user-guide.mdx | 369 ++---------------- docs/package.json | 6 +- docs/public/hyperglass-dark.svg | 21 - docs/public/hyperglass-icon-dark.svg | 7 - docs/public/hyperglass-icon-light.svg | 7 - docs/public/hyperglass-light.svg | 21 - docs/public/hyperglass.svg | 21 - docs/public/opengraph.jpg | Bin 46154 -> 34804 bytes docs/public/robots.txt | 1 - docs/public/ultraglass-dark.svg | 12 + docs/public/ultraglass-icon.svg | 12 + docs/public/ultraglass-light.svg | 12 + hyperglass/__init__.py | 4 +- hyperglass/cli/installer.py | 4 +- hyperglass/cli/main.py | 38 +- hyperglass/constants.py | 2 +- hyperglass/defaults/_strings.py | 12 +- hyperglass/images/hyperglass-dark.svg | 21 - hyperglass/images/hyperglass-icon.svg | 7 - hyperglass/images/hyperglass-light.svg | 21 - hyperglass/images/hyperglass-opengraph.jpg | Bin 46154 -> 0 bytes hyperglass/images/ultraglass-dark.svg | 12 + hyperglass/images/ultraglass-icon.svg | 12 + hyperglass/images/ultraglass-light.svg | 12 + hyperglass/images/ultraglass-opengraph.jpg | Bin 0 -> 34804 bytes hyperglass/images/ultraglass-opengraph.svg | 15 + hyperglass/models/api/response.py | 4 +- hyperglass/models/config/opengraph.py | 2 +- hyperglass/models/config/params.py | 14 +- hyperglass/models/config/web.py | 8 +- hyperglass/models/webhook.py | 4 +- hyperglass/ui/components/header/logo.tsx | 4 +- hyperglass/ui/package.json | 2 +- hyperglass/util/support_bundle.py | 161 ++++++++ hyperglass/util/system_info.py | 4 +- hyperglass/util/tests/test_support_bundle.py | 68 ++++ pyproject.toml | 4 +- 74 files changed, 729 insertions(+), 779 deletions(-) delete mode 100644 docs/public/hyperglass-dark.svg delete mode 100644 docs/public/hyperglass-icon-dark.svg delete mode 100644 docs/public/hyperglass-icon-light.svg delete mode 100644 docs/public/hyperglass-light.svg delete mode 100644 docs/public/hyperglass.svg create mode 100644 docs/public/ultraglass-dark.svg create mode 100644 docs/public/ultraglass-icon.svg create mode 100644 docs/public/ultraglass-light.svg delete mode 100644 hyperglass/images/hyperglass-dark.svg delete mode 100644 hyperglass/images/hyperglass-icon.svg delete mode 100644 hyperglass/images/hyperglass-light.svg delete mode 100644 hyperglass/images/hyperglass-opengraph.jpg create mode 100644 hyperglass/images/ultraglass-dark.svg create mode 100644 hyperglass/images/ultraglass-icon.svg create mode 100644 hyperglass/images/ultraglass-light.svg create mode 100644 hyperglass/images/ultraglass-opengraph.jpg create mode 100644 hyperglass/images/ultraglass-opengraph.svg create mode 100644 hyperglass/util/support_bundle.py create mode 100644 hyperglass/util/tests/test_support_bundle.py diff --git a/.github/ISSUE_TEMPLATE/1-feature-request.yaml b/.github/ISSUE_TEMPLATE/1-feature-request.yaml index 0639c689b..5d08da94c 100644 --- a/.github/ISSUE_TEMPLATE/1-feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/1-feature-request.yaml @@ -1,5 +1,5 @@ name: Feature Request -description: Suggest an idea for hyperglass +description: Suggest an idea for Ultraglass labels: - feature body: @@ -7,15 +7,15 @@ body: attributes: value: > If the answer to any of these questions is "no", your feature request will most likely be rejected (but may still be considered). - - Is the new feature _only_ applicable to one [platform](https://hyperglass.dev/platforms)? + - Is the new feature _only_ applicable to one [platform](https://github.com/WilhelmZA/ultraglass/tree/main/docs/content/platforms.mdx)? - Would the new feature work only on mobile, or only on desktop? - Would the new feature only support IPv4, or IPv6? - - Is the new feature something that can be reasonably customized by hyperglass users? + - Is the new feature something that can be reasonably customised by Ultraglass users? - type: input id: version attributes: label: Version - description: What version of hyperglass are you currently running? + description: What version of Ultraglass are you currently running? placeholder: v3.1.0 validations: required: true diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.yaml b/.github/ISSUE_TEMPLATE/2-bug-report.yaml index 4d9650d97..9346b5735 100644 --- a/.github/ISSUE_TEMPLATE/2-bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/2-bug-report.yaml @@ -9,7 +9,7 @@ body: required: true attributes: label: Deployment Type - description: How are you running hyperglass? + description: How are you running Ultraglass? multiple: false options: - Docker @@ -19,7 +19,7 @@ body: id: version attributes: label: Version - description: What version of hyperglass are you currently running? + description: What version of Ultraglass are you currently running? placeholder: v3.1.0 validations: required: true @@ -32,7 +32,7 @@ body: description: > Describe in detail the exact steps one can take to reproduce this bug. If reporting a UI bug, be sure to include screenshots, browser version, and operating system and platform. - If you've deployed hyperglass manually, be sure to include Python and NodeJS versions. + If you've deployed Ultraglass manually, be sure to include Python and NodeJS versions. placeholder: | 1. Click the thing 2. Type the stuff @@ -57,21 +57,21 @@ body: id: configuration attributes: label: Configuration - description: hyperglass [config](https://hyperglass.dev/configuration/config) file in YAML format. + description: Ultraglass [config](https://github.com/WilhelmZA/ultraglass/tree/main/docs/content/configuration/config) file in YAML format. placeholder: | - org_name: Beloved Hyperglass User + org_name: Beloved Ultraglass User plugins: [] primary_asn: 65000 request_timeout: 90 - site_description: Beloved Hyperglass User Network Looking Glass - site_title: Beloved Hyperglass User + site_description: Beloved Ultraglass User Network Looking Glass + site_title: Ultraglass render: yaml - type: textarea id: devices attributes: label: Devices description: > - hyperglass [devices](https://hyperglass.dev/configuration/devices) file in YAML format + Ultraglass [devices](https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/configuration/devices.mdx) file in YAML format **with passwords obfuscated or removed**. placeholder: | devices: @@ -92,5 +92,5 @@ body: id: logs attributes: label: Logs - description: Include any relevant log messages related to the bug. + description: Run `hyperglass support-bundle` on the Ultraglass host and attach the resulting JSON file. The bundle is sanitized locally and is not uploaded automatically. You may also include relevant log messages, but remove credentials, tokens, private addresses, and device output first. render: console diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3b753305b..33a2b33bd 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Contributing Policy - url: https://github.com/thatmattlove/hyperglass/blob/main/CONTRIBUTING.md + url: https://github.com/WilhelmZA/ultraglass/blob/main/CONTRIBUTING.md about: Please read through the contributing policy before opening an issue or pull request. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8f7f6e71..abe19ed69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ permissions: contents: read env: - DOCKERHUB_REPOSITORY: wilhelmza/hyperglass + DOCKERHUB_REPOSITORY: wilhelmza/ultraglass jobs: backend: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bbe3eda0..cee0718d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -This is the changelog for [WilhelmZA/hyperglass](https://github.com/WilhelmZA/hyperglass), a **fork** of [thatmattlove/hyperglass](https://github.com/thatmattlove/hyperglass). It documents the fork's changes, not upstream's. +This is the changelog for [WilhelmZA/ultraglass](https://github.com/WilhelmZA/ultraglass), a **fork** of [thatmattlove/hyperglass](https://github.com/thatmattlove/hyperglass). It documents Ultraglass changes, not upstream's. The fork's own version line starts at **3.0.0** and is currently at **3.1.0**. It is based on **upstream hyperglass 2.0.4**, plus the upstream commits that had landed on upstream `main` after 2.0.4 but were never released by upstream. Upstream's version numbering is unrelated to this one; upstream's own history continues at [thatmattlove/hyperglass/blob/main/CHANGELOG.md](https://github.com/thatmattlove/hyperglass/blob/main/CHANGELOG.md). @@ -178,7 +178,7 @@ _v2.0.0 is a major release of hyperglass. Many things have changed, and it is li ### Added -- Commands are now defined as [directives](https://hyperglass.dev/configuration/directives), which is a configuration definition of one or more commands to run on a device. A directive defines: +- Commands are now defined as [directives](https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/configuration/directives.mdx), which is a configuration definition of one or more commands to run on a device. A directive defines: - What command (or commands) to run on the device - Type of UI field, text input or select - If the field can accept multiple values @@ -186,12 +186,12 @@ _v2.0.0 is a major release of hyperglass. Many things have changed, and it is li - Validation rules - hyperglass now supports Docker, and using Docker is the default and recommended method for deployment. - The list of locations (devices) is displayed as a gallery when the number of devices is 5 or less. This is a default value and is configurable. -- hyperglass now supports custom [input or output plugins](https://hyperglass.dev/plugins). +- hyperglass now supports custom [input or output plugins](https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/plugins.mdx). - Input Plugins: Apply custom validation logic or transform user input before the query is sent to a device. - Output Plugins: Interact with the output from a device before it's displayed to the user. - [#206](https://github.com/thatmattlove/hyperglass/issues/206): OpenBGPD is natively supported by hyperglass. - [#176](https://github.com/thatmattlove/hyperglass/issues/176): Custom javascript or HTML can be injected into the web page (for tracking applications such as Google Analytics). -- [#173](https://github.com/thatmattlove/hyperglass/issues/173): Any output, such as BGP Communities, can be highlighted in the UI by defining [highlight patterns](https://hyperglass.dev/configuration/config/web-ui#highlighting). +- [#173](https://github.com/thatmattlove/hyperglass/issues/173): Any output, such as BGP Communities, can be highlighted in the UI by defining [highlight patterns](https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/configuration/config/web-ui.mdx#highlighting). - [#155](https://github.com/thatmattlove/hyperglass/issues/155): A user can now use the "My IP" button to insert their own IP into the query target field. - [#143](https://github.com/thatmattlove/hyperglass/issues/143): Any HTTP endpoint may be configured as device from which to collect output. @@ -243,7 +243,7 @@ _1.0.3 is a cosmetic release to factor in code-level changes related to the repo ## 1.0.0-beta.82 - 2021-04-22 ### BREAKING CHANGE -**NodeJS 14.15 or later is required**. See [the docs](https://hyperglass.dev/docs/getting-started) for installation instructions. +**NodeJS 14.15 or later is required**. See the [installation documentation](https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/installation/manual.mdx) for installation instructions. ### Fixed - [#135](https://github.com/thatmattlove/hyperglass/issues/135): Fix an issue where Juniper indirect next-hops were empty. @@ -313,7 +313,7 @@ Moving forward, the `name` field is only used to define the name of the VRF **as **NOTICE**: *[hyperglass-agent](https://github.com/thatmattlove/hyperglass-agent) will be deprecated soon. Use `frr_ssh` or `bird_ssh` for SSH connectivity in the meantime.* ### Added -- FRR & BIRD may now be accessed via standard SSH using the `frr_ssh` and `bird_ssh` NOS. [See the docs](https://hyperglass.dev/docs/platforms#caveats) for important caveats. +- FRR & BIRD may now be accessed via standard SSH using the `frr_ssh` and `bird_ssh` NOS. See the [platform documentation](https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/platforms.mdx) for important caveats. ### Changed - `port` in `devices.yaml` now defaults to 22 if not specified. @@ -423,7 +423,7 @@ Moving forward, the `name` field is only used to define the name of the VRF **as ### Added -- [#87](https://github.com/thatmattlove/hyperglass/issues/87): [TNSR] Support. To add a TNSR device, use the `tnsr` [NOS key](https://hyperglass.dev/docs/adding-devices#all-device-parameters). +- [#87](https://github.com/thatmattlove/hyperglass/issues/87): [TNSR] Support. To add a TNSR device, use the `tnsr` platform configuration described in the [platform documentation](https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/platforms.mdx). ### Fixed @@ -447,7 +447,7 @@ When hyperglass starts up, it will check to see if `~/hyperglass` or `/etc/hyper ### Added -- [#81](https://github.com/thatmattlove/hyperglass/issues/81): Add support for SSH key authentication. See [the docs](https://hyperglass.dev/docs/adding-devices#credential) for more details. +- [#81](https://github.com/thatmattlove/hyperglass/issues/81): Add support for SSH key authentication. See the [credential documentation](https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/configuration/devices/credentials.mdx) for more details. ### 1.0.0-beta.60 - 2020-10-10 @@ -629,7 +629,7 @@ $ hyperglass-agent send-certificate ### Added -- New NOS: **VyOS**. [See docs for important caveats](https://hyperglass.dev/docs/commands). +- New NOS: **VyOS**. See the [platform documentation](https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/platforms.mdx) for important caveats. ### Fixed @@ -642,13 +642,13 @@ $ hyperglass-agent send-certificate ### Added - Opengraph images are now automatically generated in the correct format from any valid image file. -- Better color mode toggle icons (they now match [hyperglass.dev](https://hyperglass.dev)). +- Better colour mode toggle icons. ### Changed - Improved SEO & Accessibility for UI. - Default traceroute help link now points to new docs site. -- Slightly different default black & white colors (they now match [hyperglass.dev](https://hyperglass.dev)). +- Slightly different default black and white colours. - Various docs site improvements ### Fixed @@ -699,4 +699,4 @@ $ hyperglass-agent send-certificate ### Changed -- **BREAKING CHANGE**: The `logo` section now requires the full path for logo files. See [the docs](https://hyperglass.dev/docs/ui/logo) for details. +- **BREAKING CHANGE**: The `logo` section now requires the full path for logo files. See the [web UI configuration documentation](https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/configuration/config/web-ui.mdx) for details. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 158c9d142..9b04344f8 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,32 +1,21 @@ -# hyperglass Code of Conduct +# Ultraglass Code of Conduct -Like the technical community as a whole, the hyperglass community is made up of a mixture of professionals and volunteers from all over the world, working on every aspect of the project. +Ultraglass is a technical project and its community includes maintainers, contributors, network operators, and people learning how network services work. Everyone should be able to participate without harassment or personal attacks. -Diversity is a core value of the hyperglass community, but it can also lead to communication issues. To that end, we have a few ground rules that we ask people to adhere to. This code applies equally to founders, contributors, and those seeking help and guidance. +## Expected behaviour -This isn’t an exhaustive list of things that you can’t do. Rather, take it in the spirit in which it’s intended — a guide to make it easier to enrich all of us and the technical communities in which we participate. +- Be respectful when discussing code, infrastructure, documentation, and people. +- Assume good intent, ask for clarification, and explain disagreements with evidence. +- Keep feedback specific and useful. Critique the change rather than the person. +- Protect private information and do not publish credentials, personal data, or private network details. +- Follow the project licence and respect the work of upstream contributors. -This code of conduct applies to all spaces managed by the hyperglass community. This includes mailing lists, the issue tracker, Telegram groups, Gitter communities, and any other forums created by the project team which the community uses for communication. In addition, violations of this code outside these spaces may affect a person's ability to participate within them. +## Unacceptable behaviour -If you believe someone is violating the code of conduct, we ask that you report it by emailing [matt@hyperglass.dev](mailto:matt@hyperglass.dev). +Harassment, threats, discrimination, sexualised conduct, deliberate intimidation, doxxing, personal attacks, and publishing private information without permission are not acceptable in project spaces. -**Be friendly and patient.** Be welcoming. We strive to be a community that welcomes and supports people of all backgrounds and identities. This includes, but is not limited to members of any race, ethnicity, culture, national origin, color, immigration status, social and economic class, educational level, sex, sexual orientation, gender identity and expression, age, size, family status, political belief, religion, and mental and physical ability. +## Reporting a problem -**Be considerate.** Your work will be used by other people, and you in turn will depend on the work of others. Any decision you take will affect users and colleagues, and you should take those consequences into account when making decisions. Remember that we're a world-wide community, so you might not be communicating in someone else's primary language. +Open an issue using the project's reporting tools, or contact a maintainer privately through the contact details published on the repository profile. Do not include sensitive information in a public issue. Reports will be handled as discreetly as the circumstances allow. -**Be respectful.** Not all of us will agree all the time, but disagreement is no excuse for poor behavior and poor manners. We might all experience some frustration now and then, but we cannot allow that frustration to turn into a personal attack. It’s important to remember that a community where people feel uncomfortable or threatened is not a productive one. Members of the hyperglass community should be respectful when dealing with other members as well as with people outside the hyperglass community. - -**Be careful in the words that you choose.** We are a community of professionals, and we conduct ourselves professionally. Be kind to others. Do not insult or put down other participants. Harassment and other exclusionary behavior aren't acceptable. This includes, but is not limited to: - -- Violent threats or language directed against another person. -- Discriminatory jokes and language. -- Posting sexually explicit or violent material. -- Posting (or threatening to post) other people's personally identifying information ("doxing"). -- Personal insults, especially those using racist or sexist terms. -- Unwelcome sexual attention. -- Advocating for, or encouraging, any of the above behavior. -- Repeated harassment of others. In general, if someone asks you to stop, then stop. - -**When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and this project is no exception. Disagreements and differing views must be resolved constructively. Remember that we’re different. The strength of hyperglass comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes. - -Original text courtesy of the [Django project](https://www.djangoproject.com/conduct/). +This code applies to the repository, issue tracker, pull requests, documentation, and project discussions. Maintainers may remove content or restrict participation when necessary to keep these spaces safe and useful. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e7d8fc12..cdf07a501 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,41 @@ -hyperglass is primarily maintained by me, [Matt Love](https://github.com/thatmattlove). This was my first ever open source application, and as such, it's kind of my "baby". When I first started writing hyperglass, I knew _nothing_ about development, Python, JavaScript/TypeScript, or GitHub. I was a network engineer trying to solve a problem and learn a few things while I was at it. - -Because I've been solo-maintaining and building hyperglass since around April 2019, I've become pretty particular about things that might seem trivial to someone just trying to help out. While I welcome development contributions, please don't be offended if pull requests are denied, if I request things to be done a certain way, or if I integrate something similar to your changes separately from your PR. To help understand why, here are some of the development design goals for hyperglass: - -- **Pristine code quality** - - [Black](https://github.com/python/black) formatting for Python. - - Strict adherence to ESLint/Prettier configs for frontend code. - - _ZERO_ linting errors. - - Linting exceptions only used when there is _no other way_, and should be accompanied with comments about why there is no other way. -- **No hard-coding** - - Anything visible to the end-user _must_ be customizable by the administrator. If it's not, or can't be, leave code or PR comments as to why. - - This includes things like timeouts, error messages, etc. -- **Mobile & Accessible** - - All UI element must be available on both desktop and mobile devices. - - UI must achieve a 100 Lighthouse/PageInsights score for accessibility. -- **IPv6 Support** - - Any new device support must include IPv6 commands. - - All frontend and backend code must support IPv6, both for running the application and processing queries. +# Contributing to Ultraglass + +Ultraglass is maintained as a fork of hyperglass. Contributions are welcome when they improve the application without making existing deployments harder to operate or breaking the compatibility namespace used by the Python package and CLI. + +## Before opening a pull request + +- Read the relevant documentation and tests before changing behaviour. +- Explain the user-visible problem and the approach taken to solve it. +- Add or update tests for behaviour that can be tested automatically. +- Update the documentation when configuration, installation, or user-visible behaviour changes. +- Remove credentials, tokens, private keys, and private network details from examples and issue reports. + +## Development standards + +- Format Python with Black and keep imports ordered with isort. +- Keep Ruff and the frontend lint, formatting, type, and test checks clean. +- Keep text visible to administrators and users configurable unless there is a clear reason not to. +- Keep the UI usable on desktop and mobile and preserve accessible labels and controls. +- Include IPv6 handling when adding or changing network device support. +- Keep device queries non-blocking and avoid introducing work on the event loop that can wait on a network device. +- Keep public documentation factual and include the exact commands needed to reproduce an installation or bug. + +## Local checks + +The backend tests require Redis on `localhost:6379`: + +```shell +pytest hyperglass --ignore hyperglass/plugins/external +``` + +The UI checks run from `hyperglass/ui`: + +```shell +pnpm install --frozen-lockfile +pnpm run format:check +pnpm run lint +pnpm run typecheck +pnpm run test +``` + +The internal Python package and CLI remain named `hyperglass`. Do not rename imports, environment variables, configuration paths, service names, or CLI commands as part of a documentation or public-brand change unless the change explicitly includes a migration plan. diff --git a/README.md b/README.md index b88ac840b..dcae26359 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,68 @@ -
-
- -
-

The network looking glass that tries to make the internet better.

-
- A looking glass is implemented by network operators as a way of providing customers, peers, or the general public with a way to easily view elements of, or run tests from the provider's network. -
+![Ultraglass logo](hyperglass/images/ultraglass-light.svg) -
+Ultraglass is a network looking glass for operators who want to publish useful visibility into their network without giving users direct access to network devices. -
+Operators can expose BGP route, BGP community, BGP AS path, ping, and traceroute queries through a web interface and REST API. Queries run from configured network devices, and the administrator controls the commands, targets, credentials, text, theme, and access rules. -[**Documentation**](https://hyperglass.dev)   |   [**Live Demo**](https://demo.hyperglass.dev/) +Ultraglass is a maintained fork of [hyperglass](https://github.com/thatmattlove/hyperglass). The application keeps the `hyperglass` Python package and command names for compatibility with existing deployments, while the public product, repository, documentation, and container image use the Ultraglass name. -[![Frontend Tests](https://img.shields.io/github/actions/workflow/status/thatmattlove/hyperglass/frontend.yml?label=Frontend%20Tests&style=for-the-badge)](https://github.com/thatmattlove/hyperglass/actions/workflows/frontend.yml) -[![Backend Tests](https://img.shields.io/github/actions/workflow/status/thatmattlove/hyperglass/backend.yml?label=Backend%20Tests&style=for-the-badge)](https://github.com/thatmattlove/hyperglass/actions/workflows/backend.yml) +## What it does -
+- Runs network diagnostics from selected routers or locations. +- Supports BGP route, BGP community, BGP AS path, ping, and traceroute queries. +- Provides structured BGP and traceroute output where the device platform supports it. +- Enriches routes and traceroute hops with ASN, organisation, country, IXP, and reverse DNS data when enabled. +- Supports IPv4 and IPv6, VRFs, SSH proxy servers, HTTP devices, and concurrent queries. +- Applies prefix-list and access-list style controls to query targets. +- Provides a responsive web UI and configurable OpenAPI documentation. +- Lets administrators customise the theme, logo, menus, labels, messages, directives, and error handling. -hyperglass is intended to make implementing a looking glass too easy not to do, with the lofty goal of improving the internet community at large by making looking glasses more common across autonomous systems of any size. +## Supported platforms -
+Ultraglass includes built-in support for Arista EOS, BIRD, Cisco IOS, Cisco NX-OS, Cisco IOS-XR, FRRouting, Huawei VRP, Juniper Junos, MikroTik, Nokia SR OS, OpenBGPD, TNSR, and VyOS. Netmiko provides the underlying device connectivity, so other supported platforms can be configured when their commands and output match the required query types. -### [Changelog](https://hyperglass.dev/changelog) +Structured BGP route output is available for Arista EOS, FRRouting, Huawei VRP, Juniper Junos, and MikroTik RouterOS. Structured traceroute output is available for FRRouting, Huawei VRP, and MikroTik RouterOS or SwitchOS. -## Features +## Quick start -- BGP Route, BGP Community, BGP AS Path, Ping, & Traceroute, or [add your own commands](https://hyperglass.dev/configuration/directives). -- **Structured data output** with rich metadata for supported platforms -- **Enhanced traceroute** with ASN information, organization names, country codes, and IXP detection -- Full IPv6 support -- Customizable everything: features, theme, UI/API text, error messages, commands -- Built-in support for: - - Arista EOS - - BIRD - - Cisco IOS - - Cisco NX-OS - - Cisco IOS-XR - - FRRouting - - Huawei VRP - - Juniper Junos - - Mikrotik - - Nokia SR OS - - OpenBGPD - - TNSR - - VyOS -- **Structured BGP Route support** for: Arista EOS, FRRouting, Huawei VRP, Juniper Junos, MikroTik RouterOS -- **Structured Traceroute support** for: Arista EOS, FRRouting, Huawei VRP, Juniper Junos, MikroTik RouterOS/SwitchOS -- Configurable support for any other [supported platform](https://hyperglass.dev/platforms) -- Optionally access devices via an SSH proxy/jump server -- Access-list/prefix-list style query control to whitelist or blacklist query targets -- REST API with automatic, configurable OpenAPI documentation -- Modern, responsive UI built on [ReactJS](https://reactjs.org/), with [NextJS](https://nextjs.org/) & [Chakra UI](https://chakra-ui.com/), written in [TypeScript](https://www.typescriptlang.org/) -- **AS path visualization** with interactive flow charts showing organization names -- **Real-time IP enrichment** using BGP.tools for maximum accuracy and up-to-date data -- Query multiple devices simultaneously -- **Concurrent processing** with non-blocking operations for improved performance -- Browser-based DNS-over-HTTPS resolution of FQDN queries +The repository includes a sample configuration and a fake device so you can verify the application before connecting it to production routers. -*To request support for a specific platform, please [submit a Github Issue](https://github.com/thatmattlove/hyperglass/issues/new) with the **feature** label.* +### Docker -### [Get Started →](https://hyperglass.dev/installation) +```shell +git clone https://github.com/WilhelmZA/ultraglass.git --depth=1 +cd ultraglass +mkdir -p /etc/hyperglass +cp .samples/sample_devices.yaml /etc/hyperglass/devices.yaml +docker compose up +``` -## Community +Open `http://localhost:8001` after the containers start. The sample device returns fake output and does not connect to a network device. -- [Slack](https://netdev.chat/) -- [Telegram](https://t.me/hyperglasslg) +### Manual installation -Any users, potential users, or contributors of hyperglass are welcome to join and discuss usage, feature requests, bugs, and other things. +Manual installation requires Python 3.13 or later, Node.js 22 or later, pnpm 11, and Redis 7. Install the project into a virtual environment, create `/etc/hyperglass`, copy the sample device file, and start the application with `hyperglass start`. The full procedure is in the [manual installation guide](docs/content/installation/manual.mdx). -**hyperglass is developed with the express intention of being free to the networking community**. +## Documentation -*However, if you're feeling particularly helpful or generous, small donations are welcome.* +- [Ultraglass Wiki](https://github.com/WilhelmZA/ultraglass/wiki) +- [Documentation home](docs/content/index.mdx) +- [Installation](docs/content/installation.mdx) +- [User guide](docs/content/user-guide.mdx) +- [Configuration](docs/content/configuration/overview.mdx) +- [Supported platforms](docs/content/platforms.mdx) +- [Plugins](docs/content/plugins.mdx) +- [Changelog](CHANGELOG.md) +- [Clear BSD license](LICENSE) -[![Donate](https://img.shields.io/badge/Donate-blue.svg?logo=paypal&style=for-the-badge)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=ZQFH3BB2B5M3E&source=url) +## Reporting bugs -## Acknowledgements +Use the [bug report form](https://github.com/WilhelmZA/ultraglass/issues/new/choose) and include the Ultraglass version, deployment method, steps to reproduce, expected behaviour, observed behaviour, relevant configuration with secrets removed, and a local `hyperglass support-bundle` output. The bundle is never submitted automatically. Review it before attaching it and do not include device passwords, private keys, tokens, or unredacted internal addresses. + +## Contributing -hyperglass is built entirely on open-source software. Here are some of the awesome libraries used, check them out too! +Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Changes should keep the backend and UI checks passing, preserve IPv4 and IPv6 support, and avoid hard-coding text or deployment-specific values that administrators should be able to configure. -- [Netmiko](https://github.com/ktbyers/netmiko) -- [Litestar](https://litestar.dev) -- [Pydantic](https://docs.pydantic.dev/latest/) -- [Chakra UI](https://chakra-ui.com/) -- [React Flow](https://reactflow.dev/) - AS path visualization -- [BGP.tools](https://bgp.tools/) - Real-time IP enrichment and IXP data +## Acknowledgements -[![GitHub](https://img.shields.io/github/license/thatmattlove/hyperglass?color=330036&style=for-the-badge)](https://github.com/thatmattlove/hyperglass/blob/main/LICENSE) +Ultraglass is distributed under the [Clear BSD License](LICENSE) and retains the required upstream attribution. It is built with [Netmiko](https://github.com/ktbyers/netmiko), [Litestar](https://litestar.dev), [Pydantic](https://docs.pydantic.dev/latest/), [React](https://react.dev/), [Next.js](https://nextjs.org/), [Chakra UI](https://chakra-ui.com/), [React Flow](https://reactflow.dev/), and [BGP.tools](https://bgp.tools/). diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx index dcfeaf7f1..4a9964296 100644 --- a/docs/app/layout.tsx +++ b/docs/app/layout.tsx @@ -1,22 +1,20 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; -import { Layout } from "nextra-theme-docs"; +import { Layout, Navbar } from "nextra-theme-docs"; import { getPageMap } from "nextra/page-map"; import "nextra-theme-docs/style.css"; const noIndexFollow = process.env.CF_PAGES_BRANCH !== "main"; export const metadata: Metadata = { - metadataBase: new URL("https://hyperglass.dev"), title: { - default: "hyperglass Documentation", - template: "%s | hyperglass", + default: "Ultraglass Documentation", + template: "%s | Ultraglass", }, - description: "hyperglass Documentation", + description: "Documentation for Ultraglass, a network looking glass for operators.", openGraph: { type: "website", - siteName: "hyperglass", - images: ["/opengraph.jpg"], + siteName: "Ultraglass", }, robots: { index: !noIndexFollow, @@ -33,7 +31,18 @@ export default async function RootLayout({ children }: RootLayoutProps) { + + Ultraglass + + } + projectLink="https://github.com/WilhelmZA/ultraglass" + /> + } pageMap={pageMap} > {children} diff --git a/docs/content/_meta.js b/docs/content/_meta.js index 0233a7c8e..6607062af 100644 --- a/docs/content/_meta.js +++ b/docs/content/_meta.js @@ -8,11 +8,6 @@ export default { configuration: "Configuration", platforms: "Platforms", plugins: "Plugins", - demo: { - title: "Demo", - type: "page", - href: "https://demo.hyperglass.dev", - }, documentation: { title: "Documentation", type: "menu", diff --git a/docs/content/configuration/config.mdx b/docs/content/configuration/config.mdx index 85262afe3..0e2aa5e9e 100644 --- a/docs/content/configuration/config.mdx +++ b/docs/content/configuration/config.mdx @@ -1,6 +1,6 @@ --- title: Configuration File -description: hyperglass config.yaml file reference +description: Ultraglass config.yaml file reference --- The `config.yaml` file is broken into multiple sections: @@ -10,7 +10,7 @@ The `config.yaml` file is broken into multiple sections: | Parameter | Type | Default Value | Description | | :----------------- | :-------------- | :------------------------------- | :------------------------------------------------------------ | | `org_name` | String | Beloved Hyperglass User | Your organization's name. | -| `plugins` | List of Strings | | List of hyperglass [plugins](/plugins) to load. | +| `plugins` | List of Strings | | List of Ultraglass [plugins](/plugins) to load. | | `primary_asn` | String | 65000 | Your organization's primary ASN. Used to set default UI text. | | `request_timeout` | Number | 90 | Global timeout in seconds for all requests. | | `site_description` | String | `org_name` Network Looking Glass | `` description, also used in the API documentation. | @@ -31,14 +31,14 @@ site_title: Beloved Hyperglass User | Parameter | Docs | Description | | :----------- | :--------------------------------------------------------------------- | :--------------------------------------------------------------- | -| `cache` | [Caching Docs](/configuration/config/caching.mdx) | Customize how hyperglass caches responses. | +| `cache` | [Caching Docs](/configuration/config/caching.mdx) | Customise how Ultraglass caches responses. | | `logging` | [Logging Docs](/configuration/config/logging.mdx) | Customize file logging, syslog, webhooks, etc. | | `messages` | [Messages Docs](/configuration/config/messages.mdx) | Customize messages shown to users. | -| `structured` | [Structured Output Docs](/configuration/config/structured-ouptput.mdx) | Customize how hyperglass handles structured output from devices. | -| `web` | [Web UI Docs](/configuration/config/web-ui.mdx) | Customize the look and feel of hyperglass's web UI. | +| `structured` | [Structured Output Docs](/configuration/config/structured-output.mdx) | Customise how Ultraglass handles structured output from devices. | +| `web` | [Web UI Docs](/configuration/config/web-ui.mdx) | Customise the look and feel of Ultraglass's web UI. | ## Caveats ### Arista EOS -For whatever reason, the tested version of Arista EOS does not supply certain details about routes in its JSON output when running commands `show ip bgp regex ` or `show ip bgp community `. Specifically, the the route's timestamp and any attached communities are not supplied. When these commands are used with Arista EOS, hyperglass sets the timestamp to the current time, and the community to an empty list. +The tested Arista EOS release does not supply certain route details in JSON output for `show ip bgp regex ` or `show ip bgp community `. The timestamp and attached communities are absent, so Ultraglass uses the current time and an empty community list for those responses. diff --git a/docs/content/configuration/config/api-docs.mdx b/docs/content/configuration/config/api-docs.mdx index a88dd4398..4503b4455 100644 --- a/docs/content/configuration/config/api-docs.mdx +++ b/docs/content/configuration/config/api-docs.mdx @@ -1,10 +1,10 @@ ## API Docs -hyperglass automatically generates documentation for the REST API. The `docs` section allows users to customize the look, feel, and text used for the REST API documentation. +Ultraglass automatically generates documentation for the REST API. The `docs` section allows administrators to customise the look, feel, and text used for that documentation. | Parameter | Type | Default Value | Description | | :----------------- | :------ | :----------------------------- | :---------------------------------------------------------------------------------------------- | -| `docs.base_url` | String | https://lg.example.com | Used for REST API samples. See the [demo](https://demo.hyperglass.dev/api/docs) for an example. | +| `docs.base_url` | String | https://lg.example.com | Used for REST API samples. Open `/api/docs` on your deployment to view the generated documentation. | | `docs.enable` | Boolean | `true` | Enable or disable the REST API documentation. | | `docs.path` | String | /api/docs | Path to the REST API documentation. | | `docs.title` | String | `site_title` API Documentation | API docs title. Uses the `site_title` parameter from the [global](#global) parameters. | diff --git a/docs/content/configuration/config/caching.mdx b/docs/content/configuration/config/caching.mdx index 839b8e1cf..6a23e5f52 100644 --- a/docs/content/configuration/config/caching.mdx +++ b/docs/content/configuration/config/caching.mdx @@ -1,6 +1,6 @@ ## Cache -hyperglass automatically caches responses to reduce the number of times devices are queried for the same information. +Ultraglass automatically caches responses to reduce the number of times devices are queried for the same information. | Parameter | Type | Default Value | Description | | :---------------- | :------ | :------------ | :------------------------------------------------------------------------------ | diff --git a/docs/content/configuration/config/complete-config.mdx b/docs/content/configuration/config/complete-config.mdx index 9406b1061..f6680ebfe 100644 --- a/docs/content/configuration/config/complete-config.mdx +++ b/docs/content/configuration/config/complete-config.mdx @@ -8,7 +8,7 @@ import { DocsButton } from '~/components/docs-button' # Complete Configuration Reference -This is a comprehensive reference for all available configuration options in hyperglass. The configuration is written in YAML format and saved as `config.yaml` in your hyperglass configuration directory. +This is a comprehensive reference for all available configuration options in Ultraglass. The configuration is written in YAML format and saved as `config.yaml` in your hyperglass configuration directory. ## Top-Level Parameters @@ -18,7 +18,7 @@ These parameters are configured at the root level of your `config.yaml` file. | Parameter | Type | Default | Description | | :-------- | :--- | :------ | :---------- | -| `site_title` | String | `hyperglass` | The name of your hyperglass site. Used in HTML `` tags and API documentation. | +| `site_title` | String | `Ultraglass` | The name of your Ultraglass site. Used in HTML `<title>` tags and API documentation. | | `org_name` | String | `Beloved Hyperglass User` | Your organization's name. Used in UI, API documentation, and HTML meta tags for SEO. | | `site_description` | String | `{org_name} Network Looking Glass` | Short description of your site. Used for HTML meta description tag. `{org_name}` is automatically replaced. | | `primary_asn` | String/Integer | `65001` | Your network's primary ASN. Used for defaults like subtitle and PeeringDB URL. | @@ -55,7 +55,7 @@ plugins: ## Cache Configuration -Controls how hyperglass caches query responses to improve performance. +Controls how Ultraglass caches query responses to improve performance. | Parameter | Type | Default | Description | | :-------- | :--- | :------ | :---------- | @@ -244,7 +244,7 @@ messages: ## Structured Output Configuration -Controls how hyperglass processes and displays structured data from network devices. +Controls how Ultraglass processes and displays structured data from network devices. ### BGP Communities @@ -297,7 +297,7 @@ Extensive customization options for the web interface appearance and behavior. Controls hostname resolution in the browser using DNS-over-HTTPS. <Callout type="info"> -When users enter hostnames (like `example.com`) instead of IP addresses, hyperglass uses DNS-over-HTTPS to resolve them to IP addresses directly in the user's browser. This provides privacy and security benefits over traditional DNS queries. +When users enter hostnames such as `example.com` instead of IP addresses, Ultraglass uses DNS-over-HTTPS to resolve them to IP addresses directly in the user's browser. This avoids sending the lookup through the application server. </Callout> | Parameter | Type | Default | Description | @@ -374,7 +374,7 @@ Customize almost all text displayed in the user interface. | Parameter | Type | Default | Description | | :-------- | :--- | :------ | :---------- | -| `web.text.title` | String | `hyperglass` | Main title text. | +| `web.text.title` | String | `Ultraglass` | Main title text. | | `web.text.subtitle` | String | `Network Looking Glass` | Subtitle text. | | `web.text.query_location` | String | `Location` | Label for location/router selection field. | | `web.text.query_type` | String | `Query Type` | Label for query type selection field. | @@ -649,7 +649,7 @@ web: | Parameter | Type | Default | Description | | :-------- | :--- | :------ | :---------- | -| `web.credit.enable` | Boolean | `true` | Show hyperglass developer credit in footer. | +| `web.credit.enable` | Boolean | `true` | Show the Ultraglass and upstream credit in the footer. | <Callout emoji="❤️" type="info"> **Note from the Developer** diff --git a/docs/content/configuration/config/logging.mdx b/docs/content/configuration/config/logging.mdx index 5f1e83477..4f4db0990 100644 --- a/docs/content/configuration/config/logging.mdx +++ b/docs/content/configuration/config/logging.mdx @@ -69,7 +69,7 @@ logging: password: super secret password ``` -In the above example, hyperglass will send a `POST` request to `https://httpbin.org?source=hyperglass` with Basic Authentication headers set. +In the example above, Ultraglass sends a `POST` request to `https://httpbin.org?source=hyperglass` with Basic Authentication headers set. The `source` value is an example identifier and can be changed for your deployment. ##### Microsoft Teams Webhook @@ -88,3 +88,19 @@ logging: provider: slack host: <Slack webhook URL> ``` + +### Local Support Bundles + +Use `hyperglass support-bundle` on the Ultraglass host to create a sanitized JSON file for manual attachment to a bug report. The command reads local log files only, makes no network request, and does not add a frontend endpoint or browser feature for reading source-system information. + +The bundle includes Ultraglass version, basic runtime information, recent error-level log records, and failure metadata from `logging.samples`. It excludes query targets, device names, raw device output, parsed results, credentials, tokens, and filesystem paths by default. Review the file before attaching it. + +```shell filename="terminal" copy +hyperglass support-bundle --output ultraglass-support-bundle.json +``` + +If logs are stored outside `/tmp` or the application directory, provide the directory explicitly: + +```shell filename="terminal" copy +hyperglass support-bundle --log-directory /var/log/ultraglass --output ultraglass-support-bundle.json +``` diff --git a/docs/content/configuration/config/messages.mdx b/docs/content/configuration/config/messages.mdx index 135a788b9..5683636bb 100644 --- a/docs/content/configuration/config/messages.mdx +++ b/docs/content/configuration/config/messages.mdx @@ -1,16 +1,16 @@ ## Message Customization -hyperglass provides as much control over user-facing text/messages as possible. The following messages may be adjusted as needed: +Ultraglass keeps user-facing messages configurable so each deployment can describe its own service. The following messages may be adjusted as needed: | Parameter | Type | Default Value | Description | | :------------------------------ | :----- | :------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `messages.authentication_error` | String | Authentication error occurred. | Displayed when hyperglass is unable to authenticate to a device. Usually, this indicates a configuration error. | -| `messages.connection_error` | String | Error connecting to \{device_name\}: \{error\} | Displayed when hyperglass is unable to connect to a device. Usually, this indicates a configuration error. `{device_name}` and `{error}` will be used to display the device in question and the specific connection error. | -| `messages.general` | String | Something went wrong. | Displayed when errors occur that hyperglass didn't anticipate or handle correctly. Seeing this error message may indicate a bug in hyperglass. If you see this in the wild, try enabling [debug mode](#global) and review the logs to pinpoint the source of the error. | +| `messages.authentication_error` | String | Authentication error occurred. | Displayed when Ultraglass is unable to authenticate to a device. Usually, this indicates a configuration error. | +| `messages.connection_error` | String | Error connecting to \{device_name\}: \{error\} | Displayed when Ultraglass is unable to connect to a device. Usually, this indicates a configuration error. `{device_name}` and `{error}` identify the device and the connection error. | +| `messages.general` | String | Something went wrong. | Displayed when an error occurs that Ultraglass did not anticipate or handle correctly. If this appears in production, enable [debug mode](#global) and review the logs before opening a bug report. | | `messages.invalid_input` | String | \{target\} is not valid. | Displayed when a query target's value is invalid in relation to the corresponding query type. `{target}` will be used to display the invalid target. | | `messages.invalid_query` | String | \{target\} is not a valid \{query_type\} target. | Displayed when a query target's value is invalid in relation to the corresponding query type. `{target}` and `{query_type}` may be used to display the invalid target and corresponding query type. | | `messages.no_input` | String | \{field\} must be specified. | Displayed when a required field is not specified. `{field}` will be used to display the name of the field that was omitted. | -| `messages.no_output` | String | The query completed, but no matching results were found. | Displayed when hyperglass can connect to a device and execute a query, but the response is empty. | +| `messages.no_output` | String | The query completed, but no matching results were found. | Displayed when Ultraglass can connect to a device and execute a query, but the response is empty. | | `messages.not_found` | String | \{type\} '\{name\}' not found. | Displayed when an object property does not exist in the configuration. `{type}` corresponds to a user-friendly name of the object type (for example, 'Device'), `{name}` corresponds to the object name that was not found. | | `messages.request_timeout` | String | Request timed out. | Displayed when the [`request_timeout`](#global) time expires. | | `messages.target_not_allowed` | String | \{target\} is not allowed. | Displayed when a query target is implicitly denied by a configured rule. `{target}` will be used to display the denied query target. | diff --git a/docs/content/configuration/config/structured-output.mdx b/docs/content/configuration/config/structured-output.mdx index c49a513b9..7755713c5 100644 --- a/docs/content/configuration/config/structured-output.mdx +++ b/docs/content/configuration/config/structured-output.mdx @@ -2,7 +2,7 @@ import { Callout } from "nextra/components"; ## Structured -Devices that support responding to a query with structured or easily parsable data can have their response data placed into an easier to read table (or JSON, when using the REST API). Currently, the following platforms have structured data supported in hyperglass: +Devices that return structured or easily parsed data can display their response in a table, or as JSON through the REST API. The following platforms currently have structured data support in Ultraglass: - Arista EOS - FRRouting @@ -10,18 +10,18 @@ Devices that support responding to a query with structured or easily parsable da - Juniper Junos - Mikrotik RouterOS/SwitchOS -When structured output is available, hyperglass checks the RPKI state of each BGP prefix returned using one of two methods: +When structured output is available, Ultraglass can check the RPKI state of each returned BGP prefix using one of two methods: 1. **From the router's perspective** - Uses the RPKI validation state as determined by the router itself 2. **From an external RPKI validation service** - Queries an external service to determine RPKI state independently -For external validation, hyperglass supports two backends: +For external validation, Ultraglass supports two backends: - **Cloudflare**: Uses [Cloudflare's public RPKI service](https://rpki.cloudflare.com/) via GraphQL API - **Routinator**: Connects to your own [Routinator](https://github.com/NLnetLabs/routinator) RPKI validator instance -Additionally, hyperglass provides the ability to control which BGP communities are shown to the end user. +Ultraglass can also control which BGP communities are shown to the end user. -For devices with structured traceroute support (FRRouting, Huawei VRP, and MikroTik RouterOS), hyperglass can enhance the output with IP enrichment data including ASN information, organization names, country codes, and IXP detection using real-time lookups from BGP.tools. +For devices with structured traceroute support, Ultraglass can add ASN, organisation, country, reverse DNS, and IXP details using lookups from BGP.tools. | Parameter | Type | Default Value | Description | | :-------------------------------- | :-------------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------- | @@ -69,7 +69,7 @@ structured: <Callout type="info" emoji="ℹ️"> **Routinator URL Format** - The `rpki_server_url` should be the base URL of your Routinator HTTP web API endpoint. This is typically different from the RTR port (3323). The URL should not include the `/validity` path as hyperglass will append this automatically. + The `rpki_server_url` should be the base URL of your Routinator HTTP web API endpoint. This is typically different from the RTR port (3323). Do not include the `/validity` path because Ultraglass appends it automatically. </Callout> ### Community Filtering Examples diff --git a/docs/content/configuration/config/web-ui.mdx b/docs/content/configuration/config/web-ui.mdx index 636f0d6a1..21fb72a39 100644 --- a/docs/content/configuration/config/web-ui.mdx +++ b/docs/content/configuration/config/web-ui.mdx @@ -3,7 +3,7 @@ import { Color } from "~/components/color"; ## Web UI -hyperglass provides extensive customization options for the look and feel of the web UI. +Ultraglass provides extensive customisation options for the look and feel of the web UI. | Parameter | Type | Default Value | Description | | :-------------------------- | :----- | :------------ | :----------------------------------------------------------------------------------------------------------------------------------------- | @@ -48,7 +48,7 @@ The greeting is an optional modal/popup window that will present itself to users - A data privacy acknowledgement that must be acknowledged prior to proceeding - Information about your company -- Instructions on how to use hyperglass +- Instructions on how to use Ultraglass | Parameter | Type | Default Value | Description | | :---------------------- | :------ | :------------ | :--------------------------------------------------------------------------------------------------- | @@ -72,7 +72,7 @@ web: ### OpenGraph -[OpenGraph](https://ogp.me/) is the thing that generates the pretty pictures, titles, and descriptions for links when you post them to sites/tools such as Facebook, Twitter, Slack, etc. By default, [this Opengraph image](/opengraph.jpg) is used, but you can provide your own image. You don't need to worry about sizing or formatting it properly, hyperglass will handle this for you. +[OpenGraph](https://ogp.me/) controls the image, title, and description shown when you share a link. Ultraglass uses [this OpenGraph image](/opengraph.jpg) by default, but you can provide your own image. | Parameter | Type | Default Value | Description | | :-------------------- | :----- | :------------ | :---------------------------- | @@ -80,7 +80,7 @@ web: ### Highlighting -hyperglass can highlight special values in your router output and provide users with additional information about the content. For example, your organization's BGP communities or IP address space can be visually highlighted, and a tooltip can be shown when a user hovers over the highlighted value. +Ultraglass can highlight special values in router output and provide users with additional context. For example, an organisation's BGP communities or IP address space can be highlighted with a tooltip. Each value you wish to be highlighted is defined with the following schema: @@ -105,7 +105,7 @@ web: ### Menus -hyperglass can show completely-customizable menus in the footer. Each menu can be configured with the following schema: +Ultraglass can show customisable menus in the footer. Each menu can be configured with the following schema: | Parameter | Type | Default Value | Description | | :-------- | :----- | :------------ | :------------------------------------------------------------------------------------------------------------------------- | @@ -133,7 +133,7 @@ web: ### Links -hyperglass can show customizable links to anything you think your users might find helpful. Each link can be configured with the following schema: +Ultraglass can show links to resources that users may find helpful. Each link can be configured with the following schema: | Parameter | Type | Default Value | Description | | :---------- | :------ | :------------ | :-------------------------------------------------------------------------------------------------------------------- | @@ -164,7 +164,7 @@ web: | Parameter | Type | Default | Description | | :-------- | :-----: | :-----: | :--------------------------------------------------------------------------------------- | -| `enable` | Boolean | `true` | Enable or disable the display of developer credit & link to hyperglass GitHub repository | +| `enable` | Boolean | `true` | Enable or disable the display of Ultraglass and upstream credit in the footer. | <Callout emoji="❤️" type="info"> **Note from the Developer** @@ -176,12 +176,12 @@ web: ### Text -Most of the text in the hyperglass UI can be overridden to suit your needs. +Most of the text in the Ultraglass UI can be overridden to suit your needs. | Parameter | Type | Default Value | Description | | :--------------------------- | :----- | :------------------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `web.text.title_mode` | String | logo_only | See [title mode section](#title-mode) | -| `web.text.title` | String | hyperglass | Title text. | +| `web.text.title` | String | Ultraglass | Title text. | | `web.text.subtitle` | String | Network Looking Glass | Subtitle text. | | `web.text.query_location` | String | Location | Text used to label the query location (router) field. | | `web.text.query_type` | String | Query Type | Text used to label the query type (directive) field. | @@ -198,7 +198,7 @@ Most of the text in the hyperglass UI can be overridden to suit your needs. | `web.text.rpki_unknown` | String | No ROAs Exist | Text used in table output when a route's RPKI status is unknown. | | `web.text.rpki_unverified` | String | Not Verified | Text used in table output when a route's RPKI status is unverified. | | `web.text.no_communities` | String | No Communities | Text used in table output when a route has no communities. | -| `web.text.ip_error` | String | Unable to determine IP Address | Error displayed if hyperglass is unable to determine the user's IP. | +| `web.text.ip_error` | String | Unable to determine IP Address | Error displayed if Ultraglass is unable to determine the user's IP. | | `web.text.no_ip` | String | No \{protocol\} Address | Text displayed if the user doesn't have an IP address of \{protocol\} (IPv4 or IPv6). | | `web.text.ip_select` | String | Select an IP Address | Text used to label the IP Address selection for the user's IP. | | `web.text.ip_button` | String | My IP | Text used for the user IP button. | @@ -226,11 +226,11 @@ web: ### Theme -hyperglass allows you to customize the colors and fonts used in the Web UI. +Ultraglass allows you to customise the colours and fonts used in the web UI. | Parameter | Type | Default Value | Description | | :----------------------------- | :----- | :------------ | :--------------------------------------------------------------------------------------------------------------- | -| `web.theme.default_color_mode` | String | | Set hyperglass's default color mode. By default, the user's system preference is used. Must be `light` or `dark` | +| `web.theme.default_color_mode` | String | | Set Ultraglass's default colour mode. By default, the user's system preference is used. Must be `light` or `dark` | #### Colors @@ -267,7 +267,7 @@ hyperglass allows you to customize the colors and fonts used in the Web UI. ###### Example -To override hyperglass's primary color, it's recommended to override its mapped intrinsic color. For example, to override the default primary color to <Color hex="#00ffff" noText /> cyan: +To override Ultraglass's primary colour, override its mapped intrinsic colour. For example, to override the default primary colour to <Color hex="#00ffff" noText /> cyan: ```yaml filename="config.yaml" web: @@ -278,7 +278,7 @@ web: #### Fonts -hyperglass's fonts are loaded from [Google Fonts](https://fonts.google.com/). Any Google font name may be specified to override the default fonts. +Ultraglass's fonts are loaded from [Google Fonts](https://fonts.google.com/). Any Google font name may be specified to override the default fonts. | Parameter | Type | Default Value | Description | | :--------------------- | :----- | :------------ | :--------------------------------------------------------------------------------------- | diff --git a/docs/content/configuration/devices.mdx b/docs/content/configuration/devices.mdx index f30799a95..8600dbaa0 100644 --- a/docs/content/configuration/devices.mdx +++ b/docs/content/configuration/devices.mdx @@ -26,7 +26,7 @@ Each configured device may have the following parameters: <Callout type="tip"> -hyperglass ships with predefined [directives](/configuration/directives.mdx) (commands) for the following [platforms](platforms.mdx): +Ultraglass ships with predefined [directives](/configuration/directives.mdx) (commands) for the following [platforms](platforms.mdx): <SupportedPlatforms /> diff --git a/docs/content/configuration/devices/credentials.mdx b/docs/content/configuration/devices/credentials.mdx index 463ff68f3..16091b715 100644 --- a/docs/content/configuration/devices/credentials.mdx +++ b/docs/content/configuration/devices/credentials.mdx @@ -1,4 +1,4 @@ -Each device must be configured with credentials with which hyperglass can log into the device and execute commands. +Each device must be configured with credentials that Ultraglass can use to log into the device and execute commands. ## Credential Configuration diff --git a/docs/content/configuration/devices/http-device.mdx b/docs/content/configuration/devices/http-device.mdx index ce14abe59..6dbc1adb8 100644 --- a/docs/content/configuration/devices/http-device.mdx +++ b/docs/content/configuration/devices/http-device.mdx @@ -1,10 +1,10 @@ -hyperglass supports collecting output from a generic HTTP endpoint. +Ultraglass can collect output from a generic HTTP endpoint. ## HTTP Configuration | Parameter | Type | Default Value | Description | | :---------------------- | :------ | :------------ | :--------------------------------------------------------------------------------------------------------------------- | -| `http.attribute_map` | Mapping | | Mapping/dict of hyperglass query fields as keys, and hyperglass query field replacements as values. | +| `http.attribute_map` | Mapping | | Mapping of Ultraglass query fields to the replacements sent to the HTTP endpoint. | | `http.basic_auth` | Mapping | | If basic authentication is required, provide a mapping/dict containing the basic authentication username and password. | | `http.body_format` | String | json | Body format, options are `json` `yaml` `xml` `text` | | `http.follow_redirects` | Boolean | `false` | Follow HTTP redirects from server. | @@ -39,7 +39,7 @@ devices: password: your password ``` -Given the following hyperglass query: +Given the following Ultraglass query: | Field | Value | | :------------- | :------------------ | diff --git a/docs/content/configuration/devices/ssh-proxy.mdx b/docs/content/configuration/devices/ssh-proxy.mdx index 99d19f1ec..d91feabeb 100644 --- a/docs/content/configuration/devices/ssh-proxy.mdx +++ b/docs/content/configuration/devices/ssh-proxy.mdx @@ -1,4 +1,4 @@ -In cases where access to the devices is secured behind a "jump box" or other intermediary server/device, hyperglass can use SSH local port forwarding to SSH to an intermedary device first, and then to the device. +When devices sit behind a jump box or other intermediary server, Ultraglass can use SSH local port forwarding to connect to the intermediary first and then to the device. ## SSH Proxy Configuration @@ -27,4 +27,4 @@ devices: password: your proxy password ``` -In the above example, the credentials `your proxy username`/`your proxy password` will be used to authenticate from the hyperglass server to the SSH proxy, and the credentials `you`/`your password` will be used to authentiate from the SSH proxy to the device. +In the example above, the proxy credentials authenticate the Ultraglass server to the SSH proxy. The device credentials then authenticate the proxy connection to the target device. diff --git a/docs/content/configuration/examples/add-your-own-command.mdx b/docs/content/configuration/examples/add-your-own-command.mdx index a8f588ae8..dd563c341 100644 --- a/docs/content/configuration/examples/add-your-own-command.mdx +++ b/docs/content/configuration/examples/add-your-own-command.mdx @@ -2,7 +2,7 @@ import { Steps } from "nextra/components"; ## Add Your Own Command -While hyperglass does come with several built-in [directives](/configuration/configuration/directives.mdx) (commands), you can also add your own. For example, say you want to add a command that shows the BGP summary from a device: +Ultraglass includes several built-in [directives](/configuration/directives.mdx), and you can add your own. For example, you might add a command that shows the BGP summary from a device: <Steps> diff --git a/docs/content/configuration/examples/basic-configuration.mdx b/docs/content/configuration/examples/basic-configuration.mdx index c7e64c504..796d63f6a 100644 --- a/docs/content/configuration/examples/basic-configuration.mdx +++ b/docs/content/configuration/examples/basic-configuration.mdx @@ -1,15 +1,15 @@ --- title: Basic Configuration -description: Get started with a basic hyperglass configuration +description: Get started with a basic Ultraglass configuration --- import { Callout } from "nextra/components"; -To get started, hyperglass only needs to know about your devices. +To get started, Ultraglass only needs to know about your devices. <Callout type="info"> **Devices** are your routers, switches, or whatever else you want to call the endpoints - hyperglass will query for information. + Ultraglass will query for information. </Callout> ## Simple Device Configuration @@ -17,10 +17,10 @@ To get started, hyperglass only needs to know about your devices. ```yaml filename="devices.yaml" devices: - name: NYC Router 1 - address: <IP address hyperglass will use to log into the router> + address: <IP address Ultraglass will use to log into the router> credential: - username: <Username hyperglass will use to log into the router> - password: <Password hyperglass will use to log into the router> + username: <Username Ultraglass will use to log into the router> + password: <Password Ultraglass will use to log into the router> platform: cisco_ios attrs: source4: <IPv4 address from which to source ping & traceroute commands> diff --git a/docs/content/configuration/examples/customize-the-ui.mdx b/docs/content/configuration/examples/customize-the-ui.mdx index 24e564abb..79f66836a 100644 --- a/docs/content/configuration/examples/customize-the-ui.mdx +++ b/docs/content/configuration/examples/customize-the-ui.mdx @@ -1,5 +1,5 @@ --- -description: Customize hyperglass to fit your needs. +description: Customise Ultraglass to fit your needs. --- import { DocsButton } from "~/components/docs-button"; diff --git a/docs/content/configuration/examples/quick-start.mdx b/docs/content/configuration/examples/quick-start.mdx index 8506e7e9b..a0474f01f 100644 --- a/docs/content/configuration/examples/quick-start.mdx +++ b/docs/content/configuration/examples/quick-start.mdx @@ -7,7 +7,7 @@ import { Callout } from 'nextra/components' # Quick Start Configuration Examples -Here are the most common configuration patterns to get you started with hyperglass quickly. For complete documentation, see the [Complete Configuration Reference](/configuration/config/complete-config). +Here are common configuration patterns to get you started with Ultraglass. For the full list of options, see the [Complete Configuration Reference](/configuration/config/complete-config). ## Minimal Configuration @@ -326,7 +326,7 @@ web: content: | ## ⚠️ Development Environment - This is a development/testing instance of hyperglass. + This is a development and testing instance of Ultraglass. - Data may be fake or simulated - Configuration changes frequently @@ -431,7 +431,7 @@ structured: ## Configuration File Location -Place your `config.yaml` file in the hyperglass configuration directory: +Place your `config.yaml` file in the Ultraglass configuration directory: - **Docker**: Mount to `/etc/hyperglass/config.yaml` - **System Install**: `/etc/hyperglass/config.yaml` @@ -450,4 +450,4 @@ hyperglass --fake-output # Full startup test hyperglass --debug -``` \ No newline at end of file +``` diff --git a/docs/content/configuration/overview.mdx b/docs/content/configuration/overview.mdx index 0e172360e..ee463616b 100644 --- a/docs/content/configuration/overview.mdx +++ b/docs/content/configuration/overview.mdx @@ -1,7 +1,7 @@ import { Callout } from "nextra/components"; import { SupportedPlatforms } from "~/components/platforms"; -Once you've gotten started with a basic configuration, you'll probably want to customize the look and feel of hyperglass by changing the logo or color scheme. Fortunately, there are _a lot_ ways to customize hyperglass. +Once you have a basic configuration working, you can customise the Ultraglass interface, query policy, messages, and device behaviour from the configuration files. ## Configuration Files @@ -19,7 +19,7 @@ Once you've gotten started with a basic configuration, you'll probably want to c ### Using a Python File -When using a Python file for a hyperglass configuration, one of the following methods may be used: +When using a Python file for an Ultraglass configuration, use one of the following methods: #### Define a Function Named `main` @@ -59,7 +59,7 @@ main = { ## Built-in Directives -hyperglass ships with predefined [directives](/configuration/directives.mdx) for the following [platforms](platforms.mdx): +Ultraglass ships with predefined [directives](/configuration/directives.mdx) for the following [platforms](platforms.mdx): <SupportedPlatforms /> diff --git a/docs/content/index.mdx b/docs/content/index.mdx index d12892f8c..c478a0e4b 100644 --- a/docs/content/index.mdx +++ b/docs/content/index.mdx @@ -1,35 +1,38 @@ --- title: Introduction -description: Get started with hyperglass +description: Run a public network looking glass with Ultraglass --- import { Cards } from "nextra/components"; import { SupportedPlatforms } from "~/components/platforms"; -## What is hyperglass? +## What is Ultraglass? -<strong style={{ color: "#ff5e5b" }}>hyperglass</strong> is an open source network looking glass written -by a network engineer for other network engineers. The purpose of a looking glass is to provide customers, -peers, and complete strangers with unattended visibility into the an operator's network. +Ultraglass is an open source network looking glass for network operators. It gives customers, peers, and other network engineers a controlled way to run diagnostics from selected points in your network without exposing device access. -hyperglass was created with the lofty goal of benefiting the internet community at-large by providing a faster, easier, and more secure way for operators to provide looking glass services to their customers, peers, and other network operators. +The administrator defines which devices, commands, targets, credentials, and result formats are available. Users get a web interface and REST API for the queries you choose to publish. -## Features +Ultraglass is a maintained fork of [hyperglass](https://github.com/thatmattlove/hyperglass). The public product uses the Ultraglass name, while the Python package, CLI, configuration paths, environment variables, and service names retain `hyperglass` for deployment compatibility. -- BGP Route, BGP Community, BGP AS Path, Ping, & Traceroute -- Full IPv6 support -- Customizable everything: features, theme, UI/API text, error messages, commands -- Built in support for: - <SupportedPlatforms /> -- Configurable support for any other [supported platform](platforms.mdx) -- Optionally access devices via an SSH proxy/jump server -- VRF support -- Access List/prefix-list style query control to whitelist or blacklist query targets on a per-VRF basis -- REST API with automatic, configurable OpenAPI documentation -- Modern, responsive UI -- Query multiple devices simultaneously -- Browser-based DNS-over-HTTPS resolution of FQDN queries +## What you can publish + +- BGP route, BGP community, BGP AS path, ping, and traceroute queries. +- Structured BGP and traceroute results for supported platforms. +- ASN, organisation, country, IXP, reverse DNS, and RPKI details when the relevant enrichment is enabled. +- IPv4 and IPv6 queries, including browser-based DNS-over-HTTPS resolution for hostnames. +- Multiple query locations, VRFs, SSH proxy servers, HTTP devices, and concurrent queries. +- Prefix-list and access-list style controls for limiting query targets. +- A responsive web UI and configurable OpenAPI documentation. + +## Start here <Cards> - <Cards.Card title="Get Started" href="installation/docker" arrow /> + <Cards.Card title="Install with Docker" href="installation/docker" arrow /> + <Cards.Card title="Install manually" href="installation/manual" arrow /> + <Cards.Card title="Configure Ultraglass" href="configuration" arrow /> + <Cards.Card title="Use the web interface" href="user-guide" arrow /> </Cards> + +## Supported platforms + +<SupportedPlatforms /> diff --git a/docs/content/installation.mdx b/docs/content/installation.mdx index 0eb736a47..f76d88cdb 100644 --- a/docs/content/installation.mdx +++ b/docs/content/installation.mdx @@ -1,11 +1,19 @@ +--- +title: Installation +description: Install Ultraglass with Docker or directly on a host +--- + import { Cards } from "nextra/components"; +Ultraglass can run as a Docker Compose application or as a manually installed Python application. Docker is the simpler option for most deployments because it keeps the application, UI build, and Redis service together. + <Cards> <Cards.Card href="installation/docker" title="Using Docker" /> - <Cards.Card href="installation/manual" title="Manual Installation" /> + <Cards.Card href="installation/manual" title="Manual installation" /> </Cards> <Cards> - <Cards.Card href="installation/environment-variables" title="Environment Variables" /> - <Cards.Card href="installation/reverse-proxy" title="Reverse Proxy" /> + <Cards.Card href="installation/environment-variables" title="Environment variables" /> + <Cards.Card href="installation/reverse-proxy" title="Reverse proxy" /> + <Cards.Card href="installation/upgrading" title="Upgrading" /> </Cards> diff --git a/docs/content/installation/_meta.js b/docs/content/installation/_meta.js index 6f531ec2a..e97646a1b 100644 --- a/docs/content/installation/_meta.js +++ b/docs/content/installation/_meta.js @@ -3,5 +3,5 @@ export default { manual: "Manual Installation", "environment-variables": "Environment Variables", "reverse-proxy": "Reverse Proxy", - upgrading: "Upgrading hyperglass", + upgrading: "Upgrading Ultraglass", }; diff --git a/docs/content/installation/docker.mdx b/docs/content/installation/docker.mdx index cab3e3152..bd90c795f 100644 --- a/docs/content/installation/docker.mdx +++ b/docs/content/installation/docker.mdx @@ -1,12 +1,12 @@ --- title: Using Docker -description: Installing hyperglass with Docker +description: Install Ultraglass with Docker --- import { Cards, Steps, Callout } from "nextra/components"; // import { Callout } from "nextra-theme-docs"; -<Callout type="info">**Docker is the recommended method for running hyperglass.**</Callout> +<Callout type="info">**Docker is the recommended method for running Ultraglass.**</Callout> <Steps> @@ -21,22 +21,22 @@ import { Cards, Steps, Callout } from "nextra/components"; /> </Cards> -### Download hyperglass +### Download Ultraglass ```shell copy mkdir /etc/hyperglass cd /opt -git clone https://github.com/thatmattlove/hyperglass.git --depth=1 -cd /opt/hyperglass +git clone https://github.com/WilhelmZA/ultraglass.git --depth=1 +cd /opt/ultraglass ``` ### Optional: Quickstart -Do this if you just want to see the hyperglass page working with a fake device. +Do this if you want to verify the Ultraglass page with a fake device before connecting real network devices. ```shell copy -cp /opt/hyperglass/.samples/sample_devices.yaml /etc/hyperglass/devices.yaml -cd /opt/hyperglass +cp /opt/ultraglass/.samples/sample_devices.yaml /etc/hyperglass/devices.yaml +cd /opt/ultraglass docker compose up ``` @@ -45,12 +45,12 @@ Navigate to http://localhost:8001 ### Create a `systemd` service <Callout type="info"> - Before you create and start the hyperglass service, you may want to verify whether or not you - intend to change any [environment variables](environment-variables.mdx) and change them first. + Before you create and start the Ultraglass service, verify whether you need to change any + [environment variables](environment-variables.mdx). </Callout> ```shell copy -cp /opt/hyperglass/.samples/hyperglass-docker.service /etc/hyperglass/hyperglass.service +cp /opt/ultraglass/.samples/hyperglass-docker.service /etc/hyperglass/hyperglass.service ln -s /etc/hyperglass/hyperglass.service /etc/systemd/system/hyperglass.service systemctl daemon-reload systemctl enable hyperglass diff --git a/docs/content/installation/environment-variables.mdx b/docs/content/installation/environment-variables.mdx index 64d39157e..1ed864cb9 100644 --- a/docs/content/installation/environment-variables.mdx +++ b/docs/content/installation/environment-variables.mdx @@ -1,22 +1,22 @@ --- title: Environment Variables -description: hyperglass environment variables +description: Ultraglass environment variables --- -There are some configuration variables that must be supplied before hyperglass can start or read its configuration files. Most of the time, you should not need to modify these. +Some environment variables must be supplied before Ultraglass can start or read its configuration files. Most deployments can use the defaults. Environment variables may be overridden at the command line, or by placing them in `${HYPERGLASS_APP_PATH}/hyperglass.env`. | Variable Name | Type | Default | Description | | :-------------------------- | :------ | :---------------- | :----------------------------------------------------------------------------------------------------------------- | | `HYPERGLASS_DEBUG` | boolean | `false` | Enable debug logging | -| `HYPERGLASS_DEV_MODE` | boolean | `false` | Enable developer mode. This should only be used if you are developing hyperglass under specific circumstances. | -| `HYPERGLASS_DISABLE_UI` | boolean | `false` | If set to `true`, the hyperglass UI is not built or served. The only way to access hyperglass is via REST API. | -| `HYPERGLASS_APP_PATH` | string | `/etc/hyperglass` | Directory where hyperglass configuration files and static web UI files are contained. | +| `HYPERGLASS_DEV_MODE` | boolean | `false` | Enable developer mode. Use this only while developing or troubleshooting Ultraglass. | +| `HYPERGLASS_DISABLE_UI` | boolean | `false` | If set to `true`, the Ultraglass UI is not built or served. Access is then through the REST API. | +| `HYPERGLASS_APP_PATH` | string | `/etc/hyperglass` | Directory where Ultraglass configuration files and static web UI files are stored. | | `HYPERGLASS_REDIS_HOST` | string | `localhost` | Host on which Redis is running. | | `HYPERGLASS_REDIS_PASSWORD` | string | — | Redis password, if any. | | `HYPERGLASS_REDIS_DB` | number | `1` | Redis database number. | | `HYPERGLASS_REDIS_DSN` | string | — | Redis DSN. If supplied, overrides `HYPERGLASS_REDIS_HOST`, `HYPERGLASS_REDIS_DB`, and `HYPERGLASS_REDIS_PASSWORD`. | -| `HYPERGLASS_HOST` | string | `[::1]` | Address on which hyperglass listens for requests. | -| `HYPERGLASS_PORT` | number | `8001` | TCP port on which hyperglass listens for requests. | +| `HYPERGLASS_HOST` | string | `[::1]` | Address on which Ultraglass listens for requests. | +| `HYPERGLASS_PORT` | number | `8001` | TCP port on which Ultraglass listens for requests. | | `HYPERGLASS_CA_CERT` | string | — | Path to CA certificate file for validating HTTPS certificates. If not supplied, system CAs are used. | diff --git a/docs/content/installation/manual.mdx b/docs/content/installation/manual.mdx index c5aefe61c..69cbd3a96 100644 --- a/docs/content/installation/manual.mdx +++ b/docs/content/installation/manual.mdx @@ -1,6 +1,6 @@ --- -title: Manual Installation -description: Installing hyperglass manually +title: Manual installation +description: Install Ultraglass directly on a host --- import { Steps, Callout } from "nextra/components"; @@ -9,22 +9,22 @@ import { Steps, Callout } from "nextra/components"; ### Install Dependencies -To install hyperglass manually, you'll need to install the following dependencies: +To install Ultraglass manually, install the following dependencies: -1. [Python 3.11, or 3.12](https://www.python.org/downloads/) and [`pip`](https://pip.pypa.io/en/stable/installation/) -2. [NodeJS 20.14 or later](https://nodejs.org/en/download) -3. [PNPM 9 or later](https://pnpm.io/installation) -4. [Redis 7.2 or later](https://redis.io/download/) +1. [Python 3.13 or later](https://www.python.org/downloads/) and [`pip`](https://pip.pypa.io/en/stable/installation/) +2. [Node.js 22 or later](https://nodejs.org/en/download) +3. [pnpm 11](https://pnpm.io/installation) +4. [Redis 7 or later](https://redis.io/download/) <Callout type="warning">Make sure the Redis server is started.</Callout> -### Install hyperglass +### Install Ultraglass -Once these dependencies are installed, install hyperglass via PyPI: +Once these dependencies are installed, clone and install Ultraglass: ```shell copy -git clone https://github.com/WilhelmZA/hyperglass --depth=1 -cd hyperglass +git clone https://github.com/WilhelmZA/ultraglass --depth=1 +cd ultraglass # optional - switch to the latest stable release # git switch -c v3.0.0 v3.0.0 pip3 install -e . @@ -43,17 +43,17 @@ mkdir /etc/hyperglass ### Optional: Quickstart -Do this if you just want to see the hyperglass page working with default settings and a fake device. +Do this if you want to verify the Ultraglass page with default settings and a fake device. ```shell copy -curl -o /etc/hyperglass/devices.yaml https://raw.githubusercontent.com/thatmattlove/hyperglass/main/.samples/sample_devices.yaml +curl -o /etc/hyperglass/devices.yaml https://raw.githubusercontent.com/WilhelmZA/ultraglass/main/.samples/sample_devices.yaml hyperglass start ``` ### Create a `systemd` service ```shell copy -curl -o /etc/hyperglass/hyperglass.service https://raw.githubusercontent.com/thatmattlove/hyperglass/main/.samples/hyperglass-manual.service +curl -o /etc/hyperglass/hyperglass.service https://raw.githubusercontent.com/WilhelmZA/ultraglass/main/.samples/hyperglass-manual.service ln -s /etc/hyperglass/hyperglass.service /etc/systemd/system/hyperglass.service systemctl daemon-reload systemctl enable hyperglass diff --git a/docs/content/installation/reverse-proxy.mdx b/docs/content/installation/reverse-proxy.mdx index aa821f617..d3e7fe357 100644 --- a/docs/content/installation/reverse-proxy.mdx +++ b/docs/content/installation/reverse-proxy.mdx @@ -1,6 +1,6 @@ --- title: Reverse Proxy -description: Setting up a reverse proxy for hyperglass +description: Set up a reverse proxy for Ultraglass --- import { Cards, Callout } from "nextra/components"; @@ -22,7 +22,7 @@ import { Cards, Callout } from "nextra/components"; cp /opt/hyperglass/.samples/Caddyfile /etc/caddy/Caddyfile ``` -Change the `lg.example.com` and `person@example.com` values to match your hyperglass FQDN and email address (the email address is for automatic SSL certificate generation via Let's Encrypt). +Change the `lg.example.com` and `person@example.com` values to match your Ultraglass FQDN and email address. The email address is used for automatic SSL certificate generation via Let's Encrypt. <Callout type="info"> If you prefer to use other Let's Encrypt validation methods or your own SSL certificate, modify @@ -39,6 +39,6 @@ cp /opt/hyperglass/.samples/hyperglass.nginx /etc/nginx/sites-available/hypergla ln -s /etc/nginx/sites-available/hyperglass /etc/nginx/sites-enabled/hyperglass ``` -Change the `lg.example.com` value to match your hyperglass FQDN. +Change the `lg.example.com` value to match your Ultraglass FQDN. Change the `<path to cert chain>` and `<path to key>` values to match the path to your certificate and private key files. diff --git a/docs/content/installation/upgrading.mdx b/docs/content/installation/upgrading.mdx index 30100053e..8b0d9abf0 100644 --- a/docs/content/installation/upgrading.mdx +++ b/docs/content/installation/upgrading.mdx @@ -1,10 +1,12 @@ ## Using Docker +The application keeps the `hyperglass` service and configuration names for compatibility. Pull the Ultraglass repository, then update the checked-out release before rebuilding the containers. + ```shell copy cd /opt/hyperglass docker compose down docker compose rm -f -git fetch +git fetch --tags git checkout v3.1.0 docker compose build docker compose up diff --git a/docs/content/platforms.mdx b/docs/content/platforms.mdx index 365cb171c..316d566dd 100644 --- a/docs/content/platforms.mdx +++ b/docs/content/platforms.mdx @@ -1,17 +1,17 @@ --- -description: Platforms supported by hyperglass +description: Platforms supported by Ultraglass --- import { Callout } from "nextra/components"; import { PlatformTable } from "~/components/platforms"; -hyperglass uses [Netmiko](https://github.com/ktbyers/netmiko) to interact with devices via SSH/telnet. [All platforms supported by Netmiko](https://github.com/ktbyers/netmiko/blob/develop/PLATFORMS.md) are supported by hyperglass. +Ultraglass uses [Netmiko](https://github.com/ktbyers/netmiko) to interact with devices over SSH or Telnet. [Platforms supported by Netmiko](https://github.com/ktbyers/netmiko/blob/develop/PLATFORMS.md) can be configured in Ultraglass when their commands and output match the query type you need. ## Netmiko Platforms <Callout type="info"> Just because Netmiko supports a given platform doesn't mean it will automatically work with - hyperglass. hyperglass has a limited number of built in directives (listed below). Any platforms + Ultraglass. Ultraglass has a limited number of built-in directives (listed below). Any platform other than the ones listed below will require you to [add a custom directive](configuration/examples/add-your-own-command.mdx) for each command you wish to make available. diff --git a/docs/content/plugins.mdx b/docs/content/plugins.mdx index adc5087b1..45fc9dbbe 100644 --- a/docs/content/plugins.mdx +++ b/docs/content/plugins.mdx @@ -1,11 +1,11 @@ --- title: Plugins -description: hyperglass Plugins +description: Ultraglass plugins --- import { Cards, Card } from "nextra/components"; -Starting in version 2.0, hyperglass supports custom plugins. There are two main types of plugins: +Ultraglass supports custom plugins. There are two main types of plugins: - [**Input Plugins**](#input-plugins): Apply custom validation logic to or transform user input before the query is sent to a device. - [**Output Plugins**](#output-plugins): Interact with the output from a device before it's displayed to the user. @@ -18,7 +18,7 @@ Plugins are associated with a directive. #### Convert a CIDR Prefix to Network and Mask -In this example, the following query is sent to hyperglass: +In this example, the following query is sent to Ultraglass: ```json filename="Example Query" { diff --git a/docs/content/user-guide.mdx b/docs/content/user-guide.mdx index f753432ac..c06e7baf1 100644 --- a/docs/content/user-guide.mdx +++ b/docs/content/user-guide.mdx @@ -1,362 +1,69 @@ --- title: User Guide -description: How to use the hyperglass web interface +description: Run network queries from the Ultraglass web interface --- -import { Callout } from 'nextra/components' +import { Callout } from "nextra/components"; -# User Guide +# User guide -This guide explains how to use the hyperglass web interface from an end-user perspective. If you're looking to configure hyperglass as an administrator, see the [Configuration Guide](/configuration). +Ultraglass provides a web form for running the network queries that an administrator has enabled. The available locations, query types, target validation rules, and result fields depend on the deployment configuration. -## Getting Started +## Run a query -The hyperglass web interface provides an easy-to-use form for performing network diagnostics. Here's how to use it: +### Choose a location -### 1. Select a Location +Select the router or location from which the query should run. The location determines the network path and device that will execute the command. -Choose which router/location you want to query from using the location selector. This determines which network device will execute your diagnostic command. +Deployments with a small number of locations may show them as cards. Larger deployments usually show a dropdown. -- **Dropdown Mode**: Select from a list of available locations -- **Gallery Mode**: Choose from a visual grid of location cards (used when there are 5 or fewer locations) +### Choose a query type -### 2. Choose Query Type +- **Ping** tests reachability and reports round-trip time. +- **Traceroute** shows the path toward a destination and may include hop latency, packet loss, ASN, organisation, country, IXP, or reverse DNS details. +- **BGP route** shows routes for an IP address or prefix when the selected device supports the configured directive. +- **BGP community** finds routes associated with a community value. +- **BGP AS path** finds routes that contain one or more ASNs. -Select the type of network diagnostic you want to perform: +### Enter a target -- **Ping**: Test basic connectivity and measure round-trip time -- **Traceroute**: Show the network path to a destination -- **BGP Route**: Look up BGP routing information for an IP prefix -- **BGP Community**: Find routes with specific BGP communities -- **BGP AS Path**: Find routes traversing specific ASNs +Depending on the query type, enter an IP address, hostname, prefix, or ASN. Examples include `8.8.8.8`, `2001:4860:4860::8888`, `8.8.8.0/24`, `google.com`, and `AS15169`. -### 3. Enter Your Target +The deployment may restrict target formats or networks. A rejected target means the administrator's query policy did not allow it, not that the device is unreachable. -Specify what you want to query. This can be: +### Submit the query -- **IP Address**: `8.8.8.8` or `2001:4860:4860::8888` -- **Hostname**: `google.com` or `www.example.com` -- **IP Prefix**: `8.8.8.0/24` (for BGP queries) -- **AS Number**: `AS15169` (for AS path queries) +Select **Submit** to run the query. The page shows the response when the device finishes, or an error message when the query cannot complete. -## Using Hostnames (FQDN Resolution) +## Hostname resolution -<Callout type="info"> -When you enter a hostname like `google.com` instead of an IP address, hyperglass automatically resolves it to IP addresses using secure DNS-over-HTTPS. This happens directly in your browser for privacy and security. -</Callout> +When you enter a hostname instead of an address, Ultraglass can resolve it in the browser using DNS-over-HTTPS. The resolver returns available IPv4 and IPv6 addresses, and you choose which address to query. -### How Hostname Resolution Works +<Callout type="info">Browser-based resolution means the Ultraglass server does not need to resolve the hostname on your behalf. The configured resolver determines which addresses are returned.</Callout> -1. **Enter a hostname**: Type something like `google.com` in the target field -2. **Detection**: hyperglass detects this is a hostname (not an IP address) -3. **DNS Resolution**: A modal opens showing "Resolving hostname..." -4. **Choose IP**: Select from available IPv4 and/or IPv6 addresses -5. **Submit Query**: The selected IP address is used for your diagnostic query +1. Enter a hostname such as `google.com`. +2. Select **Submit**. +3. Choose an IPv4 or IPv6 address from the resolution dialog. +4. Confirm the selected address and run the query. -### Hostname Resolution Features +If no address is returned, check the hostname, the configured DNS-over-HTTPS provider, and any browser or network policy that may block the resolver request. -- **IPv4 and IPv6**: Get both A and AAAA records when available -- **Privacy-Focused**: Uses DNS-over-HTTPS (encrypted) instead of plain DNS -- **Multiple Providers**: Supports Cloudflare DNS, Google DNS, or custom resolvers -- **Browser-Based**: Resolution happens in your browser, not on the server +## Use My IP -### Example: Resolving a Hostname +The **My IP** control detects your public IPv4 and IPv6 addresses and offers them as query targets. It is useful when checking how a looking glass reaches your current network. -``` -1. Enter "google.com" as your target -2. Click Submit -3. See resolution modal: - ┌─────────────────────────────────────┐ - │ Your browser has resolved google.com to │ - │ │ - │ [→] 142.250.191.14 (IPv4) │ - │ [→] 2607:f8b0:4004:c1b::71 (IPv6) │ - │ │ - └─────────────────────────────────────┘ -4. Click your preferred IP address -5. Query executes using the selected IP -``` +1. Select **My IP** beside the target field. +2. Choose the address version you want to test. +3. Submit the query as usual. -## Using "My IP" Feature +The detected address is the address visible to the configured detection service. It may differ from an address assigned to your device when you are behind NAT, a VPN, or a corporate proxy. -<Callout type="info"> -The "My IP" button automatically detects your public IP address and uses it as the query target. This is useful for testing connectivity or routing to your current location. -</Callout> +## Read results -### How "My IP" Works +The result panel may include raw device output, parsed tables, route metadata, AS path links, traceroute hop details, or a query error. Structured output is available only when both the deployment and the selected device support it. -1. **Click the "My IP" button** (located next to the target input field) -2. **IP Detection**: A modal opens showing "Detecting your IP address..." -3. **Choose Version**: Select your IPv4 or IPv6 address (or both if available) -4. **Auto-Population**: Your selected IP is automatically entered as the target +Cached results show a cache indicator and the time at which the result was created. A cached response is still a response from a previous query. Run the query again when you need current device output. -### When to Use "My IP" +## When a query fails -- **Connectivity Testing**: Check if the network can reach your location -- **Routing Analysis**: See what path traffic takes to reach you -- **Latency Testing**: Measure round-trip time to your connection -- **Troubleshooting**: Diagnose connectivity issues from your current network - -### Example: Using My IP - -``` -1. Click the "My IP" button -2. See detection modal: - ┌─────────────────────────────────────┐ - │ Select an IP Address │ - │ │ - │ [→] 203.0.113.45 (IPv4) │ - │ [→] 2001:db8::1 (IPv6) │ - │ │ - └─────────────────────────────────────┘ -3. Click your preferred IP version -4. IP is automatically entered as target -5. Submit your query as normal -``` - -## Query Types Explained - -### Ping - -Tests basic connectivity and measures round-trip time. - -**Use Cases:** -- Check if a host is reachable -- Measure network latency -- Basic connectivity troubleshooting - -**Sample Output:** -``` -PING google.com (142.250.191.14): 56 data bytes -64 bytes from 142.250.191.14: icmp_seq=0 ttl=58 time=12.345 ms -64 bytes from 142.250.191.14: icmp_seq=1 ttl=58 time=11.678 ms - ---- google.com ping statistics --- -2 packets transmitted, 2 packets received, 0.0% packet loss -round-trip min/avg/max/stddev = 11.678/12.012/12.345/0.334 ms -``` - -### Traceroute - -Shows the network path packets take to reach a destination. - -**Use Cases:** -- Identify network routing paths -- Find where connectivity breaks -- Analyze routing decisions -- Network troubleshooting - -**Sample Output:** -``` -traceroute to google.com (142.250.191.14), 30 hops max, 60 byte packets - 1 gateway.local (192.168.1.1) 1.234 ms 1.456 ms 1.789 ms - 2 isp-router.net (203.0.113.1) 5.123 ms 5.234 ms 5.345 ms - 3 backbone.net (198.51.100.1) 12.34 ms 12.45 ms 12.56 ms - 4 google-peer.net (142.250.191.14) 15.67 ms 15.78 ms 15.89 ms -``` - -### BGP Route - -Look up BGP routing information for IP prefixes. - -**Use Cases:** -- Check BGP route advertisements -- Verify RPKI validation status -- Analyze AS paths and communities -- Route troubleshooting - -**Sample Output:** -``` -BGP routing table entry for 8.8.8.0/24 -Paths: 1 available - 15169 - 192.0.2.1 from 192.0.2.1 - Origin IGP, metric 0, valid, external, best - Community: 65000:100 65000:200 - RPKI validation state: Valid - Last update: 2 hours ago -``` - -### BGP Community - -Find routes with specific BGP communities. - -**Use Cases:** -- Filter routes by community values -- Analyze traffic engineering policies -- Debug community-based routing - -### BGP AS Path - -Find routes traversing specific Autonomous Systems. - -**Use Cases:** -- Analyze internet routing paths -- Check AS-level connectivity -- Investigate routing policies - -## Understanding Results - -### Status Indicators - -Results include several status indicators: - -- **✓ Success**: Query completed successfully -- **⚠ Warning**: Query completed with warnings -- **✗ Error**: Query failed -- **🕒 Cached**: Result was served from cache -- **⏱ Time**: Shows how long the query took - -### Cache Information - -<Callout type="info"> -To improve performance, hyperglass caches query results. Cached results are marked with a clock icon and show when they expire. Fresh queries are automatically run when cache expires. -</Callout> - -When you see cached results: -- **Cache Timer**: Shows "Results cached for X seconds" -- **Cache Icon**: Hover to see when the original query was run -- **Auto-Refresh**: New query runs automatically when cache expires - -### RPKI Validation - -For BGP queries, routes show RPKI validation status: - -- **Valid**: Route has valid ROA (Route Origin Authorization) -- **Invalid**: Route conflicts with ROA -- **Unknown**: No ROA exists for this route -- **Not Verified**: RPKI validation not performed - -## Common Use Cases - -### Network Troubleshooting - -**Scenario**: Website seems slow from your location - -1. Use **"My IP"** feature to get your IP address -2. Run **ping** to measure basic connectivity and latency -3. Run **traceroute** to see the network path -4. Check **BGP route** to verify optimal routing - -### Connectivity Testing - -**Scenario**: Check if a server is reachable - -1. Enter the server's **hostname or IP address** -2. Select multiple **router locations** to test from different points -3. Use **ping** for basic reachability -4. Use **traceroute** if ping fails to find where connectivity breaks - -### Routing Analysis - -**Scenario**: Analyze how traffic reaches a destination - -1. Enter the **destination IP or prefix** -2. Use **BGP route** to see routing table entries -3. Check **AS path** to understand routing decisions -4. Verify **RPKI validation** for security - -### Multi-Location Testing - -**Scenario**: Test connectivity from multiple network locations - -1. Choose your **target** (IP, hostname, or prefix) -2. Select **multiple locations** using Ctrl+Click (or Cmd+Click on Mac) -3. Submit query to test from **all selected locations simultaneously** -4. Compare results to identify location-specific issues - -## Tips and Best Practices - -### Efficient Querying - -- **Use hostnames** when you don't know IP addresses - hyperglass will resolve them -- **Try "My IP"** for connectivity testing from your location -- **Select multiple locations** to get a broader view of network behavior -- **Check cached results** first - they may already have the information you need - -### Interpreting Results - -- **Compare multiple locations** to identify regional issues -- **Look at RPKI status** for BGP routes to verify security -- **Check AS paths** to understand routing decisions -- **Use traceroute** when ping shows connectivity issues - -### Privacy and Security - -- **DNS resolution** uses encrypted DNS-over-HTTPS for privacy -- **No logging** of sensitive query details in most configurations -- **RPKI validation** helps verify route authenticity -- **Rate limiting** prevents abuse while allowing legitimate use - -## Troubleshooting - -### Common Issues - -**"Unable to resolve hostname"** -- Check spelling of the hostname -- Try a different DNS provider in browser settings -- Use an IP address instead - -**"Request timed out"** -- Target may be unreachable or filtering responses -- Try a different query type -- Check from multiple locations - -**"No output"** -- Query completed but returned no results -- Target may not exist in routing tables -- Try a broader search (e.g., larger prefix) - -**"Target not allowed"** -- Query target is blocked by policy -- Contact administrator if you believe this is an error -- Try a different target - -### Getting Help - -If you encounter issues: - -1. **Check multiple locations** - issue may be location-specific -2. **Try different query types** - some may work when others don't -3. **Contact network administrators** - they can review logs and policies -4. **Report persistent issues** - help improve the service - ---- - -## Advanced Features - -### Keyboard Shortcuts - -- **Enter**: Submit query -- **Ctrl/Cmd + Click**: Select multiple locations -- **Tab**: Navigate between form fields -- **Escape**: Close modals/popups - -### URL Parameters - -You can bookmark or share queries using URL parameters: - -``` -https://lg.example.com/?location=router1&type=ping&target=8.8.8.8 -``` - -Parameters: -- `location`: Router location ID -- `type`: Query type (ping, traceroute, bgp_route, etc.) -- `target`: Query target (IP, hostname, prefix, etc.) - -### API Access - -For programmatic access, use the REST API: - -```bash -curl -X POST https://lg.example.com/api/query \ - -H "Content-Type: application/json" \ - -d '{ - "query_location": "router1", - "query_type": "ping", - "query_target": "8.8.8.8" - }' -``` - -See the [API Documentation](/api/docs) for complete details. \ No newline at end of file +Check the target format first, then try another location if one is available. If the problem continues, report it to the operator of that Ultraglass deployment with the location, query type, target format, approximate time, and visible error message. Do not include credentials or private device output in a public report. diff --git a/docs/package.json b/docs/package.json index 533e32cc5..8b79aa7a2 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,7 +1,7 @@ { - "name": "hyperglass-docs", + "name": "ultraglass-docs", "version": "2.0.0", - "description": "hyperglass documentation", + "description": "Ultraglass documentation", "private": true, "scripts": { "build": "next build", @@ -9,7 +9,7 @@ "start": "next start", "typecheck": "tsc --noEmit" }, - "author": "thatmattlove <matt@hyperglass.dev>", + "author": "Ultraglass maintainers", "license": "BSD-3-Clause-Clear", "dependencies": { "next": "15.5.21", diff --git a/docs/public/hyperglass-dark.svg b/docs/public/hyperglass-dark.svg deleted file mode 100644 index b93080f43..000000000 --- a/docs/public/hyperglass-dark.svg +++ /dev/null @@ -1,21 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2759.9 544.87"> - <g fill="#ff5e5b"> - <g> - <path d="M931.39,683.15q20.28,21.23,20.28,62.74l0,134.13c0,3.35-.85,5.94-2.51,7.76a10.52,10.52,0,0,1-14.15,0c-1.67-1.82-2.51-4.4-2.5-7.75l0-131.86q0-35.58-14.12-51.79t-45.62-16.21q-35.13,0-56.35,21.65t-21.24,57.71l0,120.44q0,5-2.51,7.76a9.12,9.12,0,0,1-7.07,2.74,9,9,0,0,1-6.84-3A10.66,10.66,0,0,1,776,880l.12-305.23q0-5,2.51-7.76a10.52,10.52,0,0,1,14.15,0c1.66,1.83,2.5,4.41,2.5,7.76l0,131.39a71.74,71.74,0,0,1,30.13-32.83q20.07-11.4,46.08-11.39Q911.09,661.93,931.39,683.15Z" transform="translate(-102 -476.45)" /> - <path d="M1201.26,663a9.19,9.19,0,0,1,6.39,2.51,8.56,8.56,0,0,1,2.73,6.62,14.35,14.35,0,0,1-.91,4.1l-131,290.12q-2.75,5.48-8.22,5.47a8.83,8.83,0,0,1-6.16-2.28,7.66,7.66,0,0,1-2.5-5.93l.91-3.65,32.88-72.08L999.6,676.1a14.33,14.33,0,0,1-.91-4.11,8.65,8.65,0,0,1,3.19-6.84,10.93,10.93,0,0,1,7.3-2.73q5,0,7.76,5.48l88,198.49.91-2.28,86.76-194.78C1194.42,665.08,1197.31,662.94,1201.26,663Z" transform="translate(-102 -476.45)" /> - <path d="M1406.57,676.48q22.34,14.38,34.66,40.39t12.29,60.69q0,34.22-12.34,60t-34.69,39.91q-22.37,14.13-51.56,14.12-27.84,0-47.67-12.33t-29.88-35.6l0,118.62c0,3.35-.84,5.93-2.51,7.76a9.15,9.15,0,0,1-7.07,2.73q-9.59,0-9.58-10.5l.11-289.25a10.34,10.34,0,0,1,2.51-7.3,10.49,10.49,0,0,1,14.14,0,10.42,10.42,0,0,1,2.51,7.3v36q10-22.81,29.89-34.9T1355,662.09Q1384.21,662.11,1406.57,676.48ZM1412,848q20.54-24.85,20.55-70.48T1412,706.37q-20.52-25.56-57-25.57-37.41,0-57.5,24.84t-20.1,70.94q0,46.09,20,71.18t57,25.12Q1391.44,872.9,1412,848Z" transform="translate(-102 -476.45)" /> - <path d="M1688.91,847.68a8.28,8.28,0,0,1,2.28,5.71,14.17,14.17,0,0,1-3.43,8.89q-3.42,4.35-12.09,9.81-32.85,19.6-68,19.59-49.73,0-78.23-30.6t-28.49-84q0-34.22,12.8-60.22t36.06-40.36q23.28-14.37,53.39-14.35,42,0,66.14,27.4t24.15,74.83q0,8.21-3.19,11.86t-11.41,3.65l-157-.06q.43,44.71,22.56,68.9t62.27,24.2q21.9,0,36.27-5.91a146.07,146.07,0,0,0,28.07-15.51q9.57-6.37,12.32-6.38A7.16,7.16,0,0,1,1688.91,847.68Zm-141.61-146q-21.22,21.68-24.89,60.44l152.85.06q-.91-39.69-19.6-60.91T1603.2,680Q1568.52,680,1547.3,701.63Z" transform="translate(-102 -476.45)" /> - <path d="M1855.05,663.19c3.65,0,6.16.69,7.52,2.06s2.06,3.42,2.06,6.16q0,7.77-9.59,9.12l-13.69,1.37q-33.3,3.18-50.19,24.61a77.43,77.43,0,0,0-16.9,49.27l0,124.56q0,5-2.51,7.75a10.5,10.5,0,0,1-14.15,0c-1.67-1.83-2.5-4.41-2.5-7.76l.08-206.68q0-10.49,9.58-10.49a9.25,9.25,0,0,1,6.84,2.74c1.83,1.83,2.74,4.41,2.74,7.76v32.85q19.62-39.22,71.19-42.86l6.38-.46Z" transform="translate(-102 -476.45)" /> - <path d="M2078.6,666a9.84,9.84,0,0,1,2.74,7.3l-.08,204.85q0,47.45-22.38,71.62T1992.26,974q-24.65,0-43.34-4.58a127.8,127.8,0,0,1-36-15.07q-8.67-5.47-12.32-9.81a13.79,13.79,0,0,1-3.64-8.9,7.82,7.82,0,0,1,8.21-8.21q2.28,0,11.86,5.48a204.67,204.67,0,0,0,32.85,16q16.86,6.39,43.34,6.4,33.75,0,51.56-18.91t17.81-55.43l0-47.9q-9.59,23.71-29.9,36.26t-49.05,12.53q-29.2,0-51.78-13.71t-35.11-38.8q-12.54-25.08-12.53-57.49t12.57-57.25q12.56-24.85,35.15-38.54t51.79-13.67q28.27,0,48.35,12.34t30.1,35.14l0-36q0-10.5,9.58-10.49A9.25,9.25,0,0,1,2078.6,666Zm-37.25,172.67q20.78-24.39,20.79-66.83t-20.73-66.62q-20.76-24.18-57.71-24.2-36.51,0-57.72,24.16t-21.25,66.6q0,42.88,21.19,67.08t57.71,24.2Q2020.59,863.1,2041.35,838.69Z" transform="translate(-102 -476.45)" /> - <path d="M2161.32,888.24c-1.67-1.83-2.51-4.41-2.5-7.76l.11-305.22q0-5,2.51-7.76a9.09,9.09,0,0,1,7.08-2.73,9,9,0,0,1,6.84,3,10.73,10.73,0,0,1,2.73,7.53L2178,880.49a10.75,10.75,0,0,1-2.74,7.53,9,9,0,0,1-6.85,3A9.09,9.09,0,0,1,2161.32,888.24Z" transform="translate(-102 -476.45)" /> - <path d="M2416.64,744.16l0,136q0,5-2.51,7.76a9.12,9.12,0,0,1-7.08,2.73,9.45,9.45,0,0,1-7.29-3,10.66,10.66,0,0,1-2.74-7.53V848.18q-10,21-29,32.38T2324.88,892a84.41,84.41,0,0,1-38.32-8.68,71.93,71.93,0,0,1-27.6-23.51,59.47,59.47,0,0,1-10.71-33.08q-.43-25.08,12.34-38.78t42.89-19.6q30.12-5.91,84.41-5.9H2397V743.24q0-32.38-13-47.22t-40.83-14.85q-21.9,0-39,5.69t-27.15,13q-1.83,1.38-7.53,5.25c-3.81,2.59-6.93,3.87-9.36,3.87a7.82,7.82,0,0,1-5.7-2.51,8.37,8.37,0,0,1-2.51-6.16q0-8.65,16-18.24a134.23,134.23,0,0,1,34.91-14.59,146.13,146.13,0,0,1,37.64-5Q2416.67,662.5,2416.64,744.16ZM2361.85,863a69.15,69.15,0,0,0,25.78-27.13q9.36-17.34,9.37-39.24V780.2h-8.67q-47.91,0-73,3.85t-35.14,13.21q-10,9.36-10,27.6,0,21,15.49,34.46t39.24,13.47Q2345.41,872.79,2361.85,863Z" transform="translate(-102 -476.45)" /> - <path d="M2526.54,886.78a122.09,122.09,0,0,1-31.93-14.38q-9.14-5.92-12.32-9.82a13.55,13.55,0,0,1-3.19-8.89,8.24,8.24,0,0,1,2.29-5.71,7.17,7.17,0,0,1,5.47-2.5q2.28,0,6.16,2.51c2.58,1.67,4.33,2.82,5.25,3.42a142.87,142.87,0,0,0,27.59,15.75q14.37,6.17,36.27,6.18,29.65,0,45.63-10.94t16-31q0-11.85-5.7-19.62t-18.7-13.46q-13-5.7-37.17-11.19Q2522,778,2504.69,763.82t-17.33-37.42q0-28.29,22.15-46.08t57.26-17.77a114.74,114.74,0,0,1,32.4,4.58,95.21,95.21,0,0,1,27.37,12.78q8.65,6.4,12.54,11.19a15.49,15.49,0,0,1,3.87,9.81,8.24,8.24,0,0,1-2.28,5.7,7.18,7.18,0,0,1-5.48,2.51c-2.44,0-6.38-2.29-11.86-6.85a127.56,127.56,0,0,0-24.86-15.07q-13-5.93-32.62-5.94-25.54,0-41.29,12.07T2508.81,725q0,11.87,5,19.39t17.1,13q12.09,5.47,34.45,10.5,31.47,7.78,48.35,15.76t24,19.18q7.07,11.18,7.06,28.51,0,27.38-22.6,44t-60,16.63A125.17,125.17,0,0,1,2526.54,886.78Z" transform="translate(-102 -476.45)" /> - <path d="M2743.71,886.86a121.76,121.76,0,0,1-31.93-14.38q-9.14-5.93-12.32-9.81a13.6,13.6,0,0,1-3.19-8.9,8.25,8.25,0,0,1,2.29-5.7,7.14,7.14,0,0,1,5.47-2.51q2.28,0,6.16,2.51l5.25,3.42a143.22,143.22,0,0,0,27.6,15.76q14.36,6.16,36.26,6.17,29.65,0,45.63-10.93t16-31q0-11.87-5.69-19.62t-18.7-13.47q-13-5.7-37.18-11.19-40.15-9.13-57.48-23.29t-17.32-37.42q0-28.29,22.14-46.07T2784,662.64a114.31,114.31,0,0,1,32.39,4.57A94.83,94.83,0,0,1,2843.71,680q8.65,6.39,12.54,11.18a15.51,15.51,0,0,1,3.87,9.81,8.24,8.24,0,0,1-2.28,5.7,7.12,7.12,0,0,1-5.47,2.51q-3.66,0-11.86-6.85a128.65,128.65,0,0,0-24.86-15.07q-13-5.92-32.62-5.94-25.56,0-41.3,12.08T2726,725.12q0,11.86,5,19.39t17.1,13q12.09,5.47,34.45,10.51,31.47,7.77,48.35,15.76t24,19.17q7.07,11.19,7.06,28.51,0,27.38-22.6,44t-60,16.62A125.19,125.19,0,0,1,2743.71,886.86Z" transform="translate(-102 -476.45)" /> - </g> - <g> - <path d="M629,748.94A254.5,254.5,0,0,1,177.8,910.5a253.51,253.51,0,0,1-57-141.26A254.75,254.75,0,0,1,269.49,517c48.18-22.05,103.68-28.13,155.56-17.55a253.76,253.76,0,0,1,204,249.5c0,11.58,18,11.6,18,0-.15-56.09-17.45-111.67-50.07-157.4A275.77,275.77,0,0,0,468,492.94c-53.4-19.67-113.16-21.73-167.89-6.22A272.65,272.65,0,0,0,141.23,889.87c28.26,46.62,70.66,84.65,120.38,107.11a275.73,275.73,0,0,0,167.55,18.95,272.71,272.71,0,0,0,142.15-78.52c38.14-39.87,64-91.3,72.33-145.91A287.4,287.4,0,0,0,647,748.94C647,737.36,629,737.34,629,748.94Z" transform="translate(-102 -476.45)" /> - <path d="M276.33,926.44a203,203,0,0,0,98.67,26c7.72,0,7.73-12,0-12a190.29,190.29,0,0,1-92.62-24.36c-6.74-3.79-12.79,6.58-6.05,10.36Z" transform="translate(-102 -476.45)" /> - <path d="M233.58,611c-28.56,28.78-48.31,65.84-55.4,105.83a199.79,199.79,0,0,0,55.4,177c5.45,5.49,13.93-3,8.48-8.48C186.68,829.56,171,742.65,205.27,671.45a191.94,191.94,0,0,1,36.79-52c5.45-5.48-3-14-8.48-8.48Z" transform="translate(-102 -476.45)" /> - </g> - </g> -</svg> diff --git a/docs/public/hyperglass-icon-dark.svg b/docs/public/hyperglass-icon-dark.svg deleted file mode 100644 index ea9b648e3..000000000 --- a/docs/public/hyperglass-icon-dark.svg +++ /dev/null @@ -1,7 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1072 1072"> - <g fill="#ff5e5b"> - <path d="M1464,1000a515,515,0,0,1-4.41,66.71l1.28-9.57a502,502,0,0,1-34.62,127.3l3.63-8.6a504.73,504.73,0,0,1-65,111.35l5.63-7.28a508.39,508.39,0,0,1-90.59,90.59l7.28-5.63a504.73,504.73,0,0,1-111.35,65l8.6-3.63a502,502,0,0,1-127.3,34.62l9.57-1.28a510.21,510.21,0,0,1-133.42,0l9.57,1.28a502,502,0,0,1-127.3-34.62l8.6,3.63a504.73,504.73,0,0,1-111.35-65l7.28,5.63a508.39,508.39,0,0,1-90.59-90.59l5.63,7.28a504.73,504.73,0,0,1-65-111.35l3.63,8.6a502,502,0,0,1-34.62-127.3l1.28,9.57a510.21,510.21,0,0,1,0-133.42l-1.28,9.57a502,502,0,0,1,34.62-127.3l-3.63,8.6a504.73,504.73,0,0,1,65-111.35l-5.63,7.28a508.39,508.39,0,0,1,90.59-90.59l-7.28,5.63a504.73,504.73,0,0,1,111.35-65l-8.6,3.63a502,502,0,0,1,127.3-34.62l-9.57,1.28a510.21,510.21,0,0,1,133.42,0l-9.57-1.28a502,502,0,0,1,127.3,34.62l-8.6-3.63a504.73,504.73,0,0,1,111.35,65l-7.28-5.63a508.39,508.39,0,0,1,90.59,90.59l-5.63-7.28a504.73,504.73,0,0,1,65,111.35l-3.63-8.6a502,502,0,0,1,34.62,127.3l-1.28-9.57A515,515,0,0,1,1464,1000c0,18.83,16.54,36.87,36,36s36-15.82,36-36c-.08-55.42-8.52-111.43-25.65-164.17-16.55-51-40.5-99.86-71.58-143.54a557.67,557.67,0,0,0-116.28-120.13c-42.63-32.66-90.23-57.89-140.61-76.39-51.82-19-107.06-29.25-162.18-31.39a533.2,533.2,0,0,0-312.23,86.46c-44.52,29.28-85.17,64.88-119.06,106A563.88,563.88,0,0,0,540,724.75c-14.59,24.29-26.7,49.77-37.45,76-20.92,51-32.75,105.19-37,160.07a534.52,534.52,0,0,0,177.42,438.88C682.81,1435,727.64,1465,776.25,1487a556.45,556.45,0,0,0,79.53,29.37c28.09,7.84,56.76,12.87,85.71,16.24,55.9,6.52,112.74,3.31,167.85-7.85a534.41,534.41,0,0,0,277.91-154.19c36.66-38.42,68.14-82.48,91.8-130,24.24-48.73,41.65-100.88,49.89-154.72A572.24,572.24,0,0,0,1536,1000c0-18.83-16.58-36.87-36-36S1464,979.82,1464,1000Z" transform="translate(-463.99 -463.99)" /> - <path d="M807.35,1347.71a396.14,396.14,0,0,0,193.6,51c12.55,0,24.58-11.06,24-24s-10.55-24-24-24c-15.93,0-32.51-.36-48.23-3.13,6.6,1.17.54,0-.61-.12q-3-.46-6.09-1-5.61-.94-11.21-2.06-11.52-2.3-22.89-5.34a365.56,365.56,0,0,1-43.22-14.52c-1.06-.42-2.11-.86-3.16-1.3l2.58,1.09a64.07,64.07,0,0,1-6.25-2.75q-4.65-2.1-9.25-4.33c-7.14-3.46-14.15-7.17-21-11.07-10.93-6.17-26.72-3-32.83,8.61-6,11.37-3.07,26.25,8.61,32.84Z" transform="translate(-463.99 -463.99)" /> - <path d="M723.68,729.37c-55.5,55.73-94.41,128.21-108.25,205.78A398.38,398.38,0,0,0,647.77,1177a391.15,391.15,0,0,0,75.91,106.88c8.87,8.91,25.18,9.54,33.94,0s9.47-24.43,0-33.94q-9.81-9.85-18.91-20.38-4-4.68-7.9-9.51c-.65-.8-1.29-1.61-1.93-2.41s-2.5-4.54-1-1.24c1.37,3,.22.27-.47-.64s-1.55-2.07-2.31-3.11a367.19,367.19,0,0,1-29.26-46.63Q689.5,1154,684,1141.42c-.41-1-.8-1.93-1.23-2.88,2.49,5.5,1.11,2.65.6,1.39-.78-1.94-1.57-3.88-2.33-5.83q-2.73-7-5.16-14a367.36,367.36,0,0,1-14-53.89c-.81-4.51-1.52-9-2.18-13.55-.63-4.26.16,1,.2,1.56-.08-1.07-.27-2.16-.4-3.22q-.5-4.05-.88-8.1-1.4-14.33-1.69-28.74t.55-29q.39-7,1.06-13.89.35-3.66.77-7.3c.12-1.08.26-2.15.38-3.23.59-4.9-.79,5.22,0-.05A386.85,386.85,0,0,1,671.76,906q4-13.21,9-26.09c.76-2,1.53-3.9,2.32-5.84l.3-.73c1-2.4.79-1.93-.6,1.39a14,14,0,0,1,1.23-2.88q2.82-6.45,5.87-12.79a367.61,367.61,0,0,1,30.71-52.19q2.24-3.17,4.53-6.28c1-1.43,6.28-8,1.84-2.55,3.24-4,6.41-8.07,9.74-12q10-11.79,20.91-22.73c8.87-8.91,9.56-25.16,0-33.94s-24.48-9.51-33.94,0Z" transform="translate(-463.99 -463.99)" /> - </g> -</svg> diff --git a/docs/public/hyperglass-icon-light.svg b/docs/public/hyperglass-icon-light.svg deleted file mode 100644 index 7dfbb562a..000000000 --- a/docs/public/hyperglass-icon-light.svg +++ /dev/null @@ -1,7 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1072 1072"> - <g fill="#40798C"> - <path d="M1464,1000a515,515,0,0,1-4.41,66.71l1.28-9.57a502,502,0,0,1-34.62,127.3l3.63-8.6a504.73,504.73,0,0,1-65,111.35l5.63-7.28a508.39,508.39,0,0,1-90.59,90.59l7.28-5.63a504.73,504.73,0,0,1-111.35,65l8.6-3.63a502,502,0,0,1-127.3,34.62l9.57-1.28a510.21,510.21,0,0,1-133.42,0l9.57,1.28a502,502,0,0,1-127.3-34.62l8.6,3.63a504.73,504.73,0,0,1-111.35-65l7.28,5.63a508.39,508.39,0,0,1-90.59-90.59l5.63,7.28a504.73,504.73,0,0,1-65-111.35l3.63,8.6a502,502,0,0,1-34.62-127.3l1.28,9.57a510.21,510.21,0,0,1,0-133.42l-1.28,9.57a502,502,0,0,1,34.62-127.3l-3.63,8.6a504.73,504.73,0,0,1,65-111.35l-5.63,7.28a508.39,508.39,0,0,1,90.59-90.59l-7.28,5.63a504.73,504.73,0,0,1,111.35-65l-8.6,3.63a502,502,0,0,1,127.3-34.62l-9.57,1.28a510.21,510.21,0,0,1,133.42,0l-9.57-1.28a502,502,0,0,1,127.3,34.62l-8.6-3.63a504.73,504.73,0,0,1,111.35,65l-7.28-5.63a508.39,508.39,0,0,1,90.59,90.59l-5.63-7.28a504.73,504.73,0,0,1,65,111.35l-3.63-8.6a502,502,0,0,1,34.62,127.3l-1.28-9.57A515,515,0,0,1,1464,1000c0,18.83,16.54,36.87,36,36s36-15.82,36-36c-.08-55.42-8.52-111.43-25.65-164.17-16.55-51-40.5-99.86-71.58-143.54a557.67,557.67,0,0,0-116.28-120.13c-42.63-32.66-90.23-57.89-140.61-76.39-51.82-19-107.06-29.25-162.18-31.39a533.2,533.2,0,0,0-312.23,86.46c-44.52,29.28-85.17,64.88-119.06,106A563.88,563.88,0,0,0,540,724.75c-14.59,24.29-26.7,49.77-37.45,76-20.92,51-32.75,105.19-37,160.07a534.52,534.52,0,0,0,177.42,438.88C682.81,1435,727.64,1465,776.25,1487a556.45,556.45,0,0,0,79.53,29.37c28.09,7.84,56.76,12.87,85.71,16.24,55.9,6.52,112.74,3.31,167.85-7.85a534.41,534.41,0,0,0,277.91-154.19c36.66-38.42,68.14-82.48,91.8-130,24.24-48.73,41.65-100.88,49.89-154.72A572.24,572.24,0,0,0,1536,1000c0-18.83-16.58-36.87-36-36S1464,979.82,1464,1000Z" transform="translate(-463.99 -463.99)" /> - <path d="M807.35,1347.71a396.14,396.14,0,0,0,193.6,51c12.55,0,24.58-11.06,24-24s-10.55-24-24-24c-15.93,0-32.51-.36-48.23-3.13,6.6,1.17.54,0-.61-.12q-3-.46-6.09-1-5.61-.94-11.21-2.06-11.52-2.3-22.89-5.34a365.56,365.56,0,0,1-43.22-14.52c-1.06-.42-2.11-.86-3.16-1.3l2.58,1.09a64.07,64.07,0,0,1-6.25-2.75q-4.65-2.1-9.25-4.33c-7.14-3.46-14.15-7.17-21-11.07-10.93-6.17-26.72-3-32.83,8.61-6,11.37-3.07,26.25,8.61,32.84Z" transform="translate(-463.99 -463.99)" /> - <path d="M723.68,729.37c-55.5,55.73-94.41,128.21-108.25,205.78A398.38,398.38,0,0,0,647.77,1177a391.15,391.15,0,0,0,75.91,106.88c8.87,8.91,25.18,9.54,33.94,0s9.47-24.43,0-33.94q-9.81-9.85-18.91-20.38-4-4.68-7.9-9.51c-.65-.8-1.29-1.61-1.93-2.41s-2.5-4.54-1-1.24c1.37,3,.22.27-.47-.64s-1.55-2.07-2.31-3.11a367.19,367.19,0,0,1-29.26-46.63Q689.5,1154,684,1141.42c-.41-1-.8-1.93-1.23-2.88,2.49,5.5,1.11,2.65.6,1.39-.78-1.94-1.57-3.88-2.33-5.83q-2.73-7-5.16-14a367.36,367.36,0,0,1-14-53.89c-.81-4.51-1.52-9-2.18-13.55-.63-4.26.16,1,.2,1.56-.08-1.07-.27-2.16-.4-3.22q-.5-4.05-.88-8.1-1.4-14.33-1.69-28.74t.55-29q.39-7,1.06-13.89.35-3.66.77-7.3c.12-1.08.26-2.15.38-3.23.59-4.9-.79,5.22,0-.05A386.85,386.85,0,0,1,671.76,906q4-13.21,9-26.09c.76-2,1.53-3.9,2.32-5.84l.3-.73c1-2.4.79-1.93-.6,1.39a14,14,0,0,1,1.23-2.88q2.82-6.45,5.87-12.79a367.61,367.61,0,0,1,30.71-52.19q2.24-3.17,4.53-6.28c1-1.43,6.28-8,1.84-2.55,3.24-4,6.41-8.07,9.74-12q10-11.79,20.91-22.73c8.87-8.91,9.56-25.16,0-33.94s-24.48-9.51-33.94,0Z" transform="translate(-463.99 -463.99)" /> - </g> -</svg> diff --git a/docs/public/hyperglass-light.svg b/docs/public/hyperglass-light.svg deleted file mode 100644 index b2bb038e7..000000000 --- a/docs/public/hyperglass-light.svg +++ /dev/null @@ -1,21 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2759.9 544.87"> - <g fill="#40798C"> - <g> - <path d="M931.39,683.15q20.28,21.23,20.28,62.74l0,134.13c0,3.35-.85,5.94-2.51,7.76a10.52,10.52,0,0,1-14.15,0c-1.67-1.82-2.51-4.4-2.5-7.75l0-131.86q0-35.58-14.12-51.79t-45.62-16.21q-35.13,0-56.35,21.65t-21.24,57.71l0,120.44q0,5-2.51,7.76a9.12,9.12,0,0,1-7.07,2.74,9,9,0,0,1-6.84-3A10.66,10.66,0,0,1,776,880l.12-305.23q0-5,2.51-7.76a10.52,10.52,0,0,1,14.15,0c1.66,1.83,2.5,4.41,2.5,7.76l0,131.39a71.74,71.74,0,0,1,30.13-32.83q20.07-11.4,46.08-11.39Q911.09,661.93,931.39,683.15Z" transform="translate(-102 -476.45)" /> - <path d="M1201.26,663a9.19,9.19,0,0,1,6.39,2.51,8.56,8.56,0,0,1,2.73,6.62,14.35,14.35,0,0,1-.91,4.1l-131,290.12q-2.75,5.48-8.22,5.47a8.83,8.83,0,0,1-6.16-2.28,7.66,7.66,0,0,1-2.5-5.93l.91-3.65,32.88-72.08L999.6,676.1a14.33,14.33,0,0,1-.91-4.11,8.65,8.65,0,0,1,3.19-6.84,10.93,10.93,0,0,1,7.3-2.73q5,0,7.76,5.48l88,198.49.91-2.28,86.76-194.78C1194.42,665.08,1197.31,662.94,1201.26,663Z" transform="translate(-102 -476.45)" /> - <path d="M1406.57,676.48q22.34,14.38,34.66,40.39t12.29,60.69q0,34.22-12.34,60t-34.69,39.91q-22.37,14.13-51.56,14.12-27.84,0-47.67-12.33t-29.88-35.6l0,118.62c0,3.35-.84,5.93-2.51,7.76a9.15,9.15,0,0,1-7.07,2.73q-9.59,0-9.58-10.5l.11-289.25a10.34,10.34,0,0,1,2.51-7.3,10.49,10.49,0,0,1,14.14,0,10.42,10.42,0,0,1,2.51,7.3v36q10-22.81,29.89-34.9T1355,662.09Q1384.21,662.11,1406.57,676.48ZM1412,848q20.54-24.85,20.55-70.48T1412,706.37q-20.52-25.56-57-25.57-37.41,0-57.5,24.84t-20.1,70.94q0,46.09,20,71.18t57,25.12Q1391.44,872.9,1412,848Z" transform="translate(-102 -476.45)" /> - <path d="M1688.91,847.68a8.28,8.28,0,0,1,2.28,5.71,14.17,14.17,0,0,1-3.43,8.89q-3.42,4.35-12.09,9.81-32.85,19.6-68,19.59-49.73,0-78.23-30.6t-28.49-84q0-34.22,12.8-60.22t36.06-40.36q23.28-14.37,53.39-14.35,42,0,66.14,27.4t24.15,74.83q0,8.21-3.19,11.86t-11.41,3.65l-157-.06q.43,44.71,22.56,68.9t62.27,24.2q21.9,0,36.27-5.91a146.07,146.07,0,0,0,28.07-15.51q9.57-6.37,12.32-6.38A7.16,7.16,0,0,1,1688.91,847.68Zm-141.61-146q-21.22,21.68-24.89,60.44l152.85.06q-.91-39.69-19.6-60.91T1603.2,680Q1568.52,680,1547.3,701.63Z" transform="translate(-102 -476.45)" /> - <path d="M1855.05,663.19c3.65,0,6.16.69,7.52,2.06s2.06,3.42,2.06,6.16q0,7.77-9.59,9.12l-13.69,1.37q-33.3,3.18-50.19,24.61a77.43,77.43,0,0,0-16.9,49.27l0,124.56q0,5-2.51,7.75a10.5,10.5,0,0,1-14.15,0c-1.67-1.83-2.5-4.41-2.5-7.76l.08-206.68q0-10.49,9.58-10.49a9.25,9.25,0,0,1,6.84,2.74c1.83,1.83,2.74,4.41,2.74,7.76v32.85q19.62-39.22,71.19-42.86l6.38-.46Z" transform="translate(-102 -476.45)" /> - <path d="M2078.6,666a9.84,9.84,0,0,1,2.74,7.3l-.08,204.85q0,47.45-22.38,71.62T1992.26,974q-24.65,0-43.34-4.58a127.8,127.8,0,0,1-36-15.07q-8.67-5.47-12.32-9.81a13.79,13.79,0,0,1-3.64-8.9,7.82,7.82,0,0,1,8.21-8.21q2.28,0,11.86,5.48a204.67,204.67,0,0,0,32.85,16q16.86,6.39,43.34,6.4,33.75,0,51.56-18.91t17.81-55.43l0-47.9q-9.59,23.71-29.9,36.26t-49.05,12.53q-29.2,0-51.78-13.71t-35.11-38.8q-12.54-25.08-12.53-57.49t12.57-57.25q12.56-24.85,35.15-38.54t51.79-13.67q28.27,0,48.35,12.34t30.1,35.14l0-36q0-10.5,9.58-10.49A9.25,9.25,0,0,1,2078.6,666Zm-37.25,172.67q20.78-24.39,20.79-66.83t-20.73-66.62q-20.76-24.18-57.71-24.2-36.51,0-57.72,24.16t-21.25,66.6q0,42.88,21.19,67.08t57.71,24.2Q2020.59,863.1,2041.35,838.69Z" transform="translate(-102 -476.45)" /> - <path d="M2161.32,888.24c-1.67-1.83-2.51-4.41-2.5-7.76l.11-305.22q0-5,2.51-7.76a9.09,9.09,0,0,1,7.08-2.73,9,9,0,0,1,6.84,3,10.73,10.73,0,0,1,2.73,7.53L2178,880.49a10.75,10.75,0,0,1-2.74,7.53,9,9,0,0,1-6.85,3A9.09,9.09,0,0,1,2161.32,888.24Z" transform="translate(-102 -476.45)" /> - <path d="M2416.64,744.16l0,136q0,5-2.51,7.76a9.12,9.12,0,0,1-7.08,2.73,9.45,9.45,0,0,1-7.29-3,10.66,10.66,0,0,1-2.74-7.53V848.18q-10,21-29,32.38T2324.88,892a84.41,84.41,0,0,1-38.32-8.68,71.93,71.93,0,0,1-27.6-23.51,59.47,59.47,0,0,1-10.71-33.08q-.43-25.08,12.34-38.78t42.89-19.6q30.12-5.91,84.41-5.9H2397V743.24q0-32.38-13-47.22t-40.83-14.85q-21.9,0-39,5.69t-27.15,13q-1.83,1.38-7.53,5.25c-3.81,2.59-6.93,3.87-9.36,3.87a7.82,7.82,0,0,1-5.7-2.51,8.37,8.37,0,0,1-2.51-6.16q0-8.65,16-18.24a134.23,134.23,0,0,1,34.91-14.59,146.13,146.13,0,0,1,37.64-5Q2416.67,662.5,2416.64,744.16ZM2361.85,863a69.15,69.15,0,0,0,25.78-27.13q9.36-17.34,9.37-39.24V780.2h-8.67q-47.91,0-73,3.85t-35.14,13.21q-10,9.36-10,27.6,0,21,15.49,34.46t39.24,13.47Q2345.41,872.79,2361.85,863Z" transform="translate(-102 -476.45)" /> - <path d="M2526.54,886.78a122.09,122.09,0,0,1-31.93-14.38q-9.14-5.92-12.32-9.82a13.55,13.55,0,0,1-3.19-8.89,8.24,8.24,0,0,1,2.29-5.71,7.17,7.17,0,0,1,5.47-2.5q2.28,0,6.16,2.51c2.58,1.67,4.33,2.82,5.25,3.42a142.87,142.87,0,0,0,27.59,15.75q14.37,6.17,36.27,6.18,29.65,0,45.63-10.94t16-31q0-11.85-5.7-19.62t-18.7-13.46q-13-5.7-37.17-11.19Q2522,778,2504.69,763.82t-17.33-37.42q0-28.29,22.15-46.08t57.26-17.77a114.74,114.74,0,0,1,32.4,4.58,95.21,95.21,0,0,1,27.37,12.78q8.65,6.4,12.54,11.19a15.49,15.49,0,0,1,3.87,9.81,8.24,8.24,0,0,1-2.28,5.7,7.18,7.18,0,0,1-5.48,2.51c-2.44,0-6.38-2.29-11.86-6.85a127.56,127.56,0,0,0-24.86-15.07q-13-5.93-32.62-5.94-25.54,0-41.29,12.07T2508.81,725q0,11.87,5,19.39t17.1,13q12.09,5.47,34.45,10.5,31.47,7.78,48.35,15.76t24,19.18q7.07,11.18,7.06,28.51,0,27.38-22.6,44t-60,16.63A125.17,125.17,0,0,1,2526.54,886.78Z" transform="translate(-102 -476.45)" /> - <path d="M2743.71,886.86a121.76,121.76,0,0,1-31.93-14.38q-9.14-5.93-12.32-9.81a13.6,13.6,0,0,1-3.19-8.9,8.25,8.25,0,0,1,2.29-5.7,7.14,7.14,0,0,1,5.47-2.51q2.28,0,6.16,2.51l5.25,3.42a143.22,143.22,0,0,0,27.6,15.76q14.36,6.16,36.26,6.17,29.65,0,45.63-10.93t16-31q0-11.87-5.69-19.62t-18.7-13.47q-13-5.7-37.18-11.19-40.15-9.13-57.48-23.29t-17.32-37.42q0-28.29,22.14-46.07T2784,662.64a114.31,114.31,0,0,1,32.39,4.57A94.83,94.83,0,0,1,2843.71,680q8.65,6.39,12.54,11.18a15.51,15.51,0,0,1,3.87,9.81,8.24,8.24,0,0,1-2.28,5.7,7.12,7.12,0,0,1-5.47,2.51q-3.66,0-11.86-6.85a128.65,128.65,0,0,0-24.86-15.07q-13-5.92-32.62-5.94-25.56,0-41.3,12.08T2726,725.12q0,11.86,5,19.39t17.1,13q12.09,5.47,34.45,10.51,31.47,7.77,48.35,15.76t24,19.17q7.07,11.19,7.06,28.51,0,27.38-22.6,44t-60,16.62A125.19,125.19,0,0,1,2743.71,886.86Z" transform="translate(-102 -476.45)" /> - </g> - <g> - <path d="M629,748.94A254.5,254.5,0,0,1,177.8,910.5a253.51,253.51,0,0,1-57-141.26A254.75,254.75,0,0,1,269.49,517c48.18-22.05,103.68-28.13,155.56-17.55a253.76,253.76,0,0,1,204,249.5c0,11.58,18,11.6,18,0-.15-56.09-17.45-111.67-50.07-157.4A275.77,275.77,0,0,0,468,492.94c-53.4-19.67-113.16-21.73-167.89-6.22A272.65,272.65,0,0,0,141.23,889.87c28.26,46.62,70.66,84.65,120.38,107.11a275.73,275.73,0,0,0,167.55,18.95,272.71,272.71,0,0,0,142.15-78.52c38.14-39.87,64-91.3,72.33-145.91A287.4,287.4,0,0,0,647,748.94C647,737.36,629,737.34,629,748.94Z" transform="translate(-102 -476.45)" /> - <path d="M276.33,926.44a203,203,0,0,0,98.67,26c7.72,0,7.73-12,0-12a190.29,190.29,0,0,1-92.62-24.36c-6.74-3.79-12.79,6.58-6.05,10.36Z" transform="translate(-102 -476.45)" /> - <path d="M233.58,611c-28.56,28.78-48.31,65.84-55.4,105.83a199.79,199.79,0,0,0,55.4,177c5.45,5.49,13.93-3,8.48-8.48C186.68,829.56,171,742.65,205.27,671.45a191.94,191.94,0,0,1,36.79-52c5.45-5.48-3-14-8.48-8.48Z" transform="translate(-102 -476.45)" /> - </g> - </g> -</svg> diff --git a/docs/public/hyperglass.svg b/docs/public/hyperglass.svg deleted file mode 100644 index bcfd80da0..000000000 --- a/docs/public/hyperglass.svg +++ /dev/null @@ -1,21 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2759.9 544.87"> - <g fill="currentColor"> - <g> - <path d="M931.39,683.15q20.28,21.23,20.28,62.74l0,134.13c0,3.35-.85,5.94-2.51,7.76a10.52,10.52,0,0,1-14.15,0c-1.67-1.82-2.51-4.4-2.5-7.75l0-131.86q0-35.58-14.12-51.79t-45.62-16.21q-35.13,0-56.35,21.65t-21.24,57.71l0,120.44q0,5-2.51,7.76a9.12,9.12,0,0,1-7.07,2.74,9,9,0,0,1-6.84-3A10.66,10.66,0,0,1,776,880l.12-305.23q0-5,2.51-7.76a10.52,10.52,0,0,1,14.15,0c1.66,1.83,2.5,4.41,2.5,7.76l0,131.39a71.74,71.74,0,0,1,30.13-32.83q20.07-11.4,46.08-11.39Q911.09,661.93,931.39,683.15Z" transform="translate(-102 -476.45)" /> - <path d="M1201.26,663a9.19,9.19,0,0,1,6.39,2.51,8.56,8.56,0,0,1,2.73,6.62,14.35,14.35,0,0,1-.91,4.1l-131,290.12q-2.75,5.48-8.22,5.47a8.83,8.83,0,0,1-6.16-2.28,7.66,7.66,0,0,1-2.5-5.93l.91-3.65,32.88-72.08L999.6,676.1a14.33,14.33,0,0,1-.91-4.11,8.65,8.65,0,0,1,3.19-6.84,10.93,10.93,0,0,1,7.3-2.73q5,0,7.76,5.48l88,198.49.91-2.28,86.76-194.78C1194.42,665.08,1197.31,662.94,1201.26,663Z" transform="translate(-102 -476.45)" /> - <path d="M1406.57,676.48q22.34,14.38,34.66,40.39t12.29,60.69q0,34.22-12.34,60t-34.69,39.91q-22.37,14.13-51.56,14.12-27.84,0-47.67-12.33t-29.88-35.6l0,118.62c0,3.35-.84,5.93-2.51,7.76a9.15,9.15,0,0,1-7.07,2.73q-9.59,0-9.58-10.5l.11-289.25a10.34,10.34,0,0,1,2.51-7.3,10.49,10.49,0,0,1,14.14,0,10.42,10.42,0,0,1,2.51,7.3v36q10-22.81,29.89-34.9T1355,662.09Q1384.21,662.11,1406.57,676.48ZM1412,848q20.54-24.85,20.55-70.48T1412,706.37q-20.52-25.56-57-25.57-37.41,0-57.5,24.84t-20.1,70.94q0,46.09,20,71.18t57,25.12Q1391.44,872.9,1412,848Z" transform="translate(-102 -476.45)" /> - <path d="M1688.91,847.68a8.28,8.28,0,0,1,2.28,5.71,14.17,14.17,0,0,1-3.43,8.89q-3.42,4.35-12.09,9.81-32.85,19.6-68,19.59-49.73,0-78.23-30.6t-28.49-84q0-34.22,12.8-60.22t36.06-40.36q23.28-14.37,53.39-14.35,42,0,66.14,27.4t24.15,74.83q0,8.21-3.19,11.86t-11.41,3.65l-157-.06q.43,44.71,22.56,68.9t62.27,24.2q21.9,0,36.27-5.91a146.07,146.07,0,0,0,28.07-15.51q9.57-6.37,12.32-6.38A7.16,7.16,0,0,1,1688.91,847.68Zm-141.61-146q-21.22,21.68-24.89,60.44l152.85.06q-.91-39.69-19.6-60.91T1603.2,680Q1568.52,680,1547.3,701.63Z" transform="translate(-102 -476.45)" /> - <path d="M1855.05,663.19c3.65,0,6.16.69,7.52,2.06s2.06,3.42,2.06,6.16q0,7.77-9.59,9.12l-13.69,1.37q-33.3,3.18-50.19,24.61a77.43,77.43,0,0,0-16.9,49.27l0,124.56q0,5-2.51,7.75a10.5,10.5,0,0,1-14.15,0c-1.67-1.83-2.5-4.41-2.5-7.76l.08-206.68q0-10.49,9.58-10.49a9.25,9.25,0,0,1,6.84,2.74c1.83,1.83,2.74,4.41,2.74,7.76v32.85q19.62-39.22,71.19-42.86l6.38-.46Z" transform="translate(-102 -476.45)" /> - <path d="M2078.6,666a9.84,9.84,0,0,1,2.74,7.3l-.08,204.85q0,47.45-22.38,71.62T1992.26,974q-24.65,0-43.34-4.58a127.8,127.8,0,0,1-36-15.07q-8.67-5.47-12.32-9.81a13.79,13.79,0,0,1-3.64-8.9,7.82,7.82,0,0,1,8.21-8.21q2.28,0,11.86,5.48a204.67,204.67,0,0,0,32.85,16q16.86,6.39,43.34,6.4,33.75,0,51.56-18.91t17.81-55.43l0-47.9q-9.59,23.71-29.9,36.26t-49.05,12.53q-29.2,0-51.78-13.71t-35.11-38.8q-12.54-25.08-12.53-57.49t12.57-57.25q12.56-24.85,35.15-38.54t51.79-13.67q28.27,0,48.35,12.34t30.1,35.14l0-36q0-10.5,9.58-10.49A9.25,9.25,0,0,1,2078.6,666Zm-37.25,172.67q20.78-24.39,20.79-66.83t-20.73-66.62q-20.76-24.18-57.71-24.2-36.51,0-57.72,24.16t-21.25,66.6q0,42.88,21.19,67.08t57.71,24.2Q2020.59,863.1,2041.35,838.69Z" transform="translate(-102 -476.45)" /> - <path d="M2161.32,888.24c-1.67-1.83-2.51-4.41-2.5-7.76l.11-305.22q0-5,2.51-7.76a9.09,9.09,0,0,1,7.08-2.73,9,9,0,0,1,6.84,3,10.73,10.73,0,0,1,2.73,7.53L2178,880.49a10.75,10.75,0,0,1-2.74,7.53,9,9,0,0,1-6.85,3A9.09,9.09,0,0,1,2161.32,888.24Z" transform="translate(-102 -476.45)" /> - <path d="M2416.64,744.16l0,136q0,5-2.51,7.76a9.12,9.12,0,0,1-7.08,2.73,9.45,9.45,0,0,1-7.29-3,10.66,10.66,0,0,1-2.74-7.53V848.18q-10,21-29,32.38T2324.88,892a84.41,84.41,0,0,1-38.32-8.68,71.93,71.93,0,0,1-27.6-23.51,59.47,59.47,0,0,1-10.71-33.08q-.43-25.08,12.34-38.78t42.89-19.6q30.12-5.91,84.41-5.9H2397V743.24q0-32.38-13-47.22t-40.83-14.85q-21.9,0-39,5.69t-27.15,13q-1.83,1.38-7.53,5.25c-3.81,2.59-6.93,3.87-9.36,3.87a7.82,7.82,0,0,1-5.7-2.51,8.37,8.37,0,0,1-2.51-6.16q0-8.65,16-18.24a134.23,134.23,0,0,1,34.91-14.59,146.13,146.13,0,0,1,37.64-5Q2416.67,662.5,2416.64,744.16ZM2361.85,863a69.15,69.15,0,0,0,25.78-27.13q9.36-17.34,9.37-39.24V780.2h-8.67q-47.91,0-73,3.85t-35.14,13.21q-10,9.36-10,27.6,0,21,15.49,34.46t39.24,13.47Q2345.41,872.79,2361.85,863Z" transform="translate(-102 -476.45)" /> - <path d="M2526.54,886.78a122.09,122.09,0,0,1-31.93-14.38q-9.14-5.92-12.32-9.82a13.55,13.55,0,0,1-3.19-8.89,8.24,8.24,0,0,1,2.29-5.71,7.17,7.17,0,0,1,5.47-2.5q2.28,0,6.16,2.51c2.58,1.67,4.33,2.82,5.25,3.42a142.87,142.87,0,0,0,27.59,15.75q14.37,6.17,36.27,6.18,29.65,0,45.63-10.94t16-31q0-11.85-5.7-19.62t-18.7-13.46q-13-5.7-37.17-11.19Q2522,778,2504.69,763.82t-17.33-37.42q0-28.29,22.15-46.08t57.26-17.77a114.74,114.74,0,0,1,32.4,4.58,95.21,95.21,0,0,1,27.37,12.78q8.65,6.4,12.54,11.19a15.49,15.49,0,0,1,3.87,9.81,8.24,8.24,0,0,1-2.28,5.7,7.18,7.18,0,0,1-5.48,2.51c-2.44,0-6.38-2.29-11.86-6.85a127.56,127.56,0,0,0-24.86-15.07q-13-5.93-32.62-5.94-25.54,0-41.29,12.07T2508.81,725q0,11.87,5,19.39t17.1,13q12.09,5.47,34.45,10.5,31.47,7.78,48.35,15.76t24,19.18q7.07,11.18,7.06,28.51,0,27.38-22.6,44t-60,16.63A125.17,125.17,0,0,1,2526.54,886.78Z" transform="translate(-102 -476.45)" /> - <path d="M2743.71,886.86a121.76,121.76,0,0,1-31.93-14.38q-9.14-5.93-12.32-9.81a13.6,13.6,0,0,1-3.19-8.9,8.25,8.25,0,0,1,2.29-5.7,7.14,7.14,0,0,1,5.47-2.51q2.28,0,6.16,2.51l5.25,3.42a143.22,143.22,0,0,0,27.6,15.76q14.36,6.16,36.26,6.17,29.65,0,45.63-10.93t16-31q0-11.87-5.69-19.62t-18.7-13.47q-13-5.7-37.18-11.19-40.15-9.13-57.48-23.29t-17.32-37.42q0-28.29,22.14-46.07T2784,662.64a114.31,114.31,0,0,1,32.39,4.57A94.83,94.83,0,0,1,2843.71,680q8.65,6.39,12.54,11.18a15.51,15.51,0,0,1,3.87,9.81,8.24,8.24,0,0,1-2.28,5.7,7.12,7.12,0,0,1-5.47,2.51q-3.66,0-11.86-6.85a128.65,128.65,0,0,0-24.86-15.07q-13-5.92-32.62-5.94-25.56,0-41.3,12.08T2726,725.12q0,11.86,5,19.39t17.1,13q12.09,5.47,34.45,10.51,31.47,7.77,48.35,15.76t24,19.17q7.07,11.19,7.06,28.51,0,27.38-22.6,44t-60,16.62A125.19,125.19,0,0,1,2743.71,886.86Z" transform="translate(-102 -476.45)" /> - </g> - <g> - <path d="M629,748.94A254.5,254.5,0,0,1,177.8,910.5a253.51,253.51,0,0,1-57-141.26A254.75,254.75,0,0,1,269.49,517c48.18-22.05,103.68-28.13,155.56-17.55a253.76,253.76,0,0,1,204,249.5c0,11.58,18,11.6,18,0-.15-56.09-17.45-111.67-50.07-157.4A275.77,275.77,0,0,0,468,492.94c-53.4-19.67-113.16-21.73-167.89-6.22A272.65,272.65,0,0,0,141.23,889.87c28.26,46.62,70.66,84.65,120.38,107.11a275.73,275.73,0,0,0,167.55,18.95,272.71,272.71,0,0,0,142.15-78.52c38.14-39.87,64-91.3,72.33-145.91A287.4,287.4,0,0,0,647,748.94C647,737.36,629,737.34,629,748.94Z" transform="translate(-102 -476.45)" /> - <path d="M276.33,926.44a203,203,0,0,0,98.67,26c7.72,0,7.73-12,0-12a190.29,190.29,0,0,1-92.62-24.36c-6.74-3.79-12.79,6.58-6.05,10.36Z" transform="translate(-102 -476.45)" /> - <path d="M233.58,611c-28.56,28.78-48.31,65.84-55.4,105.83a199.79,199.79,0,0,0,55.4,177c5.45,5.49,13.93-3,8.48-8.48C186.68,829.56,171,742.65,205.27,671.45a191.94,191.94,0,0,1,36.79-52c5.45-5.48-3-14-8.48-8.48Z" transform="translate(-102 -476.45)" /> - </g> - </g> -</svg> diff --git a/docs/public/opengraph.jpg b/docs/public/opengraph.jpg index 3d621d9ae685aa7ce30ab537aaff8f4504a76c6f..a3c0942c2b3030a40291ae4e9684189c4202bfbb 100644 GIT binary patch literal 34804 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%0DvE`Gxqdu_N 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 N5*|s1ll~W1|JkC(zlZ<; literal 46154 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%0AF8+t+~ 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 diff --git a/docs/public/robots.txt b/docs/public/robots.txt index 296c638cf..5096d969d 100644 --- a/docs/public/robots.txt +++ b/docs/public/robots.txt @@ -1,3 +1,2 @@ User-agent: * Disallow: /*__* -Sitemap: https://hyperglass.dev/sitemap.xml \ No newline at end of file diff --git a/docs/public/ultraglass-dark.svg b/docs/public/ultraglass-dark.svg new file mode 100644 index 000000000..d7879b18a --- /dev/null +++ b/docs/public/ultraglass-dark.svg @@ -0,0 +1,12 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 128" role="img" aria-labelledby="title"> + <title id="title">Ultraglass + + + + + + + + + Ultraglass + diff --git a/docs/public/ultraglass-icon.svg b/docs/public/ultraglass-icon.svg new file mode 100644 index 000000000..4a47c6fef --- /dev/null +++ b/docs/public/ultraglass-icon.svg @@ -0,0 +1,12 @@ + + Ultraglass icon + + + + + + + + + + diff --git a/docs/public/ultraglass-light.svg b/docs/public/ultraglass-light.svg new file mode 100644 index 000000000..41de21346 --- /dev/null +++ b/docs/public/ultraglass-light.svg @@ -0,0 +1,12 @@ + + Ultraglass + + + + + + + + + Ultraglass + diff --git a/hyperglass/__init__.py b/hyperglass/__init__.py index 94e20181f..7a6d9b826 100644 --- a/hyperglass/__init__.py +++ b/hyperglass/__init__.py @@ -1,6 +1,6 @@ -"""hyperglass is a modern, customizable network looking glass. +"""Ultraglass is a modern, customisable network looking glass. -https://github.com/thatmattlove/hyperglass +The internal `hyperglass` package namespace is retained for deployment compatibility. The Clear BSD License diff --git a/hyperglass/cli/installer.py b/hyperglass/cli/installer.py index eeb760dd6..c5eb0d12e 100644 --- a/hyperglass/cli/installer.py +++ b/hyperglass/cli/installer.py @@ -150,7 +150,7 @@ def copy_func(src: str, dst: str): if not compare_lists( compare_initial.left_list, compare_initial.right_list, - ignore=["hyperglass-opengraph.jpg"], + ignore=["ultraglass-opengraph.jpg"], ): shutil.rmtree(target_dir) shutil.copytree( @@ -165,7 +165,7 @@ def copy_func(src: str, dst: str): compare_post = dircmp(ASSET_DIR, target_dir, ignore=IGNORED_FILES) if not compare_lists( - compare_post.left_list, compare_post.right_list, ignore=["hyperglass-opengraph.jpg"] + compare_post.left_list, compare_post.right_list, ignore=["ultraglass-opengraph.jpg"] ): echo.error("Files in {!s} do not match files in {!s}", ASSET_DIR, target_dir) raise typer.Exit(1) diff --git a/hyperglass/cli/main.py b/hyperglass/cli/main.py index 7cc06f601..a66a4371c 100644 --- a/hyperglass/cli/main.py +++ b/hyperglass/cli/main.py @@ -4,6 +4,7 @@ import re import sys import typing as t +from pathlib import Path # Third Party import typer @@ -21,21 +22,21 @@ def _version(value: bool) -> None: raise typer.Exit() -cli = typer.Typer(name="hyperglass", help="hyperglass Command Line Interface", no_args_is_help=True) +cli = typer.Typer(name="Ultraglass", help="Ultraglass command line interface", no_args_is_help=True) def run(): - """Run the hyperglass CLI.""" + """Run the Ultraglass CLI.""" return typer.run(cli()) @cli.callback(name="version") def _version( version: t.Optional[bool] = typer.Option( - None, "--version", help="hyperglass version", callback=_version + None, "--version", help="Ultraglass version", callback=_version ), ) -> None: - """hyperglass""" + """Ultraglass""" pass @@ -116,6 +117,35 @@ def _system_info(): echo.plain(table) +@cli.command(name="support-bundle") +def _support_bundle( + output: Path = typer.Option( + Path("ultraglass-support-bundle.json"), "--output", "-o", help="Output JSON file" + ), + log_directory: t.Optional[Path] = typer.Option( + None, "--log-directory", help="Directory containing Ultraglass log files" + ), + log_lines: int = typer.Option( + 200, "--log-lines", min=1, max=5000, help="Maximum recent lines to inspect per log" + ), +) -> None: + """Create a sanitized local bundle for manual bug-report attachment.""" + # Standard Library + from tempfile import gettempdir + + # Project + from hyperglass.settings import Settings + from hyperglass.util.support_bundle import write_support_bundle + + directories = ( + [log_directory] + if log_directory is not None + else [Path(gettempdir()), Path(Settings.app_path)] + ) + bundle_path = write_support_bundle(output, directories, log_lines=log_lines) + echo.success("Wrote sanitized support bundle to {!s}", bundle_path) + + @cli.command(name="clear-cache") def _clear_cache(): """Clear the Redis cache""" diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 965a16693..c5cd2b950 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -14,7 +14,7 @@ # release the fork is based on. __upstream_version__ = "2.0.4" __upstream_url__ = "https://github.com/thatmattlove/hyperglass" -__fork_url__ = "https://github.com/WilhelmZA/hyperglass" +__fork_url__ = "https://github.com/WilhelmZA/ultraglass" METADATA = (__name__, __version__, __author__, __copyright__, __license__) diff --git a/hyperglass/defaults/_strings.py b/hyperglass/defaults/_strings.py index 0b646cecb..8f08d921e 100644 --- a/hyperglass/defaults/_strings.py +++ b/hyperglass/defaults/_strings.py @@ -1,11 +1,11 @@ """Constant store for large default values.""" CREDIT = """ -Powered by [**hyperglass**](https://hyperglass.dev). \ -Source code licensed [_BSD 3-Clause Clear_](https://hyperglass.dev/license/). +Powered by [**Ultraglass**]({fork_url}). \ +Source code licensed [_BSD 3-Clause Clear_]({fork_url}/blob/main/LICENSE). -Running [a fork of hyperglass]({fork_url}) version {version}, \ -based on [upstream]({upstream_url}) version {upstream_version}. +Ultraglass is a fork of [hyperglass]({upstream_url}), version {version}, \ +based on upstream version {upstream_version}. """ DEFAULT_TERMS = """ @@ -47,7 +47,7 @@ "traceroute": """ Performs UDP Based traceroute to the target. \ For information about how to interpret traceroute results, [click here]\ -(https://hyperglass.dev/traceroute_nanog.pdf). +(https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/user-guide.mdx). """, } @@ -82,5 +82,5 @@ Performs UDP Based traceroute to the target. For information about how to interpret traceroute results, [click here]\ -(https://hyperglass.dev/traceroute_nanog.pdf). +(https://github.com/WilhelmZA/ultraglass/blob/main/docs/content/user-guide.mdx). """ diff --git a/hyperglass/images/hyperglass-dark.svg b/hyperglass/images/hyperglass-dark.svg deleted file mode 100644 index b2bb038e7..000000000 --- a/hyperglass/images/hyperglass-dark.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/hyperglass/images/hyperglass-icon.svg b/hyperglass/images/hyperglass-icon.svg deleted file mode 100644 index ea9b648e3..000000000 --- a/hyperglass/images/hyperglass-icon.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/hyperglass/images/hyperglass-light.svg b/hyperglass/images/hyperglass-light.svg deleted file mode 100644 index b93080f43..000000000 --- a/hyperglass/images/hyperglass-light.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/hyperglass/images/hyperglass-opengraph.jpg b/hyperglass/images/hyperglass-opengraph.jpg deleted file mode 100644 index 3d621d9ae685aa7ce30ab537aaff8f4504a76c6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46154 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%0AF8+t+~ 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 diff --git a/hyperglass/images/ultraglass-dark.svg b/hyperglass/images/ultraglass-dark.svg new file mode 100644 index 000000000..d7879b18a --- /dev/null +++ b/hyperglass/images/ultraglass-dark.svg @@ -0,0 +1,12 @@ + + Ultraglass + + + + + + + + + Ultraglass + diff --git a/hyperglass/images/ultraglass-icon.svg b/hyperglass/images/ultraglass-icon.svg new file mode 100644 index 000000000..4a47c6fef --- /dev/null +++ b/hyperglass/images/ultraglass-icon.svg @@ -0,0 +1,12 @@ + + Ultraglass icon + + + + + + + + + + diff --git a/hyperglass/images/ultraglass-light.svg b/hyperglass/images/ultraglass-light.svg new file mode 100644 index 000000000..41de21346 --- /dev/null +++ b/hyperglass/images/ultraglass-light.svg @@ -0,0 +1,12 @@ + + Ultraglass + + + + + + + + + Ultraglass + diff --git a/hyperglass/images/ultraglass-opengraph.jpg b/hyperglass/images/ultraglass-opengraph.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a3c0942c2b3030a40291ae4e9684189c4202bfbb GIT binary patch literal 34804 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 N5*|s1ll~W1|JkC(zlZ<; literal 0 HcmV?d00001 diff --git a/hyperglass/images/ultraglass-opengraph.svg b/hyperglass/images/ultraglass-opengraph.svg new file mode 100644 index 000000000..a13c97ca5 --- /dev/null +++ b/hyperglass/images/ultraglass-opengraph.svg @@ -0,0 +1,15 @@ + + Ultraglass + Network looking glass for operators + + + + + + + + + + Ultraglass + NETWORK LOOKING GLASS + diff --git a/hyperglass/models/api/response.py b/hyperglass/models/api/response.py index 188f0b087..442490eca 100644 --- a/hyperglass/models/api/response.py +++ b/hyperglass/models/api/response.py @@ -202,10 +202,10 @@ class InfoResponse(BaseModel): "description": "General information about this looking glass.", "examples": [ { - "name": "hyperglass", + "name": "Ultraglass", "organization": "Company Name", "primary_asn": 65000, - "version": "hyperglass 1.0.0-beta.52", + "version": "Ultraglass 3.1.0", } ], } diff --git a/hyperglass/models/config/opengraph.py b/hyperglass/models/config/opengraph.py index 5406ca500..55e13c3d0 100644 --- a/hyperglass/models/config/opengraph.py +++ b/hyperglass/models/config/opengraph.py @@ -15,7 +15,7 @@ class OpenGraph(HyperglassModel): """Validation model for params.opengraph.""" - image: FilePath = DEFAULT_IMAGES / "hyperglass-opengraph.jpg" + image: FilePath = DEFAULT_IMAGES / "ultraglass-opengraph.jpg" @field_validator("image") def validate_opengraph(cls, value): diff --git a/hyperglass/models/config/params.py b/hyperglass/models/config/params.py index 6fabf0bc9..12a98f1cc 100644 --- a/hyperglass/models/config/params.py +++ b/hyperglass/models/config/params.py @@ -47,19 +47,19 @@ class ParamsPublic(HyperglassModel): description="Your network's primary ASN. This field is used to set some useful defaults such as the subtitle and PeeringDB URL.", ) org_name: str = Field( - "Beloved Hyperglass User", + "Beloved Ultraglass User", 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.", ) site_title: str = Field( - "hyperglass", + "Ultraglass", title="Site Title", - description="The name of your hyperglass site. This field is used in the UI & API documentation to set fields such as the `` HTML tag, and the terms & conditions footer component.", + description="The name of your Ultraglass site. This field is used in the UI & API documentation to set fields such as the `<title/>` HTML tag, and the terms & conditions footer component.", ) site_description: str = Field( "{org_name} Network Looking Glass", title="Site Description", - description='A short description of your hyperglass site. This field is used in th UI & API documentation to set the `<meta name="description"/>` tag. `{org_name}` may be used to insert the value of the `org_name` field.', + description='A short description of your Ultraglass site. This field is used in th UI & API documentation to set the `<meta name="description"/>` tag. `{org_name}` may be used to insert the value of the `org_name` field.', ) @@ -73,7 +73,7 @@ class Params(ParamsPublic, HyperglassModel): fake_output: bool = Field( False, title="Fake Output", - description="If enabled, the hyperglass backend will return static fake output for development/testing purposes.", + description="If enabled, the Ultraglass backend will return static fake output for development/testing purposes.", ) cors_origins: t.List[str] = Field( [], @@ -131,8 +131,8 @@ def validate_web(cls, web: Web, info: ValidationInfo) -> Web: for menu in web.menus: menu.content = menu.content.format( - site_title=info.data.get("site_title", "hyperglass"), - org_name=info.data.get("org_name", "hyperglass"), + site_title=info.data.get("site_title", "Ultraglass"), + org_name=info.data.get("org_name", "Ultraglass"), version=__version__, ) return web diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index f6881935c..836b113e1 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -83,9 +83,9 @@ def validate_file(cls, value: str, info: ValidationInfo): class Logo(HyperglassModel): """Validation model for logo configuration.""" - light: FilePath = DEFAULT_IMAGES / "hyperglass-light.svg" - dark: FilePath = DEFAULT_IMAGES / "hyperglass-dark.svg" - favicon: FilePath = DEFAULT_IMAGES / "hyperglass-icon.svg" + light: FilePath = DEFAULT_IMAGES / "ultraglass-light.svg" + dark: FilePath = DEFAULT_IMAGES / "ultraglass-dark.svg" + favicon: FilePath = DEFAULT_IMAGES / "ultraglass-icon.svg" width: str = Field(default="50%", pattern=PERCENTAGE_PATTERN) height: t.Optional[str] = Field(default=None, pattern=PERCENTAGE_PATTERN) @@ -101,7 +101,7 @@ class Text(HyperglassModel): """Validation model for params.branding.text.""" title_mode: TitleMode = "logo_only" - title: str = Field(default="hyperglass", max_length=32) + title: str = Field(default="Ultraglass", max_length=32) subtitle: str = Field(default="Network Looking Glass", max_length=32) query_location: str = "Location" query_type: str = "Query Type" diff --git a/hyperglass/models/webhook.py b/hyperglass/models/webhook.py index 2c831935e..22eca74d2 100644 --- a/hyperglass/models/webhook.py +++ b/hyperglass/models/webhook.py @@ -13,8 +13,8 @@ # Local from .main import HyperglassModel -_WEBHOOK_TITLE = "hyperglass received a valid query with the following data" -_ICON_URL = "https://res.cloudinary.com/hyperglass/image/upload/v1593192484/icon.png" +_WEBHOOK_TITLE = "Ultraglass received a valid query with the following data" +_ICON_URL = "https://raw.githubusercontent.com/WilhelmZA/ultraglass/main/hyperglass/images/ultraglass-icon.svg" def to_snake_case(value: str) -> str: diff --git a/hyperglass/ui/components/header/logo.tsx b/hyperglass/ui/components/header/logo.tsx index a5aa6ecdf..1f16dfa83 100644 --- a/hyperglass/ui/components/header/logo.tsx +++ b/hyperglass/ui/components/header/logo.tsx @@ -14,13 +14,13 @@ function useLogo(): [string, () => void] { const src = useColorValue(`/images/light${darkFormat}`, `/images/dark${lightFormat}`); - // Use the hyperglass logo if the user's logo can't be loaded for whatever reason. + // Use the Ultraglass logo if the user's logo cannot be loaded. const [fallback, setSource] = useState<string | null>(null); // If the user image cannot be loaded, log an error to the console and set the fallback image. const setFallback = useCallback(() => { console.warn(`Error loading image from '${src}'`); - setSource('https://res.cloudinary.com/hyperglass/image/upload/v1593916013/logo-light.svg'); + setSource('/images/ultraglass-light.svg'); }, [src]); // Only return the fallback image if it's been set. diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index 8e7921c01..92cf03877 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -1,7 +1,7 @@ { "version": "3.1.0", "name": "ui", - "description": "UI for hyperglass, the modern network looking glass", + "description": "UI for Ultraglass, a maintained fork of the modern network looking glass hyperglass", "author": "Matt Love", "license": "BSD-3-Clause-Clear", "private": true, diff --git a/hyperglass/util/support_bundle.py b/hyperglass/util/support_bundle.py new file mode 100644 index 000000000..bc0835fc1 --- /dev/null +++ b/hyperglass/util/support_bundle.py @@ -0,0 +1,161 @@ +"""Build a local, sanitized support bundle for bug reports.""" + +# Standard Library +import json +import platform +import re +import typing as t +from collections import deque +from datetime import datetime, timezone +from pathlib import Path + +# Project +from hyperglass.constants import __version__ + +_IPV4 = re.compile(r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])") +_SECRET_KEY = re.compile( + r"(?i)(password|passwd|secret|token|api[_-]?key|authorization|credential|private[_-]?key)" +) +_ERROR_LEVELS = {"ERROR", "CRITICAL"} + + +def _redact_text(value: str) -> str: + """Remove common credentials and IPv4 addresses from free-form log text.""" + value = re.sub(r"(?i)(bearer\s+)[^\s]+", r"\1[REDACTED]", value) + value = re.sub(r"(?i)(https?://[^/\s:@]+:)[^@\s]+@", r"\1[REDACTED]@", value) + return _IPV4.sub("[REDACTED-IP]", value) + + +def _redact(value: t.Any, key: t.Optional[str] = None) -> t.Any: + """Recursively sanitize JSON-compatible values.""" + if key is not None and _SECRET_KEY.search(key): + return "[REDACTED]" + if isinstance(value, dict): + return {str(k): _redact(v, str(k)) for k, v in value.items()} + if isinstance(value, list): + return [_redact(item) for item in value] + if isinstance(value, str): + return _redact_text(value) + return value + + +def _tail(path: Path, limit: int) -> t.List[str]: + """Read only the final lines from a log file.""" + try: + with path.open("r", encoding="utf-8", errors="replace") as handle: + return list(deque(handle, maxlen=limit)) + except (OSError, UnicodeError): + return [] + + +def _json_log_errors(path: Path, limit: int) -> t.List[t.Dict[str, t.Any]]: + """Extract sanitized error records from a Loguru JSON log.""" + errors: t.List[t.Dict[str, t.Any]] = [] + for line in _tail(path, limit): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + source = record.get("record", {}) + level = source.get("level", {}).get("name") + if level in _ERROR_LEVELS: + error_record: t.Dict[str, t.Any] = { + "timestamp": source.get("time", {}).get("repr"), + "level": level, + "message": _redact_text(str(source.get("message", ""))), + } + exception = source.get("exception") + if exception: + error_record["exception"] = _redact( + { + "type": exception.get("type"), + "value": exception.get("value"), + } + ) + errors.append(error_record) + return errors + + +def _text_log_errors(path: Path, limit: int) -> t.List[str]: + """Extract sanitized error lines from a text log.""" + return [ + _redact_text(line.rstrip()) + for line in _tail(path, limit) + if any(f"[{level}]" in line for level in _ERROR_LEVELS) + ] + + +def _query_failures(path: Path, limit: int) -> t.List[t.Dict[str, t.Any]]: + """Extract only non-sensitive failure metadata from query samples.""" + failures: t.List[t.Dict[str, t.Any]] = [] + for line in _tail(path, limit): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + error = record.get("error") + if not error: + continue + query = record.get("query", {}) + failures.append( + { + "timestamp": record.get("timestamp"), + "query_type": query.get("type"), + "structured": record.get("structured", False), + "runtime_s": record.get("runtime_s"), + "error": _redact(error), + } + ) + return failures + + +def build_support_bundle( + log_directories: t.Iterable[Path], *, log_lines: int = 200 +) -> t.Dict[str, t.Any]: + """Build a sanitized bundle without reading configuration or contacting a service.""" + bundle: t.Dict[str, t.Any] = { + "format": 1, + "product": "Ultraglass", + "version": __version__, + "generated_at": datetime.now(timezone.utc).isoformat(), + "runtime": { + "python": platform.python_version(), + "system": platform.system(), + "release": platform.release(), + }, + "errors": {"json": [], "text": []}, + "query_failures": [], + } + + seen: t.Set[Path] = set() + for directory in log_directories: + directory = directory.expanduser() + if directory in seen or not directory.is_dir(): + continue + seen.add(directory) + + json_log = directory / "hyperglass.log.json" + text_log = directory / "hyperglass.log" + samples = directory / "hyperglass_query_log.jsonl" + + if json_log.is_file(): + bundle["errors"]["json"].extend(_json_log_errors(json_log, log_lines)) + if text_log.is_file(): + bundle["errors"]["text"].extend(_text_log_errors(text_log, log_lines)) + if samples.is_file(): + bundle["query_failures"].extend(_query_failures(samples, log_lines)) + + return bundle + + +def write_support_bundle( + output: Path, log_directories: t.Iterable[Path], *, log_lines: int = 200 +) -> Path: + """Write a local support bundle and return its path.""" + output = output.expanduser() + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(build_support_bundle(log_directories, log_lines=log_lines), indent=2) + "\n", + encoding="utf-8", + ) + return output diff --git a/hyperglass/util/system_info.py b/hyperglass/util/system_info.py index c8f28c3b8..e3b9e8591 100644 --- a/hyperglass/util/system_info.py +++ b/hyperglass/util/system_info.py @@ -93,8 +93,8 @@ def get_system_info() -> SystemData: disk_total, disk_usage = _disk() return { - "hyperglass Version": (__version__, "text"), - "hyperglass Path": (os.environ["hyperglass_directory"], "code"), + "Ultraglass Version": (__version__, "text"), + "Ultraglass Path": (os.environ["hyperglass_directory"], "code"), "Python Version": (platform.python_version(), "code"), "Node Version": (".".join(str(v) for v in get_node_version()), "code"), "Platform Info": (platform.platform(), "code"), diff --git a/hyperglass/util/tests/test_support_bundle.py b/hyperglass/util/tests/test_support_bundle.py new file mode 100644 index 000000000..e612b2ac7 --- /dev/null +++ b/hyperglass/util/tests/test_support_bundle.py @@ -0,0 +1,68 @@ +"""Tests for local support bundle generation.""" + +# Standard Library +import json + +# Third Party +from hyperglass.util.support_bundle import build_support_bundle, write_support_bundle + + +def test_support_bundle_only_includes_sanitized_query_failures(tmp_path): + """Query samples must not expose targets, devices, raw output, or parsed output.""" + sample = { + "timestamp": "2026-08-26T10:00:00+00:00", + "device": {"id": "router-1", "name": "Production Router", "address": "192.0.2.1"}, + "query": {"type": "ping", "target": "198.51.100.5", "location": "Johannesburg"}, + "structured": False, + "runtime_s": 1.25, + "error": {"type": "DeviceTimeout", "message": "Timed out connecting to 192.0.2.1"}, + "raw": "private device output", + "parsed": {"private": "route data"}, + } + (tmp_path / "hyperglass_query_log.jsonl").write_text(json.dumps(sample) + "\n") + + bundle = build_support_bundle([tmp_path]) + failure = bundle["query_failures"][0] + + assert failure == { + "timestamp": "2026-08-26T10:00:00+00:00", + "query_type": "ping", + "structured": False, + "runtime_s": 1.25, + "error": {"type": "DeviceTimeout", "message": "Timed out connecting to [REDACTED-IP]"}, + } + assert "Production Router" not in json.dumps(bundle) + assert "private device output" not in json.dumps(bundle) + assert "route data" not in json.dumps(bundle) + + +def test_support_bundle_redacts_json_log_secrets_and_addresses(tmp_path): + """Structured error logs are retained only after basic sanitization.""" + record = { + "record": { + "level": {"name": "ERROR"}, + "file": {"name": "/srv/private/source.py"}, + "message": "Request failed for 192.0.2.10", + "extra": {"password": "do-not-share", "target": "192.0.2.20"}, + } + } + (tmp_path / "hyperglass.log.json").write_text(json.dumps(record) + "\n") + + bundle = build_support_bundle([tmp_path]) + serialized = json.dumps(bundle) + + assert "do-not-share" not in serialized + assert "192.0.2.10" not in serialized + assert "192.0.2.20" not in serialized + assert "/srv/private/source.py" not in serialized + assert "[REDACTED-IP]" in serialized + + +def test_write_support_bundle_creates_local_json(tmp_path): + """The command writes a JSON file and does not require application state.""" + output = tmp_path / "nested" / "support.json" + + result = write_support_bundle(output, [tmp_path]) + + assert result == output + assert json.loads(output.read_text())["product"] == "Ultraglass" diff --git a/pyproject.toml b/pyproject.toml index 4c3f36138..05caade66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "hyperglass" version = "3.1.0" -description = "hyperglass is the modern network looking glass that tries to make the internet better." +description = "Ultraglass is a maintained fork of the modern network looking glass hyperglass." authors = [ - { name = "thatmattlove", email = "matt@hyperglass.dev" } + { name = "Ultraglass maintainers" } ] dependencies = [ "Pillow>=12.3.0", From 7226df1d16a7b9c147155803c9a664c088df0d7e Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Wed, 26 Aug 2026 11:36:39 +1000 Subject: [PATCH 27/39] Link documentation to Wiki --- docs/app/layout.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx index 4a9964296..c884cc243 100644 --- a/docs/app/layout.tsx +++ b/docs/app/layout.tsx @@ -41,7 +41,9 @@ export default async function RootLayout({ children }: RootLayoutProps) { </picture> } projectLink="https://github.com/WilhelmZA/ultraglass" - /> + > + <a href="https://github.com/WilhelmZA/ultraglass/wiki">Wiki</a> + </Navbar> } pageMap={pageMap} > From dbc789d40188118db5b471e9f22a438e8b1b55de Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Wed, 26 Aug 2026 11:43:24 +1000 Subject: [PATCH 28/39] ci: ensure Ultraglass Docker repository exists --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abe19ed69..101839a03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,6 +133,27 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Ensure Docker Hub repository exists + if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish) }} + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + token=$(curl --fail --silent --show-error \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg username "$DOCKERHUB_USERNAME" --arg password "$DOCKERHUB_TOKEN" '{username: $username, password: $password}')" \ + https://hub.docker.com/v2/users/login/ | jq -r '.token') + status=$(curl --silent --show-error --output /tmp/dockerhub-create.json --write-out '%{http_code}' \ + -X POST \ + -H "Authorization: JWT $token" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg namespace "$DOCKERHUB_USERNAME" '{namespace: $namespace, name: "ultraglass", is_private: false}')" \ + https://hub.docker.com/v2/repositories/) + if [ "$status" != '201' ] && [ "$status" != '409' ]; then + cat /tmp/dockerhub-create.json + exit 1 + fi + - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 From 143105c26327301b8eaf2a30bf2a1ffa6c475bbb Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Wed, 26 Aug 2026 11:45:59 +1000 Subject: [PATCH 29/39] ci: remove unavailable Docker Hub auto-provisioning --- .github/workflows/ci.yml | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 101839a03..abe19ed69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,27 +133,6 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Ensure Docker Hub repository exists - if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish) }} - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - run: | - token=$(curl --fail --silent --show-error \ - -H 'Content-Type: application/json' \ - -d "$(jq -n --arg username "$DOCKERHUB_USERNAME" --arg password "$DOCKERHUB_TOKEN" '{username: $username, password: $password}')" \ - https://hub.docker.com/v2/users/login/ | jq -r '.token') - status=$(curl --silent --show-error --output /tmp/dockerhub-create.json --write-out '%{http_code}' \ - -X POST \ - -H "Authorization: JWT $token" \ - -H 'Content-Type: application/json' \ - -d "$(jq -n --arg namespace "$DOCKERHUB_USERNAME" '{namespace: $namespace, name: "ultraglass", is_private: false}')" \ - https://hub.docker.com/v2/repositories/) - if [ "$status" != '201' ] && [ "$status" != '409' ]; then - cat /tmp/dockerhub-create.json - exit 1 - fi - - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 From 29a257f36f2fc6a96aa453adef60ea1f3c786b7d Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Wed, 26 Aug 2026 14:21:55 +1000 Subject: [PATCH 30/39] ci: publish images to GitHub Container Registry --- .github/workflows/ci.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abe19ed69..3b3671e02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ on: permissions: contents: read + packages: write env: DOCKERHUB_REPOSITORY: wilhelmza/ultraglass @@ -133,11 +134,21 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Log in to GitHub Container Registry + if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish) }} + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 with: - images: docker.io/${{ env.DOCKERHUB_REPOSITORY }} + images: | + docker.io/${{ env.DOCKERHUB_REPOSITORY }} + ghcr.io/wilhelmza/ultraglass tags: | type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} @@ -154,3 +165,9 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + - name: Set GitHub Container Registry package visibility + if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish) }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh api --method PUT /user/packages/container/ultraglass/visibility -f visibility=public From 5aec06eff9f962fa9c1878f54c87227e89471d07 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Wed, 26 Aug 2026 14:28:14 +1000 Subject: [PATCH 31/39] ci: support separate GHCR publishing --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b3671e02..b0266e7c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,11 @@ on: required: true default: false type: boolean + publish_dockerhub: + description: Publish the image to Docker Hub during a manual run + required: true + default: true + type: boolean version: description: Image version when publishing manually, without the leading v required: false @@ -128,7 +133,7 @@ jobs: run: test -n "${{ inputs.version }}" - name: Log in to Docker Hub - if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish) }} + if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish && inputs.publish_dockerhub) }} uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -146,9 +151,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: | - docker.io/${{ env.DOCKERHUB_REPOSITORY }} - ghcr.io/wilhelmza/ultraglass + images: docker.io/${{ env.DOCKERHUB_REPOSITORY }} tags: | type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} @@ -156,6 +159,7 @@ jobs: type=raw,value=latest - name: Build and publish Docker image + if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish && inputs.publish_dockerhub) }} uses: docker/build-push-action@v6 with: context: . @@ -166,6 +170,29 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + - name: Extract GitHub Container Registry metadata + id: meta-ghcr + uses: docker/metadata-action@v5 + with: + images: ghcr.io/wilhelmza/ultraglass + tags: | + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' && inputs.publish && inputs.version != '' }} + type=raw,value=latest + + - name: Build and publish GitHub Container Registry image + if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish) }} + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.meta-ghcr.outputs.tags }} + labels: ${{ steps.meta-ghcr.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Set GitHub Container Registry package visibility if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish) }} env: From 21e7f8b2ca2b074e8683b2ad35ea83460876de85 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Wed, 26 Aug 2026 14:33:38 +1000 Subject: [PATCH 32/39] ci: fix GHCR package visibility endpoint --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0266e7c1..71302e52b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,4 +197,4 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish) }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh api --method PUT /user/packages/container/ultraglass/visibility -f visibility=public + run: gh api --method PUT /users/WilhelmZA/packages/container/ultraglass/visibility -f visibility=public From a998390efd5d1d0846c3236245cdb8c5ebf49ee5 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Wed, 26 Aug 2026 14:38:38 +1000 Subject: [PATCH 33/39] ci: keep GHCR publish independent of visibility --- .github/workflows/ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71302e52b..21e0bb18a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,9 +192,3 @@ jobs: labels: ${{ steps.meta-ghcr.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - - - name: Set GitHub Container Registry package visibility - if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.publish) }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh api --method PUT /users/WilhelmZA/packages/container/ultraglass/visibility -f visibility=public From c652960094234dda5e2a2a5445ffef55dcef5063 Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Wed, 26 Aug 2026 16:32:57 +1000 Subject: [PATCH 34/39] fix(docker): set HYPERGLASS_DISABLE_UI, not the misspelled variable Dockerfile and compose.yaml both carried HYPEGLASS_DISABLE_UI, missing the R. Settings reads the HYPERGLASS_ prefix, so nothing ever read either one: the Dockerfile's intent never took effect and the compose knob was inert. Set to false rather than repairing the spelling in place. The base stage is inherited by ui and by the final stage, so a literal fix to HYPERGLASS_DISABLE_UI=true would have disabled the UI in every published image. false matches what compose.yaml already defaults to. --- Dockerfile | 2 +- compose.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 13f6bff98..efa851b98 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ ENV HYPERGLASS_PORT=8001 ENV HYPERGLASS_DEBUG=false ENV HYPERGLASS_DEV_MODE=false ENV HYPERGLASS_REDIS_HOST=redis -ENV HYPEGLASS_DISABLE_UI=true +ENV HYPERGLASS_DISABLE_UI=false ENV HYPERGLASS_CONTAINER=true COPY . . diff --git a/compose.yaml b/compose.yaml index 9ccc0ba27..55c750486 100644 --- a/compose.yaml +++ b/compose.yaml @@ -11,7 +11,7 @@ services: - HYPERGLASS_DEBUG=${HYPERGLASS_DEBUG-false} - HYPERGLASS_DEV_MODE=${HYPERGLASS_DEV_MODE-false} - HYPERGLASS_REDIS_HOST=${HYPERGLASS_REDIS_HOST-redis} - - HYPEGLASS_DISABLE_UI=${HYPEGLASS_DISABLE_UI-false} + - HYPERGLASS_DISABLE_UI=${HYPERGLASS_DISABLE_UI-false} - HYPERGLASS_CONTAINER=${HYPERGLASS_CONTAINER-true} - HYPERGLASS_ORIGINAL_APP_PATH=${HYPERGLASS_APP_PATH} build: . From d1f2354c34af150cf8cae1c3e66671ab0cd1f5f9 Mon Sep 17 00:00:00 2001 From: WilhelmZA <82225237+WilhelmZA@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:26:35 +1000 Subject: [PATCH 35/39] fix: improve dependency and installation portability (#11) * fix: make packaging portable across supported hosts * fix: make bundled UI dependencies available at runtime * fix: skip pnpm install for bundled dependencies * fix: retry incomplete UI builds * fix: include frontend build dependencies in image --- .github/workflows/ci.yml | 32 ++++ Dockerfile | 8 +- README.md | 2 +- docs/content/installation/manual.mdx | 24 ++- hyperglass/__init__.py | 14 +- hyperglass/cli/main.py | 2 +- hyperglass/constants.py | 2 +- hyperglass/frontend/__init__.py | 226 +++++++++++++++++++----- hyperglass/frontend/tests/test_build.py | 45 +++++ hyperglass/main.py | 19 +- hyperglass/util/system_info.py | 11 +- pyproject.toml | 35 +++- requirements-dev.lock | 66 ++----- requirements.lock | 65 ++----- 14 files changed, 374 insertions(+), 177 deletions(-) create mode 100644 hyperglass/frontend/tests/test_build.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21e0bb18a..6e053da2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,38 @@ jobs: - name: Test TypeScript run: pnpm run test + package: + name: Build Python distributions + needs: + - backend + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + + - name: Build wheel and source distribution + run: uv build --wheel --sdist + + - name: Verify wheel installation + run: | + uv venv /tmp/hyperglass-wheel-test + uv pip install --python /tmp/hyperglass-wheel-test/bin/python dist/*.whl + /tmp/hyperglass-wheel-test/bin/hyperglass --help + + - name: Upload Python distributions + uses: actions/upload-artifact@v4 + with: + name: python-distributions + path: dist/* + docker: name: Build and publish Docker image needs: diff --git a/Dockerfile b/Dockerfile index efa851b98..a1fe3755e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,16 +28,14 @@ RUN apk add --no-cache build-base pkgconfig cairo-dev nodejs npm \ tar@7.5.21 brace-expansion@5.0.9 ip-address@10.3.1 \ && cp -a /tmp/tar-patch/node_modules/. /usr/local/lib/node_modules/npm/node_modules/ \ && rm -rf /tmp/tar-patch \ - && pnpm install -P \ + && pnpm install --frozen-lockfile \ && apk del npm FROM ui AS hyperglass WORKDIR /opt/hyperglass # Install the pinned dependency set from the lockfile (the single source of -# truth), then the package itself without re-resolving deps. --no-deps is -# required: the lock is a complete closure and pins pillow past favicons' -# declared cap (resolved via [tool.uv] override-dependencies), so letting pip -# re-resolve would fail on favicons' pillow<11 metadata. +# truth), then the package itself without re-resolving dependencies. The lock +# includes the UI extra required by this full application image. RUN pip3 install --no-cache-dir --no-deps -r requirements.lock \ && pip3 install --no-cache-dir -e . --no-deps \ && rm -rf /root/.cache/pip \ diff --git a/README.md b/README.md index dcae26359..285811907 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Open `http://localhost:8001` after the containers start. The sample device retur ### Manual installation -Manual installation requires Python 3.13 or later, Node.js 22 or later, pnpm 11, and Redis 7. Install the project into a virtual environment, create `/etc/hyperglass`, copy the sample device file, and start the application with `hyperglass start`. The full procedure is in the [manual installation guide](docs/content/installation/manual.mdx). +Manual installation requires Python 3.11 or later and Redis 7. Install the `ui` extra when enabling the web UI; it adds the Python favicon dependencies, while Node.js 22 and pnpm 11 provide the frontend build tools. The optional `performance` extra enables `uvloop`. The full procedure is in the [manual installation guide](docs/content/installation/manual.mdx). ## Documentation diff --git a/docs/content/installation/manual.mdx b/docs/content/installation/manual.mdx index 69cbd3a96..1f7c8d2fd 100644 --- a/docs/content/installation/manual.mdx +++ b/docs/content/installation/manual.mdx @@ -11,25 +11,37 @@ import { Steps, Callout } from "nextra/components"; To install Ultraglass manually, install the following dependencies: -1. [Python 3.13 or later](https://www.python.org/downloads/) and [`pip`](https://pip.pypa.io/en/stable/installation/) -2. [Node.js 22 or later](https://nodejs.org/en/download) -3. [pnpm 11](https://pnpm.io/installation) -4. [Redis 7 or later](https://redis.io/download/) +1. [Python 3.11 or later](https://www.python.org/downloads/) and [`pip`](https://pip.pypa.io/en/stable/installation/) +2. [Node.js 22 or later](https://nodejs.org/en/download) and [pnpm 11](https://pnpm.io/installation) for the UI build +3. [Redis 7 or later](https://redis.io/download/) +4. On Debian or Ubuntu, the UI image and favicon dependencies need `build-essential`, `pkg-config`, and `libcairo2-dev`. + +On Debian or Ubuntu, install the host packages with: + +```shell copy +sudo apt-get update +sudo apt-get install -y build-essential pkg-config libcairo2-dev python3-venv redis-server +``` <Callout type="warning">Make sure the Redis server is started.</Callout> ### Install Ultraglass -Once these dependencies are installed, clone and install Ultraglass: +Once these dependencies are installed, clone and install Ultraglass with its explicit UI extra: ```shell copy git clone https://github.com/WilhelmZA/ultraglass --depth=1 cd ultraglass # optional - switch to the latest stable release # git switch -c v3.0.0 v3.0.0 -pip3 install -e . +python3 -m venv .venv +. .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -e '.[ui]' ``` +For an API-only installation, omit the `ui` extra and set `HYPERGLASS_DISABLE_UI=true`; Node.js, pnpm, and the Cairo packages are not needed in that mode. + ### Create app directory <Callout type="info"> diff --git a/hyperglass/__init__.py b/hyperglass/__init__.py index 7a6d9b826..120c88b17 100644 --- a/hyperglass/__init__.py +++ b/hyperglass/__init__.py @@ -36,13 +36,17 @@ POSSIBILITY OF SUCH DAMAGE. """ -# Third Party -import uvloop - # Project from hyperglass.constants import METADATA -# Use Uvloop for performance. -uvloop.install() +# Use uvloop when the optional performance dependency is installed. The default +# runtime stays portable across supported Unix platforms and architectures. +try: + # Third Party + import uvloop +except ImportError: + pass +else: + uvloop.install() __name__, __version__, __author__, __copyright__, __license__ = METADATA diff --git a/hyperglass/cli/main.py b/hyperglass/cli/main.py index a66a4371c..888360636 100644 --- a/hyperglass/cli/main.py +++ b/hyperglass/cli/main.py @@ -30,7 +30,7 @@ def run(): return typer.run(cli()) -@cli.callback(name="version") +@cli.callback() def _version( version: t.Optional[bool] = typer.Option( None, "--version", help="Ultraglass version", callback=_version diff --git a/hyperglass/constants.py b/hyperglass/constants.py index c5cd2b950..960656660 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -18,7 +18,7 @@ METADATA = (__name__, __version__, __author__, __copyright__, __license__) -MIN_PYTHON_VERSION = (3, 8) +MIN_PYTHON_VERSION = (3, 11) MIN_NODE_VERSION = 18 diff --git a/hyperglass/frontend/__init__.py b/hyperglass/frontend/__init__.py index 0ff4fd537..4b1fb3e74 100644 --- a/hyperglass/frontend/__init__.py +++ b/hyperglass/frontend/__init__.py @@ -18,6 +18,110 @@ from hyperglass.models.ui import UIParameters +PACKAGE_UI_DIR = Path(__file__).parent.parent / "ui" +UI_BUILD_DIR_NAME = ".ui" +FAVICON_FORMATS = ( + {"dimensions": (64, 64), "image_format": "ico", "prefix": "favicon", "rel": None}, + {"dimensions": (16, 16), "image_format": "png", "prefix": "favicon", "rel": "icon"}, + {"dimensions": (32, 32), "image_format": "png", "prefix": "favicon", "rel": "icon"}, + {"dimensions": (64, 64), "image_format": "png", "prefix": "favicon", "rel": "icon"}, + {"dimensions": (96, 96), "image_format": "png", "prefix": "favicon", "rel": "icon"}, + {"dimensions": (180, 180), "image_format": "png", "prefix": "favicon", "rel": "icon"}, + { + "dimensions": (57, 57), + "image_format": "png", + "prefix": "apple-touch-icon", + "rel": "apple-touch-icon", + }, + { + "dimensions": (60, 60), + "image_format": "png", + "prefix": "apple-touch-icon", + "rel": "apple-touch-icon", + }, + { + "dimensions": (72, 72), + "image_format": "png", + "prefix": "apple-touch-icon", + "rel": "apple-touch-icon", + }, + { + "dimensions": (76, 76), + "image_format": "png", + "prefix": "apple-touch-icon", + "rel": "apple-touch-icon", + }, + { + "dimensions": (114, 114), + "image_format": "png", + "prefix": "apple-touch-icon", + "rel": "apple-touch-icon", + }, + { + "dimensions": (120, 120), + "image_format": "png", + "prefix": "apple-touch-icon", + "rel": "apple-touch-icon", + }, + { + "dimensions": (144, 144), + "image_format": "png", + "prefix": "apple-touch-icon", + "rel": "apple-touch-icon", + }, + { + "dimensions": (152, 152), + "image_format": "png", + "prefix": "apple-touch-icon", + "rel": "apple-touch-icon", + }, + { + "dimensions": (167, 167), + "image_format": "png", + "prefix": "apple-touch-icon", + "rel": "apple-touch-icon", + }, + { + "dimensions": (180, 180), + "image_format": "png", + "prefix": "apple-touch-icon", + "rel": "apple-touch-icon", + }, + {"dimensions": (70, 70), "image_format": "png", "prefix": "mstile", "rel": None}, + {"dimensions": (270, 270), "image_format": "png", "prefix": "mstile", "rel": None}, + {"dimensions": (310, 310), "image_format": "png", "prefix": "mstile", "rel": None}, + {"dimensions": (310, 150), "image_format": "png", "prefix": "mstile", "rel": None}, + {"dimensions": (196, 196), "image_format": "png", "prefix": "favicon", "rel": "shortcut icon"}, +) + + +def _prepare_ui_build_dir(app_path: Path) -> Path: + """Copy the packaged UI source into a writable application directory.""" + ui_dir = app_path / UI_BUILD_DIR_NAME + shutil.copytree( + PACKAGE_UI_DIR, + ui_dir, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns( + "node_modules", + ".next", + "out", + ".env*", + "hyperglass.json", + "*.tsbuildinfo", + ), + ) + package_node_modules = PACKAGE_UI_DIR / "node_modules" + ui_node_modules = ui_dir / "node_modules" + if package_node_modules.is_dir(): + if ui_node_modules.is_symlink() or ui_node_modules.is_file(): + ui_node_modules.unlink() + elif ui_node_modules.exists(): + shutil.rmtree(ui_node_modules) + ui_node_modules.symlink_to(package_node_modules, target_is_directory=True) + return ui_dir + + def get_ui_build_timeout() -> t.Optional[int]: """Read the UI build timeout from environment variables or set a default.""" timeout = None @@ -29,10 +133,10 @@ def get_ui_build_timeout() -> t.Optional[int]: return timeout -async def check_node_modules() -> bool: +async def check_node_modules(ui_path: t.Optional[Path] = None) -> bool: """Check if node_modules exists and has contents.""" - ui_path = Path(__file__).parent.parent / "ui" + ui_path = ui_path or PACKAGE_UI_DIR node_modules = ui_path / "node_modules" exists = node_modules.exists() @@ -44,10 +148,10 @@ async def check_node_modules() -> bool: return valid -async def read_package_json() -> t.Dict[str, t.Any]: +async def read_package_json(ui_path: t.Optional[Path] = None) -> t.Dict[str, t.Any]: """Import package.json as a python dict.""" - package_json_file = Path(__file__).parent.parent / "ui" / "package.json" + package_json_file = (ui_path or PACKAGE_UI_DIR) / "package.json" try: with package_json_file.open("r") as file: @@ -59,10 +163,12 @@ async def read_package_json() -> t.Dict[str, t.Any]: return package_json -async def node_initial(timeout: int = 600, dev_mode: bool = False) -> str: +async def node_initial( + timeout: int = 600, dev_mode: bool = False, ui_path: t.Optional[Path] = None +) -> str: """Initialize node_modules.""" - ui_path = Path(__file__).parent.parent / "ui" + ui_path = ui_path or PACKAGE_UI_DIR env_timeout = get_ui_build_timeout() @@ -88,7 +194,7 @@ async def node_initial(timeout: int = 600, dev_mode: bool = False) -> str: return "\n".join(messages) -async def build_ui(app_path: Path): +async def build_ui(app_path: Path, ui_path: t.Optional[Path] = None): """Execute `next build` & `next export` from UI directory. ### Raises @@ -97,7 +203,7 @@ async def build_ui(app_path: Path): """ timeout = get_ui_build_timeout() - ui_dir = Path(__file__).parent.parent / "ui" + ui_dir = ui_path or PACKAGE_UI_DIR build_dir = app_path / "static" / "ui" out_dir = ui_dir / "out" @@ -211,7 +317,9 @@ def migrate_images(app_path: Path, params: "UIParameters"): return copyfiles(src_files, dst_files) -def write_favicon_formats(formats: t.Tuple[t.Dict[str, t.Any]]) -> None: +def write_favicon_formats( + formats: t.Tuple[t.Dict[str, t.Any]], ui_path: t.Optional[Path] = None +) -> None: """Create a TypeScript file in the `ui` directory containing favicon formats. This file should stay the same, unless the favicons library updates @@ -220,7 +328,7 @@ def write_favicon_formats(formats: t.Tuple[t.Dict[str, t.Any]]) -> None: # Standard Library from collections import OrderedDict - file = Path(__file__).parent.parent / "ui" / "favicon-formats.ts" + file = (ui_path or PACKAGE_UI_DIR) / "favicon-formats.ts" # Sort each favicon definition to ensure the result stays the same # time the UI build runs. @@ -231,10 +339,55 @@ def write_favicon_formats(formats: t.Tuple[t.Dict[str, t.Any]]) -> None: file.write_text(data) -def write_custom_files(params: "UIParameters") -> None: +def generate_favicons(source: Path, output_directory: Path) -> t.Tuple[t.Dict[str, t.Any], ...]: + """Generate the favicon files used by the frontend.""" + # Third Party + from PIL import Image + + output_directory.mkdir(parents=True, exist_ok=True) + source_is_svg = source.suffix.lower() == ".svg" + source_image = None + + if source_is_svg: + # Third Party + import cairosvg + from io import BytesIO + else: + source_image = Image.open(source).convert("RGBA") + + try: + for favicon in FAVICON_FORMATS: + width, height = favicon["dimensions"] + target = output_directory / ( + f"{favicon['prefix']}-{width}x{height}.{favicon['image_format']}" + ) + + if source_is_svg: + rendered = cairosvg.svg2png( + url=str(source), output_width=width, output_height=height + ) + resized = Image.open(BytesIO(rendered)).convert("RGBA") + else: + resized = source_image.resize((width, height), Image.Resampling.LANCZOS) + + if favicon["image_format"] == "ico": + resized.save(target, format="ICO", sizes=[(width, height)]) + else: + resized.save(target, format="PNG") + + resized.close() + finally: + if source_image is not None: + source_image.close() + + return FAVICON_FORMATS + + +def write_custom_files(params: "UIParameters", ui_path: t.Optional[Path] = None) -> None: """Write custom files to the `ui` directory so they can be imported and rendered.""" - js = Path(__file__).parent.parent / "ui" / "custom.js" - html = Path(__file__).parent.parent / "ui" / "custom.html" + ui_path = ui_path or PACKAGE_UI_DIR + js = ui_path / "custom.js" + html = ui_path / "custom.html" # Handle Custom JS. if params.web.custom_javascript is not None: @@ -264,22 +417,20 @@ async def build_frontend( # noqa: C901 # Standard Library import hashlib - # Third Party - from favicons import Favicons # type:ignore - # Project from hyperglass.constants import __version__ # Create temporary file. json file extension is added for easy # webpack JSON parsing. - dot_env_file = Path(__file__).parent.parent / "ui" / ".env" + ui_dir = _prepare_ui_build_dir(app_path) + dot_env_file = ui_dir / ".env" env_config = {} - ui_config_file = Path(__file__).parent.parent / "ui" / "hyperglass.json" + ui_config_file = ui_dir / "hyperglass.json" ui_config_file.write_text(params.export_json(by_alias=True)) - package_json = await read_package_json() + package_json = await read_package_json(ui_dir) # Set NextJS production/development mode and base URL based on # developer_mode setting. @@ -289,18 +440,12 @@ async def build_frontend( # noqa: C901 else: env_config.update({"HYPERGLASS_URL": prod_url, "NODE_ENV": "production"}) - # Check if hyperglass/ui/node_modules has been initialized. If not, - # initialize it. - initialized = await check_node_modules() - - if initialized: - log.debug("node_modules is already initialized") - - elif not initialized: + # Development mode does not run the production build below, so initialize + # its dependencies here when needed. + initialized = await check_node_modules(ui_dir) + if dev_mode and not initialized: log.debug("node_modules has not been initialized. Starting initialization...") - - node_setup = await node_initial(timeout, dev_mode) - + node_setup = await node_initial(timeout, dev_mode, ui_dir) if node_setup == "": log.debug("Re-initialized node_modules") @@ -310,14 +455,9 @@ async def build_frontend( # noqa: C901 if not favicon_dir.exists(): favicon_dir.mkdir() - async with Favicons( - source=params.web.logo.favicon, - output_directory=favicon_dir, - base_url="/images/favicons/", - ) as favicons: - await favicons.generate() - log.bind(count=favicons.completed).debug("Generated favicons") - write_favicon_formats(favicons.formats()) + favicon_formats = generate_favicons(params.web.logo.favicon, favicon_dir) + log.bind(count=len(favicon_formats)).debug("Generated favicons") + write_favicon_formats(favicon_formats, ui_dir) build_data = { "params": params.export_dict(), @@ -338,7 +478,7 @@ async def build_frontend( # noqa: C901 env_build_id = env_data.get("HYPERGLASS_BUILD_ID", "None") log.bind(id=env_build_id).debug("Previous build detected") - if env_build_id == build_id: + if env_build_id == build_id and (app_path / "static" / "ui").is_dir(): log.debug("UI parameters unchanged since last build, skipping UI build...") return True @@ -350,8 +490,10 @@ async def build_frontend( # noqa: C901 # Initiate Next.JS export process. if any((not dev_mode, force, full)): log.info("Starting UI build") - initialize_result = await node_initial(timeout, dev_mode) - build_result = await build_ui(app_path=app_path) + initialize_result = "" + if not (ui_dir / "node_modules").is_symlink(): + initialize_result = await node_initial(timeout, dev_mode, ui_dir) + build_result = await build_ui(app_path=app_path, ui_path=ui_dir) if initialize_result: log.debug(initialize_result) @@ -365,7 +507,7 @@ async def build_frontend( # noqa: C901 migrate_images(app_path, params) - write_custom_files(params) + write_custom_files(params, ui_dir) generate_opengraph( params.web.opengraph.image, diff --git a/hyperglass/frontend/tests/test_build.py b/hyperglass/frontend/tests/test_build.py new file mode 100644 index 000000000..8c1b08579 --- /dev/null +++ b/hyperglass/frontend/tests/test_build.py @@ -0,0 +1,45 @@ +"""Tests for the packaged UI build workspace.""" + +# Standard Library +from pathlib import Path + +# Third Party +import pytest + +# Project +from hyperglass.frontend import _prepare_ui_build_dir, generate_favicons + + +def test_prepare_ui_build_dir_reuses_packaged_dependencies( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Reuse dependencies bundled in the container rather than copying them.""" + package_ui = tmp_path / "package-ui" + package_ui.mkdir() + (package_ui / "package.json").write_text("{}") + (package_ui / "node_modules").mkdir() + (package_ui / "node_modules" / "dependency").mkdir() + monkeypatch.setattr("hyperglass.frontend.PACKAGE_UI_DIR", package_ui) + + ui_dir = _prepare_ui_build_dir(tmp_path / "app") + + assert ui_dir == tmp_path / "app" / ".ui" + assert (ui_dir / "package.json").exists() + assert (ui_dir / "node_modules").is_symlink() + assert (ui_dir / "node_modules").resolve() == package_ui / "node_modules" + assert not (ui_dir / ".next").exists() + assert not (ui_dir / "out").exists() + assert not (ui_dir / "hyperglass.json").exists() + + +def test_generate_favicons_from_svg(tmp_path: Path) -> None: + """Generate the complete favicon set from the built-in SVG icon.""" + pytest.importorskip("cairosvg") + source = Path(__file__).parents[2] / "images" / "ultraglass-icon.svg" + output = tmp_path / "favicons" + + formats = generate_favicons(source, output) + + assert len(formats) == 21 + assert (output / "favicon-64x64.ico").exists() + assert (output / "favicon-196x196.png").exists() diff --git a/hyperglass/main.py b/hyperglass/main.py index 4a0576c97..97623b46e 100644 --- a/hyperglass/main.py +++ b/hyperglass/main.py @@ -12,6 +12,7 @@ # Local from .log import LibInterceptHandler, init_logger, enable_file_logging, enable_syslog_logging from .util import get_node_version +from .settings import Settings from .constants import MIN_NODE_VERSION, MIN_PYTHON_VERSION, __version__ # Ensure the Python version meets the minimum requirements. @@ -19,17 +20,21 @@ if sys.version_info < MIN_PYTHON_VERSION: raise RuntimeError(f"Python {pretty_version}+ is required.") -# Ensure the NodeJS version meets the minimum requirements. -node_major, node_minor, node_patch = get_node_version() - -if node_major < MIN_NODE_VERSION: - installed = ".".join(str(v) for v in (node_major, node_minor, node_patch)) - raise RuntimeError(f"NodeJS {MIN_NODE_VERSION!s}+ is required (version {installed} installed)") +# Ensure the NodeJS version meets the minimum requirements when the UI is enabled. +if not Settings.disable_ui: + node_version = get_node_version() + if node_version is None: + raise RuntimeError(f"NodeJS {MIN_NODE_VERSION!s}+ is required (NodeJS is not installed)") + node_major, node_minor, node_patch = node_version + if node_major < MIN_NODE_VERSION: + installed = ".".join(str(v) for v in (node_major, node_minor, node_patch)) + raise RuntimeError( + f"NodeJS {MIN_NODE_VERSION!s}+ is required (version {installed} installed)" + ) # Local from .state import use_state -from .settings import Settings LOG_LEVEL = logging.INFO if Settings.debug is False else logging.DEBUG logging.basicConfig(handlers=[LibInterceptHandler()], level=0, force=True) diff --git a/hyperglass/util/system_info.py b/hyperglass/util/system_info.py index e3b9e8591..efa90037d 100644 --- a/hyperglass/util/system_info.py +++ b/hyperglass/util/system_info.py @@ -43,7 +43,7 @@ def _disk() -> SystemData: return (total_gb, usage_percent) -def get_node_version() -> t.Tuple[int, int, int]: +def get_node_version() -> t.Optional[t.Tuple[int, int, int]]: """Get the system's NodeJS version.""" # Standard Library @@ -51,6 +51,8 @@ def get_node_version() -> t.Tuple[int, int, int]: import subprocess node_path = shutil.which("node") + if node_path is None: + return None raw_version = subprocess.check_output([node_path, "--version"]).decode() # noqa: S603 @@ -92,11 +94,16 @@ def get_system_info() -> SystemData: mem_total, mem_usage = _memory() disk_total, disk_usage = _disk() + node_version = get_node_version() + node_version_text = "not installed" + if node_version is not None: + node_version_text = ".".join(str(v) for v in node_version) + return { "Ultraglass Version": (__version__, "text"), "Ultraglass Path": (os.environ["hyperglass_directory"], "code"), "Python Version": (platform.python_version(), "code"), - "Node Version": (".".join(str(v) for v in get_node_version()), "code"), + "Node Version": (node_version_text, "code"), "Platform Info": (platform.platform(), "code"), "CPU Info": (cpu_info, "text"), "Logical Cores": (cpu_logical, "code"), diff --git a/pyproject.toml b/pyproject.toml index 05caade66..f1bfeddc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "PyYAML>=6.0.2", "aiofiles>=24.1.0", "distro==1.9.0", - "favicons==0.2.2", "httpx==0.28.1", "loguru>=0.7.3", "netmiko==4.6.0", @@ -23,17 +22,24 @@ dependencies = [ "rich<=14.0.0", "typer>=0.9.0", "uvicorn==0.37.0", - "uvloop>=0.21.0", "xmltodict==1.0.2", "toml>=0.10.2", "pydantic-settings==2.11.0", "pydantic-extra-types>=2.10.5", - "litestar[standard,brotli]>=2.22.0", + "litestar[brotli]>=2.22.0", "click<=8.1.7", "requests>=2.26.0", ] readme = "README.md" -requires-python = ">= 3.13,<4.0" +requires-python = ">= 3.11,<4.0" + +[project.optional-dependencies] +ui = [ + "CairoSVG>=2.7.1", +] +performance = [ + "uvloop>=0.21.0; sys_platform != 'win32'", +] [project.scripts] hyperglass = "hyperglass.console:run" @@ -74,9 +80,6 @@ constraint-dependencies = [ ] # Overrides for dependencies whose declared bounds conflict with a version we # need and have verified is safe: -# - favicons 0.2.2 (latest release) caps pillow<11.0.0, holding us on a Pillow -# line with HIGH CVEs (CVE-2026-25990 / -40192 / -42311). favicons is the -# only Pillow consumer and its favicon generation is verified on Pillow 12.x. # - taskipy (dev only) caps psutil<7, but hyperglass pins psutil 7.1.0. override-dependencies = [ "pillow>=12.3.0", @@ -88,6 +91,24 @@ allow-direct-references = true [tool.hatch.build.targets.wheel] packages = ["hyperglass"] +exclude = [ + "**/node_modules/**", + "**/.next/**", + "**/out/**", + "hyperglass/ui/hyperglass.json", + "**/.env*", + "**/*.tsbuildinfo", +] + +[tool.hatch.build.targets.sdist] +exclude = [ + "**/node_modules/**", + "**/.next/**", + "**/out/**", + "hyperglass/ui/hyperglass.json", + "**/.env*", + "**/*.tsbuildinfo", +] [tool.black] line-length = 100 diff --git a/requirements-dev.lock b/requirements-dev.lock index ce8abda7b..7cdd3e9a2 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -3,7 +3,7 @@ # # last locked with the following flags: # pre: false -# features: [] +# features: ["ui"] # all-features: false # with-sources: false # generate-hashes: false @@ -17,7 +17,6 @@ annotated-types==0.7.0 anyio==4.11.0 # via httpx # via litestar - # via watchfiles bandit==1.9.4 bcrypt==5.0.0 # via paramiko @@ -25,17 +24,21 @@ black==26.5.1 brotli==1.2.0 # via -c (workspace) # via litestar +cairocffi==1.7.1 + # via cairosvg +cairosvg==2.9.0 + # via hyperglass certifi==2025.8.3 # via httpcore # via httpx # via requests cffi==2.0.0 + # via cairocffi # via cryptography # via pynacl cfgv==3.5.0 # via pre-commit charset-normalizer==3.4.3 - # via reportlab # via requests click==8.1.7 # via black @@ -49,32 +52,26 @@ colorama==0.4.6 cryptography==50.0.0 # via -c (workspace) # via paramiko -cssselect2==0.8.0 - # via svglib +cssselect2==0.9.0 + # via cairosvg +defusedxml==0.7.1 + # via cairosvg distlib==0.4.3 # via virtualenv distro==1.9.0 # via hyperglass -editorconfig==0.17.1 - # via jsbeautifier faker==37.8.0 # via polyfactory -favicons==0.2.2 - # via hyperglass filelock==3.29.4 # via python-discovery # via virtualenv flake8==7.3.0 # via pep8-naming -freetype-py==2.5.1 - # via rlpycairo h11==0.16.0 # via httpcore # via uvicorn httpcore==1.0.9 # via httpx -httptools==0.6.4 - # via uvicorn httpx==0.28.1 # via hyperglass # via litestar @@ -89,23 +86,14 @@ iniconfig==2.3.0 invoke==2.2.0 # via paramiko isort==8.0.1 -jinja2==3.1.6 - # via litestar -jsbeautifier==1.15.4 - # via litestar litestar==2.24.0 # via hyperglass litestar-htmx==0.5.0 # via litestar loguru==0.7.3 # via hyperglass -lxml==6.1.1 - # via -c (workspace) - # via svglib markdown-it-py==4.0.0 # via rich -markupsafe==3.0.2 - # via jinja2 mccabe==0.7.0 # via flake8 mdurl==0.1.2 @@ -137,9 +125,8 @@ pathspec==1.1.1 pep8-naming==0.15.1 pillow==12.3.0 # via --override (workspace) - # via favicons + # via cairosvg # via hyperglass - # via reportlab platformdirs==4.10.0 # via black # via python-discovery @@ -155,8 +142,6 @@ psutil==7.1.0 # via taskipy py-cpuinfo==9.0.0 # via hyperglass -pycairo==1.28.0 - # via rlpycairo pycodestyle==2.14.0 # via flake8 pycparser==2.23 @@ -191,7 +176,6 @@ python-discovery==1.4.2 # via virtualenv python-dotenv==1.1.1 # via pydantic-settings - # via uvicorn pytokens==0.4.1 # via black pyyaml==6.0.3 @@ -200,25 +184,18 @@ pyyaml==6.0.3 # via litestar # via netmiko # via pre-commit - # via uvicorn redis==6.4.0 # via hyperglass -reportlab==4.4.4 - # via favicons - # via svglib requests==2.34.2 # via hyperglass rich==13.9.4 # via bandit - # via favicons # via hyperglass # via litestar # via netmiko # via rich-click rich-click==1.9.1 # via litestar -rlpycairo==0.3.0 - # via favicons ruamel-yaml==0.18.15 # via netmiko ruamel-yaml-clib==0.2.14 @@ -228,36 +205,33 @@ scp==0.15.0 # via netmiko setuptools==82.0.1 # via pytest-dependency -six==1.17.0 - # via jsbeautifier sniffio==1.3.1 # via anyio # via litestar stackprinter==0.2.13 stevedore==5.8.0 # via bandit -svglib==1.5.1 - # via favicons taskipy==1.14.1 textfsm==2.1.0 # via netmiko # via ntc-templates -tinycss2==1.4.0 +tinycss2==1.5.1 + # via cairosvg # via cssselect2 - # via svglib toml==0.10.2 # via hyperglass tomli==2.4.1 # via taskipy typer==0.9.4 - # via favicons # via hyperglass typing-extensions==4.15.0 + # via anyio # via litestar # via polyfactory # via pydantic # via pydantic-core # via pydantic-extra-types + # via pytest-asyncio # via typer # via typing-inspection typing-inspection==0.4.1 @@ -269,18 +243,10 @@ urllib3==2.7.0 # via requests uvicorn==0.37.0 # via hyperglass - # via litestar -uvloop==0.21.0 - # via hyperglass - # via uvicorn virtualenv==21.5.0 # via pre-commit -watchfiles==1.1.0 - # via uvicorn -webencodings==0.5.1 +webencodings==0.6.1 # via cssselect2 # via tinycss2 -websockets==15.0.1 - # via uvicorn xmltodict==1.0.2 # via hyperglass diff --git a/requirements.lock b/requirements.lock index 51a431402..17d5d887c 100644 --- a/requirements.lock +++ b/requirements.lock @@ -3,7 +3,7 @@ # # last locked with the following flags: # pre: false -# features: [] +# features: ["ui"] # all-features: false # with-sources: false # generate-hashes: false @@ -17,21 +17,24 @@ annotated-types==0.7.0 anyio==4.11.0 # via httpx # via litestar - # via watchfiles bcrypt==5.0.0 # via paramiko brotli==1.2.0 # via -c (workspace) # via litestar +cairocffi==1.7.1 + # via cairosvg +cairosvg==2.9.0 + # via hyperglass certifi==2025.8.3 # via httpcore # via httpx # via requests cffi==2.0.0 + # via cairocffi # via cryptography # via pynacl charset-normalizer==3.4.3 - # via reportlab # via requests click==8.1.7 # via hyperglass @@ -42,25 +45,19 @@ click==8.1.7 cryptography==50.0.0 # via -c (workspace) # via paramiko -cssselect2==0.8.0 - # via svglib +cssselect2==0.9.0 + # via cairosvg +defusedxml==0.7.1 + # via cairosvg distro==1.9.0 # via hyperglass -editorconfig==0.17.1 - # via jsbeautifier faker==37.8.0 # via polyfactory -favicons==0.2.2 - # via hyperglass -freetype-py==2.5.1 - # via rlpycairo h11==0.16.0 # via httpcore # via uvicorn httpcore==1.0.9 # via httpx -httptools==0.6.4 - # via uvicorn httpx==0.28.1 # via hyperglass # via litestar @@ -70,23 +67,14 @@ idna==3.10 # via requests invoke==2.2.0 # via paramiko -jinja2==3.1.6 - # via litestar -jsbeautifier==1.15.4 - # via litestar litestar==2.24.0 # via hyperglass litestar-htmx==0.5.0 # via litestar loguru==0.7.3 # via hyperglass -lxml==6.1.1 - # via -c (workspace) - # via svglib markdown-it-py==4.0.0 # via rich -markupsafe==3.0.2 - # via jinja2 mdurl==0.1.2 # via markdown-it-py msgspec==0.19.0 @@ -106,9 +94,8 @@ paramiko==4.0.0 # via scp pillow==12.3.0 # via --override (workspace) - # via favicons + # via cairosvg # via hyperglass - # via reportlab polyfactory==2.22.2 # via litestar psutil==7.1.0 @@ -116,8 +103,6 @@ psutil==7.1.0 # via hyperglass py-cpuinfo==9.0.0 # via hyperglass -pycairo==1.28.0 - # via rlpycairo pycparser==2.23 # via cffi pydantic==2.11.9 @@ -140,54 +125,42 @@ pyserial==3.5 # via netmiko python-dotenv==1.1.1 # via pydantic-settings - # via uvicorn pyyaml==6.0.3 # via hyperglass # via litestar # via netmiko - # via uvicorn redis==6.4.0 # via hyperglass -reportlab==4.4.4 - # via favicons - # via svglib requests==2.34.2 # via hyperglass rich==13.9.4 - # via favicons # via hyperglass # via litestar # via netmiko # via rich-click rich-click==1.9.1 # via litestar -rlpycairo==0.3.0 - # via favicons ruamel-yaml==0.18.15 # via netmiko ruamel-yaml-clib==0.2.14 # via ruamel-yaml scp==0.15.0 # via netmiko -six==1.17.0 - # via jsbeautifier sniffio==1.3.1 # via anyio # via litestar -svglib==1.5.1 - # via favicons textfsm==2.1.0 # via netmiko # via ntc-templates -tinycss2==1.4.0 +tinycss2==1.5.1 + # via cairosvg # via cssselect2 - # via svglib toml==0.10.2 # via hyperglass typer==0.9.4 - # via favicons # via hyperglass typing-extensions==4.15.0 + # via anyio # via litestar # via polyfactory # via pydantic @@ -204,16 +177,8 @@ urllib3==2.7.0 # via requests uvicorn==0.37.0 # via hyperglass - # via litestar -uvloop==0.21.0 - # via hyperglass - # via uvicorn -watchfiles==1.1.0 - # via uvicorn -webencodings==0.5.1 +webencodings==0.6.1 # via cssselect2 # via tinycss2 -websockets==15.0.1 - # via uvicorn xmltodict==1.0.2 # via hyperglass From 76ac1972914762fca3383685968c8f82a166388f Mon Sep 17 00:00:00 2001 From: WilhelmZA <82225237+WilhelmZA@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:27:24 +1000 Subject: [PATCH 36/39] fix: normalize CIDR targets before validation (#12) * fix: normalize CIDR targets before validation (#8) * test: isolate query construction state * test: preserve parser state after construction tests --- hyperglass/defaults/directives/mikrotik.py | 6 +- .../execution/drivers/tests/test_construct.py | 15 +++++ hyperglass/models/api/query.py | 8 ++- hyperglass/models/directive.py | 19 +++++- hyperglass/models/tests/test_directive.py | 27 +++++++++ hyperglass/plugins/_builtin/__init__.py | 2 - .../_builtin/mikrotik_normalize_input.py | 58 ------------------- 7 files changed, 68 insertions(+), 67 deletions(-) create mode 100644 hyperglass/models/tests/test_directive.py delete mode 100644 hyperglass/plugins/_builtin/mikrotik_normalize_input.py diff --git a/hyperglass/defaults/directives/mikrotik.py b/hyperglass/defaults/directives/mikrotik.py index 35469ecdb..adba713e3 100644 --- a/hyperglass/defaults/directives/mikrotik.py +++ b/hyperglass/defaults/directives/mikrotik.py @@ -39,7 +39,7 @@ ), ], field=Text(description="IP Address, Prefix, or Hostname"), - plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + plugins=["mikrotik_garbage_output", "bgp_routestr_mikrotik"], table_output="__hyperglass_mikrotik_bgp_route_table__", platforms=PLATFORMS, ) @@ -58,7 +58,7 @@ ) ], field=Text(description="AS Path Regular Expression"), - plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + plugins=["mikrotik_garbage_output", "bgp_routestr_mikrotik"], table_output="__hyperglass_mikrotik_bgp_aspath_table__", platforms=PLATFORMS, ) @@ -77,7 +77,7 @@ ) ], field=Text(description="BGP Community String"), - plugins=["mikrotik_normalize_input", "mikrotik_garbage_output", "bgp_routestr_mikrotik"], + plugins=["mikrotik_garbage_output", "bgp_routestr_mikrotik"], table_output="__hyperglass_mikrotik_bgp_community_table__", platforms=PLATFORMS, ) diff --git a/hyperglass/execution/drivers/tests/test_construct.py b/hyperglass/execution/drivers/tests/test_construct.py index 7e5adee2b..5c02caf01 100644 --- a/hyperglass/execution/drivers/tests/test_construct.py +++ b/hyperglass/execution/drivers/tests/test_construct.py @@ -6,6 +6,7 @@ # Project from hyperglass.state import use_state +from hyperglass.state.hooks import _use_state from hyperglass.models.api import Query from hyperglass.configuration import init_ui_params from hyperglass.models.directive import Directives @@ -46,6 +47,7 @@ def directives(): "juniper_bgp_route": { "name": "BGP Route", "field": {"description": "test"}, + "rules": [{"condition": "0.0.0.0/0"}], } } ] @@ -59,6 +61,7 @@ def state( devices: t.Sequence[t.Dict[str, t.Any]], ) -> t.Generator["HyperglassState", None, None]: """Test fixture to initialize Redis store.""" + _use_state.cache_clear() _state = use_state() _params = Params(**params) _directives = Directives.new(*directives) @@ -88,3 +91,15 @@ def test_construct(state): ) constructor = Construct(device=state.devices["test1"], query=query) assert constructor.target == "192.0.2.0/24" + + +def test_construct_normalizes_cidr_host_bits(state): + query = Query( + queryLocation="test1", + queryTarget="192.0.2.1/24", + queryType="juniper_bgp_route", + ) + + assert query.query_target == "192.0.2.0/24" + constructor = Construct(device=state.devices["test1"], query=query) + assert constructor.target == "192.0.2.0/24" diff --git a/hyperglass/models/api/query.py b/hyperglass/models/api/query.py index f37b494d6..b5f1138fe 100644 --- a/hyperglass/models/api/query.py +++ b/hyperglass/models/api/query.py @@ -21,7 +21,6 @@ # Local from ..config.devices import Device - QueryLocation = Annotated[str, StringConstraints(strict=True, min_length=1, strip_whitespace=True)] QueryTarget = Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)] QueryType = Annotated[str, StringConstraints(strict=True, min_length=1, strip_whitespace=True)] @@ -70,6 +69,7 @@ def __init__(self, **data) -> None: self.directive = query_directives[0] self._input_plugin_manager = InputPluginManager() + self.query_target = self.directive.normalize_target(self.query_target) try: self.validate_query_target() @@ -152,8 +152,10 @@ def device(self) -> Device: effective_structured = False class _DeviceProxy: - """Tiny proxy object that delegates to the real device but - overrides structured_output.""" + """Tiny proxy object that delegates to the real device. + + Overrides ``structured_output``. + """ def __init__(self, real, structured_value: bool) -> None: self._real = real diff --git a/hyperglass/models/directive.py b/hyperglass/models/directive.py index c88c2c10b..39a2e014b 100644 --- a/hyperglass/models/directive.py +++ b/hyperglass/models/directive.py @@ -304,6 +304,23 @@ def validate_target(self, target: str) -> bool: continue raise InputValidationError(error="No matched validation rules", target=target) + def normalize_target(self, target: t.Union[t.List[str], str]) -> t.Union[t.List[str], str]: + """Canonicalize explicit IP prefixes before validation and execution.""" + if not any(isinstance(rule, RuleWithIP) for rule in self.rules): + return target + + def normalize(value: str) -> str: + if "/" not in value: + return value + try: + return ip_network(value, strict=False).with_prefixlen + except ValueError: + return value + + if isinstance(target, list): + return [normalize(value) for value in target] + return normalize(target) + @property def field_type(self) -> t.Literal["text", "select", None]: """Get the linked field type.""" @@ -339,7 +356,7 @@ def frontend(self: "Directive") -> t.Dict[str, t.Any]: "name": self.name, "field_type": self.field_type, "groups": self.groups, - "description": self.field.description if self.field is not None else '', + "description": self.field.description if self.field is not None else "", "info": None, } diff --git a/hyperglass/models/tests/test_directive.py b/hyperglass/models/tests/test_directive.py new file mode 100644 index 000000000..d4af2ebfc --- /dev/null +++ b/hyperglass/models/tests/test_directive.py @@ -0,0 +1,27 @@ +"""Tests for directive target handling.""" + +# Third Party +import pytest + +# Project +from hyperglass.models.directive import Directive + + +@pytest.mark.parametrize( + ("condition", "target", "expected"), + ( + ("0.0.0.0/0", "192.0.2.1/24", "192.0.2.0/24"), + ("::/0", "2001:db8::1/64", "2001:db8::/64"), + ("0.0.0.0/0", "192.0.2.1", "192.0.2.1"), + ), +) +def test_normalize_target(condition: str, target: str, expected: str) -> None: + """Canonicalize prefixed IP targets without changing host targets.""" + directive = Directive( + id="test", + name="Test", + field={"description": "test"}, + rules=[{"condition": condition}], + ) + + assert directive.normalize_target(target) == expected diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py index 75a78ee9f..6afcef5b3 100644 --- a/hyperglass/plugins/_builtin/__init__.py +++ b/hyperglass/plugins/_builtin/__init__.py @@ -8,7 +8,6 @@ from .bgp_routestr_huawei import BGPSTRRoutePluginHuawei from .bgp_route_juniper import BGPRoutePluginJuniper from .mikrotik_garbage_output import MikrotikGarbageOutput -from .mikrotik_normalize_input import MikrotikTargetNormalizerInput from .bgp_routestr_mikrotik import BGPSTRRoutePluginMikrotik from .trace_route_frr import TraceroutePluginFrr from .trace_route_huawei import TraceroutePluginHuawei @@ -23,7 +22,6 @@ "BGPRoutePluginHuawei", "BGPSTRRoutePluginHuawei", "MikrotikGarbageOutput", - "MikrotikTargetNormalizerInput", "BGPSTRRoutePluginMikrotik", "TraceroutePluginFrr", "TraceroutePluginHuawei", diff --git a/hyperglass/plugins/_builtin/mikrotik_normalize_input.py b/hyperglass/plugins/_builtin/mikrotik_normalize_input.py deleted file mode 100644 index d7bf556e0..000000000 --- a/hyperglass/plugins/_builtin/mikrotik_normalize_input.py +++ /dev/null @@ -1,58 +0,0 @@ -# Standard Library -from ipaddress import ip_network, ip_address -import typing as t - -# Third Party -from pydantic import PrivateAttr - -# Project -from hyperglass.log import log - -# REMOVIDA a importação direta de Query para evitar o erro circular -# from hyperglass.models.api.query import Query - -# Local -from .._input import InputPlugin, InputPluginValidationReturn - - -class MikrotikTargetNormalizerInput(InputPlugin): - """ - InputPlugin to normalize a target IP address to its network prefix. - This ensures that queries for different IPs within the same subnet - resolve to the same cache key. - """ - - _hyperglass_builtin: bool = PrivateAttr(False) - name: str = "mikrotik_normalizer" - platforms: t.Sequence[str] = ("mikrotik_routeros", "mikrotik_switchos", "mikrotik") - - def validate(self, query: t.Any) -> InputPluginValidationReturn: - """Take the query object and modify the target if it's a BGP Route query.""" - - # Acessamos os atributos normalmente, pois sabemos que o objeto 'query' os terá. - if query.query_type != "bgp_route": - return True, None - - try: - target_ip = ip_address(query.target) - - if target_ip.version == 4: - prefix_len = 24 - else: - prefix_len = 48 - - network = ip_network(f"{str(target_ip)}/{prefix_len}", strict=False) - - normalized_target = str(network.with_prefixlen) - - if query.target != normalized_target: - log.debug( - f"Normalizing target '{query.target}' to network prefix " - f"'{normalized_target}' for MikroTik cache key." - ) - query.target = normalized_target - - except ValueError: - pass - - return True, None From 68bdc8b0edb1d612628ac8786ad0f0b0f5a2e44e Mon Sep 17 00:00:00 2001 From: WilhelmZA <82225237+WilhelmZA@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:28:12 +1000 Subject: [PATCH 37/39] fix: add asynchronous IP enrichment (#13) * fix: add asynchronous IP enrichment * fix: make async enrichment cache updates generation-safe * test: correct async enrichment coverage * refactor: isolate async enrichment eligibility --- hyperglass/api/__init__.py | 2 + hyperglass/api/routes.py | 82 ++++++++++++++++--- hyperglass/api/tasks.py | 39 ++++++++- hyperglass/external/ip_enrichment.py | 43 ++++++---- .../external/tests/test_ip_enrichment.py | 26 ++++++ hyperglass/models/api/response.py | 3 + hyperglass/models/config/structured.py | 32 +++++++- .../tests/test_structured_ip_enrichment.py | 36 ++++++++ .../plugins/_builtin/bgp_route_enrichment.py | 6 ++ .../_builtin/traceroute_ip_enrichment.py | 10 +++ hyperglass/state/redis.py | 30 +++++++ hyperglass/state/tests/test_hooks.py | 27 ++++++ hyperglass/ui/hooks/use-lg-query.ts | 79 +++++++++++++++++- hyperglass/ui/types/globals.d.ts | 2 + 14 files changed, 386 insertions(+), 31 deletions(-) create mode 100644 hyperglass/external/tests/test_ip_enrichment.py create mode 100644 hyperglass/models/tests/test_structured_ip_enrichment.py diff --git a/hyperglass/api/__init__.py b/hyperglass/api/__init__.py index 74cf9dbba..8bd5fc41e 100644 --- a/hyperglass/api/__init__.py +++ b/hyperglass/api/__init__.py @@ -23,6 +23,7 @@ devices, queries, aspath_enrich, + query_enrichment, ) from .middleware import COMPRESSION_CONFIG, create_cors_config from .error_handlers import app_handler, http_handler, default_handler, validation_handler @@ -50,6 +51,7 @@ info, query, aspath_enrich, + query_enrichment, ] if not STATE.settings.disable_ui: diff --git a/hyperglass/api/routes.py b/hyperglass/api/routes.py index b84a0fabc..2011b3f29 100644 --- a/hyperglass/api/routes.py +++ b/hyperglass/api/routes.py @@ -5,6 +5,7 @@ import time import asyncio import typing as t +from uuid import uuid4 from datetime import UTC, datetime # Third Party @@ -26,7 +27,7 @@ # Local from .state import get_state, get_params, get_devices -from .tasks import send_webhook +from .tasks import enrich_query_output, send_webhook from .fake_output import fake_output __all__ = ( @@ -36,6 +37,7 @@ "info", "query", "aspath_enrich", + "query_enrichment", ) # MikroTik occasionally answers a BGP query before its routing table has finished @@ -47,18 +49,14 @@ def _is_empty_bgp_table(output: t.Any) -> bool: - """True if output is a structured BGP table containing no routes.""" + """Return whether output is a structured BGP table containing no routes.""" if not is_type(output, OutputDataModel): return False try: raw = json.loads(output.export_json()) except Exception: return False - return ( - isinstance(raw, dict) - and raw.get("count", None) == 0 - and not raw.get("routes") - ) + return isinstance(raw, dict) and raw.get("count", None) == 0 and not raw.get("routes") async def _execute_query(data: "Query") -> t.Any: @@ -118,6 +116,22 @@ async def aspath_enrich(data: dict) -> dict: return {"success": True, "asn_organizations": results} +@get("/api/query/{query_id:str}/enrichment") +async def query_enrichment(query_id: str) -> dict: + """Return the current output and enrichment status for a cached query.""" + from hyperglass.state import use_state + + cache = use_state("redis") + output = cache.get_map(query_id, "output") + if output is None: + return {"status": "not_found"} + + return { + "status": cache.get_map(query_id, "enrichment_status") or "complete", + "output": output, + } + + @get("/api/devices/{id:str}", dependencies={"devices": Provide(get_devices)}) async def device(devices: Devices, id: str) -> APIDevice: """Retrieve a device by ID.""" @@ -160,9 +174,24 @@ async def query(_state: HyperglassState, request: Request, data: Query) -> Query _log.info("Starting query execution") cache_response = cache.get_map(cache_key, "output") + cached_enrichment_status = cache.get_map(cache_key, "enrichment_status") + if ( + cache_response + and cached_enrichment_status is None + and _state.params.structured.is_async_enrichment_enabled(cache_response) + ): + _log.bind(cache_key=cache_key).debug( + "Discarding legacy cache entry without enrichment state" + ) + cache.delete(cache_key) + cache_response = None + json_output = False cached = False runtime = 65535 + enrichment_status = "complete" + enrichment_output = None + enrichment_generation = None if cache_response: _log.bind(cache_key=cache_key).debug("Cache hit") @@ -173,6 +202,7 @@ async def query(_state: HyperglassState, request: Request, data: Query) -> Query cached = True runtime = 0 timestamp = cache.get_map(cache_key, "timestamp") + enrichment_status = cached_enrichment_status or "complete" elif not cache_response: _log.bind(cache_key=cache_key).debug("Cache miss") @@ -208,9 +238,18 @@ async def query(_state: HyperglassState, request: Request, data: Query) -> Query else: raw_output = str(output) - cache.set_map_item(cache_key, "output", raw_output) - cache.set_map_item(cache_key, "timestamp", timestamp) - cache.expire(cache_key, expire_in=_state.params.cache.timeout) + if _state.params.structured.is_async_enrichment_enabled(output): + enrichment_status = "pending" + enrichment_output = output + enrichment_generation = uuid4().hex + + with cache.pipeline() as pipeline: + pipeline.set_map_item(cache_key, "output", raw_output) + pipeline.set_map_item(cache_key, "timestamp", timestamp) + pipeline.set_map_item(cache_key, "enrichment_status", enrichment_status) + if enrichment_generation is not None: + pipeline.set_map_item(cache_key, "enrichment_generation", enrichment_generation) + pipeline.expire(cache_key, expire_in=_state.params.cache.timeout) _log.bind(cache_timeout=_state.params.cache.timeout).debug("Response cached") @@ -233,6 +272,7 @@ async def query(_state: HyperglassState, request: Request, data: Query) -> Query "runtime": runtime, "timestamp": timestamp, "format": response_format, + "enrichment": enrichment_status, "random": data.random(), "level": "success", "keywords": [], @@ -241,10 +281,30 @@ async def query(_state: HyperglassState, request: Request, data: Query) -> Query return Response( response, background=BackgroundTask( - send_webhook, + _run_query_background_tasks, + enrichment_output=enrichment_output, + enrichment_generation=enrichment_generation, + cache=cache, + cache_key=cache_key, params=_state.params, data=data, request=request, timestamp=timestamp, ), ) + + +async def _run_query_background_tasks( + enrichment_output: t.Any, + enrichment_generation: t.Optional[str], + cache: t.Any, + cache_key: str, + params: Params, + data: Query, + request: Request, + timestamp: datetime, +) -> None: + """Run optional enrichment and webhook work after sending the response.""" + if enrichment_output is not None and enrichment_generation is not None: + await enrich_query_output(enrichment_output, cache, cache_key, enrichment_generation) + await send_webhook(params=params, data=data, request=request, timestamp=timestamp) diff --git a/hyperglass/api/tasks.py b/hyperglass/api/tasks.py index 8527c53be..16f7f6476 100644 --- a/hyperglass/api/tasks.py +++ b/hyperglass/api/tasks.py @@ -1,6 +1,7 @@ """Tasks to be executed from web API.""" # Standard Library +import json import typing as t from datetime import datetime @@ -17,7 +18,7 @@ # Project from hyperglass.models.config.params import Params -__all__ = ("send_webhook",) +__all__ = ("enrich_query_output", "send_webhook") async def process_headers(headers: Headers) -> t.Dict[str, t.Any]: @@ -68,3 +69,39 @@ async def send_webhook( log.bind(destination=params.logging.http.provider, error=str(err)).error( "Failed to send webhook" ) + + +async def enrich_query_output(output: t.Any, cache: t.Any, cache_key: str, generation: str) -> None: + """Enrich a structured query result and update its cached representation.""" + from hyperglass.models.data.bgp_route import BGPRouteTable + from hyperglass.models.data.traceroute import TracerouteResult + + status = "complete" + try: + if isinstance(output, BGPRouteTable): + await output.enrich_as_path_organizations() + await output.enrich_with_ip_enrichment() + elif isinstance(output, TracerouteResult): + from hyperglass.plugins._builtin.traceroute_ip_enrichment import ( + ZTracerouteIpEnrichment, + ) + + await ZTracerouteIpEnrichment().enrich(output) + except Exception: + status = "failed" + log.exception("Failed to asynchronously enrich query output") + + try: + updated = cache.set_map_items_if( + cache_key, + expected_item="enrichment_generation", + expected_value=generation, + values={ + "output": json.loads(output.export_json()), + "enrichment_status": status, + }, + ) + if not updated: + log.bind(cache_key=cache_key).debug("Discarded stale asynchronous enrichment result") + except Exception: + log.exception("Failed to update asynchronously enriched query output") diff --git a/hyperglass/external/ip_enrichment.py b/hyperglass/external/ip_enrichment.py index 1f52609df..05a4e7859 100644 --- a/hyperglass/external/ip_enrichment.py +++ b/hyperglass/external/ip_enrichment.py @@ -6,7 +6,7 @@ import asyncio import typing as t -from ipaddress import ip_address, IPv4Address, IPv6Address +from ipaddress import ip_address import socket from hyperglass.log import log @@ -43,6 +43,24 @@ def __init__(self): self._ip_cache: t.Dict[str, t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]] = {} self.asn_info: t.Dict[int, t.Dict[str, str]] = {} # asn -> {name, country} + def _ip_info_from_lookup( + self, + ip: str, + asn: t.Optional[int], + asn_name: t.Optional[str], + prefix: t.Optional[str], + ) -> IPInfo: + """Convert a cached lookup tuple to the public result model.""" + if asn is None and asn_name is not None: + return IPInfo(ip, is_ixp=True, ixp_name=asn_name) + if asn: + try: + self.asn_info[int(asn)] = {"name": asn_name or f"AS{asn}", "country": ""} + except Exception: + pass + return IPInfo(ip, asn=asn, asn_name=asn_name, prefix=prefix) + return IPInfo(ip, asn=0, asn_name="Unknown") + async def _query_bgp_tools_for_ip( self, ip_str: str ) -> t.Tuple[t.Optional[int], t.Optional[str], t.Optional[str]]: @@ -266,6 +284,7 @@ async def lookup_ips_bulk(self, ips: t.List[str]) -> t.Dict[str, IPInfo]: # Prepare list for query query_ips: t.List[str] = [] + cached_ips: t.List[str] = [] for ip in ips: try: target_ip = ip_address(ip) @@ -278,27 +297,21 @@ async def lookup_ips_bulk(self, ips: t.List[str]) -> t.Dict[str, IPInfo]: results[ip] = IPInfo(ip, asn=0, asn_name="Private", prefix="Private Network") continue - query_ips.append(ip) + if ip in self._ip_cache: + cached_ips.append(ip) + else: + query_ips.append(ip) # Query BGP.TOOLS in bulk for all IPs if query_ips: bulk = await self._query_bgp_tools_bulk(query_ips) for ip in query_ips: asn, asn_name, prefix = bulk.get(ip, (None, None, None)) + self._ip_cache[ip] = (asn, asn_name, prefix) - # Check if this is an IXP (indicated by None ASN from our parsing) - is_ixp = asn is None and asn_name is not None - - if is_ixp: - results[ip] = IPInfo(ip, is_ixp=True, ixp_name=asn_name) - elif asn: - try: - self.asn_info[int(asn)] = {"name": asn_name or f"AS{asn}", "country": ""} - except Exception: - pass - results[ip] = IPInfo(ip, asn=asn, asn_name=asn_name, prefix=prefix) - else: - results[ip] = IPInfo(ip, asn=0, asn_name="Unknown") + for ip in [*query_ips, *cached_ips]: + asn, asn_name, prefix = self._ip_cache.get(ip, (None, None, None)) + results[ip] = self._ip_info_from_lookup(ip, asn, asn_name, prefix) return results diff --git a/hyperglass/external/tests/test_ip_enrichment.py b/hyperglass/external/tests/test_ip_enrichment.py new file mode 100644 index 000000000..0d3bccb48 --- /dev/null +++ b/hyperglass/external/tests/test_ip_enrichment.py @@ -0,0 +1,26 @@ +"""Tests for IP enrichment caching.""" + +import pytest + +from hyperglass.external.ip_enrichment import IPEnrichmentService + + +@pytest.mark.asyncio +async def test_bulk_lookup_caches_negative_results(monkeypatch) -> None: + """Cache a missing bulk lookup so repeated queries do not repeat the network call.""" + service = IPEnrichmentService() + calls = 0 + + async def lookup(_ips): + nonlocal calls + calls += 1 + return {"8.8.8.8": (None, None, None)} + + monkeypatch.setattr(service, "_query_bgp_tools_bulk", lookup) + + first = await service.lookup_ips_bulk(["8.8.8.8"]) + second = await service.lookup_ips_bulk(["8.8.8.8"]) + + assert calls == 1 + assert first["8.8.8.8"].asn == 0 + assert second["8.8.8.8"].asn == 0 diff --git a/hyperglass/models/api/response.py b/hyperglass/models/api/response.py index 442490eca..8fd537dc5 100644 --- a/hyperglass/models/api/response.py +++ b/hyperglass/models/api/response.py @@ -12,6 +12,7 @@ ErrorName = t.Literal["success", "warning", "error", "danger"] ResponseLevel = t.Literal["success"] ResponseFormat = t.Literal[r"text/plain", r"application/json"] +EnrichmentStatus = t.Literal["complete", "pending", "failed"] schema_query_output = { "title": "Output", @@ -142,6 +143,8 @@ class QueryResponse(BaseModel): ) output: t.Union[t.Dict, StrictStr] = Field(json_schema_extra=schema_query_output) + id: StrictStr + enrichment: EnrichmentStatus = "complete" level: ResponseLevel = Field("success", json_schema_extra=schema_query_level) random: str = Field(json_schema_extra=schema_query_random) cached: bool = Field(json_schema_extra=schema_query_cached) diff --git a/hyperglass/models/config/structured.py b/hyperglass/models/config/structured.py index 692b800d2..a4e8d7a33 100644 --- a/hyperglass/models/config/structured.py +++ b/hyperglass/models/config/structured.py @@ -12,6 +12,7 @@ StructuredCommunityMode = t.Literal["permit", "deny", "name"] StructuredRPKIMode = t.Literal["router", "external"] StructuredRPKIBackend = t.Literal["cloudflare", "routinator"] +StructuredIpEnrichmentMode = t.Literal["inline", "async"] class StructuredCommunities(HyperglassModel): @@ -52,12 +53,15 @@ def validate_routinator_url(self) -> "StructuredRpki": class StructuredIpEnrichment(HyperglassModel): """Control IP enrichment for structured data responses using real-time BGP.tools lookups.""" + mode: StructuredIpEnrichmentMode = "inline" + """Run enrichment before the response or asynchronously after it is returned.""" + enrich_traceroute: bool = False """Enable ASN/org/IP enrichment for traceroute hops using real-time BGP.tools data.""" enrich_bgproute: bool = False - """Enable early ASN/org enrichment for BGP routes. When true, enrichment happens immediately - on query response. When false, enrichment is lazy-loaded when viewing the AS Path.""" + """Enable ASN/org enrichment for BGP routes. Enrichment is inline by default and can be + deferred with ``mode: async``. When false, enrichment is lazy-loaded when viewing the AS Path.""" class Structured(HyperglassModel): @@ -73,3 +77,27 @@ class Structured(HyperglassModel): # the structured table output even when a `structured:` block exists. enable_for_traceroute: t.Optional[bool] = None enable_for_bgp_route: t.Optional[bool] = None + + def is_async_enrichment_enabled(self, output: t.Any) -> bool: + """Return whether an output should be enriched after the initial response.""" + config = self.ip_enrichment + if config.mode != "async": + return False + + from hyperglass.models.data.bgp_route import BGPRouteTable + from hyperglass.models.data.traceroute import TracerouteResult + + is_bgp_output = isinstance(output, BGPRouteTable) or ( + isinstance(output, dict) and isinstance(output.get("routes"), list) + ) + is_traceroute_output = isinstance(output, TracerouteResult) or ( + isinstance(output, dict) and isinstance(output.get("hops"), list) + ) + + return ( + is_bgp_output and config.enrich_bgproute and self.enable_for_bgp_route is not False + ) or ( + is_traceroute_output + and config.enrich_traceroute + and self.enable_for_traceroute is not False + ) diff --git a/hyperglass/models/tests/test_structured_ip_enrichment.py b/hyperglass/models/tests/test_structured_ip_enrichment.py new file mode 100644 index 000000000..20122b15f --- /dev/null +++ b/hyperglass/models/tests/test_structured_ip_enrichment.py @@ -0,0 +1,36 @@ +"""Tests for structured IP enrichment configuration.""" + +import pytest + +from hyperglass.models.config.structured import Structured, StructuredIpEnrichment + + +def test_ip_enrichment_defaults_to_inline() -> None: + """Keep existing deployments on inline enrichment unless opted in.""" + assert StructuredIpEnrichment().mode == "inline" + + +def test_ip_enrichment_accepts_async_mode() -> None: + """Allow asynchronous enrichment to be selected explicitly.""" + assert StructuredIpEnrichment(mode="async").mode == "async" + + +def test_ip_enrichment_rejects_unknown_mode() -> None: + """Reject unsupported enrichment execution modes.""" + with pytest.raises(ValueError): + StructuredIpEnrichment(mode="later") + + +def test_async_enrichment_recognizes_legacy_cached_output() -> None: + """Treat structured cache dictionaries without status as enrichment candidates.""" + structured = Structured( + ip_enrichment=StructuredIpEnrichment( + mode="async", enrich_bgproute=True, enrich_traceroute=True + ), + enable_for_bgp_route=True, + enable_for_traceroute=True, + ) + + assert structured.is_async_enrichment_enabled({"routes": []}) + assert structured.is_async_enrichment_enabled({"hops": []}) + assert not structured.is_async_enrichment_enabled("123") diff --git a/hyperglass/plugins/_builtin/bgp_route_enrichment.py b/hyperglass/plugins/_builtin/bgp_route_enrichment.py index c1dbcdb6f..00920bd28 100644 --- a/hyperglass/plugins/_builtin/bgp_route_enrichment.py +++ b/hyperglass/plugins/_builtin/bgp_route_enrichment.py @@ -48,6 +48,12 @@ def process(self, *, output: "OutputDataModel", query: "Query") -> "OutputDataMo from hyperglass.settings import Settings params = use_state("params") + if ( + params.structured.ip_enrichment.mode == "async" + and params.structured.ip_enrichment.enrich_bgproute + ): + return output + # If structured config missing or BGP route enrichment disabled, skip if ( not getattr(params, "structured", None) diff --git a/hyperglass/plugins/_builtin/traceroute_ip_enrichment.py b/hyperglass/plugins/_builtin/traceroute_ip_enrichment.py index fb260b36b..1a80bade8 100644 --- a/hyperglass/plugins/_builtin/traceroute_ip_enrichment.py +++ b/hyperglass/plugins/_builtin/traceroute_ip_enrichment.py @@ -100,6 +100,10 @@ async def _enrich_async(self, output: TracerouteResult) -> None: else: hop.hostname = res + async def enrich(self, output: TracerouteResult) -> None: + """Run asynchronous traceroute enrichment for a parsed result.""" + await self._enrich_async(output) + def process(self, *, output: "OutputDataModel", query: "Query") -> "OutputDataModel": """Enrich structured traceroute data with IP enrichment and reverse DNS information.""" @@ -118,6 +122,12 @@ def process(self, *, output: "OutputDataModel", query: "Query") -> "OutputDataMo from hyperglass.state import use_state params = use_state("params") + if ( + params.structured.ip_enrichment.mode == "async" + and params.structured.ip_enrichment.enrich_traceroute + ): + return output + # If structured config missing or traceroute enrichment disabled, skip # IP enrichment but still perform reverse DNS lookups. if ( diff --git a/hyperglass/state/redis.py b/hyperglass/state/redis.py index 33fc0ae55..4615d0700 100644 --- a/hyperglass/state/redis.py +++ b/hyperglass/state/redis.py @@ -129,6 +129,36 @@ def set_map_item(self, key: str, item: str, value: t.Any) -> None: name = self.key(key) self.instance.hset(name, item, pickle.dumps(value)) + def set_map_items_if( + self, + key: str, + *, + expected_item: str, + expected_value: t.Any, + values: t.Mapping[str, t.Any], + ) -> bool: + """Atomically update a map when its generation marker still matches.""" + # Third Party + from redis.exceptions import WatchError + + name = self.key(key) + expected = pickle.dumps(expected_value) + encoded = {item: pickle.dumps(value) for item, value in values.items()} + + while True: + with self.instance.pipeline() as pipeline: + try: + pipeline.watch(name) + if pipeline.hget(name, expected_item) != expected: + pipeline.unwatch() + return False + pipeline.multi() + pipeline.hset(name, mapping=encoded) + pipeline.execute() + return True + except WatchError: + continue + def pipeline(self): """Enter a Redis Pipeline, but expose all the custom interaction methods.""" # Copy the base RedisManager and remove the pipeline method (this method). diff --git a/hyperglass/state/tests/test_hooks.py b/hyperglass/state/tests/test_hooks.py index e82f1a432..43d855387 100644 --- a/hyperglass/state/tests/test_hooks.py +++ b/hyperglass/state/tests/test_hooks.py @@ -100,3 +100,30 @@ def test_use_state_caching(state): f"{instance!r} is not an instance of '{model.__name__}'" ) assert instance == first, f"{instance!r} is not equal to {first!r}" + + +def test_set_map_items_if_rejects_stale_generation(state): + """Only the worker for the current cache generation may update query output.""" + cache = state.redis + key = "query.test-generation" + cache.set_map_item(key, "enrichment_generation", "current") + cache.set_map_item(key, "output", {"value": "initial"}) + cache.expire(key, expire_in=60) + initial_ttl = cache.instance.ttl(cache.key(key)) + + assert cache.set_map_items_if( + key, + expected_item="enrichment_generation", + expected_value="current", + values={"output": {"value": "updated"}}, + ) + assert cache.get_map(key, "output") == {"value": "updated"} + assert 0 < cache.instance.ttl(cache.key(key)) <= initial_ttl + + assert not cache.set_map_items_if( + key, + expected_item="enrichment_generation", + expected_value="stale", + values={"output": {"value": "stale"}}, + ) + assert cache.get_map(key, "output") == {"value": "updated"} diff --git a/hyperglass/ui/hooks/use-lg-query.ts b/hyperglass/ui/hooks/use-lg-query.ts index 09b9f3e0b..369a02068 100644 --- a/hyperglass/ui/hooks/use-lg-query.ts +++ b/hyperglass/ui/hooks/use-lg-query.ts @@ -1,8 +1,10 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; import { useConfig } from '~/context'; import { fetchWithTimeout } from '~/util'; +import { useFormState } from './use-form-state'; + import type { QueryFunction, QueryFunctionContext, @@ -69,7 +71,13 @@ export function useLGQuery( [controller], ); - return useQuery<QueryResponse, Response | QueryResponse | Error, QueryResponse, LGQueryKey>({ + const queryClient = useQueryClient(); + const result = useQuery< + QueryResponse, + Response | QueryResponse | Error, + QueryResponse, + LGQueryKey + >({ queryKey: ['/api/query', query], queryFn: runQuery, // Don't refetch when window refocuses. @@ -80,4 +88,71 @@ export function useLGQuery( refetchOnMount: false, ...options, }); + + useEffect(() => { + const resultData = result.data; + const enrichmentId = resultData?.id; + if (!resultData || !enrichmentId || resultData.enrichment !== 'pending') { + return undefined; + } + + const controller = new AbortController(); + let polling = false; + const poll = async (): Promise<void> => { + if (polling) { + return; + } + polling = true; + try { + const response = await fetch(`/api/query/${encodeURIComponent(enrichmentId)}/enrichment`, { + signal: controller.signal, + cache: 'no-store', + }); + if (!response.ok) { + return; + } + + const update = (await response.json()) as { + status: QueryResponse['enrichment'] | 'not_found'; + output?: QueryResponse['output']; + }; + if (update.status === 'not_found') { + window.clearInterval(interval); + return; + } + if (update.status === 'pending') { + return; + } + const enrichment = update.status; + + const queryKey: LGQueryKey = ['/api/query', query]; + const current = queryClient.getQueryData<QueryResponse>(queryKey); + if (!current) { + return; + } + const updated = { + ...current, + enrichment, + ...(update.output === undefined ? {} : { output: update.output }), + }; + queryClient.setQueryData<QueryResponse>(queryKey, updated); + useFormState.getState().addResponse(query.queryLocation, updated); + } catch (error) { + if (!controller.signal.aborted) { + console.error('Failed to poll query enrichment', error); + } + } finally { + polling = false; + } + }; + + const interval = window.setInterval(() => void poll(), 1000); + void poll(); + return () => { + controller.abort(); + window.clearInterval(interval); + }; + }, [query, queryClient, result.data?.enrichment, result.data?.id]); + + return result; } diff --git a/hyperglass/ui/types/globals.d.ts b/hyperglass/ui/types/globals.d.ts index 5d48a0e55..95812214e 100644 --- a/hyperglass/ui/types/globals.d.ts +++ b/hyperglass/ui/types/globals.d.ts @@ -95,6 +95,7 @@ export declare global { type AllStructuredResponses = BGPStructuredOutput | TracerouteStructuredOutput; type QueryResponse = { + id: string; random: string; cached: boolean; runtime: number; @@ -103,6 +104,7 @@ export declare global { keywords: string[]; output: string | AllStructuredResponses; format: 'text/plain' | 'application/json'; + enrichment: 'complete' | 'pending' | 'failed'; }; type RequiredProps<T> = { [P in keyof T]-?: Exclude<T[P], undefined> }; From 0bc8454bf18d5175208da466ad4344de49cb3a8e Mon Sep 17 00:00:00 2001 From: WilhelmZA <82225237+WilhelmZA@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:36:59 +1000 Subject: [PATCH 38/39] release: Ultraglass 3.2.0 (#14) --- .github/ISSUE_TEMPLATE/1-feature-request.yaml | 2 +- .github/ISSUE_TEMPLATE/2-bug-report.yaml | 2 +- CHANGELOG.md | 43 ++++++++++++++++++- docs/content/installation/upgrading.mdx | 2 +- hyperglass/constants.py | 2 +- hyperglass/models/api/response.py | 2 +- hyperglass/ui/package.json | 2 +- pyproject.toml | 2 +- 8 files changed, 49 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/1-feature-request.yaml b/.github/ISSUE_TEMPLATE/1-feature-request.yaml index 5d08da94c..abc2ffb07 100644 --- a/.github/ISSUE_TEMPLATE/1-feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/1-feature-request.yaml @@ -16,7 +16,7 @@ body: attributes: label: Version description: What version of Ultraglass are you currently running? - placeholder: v3.1.0 + placeholder: v3.2.0 validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.yaml b/.github/ISSUE_TEMPLATE/2-bug-report.yaml index 9346b5735..be0133a43 100644 --- a/.github/ISSUE_TEMPLATE/2-bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/2-bug-report.yaml @@ -20,7 +20,7 @@ body: attributes: label: Version description: What version of Ultraglass are you currently running? - placeholder: v3.1.0 + placeholder: v3.2.0 validations: required: true - type: textarea diff --git a/CHANGELOG.md b/CHANGELOG.md index cee0718d5..89a0b5a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,53 @@ This is the changelog for [WilhelmZA/ultraglass](https://github.com/WilhelmZA/ultraglass), a **fork** of [thatmattlove/hyperglass](https://github.com/thatmattlove/hyperglass). It documents Ultraglass changes, not upstream's. -The fork's own version line starts at **3.0.0** and is currently at **3.1.0**. It is based on **upstream hyperglass 2.0.4**, plus the upstream commits that had landed on upstream `main` after 2.0.4 but were never released by upstream. Upstream's version numbering is unrelated to this one; upstream's own history continues at [thatmattlove/hyperglass/blob/main/CHANGELOG.md](https://github.com/thatmattlove/hyperglass/blob/main/CHANGELOG.md). +The fork's own version line starts at **3.0.0** and is currently at **3.2.0**. It is based on **upstream hyperglass 2.0.4**, plus the upstream commits that had landed on upstream `main` after 2.0.4 but were never released by upstream. Upstream's version numbering is unrelated to this one; upstream's own history continues at [thatmattlove/hyperglass/blob/main/CHANGELOG.md](https://github.com/thatmattlove/hyperglass/blob/main/CHANGELOG.md). Everything from [2.0.4](#204---2024-06-30) downward is inherited upstream history, kept verbatim for reference. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 3.2.0 - 2026-08-27 + +This release adds opt-in asynchronous IP enrichment, normalises explicit CIDR targets, and makes packaged installations and container UI builds more reliable. + +### Added + +- Asynchronous structured IP enrichment through `structured.ip_enrichment.mode: async`, with `inline` retained as the default. +- A query enrichment polling endpoint and UI polling that update completed BGP and traceroute results without delaying the initial response. +- Generation-safe Redis cache updates so a stale enrichment worker cannot overwrite a newer query, while preserving the original cache TTL. +- Cached successful and negative bulk IP lookups to avoid repeating external enrichment requests. +- Python wheel and source distribution validation in CI. +- Container publishing to both Docker Hub and GitHub Container Registry. +- Ultraglass support bundles containing sanitised runtime, configuration and diagnostic information. + +### Changed + +- Rebranded the fork and its documentation, UI assets, metadata and support output as Ultraglass. +- Linked the application documentation entry point to the project wiki. +- Split optional UI and performance dependencies from the API-only Python installation. +- Built the frontend from an application-local writable workspace while reusing the dependencies packaged in the container image. +- Updated the Python package, runtime metadata, UI package, upgrade guide and issue templates to `3.2.0`. + +### Fixed + +- Normalised IPv4 and IPv6 prefixes before directive validation so validation, caching, command construction and responses use the canonical network address. +- Kept host-only query targets unchanged while removing an unused MikroTik normaliser that could widen them to fixed network sizes. +- Prevented legacy asynchronous cache entries and stale enrichment workers from returning or storing incorrect output. +- Preserved plain string query output without attempting to decode it as JSON. +- Retried incomplete runtime UI builds and reused packaged frontend dependencies without creating a second `node_modules` tree under application data. +- Restored SVG favicon generation and included its required runtime dependencies in the container image. +- Fixed API-only wheel installation, current Typer compatibility and the Docker UI-disable environment variable. + +### Validation + +- Combined Python test suite: 85 passed. +- UI test suite: 39 passed. +- UI formatting, lint and TypeScript checks: passed. +- Wheel build, clean wheel installation and CLI smoke test: passed. +- Docker image build and fresh application-data startup: passed. +- Live validation confirmed canonical CIDR cache identities, asynchronous enrichment completion, stable cache TTLs and legacy cache recovery. + ## 3.1.0 - 2026-08-25 Security and platform hardening release covering the dependency estate, container build, documentation platform and frontend validation. diff --git a/docs/content/installation/upgrading.mdx b/docs/content/installation/upgrading.mdx index 8b0d9abf0..d819d95eb 100644 --- a/docs/content/installation/upgrading.mdx +++ b/docs/content/installation/upgrading.mdx @@ -7,7 +7,7 @@ cd /opt/hyperglass docker compose down docker compose rm -f git fetch --tags -git checkout v3.1.0 +git checkout v3.2.0 docker compose build docker compose up ``` diff --git a/hyperglass/constants.py b/hyperglass/constants.py index 960656660..8a147fbaf 100644 --- a/hyperglass/constants.py +++ b/hyperglass/constants.py @@ -4,7 +4,7 @@ from datetime import datetime __name__ = "hyperglass" -__version__ = "3.1.0" +__version__ = "3.2.0" __author__ = "Matt Love" __copyright__ = f"Copyright {datetime.now().year} Matthew Love" __license__ = "BSD 3-Clause Clear License" diff --git a/hyperglass/models/api/response.py b/hyperglass/models/api/response.py index 8fd537dc5..0b548954d 100644 --- a/hyperglass/models/api/response.py +++ b/hyperglass/models/api/response.py @@ -208,7 +208,7 @@ class InfoResponse(BaseModel): "name": "Ultraglass", "organization": "Company Name", "primary_asn": 65000, - "version": "Ultraglass 3.1.0", + "version": "Ultraglass 3.2.0", } ], } diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index 92cf03877..e2f4673a0 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -1,5 +1,5 @@ { - "version": "3.1.0", + "version": "3.2.0", "name": "ui", "description": "UI for Ultraglass, a maintained fork of the modern network looking glass hyperglass", "author": "Matt Love", diff --git a/pyproject.toml b/pyproject.toml index f1bfeddc9..e830fc353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hyperglass" -version = "3.1.0" +version = "3.2.0" description = "Ultraglass is a maintained fork of the modern network looking glass hyperglass." authors = [ { name = "Ultraglass maintainers" } From e4f0492039aca5e143286ecb4ab4a5ca50da5e3b Mon Sep 17 00:00:00 2001 From: Wilhelm Schonfeldt <wilhelms@insight.co.za> Date: Fri, 28 Aug 2026 09:36:05 +1000 Subject: [PATCH 39/39] fix(frontend): avoid stale UI after failed build --- hyperglass/frontend/__init__.py | 6 ++- hyperglass/frontend/tests/test_build.py | 61 ++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/hyperglass/frontend/__init__.py b/hyperglass/frontend/__init__.py index 4b1fb3e74..8bd5827d2 100644 --- a/hyperglass/frontend/__init__.py +++ b/hyperglass/frontend/__init__.py @@ -482,8 +482,6 @@ async def build_frontend( # noqa: C901 log.debug("UI parameters unchanged since last build, skipping UI build...") return True - env_config.update({"HYPERGLASS_BUILD_ID": build_id}) - dot_env_file.write_text("\n".join(f"{k}={v}" for k, v in env_config.items())) log.bind(path=str(dot_env_file)).debug("Wrote UI environment file") @@ -517,4 +515,8 @@ async def build_frontend( # noqa: C901 params.web.theme.colors.black, ) + env_config.update({"HYPERGLASS_BUILD_ID": build_id}) + dot_env_file.write_text("\n".join(f"{k}={v}" for k, v in env_config.items())) + log.bind(path=str(dot_env_file)).debug("Recorded successful UI build") + return True diff --git a/hyperglass/frontend/tests/test_build.py b/hyperglass/frontend/tests/test_build.py index 8c1b08579..b7d1b5bbf 100644 --- a/hyperglass/frontend/tests/test_build.py +++ b/hyperglass/frontend/tests/test_build.py @@ -1,13 +1,14 @@ """Tests for the packaged UI build workspace.""" # Standard Library +import asyncio from pathlib import Path # Third Party import pytest # Project -from hyperglass.frontend import _prepare_ui_build_dir, generate_favicons +from hyperglass.frontend import _prepare_ui_build_dir, build_frontend, generate_favicons def test_prepare_ui_build_dir_reuses_packaged_dependencies( @@ -43,3 +44,61 @@ def test_generate_favicons_from_svg(tmp_path: Path) -> None: assert len(formats) == 21 assert (output / "favicon-64x64.ico").exists() assert (output / "favicon-196x196.png").exists() + + +def test_failed_build_does_not_record_build_id( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A failed build must not make a stale export appear current.""" + package_ui = tmp_path / "package-ui" + package_ui.mkdir() + (package_ui / "package.json").write_text("{}") + (package_ui / "node_modules").mkdir() + monkeypatch.setattr("hyperglass.frontend.PACKAGE_UI_DIR", package_ui) + + app_path = tmp_path / "app" + (app_path / "static" / "ui").mkdir(parents=True) + (app_path / "static" / "images" / "favicons").mkdir(parents=True) + dot_env_file = app_path / ".ui" / ".env" + dot_env_file.parent.mkdir() + dot_env_file.write_text("HYPERGLASS_BUILD_ID=old-build") + + class Logo: + favicon = package_ui / "favicon.svg" + + class Web: + logo = Logo() + opengraph = type("OpenGraph", (), {"image": package_ui / "opengraph.png"})() + theme = type("Theme", (), {"colors": type("Colors", (), {"black": "#000000"})()})() + + class Params: + web = Web() + + def export_json(self, by_alias: bool) -> str: + return "{}" + + def export_dict(self) -> dict: + return {} + + async def fail_build(*args, **kwargs): + raise RuntimeError("build failed") + + async def skip_node_initial(*args, **kwargs): + return "" + + monkeypatch.setattr("hyperglass.frontend.build_ui", fail_build) + monkeypatch.setattr("hyperglass.frontend.node_initial", skip_node_initial) + monkeypatch.setattr("hyperglass.frontend.generate_favicons", lambda *args: ()) + + with pytest.raises(RuntimeError, match="build failed"): + asyncio.run( + build_frontend( + dev_mode=False, + dev_url="http://localhost/", + prod_url="/api/", + params=Params(), + app_path=app_path, + ) + ) + + assert "HYPERGLASS_BUILD_ID" not in dot_env_file.read_text()