commit 57a85465db01ba3e0acb851f3b6f8f3bdff3d163 Author: paulmataruso Date: Fri Jan 24 16:58:57 2025 +0000 First Upload diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b17f6cc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,583 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + +## 2.0.4 - 2024-06-30 + +### Fixed + +- [#264](https://github.com/thatmattlove/hyperglass/issues/264): Fixed issue where IPv6 traceroutes fail on Juniper devices due to `traceroute: wait must be >1 sec.` error. Thanks @renatoornelas! +- [#267](https://github.com/thatmattlove/hyperglass/issues/267): Fixed issue where responses were incorrectly cached, resulting in no data being shown in the AS Path viewer. +- [#268](https://github.com/thatmattlove/hyperglass/issues/268): Fixed issue where some Mikrotik commands failed to execute properly. +- [#269](https://github.com/thatmattlove/hyperglass/issues/269): Updated documentation regarding `structured.rpki.mode`. +- Removed unnecessary logging statements which caused logging errors. +- Fixed issue where validation of structured BGP route data may have failed under certain conditions. + +### Changed +- Error responses are no longer cached. + +## 2.0.3 - 2024-06-16 + +### Fixed + +- [#262](https://github.com/thatmattlove/hyperglass/issues/262): Fix issue where Mikrotik output was improperly parsed and displayed an error as a result. +- Fixed issue where incorrect error styles were displayed. +- Fixed issue where 'results' accordion component did not re-open when closed. +- Fixed issue where pattern-based directive rules failed validation. + +### Changed + +- Set default logo width (back) to 50%, adjusted how the `web.logo.width` setting is handled in the UI. + +## 2.0.2 - 2024-06-01 + +### Fixed + +- [#257](https://github.com/thatmattlove/hyperglass/issues/257): Fix issue where if `web.location_display_mode` is set to `dropdown` (automatically or otherwise), the menu would remain open but become detached from the main element because the Query Type element came into view. +- [#253](https://github.com/thatmattlove/hyperglass/issues/253): _Actually_ fix issue where configuration values were improperly prepended with the `HYPERGLASS_APP_PATH` value. +- [#258](https://github.com/thatmattlove/hyperglass/issues/258): Center logo alignment on small screens. +- Fix broken license link in default credit menu. + +### Added + +- Added license to docs. +- [#254](https://github.com/thatmattlove/hyperglass/issues/254): Users may specify their own DNS over HTTPS provider if desired. + +## 2.0.1 - 2024-05-31 + +### Fixed +- [#244](https://github.com/thatmattlove/hyperglass/issues/244): Fix issue with UI build where UI build directory already existed and therefore could not be created. +- [#249](https://github.com/thatmattlove/hyperglass/issues/249): Fix issue where configuration values were improperly prepended with the `HYPERGLASS_APP_PATH` value. +- [#251](https://github.com/thatmattlove/hyperglass/issues/251): Fix issue where browser-based DNS resolution did not show, causing FQDN queries to fail due to validation. +- Fix issue where logo was improperly sized on small screens. + +## 2.0.0 - 2024-05-28 + +_v2.0.0 is a major release of hyperglass. Many things have changed, and it is likely best to redeploy hyperglass in a new environment to migrate to v2._ + +### 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: + - What command (or commands) to run on the device + - Type of UI field, text input or select + - If the field can accept multiple values + - Help information to show about the directive + - 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). + - 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). +- [#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. + +### Fixed +- [#229](https://github.com/thatmattlove/hyperglass/issues/229): Fixed an issue where the logo was not visible when using Firefox. +- [#180](https://github.com/thatmattlove/hyperglass/issues/180): Fixed an issue where certain FQDNs were considered invalid. +- [#178](https://github.com/thatmattlove/hyperglass/issues/178): Fixed an issue where parsing of Arista EOS routes failed if MED is unset. +- [#145](https://github.com/thatmattlove/hyperglass/issues/145): Fixed an issue where menu links were improperly generated. + +## 1.0.4 - 2021-07-03 + +### Fixed +- [#148](https://github.com/thatmattlove/hyperglass/issues/148): Update Debian/Ubuntu Python package name in installer and documentation. +- [#151](https://github.com/thatmattlove/hyperglass/issues/151): Fix issue with Junos structured output parsing from d1160fe where hyperglass would always query both IPv4 and IPv6 for any query type. + +### Changed +- Improve handling of Junos XML errors. When a Junos device returns an error in the XML output, it will be displayed in the UI. +- Improve `hyperglass system-info` output. NodeJS version is now included in the output. + +## 1.0.3 - 2021-06-23 + +_1.0.3 is a cosmetic release to factor in code-level changes related to the repository name change from checktheroads to thatmattlove._ + +## 1.0.2 - 2021-06-18 + +### Fixed +- [#150](https://github.com/thatmattlove/hyperglass/issues/150): Fix handling of BIRD AS_PATH/Community targets. + +## 1.0.1 - 2021-06-17 + +### Fixed +- UI: fix body overflow issue + +## 1.0.0 - 2021-05-30 + +### BREAKING CHANGES +- The `external_link`, `help`, and `terms` parameters no longer exist and have been replaced with generic `links` and `menus` options. +- The transitionary `frr_ssh` and `bird_ssh` NOS parameters no longer exist — `frr` and `bird` can now be used for SSH-based connectivity. hyperglass-agent users must now use `frr_legacy` and `bird_legacy` until hyperglass-agent is fully deprecated. + +### Fixed +- [#139](https://github.com/thatmattlove/hyperglass/issues/139): Fix an issue where the API cannot be queried by device name. + +### Changed +- Updated UI dependencies + +### Added +- [#140](https://github.com/thatmattlove/hyperglass/issues/140): Genericize links and menus so that multiple links and/or menus can be defined and fully customized. + +## 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. + +### Fixed +- [#135](https://github.com/thatmattlove/hyperglass/issues/135): Fix an issue where Juniper indirect next-hops were empty. +- Fix an issue where Juniper structured AS_PATH or Community queries would appear to fail if one address family (IPv4 or IPv6) had an empty response. For example, if an AS_PATH query for `.* 29414 .*` was made (which only returns IPv4 routes), the query would fail. + +### Changed +- Updated major Python dependencies (FastAPI, Scrapli, Netmiko, Pydantic, Uvicorn, Gunicorn, etc.) +- Updated UI dependencies +- [#128](https://github.com/thatmattlove/hyperglass/pull/128): Add `best` to all Juniper BGP Route queries. See [Juniper docs](https://www.juniper.net/documentation/us/en/software/junos/bgp/topics/ref/command/show-route-best.html) for more details. + +### Added +- The driver for devices can now be overridden with the `driver` parameter. + +## 1.0.0-beta.81 - 2021-04-10 + +### Fixed +- [#124](https://github.com/thatmattlove/hyperglass/issues/124): Fix an issue where networks weren't always sorted alphabetically. +- [#126](https://github.com/thatmattlove/hyperglass/issues/126): Fix rendering of markdown tables. +- [#132](https://github.com/thatmattlove/hyperglass/issues/132): Fix an issue where iBGP routes on Arista devices caused output parsing to fail. +- [#133](https://github.com/thatmattlove/hyperglass/issues/133): Use body styles for background/foreground color, allowing the user to override the `light` and `dark` colors per the docs. +- Fix an issue with select menu list style. + +### 1.0.0-beta.80 - 2021-03-03 + +### Fixed +- Fix an issue where the UI did not properly filter and detect the correct Query VRF when only one was defined. +- [#121](https://github.com/thatmattlove/hyperglass/issues/121): Fix issue with select menu styling in light mode. + +### 1.0.0-beta.79 - 2021-02-26 + +### BREAKING CHANGE +**Major changes have been made to how VRFs are defined and handled.** Previously, you would signal to hyperglass that a VRF was the "default" VRF (meaning, a VRF does not need to be specified in any commands) by setting `name: default` in the VRF block. This limitation meant that a VRF named `default` _had_ to be defined, and that any users who keep their global routing table in a non-default VRF must define it separately. + +Moving forward, the `name` field is only used to define the name of the VRF **as known by the device**. To signal that hyperglass should use the device's default VRF, set `default: true` on the VRF. **This is not the default**. + +### Fixed +- Fix an issue where long-running commands, such as traceroutes that never complete, time out and display an error instead of the output. + +### Changed +- Don't do external RPKI lookups for non global unicast prefixes. +- Migrate to palette-by-numbers for theming. +- Update UI dependencies. + +### 1.0.0-beta.78 - 2021-02-12 + +### Added +- Experimental table output/structured data support for Arista EOS. + +### Fixed +- Corrected warning color on active routes in table output. + +### Changed +- Caught fetch errors now display the HTTP status text in the UI, instead of the caught error message. + +### 1.0.0-beta.77 - 2021-02-10 + +**POTENTIALLY BREAKING CHANGE**: The device `display_name` field is being deprecated, in favor of a single `name` field, which will be displayed to the end user. The `display_name` field still works, but you should migrate away from it as soon as possible. + +### Fixed +- [#117](https://github.com/thatmattlove/hyperglass/issues/117): Fix naming and mapping of the Arista EOS driver. `arista` and `arista_eos` will both work now. + +### Changed +- Removed `display_name` field from device model. The `name` field will be used in the UI. If a `display_name` is defined, it will be used, for backwards compatibility. + +### 1.0.0-beta.76 - 2021-02-06 + +**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. + +### Changed +- `port` in `devices.yaml` now defaults to 22 if not specified. + +### Fixed +- AS Path graph view now uses [dagre](https://github.com/dagrejs/dagre) to properly arrange each AS. +- Added timeout argument to `hyperglass start --build` - fixes issue where running a UI build in this way failed due to a missing timeout argument error. + +### 1.0.0-beta.75 - 2021-01-28 + +### Changed +- Default UI build timeout is now 180 seconds. +- The hyperglass `build-ui` CLI command now accepts a `--timeout` argument to override the UI build timeout. + +### 1.0.0-beta.74 - 2021-01-25 + +### Changed +- The Scrapli driver no longer specifically ignores the system's SSH config file. +- Updated UI dependencies. + +### Fixed +- [#109](https://github.com/thatmattlove/hyperglass/issues/109): Remove the custom error page, because it doesn't work and doesn't really add much. + +### 1.0.0-beta.73 - 2021-01-18 + +### Added +- [#106](https://github.com/thatmattlove/hyperglass/issues/106): Add built-in support for Nokia SR OS (thanks @paunadeu!). + +### Changed +- [#105](https://github.com/thatmattlove/hyperglass/issues/105): Check NodeJS version on startup to ensure the minimum supported version is present. +- Update UI dependencies. + +### Fixed +- [#107](https://github.com/thatmattlove/hyperglass/issues/107): Fix footer menu styling so it doesn't overflow the viewport, especially on mobile. + +### 1.0.0-beta.72 - 2021-01-16 + +### Fixed +- [#104](https://github.com/thatmattlove/hyperglass/issues/104): Handle the usage of `juniper_junos` as a NOS. `juniper_junos` will now automatically be mapped to `juniper`. +- Fix an issue with dual RP juniper devices and structured output, where output containing `{master}` outside of the XML output was improperly stripped out, causing a parsing failure. + +### Changed +- **BREAKING**: The installer no longer generates a Systemd service file. While this was likely convenient for most, it introduced significant complexity and caused most installations using `~/hyperglass` as the app path to fail, with no clear way to resolve it. Further, while Systemd is arguably the most common, it is not the *only* process manager available. As such, the docs will be updated with a Systemd example, much like the current reverse proxy documentation. + +### 1.0.0-beta.71 - 2021-01-10 + +### Added +- Added Google Analytics Support. Use the `google_analytics` field for the tracking ID in `hyperglass.yaml`. + +### Changed +- Minor frontend code improvements. + +### 1.0.0-beta.70 - 2021-01-05 + +### Fixed + +- [#100](https://github.com/thatmattlove/hyperglass/issues/100): Fix result panel bug where incorrect panels would open, or panels would not open at all. Resolved by accessing internal state of the `Accordion />` component via `useAccordionContext()` instead of directly changing the index prop via state. + +### Changed +- Query results now automatically cancel when each result panel unmounts (e.g. when one clicks the back button). + +### 1.0.0-beta.69 - 2021-01-03 + +### Fixed + +- Fix Safari browser-specific issues +- Setup no longer fails when `commands.yaml` doesn't exist, even though it isn't needed. + +### Changed + +- Setup no longer adds example files + +### 1.0.0-beta.67 - 2021-01-02 + +### Fixed + +- Fix handling of `web.theme.default_color_mode`. Starting in 1.0.0-beta.65, it was completely ignored and used the library's default of `light`. Now, it's handled properly. +- Fix table output layout issues, particularly on mobile. + +### 1.0.0-beta.66 - 2021-01-02 + +### Fixed + +- Fixed Safari browser-specific issues +- Fixed mobile layout issues + +### Changed + +- `web.theme.colors.black` and `web.theme.colors.white` are now `web.theme.colors.dark` and `web.theme.colors.light respectively` + +### 1.0.0-beta.65 - 2021-01-01 + +### Added + +- [#72](https://github.com/thatmattlove/hyperglass/issues/72): _EXPERIMENTAL_ BGP map support for devices supporting structured output (Juniper Junos, currently). + +### Fixed + +- Fix an issue causing Juniper Junos BGP output parsing to fail if the XML output contains a banner. + +### Changed + +- `web.text.title` and `web.text.subtitle` now carry a 32 character limit for simpler styling. +- Various UI layout, styling improvements, and stability improvements. + +### 1.0.0-beta.63 - 2020-10-18 + +### 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). + +### Fixed + +- Fix an issue causing hyperglass custom exceptions to not be properly raised, which caused more generic error messages in the UI/API. + +### 1.0.0-beta.62 - 2020-10-17 + +### Fixed + +- Fix an issue causing exceptions not to be logged to the log file (but logged to stdout). + +### 1.0.0-beta.61 - 2020-10-11 + +### POTENTIALLY BREAKING CHANGE + +When hyperglass starts up, it will check to see if `~/hyperglass` or `/etc/hyperglass/` exists. Previously, it would silently choose the first one found, even if both exist. Now, if both exist, an exception is raised with instruction to delete one of them. If your system has both directories, hyperglass may not start up normally after you upgrade. + +### Fixed + +- Fix a DNS resolution issue which caused Debian systems to be unable to resolve the hostnames of any devices. This was due to differences in how the Python socket module works on Debian vs other distros (even Ubuntu). + +### 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. + +### 1.0.0-beta.60 - 2020-10-10 + +### Fixed + +- [#90](https://github.com/thatmattlove/hyperglass/issues/90): Fix a typing error that caused ping & traceroute queries to fail for certain devices. + +### Added + +- [#82](https://github.com/thatmattlove/hyperglass/issues/82): Add support for Redis password authentication. Authentication can be configured in the following manner: + +```yaml +# hyperglass.yaml +cache: + password: examplepassword +``` + +This would correspond with the following stanza in the Redis configuration file: + +``` +requirepass examplepassword +``` + +### 1.0.0-beta.59 - 2020-10-05 + +### Added + +- Native Mikrotik support. +- `hyperglass clear-cache` command for easy manual clearing of the Redis cache. + +### Changed + +- Improve output parsing scalability - parsers can now be defined on a per-NOS basis regardless of whether or not structured-data is used. +- Restructure model locations & importing to remove some complexities. + +### 1.0.0-beta.58 - 2020-09-28 + +### Changed + +- [#79](https://github.com/thatmattlove/hyperglass/issues/79): Run the UI build on startup & clarify docs. +- Removed all f-strings from log messages. +- Migrate icon library to [@meronex/icons](https://github.com/meronex/meronex-icons) for better tree-shaking. +- Improve console (stdout) logging +- Fix file logging format + +### Fixed + +- [#74](https://github.com/thatmattlove/hyperglass/issues/74): Fix UI build failures caused by `.alias.js`. +- [#75](https://github.com/thatmattlove/hyperglass/issues/75): Fix whitespace stripping of query target. +- [#77](https://github.com/thatmattlove/hyperglass/issues/77): Allow dashes in FQDN validation pattern. +- [#83](https://github.com/thatmattlove/hyperglass/issues/83): Fix lack of support for `protocol-nh` field in Juniper XML BGP table. + +### 1.0.0-beta.57 - 2020-07-30 + +### BREAKING CHANGE + +If you use [hyperglass-agent](https://github.com/thatmattlove/hyperglass-agent), you must upgrade your version of hyperglass-agent to 0.1.6 or later. If using hyperglass-agent with SSL, this release will require you to re-generate & re-send your SSL certificates to hyperglass: + +```console +$ hyperglass-agent certificate +$ hyperglass-agent send-certificate +``` + +### Changed + +- Verify a device's address is either an IPv4 or IPv6 address, or a resolvable hostname. +- Devices using hyperglass-agent (FRR, BIRD) no longer need to use a DNS-resolvable hostname in the `address:` field, as long as the certificate has been generated by hyperglass-agent, and the proper IP addresses were selected during the prompts to generate the certificate. _If using your own certificate and you want to connect to hyperglass-agent via an IP address instead of a hostname, you need to ensure the IP address of hyperglass-agent is listed as a Subject Alternative Name in the certificate extensions._ +- Refactored device, query, proxy models to no longer scrub unsupported characters from the device name for the purposes of Python class attribute accessing. +- Updated hyperglass-agent docs. + +### 1.0.0-beta.56 - 2020-07-28 + +### Changed + +- Improved Gunicorn address formatting. +- Improved Redis connection error handling. + +### Fixed + +- [#56](https://github.com/thatmattlove/hyperglass/issues/56): Fix a silent Redis connection error if the Redis server was anything other than `localhost`, preventing hyperglass from starting. + +### 1.0.0-beta.55 - 2020-07-27 + +### Changed + +- Removed JS favicon build process in favor of native Python implementation ([favicons](https://github/thatmattlove/favicons)) + +### 1.0.0-beta.54 - 2020-07-25 + +### Fixed + +- Queries to hyperglass-agent devices failed due to the error `AttributeError: 'AgentConnection' object has no attribute 'collect'` + +### 1.0.0-beta.53 - 2020-07-23 + +### Added + +- **BREAKING CHANGE**: [Scrapli](https://github.com/carlmontanari/scrapli) is now used for SSH connectivity to Cisco IOS, Cisco IOS-XE, Cisco IOS-XR, Cisco NX-OS Juniper Junos, and Arista EOS, which should improve the speed at which output is gathered from devices. _As of this release, Cisco IOS/IOS-XE and Juniper Junos have been directly tested and worked without issue. However, if you discover any anomalies with any of these operating systems, please [open an issue](https://github.com/thatmattlove/hyperglass/issues)._ + +### Changed + +- Refactor of SSH & HTTPS command execution to enable pluggable underlying driver capabilities. +- Remove `aiofile` dependency by removing unnecessary asyncio file operations in the UI build process. +- Added `scrapli[asyncssh]` dependency for Scrapli driver support. + +### Fixed + +- UI: Error messages couldn't be copied with the copy button + +### 1.0.0-beta.52 - 2020-07-19 + +### Added + +- API route `/api/info`, which displays general system information such as the name of the organization and version of hyperglass. +- API docs configuration parameters for the `/api/info` route. +- [#63](https://github.com/thatmattlove/hyperglass/issues/63): Minimum RAM requirements. +- `hyperglass system-info` CLI command to gather system CPU, Memory, Disk, Python Version, hyperglass Version, & OS info. _Note: this information is only gathered if you run the command, and even then, is printed to the console and not otherwise shared or exported_. + +### Changed + +- Updated docs dependencies. +- Improved YAML alias & anchor docs. +- [#55](https://github.com/thatmattlove/hyperglass/issues/55): Removed YAML alias & anchors from default examples to avoid confusion. + +### Fixed + +- API docs logo URL now displays correctly. +- [#62](https://github.com/thatmattlove/hyperglass/issues/62): Added `epel-release` to CentOS installation instructions. +- [#59](https://github.com/thatmattlove/hyperglass/issues/59): Fixed copy output for Juniper devices on non-table output query types. +- [hyperglass-agent #6](https://github.com/hyperglass-agent/issues/6): Fixed hyperglass-agent documentation issues. +- Improve command customization docs. +- [#61](https://github.com/thatmattlove/hyperglass/issues/61): Fixed copy output for table data. Output is now a bulleted list of parsed data. + +### 1.0.0-beta.51 - 2020-07-13 + +### Changed + +- Improved config import process & error handling. +- Improved logging initialization so that noisy logs aren't generated on startup unless debugging is enabled. + +### Fixed + +- [#54](https://github.com/thatmattlove/hyperglass/issues/54): A Junos parsing error caused routes with no communities to raise an error. +- Pre-validated config files are no longer logged on startup unless debugging is enabled. + +### 1.0.0-beta.50 - 2020-07-12 + +### Added + +- Synchronous API for Redis caching. +- New `redis-py` dependency for synchronous Redis communication. + +### Changed + +- Improved cache type conversion when reading cached data. +- External data via [bgp.tools](https://bgp.tools) is now gathered via their bulk mode API. +- External data via [bgp.tools](https://bgp.tools) is now cached via Redis to reduce external traffic and improve performance. +- RPKI validation via [Cloudflare](https://rpki.cloudflare.com/) is now cached via Redis to reduce external traffic and improve performance. +- Update Python dependencies. + +### Fixed + +- [#54](https://github.com/thatmattlove/hyperglass/issues/54): A Junos structured/table output parsing error caused routes with multiple next-hops to raise an error. +- RPKI validation no longer occurs twice (once on serialization of the output, once on validation of the API response). + +### 1.0.0-beta.49 - 2020-07-05 + +### Changed + +- Update UI dependencies +- Removed react-textfit in favor of responsive font sizes and line breaking +- Refactor & clean up React components + +### Fixed + +- Route lookups for private (RFC 1918) addresses failed due to an unnecessary lookup to [bgp.tools](https://bgp.tools) + +### 1.0.0-beta.48 - 2020-07-04 + +### Added + +- New NOS: **VyOS**. [See docs for important caveats](https://hyperglass.dev/docs/commands). + +### Fixed + +- UI: If the logo `width` parameter was set to ~ 50% and the `title_mode` was set to `logo_subtitle`, the subtitle would appear next to the logo instead of underneath. +- When copying the opengraph image, the copied image was not deleted. +- Default traceroute help link now _actually_ points to the new docs site. + +### 1.0.0-beta.47 - 2020-07-04 + +### 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)). + +### 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)). +- Various docs site improvements + +### Fixed + +- Remove `platform.linux_distribution()` which was removed in Python 3.8 +- Width of page is no longer askew when `logo_subtitle` is set as the `title_mode` +- Generated favicon manifest files now go to the correct directory. +- Various docs site fixes + +### 1.0.0-beta.46 - 2020-06-28 + +### Added + +- Support for hyperglass-agent [0.1.5](https://github.com/thatmattlove/hyperglass-agent) + +### 1.0.0-beta.45 - 2020-06-27 + +### Changed + +- Removed RIPEStat for external data gathering, switched to [bgp.tools](https://bgp.tools) + +### Fixed + +- Webhook construction bugs that caused webhooks not to send +- Empty response handling for table output + +### 1.0.0-beta.44 - 2020-06-26 + +### Added + +- Support for Microsoft Teams webhook + +### Fixed + +- If webhooks were enabled, a hung test connection to RIPEStat would cause the query to time out + +### 1.0.0-beta.43 - 2020-06-22 + +### Fixed + +- Logo path handling in UI + +### 1.0.0-beta.42 - 2020-06-21 + +### Added + +- Automatic favicon generation + +### 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. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..158c9d1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,32 @@ +# hyperglass 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. + +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. + +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. + +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. + +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). + +**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. + +**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. + +**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/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1e7d8fc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,18 @@ +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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..55442c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12.3-alpine as base +WORKDIR /opt/hyperglass +ENV HYPERGLASS_APP_PATH=/etc/hyperglass +ENV HYPERGLASS_HOST=0.0.0.0 +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_CONTAINER=true +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 + +FROM ui as hyperglass +WORKDIR /opt/hyperglass +RUN pip3 install -e . + +EXPOSE ${HYPERGLASS_PORT} +CMD ["python3", "-m", "hyperglass.console", "start"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3f48246 --- /dev/null +++ b/LICENSE @@ -0,0 +1,32 @@ +The Clear BSD License + +Copyright (c) 2024 Matthew Love +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted (subject to the limitations in the disclaimer +below) provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY +THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..869c03f --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +
+
+ +
+

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. +
+ +
+ +
+ +[**Documentation**](https://hyperglass.dev)   |   [**Live Demo**](https://demo.hyperglass.dev/) + +[![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) + +
+ +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. + +
+ +### [Changelog](https://hyperglass.dev/changelog) + +## Features + +- BGP Route, BGP Community, BGP AS Path, Ping, & Traceroute, or [add your own commands](https://hyperglass.dev/configuration/directives). +- 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 +- 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/) +- Query multiple devices simultaneously +- 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.* + +### [Get Started →](https://hyperglass.dev/installation) + +## Community + +- [Slack](https://netdev.chat/) +- [Telegram](https://t.me/hyperglasslg) + +Any users, potential users, or contributors of hyperglass are welcome to join and discuss usage, feature requests, bugs, and other things. + +**hyperglass is developed with the express intention of being free to the networking community**. + +*However, if you're feeling particularly helpful or generous, small donations are welcome.* + +[![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) + +## Acknowledgements + +hyperglass is built entirely on open-source software. Here are some of the awesome libraries used, check them out too! + +- [Netmiko](https://github.com/ktbyers/netmiko) +- [Litestar](https://litestar.dev) +- [Pydantic](https://docs.pydantic.dev/latest/) +- [Chakra UI](https://chakra-ui.com/) + +[![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/biome.json b/biome.json new file mode 100644 index 0000000..6329765 --- /dev/null +++ b/biome.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", + "organizeImports": { + "enabled": true + }, + "files": { + "ignore": [ + "node_modules", + "dist", + ".next/", + "out/", + "favicon-formats.ts", + "custom.*[js, html]", + "hyperglass.json" + ] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noUselessTypeConstraint": "off", + "noBannedTypes": "off" + }, + "style": { + "noInferrableTypes": "off", + "noNonNullAssertion": "off" + }, + "correctness": { + "useExhaustiveDependencies": "off" + } + } + }, + "formatter": { + "indentStyle": "space", + "lineWidth": 100, + "indentWidth": 2 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "bracketSpacing": true, + "semicolons": "always", + "arrowParentheses": "asNeeded", + "trailingComma": "all" + } + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..74a6ff6 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,21 @@ +services: + redis: + image: "redis:alpine" + hyperglass: + depends_on: + - redis + environment: + - HYPERGLASS_APP_PATH=/etc/hyperglass + - HYPERGLASS_HOST=${HYPERGLASS_HOST-0.0.0.0} + - HYPERGLASS_PORT=${HYPERGLASS_PORT-8001} + - 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_CONTAINER=${HYPERGLASS_CONTAINER-true} + - HYPERGLASS_ORIGINAL_APP_PATH=${HYPERGLASS_APP_PATH} + build: . + ports: + - "${HYPERGLASS_PORT-8001}:${HYPERGLASS_PORT-8001}" + volumes: + - ${HYPERGLASS_APP_PATH-/etc/hyperglass}:/etc/hyperglass diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..8ade9b2 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.env* +*.tsbuildinfo +*.tmp* +*.log +node_modules/ +fonts/ +.next +out +pages/changelog.mdx diff --git a/docs/components/color.tsx b/docs/components/color.tsx new file mode 100644 index 0000000..0234cba --- /dev/null +++ b/docs/components/color.tsx @@ -0,0 +1,29 @@ +type ColorProps = { + hex: string; + noText?: boolean; +}; + +export const Color = (props: ColorProps) => { + const { hex, noText = false } = props; + return ( + + + {noText ? "" : hex} + + + ); +}; diff --git a/docs/components/docs-button.tsx b/docs/components/docs-button.tsx new file mode 100644 index 0000000..c06c177 --- /dev/null +++ b/docs/components/docs-button.tsx @@ -0,0 +1,36 @@ +import NextLink from "next/link"; +import { Button } from "nextra/components"; + +const DocsIcon = () => ( + + Docs + + +); + +export interface DocsButtonProps extends React.ComponentProps<"button"> { + href: string; + side?: "left" | "right"; +} + +export const DocsButton = (props: DocsButtonProps) => { + const { href, side = "left", ...rest } = props; + return ( + + + + ); +}; diff --git a/docs/components/not-supported-icon.tsx b/docs/components/not-supported-icon.tsx new file mode 100644 index 0000000..5176aa2 --- /dev/null +++ b/docs/components/not-supported-icon.tsx @@ -0,0 +1,14 @@ +export const NotSupported = (props: React.ComponentProps<"svg">) => ( + + Not Supported + + + +); diff --git a/docs/components/platforms.tsx b/docs/components/platforms.tsx new file mode 100644 index 0000000..dda7d70 --- /dev/null +++ b/docs/components/platforms.tsx @@ -0,0 +1,44 @@ +import { Code, Table, Td, Th, Tr } from "nextra/components"; +import platforms from "~/platforms.json"; +import { NotSupported } from "./not-supported-icon"; +import { Supported } from "./supported-icon"; + +export const SupportedPlatforms = () => ( + +); + +export const PlatformTable = () => ( + + + + + + + {platforms.map((spec) => ( + + + + + ))} + +
Platform KeysNatively Supported
+ {spec.keys.map((key) => ( + + {key} + + ))} + {spec.native ? : }
+); diff --git a/docs/components/supported-icon.tsx b/docs/components/supported-icon.tsx new file mode 100644 index 0000000..f053c38 --- /dev/null +++ b/docs/components/supported-icon.tsx @@ -0,0 +1,13 @@ +export const Supported = (props: React.ComponentProps<"svg">) => ( + + Supported + + +); diff --git a/docs/favicon-formats.ts b/docs/favicon-formats.ts new file mode 100644 index 0000000..1ac6233 --- /dev/null +++ b/docs/favicon-formats.ts @@ -0,0 +1,80 @@ +interface Favicon { + rel: string | null; + dimensions: [number, number]; + image_format: string; + prefix: string; +} + +export default [ + { dimensions: [48, 48], image_format: "ico", prefix: "favicon", rel: null }, + { 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: null }, + { dimensions: [270, 270], image_format: "png", prefix: "mstile", rel: null }, + { dimensions: [310, 310], image_format: "png", prefix: "mstile", rel: null }, + { dimensions: [310, 150], image_format: "png", prefix: "mstile", rel: null }, + { dimensions: [196, 196], image_format: "png", prefix: "favicon", rel: "shortcut icon" }, +] as Favicon[]; diff --git a/docs/global.d.ts b/docs/global.d.ts new file mode 100644 index 0000000..75facdc --- /dev/null +++ b/docs/global.d.ts @@ -0,0 +1,8 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + NODE_ENV: 'development' | 'production'; + CF_PAGES_BRANCH: string; + } + } +} diff --git a/docs/global.module.css b/docs/global.module.css new file mode 100644 index 0000000..d6df9b3 --- /dev/null +++ b/docs/global.module.css @@ -0,0 +1,7 @@ +.logo { + color: #d84b4b; +} + +html[class~=dark] .logo { + color: #fff; +} \ No newline at end of file diff --git a/docs/next-env.d.ts b/docs/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/docs/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/docs/next.config.mjs b/docs/next.config.mjs new file mode 100644 index 0000000..9223adc --- /dev/null +++ b/docs/next.config.mjs @@ -0,0 +1,35 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import withNextra 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"); + fs.writeFileSync(dst, replaced); +} + +copyChangelog(); + +/** + * @type {import('nextra').NextraConfig} + */ +const nextraConfig = { + theme: "nextra-theme-docs", + themeConfig: "./theme.config.tsx", +}; + +/** + * @type {import('next').NextConfig} + */ +const config = { + images: { + unoptimized: true, + }, + output: "export", +}; + +export default withNextra(nextraConfig)(config); diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..a63a8e9 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,24 @@ +{ + "name": "hyperglass-docs", + "version": "2.0.0", + "description": "hyperglass documentation", + "private": true, + "scripts": { + "dev": "next dev", + "start": "next start", + "typecheck": "tsc --noEmit" + }, + "author": "thatmattlove ", + "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" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "typescript": "^5.3.3" + } +} diff --git a/docs/pages/_meta.tsx b/docs/pages/_meta.tsx new file mode 100644 index 0000000..78bc398 --- /dev/null +++ b/docs/pages/_meta.tsx @@ -0,0 +1,42 @@ +export default { + index: { title: "Introduction", theme: { breadcrumb: false } }, + "---": { + type: "separator", + }, + installation: "Installation", + configuration: "Configuration", + platforms: "Platforms", + plugins: "Plugins", + documentation: { + title: "Documentation", + type: "menu", + items: { + installation: { + title: "Installation", + href: "/installation", + }, + configuration: { + title: "Configuration", + href: "/configuration", + }, + plugins: { + title: "Plugins", + href: "/plugins", + }, + changelog: { + title: "Changelog", + href: "/changelog", + }, + license: { + title: "License", + href: "/license", + }, + }, + }, + demo: { + title: "Demo", + type: "page", + href: "https://demo.hyperglass.dev", + newWindow: true, + }, +}; diff --git a/docs/pages/configuration/_meta.tsx b/docs/pages/configuration/_meta.tsx new file mode 100644 index 0000000..713550e --- /dev/null +++ b/docs/pages/configuration/_meta.tsx @@ -0,0 +1,7 @@ +export default { + overview: "Overview", + config: "Config File", + devices: "Devices File", + directives: "Directives File", + examples: "Examples", +}; diff --git a/docs/pages/configuration/config.mdx b/docs/pages/configuration/config.mdx new file mode 100644 index 0000000..85262af --- /dev/null +++ b/docs/pages/configuration/config.mdx @@ -0,0 +1,44 @@ +--- +title: Configuration File +description: hyperglass config.yaml file reference +--- + +The `config.yaml` file is broken into multiple sections: + +## Top Level Parameters + +| 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. | +| `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. | +| `site_title` | String | `org_name` | Browser title, also used in the default terms & conditions. | + +#### Example with Defaults + +```yaml filename="config.yaml" +org_name: Beloved Hyperglass User +plugins: [] +primary_asn: 65000 +request_timeout: 90 +site_description: Beloved Hyperglass User Network Looking Glass +site_title: Beloved Hyperglass User +``` + +## Other Configuration Sections + +| Parameter | Docs | Description | +| :----------- | :--------------------------------------------------------------------- | :--------------------------------------------------------------- | +| `cache` | [Caching Docs](/configuration/config/caching.mdx) | Customize how hyperglass 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. | + +## 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. diff --git a/docs/pages/configuration/config/_meta.tsx b/docs/pages/configuration/config/_meta.tsx new file mode 100644 index 0000000..c91b461 --- /dev/null +++ b/docs/pages/configuration/config/_meta.tsx @@ -0,0 +1,8 @@ +export default { + "api-docs": "API Docs", + caching: "Caching", + logging: "Logging & Webhooks", + messages: "Messages", + "structured-output": "Structured Output", + "web-ui": "Web UI", +}; diff --git a/docs/pages/configuration/config/api-docs.mdx b/docs/pages/configuration/config/api-docs.mdx new file mode 100644 index 0000000..a88dd43 --- /dev/null +++ b/docs/pages/configuration/config/api-docs.mdx @@ -0,0 +1,72 @@ +## 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. + +| 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.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. | +| `docs.description` | String | | API docs description. Appears below the title. | + +The documentation for API endpoints follow a common schema: + +- `devices` +- `info` +- `queries` +- `query` + +### Schema + +| Parameter | Type | Description | +| :------------ | :----- | :------------------------------------------------------------------------------- | +| `title` | String | API endpoint title, displayed as the header text above the API endpoint section. | +| `description` | String | API endpoint description, displayed inside each API endpoint section. | +| `summary` | String | API endpoint summary, displayed beside the API endpoint path. | + +### Parameters + +| Parameter | Default Value | +| :------------------------- | :------------------------------------------------------------------------------------------ | +| `docs.devices.title` | Devices | +| `docs.devices.description` | List of all devices/locations with associated identifiers, display names, networks, & VRFs. | +| `docs.devices.summary` | Devices List | +| `docs.info.title` | System Information | +| `docs.info.description` | General information about this looking glass. | +| `docs.info.summary` | System Information | +| `docs.queries.title` | Supported Queries | +| `docs.queries.description` | List of supported query types. | +| `docs.queries.summary` | Query Types | +| `docs.query.title` | Supported Query | +| `docs.query.description` | Request a query response per-location. | +| `docs.query.summary` | Query the Looking Glass | + +#### Example with Defaults + +```yaml filename="config.yaml" +docs: + base_url: https://lg.example.com + enable: true + mode: redoc + path: /api/docs + title: Beloved Hyperglass User Looking Glass API Documentation + description: null + # API Endpoints ↓ + devices: + title: Devices + description: List of all devices/locations with associated identifiers, display names, networks, & VRFs. + summary: Devices List + info: + title: System Information + description: General information about this looking glass. + summary: System Information + queries: + title: Supported Queries + description: List of supported query types. + summary: Query Types + query: + title: Supported Query + description: Request a query response per-location. + summary: Query the Looking Glass +``` diff --git a/docs/pages/configuration/config/caching.mdx b/docs/pages/configuration/config/caching.mdx new file mode 100644 index 0000000..839b8e1 --- /dev/null +++ b/docs/pages/configuration/config/caching.mdx @@ -0,0 +1,16 @@ +## Cache + +hyperglass automatically caches responses to reduce the number of times devices are queried for the same information. + +| Parameter | Type | Default Value | Description | +| :---------------- | :------ | :------------ | :------------------------------------------------------------------------------ | +| `cache.timeout` | Number | 120 | Number of seconds for which to cache device responses. | +| `cache.show_text` | Boolean | True | If true, an indication that a user is viewing cached information will be shown. | + +### Example with Defaults + +```yaml filename="config.yaml" +cache: + timeout: 120 + show_text: true +``` diff --git a/docs/pages/configuration/config/logging.mdx b/docs/pages/configuration/config/logging.mdx new file mode 100644 index 0000000..5f1e834 --- /dev/null +++ b/docs/pages/configuration/config/logging.mdx @@ -0,0 +1,90 @@ +## Logging + +Console, file, HTTP, and/or syslog logging configuration. + +| Parameter | Type | Default Value | Description | +| :---------- | :----- | :------------ | :---------------------------------------------- | +| `directory` | String | /tmp | Path to directory where logs will be created. | +| `format` | String | text | Log text format, must be `text` or `json`. | +| `max_size` | String | 50MB | Maximum log file size before being overwritten. | + +### Syslog + +| Parameter | Type | Default Value | Description | +| :-------- | :----- | :------------ | :---------------------- | +| `host` | String | | Syslog target host. | +| `port` | Number | 514 | Syslog target UDP port. | + +##### Syslog Example + +```yaml filename="config.yaml" copy +logging: + syslog: + host: log.example.com + port: 514 +``` + +### HTTP Logging + +If enabled, logs will be sent by HTTP `POST` method. + +| Parameter | Type | Default Value | Description | +| :----------- | :------ | :------------ | :---------------------------------------------- | +| `provider` | String | generic | Must be `generic`, `msteams`, or `slack`. | +| `host` | String | | URL | +| `headers` | Map | | | +| `params` | Map | | | +| `verify_ssl` | Boolean | true | Enable or disable SSL certificate verification. | +| `timeout` | Number | 5 | HTTP connection timeout in seconds. | + +#### Authentication + +Authentication is supported using HTTP basic authentication or an API key. + +| Parameter | Type | Default Value | Description | +| :----------------------------- | :----- | :------------ | :--------------------------------------------------------------------------------------------------------- | +| `http.authentication.mode` | String | basic | Must be `basic` or `api_key`. | +| `http.authentication.username` | String | | Basic authentication username if `mode` is set to `basic`. | +| `http.authentication.password` | String | | Basic authentication password if `mode` is set to `basic`, or API key value if `mode` is set to `api_key`. | +| `http.authentication.header` | String | X-API-Key | Header name if `mode` is set to `api_key`. | + +#### Examples + +##### Generic + +```yaml filename="config.yaml" copy +logging: + http: + provider: generic + host: https://httpbin.org + headers: + x-special-header: super special header value + params: + source: hyperglass + verify_ssl: true + timeout: 5 + authentication: + mode: basic + username: your username + 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. + +##### Microsoft Teams Webhook + +```yaml filename="config.yaml" copy +logging: + http: + provider: msteams + host: +``` + +##### Slack + +```yaml filename="config.yaml" copy +logging: + http: + provider: slack + host: +``` diff --git a/docs/pages/configuration/config/messages.mdx b/docs/pages/configuration/config/messages.mdx new file mode 100644 index 0000000..135a788 --- /dev/null +++ b/docs/pages/configuration/config/messages.mdx @@ -0,0 +1,23 @@ +## Message Customization + +hyperglass provides as much control over user-facing text/messages as possible. 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.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.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. | + +##### Example + +```yaml filename="config.yaml" +message: + general: Something with wrong. +``` diff --git a/docs/pages/configuration/config/structured-output.mdx b/docs/pages/configuration/config/structured-output.mdx new file mode 100644 index 0000000..df93625 --- /dev/null +++ b/docs/pages/configuration/config/structured-output.mdx @@ -0,0 +1,61 @@ +## 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 +- Juniper Junos + +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/) + +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. | + +### RPKI Examples + +#### Show RPKI State from the Device's Perspective + +```yaml filename="config.yaml" copy {2} +structured: + rpki: + mode: router +``` + +#### Show RPKI State from a Public/External Perspective + +```yaml filename="config.yaml" copy {2} +structured: + rpki: + mode: external +``` + +### Community Filtering Examples + +#### Deny Listed Communities by Regex pattern + +```yaml filename="config.yaml" {5-6} +structured: + communities: + mode: deny + items: + - '^65000:1\d+$' # don't show any communities starting with 65000:1. 65000:1234 would be denied, but 65000:4321 would be permitted. + - "65000:2345" # don't show the 65000:2345 community. +``` + +#### Permit only Listed Communities + +```yaml filename="config.yaml" {5-6} +structured: + communities: + mode: permit + items: + - "^65000:.*$" # permit any communities starting with 65000, but no others. + - "1234:1" # permit only the 1234:1 community. +``` diff --git a/docs/pages/configuration/config/web-ui.mdx b/docs/pages/configuration/config/web-ui.mdx new file mode 100644 index 0000000..7613447 --- /dev/null +++ b/docs/pages/configuration/config/web-ui.mdx @@ -0,0 +1,294 @@ +import { Callout } from "nextra/components"; +import { Color } from "~/components/color"; + +## Web UI + +hyperglass provides extensive customization options for the look and feel of the web UI. + +| Parameter | Type | Default Value | Description | +| :-------------------------- | :----- | :------------ | :----------------------------------------------------------------------------------------------------------------------------------------- | +| `web.location_display_mode` | String | auto | Show the locations field as a `dropdown`, `gallery`. When using `auto`, if there are more than 5 location groups, `dropdown` will be used. | +| `web.custom_javascript` | String | | Path to a javascript file that will be loaded with the website. | +| `web.custom_html` | String | | Path to a custom HTML file that will be loaded with the website and rendered beneath the app. | + +### DNS over HTTPS + +[DNS over HTTPS](https://www.rfc-editor.org/rfc/rfc8484) is used to look up an FQDN query target from the perspective of the user's browser. + +| Parameter | Type | Default Value | Description | +| :------------------ | :----- | :------------------------------------- | :-------------------------------------------------------------------------- | +| `dns_provider.name` | String | cloudflare | If `cloudflare` or `google` are provided, no URL is necessary. | +| `dns_provider.url` | String | `https://cloudflare-dns.com/dns-query` | Provide a custom DNS over HTTPS URL if you'd like to use your own resolver. | + +### Logo + +| Parameter | Type | Default Value | Description | +| :----------------- | :--------------- | :------------ | :------------------------------------------------- | +| `web.logo.light` | String | | Path to logo to show in light mode. | +| `web.logo.dark` | String | | Path to logo to show in dark mode. | +| `web.logo.favicon` | String | | Path to icon/logo from which to generate favicons. | +| `web.logo.width` | Number or String | 100% | Width of logo, either as pixels or a percentage. | +| `web.logo.height` | Number or String | | Height of logo, either as pixels or a percentage. | + +##### Example + +```yaml filename="config.yaml" copy +web: + logo: + light: /path/to/light-logo.svg + dark: /path/to/dark-logo.svg + favicon: /path/to/favicon.svg + width: 100% + height: null +``` + +### Greeting + +The greeting is an optional modal/popup window that will present itself to users in the Web UI. It can be used for anything you want; for example: + +- A data privacy acknowledgement that must be acknowledged prior to proceeding +- Information about your company +- Instructions on how to use hyperglass + +| Parameter | Type | Default Value | Description | +| :---------------------- | :------ | :------------ | :--------------------------------------------------------------------------------------------------- | +| `web.greeting.enable` | Boolean | False | Enable or disable the greeting. | +| `web.greeting.file` | String | | Path to markdown or plain text file that contains greeting content. | +| `web.greeting.title` | String | Welcome | Text to be used as the greeting title. | +| `web.greeting.button` | String | Continue | Text to be used for the button that acknowledges/closes the greeting. | +| `web.greeting.required` | Boolean | False | If `true`, users cannot close the modal or run any queries until the greeting has been acknowledged. | + +##### Example + +```yaml filename="config.yaml" copy +web: + greeting: + enable: true + file: /path/to/your/file.md + title: Welcome + button: Continue + required: false +``` + +### 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. + +| Parameter | Type | Default Value | Description | +| :-------------------- | :----- | :------------ | :---------------------------- | +| `web.opengraph.image` | String | | Path to your OpenGraph image. | + +### 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. + +Each value you wish to be highlighted is defined with the following schema: + +| Parameter | Type | Default Value | Description | +| :-------- | :----- | :------------ | :------------------------------------------------------ | +| `pattern` | String | | RegEx pattern or string to match against router output. | +| `label` | String | | Tooltip value when the highlighted text is hovered. | +| `color` | String | | Color name from [`web.theme.colors`](#colors). | + +##### Example + +```yaml filename="config.yaml" copy +web: + highlight: + - pattern: "65000:1234" + label: Special snowflake community that does a thing + color: primary + - pattern: '^192\.0\.2\.[0-9]+$' + label: Magical IP Address + color: blue +``` + +### Menus + +hyperglass can show completely-customizable menus in the footer. Each menu can be configured with the following schema: + +| Parameter | Type | Default Value | Description | +| :-------- | :----- | :------------ | :------------------------------------------------------------------------------------------------------------------------- | +| `title` | String | | Menu title, will be the text that shows on footer. | +| `content` | String | | Plain text or markdown content of the menu or path to a file that contains plain text or markdown content. | +| `side` | String | left | Side of the footer with which the menu will be grouped. | +| `order` | Number | 0 | Optionally specify an order for each menu item. If not specified, menus will be rendered in the order they are configured. | + +##### Example + +```yaml filename="config.yaml" copy +web: + menus: + - title: Terms & Conditions + content: + | **Don't** break stuff! + _please_ + side: right + order: 1 + - title: Help + content: /path/to/help/file.md + side: left + order: 0 +``` + +### Links + +hyperglass can show customizable links to anything you think your users might find helpful. Each link can be configured with the following schema: + +| Parameter | Type | Default Value | Description | +| :---------- | :------ | :------------ | :-------------------------------------------------------------------------------------------------------------------- | +| `title` | String | | Link text. | +| `url` | String | | Link URL. | +| `show_icon` | Boolean | True | If `true`, an icon indicating the link is an external link is shown. | +| `side` | String | left | Side of the footer with which the link will be grouped. | +| `order` | Number | 0 | Optionally specify an order for each link. If not specified, links will be rendered in the order they are configured. | + +##### Example + +```yaml filename="config.yaml" copy +web: + links: + - title: PeeringDB + url: https://www.peeringdb.com/65000 + show_icon: true + side: right + order: 1 + - title: Our Website + url: https://example.com + show_icon: false + side: left + order: 0 +``` + +### Credit + +| Parameter | Type | Default | Description | +| :-------- | :-----: | :-----: | :--------------------------------------------------------------------------------------- | +| `enable` | Boolean | `true` | Enable or disable the display of developer credit & link to hyperglass GitHub repository | + + + **Note from the Developer** +
If your organization's policy allows, and you don't mind, I request that you keep `credit` + enabled. Remember: my goal for this project is get more networks to use looking glasses to make all + of our lives easier. Because it's primarily other network operators who will use this tool to begin + with, I'd love for any operators that use your looking glass to know where they can get their own. +
+ +### Text + +Most of the text in the hyperglass 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.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. | +| `web.text.query_target` | String | Target | Text used to label the query target field. | +| `web.text.fqdn_tooltip` | String | Use \{protocol\} | Tooltip text used when a user hovers over the FQDN tooltip. | +| `web.text.fqdn_message` | String | Your browser has resolved \{fqdn\} to | Text used when prompting a user to select a resolve IPv4 or IPv6 address for an FQDN query. | +| `web.text.fqdn_error` | String | Unable to resolve \{fqdn\} | Text used when an FQDN is not resolvable. | +| `web.text.fqdn_error_button` | String | Try Again | Button text used when an FQDN is not resolvable. | +| `web.text.cache_prefix` | String | Results cached for | Text displayed with the cache timeout countdown. | +| `web.text.cache_icon` | String | Cached from \{time\} UTC | Text used when a user hovers over the cache icon, which is displayed when a response was a cached response. `{time}` is replaced with the _original_ query's timestamp. | +| `web.text.complete_time` | String | Completed in \{seconds\} | Text used when a user hovers over the success icon for a query result. `{seconds}` will be replaced with 'n seconds' where n is the time a query took to complete. | +| `web.text.rpki_invalid` | String | Invalid | Text used in table output when a route's RPKI status is invalid. | +| `web.text.rpki_valid` | String | Valid | Text used in table output when a route's RPKI status is valid. | +| `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.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. | + +#### Title Mode + +Available title modes are: + +| `title_mode` Value | Action | +| :----------------- | :------------------------------------------------------------------ | +| `logo_only` | Only the logo is displayed, no title or subtitle will be visible. | +| `text_only` | Only the title and subtitle are displayed, no logo will be visible. | +| `logo_subtitle` | Only the logo and subtitle are displayed, no title will be visible. | +| `all` | Logo, title, and subtitle will all be visible. | + +##### Example + +```yaml filename="config.yaml" +web: + text: + title: Our Looking Glass + subtitle: Company Name, Inc. + title_mode: text_only +``` + +### Theme + +hyperglass allows you to customize the colors 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` | + +#### Colors + +##### Intrinsic Colors + +| Parameter | Default Value | +| :------------------------ | :---------------------- | +| `web.theme.colors.black` | | +| `web.theme.colors.white` | | +| `web.theme.colors.dark` | | +| `web.theme.colors.light` | | +| `web.theme.colors.gray` | | +| `web.theme.colors.red` | | +| `web.theme.colors.orange` | | +| `web.theme.colors.yellow` | | +| `web.theme.colors.green` | | +| `web.theme.colors.blue` | | +| `web.theme.colors.teal` | | +| `web.theme.colors.cyan` | | +| `web.theme.colors.pink` | | +| `web.theme.colors.purple` | | + +##### Functional Colors + +| Parameter | Default Value | +| :--------------------------- | :--------------------- | +| `web.theme.colors.primary` | | +| `web.theme.colors.secondary` | | +| `web.theme.colors.success` | | +| `web.theme.colors.warning` | | +| `web.theme.colors.error` | | +| `web.theme.colors.danger` | | + +###### 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 cyan: + +```yaml filename="config.yaml" +web: + theme: + colors: + cyan: "#00ffff" +``` + +#### 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. + +| Parameter | Type | Default Value | Description | +| :--------------------- | :----- | :------------ | :--------------------------------------------------------------------------------------- | +| `web.theme.fonts.body` | String | Nunito | Font for all standard body text, including headings. | +| `web.theme.fonts.mono` | String | Fire Code | Font for all monospace text such as inline code or code blocks, including device output. | + +##### Example + +```yaml filename="config.yaml" +web: + theme: + fonts: + body: Inter +``` diff --git a/docs/pages/configuration/devices.mdx b/docs/pages/configuration/devices.mdx new file mode 100644 index 0000000..f30799a --- /dev/null +++ b/docs/pages/configuration/devices.mdx @@ -0,0 +1,135 @@ +import { Callout } from "nextra/components"; +import { SupportedPlatforms } from "~/components/platforms"; +import { DocsButton } from "~/components/docs-button"; + +## Device Configuration Parameters + +Each configured device may have the following parameters: + +| Parameter | Type | Default Value | Description | +| :------------------ | :-------------- | :------------ | :----------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | String | | Display name of the device. | +| `description` | String | | Description of the device, displayed as a subtle label. | +| `avatar` | String | | Path to an avatar/logo image for this site. Used when [`web.location_display_mode`](/configuration/config/web-ui.mdx) is set to `gallery`. | +| `address` | String | | IPv4 address, IPv6 address, or hostname of the device. | +| `group` | String | | Group name, used to visually group devices in the UI. | +| `port` | Number | | TCP port on which to connect to the device. | +| `platform` | String | | Device platform/OS. Must be a [supported platform](/platforms.mdx). | +| `structured_output` | Boolean | True | Disable structured output for a device that supports it. | +| `directives` | List of Strings | | Enable referenced directives configured in the [directives config file](/configuration/directives.mdx). | +| `driver` | String | netmiko | Specify which driver to use for this device. Currently, only `netmiko` is supported. | +| `driver_config` | Mapping | | Mapping/dict of options to pass to the connection driver. | +| `attrs` | Mapping | | Mapping/dict of variables, as referenced in configured directives. | +| `credential` | Mapping | | Mapping/dict of a [credential configuration](/configuration/devices/credentials.mdx). | +| `http` | Mapping | | Mapping/dict of [HTTP client options](/configuration/devices/http-device.mdx), if this device is connected via HTTP. | +| `proxy` | Mapping | | Mapping/dict of [SSH proxy config](/configuration/devices/ssh-proxy.mdx) to use for this device's requests. | + + + +hyperglass ships with predefined [directives](/configuration/directives.mdx) (commands) for the following [platforms](platforms.mdx): + + + +All built in directives require that the following `attrs` be defined on each device using the directive: + +| Attribute | Value | +| :-------- | :-------------------------------------------------------- | +| `source4` | IPv4 address used to source Ping and Traceroute commands. | +| `source6` | IPv6 address used to source Ping and Traceroute commands. | + +**Example** + +```yaml filename="devices.yaml" {5-7} copy +devices: + - name: New York, NY + address: 192.0.2.1 + platform: cisco_ios + attrs: + source4: 192.0.2.1 + source6: "2001:db8::1" +``` + + + + + If you do not utilize IPv6 in your network, you'll need to create your own directive that only + has IPv4 commands. + + +## Examples + +### Simple + +```yaml filename="devices.yaml" copy +devices: + - name: New York, NY + address: 192.0.2.1 + platform: cisco_ios + credential: + username: you + password: your password + - name: San Francisco, CA + address: 192.0.2.2 + platform: juniper + credential: + username: you + password: your password +``` + +

+ {" "} + With Directives +

+ +In this example, an additional directive `cisco-show-lldp-neighbors` is added to the built-in directives. + +```yaml filename="devices.yaml" copy {8-9} +devices: + - name: New York, NY + address: 192.0.2.1 + platform: cisco_ios + credential: + username: you + password: your password + directives: + - cisco-show-lldp-neighbors +``` + +

+ {" "} + Disable Built-in Directives +

+ +In this example, _only_ the `cisco-show-lldp-neighbors` directive will be available. Built-in directives are disabled. + +```yaml filename="devices.yaml" copy {8-10} +devices: + - name: New York, NY + address: 192.0.2.1 + platform: cisco_ios + credential: + username: you + password: your password + directives: + - builtin: false + - cisco-show-lldp-neighbors +``` + +

+ {" "} + Enable Specifc Built-in Directives +

+ +In this example, only specified built-in directives are made available. + +```yaml filename="devices.yaml" copy {8-9} +devices: + - name: New York, NY + address: 192.0.2.1 + platform: cisco_ios + credential: + username: you + password: your password + directives: + - builtin: [bgp_route, traceroute] +``` diff --git a/docs/pages/configuration/devices/_meta.tsx b/docs/pages/configuration/devices/_meta.tsx new file mode 100644 index 0000000..cc95490 --- /dev/null +++ b/docs/pages/configuration/devices/_meta.tsx @@ -0,0 +1,5 @@ +export default { + credentials: "Credentials", + "http-device": "HTTP Device", + "ssh-proxy": "SSH Proxy", +}; diff --git a/docs/pages/configuration/devices/credentials.mdx b/docs/pages/configuration/devices/credentials.mdx new file mode 100644 index 0000000..463ff68 --- /dev/null +++ b/docs/pages/configuration/devices/credentials.mdx @@ -0,0 +1,35 @@ +Each device must be configured with credentials with which hyperglass can log into the device and execute commands. + +## Credential Configuration + +| Parameter | Type | Default Value | Description | +| :-------------------- | :----- | :------------ | :----------------------------------------------------- | +| `credential.username` | String | | Username to use for authentication to the device. | +| `credential.password` | String | | Password to use for authentication to the device. | +| `credential.key` | String | | Path to SSH key used for authentication to the device. | + +### Examples + +#### Username & Password + +```yaml filename="devices.yaml" copy {5-7} +devices: + - name: New York, NY + address: 192.0.2.1 + platform: cisco_ios + credential: + username: you + password: your password +``` + +#### SSH Private Key + +```yaml filename="devices.yaml" copy {5-7} +devices: + - name: San Francisco, CA + address: 192.0.2.2 + platform: juniper + credential: + username: you + key: /path/to/your/ssh/key +``` diff --git a/docs/pages/configuration/devices/http-device.mdx b/docs/pages/configuration/devices/http-device.mdx new file mode 100644 index 0000000..ce14abe --- /dev/null +++ b/docs/pages/configuration/devices/http-device.mdx @@ -0,0 +1,85 @@ +hyperglass supports collecting 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.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. | +| `http.headers` | Mapping | | Mapping/dict of http headers to append to requests. | +| `http.method` | String | GET | HTTP method to use for requests. | +| `http.path` | String | / | HTTP URI/Path. | +| `http.query` | Mapping | | Mapping/Dict of URL Query Parameters. | +| `http.retries` | Number | 0 | Number of retries to perform before request failure. | +| `http.scheme` | String | https | HTTP schema, must be `http` or `https` | +| `http.source` | String | | Request source IP address. | +| `http.ssl_ca` | String | | Path to SSL CA certificate file for SSL validation. | +| `http.ssl_client` | String | | Path to client SSL certificates for request. | +| `http.timeout` | Number | 5 | Request timeout in seconds. | +| `http.verify_ssl` | Boolean | `true` | If `false`, invalid certificates for HTTPS hosts will be ignored. | + +### Example + +#### Basic + +The following example will send an HTTP POST request to `https://192.0.2/path/to/query/device` with HTTP basic authentication, and will not verify the SSL certificate. + +```yaml filename="devices.yaml" copy +devices: + - name: New York, NY + address: 192.0.2.1 + http: + path: /path/to/query/device + method: POST + verify_ssl: false + basic_auth: + username: you + password: your password +``` + +Given the following hyperglass query: + +| Field | Value | +| :------------- | :------------------ | +| Query Target | `192.0.2.0/24` | +| Query Location | `your_location` | +| Query Type | `example_directive` | + +The body of the request will be: + +```json +{ + "query_target": "192.0.2.0/24", + "query_location": "your_location", + "query_type": "example_directive" +} +``` + +#### Non-HTTPS Request + +The following example will send an HTTP GET request to `http://192.0.2.1/path/to/query/device`: + +```yaml filename="devices.yaml" {6} copy +devices: + - name: New York, NY + address: 192.0.2.1 + http: + path: /path/to/query/device + scheme: http +``` + +#### Header Authentication + +The following example will send an HTTP GET request to `https://192.0.2.1/path/to/query/device` with an `Authorization` header: + +```yaml filename="devices.yaml" {6-7} copy +devices: + - name: New York, NY + address: 192.0.2.1 + http: + path: /path/to/query/device + headers: + Authorization: your special token +``` diff --git a/docs/pages/configuration/devices/ssh-proxy.mdx b/docs/pages/configuration/devices/ssh-proxy.mdx new file mode 100644 index 0000000..99d19f1 --- /dev/null +++ b/docs/pages/configuration/devices/ssh-proxy.mdx @@ -0,0 +1,30 @@ +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. + +## SSH Proxy Configuration + +| Parameter | Type | Default Value | Description | +| :----------------- | :------ | :------------ | :----------------------------------------------------------------------------------- | +| `proxy.address` | String | | IPv4 address, IPv6 address, or hostname of SSH proxy. | +| `proxy.port` | Number | 22 | TCP port to use for connecting to the SSH proxy. | +| `proxy.platform` | String | linux_ssh | Currently, only `linux_ssh` is supported. | +| `proxy.credential` | Mapping | | Mapping/dict of a [credential configuration](/configuration/config/credentials.mdx). | + +### Examples + +#### Use an SSH Proxy When Connecting to a Device + +```yaml filename="devices.yaml" copy +devices: + - name: New York, NY + address: 192.0.2.1 + credential: + username: you + password: your password + proxy: + address: 203.0.113.1 + credential: + username: your proxy username + 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. diff --git a/docs/pages/configuration/directives.mdx b/docs/pages/configuration/directives.mdx new file mode 100644 index 0000000..02891a9 --- /dev/null +++ b/docs/pages/configuration/directives.mdx @@ -0,0 +1,186 @@ +import { Callout } from "nextra/components"; + +## What is a directive? + + + +A directive is a defined configuration for one or more **commands** to run on a device. For example, a BGP Route query is a built-in directive. 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 +- Help information to show about the directive +- Validation rules + + + +Each directive has the following options: + +| Parameter | Type | Default Value | Description | +| :------------------- | :-------------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | String | | Display name of the directive. | +| `rules` | List of Rules | | List of [rule configs](#rules) | +| `field` | Mapping | | Mapping/dict of [fields config](#fields) | +| `info` | String | | File path to markdown-formatted help information about the directive. | +| `plugins` | List of Strings | | List of plugin names to use with this directive. | +| `groups` | List of Strings | | List of names by which directives are grouped in the UI. | +| `multiple` | Boolean | `false` | Command supports receiving multiple values. For example, Cisco IOS's `show ip bgp community` accepts multiple communities as arguments. | +| `multiple_separator` | String | `" "` | String by which multiple values are separated. For example, a list of values `[65001, 65002, 65003]` would be rendered as `65001 65002 65003` for when the command is run. | + +## Rules + +A rule is a way of saying "if a query target matches the rule's conditions, run this command". + +| Parameter | Type | Default Value | Description | +| :---------- | :-------------- | :------------ | :--------------------------------------------------------------------------------------------- | +| `condition` | String | | Regular expression to match or IP prefix in which the value being evaluated must be contained. | +| `action` | String | permit | `permit` or `deny` the directive target when this rule is matched. | +| `commands` | List of Strings | | Commands to run when this rule matches. `{target}` is replaced with the query target. | + +### IP Rule + +| Parameter | Type | Default Value | Description | +| :------------------ | :------ | :------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ge` | Number | 0 | Prefix length greater than defined will be matched. | +| `le` | Number | 32,128 | `32` for IPv4 evaluations, `128` for IPv6 evaluations. Prefix length less than defined will be matched. | +| `allow_reserved` | Boolean | `false` | Allow reserved ([RFC1918](https://www.rfc-editor.org/rfc/rfc1918), [RFC5735](https://www.rfc-editor.org/rfc/rfc5735), [RFC5737](https://www.rfc-editor.org/rfc/rfc5737.html), etc.) addresses to pass validation. | +| `allow_unspecified` | Boolean | `false` | Allow unspecified addresses (`0.0.0.0` `::`) to pass validation. | +| `allow_loopback` | Boolean | `false` | Allow [loopback addresses](https://www.rfc-editor.org/rfc/rfc1700.html) (`127.0.0.0/8` `::1`) to pass validation. | + +#### Examples + +##### Require IPv4 Queries between /8 and /24 + +```yaml filename="directives.yaml" {5-6} /{target} {mask}/ copy +your-directive: + name: IP Route + rules: + - condition: 0.0.0.0/0 + ge: 8 + le: 24 + command: "show ip route {target} {mask}" +``` + +Given a query target of 198.18.0.0/15, the command run on the device would be: + +```none /198.18.0.0 255.254.0.0/ +show ip route 198.18.0.0 255.254.0.0 +``` + +##### Deny a Specific Prefix + +```yaml filename="directives.yaml" {5} copy +your directive: + name: BGP Route + rules: + - condition: "192.0.2.0/24" + action: deny + - condition: "0.0.0.0/0" + command: "show ip bgp {target}" +``` + +In this example, a query of any IP address or prefix contained within 192.0.2.0/24 will result in an error. + +##### Run Multiple Commands + +```yaml filename="directives.yaml" /{target}/ {6-7} copy +your-directive: + name: BGP Communities + rules: + - condition: "65000:[0-9]+" + commands: + - "show route table inet.0 community {target} detail" + - "show route table inet6.0 community {target} detail" +``` + +In this example, a query of `65000:1` would result in the following commands being sent to the device: + +```none /65000:1/ +show route table inet.0 community 65000:1 detail +show route table inet6.0 community 65000:1 detail +``` + +The output for both commands will be shown as the query result. + +### Regex Validation + +To validate input by regex pattern, just specify a regex pattern as the `condition` + +```yaml filename="directives.yaml" copy +your-directive: + name: DNS Query + rules: + - condition: '^.+\.yourdomain\.com$' +``` + +### No Validation + +```yaml filename="directives.yaml" /{target}/ copy {4} +your-directive: + name: IP Route + rules: + - condition: null + command: show ip route {target} +``` + +In this example, any query would pass, regardless of query input. For instance, if a user selected this directive/query type and queried `your mom`, the real command sent to the device will be: + +```none /your mom/ +show ip route your mom +``` + +## Fields + +### Text Input + +| Parameter | Type | Default Value | Description | +| :------------ | :----- | :------------ | :---------------------------------------------------- | +| `description` | String | | Field description, displayed as a label or help text. | +| `validation` | String | | Regex pattern to validate text input. | + +### Select + +| Parameter | Type | Default Value | Description | +| :------------ | :-------------- | :------------ | :---------------------------------------------------- | +| `description` | String | | Field description, displayed as a label or help text. | +| `options` | List of Options | | | + +#### Options + +Each select option uses the following schema: + +| Parameter | Type | Default Value | Description | +| :------------ | :----- | :------------ | :------------------------------------------------------- | +| `description` | String | | Field description, displayed as a label or help text. | +| `name` | String | | If specified, will be used as the option's display name. | +| `value` | String | | Option value sent to the device. | + +### Examples + +Example of a text directive expecting a string value matching a regex pattern: + +```yaml filename="directives.yaml" copy {6-7} +your-directive: + name: IP Route + rules: + - condition: null + command: show ip route {target} + field: + validation: '[0-9a-f\.\:]+' +``` + +Example of a select directive: + +```yaml filename="directives.yaml" copy {6-12} +your-directive: + name: BGP Community + rules: + - condition: null + command: show ip bgp community {target} + field: + options: + - value: "65001:1" + description: Provider A Routes + - value: "65001:2" + description: Provider B Routes +``` diff --git a/docs/pages/configuration/examples/_meta.tsx b/docs/pages/configuration/examples/_meta.tsx new file mode 100644 index 0000000..d415124 --- /dev/null +++ b/docs/pages/configuration/examples/_meta.tsx @@ -0,0 +1,5 @@ +export default { + "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/add-your-own-command.mdx b/docs/pages/configuration/examples/add-your-own-command.mdx new file mode 100644 index 0000000..a8f588a --- /dev/null +++ b/docs/pages/configuration/examples/add-your-own-command.mdx @@ -0,0 +1,60 @@ +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: + + + +### Create the Directive + +```yaml filename="directives.yaml" copy +show-bgp-summary: + name: BGP Summary + rules: + - condition: null + command: show bgp all summary + field: null +``` + +### Associate the Directive with the Device + +```yaml filename="devices.yaml" {5-6} copy +devices: + - name: Your Router + address: 192.0.2.1 + platform: cisco_ios + directives: + - show-bgp-summary +``` + + + +## Default Directives + +By default, all built-in directives are _also_ enabled. If you wish to _only_ enable directives you specify, you can use `builtins: false` as a directive: + +```yaml filename="devices.yaml" {6-7} copy +devices: + - name: Your Router + address: 192.0.2.1 + platform: cisco_ios + directives: + - builtins: false + - show-bgp-summary +``` + +In the above example, _only_ the `show-bgp-summary` directive will be enabled. + +You can also selectively enable certain built-in directives: + +```yaml filename="devices.yaml" {6} copy +devices: + - name: Your Router + address: 192.0.2.1 + platform: cisco_ios + directives: + - builtins: [bgp_route, traceroute] +``` + +In the above example, _only_ the BGP Route and Traceroute built-in directives will be enabled. diff --git a/docs/pages/configuration/examples/basic-configuration.mdx b/docs/pages/configuration/examples/basic-configuration.mdx new file mode 100644 index 0000000..c7e64c5 --- /dev/null +++ b/docs/pages/configuration/examples/basic-configuration.mdx @@ -0,0 +1,30 @@ +--- +title: Basic Configuration +description: Get started with a basic hyperglass configuration +--- + +import { Callout } from "nextra/components"; + +To get started, hyperglass only needs to know about your devices. + + + **Devices** are your routers, switches, or whatever else you want to call the endpoints + hyperglass will query for information. + + +## Simple Device Configuration + +```yaml filename="devices.yaml" +devices: + - name: NYC Router 1 + address: + credential: + username: + password: + platform: cisco_ios + attrs: + source4: + source6: +``` + +That's it! diff --git a/docs/pages/configuration/examples/customize-the-ui.mdx b/docs/pages/configuration/examples/customize-the-ui.mdx new file mode 100644 index 0000000..24e564a --- /dev/null +++ b/docs/pages/configuration/examples/customize-the-ui.mdx @@ -0,0 +1,60 @@ +--- +description: Customize hyperglass to fit your needs. +--- + +import { DocsButton } from "~/components/docs-button"; + +

+ {" "} + Change the Title and Organization Name +

+ +```yaml filename="config.yaml" +site_title: Our super neat looking glass +org_name: Cool Company +``` + +

+ {" "} + Change the Logo +

+ +```yaml filename="config.yaml" {2-4} copy +web: + logo: + light: + dark: +``` + +

+ {" "} + Change the Color Scheme +

+ +```yaml filename="config.yaml" copy {3-5} +web: + theme: + colors: + primary: "#d84b4b" + secondary: "#118ab2" +``` + +

+ {" "} + Add a Link to the Footer +

+ +```yaml filename="config.yaml" copy +web: + links: + - title: PeeringDB + url: https://www.peeringdb.com/65000 + show_icon: true + side: right + order: 1 + - title: Our Website + url: https://example.com + show_icon: false + side: left + order: 0 +``` diff --git a/docs/pages/configuration/overview.mdx b/docs/pages/configuration/overview.mdx new file mode 100644 index 0000000..c90c4dc --- /dev/null +++ b/docs/pages/configuration/overview.mdx @@ -0,0 +1,76 @@ +import { Code, Table, Td, Th, Tr, 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. + +## Configuration Files + +| File Name | Docs | Purpose | +| :----------- | :---------------------------------------------------: | :------------------------------------------------------------------------- | +| `config` | [Config File Docs](/configuration/config.mdx) | Application-wide configuration such as logging, web UI customization, etc. | +| `devices` | [Devices File Docs](/configuration/devices.mdx) | Your devices and their associated configurations. | +| `directives` | [Directives File Docs](/configuration/directives.mdx) | Custom directives (commands). | + + + **File Extensions**
+ All the examples in the docs are provided in [YAML](https://yaml.org/) format, but [TOML](https://toml.io/), + JSON, and Python files are also supported. +
+ +### Using a Python File + +When using a Python file for a hyperglass configuration, one of the following methods may be used: + +#### Define a Function Named `main` + +```python filename="Example: Using a Python function to define configuration parameters" +def main(): + return { + "org_name": "Your Organization Name", + "web": { + "theme": { + "colors": { + "blue": "#0000ff", + } + } + } + } + +# The main function can also be an async function. +async def main(): + config = await some_function_to_fetch_config() + return config +``` + +#### Define a Dictionary Named `main` + +```python filename="Example: Using a Python dictionary to define configuration parameters" +main = { + "org_name": "Your Organization Name", + "web": { + "theme": { + "colors": { + "blue": "#0000ff", + } + } + } + } +``` + +## Built-in Directives + +hyperglass ships with predefined [directives](/configuration/directives.mdx) for the following [platforms](platforms.mdx): + + + +All built in directives require that the following `attrs` be defined on each device using the directive: + +| Attribute | Value | +| :-------- | :-------------------------------------------------------- | +| `source4` | IPv4 address used to source Ping and Traceroute commands. | +| `source6` | IPv6 address used to source Ping and Traceroute commands. | + + + If you do not utilize IPv6 in your network, you'll need to create your own directive that only + has IPv4 commands. + diff --git a/docs/pages/index.mdx b/docs/pages/index.mdx new file mode 100644 index 0000000..d12892f --- /dev/null +++ b/docs/pages/index.mdx @@ -0,0 +1,35 @@ +--- +title: Introduction +description: Get started with hyperglass +--- + +import { Cards } from "nextra/components"; +import { SupportedPlatforms } from "~/components/platforms"; + +## What is hyperglass? + +hyperglass 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. + +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. + +## Features + +- 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: + +- 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 + + + + diff --git a/docs/pages/installation.mdx b/docs/pages/installation.mdx new file mode 100644 index 0000000..0eb736a --- /dev/null +++ b/docs/pages/installation.mdx @@ -0,0 +1,11 @@ +import { Cards } from "nextra/components"; + + + + + + + + + + diff --git a/docs/pages/installation/_meta.tsx b/docs/pages/installation/_meta.tsx new file mode 100644 index 0000000..6f531ec --- /dev/null +++ b/docs/pages/installation/_meta.tsx @@ -0,0 +1,7 @@ +export default { + docker: "Using Docker", + manual: "Manual Installation", + "environment-variables": "Environment Variables", + "reverse-proxy": "Reverse Proxy", + upgrading: "Upgrading hyperglass", +}; diff --git a/docs/pages/installation/docker.mdx b/docs/pages/installation/docker.mdx new file mode 100644 index 0000000..cab3e31 --- /dev/null +++ b/docs/pages/installation/docker.mdx @@ -0,0 +1,60 @@ +--- +title: Using Docker +description: Installing hyperglass with Docker +--- + +import { Cards, Steps, Callout } from "nextra/components"; +// import { Callout } from "nextra-theme-docs"; + +**Docker is the recommended method for running hyperglass.** + + + +### Install Docker + + + + + +### Download hyperglass + +```shell copy +mkdir /etc/hyperglass +cd /opt +git clone https://github.com/thatmattlove/hyperglass.git --depth=1 +cd /opt/hyperglass +``` + +### Optional: Quickstart + +Do this if you just want to see the hyperglass page working with a fake device. + +```shell copy +cp /opt/hyperglass/.samples/sample_devices.yaml /etc/hyperglass/devices.yaml +cd /opt/hyperglass +docker compose up +``` + +Navigate to http://localhost:8001 + +### Create a `systemd` service + + + 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. + + +```shell copy +cp /opt/hyperglass/.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 +systemctl start hyperglass +``` + + diff --git a/docs/pages/installation/environment-variables.mdx b/docs/pages/installation/environment-variables.mdx new file mode 100644 index 0000000..64d3915 --- /dev/null +++ b/docs/pages/installation/environment-variables.mdx @@ -0,0 +1,22 @@ +--- +title: Environment Variables +description: hyperglass 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. + +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_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_CA_CERT` | string | — | Path to CA certificate file for validating HTTPS certificates. If not supplied, system CAs are used. | diff --git a/docs/pages/installation/manual.mdx b/docs/pages/installation/manual.mdx new file mode 100644 index 0000000..ef17b03 --- /dev/null +++ b/docs/pages/installation/manual.mdx @@ -0,0 +1,66 @@ +--- +title: Manual Installation +description: Installing hyperglass manually +--- + +import { Steps, Callout } from "nextra/components"; + + + +### Install Dependencies + +To install hyperglass manually, you'll need to 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 8 or later](https://pnpm.io/installation) +4. [Redis 7.2 or later](https://redis.io/download/) + +Make sure the Redis server is started. + +### Install hyperglass + +Once these dependencies are installed, install hyperglass via PyPI: + +```shell copy +git clone https://github.com/thatmattlove/hyperglass --depth=1 +cd hyperglass +pip3 install -e . +``` + +### Create app directory + + + If you plan on using a different directory, be sure to set the directory you wish to use in your + [environment variables](environment-variables.mdx). + + +```shell copy +mkdir /etc/hyperglass +``` + +### Optional: Quickstart + +Do this if you just want to see the hyperglass page working 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 +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 +ln -s /etc/hyperglass/hyperglass.service /etc/systemd/system/hyperglass.service +systemctl daemon-reload +systemctl enable hyperglass +systemctl start hyperglass +``` + + + If you used a different app directory from the default `/etc/hyperglass`, change the + `EnvironmentFile` value in the `hyperglass.service` file. + + + diff --git a/docs/pages/installation/reverse-proxy.mdx b/docs/pages/installation/reverse-proxy.mdx new file mode 100644 index 0000000..aa821f6 --- /dev/null +++ b/docs/pages/installation/reverse-proxy.mdx @@ -0,0 +1,44 @@ +--- +title: Reverse Proxy +description: Setting up a reverse proxy for hyperglass +--- + +import { Cards, Callout } from "nextra/components"; + +[Caddy](https://caddyserver.com) is recommended, but any reverse proxy ([NGINX](https://www.nginx.com), [Apache2](https://httpd.apache.org)) will work. + +## Caddy + + + + + +```shell copy +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). + + + If you prefer to use other Let's Encrypt validation methods or your own SSL certificate, modify + your `/etc/hyperglass/Caddyfile` in accordance with the [Caddy + docs](https://caddyserver.com/docs/caddyfile-tutorial). + + +Restart the Caddy service: `systemctl restart caddy{:shell}` + +## NGINX + +```shell copy +cp /opt/hyperglass/.samples/hyperglass.nginx /etc/nginx/sites-available/hyperglass +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 `` and `` values to match the path to your certificate and private key files. diff --git a/docs/pages/installation/upgrading.mdx b/docs/pages/installation/upgrading.mdx new file mode 100644 index 0000000..f70da5e --- /dev/null +++ b/docs/pages/installation/upgrading.mdx @@ -0,0 +1,11 @@ +## Using Docker + +```shell copy +cd /opt/hyperglass +docker compose down +docker compose rm -f +git fetch +git checkout v2.0.4 +docker compose build +docker compose up +``` diff --git a/docs/pages/license.mdx b/docs/pages/license.mdx new file mode 100644 index 0000000..7d80c0f --- /dev/null +++ b/docs/pages/license.mdx @@ -0,0 +1,20 @@ +# The Clear BSD License + +export const Year = () => new Date().getFullYear(); + +**Copyright © Matthew Love** + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted (subject to the limitations in the disclaimer below) provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +- Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +> NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/pages/platforms.mdx b/docs/pages/platforms.mdx new file mode 100644 index 0000000..365cb17 --- /dev/null +++ b/docs/pages/platforms.mdx @@ -0,0 +1,28 @@ +--- +description: Platforms supported by hyperglass +--- + +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. + +## Netmiko Platforms + + + 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 + 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. + + +
+ + + +## Other Platforms + +| Platform | Key | Natively Supported | +| :---------------- | :----- | :------------------------------------------------------------------: | +| Any HTTP Endpoint | `http` | [See HTTP Device Docs](configuration/devices.mdx#http-configuration) | diff --git a/docs/pages/plugins.mdx b/docs/pages/plugins.mdx new file mode 100644 index 0000000..2f256f8 --- /dev/null +++ b/docs/pages/plugins.mdx @@ -0,0 +1,144 @@ +--- +title: Plugins +description: hyperglass Plugins +--- + +import { Cards, Card } from "nextra/components"; + +Starting in version 2.0, hyperglass 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. + +Plugins are associated with a directive. + +## Examples + +### Input Plugins + +#### Convert a CIDR Prefix to Network and Mask + +In this example, the following query is sent to hyperglass: + +```json filename="Example Query" +{ + "query_target": "192.0.2.0/24", + "query_location": "your_location", + "query_type": "ip_route_directive" +} +``` + +The below plugin is defined and referenced by a directive: + +```python filename="/path/to/your/transform_plugin.py" +from ipaddress import ip_network +from hyperglass.plugins import InputPlugin + + +class TransformCIDR(InputPlugin): + def transform(self, query): + (target := query.query_target) + target_network = ip_network(target) + if target_network.version == 4: + return f"{target_network.network_address!s} {target_network.netmask!s}" + return target +``` + +```yaml filename="directives.yaml" +ip_route_directive: + name: IP Route + plugins: + - "/path/to/your/transform_plugin.py" + rules: + - condition: "0.0.0.0/0" + action: permit + command: "show ip route {target}" + - condition: "::/0" + action: permit + command: "show ipv6 route {target}" +``` + +When the query is received, the query target is transformed, resulting in this being sent to the device: + +``` +show ip route 192.0.2.0 255.255.255.0 +``` + +instead of: + +``` +show ip route 192.0.2.0/24 +``` + +#### Validate User Input + +In this example, we want to perform some custom validation not available via the directives `condition` API. For instance, say you wanted to ensure a query target isn't contained within a dynamic list of [bogon prefixes from Team Cymru](https://www.team-cymru.com/bogon-networks). + +```python filename="/path/to/your/validation_plugin.py" +from ipaddress import ip_network + +from hyperglass.plugins import InputPlugin +from hyperglass.external import HTTPClient + +class BogonPlugin(InputPlugin): + def validate(self, query): + target = ip_network(query.query_target) + + with HTTPClient(base_url="https://team-cymru.org") as client: + response = client.get("/Services/Bogons/fullbogons-ipv4.txt") + + bogon_strings = [line.strip() for line in response.text.split("\n") if not line.startswith("#")] + bogons = [ip_network(bogon) for bogon in bogon_strings] + + for bogon in bogons: + if target in bogon or target == bogon: + return False # Return false to fail validation. + return True +``` + +This isn't the best real-world example, since the above plugin would be run on every request, likely resulting in slow query responses, but it illustrates the power of plugins. + +### Output Plugins + +#### Redact Sensitive Information + +Say one of your directives might show some sensitive information in the query output. Using an output plugin, we can replace any text matching a pattern (or multiple patterns) with some other text. + +```python +import re +from hyperglass.plugins import OutputPlugin + +SENSITIVE_PATTERN = re.compile("SuperSecretInfo") + +class Redact(OutputPlugin): + def process(self, output, query): + result = [] + for each_output in output: + redacted = SENSITIVE_PATTERN.sub("", each_output) + result.append(redacted) + return result +``` + +If the query output was: + +```text +Lorem ipsum dolor sit amet, SuperSecretInfo consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +Viverra suspendisse potenti nullam ac. At elementum eu facilisis sed odio morbi. SuperSecretInfo iaculis urna id volutpat lacus.Nisl nisi +scelerisque eu ultrices vitae. Accumsan SuperSecretInfo tortor posuere ac ut consequat semper viverra nam libero. Libero id faucibus nisl +tincidunt eget nullam non nisi. Et ligula ullamcorper malesuada SuperSecretInfo proin libero nunc. Et malesuada fames ac turpis egestas sed. +Nulla facilisi cras fermentum odio eu. SuperSecretInfo condimentum mattis pellentesque id nibh tortor id aliquet lectus proin. Nisl rhoncus +mattis rhoncus urna neque. Tortor aliquam nulla facilisi cras SuperSecretInfo fermentum odio eu feugiat. Neque egestas congue quisque egestas +diam in arcu cursus SuperSecretInfo. +``` + +The above plugin would transform the output to: + +```text +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +Viverra suspendisse potenti nullam ac. At elementum eu facilisis sed odio morbi. iaculis urna id volutpat lacus.Nisl nisi +scelerisque eu ultrices vitae. Accumsan tortor posuere ac ut consequat semper viverra nam libero. Libero id faucibus nisl +tincidunt eget nullam non nisi. Et ligula ullamcorper malesuada proin libero nunc. Et malesuada fames ac turpis egestas sed. +Nulla facilisi cras fermentum odio eu. condimentum mattis pellentesque id nibh tortor id aliquet lectus proin. Nisl rhoncus +mattis rhoncus urna neque. Tortor aliquam nulla facilisi cras fermentum odio eu feugiat. Neque egestas congue quisque egestas +diam in arcu cursus . +``` diff --git a/docs/platforms.json b/docs/platforms.json new file mode 100644 index 0000000..43164ab --- /dev/null +++ b/docs/platforms.json @@ -0,0 +1 @@ +[{"name": "Arista EOS", "keys": ["arista_eos"], "native": true}, {"name": "BIRD", "keys": ["bird"], "native": true}, {"name": "Cisco IOS", "keys": ["cisco_ios"], "native": true}, {"name": "Cisco NX-OS", "keys": ["cisco_nxos"], "native": true}, {"name": "Cisco IOS-XR", "keys": ["cisco_xr"], "native": true}, {"name": "FRRouting", "keys": ["frr"], "native": true}, {"name": "Huawei VRP", "keys": ["huawei", "huawei_vrpv8"], "native": true}, {"name": "Juniper Junos", "keys": ["juniper", "juniper_junos"], "native": true}, {"name": "Mikrotik", "keys": ["mikrotik_routeros", "mikrotik_switchos"], "native": true}, {"name": "Nokia SR OS", "keys": ["nokia_sros"], "native": true}, {"name": "OpenBGPD", "keys": ["openbgpd"], "native": true}, {"name": "TNSR", "keys": ["tnsr"], "native": true}, {"name": "VyOS", "keys": ["vyos"], "native": true}, {"name": "", "keys": ["a10"], "native": false}, {"name": "", "keys": ["a10_ssh"], "native": false}, {"name": "", "keys": ["accedian"], "native": false}, {"name": "", "keys": ["accedian_ssh"], "native": false}, {"name": "", "keys": ["adtran_os"], "native": false}, {"name": "", "keys": ["adtran_os_ssh"], "native": false}, {"name": "", "keys": ["adtran_os_telnet"], "native": false}, {"name": "", "keys": ["alcatel_aos"], "native": false}, {"name": "", "keys": ["alcatel_aos_ssh"], "native": false}, {"name": "", "keys": ["alcatel_sros"], "native": false}, {"name": "", "keys": ["alcatel_sros_ssh"], "native": false}, {"name": "", "keys": ["allied_telesis_awplus"], "native": false}, {"name": "", "keys": ["allied_telesis_awplus_ssh"], "native": false}, {"name": "", "keys": ["apresia_aeos"], "native": false}, {"name": "", "keys": ["apresia_aeos_ssh"], "native": false}, {"name": "", "keys": ["apresia_aeos_telnet"], "native": false}, {"name": "", "keys": ["arista_eos_ssh"], "native": false}, {"name": "", "keys": ["arista_eos_telnet"], "native": false}, {"name": "", "keys": ["aruba_os"], "native": false}, {"name": "", "keys": ["aruba_os_ssh"], "native": false}, {"name": "", "keys": ["aruba_osswitch"], "native": false}, {"name": "", "keys": ["aruba_osswitch_ssh"], "native": false}, {"name": "", "keys": ["aruba_procurve"], "native": false}, {"name": "", "keys": ["aruba_procurve_ssh"], "native": false}, {"name": "", "keys": ["aruba_procurve_telnet"], "native": false}, {"name": "", "keys": ["audiocode_66"], "native": false}, {"name": "", "keys": ["audiocode_66_ssh"], "native": false}, {"name": "", "keys": ["audiocode_66_telnet"], "native": false}, {"name": "", "keys": ["audiocode_72"], "native": false}, {"name": "", "keys": ["audiocode_72_ssh"], "native": false}, {"name": "", "keys": ["audiocode_72_telnet"], "native": false}, {"name": "", "keys": ["audiocode_shell"], "native": false}, {"name": "", "keys": ["audiocode_shell_ssh"], "native": false}, {"name": "", "keys": ["audiocode_shell_telnet"], "native": false}, {"name": "", "keys": ["autodetect"], "native": false}, {"name": "", "keys": ["avaya_ers"], "native": false}, {"name": "", "keys": ["avaya_ers_ssh"], "native": false}, {"name": "", "keys": ["avaya_vsp"], "native": false}, {"name": "", "keys": ["avaya_vsp_ssh"], "native": false}, {"name": "", "keys": ["broadcom_icos"], "native": false}, {"name": "", "keys": ["broadcom_icos_ssh"], "native": false}, {"name": "", "keys": ["brocade_fastiron"], "native": false}, {"name": "", "keys": ["brocade_fastiron_ssh"], "native": false}, {"name": "", "keys": ["brocade_fastiron_telnet"], "native": false}, {"name": "", "keys": ["brocade_fos"], "native": false}, {"name": "", "keys": ["brocade_fos_ssh"], "native": false}, {"name": "", "keys": ["brocade_netiron"], "native": false}, {"name": "", "keys": ["brocade_netiron_ssh"], "native": false}, {"name": "", "keys": ["brocade_netiron_telnet"], "native": false}, {"name": "", "keys": ["brocade_nos"], "native": false}, {"name": "", "keys": ["brocade_nos_ssh"], "native": false}, {"name": "", "keys": ["brocade_vdx"], "native": false}, {"name": "", "keys": ["brocade_vdx_ssh"], "native": false}, {"name": "", "keys": ["brocade_vyos"], "native": false}, {"name": "", "keys": ["brocade_vyos_ssh"], "native": false}, {"name": "", "keys": ["calix_b6"], "native": false}, {"name": "", "keys": ["calix_b6_ssh"], "native": false}, {"name": "", "keys": ["calix_b6_telnet"], "native": false}, {"name": "", "keys": ["cdot_cros"], "native": false}, {"name": "", "keys": ["cdot_cros_ssh"], "native": false}, {"name": "", "keys": ["centec_os"], "native": false}, {"name": "", "keys": ["centec_os_ssh"], "native": false}, {"name": "", "keys": ["centec_os_telnet"], "native": false}, {"name": "", "keys": ["checkpoint_gaia"], "native": false}, {"name": "", "keys": ["checkpoint_gaia_ssh"], "native": false}, {"name": "", "keys": ["ciena_saos"], "native": false}, {"name": "", "keys": ["ciena_saos_ssh"], "native": false}, {"name": "", "keys": ["ciena_saos_telnet"], "native": false}, {"name": "", "keys": ["cisco_asa"], "native": false}, {"name": "", "keys": ["cisco_asa_ssh"], "native": false}, {"name": "", "keys": ["cisco_ftd"], "native": false}, {"name": "", "keys": ["cisco_ftd_ssh"], "native": false}, {"name": "", "keys": ["cisco_ios_serial"], "native": false}, {"name": "", "keys": ["cisco_ios_ssh"], "native": false}, {"name": "", "keys": ["cisco_ios_telnet"], "native": false}, {"name": "", "keys": ["cisco_nxos_ssh"], "native": false}, {"name": "", "keys": ["cisco_s300"], "native": false}, {"name": "", "keys": ["cisco_s300_ssh"], "native": false}, {"name": "", "keys": ["cisco_s300_telnet"], "native": false}, {"name": "", "keys": ["cisco_tp"], "native": false}, {"name": "", "keys": ["cisco_tp_ssh"], "native": false}, {"name": "", "keys": ["cisco_viptela"], "native": false}, {"name": "", "keys": ["cisco_viptela_ssh"], "native": false}, {"name": "", "keys": ["cisco_wlc"], "native": false}, {"name": "", "keys": ["cisco_wlc_ssh"], "native": false}, {"name": "", "keys": ["cisco_xe"], "native": false}, {"name": "", "keys": ["cisco_xe_ssh"], "native": false}, {"name": "", "keys": ["cisco_xr_ssh"], "native": false}, {"name": "", "keys": ["cisco_xr_telnet"], "native": false}, {"name": "", "keys": ["cloudgenix_ion"], "native": false}, {"name": "", "keys": ["cloudgenix_ion_ssh"], "native": false}, {"name": "", "keys": ["coriant"], "native": false}, {"name": "", "keys": ["coriant_ssh"], "native": false}, {"name": "", "keys": ["dell_dnos6_telnet"], "native": false}, {"name": "", "keys": ["dell_dnos9"], "native": false}, {"name": "", "keys": ["dell_dnos9_ssh"], "native": false}, {"name": "", "keys": ["dell_force10"], "native": false}, {"name": "", "keys": ["dell_force10_ssh"], "native": false}, {"name": "", "keys": ["dell_isilon"], "native": false}, {"name": "", "keys": ["dell_isilon_ssh"], "native": false}, {"name": "", "keys": ["dell_os10"], "native": false}, {"name": "", "keys": ["dell_os10_ssh"], "native": false}, {"name": "", "keys": ["dell_os6"], "native": false}, {"name": "", "keys": ["dell_os6_ssh"], "native": false}, {"name": "", "keys": ["dell_os9"], "native": false}, {"name": "", "keys": ["dell_os9_ssh"], "native": false}, {"name": "", "keys": ["dell_powerconnect"], "native": false}, {"name": "", "keys": ["dell_powerconnect_ssh"], "native": false}, {"name": "", "keys": ["dell_powerconnect_telnet"], "native": false}, {"name": "", "keys": ["dell_sonic"], "native": false}, {"name": "", "keys": ["dell_sonic_ssh"], "native": false}, {"name": "", "keys": ["dlink_ds"], "native": false}, {"name": "", "keys": ["dlink_ds_ssh"], "native": false}, {"name": "", "keys": ["dlink_ds_telnet"], "native": false}, {"name": "", "keys": ["eltex"], "native": false}, {"name": "", "keys": ["eltex_esr"], "native": false}, {"name": "", "keys": ["eltex_esr_ssh"], "native": false}, {"name": "", "keys": ["eltex_ssh"], "native": false}, {"name": "", "keys": ["endace"], "native": false}, {"name": "", "keys": ["endace_ssh"], "native": false}, {"name": "", "keys": ["enterasys"], "native": false}, {"name": "", "keys": ["enterasys_ssh"], "native": false}, {"name": "", "keys": ["ericsson_ipos"], "native": false}, {"name": "", "keys": ["ericsson_ipos_ssh"], "native": false}, {"name": "", "keys": ["extreme"], "native": false}, {"name": "", "keys": ["extreme_ers"], "native": false}, {"name": "", "keys": ["extreme_ers_ssh"], "native": false}, {"name": "", "keys": ["extreme_exos"], "native": false}, {"name": "", "keys": ["extreme_exos_ssh"], "native": false}, {"name": "", "keys": ["extreme_exos_telnet"], "native": false}, {"name": "", "keys": ["extreme_netiron"], "native": false}, {"name": "", "keys": ["extreme_netiron_ssh"], "native": false}, {"name": "", "keys": ["extreme_netiron_telnet"], "native": false}, {"name": "", "keys": ["extreme_nos"], "native": false}, {"name": "", "keys": ["extreme_nos_ssh"], "native": false}, {"name": "", "keys": ["extreme_slx"], "native": false}, {"name": "", "keys": ["extreme_slx_ssh"], "native": false}, {"name": "", "keys": ["extreme_ssh"], "native": false}, {"name": "", "keys": ["extreme_telnet"], "native": false}, {"name": "", "keys": ["extreme_tierra"], "native": false}, {"name": "", "keys": ["extreme_tierra_ssh"], "native": false}, {"name": "", "keys": ["extreme_vdx"], "native": false}, {"name": "", "keys": ["extreme_vdx_ssh"], "native": false}, {"name": "", "keys": ["extreme_vsp"], "native": false}, {"name": "", "keys": ["extreme_vsp_ssh"], "native": false}, {"name": "", "keys": ["extreme_wing"], "native": false}, {"name": "", "keys": ["extreme_wing_ssh"], "native": false}, {"name": "", "keys": ["f5_linux"], "native": false}, {"name": "", "keys": ["f5_linux_ssh"], "native": false}, {"name": "", "keys": ["f5_ltm"], "native": false}, {"name": "", "keys": ["f5_ltm_ssh"], "native": false}, {"name": "", "keys": ["f5_tmsh"], "native": false}, {"name": "", "keys": ["f5_tmsh_ssh"], "native": false}, {"name": "", "keys": ["flexvnf"], "native": false}, {"name": "", "keys": ["flexvnf_ssh"], "native": false}, {"name": "", "keys": ["fortinet"], "native": false}, {"name": "", "keys": ["fortinet_ssh"], "native": false}, {"name": "", "keys": ["generic"], "native": false}, {"name": "", "keys": ["generic_ssh"], "native": false}, {"name": "", "keys": ["generic_telnet"], "native": false}, {"name": "", "keys": ["generic_termserver"], "native": false}, {"name": "", "keys": ["generic_termserver_ssh"], "native": false}, {"name": "", "keys": ["generic_termserver_telnet"], "native": false}, {"name": "", "keys": ["hp_comware"], "native": false}, {"name": "", "keys": ["hp_comware_ssh"], "native": false}, {"name": "", "keys": ["hp_comware_telnet"], "native": false}, {"name": "", "keys": ["hp_procurve"], "native": false}, {"name": "", "keys": ["hp_procurve_ssh"], "native": false}, {"name": "", "keys": ["hp_procurve_telnet"], "native": false}, {"name": "", "keys": ["huawei_olt"], "native": false}, {"name": "", "keys": ["huawei_olt_ssh"], "native": false}, {"name": "", "keys": ["huawei_olt_telnet"], "native": false}, {"name": "", "keys": ["huawei_smartax"], "native": false}, {"name": "", "keys": ["huawei_smartax_ssh"], "native": false}, {"name": "", "keys": ["huawei_ssh"], "native": false}, {"name": "", "keys": ["huawei_telnet"], "native": false}, {"name": "", "keys": ["huawei_vrpv8_ssh"], "native": false}, {"name": "", "keys": ["ipinfusion_ocnos"], "native": false}, {"name": "", "keys": ["ipinfusion_ocnos_ssh"], "native": false}, {"name": "", "keys": ["ipinfusion_ocnos_telnet"], "native": false}, {"name": "", "keys": ["juniper_junos_ssh"], "native": false}, {"name": "", "keys": ["juniper_junos_telnet"], "native": false}, {"name": "", "keys": ["juniper_screenos"], "native": false}, {"name": "", "keys": ["juniper_screenos_ssh"], "native": false}, {"name": "", "keys": ["juniper_ssh"], "native": false}, {"name": "", "keys": ["keymile"], "native": false}, {"name": "", "keys": ["keymile_nos"], "native": false}, {"name": "", "keys": ["keymile_nos_ssh"], "native": false}, {"name": "", "keys": ["keymile_ssh"], "native": false}, {"name": "", "keys": ["linux"], "native": false}, {"name": "", "keys": ["linux_ssh"], "native": false}, {"name": "", "keys": ["mellanox"], "native": false}, {"name": "", "keys": ["mellanox_mlnxos"], "native": false}, {"name": "", "keys": ["mellanox_mlnxos_ssh"], "native": false}, {"name": "", "keys": ["mellanox_ssh"], "native": false}, {"name": "", "keys": ["mikrotik_routeros_ssh"], "native": false}, {"name": "", "keys": ["mikrotik_switchos_ssh"], "native": false}, {"name": "", "keys": ["mrv_lx"], "native": false}, {"name": "", "keys": ["mrv_lx_ssh"], "native": false}, {"name": "", "keys": ["mrv_optiswitch"], "native": false}, {"name": "", "keys": ["mrv_optiswitch_ssh"], "native": false}, {"name": "", "keys": ["netapp_cdot"], "native": false}, {"name": "", "keys": ["netapp_cdot_ssh"], "native": false}, {"name": "", "keys": ["netgear_prosafe"], "native": false}, {"name": "", "keys": ["netgear_prosafe_ssh"], "native": false}, {"name": "", "keys": ["netscaler"], "native": false}, {"name": "", "keys": ["netscaler_ssh"], "native": false}, {"name": "", "keys": ["nokia_srl"], "native": false}, {"name": "", "keys": ["nokia_srl_ssh"], "native": false}, {"name": "", "keys": ["nokia_sros_ssh"], "native": false}, {"name": "", "keys": ["nokia_sros_telnet"], "native": false}, {"name": "", "keys": ["oneaccess_oneos"], "native": false}, {"name": "", "keys": ["oneaccess_oneos_ssh"], "native": false}, {"name": "", "keys": ["oneaccess_oneos_telnet"], "native": false}, {"name": "", "keys": ["ovs_linux"], "native": false}, {"name": "", "keys": ["ovs_linux_ssh"], "native": false}, {"name": "", "keys": ["paloalto_panos"], "native": false}, {"name": "", "keys": ["paloalto_panos_ssh"], "native": false}, {"name": "", "keys": ["paloalto_panos_telnet"], "native": false}, {"name": "", "keys": ["pluribus"], "native": false}, {"name": "", "keys": ["pluribus_ssh"], "native": false}, {"name": "", "keys": ["quanta_mesh"], "native": false}, {"name": "", "keys": ["quanta_mesh_ssh"], "native": false}, {"name": "", "keys": ["rad_etx"], "native": false}, {"name": "", "keys": ["rad_etx_ssh"], "native": false}, {"name": "", "keys": ["rad_etx_telnet"], "native": false}, {"name": "", "keys": ["raisecom_roap"], "native": false}, {"name": "", "keys": ["raisecom_roap_ssh"], "native": false}, {"name": "", "keys": ["raisecom_telnet"], "native": false}, {"name": "", "keys": ["ruckus_fastiron"], "native": false}, {"name": "", "keys": ["ruckus_fastiron_ssh"], "native": false}, {"name": "", "keys": ["ruckus_fastiron_telnet"], "native": false}, {"name": "", "keys": ["ruijie_os"], "native": false}, {"name": "", "keys": ["ruijie_os_ssh"], "native": false}, {"name": "", "keys": ["ruijie_os_telnet"], "native": false}, {"name": "", "keys": ["sixwind_os"], "native": false}, {"name": "", "keys": ["sixwind_os_ssh"], "native": false}, {"name": "", "keys": ["sophos_sfos"], "native": false}, {"name": "", "keys": ["sophos_sfos_ssh"], "native": false}, {"name": "", "keys": ["supermicro_smis"], "native": false}, {"name": "", "keys": ["supermicro_smis_ssh"], "native": false}, {"name": "", "keys": ["supermicro_smis_telnet"], "native": false}, {"name": "", "keys": ["terminal_server"], "native": false}, {"name": "", "keys": ["tplink_jetstream"], "native": false}, {"name": "", "keys": ["tplink_jetstream_ssh"], "native": false}, {"name": "", "keys": ["tplink_jetstream_telnet"], "native": false}, {"name": "", "keys": ["ubiquiti_edge"], "native": false}, {"name": "", "keys": ["ubiquiti_edge_ssh"], "native": false}, {"name": "", "keys": ["ubiquiti_edgerouter"], "native": false}, {"name": "", "keys": ["ubiquiti_edgerouter_ssh"], "native": false}, {"name": "", "keys": ["ubiquiti_edgeswitch"], "native": false}, {"name": "", "keys": ["ubiquiti_edgeswitch_ssh"], "native": false}, {"name": "", "keys": ["ubiquiti_unifiswitch"], "native": false}, {"name": "", "keys": ["ubiquiti_unifiswitch_ssh"], "native": false}, {"name": "", "keys": ["vyatta_vyos"], "native": false}, {"name": "", "keys": ["vyatta_vyos_ssh"], "native": false}, {"name": "", "keys": ["vyos_ssh"], "native": false}, {"name": "", "keys": ["watchguard_fireware"], "native": false}, {"name": "", "keys": ["watchguard_fireware_ssh"], "native": false}, {"name": "", "keys": ["yamaha"], "native": false}, {"name": "", "keys": ["yamaha_ssh"], "native": false}, {"name": "", "keys": ["yamaha_telnet"], "native": false}, {"name": "", "keys": ["zte_zxros"], "native": false}, {"name": "", "keys": ["zte_zxros_ssh"], "native": false}, {"name": "", "keys": ["zte_zxros_telnet"], "native": false}, {"name": "", "keys": ["zyxel_os"], "native": false}, {"name": "", "keys": ["zyxel_os_ssh"], "native": false}] \ No newline at end of file diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 0000000..9585e0d --- /dev/null +++ b/docs/pnpm-lock.yaml @@ -0,0 +1,3554 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + next: + specifier: ^14.1.1 + version: 14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.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) + 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) + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + devDependencies: + '@types/node': + specifier: ^20.11.24 + version: 20.11.24 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + +packages: + + '@braintree/sanitize-url@6.0.4': + resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} + + '@headlessui/react@1.7.18': + resolution: {integrity: sha512-4i5DOrzwN4qSgNsL4Si61VMkUcWbcSKueUV7sFhpHzQcSShdlHENE5+QBntMSRvHt8NyoFO2AGG8si9lq+w4zQ==} + engines: {node: '>=10'} + peerDependencies: + react: ^16 || ^17 || ^18 + react-dom: ^16 || ^17 || ^18 + + '@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'} + cpu: [arm] + os: [android] + + '@napi-rs/simple-git-android-arm64@0.1.16': + resolution: {integrity: sha512-xYz+TW5J09iK8SuTAKK2D5MMIsBUXVSs8nYp7HcMi8q6FCRO7yJj96YfP9PvKsc/k64hOyqGmL5DhCzY9Cu1FQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/simple-git-darwin-arm64@0.1.16': + resolution: {integrity: sha512-XfgsYqxhUE022MJobeiX563TJqyQyX4FmYCnqrtJwAfivESVeAJiH6bQIum8dDEYMHXCsG7nL8Ok0Dp8k2m42g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/simple-git-darwin-x64@0.1.16': + resolution: {integrity: sha512-tkEVBhD6vgRCbeWsaAQqM3bTfpIVGeitamPPRVSbsq8qgzJ5Dx6ZedH27R7KSsA/uao7mZ3dsrNLXbu1Wy5MzA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/simple-git-linux-arm-gnueabihf@0.1.16': + resolution: {integrity: sha512-R6VAyNnp/yRaT7DV1Ao3r67SqTWDa+fNq2LrNy0Z8gXk2wB9ZKlrxFtLPE1WSpWknWtyRDLpRlsorh7Evk7+7w==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/simple-git-linux-arm64-gnu@0.1.16': + resolution: {integrity: sha512-LAGI0opFKw/HBMCV2qIBK3uWSEW9h4xd2ireZKLJy8DBPymX6NrWIamuxYNyCuACnFdPRxR4LaRFy4J5ZwuMdw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/simple-git-linux-arm64-musl@0.1.16': + resolution: {integrity: sha512-I57Ph0F0Yn2KW93ep+V1EzKhACqX0x49vvSiapqIsdDA2PifdEWLc1LJarBolmK7NKoPqKmf6lAKKO9lhiZzkg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/simple-git-linux-x64-gnu@0.1.16': + resolution: {integrity: sha512-AZYYFY2V7hlcQASPEOWyOa3e1skzTct9QPzz0LiDM3f/hCFY/wBaU2M6NC5iG3d2Kr38heuyFS/+JqxLm5WaKA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/simple-git-linux-x64-musl@0.1.16': + resolution: {integrity: sha512-9TyMcYSBJwjT8jwjY9m24BZbu7ozyWTjsmYBYNtK3B0Um1Ov6jthSNneLVvouQ6x+k3Ow+00TiFh6bvmT00r8g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/simple-git-win32-arm64-msvc@0.1.16': + resolution: {integrity: sha512-uslJ1WuAHCYJWui6xjsyT47SjX6KOHDtClmNO8hqKz1pmDSNY7AjyUY8HxvD1lK9bDnWwc4JYhikS9cxCqHybw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/simple-git-win32-x64-msvc@0.1.16': + resolution: {integrity: sha512-SoEaVeCZCDF1MP+M9bMSXsZWgEjk4On9GWADO5JOulvzR1bKjk0s9PMHwe/YztR9F0sJzrCxwtvBZowhSJsQPg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/simple-git@0.1.16': + resolution: {integrity: sha512-C5wRPw9waqL2jk3jEDeJv+f7ScuO3N0a39HVdyFLkwKxHH4Sya4ZbzZsu2JLi6eEqe7RuHipHL6mC7B2OfYZZw==} + engines: {node: '>= 10'} + + '@next/env@14.1.1': + resolution: {integrity: sha512-7CnQyD5G8shHxQIIg3c7/pSeYFeMhsNbpU/bmvH7ZnDql7mNRgg8O2JZrhrc/soFnfBnKP4/xXNiiSIPn2w8gA==} + + '@next/swc-darwin-arm64@14.1.1': + resolution: {integrity: sha512-yDjSFKQKTIjyT7cFv+DqQfW5jsD+tVxXTckSe1KIouKk75t1qZmj/mV3wzdmFb0XHVGtyRjDMulfVG8uCKemOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@14.1.1': + resolution: {integrity: sha512-KCQmBL0CmFmN8D64FHIZVD9I4ugQsDBBEJKiblXGgwn7wBCSe8N4Dx47sdzl4JAg39IkSN5NNrr8AniXLMb3aw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@14.1.1': + resolution: {integrity: sha512-YDQfbWyW0JMKhJf/T4eyFr4b3tceTorQ5w2n7I0mNVTFOvu6CGEzfwT3RSAQGTi/FFMTFcuspPec/7dFHuP7Eg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@14.1.1': + resolution: {integrity: sha512-fiuN/OG6sNGRN/bRFxRvV5LyzLB8gaL8cbDH5o3mEiVwfcMzyE5T//ilMmaTrnA8HLMS6hoz4cHOu6Qcp9vxgQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@14.1.1': + resolution: {integrity: sha512-rv6AAdEXoezjbdfp3ouMuVqeLjE1Bin0AuE6qxE6V9g3Giz5/R3xpocHoAi7CufRR+lnkuUjRBn05SYJ83oKNQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@14.1.1': + resolution: {integrity: sha512-YAZLGsaNeChSrpz/G7MxO3TIBLaMN8QWMr3X8bt6rCvKovwU7GqQlDu99WdvF33kI8ZahvcdbFsy4jAFzFX7og==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@14.1.1': + resolution: {integrity: sha512-1L4mUYPBMvVDMZg1inUYyPvFSduot0g73hgfD9CODgbr4xiTYe0VOMTZzaRqYJYBA9mana0x4eaAaypmWo1r5A==} + 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==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@shikijs/core@1.6.1': + resolution: {integrity: sha512-CqYyepN4SnBopaoXYwng4NO8riB5ask/LTCkhOFq+GNGtr2X+aKeD767eYdqYukeixEUvv4bXdyTYVaogj7KBw==} + + '@shikijs/twoslash@1.6.1': + resolution: {integrity: sha512-VluGZXQ97sDFyxneOzsPkEHK06A6C1SRDh+kSM9AZAkzHorZaGxF4awgA3rh2K0oZnR94NZzfhq8GtERm38EEQ==} + + '@swc/helpers@0.5.2': + resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} + + '@tanstack/react-virtual@3.1.3': + resolution: {integrity: sha512-YCzcbF/Ws/uZ0q3Z6fagH+JVhx4JLvbSflgldMgLsuvB8aXjZLLb3HvrEVxY480F9wFlBiXlvQxOyXb5ENPrNA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@tanstack/virtual-core@3.1.3': + resolution: {integrity: sha512-Y5B4EYyv1j9V8LzeAoOVeTg0LI7Fo5InYKgAjkY1Pu9GjtUwX/EKxNcU7ng3sKr99WEf+bPTcktAeybyMOYo+g==} + + '@theguild/remark-mermaid@0.0.5': + resolution: {integrity: sha512-e+ZIyJkEv9jabI4m7q29wZtZv+2iwPGsXJ2d46Zi7e+QcFudiyuqhLhHG/3gX3ZEB+hxTch+fpItyMS8jwbIcw==} + peerDependencies: + react: ^18.2.0 + + '@theguild/remark-npm2yarn@0.3.0': + resolution: {integrity: sha512-Fofw+9airYgjBd9G6PiHHCrptjyUybQ50JH9/5o9LCH54kggJ7stpCofzHjICB8L7VQbQ1Gwu23P/3CMVY1R4Q==} + + '@types/acorn@4.0.6': + resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + + '@types/d3-scale-chromatic@3.0.3': + resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==} + + '@types/d3-scale@4.0.8': + resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==} + + '@types/d3-time@3.0.3': + resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==} + + '@types/debug@4.1.7': + resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==} + + '@types/estree-jsx@1.0.0': + resolution: {integrity: sha512-3qvGd0z8F2ENTGr/GG1yViqfiKmRfrXVx5sJyHGFu3z7m5g5utCQtGp/g29JnjflhtQJBv1WDQukHiT58xPcYQ==} + + '@types/estree@1.0.0': + resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/katex@0.16.7': + resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} + + '@types/mdast@3.0.10': + resolution: {integrity: sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==} + + '@types/mdast@4.0.3': + resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==} + + '@types/mdx@2.0.3': + resolution: {integrity: sha512-IgHxcT3RC8LzFLhKwP3gbMPeaK7BM9eBH46OdapPA7yvuIUJ8H6zHZV53J8hGZcTSnt95jANt+rTBNUUc22ACQ==} + + '@types/ms@0.7.31': + resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} + + '@types/nlcst@1.0.4': + resolution: {integrity: sha512-ABoYdNQ/kBSsLvZAekMhIPMQ3YUZvavStpKYs7BjLLuKVmIMA0LUgZ7b54zzuWJRbHF80v1cNf4r90Vd6eMQDg==} + + '@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/scheduler@0.16.2': + resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==} + + '@types/unist@2.0.6': + resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} + + '@types/unist@3.0.2': + resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} + + '@typescript/vfs@1.5.0': + resolution: {integrity: sha512-AJS307bPgbsZZ9ggCT3wwpg3VbTKMFNHfaY/uF0ahSkYYrPF2dSSKDNIDIQAHm9qJqbLvCsSJH7yN4Vs/CsMMg==} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.8.1: + resolution: {integrity: sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + 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==} + + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + + astring@1.8.3: + resolution: {integrity: sha512-sRpyiNrx2dEYIMmUXprS8nlpRg2Drs8m9ElX9vVEXaCB4XEAJhKfs7IcX0IwShjuOAjLR6wzIrgoptz1n19i1A==} + hasBin: true + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + better-react-mathjax@2.0.3: + resolution: {integrity: sha512-wfifT8GFOKb1TWm2+E50I6DJpLZ5kLbch283Lu043EJtwSv0XvZDjr4YfR4d2MjAhqP6SH4VjjrKgbX8R00oCQ==} + peerDependencies: + react: '>=16.8' + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + caniuse-lite@1.0.30001591: + resolution: {integrity: sha512-PCzRMei/vXjJyL5mJtzNiUCKP59dm8Apqc3PH8gJkMnMXZGox93RbE76jHsmLwmIo6/3nsYIpJtx0O7u5PqFuQ==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@2.3.0: + resolution: {integrity: sha512-Az5zJR2CBujap2rqXGaJKaPHyJ0IrUimvYNX+ncCy8PJP4ltOGTrHUIo097ZaL2zMeKYpiCdqDvS6zdrTFok3Q==} + engines: {node: '>=4'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clipboardy@1.2.2: + resolution: {integrity: sha512-16KrBOV7bHmHdxcQiCvfUFYVFyEah4FI8vYT1Fr7CGSA4G+xBWMEfUEQJS1hxeHGtI9ju1Bzs9uXSbj5HZKArw==} + engines: {node: '>=4'} + + clsx@2.1.0: + resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==} + engines: {node: '>=6'} + + 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==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + commander@9.2.0: + resolution: {integrity: sha512-e2i4wANQiSXgnrBlIatyHtP1odfUp0BbV5Y5nEGbxtIrStkEOAAzCUirvLBNXHLr7kwLvJl6V+4V3XV9x7Wd9w==} + engines: {node: ^12.20.0 || >=14} + + compute-scroll-into-view@3.1.0: + resolution: {integrity: sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cross-spawn@5.1.0: + resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} + + csstype@3.1.1: + resolution: {integrity: sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.28.1: + resolution: {integrity: sha512-xyItz4O/4zp9/239wCcH8ZcFuuZooEeF8KHRmzjDfGdXsj3OG9MFSMA0pJE0uX3uCN/ygof6hHf4L7lst+JaDg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.0.0: + resolution: {integrity: sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.8.5: + resolution: {integrity: sha512-JgoahDG51ncUfJu6wX/1vWQEqOflgXyl4MaHqlcSruTez7yhaRKR9i8VjjcQGeS2en/jnFivXuaIMnseMMt0XA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.10: + resolution: {integrity: sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==} + + dayjs@1.11.10: + resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.0.2: + resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} + + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@5.1.0: + 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==} + + elkjs@0.9.2: + resolution: {integrity: sha512-2Y/RaA1pdgSHpY0YG4TYuYCD2wh97CRvu22eLG3Kz0pgQ/6KbIFTxsTnDc4MH/6hFlg2L/9qXrDMG0nMjP63iw==} + + entities@4.5.0: + 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'} + + esm@3.2.25: + 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-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + 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'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + flexsearch@0.7.43: + resolution: {integrity: sha512-c5o/+Um8aqCSOXGcZoqZOm+NqtVwNsvVpWv6lfmSclU954O3wvQKxxK8zj74fPaSJbXpSLTs4PRhh+wnoCXnKg==} + + focus-visible@5.2.0: + resolution: {integrity: sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ==} + + 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'} + + 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'} + + has-flag@2.0.0: + resolution: {integrity: sha512-P+1n3MnwjR/Epg9BBo1KT8qbye2g2Ou4sFumihwt6I4tsUX7jnLcX4BTOSKg/B1ZrIYMN9FcEnG4x5a7NB8Eng==} + engines: {node: '>=0.10.0'} + + hast-util-from-dom@5.0.0: + resolution: {integrity: sha512-d6235voAp/XR3Hh5uy7aGLbM3S4KamdW0WEgOaU1YoewnuYw4HXb5eRtv9g65m/RFGEfUY1Mw4UqCc5Y8L4Stg==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + + hast-util-from-html@2.0.1: + resolution: {integrity: sha512-RXQBLMl9kjKVNkJTIO6bZyb2n+cUH8LFaSSzo82jiLT6Tfc+Pt7VQCS+/h3YwG4jaNE2TA2sdJisGWR+aJrp0g==} + + hast-util-from-parse5@8.0.1: + resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.0.2: + resolution: {integrity: sha512-PldBy71wO9Uq1kyaMch9AHIghtQvIwxBUkv823pKmkTM3oV1JxtsTNYdevMxvUHqcnOAuO65JKU2+0NOxc2ksA==} + + hast-util-to-estree@3.1.0: + resolution: {integrity: sha512-lfX5g6hqVh9kjS/B9E2gSkvHH4SZNiQFiqWS0x9fENzEl+8W12RqdRxX6d/Cwxi30tPQs3bIO+aolQJNp1bIyw==} + + hast-util-to-jsx-runtime@2.3.0: + resolution: {integrity: sha512-H/y0+IWPdsLLS738P8tDnrQ8Z+dj12zQQ6WC11TIM21C8WFVoIxcqWXf2H3hiTVZjF1AWqoimGwrTWecWrnmRQ==} + + hast-util-to-parse5@8.0.0: + resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==} + + hast-util-to-string@3.0.0: + resolution: {integrity: sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA==} + + hast-util-to-text@4.0.0: + resolution: {integrity: sha512-EWiE1FSArNBPUo1cKWtzqgnuRQwEeQbQtnFJRYV1hb1BWDgrAlBU0ExptvZMM/KSA82cDpm2sFGf3Dmc5Mza3w==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@8.0.0: + resolution: {integrity: sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw==} + + heap@0.2.7: + resolution: {integrity: sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + inline-style-parser@0.1.1: + resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} + + inline-style-parser@0.2.3: + resolution: {integrity: sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + 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==} + 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-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + 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'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + katex@0.16.9: + resolution: {integrity: sha512-fsSYjWS0EEOwvy81j3vRA8TEAhQhKiqO+FQaKWp0m39qwOzHVBgAUBIXWj1pB+O2W3fIpNa6Y9KSKCVbfPhyAQ==} + 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'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + 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'} + + markdown-table@3.0.3: + resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} + + mathjax-full@3.2.2: + resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==} + + mdast-util-find-and-replace@3.0.1: + resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} + + mdast-util-from-markdown@1.3.1: + resolution: {integrity: sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==} + + mdast-util-from-markdown@2.0.1: + resolution: {integrity: sha512-aJEUyzZ6TzlsX2s5B4Of7lN7EQtAxvtradMMglCQDyaTFgse6CmtmdJ15ElnVRlCg1vpNyVtbem0PWzlNieZsA==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.0: + resolution: {integrity: sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==} + + mdast-util-gfm-footnote@2.0.0: + resolution: {integrity: sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.0.0: + resolution: {integrity: sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==} + + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-mdx-expression@2.0.0: + resolution: {integrity: sha512-fGCu8eWdKUKNu5mohVGkhBXCXGnOTLuFqOvGMvdikr+J1w7lDJgxThOKpwRWzzbyXAU2hhSwsmssOY4yTokluw==} + + mdast-util-mdx-jsx@3.1.2: + resolution: {integrity: sha512-eKMQDeywY2wlHc97k5eD8VC+9ASMjN8ItEZQNGwJ6E0XWKiW/Z0V5/H8pvoXUf+y+Mj0VIgeRRbujBmFn4FTyA==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + 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-markdown@2.1.0: + resolution: {integrity: sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==} + + mdast-util-to-string@3.1.0: + resolution: {integrity: sha512-n4Vypz/DZgwo0iMHLQL49dJzlp7YtAJP+N07MZHpjPf/5XJuHUWstviF4Mn2jEiR/GNmtnRRqnwsXExk3igfFA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mermaid@10.8.0: + resolution: {integrity: sha512-9CzfSreRjdDJxX796+jW4zjEq0DVw5xVF0nWsqff8OTbrt+ml0TZ5PyYUjjUZJa2NYxYJZZXewEquxGiM8qZEA==} + + mhchemparser@4.2.1: + resolution: {integrity: sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==} + + micromark-core-commonmark@1.0.6: + resolution: {integrity: sha512-K+PkJTxqjFfSNkfAhp4GB+cZPfQd6dxtTXnf+RjZOV7T4EEXnvgzOcnp+eSTmpGk9d1S9sL6/lqrgSNn/s0HZA==} + + micromark-core-commonmark@2.0.1: + resolution: {integrity: sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.0.0: + resolution: {integrity: sha512-rTHfnpt/Q7dEAK1Y5ii0W8bhfJlVJFnJMHIPisfPK3gpVNuOP0VnRl96+YJ3RYWV/P4gFeQoGKNlT3RhuvpqAg==} + + micromark-extension-gfm-footnote@2.0.0: + resolution: {integrity: sha512-6Rzu0CYRKDv3BfLAUnZsSlzx3ak6HAoI85KTiijuKIz5UxZxbUI+pD6oHgw+6UtQuiRwnGRhzMmPRv4smcz0fg==} + + micromark-extension-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-c3BR1ClMp5fxxmwP6AoOY2fXO9U8uFMKs4ADD66ahLTNcwzSCyRVU4k7LPV5Nxo/VJiR4TdzxRQY2v3qIUceCw==} + + micromark-extension-gfm-table@2.0.0: + resolution: {integrity: sha512-PoHlhypg1ItIucOaHmKE8fbin3vTLpDOUg8KAr8gRCF1MOZI9Nquq2i/44wFvviM4WuxJzc3demT8Y3dkfvYrw==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.0.1: + resolution: {integrity: sha512-cY5PzGcnULaN5O7T+cOzfMoHjBW7j+T9D2sucA5d/KbsBTPcYdebm9zUd9zzdgJGCwahV+/W78Z3nbulBYVbTw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-math@3.0.0: + resolution: {integrity: sha512-iJ2Q28vBoEovLN5o3GO12CpqorQRYDPT+p4zW50tGwTfJB+iv/VnB6Ini+gqa24K97DwptMBBIvVX6Bjk49oyQ==} + + micromark-extension-mdx-expression@3.0.0: + resolution: {integrity: sha512-sI0nwhUDz97xyzqJAbHQhp5TfaxEvZZZ2JDqUo+7NvyIYG6BZ5CPPqj2ogUoPJlmXHBnyZUzISg9+oUmU6tUjQ==} + + micromark-extension-mdx-jsx@3.0.0: + resolution: {integrity: sha512-uvhhss8OGuzR4/N17L1JwvmJIpPhAd8oByMawEKx6NVdBCbesjH4t+vjEp3ZXft9DwvlKSD07fCeI44/N0Vf2w==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@1.0.0: + resolution: {integrity: sha512-eUBA7Rs1/xtTVun9TmV3gjfPz2wEwgK5R5xcbIM5ZYAtvGF6JkyaDsj0agx8urXnO31tEO6Ug83iVH3tdedLnw==} + + micromark-factory-destination@2.0.0: + resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} + + micromark-factory-label@1.0.2: + resolution: {integrity: sha512-CTIwxlOnU7dEshXDQ+dsr2n+yxpP0+fn271pu0bwDIS8uqfFcumXpj5mLn3hSC8iw2MUr6Gx8EcKng1dD7i6hg==} + + micromark-factory-label@2.0.0: + resolution: {integrity: sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==} + + micromark-factory-mdx-expression@2.0.1: + resolution: {integrity: sha512-F0ccWIUHRLRrYp5TC9ZYXmZo+p2AM13ggbsW4T0b5CRKP8KHVRB8t4pwtBgTxtjRmwrK0Irwm7vs2JOZabHZfg==} + + micromark-factory-space@1.0.0: + resolution: {integrity: sha512-qUmqs4kj9a5yBnk3JMLyjtWYN6Mzfcx8uJfi5XAveBniDevmZasdGBba5b4QsvRcAkmvGo5ACmSUmyGiKTLZew==} + + micromark-factory-space@2.0.0: + resolution: {integrity: sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==} + + micromark-factory-title@1.0.2: + resolution: {integrity: sha512-zily+Nr4yFqgMGRKLpTVsNl5L4PMu485fGFDOQJQBl2NFpjGte1e86zC0da93wf97jrc4+2G2GQudFMHn3IX+A==} + + micromark-factory-title@2.0.0: + resolution: {integrity: sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==} + + micromark-factory-whitespace@1.0.0: + resolution: {integrity: sha512-Qx7uEyahU1lt1RnsECBiuEbfr9INjQTGa6Err+gF3g0Tx4YEviPbqqGKNv/NrBaE7dVHdn1bVZKM/n5I/Bak7A==} + + micromark-factory-whitespace@2.0.0: + resolution: {integrity: sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==} + + micromark-util-character@1.1.0: + resolution: {integrity: sha512-agJ5B3unGNJ9rJvADMJ5ZiYjBRyDpzKAOk01Kpi1TKhlT1APx3XZk6eN7RtSz1erbWHC2L8T3xLZ81wdtGRZzg==} + + micromark-util-character@2.1.0: + resolution: {integrity: sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==} + + micromark-util-chunked@1.0.0: + resolution: {integrity: sha512-5e8xTis5tEZKgesfbQMKRCyzvffRRUX+lK/y+DvsMFdabAicPkkZV6gO+FEWi9RfuKKoxxPwNL+dFF0SMImc1g==} + + micromark-util-chunked@2.0.0: + resolution: {integrity: sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==} + + micromark-util-classify-character@1.0.0: + resolution: {integrity: sha512-F8oW2KKrQRb3vS5ud5HIqBVkCqQi224Nm55o5wYLzY/9PwHGXC01tr3d7+TqHHz6zrKQ72Okwtvm/xQm6OVNZA==} + + micromark-util-classify-character@2.0.0: + resolution: {integrity: sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==} + + micromark-util-combine-extensions@1.0.0: + resolution: {integrity: sha512-J8H058vFBdo/6+AsjHp2NF7AJ02SZtWaVUjsayNFeAiydTxUwViQPxN0Hf8dp4FmCQi0UUFovFsEyRSUmFH3MA==} + + micromark-util-combine-extensions@2.0.0: + resolution: {integrity: sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==} + + micromark-util-decode-numeric-character-reference@1.0.0: + resolution: {integrity: sha512-OzO9AI5VUtrTD7KSdagf4MWgHMtET17Ua1fIpXTpuhclCqD8egFWo85GxSGvxgkGS74bEahvtM0WP0HjvV0e4w==} + + micromark-util-decode-numeric-character-reference@2.0.1: + resolution: {integrity: sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==} + + micromark-util-decode-string@1.0.2: + resolution: {integrity: sha512-DLT5Ho02qr6QWVNYbRZ3RYOSSWWFuH3tJexd3dgN1odEuPNxCngTCXJum7+ViRAd9BbdxCvMToPOD/IvVhzG6Q==} + + micromark-util-decode-string@2.0.0: + resolution: {integrity: sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==} + + micromark-util-encode@1.0.1: + resolution: {integrity: sha512-U2s5YdnAYexjKDel31SVMPbfi+eF8y1U4pfiRW/Y8EFVCy/vgxk/2wWTxzcqE71LHtCuCzlBDRU2a5CQ5j+mQA==} + + micromark-util-encode@2.0.0: + resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} + + micromark-util-events-to-acorn@2.0.2: + resolution: {integrity: sha512-Fk+xmBrOv9QZnEDguL9OI9/NQQp6Hz4FuQ4YmCb/5V7+9eAh1s6AYSvL20kHkD67YIg7EpE54TiSlcsf3vyZgA==} + + micromark-util-html-tag-name@1.1.0: + resolution: {integrity: sha512-BKlClMmYROy9UiV03SwNmckkjn8QHVaWkqoAqzivabvdGcwNGMMMH/5szAnywmsTBUzDsU57/mFi0sp4BQO6dA==} + + micromark-util-html-tag-name@2.0.0: + resolution: {integrity: sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==} + + micromark-util-normalize-identifier@1.0.0: + resolution: {integrity: sha512-yg+zrL14bBTFrQ7n35CmByWUTFsgst5JhA4gJYoty4Dqzj4Z4Fr/DHekSS5aLfH9bdlfnSvKAWsAgJhIbogyBg==} + + micromark-util-normalize-identifier@2.0.0: + resolution: {integrity: sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==} + + micromark-util-resolve-all@1.0.0: + resolution: {integrity: sha512-CB/AGk98u50k42kvgaMM94wzBqozSzDDaonKU7P7jwQIuH2RU0TeBqGYJz2WY1UdihhjweivStrJ2JdkdEmcfw==} + + micromark-util-resolve-all@2.0.0: + resolution: {integrity: sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==} + + micromark-util-sanitize-uri@1.1.0: + resolution: {integrity: sha512-RoxtuSCX6sUNtxhbmsEFQfWzs8VN7cTctmBPvYivo98xb/kDEoTCtJQX5wyzIYEmk/lvNFTat4hL8oW0KndFpg==} + + micromark-util-sanitize-uri@2.0.0: + resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} + + micromark-util-subtokenize@1.0.2: + resolution: {integrity: sha512-d90uqCnXp/cy4G881Ub4psE57Sf8YD0pim9QdjCRNjfas2M1u6Lbt+XZK9gnHL2XFhnozZiEdCa9CNfXSfQ6xA==} + + micromark-util-subtokenize@2.0.1: + resolution: {integrity: sha512-jZNtiFl/1aY73yS3UGQkutD0UbhTt68qnRpw2Pifmz5wV9h8gOVsN70v+Lq/f1rKaU/W8pxRe8y8Q9FX1AOe1Q==} + + micromark-util-symbol@1.0.1: + resolution: {integrity: sha512-oKDEMK2u5qqAptasDAwWDXq0tG9AssVwAx3E9bBF3t/shRIGsWIRG+cGafs2p/SnDSOecnt6hZPCE2o6lHfFmQ==} + + micromark-util-symbol@2.0.0: + resolution: {integrity: sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==} + + micromark-util-types@1.0.2: + resolution: {integrity: sha512-DCfg/T8fcrhrRKTPjRrw/5LLvdGV7BHySf/1LOZx7TzWZdYRjogNtyNq885z3nNallwr3QUKARjqvHqX1/7t+w==} + + micromark-util-types@2.0.0: + resolution: {integrity: sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==} + + micromark@3.1.0: + resolution: {integrity: sha512-6Mj0yHLdUZjHnOPgr5xfWIMqMWS12zDN6iws9SLuSz76W8jTtAv24MN4/CL7gJrl5vtxGInkkqDv/JIoRsQOvA==} + + micromark@4.0.0: + resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} + + mj-context-menu@0.6.1: + resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next-themes@0.3.0: + resolution: {integrity: sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==} + peerDependencies: + react: ^16.8 || ^17 || ^18 + react-dom: ^16.8 || ^17 || ^18 + + next@14.1.1: + resolution: {integrity: sha512-McrGJqlGSHeaz2yTRPkEucxQKe5Zq7uPwyeHNmJaZNY4wx9E9QdxmTp310agFRoMuIYgQrCrT3petg13fSVOww==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + sass: + optional: true + + nextra-theme-docs@3.0.0-alpha.24: + resolution: {integrity: sha512-AUuv3o1xYzGGvUP/lxxf7i++Zyycy4bH22l6T8uDVez2n0j/RDbzqmSIdL4Jh4POUd0fyxEG+BUomKXKyj3L1g==} + peerDependencies: + next: '>=13' + nextra: 3.0.0-alpha.24 + react: '>=16.13.1' + react-dom: '>=16.13.1' + + nextra@3.0.0-alpha.24: + resolution: {integrity: sha512-KScl/DQG68JBGy6gfOFNdVcrlc24YvcegGnYwUXvpCoHx2YcBUSp6FE7LeaX/C/mI4PPIMmTJuEWIjGH9DXeCQ==} + engines: {node: '>=18'} + peerDependencies: + next: '>=13' + react: '>=16.13.1' + react-dom: '>=16.13.1' + + nlcst-to-string@3.1.1: + resolution: {integrity: sha512-63mVyqaqt0cmn2VcI2aH6kxe1rLAmSROqHMA0i4qqg1tidkfExgpb0FGMikMCn86mw5dFtBtEANfmSSK7TjNHw==} + + 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-to-yarn@2.2.1: + resolution: {integrity: sha512-O/j/ROyX0KGLG7O6Ieut/seQ0oiTpHF2tXAcFbpdTLQFiaNtkyTXXocM1fwpaa60dg1qpWj0nHlbNhx6qwuENQ==} + 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'} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + parse-entities@4.0.0: + resolution: {integrity: sha512-5nk9Fn03x3rEhGaX1FU6IDwG/k+GxLXlFAkgrbM1asuAFl3BhdQWvASaIsmwWypRNcZKHPYnIuOSfIWEyEQnPQ==} + + parse-latin@5.0.1: + resolution: {integrity: sha512-b/K8ExXaWC9t34kKeDV8kGXBkXZ1HCSAZRYE7HR14eA1GlXX5L8iWhs8USJNhQU9q5ci413jCKF0gOyovvyRBg==} + + parse-numeric-range@1.3.0: + resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} + + 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'} + + periscopic@3.0.4: + resolution: {integrity: sha512-SFx68DxCv0Iyo6APZuw/AKewkkThGwssmU0QWtTlvov3VAtPX+QJ4CadwSaz8nrT5jPIuxdvJWB4PnD2KNDxQg==} + + picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + 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==} + + react-dom@18.2.0: + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + + react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + + reading-time@1.5.0: + resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} + + 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==} + engines: {node: '>=18'} + peerDependencies: + shiki: ^1.0.0 + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + remark-frontmatter@5.0.0: + resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} + + remark-gfm@4.0.0: + resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} + + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + + remark-mdx@3.0.1: + resolution: {integrity: sha512-3Pz3yPQ5Rht2pM5R+0J2MrGoBSrzf+tJG94N+t/ilfdh8YLyyKYtidAYwTveB20BoHAcwIopOUqhcmh2F7hGYA==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-reading-time@2.0.1: + resolution: {integrity: sha512-fy4BKy9SRhtYbEHvp6AItbRTnrhiDGbqLQTSYVbQPGuRCncU1ubSsh9p/W5QZSxtYcUXv8KGL0xBgPLyNJA1xw==} + + 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-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + retext-latin@3.1.0: + resolution: {integrity: sha512-5MrD1tuebzO8ppsja5eEu+ZbBeUNCjoEarn70tkXOS7Bdsdf6tNahsv2bY0Z8VooFF6cw7/6S+d3yI/TMlMVVQ==} + + retext-smartypants@5.2.0: + resolution: {integrity: sha512-Do8oM+SsjrbzT2UNIKgheP0hgUQTDDQYyZaIY3kfq0pdFzoPk+ZClYJ+OERNXveog4xf1pZL4PfRxNoVL7a/jw==} + + retext-stringify@3.1.0: + resolution: {integrity: sha512-767TLOaoXFXyOnjx/EggXlb37ZD2u4P1n0GJqVdpipqACsQP+20W+BNpMYrlJkq7hxffnFk+jc6mAK9qrbuB8w==} + + retext@8.1.0: + resolution: {integrity: sha512-N9/Kq7YTn6ZpzfiGW45WfEGJqFf1IM1q8OsRa1CGzIebCJBNCANDRmOrholiDRGKo/We7ofKR4SEvcGAWEMD3Q==} + + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.23.0: + resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + shebang-command@1.2.0: + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} + + shebang-regex@1.0.0: + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} + + shiki@1.6.1: + resolution: {integrity: sha512-1Pu/A1rtsG6HZvQm4W0NExQ45e02og+rPog7PDaFDiMumZgOYnZIu4JtGQeAIfMwdbKSjJQoCUr79vDLKUUxWA==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + 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==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speech-rule-engine@4.0.7: + 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'} + + style-to-object@0.4.4: + resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} + + style-to-object@1.0.6: + resolution: {integrity: sha512-khxq+Qm3xEyZfKd/y9L3oIWQimxuc4STrQKtQn8aSDRHb8mFgpukgX1hdzfrMEW6JCjyJ8p89x+IUMVnCBI1PA==} + + styled-jsx@5.1.1: + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + stylis@4.3.1: + resolution: {integrity: sha512-EQepAV+wMsIaGVGX1RECzgrcqRRU/0sYOHkeLsZ3fzHaHXZy4DaOOX0vOlGQdlsjkh3mFHAIlVimpwAs4dslyQ==} + + supports-color@4.5.0: + resolution: {integrity: sha512-ycQR/UbvI9xIlEdQT1TQqwoXtEldExbCEAJgRo5YXlmSKjv6ThHnP9/vwGa1gr19Gfw+LkFd7KqYMhzrRC5JYw==} + engines: {node: '>=4'} + + title@3.5.3: + resolution: {integrity: sha512-20JyowYglSEeCvZv3EZ0nZ046vLarO37prvV0mbtQV7C8DJPGgN967r8SJkqd3XK3K3lD3/Iyfp3avjfil8Q2Q==} + hasBin: true + + titleize@1.0.0: + resolution: {integrity: sha512-TARUb7z1pGvlLxgPk++7wJ6aycXF3GJ0sNSBTAsTuJrQG5QuZlkUQP+zl+nbjAh4gMX9yDw9ZYklMd7vAfJKEw==} + engines: {node: '>=0.10.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.1.0: + resolution: {integrity: sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==} + + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + + tslib@2.4.1: + resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} + + twoslash-protocol@0.2.6: + resolution: {integrity: sha512-8NbJlYeRdBcCTQ7ui7pdRPC1NL16aOnoYNz06oBW+W0qWNuiQXHgE8UeNvbA038aDd6ZPuuD5WedsBIESocB4g==} + + twoslash@0.2.6: + resolution: {integrity: sha512-DcAKIyXMB6xNs+SOw/oF8GvUr/cfJSqznngVXYbAUIVfTW3M8vWSEoCaz/RgSD+M6vwtK8DJ4/FmYBF5MN8BGw==} + peerDependencies: + typescript: '*' + + typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + engines: {node: '>=14.17'} + hasBin: true + + 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==} + + 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-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-remove@4.0.0: + resolution: {integrity: sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==} + + unist-util-stringify-position@3.0.2: + resolution: {integrity: sha512-7A6eiDCs9UtjcwZOcCpM4aPII3bAAGv13E96IkawkOAW0OhH+yRxtY0lzo8KiHpzEMfH7Q+FizUmwp8Iqy5EWg==} + + 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-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==} + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + uvu@0.5.6: + resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} + engines: {node: '>=8'} + hasBin: true + + 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==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + web-worker@1.3.0: + resolution: {integrity: sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + 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'} + + yallist@2.1.2: + resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} + + yaml@2.4.2: + resolution: {integrity: sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==} + engines: {node: '>= 14'} + hasBin: true + + yocto-queue@1.0.0: + resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} + engines: {node: '>=12.20'} + + zod-validation-error@1.5.0: + resolution: {integrity: sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw==} + engines: {node: '>=16.0.0'} + peerDependencies: + zod: ^3.18.0 + + zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@braintree/sanitize-url@6.0.4': {} + + '@headlessui/react@1.7.18(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + 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) + + '@mdx-js/mdx@3.0.1': + dependencies: + '@types/estree': 1.0.0 + '@types/estree-jsx': 1.0.0 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.3 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-build-jsx: 3.0.1 + 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 + markdown-extensions: 2.0.0 + periscopic: 3.0.4 + remark-mdx: 3.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.0 + source-map: 0.7.4 + unified: 11.0.4 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.1 + 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 + + '@napi-rs/simple-git-android-arm64@0.1.16': + optional: true + + '@napi-rs/simple-git-darwin-arm64@0.1.16': + optional: true + + '@napi-rs/simple-git-darwin-x64@0.1.16': + optional: true + + '@napi-rs/simple-git-linux-arm-gnueabihf@0.1.16': + optional: true + + '@napi-rs/simple-git-linux-arm64-gnu@0.1.16': + optional: true + + '@napi-rs/simple-git-linux-arm64-musl@0.1.16': + optional: true + + '@napi-rs/simple-git-linux-x64-gnu@0.1.16': + optional: true + + '@napi-rs/simple-git-linux-x64-musl@0.1.16': + optional: true + + '@napi-rs/simple-git-win32-arm64-msvc@0.1.16': + optional: true + + '@napi-rs/simple-git-win32-x64-msvc@0.1.16': + optional: true + + '@napi-rs/simple-git@0.1.16': + optionalDependencies: + '@napi-rs/simple-git-android-arm-eabi': 0.1.16 + '@napi-rs/simple-git-android-arm64': 0.1.16 + '@napi-rs/simple-git-darwin-arm64': 0.1.16 + '@napi-rs/simple-git-darwin-x64': 0.1.16 + '@napi-rs/simple-git-linux-arm-gnueabihf': 0.1.16 + '@napi-rs/simple-git-linux-arm64-gnu': 0.1.16 + '@napi-rs/simple-git-linux-arm64-musl': 0.1.16 + '@napi-rs/simple-git-linux-x64-gnu': 0.1.16 + '@napi-rs/simple-git-linux-x64-musl': 0.1.16 + '@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/swc-darwin-arm64@14.1.1': + optional: true + + '@next/swc-darwin-x64@14.1.1': + optional: true + + '@next/swc-linux-arm64-gnu@14.1.1': + optional: true + + '@next/swc-linux-arm64-musl@14.1.1': + optional: true + + '@next/swc-linux-x64-gnu@14.1.1': + optional: true + + '@next/swc-linux-x64-musl@14.1.1': + optional: true + + '@next/swc-win32-arm64-msvc@14.1.1': + optional: true + + '@next/swc-win32-ia32-msvc@14.1.1': + optional: true + + '@next/swc-win32-x64-msvc@14.1.1': + optional: true + + '@popperjs/core@2.11.8': {} + + '@shikijs/core@1.6.1': {} + + '@shikijs/twoslash@1.6.1(typescript@5.3.3)': + dependencies: + '@shikijs/core': 1.6.1 + twoslash: 0.2.6(typescript@5.3.3) + transitivePeerDependencies: + - supports-color + - typescript + + '@swc/helpers@0.5.2': + dependencies: + tslib: 2.4.1 + + '@tanstack/react-virtual@3.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.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.1.3': {} + + '@theguild/remark-mermaid@0.0.5(react@18.2.0)': + dependencies: + mermaid: 10.8.0 + react: 18.2.0 + unist-util-visit: 5.0.0 + transitivePeerDependencies: + - supports-color + + '@theguild/remark-npm2yarn@0.3.0': + dependencies: + npm-to-yarn: 2.2.1 + unist-util-visit: 5.0.0 + + '@types/acorn@4.0.6': + dependencies: + '@types/estree': 1.0.0 + + '@types/d3-scale-chromatic@3.0.3': {} + + '@types/d3-scale@4.0.8': + dependencies: + '@types/d3-time': 3.0.3 + + '@types/d3-time@3.0.3': {} + + '@types/debug@4.1.7': + dependencies: + '@types/ms': 0.7.31 + + '@types/estree-jsx@1.0.0': + dependencies: + '@types/estree': 1.0.0 + + '@types/estree@1.0.0': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.2 + + '@types/katex@0.16.7': {} + + '@types/mdast@3.0.10': + dependencies: + '@types/unist': 3.0.2 + + '@types/mdast@4.0.3': + dependencies: + '@types/unist': 3.0.2 + + '@types/mdx@2.0.3': {} + + '@types/ms@0.7.31': {} + + '@types/nlcst@1.0.4': + dependencies: + '@types/unist': 2.0.6 + + '@types/node@20.11.24': + dependencies: + undici-types: 5.26.5 + + '@types/prop-types@15.7.5': {} + + '@types/react@18.0.26': + dependencies: + '@types/prop-types': 15.7.5 + '@types/scheduler': 0.16.2 + csstype: 3.1.1 + + '@types/scheduler@0.16.2': {} + + '@types/unist@2.0.6': {} + + '@types/unist@3.0.2': {} + + '@typescript/vfs@1.5.0': + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + + '@ungap/structured-clone@1.2.0': {} + + acorn-jsx@5.3.2(acorn@8.8.1): + dependencies: + acorn: 8.8.1 + + acorn@8.8.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + arch@2.2.0: {} + + arg@1.0.0: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + array-iterate@2.0.1: {} + + astring@1.8.3: {} + + bail@2.0.2: {} + + better-react-mathjax@2.0.3(react@18.2.0): + dependencies: + mathjax-full: 3.2.2 + react: 18.2.0 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + 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 + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + client-only@0.0.1: {} + + clipboardy@1.2.2: + dependencies: + arch: 2.2.0 + execa: 0.8.0 + + clsx@2.1.0: {} + + collapse-white-space@2.1.0: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-name@1.1.3: {} + + comma-separated-tokens@2.0.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + commander@9.2.0: {} + + compute-scroll-into-view@3.1.0: {} + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cross-spawn@5.1.0: + dependencies: + lru-cache: 4.1.5 + shebang-command: 1.2.0 + which: 1.3.1 + + csstype@3.1.1: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.28.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.28.1 + + cytoscape@3.28.1: + dependencies: + heap: 0.2.7 + lodash: 4.17.21 + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.0: {} + + d3-geo@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.0.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.8.5: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.0.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.10: + dependencies: + d3: 7.8.5 + lodash-es: 4.17.21 + + dayjs@1.11.10: {} + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + decode-named-character-reference@1.0.2: + dependencies: + character-entities: 2.0.2 + + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@5.1.0: {} + + dompurify@3.0.9: {} + + 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 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.0 + devlop: 1.1.0 + 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: + dependencies: + '@types/estree-jsx': 1.0.0 + 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: + dependencies: + '@types/estree': 1.0.0 + is-plain-obj: 4.1.0 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.0 + '@types/unist': 3.0.2 + + estree-walker@3.0.1: {} + + execa@0.8.0: + 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 + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend@3.0.2: {} + + fault@2.0.1: + dependencies: + format: 0.2.2 + + flexsearch@0.7.43: {} + + focus-visible@5.2.0: {} + + format@0.2.2: {} + + get-stream@3.0.0: {} + + github-slugger@2.0.0: {} + + graceful-fs@4.2.11: {} + + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.1 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + has-flag@2.0.0: {} + + hast-util-from-dom@5.0.0: + dependencies: + '@types/hast': 3.0.4 + hastscript: 8.0.0 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-dom: 5.0.0 + hast-util-from-html: 2.0.1 + unist-util-remove-position: 5.0.0 + + hast-util-from-html@2.0.1: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.1 + parse5: 7.1.2 + vfile: 6.0.1 + vfile-message: 4.0.2 + + hast-util-from-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.2 + devlop: 1.1.0 + hastscript: 8.0.0 + property-information: 6.2.0 + vfile: 6.0.1 + vfile-location: 5.0.2 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.2 + '@ungap/structured-clone': 1.2.0 + 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 + parse5: 7.1.2 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.1 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.0: + dependencies: + '@types/estree': 1.0.0 + '@types/estree-jsx': 1.0.0 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + 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 + property-information: 6.2.0 + space-separated-tokens: 2.0.2 + style-to-object: 0.4.4 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-jsx-runtime@2.3.0: + dependencies: + '@types/estree': 1.0.0 + '@types/hast': 3.0.4 + '@types/unist': 3.0.2 + comma-separated-tokens: 2.0.3 + 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 + property-information: 6.2.0 + space-separated-tokens: 2.0.2 + style-to-object: 1.0.6 + unist-util-position: 5.0.0 + vfile-message: 4.0.2 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 6.2.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-string@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-to-text@4.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.2 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 6.2.0 + space-separated-tokens: 2.0.2 + + heap@0.2.7: {} + + html-void-elements@3.0.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + inline-style-parser@0.1.1: {} + + inline-style-parser@0.2.3: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + intersection-observer@0.12.2: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + 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-hexadecimal@2.0.1: {} + + is-plain-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-reference@3.0.0: + dependencies: + '@types/estree': 1.0.0 + + is-stream@1.1.0: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + katex@0.16.9: + 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@4.17.21: {} + + 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: {} + + mathjax-full@3.2.2: + dependencies: + esm: 3.2.25 + mhchemparser: 4.2.1 + mj-context-menu: 0.6.1 + speech-rule-engine: 4.0.7 + + mdast-util-find-and-replace@3.0.1: + dependencies: + '@types/mdast': 4.0.3 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + mdast-util-from-markdown@1.3.1: + 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-util-decode-numeric-character-reference: 1.0.0 + micromark-util-decode-string: 1.0.2 + micromark-util-normalize-identifier: 1.0.0 + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + unist-util-stringify-position: 3.0.2 + uvu: 0.5.6 + transitivePeerDependencies: + - supports-color + + mdast-util-from-markdown@2.0.1: + 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-util-decode-numeric-character-reference: 2.0.1 + micromark-util-decode-string: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + 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-to-markdown: 2.1.0 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.1 + micromark-util-character: 2.1.0 + + mdast-util-gfm-footnote@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.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-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.1 + 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-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.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-to-markdown: 2.1.0 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.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-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.1.2: + dependencies: + '@types/estree-jsx': 1.0.0 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.3 + '@types/unist': 3.0.2 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + parse-entities: 4.0.0 + stringify-entities: 4.0.3 + unist-util-remove-position: 5.0.0 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.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-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + 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-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.3 + unist-util-is: 6.0.0 + + mdast-util-to-hast@13.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.3 + '@ungap/structured-clone': 1.2.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.0 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.1 + + mdast-util-to-markdown@2.1.0: + dependencies: + '@types/mdast': 4.0.3 + '@types/unist': 3.0.2 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-decode-string: 2.0.0 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + mdast-util-to-string@3.1.0: {} + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.3 + + mermaid@10.8.0: + dependencies: + '@braintree/sanitize-url': 6.0.4 + '@types/d3-scale': 4.0.8 + '@types/d3-scale-chromatic': 3.0.3 + cytoscape: 3.28.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.28.1) + d3: 7.8.5 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.10 + dayjs: 1.11.10 + dompurify: 3.0.9 + elkjs: 0.9.2 + khroma: 2.1.0 + lodash-es: 4.17.21 + mdast-util-from-markdown: 1.3.1 + non-layered-tidy-tree-layout: 2.0.2 + stylis: 4.3.1 + ts-dedent: 2.2.0 + uuid: 9.0.1 + web-worker: 1.3.0 + transitivePeerDependencies: + - supports-color + + mhchemparser@4.2.1: {} + + micromark-core-commonmark@1.0.6: + dependencies: + decode-named-character-reference: 1.0.2 + micromark-factory-destination: 1.0.0 + micromark-factory-label: 1.0.2 + micromark-factory-space: 1.0.0 + micromark-factory-title: 1.0.2 + micromark-factory-whitespace: 1.0.0 + micromark-util-character: 1.1.0 + micromark-util-chunked: 1.0.0 + micromark-util-classify-character: 1.0.0 + micromark-util-html-tag-name: 1.1.0 + micromark-util-normalize-identifier: 1.0.0 + micromark-util-resolve-all: 1.0.0 + micromark-util-subtokenize: 1.0.2 + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + uvu: 0.5.6 + + micromark-core-commonmark@2.0.1: + dependencies: + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-factory-destination: 2.0.0 + micromark-factory-label: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-factory-title: 2.0.0 + micromark-factory-whitespace: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-html-tag-name: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-subtokenize: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-autolink-literal@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-footnote@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.1 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-strikethrough@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-table@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-extension-gfm-task-list-item@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.0.0 + micromark-extension-gfm-footnote: 2.0.0 + micromark-extension-gfm-strikethrough: 2.0.0 + micromark-extension-gfm-table: 2.0.0 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.0.1 + micromark-util-combine-extensions: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-math@3.0.0: + dependencies: + '@types/katex': 0.16.7 + devlop: 1.1.0 + katex: 0.16.9 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-mdx-expression@3.0.0: + dependencies: + '@types/estree': 1.0.0 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.1 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-mdx-jsx@3.0.0: + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.1 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + vfile-message: 4.0.2 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.1 + micromark-util-character: 2.1.0 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.2 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.8.1 + acorn-jsx: 5.3.2(acorn@8.8.1) + micromark-extension-mdx-expression: 3.0.0 + micromark-extension-mdx-jsx: 3.0.0 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-destination@1.0.0: + dependencies: + micromark-util-character: 1.1.0 + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + + micromark-factory-destination@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-label@1.0.2: + dependencies: + micromark-util-character: 1.1.0 + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + uvu: 0.5.6 + + micromark-factory-label@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-mdx-expression@2.0.1: + dependencies: + '@types/estree': 1.0.0 + devlop: 1.1.0 + micromark-util-character: 2.1.0 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.2 + + micromark-factory-space@1.0.0: + dependencies: + micromark-util-character: 1.1.0 + micromark-util-types: 1.0.2 + + micromark-factory-space@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-types: 2.0.0 + + micromark-factory-title@1.0.2: + dependencies: + micromark-factory-space: 1.0.0 + micromark-util-character: 1.1.0 + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + uvu: 0.5.6 + + micromark-factory-title@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-whitespace@1.0.0: + dependencies: + micromark-factory-space: 1.0.0 + micromark-util-character: 1.1.0 + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + + micromark-factory-whitespace@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-character@1.1.0: + dependencies: + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + + micromark-util-character@2.1.0: + dependencies: + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-chunked@1.0.0: + dependencies: + micromark-util-symbol: 1.0.1 + + micromark-util-chunked@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-classify-character@1.0.0: + dependencies: + micromark-util-character: 1.1.0 + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + + micromark-util-classify-character@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-combine-extensions@1.0.0: + dependencies: + micromark-util-chunked: 1.0.0 + micromark-util-types: 1.0.2 + + micromark-util-combine-extensions@2.0.0: + dependencies: + micromark-util-chunked: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-decode-numeric-character-reference@1.0.0: + dependencies: + micromark-util-symbol: 1.0.1 + + micromark-util-decode-numeric-character-reference@2.0.1: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-decode-string@1.0.2: + dependencies: + decode-named-character-reference: 1.0.2 + micromark-util-character: 1.1.0 + micromark-util-decode-numeric-character-reference: 1.0.0 + micromark-util-symbol: 1.0.1 + + micromark-util-decode-string@2.0.0: + dependencies: + decode-named-character-reference: 1.0.2 + micromark-util-character: 2.1.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-symbol: 2.0.0 + + micromark-util-encode@1.0.1: {} + + micromark-util-encode@2.0.0: {} + + micromark-util-events-to-acorn@2.0.2: + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.0 + '@types/unist': 3.0.2 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + vfile-message: 4.0.2 + + micromark-util-html-tag-name@1.1.0: {} + + micromark-util-html-tag-name@2.0.0: {} + + micromark-util-normalize-identifier@1.0.0: + dependencies: + micromark-util-symbol: 1.0.1 + + micromark-util-normalize-identifier@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-resolve-all@1.0.0: + dependencies: + micromark-util-types: 1.0.2 + + micromark-util-resolve-all@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-util-sanitize-uri@1.1.0: + dependencies: + micromark-util-character: 1.1.0 + micromark-util-encode: 1.0.1 + micromark-util-symbol: 1.0.1 + + micromark-util-sanitize-uri@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-encode: 2.0.0 + micromark-util-symbol: 2.0.0 + + micromark-util-subtokenize@1.0.2: + dependencies: + micromark-util-chunked: 1.0.0 + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + uvu: 0.5.6 + + micromark-util-subtokenize@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-symbol@1.0.1: {} + + micromark-util-symbol@2.0.0: {} + + micromark-util-types@1.0.2: {} + + micromark-util-types@2.0.0: {} + + micromark@3.1.0: + dependencies: + '@types/debug': 4.1.7 + debug: 4.3.4 + decode-named-character-reference: 1.0.2 + micromark-core-commonmark: 1.0.6 + micromark-factory-space: 1.0.0 + micromark-util-character: 1.1.0 + micromark-util-chunked: 1.0.0 + micromark-util-combine-extensions: 1.0.0 + micromark-util-decode-numeric-character-reference: 1.0.0 + micromark-util-encode: 1.0.1 + micromark-util-normalize-identifier: 1.0.0 + micromark-util-resolve-all: 1.0.0 + micromark-util-sanitize-uri: 1.1.0 + micromark-util-subtokenize: 1.0.2 + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + uvu: 0.5.6 + transitivePeerDependencies: + - supports-color + + micromark@4.0.0: + dependencies: + '@types/debug': 4.1.7 + debug: 4.3.4 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.1 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-encode: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-subtokenize: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + transitivePeerDependencies: + - supports-color + + mj-context-menu@0.6.1: {} + + mri@1.2.0: {} + + ms@2.1.2: {} + + nanoid@3.3.7: {} + + next-themes@0.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + next@14.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + '@next/env': 14.1.1 + '@swc/helpers': 0.5.2 + busboy: 1.6.0 + 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) + 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 + 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): + dependencies: + '@headlessui/react': 1.7.18(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@popperjs/core': 2.11.8 + 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) + scroll-into-view-if-needed: 3.1.0 + zod: 3.22.4 + + 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): + 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) + '@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) + clsx: 2.1.0 + estree-util-to-js: 2.0.0 + estree-util-value-to-estree: 3.1.1 + 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) + rehype-katex: 7.0.0 + rehype-pretty-code: 0.13.0(shiki@1.6.1) + 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 + slash: 5.1.0 + title: 3.5.3 + unist-util-remove: 4.0.0 + unist-util-visit: 5.0.0 + yaml: 2.4.2 + zod: 3.22.4 + zod-validation-error: 1.5.0(zod@3.22.4) + transitivePeerDependencies: + - '@types/react' + - supports-color + - typescript + + nlcst-to-string@3.1.1: + dependencies: + '@types/nlcst': 1.0.4 + + non-layered-tidy-tree-layout@2.0.2: {} + + npm-run-path@2.0.2: + dependencies: + path-key: 2.0.1 + + npm-to-yarn@2.2.1: {} + + p-finally@1.0.0: {} + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.0.0 + + parse-entities@4.0.0: + dependencies: + '@types/unist': 2.0.6 + character-entities: 2.0.2 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.0.2 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-latin@5.0.1: + dependencies: + nlcst-to-string: 3.1.1 + unist-util-modify-children: 3.1.1 + unist-util-visit-children: 2.0.2 + + parse-numeric-range@1.3.0: {} + + parse5@7.1.2: + dependencies: + entities: 4.5.0 + + path-key@2.0.1: {} + + periscopic@3.0.4: + dependencies: + estree-walker: 3.0.1 + is-reference: 3.0.0 + + picocolors@1.0.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.0.2 + + property-information@6.2.0: {} + + pseudomap@1.0.2: {} + + react-dom@18.2.0(react@18.2.0): + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.0 + + react@18.2.0: + dependencies: + loose-envify: 1.4.0 + + reading-time@1.5.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 + unist-util-visit-parents: 6.0.1 + vfile: 6.0.1 + + rehype-parse@9.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.1 + unified: 11.0.4 + + rehype-pretty-code@0.13.0(shiki@1.6.1): + 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 + unist-util-visit: 5.0.0 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.0.2 + vfile: 6.0.1 + + remark-frontmatter@5.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-frontmatter: 2.0.1 + micromark-extension-frontmatter: 2.0.0 + unified: 11.0.4 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-gfm: 3.0.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.4 + transitivePeerDependencies: + - supports-color + + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.0.0 + unified: 11.0.4 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.0.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-from-markdown: 2.0.1 + micromark-util-types: 2.0.0 + unified: 11.0.4 + transitivePeerDependencies: + - supports-color + + remark-reading-time@2.0.1: + dependencies: + estree-util-is-identifier-name: 2.0.1 + estree-util-value-to-estree: 1.3.0 + reading-time: 1.5.0 + unist-util-visit: 3.1.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 + vfile: 6.0.1 + + remark-smartypants@2.1.0: + dependencies: + retext: 8.1.0 + retext-smartypants: 5.2.0 + 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 + + retext-latin@3.1.0: + dependencies: + '@types/nlcst': 1.0.4 + parse-latin: 5.0.1 + unherit: 3.0.1 + unified: 10.1.2 + + retext-smartypants@5.2.0: + dependencies: + '@types/nlcst': 1.0.4 + nlcst-to-string: 3.1.1 + unified: 10.1.2 + unist-util-visit: 4.1.1 + + retext-stringify@3.1.0: + dependencies: + '@types/nlcst': 1.0.4 + nlcst-to-string: 3.1.1 + unified: 10.1.2 + + retext@8.1.0: + dependencies: + '@types/nlcst': 1.0.4 + retext-latin: 3.1.0 + retext-stringify: 3.1.0 + unified: 10.1.2 + + robust-predicates@3.0.2: {} + + rw@1.3.3: {} + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safer-buffer@2.1.2: {} + + scheduler@0.23.0: + dependencies: + loose-envify: 1.4.0 + + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.0 + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + shebang-command@1.2.0: + dependencies: + shebang-regex: 1.0.0 + + shebang-regex@1.0.0: {} + + shiki@1.6.1: + dependencies: + '@shikijs/core': 1.6.1 + + signal-exit@3.0.7: {} + + slash@5.1.0: {} + + source-map-js@1.0.2: {} + + source-map@0.7.4: {} + + space-separated-tokens@2.0.2: {} + + speech-rule-engine@4.0.7: + dependencies: + commander: 9.2.0 + 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: {} + + style-to-object@0.4.4: + dependencies: + inline-style-parser: 0.1.1 + + style-to-object@1.0.6: + dependencies: + inline-style-parser: 0.2.3 + + styled-jsx@5.1.1(react@18.2.0): + dependencies: + client-only: 0.0.1 + react: 18.2.0 + + stylis@4.3.1: {} + + supports-color@4.5.0: + dependencies: + has-flag: 2.0.0 + + title@3.5.3: + dependencies: + arg: 1.0.0 + chalk: 2.3.0 + clipboardy: 1.2.2 + titleize: 1.0.0 + + titleize@1.0.0: {} + + trim-lines@3.0.1: {} + + trough@2.1.0: {} + + ts-dedent@2.2.0: {} + + tslib@2.4.1: {} + + twoslash-protocol@0.2.6: {} + + twoslash@0.2.6(typescript@5.3.3): + dependencies: + '@typescript/vfs': 1.5.0 + twoslash-protocol: 0.2.6 + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + typescript@5.3.3: {} + + 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: + dependencies: + '@types/unist': 3.0.2 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.1.0 + vfile: 6.0.1 + + unist-util-find-after@5.0.0: + dependencies: + '@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: + dependencies: + '@types/unist': 2.0.6 + array-iterate: 2.0.1 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.2 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.2 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.2 + unist-util-visit: 5.0.0 + + unist-util-remove@4.0.0: + dependencies: + '@types/unist': 3.0.2 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + unist-util-stringify-position@3.0.2: + dependencies: + '@types/unist': 2.0.6 + + unist-util-stringify-position@4.0.0: + 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: + dependencies: + '@types/unist': 2.0.6 + unist-util-is: 5.1.1 + + 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 + + uuid@9.0.1: {} + + uvu@0.5.6: + dependencies: + dequal: 2.0.3 + diff: 5.1.0 + kleur: 4.1.5 + sade: 1.8.1 + + vfile-location@5.0.2: + dependencies: + '@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 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.2 + + web-namespaces@2.0.1: {} + + web-worker@1.3.0: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + wicked-good-xpath@1.3.0: {} + + 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@3.22.4: {} + + zwitch@2.0.4: {} diff --git a/docs/public/hyperglass-dark.svg b/docs/public/hyperglass-dark.svg new file mode 100644 index 0000000..b93080f --- /dev/null +++ b/docs/public/hyperglass-dark.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/hyperglass-icon-dark.svg b/docs/public/hyperglass-icon-dark.svg new file mode 100644 index 0000000..ea9b648 --- /dev/null +++ b/docs/public/hyperglass-icon-dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/public/hyperglass-icon-light.svg b/docs/public/hyperglass-icon-light.svg new file mode 100644 index 0000000..7dfbb56 --- /dev/null +++ b/docs/public/hyperglass-icon-light.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/public/hyperglass-light.svg b/docs/public/hyperglass-light.svg new file mode 100644 index 0000000..b2bb038 --- /dev/null +++ b/docs/public/hyperglass-light.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/hyperglass.svg b/docs/public/hyperglass.svg new file mode 100644 index 0000000..bcfd80d --- /dev/null +++ b/docs/public/hyperglass.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/img/android-chrome-144x144.png b/docs/public/img/android-chrome-144x144.png new file mode 100644 index 0000000..1b6adf5 Binary files /dev/null and b/docs/public/img/android-chrome-144x144.png differ diff --git a/docs/public/img/android-chrome-192x192.png b/docs/public/img/android-chrome-192x192.png new file mode 100644 index 0000000..6719465 Binary files /dev/null and b/docs/public/img/android-chrome-192x192.png differ diff --git a/docs/public/img/android-chrome-256x256.png b/docs/public/img/android-chrome-256x256.png new file mode 100644 index 0000000..b79802f Binary files /dev/null and b/docs/public/img/android-chrome-256x256.png differ diff --git a/docs/public/img/android-chrome-36x36.png b/docs/public/img/android-chrome-36x36.png new file mode 100644 index 0000000..bc7f529 Binary files /dev/null and b/docs/public/img/android-chrome-36x36.png differ diff --git a/docs/public/img/android-chrome-384x384.png b/docs/public/img/android-chrome-384x384.png new file mode 100644 index 0000000..03175a4 Binary files /dev/null and b/docs/public/img/android-chrome-384x384.png differ diff --git a/docs/public/img/android-chrome-48x48.png b/docs/public/img/android-chrome-48x48.png new file mode 100644 index 0000000..574f207 Binary files /dev/null and b/docs/public/img/android-chrome-48x48.png differ diff --git a/docs/public/img/android-chrome-512x512.png b/docs/public/img/android-chrome-512x512.png new file mode 100644 index 0000000..d906d9f Binary files /dev/null and b/docs/public/img/android-chrome-512x512.png differ diff --git a/docs/public/img/android-chrome-72x72.png b/docs/public/img/android-chrome-72x72.png new file mode 100644 index 0000000..8fd8da4 Binary files /dev/null and b/docs/public/img/android-chrome-72x72.png differ diff --git a/docs/public/img/android-chrome-96x96.png b/docs/public/img/android-chrome-96x96.png new file mode 100644 index 0000000..83c62fa Binary files /dev/null and b/docs/public/img/android-chrome-96x96.png differ diff --git a/docs/public/img/apple-touch-icon-1024x1024.png b/docs/public/img/apple-touch-icon-1024x1024.png new file mode 100644 index 0000000..1cc2ce0 Binary files /dev/null and b/docs/public/img/apple-touch-icon-1024x1024.png differ diff --git a/docs/public/img/apple-touch-icon-114x114.png b/docs/public/img/apple-touch-icon-114x114.png new file mode 100644 index 0000000..3ba48ae Binary files /dev/null and b/docs/public/img/apple-touch-icon-114x114.png differ diff --git a/docs/public/img/apple-touch-icon-120x120.png b/docs/public/img/apple-touch-icon-120x120.png new file mode 100644 index 0000000..c326a20 Binary files /dev/null and b/docs/public/img/apple-touch-icon-120x120.png differ diff --git a/docs/public/img/apple-touch-icon-144x144.png b/docs/public/img/apple-touch-icon-144x144.png new file mode 100644 index 0000000..ca328b7 Binary files /dev/null and b/docs/public/img/apple-touch-icon-144x144.png differ diff --git a/docs/public/img/apple-touch-icon-152x152.png b/docs/public/img/apple-touch-icon-152x152.png new file mode 100644 index 0000000..8421ceb Binary files /dev/null and b/docs/public/img/apple-touch-icon-152x152.png differ diff --git a/docs/public/img/apple-touch-icon-167x167.png b/docs/public/img/apple-touch-icon-167x167.png new file mode 100644 index 0000000..2e8b2c0 Binary files /dev/null and b/docs/public/img/apple-touch-icon-167x167.png differ diff --git a/docs/public/img/apple-touch-icon-180x180.png b/docs/public/img/apple-touch-icon-180x180.png new file mode 100644 index 0000000..7ce8953 Binary files /dev/null and b/docs/public/img/apple-touch-icon-180x180.png differ diff --git a/docs/public/img/apple-touch-icon-57x57.png b/docs/public/img/apple-touch-icon-57x57.png new file mode 100644 index 0000000..58910f5 Binary files /dev/null and b/docs/public/img/apple-touch-icon-57x57.png differ diff --git a/docs/public/img/apple-touch-icon-60x60.png b/docs/public/img/apple-touch-icon-60x60.png new file mode 100644 index 0000000..75cbf7c Binary files /dev/null and b/docs/public/img/apple-touch-icon-60x60.png differ diff --git a/docs/public/img/apple-touch-icon-72x72.png b/docs/public/img/apple-touch-icon-72x72.png new file mode 100644 index 0000000..e310a90 Binary files /dev/null and b/docs/public/img/apple-touch-icon-72x72.png differ diff --git a/docs/public/img/apple-touch-icon-76x76.png b/docs/public/img/apple-touch-icon-76x76.png new file mode 100644 index 0000000..41ac8e8 Binary files /dev/null and b/docs/public/img/apple-touch-icon-76x76.png differ diff --git a/docs/public/img/apple-touch-icon-precomposed.png b/docs/public/img/apple-touch-icon-precomposed.png new file mode 100644 index 0000000..7ce8953 Binary files /dev/null and b/docs/public/img/apple-touch-icon-precomposed.png differ diff --git a/docs/public/img/apple-touch-icon.png b/docs/public/img/apple-touch-icon.png new file mode 100644 index 0000000..7ce8953 Binary files /dev/null and b/docs/public/img/apple-touch-icon.png differ diff --git a/docs/public/img/apple-touch-startup-image-1125x2436.png b/docs/public/img/apple-touch-startup-image-1125x2436.png new file mode 100644 index 0000000..648279b Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-1125x2436.png differ diff --git a/docs/public/img/apple-touch-startup-image-1136x640.png b/docs/public/img/apple-touch-startup-image-1136x640.png new file mode 100644 index 0000000..fae39ea Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-1136x640.png differ diff --git a/docs/public/img/apple-touch-startup-image-1242x2208.png b/docs/public/img/apple-touch-startup-image-1242x2208.png new file mode 100644 index 0000000..ecd5291 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-1242x2208.png differ diff --git a/docs/public/img/apple-touch-startup-image-1242x2688.png b/docs/public/img/apple-touch-startup-image-1242x2688.png new file mode 100644 index 0000000..8fa11c1 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-1242x2688.png differ diff --git a/docs/public/img/apple-touch-startup-image-1334x750.png b/docs/public/img/apple-touch-startup-image-1334x750.png new file mode 100644 index 0000000..08c1a7e Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-1334x750.png differ diff --git a/docs/public/img/apple-touch-startup-image-1536x2048.png b/docs/public/img/apple-touch-startup-image-1536x2048.png new file mode 100644 index 0000000..423d4cc Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-1536x2048.png differ diff --git a/docs/public/img/apple-touch-startup-image-1620x2160.png b/docs/public/img/apple-touch-startup-image-1620x2160.png new file mode 100644 index 0000000..4cf396c Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-1620x2160.png differ diff --git a/docs/public/img/apple-touch-startup-image-1668x2224.png b/docs/public/img/apple-touch-startup-image-1668x2224.png new file mode 100644 index 0000000..2add1c5 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-1668x2224.png differ diff --git a/docs/public/img/apple-touch-startup-image-1668x2388.png b/docs/public/img/apple-touch-startup-image-1668x2388.png new file mode 100644 index 0000000..5ffbf5a Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-1668x2388.png differ diff --git a/docs/public/img/apple-touch-startup-image-1792x828.png b/docs/public/img/apple-touch-startup-image-1792x828.png new file mode 100644 index 0000000..3e359af Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-1792x828.png differ diff --git a/docs/public/img/apple-touch-startup-image-2048x1536.png b/docs/public/img/apple-touch-startup-image-2048x1536.png new file mode 100644 index 0000000..1e11671 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-2048x1536.png differ diff --git a/docs/public/img/apple-touch-startup-image-2048x2732.png b/docs/public/img/apple-touch-startup-image-2048x2732.png new file mode 100644 index 0000000..7218956 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-2048x2732.png differ diff --git a/docs/public/img/apple-touch-startup-image-2160x1620.png b/docs/public/img/apple-touch-startup-image-2160x1620.png new file mode 100644 index 0000000..b8167a1 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-2160x1620.png differ diff --git a/docs/public/img/apple-touch-startup-image-2208x1242.png b/docs/public/img/apple-touch-startup-image-2208x1242.png new file mode 100644 index 0000000..73b0ec4 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-2208x1242.png differ diff --git a/docs/public/img/apple-touch-startup-image-2224x1668.png b/docs/public/img/apple-touch-startup-image-2224x1668.png new file mode 100644 index 0000000..42adbbf Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-2224x1668.png differ diff --git a/docs/public/img/apple-touch-startup-image-2388x1668.png b/docs/public/img/apple-touch-startup-image-2388x1668.png new file mode 100644 index 0000000..1272138 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-2388x1668.png differ diff --git a/docs/public/img/apple-touch-startup-image-2436x1125.png b/docs/public/img/apple-touch-startup-image-2436x1125.png new file mode 100644 index 0000000..b431124 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-2436x1125.png differ diff --git a/docs/public/img/apple-touch-startup-image-2688x1242.png b/docs/public/img/apple-touch-startup-image-2688x1242.png new file mode 100644 index 0000000..2e45338 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-2688x1242.png differ diff --git a/docs/public/img/apple-touch-startup-image-2732x2048.png b/docs/public/img/apple-touch-startup-image-2732x2048.png new file mode 100644 index 0000000..9a44a06 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-2732x2048.png differ diff --git a/docs/public/img/apple-touch-startup-image-640x1136.png b/docs/public/img/apple-touch-startup-image-640x1136.png new file mode 100644 index 0000000..26cfb6f Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-640x1136.png differ diff --git a/docs/public/img/apple-touch-startup-image-750x1334.png b/docs/public/img/apple-touch-startup-image-750x1334.png new file mode 100644 index 0000000..50fb34b Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-750x1334.png differ diff --git a/docs/public/img/apple-touch-startup-image-828x1792.png b/docs/public/img/apple-touch-startup-image-828x1792.png new file mode 100644 index 0000000..75d8699 Binary files /dev/null and b/docs/public/img/apple-touch-startup-image-828x1792.png differ diff --git a/docs/public/img/browserconfig.xml b/docs/public/img/browserconfig.xml new file mode 100644 index 0000000..6a446a9 --- /dev/null +++ b/docs/public/img/browserconfig.xml @@ -0,0 +1,15 @@ + + + + + + + + + #f5f6f7 + + + + + + diff --git a/docs/public/img/coast-228x228.png b/docs/public/img/coast-228x228.png new file mode 100644 index 0000000..216825f Binary files /dev/null and b/docs/public/img/coast-228x228.png differ diff --git a/docs/public/img/favicon-16x16.png b/docs/public/img/favicon-16x16.png new file mode 100644 index 0000000..f742b4a Binary files /dev/null and b/docs/public/img/favicon-16x16.png differ diff --git a/docs/public/img/favicon-32x32.png b/docs/public/img/favicon-32x32.png new file mode 100644 index 0000000..a1c67cc Binary files /dev/null and b/docs/public/img/favicon-32x32.png differ diff --git a/docs/public/img/favicon-48x48.png b/docs/public/img/favicon-48x48.png new file mode 100644 index 0000000..574f207 Binary files /dev/null and b/docs/public/img/favicon-48x48.png differ diff --git a/docs/public/img/favicon.ico b/docs/public/img/favicon.ico new file mode 100644 index 0000000..ada0c90 Binary files /dev/null and b/docs/public/img/favicon.ico differ diff --git a/docs/public/img/firefox_app_128x128.png b/docs/public/img/firefox_app_128x128.png new file mode 100644 index 0000000..d155a12 Binary files /dev/null and b/docs/public/img/firefox_app_128x128.png differ diff --git a/docs/public/img/firefox_app_512x512.png b/docs/public/img/firefox_app_512x512.png new file mode 100644 index 0000000..00e4a5c Binary files /dev/null and b/docs/public/img/firefox_app_512x512.png differ diff --git a/docs/public/img/firefox_app_60x60.png b/docs/public/img/firefox_app_60x60.png new file mode 100644 index 0000000..ae76a46 Binary files /dev/null and b/docs/public/img/firefox_app_60x60.png differ diff --git a/docs/public/img/icon.svg b/docs/public/img/icon.svg new file mode 100644 index 0000000..ea9b648 --- /dev/null +++ b/docs/public/img/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/public/img/manifest.json b/docs/public/img/manifest.json new file mode 100644 index 0000000..8b40b48 --- /dev/null +++ b/docs/public/img/manifest.json @@ -0,0 +1,60 @@ +{ + "name": "dev hyperglass", + "short_name": "dev hyperglass", + "description": "Beloved Hyperglass User Network Looking Glass", + "dir": "auto", + "lang": "en-US", + "display": "standalone", + "orientation": "any", + "scope": "/", + "start_url": "/?homescreen=1", + "background_color": "#f5f6f7", + "theme_color": "#118ab2", + "icons": [ + { + "src": "/images/favicons/android-chrome-36x36.png", + "sizes": "36x36", + "type": "image/png" + }, + { + "src": "/images/favicons/android-chrome-48x48.png", + "sizes": "48x48", + "type": "image/png" + }, + { + "src": "/images/favicons/android-chrome-72x72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "/images/favicons/android-chrome-96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "/images/favicons/android-chrome-144x144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "/images/favicons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/images/favicons/android-chrome-256x256.png", + "sizes": "256x256", + "type": "image/png" + }, + { + "src": "/images/favicons/android-chrome-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "/images/favicons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} \ No newline at end of file diff --git a/docs/public/img/manifest.webapp b/docs/public/img/manifest.webapp new file mode 100644 index 0000000..7e94553 --- /dev/null +++ b/docs/public/img/manifest.webapp @@ -0,0 +1,14 @@ +{ + "version": "1.0", + "name": "dev hyperglass", + "description": "Beloved Hyperglass User Network Looking Glass", + "icons": { + "60": "/images/favicons/firefox_app_60x60.png", + "128": "/images/favicons/firefox_app_128x128.png", + "512": "/images/favicons/firefox_app_512x512.png" + }, + "developer": { + "name": null, + "url": null + } +} \ No newline at end of file diff --git a/docs/public/img/mstile-144x144.png b/docs/public/img/mstile-144x144.png new file mode 100644 index 0000000..1b6adf5 Binary files /dev/null and b/docs/public/img/mstile-144x144.png differ diff --git a/docs/public/img/mstile-150x150.png b/docs/public/img/mstile-150x150.png new file mode 100644 index 0000000..0a11b4f Binary files /dev/null and b/docs/public/img/mstile-150x150.png differ diff --git a/docs/public/img/mstile-310x150.png b/docs/public/img/mstile-310x150.png new file mode 100644 index 0000000..13a0c9e Binary files /dev/null and b/docs/public/img/mstile-310x150.png differ diff --git a/docs/public/img/mstile-310x310.png b/docs/public/img/mstile-310x310.png new file mode 100644 index 0000000..ce0fbfe Binary files /dev/null and b/docs/public/img/mstile-310x310.png differ diff --git a/docs/public/img/mstile-70x70.png b/docs/public/img/mstile-70x70.png new file mode 100644 index 0000000..cd0fa26 Binary files /dev/null and b/docs/public/img/mstile-70x70.png differ diff --git a/docs/public/img/undraw_docusaurus_mountain.svg b/docs/public/img/undraw_docusaurus_mountain.svg new file mode 100755 index 0000000..431cef2 --- /dev/null +++ b/docs/public/img/undraw_docusaurus_mountain.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/img/undraw_docusaurus_react.svg b/docs/public/img/undraw_docusaurus_react.svg new file mode 100755 index 0000000..e417050 --- /dev/null +++ b/docs/public/img/undraw_docusaurus_react.svg @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/img/undraw_docusaurus_tree.svg b/docs/public/img/undraw_docusaurus_tree.svg new file mode 100755 index 0000000..a05cc03 --- /dev/null +++ b/docs/public/img/undraw_docusaurus_tree.svg @@ -0,0 +1 @@ +docu_tree \ No newline at end of file diff --git a/docs/public/img/yandex-browser-50x50.png b/docs/public/img/yandex-browser-50x50.png new file mode 100644 index 0000000..cf98b52 Binary files /dev/null and b/docs/public/img/yandex-browser-50x50.png differ diff --git a/docs/public/img/yandex-browser-manifest.json b/docs/public/img/yandex-browser-manifest.json new file mode 100644 index 0000000..1fbf8df --- /dev/null +++ b/docs/public/img/yandex-browser-manifest.json @@ -0,0 +1,9 @@ +{ + "version": "1.0", + "api_version": 1, + "layout": { + "logo": "/images/favicons/yandex-browser-50x50.png", + "color": "#f5f6f7", + "show_title": true + } +} \ No newline at end of file diff --git a/docs/public/opengraph.jpg b/docs/public/opengraph.jpg new file mode 100644 index 0000000..3d621d9 Binary files /dev/null and b/docs/public/opengraph.jpg differ diff --git a/docs/public/robots.txt b/docs/public/robots.txt new file mode 100644 index 0000000..296c638 --- /dev/null +++ b/docs/public/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Disallow: /*__* +Sitemap: https://hyperglass.dev/sitemap.xml \ No newline at end of file diff --git a/docs/public/screenshots/screenshot-favicons.jpg b/docs/public/screenshots/screenshot-favicons.jpg new file mode 100644 index 0000000..4795652 Binary files /dev/null and b/docs/public/screenshots/screenshot-favicons.jpg differ diff --git a/docs/public/traceroute_nanog.pdf b/docs/public/traceroute_nanog.pdf new file mode 100644 index 0000000..e9ca8b1 Binary files /dev/null and b/docs/public/traceroute_nanog.pdf differ diff --git a/docs/theme.config.tsx b/docs/theme.config.tsx new file mode 100644 index 0000000..97d17d9 --- /dev/null +++ b/docs/theme.config.tsx @@ -0,0 +1,144 @@ +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: ( + + + 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 new file mode 100644 index 0000000..4fe9021 --- /dev/null +++ b/docs/tsconfig.json @@ -0,0 +1,26 @@ +{ + "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": { + "~/*": ["./*"] + } + }, + + "exclude": ["node_modules", ".next"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.mjs"] +} diff --git a/hooks.sh b/hooks.sh new file mode 100755 index 0000000..34d35d9 --- /dev/null +++ b/hooks.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +function isort_all () { + isort -y hyperglass/*.py + if [[ ! $? == 0 ]]; then + exit 1 + fi + isort -y hyperglass/**/*.py + if [[ ! $? == 0 ]]; then + exit 1 + fi +} + +function validate_examples () { + python3 ./validate_examples.py + if [[ ! $? == 0 ]]; then + exit 1 + fi +} + +# isort_all +validate_examples + +exit 0 \ No newline at end of file diff --git a/hyperglass/.gitignore b/hyperglass/.gitignore new file mode 100644 index 0000000..9aa4ea5 --- /dev/null +++ b/hyperglass/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.sass-cache/ +.flask_cache +.flask_cache/* +gunicorn_config.py +gunicorn_dev_config.py +test.py +__pycache__/ +*_old +certs/ diff --git a/hyperglass/__init__.py b/hyperglass/__init__.py new file mode 100644 index 0000000..94e2018 --- /dev/null +++ b/hyperglass/__init__.py @@ -0,0 +1,48 @@ +"""hyperglass is a modern, customizable network looking glass. + +https://github.com/thatmattlove/hyperglass + +The Clear BSD License + +Copyright (c) 2023 Matthew Love +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted (subject to the limitations in the disclaimer +below) provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY +THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR +BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +""" + +# Third Party +import uvloop + +# Project +from hyperglass.constants import METADATA + +# Use Uvloop for performance. +uvloop.install() + +__name__, __version__, __author__, __copyright__, __license__ = METADATA diff --git a/hyperglass/api/__init__.py b/hyperglass/api/__init__.py new file mode 100644 index 0000000..5684629 --- /dev/null +++ b/hyperglass/api/__init__.py @@ -0,0 +1,71 @@ +"""hyperglass API.""" +# Standard Library +import logging + +# Third Party +from litestar import Litestar +from litestar.openapi import OpenAPIConfig +from litestar.exceptions import HTTPException, ValidationException +from litestar.static_files import create_static_files_router + +# Project +from hyperglass.state import use_state +from hyperglass.constants import __version__ +from hyperglass.exceptions import HyperglassError + +# Local +from .events import check_redis +from .routes import info, query, device, devices, queries +from .middleware import COMPRESSION_CONFIG, create_cors_config +from .error_handlers import app_handler, http_handler, default_handler, validation_handler + +__all__ = ("app",) + +STATE = use_state() + +UI_DIR = STATE.settings.static_path / "ui" +IMAGES_DIR = STATE.settings.static_path / "images" + + +OPEN_API = OpenAPIConfig( + title=STATE.params.docs.title.format(site_title=STATE.params.site_title), + version=__version__, + description=STATE.params.docs.description, + path=STATE.params.docs.path, + root_schema_site="elements", +) + +HANDLERS = [ + device, + devices, + queries, + info, + query, +] + +if not STATE.settings.disable_ui: + HANDLERS = [ + *HANDLERS, + create_static_files_router( + path="/images", directories=[IMAGES_DIR], name="images", include_in_schema=False + ), + create_static_files_router( + path="/", directories=[UI_DIR], name="ui", html_mode=True, include_in_schema=False + ), + ] + + +app = Litestar( + route_handlers=HANDLERS, + exception_handlers={ + HTTPException: http_handler, + HyperglassError: app_handler, + ValidationException: validation_handler, + Exception: default_handler, + }, + on_startup=[check_redis], + debug=STATE.settings.debug, + cors_config=create_cors_config(state=STATE), + compression_config=COMPRESSION_CONFIG, + openapi_config=OPEN_API if STATE.params.docs.enable else None, +) diff --git a/hyperglass/api/error_handlers.py b/hyperglass/api/error_handlers.py new file mode 100644 index 0000000..e56f884 --- /dev/null +++ b/hyperglass/api/error_handlers.py @@ -0,0 +1,78 @@ +"""API Error Handlers.""" + +# Standard Library +import typing as t + +# Third Party +from litestar import Request, Response +from litestar.exceptions import ValidationException + +# Project +from hyperglass.log import log +from hyperglass.state import use_state + +__all__ = ( + "default_handler", + "http_handler", + "app_handler", + "validation_handler", +) + + +def get_validation_exception_detail(exc: ValidationException) -> Response: + data: dict[str, t.Any] = { + "level": "error", + "status_code": 422, + "keywords": [], + "output": repr(exc), + } + if isinstance(exc.extra, dict): + outputs = [] + kw = [] + for k, v in exc.extra.values(): + outputs = [*outputs, f"{k}: {v!r}"] + kw = [*kw, k] + data["output"] = "\n".join(outputs) + data["keywords"] = kw + + if isinstance(exc.extra, list): + data["output"] = "\n".join(str(v) for v in exc.extra) + data["keywords"] = [] + + return Response(data) + + +def default_handler(request: Request, exc: BaseException) -> Response: + """Handle uncaught errors.""" + state = use_state() + log.bind(method=request.method, path=request.url.path, detail=str(exc)).critical("Error") + return Response( + {"output": state.params.messages.general, "level": "danger", "keywords": []}, + status_code=500, + ) + + +def http_handler(request: Request, exc: BaseException) -> Response: + """Handle web server errors.""" + log.bind(method=request.method, path=request.url.path, detail=exc.detail).critical("HTTP Error") + return Response( + {"output": exc.detail, "level": "danger", "keywords": []}, + status_code=exc.status_code, + ) + + +def app_handler(request: Request, exc: BaseException) -> Response: + """Handle application errors.""" + log.bind(method=request.method, path=request.url.path, detail=exc.message).critical( + "hyperglass Error" + ) + return Response( + {"output": exc.message, "level": exc.level, "keywords": exc.keywords}, + status_code=exc.status_code, + ) + + +def validation_handler(request: Request, exc: ValidationException) -> Response: + """Handle Pydantic validation errors raised by FastAPI.""" + log.bind(method=request.method, path=request.url.path, detail=exc).critical("Validation Error") + return get_validation_exception_detail(exc) diff --git a/hyperglass/api/events.py b/hyperglass/api/events.py new file mode 100644 index 0000000..3d20c4a --- /dev/null +++ b/hyperglass/api/events.py @@ -0,0 +1,18 @@ +"""API Events.""" + +# Standard Library +import typing as t + +# Third Party +from litestar import Litestar + +# Project +from hyperglass.state import use_state + +__all__ = ("check_redis",) + + +async def check_redis(_: Litestar) -> t.NoReturn: + """Ensure Redis is running before starting server.""" + cache = use_state("cache") + cache.check() diff --git a/hyperglass/api/fake_output.py b/hyperglass/api/fake_output.py new file mode 100644 index 0000000..8405a82 --- /dev/null +++ b/hyperglass/api/fake_output.py @@ -0,0 +1,199 @@ +"""Return fake, static data for development purposes.""" + +# Standard Library +import typing as t + +# Project +from hyperglass.models.data import BGPRouteTable + +BGP_PLAIN = r"""BGP routing table entry for 4.0.0.0/9, version 1017877672 +BGP Bestpath: deterministic-med +Paths: (10 available, best #9, table default) + Advertised to update-groups: + 50 + 1299 3356, (aggregated by 3356 4.69.130.24) + 216.250.230.1 (metric 2000) from 216.250.230.1 (216.250.230.1) + Origin IGP, metric 0, localpref 100, weight 100, valid, internal, atomic-aggregate + Community: 1299:25000 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3003 14525:4002 14525:9003 + 1299 3356, (aggregated by 3356 4.69.130.24), (received-only) + 216.250.230.1 (metric 2000) from 216.250.230.1 (216.250.230.1) + Origin IGP, metric 0, localpref 150, valid, internal, atomic-aggregate + Community: 1299:25000 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3003 14525:4002 14525:9003 + 1299 3356, (aggregated by 3356 4.69.130.184) + 199.34.92.9 (metric 1000) from 199.34.92.9 (199.34.92.9) + Origin IGP, metric 0, localpref 100, weight 100, valid, internal, atomic-aggregate + Community: 1299:25000 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3001 14525:4001 14525:9003 + 1299 3356, (aggregated by 3356 4.69.130.184), (received-only) + 199.34.92.9 (metric 1000) from 199.34.92.9 (199.34.92.9) + Origin IGP, metric 0, localpref 150, valid, internal, atomic-aggregate + Community: 1299:25000 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3001 14525:4001 14525:9003 + 174 3356, (aggregated by 3356 4.69.130.4) + 199.34.92.10 (metric 1000) from 199.34.92.10 (199.34.92.10) + Origin IGP, metric 0, localpref 100, weight 100, valid, internal, atomic-aggregate + Community: 174:21000 174:22013 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3001 14525:4001 14525:9001 + 174 3356, (aggregated by 3356 4.69.130.4), (received-only) + 199.34.92.10 (metric 1000) from 199.34.92.10 (199.34.92.10) + Origin IGP, metric 0, localpref 150, valid, internal, atomic-aggregate + Community: 174:21000 174:22013 14525:0 14525:40 14525:601 14525:1021 14525:2840 14525:3001 14525:4001 14525:9001 + 209 3356, (aggregated by 3356 4.69.130.2) + 199.34.92.5 (metric 101) from 199.34.92.5 (199.34.92.5) + Origin IGP, metric 8006570, localpref 150, weight 200, valid, internal, atomic-aggregate + Community: 209:88 209:888 3356:0 3356:3 3356:100 3356:123 3356:575 3356:2011 14525:0 14525:40 14525:1021 14525:2840 14525:3002 14525:4003 14525:9005 + 209 3356, (aggregated by 3356 4.69.130.2), (received-only) + 199.34.92.5 (metric 101) from 199.34.92.5 (199.34.92.5) + Origin IGP, metric 8006570, localpref 150, valid, internal, atomic-aggregate + Community: 209:88 209:888 3356:0 3356:3 3356:100 3356:123 3356:575 3356:2011 14525:0 14525:40 14525:1021 14525:2840 14525:3002 14525:4003 14525:9005 + 6939 3356, (aggregated by 3356 4.69.130.4) + 184.105.247.177 from 184.105.247.177 (216.218.252.234) + Origin IGP, localpref 150, weight 200, valid, external, atomic-aggregate, best + Community: 6939:7016 6939:8840 6939:9001 14525:0 14525:40 14525:1021 14525:2840 14525:3002 14525:4003 14525:9002 + 6939 3356, (aggregated by 3356 4.69.130.4), (received-only) + 184.105.247.177 from 184.105.247.177 (216.218.252.234) + Origin IGP, localpref 100, valid, external, atomic-aggregate + Community: 6939:7016 6939:8840 6939:9001 +""" # noqa: W291,E501 + +BGP_ROUTES = [ + { + "prefix": "1.1.1.0/24", + "active": True, + "age": 1025337, + "weight": 170, + "med": 0, + "local_preference": 175, + "as_path": [1299, 13335], + "communities": [ + "1299:35000", + "14525:0", + "14525:41", + "14525:600", + "14525:1021", + "14525:2840", + "14525:3001", + "14525:4001", + "14525:9003", + ], + "next_hop": "62.115.189.136", + "source_as": 13335, + "source_rid": "141.101.72.1", + "peer_rid": "2.255.254.43", + "rpki_state": 1, + }, + { + "prefix": "1.1.1.0/24", + "active": False, + "age": 1584622, + "weight": 200, + "med": 0, + "local_preference": 250, + "as_path": [13335], + "communities": [ + "14525:0", + "14525:20", + "14525:600", + "14525:1021", + "14525:2840", + "14525:3002", + "14525:4003", + "14525:9009", + ], + "next_hop": "", + "source_as": 13335, + "source_rid": "172.68.129.1", + "peer_rid": "199.34.92.5", + "rpki_state": 3, + }, + { + "prefix": "1.1.1.0/24", + "active": False, + "age": 982517, + "weight": 200, + "med": 0, + "local_preference": 250, + "as_path": [13335], + "communities": [ + "14525:0", + "14525:20", + "14525:600", + "14525:1021", + "14525:2840", + "14525:3002", + "14525:4003", + "14525:9009", + ], + "next_hop": "", + "source_as": 13335, + "source_rid": "172.68.129.1", + "peer_rid": "199.34.92.6", + "rpki_state": 3, + }, + { + "prefix": "1.1.1.0/24", + "active": False, + "age": 1000101, + "weight": 200, + "med": 0, + "local_preference": 250, + "as_path": [13335], + "communities": [ + "13335:10014", + "13335:19000", + "13335:20050", + "13335:20500", + "13335:20530", + "14525:0", + "14525:20", + "14525:600", + "14525:1021", + "14525:2840", + "14525:3003", + "14525:4002", + "14525:9009", + ], + "next_hop": "", + "source_as": 13335, + "source_rid": "141.101.73.1", + "peer_rid": "216.250.230.2", + "rpki_state": 3, + }, +] + +PING = r"""PING 1.1.1.1 (1.1.1.1): 56 data bytes +64 bytes from 1.1.1.1: icmp_seq=0 ttl=59 time=4.696 ms +64 bytes from 1.1.1.1: icmp_seq=1 ttl=59 time=4.699 ms +64 bytes from 1.1.1.1: icmp_seq=2 ttl=59 time=4.640 ms +64 bytes from 1.1.1.1: icmp_seq=3 ttl=59 time=4.583 ms +64 bytes from 1.1.1.1: icmp_seq=4 ttl=59 time=4.640 ms + +--- 1.1.1.1 ping statistics --- +5 packets transmitted, 5 packets received, 0% packet loss +round-trip min/avg/max/stddev = 4.583/4.652/4.699/0.043 ms +""" + +TRACEROUTE = r"""traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 52 byte packets + 1 157.231.183.50 4.412 ms + 2 129.219.10.4 4.612 ms + 3 128.249.9.12 4.503 ms + 4 139.15.19.3 7.458 ms + 5 172.69.68.3 4.814 ms + 6 1.1.1.1 4.564 ms +""" + + +async def fake_output(query_type: str, structured: bool) -> t.Union[str, BGPRouteTable]: + """Bypass the standard execution process and return static, fake output.""" + + if "ping" in query_type: + return PING + if "traceroute" in query_type: + return TRACEROUTE + if "bgp" in query_type: + if structured: + return BGPRouteTable( + vrf="default", + count=len(BGP_ROUTES), + routes=BGP_ROUTES, + winning_weight="high", + ) + return BGP_PLAIN + return BGP_PLAIN diff --git a/hyperglass/api/middleware.py b/hyperglass/api/middleware.py new file mode 100644 index 0000000..71a5726 --- /dev/null +++ b/hyperglass/api/middleware.py @@ -0,0 +1,34 @@ +"""hyperglass API middleware.""" + +# Standard Library +import typing as t + +# Third Party +from litestar.config.cors import CORSConfig +from litestar.config.compression import CompressionConfig + +if t.TYPE_CHECKING: + # Project + from hyperglass.state import HyperglassState + +__all__ = ("create_cors_config", "COMPRESSION_CONFIG") + +COMPRESSION_CONFIG = CompressionConfig(backend="brotli", brotli_gzip_fallback=True) + +REQUEST_LOG_MESSAGE = "REQ" +RESPONSE_LOG_MESSAGE = "RES" +REQUEST_LOG_FIELDS = ("method", "path", "path_params", "query") +RESPONSE_LOG_FIELDS = ("status_code",) + + +def create_cors_config(state: "HyperglassState") -> CORSConfig: + """Create CORS configuration from parameters.""" + origins = state.params.cors_origins.copy() + if state.settings.dev_mode: + origins = [*origins, state.settings.dev_url, "http://localhost:3000"] + + return CORSConfig( + allow_origins=origins, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["*"], + ) diff --git a/hyperglass/api/routes.py b/hyperglass/api/routes.py new file mode 100644 index 0000000..9e17c9a --- /dev/null +++ b/hyperglass/api/routes.py @@ -0,0 +1,169 @@ +"""API Routes.""" + +# Standard Library +import json +import time +import typing as t +from datetime import UTC, datetime + +# Third Party +from litestar import Request, Response, get, post +from litestar.di import Provide +from litestar.background_tasks import BackgroundTask + +# Project +from hyperglass.log import log +from hyperglass.state import HyperglassState +from hyperglass.exceptions import HyperglassError +from hyperglass.models.api import Query +from hyperglass.models.data import OutputDataModel +from hyperglass.util.typing import is_type +from hyperglass.execution.main import execute +from hyperglass.models.api.response import QueryResponse +from hyperglass.models.config.params import Params, APIParams +from hyperglass.models.config.devices import Devices, APIDevice + +# Local +from .state import get_state, get_params, get_devices +from .tasks import send_webhook +from .fake_output import fake_output + +__all__ = ( + "device", + "devices", + "queries", + "info", + "query", +) + + +@get("/api/devices/{id:str}", dependencies={"devices": Provide(get_devices)}) +async def device(devices: Devices, id: str) -> APIDevice: + """Retrieve a device by ID.""" + return devices[id].export_api() + + +@get("/api/devices", dependencies={"devices": Provide(get_devices)}) +async def devices(devices: Devices) -> t.List[APIDevice]: + """Retrieve all devices.""" + return devices.export_api() + + +@get("/api/queries", dependencies={"devices": Provide(get_devices)}) +async def queries(devices: Devices) -> t.List[str]: + """Retrieve all directive names.""" + return devices.directive_names() + + +@get("/api/info", dependencies={"params": Provide(get_params)}) +async def info(params: Params) -> APIParams: + """Retrieve looking glass parameters.""" + return params.export_api() + + +@post("/api/query", dependencies={"_state": Provide(get_state)}) +async def query(_state: HyperglassState, request: Request, data: Query) -> QueryResponse: + """Ingest request data pass it to the backend application to perform the query.""" + + timestamp = datetime.now(UTC) + + # Initialize cache + cache = _state.redis + + # Use hashed `data` string as key for for k/v cache store so + # each command output value is unique. + cache_key = f"hyperglass.query.{data.digest()}" + + _log = log.bind(query=data.summary()) + + _log.info("Starting query execution") + + cache_response = cache.get_map(cache_key, "output") + json_output = False + cached = False + runtime = 65535 + + if cache_response: + _log.bind(cache_key=cache_key).debug("Cache hit") + + # If a cached response exists, reset the expiration time. + cache.expire(cache_key, expire_in=_state.params.cache.timeout) + + cached = True + runtime = 0 + timestamp = cache.get_map(cache_key, "timestamp") + + elif not cache_response: + _log.bind(cache_key=cache_key).debug("Cache miss") + + timestamp = data.timestamp + + starttime = time.time() + + if _state.params.fake_output: + # Return fake, static data for development purposes, if enabled. + output = await fake_output( + query_type=data.query_type, + structured=data.device.structured_output or False, + ) + else: + # Pass request to execution module + output = await execute(data) + + endtime = time.time() + elapsedtime = round(endtime - starttime, 4) + _log.debug("Runtime: {!s} seconds", elapsedtime) + + if output is None: + raise HyperglassError(message=_state.params.messages.general, alert="danger") + + json_output = is_type(output, OutputDataModel) + + if json_output: + # Export structured output as JSON string to guarantee value + # is serializable, then convert it back to a dict. + as_json = output.export_json() + raw_output = json.loads(as_json) + 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) + + _log.bind(cache_timeout=_state.params.cache.timeout).debug("Response cached") + + runtime = int(round(elapsedtime, 0)) + + # If it does, return the cached entry + cache_response = cache.get_map(cache_key, "output") + + json_output = is_type(cache_response, t.Dict) + response_format = "text/plain" + + if json_output: + response_format = "application/json" + _log.info("Execution completed") + + response = { + "output": cache_response, + "id": cache_key, + "cached": cached, + "runtime": runtime, + "timestamp": timestamp, + "format": response_format, + "random": data.random(), + "level": "success", + "keywords": [], + } + + return Response( + response, + background=BackgroundTask( + send_webhook, + params=_state.params, + data=data, + request=request, + timestamp=timestamp, + ), + ) diff --git a/hyperglass/api/state.py b/hyperglass/api/state.py new file mode 100644 index 0000000..f41a96d --- /dev/null +++ b/hyperglass/api/state.py @@ -0,0 +1,27 @@ +"""hyperglass state dependencies.""" + +# Standard Library +import typing as t + +# Project +from hyperglass.state import use_state + + +async def get_state(attr: t.Optional[str] = None): + """Get hyperglass state as a FastAPI dependency.""" + return use_state(attr) + + +async def get_params(): + """Get hyperglass params as FastAPI dependency.""" + return use_state("params") + + +async def get_devices(): + """Get hyperglass devices as FastAPI dependency.""" + return use_state("devices") + + +async def get_ui_params(): + """Get hyperglass ui_params as FastAPI dependency.""" + return use_state("ui_params") diff --git a/hyperglass/api/tasks.py b/hyperglass/api/tasks.py new file mode 100644 index 0000000..0a7d16d --- /dev/null +++ b/hyperglass/api/tasks.py @@ -0,0 +1,70 @@ +"""Tasks to be executed from web API.""" + +# Standard Library +import typing as t +from datetime import datetime + +# Third Party +from httpx import Headers +from litestar import Request + +# Project +from hyperglass.log import log +from hyperglass.external import Webhook, bgptools +from hyperglass.models.api import Query + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.config.params import Params + +__all__ = ("send_webhook",) + + +async def process_headers(headers: Headers) -> t.Dict[str, t.Any]: + """Filter out unwanted headers and return as a dictionary.""" + headers = dict(headers) + header_keys = ( + "user-agent", + "referer", + "accept-encoding", + "accept-language", + "x-real-ip", + "x-forwarded-for", + ) + return {k: headers.get(k) for k in header_keys} + + +async def send_webhook( + params: "Params", + data: Query, + request: Request, + timestamp: datetime, +) -> t.NoReturn: + """If webhooks are enabled, get request info and send a webhook.""" + try: + if params.logging.http is not None: + headers = await process_headers(headers=request.headers) + + if headers.get("x-real-ip") is not None: + host = headers["x-real-ip"] + elif headers.get("x-forwarded-for") is not None: + host = headers["x-forwarded-for"] + else: + host = request.client.host + + network_info = await bgptools.network_info(host) + + async with Webhook(params.logging.http) as hook: + await hook.send( + query={ + **data.dict(), + "headers": headers, + "source": host, + "network": network_info.get(host, {}), + "timestamp": timestamp, + } + ) + except Exception as err: + log.bind(destination=params.logging.http.provider, error=str(err)).error( + "Failed to send webhook" + ) diff --git a/hyperglass/cli/__init__.py b/hyperglass/cli/__init__.py new file mode 100644 index 0000000..be23627 --- /dev/null +++ b/hyperglass/cli/__init__.py @@ -0,0 +1,6 @@ +"""hyperglass cli module.""" + +# Local +from .main import cli, run + +__all__ = ("cli", "run") diff --git a/hyperglass/cli/echo.py b/hyperglass/cli/echo.py new file mode 100644 index 0000000..642c1ce --- /dev/null +++ b/hyperglass/cli/echo.py @@ -0,0 +1,42 @@ +"""Helper functions for CLI message printing.""" +# Standard Library +import typing as t + +# Project +from hyperglass.log import HyperglassConsole + + +class Echo: + """Container for console-printing functions.""" + + _console = HyperglassConsole + + def _fmt(self, message: t.Any, *args: t.Any, **kwargs: t.Any) -> t.Any: + if isinstance(message, str): + args = (f"[bold]{arg}[/bold]" for arg in args) + kwargs = {k: f"[bold]{v}[/bold]" for k, v in kwargs.items()} + return message.format(*args, **kwargs) + return message + + def error(self, message: str, *args, **kwargs): + """Print an error message.""" + return self._console.print(self._fmt(message, *args, **kwargs), style="error") + + def info(self, message: str, *args, **kwargs): + """Print an informational message.""" + return self._console.print(self._fmt(message, *args, **kwargs), style="info") + + def warning(self, message: str, *args, **kwargs): + """Print a warning message.""" + return self._console.print(self._fmt(message, *args, **kwargs), style="info") + + def success(self, message: str, *args, **kwargs): + """Print a success message.""" + return self._console.print(self._fmt(message, *args, **kwargs), style="success") + + def plain(self, message: str, *args, **kwargs): + """Print an unformatted message.""" + return self._console.print(self._fmt(message, *args, **kwargs)) + + +echo = Echo() diff --git a/hyperglass/cli/installer.py b/hyperglass/cli/installer.py new file mode 100644 index 0000000..eeb760d --- /dev/null +++ b/hyperglass/cli/installer.py @@ -0,0 +1,187 @@ +"""Install hyperglass.""" + +# Standard Library +import os +import time +import shutil +import typing as t +import getpass +from types import TracebackType +from filecmp import dircmp +from pathlib import Path + +# Third Party +import typer +from rich.progress import Progress + +# Project +from hyperglass.util import compare_lists +from hyperglass.settings import Settings +from hyperglass.constants import __version__ + +# Local +from .echo import echo + +ASSET_DIR = Path(__file__).parent.parent / "images" +IGNORED_FILES = [".DS_Store"] + + +class Installer: + """Install hyperglass.""" + + app_path: Path + progress: Progress + user: str + assets: int + + def __init__(self): + """Start hyperglass installer.""" + self.app_path = Settings.app_path + self.progress: Progress = Progress(console=echo._console) + self.user = getpass.getuser() + self.assets = len([p for p in ASSET_DIR.iterdir() if p.name not in IGNORED_FILES]) + + def install(self) -> None: + """Initialize tasks and start installer.""" + permissions_task = self.progress.add_task("[bright purple]Checking System", total=2) + scaffold_task = self.progress.add_task( + "[bright blue]Creating Directory Structures", total=3 + ) + asset_task = self.progress.add_task( + "[bright cyan]Migrating Static Assets", total=self.assets + ) + ui_task = self.progress.add_task("[bright teal]Initialzing UI", total=1, start=False) + + self.progress.start() + + self.check_permissions(task_id=permissions_task) + self.scaffold(task_id=scaffold_task) + self.migrate_static_assets(task_id=asset_task) + self.init_ui(task_id=ui_task) + + def __enter__(self) -> t.Callable[[], None]: + """Initialize tasks.""" + self.progress.print(f"Starting hyperglass {__version__} setup") + return self.install + + def __exit__( + self, + exc_type: t.Optional[t.Type[BaseException]] = None, + exc_value: t.Optional[BaseException] = None, + exc_traceback: t.Optional[TracebackType] = None, + ): + """Print errors on exit.""" + self.progress.stop() + if exc_type is not None: + echo._console.print_exception(show_locals=True) + raise typer.Exit(1) + raise typer.Exit(0) + + def check_permissions(self, task_id: int) -> None: + """Ensure the executing user has permissions to the app path.""" + read = os.access(self.app_path, os.R_OK) + if not read: + self.progress.print( + f"User {self.user!r} does not have read access to {self.app_path!s}", style="error" + ) + raise typer.Exit(1) + + self.progress.advance(task_id) + time.sleep(0.4) + + write = os.access(self.app_path, os.W_OK) + if not write: + self.progress.print( + f"User {self.user!r} does not have write access to {self.app_path!s}", style="error" + ) + raise typer.Exit(1) + self.progress.advance(task_id) + + def scaffold(self, task_id: int) -> None: + """Create the file structure necessary for hyperglass to run.""" + + if not self.app_path.exists(): + self.progress.print("Created {!s}".format(self.app_path), style="info") + self.app_path.mkdir(parents=True) + + self.progress.print(f"hyperglass path is {self.app_path!s}", style="subtle") + self.progress.advance(task_id) + + ui_dir = self.app_path / "static" / "ui" + favicon_dir = self.app_path / "static" / "images" / "favicons" + + for path in (ui_dir, favicon_dir): + if not path.exists(): + self.progress.print("Created {!s}".format(path), style="info") + path.mkdir(parents=True) + + self.progress.advance(task_id) + time.sleep(0.4) + + def migrate_static_assets(self, task_id: int) -> None: + """Synchronize the project assets with the installation assets.""" + + target_dir = self.app_path / "static" / "images" + + def copy_func(src: str, dst: str): + time.sleep(self.assets / 10) + + exists = Path(dst).exists() + if not exists: + copied = shutil.copy2(src, dst) + self.progress.print(f"Copied {copied!s}", style="info") + self.progress.advance(task_id) + return dst + + if not target_dir.exists(): + shutil.copytree( + ASSET_DIR, + target_dir, + ignore=shutil.ignore_patterns(*IGNORED_FILES), + copy_function=copy_func, + ) + + # Compare the contents of the project's asset directory (considered + # the source of truth) with the installation directory. If they do + # not match, delete the installation directory's asset directory and + # re-copy it. + compare_initial = dircmp(ASSET_DIR, target_dir, ignore=IGNORED_FILES) + + if not compare_lists( + compare_initial.left_list, + compare_initial.right_list, + ignore=["hyperglass-opengraph.jpg"], + ): + shutil.rmtree(target_dir) + shutil.copytree( + ASSET_DIR, + target_dir, + copy_function=copy_func, + ignore=shutil.ignore_patterns(*IGNORED_FILES), + ) + + # Re-compare the source and destination directory contents to + # ensure they match. + 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"] + ): + echo.error("Files in {!s} do not match files in {!s}", ASSET_DIR, target_dir) + raise typer.Exit(1) + else: + self.progress.update(task_id, completed=self.assets, refresh=True) + + def init_ui(self, task_id: int) -> None: + """Initialize UI.""" + # Project + from hyperglass.log import log + + # Local + from .util import build_ui + + with self.progress.console.capture(): + log.disable("hyperglass") + build_ui(timeout=180) + log.enable("hyperglass") + self.progress.advance(task_id) diff --git a/hyperglass/cli/main.py b/hyperglass/cli/main.py new file mode 100644 index 0000000..2ee2107 --- /dev/null +++ b/hyperglass/cli/main.py @@ -0,0 +1,349 @@ +"""hyperglass Command Line Interface.""" + +# Standard Library +import re +import sys +import typing as t + +# Third Party +import typer + +# Local +from .echo import echo + + +def _version(value: bool) -> None: + # Project + from hyperglass import __version__ + + if value: + echo.info(__version__) + raise typer.Exit() + + +cli = typer.Typer(name="hyperglass", help="hyperglass Command Line Interface", no_args_is_help=True) + + +def run(): + """Run the hyperglass 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: + """hyperglass""" + pass + + +@cli.command(name="start") +def _start(build: bool = False, workers: t.Optional[int] = None) -> None: + """Start hyperglass""" + # Project + from hyperglass.main import run + + # Local + from .util import build_ui + + kwargs = {} + if workers != 0: + kwargs["workers"] = workers + + try: + if build: + build_complete = build_ui(timeout=180) + if build_complete: + run(workers) + else: + run(workers) + + except (KeyboardInterrupt, SystemExit) as err: + error_message = str(err) + if (len(error_message)) > 1: + echo.warning(str(err)) + echo.error("Stopping hyperglass due to keyboard interrupt.") + raise typer.Exit(0) + + +@cli.command(name="build-ui") +def _build_ui(timeout: int = typer.Option(180, help="Timeout in seconds")) -> None: + """Create a new UI build.""" + # Local + from .util import build_ui as _build_ui + + with echo._console.status( + f"Starting new UI build with a {timeout} second timeout...", spinner="aesthetic" + ): + + _build_ui(timeout=120) + + +@cli.command(name="system-info") +def _system_info(): + """Get system information for a bug report""" + # Third Party + from rich import box + from rich.panel import Panel + from rich.table import Table + + # Project + from hyperglass.util.system_info import get_system_info + + # Local + from .static import MD_BOX + + data = get_system_info() + + rows = tuple( + (f"**{title}**", f"`{value!s}`" if mod == "code" else str(value)) + for title, (value, mod) in data.items() + ) + + table = Table("Metric", "Value", box=MD_BOX) + for title, metric in rows: + table.add_row(title, metric) + echo._console.print( + Panel( + "Please copy & paste this table in your bug report", + style="bold yellow", + expand=False, + border_style="yellow", + box=box.HEAVY, + ) + ) + echo.plain(table) + + +@cli.command(name="clear-cache") +def _clear_cache(): + """Clear the Redis cache""" + # Project + from hyperglass.state import use_state + + state = use_state() + + try: + state.clear() + echo.success("Cleared Redis Cache") + + except Exception as err: + if not sys.stdout.isatty(): + echo._console.print_exception(show_locals=True) + raise typer.Exit(1) + + echo.error("Error clearing cache: {!s}", err) + raise typer.Exit(1) + + +@cli.command(name="devices") +def _devices( + search: t.Optional[str] = typer.Argument(None, help="Device ID or Name Search Pattern") +): + """Show all configured devices""" + # Third Party + from rich.columns import Columns + from rich._inspect import Inspect + + # Project + from hyperglass.state import use_state + + devices = use_state("devices") + if search is not None: + pattern = re.compile(search, re.IGNORECASE) + for device in devices: + if pattern.match(device.id) or pattern.match(device.name): + echo._console.print( + Inspect( + device, + title=device.name, + docs=False, + methods=False, + dunder=False, + sort=True, + all=False, + value=True, + help=False, + ) + ) + raise typer.Exit(0) + + panels = [ + Inspect( + device, + title=device.name, + docs=False, + methods=False, + dunder=False, + sort=True, + all=False, + value=True, + help=False, + ) + for device in devices + ] + echo._console.print(Columns(panels)) + + +@cli.command(name="directives") +def _directives( + search: t.Optional[str] = typer.Argument(None, help="Directive ID or Name Search Pattern") +): + """Show all configured devices""" + # Third Party + from rich.columns import Columns + from rich._inspect import Inspect + + # Project + from hyperglass.state import use_state + + directives = use_state("directives") + if search is not None: + pattern = re.compile(search, re.IGNORECASE) + for directive in directives: + if pattern.match(directive.id) or pattern.match(directive.name): + echo._console.print( + Inspect( + directive, + title=directive.name, + docs=False, + methods=False, + dunder=False, + sort=True, + all=False, + value=True, + help=False, + ) + ) + raise typer.Exit(0) + + panels = [ + Inspect( + directive, + title=directive.name, + docs=False, + methods=False, + dunder=False, + sort=True, + all=False, + value=True, + help=False, + ) + for directive in directives + ] + echo._console.print(Columns(panels)) + + +@cli.command(name="plugins") +def _plugins( + search: t.Optional[str] = typer.Argument(None, help="Plugin ID or Name Search Pattern"), + _input: bool = typer.Option( + False, "--input", show_default=False, is_flag=True, help="Show Input Plugins" + ), + output: bool = typer.Option( + False, "--output", show_default=False, is_flag=True, help="Show Output Plugins" + ), +): + """Show all configured devices""" + # Third Party + from rich.columns import Columns + + # Project + from hyperglass.state import use_state + + to_fetch = ("input", "output") + if _input is True: + to_fetch = ("input",) + + elif output is True: + to_fetch = ("output",) + + state = use_state() + all_plugins = [plugin for _type in to_fetch for plugin in state.plugins(_type)] + + if search is not None: + pattern = re.compile(search, re.IGNORECASE) + matching = [plugin for plugin in all_plugins if pattern.match(plugin.name)] + if len(matching) == 0: + echo.error(f"No plugins matching {search!r}") + raise typer.Exit(1) + + echo._console.print(Columns(matching)) + raise typer.Exit(0) + + echo._console.print(Columns(all_plugins)) + + +@cli.command(name="params") +def _params( + path: t.Optional[str] = typer.Argument( + None, help="Parameter Object Path, for example 'messages.no_input'" + ) +): + """Show configuration parameters""" + # Standard Library + from operator import attrgetter + + # Third Party + from rich._inspect import Inspect + + # Project + from hyperglass.state import use_state + + params = use_state("params") + if path is not None: + try: + value = attrgetter(path)(params) + + echo._console.print( + Inspect( + value, + title=f"params.{path}", + docs=False, + methods=False, + dunder=False, + sort=True, + all=False, + value=True, + help=False, + ) + ) + raise typer.Exit(0) + except AttributeError: + echo.error(f"{'params.'+path!r} does not exist") + raise typer.Exit(1) + + panel = Inspect( + params, + title="hyperglass Configuration Parameters", + docs=False, + methods=False, + dunder=False, + sort=True, + all=False, + value=True, + help=False, + ) + echo._console.print(panel) + + +@cli.command(name="setup") +def _setup(): + """Initialize hyperglass setup.""" + # Local + from .installer import Installer + + with Installer() as start: + start() + + +@cli.command(name="settings") +def _settings(): + """Show hyperglass system settings (environment variables)""" + + # Project + from hyperglass.settings import Settings + + echo.plain(Settings) diff --git a/hyperglass/cli/static.py b/hyperglass/cli/static.py new file mode 100644 index 0000000..d6b9c82 --- /dev/null +++ b/hyperglass/cli/static.py @@ -0,0 +1,47 @@ +"""Static string definitions.""" + +# Third Party +from rich.box import Box + +MD_BOX = Box( + """\ + +| || +|-|| +| || +| | +| | +| || + +""", + ascii=True, +) + + +class Char: + """Helper class for single-character strings.""" + + def __init__(self, char): + """Set instance character.""" + self.char = char + + def __getitem__(self, i): + """Subscription returns the instance's character * n.""" + return self.char * i + + def __str__(self): + """Stringify the instance character.""" + return str(self.char) + + def __repr__(self): + """Stringify the instance character for representation.""" + return str(self.char) + + def __add__(self, other): + """Addition method for string concatenation.""" + return str(self.char) + str(other) + + +WS = Char(" ") +NL = Char("\n") +CL = Char(":") diff --git a/hyperglass/cli/util.py b/hyperglass/cli/util.py new file mode 100644 index 0000000..4dbb6cf --- /dev/null +++ b/hyperglass/cli/util.py @@ -0,0 +1,51 @@ +"""CLI utility functions.""" + +# Standard Library +import sys +import asyncio + +# Third Party +import typer + +# Local +from .echo import echo + + +def build_ui(timeout: int) -> None: + """Create a new UI build.""" + # Project + from hyperglass.state import use_state + from hyperglass.frontend import build_frontend + from hyperglass.configuration import init_user_config + + # Populate configuration to Redis prior to accessing it. + init_user_config() + + state = use_state() + + dev_mode = "production" + if state.settings.dev_mode: + dev_mode = "development" + + try: + build_success = asyncio.run( + build_frontend( + app_path=state.settings.app_path, + dev_mode=state.settings.dev_mode, + dev_url=f"http://localhost:{state.settings.port!s}/", + force=True, + params=state.ui_params, + prod_url="/api/", + timeout=timeout, + ) + ) + if build_success: + echo.success("Completed UI build in {} mode", dev_mode) + + except Exception as e: + if not sys.stdout.isatty(): + echo._console.print_exception(show_locals=True) + raise typer.Exit(1) + + echo.error("Error building UI: {!s}", e) + raise typer.Exit(1) diff --git a/hyperglass/compat/__init__.py b/hyperglass/compat/__init__.py new file mode 100644 index 0000000..e498f61 --- /dev/null +++ b/hyperglass/compat/__init__.py @@ -0,0 +1,10 @@ +"""Functions for maintaining compatibility with older Python versions or libraries.""" + +# Local +from ._sshtunnel import SSHTunnelForwarder, BaseSSHTunnelForwarderError, open_tunnel + +__all__ = ( + "BaseSSHTunnelForwarderError", + "open_tunnel", + "SSHTunnelForwarder", +) diff --git a/hyperglass/compat/_sshtunnel.py b/hyperglass/compat/_sshtunnel.py new file mode 100644 index 0000000..ca6547f --- /dev/null +++ b/hyperglass/compat/_sshtunnel.py @@ -0,0 +1,1551 @@ +"""Initiate SSH tunnels via a remote gateway. + +Copyright (c) 2014-2019 Pahaz Blinov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +*sshtunnel* - Initiate SSH tunnels via a remote gateway. + +``sshtunnel`` works by opening a port forwarding SSH connection in the +background, using threads. + +The connection(s) are closed when explicitly calling the +:meth:`SSHTunnelForwarder.stop` method or using it as a context. +""" + +# Standard Library +import os +import sys +import queue +import socket +import getpass +import argparse +import warnings +import threading +import socketserver +from select import select +from binascii import hexlify + +# Third Party +import paramiko + +# Project +from hyperglass.log import log + +TUNNEL_TIMEOUT = 1.0 #: Timeout (seconds) for tunnel connection +_DAEMON = False #: Use daemon threads in connections +_CONNECTION_COUNTER = 1 +_LOCK = threading.Lock() + +DEPRECATIONS = { + "ssh_address": "ssh_address_or_host", + "ssh_host": "ssh_address_or_host", + "ssh_private_key": "ssh_pkey", + "raise_exception_if_any_forwarder_have_a_problem": "mute_exceptions", +} + +if os.name == "posix": + DEFAULT_SSH_DIRECTORY = "~/.ssh" + UnixStreamServer = socketserver.UnixStreamServer +else: + DEFAULT_SSH_DIRECTORY = "~/ssh" + UnixStreamServer = socketserver.TCPServer + +#: Path of optional ssh configuration file +SSH_CONFIG_FILE = os.path.join(DEFAULT_SSH_DIRECTORY, "config") + +######################## +# # +# Utils # +# # +######################## + + +def check_host(host): + assert isinstance(host, str), "IP is not a string ({0})".format(type(host).__name__) + + +def check_port(port): + assert isinstance(port, int), "PORT is not a number" + assert port >= 0, "PORT < 0 ({0})".format(port) + + +def check_address(address): + """Check if the format of the address is correct. + + Arguments: + address (tuple): + (``str``, ``int``) representing an IP address and port, + respectively + + .. note:: + alternatively a local ``address`` can be a ``str`` when working + with UNIX domain sockets, if supported by the platform + Raises: + ValueError: + raised when address has an incorrect format + + Example: + >>> check_address(('127.0.0.1', 22)) + """ + if isinstance(address, tuple): + check_host(address[0]) + check_port(address[1]) + elif isinstance(address, str): + if os.name != "posix": + raise ValueError("Platform does not support UNIX domain sockets") + if not (os.path.exists(address) or os.access(os.path.dirname(address), os.W_OK)): + raise ValueError("ADDRESS not a valid socket domain socket ({0})".format(address)) + else: + raise ValueError( + "ADDRESS is not a tuple, string, or character buffer " + "({0})".format(type(address).__name__) + ) + + +def check_addresses(address_list, is_remote=False): + """ + Check if the format of the addresses is correct + + Arguments: + address_list (list[tuple]): + Sequence of (``str``, ``int``) pairs, each representing an IP + address and port respectively + + .. note:: + when supported by the platform, one or more of the elements in + the list can be of type ``str``, representing a valid UNIX + domain socket + + is_remote (boolean): + Whether or not the address list + Raises: + AssertionError: + raised when ``address_list`` contains an invalid element + ValueError: + raised when any address in the list has an incorrect format + + Example: + + >>> check_addresses([('127.0.0.1', 22), ('127.0.0.1', 2222)]) + """ + assert all(isinstance(x, (tuple, str)) for x in address_list) + if is_remote and any(isinstance(x, str) for x in address_list): + raise AssertionError("UNIX domain sockets not allowed for remote" "addresses") + + for address in address_list: + check_address(address) + + +def address_to_str(address): + if isinstance(address, tuple): + return "{0[0]}:{0[1]}".format(address) + return str(address) + + +def get_connection_id(): + global _CONNECTION_COUNTER + with _LOCK: + uid = _CONNECTION_COUNTER + _CONNECTION_COUNTER += 1 + return uid + + +def _remove_none_values(dictionary): + """Remove dictionary keys whose value is None.""" + return list(map(dictionary.pop, [i for i in dictionary if dictionary[i] is None])) + + +######################## +# # +# Errors # +# # +######################## + + +class BaseSSHTunnelForwarderError(Exception): + """Exception raised by :class:`SSHTunnelForwarder` errors""" + + def __init__(self, *args, **kwargs): + self.value = kwargs.pop("value", args[0] if args else "") + + def __str__(self): + return self.value + + +class HandlerSSHTunnelForwarderError(BaseSSHTunnelForwarderError): + """Exception for Tunnel forwarder errors""" + + pass + + +######################## +# # +# Handlers # +# # +######################## + + +class _ForwardHandler(socketserver.BaseRequestHandler): + """Base handler for tunnel connections""" + + remote_address = None + ssh_transport = None + logger = None + info = None + + def _redirect(self, chan): + while chan.active: + rqst, _, _ = select([self.request, chan], [], [], 5) + if self.request in rqst: + data = self.request.recv(1024) + if not data: + break + chan.sendall(data) + if chan in rqst: # else + if not chan.recv_ready(): + break + data = chan.recv(1024) + self.request.sendall(data) + + def handle(self): + uid = get_connection_id() + self.info = "#{0} <-- {1}".format(uid, self.client_address or self.server.local_address) + src_address = self.request.getpeername() + if not isinstance(src_address, tuple): + src_address = ("dummy", 12345) + try: + chan = self.ssh_transport.open_channel( + kind="direct-tcpip", + dest_addr=self.remote_address, + src_addr=src_address, + timeout=TUNNEL_TIMEOUT, + ) + except paramiko.SSHException: + chan = None + if chan is None: + msg = "{0} to {1} was rejected by the SSH server".format(self.info, self.remote_address) + raise HandlerSSHTunnelForwarderError(msg) + + try: + self._redirect(chan) + except socket.error: + # Sometimes a RST is sent and a socket error is raised, treat this + # exception. It was seen that a 3way FIN is processed later on, so + # no need to make an ordered close of the connection here or raise + # the exception beyond this point... + pass + except Exception: + pass + finally: + chan.close() + self.request.close() + + +class _ForwardServer(socketserver.TCPServer): # Not Threading + """ + Non-threading version of the forward server + """ + + allow_reuse_address = True # faster rebinding + + def __init__(self, *args, **kwargs): + self.logger = kwargs.pop("logger") or log + self.tunnel_ok = queue.Queue() + socketserver.TCPServer.__init__(self, *args, **kwargs) + + def handle_error(self, request, client_address): + (exc_class, exc, tb) = sys.exc_info() + self.logger.bind(source=request.getsockname()).error("Could not establish connection to remote side of the tunnel") + self.tunnel_ok.put(False) + + @property + def local_address(self): + return self.server_address + + @property + def local_host(self): + return self.server_address[0] + + @property + def local_port(self): + return self.server_address[1] + + @property + def remote_address(self): + return self.RequestHandlerClass.remote_address + + @property + def remote_host(self): + return self.RequestHandlerClass.remote_address[0] + + @property + def remote_port(self): + return self.RequestHandlerClass.remote_address[1] + + +class _ThreadingForwardServer(socketserver.ThreadingMixIn, _ForwardServer): + """ + Allow concurrent connections to each tunnel + """ + + # If True, cleanly stop threads created by ThreadingMixIn when quitting + daemon_threads = _DAEMON + + +class _UnixStreamForwardServer(UnixStreamServer): + """ + Serve over UNIX domain sockets (does not work on Windows) + """ + + def __init__(self, *args, **kwargs): + self.logger = kwargs.pop("logger") or log + self.tunnel_ok = queue.Queue() + UnixStreamServer.__init__(self, *args, **kwargs) + + @property + def local_address(self): + return self.server_address + + @property + def local_host(self): + return None + + @property + def local_port(self): + return None + + @property + def remote_address(self): + return self.RequestHandlerClass.remote_address + + @property + def remote_host(self): + return self.RequestHandlerClass.remote_address[0] + + @property + def remote_port(self): + return self.RequestHandlerClass.remote_address[1] + + +class _ThreadingUnixStreamForwardServer(socketserver.ThreadingMixIn, _UnixStreamForwardServer): + """ + Allow concurrent connections to each tunnel + """ + + # If True, cleanly stop threads created by ThreadingMixIn when quitting + daemon_threads = _DAEMON + + +class SSHTunnelForwarder: + """ + **SSH tunnel class** + + - Initialize a SSH tunnel to a remote host according to the input + arguments + + - Optionally: + + Read an SSH configuration file (typically ``~/.ssh/config``) + + Load keys from a running SSH agent (i.e. Pageant, GNOME Keyring) + + Raises: + + :class:`.BaseSSHTunnelForwarderError`: + raised by SSHTunnelForwarder class methods + + :class:`.HandlerSSHTunnelForwarderError`: + raised by tunnel forwarder threads + + .. note:: + Attributes ``mute_exceptions`` and + ``raise_exception_if_any_forwarder_have_a_problem`` + (deprecated) may be used to silence most exceptions raised + from this class + + Keyword Arguments: + + ssh_address_or_host (tuple or str): + IP or hostname of ``REMOTE GATEWAY``. It may be a two-element + tuple (``str``, ``int``) representing IP and port respectively, + or a ``str`` representing the IP address only + + .. versionadded:: 0.0.4 + + ssh_config_file (str): + SSH configuration file that will be read. If explicitly set to + ``None``, parsing of this configuration is omitted + + Default: :const:`SSH_CONFIG_FILE` + + .. versionadded:: 0.0.4 + + ssh_host_key (str): + Representation of a line in an OpenSSH-style "known hosts" + file. + + ``REMOTE GATEWAY``'s key fingerprint will be compared to this + host key in order to prevent against SSH server spoofing. + Important when using passwords in order not to accidentally + do a login attempt to a wrong (perhaps an attacker's) machine + + ssh_username (str): + Username to authenticate as in ``REMOTE SERVER`` + + Default: current local user name + + ssh_password (str): + Text representing the password used to connect to ``REMOTE + SERVER`` or for unlocking a private key. + + .. note:: + Avoid coding secret password directly in the code, since this + may be visible and make your service vulnerable to attacks + + ssh_port (int): + Optional port number of the SSH service on ``REMOTE GATEWAY``, + when `ssh_address_or_host`` is a ``str`` representing the + IP part of ``REMOTE GATEWAY``'s address + + Default: 22 + + ssh_pkey (str or paramiko.PKey): + **Private** key file name (``str``) to obtain the public key + from or a **public** key (:class:`paramiko.pkey.PKey`) + + ssh_private_key_password (str): + Password for an encrypted ``ssh_pkey`` + + .. note:: + Avoid coding secret password directly in the code, since this + may be visible and make your service vulnerable to attacks + + ssh_proxy (socket-like object or tuple): + Proxy where all SSH traffic will be passed through. + It might be for example a :class:`paramiko.proxy.ProxyCommand` + instance. + See either the :class:`paramiko.transport.Transport`'s sock + parameter documentation or ``ProxyCommand`` in ``ssh_config(5)`` + for more information. + + It is also possible to specify the proxy address as a tuple of + type (``str``, ``int``) representing proxy's IP and port + + .. note:: + Ignored if ``ssh_proxy_enabled`` is False + + .. versionadded:: 0.0.5 + + ssh_proxy_enabled (boolean): + Enable/disable SSH proxy. If True and user's + ``ssh_config_file`` contains a ``ProxyCommand`` directive + that matches the specified ``ssh_address_or_host``, + a :class:`paramiko.proxy.ProxyCommand` object will be created where + all SSH traffic will be passed through + + Default: ``True`` + + .. versionadded:: 0.0.4 + + local_bind_address (tuple): + Local tuple in the format (``str``, ``int``) representing the + IP and port of the local side of the tunnel. Both elements in + the tuple are optional so both ``('', 8000)`` and + ``('10.0.0.1', )`` are valid values + + Default: ``('0.0.0.0', RANDOM_PORT)`` + + .. versionchanged:: 0.0.8 + Added the ability to use a UNIX domain socket as local bind + address + + local_bind_addresses (list[tuple]): + In case more than one tunnel is established at once, a list + of tuples (in the same format as ``local_bind_address``) + can be specified, such as [(ip1, port_1), (ip_2, port2), ...] + + Default: ``[local_bind_address]`` + + .. versionadded:: 0.0.4 + + remote_bind_address (tuple): + Remote tuple in the format (``str``, ``int``) representing the + IP and port of the remote side of the tunnel. + + remote_bind_addresses (list[tuple]): + In case more than one tunnel is established at once, a list + of tuples (in the same format as ``remote_bind_address``) + can be specified, such as [(ip1, port_1), (ip_2, port2), ...] + + Default: ``[remote_bind_address]`` + + .. versionadded:: 0.0.4 + + allow_agent (boolean): + Enable/disable load of keys from an SSH agent + + Default: ``True`` + + .. versionadded:: 0.0.8 + + host_pkey_directories (list): + Look for pkeys in folders on this list, for example ['~/.ssh']. + + Default: ``None`` (disabled) + + .. versionadded:: 0.1.4 + + compression (boolean): + Turn on/off transport compression. By default compression is + disabled since it may negatively affect interactive sessions + + Default: ``False`` + + .. versionadded:: 0.0.8 + + logger (logging.Logger): + logging instance for sshtunnel and paramiko + + Default: :class:`logging.Logger` instance with a single + :class:`logging.StreamHandler` handler and + :const:`DEFAULT_LOGLEVEL` level + + .. versionadded:: 0.0.3 + + mute_exceptions (boolean): + Allow silencing :class:`BaseSSHTunnelForwarderError` or + :class:`HandlerSSHTunnelForwarderError` exceptions when enabled + + Default: ``False`` + + .. versionadded:: 0.0.8 + + set_keepalive (float): + Interval in seconds defining the period in which, if no data + was sent over the connection, a *'keepalive'* packet will be + sent (and ignored by the remote host). This can be useful to + keep connections alive over a NAT + + Default: 0.0 (no keepalive packets are sent) + + .. versionadded:: 0.0.7 + + threaded (boolean): + Allow concurrent connections over a single tunnel + + Default: ``True`` + + .. versionadded:: 0.0.3 + + ssh_address (str): + Superseded by ``ssh_address_or_host``, tuple of type (str, int) + representing the IP and port of ``REMOTE SERVER`` + + .. deprecated:: 0.0.4 + + ssh_host (str): + Superseded by ``ssh_address_or_host``, tuple of type + (str, int) representing the IP and port of ``REMOTE SERVER`` + + .. deprecated:: 0.0.4 + + ssh_private_key (str or paramiko.PKey): + Superseded by ``ssh_pkey``, which can represent either a + **private** key file name (``str``) or a **public** key + (:class:`paramiko.pkey.PKey`) + + .. deprecated:: 0.0.8 + + raise_exception_if_any_forwarder_have_a_problem (boolean): + Allow silencing :class:`BaseSSHTunnelForwarderError` or + :class:`HandlerSSHTunnelForwarderError` exceptions when set to + False + + Default: ``True`` + + .. versionadded:: 0.0.4 + + .. deprecated:: 0.0.8 (use ``mute_exceptions`` instead) + + Attributes: + + tunnel_is_up (dict): + Describe whether or not the other side of the tunnel was reported + to be up (and we must close it) or not (skip shutting down that + tunnel) + + .. note:: + This attribute should not be modified + + .. note:: + When :attr:`.skip_tunnel_checkup` is disabled or the local bind + is a UNIX socket, the value will always be ``True`` + + **Example**:: + + {('127.0.0.1', 55550): True, # this tunnel is up + ('127.0.0.1', 55551): False} # this one isn't + + where 55550 and 55551 are the local bind ports + + skip_tunnel_checkup (boolean): + Disable tunnel checkup (default for backwards compatibility). + + .. versionadded:: 0.1.0 + + """ + + skip_tunnel_checkup = True + daemon_forward_servers = _DAEMON #: flag tunnel threads in daemon mode + daemon_transport = _DAEMON #: flag SSH transport thread in daemon mode + + def local_is_up(self, target): + """ + Check if a tunnel is up (remote target's host is reachable on TCP + target's port) + + Arguments: + target (tuple): + tuple of type (``str``, ``int``) indicating the listen IP + address and port + Return: + boolean + + .. deprecated:: 0.1.0 + Replaced by :meth:`.check_tunnels()` and :attr:`.tunnel_is_up` + """ + try: + check_address(target) + except ValueError: + self.logger.warning( + "Target must be a tuple (IP, port), where IP " + 'is a string (i.e. "192.168.0.1") and port is ' + "an integer (i.e. 40000). Alternatively " + "target can be a valid UNIX domain socket." + ) + return False + + if self.skip_tunnel_checkup: # force tunnel check at this point + self.skip_tunnel_checkup = False + self.check_tunnels() + self.skip_tunnel_checkup = True # roll it back + return self.tunnel_is_up.get(target, True) + + def _make_ssh_forward_handler_class(self, remote_address_): + """ + Make SSH Handler class + """ + + class Handler(_ForwardHandler): + remote_address = remote_address_ + ssh_transport = self._transport + logger = self.logger + + return Handler + + def _make_ssh_forward_server_class(self, remote_address_): + return _ThreadingForwardServer if self._threaded else _ForwardServer + + def _make_unix_ssh_forward_server_class(self, remote_address_): + return _ThreadingUnixStreamForwardServer if self._threaded else _UnixStreamForwardServer + + def _make_ssh_forward_server(self, remote_address, local_bind_address): + """ + Make SSH forward proxy Server class + """ + _Handler = self._make_ssh_forward_handler_class(remote_address) + try: + if isinstance(local_bind_address, str): + forward_maker_class = self._make_unix_ssh_forward_server_class + else: + forward_maker_class = self._make_ssh_forward_server_class + _Server = forward_maker_class(remote_address) + ssh_forward_server = _Server( + local_bind_address, + _Handler, + logger=self.logger, + ) + + if ssh_forward_server: + ssh_forward_server.daemon_threads = self.daemon_forward_servers + self._server_list.append(ssh_forward_server) + self.tunnel_is_up[ssh_forward_server.server_address] = False + else: + self._raise( + BaseSSHTunnelForwarderError, + "Problem setting up ssh {0} <> {1} forwarder. You can " + "suppress this exception by using the `mute_exceptions`" + "argument".format( + address_to_str(local_bind_address), + address_to_str(remote_address), + ), + ) + except IOError: + self._raise( + BaseSSHTunnelForwarderError, + "Couldn't open tunnel {0} <> {1} might be in use or " + "destination not reachable".format( + address_to_str(local_bind_address), address_to_str(remote_address) + ), + ) + + def __init__( + self, + ssh_address_or_host=None, + ssh_config_file=SSH_CONFIG_FILE, + ssh_host_key=None, + ssh_password=None, + ssh_pkey=None, + ssh_private_key_password=None, + ssh_proxy=None, + ssh_proxy_enabled=True, + ssh_username=None, + local_bind_address=None, + local_bind_addresses=None, + logger=None, + mute_exceptions=False, + remote_bind_address=None, + remote_bind_addresses=None, + set_keepalive=0.0, + threaded=True, # old version False + compression=None, + allow_agent=True, # look for keys from an SSH agent + host_pkey_directories=None, # look for keys in ~/.ssh + gateway_timeout=None, + *args, + **kwargs, # for backwards compatibility + ) -> None: + self.logger = logger or log + self.ssh_host_key = ssh_host_key + self.set_keepalive = set_keepalive + self._server_list = [] # reset server list + self.tunnel_is_up = {} # handle tunnel status + self._threaded = threaded + self.is_alive = False + self.gateway_timeout = gateway_timeout + # Check if deprecated arguments ssh_address or ssh_host were used + for deprecated_argument in ["ssh_address", "ssh_host"]: + ssh_address_or_host = self._process_deprecated( + ssh_address_or_host, deprecated_argument, kwargs + ) + # other deprecated arguments + ssh_pkey = self._process_deprecated(ssh_pkey, "ssh_private_key", kwargs) + + self._raise_fwd_exc = ( + self._process_deprecated( + None, "raise_exception_if_any_forwarder_have_a_problem", kwargs + ) + or not mute_exceptions + ) + + if isinstance(ssh_address_or_host, tuple): + check_address(ssh_address_or_host) + (ssh_host, ssh_port) = ssh_address_or_host + else: + ssh_host = ssh_address_or_host + ssh_port = kwargs.pop("ssh_port", None) + + if kwargs: + raise ValueError("Unknown arguments: {0}".format(kwargs)) + + # remote binds + self._remote_binds = self._get_binds( + remote_bind_address, remote_bind_addresses, is_remote=True + ) + # local binds + self._local_binds = self._get_binds(local_bind_address, local_bind_addresses) + self._local_binds = self._consolidate_binds(self._local_binds, self._remote_binds) + + ( + self.ssh_host, + self.ssh_username, + ssh_pkey, # still needs to go through _consolidate_auth + self.ssh_port, + self.ssh_proxy, + self.compression, + ) = self._read_ssh_config( + ssh_host, + ssh_config_file, + ssh_username, + ssh_pkey, + ssh_port, + ssh_proxy if ssh_proxy_enabled else None, + compression, + self.logger, + ) + + (self.ssh_password, self.ssh_pkeys) = self._consolidate_auth( + ssh_password=ssh_password, + ssh_pkey=ssh_pkey, + ssh_pkey_password=ssh_private_key_password, + allow_agent=allow_agent, + host_pkey_directories=host_pkey_directories, + logger=self.logger, + ) + + check_host(self.ssh_host) + check_port(self.ssh_port) + self.logger.bind( + host=self.ssh_host, + port=self.ssh_port, + username=self.ssh_username, + timeout=self.gateway_timeout, + ).info("Connecting to gateway") + self.logger.bind(count=self._threaded).debug("Concurrent connections allowed") + + @staticmethod + def _read_ssh_config( + ssh_host, + ssh_config_file, + ssh_username=None, + ssh_pkey=None, + ssh_port=None, + ssh_proxy=None, + compression=None, + logger=log, + ): + """Read ssh_config_file. + + Read ssh_config_file and try to look for user (ssh_username), + identityfile (ssh_pkey), port (ssh_port) and proxycommand + (ssh_proxy) entries for ssh_host + """ + ssh_config = paramiko.SSHConfig() + if not ssh_config_file: # handle case where it's an empty string + ssh_config_file = None + + # Try to read SSH_CONFIG_FILE + try: + # open the ssh config file + with open(os.path.expanduser(ssh_config_file), "r") as f: + ssh_config.parse(f) + # looks for information for the destination system + hostname_info = ssh_config.lookup(ssh_host) + # gather settings for user, port and identity file + # last resort: use the 'login name' of the user + ssh_username = ssh_username or hostname_info.get("user") + ssh_pkey = ssh_pkey or hostname_info.get("identityfile", [None])[0] + ssh_host = hostname_info.get("hostname") + ssh_port = ssh_port or hostname_info.get("port") + + proxycommand = hostname_info.get("proxycommand") + ssh_proxy = ssh_proxy or (paramiko.ProxyCommand(proxycommand) if proxycommand else None) + if compression is None: + compression = hostname_info.get("compression", "") + compression = True if compression.upper() == "YES" else False + except IOError: + logger.warning("Could not read SSH configuration file: {f}", f=ssh_config_file) + except (AttributeError, TypeError): # ssh_config_file is None + logger.info("Skipping loading of ssh configuration file") + finally: + return ( + ssh_host, + ssh_username or getpass.getuser(), + ssh_pkey, + int(ssh_port) if ssh_port else 22, # fallback value + ssh_proxy, + compression, + ) + + @staticmethod + def get_agent_keys(logger=log): + """Load public keys from any available SSH agent. + + Arguments: + logger (Optional[logging.Logger]) + + Return: + list + """ + paramiko_agent = paramiko.Agent() + agent_keys = paramiko_agent.get_keys() + + logger.info("{k} keys loaded from agent", k=len(agent_keys)) + + return list(agent_keys) + + @staticmethod + def get_keys(logger=log, host_pkey_directories=None, allow_agent=False): + """Load public keys from any available SSH agent or local .ssh directory. + + Arguments: + logger (Optional[logging.Logger]) + + host_pkey_directories (Optional[list[str]]): + List of local directories where host SSH pkeys in the format + "id_*" are searched. For example, ['~/.ssh'] + + .. versionadded:: 0.1.0 + + allow_agent (Optional[boolean]): + Whether or not load keys from agent + + Default: False + + Return: + list + """ + keys = SSHTunnelForwarder.get_agent_keys(logger=logger) if allow_agent else [] + + if host_pkey_directories is not None: + paramiko_key_types = { + "rsa": paramiko.RSAKey, + "dsa": paramiko.DSSKey, + "ecdsa": paramiko.ECDSAKey, + "ed25519": paramiko.Ed25519Key, + } + for directory in host_pkey_directories or [DEFAULT_SSH_DIRECTORY]: + for keytype in paramiko_key_types.keys(): + ssh_pkey_expanded = os.path.expanduser( + os.path.join(directory, "id_{}".format(keytype)) + ) + if os.path.isfile(ssh_pkey_expanded): + ssh_pkey = SSHTunnelForwarder.read_private_key_file( + pkey_file=ssh_pkey_expanded, + logger=logger, + key_type=paramiko_key_types[keytype], + ) + if ssh_pkey: + keys.append(ssh_pkey) + + logger.info("{k} keys loaded from host directory", k=len(keys)) + + return keys + + @staticmethod + def _consolidate_binds(local_binds, remote_binds): + """Fill local_binds with defaults. + + Fill local_binds with defaults when no value/s were specified, + leaving paramiko to decide in which local port the tunnel will be open. + """ + count = len(remote_binds) - len(local_binds) + if count < 0: + raise ValueError( + "Too many local bind addresses " "(local_bind_addresses > remote_bind_addresses)" + ) + local_binds.extend([("0.0.0.0", 0) for x in range(count)]) + return local_binds + + @staticmethod + def _consolidate_auth( + ssh_password=None, + ssh_pkey=None, + ssh_pkey_password=None, + allow_agent=True, + host_pkey_directories=None, + logger=log, + ): + """Get sure authentication information is in place. + + ``ssh_pkey`` may be of classes: + - ``str`` - in this case it represents a private key file; public + key will be obtained from it + - ``paramiko.Pkey`` - it will be transparently added to loaded keys + """ + ssh_loaded_pkeys = SSHTunnelForwarder.get_keys( + logger=logger, + host_pkey_directories=host_pkey_directories, + allow_agent=allow_agent, + ) + + if isinstance(ssh_pkey, str): + ssh_pkey_expanded = os.path.expanduser(ssh_pkey) + if os.path.exists(ssh_pkey_expanded): + ssh_pkey = SSHTunnelForwarder.read_private_key_file( + pkey_file=ssh_pkey_expanded, + pkey_password=ssh_pkey_password or ssh_password, + logger=logger, + ) + else: + logger.warning("Private key file not found: {k}", k=ssh_pkey) + + if isinstance(ssh_pkey, paramiko.pkey.PKey): + ssh_loaded_pkeys.insert(0, ssh_pkey) + + if not ssh_password and not ssh_loaded_pkeys: + raise ValueError("No password or public key available!") + return (ssh_password, ssh_loaded_pkeys) + + def _raise(self, exception=BaseSSHTunnelForwarderError, reason=None): + if self._raise_fwd_exc: + raise exception(reason) + else: + self.logger.error(repr(exception(reason))) + + def _get_transport(self): + """Return the SSH transport to the remote gateway.""" + if self.ssh_proxy: + if isinstance(self.ssh_proxy, paramiko.proxy.ProxyCommand): + proxy_repr = repr(self.ssh_proxy.cmd[1]) + else: + proxy_repr = repr(self.ssh_proxy) + self.logger.debug("Connecting via proxy: {0}".format(proxy_repr)) + _socket = self.ssh_proxy + else: + _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if isinstance(_socket, socket.socket): + _socket.settimeout(self.gateway_timeout) + _socket.connect((self.ssh_host, self.ssh_port)) + transport = paramiko.Transport(_socket) + transport.set_keepalive(self.set_keepalive) + transport.use_compression(compress=self.compression) + transport.daemon = self.daemon_transport + + return transport + + def _create_tunnels(self): + """Create SSH tunnels on top of a transport to the remote gateway.""" + if not self.is_active: + try: + self._connect_to_gateway() + except socket.gaierror: # raised by paramiko.Transport + msg = "Could not resolve IP address for {0}, aborting!".format(self.ssh_host) + self.logger.error(msg) + return + except (paramiko.SSHException, socket.error) as e: + template = "Could not connect to gateway {0}:{1} : {2}" + msg = template.format(self.ssh_host, self.ssh_port, e.args[0]) + self.logger.error(msg) + return + for (rem, loc) in zip(self._remote_binds, self._local_binds): + try: + self._make_ssh_forward_server(rem, loc) + except BaseSSHTunnelForwarderError as e: + msg = "Problem setting SSH Forwarder up: {0}".format(e.value) + self.logger.error(msg) + + @staticmethod + def _get_binds(bind_address, bind_addresses, is_remote=False): + addr_kind = "remote" if is_remote else "local" + + if not bind_address and not bind_addresses: + if is_remote: + raise ValueError( + "No {0} bind addresses specified. Use " + "'{0}_bind_address' or '{0}_bind_addresses'" + " argument".format(addr_kind) + ) + else: + return [] + elif bind_address and bind_addresses: + raise ValueError( + "You can't use both '{0}_bind_address' and " + "'{0}_bind_addresses' arguments. Use one of " + "them.".format(addr_kind) + ) + if bind_address: + bind_addresses = [bind_address] + if not is_remote: + # Add random port if missing in local bind + for (i, local_bind) in enumerate(bind_addresses): + if isinstance(local_bind, tuple) and len(local_bind) == 1: + bind_addresses[i] = (local_bind[0], 0) + check_addresses(bind_addresses, is_remote) + return bind_addresses + + @staticmethod + def _process_deprecated(attrib, deprecated_attrib, kwargs): + """Processes optional deprecate arguments.""" + + if deprecated_attrib not in DEPRECATIONS: + raise ValueError("{0} not included in deprecations list".format(deprecated_attrib)) + if deprecated_attrib in kwargs: + warnings.warn( + "'{0}' is DEPRECATED use '{1}' instead".format( + deprecated_attrib, DEPRECATIONS[deprecated_attrib] + ), + DeprecationWarning, + ) + if attrib: + raise ValueError( + "You can't use both '{0}' and '{1}'. " + "Please only use one of them".format( + deprecated_attrib, DEPRECATIONS[deprecated_attrib] + ) + ) + else: + return kwargs.pop(deprecated_attrib) + return attrib + + @staticmethod + def read_private_key_file(pkey_file, pkey_password=None, key_type=None, logger=log): + """Get SSH Public key from a private key file, given an optional password. + + Arguments: + pkey_file (str): + File containing a private key (RSA, DSS or ECDSA) + Keyword Arguments: + pkey_password (Optional[str]): + Password to decrypt the private key + logger (Optional[logging.Logger]) + Return: + paramiko.Pkey + """ + ssh_pkey = None + for pkey_class in ( + (key_type,) + if key_type + else ( + paramiko.RSAKey, + paramiko.DSSKey, + paramiko.ECDSAKey, + paramiko.Ed25519Key, + ) + ): + try: + ssh_pkey = pkey_class.from_private_key_file(pkey_file, password=pkey_password) + + logger.debug( + "Private key file ({k0}, {k1}) successfully loaded", + k0=pkey_file, + k1=pkey_class, + ) + + break + except paramiko.PasswordRequiredException: + + logger.error("Password is required for key {k}", k=pkey_file) + + break + except paramiko.SSHException: + logger.debug( + "Private key file ({k0}) could not be loaded as type {k1} or bad password", + k0=pkey_file, + k1=pkey_class, + ) + + return ssh_pkey + + def _check_tunnel(self, _srv) -> None: + """Check if tunnel is already established.""" + if self.skip_tunnel_checkup: + self.tunnel_is_up[_srv.local_address] = True + return + self.logger.debug("Checking tunnel", address=_srv.remote_address) + + if isinstance(_srv.local_address, str): # UNIX stream + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + else: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(TUNNEL_TIMEOUT) + try: + # Windows raises WinError 10049 if trying to connect to 0.0.0.0 + connect_to = ( + ("127.0.0.1", _srv.local_port) + if _srv.local_host == "0.0.0.0" + else _srv.local_address + ) + s.connect(connect_to) + self.tunnel_is_up[_srv.local_address] = _srv.tunnel_ok.get(timeout=TUNNEL_TIMEOUT * 1.1) + self.logger.bind(status="DOWN", address=_srv.remote_address).debug("Tunnel Status") + except socket.error: + self.logger.bind(status="DOWN", address=_srv.remote_address).debug("Tunnel Status") + self.tunnel_is_up[_srv.local_address] = False + + except queue.Empty: + self.logger.bind(status="UP", address=_srv.remote_address).debug("Tunnel Status") + self.tunnel_is_up[_srv.local_address] = True + finally: + s.close() + + def check_tunnels(self) -> None: + """Check that if all tunnels are established and populates. + + :attr:`.tunnel_is_up` + """ + for _srv in self._server_list: + self._check_tunnel(_srv) + + def start(self) -> None: + """Start the SSH tunnels.""" + if self.is_alive: + self.logger.warning("Already started!") + return + self._create_tunnels() + if not self.is_active: + self._raise( + BaseSSHTunnelForwarderError, + reason="Could not establish session to SSH gateway", + ) + for _srv in self._server_list: + thread = threading.Thread( + target=self._serve_forever_wrapper, + args=(_srv,), + name="Srv-{0}".format(address_to_str(_srv.local_port)), + ) + thread.daemon = self.daemon_forward_servers + thread.start() + self._check_tunnel(_srv) + self.is_alive = any(self.tunnel_is_up.values()) + if not self.is_alive: + self._raise( + HandlerSSHTunnelForwarderError, + "An error occurred while opening tunnels.", + ) + + def stop(self) -> None: + """Shut the tunnel down. + + .. note:: This **had** to be handled with care before ``0.1.0``: + + - if a port redirection is opened + - the destination is not reachable + - we attempt a connection to that tunnel (``SYN`` is sent and + acknowledged, then a ``FIN`` packet is sent and never + acknowledged... weird) + - we try to shutdown: it will not succeed until ``FIN_WAIT_2`` and + ``CLOSE_WAIT`` time out. + + .. note:: + Handle these scenarios with :attr:`.tunnel_is_up`: if False, server + ``shutdown()`` will be skipped on that tunnel + """ + self.logger.info("Closing all open connections...") + opened_address_text = ( + ", ".join((address_to_str(k.local_address) for k in self._server_list)) or "None" + ) + self.logger.debug("Listening tunnels: " + opened_address_text) + self._stop_transport() + self._server_list = [] # reset server list + self.tunnel_is_up = {} # reset tunnel status + + def close(self) -> None: + """Stop the an active tunnel, alias to :meth:`.stop`.""" + self.stop() + + def restart(self) -> None: + """Restart connection to the gateway and tunnels.""" + self.stop() + self.start() + + def _connect_to_gateway(self) -> None: + """Open connection to SSH gateway. + + - First try with all keys loaded from an SSH agent (if allowed) + - Then with those passed directly or read from ~/.ssh/config + - As last resort, try with a provided password + """ + for key in self.ssh_pkeys: + self.logger.debug( + "Trying to log in with key: {0}".format(hexlify(key.get_fingerprint())) + ) + try: + self._transport = self._get_transport() + self._transport.connect( + hostkey=self.ssh_host_key, username=self.ssh_username, pkey=key + ) + if self._transport.is_alive: + return + except paramiko.AuthenticationException: + self.logger.debug("Authentication error") + self._stop_transport() + + if self.ssh_password: # avoid conflict using both pass and pkey + self.logger.debug( + "Trying to log in with password: {0}".format("*" * len(self.ssh_password)) + ) + try: + self._transport = self._get_transport() + self._transport.connect( + hostkey=self.ssh_host_key, + username=self.ssh_username, + password=self.ssh_password, + ) + if self._transport.is_alive: + return + except paramiko.AuthenticationException: + self.logger.debug("Authentication error") + self._stop_transport() + + self.logger.error("Could not open connection to gateway") + + def _serve_forever_wrapper(self, _srv, poll_interval=0.1) -> None: + """Wrapper for the server created for a SSH forward.""" + self.logger.info( + "Opening tunnel: {0} <> {1}".format( + address_to_str(_srv.local_address), address_to_str(_srv.remote_address) + ) + ) + _srv.serve_forever(poll_interval) # blocks until finished + + self.logger.info( + "Tunnel: {0} <> {1} released".format( + address_to_str(_srv.local_address), address_to_str(_srv.remote_address) + ) + ) + + def _stop_transport(self) -> None: + """Close the underlying transport when nothing more is needed.""" + + try: + self._check_is_started() + except (BaseSSHTunnelForwarderError, HandlerSSHTunnelForwarderError) as e: + self.logger.warning(e) + for _srv in self._server_list: + tunnel = _srv.local_address + if self.tunnel_is_up[tunnel]: + self.logger.info("Shutting down tunnel {0}".format(tunnel)) + _srv.shutdown() + _srv.server_close() + # clean up the UNIX domain socket if we're using one + if isinstance(_srv, _UnixStreamForwardServer): + try: + os.unlink(_srv.local_address) + except Exception as e: + self.logger.error( + "Unable to unlink socket {0}: {1}".format(self.local_address, repr(e)) + ) + self.is_alive = False + if self.is_active: + self._transport.close() + self._transport.stop_thread() + self.logger.debug("Transport is closed") + + @property + def local_bind_port(self): + + # BACKWARDS COMPATIBILITY + self._check_is_started() + if len(self._server_list) != 1: + raise BaseSSHTunnelForwarderError( + "Use .local_bind_ports property for more than one tunnel" + ) + return self.local_bind_ports[0] + + @property + def local_bind_host(self): + + # BACKWARDS COMPATIBILITY + self._check_is_started() + if len(self._server_list) != 1: + raise BaseSSHTunnelForwarderError( + "Use .local_bind_hosts property for more than one tunnel" + ) + return self.local_bind_hosts[0] + + @property + def local_bind_address(self): + + # BACKWARDS COMPATIBILITY + self._check_is_started() + if len(self._server_list) != 1: + raise BaseSSHTunnelForwarderError( + "Use .local_bind_addresses property for more than one tunnel" + ) + return self.local_bind_addresses[0] + + @property + def local_bind_ports(self): + """Return a list containing the ports of local side of the TCP tunnels.""" + + self._check_is_started() + return [ + _server.local_port for _server in self._server_list if _server.local_port is not None + ] + + @property + def local_bind_hosts(self): + """Return a list containing the IP addresses listening for the tunnels.""" + self._check_is_started() + return [ + _server.local_host for _server in self._server_list if _server.local_host is not None + ] + + @property + def local_bind_addresses(self): + """Return a list of (IP, port) pairs for the local side of the tunnels.""" + self._check_is_started() + return [_server.local_address for _server in self._server_list] + + @property + def tunnel_bindings(self): + """Return a dictionary containing the active local<>remote tunnel_bindings.""" + return dict( + (_server.remote_address, _server.local_address) + for _server in self._server_list + if self.tunnel_is_up[_server.local_address] + ) + + @property + def is_active(self) -> bool: + """Return True if the underlying SSH transport is up""" + if "_transport" in self.__dict__ and self._transport.is_active(): + return True + return False + + def _check_is_started(self) -> None: + if not self.is_active: # underlying transport not alive + msg = "Server is not started. Please .start() first!" + raise BaseSSHTunnelForwarderError(msg) + if not self.is_alive: + msg = "Tunnels are not started. Please .start() first!" + raise HandlerSSHTunnelForwarderError(msg) + + def __str__(self) -> str: + credentials = { + "password": self.ssh_password, + "pkeys": [(key.get_name(), hexlify(key.get_fingerprint())) for key in self.ssh_pkeys] + if any(self.ssh_pkeys) + else None, + } + _remove_none_values(credentials) + template = os.linesep.join( + [ + "{0} object", + "ssh gateway: {1}:{2}", + "proxy: {3}", + "username: {4}", + "authentication: {5}", + "hostkey: {6}", + "status: {7}started", + "keepalive messages: {8}", + "tunnel connection check: {9}", + "concurrent connections: {10}allowed", + "compression: {11}requested", + "logging level: {12}", + "local binds: {13}", + "remote binds: {14}", + ] + ) + return template.format( + self.__class__, + self.ssh_host, + self.ssh_port, + self.ssh_proxy.cmd[1] if self.ssh_proxy else "no", + self.ssh_username, + credentials, + self.ssh_host_key if self.ssh_host_key else "not checked", + "" if self.is_alive else "not ", + "disabled" if not self.set_keepalive else "every {0} sec".format(self.set_keepalive), + "disabled" if self.skip_tunnel_checkup else "enabled", + "" if self._threaded else "not ", + "" if self.compression else "not ", + os.environ.get("HYPERGLASS_LOG_LEVEL") or "INFO", + self._local_binds, + self._remote_binds, + ) + + def __repr__(self) -> str: + return self.__str__() + + def __enter__(self) -> "SSHTunnelForwarder": + try: + self.start() + return self + except KeyboardInterrupt: + self.__exit__() + + def __exit__(self, *args) -> None: + self._stop_transport() + + +def open_tunnel(*args, **kwargs) -> "SSHTunnelForwarder": + """Open an SSH Tunnel, wrapper for :class:`SSHTunnelForwarder`. + + Arguments: + destination (Optional[tuple]): + SSH server's IP address and port in the format + (``ssh_address``, ``ssh_port``) + + Keyword Arguments: + debug_level (Optional[int or str]): + log level for :class:`logging.Logger` instance, i.e. ``DEBUG`` + + skip_tunnel_checkup (boolean): + Enable/disable the local side check and populate + :attr:`~SSHTunnelForwarder.tunnel_is_up` + + Default: True + + .. versionadded:: 0.1.0 + + block_on_close (boolean): + Wait until all connections are done during close by changing the + value of :attr:`~SSHTunnelForwarder.block_on_close` + + Default: True + + .. note:: + A value of ``debug_level`` set to 1 == ``TRACE`` enables tracing mode + .. note:: + See :class:`SSHTunnelForwarder` for keyword arguments + + **Example**:: + + from sshtunnel import open_tunnel + + with open_tunnel(SERVER, + ssh_username=SSH_USER, + ssh_port=22, + ssh_password=SSH_PASSWORD, + remote_bind_address=(REMOTE_HOST, REMOTE_PORT), + local_bind_address=('', LOCAL_PORT)) as server: + def do_something(port): + pass + + print("LOCAL PORTS:", server.local_bind_port) + + do_something(server.local_bind_port) + """ + # Attach a console handler to the logger or create one if not passed + kwargs["logger"] = kwargs.get("logger") or log + + ssh_address_or_host = kwargs.pop("ssh_address_or_host", None) + # Check if deprecated arguments ssh_address or ssh_host were used + for deprecated_argument in ["ssh_address", "ssh_host"]: + ssh_address_or_host = SSHTunnelForwarder._process_deprecated( + ssh_address_or_host, deprecated_argument, kwargs + ) + + ssh_port = kwargs.pop("ssh_port", 22) + skip_tunnel_checkup = kwargs.pop("skip_tunnel_checkup", True) + block_on_close = kwargs.pop("block_on_close", _DAEMON) + if not args: + if isinstance(ssh_address_or_host, tuple): + args = (ssh_address_or_host,) + else: + args = ((ssh_address_or_host, ssh_port),) + forwarder = SSHTunnelForwarder(*args, **kwargs) + forwarder.skip_tunnel_checkup = skip_tunnel_checkup + forwarder.daemon_forward_servers = not block_on_close + forwarder.daemon_transport = not block_on_close + return forwarder + + +def _bindlist(input_str): + """Define type of data expected for remote and local bind address lists. + + Returns a tuple (ip_address, port) whose elements are (str, int) + """ + try: + ip_port = input_str.split(":") + if len(ip_port) == 1: + _ip = ip_port[0] + _port = None + else: + (_ip, _port) = ip_port + if not _ip and not _port: + raise AssertionError + elif not _port: + _port = "22" # default port if not given + return _ip, int(_port) + except ValueError: + raise argparse.ArgumentTypeError("Address tuple must be of type IP_ADDRESS:PORT") + except AssertionError: + raise argparse.ArgumentTypeError("Both IP:PORT can't be missing!") diff --git a/hyperglass/configuration/.gitignore b/hyperglass/configuration/.gitignore new file mode 100644 index 0000000..4ecc56a --- /dev/null +++ b/hyperglass/configuration/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +*.toml +*.yaml +*.test +configuration_old \ No newline at end of file diff --git a/hyperglass/configuration/__init__.py b/hyperglass/configuration/__init__.py new file mode 100644 index 0000000..4d42515 --- /dev/null +++ b/hyperglass/configuration/__init__.py @@ -0,0 +1,45 @@ +"""hyperglass Configuration.""" + +# Standard Library +import typing as t + +# Project +from hyperglass.state import use_state +from hyperglass.defaults.directives import init_builtin_directives + +# Local +from .validate import init_files, init_params, init_devices, init_ui_params, init_directives + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.directive import Directives + from hyperglass.models.config.params import Params + from hyperglass.models.config.devices import Devices + +__all__ = ("init_user_config",) + + +def init_user_config( + params: t.Optional["Params"] = None, + directives: t.Optional["Directives"] = None, + devices: t.Optional["Devices"] = None, +) -> None: + """Initialize all user configurations and add them to global state.""" + state = use_state() + init_files() + + _params = params or init_params() + builtins = init_builtin_directives() + _custom = directives or init_directives() + _directives = builtins + _custom + with state.cache.pipeline() as pipeline: + # Write params and directives to the cache first to avoid a race condition where ui_params + # or devices try to access params or directives before they're available. + pipeline.set("params", _params) + pipeline.set("directives", _directives) + + _devices = devices or init_devices() + ui_params = init_ui_params(params=_params, devices=_devices) + with state.cache.pipeline() as pipeline: + pipeline.set("devices", _devices) + pipeline.set("ui_params", ui_params) diff --git a/hyperglass/configuration/load.py b/hyperglass/configuration/load.py new file mode 100644 index 0000000..0ea6dab --- /dev/null +++ b/hyperglass/configuration/load.py @@ -0,0 +1,125 @@ +"""Collect configurations from files.""" + +# Standard Library +import typing as t +from pathlib import Path + +# Project +from hyperglass.log import log +from hyperglass.util import run_coroutine_in_new_thread +from hyperglass.settings import Settings +from hyperglass.constants import CONFIG_EXTENSIONS +from hyperglass.exceptions.private import ConfigError, ConfigMissing, ConfigLoaderMissing + +LoadedConfig = t.Union[t.Dict[str, t.Any], t.List[t.Any], t.Tuple[t.Any, ...]] + + +def find_path(file_name: str, *, required: bool) -> t.Union[Path, None]: + """Find the first matching configuration file.""" + for extension in CONFIG_EXTENSIONS: + path = Settings.app_path / f"{file_name}.{extension}" + if path.exists(): + return path + + if required: + raise ConfigMissing(file_name, app_path=Settings.app_path) + return None + + +def load_dsl(path: Path, *, empty_allowed: bool) -> LoadedConfig: + """Verify and load data from DSL (non-python) config files.""" + loader = None + if path.suffix in (".yaml", ".yml"): + try: + # Third Party + import yaml + + loader = yaml.safe_load + + except ImportError as err: + raise ConfigLoaderMissing(path) from err + elif path.suffix == ".toml": + try: + # Third Party + import toml + + loader = toml.load + + except ImportError as err: + raise ConfigLoaderMissing(path) from err + + elif path.suffix == ".json": + # Standard Library + import json + + loader = json.load + + if loader is None: + raise ConfigLoaderMissing(path) + + with path.open("r") as f: + data = loader(f) + if data is None and empty_allowed is False: + raise ConfigError( + "'{!s}' exists, but it is empty and is required to start hyperglass.".format(path), + ) + log.bind(path=path).debug("Loaded configuration") + return data or {} + + +def load_python(path: Path, *, empty_allowed: bool) -> LoadedConfig: + """Import configuration from a python configuration file.""" + # Standard Library + import inspect + from importlib.util import module_from_spec, spec_from_file_location + + # Load the file as a module. + name, _ = path.name.split(".") + spec = spec_from_file_location(name, location=path) + module = module_from_spec(spec) + spec.loader.exec_module(module) + # Get all exports that are named 'main' (any case). + exports = tuple(getattr(module, e, None) for e in dir(module) if e.lower() == "main") + if len(exports) < 1: + # Raise an error if there are no exports named main. + raise ConfigError( + f"'{path!s} exists', but it is missing a variable or function named 'main'" + ) + # Pick the first export named main. + main, *_ = exports + data = None + if isinstance(main, t.Callable): + if inspect.iscoroutinefunction(main): + # Resolve an async funcion. + data = run_coroutine_in_new_thread(main) + else: + # Resolve a standard function. + data = main() + elif isinstance(main, (t.Dict, t.List, t.Tuple)): + data = main + + if data is None and empty_allowed is False: + raise ConfigError(f"'{path!s} exists', but variable or function 'main' is an invalid type") + + log.bind(path=path).debug("Loaded configuration") + return data or {} + + +def load_config(name: str, *, required: bool) -> LoadedConfig: + """Load a configuration file.""" + path = find_path(name, required=required) + + if path is None and required is False: + return {} + + if path.suffix == ".py": + return load_python(path, empty_allowed=not required) + + if path.suffix.replace(".", "") in CONFIG_EXTENSIONS: + return load_dsl(path, empty_allowed=not required) + + raise ConfigError( + "{p} has an unsupported file extension. Must be one of {e}", + p=path, + e=", ".join(CONFIG_EXTENSIONS), + ) diff --git a/hyperglass/configuration/markdown.py b/hyperglass/configuration/markdown.py new file mode 100644 index 0000000..3df2762 --- /dev/null +++ b/hyperglass/configuration/markdown.py @@ -0,0 +1,26 @@ +"""Markdown processing utility functions.""" + +# Standard Library +import typing as t +from pathlib import Path + +if t.TYPE_CHECKING: + # Project + from hyperglass.models import HyperglassModel + + +def get_markdown(config: "HyperglassModel", default: str, params: t.Dict[str, t.Any]) -> str: + """Get markdown file if specified, or use default.""" + + if config.enable and config.file is not None: + # with config_path.file + if hasattr(config, "file") and isinstance(config.file, Path): + with config.file.open("r") as config_file: + md = config_file.read() + else: + md = default + + try: + return md.format(**params) + except KeyError: + return md diff --git a/hyperglass/configuration/tests/__init__.py b/hyperglass/configuration/tests/__init__.py new file mode 100644 index 0000000..233c95a --- /dev/null +++ b/hyperglass/configuration/tests/__init__.py @@ -0,0 +1 @@ +"""hyperglass configuration tests.""" diff --git a/hyperglass/configuration/tests/test_load.py b/hyperglass/configuration/tests/test_load.py new file mode 100644 index 0000000..dba92e9 --- /dev/null +++ b/hyperglass/configuration/tests/test_load.py @@ -0,0 +1,58 @@ +"""Test configuration file collection.""" + +# Standard Library +import tempfile +from pathlib import Path + +# Project +from hyperglass.settings import Settings + +# Local +from ..load import load_config + +TOML = """ +test = "from toml" +""" + +YAML = """ +test: from yaml +""" + +JSON = """ +{"test": "from json"} +""" + +PY_VARIABLE = """ +MAIN = {'test': 'from python variable'} +""" + +PY_FUNCTION = """ +def main(): + return {'test': 'from python function'} +""" + +PY_COROUTINE = """ +async def main(): + return {'test': 'from python coroutine'} +""" + +CASES = ( + ("test.toml", "from toml", TOML), + ("test.yaml", "from yaml", YAML), + ("test_py_variable.py", "from python variable", PY_VARIABLE), + ("test_py_function.py", "from python function", PY_FUNCTION), + ("test_py_coroutine.py", "from python coroutine", PY_COROUTINE), +) + + +def test_collect(monkeypatch): + with tempfile.TemporaryDirectory() as directory_name: + directory = Path(directory_name) + monkeypatch.setattr(Settings, "app_path", directory) + for name, value, data in CASES: + path = directory / Path(name) + with path.open("w") as p: + p.write(data) + loaded = load_config(path.stem, required=True) + assert loaded.get("test") is not None + assert loaded["test"] == value diff --git a/hyperglass/configuration/validate.py b/hyperglass/configuration/validate.py new file mode 100644 index 0000000..3accb7e --- /dev/null +++ b/hyperglass/configuration/validate.py @@ -0,0 +1,136 @@ +"""Import configuration files and run validation.""" + +# Third Party +from pydantic import ValidationError + +# Project +from hyperglass.log import log +from hyperglass.settings import Settings +from hyperglass.models.ui import UIParameters +from hyperglass.models.directive import Directive, Directives +from hyperglass.exceptions.private import ConfigError, ConfigInvalid +from hyperglass.models.config.params import Params +from hyperglass.models.config.devices import Devices + +# Local +from .load import load_config +from .markdown import get_markdown + +__all__ = ( + "init_devices", + "init_directives", + "init_files", + "init_params", + "init_ui_params", +) + + +def init_files() -> None: + """Check if required directories exist and if not, create them.""" + for directory in ("plugins", "static/images"): + path = Settings.app_path / directory + if not path.exists(): + path.mkdir(parents=True) + log.debug("Created directory", path=path) + + +def init_params() -> "Params": + """Validate & initialize configuration parameters.""" + user_config = load_config("config", required=False) + # Map imported user configuration to expected schema. + params = Params(**user_config) + + # # Set up file logging once configuration parameters are initialized. + # enable_file_logging( + # log_directory=params.logging.directory, + # log_format=params.logging.format, + # log_max_size=params.logging.max_size, + # debug=Settings.debug, + # ) + + # Set up syslog logging if enabled. + # if params.logging.syslog is not None and params.logging.syslog.enable: + # enable_syslog_logging( + # syslog_host=params.logging.syslog.host, + # syslog_port=params.logging.syslog.port, + # ) + + if params.logging.http is not None and params.logging.http.enable: + log.debug("HTTP logging is enabled") + + # Perform post-config initialization string formatting or other + # functions that require access to other config levels. E.g., + # something in 'params.web.text' needs to be formatted with a value + # from params. + try: + params.web.text.subtitle = params.web.text.subtitle.format( + **params.model_dump(exclude={"web", "queries", "messages"}) + ) + except KeyError: + pass + + return params + + +def init_directives() -> "Directives": + """Validate & initialize directives.""" + # Map imported user directives to expected schema. + directives = load_config("directives", required=False) + try: + directives = ( + Directive(id=name, **directive) + for name, directive in load_config("directives", required=False).items() + ) + + except ValidationError as err: + raise ConfigInvalid(errors=err.errors()) from err + + return Directives(*directives) + + +def init_devices() -> "Devices": + """Validate & initialize devices.""" + devices_config = load_config("devices", required=True) + items = [] + + # Support first matching main key name. + for key in ("main", "devices", "routers"): + if key in devices_config: + items = devices_config[key] + break + + if len(items) < 1: + raise ConfigError("No devices are defined in devices file") + + devices = Devices(*items) + log.debug("Initialized devices", devices=devices) + + return devices + + +def init_ui_params(*, params: "Params", devices: "Devices") -> "UIParameters": + """Validate & initialize UI parameters.""" + + # Project + from hyperglass.defaults import CREDIT + from hyperglass.constants import PARSED_RESPONSE_FIELDS, __version__ + + content_greeting = get_markdown( + config=params.web.greeting, + default="", + params={"title": params.web.greeting.title}, + ) + content_credit = CREDIT.format(version=__version__) + + _ui_params = params.frontend() + _ui_params["web"]["logo"]["light_format"] = params.web.logo.light.suffix + _ui_params["web"]["logo"]["dark_format"] = params.web.logo.dark.suffix + + return UIParameters( + **_ui_params, + version=__version__, + devices=devices.frontend(), + developer_mode=Settings.dev_mode, + parsed_data_fields=PARSED_RESPONSE_FIELDS, + content={"credit": content_credit, "greeting": content_greeting}, + ) diff --git a/hyperglass/console.py b/hyperglass/console.py new file mode 100755 index 0000000..bdc2d07 --- /dev/null +++ b/hyperglass/console.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""hyperglass CLI management tool.""" + +# Local +from .cli import run + +if __name__ == "__main__": + run() diff --git a/hyperglass/constants.py b/hyperglass/constants.py new file mode 100644 index 0000000..5fb0870 --- /dev/null +++ b/hyperglass/constants.py @@ -0,0 +1,89 @@ +"""Constant definitions used throughout the application.""" + +# Standard Library +from datetime import datetime + +__name__ = "hyperglass" +__version__ = "2.0.4" +__author__ = "Matt Love" +__copyright__ = f"Copyright {datetime.now().year} Matthew Love" +__license__ = "BSD 3-Clause Clear License" + +METADATA = (__name__, __version__, __author__, __copyright__, __license__) + +MIN_PYTHON_VERSION = (3, 8) + +MIN_NODE_VERSION = 18 + +TARGET_FORMAT_SPACE = ("huawei", "huawei_vrpv8") + +TARGET_JUNIPER_ASPATH = ("juniper", "juniper_junos") + +SUPPORTED_STRUCTURED_OUTPUT = ("juniper", "arista_eos") + +CONFIG_EXTENSIONS = ("py", "yaml", "yml", "json", "toml") + +STATUS_CODE_MAP = {"warning": 400, "error": 400, "danger": 500} + +DNS_OVER_HTTPS = { + "google": "https://dns.google/resolve", + "cloudflare": "https://cloudflare-dns.com/dns-query", +} + +PARSED_RESPONSE_FIELDS = ( + ("Prefix", "prefix", "left"), + ("Active", "active", None), + ("RPKI State", "rpki_state", "center"), + ("AS Path", "as_path", "left"), + ("Next Hop", "next_hop", "left"), + ("Origin", "source_as", None), + ("Weight", "weight", "center"), + ("Local Preference", "local_preference", "center"), + ("MED", "med", "center"), + ("Communities", "communities", "center"), + ("Originator", "source_rid", "right"), + ("Peer", "peer_rid", "right"), + ("Age", "age", "right"), +) + +SUPPORTED_QUERY_FIELDS = ("query_location", "query_type", "query_target", "query_vrf") +SUPPORTED_QUERY_TYPES = ( + "bgp_route", + "bgp_community", + "bgp_aspath", + "ping", + "traceroute", +) + +FUNC_COLOR_MAP = { + "primary": "cyan", + "secondary": "blue", + "success": "green", + "warning": "yellow", + "error": "orange", + "danger": "red", +} + +TRANSPORT_REST = ("frr_legacy", "bird_legacy") + +SCRAPE_HELPERS = { + "arista": "arista_eos", + "ios": "cisco_ios", + "juniper_junos": "juniper", + "junos": "juniper", + "mikrotik": "mikrotik_routeros", + "tsnr": "tnsr", +} + +DRIVER_MAP = { + "bird": "netmiko", + "frr": "netmiko", + "openbgpd": "netmiko", + "http": "hyperglass_http_client", +} + +LINUX_PLATFORMS = ( + "frr", + "bird", + "openbgpd", +) diff --git a/hyperglass/defaults/__init__.py b/hyperglass/defaults/__init__.py new file mode 100644 index 0000000..ce1f492 --- /dev/null +++ b/hyperglass/defaults/__init__.py @@ -0,0 +1,11 @@ +"""Default or built-in hyperglass data.""" + +# Local +from ._strings import CREDIT, DEFAULT_HELP, DEFAULT_TERMS, DEFAULT_DETAILS + +__all__ = ( + "CREDIT", + "DEFAULT_TERMS", + "DEFAULT_DETAILS", + "DEFAULT_HELP", +) diff --git a/hyperglass/defaults/_strings.py b/hyperglass/defaults/_strings.py new file mode 100644 index 0000000..e1ae145 --- /dev/null +++ b/hyperglass/defaults/_strings.py @@ -0,0 +1,83 @@ +"""Constant store for large default values.""" + +CREDIT = """ +Powered by [**hyperglass**](https://hyperglass.dev) version {version}. \ +Source code licensed [_BSD 3-Clause Clear_](https://hyperglass.dev/license/). +""" + +DEFAULT_TERMS = """ +By using {site_title}, you agree to be bound by the following terms of use: + +All queries executed on this page are logged for analysis and troubleshooting. \ +Users are prohibited from automating queries, or attempting to process queries in \ +bulk. This service is provided on a best effort basis, and {org_name} \ +makes no availability or performance warranties or guarantees whatsoever. +""" + +DEFAULT_DETAILS = { + "bgp_aspath": """ +{site_title} accepts the following `AS_PATH` regular expression patterns: + +| Expression | Match | +| :------------------- | :-------------------------------------------- | +| `_65000$` | Originated by 65000 | +| `^65000_` | Received from 65000 | +| `_65000_` | Via 65000 | +| `_65000_65001_` | Via 65000 and 65001 | +| `_65000(_.+_)65001$` | Anything from 65001 that passed through 65000 | +""", + "bgp_community": """ +{site_title} makes use of the following BGP communities: + +| Community | Description | +| :-------- | :---------- | +| `65000:1` | Example 1 | +| `65000:2` | Example 2 | +| `65000:3` | Example 3 | +""", + "bgp_route": """ +Performs BGP table lookup based on IPv4/IPv6 prefix. +""", + "ping": """ +Sends 5 ICMP echo requests to the target. +""", + "traceroute": """ +Performs UDP Based traceroute to the target. \ +For information about how to interpret traceroute results, [click here]\ +(https://hyperglass.dev/traceroute_nanog.pdf). +""", +} + +DEFAULT_HELP = """ +##### BGP Route + +Performs BGP table lookup based on IPv4/IPv6 prefix. + +--- + +##### BGP Community + +Performs BGP table lookup based on [Extended](https://tools.ietf.org/html/rfc4360) \ +or [Large](https://tools.ietf.org/html/rfc8195) community value. + +--- + +##### BGP AS Path + +Performs BGP table lookup based on `AS_PATH` regular expression. + +--- + +##### Ping + +Sends 5 ICMP echo requests to the target. + +--- + +##### Traceroute + +Performs UDP Based traceroute to the target. + +For information about how to interpret traceroute results, [click here]\ +(https://hyperglass.dev/traceroute_nanog.pdf). +""" diff --git a/hyperglass/defaults/directives/__init__.py b/hyperglass/defaults/directives/__init__.py new file mode 100644 index 0000000..8b29355 --- /dev/null +++ b/hyperglass/defaults/directives/__init__.py @@ -0,0 +1,26 @@ +"""Built-in hyperglass directives.""" + +# Standard Library +import pkgutil +import importlib +from pathlib import Path + +# Project +from hyperglass.log import log +from hyperglass.models.directive import Directives + + +def init_builtin_directives() -> "Directives": + """Find all directives and register them with global state manager.""" + directives_dir = Path(__file__).parent + directives = () + for _, name, __ in pkgutil.iter_modules([directives_dir]): + module = importlib.import_module(f"hyperglass.defaults.directives.{name}") + + if not all((hasattr(module, "__all__"), len(getattr(module, "__all__", ())) > 0)): + # Warn if there is no __all__ export or if it is empty. + log.warning("Module '{!s}' is missing an '__all__' export", module) + + exports = (getattr(module, p) for p in module.__all__ if hasattr(module, p)) + directives += (*exports,) + return Directives(*directives) diff --git a/hyperglass/defaults/directives/arista_eos.py b/hyperglass/defaults/directives/arista_eos.py new file mode 100644 index 0000000..5d1bad7 --- /dev/null +++ b/hyperglass/defaults/directives/arista_eos.py @@ -0,0 +1,174 @@ +"""Default Arista Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "AristaBGPRoute", + "AristaBGPASPath", + "AristaBGPCommunity", + "AristaPing", + "AristaTraceroute", + "AristaBGPRouteTable", + "AristaBGPASPathTable", + "AristaBGPCommunityTable", +) + +NAME = "Arista EOS" +PLATFORMS = ["arista_eos"] + +AristaBGPRoute = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="show ip bgp {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="show ipv6 bgp {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + table_output="__hyperglass_arista_eos_bgp_route_table__", + platforms=PLATFORMS, +) + +AristaBGPASPath = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "show ip bgp regexp {target}", + "show ipv6 bgp regexp {target}", + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + table_output="__hyperglass_arista_eos_bgp_aspath_table__", + platforms=PLATFORMS, +) + +AristaBGPCommunity = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "show ip bgp community {target}", + "show ipv6 bgp community {target}", + ], + ) + ], + field=Text(description="BGP Community String"), + table_output="__hyperglass_arista_eos_bgp_community_table__", + platforms=PLATFORMS, +) + + +AristaPing = BuiltinDirective( + id="__hyperglass_arista_eos_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping ip {target} source {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping ipv6 {target} source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +AristaTraceroute = BuiltinDirective( + id="__hyperglass_arista_eos_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="traceroute ip {target} source {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="traceroute ipv6 {target} source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +# Table Output Directives + +AristaBGPRouteTable = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="show ip bgp {target} | json", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="show ipv6 bgp {target} | json", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +AristaBGPASPathTable = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "show ip bgp regexp {target} | json", + "show ipv6 bgp regexp {target} | json", + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +AristaBGPCommunityTable = BuiltinDirective( + id="__hyperglass_arista_eos_bgp_community_table__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "show ip bgp community {target} | json", + "show ipv6 bgp community {target} | json", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/bird.py b/hyperglass/defaults/directives/bird.py new file mode 100644 index 0000000..14c9712 --- /dev/null +++ b/hyperglass/defaults/directives/bird.py @@ -0,0 +1,110 @@ +"""Default BIRD Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "BIRD_BGPASPath", + "BIRD_BGPCommunity", + "BIRD_BGPRoute", + "BIRD_Ping", + "BIRD_Traceroute", +) + +NAME = "BIRD" +PLATFORMS = ["bird"] + +BIRD_BGPRoute = BuiltinDirective( + id="__hyperglass_bird_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command='birdc "show route all where {target} ~ net"', + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command='birdc "show route all where {target} ~ net"', + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +BIRD_BGPASPath = BuiltinDirective( + id="__hyperglass_bird_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'birdc "show route all where bgp_path ~ {target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +BIRD_BGPCommunity = BuiltinDirective( + id="__hyperglass_bird_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'birdc "show route all where {target} ~ bgp_community"', + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) + +BIRD_Ping = BuiltinDirective( + id="__hyperglass_bird_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping -4 -c 5 -I {source4} {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping -6 -c 5 -I {source6} {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +BIRD_Traceroute = BuiltinDirective( + id="__hyperglass_bird_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="traceroute -4 -w 1 -q 1 -s {source4} {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="traceroute -6 -w 1 -q 1 -s {source6} {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/cisco_ios.py b/hyperglass/defaults/directives/cisco_ios.py new file mode 100644 index 0000000..f4327c9 --- /dev/null +++ b/hyperglass/defaults/directives/cisco_ios.py @@ -0,0 +1,112 @@ +"""Default Cisco IOS Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "CiscoIOS_BGPASPath", + "CiscoIOS_BGPCommunity", + "CiscoIOS_BGPRoute", + "CiscoIOS_Ping", + "CiscoIOS_Traceroute", +) + +NAME = "Cisco IOS" +PLATFORMS = ["cisco_ios"] + +CiscoIOS_BGPRoute = BuiltinDirective( + id="__hyperglass_cisco_ios_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="show bgp ipv4 unicast {target} | exclude pathid:|Epoch", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="show bgp ipv6 unicast {target} | exclude pathid:|Epoch", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +CiscoIOS_BGPASPath = BuiltinDirective( + id="__hyperglass_cisco_ios_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'show bgp ipv4 unicast quote-regexp "{target}"', + 'show bgp ipv6 unicast quote-regexp "{target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +CiscoIOS_BGPCommunity = BuiltinDirective( + id="__hyperglass_cisco_ios_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "show bgp ipv4 unicast community {target}", + "show bgp ipv6 unicast community {target}", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) + +CiscoIOS_Ping = BuiltinDirective( + id="__hyperglass_cisco_ios_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping {target} repeat 5 source {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping ipv6 {target} repeat 5 source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +CiscoIOS_Traceroute = BuiltinDirective( + id="__hyperglass_cisco_ios_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="traceroute {target} timeout 1 probe 2 source {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="traceroute ipv6 {target} timeout 1 probe 2 source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/cisco_nxos.py b/hyperglass/defaults/directives/cisco_nxos.py new file mode 100644 index 0000000..1c7a8ea --- /dev/null +++ b/hyperglass/defaults/directives/cisco_nxos.py @@ -0,0 +1,112 @@ +"""Default Cisco NX-OS Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "CiscoNXOS_BGPASPath", + "CiscoNXOS_BGPCommunity", + "CiscoNXOS_BGPRoute", + "CiscoNXOS_Ping", + "CiscoNXOS_Traceroute", +) + +NAME = "Cisco NX-OS" +PLATFORMS = ["cisco_nxos"] + +CiscoNXOS_BGPRoute = BuiltinDirective( + id="__hyperglass_cisco_nxos_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="show bgp ipv4 unicast {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="show bgp ipv6 unicast {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +CiscoNXOS_BGPASPath = BuiltinDirective( + id="__hyperglass_cisco_nxos_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'show bgp ipv4 unicast regexp "{target}"', + 'show bgp ipv6 unicast regexp "{target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +CiscoNXOS_BGPCommunity = BuiltinDirective( + id="__hyperglass_cisco_nxos_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "show bgp ipv4 unicast community {target}", + "show bgp ipv6 unicast community {target}", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) + +CiscoNXOS_Ping = BuiltinDirective( + id="__hyperglass_cisco_nxos_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping {target} source {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping6 {target} source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +CiscoNXOS_Traceroute = BuiltinDirective( + id="__hyperglass_cisco_nxos_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="traceroute {target} source {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="traceroute6 {target} source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/cisco_xr.py b/hyperglass/defaults/directives/cisco_xr.py new file mode 100644 index 0000000..8450180 --- /dev/null +++ b/hyperglass/defaults/directives/cisco_xr.py @@ -0,0 +1,112 @@ +"""Default Cisco IOS-XR Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "CiscoXR_BGPASPath", + "CiscoXR_BGPCommunity", + "CiscoXR_BGPRoute", + "CiscoXR_Ping", + "CiscoXR_Traceroute", +) + +NAME = "Cisco IOS-XR" +PLATFORMS = ["cisco_xr"] + +CiscoXR_BGPRoute = BuiltinDirective( + id="__hyperglass_cisco_xr_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="show bgp ipv4 unicast {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="show bgp ipv6 unicast {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +CiscoXR_BGPASPath = BuiltinDirective( + id="__hyperglass_cisco_xr_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "show bgp ipv4 unicast regexp {target}", + "show bgp ipv6 unicast regexp {target}", + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +CiscoXR_BGPCommunity = BuiltinDirective( + id="__hyperglass_cisco_xr_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "show bgp ipv4 unicast community {target}", + "show bgp ipv6 unicast community {target}", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) + +CiscoXR_Ping = BuiltinDirective( + id="__hyperglass_cisco_xr_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping ipv4 {target} count 5 source {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping ipv6 {target} count 5 source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +CiscoXR_Traceroute = BuiltinDirective( + id="__hyperglass_cisco_xr_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="traceroute ipv4 {target} timeout 1 probe 2 source {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="traceroute ipv6 {target} timeout 1 probe 2 source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/frr.py b/hyperglass/defaults/directives/frr.py new file mode 100644 index 0000000..09e8f2d --- /dev/null +++ b/hyperglass/defaults/directives/frr.py @@ -0,0 +1,112 @@ +"""Default FRRouting Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "FRRouting_BGPASPath", + "FRRouting_BGPCommunity", + "FRRouting_BGPRoute", + "FRRouting_Ping", + "FRRouting_Traceroute", +) + +NAME = "FRRouting" +PLATFORMS = ["frr"] + +FRRouting_BGPRoute = BuiltinDirective( + id="__hyperglass_frr_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command='vtysh -c "show bgp ipv4 unicast {target}"', + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command='vtysh -c "show bgp ipv6 unicast {target}"', + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +FRRouting_BGPASPath = BuiltinDirective( + id="__hyperglass_frr_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'vtysh -c "show bgp ipv4 unicast regexp {target}"', + 'vtysh -c "show bgp ipv6 unicast regexp {target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +FRRouting_BGPCommunity = BuiltinDirective( + id="__hyperglass_frr_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'vtysh -c "show bgp ipv4 unicast community {target}"', + 'vtysh -c "show bgp ipv6 unicast community {target}"', + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) + +FRRouting_Ping = BuiltinDirective( + id="__hyperglass_frr_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping -4 -c 5 -I {source4} {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping -6 -c 5 -I {source6} {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +FRRouting_Traceroute = BuiltinDirective( + id="__hyperglass_frr_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="traceroute -4 -w 1 -q 1 -s {source4} {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="traceroute -6 -w 1 -q 1 -s {source6} {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/huawei.py b/hyperglass/defaults/directives/huawei.py new file mode 100644 index 0000000..b517503 --- /dev/null +++ b/hyperglass/defaults/directives/huawei.py @@ -0,0 +1,112 @@ +"""Default Huawei Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "Huawei_BGPASPath", + "Huawei_BGPCommunity", + "Huawei_BGPRoute", + "Huawei_Ping", + "Huawei_Traceroute", +) + +NAME = "Huawei VRP" +PLATFORMS = ["huawei", "huawei_vrpv8"] + +Huawei_BGPRoute = BuiltinDirective( + id="__hyperglass_huawei_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="display bgp routing-table {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="display bgp ipv6 routing-table {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +Huawei_BGPASPath = BuiltinDirective( + id="__hyperglass_huawei_bgp_aspath__", + 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, +) + +Huawei_BGPCommunity = BuiltinDirective( + id="__hyperglass_huawei_bgp_community__", + 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, +) + +Huawei_Ping = BuiltinDirective( + id="__hyperglass_huawei_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping -c 5 -a {source4} {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping ipv6 -c 5 -a {source6} {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +Huawei_Traceroute = BuiltinDirective( + id="__hyperglass_huawei_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="tracert -q 2 -f 1 -a {source4} {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="tracert -q 2 -f 1 -a {source6} {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/juniper.py b/hyperglass/defaults/directives/juniper.py new file mode 100644 index 0000000..dda31e1 --- /dev/null +++ b/hyperglass/defaults/directives/juniper.py @@ -0,0 +1,174 @@ +"""Default Juniper Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "JuniperBGPRoute", + "JuniperBGPASPath", + "JuniperBGPCommunity", + "JuniperPing", + "JuniperTraceroute", + "JuniperBGPRouteTable", + "JuniperBGPASPathTable", + "JuniperBGPCommunityTable", +) + +NAME = "Juniper Junos" +PLATFORMS = ["juniper", "juniper_junos"] + +JuniperBGPRoute = BuiltinDirective( + id="__hyperglass_juniper_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="show route protocol bgp table inet.0 {target} detail", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="show route protocol bgp table inet6.0 {target} detail", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + table_output="__hyperglass_juniper_bgp_route_table__", + platforms=PLATFORMS, +) + +JuniperBGPASPath = BuiltinDirective( + id="__hyperglass_juniper_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'show route protocol bgp table inet.0 aspath-regex "{target}"', + 'show route protocol bgp table inet6.0 aspath-regex "{target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + table_output="__hyperglass_juniper_bgp_aspath_table__", + platforms=PLATFORMS, +) + +JuniperBGPCommunity = BuiltinDirective( + id="__hyperglass_juniper_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'show route protocol bgp table inet.0 community "{target}" detail', + 'show route protocol bgp table inet6.0 community "{target}" detail', + ], + ) + ], + field=Text(description="BGP Community String"), + table_output="__hyperglass_juniper_bgp_community_table__", + platforms=PLATFORMS, +) + + +JuniperPing = BuiltinDirective( + id="__hyperglass_juniper_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping inet {target} count 5 source {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping inet6 {target} count 5 source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +JuniperTraceroute = BuiltinDirective( + id="__hyperglass_juniper_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="traceroute inet {target} wait 1 source {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="traceroute inet6 {target} wait 2 source {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +# Table Output Directives + +JuniperBGPRouteTable = BuiltinDirective( + id="__hyperglass_juniper_bgp_route_table__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="show route protocol bgp table inet.0 {target} best detail | display xml", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="show route protocol bgp table inet6.0 {target} best detail | display xml", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +JuniperBGPASPathTable = BuiltinDirective( + id="__hyperglass_juniper_bgp_aspath_table__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'show route protocol bgp table inet.0 aspath-regex "{target}" detail | display xml', + 'show route protocol bgp table inet6.0 aspath-regex "{target}" detail | display xml', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +JuniperBGPCommunityTable = BuiltinDirective( + id="__hyperglass_juniper_bgp_community_table__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "show route protocol bgp table inet.0 community {target} detail | display xml", + "show route protocol bgp table inet6.0 community {target} detail | display xml", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/mikrotik.py b/hyperglass/defaults/directives/mikrotik.py new file mode 100644 index 0000000..16c948a --- /dev/null +++ b/hyperglass/defaults/directives/mikrotik.py @@ -0,0 +1,112 @@ +"""Default Mikrotik Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "Mikrotik_BGPASPath", + "Mikrotik_BGPCommunity", + "Mikrotik_BGPRoute", + "Mikrotik_Ping", + "Mikrotik_Traceroute", +) + +NAME = "Mikrotik" +PLATFORMS = ["mikrotik_routeros", "mikrotik_switchos"] + +Mikrotik_BGPRoute = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ip route print where dst-address={target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ipv6 route print where dst-address={target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +Mikrotik_BGPASPath = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "ip route print where bgp-as-path={target}", + "ipv6 route print where bgp-as-path={target}", + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +Mikrotik_BGPCommunity = BuiltinDirective( + id="__hyperglass_mikrotik_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "ip route print where bgp-communities={target}", + "ipv6 route print where bgp-communities={target}", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) + +Mikrotik_Ping = BuiltinDirective( + id="__hyperglass_mikrotik_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping src-address={source4} count=5 {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping src-address={source6} count=5 {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +Mikrotik_Traceroute = BuiltinDirective( + id="__hyperglass_mikrotik_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="tool traceroute src-address={source4} timeout=1 duration=5 count=1 {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="tool traceroute src-address={source6} timeout=1 duration=5 count=1 {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/nokia_sros.py b/hyperglass/defaults/directives/nokia_sros.py new file mode 100644 index 0000000..1e8ea5e --- /dev/null +++ b/hyperglass/defaults/directives/nokia_sros.py @@ -0,0 +1,110 @@ +"""Default Nokia SR-OS Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "NokiaSROS_BGPASPath", + "NokiaSROS_BGPCommunity", + "NokiaSROS_BGPRoute", + "NokiaSROS_Ping", + "NokiaSROS_Traceroute", +) + +NAME = "Nokia SR OS" +PLATFORMS = ["nokia_sros"] + +NokiaSROS_BGPRoute = BuiltinDirective( + id="__hyperglass_nokia_sros_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="/show router bgp routes {target} ipv4 hunt", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="/show router bgp routes {target} ipv6 hunt", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +NokiaSROS_BGPASPath = BuiltinDirective( + id="__hyperglass_nokia_sros_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "/show router bgp routes aspath-regex {target}", + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +NokiaSROS_BGPCommunity = BuiltinDirective( + id="__hyperglass_nokia_sros_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "/show router bgp routes community {target}", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) + +NokiaSROS_Ping = BuiltinDirective( + id="__hyperglass_nokia_sros_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="/ping {target} source-address {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="/ping {target} source-address {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +NokiaSROS_Traceroute = BuiltinDirective( + id="__hyperglass_nokia_sros_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="/traceroute {target} source-address {source4} wait 2 seconds", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="/traceroute {target} source-address {source6} wait 2 seconds", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/openbgpd.py b/hyperglass/defaults/directives/openbgpd.py new file mode 100644 index 0000000..71e7077 --- /dev/null +++ b/hyperglass/defaults/directives/openbgpd.py @@ -0,0 +1,112 @@ +"""Default FRRouting Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "OpenBGPD_BGPASPath", + "OpenBGPD_BGPCommunity", + "OpenBGPD_BGPRoute", + "OpenBGPD_Ping", + "OpenBGPD_Traceroute", +) + +NAME = "OpenBGPD" +PLATFORMS = ["openbgpd"] + +OpenBGPD_BGPRoute = BuiltinDirective( + id="__hyperglass_openbgpd_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="bgpctl show rib inet {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="bgpctl show rib inet6 {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +OpenBGPD_BGPASPath = BuiltinDirective( + id="__hyperglass_openbgpd_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "bgpctl show rib inet as {target}", + "bgpctl show rib inet6 as {target}", + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +OpenBGPD_BGPCommunity = BuiltinDirective( + id="__hyperglass_openbgpd_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "bgpctl show rib inet community {target}", + "bgpctl show rib inet6 community {target}", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) + +OpenBGPD_Ping = BuiltinDirective( + id="__hyperglass_openbgpd_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping -4 -c 5 -I {source4} {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping -6 -c 5 -I {source6} {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +OpenBGPD_Traceroute = BuiltinDirective( + id="__hyperglass_openbgpd_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="traceroute -4 -w 1 -q 1 -s {source4} {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="traceroute -6 -w 1 -q 1 -s {source6} {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/tnsr.py b/hyperglass/defaults/directives/tnsr.py new file mode 100644 index 0000000..cea4681 --- /dev/null +++ b/hyperglass/defaults/directives/tnsr.py @@ -0,0 +1,112 @@ +"""Default TNSR Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "TNSR_BGPASPath", + "TNSR_BGPCommunity", + "TNSR_BGPRoute", + "TNSR_Ping", + "TNSR_Traceroute", +) + +NAME = "TNSR" +PLATFORMS = ["tnsr"] + +TNSR_BGPRoute = BuiltinDirective( + id="__hyperglass_tnsr_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command='dataplane shell sudo vtysh -c "show bgp ipv4 unicast {target}"', + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command='dataplane shell sudo vtysh -c "show bgp ipv6 unicast {target}"', + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +TNSR_BGPASPath = BuiltinDirective( + id="__hyperglass_tnsr_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'dataplane shell sudo vtysh -c "show bgp ipv4 unicast regexp {target}"', + 'dataplane shell sudo vtysh -c "show bgp ipv6 unicast regexp {target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +TNSR_BGPCommunity = BuiltinDirective( + id="__hyperglass_tnsr_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'dataplane shell sudo vtysh -c "show bgp ipv4 unicast community {target}"', + 'dataplane shell sudo vtysh -c "show bgp ipv6 unicast community {target}"', + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) + +TNSR_Ping = BuiltinDirective( + id="__hyperglass_tnsr_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping {target} ipv4 source {source4} count 5 timeout 1", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping {target} ipv6 source {source6} count 5 timeout 1", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +TNSR_Traceroute = BuiltinDirective( + id="__hyperglass_tnsr_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="traceroute {target} ipv4 source {source4} timeout 1 waittime 1", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="traceroute {target} ipv6 source {source6} timeout 1 waittime 1", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/defaults/directives/vyos.py b/hyperglass/defaults/directives/vyos.py new file mode 100644 index 0000000..fcfb4ac --- /dev/null +++ b/hyperglass/defaults/directives/vyos.py @@ -0,0 +1,112 @@ +"""Default VyOS Directives.""" + +# Project +from hyperglass.models.directive import ( + Text, + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + BuiltinDirective, +) + +__all__ = ( + "VyOS_BGPASPath", + "VyOS_BGPCommunity", + "VyOS_BGPRoute", + "VyOS_Ping", + "VyOS_Traceroute", +) + +NAME = "VyOS" +PLATFORMS = ["vyos"] + +VyOS_BGPRoute = BuiltinDirective( + id="__hyperglass_vyos_bgp_route__", + name="BGP Route", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="show ip bgp {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="show ipv6 bgp {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +VyOS_BGPASPath = BuiltinDirective( + id="__hyperglass_vyos_bgp_aspath__", + name="BGP AS Path", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + 'show ip bgp regexp "{target}"', + 'show ipv6 bgp regexp "{target}"', + ], + ) + ], + field=Text(description="AS Path Regular Expression"), + platforms=PLATFORMS, +) + +VyOS_BGPCommunity = BuiltinDirective( + id="__hyperglass_vyos_bgp_community__", + name="BGP Community", + rules=[ + RuleWithPattern( + condition="*", + action="permit", + commands=[ + "show ip bgp community {target}", + "show ipv6 bgp community {target}", + ], + ) + ], + field=Text(description="BGP Community String"), + platforms=PLATFORMS, +) + +VyOS_Ping = BuiltinDirective( + id="__hyperglass_vyos_ping__", + name="Ping", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="ping {target} count 5 interface {source4}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="ping {target} count 5 interface {source6}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) + +VyOS_Traceroute = BuiltinDirective( + id="__hyperglass_vyos_traceroute__", + name="Traceroute", + rules=[ + RuleWithIPv4( + condition="0.0.0.0/0", + action="permit", + command="mtr -4 -G 1 -c 1 -w -o SAL -a {source4} {target}", + ), + RuleWithIPv6( + condition="::/0", + action="permit", + command="mtr -6 -G 1 -c 1 -w -o SAL -a {source6} {target}", + ), + ], + field=Text(description="IP Address, Prefix, or Hostname"), + platforms=PLATFORMS, +) diff --git a/hyperglass/exceptions/__init__.py b/hyperglass/exceptions/__init__.py new file mode 100644 index 0000000..0f3fb9e --- /dev/null +++ b/hyperglass/exceptions/__init__.py @@ -0,0 +1,10 @@ +"""Custom exceptions for hyperglass.""" + +# Local +from ._common import HyperglassError, PublicHyperglassError, PrivateHyperglassError + +__all__ = ( + "HyperglassError", + "PublicHyperglassError", + "PrivateHyperglassError", +) diff --git a/hyperglass/exceptions/_common.py b/hyperglass/exceptions/_common.py new file mode 100644 index 0000000..9963324 --- /dev/null +++ b/hyperglass/exceptions/_common.py @@ -0,0 +1,191 @@ +"""Custom exceptions for hyperglass.""" + +# Standard Library +import json as _json +from typing import Any, Dict, List, Union, Literal, Optional, Set + +# Third Party +from pydantic import ValidationError + +# Project +from hyperglass.log import log +from hyperglass.util import get_fmt_keys, repr_from_attrs +from hyperglass.constants import STATUS_CODE_MAP + +ErrorLevel = Literal["danger", "warning"] + + +class HyperglassError(Exception): + """hyperglass base exception.""" + + def __init__( + self, + message: str = "", + level: ErrorLevel = "warning", + keywords: Optional[List[str]] = None, + ) -> None: + """Initialize the hyperglass base exception class.""" + self._message = message + self._level = level + self._keywords = keywords or [] + if self._level == "warning": + log.error(str(self)) + elif self._level == "danger": + log.critical(str(self)) + else: + log.info(str(self)) + + def __str__(self) -> str: + """Return the instance's error message.""" + return self._message + + def __repr__(self) -> str: + """Return the instance's severity & error message in a string.""" + return repr_from_attrs(self, ("_message", "level", "keywords"), strip="_") + + def dict(self) -> Dict[str, Union[str, List[str]]]: + """Return the instance's attributes as a dictionary.""" + return { + "message": self._message, + "level": self._level, + "keywords": self.keywords, + } + + def json(self) -> str: + """Return the instance's attributes as a JSON object.""" + return _json.dumps(self.__dict__()) + + @staticmethod + def _safe_format(template: str, **kwargs: Dict[str, str]) -> str: + """Safely format a string template from keyword arguments.""" + + keys = get_fmt_keys(template) + for key in keys: + if key not in kwargs: + kwargs.pop(key) + else: + kwargs[key] = str(kwargs[key]) + return template.format(**kwargs) + + def _parse_pydantic_errors(*errors: Dict[str, Any]) -> str: + errs = ("\n",) + + for err in errors: + loc = " → ".join(str(loc) for loc in err["loc"]) + errs += (f'Field: {loc}\n Error: {err["msg"]}\n',) + + return "\n".join(errs) + + def _process_keywords(self) -> None: + out: Set[str] = set() + for val in self._keywords: + if isinstance(val, str): + out.add(val) + elif isinstance(val, list): + for v in val: + out.add(v) + else: + out.add(str(val)) + self._keywords = list(out) + + @property + def message(self) -> str: + """Return the instance's `message` attribute.""" + return self._message + + @property + def level(self) -> str: + """Return the instance's `level` attribute.""" + return self._level + + @property + def keywords(self) -> List[str]: + """Return the instance's `keywords` attribute.""" + self._process_keywords() + return self._keywords + + @property + def status_code(self) -> int: + """Return HTTP status code based on level level.""" + return STATUS_CODE_MAP.get(self._level, 500) + + +class PublicHyperglassError(HyperglassError): + """Base exception class for user-facing errors. + + Error text should be defined in + `hyperglass.configuration.params.messages` and associated with the + exception class at start time. + """ + + _level = "warning" + _message_template = "Something went wrong." + _original_template_name: str = "" + + def __init_subclass__( + cls, *, template: Optional[str] = None, level: Optional[ErrorLevel] = None + ) -> None: + """Override error attributes from subclass.""" + + if template is not None: + cls._message_template = template + cls._original_template_name = template + if level is not None: + cls._level = level + + def __init__(self, **kwargs: str) -> None: + """Format error message with keyword arguments.""" + # Project + from hyperglass.state import use_state + + if "error" in kwargs: + error = kwargs.pop("error") + error = self._safe_format(str(error), **kwargs) + kwargs["error"] = error + + template = self._message_template + + (messages := use_state("params").messages) + if messages.has(self._original_template_name): + template = messages[self._original_template_name] + if "error" in kwargs and "({error})" not in template: + template += " ({error})" + self._message = self._safe_format(template, **kwargs) + self._keywords = list(kwargs.values()) + super().__init__(message=self._message, level=self._level, keywords=self._keywords) + + +class PrivateHyperglassError(HyperglassError): + """Base exception class for internal system errors. + + Error text is dynamic based on the exception being caught. + """ + + _level = "warning" + + def _parse_validation_error(self, err: ValidationError) -> str: + errors = err.errors() + parsed = { + k: ", ".join(str(loc) for t in errors for loc in t["loc"] if t["type"] == k) + for k in {e["type"] for e in errors} + } + return ", ".join(parsed.values()) + + def __init_subclass__(cls, *, level: Optional[ErrorLevel] = None) -> None: + """Override error attributes from subclass.""" + if level is not None: + cls._level = level + + def __init__(self, message: str, **kwargs: Any) -> None: + """Format error message with keyword arguments.""" + if "error" in kwargs: + error = kwargs.pop("error") + error = self._safe_format(str(error), **kwargs) + kwargs["error"] = error + + if isinstance(message, ValidationError): + message = self._parse_validation_error(message) + + self._message = self._safe_format(message, **kwargs) + self._keywords = list(kwargs.values()) + super().__init__(message=self._message, level=self._level, keywords=self._keywords) diff --git a/hyperglass/exceptions/private.py b/hyperglass/exceptions/private.py new file mode 100644 index 0000000..80b5c8d --- /dev/null +++ b/hyperglass/exceptions/private.py @@ -0,0 +1,115 @@ +"""Internal/private exceptions.""" + +# Standard Library +from typing import Any, Dict, List +from pathlib import Path + +# Project +from hyperglass.constants import CONFIG_EXTENSIONS + +# Local +from ._common import ErrorLevel, PrivateHyperglassError + + +class ExternalError(PrivateHyperglassError): + """Raised when an error during a connection to an external service occurs.""" + + def __init__(self, message: str, level: ErrorLevel, **kwargs: Dict[str, Any]) -> None: + """Set level according to level argument.""" + self._level = level + super().__init__(message, **kwargs) + + +class UnsupportedDevice(PrivateHyperglassError): + """Raised when an input platform is not in the supported platform list.""" + + def __init__(self, platform: str) -> None: + """Show the unsupported device type and a list of supported drivers.""" + # Third Party + from netmiko.ssh_dispatcher import CLASS_MAPPER # type: ignore + + # Project + from hyperglass.constants import DRIVER_MAP + + sorted_drivers = sorted([*DRIVER_MAP.keys(), *CLASS_MAPPER.keys()]) + driver_list = "\n - ".join(("", *sorted_drivers)) + super().__init__(message=f"'{platform}' is not supported. Must be one of:{driver_list}") + + +class InputValidationError(PrivateHyperglassError): + """Raised when a validation check fails. + + This needs to be separate from `hyperglass.exceptions.public` for + circular import reasons. + """ + + kwargs: Dict[str, Any] + + def __init__(self, **kwargs: Dict[str, Any]) -> None: + """Set kwargs instance attribute so it can be consumed later. + + `hyperglass.exceptions.public.InputInvalid` will be raised from + these kwargs. + """ + self.kwargs = kwargs + super().__init__(message="", **kwargs) + + +class ConfigInvalid(PrivateHyperglassError): + """Raised when a config item fails type or option validation.""" + + def __init__(self, errors: List[Dict[str, Any]]) -> None: + """Parse Pydantic ValidationError.""" + + super().__init__(message=self._parse_pydantic_errors(*errors)) + + +class ConfigMissing(PrivateHyperglassError): + """Raised when a required config file or item is missing or undefined.""" + + def __init__(self, file_name: str, *, app_path: Path) -> None: + """Customize error message.""" + message = " ".join( + ( + file_name.capitalize(), + "file is missing in", + f"'{app_path!s}', and is required to start hyperglass.", + "Supported file names are:", + ", ".join(f"'{file_name}.{e}'" for e in CONFIG_EXTENSIONS), + ". Please consult the installation documentation.", + ) + ) + super().__init__(message) + + +class ConfigLoaderMissing(PrivateHyperglassError): + """Raised when a configuration file is using a file extension that requires a missing loader.""" + + def __init__(self, path: Path, /) -> None: + """Customize error message.""" + message = "'{path}' requires a {loader} loader, but it is not installed" + super().__init__(message=message, path=path, loader=path.suffix.strip(".")) + + +class ConfigError(PrivateHyperglassError): + """Raised for generic user-config issues.""" + + +class UnsupportedError(PrivateHyperglassError): + """Raised when an unsupported action or request occurs.""" + + +class ParsingError(PrivateHyperglassError): + """Raised when there is a problem parsing a structured response.""" + + +class DependencyError(PrivateHyperglassError): + """Raised when a dependency is missing, not running, or on the wrong version.""" + + +class PluginError(PrivateHyperglassError): + """Raised when a plugin error occurs.""" + + +class StateError(PrivateHyperglassError): + """Raised when an error occurs while fetching state from Redis.""" diff --git a/hyperglass/exceptions/public.py b/hyperglass/exceptions/public.py new file mode 100644 index 0000000..912e63b --- /dev/null +++ b/hyperglass/exceptions/public.py @@ -0,0 +1,148 @@ +"""User-facing/Public exceptions.""" + +# Standard Library +from typing import TYPE_CHECKING, Any, Dict, Optional + +# Local +from ._common import PublicHyperglassError + +if TYPE_CHECKING: + # Project + from hyperglass.models.api.query import Query + from hyperglass.models.config.devices import Device + + +class ScrapeError( + PublicHyperglassError, + template="connection_error", + level="danger", +): + """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) + + +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) + + +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) + + +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) + + +class InvalidQuery(PublicHyperglassError, template="request_timeout"): + """Raised when input validation fails.""" + + def __init__( + self, *, error: Optional[str] = None, query: "Query", **kwargs: Dict[str, Any] + ) -> None: + """Initialize parent error.""" + + kwargs = { + "query_type": query.query_type, + "target": query.query_target, + "error": str(error), + **kwargs, + } + + super().__init__(**kwargs) + + +class NotFound(PublicHyperglassError, template="not_found"): + """Raised when an object is not found.""" + + def __init__(self, type: str, name: str, **kwargs: Dict[str, str]) -> None: + """Initialize parent error.""" + super().__init__(type=type, name=name, **kwargs) + + +class QueryLocationNotFound(NotFound): + """Raised when a query location is not found.""" + + def __init__(self, location: Any, **kwargs: Dict[str, Any]) -> None: + """Initialize a NotFound error for a query location.""" + # Project + from hyperglass.state import use_state + + (text := use_state("params").web.text) + + super().__init__(type=text.query_location, name=str(location), **kwargs) + + +class QueryTypeNotFound(NotFound): + """Raised when a query type is not found.""" + + def __init__(self, query_type: Any, **kwargs: Dict[str, Any]) -> None: + """Initialize a NotFound error for a query type.""" + # Project + from hyperglass.state import use_state + + (text := use_state("params").web.text) + super().__init__(type=text.query_type, name=str(query_type), **kwargs) + + +class InputInvalid(PublicHyperglassError, template="invalid_input"): + """Raised when input validation fails.""" + + def __init__( + self, *, error: Optional[Any] = None, target: str, **kwargs: Dict[str, Any] + ) -> None: + """Initialize parent error.""" + + kwargs = {"target": target, "error": str(error), **kwargs} + + super().__init__(**kwargs) + + +class InputNotAllowed(PublicHyperglassError, template="target_not_allowed"): + """Raised when input validation fails due to a configured check.""" + + def __init__( + self, *, error: Optional[str] = None, query: "Query", **kwargs: Dict[str, Any] + ) -> None: + """Initialize parent error.""" + + kwargs = { + "query_type": query.query_type, + "target": query.query_target, + "error": str(error), + **kwargs, + } + + super().__init__(**kwargs) + + +class ResponseEmpty(PublicHyperglassError, template="no_output"): + """Raised when hyperglass can connect to the device but the response is empty.""" + + def __init__( + self, *, error: Optional[str] = None, query: "Query", **kwargs: Dict[str, Any] + ) -> None: + """Initialize parent error.""" + + kwargs = { + "query_type": query.query_type, + "target": query.query_target, + "error": str(error), + **kwargs, + } + + super().__init__(**kwargs) diff --git a/hyperglass/execution/__init__.py b/hyperglass/execution/__init__.py new file mode 100644 index 0000000..ec33dd5 --- /dev/null +++ b/hyperglass/execution/__init__.py @@ -0,0 +1,5 @@ +"""Validate, construct, execute queries. + +Constructs SSH commands or API call parameters based on front end +input, executes the commands/calls, returns the output to front end. +""" diff --git a/hyperglass/execution/drivers/__init__.py b/hyperglass/execution/drivers/__init__.py new file mode 100644 index 0000000..1ebbab4 --- /dev/null +++ b/hyperglass/execution/drivers/__init__.py @@ -0,0 +1,12 @@ +"""Individual transport driver classes & subclasses.""" + +# Local +from ._common import Connection +from .http_client import HttpClient +from .ssh_netmiko import NetmikoConnection + +__all__ = ( + "Connection", + "HttpClient", + "NetmikoConnection", +) diff --git a/hyperglass/execution/drivers/_common.py b/hyperglass/execution/drivers/_common.py new file mode 100644 index 0000000..bec6778 --- /dev/null +++ b/hyperglass/execution/drivers/_common.py @@ -0,0 +1,48 @@ +"""Base Connection Class.""" + +# Standard Library +import typing as t +from abc import ABC, abstractmethod + +# Project +from hyperglass.types import Series +from hyperglass.plugins import OutputPluginManager + +# Local +from ._construct import Construct + +if t.TYPE_CHECKING: + # Project + from hyperglass.compat import SSHTunnelForwarder + from hyperglass.models.api import Query + from hyperglass.models.data import OutputDataModel + from hyperglass.models.config.devices import Device + + +class Connection(ABC): + """Base transport driver class.""" + + def __init__(self, device: "Device", query_data: "Query") -> None: + """Initialize connection to device.""" + self.device = device + self.query_data = query_data + self.query_type = self.query_data.query_type + self.query_target = self.query_data.query_target + self._query = Construct(device=self.device, query=self.query_data) + self.query = self._query.queries() + self.plugin_manager = OutputPluginManager() + + @abstractmethod + def setup_proxy(self: "Connection") -> "SSHTunnelForwarder": + """Return a preconfigured sshtunnel.SSHTunnelForwarder instance.""" + pass + + async def response(self, output: Series[str]) -> t.Union["OutputDataModel", str]: + """Send output through common parsers.""" + + response = self.plugin_manager.execute(output=output, query=self.query_data) + + if response is None: + response = () + + return response diff --git a/hyperglass/execution/drivers/_construct.py b/hyperglass/execution/drivers/_construct.py new file mode 100644 index 0000000..86032bc --- /dev/null +++ b/hyperglass/execution/drivers/_construct.py @@ -0,0 +1,227 @@ +"""Construct SSH command/API parameters from validated query data. + +Accepts filtered & validated input from execute.py, constructs SSH +command for Netmiko library or API call parameters for supported +hyperglass API modules. +""" + +# Standard Library +import re +import json as _json +import typing as t +import ipaddress + +# Project +from hyperglass.log import log +from hyperglass.util import get_fmt_keys +from hyperglass.constants import TRANSPORT_REST, TARGET_FORMAT_SPACE +from hyperglass.exceptions.public import InputInvalid +from hyperglass.exceptions.private import ConfigError + +if t.TYPE_CHECKING: + # Third Party + from loguru import Logger + + # Project + from hyperglass.models.api.query import Query + from hyperglass.models.directive import Directive + from hyperglass.models.config.devices import Device + +FormatterCallback = t.Callable[[str], t.Union[t.List[str], str]] + + +class Construct: + """Construct SSH commands/REST API parameters from validated query data.""" + + directive: "Directive" + device: "Device" + query: "Query" + transport: str + target: str + _log: "Logger" + + def __init__(self, device: "Device", query: "Query"): + """Initialize command construction.""" + self._log = log.bind(type=query.query_type, target=query.query_target) + self._log.debug("Constructing query") + self.query = query + self.device = device + self.target = self.query.query_target + self.directive = query.directive + + # Set transport method based on NOS type + self.transport = "scrape" + if self.device.platform in TRANSPORT_REST: + self.transport = "rest" + + # Remove slashes from target for required platforms + if self.device.platform in TARGET_FORMAT_SPACE: + self.target = re.sub(r"\/", r" ", str(self.query.query_target)) + + with Formatter(self.query) as formatter: + self.target = formatter(self.prepare_target()) + + def prepare_target(self) -> t.Union[t.List[str], str]: + """Format the query target based on directive parameters.""" + if isinstance(self.query.query_target, t.List): + # Directive can accept multiple values in a single command. + if self.directive.multiple: + return self.directive.multiple_separator.join(self.query.query_target) + # Target is an array of one, return single item. + if len(self.query.query_target) == 1: + return self.query.query_target[0] + # Directive commands should be run once for each item in the target. + + return self.query.query_target + + def json(self, afi): + """Return JSON version of validated query for REST devices.""" + self._log.debug("Building JSON query") + return _json.dumps( + { + "query_type": self.query.query_type, + "vrf": self.query.query_vrf.name, + "afi": afi.protocol, + "source": str(afi.source_address), + "target": str(self.target), + } + ) + + def format(self, command: str) -> str: + """Return formatted command for 'Scrape' endpoints (SSH).""" + keys = get_fmt_keys(command) + attrs = {k: v for k, v in self.device.attrs.items() if k in keys} + for key in [k for k in keys if k != "target" and k != "mask"]: + if key not in attrs: + raise ConfigError( + ("Command '{c}' has attribute '{k}', " "which is missing from device '{d}'"), + level="danger", + c=self.directive.name, + k=key, + d=self.device.name, + ) + + mask = ipaddress.ip_address("255.255.255.255") + try: + network = ipaddress.ip_network(self.target) + if network.version == 4 and network.network_address != network.broadcast_address: + # Network is an IPv4 network with more than one host. + mask = network.netmask + except ValueError: + pass + + return command.format(target=self.target, mask=mask, **attrs) + + def queries(self): + """Return queries for each enabled AFI.""" + query = [] + + rules = [r for r in self.directive.rules if r._passed is True] + if len(rules) < 1: + raise InputInvalid( + error="No validation rules matched target '{target}'", + target=self.query.query_target, + ) + + for rule in [r for r in self.directive.rules if r._passed is True]: + for command in rule.commands: + query.append(self.format(command)) + self._log.bind(constructed_query=query).debug("Constructed query") + return query + + +class Formatter: + """Modify query target based on the device's NOS requirements and the query type.""" + + def __init__(self, query: "Query") -> None: + """Initialize target formatting.""" + self.query = query + self.platform = query.device.platform + self.query_type = query.query_type + + def __enter__(self): + """Get the relevant formatter.""" + return self._get_formatter() + + def __exit__(self, exc_type, exc_value, exc_traceback): + """Handle context exit.""" + if exc_type is not None: + log.error(exc_traceback) + pass + + def _get_formatter(self): + if self.platform in ("juniper", "juniper_junos"): + if self.query_type == "bgp_aspath": + return self._with_formatter(self._juniper_bgp_aspath) + if self.platform in ("bird", "bird_ssh"): + if self.query_type == "bgp_aspath": + return self._with_formatter(self._bird_bgp_aspath) + if self.query_type == "bgp_community": + return self._with_formatter(self._bird_bgp_community) + return self._with_formatter(self._default) + + def _default(self, target: str) -> str: + """Don't format targets by default.""" + return target + + def _with_formatter(self, formatter: t.Callable[[str], str]) -> FormatterCallback: + result: FormatterCallback + if isinstance(self.query.query_target, t.List): + result = lambda s: [formatter(i) for i in s] + result = lambda s: formatter(s) + return result + + def _juniper_bgp_aspath(self, target: str) -> str: + """Convert from Cisco AS_PATH format to Juniper format.""" + query = str(target) + asns = re.findall(r"\d+", query) + was_modified = False + + if bool(re.match(r"^\_", query)): + # Replace `_65000` with `.* 65000` + asns.insert(0, r".*") + was_modified = True + + if bool(re.match(r".*(\_)$", query)): + # Replace `65000_` with `65000 .*` + asns.append(r".*") + was_modified = True + + if was_modified: + modified = " ".join(asns) + log.bind(original=target, modified=modified).debug("Modified target") + return modified + + return query + + def _bird_bgp_aspath(self, target: str) -> str: + """Convert from Cisco AS_PATH format to BIRD format.""" + + # Extract ASNs from query target string + asns = re.findall(r"\d+", target) + was_modified = False + + if bool(re.match(r"^\_", target)): + # Replace `_65000` with `.* 65000` + asns.insert(0, "*") + was_modified = True + + if bool(re.match(r".*(\_)$", target)): + # Replace `65000_` with `65000 .*` + asns.append("*") + was_modified = True + + asns.insert(0, "[=") + asns.append("=]") + + result = " ".join(asns) + + if was_modified: + log.bind(original=target, modified=result).debug("Modified target") + + return result + + def _bird_bgp_community(self, target: str) -> str: + """Convert from standard community format to BIRD format.""" + parts = target.split(":") + return f'({",".join(parts)})' diff --git a/hyperglass/execution/drivers/http_client.py b/hyperglass/execution/drivers/http_client.py new file mode 100644 index 0000000..c2ad2ae --- /dev/null +++ b/hyperglass/execution/drivers/http_client.py @@ -0,0 +1,117 @@ +"""Interact with an http-based device.""" + +# Standard Library +import typing as t + +# Third Party +import httpx + +# Project +from hyperglass.util import get_fmt_keys +from hyperglass.exceptions.public import AuthError, RestError, DeviceTimeout, ResponseEmpty + +# Local +from ._common import Connection + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.api import Query + from hyperglass.models.config.devices import Device + from hyperglass.models.config.http_client import HttpConfiguration + + +class HttpClient(Connection): + """Interact with an http-based device.""" + + config: "HttpConfiguration" + client: httpx.AsyncClient + + def __init__(self, device: "Device", query_data: "Query") -> None: + """Initialize base connection and set http config & client.""" + super().__init__(device, query_data) + self.config = device.http + self.client = self.config.create_client(device=device) + + def setup_proxy(self: "Connection"): + """HTTP Client does not support SSH proxies.""" + raise NotImplementedError("HTTP Client does not support SSH proxies.") + + def _query_params(self) -> t.Dict[str, str]: + if self.config.query is None: + return { + self.config._attribute_map.query_target: self.query_data.query_target, + self.config._attribute_map.query_location: self.query_data.query_location, + self.config._attribute_map.query_type: self.query_data.query_type, + } + if isinstance(self.config.query, t.Dict): + return { + key: value.format( + **{ + str(v): str(getattr(self.query_data, k, None)) + for k, v in self.config.attribute_map.model_dump().items() + if v in get_fmt_keys(value) + } + ) + for key, value in self.config.query.items() + } + return {} + + def _body(self) -> t.Dict[str, t.Union[t.Dict[str, t.Any], str]]: + data = { + self.config._attribute_map.query_target: self.query_data.query_target, + self.config._attribute_map.query_location: self.query_data.query_location, + self.config._attribute_map.query_type: self.query_data.query_type, + } + if self.config.body_format == "json": + return {"json": data} + + if self.config.body_format == "yaml": + # Third Party + import yaml + + return {"content": yaml.dump(data), "headers": {"content-type": "text/yaml"}} + + if self.config.body_format == "xml": + # Third Party + import xmltodict # type: ignore + + return { + "content": xmltodict.unparse({"query": data}), + "headers": {"content-type": "application/xml"}, + } + if self.config.body_format == "text": + return {"data": data} + + return {} + + async def collect(self, *args: t.Any, **kwargs: t.Any) -> t.Iterable: + """Collect response data from an HTTP endpoint.""" + + query = self._query_params() + responses = () + + async with self.client as client: + body = {} + if self.config.method in ("POST", "PATCH", "PUT"): + body = self._body() + + try: + response: httpx.Response = await client.request( + method=self.config.method, url=self.config.path, params=query, **body + ) + response.raise_for_status() + data = response.text.strip() + + if len(data) == 0: + raise ResponseEmpty(query=self.query_data) + + responses += (data,) + + except httpx.TimeoutException as error: + raise DeviceTimeout(error=error, device=self.device) from error + + except httpx.HTTPStatusError as error: + if error.response.status_code == 401: + raise AuthError(error=error, device=self.device) from error + raise RestError(error=error, device=self.device) from error + return responses diff --git a/hyperglass/execution/drivers/ssh.py b/hyperglass/execution/drivers/ssh.py new file mode 100644 index 0000000..13e070e --- /dev/null +++ b/hyperglass/execution/drivers/ssh.py @@ -0,0 +1,61 @@ +"""Common Classes or Utilities for SSH Drivers.""" + +# Standard Library +from typing import TYPE_CHECKING + +# Project +from hyperglass.log import log +from hyperglass.state import use_state +from hyperglass.compat import BaseSSHTunnelForwarderError, open_tunnel +from hyperglass.exceptions.public import ScrapeError + +# Local +from ._common import Connection + +if TYPE_CHECKING: + # Project + from hyperglass.compat import SSHTunnelForwarder + + +class SSHConnection(Connection): + """Base class for SSH drivers.""" + + def setup_proxy(self) -> "SSHTunnelForwarder": + """Return a preconfigured sshtunnel.SSHTunnelForwarder instance.""" + + proxy = self.device.proxy + params = use_state("params") + + def opener(): + """Set up an SSH tunnel according to a device's configuration.""" + tunnel_kwargs = { + "ssh_username": proxy.credential.username, + "remote_bind_address": (self.device._target, self.device.port), + "local_bind_address": ("localhost", 0), + "skip_tunnel_checkup": False, + "gateway_timeout": params.request_timeout - 2, + } + if proxy.credential._method == "password": + # Use password auth if no key is defined. + tunnel_kwargs["ssh_password"] = proxy.credential.password.get_secret_value() + else: + # Otherwise, use key auth. + tunnel_kwargs["ssh_pkey"] = proxy.credential.key.as_posix() + if proxy.credential._method == "encrypted_key": + # If the key is encrypted, use the password field as the + # private key password. + tunnel_kwargs[ + "ssh_private_key_password" + ] = proxy.credential.password.get_secret_value() + try: + return open_tunnel(proxy._target, proxy.port, **tunnel_kwargs) + + except BaseSSHTunnelForwarderError as scrape_proxy_error: + log.bind(device=self.device.name, proxy=proxy.name).error( + "Failed to connect to device via proxy" + ) + raise ScrapeError( + error=scrape_proxy_error, device=self.device + ) from scrape_proxy_error + + return opener diff --git a/hyperglass/execution/drivers/ssh_netmiko.py b/hyperglass/execution/drivers/ssh_netmiko.py new file mode 100644 index 0000000..e96f68c --- /dev/null +++ b/hyperglass/execution/drivers/ssh_netmiko.py @@ -0,0 +1,106 @@ +"""Netmiko-Specific Classes & Utilities. + +https://github.com/ktbyers/netmiko +""" + +# Standard Library +import math +from typing import Iterable + +# Third Party +from netmiko import ( # type: ignore + ConnectHandler, + NetMikoTimeoutException, + NetMikoAuthenticationException, +) + +# Project +from hyperglass.log import log +from hyperglass.state import use_state +from hyperglass.exceptions.public import AuthError, DeviceTimeout, ResponseEmpty + +# Local +from .ssh import SSHConnection + +netmiko_device_globals = { + # Netmiko doesn't currently handle Mikrotik echo verification well, + # see ktbyers/netmiko#1600 + "mikrotik_routeros": {"global_cmd_verify": False}, + "mikrotik_switchos": {"global_cmd_verify": False}, +} + +netmiko_device_send_args = {} + + +class NetmikoConnection(SSHConnection): + """Handle a device connection via Netmiko.""" + + async def collect(self, host: str = None, port: int = None) -> Iterable: + """Connect directly to a device. + + Directly connects to the router via Netmiko library, returns the + command output. + """ + params = use_state("params") + _log = log.bind( + device=self.device.name, + address=f"{host}:{port}", + proxy=str(self.device.proxy.address) if self.device.proxy is not None else None, + ) + + _log.debug("Connecting to device") + + global_args = netmiko_device_globals.get(self.device.platform, {}) + + send_args = netmiko_device_send_args.get(self.device.platform, {}) + + driver_kwargs = { + "host": host or self.device._target, + "port": port or self.device.port, + "device_type": self.device.get_device_type(), + "username": self.device.credential.username, + "global_delay_factor": 0.1, + "timeout": math.floor(params.request_timeout * 1.25), + "session_timeout": math.ceil(params.request_timeout - 1), + **global_args, + **self.device.driver_config, + } + + if "_telnet" in self.device.platform: + # Telnet devices with a low delay factor (default) tend to + # throw login errors. + driver_kwargs["global_delay_factor"] = 2 + + if self.device.credential._method == "password": + # Use password auth if no key is defined. + driver_kwargs["password"] = self.device.credential.password.get_secret_value() + else: + # Otherwise, use key auth. + driver_kwargs["use_keys"] = True + driver_kwargs["key_file"] = self.device.credential.key + if self.device.credential._method == "encrypted_key": + # If the key is encrypted, use the password field as the + # private key password. + driver_kwargs["passphrase"] = self.device.credential.password.get_secret_value() + + try: + nm_connect_direct = ConnectHandler(**driver_kwargs) + + responses = () + + for query in self.query: + raw = nm_connect_direct.send_command(query, **send_args) + responses += (raw,) + + nm_connect_direct.disconnect() + + 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 + + if not responses: + raise ResponseEmpty(query=self.query_data) + + return responses diff --git a/hyperglass/execution/drivers/tests/__init__.py b/hyperglass/execution/drivers/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperglass/execution/drivers/tests/test_construct.py b/hyperglass/execution/drivers/tests/test_construct.py new file mode 100644 index 0000000..7e5adee --- /dev/null +++ b/hyperglass/execution/drivers/tests/test_construct.py @@ -0,0 +1,90 @@ +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.api import Query +from hyperglass.configuration import init_ui_params +from hyperglass.models.directive import Directives +from hyperglass.models.config.params import Params +from hyperglass.models.config.devices import Devices + +# Local +from .._construct import Construct + +if t.TYPE_CHECKING: + # Project + from hyperglass.state import HyperglassState + + +@pytest.fixture +def params(): + return {} + + +@pytest.fixture +def devices(): + return [ + { + "name": "test1", + "address": "127.0.0.1", + "credential": {"username": "", "password": ""}, + "platform": "juniper", + "attrs": {"source4": "192.0.2.1", "source6": "2001:db8::1"}, + "directives": ["juniper_bgp_route"], + } + ] + + +@pytest.fixture +def directives(): + return [ + { + "juniper_bgp_route": { + "name": "BGP Route", + "field": {"description": "test"}, + } + } + ] + + +@pytest.fixture +def state( + *, + params: t.Dict[str, t.Any], + directives: t.Sequence[t.Dict[str, t.Any]], + devices: t.Sequence[t.Dict[str, t.Any]], +) -> t.Generator["HyperglassState", None, None]: + """Test fixture to initialize Redis store.""" + _state = use_state() + _params = Params(**params) + _directives = Directives.new(*directives) + + with _state.cache.pipeline() as pipeline: + # Write params and directives to the cache first to avoid a race condition where ui_params + # or devices try to access params or directives before they're available. + pipeline.set("params", _params) + pipeline.set("directives", _directives) + + _devices = Devices(*devices) + ui_params = init_ui_params(params=_params, devices=_devices) + + with _state.cache.pipeline() as pipeline: + pipeline.set("devices", _devices) + pipeline.set("ui_params", ui_params) + + yield _state + _state.clear() + + +def test_construct(state): + query = Query( + queryLocation="test1", + queryTarget="192.0.2.0/24", + queryType="juniper_bgp_route", + ) + constructor = Construct(device=state.devices["test1"], query=query) + assert constructor.target == "192.0.2.0/24" diff --git a/hyperglass/execution/main.py b/hyperglass/execution/main.py new file mode 100644 index 0000000..08c9483 --- /dev/null +++ b/hyperglass/execution/main.py @@ -0,0 +1,90 @@ +"""Execute validated & constructed query on device. + +Accepts input from front end application, validates the input and +returns errors if input is invalid. Passes validated parameters to +construct.py, which is used to build & run the Netmiko connections or +http client API calls, returns the output back to the front end. +""" + +# Standard Library +import signal +from typing import TYPE_CHECKING, Any, Dict, Union, Callable + +# Project +from hyperglass.log import log +from hyperglass.state import use_state +from hyperglass.util.typing import is_series +from hyperglass.exceptions.public import DeviceTimeout, ResponseEmpty + +if TYPE_CHECKING: + from hyperglass.models.api import Query + from .drivers import Connection + from hyperglass.models.data import OutputDataModel + +# Local +from .drivers import HttpClient, NetmikoConnection + + +def map_driver(driver_name: str) -> "Connection": + """Get the correct driver class based on the driver name.""" + + if driver_name == "hyperglass_http_client": + return HttpClient + + return NetmikoConnection + + +def handle_timeout(**exc_args: Any) -> Callable: + """Return a function signal can use to raise a timeout exception.""" + + def handler(*args: Any, **kwargs: Any) -> None: + raise DeviceTimeout(**exc_args) + + return handler + + +async def execute(query: "Query") -> Union["OutputDataModel", str]: + """Initiate query validation and execution.""" + params = use_state("params") + output = params.messages.general + _log = log.bind(query=query.summary(), device=query.device.id) + _log.debug("") + + mapped_driver = map_driver(query.device.driver) + driver: "Connection" = mapped_driver(query.device, query) + + signal.signal( + signal.SIGALRM, + handle_timeout(error=TimeoutError("Connection timed out"), device=query.device), + ) + 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) + + return output diff --git a/hyperglass/external/__init__.py b/hyperglass/external/__init__.py new file mode 100644 index 0000000..4d9d22e --- /dev/null +++ b/hyperglass/external/__init__.py @@ -0,0 +1,21 @@ +"""Functions & handlers for external data.""" + +# Local +from .rpki import rpki_state +from .slack import SlackHook +from .generic import BaseExternal +from .msteams import MSTeams +from .bgptools import network_info, network_info_sync +from .webhooks import Webhook +from .http_client import HTTPClient + +__all__ = ( + "BaseExternal", + "HTTPClient", + "MSTeams", + "network_info_sync", + "network_info", + "rpki_state", + "SlackHook", + "Webhook", +) diff --git a/hyperglass/external/_base.py b/hyperglass/external/_base.py new file mode 100644 index 0000000..60c35da --- /dev/null +++ b/hyperglass/external/_base.py @@ -0,0 +1,367 @@ +"""Session handler for external http data sources.""" + +# Standard Library +import re +import json as _json +import socket +import typing as t +from json import JSONDecodeError +from socket import gaierror + +# Third Party +import httpx + +# Project +from hyperglass.log import log +from hyperglass.util import parse_exception, repr_from_attrs +from hyperglass.settings import Settings +from hyperglass.constants import __version__ +from hyperglass.models.fields import JsonValue, HttpMethod, Primitives +from hyperglass.exceptions.private import ExternalError + +if t.TYPE_CHECKING: + # Standard Library + from types import TracebackType + + # Project + from hyperglass.exceptions._common import ErrorLevel + from hyperglass.models.config.logging import Http + +D = t.TypeVar("D", bound=t.Dict) + + +def _prepare_dict(_dict: D) -> D: + return _json.loads(_json.dumps(_dict, default=str)) + + +class BaseExternal: + """Base session handler.""" + + def __init__( + self, + base_url: str, + config: t.Optional["Http"] = None, + uri_prefix: str = "", + uri_suffix: str = "", + verify_ssl: bool = True, + timeout: int = 10, + parse: bool = True, + ) -> None: + """Initialize connection instance.""" + self.__name__ = getattr(self, "name", "BaseExternal") + self.name = self.__name__ + self.config = config + self.base_url = base_url.strip("/") + self.uri_prefix = uri_prefix.strip("/") + self.uri_suffix = uri_suffix.strip("/") + self.verify_ssl = verify_ssl + self.timeout = timeout + self.parse = parse + + context = httpx.create_ssl_context(verify=verify_ssl) + + if Settings.ca_cert is not None: + context.load_verify_locations(cafile=str(Settings.ca_cert)) + + client_kwargs = { + "base_url": self.base_url, + "timeout": self.timeout, + "verify": context, + } + + self._session = httpx.Client(**client_kwargs) + self._asession = httpx.AsyncClient(**client_kwargs) + + @classmethod + def __init_subclass__( + cls: "BaseExternal", name: t.Optional[str] = None, **kwargs: t.Any + ) -> None: + """Set correct subclass name.""" + super().__init_subclass__(**kwargs) + cls.name = name or cls.__name__ + + async def __aenter__(self: "BaseExternal") -> "BaseExternal": + """Test connection on entry.""" + available = await self._atest() + + if available: + log.bind(url=self.base_url).debug("Initialized session") + return self + raise self._exception(f"Unable to create session to {self.name}") + + async def __aexit__( + self: "BaseExternal", + exc_type: t.Optional[t.Type[BaseException]] = None, + exc_value: t.Optional[BaseException] = None, + traceback: t.Optional["TracebackType"] = None, + ) -> True: + """Close connection on exit.""" + log.bind(url=self.base_url).debug("Closing session") + + if exc_type is not None: + log.error(str(exc_value)) + + await self._asession.aclose() + if exc_value is not None: + raise exc_value + return True + + def __enter__(self: "BaseExternal") -> "BaseExternal": + """Test connection on entry.""" + available = self._test() + + if available: + log.bind(url=self.base_url).debug("Initialized session") + return self + raise self._exception(f"Unable to create session to {self.name}") + + def __exit__( + self: "BaseExternal", + exc_type: t.Optional[t.Type[BaseException]] = None, + exc_value: t.Optional[BaseException] = None, + exc_traceback: t.Optional["TracebackType"] = None, + ) -> bool: + """Close connection on exit.""" + if exc_type is not None: + log.error(str(exc_value)) + self._session.close() + if exc_value is not None: + raise exc_value + return True + + def __repr__(self: "BaseExternal") -> str: + """Return user friendly representation of instance.""" + return repr_from_attrs(self, ("name", "base_url", "config", "parse")) + + def _exception( + self: "BaseExternal", + message: str, + exc: t.Optional[BaseException] = None, + level: "ErrorLevel" = "warning", + **kwargs: t.Any, + ) -> ExternalError: + """Add stringified exception to message if passed.""" + if exc is not None: + message = f"{message!s}: {exc!s}" + + return ExternalError(message=message, level=level, **kwargs) + + def _parse_response(self: "BaseExternal", response: httpx.Response) -> t.Any: + if self.parse: + parsed = {} + try: + parsed = response.json() + except JSONDecodeError: + try: + parsed = _json.loads(response) + except (JSONDecodeError, TypeError): + parsed = {"data": response.text} + else: + parsed = response + return parsed + + def _test(self: "BaseExternal") -> bool: + """Open a low-level connection to the base URL to ensure its port is open.""" + log.bind(url=self.base_url).debug("Testing connection") + + try: + # Parse out just the hostname from a URL string. + # E.g. `https://www.example.com` becomes `www.example.com` + test_host = re.sub(r"http(s)?\:\/\/", "", self.base_url) + + # Create a generic socket object + test_socket = socket.socket() + + # Try opening a low-level socket to make sure it's even + # listening on the port prior to trying to use it. + test_socket.connect((test_host, 443)) + + # Properly shutdown & close the socket. + test_socket.shutdown(1) + test_socket.close() + + except gaierror as err: + # Raised if the target isn't listening on the port + raise self._exception( + f"{self.name!r} appears to be unreachable at {self.base_url!r}", err + ) from None + + return True + + async def _atest(self: "BaseExternal") -> bool: + """Open a low-level connection to the base URL to ensure its port is open.""" + return self._test() + + def _build_request(self: "BaseExternal", **kwargs: t.Any) -> t.Dict[str, t.Any]: + """Process requests parameters into structure usable by http library.""" + # Standard Library + from operator import itemgetter + + supported_methods = ("GET", "POST", "PUT", "DELETE", "HEAD", "PATCH") + + ( + method, + endpoint, + item, + headers, + params, + data, + timeout, + response_required, + ) = itemgetter(*kwargs.keys())(kwargs) + + if method.upper() not in supported_methods: + raise self._exception( + f'Method must be one of {", ".join(supported_methods)}. ' f"Got: {str(method)}" + ) + + endpoint = "/".join( + i + for i in ( + "", + self.uri_prefix.strip("/"), + endpoint.strip("/"), + self.uri_suffix.strip("/"), + item, + ) + if i + ) + + request = { + "method": method, + "url": endpoint, + "headers": {"user-agent": f"hyperglass/{__version__}"}, + } + + if headers is not None: + request.update({"headers": headers}) + + if params is not None: + params = {str(k): str(v) for k, v in params.items() if v is not None} + request["params"] = params + + if data is not None: + if not isinstance(data, dict): + raise self._exception(f"Data must be a dict, got: {str(data)}") + request["json"] = _prepare_dict(data) + + if timeout is not None: + if not isinstance(timeout, int): + try: + timeout = int(timeout) + except TypeError as err: + raise self._exception(f"Timeout must be an int, got: {str(timeout)}") from err + request["timeout"] = timeout + return request + + async def _arequest( # noqa: C901 + self: "BaseExternal", + method: HttpMethod, + endpoint: str, + item: t.Union[str, int, None] = None, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + response_required: bool = False, + ) -> t.Any: + """Run HTTP POST operation.""" + request = self._build_request( + method=method, + endpoint=endpoint, + item=item, + headers=None, + params=params, + data=data, + timeout=timeout, + response_required=response_required, + ) + + try: + response = await self._asession.request(**request) + + if response.status_code not in range(200, 300): + status = httpx.codes(response.status_code) + error = self._parse_response(response) + raise self._exception( + f'{status.name.replace("_", " ")}: {error}', level="danger" + ) from None + + except httpx.HTTPError as http_err: + raise self._exception(parse_exception(http_err), level="danger") from None + + return self._parse_response(response) + + async def _aget(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return await self._arequest(method="GET", endpoint=endpoint, **kwargs) + + async def _apost(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return await self._arequest(method="POST", endpoint=endpoint, **kwargs) + + async def _aput(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return await self._arequest(method="PUT", endpoint=endpoint, **kwargs) + + async def _adelete(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return await self._arequest(method="DELETE", endpoint=endpoint, **kwargs) + + async def _apatch(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return await self._arequest(method="PATCH", endpoint=endpoint, **kwargs) + + async def _ahead(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return await self._arequest(method="HEAD", endpoint=endpoint, **kwargs) + + def _request( # noqa: C901 + self: "BaseExternal", + method: HttpMethod, + endpoint: str, + item: t.Union[str, int, None] = None, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + response_required: bool = False, + ) -> t.Any: + """Run HTTP POST operation.""" + request = self._build_request( + method=method, + endpoint=endpoint, + item=item, + headers=None, + params=params, + data=data, + timeout=timeout, + response_required=response_required, + ) + + try: + response = self._session.request(**request) + + if response.status_code not in range(200, 300): + status = httpx.codes(response.status_code) + error = self._parse_response(response) + raise self._exception( + f'{status.name.replace("_", " ")}: {error}', level="danger" + ) from None + + except httpx.HTTPError as http_err: + raise self._exception(parse_exception(http_err), level="danger") from None + + return self._parse_response(response) + + def _get(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return self._request(method="GET", endpoint=endpoint, **kwargs) + + def _post(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return self._request(method="POST", endpoint=endpoint, **kwargs) + + def _put(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return self._request(method="PUT", endpoint=endpoint, **kwargs) + + def _delete(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return self._request(method="DELETE", endpoint=endpoint, **kwargs) + + def _patch(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return self._request(method="PATCH", endpoint=endpoint, **kwargs) + + def _head(self: "BaseExternal", endpoint: str, **kwargs: t.Any) -> t.Any: + return self._request(method="HEAD", endpoint=endpoint, **kwargs) diff --git a/hyperglass/external/bgptools.py b/hyperglass/external/bgptools.py new file mode 100644 index 0000000..1ea1cee --- /dev/null +++ b/hyperglass/external/bgptools.py @@ -0,0 +1,178 @@ +"""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 = {k: "None" for k in DEFAULT_KEYS} + 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: {k: "" for k in 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/generic.py b/hyperglass/external/generic.py new file mode 100644 index 0000000..b36cc91 --- /dev/null +++ b/hyperglass/external/generic.py @@ -0,0 +1,37 @@ +"""Session handler for Generic HTTP API endpoint.""" + +# Standard Library +import typing as t + +# Project +from hyperglass.log import log +from hyperglass.models.webhook import Webhook + +# Local +from ._base import BaseExternal + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.config.logging import Http + + +class GenericHook(BaseExternal, name="Generic"): + """Slack session handler.""" + + def __init__(self: "GenericHook", config: "Http") -> None: + """Initialize external base class with http connection details.""" + + super().__init__(base_url=f"{config.host.scheme}://{config.host.host}", config=config) + + async def send(self: "GenericHook", query: t.Dict[str, t.Any]): + """Send an incoming webhook to http endpoint.""" + + payload = Webhook(**query) + log.bind(host=self.config.host.host, payload=payload).debug("Sending request") + + return await self._apost( + endpoint=self.config.host.path, + headers=self.config.headers, + params=self.config.params, + data=payload.export_dict(), + ) diff --git a/hyperglass/external/http_client.py b/hyperglass/external/http_client.py new file mode 100644 index 0000000..dc968c4 --- /dev/null +++ b/hyperglass/external/http_client.py @@ -0,0 +1,234 @@ +"""HTTP Client for plugin use.""" + +# Standard Library +import typing as t + +# Project +from hyperglass.models.fields import JsonValue, Primitives + +# Local +from ._base import BaseExternal + + +class HTTPClient(BaseExternal, name="HTTPClient"): + """Wrapper around a standard HTTP Client.""" + + def __init__(self: "HTTPClient", base_url: str, timeout: int = 10) -> None: + """Create an HTTPClient instance.""" + super().__init__(base_url=base_url, timeout=timeout, parse=False) + + async def aget( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an async HTTP GET request.""" + return await self._arequest( + method="GET", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) + + async def apost( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an async HTTP POST request.""" + return await self._arequest( + method="POST", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) + + async def aput( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an async HTTP PUT request.""" + return await self._arequest( + method="PUT", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) + + async def adelete( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an async HTTP DELETE request.""" + return await self._arequest( + method="DELETE", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) + + async def apatch( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an async HTTP PATCH request.""" + return await self._arequest( + method="PATCH", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) + + async def ahead( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an async HTTP HEAD request.""" + return await self._arequest( + method="HEAD", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) + + def get( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an HTTP GET request.""" + return self._request( + method="GET", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) + + def post( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an HTTP POST request.""" + return self._request( + method="POST", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) + + def put( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an HTTP PUT request.""" + return self._request( + method="PUT", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) + + def delete( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an HTTP DELETE request.""" + return self._request( + method="DELETE", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) + + def patch( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an HTTP PATCH request.""" + return self._request( + method="PATCH", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) + + def head( + self: "HTTPClient", + endpoint: str, + headers: t.Dict[str, str] = None, + params: t.Dict[str, JsonValue[Primitives]] = None, + data: t.Optional[t.Any] = None, + timeout: t.Optional[int] = None, + ) -> t.Any: + """Perform an HTTP HEAD request.""" + return self._request( + method="HEAD", + endpoint=endpoint, + headers=headers, + params=params, + data=data, + timeout=timeout, + ) diff --git a/hyperglass/external/msteams.py b/hyperglass/external/msteams.py new file mode 100644 index 0000000..576cdf1 --- /dev/null +++ b/hyperglass/external/msteams.py @@ -0,0 +1,30 @@ +"""Session handler for Microsoft Teams API.""" + +# Standard Library +import typing as t + +# Project +from hyperglass.log import log +from hyperglass.external._base import BaseExternal +from hyperglass.models.webhook import Webhook + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.config.logging import Http + + +class MSTeams(BaseExternal, name="MSTeams"): + """Microsoft Teams session handler.""" + + def __init__(self: "MSTeams", config: "Http") -> None: + """Initialize external base class with Microsoft Teams connection details.""" + + super().__init__(base_url="https://outlook.office.com", config=config, parse=False) + + async def send(self: "MSTeams", query: t.Dict[str, t.Any]): + """Send an incoming webhook to Microsoft Teams.""" + + payload = Webhook(**query) + log.bind(destination="MS Teams", payload=payload).debug("Sending request") + + return await self._apost(endpoint=self.config.host.path, data=payload.msteams()) diff --git a/hyperglass/external/rpki.py b/hyperglass/external/rpki.py new file mode 100644 index 0000000..139ccf8 --- /dev/null +++ b/hyperglass/external/rpki.py @@ -0,0 +1,59 @@ +"""Validate RPKI state via Cloudflare GraphQL API.""" + +# Standard Library +import typing as t + +# 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()} +CACHE_KEY = "hyperglass.external.rpki" + + +def rpki_state(prefix: t.Union["IPv4Address", "IPv6Address", str], asn: t.Union[int, 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: + validation_state = response["data"]["validation"]["state"] + except KeyError as missing: + _log.error("Response from Cloudflare missing key '{}': {!r}", missing, response) + validation_state = 3 + + state = RPKI_STATE_MAP[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/slack.py b/hyperglass/external/slack.py new file mode 100644 index 0000000..793663f --- /dev/null +++ b/hyperglass/external/slack.py @@ -0,0 +1,30 @@ +"""Session handler for Slack API.""" + +# Standard Library +import typing as t + +# Project +from hyperglass.log import log +from hyperglass.external._base import BaseExternal +from hyperglass.models.webhook import Webhook + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.config.logging import Http + + +class SlackHook(BaseExternal, name="Slack"): + """Slack session handler.""" + + def __init__(self: "SlackHook", config: "Http") -> None: + """Initialize external base class with Slack connection details.""" + + super().__init__(base_url="https://hooks.slack.com", config=config, parse=False) + + async def send(self: "SlackHook", query: t.Dict[str, t.Any]): + """Send an incoming webhook to Slack.""" + + payload = Webhook(**query) + log.bind(destination="Slack", payload=payload).debug("Sending request") + + return await self._apost(endpoint=self.config.host.path, data=payload.slack()) diff --git a/hyperglass/external/tests/__init__.py b/hyperglass/external/tests/__init__.py new file mode 100644 index 0000000..4d703de --- /dev/null +++ b/hyperglass/external/tests/__init__.py @@ -0,0 +1 @@ +"""External data testing.""" diff --git a/hyperglass/external/tests/test_base.py b/hyperglass/external/tests/test_base.py new file mode 100644 index 0000000..5f23594 --- /dev/null +++ b/hyperglass/external/tests/test_base.py @@ -0,0 +1,49 @@ +"""Test external http client.""" +# Standard Library +import asyncio + +# Third Party +import pytest + +# Project +from hyperglass.exceptions.private import ExternalError +from hyperglass.models.config.logging import Http + +# Local +from .._base import BaseExternal + +config = Http(provider="generic", host="https://httpbin.org") + + +def test_base_external_sync(): + with BaseExternal(base_url="https://httpbin.org", config=config) as client: + res1 = client._get("/get") + res2 = client._get("/get", params={"key": "value"}) + res3 = client._post("/post", data={"strkey": "value", "intkey": 1}) + assert res1["url"] == "https://httpbin.org/get" + assert res2["args"].get("key") == "value" + assert res3["json"].get("strkey") == "value" + assert res3["json"].get("intkey") == 1 + + with pytest.raises(ExternalError): + with BaseExternal(base_url="https://httpbin.org", config=config, timeout=2) as client: + client._get("/delay/4") + + +async def _run_test_base_external_async(): + async with BaseExternal(base_url="https://httpbin.org", config=config) as client: + res1 = await client._aget("/get") + res2 = await client._aget("/get", params={"key": "value"}) + res3 = await client._apost("/post", data={"strkey": "value", "intkey": 1}) + assert res1["url"] == "https://httpbin.org/get" + assert res2["args"].get("key") == "value" + assert res3["json"].get("strkey") == "value" + assert res3["json"].get("intkey") == 1 + + with pytest.raises(ExternalError): + async with BaseExternal(base_url="https://httpbin.org", config=config, timeout=2) as client: + await client._get("/delay/4") + + +def test_base_external_async(): + asyncio.run(_run_test_base_external_async()) diff --git a/hyperglass/external/tests/test_bgptools.py b/hyperglass/external/tests/test_bgptools.py new file mode 100644 index 0000000..ca8f98d --- /dev/null +++ b/hyperglass/external/tests/test_bgptools.py @@ -0,0 +1,50 @@ +"""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 new file mode 100644 index 0000000..66da2e4 --- /dev/null +++ b/hyperglass/external/tests/test_rpki.py @@ -0,0 +1,25 @@ +"""Test RPKI data fetching.""" +# Third Party +import pytest + +# Local +from ..rpki import RPKI_NAME_MAP, rpki_state + +TEST_STATES = ( + ("103.21.244.0/24", 13335, 0), + ("1.1.1.0/24", 13335, 1), + ("192.0.2.0/24", 65000, 2), +) + + +@pytest.mark.dependency() +def test_rpki(): + for prefix, asn, expected in TEST_STATES: + 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 + ) diff --git a/hyperglass/external/webhooks.py b/hyperglass/external/webhooks.py new file mode 100644 index 0000000..41ffb57 --- /dev/null +++ b/hyperglass/external/webhooks.py @@ -0,0 +1,38 @@ +"""Convenience functions for webhooks.""" + +# Standard Library +import typing as t + +# Project +from hyperglass.exceptions.private import UnsupportedError + +# Local +from ._base import BaseExternal +from .slack import SlackHook +from .generic import GenericHook +from .msteams import MSTeams + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.config.logging import Http + +PROVIDER_MAP = { + "generic": GenericHook, + "msteams": MSTeams, + "slack": SlackHook, +} + + +class Webhook(BaseExternal): + """Get webhook for provider name.""" + + def __new__(cls: "Webhook", config: "Http") -> "BaseExternal": + """Return instance for correct provider handler.""" + try: + provider_class = PROVIDER_MAP[config.provider] + return provider_class(config) + except KeyError as err: + raise UnsupportedError( + message="{p} is not yet supported as a webhook target.", + p=config.provider.title(), + ) from err diff --git a/hyperglass/frontend/__init__.py b/hyperglass/frontend/__init__.py new file mode 100644 index 0000000..bbd7ace --- /dev/null +++ b/hyperglass/frontend/__init__.py @@ -0,0 +1,378 @@ +"""Utility functions for frontend-related tasks.""" + +# Standard Library +import os +import json +import math +import shutil +import typing as t +import asyncio +from pathlib import Path + +# Project +from hyperglass.log import log +from hyperglass.util import copyfiles, check_path, move_files, dotenv_to_dict + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.ui import UIParameters + + +def get_ui_build_timeout() -> t.Optional[int]: + """Read the UI build timeout from environment variables or set a default.""" + timeout = None + + if "HYPERGLASS_UI_BUILD_TIMEOUT" in os.environ: + timeout = int(os.environ["HYPERGLASS_UI_BUILD_TIMEOUT"]) + log.bind(timeout=timeout).debug("Found UI build timeout environment variable") + + return timeout + + +async def check_node_modules() -> bool: + """Check if node_modules exists and has contents.""" + + ui_path = Path(__file__).parent.parent / "ui" + node_modules = ui_path / "node_modules" + + exists = node_modules.exists() + valid = exists + + if exists and not tuple(node_modules.iterdir()): + valid = False + + return valid + + +async def read_package_json() -> t.Dict[str, t.Any]: + """Import package.json as a python dict.""" + + package_json_file = Path(__file__).parent.parent / "ui" / "package.json" + + try: + with package_json_file.open("r") as file: + package_json = json.load(file) + + except Exception as err: + raise RuntimeError(f"Error reading package.json: {str(err)}") from err + + return package_json + + +async def node_initial(timeout: int = 180, 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: + timeout = env_timeout + + proc = await asyncio.create_subprocess_shell( + cmd="pnpm install", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=ui_path, + ) + + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + messages = stdout.decode("utf-8").strip() + errors = stderr.decode("utf-8").strip() + + if proc.returncode != 0: + raise RuntimeError(f"\nMessages:\n{messages}\nErrors:\n{errors}") + + await proc.wait() + + return "\n".join(messages) + + +async def build_ui(app_path: Path): + """Execute `next build` & `next export` from UI directory. + + ### Raises + RuntimeError: Raised if exit code is not 0. + RuntimeError: Raised when any other error occurs. + """ + timeout = get_ui_build_timeout() + + ui_dir = Path(__file__).parent.parent / "ui" + build_dir = app_path / "static" / "ui" + out_dir = ui_dir / "out" + + build_command = "node_modules/.bin/next build" + + all_messages = [] + try: + proc = await asyncio.create_subprocess_shell( + cmd=build_command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=ui_dir, + ) + + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + messages = stdout.decode("utf-8").strip() + errors = stderr.decode("utf-8").strip() + + if proc.returncode != 0: + raise RuntimeError(f"\nMessages:\n{messages}\nErrors:\n{errors}") + + await proc.wait() + all_messages.append(messages) + + except asyncio.TimeoutError as err: + raise RuntimeError(f"{timeout} second timeout exceeded while building UI") from err + + except Exception as err: + log.error(err) + raise RuntimeError(str(err)) from err + + if build_dir.exists(): + shutil.rmtree(build_dir) + shutil.copytree(src=out_dir, dst=build_dir, dirs_exist_ok=False) + log.bind(src=out_dir, dst=build_dir).debug("Migrated Next.JS build output") + + return "\n".join(all_messages) + + +def generate_opengraph( + image_path: Path, + max_width: int, + max_height: int, + target_path: Path, + background_color: str, +): + """Generate an OpenGraph compliant image.""" + # Third Party + from PIL import Image + + def center_point(background: Image, foreground: Image): + """Generate a tuple of center points for PIL.""" + bg_x, bg_y = background.size[0:2] + fg_x, fg_y = foreground.size[0:2] + x1 = math.floor((bg_x / 2) - (fg_x / 2)) + y1 = math.floor((bg_y / 2) - (fg_y / 2)) + x2 = math.floor((bg_x / 2) + (fg_x / 2)) + y2 = math.floor((bg_y / 2) + (fg_y / 2)) + return (x1, y1, x2, y2) + + # Convert image to JPEG format with static name "opengraph.jpg" + dst_path = target_path / "opengraph.jpg" + + # Copy the original image to the target path + copied = shutil.copy2(image_path, target_path) + log.bind(source=str(image_path), destination=str(target_path)).debug("Copied OpenGraph image") + + with Image.open(copied) as src: + # Only resize the image if it needs to be resized + if src.size[0] != max_width or src.size[1] != max_height: + # Resize image while maintaining aspect ratio + log.debug("Opengraph image is not 1200x630, resizing...") + src.thumbnail((max_width, max_height)) + + # Only impose a background image if the original image has + # alpha/transparency channels + if src.mode in ("RGBA", "LA"): + log.debug("Opengraph image has transparency, converting...") + background = Image.new("RGB", (max_width, max_height), background_color) + background.paste(src, box=center_point(background, src)) + dst = background + else: + dst = src + + # Save new image to derived target path + dst.save(dst_path) + + # Delete the copied image + Path(copied).unlink() + + if not dst_path.exists(): + raise RuntimeError(f"Unable to save resized image to {str(dst_path)}") + log.bind(path=str(dst_path)).debug("OpenGraph image ready") + + return True + + +def migrate_images(app_path: Path, params: "UIParameters"): + """Migrate images from source code to install directory.""" + images_dir = app_path / "static" / "images" + favicon_dir = images_dir / "favicons" + check_path(favicon_dir, create=True) + src_files = () + dst_files = () + + for image in ("light", "dark", "favicon"): + src: Path = getattr(params.web.logo, image) + dst = images_dir / f"{image + src.suffix}" + src_files += (src,) + dst_files += (dst,) + return copyfiles(src_files, dst_files) + + +def write_favicon_formats(formats: t.Tuple[t.Dict[str, t.Any]]) -> None: + """Create a TypeScript file in the `ui` directory containing favicon formats. + + This file should stay the same, unless the favicons library updates + supported formats. + """ + # Standard Library + from collections import OrderedDict + + file = Path(__file__).parent.parent / "ui" / "favicon-formats.ts" + + # Sort each favicon definition to ensure the result stays the same + # time the UI build runs. + ordered = json.dumps([OrderedDict(sorted(fmt.items())) for fmt in formats]) + data = "import type {{ Favicon }} from '~/types';export default {} as Favicon[];".format( + ordered + ) + file.write_text(data) + + +def write_custom_files(params: "UIParameters") -> 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" + + # Handle Custom JS. + if params.web.custom_javascript is not None: + copyfiles((params.web.custom_javascript,), (js,)) + else: + with js.open("w") as f: + f.write("") + # Handle Custom HTML. + if params.web.custom_html is not None: + copyfiles((params.web.custom_html,), (html,)) + else: + with html.open("w") as f: + f.write("") + + +async def build_frontend( # noqa: C901 + dev_mode: bool, + dev_url: str, + prod_url: str, + params: "UIParameters", + app_path: Path, + force: bool = False, + timeout: int = 180, + full: bool = False, +) -> bool: + """Perform full frontend UI build process.""" + # 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" + env_config = {} + + ui_config_file = Path(__file__).parent.parent / "ui" / "hyperglass.json" + + ui_config_file.write_text(params.export_json(by_alias=True)) + + package_json = await read_package_json() + + # Set NextJS production/development mode and base URL based on + # developer_mode setting. + if dev_mode: + env_config.update({"HYPERGLASS_URL": dev_url, "NODE_ENV": "development"}) + + 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: + log.debug("node_modules has not been initialized. Starting initialization...") + + node_setup = await node_initial(timeout, dev_mode) + + if node_setup == "": + log.debug("Re-initialized node_modules") + + images_dir = app_path / "static" / "images" + favicon_dir = images_dir / "favicons" + + 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()) + + build_data = { + "params": params.export_dict(), + "version": __version__, + "package_json": package_json, + } + + build_json = json.dumps(build_data, default=str) + + # Create SHA256 hash from all parameters passed to UI, use as + # build identifier. + build_id = hashlib.sha256(build_json.encode()).hexdigest() + + # Read hard-coded environment file from last build. If build ID + # matches this build's ID, don't run a new build. + if dot_env_file.exists() and not force: + env_data = dotenv_to_dict(dot_env_file) + 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: + 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") + + # 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) + + if initialize_result: + log.debug(initialize_result) + elif initialize_result == "": + log.debug("Re-initialized node_modules") + + if build_result: + log.info("Completed UI build") + elif dev_mode and not force: + log.debug("Running in developer mode, did not build new UI files") + + migrate_images(app_path, params) + + write_custom_files(params) + + generate_opengraph( + params.web.opengraph.image, + 1200, + 630, + images_dir, + params.web.theme.colors.black, + ) + + return True diff --git a/hyperglass/images/hyperglass-dark.svg b/hyperglass/images/hyperglass-dark.svg new file mode 100644 index 0000000..b2bb038 --- /dev/null +++ b/hyperglass/images/hyperglass-dark.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/hyperglass/images/hyperglass-icon.svg b/hyperglass/images/hyperglass-icon.svg new file mode 100644 index 0000000..ea9b648 --- /dev/null +++ b/hyperglass/images/hyperglass-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/hyperglass/images/hyperglass-light.svg b/hyperglass/images/hyperglass-light.svg new file mode 100644 index 0000000..b93080f --- /dev/null +++ b/hyperglass/images/hyperglass-light.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/hyperglass/images/hyperglass-opengraph.jpg b/hyperglass/images/hyperglass-opengraph.jpg new file mode 100644 index 0000000..3d621d9 Binary files /dev/null and b/hyperglass/images/hyperglass-opengraph.jpg differ diff --git a/hyperglass/log.py b/hyperglass/log.py new file mode 100644 index 0000000..aedf309 --- /dev/null +++ b/hyperglass/log.py @@ -0,0 +1,224 @@ +"""Logging instance setup & configuration.""" + +# Standard Library +import sys +import typing as t +import logging +from datetime import datetime + +# Third Party +from loguru import logger as _loguru_logger +from rich.theme import Theme +from rich.console import Console +from rich.logging import RichHandler + +# Local +from .util import dict_to_kwargs +from .constants import __version__ + +if t.TYPE_CHECKING: + # Standard Library + from pathlib import Path + + # Third Party + from loguru import Logger as Record + from pydantic import ByteSize + + # Project + from hyperglass.models.fields import LogFormat + +_FMT_DEBUG = ( + "[{level}] {time:YYYYMMDD} {time:HH:mm:ss} |" + "{line} | {function} {message} {extra}" +) + +_FMT = "[{level}] {time:YYYYMMDD} {time:HH:mm:ss} | {message} {extra}" + +_FMT_FILE = "[{time:YYYYMMDD} {time:HH:mm:ss}] {message} {extra}" +_FMT_BASIC = "{message} {extra}" +_LOG_LEVELS = [ + {"name": "TRACE", "color": ""}, + {"name": "DEBUG", "color": ""}, + {"name": "INFO", "color": ""}, + {"name": "SUCCESS", "color": ""}, + {"name": "WARNING", "color": ""}, + {"name": "ERROR", "color": ""}, + {"name": "CRITICAL", "color": ""}, +] + +_EXCLUDE_MODULES = ( + "PIL", + "svglib", + "paramiko.transport", +) + +HyperglassConsole = Console( + theme=Theme( + { + "info": "bold cyan", + "warning": "bold yellow", + "error": "bold red", + "success": "bold green", + "critical": "bold bright_red", + "logging.level.info": "bold cyan", + "logging.level.warning": "bold yellow", + "logging.level.error": "bold red", + "logging.level.critical": "bold bright_red", + "logging.level.success": "bold green", + "subtle": "rgb(128,128,128)", + } + ) +) + +log = _loguru_logger + + +def formatter(record: "Record") -> str: + """Format log messages with extra data as kwargs string.""" + msg = record.get("message", "") + extra = record.get("extra", {}) + extra_str = dict_to_kwargs(extra) + return " ".join((msg, extra_str)) + + +def filter_uvicorn_values(record: "Record") -> bool: + """Drop noisy uvicorn messages.""" + drop = ( + "Application startup", + "Application shutdown", + "Finished server process", + "Shutting down", + "Waiting for application", + "Started server process", + "Started parent process", + "Stopping parent process", + ) + for match in drop: + if match in record["message"]: + return False + return True + + +class LibInterceptHandler(logging.Handler): + """Custom log handler for integrating third party library logging with hyperglass's logger.""" + + def emit(self, record): + """Emit log record. + + See: https://github.com/Delgan/loguru (Readme) + """ + # Get corresponding Loguru level if it exists + try: + level = _loguru_logger.level(record.levelname).name + except ValueError: + level = record.levelno + + # Find caller from where originated the logged message + frame, depth = logging.currentframe(), 2 + while frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + _loguru_logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +def init_logger(level: t.Union[int, str] = logging.INFO): + """Initialize hyperglass logging instance.""" + + for mod in _EXCLUDE_MODULES: + logging.getLogger(mod).propagate = False + + # Reset built-in Loguru configurations. + _loguru_logger.remove() + + if sys.stdout.isatty(): + # Use Rich for logging if hyperglass started from a TTY. + + _loguru_logger.add( + sink=RichHandler( + console=HyperglassConsole, + rich_tracebacks=True, + tracebacks_show_locals=level == logging.DEBUG, + log_time_format="[%Y%m%d %H:%M:%S]", + ), + format=formatter, + colorize=False, + level=level, + filter=filter_uvicorn_values, + enqueue=True, + ) + else: + # Otherwise, use regular format. + _loguru_logger.add( + sink=sys.stdout, + enqueue=True, + format=_FMT if level == logging.INFO else _FMT_DEBUG, + level=level, + colorize=False, + filter=filter_uvicorn_values, + ) + + _loguru_logger.configure(levels=_LOG_LEVELS) + + return _loguru_logger + + +def enable_file_logging( + *, + directory: "Path", + log_format: "LogFormat", + max_size: "ByteSize", + level: t.Union[str, int], +) -> None: + """Set up file-based logging from configuration parameters.""" + + if log_format == "json": + log_file_name = "hyperglass.log.json" + structured = True + else: + log_file_name = "hyperglass.log" + structured = False + + log_file = directory / log_file_name + + if log_format == "text": + now_str = datetime.utcnow().strftime("%B %d, %Y beginning at %H:%M:%S UTC") + header_lines = ( + f"# {line}" + for line in ( + f"hyperglass {__version__}", + f"Logs for {now_str}", + f"Log Level: {'INFO' if level == logging.INFO else 'DEBUG'}", + ) + ) + header = "\n" + "\n".join(header_lines) + "\n" + + with log_file.open("a+") as lf: + lf.write(header) + + _loguru_logger.add( + enqueue=True, + sink=log_file, + format=_FMT_FILE, + serialize=structured, + level=level, + encoding="utf8", + colorize=False, + rotation=max_size.human_readable(), + ) + _loguru_logger.bind(path=log_file).debug("Logging to file") + + +def enable_syslog_logging(*, host: str, port: int) -> None: + """Set up syslog logging from configuration parameters.""" + + # Standard Library + from logging.handlers import SysLogHandler + + _loguru_logger.add( + SysLogHandler(address=(str(host), port)), + format=_FMT_BASIC, + enqueue=True, + colorize=False, + ) + _loguru_logger.bind(host=host, port=port).debug("Logging to syslog target") diff --git a/hyperglass/main.py b/hyperglass/main.py new file mode 100644 index 0000000..51d21da --- /dev/null +++ b/hyperglass/main.py @@ -0,0 +1,190 @@ +"""Start hyperglass.""" + +# Standard Library +import sys +import typing as t +import asyncio +import logging + +# Third Party +import uvicorn + +# Local +from .log import LibInterceptHandler, init_logger, enable_file_logging, enable_syslog_logging +from .util import get_node_version +from .constants import MIN_NODE_VERSION, MIN_PYTHON_VERSION, __version__ + +# Ensure the Python version meets the minimum requirements. +pretty_version = ".".join(tuple(str(v) for v in MIN_PYTHON_VERSION)) +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)") + + +# Local +from .util import cpu_count +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) +log = init_logger(LOG_LEVEL) + + +async def build_ui() -> bool: + """Perform a UI build prior to starting the application.""" + # Local + from .frontend import build_frontend + + state = use_state() + await build_frontend( + dev_mode=Settings.dev_mode, + dev_url=Settings.dev_url, + prod_url=Settings.prod_url, + params=state.ui_params, + app_path=Settings.app_path, + ) + 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 + from .plugins import InputPluginManager, OutputPluginManager + + for manager in (InputPluginManager, OutputPluginManager): + manager().reset() + + +def start(*, log_level: t.Union[str, int], workers: int) -> None: + """Start hyperglass via ASGI server.""" + + register_all_plugins() + + if not Settings.disable_ui: + asyncio.run(build_ui()) + + uvicorn.run( + app="hyperglass.api:app", + host=str(Settings.host), + port=Settings.port, + workers=workers, + log_level=log_level, + log_config={ + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "format": "%(message)s", + }, + "access": { + "()": "uvicorn.logging.AccessFormatter", + "format": "%(message)s", + }, + }, + "handlers": { + "default": {"formatter": "default", "class": "hyperglass.log.LibInterceptHandler"}, + "access": {"formatter": "access", "class": "hyperglass.log.LibInterceptHandler"}, + }, + "loggers": { + "uvicorn.error": {"level": "ERROR", "handlers": ["default"], "propagate": False}, + "uvicorn.access": {"level": "INFO", "handlers": ["access"], "propagate": False}, + }, + }, + ) + + +def run(workers: int = None): + """Run hyperglass.""" + # Local + from .configuration import init_user_config + + try: + log.debug(repr(Settings)) + + state = use_state() + state.clear() + + init_user_config() + + enable_file_logging( + directory=state.params.logging.directory, + max_size=state.params.logging.max_size, + log_format=state.params.logging.format, + level=LOG_LEVEL, + ) + + if state.params.logging.syslog is not None: + enable_syslog_logging( + 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) + + log.bind( + version=__version__, + listening=f"http://{Settings.bind()}", + app_path=f"{Settings.app_path.absolute()!s}", + container=Settings.container, + original_app_path=f"{Settings.original_app_path.absolute()!s}", + workers=_workers, + ).info( + "Starting hyperglass", + ) + + start(log_level=LOG_LEVEL, workers=_workers) + log.bind(version=__version__).critical("Stopping hyperglass") + except Exception as error: + log.critical(error) + # Handle app exceptions. + if not Settings.dev_mode: + state = use_state() + state.clear() + log.debug("Cleared hyperglass state") + unregister_all_plugins() + raise error + except (SystemExit, BaseException): + unregister_all_plugins() + sys.exit(4) + + +if __name__ == "__main__": + run() diff --git a/hyperglass/models/__init__.py b/hyperglass/models/__init__.py new file mode 100644 index 0000000..5aecd38 --- /dev/null +++ b/hyperglass/models/__init__.py @@ -0,0 +1,10 @@ +"""All Data Models used by hyperglass.""" + +# Local +from .main import MultiModel, HyperglassModel, HyperglassModelWithId + +__all__ = ( + "MultiModel", + "HyperglassModel", + "HyperglassModelWithId", +) diff --git a/hyperglass/models/api/__init__.py b/hyperglass/models/api/__init__.py new file mode 100644 index 0000000..70769f4 --- /dev/null +++ b/hyperglass/models/api/__init__.py @@ -0,0 +1,23 @@ +"""Query & Response Validation Models.""" +# Local +from .query import Query +from .response import ( + QueryError, + InfoResponse, + QueryResponse, + RoutersResponse, + CommunityResponse, + SupportedQueryResponse, +) +from .cert_import import EncodedRequest + +__all__ = ( + "Query", + "QueryError", + "InfoResponse", + "QueryResponse", + "EncodedRequest", + "RoutersResponse", + "CommunityResponse", + "SupportedQueryResponse", +) diff --git a/hyperglass/models/api/cert_import.py b/hyperglass/models/api/cert_import.py new file mode 100644 index 0000000..215d248 --- /dev/null +++ b/hyperglass/models/api/cert_import.py @@ -0,0 +1,14 @@ +"""hyperglass-agent certificate import models.""" + +# Standard Library +from typing import Union + +# Third Party +from pydantic import BaseModel, StrictStr, StrictBytes + + +class EncodedRequest(BaseModel): + """Certificate request model.""" + + device: StrictStr + encoded: Union[StrictStr, StrictBytes] diff --git a/hyperglass/models/api/query.py b/hyperglass/models/api/query.py new file mode 100644 index 0000000..2f2a1c7 --- /dev/null +++ b/hyperglass/models/api/query.py @@ -0,0 +1,140 @@ +"""Input query validation model.""" + +# Standard Library +import typing as t +import hashlib +import secrets +from datetime import datetime + +# Third Party +from pydantic import Field, BaseModel, ConfigDict, field_validator + +# Project +from hyperglass.log import log +from hyperglass.util import snake_to_camel, repr_from_attrs +from hyperglass.state import use_state +from hyperglass.plugins import InputPluginManager +from hyperglass.exceptions.public import InputInvalid, QueryTypeNotFound, QueryLocationNotFound +from hyperglass.exceptions.private import InputValidationError + +# Local +from ..config.devices import Device + + +class SimpleQuery(BaseModel): + """A simple representation of a post-validated query.""" + + query_location: str + query_target: t.Union[t.List[str], str] + query_type: str + + def __repr_name__(self) -> str: + """Alias SimpleQuery to Query for clarity in logging.""" + return "Query" + + +class Query(BaseModel): + """Validation model for input query parameters.""" + + model_config = ConfigDict(extra="allow", alias_generator=snake_to_camel, populate_by_name=True) + + # Device `name` field + query_location: str = Field(strict=True, min_length=1, strip_whitespace=True) + + query_target: t.Union[t.List[str], str] = Field(min_length=1, strip_whitespace=True) + + # Directive `id` field + query_type: str = Field(strict=True, min_length=1, strip_whitespace=True) + _kwargs: t.Dict[str, t.Any] + + def __init__(self, **data) -> None: + """Initialize the query with a UTC timestamp at initialization time.""" + super().__init__(**data) + self._kwargs = data + self.timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + + state = use_state() + self._state = state + + query_directives = self.device.directives.matching(self.query_type) + + if len(query_directives) < 1: + raise QueryTypeNotFound(query_type=self.query_type) + + self.directive = query_directives[0] + + self._input_plugin_manager = InputPluginManager() + + self.query_target = self.transform_query_target() + + try: + self.validate_query_target() + except InputValidationError as err: + raise InputInvalid(**err.kwargs) from err + + def summary(self) -> SimpleQuery: + """Summarized and post-validated model of a Query.""" + return SimpleQuery( + query_location=self.query_location, + query_target=self.query_target, + query_type=self.query_type, + ) + + def __repr__(self) -> str: + """Represent only the query fields.""" + return repr_from_attrs(self, ("query_location", "query_type", "query_target")) + + def __str__(self) -> str: + """Alias __str__ to __repr__.""" + return repr(self) + + def digest(self) -> str: + """Create SHA256 hash digest of model representation.""" + return hashlib.sha256(repr(self).encode()).hexdigest() + + def random(self) -> str: + """Create a random string to prevent client or proxy caching.""" + return hashlib.sha256( + secrets.token_bytes(8) + repr(self).encode() + secrets.token_bytes(8) + ).hexdigest() + + def validate_query_target(self) -> None: + """Validate a query target after all fields/relationships have been initialized.""" + # Run config/rule-based validations. + self.directive.validate_target(self.query_target) + # Run plugin-based validations. + self._input_plugin_manager.validate(query=self) + log.bind(query=self.summary()).debug("Validation passed") + + def transform_query_target(self) -> t.Union[t.List[str], str]: + """Transform a query target based on defined plugins.""" + return self._input_plugin_manager.transform(query=self) + + def dict(self) -> t.Dict[str, t.Union[t.List[str], str]]: + """Include only public fields.""" + return super().model_dump(include={"query_location", "query_target", "query_type"}) + + @property + def device(self) -> Device: + """Get this query's device object by query_location.""" + return self._state.devices[self.query_location] + + @field_validator("query_location") + def validate_query_location(cls, value): + """Ensure query_location is defined.""" + + devices = use_state("devices") + + if not devices.valid_id_or_name(value): + raise QueryLocationNotFound(location=value) + + return value + + @field_validator("query_type") + def validate_query_type(cls, value: t.Any): + """Ensure a requested query type exists.""" + devices = use_state("devices") + if any((device.has_directives(value) for device in devices)): + return value + + raise QueryTypeNotFound(query_type=value) diff --git a/hyperglass/models/api/response.py b/hyperglass/models/api/response.py new file mode 100644 index 0000000..188f0b0 --- /dev/null +++ b/hyperglass/models/api/response.py @@ -0,0 +1,217 @@ +"""Response model.""" + +# Standard Library +import typing as t + +# Third Party +from pydantic import Field, BaseModel, StrictInt, StrictStr, ConfigDict, StrictBool, field_validator + +# Project +from hyperglass.state import use_state + +ErrorName = t.Literal["success", "warning", "error", "danger"] +ResponseLevel = t.Literal["success"] +ResponseFormat = t.Literal[r"text/plain", r"application/json"] + +schema_query_output = { + "title": "Output", + "description": "Looking Glass Response", + "example": """ +BGP routing table entry for 1.1.1.0/24, version 224184946 +BGP Bestpath: deterministic-med +Paths: (12 available, best #1, table default) + Advertised to update-groups: + 1 40 + 13335, (aggregated by 13335 172.68.129.1), (received & used) + 192.0.2.1 (metric 51) from 192.0.2.1 (192.0.2.1) + Origin IGP, metric 0, localpref 250, valid, internal + Community: 65000:1 65000:2 + """, +} + +schema_query_level = {"title": "Level", "description": "Severity"} + +schema_query_random = { + "title": "Random", + "description": "Random string to prevent client or intermediate caching.", + "example": "504cbdb47eb8310ca237bf512c3e10b44b0a3d85868c4b64a20037dc1c3ef857", +} + +schema_query_cached = { + "title": "Cached", + "description": "`true` if the response is from a previously cached query.", +} + +schema_query_runtime = { + "title": "Runtime", + "description": "Time it took to run the query in seconds.", + "example": 6, +} + +schema_query_keywords = { + "title": "Keywords", + "description": "Relevant keyword values contained in the `output` field, which can be used for formatting.", + "example": ["1.1.1.0/24", "best #1"], +} + +schema_query_timestamp = { + "title": "Timestamp", + "description": "UTC Time at which the backend application received the query.", + "example": "2020-04-18 14:45:37", +} + +schema_query_format = { + "title": "Format", + "description": "Response [MIME Type](http://www.iana.org/assignments/media-types/media-types.xhtml). Supported values: `text/plain` and `application/json`.", + "example": "text/plain", +} + +schema_query_examples = [ + { + "output": """ +BGP routing table entry for 1.1.1.0/24, version 224184946 +BGP Bestpath: deterministic-med +Paths: (12 available, best #1, table default) + Advertised to update-groups: + 1 40 + 13335, (aggregated by 13335 172.68.129.1), (received & used) + 192.0.2.1 (metric 51) from 192.0.2.1 (192.0.2.1) + Origin IGP, metric 0, localpref 250, valid, internal + Community: 65000:1 65000:2 + """, + "level": "success", + "keywords": ["1.1.1.0/24", "best #1"], + } +] + +schema_query_error_output = { + "title": "Output", + "description": "Error Details", + "example": "192.0.2.1/32 is not allowed.", +} + +schema_query_error_level = {"title": "Level", "description": "Error Severity", "example": "danger"} + +schema_query_error_keywords = { + "title": "Keywords", + "description": "Relevant keyword values contained in the `output` field, which can be used for formatting.", + "example": ["192.0.2.1/32"], +} + + +class QueryError(BaseModel): + """Query response model.""" + + model_config = ConfigDict( + json_schema_extra={ + "title": "Query Error", + "description": "Response received when there is an error executing the requested query.", + "examples": [ + { + "output": "192.0.2.1/32 is not allowed.", + "level": "danger", + "keywords": ["192.0.2.1/32"], + } + ], + } + ) + + output: str = Field(json_schema_extra=schema_query_error_output) + level: ErrorName = Field("danger", json_schema_extra=schema_query_error_level) + # id: t.Optional[StrictStr] + keywords: t.List[StrictStr] = Field([], json_schema_extra=schema_query_error_keywords) + + @field_validator("output") + def validate_output(cls: "QueryError", value): + """If no output is specified, use a customizable generic message.""" + if value is None: + (messages := use_state("params").messages) + return messages.general + return value + + +class QueryResponse(BaseModel): + """Query response model.""" + + model_config = ConfigDict( + json_schema_extra={ + "title": "Query Response", + "description": "Looking glass response", + "examples": schema_query_examples, + } + ) + + output: t.Union[t.Dict, StrictStr] = Field(json_schema_extra=schema_query_output) + 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) + runtime: int = Field(json_schema_extra=schema_query_runtime) + keywords: t.List[str] = Field([], json_schema_extra=schema_query_keywords) + timestamp: str = Field(json_schema_extra=schema_query_timestamp) + format: ResponseFormat = Field("text/plain", json_schema_extra=schema_query_format) + + +class RoutersResponse(BaseModel): + """Response model for /api/devices list items.""" + + model_config = ConfigDict( + json_schema_extra={ + "title": "Device", + "description": "Device attributes", + "examples": [ + {"id": "nyc_router_1", "name": "NYC Router 1", "group": "New York City, NY"} + ], + } + ) + + id: StrictStr + name: StrictStr + group: t.Union[StrictStr, None] + + +class CommunityResponse(BaseModel): + """Response model for /api/communities.""" + + community: StrictStr + display_name: StrictStr + description: StrictStr + + +class SupportedQueryResponse(BaseModel): + """Response model for /api/queries list items.""" + + model_config = ConfigDict( + json_schema_extra={ + "title": "Query Type", + "description": "If enabled is `true`, the `name` field may be used to specify the query type.", + "examples": [{"name": "bgp_route", "display_name": "BGP Route", "enable": True}], + } + ) + + name: StrictStr + display_name: StrictStr + enable: StrictBool + + +class InfoResponse(BaseModel): + """Response model for /api/info endpoint.""" + + model_config = ConfigDict( + json_schema_extra={ + "title": "System Information", + "description": "General information about this looking glass.", + "examples": [ + { + "name": "hyperglass", + "organization": "Company Name", + "primary_asn": 65000, + "version": "hyperglass 1.0.0-beta.52", + } + ], + } + ) + + name: StrictStr + organization: StrictStr + primary_asn: StrictInt + version: StrictStr diff --git a/hyperglass/models/api/rfc8522.py b/hyperglass/models/api/rfc8522.py new file mode 100644 index 0000000..f7310b5 --- /dev/null +++ b/hyperglass/models/api/rfc8522.py @@ -0,0 +1,70 @@ +"""Response model.""" + +# Standard Library +# flake8: noqa +import math +import typing as t +import secrets +from datetime import datetime + +# Third Party +from pydantic import Field, BaseModel, ConfigDict, field_validator + +"""Patterns: +GET /.well-known/looking-glass/v1/ping/2001:DB8::35?protocol=2,1 +GET /.well-known/looking-glass/v1/traceroute/192.0.2.8?routerindex=5 +GET /.well-known/looking-glass/v1/show/route/2001:DB8::/48?protocol=2,1 +GET /.well-known/looking-glass/v1/show/bgp/192.0.2.0/24 +GET /.well-known/looking-glass/v1/show/bgp/summary?protocol=2&routerindex=3 +GET /.well-known/looking-glass/v1/show/bgp/neighbors/192.0.2.226 +GET /.well-known/looking-glass/v1/routers +GET /.well-known/looking-glass/v1/routers/1 +GET /.well-known/looking-glass/v1/cmd +""" + +QueryFormat = t.Literal[r"text/plain", r"application/json"] + + +class _HyperglassQuery(BaseModel): + model_config = ConfigDict(validate_assignment=True, validate_default=True) + + +class BaseQuery(_HyperglassQuery): + protocol: str = "1,1" + router: str + routerindex: int + random: str = secrets.token_urlsafe(16) + runtime: int = 30 + query_format: QueryFormat = Field("text/plain", alias="format") + + @field_validator("runtime") + def validate_runtime(cls, value): + if isinstance(value, float) and math.modf(value)[0] == 0: + value = math.ceil(value) + return value + + +class BaseData(_HyperglassQuery): + model_config = ConfigDict(extra="allow") + + router: str + performed_at: datetime + runtime: t.Union[float, int] + output: t.List[str] + data_format: str = Field(alias="format") + + @field_validator("runtime") + def validate_runtime(cls, value): + if isinstance(value, float) and math.modf(value)[0] == 0: + value = math.ceil(value) + return value + + +class QueryError(_HyperglassQuery): + status: t.Literal["error"] + message: str + + +class QueryResponse(_HyperglassQuery): + status: t.Literal["success", "fail"] + data: BaseData diff --git a/hyperglass/models/api/types.py b/hyperglass/models/api/types.py new file mode 100644 index 0000000..397ca8f --- /dev/null +++ b/hyperglass/models/api/types.py @@ -0,0 +1,20 @@ +"""Custom validation types.""" + +# Standard Library +import typing as t + +# Third Party +from pydantic import AfterValidator + +# Project +from hyperglass.constants import SUPPORTED_QUERY_TYPES + + +def validate_query_type(value: str) -> str: + """Ensure query type is supported by hyperglass.""" + if value not in SUPPORTED_QUERY_TYPES: + raise ValueError("'{}' is not a supported query type".format(value)) + return value + + +SupportedQuery = t.Annotated[str, AfterValidator(validate_query_type)] diff --git a/hyperglass/models/config/__init__.py b/hyperglass/models/config/__init__.py new file mode 100644 index 0000000..1bff0e6 --- /dev/null +++ b/hyperglass/models/config/__init__.py @@ -0,0 +1,5 @@ +"""Define models for all config variables. + +Import config variables and overrides default class attributes. +Validate input for overridden parameters. +""" diff --git a/hyperglass/models/config/cache.py b/hyperglass/models/config/cache.py new file mode 100644 index 0000000..0871b94 --- /dev/null +++ b/hyperglass/models/config/cache.py @@ -0,0 +1,12 @@ +"""Validation model for cache config.""" + + +# Local +from ..main import HyperglassModel + + +class Cache(HyperglassModel): + """Public cache parameters.""" + + timeout: int = 120 + show_text: bool = True diff --git a/hyperglass/models/config/credential.py b/hyperglass/models/config/credential.py new file mode 100644 index 0000000..905f22e --- /dev/null +++ b/hyperglass/models/config/credential.py @@ -0,0 +1,43 @@ +"""Validate credential configuration variables.""" + +# Standard Library +import typing as t + +# Third Party +from pydantic import FilePath, SecretStr, model_validator + +# Local +from ..main import HyperglassModel + +AuthMethod = t.Literal["password", "unencrypted_key", "encrypted_key"] + + +class Credential(HyperglassModel, extra="allow"): + """Model for per-credential config in devices.yaml.""" + + username: str + password: t.Optional[SecretStr] = None + key: t.Optional[FilePath] = None + _method: t.Optional[AuthMethod] = None + + @model_validator(mode="after") + def validate_credential(cls, data: "Credential"): + """Ensure either a password or an SSH key is set.""" + if data.key is None and data.password is None: + raise ValueError( + "Either a password or an SSH key must be specified for user '{}'".format( + data.username + ) + ) + return data + + def __init__(self, **kwargs): + """Set private attribute _method based on validated model.""" + super().__init__(**kwargs) + self._method = None + if self.password is not None and self.key is not None: + self._method = "encrypted_key" + elif self.password is None: + self._method = "unencrypted_key" + elif self.key is None: + self._method = "password" diff --git a/hyperglass/models/config/devices.py b/hyperglass/models/config/devices.py new file mode 100644 index 0000000..99d868b --- /dev/null +++ b/hyperglass/models/config/devices.py @@ -0,0 +1,371 @@ +"""Validate router configuration variables.""" + +# Standard Library +import re +import typing as t +from pathlib import Path +from ipaddress import IPv4Address, IPv6Address + +# Third Party +from pydantic import FilePath, ValidationInfo, field_validator +from netmiko.ssh_dispatcher import CLASS_MAPPER # type: ignore + +# Project +from hyperglass.log import log +from hyperglass.util import get_driver, get_fmt_keys, resolve_hostname +from hyperglass.state import use_state +from hyperglass.settings import Settings +from hyperglass.constants import ( + DRIVER_MAP, + SCRAPE_HELPERS, + LINUX_PLATFORMS, + SUPPORTED_STRUCTURED_OUTPUT, +) +from hyperglass.exceptions.private import ConfigError, UnsupportedDevice + +# Local +from ..main import MultiModel, HyperglassModel, HyperglassModelWithId +from ..util import check_legacy_fields +from .proxy import Proxy +from ..fields import SupportedDriver +from ..directive import Directives +from .credential import Credential +from .http_client import HttpConfiguration + +ALL_DEVICE_TYPES = {*DRIVER_MAP.keys(), *CLASS_MAPPER.keys()} + + +class APIDevice(t.TypedDict): + """API Response Model for a device.""" + + id: str + name: str + group: t.Union[str, None] + + +class DirectiveOptions(HyperglassModel, extra="ignore"): + """Per-device directive options.""" + + builtins: t.Union[bool, t.List[str]] = True + + +class Device(HyperglassModelWithId, extra="allow"): + """Validation model for per-router config in devices.yaml.""" + + id: str + name: str + description: t.Optional[str] = None + avatar: t.Optional[FilePath] = None + address: t.Union[IPv4Address, IPv6Address, str] + group: t.Optional[str] = None + credential: Credential + proxy: t.Optional[Proxy] = None + display_name: t.Optional[str] = None + port: int = 22 + http: HttpConfiguration = HttpConfiguration() + platform: str + structured_output: t.Optional[bool] = None + directives: Directives = Directives() + driver: t.Optional[SupportedDriver] = None + driver_config: t.Dict[str, t.Any] = {} + attrs: t.Dict[str, str] = {} + + def __init__(self, **kw) -> None: + """Check legacy fields and ensure an `id` is set.""" + kw = check_legacy_fields(model="Device", data=kw) + if "id" not in kw: + kw = self._with_id(kw) + super().__init__(**kw) + self._validate_directive_attrs() + + @property + def _target(self): + return str(self.address) + + @staticmethod + def _with_id(values: t.Dict) -> str: + """Generate device id & handle legacy display_name field.""" + + def generate_id(name: str) -> str: + scrubbed = re.sub(r"[^A-Za-z0-9\_\-\s]", "", name) + return "_".join(scrubbed.split()).lower() + + name = values.pop("name", None) + + if name is None: + raise ValueError("name is required.") + + device_id = generate_id(name) + display_name = name + + return {"id": device_id, "name": display_name, "display_name": None, **values} + + def export_api(self) -> APIDevice: + """Export API-facing device fields.""" + return { + "id": self.id, + "name": self.name, + "group": self.group, + } + + @property + def directive_commands(self) -> t.List[str]: + """Get all commands associated with the device.""" + return [ + command + for directive in self.directives + for rule in directive.rules + for command in rule.commands + ] + + @property + def directive_ids(self) -> t.List[str]: + """Get all directive IDs associated with the device.""" + return [directive.id for directive in self.directives] + + @property + def directive_names(self) -> t.List[str]: + """Get all directive names associated with the device.""" + return list({directive.name for directive in self.directives}) + + def has_directives(self, *directive_ids: str) -> bool: + """Determine if a directive is used on this device.""" + for directive_id in directive_ids: + if directive_id in self.directive_ids: + return True + return False + + def get_device_type(self) -> str: + """Get the `device_type` field for use by Netmiko. + + In some cases, the platform might be different than the + device_type. For example, any linux-based platform like FRR, + BIRD, or OpenBGPD will have directives associated with those + platforms, but the `device_type` sent to Netmiko needs to be + `linux_ssh`. + """ + if self.platform in LINUX_PLATFORMS: + return "linux_ssh" + return self.platform + + def _validate_directive_attrs(self) -> None: + # Set of all keys except for built-in key `target`. + keys = { + key + for group in [get_fmt_keys(command) for command in self.directive_commands] + for key in group + if key != "target" + } + + attrs = {k: v for k, v in self.attrs.items() if k in keys} + + # Verify all keys in associated commands contain values in device's `attrs`. + for key in keys: + if key not in attrs: + raise ConfigError( + "Device '{d}' has a command that references attribute '{a}', but '{a}' is missing from device attributes", + d=self.name, + a=key, + ) + + @field_validator("address") + def validate_address( + cls, value: t.Union[IPv4Address, IPv6Address, str], values: t.Dict[str, t.Any] + ) -> t.Union[IPv4Address, IPv6Address, str]: + """Ensure a hostname is resolvable.""" + + if not isinstance(value, (IPv4Address, IPv6Address)): + if not any(resolve_hostname(value)): + raise ConfigError( + "Device '{d}' has an address of '{a}', which is not resolvable.", + d=values["name"], + a=value, + ) + return value + + @field_validator("avatar") + def validate_avatar( + cls, value: t.Union[FilePath, None], values: t.Dict[str, t.Any] + ) -> t.Union[FilePath, None]: + """Migrate avatar to static directory.""" + if value is not None: + # Standard Library + import shutil + + # Third Party + from PIL import Image + + target = Settings.static_path / "images" / value.name + copied = shutil.copy2(value, target) + log.bind( + device=values["name"], + source=str(value), + destination=str(target), + ).debug("Copied device avatar") + + with Image.open(copied) as src: + if src.width > 512: + src.thumbnail((512, 512 * src.height / src.width)) + src.save(target) + return value + + @field_validator("platform", mode="before") + def validate_platform(cls: "Device", value: t.Any, values: t.Dict[str, t.Any]) -> str: + """Validate & rewrite device platform, set default `directives`.""" + + if value == "http": + if values.get("http") is None: + raise ConfigError( + "Device '{device}' has platform 'http' configured, but no http parameters are defined.", + device=values["name"], + ) + + if value is None: + if values.get("http") is not None: + value = "http" + else: + # Ensure device platform is defined. + raise ConfigError( + "Device '{device}' is missing a 'platform' (Network Operating System) property", + device=values["name"], + ) + + if value in SCRAPE_HELPERS.keys(): + # Rewrite platform to helper value if needed. + value = SCRAPE_HELPERS[value] + + # Verify device platform is supported by hyperglass. + if value not in ALL_DEVICE_TYPES: + raise UnsupportedDevice(value) + + return value + + @field_validator("structured_output", mode="before") + def validate_structured_output(cls, value: bool, info: ValidationInfo) -> bool: + """Validate structured output is supported on the device & set a default.""" + + 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"), + ) + return value + if value is None and info.data.get("platform") in SUPPORTED_STRUCTURED_OUTPUT: + value = True + else: + value = False + return value + + @field_validator("directives", mode="before") + def validate_directives( + cls: "Device", value: t.Optional[t.List[str]], info: ValidationInfo + ) -> "Directives": + """Associate directive IDs to loaded directive objects.""" + directives = use_state("directives") + + directive_ids = value or [] + structured_output = info.data.get("structured_output", False) + platform = info.data.get("platform") + + # Directive options + directive_options = DirectiveOptions( + **{ + k: v + for statement in directive_ids + if isinstance(statement, t.Dict) + for k, v in statement.items() + } + ) + + # String directive IDs, excluding builtins and options. + directive_ids = [ + statement + for statement in directive_ids + if isinstance(statement, str) and not statement.startswith("__") + ] + # Directives matching provided IDs. + device_directives = directives.filter(*directive_ids) + # Matching built-in directives for this device's platform. + builtins = directives.device_builtins(platform=platform, table_output=structured_output) + + if directive_options.builtins is True: + # Add all builtins. + device_directives += builtins + elif isinstance(directive_options.builtins, t.List): + # If the user provides a list of builtin directives to include, add only those. + device_directives += builtins.matching(*directive_options.builtins) + + return device_directives + + @field_validator("driver") + def validate_driver(cls: "Device", value: t.Optional[str], info: ValidationInfo) -> str: + """Set the correct driver and override if supported.""" + return get_driver(info.data.get("platform"), value) + + +class Devices(MultiModel, model=Device, unique_by="id"): + """Container for all devices.""" + + def __init__(self: "Devices", *items: t.Dict[str, t.Any]) -> None: + """Generate IDs prior to validation.""" + with_id = (Device._with_id(item) for item in items) + super().__init__(*with_id) + + def export_api(self: "Devices") -> t.List[APIDevice]: + """Export API-facing device fields.""" + return [d.export_api() for d in self] + + def valid_id_or_name(self: "Devices", value: str) -> bool: + """Determine if a value is a valid device name or ID.""" + for device in self: + if value == device.id or value == device.name: + return True + return False + + def directive_plugins(self: "Devices") -> t.Dict[Path, t.Tuple[str]]: + """Get a mapping of plugin paths to associated directive IDs.""" + result: t.Dict[Path, t.Set[str]] = {} + # Unique set of all directives. + directives = {directive for device in self for directive in device.directives} + # Unique set of all plugin file names. + plugin_names = {plugin for directive in directives for plugin in directive.plugins} + + for directive in directives: + # Convert each plugin file name to a `Path` object. + for plugin in (Path(p) for p in directive.plugins if p in plugin_names): + if plugin not in result: + result[plugin] = set() + result[plugin].add(directive.id) + # Convert the directive set to a tuple. + return {k: tuple(v) for k, v in result.items()} + + def directive_names(self) -> t.List[str]: + """Get all directive names for all devices.""" + return list({directive.name for device in self for directive in device.directives}) + + def frontend(self: "Devices") -> t.List[t.Dict[str, t.Any]]: + """Export grouped devices for UIParameters.""" + groups = {device.group for device in self} + return [ + { + "group": group, + "locations": [ + { + "group": group, + "id": device.id, + "name": device.name, + "avatar": f"/images/{device.avatar.name}" + if device.avatar is not None + else None, + "description": device.description, + "directives": [d.frontend() for d in device.directives], + } + for device in self + if device.group == group + ], + } + for group in groups + ] diff --git a/hyperglass/models/config/docs.py b/hyperglass/models/config/docs.py new file mode 100644 index 0000000..d219a75 --- /dev/null +++ b/hyperglass/models/config/docs.py @@ -0,0 +1,79 @@ +"""Configuration for API docs feature.""" + +# Standard Library +import typing as t + +# Third Party +from pydantic import Field, HttpUrl + +# Local +from ..main import HyperglassModel +from ..fields import AnyUri + +DocsMode = t.Literal["swagger", "redoc"] + + +class EndpointConfig(HyperglassModel): + """Validation model for per API endpoint documentation.""" + + title: str = Field( + ..., + title="Endpoint Title", + description="Displayed as the header text above the API endpoint section.", + ) + description: str = Field( + ..., + title="Endpoint Description", + description="Displayed inside each API endpoint section.", + ) + summary: str = Field( + ..., + title="Endpoint Summary", + description="Displayed beside the API endpoint URI.", + ) + + +class Docs(HyperglassModel): + """Validation model for params.docs.""" + + enable: bool = Field(True, title="Enable", description="Enable or disable API documentation.") + base_url: HttpUrl = Field( + "https://lg.example.net", + title="Base URL", + description="Base URL used in request samples.", + ) + path: AnyUri = Field( + "/api/docs", + title="URI", + description="HTTP URI/path where API documentation can be accessed.", + ) + title: str = Field( + "{site_title} API Documentation", + title="Title", + description="API documentation title. `{site_title}` may be used to display the `site_title` parameter.", + ) + description: str = Field( + "", + title="Description", + description="API documentation description appearing below the title.", + ) + query: EndpointConfig = EndpointConfig( + title="Submit Query", + description="Request a query response per-location.", + summary="Query the Looking Glass", + ) + devices: EndpointConfig = EndpointConfig( + title="Devices", + description="List of all devices/locations with associated identifiers, display names, networks, & VRFs.", + summary="Devices List", + ) + queries: EndpointConfig = EndpointConfig( + title="Supported Queries", + description="List of supported query types.", + summary="Query Types", + ) + info: EndpointConfig = EndpointConfig( + title="System Information", + description="General information about this looking glass.", + summary="System Information", + ) diff --git a/hyperglass/models/config/http_client.py b/hyperglass/models/config/http_client.py new file mode 100644 index 0000000..a2f2ff1 --- /dev/null +++ b/hyperglass/models/config/http_client.py @@ -0,0 +1,130 @@ +"""Configuration models for hyperglass http client.""" + +# Standard Library +import typing as t + +# Third Party +import httpx +from pydantic import FilePath, SecretStr, PrivateAttr, IPvAnyAddress + +# Project +from hyperglass.models import HyperglassModel +from hyperglass.constants import __version__ + +# Local +from ..fields import IntFloat, HttpMethod, Primitives + +if t.TYPE_CHECKING: + # Local + from .devices import Device + +DEFAULT_QUERY_PARAMETERS: t.Dict[str, str] = { + "query_target": "{query_target}", + "query_type": "{query_type}", + "query_location": "{query_location}", +} + +BodyFormat = t.Literal["json", "yaml", "xml", "text"] +Scheme = t.Literal["http", "https"] + + +class AttributeMapConfig(HyperglassModel): + """Allow the user to 'rewrite' hyperglass field names to their own values.""" + + query_target: t.Optional[str] = None + query_type: t.Optional[str] = None + query_location: t.Optional[str] = None + + +class AttributeMap(HyperglassModel): + """Merged implementation of attribute map configuration.""" + + query_target: str + query_type: str + query_location: str + + +class HttpBasicAuth(HyperglassModel): + """Configuration model for HTTP basic authentication.""" + + username: str + password: SecretStr + + +class HttpConfiguration(HyperglassModel): + """HTTP client configuration.""" + + _attribute_map: AttributeMap = PrivateAttr() + path: str = "/" + method: HttpMethod = "GET" + scheme: Scheme = "https" + query: t.Optional[t.Union[t.Literal[False], t.Dict[str, Primitives]]] = None + verify_ssl: bool = True + ssl_ca: t.Optional[FilePath] = None + ssl_client: t.Optional[FilePath] = None + source: t.Optional[IPvAnyAddress] = None + timeout: IntFloat = 5 + headers: t.Dict[str, str] = {} + follow_redirects: bool = False + basic_auth: t.Optional[HttpBasicAuth] = None + attribute_map: AttributeMapConfig = AttributeMapConfig() + body_format: BodyFormat = "json" + retries: int = 0 + + def __init__(self, **data: t.Any) -> None: + """Create HTTP Client Configuration Definition.""" + + super().__init__(**data) + self._attribute_map = self._create_attribute_map() + + def _create_attribute_map(self) -> AttributeMap: + """Create AttributeMap instance with defined overrides.""" + + return AttributeMap( + query_location=self.attribute_map.query_location or "query_location", + query_type=self.attribute_map.query_type or "query_type", + query_target=self.attribute_map.query_target or "query_target", + ) + + def create_client(self, *, device: "Device") -> httpx.AsyncClient: + """Create a pre-configured http client.""" + + # Use the CA certificates for SSL verification, if present. + verify = self.verify_ssl + if self.ssl_ca is not None: + verify = httpx.create_ssl_context(verify=str(self.ssl_ca)) + + transport_constructor = {"retries": self.retries} + + # Use `source` IP address as httpx transport's `local_address`, if defined. + if self.source is not None: + transport_constructor["local_address"] = str(self.source) + + transport = httpx.AsyncHTTPTransport(**transport_constructor) + + # Add the port to the URL only if it is not 22, 80, or 443. + base_url = f"{self.scheme}://{device.address!s}".strip("/") + if device.port not in (22, 80, 443): + base_url += f":{device.port!s}" + + parameters = { + "verify": verify, + "transport": transport, + "timeout": self.timeout, + "follow_redirects": self.follow_redirects, + "base_url": f"{self.scheme}://{device.address!s}".strip("/"), + "headers": {"user-agent": f"hyperglass/{__version__}", **self.headers}, + } + + # Use client certificate authentication, if defined. + if self.ssl_client is not None: + parameters["cert"] = str(self.ssl_client) + + # Use basic authentication, if defined. + if self.basic_auth is not None: + parameters["auth"] = httpx.BasicAuth( + username=self.basic_auth.username, + password=self.basic_auth.password.get_secret_value(), + ) + + return httpx.AsyncClient(**parameters) diff --git a/hyperglass/models/config/logging.py b/hyperglass/models/config/logging.py new file mode 100644 index 0000000..3604dd0 --- /dev/null +++ b/hyperglass/models/config/logging.py @@ -0,0 +1,88 @@ +"""Validate logging configuration.""" + +# Standard Library +import typing as t +from pathlib import Path + +# Third Party +from pydantic import ByteSize, SecretStr, AnyHttpUrl, DirectoryPath, field_validator + +# Project +from hyperglass.constants import __version__ + +# Local +from ..main import HyperglassModel +from ..fields import LogFormat, HttpAuthMode, HttpProvider + + +class Syslog(HyperglassModel): + """Validation model for syslog configuration.""" + + enable: bool = True + host: str + port: int = 514 + + +class HttpAuth(HyperglassModel): + """HTTP hook authentication parameters.""" + + mode: HttpAuthMode = "basic" + username: t.Optional[str] = None + password: SecretStr + header: str = "x-api-key" + + def api_key(self): + """Represent authentication as an API key header.""" + return {self.header: self.password.get_secret_value()} + + def basic(self): + """Represent HTTP basic authentication.""" + return (self.username, self.password.get_secret_value()) + + +class Http(HyperglassModel, extra="allow"): + """HTTP logging parameters.""" + + enable: bool = True + provider: HttpProvider = "generic" + host: AnyHttpUrl + authentication: t.Optional[HttpAuth] = None + headers: t.Dict[str, t.Union[str, int, bool, None]] = {} + params: t.Dict[str, t.Union[str, int, bool, None]] = {} + verify_ssl: bool = True + timeout: t.Union[float, int] = 5.0 + + @field_validator("headers", "params") + def stringify_headers_params(cls, value): + """Ensure headers and URL parameters are strings.""" + for k, v in value.items(): + if not isinstance(v, str): + value[k] = str(v) + return value + + def __init__(self, **kwargs): + """Initialize model, add obfuscated connection details as attribute.""" + super().__init__(**kwargs) + dumped = { + "headers": self.headers, + "params": self.params, + "verify": self.verify_ssl, + "timeout": self.timeout, + } + dumped["headers"].update({"user-agent": f"hyperglass/{__version__}"}) + + if self.authentication is not None: + if self.authentication.mode == "api_key": + dumped["headers"].update(self.authentication.api_key()) + else: + dumped["auth"] = self.authentication.basic() + + +class Logging(HyperglassModel): + """Validation model for logging configuration.""" + + directory: DirectoryPath = Path("/tmp") # noqa: S108 + format: LogFormat = "text" + max_size: ByteSize = "50MB" + syslog: t.Optional[Syslog] = None + http: t.Optional[Http] = None diff --git a/hyperglass/models/config/messages.py b/hyperglass/models/config/messages.py new file mode 100644 index 0000000..cb47f0d --- /dev/null +++ b/hyperglass/models/config/messages.py @@ -0,0 +1,93 @@ +"""Validate error message configuration variables.""" + +# Third Party +from pydantic import Field, ConfigDict + +# Local +from ..main import HyperglassModel + + +class Messages(HyperglassModel): + """Validation model for params.messages.""" + + model_config = ConfigDict( + title="Messages", + description="Customize almost all user-facing UI & API messages.", + json_schema_extra={"level": 2}, + ) + + no_input: str = Field( + "{field} must be specified.", + title="No Input", + description="Displayed when no a required field is not specified. `{field}` may be used to display the `display_name` of the field that was omitted.", + ) + target_not_allowed: str = Field( + "{target} is not allowed.", + title="Target Not Allowed", + description="Displayed when a query target is implicitly denied by a configured rule. `{target}` will be used to display the denied query target.", + ) + feature_not_enabled: str = Field( + "{feature} is not enabled.", + title="Feature Not Enabled", + description="Displayed when a query type is submitted that is not supported or disabled. The hyperglass UI performs validation of supported query types prior to submitting any requests, so this is primarily relevant to the hyperglass API. `{feature}` may be used to display the disabled feature.", + ) + invalid_input: str = Field( + "{target} is not valid.", + title="Invalid Input", + description="Displayed when a query target's value is invalid in relation to the corresponding query type. `{target}` may be used to display the invalid target.", + ) + invalid_query: str = Field( + "{target} is not a valid {query_type} target.", + title="Invalid Query", + description="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.", + ) + invalid_field: str = Field( + "{input} is an invalid {field}.", + title="Invalid Field", + description="Displayed when a query field contains an invalid or unsupported value. `{input}` and `{field}` may be used to display the invalid input value and corresponding field name.", + ) + general: str = Field( + "Something went wrong.", + title="General Error", + description="Displayed when generalized errors occur. Seeing this error message may indicate a bug in hyperglass, as most other errors produced are highly contextual. If you see this in the wild, try enabling [debug mode](/fixme) and review the logs to pinpoint the source of the error.", + ) + not_found: str = Field( + "{type} '{name}' not found.", + title="Not Found", + description="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.", + ) + request_timeout: str = Field( + "Request timed out.", + title="Request Timeout", + description="Displayed when the [request_timeout](/fixme) time expires.", + ) + connection_error: str = Field( + "Error connecting to {device_name}: {error}", + title="Displayed when hyperglass is unable to connect to a configured device. Usually, this indicates a configuration error. `{device_name}` and `{error}` may be used to display the device in question and the specific connection error.", + ) + authentication_error: str = Field( + "Authentication error occurred.", + title="Authentication Error", + description="Displayed when hyperglass is unable to authenticate to a configured device. Usually, this indicates a configuration error.", + ) + no_response: str = Field( + "No response.", + title="No Response", + description="Displayed when hyperglass can connect to a device, but no output able to be read. Seeing this error may indicate a bug in hyperglas or one of its dependencies. If you see this in the wild, try enabling [debug mode](/fixme) and review the logs to pinpoint the source of the error.", + ) + no_output: str = Field( + "The query completed, but no matching results were found.", + title="No Output", + description="Displayed when hyperglass can connect to a device and execute a query, but the response is empty.", + ) + + def has(self, attr: str) -> bool: + """Determine if message type exists in Messages model.""" + return attr in self.model_dump().keys() + + def __getitem__(self, attr: str) -> str: + """Make messages subscriptable.""" + + if not self.has(attr): + raise KeyError(f"'{attr}' does not exist on Messages model") + return getattr(self, attr) diff --git a/hyperglass/models/config/opengraph.py b/hyperglass/models/config/opengraph.py new file mode 100644 index 0000000..5406ca5 --- /dev/null +++ b/hyperglass/models/config/opengraph.py @@ -0,0 +1,29 @@ +"""Validate OpenGraph Configuration Parameters.""" + +# Standard Library +from pathlib import Path + +# Third Party +from pydantic import FilePath, field_validator + +# Local +from ..main import HyperglassModel + +DEFAULT_IMAGES = Path(__file__).parent.parent.parent / "images" + + +class OpenGraph(HyperglassModel): + """Validation model for params.opengraph.""" + + image: FilePath = DEFAULT_IMAGES / "hyperglass-opengraph.jpg" + + @field_validator("image") + def validate_opengraph(cls, value): + """Ensure the opengraph image is a supported format.""" + supported_extensions = (".jpg", ".jpeg", ".png") + if value is not None and value.suffix not in supported_extensions: + raise ValueError( + "OpenGraph image must be one of {e}".format(e=", ".join(supported_extensions)) + ) + + return value diff --git a/hyperglass/models/config/params.py b/hyperglass/models/config/params.py new file mode 100644 index 0000000..1e35ba2 --- /dev/null +++ b/hyperglass/models/config/params.py @@ -0,0 +1,168 @@ +"""Configuration validation entry point.""" + +# Standard Library +import typing as t +import urllib.parse +from pathlib import Path + +# Third Party +from pydantic import Field, HttpUrl, ConfigDict, ValidationInfo, field_validator + +# Project +from hyperglass.settings import Settings +from hyperglass.constants import __version__ + +# Local +from .web import Web +from .docs import Docs +from ..main import HyperglassModel +from .cache import Cache +from .logging import Logging +from .messages import Messages +from .structured import Structured + +Localhost = t.Literal["localhost"] + + +class APIParams(t.TypedDict): + """/api/info response model.""" + + name: str + organization: str + primary_asn: int + version: str + + +class ParamsPublic(HyperglassModel): + """Public configuration parameters.""" + + request_timeout: int = Field( + 90, + title="Request Timeout", + description="Global timeout in seconds for all requests. The frontend application (UI) uses this field's exact value when submitting queries. The backend application uses this field's value, minus one second, for its own timeout handling. This is to ensure a contextual timeout error is presented to the end user in the event of a backend application timeout.", + ) + primary_asn: t.Union[int, str] = Field( + "65001", + title="Primary ASN", + 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", + 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", + 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.", + ) + 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.', + ) + + +class Params(ParamsPublic, HyperglassModel): + """Validation model for all configuration variables.""" + + model_config = ConfigDict(json_schema_extra={"level": 1}) + + # Top Level Params + + fake_output: bool = Field( + False, + title="Fake Output", + description="If enabled, the hyperglass backend will return static fake output for development/testing purposes.", + ) + cors_origins: t.List[str] = Field( + [], + title="Cross-Origin Resource Sharing", + description="Allowed CORS hosts. By default, no CORS hosts are allowed.", + ) + 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() + + def __init__(self, **kw: t.Any) -> None: + return super().__init__(**self.convert_paths(kw)) + + @field_validator("site_description") + def validate_site_description(cls: "Params", value: str, info: ValidationInfo) -> str: + """Format the site description with the org_name field.""" + return value.format(org_name=info.data.get("org_name")) + + @field_validator("primary_asn") + def validate_primary_asn(cls: "Params", value: t.Union[int, str]) -> str: + """Stringify primary_asn if passed as an integer.""" + return str(value) + + @field_validator("plugins") + def validate_plugins(cls: "Params", value: t.List[str]) -> t.List[str]: + """Validate and register configured plugins.""" + plugin_dir = Settings.app_path / "plugins" + + if plugin_dir.exists(): + # Path objects whose file names match configured file names, should work + # whether or not file extension is specified. + matching_plugins = ( + f + for f in plugin_dir.iterdir() + if f.name.split(".")[0] in (p.split(".")[0] for p in value) + ) + return [str(f) for f in matching_plugins] + return [] + + @field_validator("web", mode="after") + @classmethod + def validate_web(cls, web: Web, info: ValidationInfo) -> Web: + """String-format Link URLs.""" + for link in web.links: + url = urllib.parse.unquote(str(link.url), encoding="utf-8", errors="replace").format( + primary_asn=info.data.get("primary_asn", "65000") + ) + link.url = HttpUrl(url) + + 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"), + version=__version__, + ) + return web + + def common_plugins(self) -> t.Tuple[Path, ...]: + """Get all validated external common plugins as Path objects.""" + return tuple(Path(p) for p in self.plugins) + + def export_api(self) -> APIParams: + """Export API-specific parameters.""" + return { + "name": self.site_title, + "organization": self.org_name, + "primary_asn": int(self.primary_asn), + "version": __version__, + } + + def frontend(self) -> t.Dict[str, t.Any]: + """Export UI-specific parameters.""" + + return self.export_dict( + include={ + "cache": {"show_text", "timeout"}, + "developer_mode": ..., + "primary_asn": ..., + "request_timeout": ..., + "org_name": ..., + "site_title": ..., + "site_description": ..., + "web": ..., + "messages": ..., + } + ) diff --git a/hyperglass/models/config/proxy.py b/hyperglass/models/config/proxy.py new file mode 100644 index 0000000..2e9b29b --- /dev/null +++ b/hyperglass/models/config/proxy.py @@ -0,0 +1,59 @@ +"""Validate SSH proxy configuration variables.""" + +# Standard Library +import typing as t +from ipaddress import IPv4Address, IPv6Address + +# Third Party +from pydantic import ValidationInfo, field_validator + +# Project +from hyperglass.util import resolve_hostname +from hyperglass.exceptions.private import ConfigError, UnsupportedDevice + +# Local +from ..main import HyperglassModel +from ..util import check_legacy_fields +from .credential import Credential + + +class Proxy(HyperglassModel): + """Validation model for per-proxy config in devices.yaml.""" + + address: t.Union[IPv4Address, IPv6Address, str] + port: int = 22 + credential: Credential + platform: str = "linux_ssh" + + def __init__(self: "Proxy", **kwargs: t.Any) -> None: + """Check for legacy fields.""" + kwargs = check_legacy_fields(model="Proxy", data=kwargs) + super().__init__(**kwargs) + + @property + def _target(self): + return str(self.address) + + @field_validator("address") + def validate_address(cls, value): + """Ensure a hostname is resolvable.""" + + if not isinstance(value, (IPv4Address, IPv6Address)): + if not any(resolve_hostname(value)): + raise ConfigError( + "Proxy '{a}' is not resolvable.", + a=value, + ) + return value + + @field_validator("platform", mode="before") + def validate_type(cls: "Proxy", value: t.Any, info: ValidationInfo) -> str: + """Validate device type.""" + + if value != "linux_ssh": + raise UnsupportedDevice( + "Proxy '{}' uses platform '{}', which is currently unsupported.", + info.data.get("address"), + value, + ) + return value diff --git a/hyperglass/models/config/structured.py b/hyperglass/models/config/structured.py new file mode 100644 index 0000000..4373e81 --- /dev/null +++ b/hyperglass/models/config/structured.py @@ -0,0 +1,30 @@ +"""Structured data configuration variables.""" + +# Standard Library +import typing as t + +# Local +from ..main import HyperglassModel + +StructuredCommunityMode = t.Literal["permit", "deny"] +StructuredRPKIMode = t.Literal["router", "external"] + + +class StructuredCommunities(HyperglassModel): + """Control structured data response for BGP communities.""" + + mode: StructuredCommunityMode = "deny" + items: t.List[str] = [] + + +class StructuredRpki(HyperglassModel): + """Control structured data response for RPKI state.""" + + mode: StructuredRPKIMode = "router" + + +class Structured(HyperglassModel): + """Control structured data responses.""" + + communities: StructuredCommunities = StructuredCommunities() + rpki: StructuredRpki = StructuredRpki() diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py new file mode 100644 index 0000000..7916d9e --- /dev/null +++ b/hyperglass/models/config/web.py @@ -0,0 +1,252 @@ +"""Validate branding configuration variables.""" + +# Standard Library +import typing as t +from pathlib import Path + +# Third Party +from pydantic import Field, HttpUrl, FilePath, ValidationInfo, field_validator, model_validator +from pydantic_extra_types.color import Color + +# Project +from hyperglass.defaults import DEFAULT_HELP, DEFAULT_TERMS +from hyperglass.constants import DNS_OVER_HTTPS, FUNC_COLOR_MAP + +# Local +from ..main import HyperglassModel +from .opengraph import OpenGraph + +DEFAULT_IMAGES = Path(__file__).parent.parent.parent / "images" +DOH_PROVIDERS_PATTERN = "|".join(DNS_OVER_HTTPS.keys()) +PERCENTAGE_PATTERN = r"^([1-9][0-9]?|100)\%?$" + +Percentage = Field(pattern=r"^([1-9][0-9]?|100)\%$") +TitleMode = t.Literal["logo_only", "text_only", "logo_subtitle", "all"] +ColorMode = t.Literal["light", "dark"] +Side = t.Literal["left", "right"] +LocationDisplayMode = t.Literal["auto", "dropdown", "gallery"] + + +class Credit(HyperglassModel): + """Validation model for developer credit.""" + + enable: bool = True + + +class Link(HyperglassModel): + """Validation model for generic link.""" + + title: str + url: HttpUrl + show_icon: bool = True + side: Side = "left" + order: int = 0 + + +class Menu(HyperglassModel): + """Validation model for generic menu.""" + + title: str + content: str + side: Side = "left" + order: int = 0 + + @field_validator("content") + def validate_content(cls: "Menu", value: str) -> str: + """Read content from file if a path is provided.""" + + if len(value) < 260: + path = Path(value) + if path.is_file() and path.exists(): + with path.open("r") as f: + return f.read() + return value + + +class Greeting(HyperglassModel): + """Validation model for greeting modal.""" + + enable: bool = False + file: t.Optional[FilePath] = None + title: str = "Welcome" + button: str = "Continue" + required: bool = False + + @field_validator("file") + def validate_file(cls, value: str, info: ValidationInfo): + """Ensure file is specified if greeting is enabled.""" + if info.data.get("enable") and value is None: + raise ValueError("Greeting is enabled, but no file is specified.") + return value + + +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" + width: str = Field(default="50%", pattern=PERCENTAGE_PATTERN) + height: t.Optional[str] = Field(default=None, pattern=PERCENTAGE_PATTERN) + + +class LogoPublic(Logo): + """Public logo configuration.""" + + light_format: str + dark_format: str + + +class Text(HyperglassModel): + """Validation model for params.branding.text.""" + + title_mode: TitleMode = "logo_only" + title: str = Field(default="hyperglass", max_length=32) + subtitle: str = Field(default="Network Looking Glass", max_length=32) + query_location: str = "Location" + query_type: str = "Query Type" + query_target: str = "Target" + fqdn_tooltip: str = "Use {protocol}" # Formatted by Javascript + fqdn_message: str = "Your browser has resolved {fqdn} to" # Formatted by Javascript + fqdn_error: str = "Unable to resolve {fqdn}" # Formatted by Javascript + fqdn_error_button: str = "Try Again" + cache_prefix: str = "Results cached for " + cache_icon: str = "Cached from {time} UTC" # Formatted by Javascript + complete_time: str = "Completed in {seconds}" # Formatted by Javascript + rpki_invalid: str = "Invalid" + rpki_valid: str = "Valid" + rpki_unknown: str = "No ROAs Exist" + rpki_unverified: str = "Not Verified" + no_communities: str = "No Communities" + ip_error: str = "Unable to determine IP Address" + no_ip: str = "No {protocol} Address" + ip_select: str = "Select an IP Address" + ip_button: str = "My IP" + + @field_validator("cache_prefix") + def validate_cache_prefix(cls: "Text", value: str) -> str: + """Ensure trailing whitespace.""" + return " ".join(value.split()) + " " + + +class ThemeColors(HyperglassModel): + """Validation model for theme colors.""" + + black: Color = "#000000" + white: Color = "#ffffff" + dark: Color = "#010101" + light: Color = "#f5f6f7" + gray: Color = "#c1c7cc" + red: Color = "#d84b4b" + orange: Color = "#ff6b35" + yellow: Color = "#edae49" + green: Color = "#35b246" + blue: Color = "#314cb6" + teal: Color = "#35b299" + cyan: Color = "#118ab2" + pink: Color = "#f2607d" + purple: Color = "#8d30b5" + primary: t.Optional[Color] = None + secondary: t.Optional[Color] = None + success: t.Optional[Color] = None + warning: t.Optional[Color] = None + error: t.Optional[Color] = None + danger: t.Optional[Color] = None + + @field_validator(*FUNC_COLOR_MAP.keys(), mode="before") + def validate_colors(cls: "ThemeColors", value: str, info: ValidationInfo) -> str: + """Set default functional color mapping.""" + if value is None: + default_color = FUNC_COLOR_MAP[info.field_name] + value = str(info.data[default_color]) + return value + + def dict(self, *args: t.Any, **kwargs: t.Any) -> t.Dict[str, str]: + """Return dict for colors only.""" + return {k: v.as_hex() for k, v in self.__dict__.items()} + + +class ThemeFonts(HyperglassModel): + """Validation model for theme fonts.""" + + body: str = "Nunito" + mono: str = "Fira Code" + + +class Theme(HyperglassModel): + """Validation model for theme variables.""" + + colors: ThemeColors = ThemeColors() + default_color_mode: t.Optional[ColorMode] = None + fonts: ThemeFonts = ThemeFonts() + + +class DnsOverHttps(HyperglassModel): + """Validation model for DNS over HTTPS resolution.""" + + name: str = "cloudflare" + url: str = "" + + @model_validator(mode="before") + def validate_dns(cls, data: "DnsOverHttps") -> t.Dict[str, str]: + """Assign url field to model based on selected provider.""" + name = data.get("name", "cloudflare") + url = data.get("url", DNS_OVER_HTTPS["cloudflare"]) + if url not in DNS_OVER_HTTPS.values(): + return { + "name": "custom", + "url": url, + } + url = DNS_OVER_HTTPS[name] + return { + "name": name, + "url": url, + } + + +class HighlightPattern(HyperglassModel): + """Validation model for highlight pattern configuration.""" + + pattern: str + label: t.Optional[str] = None + color: str = "primary" + + @field_validator("color") + def validate_color(cls: "HighlightPattern", value: str) -> str: + """Ensure highlight color is a valid theme color.""" + colors = list(ThemeColors.model_fields.keys()) + color_list = "\n - ".join(("", *colors)) + if value not in colors: + raise ValueError( + "{!r} is not a supported color. Must be one of:{!s}".format(value, color_list) + ) + return value + + +class Web(HyperglassModel): + """Validation model for all web/browser-related configuration.""" + + credit: Credit = Credit() + dns_provider: DnsOverHttps = DnsOverHttps() + links: t.Sequence[Link] = [ + Link(title="PeeringDB", url="https://www.peeringdb.com/asn/{primary_asn}") + ] + menus: t.Sequence[Menu] = [ + Menu(title="Terms", content=DEFAULT_TERMS), + Menu(title="Help", content=DEFAULT_HELP), + ] + greeting: Greeting = Greeting() + logo: Logo = Logo() + opengraph: OpenGraph = OpenGraph() + text: Text = Text() + theme: Theme = Theme() + location_display_mode: LocationDisplayMode = "auto" + custom_javascript: t.Optional[FilePath] = None + custom_html: t.Optional[FilePath] = None + highlight: t.List[HighlightPattern] = [] + + +class WebPublic(Web): + """Public web configuration.""" + + logo: LogoPublic diff --git a/hyperglass/models/data/__init__.py b/hyperglass/models/data/__init__.py new file mode 100644 index 0000000..02d8f27 --- /dev/null +++ b/hyperglass/models/data/__init__.py @@ -0,0 +1,14 @@ +"""Data structure models.""" + +# Standard Library +from typing import Union + +# Local +from .bgp_route import BGPRouteTable + +OutputDataModel = Union[BGPRouteTable] + +__all__ = ( + "BGPRouteTable", + "OutputDataModel", +) diff --git a/hyperglass/models/data/bgp_route.py b/hyperglass/models/data/bgp_route.py new file mode 100644 index 0000000..dc417ed --- /dev/null +++ b/hyperglass/models/data/bgp_route.py @@ -0,0 +1,124 @@ +"""Device-Agnostic Parsed Response Data Model.""" + +# Standard Library +import re +import typing as t +from ipaddress import ip_network + +# Third Party +from pydantic import field_validator, ValidationInfo + +# Project +from hyperglass.state import use_state +from hyperglass.external.rpki import rpki_state + +# Local +from ..main import HyperglassModel + +WinningWeight = t.Literal["low", "high"] + + +class BGPRoute(HyperglassModel): + """Post-parsed BGP route.""" + + prefix: str + active: bool + age: int + weight: int + med: int + local_preference: int + as_path: t.List[int] + communities: t.List[str] + next_hop: str + source_as: int + source_rid: str + peer_rid: str + rpki_state: int + + @field_validator("communities") + def validate_communities(cls, value): + """Filter returned communities against configured policy. + + Actions: + permit: only permit matches + deny: only deny matches + """ + + (structured := use_state("params").structured) + + def _permit(comm): + """Only allow matching patterns.""" + valid = False + for pattern in structured.communities.items: + if re.match(pattern, comm): + valid = True + break + return valid + + def _deny(comm): + """Allow any except matching patterns.""" + valid = True + for pattern in structured.communities.items: + if re.match(pattern, comm): + valid = False + break + return valid + + func_map = {"permit": _permit, "deny": _deny} + func = func_map[structured.communities.mode] + + return [c for c in value if func(c)] + + @field_validator("rpki_state") + def validate_rpki_state(cls, value, info: ValidationInfo): + """If external RPKI validation is enabled, get validation state.""" + + (structured := use_state("params").structured) + + if structured.rpki.mode == "router": + # If router validation is enabled, return the value as-is. + return value + + if structured.rpki.mode == "external": + # If external validation is enabled, validate the prefix + # & asn with Cloudflare's RPKI API. + 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. + return 3 + # Get last ASN in path + asn = as_path[-1] + + 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) + + return value + + +class BGPRouteTable(HyperglassModel): + """Post-parsed BGP route table.""" + + vrf: str + count: int = 0 + routes: t.List[BGPRoute] + winning_weight: WinningWeight + + def __init__(self, **kwargs): + """Sort routes by prefix after validation.""" + super().__init__(**kwargs) + self.routes = sorted(self.routes, key=lambda r: r.prefix) + + def __add__(self: "BGPRouteTable", other: "BGPRouteTable") -> "BGPRouteTable": + """Merge another BGP table instance with this instance.""" + if isinstance(other, BGPRouteTable): + self.routes = sorted([*self.routes, *other.routes], key=lambda r: r.prefix) + self.count = len(self.routes) + return self diff --git a/hyperglass/models/directive.py b/hyperglass/models/directive.py new file mode 100644 index 0000000..058eaae --- /dev/null +++ b/hyperglass/models/directive.py @@ -0,0 +1,394 @@ +"""Generic command models.""" + +# Standard Library +import re +import typing as t +from ipaddress import IPv4Network, IPv6Network, ip_network + +# Third Party +from pydantic import Field, FilePath, PrivateAttr, IPvAnyNetwork, field_validator + +# Project +from hyperglass.log import log +from hyperglass.types import Series +from hyperglass.settings import Settings +from hyperglass.exceptions.private import InputValidationError + +# Local +from .main import MultiModel, HyperglassModel, HyperglassUniqueModel +from .fields import Action + +StringOrArray = t.Union[str, t.List[str]] +Condition = t.Union[IPvAnyNetwork, str] +RuleValidation = t.Union[t.Literal["ipv4", "ipv6", "pattern"], None] +PassedValidation = t.Union[bool, None] +IPFamily = t.Literal["ipv4", "ipv6"] +RuleTypeAttr = t.Literal["ipv4", "ipv6", "pattern", "none"] + + +class Input(HyperglassModel): + """Base input field.""" + + _type: PrivateAttr + description: str + + @property + def is_select(self) -> bool: + """Determine if this field is a select field.""" + return self._type == "select" + + @property + def is_text(self) -> bool: + """Determine if this field is an input/text field.""" + return self._type == "text" + + +class Text(Input): + """Text/input field model.""" + + _type: PrivateAttr = PrivateAttr("text") + validation: t.Optional[str] = None + + +class Option(HyperglassModel): + """Select option model.""" + + name: t.Optional[str] = None + description: t.Optional[str] = None + value: str + + +class Select(Input): + """Select field model.""" + + _type: PrivateAttr = PrivateAttr("select") + options: t.List[Option] + + +class Rule(HyperglassModel): + """Base rule.""" + + _type: RuleTypeAttr = "none" + _passed: PassedValidation = PrivateAttr(None) + condition: Condition + action: Action = "permit" + commands: t.List[str] = Field([], alias="command") + + @field_validator("commands", mode="before") + def validate_commands(cls, value: t.Union[str, t.List[str]]) -> t.List[str]: + """Ensure commands is a list.""" + if isinstance(value, str): + return [value] + return value + + def validate_target(self, target: str, *, multiple: bool) -> bool: + """Validate a query target (Placeholder signature).""" + raise NotImplementedError( + f"{self._type} rule does not implement a 'validate_target()' method" + ) + + +class RuleWithIP(Rule): + """Base IP-based rule.""" + + condition: t.Union[IPv4Network, IPv6Network] + allow_reserved: bool = False + allow_unspecified: bool = False + allow_loopback: bool = False + ge: int + le: int + + def __init__(self, **kw) -> None: + super().__init__(**kw) + if self.condition.network_address.version == 4: + self._type = "ipv4" + else: + self._type = "ipv6" + + def membership(self, target: IPvAnyNetwork, network: IPvAnyNetwork) -> bool: + """Check if IP address belongs to network.""" + _log = log.bind(target=str(target), network=str(network)) + _log.debug("Checking target membership") + if ( + network.network_address <= target.network_address + and network.broadcast_address >= target.broadcast_address + ): + _log.debug("Target membership verified") + return True + return False + + def in_range(self, target: IPvAnyNetwork) -> bool: + """Verify if target prefix length is within ge/le threshold.""" + if target.prefixlen <= self.le and target.prefixlen >= self.ge: + log.bind(target=str(target), range=f"{self.ge!s}-{self.le!s}").debug( + "Target is in range" + ) + return True + + return False + + def validate_target(self, target: str, *, multiple: bool) -> bool: + """Validate an IP address target against this rule's conditions.""" + + if isinstance(target, t.List): + if len(target) > 1: + self._passed = False + raise InputValidationError(error="Target must be a single value", target=target) + target = target[0] + + try: + # Attempt to use IP object factory to create an IP address object + valid_target = ip_network(target) + + except ValueError as err: + raise InputValidationError(error=str(err), target=target) from err + + if valid_target.version != self.condition.version: + log.bind(target=str(target), condition=str(self.condition)).debug( + "Mismatching IP version" + ) + return False + + is_member = self.membership(valid_target, self.condition) + in_range = self.in_range(valid_target) + + if all((is_member, in_range, self.action == "permit")): + self._passed = True + return True + + if is_member and not in_range: + self._passed = False + raise InputValidationError( + error="Prefix-length is not within range {ge}-{le}", + target=target, + ge=self.ge, + le=self.le, + ) + + if is_member and self.action == "deny": + self._passed = False + raise InputValidationError( + error="Member of denied network '{network}'", + target=target, + network=str(self.condition), + ) + + return False + + +class RuleWithIPv4(RuleWithIP): + """A rule by which to evaluate an IPv4 target.""" + + _type: RuleTypeAttr = "ipv4" + condition: IPv4Network + ge: int = Field(0, ge=0, le=32) + le: int = Field(32, ge=0, le=32) + + +class RuleWithIPv6(RuleWithIP): + """A rule by which to evaluate an IPv6 target.""" + + _type: RuleTypeAttr = "ipv6" + condition: IPv6Network + ge: int = Field(0, ge=0, le=128) + le: int = Field(128, ge=0, le=128) + + +class RuleWithPattern(Rule): + """A rule validated by a regular expression pattern.""" + + _type: RuleTypeAttr = "pattern" + condition: str + + def validate_target(self, target: str, *, multiple: bool) -> str: # noqa: C901 + """Validate a string target against configured regex patterns.""" + + def validate_single_value(value: str) -> t.Union[bool, BaseException]: + if self.condition == "*": + pattern = re.compile(".+", re.IGNORECASE) + else: + pattern = re.compile(self.condition, re.IGNORECASE) + is_match = pattern.match(value) + + if is_match and self.action == "permit": + return True + if is_match and self.action == "deny": + return InputValidationError(target=value, error="Denied") + return False + + if isinstance(target, t.List): + for result in (validate_single_value(v) for v in target): + if isinstance(result, BaseException): + self._passed = False + raise result + if result is False: + self._passed = False + return result + self._passed = True + return True + + result = validate_single_value(target) + + if isinstance(result, BaseException): + self._passed = False + raise result + self._passed = result + return result + + +class RuleWithoutValidation(Rule): + """A rule with no validation.""" + + _type: RuleTypeAttr = "none" + condition: None = None + + def validate_target(self, target: str, *, multiple: bool) -> t.Literal[True]: + """Don't validate a target. Always returns `True`.""" + self._passed = True + return True + + +RuleType = t.Union[ + RuleWithIPv4, + RuleWithIPv6, + RuleWithPattern, + RuleWithoutValidation, +] + + +class Directive(HyperglassUniqueModel, unique_by=("id", "table_output")): + """A directive contains commands that can be run on a device, as long as defined rules are met.""" + + _hyperglass_builtin: bool = PrivateAttr(False) + + id: str + name: str + rules: t.List[RuleType] = [RuleWithoutValidation()] + field: t.Union[Text, Select] + info: t.Optional[FilePath] = None + plugins: t.List[str] = [] + table_output: t.Optional[str] = None + groups: t.List[str] = [] + multiple: bool = False + multiple_separator: str = " " + + @field_validator("rules", mode="before") + @classmethod + def validate_rules(cls, rules: t.List[t.Dict[str, t.Any]]): + """Initialize the correct rule type based on condition value.""" + out_rules: t.List[RuleType] = [] + for rule in rules: + if isinstance(rule, dict): + condition = rule.get("condition") + if condition is None: + out_rules.append(RuleWithoutValidation(**rule)) + try: + condition_net = ip_network(condition) + if condition_net.version == 4: + out_rules.append(RuleWithIPv4(**rule)) + if condition_net.version == 6: + out_rules.append(RuleWithIPv6(**rule)) + except ValueError: + out_rules.append(RuleWithPattern(**rule)) + if isinstance(rule, Rule): + out_rules.append(rule) + return out_rules + + def validate_target(self, target: str) -> bool: + """Validate a target against all configured rules.""" + for rule in self.rules: + valid = rule.validate_target(target, multiple=self.multiple) + if valid is True: + return True + continue + raise InputValidationError(error="No matched validation rules", target=target) + + @property + def field_type(self) -> t.Literal["text", "select", None]: + """Get the linked field type.""" + + if self.field.is_select: + return "select" + if self.field.is_text or self.field.is_ip: + return "text" + return None + + @field_validator("plugins") + def validate_plugins(cls: "Directive", plugins: t.List[str]) -> t.List[str]: + """Validate and register configured plugins.""" + plugin_dir = Settings.app_path / "plugins" + + if plugin_dir.exists(): + # Path objects whose file names match configured file names, should work + # whether or not file extension is specified. + matching_plugins = ( + f + for f in plugin_dir.iterdir() + if f.name.split(".")[0] in (p.split(".")[0] for p in plugins) + ) + return [str(f) for f in matching_plugins] + return [] + + def frontend(self: "Directive") -> t.Dict[str, t.Any]: + """Prepare a representation of the directive for the UI.""" + + value = { + "id": self.id, + "name": self.name, + "field_type": self.field_type, + "groups": self.groups, + "description": self.field.description, + "info": None, + } + + if self.info is not None: + with self.info.open() as md: + value["info"] = md.read() + + if self.field.is_select: + value["options"] = [o.export_dict() for o in self.field.options if o is not None] + + return value + + +class BuiltinDirective(Directive, unique_by=("id", "table_output", "platforms")): + """Natively-supported directive.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: Series[str] = [] + + +DirectiveT = t.Union[BuiltinDirective, Directive] + + +class Directives(MultiModel[Directive], model=Directive, unique_by="id"): + """Collection of directives.""" + + def device_builtins(self, *, platform: str, table_output: bool): + """Get builtin directives for a device.""" + + return Directives( + *( + self.table_if_available(directive) if table_output else directive # noqa: IF100 GFY + for directive in self + if directive._hyperglass_builtin is True + and platform in getattr(directive, "platforms", ()) + ) + ) + + def table_if_available(self, directive: "Directive") -> "Directive": + """Get the table-output variant of a directive if it exists.""" + for _directive in self: + if _directive.id == directive.table_output: + return _directive + return directive + + @classmethod + def new(cls, /, *raw_directives: t.Dict[str, t.Any]) -> "Directives": + """Create a new Directives collection from raw directive configurations.""" + directives = ( + Directive(id=name, **directive) + for raw_directive in raw_directives + for name, directive in raw_directive.items() + ) + return Directives(*directives) diff --git a/hyperglass/models/fields.py b/hyperglass/models/fields.py new file mode 100644 index 0000000..6314a91 --- /dev/null +++ b/hyperglass/models/fields.py @@ -0,0 +1,57 @@ +"""Custom Pydantic Fields/Types.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import AfterValidator, BeforeValidator + +IntFloat = t.TypeVar("IntFloat", int, float) +J = t.TypeVar("J") + +SupportedDriver = t.Literal["netmiko", "hyperglass_agent"] +HttpAuthMode = t.Literal["basic", "api_key"] +HttpProvider = t.Literal["msteams", "slack", "generic"] +LogFormat = t.Literal["text", "json"] +Primitives = t.Union[None, float, int, bool, str] +JsonValue = t.Union[J, t.Sequence[J], t.Dict[str, J]] +ActionValue = t.Literal["permit", "deny"] +HttpMethodValue = t.Literal[ + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "TRACE", +] + + +def validate_uri(value: str) -> str: + """Ensure URI string contains a leading forward-slash.""" + uri_regex = re.compile(r"^(\/.*)$") + match = uri_regex.fullmatch(value) + if not match: + raise ValueError("Invalid format. A URI must begin with a forward slash, e.g. '/example'") + return match.group() + + +def validate_action(value: str) -> ActionValue: + """Ensure action is an allowed value or acceptable alias.""" + permits = ("permit", "allow", "accept") + denies = ("deny", "block", "reject") + value = value.strip().lower() + if value in permits: + return "permit" + if value in denies: + return "deny" + + raise ValueError("Action must be one of '{}'".format(", ".join((*permits, *denies)))) + + +AnyUri = t.Annotated[str, AfterValidator(validate_uri)] +Action = t.Annotated[ActionValue, AfterValidator(validate_action)] +HttpMethod = t.Annotated[HttpMethodValue, BeforeValidator(str.upper)] diff --git a/hyperglass/models/main.py b/hyperglass/models/main.py new file mode 100644 index 0000000..d4fa3ae --- /dev/null +++ b/hyperglass/models/main.py @@ -0,0 +1,358 @@ +"""Data models used throughout hyperglass.""" + +# Standard Library + +# Standard Library +import re +import json +import typing as t +from pathlib import Path + +# Third Party +from pydantic import HttpUrl, BaseModel, RootModel, ConfigDict, PrivateAttr + +# Project +from hyperglass.log import log +from hyperglass.util import compare_init, snake_to_camel, repr_from_attrs +from hyperglass.types import Series + +MultiModelT = t.TypeVar("MultiModelT", bound=BaseModel) + +PathTypeT = t.TypeVar("PathTypeT") + + +def alias_generator(field: str) -> str: + """Remove unsupported characters from field names. + + Converts any "desirable" separators to underscore, then removes all + characters that are unsupported in Python class variable names. + Also removes leading numbers underscores. + """ + _replaced = re.sub(r"[\-|\.|\@|\~|\:\/|\s]", "_", field) + _scrubbed = "".join(re.findall(r"([a-zA-Z]\w+|\_+)", _replaced)) + snake_field = _scrubbed.lower() + return snake_to_camel(snake_field) + + +class HyperglassModel(BaseModel): + """Base model for all hyperglass configuration models.""" + + model_config = ConfigDict( + extra="forbid", + json_encoders={HttpUrl: lambda v: str(v), Path: str}, + populate_by_name=True, + validate_assignment=True, + validate_default=True, + alias_generator=alias_generator, + ) + + def convert_paths(self, value: t.Type[PathTypeT]) -> PathTypeT: + """Change path to relative to app_path. + + This is required when running hyperglass in a container so that + the original app_path on the host system is not passed through + to the container. + """ + # Project + from hyperglass.settings import Settings + + if isinstance(value, Path): + if Settings.container: + return Settings.default_app_path.joinpath( + *( + p + for p in value.parts + if p not in Settings.original_app_path.absolute().parts + ) + ) + + if isinstance(value, str) and str(Settings.original_app_path.absolute()) in value: + if Settings.container: + path = Path(value) + return str( + Settings.default_app_path.joinpath( + *( + p + for p in path.parts + if p not in Settings.original_app_path.absolute().parts + ) + ) + ) + + if isinstance(value, t.Tuple): + return tuple(self.convert_paths(v) for v in value) + if isinstance(value, t.List): + return [self.convert_paths(v) for v in value] + if isinstance(value, t.Generator): + return (self.convert_paths(v) for v in value) + if isinstance(value, t.Dict): + return {k: self.convert_paths(v) for k, v in value.items()} + return value + + def _repr_from_attrs(self, attrs: Series[str]) -> str: + """Alias to `hyperglass.util:repr_from_attrs` in the context of this model.""" + return repr_from_attrs(self, attrs) + + def export_json(self, *args, **kwargs): + """Return instance as JSON.""" + + export_kwargs = {"by_alias": False, "exclude_unset": False} + + for key in kwargs.keys(): + export_kwargs.pop(key, None) + + return self.model_dump_json(*args, **export_kwargs, **kwargs) + + def export_dict(self, *args, **kwargs): + """Return instance as dictionary.""" + + export_kwargs = {"by_alias": False, "exclude_unset": False} + + for key in kwargs.keys(): + export_kwargs.pop(key, None) + + return self.model_dump(*args, **export_kwargs, **kwargs) + + def export_yaml(self, *args, **kwargs): + """Return instance as YAML.""" + + # Standard Library + import json + + # Third Party + import yaml + + export_kwargs = { + "by_alias": kwargs.pop("by_alias", False), + "exclude_unset": kwargs.pop("exclude_unset", False), + } + + return yaml.safe_dump(json.loads(self.export_json(**export_kwargs)), *args, **kwargs) + + +class HyperglassUniqueModel(HyperglassModel): + """hyperglass model that is unique by its `id` field.""" + + _unique_fields: t.ClassVar[Series[str]] = () + + def __init_subclass__(cls, *, unique_by: Series[str], **kw: t.Any) -> None: + """Assign unique fields to class.""" + cls._unique_fields = tuple(unique_by) + return super().__init_subclass__(**kw) + + def __eq__(self: "HyperglassUniqueModel", other: "HyperglassUniqueModel") -> bool: + """Other model is equal to this model.""" + if not isinstance(other, self.__class__): + return False + if hash(self) == hash(other): + return True + return False + + def __ne__(self: "HyperglassUniqueModel", other: "HyperglassUniqueModel") -> bool: + """Other model is not equal to this model.""" + return not self.__eq__(other) + + def __hash__(self: "HyperglassUniqueModel") -> int: + """Create a hashed representation of this model's name.""" + fields = dict(zip(self._unique_fields, (getattr(self, f) for f in self._unique_fields))) + return hash(json.dumps(fields)) + + +class HyperglassModelWithId(HyperglassModel): + """hyperglass model that is unique by its `id` field.""" + + id: str + + def __eq__(self: "HyperglassModelWithId", other: "HyperglassModelWithId") -> bool: + """Other model is equal to this model.""" + if not isinstance(other, self.__class__): + return False + if hasattr(other, "id"): + return other and self.id == other.id + return False + + def __ne__(self: "HyperglassModelWithId", other: "HyperglassModelWithId") -> bool: + """Other model is not equal to this model.""" + return not self.__eq__(other) + + def __hash__(self: "HyperglassModelWithId") -> int: + """Create a hashed representation of this model's name.""" + return hash(self.id) + + +class MultiModel(RootModel[MultiModelT]): + """Extension of HyperglassModel for managing multiple models as a list.""" + + model_config = ConfigDict( + validate_default=True, + validate_assignment=True, + ) + + model: t.ClassVar[MultiModelT] + unique_by: t.ClassVar[str] + _model_name: t.ClassVar[str] = "MultiModel" + + root: t.List[MultiModelT] = [] + _count: int = PrivateAttr() + + def __init__(self, *items: t.Union[MultiModelT, t.Dict[str, t.Any]]) -> None: + """Validate items.""" + for cls_var in ("model", "unique_by"): + if getattr(self, cls_var, None) is None: + raise AttributeError(f"MultiModel is missing class variable '{cls_var}'") + valid = self._valid_items(*items) + super().__init__(root=valid) + self._count = len(self.root) + + def __init_subclass__(cls, **kw: t.Any) -> None: + """Add class variables from keyword arguments.""" + model = kw.pop("model", None) + cls.model = model + cls.unique_by = kw.pop("unique_by", None) + cls._model_name = getattr(model, "__name__", "MultiModel") + super().__init_subclass__() + + def __repr__(self) -> str: + """Represent model.""" + return repr_from_attrs(self, ["_count", "unique_by", "_model_name"], strip="_") + + def __iter__(self) -> t.Iterator[MultiModelT]: + """Iterate items.""" + return iter(self.root) + + def __getitem__(self, value: t.Union[int, str]) -> MultiModelT: + """Get an item by its `unique_by` property.""" + if not isinstance(value, (str, int)): + raise TypeError( + "Value of {}.{!s} should be a string or integer. Got {!r} ({!s})".format( + self.__class__.__name__, self.unique_by, value, type(value) + ) + ) + if isinstance(value, int): + return self.root[value] + + for item in self: + if hasattr(item, self.unique_by) and getattr(item, self.unique_by) == value: + return item + raise IndexError( + "No match found for {!s}.{!s}={!r}".format( + self.model.__class__.__name__, self.unique_by, value + ), + ) + + def __add__(self, other: MultiModelT) -> MultiModelT: + """Merge another MultiModel with this one. + + Note: If you're subclassing `HyperglassMultiModel` and overriding `__init__`, you need to + override this too. + """ + valid = all( + ( + isinstance(other, self.__class__), + hasattr(other, "model"), + getattr(other, "model", None) == self.model, + ), + ) + if not valid: + raise TypeError(f"Cannot add {other!r} to {self.__class__.__name__}") + merged = self._merge_with(*other, unique_by=self.unique_by) + + if compare_init(self.__class__, other.__class__): + return self.__class__(*merged) + raise TypeError( + f"{self.__class__.__name__} and {other.__class__.__name__} have different `__init__` " + "signatures. You probably need to override `MultiModel.__add__`" + ) + + def __len__(self) -> int: + """Get number of items.""" + return len(self.root) + + @property + def ids(self) -> t.Tuple[t.Any, ...]: + """Get values of all items by `unique_by` property.""" + return tuple(sorted(getattr(item, self.unique_by) for item in self)) + + @property + def count(self) -> int: + """Access item count.""" + return self._count + + @classmethod + def create(cls, name: str, *, model: MultiModelT, unique_by: str) -> "MultiModel": + """Create a MultiModel.""" + new = type(name, (cls,), cls.__dict__) + new.model = model + new.unique_by = unique_by + new._model_name = getattr(model, "__name__", "MultiModel") + return new + + def _valid_items( + self, *to_validate: t.List[t.Union[MultiModelT, t.Dict[str, t.Any]]] + ) -> t.List[MultiModelT]: + items = [ + item + for item in to_validate + if any( + ( + (isinstance(item, self.model) and hasattr(item, self.unique_by)), + (isinstance(item, t.Dict) and self.unique_by in item), + ), + ) + ] + for index, item in enumerate(items): + if isinstance(item, t.Dict): + items[index] = self.model(**item) + return items + + def _merge_with(self, *items, unique_by: t.Optional[str] = None) -> Series[MultiModelT]: + to_add = self._valid_items(*items) + if unique_by is not None: + unique_by_values = { + getattr(obj, unique_by) for obj in (*self, *to_add) if hasattr(obj, unique_by) + } + unique_by_objects = { + v: o + for v in unique_by_values + for o in (*self, *to_add) + if getattr(o, unique_by) == v + } + return tuple(unique_by_objects.values()) + return (*self.root, *to_add) + + def filter(self, *properties: str) -> MultiModelT: + """Get only items with `unique_by` properties matching values in `properties`.""" + return self.__class__( + *(item for item in self if getattr(item, self.unique_by, None) in properties) + ) + + def matching(self, *unique: str) -> MultiModelT: + """Get a new instance containing partial matches from `accessors`.""" + + def matches(*searches: str) -> t.Generator[MultiModelT, None, None]: + """Get any matching items by unique_by property. + + For example, if `unique` is `('one', 'two')`, and `Model.<unique_by>` is `'one'`, + `Model` is yielded. + """ + for search in searches: + pattern = re.compile(rf".*{search}.*", re.IGNORECASE) + for item in self: + if pattern.match(getattr(item, self.unique_by)): + yield item + + return self.__class__(*matches(*unique)) + + def add(self, *items, unique_by: t.Optional[str] = None) -> None: + """Add an item to the model.""" + new = self._merge_with(*items, unique_by=unique_by) + self.root = new + self._count = len(self.root) + for item in new: + log.debug( + "Added {} '{!s}' to {}".format( + item.__class__.__name__, + getattr(item, self.unique_by), + self.__class__.__name__, + ) + ) diff --git a/hyperglass/models/parsing/__init__.py b/hyperglass/models/parsing/__init__.py new file mode 100644 index 0000000..9ce8b52 --- /dev/null +++ b/hyperglass/models/parsing/__init__.py @@ -0,0 +1 @@ +"""Data models for parsed responses.""" diff --git a/hyperglass/models/parsing/arista_eos.py b/hyperglass/models/parsing/arista_eos.py new file mode 100644 index 0000000..2b00e01 --- /dev/null +++ b/hyperglass/models/parsing/arista_eos.py @@ -0,0 +1,167 @@ +"""Data Models for Parsing Arista JSON Response.""" + +# Standard Library +import typing as t +from datetime import datetime + +# Third Party +from pydantic import ConfigDict + +# Project +from hyperglass.log import log +from hyperglass.models.data import BGPRouteTable + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "notFound": 2, + "notValidated": 3, +} + +WINNING_WEIGHT = "high" + + +def _alias_generator(field: str) -> str: + caps = "".join(x for x in field.title() if x.isalnum()) + return caps[0].lower() + caps[1:] + + +class _AristaBase(HyperglassModel): + """Base Model for Arista validation.""" + + model_config = ConfigDict(extra="ignore", alias_generator=_alias_generator) + + +class AristaAsPathEntry(_AristaBase): + """Validation model for Arista asPathEntry.""" + + as_path_type: str = "External" + as_path: t.Optional[str] = "" + + +class AristaPeerEntry(_AristaBase): + """Validation model for Arista peerEntry.""" + + peer_router_id: str + peer_addr: str + + +class AristaRouteType(_AristaBase): + """Validation model for Arista routeType.""" + + origin: str + suppressed: bool + valid: bool + active: bool + origin_validity: t.Optional[str] = "notVerified" + + +class AristaRouteDetail(_AristaBase): + """Validation for Arista routeDetail.""" + + origin: str + label_stack: t.List = [] + ext_community_list: t.List[str] = [] + ext_community_list_raw: t.List[str] = [] + community_list: t.List[str] = [] + large_community_list: t.List[str] = [] + + +class AristaRoutePath(_AristaBase): + """Validation model for Arista bgpRoutePaths.""" + + as_path_entry: AristaAsPathEntry + med: int = 0 + local_preference: int + weight: int + peer_entry: AristaPeerEntry + reason_not_bestpath: str + timestamp: int = int(datetime.utcnow().timestamp()) + next_hop: str + route_type: AristaRouteType + route_detail: t.Optional[AristaRouteDetail] + + +class AristaRouteEntry(_AristaBase): + """Validation model for Arista bgpRouteEntries.""" + + total_paths: int = 0 + bgp_advertised_peer_groups: t.Dict = {} + mask_length: int + bgp_route_paths: t.List[AristaRoutePath] = [] + + +class AristaBGPTable(_AristaBase): + """Validation model for Arista bgpRouteEntries data.""" + + router_id: str + vrf: str + bgp_route_entries: t.Dict[str, AristaRouteEntry] + # The raw value is really a string, but `int` will convert it. + asn: int + + @staticmethod + def _get_route_age(timestamp: int) -> int: + now = datetime.utcnow() + now_timestamp = int(now.timestamp()) + return now_timestamp - timestamp + + @staticmethod + def _get_as_path(as_path: str) -> t.List[str]: + if as_path == "": + return [] + return [int(p) for p in as_path.split() if p.isdecimal()] + + def bgp_table(self: "AristaBGPTable") -> "BGPRouteTable": + """Convert the Arista-formatted fields to standard parsed data model.""" + routes = [] + count = 0 + for prefix, entries in self.bgp_route_entries.items(): + count += entries.total_paths + + for route in entries.bgp_route_paths: + as_path = self._get_as_path(route.as_path_entry.as_path) + rpki_state = RPKI_STATE_MAP.get(route.route_type.origin_validity, 3) + + # BGP AS Path and BGP Community queries do not include the routeDetail + # block. Therefore, we must verify it exists before including its data. + communities = [] + if route.route_detail is not None: + communities = route.route_detail.community_list + + # iBGP paths contain an empty AS_PATH array. If the AS_PATH is empty, we + # set the source_as to the router's local-as. + source_as = self.asn + if len(as_path) != 0: + source_as = as_path[0] + + routes.append( + { + "prefix": prefix, + "active": route.route_type.active, + "age": self._get_route_age(route.timestamp), + "weight": route.weight, + "med": route.med, + "local_preference": route.local_preference, + "as_path": as_path, + "communities": communities, + "next_hop": route.next_hop, + "source_as": source_as, + "source_rid": route.peer_entry.peer_router_id, + "peer_rid": route.peer_entry.peer_router_id, + "rpki_state": rpki_state, + } + ) + + serialized = BGPRouteTable( + vrf=self.vrf, + count=count, + routes=routes, + winning_weight=WINNING_WEIGHT, + ) + + log.bind(platform="arista_eos", response=repr(serialized)).debug("Serialized response") + return serialized diff --git a/hyperglass/models/parsing/frr.py b/hyperglass/models/parsing/frr.py new file mode 100644 index 0000000..895046b --- /dev/null +++ b/hyperglass/models/parsing/frr.py @@ -0,0 +1,119 @@ +"""Data Models for Parsing FRRouting JSON Response.""" + +# Standard Library +import typing as t +from datetime import datetime + +# Third Party +from pydantic import ConfigDict, model_validator + +# Project +from hyperglass.log import log +from hyperglass.models.data import BGPRouteTable + +# Local +from ..main import HyperglassModel + +FRRPeerType = t.Literal["internal", "external"] + + +def _alias_generator(field): + components = field.split("_") + return components[0] + "".join(x.title() for x in components[1:]) + + +class _FRRBase(HyperglassModel): + model_config = ConfigDict(alias_generator=_alias_generator, extra="ignore") + + +class FRRNextHop(_FRRBase): + """FRR Next Hop Model.""" + + ip: str + afi: str + metric: int + accessible: bool + used: bool + + +class FRRPeer(_FRRBase): + """FRR Peer Model.""" + + peer_id: str + router_id: str + type: FRRPeerType + + +class FRRPath(_FRRBase): + """FRR Path Model.""" + + aspath: t.List[int] + aggregator_as: int + aggregator_id: str + med: int = 0 + localpref: int + weight: int + valid: bool + last_update: int + bestpath: bool + community: t.List[str] + nexthops: t.List[FRRNextHop] + peer: FRRPeer + + @model_validator(pre=True) + def validate_path(cls, values): + """Extract meaningful data from FRR response.""" + new = values.copy() + new["aspath"] = values["aspath"]["segments"][0]["list"] + new["community"] = values["community"]["list"] + new["lastUpdate"] = values["lastUpdate"]["epoch"] + bestpath = values.get("bestpath", {}) + new["bestpath"] = bestpath.get("overall", False) + return new + + +class FRRRoute(_FRRBase): + """FRR Route Model.""" + + prefix: str + paths: t.List[FRRPath] = [] + + def serialize(self): + """Convert the FRR-specific fields to standard parsed data model.""" + + # TODO: somehow, get the actual VRF + vrf = "default" + + routes = [] + for route in self.paths: + now = datetime.utcnow().timestamp() + then = datetime.utcfromtimestamp(route.last_update).timestamp() + age = int(now - then) + routes.append( + { + "prefix": self.prefix, + "active": route.bestpath, + "age": age, + "weight": route.weight, + "med": route.med, + "local_preference": route.localpref, + "as_path": route.aspath, + "communities": route.community, + "next_hop": route.nexthops[0].ip, + "source_as": route.aggregator_as, + "source_rid": route.aggregator_id, + "peer_rid": route.peer.peer_id, + # TODO: somehow, get the actual RPKI state + "rpki_state": 3, + } + ) + + serialized = BGPRouteTable( + vrf=vrf, + count=len(routes), + routes=routes, + winning_weight="high", + ) + + log.bind(platform="frr", response=repr(serialized)).debug("Serialized response") + return serialized diff --git a/hyperglass/models/parsing/juniper.py b/hyperglass/models/parsing/juniper.py new file mode 100644 index 0000000..f4f999c --- /dev/null +++ b/hyperglass/models/parsing/juniper.py @@ -0,0 +1,199 @@ +"""Data Models for Parsing Juniper XML Response.""" + +# Standard Library +import typing as t + +# Third Party +from pydantic import ConfigDict, field_validator, model_validator + +# Project +from hyperglass.log import log +from hyperglass.util import deep_convert_keys +from hyperglass.models.data.bgp_route import BGPRouteTable + +# Local +from ..main import HyperglassModel + +RPKI_STATE_MAP = { + "invalid": 0, + "valid": 1, + "unknown": 2, + "unverified": 3, +} + + +class JuniperBase(HyperglassModel, extra="ignore"): + """Base Juniper model.""" + + def __init__(self, **kwargs: t.Any) -> None: + """Convert all `-` keys to `_`. + + Default camelCase alias generator will still be used. + """ + rebuilt = deep_convert_keys(kwargs, lambda k: k.replace("-", "_")) + super().__init__(**rebuilt) + + +class JuniperRouteTableEntry(JuniperBase): + """Parse Juniper rt-entry data.""" + + model_config = ConfigDict(validate_assignment=False) + + active_tag: bool + preference: int + age: int + local_preference: int + metric: int = 0 + as_path: t.List[int] = [] + validation_state: int = 3 + next_hop: str + peer_rid: str + peer_as: int + source_as: int + source_rid: str + communities: t.List[str] = None + + @model_validator(mode="before") + def validate_optional_flags(cls, values: t.Dict[str, t.Any]): + """Flatten & rename keys prior to validation.""" + next_hops = [] + nh = None + + # Handle Juniper's 'Router' Next Hop Type + if "nh" in values: + nh = values.pop("nh") + + # Handle Juniper's 'Indirect' Next Hop Type + if "protocol_nh" in values: + nh = values.pop("protocol_nh") + + # Force the next hops to be a list + if isinstance(nh, t.Dict): + nh = [nh] + + if nh is not None: + next_hops.extend(nh) + + # Extract the 'to:' value from the next-hop + selected_next_hop = "" + for hop in next_hops: + if "selected_next_hop" in hop: + selected_next_hop = hop.get("to", "") + break + if hop.get("to") is not None: + selected_next_hop = hop["to"] + break + + values["next_hop"] = selected_next_hop + + _path_attr = values.get("bgp_path_attributes", {}) + _path_attr_agg = _path_attr.get("attr_aggregator", {}).get("attr_value", {}) + values["as_path"] = _path_attr.get("attr_as_path_effective", {}).get("attr_value", "") + values["source_as"] = _path_attr_agg.get("aggr_as_number", 0) + values["source_rid"] = _path_attr_agg.get("aggr_router_id", "") + values["peer_rid"] = values.get("peer_id", "") + + return values + + @field_validator("validation_state", mode="before") + def validate_rpki_state(cls, value): + """Convert string RPKI state to standard integer mapping.""" + return RPKI_STATE_MAP.get(value, 3) + + @field_validator("active_tag", mode="before") + def validate_active_tag(cls, value): + """Convert active-tag from string/null to boolean.""" + if value == "*": + value = True + else: + value = False + return value + + @field_validator("age", mode="before") + def validate_age(cls, value): + """Get age as seconds.""" + if not isinstance(value, dict): + try: + value = int(value) + except ValueError as err: + raise ValueError(f"Age field is in an unexpected format. Got: {value}") from err + else: + value = value.get("@junos:seconds", 0) + return int(value) + + @field_validator("as_path", mode="before") + def validate_as_path(cls, value): + """Remove origin flags from AS_PATH.""" + disallowed = ("E", "I", "?") + return [int(a) for a in value.split() if a not in disallowed] + + @field_validator("communities", mode="before") + def validate_communities(cls, value): + """Flatten community list.""" + if value is not None: + flat = value.get("community", []) + else: + flat = [] + return flat + + +class JuniperRouteTable(JuniperBase): + """Validation model for Juniper rt data.""" + + rt_destination: str + rt_prefix_length: int + rt_entry_count: int + rt_announced_count: int + rt_entry: t.List[JuniperRouteTableEntry] + + @field_validator("rt_entry_count", mode="before") + def validate_entry_count(cls, value): + """Flatten & convert entry-count to integer.""" + return int(value.get("#text")) + + +class JuniperBGPTable(JuniperBase): + """Validation model for route-table data.""" + + table_name: str + destination_count: int + total_route_count: int + active_route_count: int + hidden_route_count: int + rt: t.List[JuniperRouteTable] + + def bgp_table(self: "JuniperBGPTable") -> "BGPRouteTable": + """Convert the Juniper-specific fields to standard parsed data model.""" + vrf_parts = self.table_name.split(".") + if len(vrf_parts) == 2: + vrf = "default" + else: + vrf = vrf_parts[0] + + routes = [] + count = 0 + for table in self.rt: + count += table.rt_entry_count + prefix = "/".join(str(i) for i in (table.rt_destination, table.rt_prefix_length)) + for route in table.rt_entry: + routes.append( + { + "prefix": prefix, + "active": route.active_tag, + "age": route.age, + "weight": route.preference, + "med": route.metric, + "local_preference": route.local_preference, + "as_path": route.as_path, + "communities": route.communities, + "next_hop": route.next_hop, + "source_as": route.source_as, + "source_rid": route.source_rid, + "peer_rid": route.peer_rid, + "rpki_state": route.validation_state, + } + ) + + serialized = BGPRouteTable(vrf=vrf, count=count, routes=routes, winning_weight="low") + log.bind(platform="juniper", response=repr(serialized)).debug("Serialized response") + return serialized diff --git a/hyperglass/models/system.py b/hyperglass/models/system.py new file mode 100644 index 0000000..0494526 --- /dev/null +++ b/hyperglass/models/system.py @@ -0,0 +1,176 @@ +"""hyperglass System Settings model.""" + +# Standard Library +import typing as t +from pathlib import Path +from ipaddress import ip_address + +# Third Party +from pydantic import ( + FilePath, + RedisDsn, + SecretStr, + DirectoryPath, + IPvAnyAddress, + ValidationInfo, + field_validator, +) +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Project +from hyperglass.util import at_least, cpu_count + +if t.TYPE_CHECKING: + # Third Party + from rich.console import Console, RenderResult, ConsoleOptions + +ListenHost = t.Union[None, IPvAnyAddress, t.Literal["localhost"]] + +_default_app_path = Path("/etc/hyperglass") + + +class HyperglassSettings(BaseSettings): + """hyperglass system settings, required to start hyperglass.""" + + model_config = SettingsConfigDict(env_prefix="hyperglass_") + + config_file_names: t.ClassVar[t.Tuple[str, ...]] = ("config", "devices", "directives") + default_app_path: t.ClassVar[Path] = _default_app_path + original_app_path: Path = _default_app_path + + debug: bool = False + dev_mode: bool = False + disable_ui: bool = False + app_path: DirectoryPath = _default_app_path + redis_host: str = "localhost" + redis_password: t.Optional[SecretStr] = None + redis_db: int = 1 + redis_dsn: RedisDsn = None + host: IPvAnyAddress = None + port: int = 8001 + ca_cert: t.Optional[FilePath] = None + container: bool = False + + def __init__(self, **kwargs) -> None: + """Create hyperglass Settings instance.""" + super().__init__(**kwargs) + if self.container: + self.app_path = self.default_app_path + + def __rich_console__(self, console: "Console", options: "ConsoleOptions") -> "RenderResult": + """Render a Rich table representation of hyperglass settings.""" + # Third Party + from rich.panel import Panel + from rich.style import Style + from rich.table import Table, box + from rich.pretty import Pretty + + table = Table(box=box.MINIMAL, border_style="subtle") + table.add_column("Environment Variable", style=Style(color="#118ab2", bold=True)) + table.add_column("Value") + params = sorted( + ( + "debug", + "dev_mode", + "app_path", + "redis_host", + "redis_db", + "redis_dsn", + "host", + "port", + ) + ) + for attr in params: + table.add_row(f"hyperglass_{attr}".upper(), Pretty(getattr(self, attr))) + + yield Panel.fit(table, title="hyperglass settings", border_style="subtle") + + @field_validator("host", mode="before") + def validate_host( + cls: "HyperglassSettings", value: t.Any, info: ValidationInfo + ) -> IPvAnyAddress: + """Set default host based on debug mode.""" + + if value is None: + if info.data.get("debug") is False: + return ip_address("::1") + if info.data.get("debug") is True: + return ip_address("::") + + if isinstance(value, str): + if value != "localhost": + try: + return ip_address(value) + except ValueError as err: + raise ValueError(str(value)) from err + + elif value == "localhost": + return ip_address("::1") + + raise ValueError(str(value)) + + @field_validator("redis_dsn", mode="before") + def validate_redis_dsn(cls, value: t.Any, info: ValidationInfo) -> RedisDsn: + """Construct a Redis DSN if none is provided.""" + if value is None: + host = info.data.get("redis_host") + db = info.data.get("redis_db") + dsn = "redis://{}/{!s}".format(host, db) + password = info.data.get("redis_password") + if password is not None: + dsn = "redis://:{}@{}/{!s}".format(password.get_secret_value(), host, db) + return dsn + return value + + def bind(self: "HyperglassSettings") -> str: + """Format a listen_address. Wraps IPv6 address in brackets.""" + if self.host.version == 6: + return f"[{self.host!s}]:{self.port!s}" + return f"{self.host!s}:{self.port!s}" + + @property + def log_level(self: "HyperglassSettings") -> str: + """Get log level as string, inferred from debug mode.""" + if self.debug: + return "DEBUG" + return "WARNING" + + @property + def workers(self: "HyperglassSettings") -> int: + """Get worker count, inferred from debug mode.""" + if self.debug: + return 1 + return cpu_count(2) + + @property + def redis(self: "HyperglassSettings") -> t.Dict[str, t.Union[None, int, str]]: + """Get redis parameters as a dict for convenient connection setups.""" + password = None + if self.redis_password is not None: + password = self.redis_password.get_secret_value() + + return { + "db": self.redis_db, + "host": self.redis_host, + "password": password, + } + + @property + def redis_connection_pool(self: "HyperglassSettings") -> t.Dict[str, t.Any]: + """Get Redis ConnectionPool keyword arguments.""" + return {"url": str(self.redis_dsn), "max_connections": at_least(8, cpu_count(2))} + + @property + def dev_url(self: "HyperglassSettings") -> str: + """Get the hyperglass URL for when dev_mode is enabled.""" + return f"http://localhost:{self.port!s}/" + + @property + def prod_url(self: "HyperglassSettings") -> str: + """Get the UI-facing hyperglass URL/path.""" + return "/api/" + + @property + def static_path(self: "HyperglassSettings") -> Path: + """Get static asset path.""" + return Path(self.app_path / "static") diff --git a/hyperglass/models/tests/__init__.py b/hyperglass/models/tests/__init__.py new file mode 100644 index 0000000..bb9fb66 --- /dev/null +++ b/hyperglass/models/tests/__init__.py @@ -0,0 +1 @@ +"""Model tests.""" diff --git a/hyperglass/models/tests/test_multi_model.py b/hyperglass/models/tests/test_multi_model.py new file mode 100644 index 0000000..43d8083 --- /dev/null +++ b/hyperglass/models/tests/test_multi_model.py @@ -0,0 +1,48 @@ +"""Test HyperglassMultiModel.""" + +# Third Party +from pydantic import BaseModel + +# Local +from ..main import MultiModel + + +class Item(BaseModel): + """Test item.""" + + id: str + name: str + + +class Items(MultiModel, model=Item, unique_by="id"): + """Multi Model Test.""" + + +ITEMS_1 = [ + {"id": "item1", "name": "Item One"}, + Item(id="item2", name="Item Two"), + {"id": "item3", "name": "Item Three"}, +] + +ITEMS_2 = [ + Item(id="item4", name="Item Four"), + {"id": "item5", "name": "Item Five"}, +] + +ITEMS_3 = [ + {"id": "item1", "name": "Item New One"}, + {"id": "item6", "name": "Item Six"}, +] + + +def test_multi_model(): + model = Items(*ITEMS_1) + assert model.count == 3 + assert len([o for o in model]) == model.count # noqa: C416 (Iteration testing) + assert model["item1"].name == "Item One" + model.add(*ITEMS_2) + assert model.count == 5 + assert model[3].name == "Item Four" + model.add(*ITEMS_3, unique_by="id") + assert model.count == 6 + assert model["item1"].name == "Item New One" diff --git a/hyperglass/models/tests/test_util.py b/hyperglass/models/tests/test_util.py new file mode 100644 index 0000000..bb133d6 --- /dev/null +++ b/hyperglass/models/tests/test_util.py @@ -0,0 +1,30 @@ +"""Test model utilities.""" + +# Third Party +import pytest + +# Local +from ..util import check_legacy_fields + + +@pytest.mark.dependency() +def test_check_legacy_fields(): + test1 = {"name": "Device A", "nos": "juniper"} + test1_expected = {"name": "Device A", "platform": "juniper"} + test2 = {"name": "Device B", "platform": "juniper"} + test3 = {"name": "Device C"} + test4 = {"name": "Device D", "network": "this is wrong"} + + assert set(check_legacy_fields(model="Device", data=test1).keys()) == set( + test1_expected.keys() + ), "legacy field not replaced" + + assert set(check_legacy_fields(model="Device", data=test2).keys()) == set( + test2.keys() + ), "new field not left unmodified" + + with pytest.raises(ValueError): + check_legacy_fields(model="Device", data=test3) + + with pytest.raises(ValueError): + check_legacy_fields(model="Device", data=test4) diff --git a/hyperglass/models/ui.py b/hyperglass/models/ui.py new file mode 100644 index 0000000..3e2c106 --- /dev/null +++ b/hyperglass/models/ui.py @@ -0,0 +1,64 @@ +"""UI Configuration models.""" + +# Standard Library +import typing as t + +# Local +from .main import HyperglassModel +from .config.web import WebPublic +from .config.cache import Cache +from .config.params import ParamsPublic +from .config.messages import Messages + +Alignment = t.Union[t.Literal["left"], t.Literal["center"], t.Literal["right"], None] +StructuredDataField = t.Tuple[str, str, Alignment] + + +class UIDirective(HyperglassModel): + """UI: Directive.""" + + id: str + name: str + field_type: str + groups: t.List[str] + description: str + info: t.Optional[str] = None + options: t.Optional[t.List[t.Dict[str, t.Any]]] = None + + +class UILocation(HyperglassModel): + """UI: Location (Device).""" + + id: str + name: str + group: t.Optional[str] = None + avatar: t.Optional[str] = None + description: t.Optional[str] = None + directives: t.List[UIDirective] = [] + + +class UIDevices(HyperglassModel): + """UI: Devices.""" + + group: t.Optional[str] = None + locations: t.List[UILocation] = [] + + +class UIContent(HyperglassModel): + """UI: Content.""" + + credit: str + greeting: str + + +class UIParameters(ParamsPublic, HyperglassModel): + """UI Configuration Parameters.""" + + cache: Cache + web: WebPublic + messages: Messages + version: str + devices: t.List[UIDevices] = [] + parsed_data_fields: t.Tuple[StructuredDataField, ...] + content: UIContent + developer_mode: bool diff --git a/hyperglass/models/util.py b/hyperglass/models/util.py new file mode 100644 index 0000000..75f432c --- /dev/null +++ b/hyperglass/models/util.py @@ -0,0 +1,67 @@ +"""Model utilities.""" + +# Standard Library +import typing as t + +# Third Party +from pydantic import BaseModel + +# Project +from hyperglass.log import log + + +class LegacyField(BaseModel): + """Define legacy fields on a per-model basis. + + When `overwrite` is `True`, the old key is replaced with the new + key. This will generally only occur when the value type is the same, + and the key name has only changed names for clarity or cosmetic + purposes. + + When `overwrite` is `False` and the old key is found, an error is + raised. This generally occurs when the overall function of the old + and new keys has remained the same, but the value type has changed, + requiring the user to make changes to the config file. + + When `required` is `True` and neither the old or new keys are found, + an error is raised. When `required` is false and neither keys are + found, nothing happens. + """ + + old: str + new: str + overwrite: bool = False + required: bool = True + + +LEGACY_FIELDS: t.Dict[str, t.Tuple[LegacyField, ...]] = { + "Device": ( + LegacyField(old="nos", new="platform", overwrite=True), + LegacyField(old="network", new="group", overwrite=False, required=False), + ), + "Proxy": (LegacyField(old="nos", new="platform", overwrite=True),), +} + + +def check_legacy_fields(*, model: str, data: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: + """Check for legacy fields prior to model initialization.""" + if model in LEGACY_FIELDS: + for field in LEGACY_FIELDS[model]: + legacy_value = data.pop(field.old, None) + new_value = data.get(field.new) + if legacy_value is not None and new_value is None: + if field.overwrite: + log.bind(old_field=f"{model}.{field.old}", new_field=field.new).warning( + "Deprecated field" + ) + data[field.new] = legacy_value + else: + raise ValueError( + ( + "The {!r} field has been replaced with the {!r} field. " + "Please consult the documentation and/or changelog to determine the appropriate migration path." + ).format(f"{model}.{field.old}", field.new) + ) + elif legacy_value is None and new_value is None and field.required: + raise ValueError(f"'{field.new}' is missing") + return data diff --git a/hyperglass/models/webhook.py b/hyperglass/models/webhook.py new file mode 100644 index 0000000..2c83193 --- /dev/null +++ b/hyperglass/models/webhook.py @@ -0,0 +1,162 @@ +"""Data models used throughout hyperglass.""" + +# Standard Library +import typing as t +from datetime import datetime + +# Third Party +from pydantic import ConfigDict, model_validator + +# Project +from hyperglass.log import log + +# 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" + + +def to_snake_case(value: str) -> str: + """Convert string to snake case.""" + return value.replace("_", "-") + + +class WebhookHeaders(HyperglassModel): + """Webhook data model.""" + + model_config = ConfigDict(alias_generator=to_snake_case) + + user_agent: t.Optional[str] = None + referer: t.Optional[str] = None + accept_encoding: t.Optional[str] = None + accept_language: t.Optional[str] = None + x_real_ip: t.Optional[str] = None + x_forwarded_for: t.Optional[str] = None + + +class WebhookNetwork(HyperglassModel): + """Webhook data model.""" + + model_config = ConfigDict(extra="allow") + + prefix: str = "Unknown" + asn: str = "Unknown" + org: str = "Unknown" + country: str = "Unknown" + + +class Webhook(HyperglassModel): + """Webhook data model.""" + + query_location: str + query_type: str + query_target: t.Union[t.List[str], str] + headers: WebhookHeaders + source: str = "Unknown" + network: WebhookNetwork + timestamp: datetime + + @model_validator(mode="before") + def validate_webhook(cls, model: "Webhook") -> "Webhook": + """Reset network attributes if the source is localhost.""" + if model.source in ("127.0.0.1", "::1"): + model.network = {} + return model + + def msteams(self) -> t.Dict[str, t.Any]: + """Format the webhook data as a Microsoft Teams card.""" + + def code(value: t.Any) -> str: + """Wrap argument in backticks for markdown inline code formatting.""" + return f"`{str(value)}`" + + header_data = [ + {"name": k, "value": code(v)} for k, v in self.headers.model_dump(by_alias=True).items() + ] + time_fmt = self.timestamp.strftime("%Y %m %d %H:%M:%S") + payload = { + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "themeColor": "118ab2", + "summary": _WEBHOOK_TITLE, + "sections": [ + { + "activityTitle": _WEBHOOK_TITLE, + "activitySubtitle": f"{time_fmt} UTC", + "activityImage": _ICON_URL, + "facts": [ + {"name": "Query Location", "value": self.query_location}, + {"name": "Query Target", "value": code(self.query_target)}, + {"name": "Query Type", "value": self.query_type}, + ], + }, + {"markdown": True, "text": "**Source Information**"}, + {"markdown": True, "text": "---"}, + { + "markdown": True, + "facts": [ + {"name": "IP", "value": code(self.source)}, + {"name": "Prefix", "value": code(self.network.prefix)}, + {"name": "ASN", "value": code(self.network.asn)}, + {"name": "Country", "value": self.network.country}, + {"name": "Organization", "value": self.network.org}, + ], + }, + {"markdown": True, "text": "**Request Headers**"}, + {"markdown": True, "text": "---"}, + {"markdown": True, "facts": header_data}, + ], + } + log.bind(type="MS Teams", payload=str(payload)).debug("Created webhook") + + return payload + + def slack(self) -> t.Dict[str, t.Any]: + """Format the webhook data as a Slack message.""" + + def make_field(key, value, code=False): + if code: + value = f"`{value}`" + return f"*{key}*\n{value}" + + header_data = [] + for k, v in self.headers.model_dump(by_alias=True).items(): + field = make_field(k, v, code=True) + header_data.append(field) + + query_data = [ + {"type": "mrkdwn", "text": make_field("Query Location", self.query_location)}, + {"type": "mrkdwn", "text": make_field("Query Target", self.query_target, code=True)}, + {"type": "mrkdwn", "text": make_field("Query Type", self.query_type)}, + ] + + source_data = [ + {"type": "mrkdwn", "text": make_field("Source IP", self.source, code=True)}, + { + "type": "mrkdwn", + "text": make_field("Source Prefix", self.network.prefix, code=True), + }, + {"type": "mrkdwn", "text": make_field("Source ASN", self.network.asn, code=True)}, + {"type": "mrkdwn", "text": make_field("Source Country", self.network.country)}, + {"type": "mrkdwn", "text": make_field("Source Organization", self.network.org)}, + ] + + time_fmt = self.timestamp.strftime("%Y %m %d %H:%M:%S") + + payload = { + "text": _WEBHOOK_TITLE, + "blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": f"*{time_fmt} UTC*"}}, + {"type": "section", "fields": query_data}, + {"type": "divider"}, + {"type": "section", "fields": source_data}, + {"type": "divider"}, + { + "type": "section", + "text": {"type": "mrkdwn", "text": "*Headers*\n" + "\n".join(header_data)}, + }, + ], + } + log.bind(type="Slack", payload=str(payload)).debug("Created webhook") + return payload diff --git a/hyperglass/plugins/__init__.py b/hyperglass/plugins/__init__.py new file mode 100644 index 0000000..52933a1 --- /dev/null +++ b/hyperglass/plugins/__init__.py @@ -0,0 +1,18 @@ +"""hyperglass Plugins.""" + +# Local +from .main import register_plugin, init_builtin_plugins +from ._input import InputPlugin, InputPluginValidationReturn +from ._output import OutputType, OutputPlugin +from ._manager import InputPluginManager, OutputPluginManager + +__all__ = ( + "init_builtin_plugins", + "InputPlugin", + "InputPluginManager", + "InputPluginValidationReturn", + "OutputPlugin", + "OutputPluginManager", + "OutputType", + "register_plugin", +) diff --git a/hyperglass/plugins/_base.py b/hyperglass/plugins/_base.py new file mode 100644 index 0000000..ea4e2e1 --- /dev/null +++ b/hyperglass/plugins/_base.py @@ -0,0 +1,119 @@ +"""Base Plugin Definition.""" + +# Standard Library +import typing as t +from abc import ABC +from inspect import Signature + +# Third Party +from pydantic import BaseModel, PrivateAttr + +# Project +from hyperglass.log import log as _logger + +if t.TYPE_CHECKING: + # Third Party + from loguru import Logger + +PluginType = t.Union[t.Literal["output"], t.Literal["input"]] +SupportedMethod = t.TypeVar("SupportedMethod") + + +class HyperglassPlugin(BaseModel, ABC): + """Plugin to interact with device command output.""" + + _hyperglass_builtin: bool = PrivateAttr(False) + _type: t.ClassVar[str] + name: str + common: bool = False + ref: t.Optional[str] = None + log: t.ClassVar["Logger"] = _logger + + @property + def _signature(self) -> Signature: + """Get this instance's class signature.""" + return self.__class__.__signature__ + + def __eq__(self, other: "HyperglassPlugin") -> bool: + """Other plugin is equal to this plugin.""" + if hasattr(other, "_signature"): + return other and self._signature == other._signature + return False + + def __ne__(self, other: "HyperglassPlugin") -> bool: + """Other plugin is not equal to this plugin.""" + return not self.__eq__(other) + + def __hash__(self) -> int: + """Create a hashed representation of this plugin's name.""" + return hash(self._signature) + + def __str__(self) -> str: + """Represent plugin by its name.""" + return self.name + + @classmethod + def __init_subclass__(cls, **kwargs: t.Any) -> None: + """Initialize plugin object.""" + name = kwargs.pop("name", None) or cls.__name__ + cls.name = name + super().__init_subclass__() + + def __init__(self, **kwargs: t.Any) -> None: + """Initialize plugin instance.""" + name = kwargs.pop("name", None) or self.__class__.__name__ + super().__init__(name=name, **kwargs) + + def __rich_console__(self, *_, **__): + """Create a rich representation of this plugin for the hyperglass CLI.""" + + # Third Party + from rich.text import Text + from rich.panel import Panel + from rich.table import Table + from rich.pretty import Pretty + + table = Table.grid(padding=(0, 1), expand=False) + table.add_column(justify="right") + + data = {"builtin": True if self._hyperglass_builtin else False} + data.update( + { + attr: getattr(self, attr) + for attr in ("name", "common", "directives", "platforms") + if hasattr(self, attr) + } + ) + data = {k: data[k] for k in sorted(data.keys())} + for key, value in data.items(): + table.add_row( + Text.assemble((key, "inspect.attr"), (" =", "inspect.equals")), Pretty(value) + ) + + yield Panel( + table, + expand=False, + title=f"[bold magenta]{self.name}", + title_align="left", + subtitle=f"[bold cornflower_blue]{self._type.capitalize()} Plugin", + subtitle_align="right", + padding=(1, 3), + ) + + +class DirectivePlugin(BaseModel): + """Plugin associated with directives. + + Should always be subclassed with `HyperglassPlugin`. + """ + + directives: t.Sequence[str] = () + + +class PlatformPlugin(BaseModel): + """Plugin associated with specific device platform. + + Should always be subclassed with `HyperglassPlugin`. + """ + + platforms: t.Sequence[str] = () diff --git a/hyperglass/plugins/_builtin/__init__.py b/hyperglass/plugins/_builtin/__init__.py new file mode 100644 index 0000000..4a36861 --- /dev/null +++ b/hyperglass/plugins/_builtin/__init__.py @@ -0,0 +1,14 @@ +"""Built-in hyperglass plugins.""" + +# Local +from .remove_command import RemoveCommand +from .bgp_route_arista import BGPRoutePluginArista +from .bgp_route_juniper import BGPRoutePluginJuniper +from .mikrotik_garbage_output import MikrotikGarbageOutput + +__all__ = ( + "BGPRoutePluginArista", + "BGPRoutePluginJuniper", + "MikrotikGarbageOutput", + "RemoveCommand", +) diff --git a/hyperglass/plugins/_builtin/bgp_community.py b/hyperglass/plugins/_builtin/bgp_community.py new file mode 100644 index 0000000..5573d9e --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_community.py @@ -0,0 +1,112 @@ +"""Remove anything before the command if found in output.""" + +# Standard Library +import typing as t +from ipaddress import ip_address + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.state.hooks import use_state + +# Local +from .._input import InputPlugin + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.api.query import Query + + # Local + from .._input import InputPluginValidationReturn + +_32BIT = 0xFFFFFFFF +_16BIT = 0xFFFF +EXTENDED_TYPES = ("target", "origin") + + +def check_decimal(value: str, size: int) -> bool: + """Verify the value is a 32 bit number.""" + try: + return abs(int(value)) <= size + except Exception: + return False + + +def check_string(value: str) -> bool: + """Verify part of a community is an IPv4 address, per RFC4360.""" + try: + addr = ip_address(value) + return addr.version == 4 + except ValueError: + return False + + +def validate_decimal(value: str) -> bool: + """Verify a community is a 32 bit decimal number.""" + return check_decimal(value, _32BIT) + + +def validate_new_format(value: str) -> bool: + """Verify a community matches "new" format, standard or extended.""" + if ":" in value: + parts = [p for p in value.split(":") if p] + if len(parts) == 3: + if parts[0].lower() not in EXTENDED_TYPES: + # Handle extended community format with `target:` or `origin:` prefix. + return False + # Remove type from parts list after it's been validated. + parts = parts[1:] + if len(parts) != 2: + # Only allow two sections in new format, e.g. 65000:1 + return False + + one, two = parts + + if all((check_decimal(one, _16BIT), check_decimal(two, _16BIT))): + # Handle standard format, e.g. `65000:1` + return True + if all((check_decimal(one, _16BIT), check_decimal(two, _32BIT))): + # Handle extended format, e.g. `65000:4294967295` + return True + if all((check_string(one), check_decimal(two, _16BIT))): + # Handle IP address format, e.g. `192.0.2.1:65000` + return True + + return False + + +def validate_large_community(value: str) -> bool: + """Verify a community matches "large" format. E.g., `65000:65001:65002`.""" + if ":" in value: + parts = [p for p in value.split(":") if p] + if len(parts) != 3: + return False + for part in parts: + if not check_decimal(part, _32BIT): + # Each member must be a 32 bit number. + return False + return True + return False + + +class ValidateBGPCommunity(InputPlugin): + """Validate a BGP community string.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + + def validate(self, query: "Query") -> "InputPluginValidationReturn": + """Ensure an input query target is a valid BGP community.""" + + params = use_state("params") + + if not isinstance(query.query_target, str): + return None + + for validator in (validate_decimal, validate_new_format, validate_large_community): + result = validator(query.query_target) + if result is True: + return True + + self.failure_reason = params.messages.invalid_input + return False diff --git a/hyperglass/plugins/_builtin/bgp_route_arista.py b/hyperglass/plugins/_builtin/bgp_route_arista.py new file mode 100644 index 0000000..b65310a --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_route_arista.py @@ -0,0 +1,92 @@ +"""Parse Arista JSON Response to Structured Data.""" + +# Standard Library +import json +import typing as t + +# Third Party +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.arista_eos import AristaBGPTable + +# Local +from .._output import OutputPlugin + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + + # Local + from .._output import OutputType + + +def parse_arista(output: t.Sequence[str]) -> "OutputDataModel": + """Parse a Arista BGP JSON response.""" + result = None + + _log = log.bind(plugin=BGPRoutePluginArista.__name__) + + for response in output: + try: + parsed: t.Dict = json.loads(response) + + _log.debug("Pre-parsed data", data=parsed) + + vrf = list(parsed["vrfs"].keys())[0] + routes = parsed["vrfs"][vrf] + + validated = AristaBGPTable(**routes) + bgp_table = validated.bgp_table() + + if result is None: + result = bgp_table + else: + result += bgp_table + + except json.JSONDecodeError as err: + _log.bind(error=str(err)).critical("Failed to decode JSON") + raise ParsingError("Error parsing response data") from err + + except KeyError as err: + _log.bind(key=str(err)).critical("Missing required key in response") + raise ParsingError("Error parsing response data") from err + + except IndexError as err: + _log.critical(err) + raise ParsingError("Error parsing response data") from err + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err.errors()) from err + + return result + + +class BGPRoutePluginArista(OutputPlugin): + """Coerce a Arista route table in JSON format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: t.Sequence[str] = ("arista_eos",) + directives: t.Sequence[str] = ( + "__hyperglass_arista_eos_bgp_route_table__", + "__hyperglass_arista_eos_bgp_aspath_table__", + "__hyperglass_arista_eos_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse Arista response if data is a string (and is therefore unparsed).""" + 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_arista(output) + return output diff --git a/hyperglass/plugins/_builtin/bgp_route_juniper.py b/hyperglass/plugins/_builtin/bgp_route_juniper.py new file mode 100644 index 0000000..eb5d38e --- /dev/null +++ b/hyperglass/plugins/_builtin/bgp_route_juniper.py @@ -0,0 +1,143 @@ +"""Coerce a Juniper route table in XML format to a standard BGP Table structure.""" + +# Standard Library +import re +from typing import TYPE_CHECKING, List, Sequence, Generator + +# Third Party +import xmltodict # type: ignore +from pydantic import PrivateAttr, ValidationError + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import ParsingError +from hyperglass.models.parsing.juniper import JuniperBGPTable + +# Local +from .._output import OutputPlugin + +if TYPE_CHECKING: + # Standard Library + from collections import OrderedDict + + # Project + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + + # Local + from .._output import OutputType + + +REMOVE_PATTERNS = ( + # The XML response can a CLI banner appended to the end of the XML + # string. For example: + # ``` + # <rpc-reply> + # ... + # <cli> + # <banner>{master}</banner> + # </cli> + # </rpc-reply> + # + # {master} noqa: E800 + # ``` + # + # This pattern will remove anything inside braces, including the braces. + r"\{.+\}", +) + + +def clean_xml_output(output: str) -> str: + """Remove Juniper-specific patterns from output.""" + + def scrub(lines: List[str]) -> Generator[str, None, None]: + """Clean & remove each pattern from each line.""" + for pattern in REMOVE_PATTERNS: + for line in lines: + # Remove the pattern & strip extra newlines + scrubbed = re.sub(pattern, "", line.strip()) + # Only return non-empty and non-newline lines + if scrubbed and scrubbed != "\n": + yield scrubbed + + lines = scrub(output.splitlines()) + + return "\n".join(lines) + + +def parse_juniper(output: Sequence[str]) -> "OutputDataModel": # noqa: C901 + """Parse a Juniper BGP XML response.""" + result = None + + _log = log.bind(plugin=BGPRoutePluginJuniper.__name__) + for response in output: + cleaned = clean_xml_output(response) + + try: + parsed: "OrderedDict" = xmltodict.parse( + cleaned, force_list=("rt", "rt-entry", "community") + ) + if "rpc-reply" in parsed.keys(): + if "xnm:error" in parsed["rpc-reply"]: + if "message" in parsed["rpc-reply"]["xnm:error"]: + err = parsed["rpc-reply"]["xnm:error"]["message"] + raise ParsingError('Error from device: "{}"', err) + + parsed_base = parsed["rpc-reply"]["route-information"] + elif "route-information" in parsed.keys(): + parsed_base = parsed["route-information"] + + if "route-table" not in parsed_base: + return result + + if "rt" not in parsed_base["route-table"]: + return result + + parsed = parsed_base["route-table"] + validated = JuniperBGPTable(**parsed) + bgp_table = validated.bgp_table() + + if result is None: + result = bgp_table + else: + result += bgp_table + + except xmltodict.expat.ExpatError as err: + _log.bind(error=str(err)).critical("Failed to decode XML") + raise ParsingError("Error parsing response data") from err + + except KeyError as err: + _log.bind(key=str(err)).critical("Missing required key in response") + raise ParsingError("{key} was not found in the response", key=str(err)) from err + + except ValidationError as err: + _log.critical(err) + raise ParsingError(err) from err + + return result + + +class BGPRoutePluginJuniper(OutputPlugin): + """Coerce a Juniper route table in XML format to a standard BGP Table structure.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + platforms: Sequence[str] = ("juniper",) + directives: Sequence[str] = ( + "__hyperglass_juniper_bgp_route_table__", + "__hyperglass_juniper_bgp_aspath_table__", + "__hyperglass_juniper_bgp_community_table__", + ) + + def process(self, *, output: "OutputType", query: "Query") -> "OutputType": + """Parse Juniper response if data is a string (and is therefore unparsed).""" + 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_juniper(output) + return output diff --git a/hyperglass/plugins/_builtin/mikrotik_garbage_output.py b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py new file mode 100644 index 0000000..8718604 --- /dev/null +++ b/hyperglass/plugins/_builtin/mikrotik_garbage_output.py @@ -0,0 +1,82 @@ +"""Remove anything before the command if found in output.""" + +# Standard Library +import re +import typing as t + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.types import Series + +# Local +from .._output import OutputType, OutputPlugin + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.api.query import Query + + +class MikrotikGarbageOutput(OutputPlugin): + """Parse Mikrotik output to remove garbage.""" + + _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__", + ) + + def process(self, *, output: OutputType, query: "Query") -> Series[str]: + """Parse Mikrotik output to remove garbage.""" + + result = () + + 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 = "" + 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 diff --git a/hyperglass/plugins/_builtin/remove_command.py b/hyperglass/plugins/_builtin/remove_command.py new file mode 100644 index 0000000..bc301ca --- /dev/null +++ b/hyperglass/plugins/_builtin/remove_command.py @@ -0,0 +1,42 @@ +"""Remove anything before the command if found in output.""" + +# Standard Library +from typing import TYPE_CHECKING, Sequence + +# Third Party +from pydantic import PrivateAttr + +# Project +from hyperglass.util.typing import is_series + +# Local +from .._output import OutputType, OutputPlugin + +if TYPE_CHECKING: + # Project + from hyperglass.models.api.query import Query + + +class RemoveCommand(OutputPlugin): + """Remove anything before the command if found in output.""" + + _hyperglass_builtin: bool = PrivateAttr(True) + + def process(self, *, output: OutputType, query: "Query") -> Sequence[str]: + """Remove anything before the command if found in output.""" + + def _remove_command(output_in: str) -> str: + output_out = output_in.strip().split("\n") + + for command in query.device.directive_commands: + for line in output_out: + if command in line: + idx = output_out.index(line) + 1 + output_out = output_out[idx:] + + return "\n".join(output_out) + + if is_series(output): + return tuple(_remove_command(o) for o in output) + + return output diff --git a/hyperglass/plugins/_input.py b/hyperglass/plugins/_input.py new file mode 100644 index 0000000..e2316bf --- /dev/null +++ b/hyperglass/plugins/_input.py @@ -0,0 +1,30 @@ +"""Input validation plugins.""" + +# Standard Library +import typing as t + +# Local +from ._base import DirectivePlugin, HyperglassPlugin + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.api.query import Query + + +InputPluginValidationReturn = t.Union[None, bool] +InputPluginTransformReturn = t.Union[t.Sequence[str], str] + + +class InputPlugin(HyperglassPlugin, DirectivePlugin): + """Plugin to validate user input prior to running commands.""" + + _type = "input" + failure_reason: t.Optional[str] = None + + def validate(self, query: "Query") -> InputPluginValidationReturn: + """Validate input from hyperglass UI/API.""" + return None + + def transform(self, query: "Query") -> InputPluginTransformReturn: + """Transform query target prior to running commands.""" + return query.query_target diff --git a/hyperglass/plugins/_manager.py b/hyperglass/plugins/_manager.py new file mode 100644 index 0000000..c0194ac --- /dev/null +++ b/hyperglass/plugins/_manager.py @@ -0,0 +1,193 @@ +"""Plugin manager definition.""" + +# Standard Library +import typing as t +from inspect import isclass + +# Project +from hyperglass.log import log +from hyperglass.state import use_state +from hyperglass.exceptions.private import PluginError, InputValidationError + +# Local +from ._base import PluginType, HyperglassPlugin +from ._input import InputPlugin, InputPluginTransformReturn, InputPluginValidationReturn +from ._output import OutputType, OutputPlugin + +if t.TYPE_CHECKING: + # Project + from hyperglass.state import HyperglassState + from hyperglass.models.api.query import Query + +PluginT = t.TypeVar("PluginT", bound=HyperglassPlugin) + + +class PluginManager(t.Generic[PluginT]): + """Manage all plugins.""" + + _type: PluginType + _state: "HyperglassState" + _index: int = 0 + _cache_key: str + + def __init__(self: "PluginManager") -> None: + """Initialize plugin manager.""" + self._state = use_state() + self._cache_key = f"hyperglass.plugins.{self._type}" + + def __init_subclass__(cls: "PluginManager", **kwargs: PluginType) -> None: + """Set this plugin manager's type on subclass initialization.""" + _type = kwargs.get("type", None) or cls._type + if _type is None: + raise PluginError("Plugin '{}' is missing a 'type', keyword argument", repr(cls)) + cls._type = _type + return super().__init_subclass__() + + def __iter__(self: "PluginManager") -> "PluginManager": + """Plugin manager iterator.""" + return self + + def __next__(self: "PluginManager") -> PluginT: + """Plugin manager iteration.""" + if self._index <= len(self.plugins()): + result = self.plugins()[self._index - 1] + self._index += 1 + return result + self._index = 0 + raise StopIteration + + def plugins(self: "PluginManager", *, builtins: bool = True) -> t.List[PluginT]: + """Get all plugins, with built-in plugins last.""" + plugins = self._state.plugins(self._type) + + if builtins is False: + plugins = [p for p in plugins if p._hyperglass_builtin is False] + + # Sort plugins by their name attribute, which is the name of the class by default. + sorted_by_name = sorted(plugins, key=lambda p: str(p)) + + # Sort with built-in plugins last. + return sorted( + sorted_by_name, + key=lambda p: -1 if p._hyperglass_builtin else 1, + reverse=True, + ) + + @property + def name(self: PluginT) -> str: + """Get this plugin manager's name.""" + return self.__class__.__name__ + + def methods(self: "PluginManager", name: str) -> t.Generator[t.Callable, None, None]: + """Get methods of all registered plugins matching `name`.""" + for plugin in self.plugins(): + if hasattr(plugin, name): + method = getattr(plugin, name) + if callable(method): + yield method + + def execute(self, *args, **kwargs) -> None: + """Gather all plugins and execute in order.""" + raise NotImplementedError(f"Plugin Manager '{self.name}' is missing an 'execute()' method.") + + def reset(self: "PluginManager") -> None: + """Remove all plugins.""" + self._index = 0 + self._state.reset_plugins(self._type) + + def unregister(self: "PluginManager", plugin: PluginT) -> None: + """Remove a plugin from currently active plugins.""" + if isclass(plugin): + if issubclass(plugin, HyperglassPlugin): + self._state.remove_plugin(self._type, plugin) + + return + raise PluginError("Plugin '{}' is not a valid hyperglass plugin", repr(plugin)) + + def register(self: "PluginManager", plugin: PluginT, *args: t.Any, **kwargs: t.Any) -> None: + """Add a plugin to currently active plugins.""" + # Create a set of plugins so duplicate plugins are not mistakenly added. + try: + if issubclass(plugin, HyperglassPlugin): + instance = plugin(*args, **kwargs) + self._state.add_plugin(self._type, instance) + _log = log.bind(type=self._type, name=instance.name) + if instance._hyperglass_builtin is True: + _log.debug("Registered built-in plugin") + else: + _log.info("Registered plugin") + return + except TypeError: + raise PluginError( # noqa: B904 + "Plugin '{p}' has not defined a required method. " + "Please consult the hyperglass documentation.", + p=repr(plugin), + ) + raise PluginError("Plugin '{p}' is not a valid hyperglass plugin", p=repr(plugin)) + + +class InputPluginManager(PluginManager[InputPlugin], type="input"): + """Manage Input Validation Plugins.""" + + def _gather_plugins( + self: "InputPluginManager", query: "Query" + ) -> t.Generator[InputPlugin, None, None]: + for plugin in self.plugins(builtins=True): + if plugin.directives and query.directive.id in plugin.directives: + yield plugin + if plugin.ref in query.directive.plugins: + yield plugin + if plugin.common is True: + yield plugin + + def validate(self: "InputPluginManager", query: "Query") -> InputPluginValidationReturn: + """Execute all input validation plugins. + + If any plugin returns `False`, execution is halted. + """ + result = None + for plugin in self._gather_plugins(query): + result = plugin.validate(query) + result_test = "valid" if result is True else "invalid" if result is False else "none" + log.bind(name=plugin.name, result=result_test).debug("Input Plugin Validation") + if result is False: + raise InputValidationError( + error="No matched validation rules", target=query.query_target + ) + if result is True: + return result + return result + + def transform(self: "InputPluginManager", *, query: "Query") -> InputPluginTransformReturn: + """Execute all input transformation plugins.""" + result = query.query_target + for plugin in self._gather_plugins(query): + result = plugin.transform(query=query.summary()) + log.bind(name=plugin.name, result=repr(result)).debug("Input Plugin Transform") + return result + + +class OutputPluginManager(PluginManager[OutputPlugin], type="output"): + """Manage Output Processing Plugins.""" + + def execute(self: "OutputPluginManager", *, output: OutputType, query: "Query") -> OutputType: + """Execute all output parsing plugins. + + The result of each plugin is passed to the next plugin. + """ + result = output + directives = ( + plugin + for plugin in self.plugins() + if query.directive.id in plugin.directives and query.device.platform in plugin.platforms + ) + common = (plugin for plugin in self.plugins() if plugin.common is True) + for plugin in (*directives, *common): + log.bind(plugin=plugin.name, value=result).debug("Output Plugin Starting Value") + result = plugin.process(output=result, query=query) + log.bind(plugin=plugin.name, value=result).debug("Output Plugin Ending Value") + + if result is False: + return result + # Pass the result of each plugin to the next plugin. + return result diff --git a/hyperglass/plugins/_output.py b/hyperglass/plugins/_output.py new file mode 100644 index 0000000..8f14dd4 --- /dev/null +++ b/hyperglass/plugins/_output.py @@ -0,0 +1,29 @@ +"""Device output plugins.""" + +# Standard Library +from typing import TYPE_CHECKING, Union + +# Project +from hyperglass.log import log +from hyperglass.types import Series + +# Local +from ._base import PlatformPlugin, DirectivePlugin, HyperglassPlugin + +if TYPE_CHECKING: + # Project + from hyperglass.models.data import OutputDataModel + from hyperglass.models.api.query import Query + +OutputType = Union["OutputDataModel", Series[str]] + + +class OutputPlugin(HyperglassPlugin, DirectivePlugin, PlatformPlugin): + """Plugin to interact with device command output.""" + + _type = "output" + + def process(self, *, output: OutputType, query: "Query") -> OutputType: + """Process or manipulate output from a device.""" + log.warning("Output plugin has not implemented a 'process()' method", plugin=self.name) + return output diff --git a/hyperglass/plugins/external/.gitignore b/hyperglass/plugins/external/.gitignore new file mode 100644 index 0000000..9beee42 --- /dev/null +++ b/hyperglass/plugins/external/.gitignore @@ -0,0 +1,4 @@ +# Ignore all files as they're imported at runtime. +* +!__init__.py +!.gitignore diff --git a/hyperglass/plugins/external/__init__.py b/hyperglass/plugins/external/__init__.py new file mode 100644 index 0000000..78c58eb --- /dev/null +++ b/hyperglass/plugins/external/__init__.py @@ -0,0 +1 @@ +"""Container for external plugins. External plugins are copied here on registration.""" diff --git a/hyperglass/plugins/main.py b/hyperglass/plugins/main.py new file mode 100644 index 0000000..abc83fb --- /dev/null +++ b/hyperglass/plugins/main.py @@ -0,0 +1,69 @@ +"""Register all plugins.""" + +# Standard Library +import sys +import shutil +import typing as t +from inspect import isclass, getmembers +from pathlib import Path +from importlib.util import module_from_spec, spec_from_file_location + +# Local +from . import _builtin +from ._input import InputPlugin +from ._output import OutputPlugin +from ._manager import InputPluginManager, OutputPluginManager + + +def _is_class(module: t.Any, obj: object) -> bool: + if isclass(obj): + # Get the object's containing module name. + obj_module_name: str = getattr(obj, "__module__", "") + # Get the module's name. + module_name: str = getattr(module, "__name__", None) + # Only validate objects that are members of the module. + return module_name in obj_module_name + return False + + +def _register_from_module(module: t.Any, **kwargs: t.Any) -> t.Tuple[str, ...]: + """Register defined classes from the module.""" + failures = () + defs = getmembers(module, lambda o: _is_class(module, o)) + sys.modules[module.__name__] = module + for name, plugin in defs: + if issubclass(plugin, OutputPlugin): + manager = OutputPluginManager() + elif issubclass(plugin, InputPlugin): + manager = InputPluginManager() + else: + failures += (name,) + continue + manager.register(plugin, **kwargs) + return failures + + +def _module_from_file(file: Path) -> t.Any: + """Import a plugin module from its file Path object.""" + plugins_dir = Path(__file__).parent / "external" + dst = plugins_dir / f"imported_{file.name}" + shutil.copy2(file, dst) + name = f"imported_{file.name.split('.')[0]}" + spec = spec_from_file_location(f"hyperglass.plugins.external.{name}", dst) + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def init_builtin_plugins() -> None: + """Initialize all built-in plugins.""" + _register_from_module(_builtin) + + +def register_plugin(plugin_file: Path, **kwargs) -> t.Tuple[str, ...]: + """Register an external plugin by file path.""" + if plugin_file.exists(): + module = _module_from_file(plugin_file) + results = _register_from_module(module, ref=plugin_file.stem, **kwargs) + return results + raise FileNotFoundError(str(plugin_file)) diff --git a/hyperglass/plugins/tests/__init__.py b/hyperglass/plugins/tests/__init__.py new file mode 100644 index 0000000..30d0597 --- /dev/null +++ b/hyperglass/plugins/tests/__init__.py @@ -0,0 +1 @@ +"""Plugin tests.""" diff --git a/hyperglass/plugins/tests/_fixtures.py b/hyperglass/plugins/tests/_fixtures.py new file mode 100644 index 0000000..f054a3c --- /dev/null +++ b/hyperglass/plugins/tests/_fixtures.py @@ -0,0 +1,7 @@ +# Project +from hyperglass.models.config.devices import Device + + +class MockDevice(Device): + def has_directives(self, *_: str) -> bool: + return True diff --git a/hyperglass/plugins/tests/test_bgp_community.py b/hyperglass/plugins/tests/test_bgp_community.py new file mode 100644 index 0000000..d5f8834 --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_community.py @@ -0,0 +1,65 @@ +"""Test BGP Community validation.""" +# Standard Library +import typing as t + +# Third Party +import pytest + +# Project +from hyperglass.state import use_state +from hyperglass.models.config.params import Params + +# Local +from .._builtin.bgp_community import ValidateBGPCommunity + +if t.TYPE_CHECKING: + # Project + from hyperglass.state import HyperglassState + + +CHECKS = ( + ("32768", True), + ("65000:1", True), + ("65000:4294967296", False), + ("4294967295:65000", False), + ("192.0.2.1:65000", True), + ("65000:192.0.2.1", False), + ("target:65000:1", True), + ("origin:65001:1", True), + ("wrong:65000:1", False), + ("65000:65001:65002", True), + ("4294967295:4294967294:4294967293", True), + ("65000:4294967295:1", True), + ("65000:192.0.2.1:1", False), + ("gibberish", False), + ("192.0.2.1", False), + (True, None), + (type("FakeClass", (), {}), None), +) + + +@pytest.fixture +def params(): + return {} + + +@pytest.fixture +def state(*, params: t.Dict[str, t.Any]) -> t.Generator["HyperglassState", None, None]: + """Test fixture to initialize Redis store.""" + _state = use_state() + _params = Params(**params) + + with _state.cache.pipeline() as pipeline: + pipeline.set("params", _params) + + yield _state + _state.clear() + + +def test_bgp_community(state): + plugin = ValidateBGPCommunity() + + for value, expected in CHECKS: + query = type("Query", (), {"query_target": value}) + result = plugin.validate(query) + assert result == expected, f"Invalid value {value!r}" diff --git a/hyperglass/plugins/tests/test_bgp_route_arista.py b/hyperglass/plugins/tests/test_bgp_route_arista.py new file mode 100644 index 0000000..dbb02bc --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_arista.py @@ -0,0 +1,55 @@ +"""Arista BGP Route Parsing Tests.""" + +# flake8: noqa +# Standard Library +from pathlib import Path + +# Third Party +import pytest + +# Project +from hyperglass.models.config.devices import Device +from hyperglass.models.data.bgp_route import BGPRouteTable + +# Local +from ._fixtures import MockDevice +from .._builtin.bgp_route_arista import BGPRoutePluginArista + +DEPENDS_KWARGS = { + "depends": [ + "hyperglass/models/tests/test_util.py::test_check_legacy_fields", + "hyperglass/external/tests/test_rpki.py::test_rpki", + ], + "scope": "session", +} + +SAMPLE = Path(__file__).parent.parent.parent.parent / ".samples" / "arista_route.json" + + +def _tester(sample: str): + plugin = BGPRoutePluginArista() + + device = MockDevice( + name="Test Device", + address="127.0.0.1", + group="Test Network", + credential={"username": "", "password": ""}, + platform="arista", + structured_output=True, + directives=["__hyperglass_arista_eos_bgp_route_table__"], + attrs={"source4": "192.0.2.1", "source6": "2001:db8::1"}, + ) + + query = type("Query", (), {"device": device}) + + result = plugin.process(output=(sample,), query=query) + assert isinstance(result, BGPRouteTable), "Invalid parsed result" + assert hasattr(result, "count"), "BGP Table missing count" + assert result.count > 0, "BGP Table count is 0" + + +@pytest.mark.dependency(**DEPENDS_KWARGS) +def test_arista_route_sample(): + with SAMPLE.open("r") as file: + sample = file.read() + return _tester(sample) diff --git a/hyperglass/plugins/tests/test_bgp_route_juniper.py b/hyperglass/plugins/tests/test_bgp_route_juniper.py new file mode 100644 index 0000000..7a02274 --- /dev/null +++ b/hyperglass/plugins/tests/test_bgp_route_juniper.py @@ -0,0 +1,70 @@ +"""Juniper BGP Route Parsing Tests.""" + +# flake8: noqa +# Standard Library +from pathlib import Path + +# Third Party +import pytest + +# Project +from hyperglass.models.data.bgp_route import BGPRouteTable + +# Local +from ._fixtures import MockDevice +from .._builtin.bgp_route_juniper import BGPRoutePluginJuniper + +DEPENDS_KWARGS = { + "depends": [ + "hyperglass/models/tests/test_util.py::test_check_legacy_fields", + "hyperglass/external/tests/test_rpki.py::test_rpki", + ], + "scope": "session", +} + +DIRECT = Path(__file__).parent.parent.parent.parent / ".samples" / "juniper_route_direct.xml" +INDIRECT = Path(__file__).parent.parent.parent.parent / ".samples" / "juniper_route_indirect.xml" +AS_PATH = Path(__file__).parent.parent.parent.parent / ".samples" / "juniper_route_aspath.xml" + + +def _tester(sample: str): + plugin = BGPRoutePluginJuniper() + + device = MockDevice( + name="Test Device", + address="127.0.0.1", + group="Test Network", + credential={"username": "", "password": ""}, + platform="juniper", + structured_output=True, + directives=[], + attrs={"source4": "192.0.2.1", "source6": "2001:db8::1"}, + ) + + query = type("Query", (), {"device": device}) + + result = plugin.process(output=(sample,), query=query) + assert isinstance(result, BGPRouteTable), "Invalid parsed result" + assert hasattr(result, "count"), "BGP Table missing count" + assert result.count > 0, "BGP Table count is 0" + + +@pytest.mark.dependency(**DEPENDS_KWARGS) +def test_juniper_bgp_route_direct(): + with DIRECT.open("r") as file: + sample = file.read() + return _tester(sample) + + +@pytest.mark.dependency(**DEPENDS_KWARGS) +def test_juniper_bgp_route_indirect(): + with INDIRECT.open("r") as file: + sample = file.read() + return _tester(sample) + + +@pytest.mark.dependency(**DEPENDS_KWARGS) +def test_juniper_bgp_route_aspath(): + with AS_PATH.open("r") as file: + sample = file.read() + return _tester(sample) diff --git a/hyperglass/settings.py b/hyperglass/settings.py new file mode 100644 index 0000000..b8dac19 --- /dev/null +++ b/hyperglass/settings.py @@ -0,0 +1,19 @@ +"""Access hyperglass global system settings.""" + +# Standard Library +import typing as t + +if t.TYPE_CHECKING: + # Local + from .models.system import HyperglassSettings + + +def _system_settings() -> "HyperglassSettings": + """Get system settings from local environment.""" + # Local + from .models.system import HyperglassSettings + + return HyperglassSettings() + + +Settings = _system_settings() diff --git a/hyperglass/state/__init__.py b/hyperglass/state/__init__.py new file mode 100644 index 0000000..dd8cc76 --- /dev/null +++ b/hyperglass/state/__init__.py @@ -0,0 +1,10 @@ +"""hyperglass global state management.""" + +# Local +from .hooks import use_state +from .store import HyperglassState + +__all__ = ( + "use_state", + "HyperglassState", +) diff --git a/hyperglass/state/hooks.py b/hyperglass/state/hooks.py new file mode 100644 index 0000000..1076553 --- /dev/null +++ b/hyperglass/state/hooks.py @@ -0,0 +1,77 @@ +"""Hooks for accessing hyperglass global state.""" + +# Standard Library +import typing as t +from functools import lru_cache + +# Project +from hyperglass.exceptions.private import StateError + +# Local +from .store import HyperglassState +from ..settings import Settings + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.ui import UIParameters + from hyperglass.models.directive import Directives + from hyperglass.models.config.params import Params + from hyperglass.models.config.devices import Devices + + # Local + from .redis import RedisManager + + +@lru_cache +def _use_state(attr: t.Optional[str] = None) -> "HyperglassState": + """Get hyperglass state by property. + + Implemented separately due to typing issues related to lru_cache described here: + https://github.com/python/mypy/issues/8356 + https://github.com/python/mypy/issues/9112 + """ + if attr is None: + return HyperglassState(settings=Settings) + if attr in ("cache", "redis"): + return HyperglassState(settings=Settings).cache + if attr in HyperglassState.properties(): + return getattr(HyperglassState(settings=Settings), attr) + raise StateError("'{attr}' does not exist on HyperglassState", attr=attr) + + +@t.overload +def use_state(attr: t.Literal["params"]) -> "Params": + """Access hyperglass configuration parameters from global state.""" + + +@t.overload +def use_state(attr: t.Literal["devices"]) -> "Devices": + """Access hyperglass devices from global state.""" + + +@t.overload +def use_state(attr: t.Literal["ui_params"]) -> "UIParameters": + """Access hyperglass UI parameters from global state.""" + + +@t.overload +def use_state(attr: t.Literal["cache", "redis"]) -> "RedisManager": + """Directly access hyperglass Redis cache manager.""" + + +@t.overload +def use_state(attr: t.Literal["directives"]) -> "Directives": + """Access all hyperglass directives.""" + + +@t.overload +def use_state(attr=None) -> "HyperglassState": + """Access entire global state. + + This overload needs to be defined last since it's a catchall. + """ + + +def use_state(attr: t.Optional[str] = None) -> "HyperglassState": + """Access global hyperglass state.""" + return _use_state(attr) diff --git a/hyperglass/state/manager.py b/hyperglass/state/manager.py new file mode 100644 index 0000000..c143b10 --- /dev/null +++ b/hyperglass/state/manager.py @@ -0,0 +1,53 @@ +"""hyperglass global state.""" + +# Standard Library +import typing as t + +# Third Party +from redis import Redis, ConnectionPool + +# Project +from hyperglass.util import repr_from_attrs + +# Local +from .redis import RedisManager + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.system import HyperglassSettings + + +class StateManager: + """Global State Manager. + + Maintains configuration objects in Redis cache and accesses them as needed. + """ + + settings: "HyperglassSettings" + redis: RedisManager + _namespace: str = "hyperglass.state" + + def __init__(self, *, settings: "HyperglassSettings") -> None: + """Set up Redis connection and add configuration objects.""" + + self.settings = settings + connection_pool = ConnectionPool.from_url(**self.settings.redis_connection_pool) + redis = Redis(connection_pool=connection_pool) + self.redis = RedisManager(instance=redis, namespace=self._namespace) + + def __repr__(self) -> str: + """Represent state manager by name and namespace.""" + return repr_from_attrs(self, ("redis", "namespace")) + + def __str__(self) -> str: + """Represent state manager by __repr__.""" + return repr(self) + + @classmethod + def properties(cls: "StateManager") -> t.Tuple[str, ...]: + """Get all read-only properties of the state manager.""" + return tuple( + attr + for attr in dir(cls) + if not attr.startswith("_") and "fget" in dir(getattr(cls, attr)) + ) diff --git a/hyperglass/state/redis.py b/hyperglass/state/redis.py new file mode 100644 index 0000000..33fc0ae --- /dev/null +++ b/hyperglass/state/redis.py @@ -0,0 +1,187 @@ +"""Interact with redis for state management.""" + +# Standard Library +import pickle +import typing as t +from types import TracebackType +from typing import overload +from datetime import datetime, timedelta + +# Project +from hyperglass.log import log +from hyperglass.exceptions.private import StateError + +if t.TYPE_CHECKING: + # Third Party + from redis import Redis + from redis.client import Pipeline + + +class RedisManager: + """Convenience wrapper for managing a redis session.""" + + instance: "Redis" + namespace: str + + def __init__(self, instance: "Redis", namespace: str) -> None: + """Set up Redis connection and add configuration objects.""" + self.instance = instance + self.namespace = namespace + + def __repr__(self) -> str: + """Alias repr to Redis instance's repr.""" + return repr(self.instance) + + def __str__(self) -> str: + """String-friendly redis manager.""" + return repr(self) + + def _key_join(self, *keys: str) -> str: + """Format keys with state namespace.""" + key_in_parts = (k for key in keys for k in key.split(".")) + key_parts = list(dict.fromkeys((*self.namespace.split("."), *key_in_parts))) + return ".".join(key_parts) + + def key(self, key: t.Union[str, t.Sequence[str]]) -> str: + """Format keys with state namespace.""" + if isinstance(key, (t.List, t.Tuple, t.Generator)): + return self._key_join(*key) + return self._key_join(key) + + def check(self) -> bool: + """Ensure the redis instance is running and reachable.""" + result = self.instance.ping() + if result is False: + raise RuntimeError( + "Redis instance {!r} is not running or reachable".format(self.instance) + ) + return result + + def delete(self, key: t.Union[str, t.Sequence[str]]) -> None: + """Delete a key and value from the cache.""" + self.instance.delete(self.key(key)) + + def expire( + self, + key: t.Union[str, t.Sequence[str]], + *, + expire_in: t.Optional[t.Union[timedelta, int]] = None, + expire_at: t.Optional[t.Union[datetime, int]] = None, + ) -> None: + """Expire a cache key, either at a time, or in a number of seconds. + + If no at or in time is specified, the key is deleted. + """ + key = self.key(key) + if isinstance(expire_at, (datetime, int)): + self.instance.expireat(key, expire_at) + return + if isinstance(expire_in, (timedelta, int)): + self.instance.expire(key, expire_in) + return + self.instance.delete(key) + + def get( + self, + key: t.Union[str, t.Sequence[str]], + *, + raise_if_none: bool = False, + value_if_none: t.Any = None, + ) -> t.Union[None, t.Any]: + """Get and decode a value from the cache.""" + name = self.key(key) + value: t.Optional[bytes] = self.instance.get(name) + if isinstance(value, bytes): + return pickle.loads(value) # noqa + if raise_if_none is True: + raise StateError("'{key}' ('{name}') does not exist in Redis store", key=key, name=name) + if value_if_none is not None: + return value_if_none + return None + + def set(self, key: t.Union[str, t.Sequence[str]], value: t.Any) -> None: + """Add an object to the cache.""" + name = self.key(key) + self.instance.set(name, pickle.dumps(value)) + + @overload + def get_map(self, key: str, item: str) -> t.Any: + """Get a single value from a Redis hash map (dict).""" + + @overload + def get_map(self, key: str, item=None) -> t.Any: + """Get a single value from a Redis hash map (dict).""" + + def get_map(self, key: str, item: t.Optional[str] = None) -> t.Any: + """Get a Redis hash map or hash map value.""" + name = self.key(key) + if isinstance(item, str): + value = self.instance.hget(name, item) + else: + value = self.instance.hgetall(name) + + if isinstance(value, bytes): + return pickle.loads(value) # noqa + return None + + def set_map_item(self, key: str, item: str, value: t.Any) -> None: + """Add a value to a hash map (dict).""" + name = self.key(key) + self.instance.hset(name, item, pickle.dumps(value)) + + 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). + ctx = type( + "RedisManagerExcludePipeline", + (RedisManager,), + {k: v for k, v in self.__dict__.items() if k != "pipeline"}, + ) + + def nested_pipeline(*_, **__) -> None: + """Ensure pipeline is never called from within pipeline.""" + raise AttributeError("Cannot access pipeline from pipeline") + + class RedisManagerPipeline(ctx): + """Copy of RedisManager, but uses `Redis.pipeline` as the `instance`.""" + + parent: "Redis" + instance: "Pipeline" + pipeline: t.Any = nested_pipeline + + def __init__( + pipeline_self, # noqa: N805 Avoid `self` namespace conflict + *, + parent: "Redis", + instance: "Pipeline", + namespace: str, + ) -> None: + pipeline_self.parent = parent + super().__init__(instance=instance, namespace=namespace) + + def __enter__( + pipeline_self: "RedisManagerPipeline", # noqa: N805 Avoid `self` namespace conflict + ) -> "RedisManagerPipeline": + return pipeline_self + + def __exit__( + pipeline_self: "RedisManagerPipeline", # noqa: N805 Avoid `self` namespace conflict + exc_type: t.Optional[t.Type[BaseException]] = None, + exc_value: t.Optional[BaseException] = None, + _: t.Optional[TracebackType] = None, + ) -> None: + pipeline_self.instance.execute() + if exc_type is not None: + log.bind( + pipeline=repr(pipeline_self), + parent=repr(pipeline_self.parent), + error=exc_value, + ).error( + "Error exiting pipeline", + ) + + return RedisManagerPipeline( + parent=self.instance, + instance=self.instance.pipeline(), + namespace=self.namespace, + ) diff --git a/hyperglass/state/store.py b/hyperglass/state/store.py new file mode 100644 index 0000000..9414adc --- /dev/null +++ b/hyperglass/state/store.py @@ -0,0 +1,79 @@ +"""Primary state container.""" + +# Standard Library +import typing as t + +# Local +from .manager import StateManager + +if t.TYPE_CHECKING: + # Project + from hyperglass.models.ui import UIParameters + from hyperglass.plugins._base import HyperglassPlugin + from hyperglass.models.directive import Directive, Directives + from hyperglass.models.config.params import Params + from hyperglass.models.config.devices import Devices + + # Local + from .manager import RedisManager + + +PluginT = t.TypeVar("PluginT", bound="HyperglassPlugin") + + +class HyperglassState(StateManager): + """Primary hyperglass state container.""" + + def add_plugin(self, _type: str, plugin: "HyperglassPlugin") -> None: + """Add a plugin to its list by type.""" + current = self.plugins(_type) + self.redis.set(("plugins", _type), list({*current, plugin})) + + def remove_plugin(self, _type: str, plugin: "HyperglassPlugin") -> None: + """Remove a plugin from its list by type.""" + current = self.plugins(_type) + plugins = {p for p in current if p != plugin} + self.redis.set(("plugins", _type), list(plugins)) + + def reset_plugins(self, _type: str) -> None: + """Remove all plugins of `_type`.""" + self.redis.set(("plugins", _type), []) + + def add_directive(self, *directives: t.Union["Directive", t.Dict[str, t.Any]]) -> None: + """Add a directive.""" + current = self.directives + current.add(*directives, unique_by="id") + self.redis.set("directives", current) + + def clear(self) -> None: + """Delete all cache keys.""" + self.redis.instance.flushdb(asynchronous=True) + + @property + def cache(self) -> "RedisManager": + """Get the redis manager instance.""" + return self.redis + + @property + def params(self) -> "Params": + """Get hyperglass configuration parameters (`hyperglass.yaml`).""" + return self.redis.get("params", raise_if_none=True) + + @property + def devices(self) -> "Devices": + """Get hyperglass devices (`devices.yaml`).""" + return self.redis.get("devices", raise_if_none=True) + + @property + def ui_params(self) -> "UIParameters": + """UI parameters, built from params.""" + return self.redis.get("ui_params", raise_if_none=True) + + @property + def directives(self) -> "Directives": + """All directives.""" + return self.redis.get("directives", raise_if_none=True) + + def plugins(self, _type: str) -> t.List[PluginT]: + """Get plugins by type.""" + return self.redis.get(("plugins", _type), raise_if_none=False, value_if_none=[]) diff --git a/hyperglass/state/tests/__init__.py b/hyperglass/state/tests/__init__.py new file mode 100644 index 0000000..b8e28d4 --- /dev/null +++ b/hyperglass/state/tests/__init__.py @@ -0,0 +1 @@ +"""State tests.""" diff --git a/hyperglass/state/tests/test_hooks.py b/hyperglass/state/tests/test_hooks.py new file mode 100644 index 0000000..aaa9d5c --- /dev/null +++ b/hyperglass/state/tests/test_hooks.py @@ -0,0 +1,102 @@ +"""Test state hooks.""" + +# Standard Library +import typing as t + +# Third Party +import pytest + +if t.TYPE_CHECKING: + from hyperglass.state import HyperglassState + +# Project +from hyperglass.models.ui import UIParameters +from hyperglass.configuration import init_ui_params +from hyperglass.models.directive import Directives +from hyperglass.models.config.params import Params +from hyperglass.models.config.devices import Devices + +# Local +from ..hooks import use_state +from ..store import HyperglassState + +STATE_ATTRS = ( + ("params", Params), + ("devices", Devices), + ("ui_params", UIParameters), + ("directives", Directives), + (None, HyperglassState), +) + + +@pytest.fixture +def params(): + return {} + + +@pytest.fixture +def devices(): + return [ + { + "name": "test1", + "address": "127.0.0.1", + "credential": {"username": "", "password": ""}, + "platform": "juniper", + "attrs": {"source4": "192.0.2.1", "source6": "2001:db8::1"}, + "directives": ["juniper_bgp_route"], + } + ] + + +@pytest.fixture +def directives(): + return [ + { + "juniper_bgp_route": { + "name": "BGP Route", + "field": {"description": "test"}, + } + } + ] + + +@pytest.fixture +def state( + *, + params: t.Dict[str, t.Any], + directives: t.Sequence[t.Dict[str, t.Any]], + devices: t.Sequence[t.Dict[str, t.Any]], +) -> t.Generator["HyperglassState", None, None]: + """Test fixture to initialize Redis store.""" + _state = use_state() + _params = Params(**params) + _directives = Directives.new(*directives) + + with _state.cache.pipeline() as pipeline: + # Write params and directives to the cache first to avoid a race condition where ui_params + # or devices try to access params or directives before they're available. + pipeline.set("params", _params) + pipeline.set("directives", _directives) + + _devices = Devices(*devices) + ui_params = init_ui_params(params=_params, devices=_devices) + + with _state.cache.pipeline() as pipeline: + pipeline.set("devices", _devices) + pipeline.set("ui_params", ui_params) + + yield _state + _state.clear() + + +def test_use_state_caching(state): + first = None + for attr, model in STATE_ATTRS: + for i in range(0, 5): + instance = use_state(attr) + if i == 0: + first = instance + assert isinstance( + instance, model + ), f"{instance!r} is not an instance of '{model.__name__}'" + assert instance == first, f"{instance!r} is not equal to {first!r}" diff --git a/hyperglass/types.py b/hyperglass/types.py new file mode 100644 index 0000000..6bfd9ca --- /dev/null +++ b/hyperglass/types.py @@ -0,0 +1,9 @@ +"""Custom types.""" + +# Standard Library +import typing as _t + +_S = _t.TypeVar("_S") + +Series = _t.Union[_t.MutableSequence[_S], _t.Tuple[_S], _t.Set[_S]] +"""Like `typing.Sequence`, but excludes `str`.""" diff --git a/hyperglass/ui/.eslintignore-delete b/hyperglass/ui/.eslintignore-delete new file mode 100644 index 0000000..e06f250 --- /dev/null +++ b/hyperglass/ui/.eslintignore-delete @@ -0,0 +1,5 @@ +node_modules +dist +.next/ +favicon-formats.ts +custom.*[js, html] diff --git a/hyperglass/ui/.eslintrc-delete.js b/hyperglass/ui/.eslintrc-delete.js new file mode 100644 index 0000000..cd4d95d --- /dev/null +++ b/hyperglass/ui/.eslintrc-delete.js @@ -0,0 +1,69 @@ +module.exports = { + root: true, + extends: ['eslint:recommended'], + env: { + es6: true, + node: true, + }, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + arrowFunctions: true, + }, + project: './tsconfig.json', + }, + overrides: [ + { + files: ['**/*.ts', '**/*.tsx'], + parser: '@typescript-eslint/parser', + settings: { + react: { version: 'detect' }, + 'import/resolver': { + typescript: {}, + }, + }, + env: { + browser: true, + node: true, + es6: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:import/errors', + 'plugin:import/warnings', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:jsx-a11y/recommended', + 'plugin:prettier/recommended', + ], + rules: { + 'prettier/prettier': ['error', {}, { usePrettierrc: true }], + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars-experimental': 'error', + 'no-unused-vars': 'off', + 'react/jsx-uses-react': 'off', + 'react/react-in-jsx-scope': 'off', + 'comma-dangle': ['error', 'always-multiline'], + 'global-require': 'off', + 'import/no-dynamic-require': 'off', + 'import/prefer-default-export': 'off', + 'import/no-named-as-default-member': 'off', + '@typescript-eslint/no-inferrable-types': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-namespace': 'off', + '@typescript-eslint/no-empty-interface': [ + 'error', + { + allowSingleExtends: true, + }, + ], + }, + }, + ], +}; diff --git a/hyperglass/ui/.gitignore b/hyperglass/ui/.gitignore new file mode 100644 index 0000000..e544177 --- /dev/null +++ b/hyperglass/ui/.gitignore @@ -0,0 +1,20 @@ +.DS_Store +.env* +hyperglass.json +custom.*[js, html] +*.tsbuildinfo +# dev/test files +TODO.txt +*.tmp* +test* +*.log +# generated theme file from hyperglass/hyperglass/render/templates/theme.sass.j2 +theme.sass +# generated JSON file from ingested & validated YAML config +frontend.json +# NPM modules +node_modules/ +# Downloaded Google Fonts +fonts/ +.next +out diff --git a/hyperglass/ui/.prettierignore b/hyperglass/ui/.prettierignore new file mode 100644 index 0000000..fc2cfe7 --- /dev/null +++ b/hyperglass/ui/.prettierignore @@ -0,0 +1,10 @@ +node_modules +dist +package.json +yarn.lock +package-lock.json +.eslintrc +tsconfig.json +.next/ +favicon-formats.ts +custom.*[js, html] diff --git a/hyperglass/ui/.prettierrc b/hyperglass/ui/.prettierrc new file mode 100644 index 0000000..354b46a --- /dev/null +++ b/hyperglass/ui/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "printWidth": 100, + "tabWidth": 2, + "singleQuote": true, + "bracketSpacing": true, + "jsxBracketSameLine": false, + "useTabs": false, + "arrowParens": "avoid", + "trailingComma": "all" +} diff --git a/hyperglass/ui/README.md b/hyperglass/ui/README.md new file mode 100644 index 0000000..7bb61f2 --- /dev/null +++ b/hyperglass/ui/README.md @@ -0,0 +1,3 @@ +# hyperglass-ui + +[hyperglass](https://github.com/thatmattlove/hyperglass) UI, written in [React](https://reactjs.org/), on [Next.js](https://nextjs.org/), with [Chakra UI](https://chakra-ui.com/). diff --git a/hyperglass/ui/biome.json b/hyperglass/ui/biome.json new file mode 100644 index 0000000..9ba7576 --- /dev/null +++ b/hyperglass/ui/biome.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", + "extends": ["../../biome.json"] +} diff --git a/hyperglass/ui/components/debugger.tsx b/hyperglass/ui/components/debugger.tsx new file mode 100644 index 0000000..4c2fc28 --- /dev/null +++ b/hyperglass/ui/components/debugger.tsx @@ -0,0 +1,115 @@ +import { + Tag, + Modal, + HStack, + Button, + ModalBody, + ModalHeader, + ModalOverlay, + ModalContent, + useDisclosure, + ModalCloseButton, +} from '@chakra-ui/react'; +import { useConfig } from '~/context'; +import { CodeBlock, DynamicIcon } from '~/elements'; +import { + useTheme, + useColorMode, + useColorValue, + useBreakpointValue, + // useHyperglassConfig, +} from '~/hooks'; + +import type { UseDisclosureReturn } from '@chakra-ui/react'; + +interface TViewer extends Pick<UseDisclosureReturn, 'isOpen' | 'onClose'> { + title: string; + children: React.ReactNode; +} + +const Viewer = (props: TViewer): JSX.Element => { + const { title, isOpen, onClose, children } = props; + const bg = useColorValue('white', 'blackSolid.700'); + const color = useColorValue('black', 'white'); + return ( + <Modal isOpen={isOpen} onClose={onClose} size="full" scrollBehavior="inside"> + <ModalOverlay /> + <ModalContent bg={bg} color={color} py={4} borderRadius="md" maxW="90%" minH="90vh"> + <ModalHeader>{title}</ModalHeader> + <ModalCloseButton /> + <ModalBody> + <CodeBlock>{children}</CodeBlock> + </ModalBody> + </ModalContent> + </Modal> + ); +}; + +export const Debugger = (): JSX.Element => { + const { isOpen: configOpen, onOpen: onConfigOpen, onClose: configClose } = useDisclosure(); + const { isOpen: themeOpen, onOpen: onThemeOpen, onClose: themeClose } = useDisclosure(); + const { colorMode } = useColorMode(); + const config = useConfig(); + const theme = useTheme(); + const borderColor = useColorValue('gray.100', 'gray.600'); + const mediaSize = + useBreakpointValue({ base: 'SMALL', md: 'MEDIUM', lg: 'LARGE', xl: 'X-LARGE' }) ?? 'UNKNOWN'; + const tagSize = useBreakpointValue({ base: 'sm', lg: 'lg' }) ?? 'lg'; + const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }) ?? 'sm'; + // const { refetch } = useHyperglassConfig(); + return ( + <> + <HStack + py={4} + px={4} + left={0} + right={0} + bottom={0} + maxW="100%" + zIndex={1000} + borderWidth="1px" + position="relative" + justifyContent="center" + borderColor={borderColor} + spacing={{ base: 2, lg: 8 }} + > + <Tag size={tagSize} colorScheme="gray"> + {colorMode.toUpperCase()} + </Tag> + <Button + size={btnSize} + colorScheme="cyan" + onClick={onConfigOpen} + leftIcon={<DynamicIcon icon={{ bs: 'BsBraces' }} />} + > + View Config + </Button> + <Button + size={btnSize} + leftIcon={<DynamicIcon icon={{ io: 'IoIosColorPalette' }} />} + colorScheme="blue" + onClick={onThemeOpen} + > + View Theme + </Button> + {/* <Button + size={btnSize} + colorScheme="purple" + leftIcon={<DynamicIcon icon={{ hi: 'HiOutlineDownload' }} />} + onClick={() => refetch()} + > + Reload Config + </Button> */} + <Tag size={tagSize} colorScheme="teal"> + {mediaSize} + </Tag> + </HStack> + <Viewer isOpen={configOpen} onClose={configClose} title="Config"> + {JSON.stringify(config, null, 4)} + </Viewer> + <Viewer isOpen={themeOpen} onClose={themeClose} title="Theme"> + {JSON.stringify(theme, null, 4)} + </Viewer> + </> + ); +}; diff --git a/hyperglass/ui/components/directive-info-modal.tsx b/hyperglass/ui/components/directive-info-modal.tsx new file mode 100644 index 0000000..9b7b915 --- /dev/null +++ b/hyperglass/ui/components/directive-info-modal.tsx @@ -0,0 +1,60 @@ +import { + Modal, + ScaleFade, + ModalBody, + IconButton, + ModalHeader, + ModalOverlay, + ModalContent, + useDisclosure, + ModalCloseButton, +} from '@chakra-ui/react'; +import { DynamicIcon, Markdown } from '~/elements'; +import { useColorValue } from '~/hooks'; + +import type { ModalContentProps } from '@chakra-ui/react'; + +interface DirectiveInfoModalProps extends Omit<ModalContentProps, 'title'> { + title: string | null; + item: string | null; + name: string; + visible: boolean; +} + +export const DirectiveInfoModal = (props: DirectiveInfoModalProps): JSX.Element => { + const { visible, item, name, title, ...rest } = props; + const { isOpen, onOpen, onClose } = useDisclosure(); + const bg = useColorValue('whiteSolid.50', 'blackSolid.700'); + const color = useColorValue('black', 'white'); + if (item === null) { + return <></>; + } + return ( + <> + <ScaleFade in={visible} unmountOnExit> + <IconButton + mb={1} + ml={1} + minH={3} + minW={3} + size="md" + variant="link" + onClick={onOpen} + colorScheme="blue" + aria-label={`${title} Details`} + icon={<DynamicIcon icon={{ fa: 'InfoCircle' }} />} + /> + </ScaleFade> + <Modal isOpen={isOpen} onClose={onClose} size="xl" motionPreset="scale"> + <ModalOverlay /> + <ModalContent bg={bg} color={color} py={4} borderRadius="md" {...rest}> + <ModalHeader>{title}</ModalHeader> + <ModalCloseButton /> + <ModalBody> + <Markdown content={item} /> + </ModalBody> + </ModalContent> + </Modal> + </> + ); +}; diff --git a/hyperglass/ui/components/footer/button.tsx b/hyperglass/ui/components/footer/button.tsx new file mode 100644 index 0000000..3546849 --- /dev/null +++ b/hyperglass/ui/components/footer/button.tsx @@ -0,0 +1,73 @@ +import { useMemo } from 'react'; +import { Button, Menu, MenuButton, MenuList } from '@chakra-ui/react'; +import { useConfig } from '~/context'; +import { Markdown } from '~/elements'; +import { useColorValue, useBreakpointValue, useOpposingColor, useStrf } from '~/hooks'; + +import type { MenuListProps } from '@chakra-ui/react'; +import type { Config } from '~/types'; + +interface FooterButtonProps extends Omit<MenuListProps, 'title'> { + side: 'left' | 'right'; + title?: MenuListProps['children']; + content: string; +} + +/** + * Filter the configuration object based on values that are strings for formatting. + */ +function getConfigFmt(config: Config): Record<string, string> { + const fmt = {} as Record<string, string>; + for (const [k, v] of Object.entries(config)) { + if (typeof v === 'string') { + fmt[k] = v; + } + } + return fmt; +} + +export const FooterButton = (props: FooterButtonProps): JSX.Element => { + const { content, title, side, ...rest } = props; + + const config = useConfig(); + const strF = useStrf(); + const fmt = useMemo(() => getConfigFmt(config), [config]); + const fmtContent = useMemo(() => strF(content, fmt), [fmt, content, strF]); + + const placement = side === 'left' ? 'top' : side === 'right' ? 'top-end' : undefined; + const bg = useColorValue('white', 'gray.900'); + const color = useOpposingColor(bg); + const size = useBreakpointValue({ base: 'xs', lg: 'sm' }); + + return ( + <Menu placement={placement} preventOverflow isLazy> + <MenuButton + zIndex={2} + as={Button} + size={size} + variant="ghost" + lineHeight={0} + aria-label={typeof title === 'string' ? title : undefined} + > + {title} + </MenuButton> + <MenuList + px={6} + py={4} + bg={bg} + // Ensure the height doesn't overtake the viewport, especially on mobile. See overflow also. + maxH="50vh" + color={color} + boxShadow="2xl" + textAlign="left" + overflowY="auto" + whiteSpace="normal" + mx={{ base: 1, lg: 2 }} + maxW={{ base: '100%', lg: '50vw' }} + {...rest} + > + <Markdown content={fmtContent} /> + </MenuList> + </Menu> + ); +}; diff --git a/hyperglass/ui/components/footer/color-mode.tsx b/hyperglass/ui/components/footer/color-mode.tsx new file mode 100644 index 0000000..5059b97 --- /dev/null +++ b/hyperglass/ui/components/footer/color-mode.tsx @@ -0,0 +1,48 @@ +import { forwardRef } from 'react'; +import { Button, Tooltip } from '@chakra-ui/react'; +import { Switch, Case } from 'react-if'; +import { DynamicIcon } from '~/elements'; +import { useOpposingColor, useColorMode, useColorValue, useBreakpointValue } from '~/hooks'; + +import type { ButtonProps } from '@chakra-ui/react'; + +interface ColorModeToggleProps extends Omit<ButtonProps, 'size'> { + size?: string | number; +} + +export const ColorModeToggle = forwardRef<HTMLButtonElement, ColorModeToggleProps>( + (props: ColorModeToggleProps, ref) => { + const { size = '1.5rem', ...rest } = props; + const { colorMode, toggleColorMode } = useColorMode(); + + const bg = useColorValue('primary.500', 'yellow.300'); + const color = useOpposingColor(bg); + const label = useColorValue('Switch to dark mode', 'Switch to light mode'); + const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }); + + return ( + <Tooltip hasArrow placement="top-end" label={label} bg={bg} color={color}> + <Button + ref={ref} + size={btnSize} + title={label} + variant="ghost" + aria-label={label} + _hover={{ color: bg }} + color="currentColor" + onClick={toggleColorMode} + {...rest} + > + <Switch> + <Case condition={colorMode === 'light'}> + <DynamicIcon icon={{ hi: 'HiMoon' }} boxSize={size} /> + </Case> + <Case condition={colorMode === 'dark'}> + <DynamicIcon icon={{ hi: 'HiSun' }} boxSize={size} /> + </Case> + </Switch> + </Button> + </Tooltip> + ); + }, +); diff --git a/hyperglass/ui/components/footer/footer.tsx b/hyperglass/ui/components/footer/footer.tsx new file mode 100644 index 0000000..17d3fea --- /dev/null +++ b/hyperglass/ui/components/footer/footer.tsx @@ -0,0 +1,88 @@ +import { Flex, HStack, useToken } from '@chakra-ui/react'; +import { useMemo } from 'react'; +import { useConfig } from '~/context'; +import { DynamicIcon } from '~/elements'; +import { useBreakpointValue, useColorValue, useMobile } from '~/hooks'; +import { isLink, isMenu } from '~/types'; +import { FooterButton } from './button'; +import { ColorModeToggle } from './color-mode'; +import { FooterLink } from './link'; + +import type { ButtonProps, LinkProps } from '@chakra-ui/react'; +import type { Link, Menu } from '~/types'; + +type MenuItems = (Link | Menu)[]; + +function buildItems(links: Link[], menus: Menu[]): [MenuItems, MenuItems] { + const leftLinks = links.filter(link => link.side === 'left'); + const leftMenus = menus.filter(menu => menu.side === 'left'); + const rightLinks = links.filter(link => link.side === 'right'); + const rightMenus = menus.filter(menu => menu.side === 'right'); + + const left = [...leftLinks, ...leftMenus].sort((a, b) => (a.order > b.order ? 1 : -1)); + const right = [...rightLinks, ...rightMenus].sort((a, b) => (a.order > b.order ? 1 : -1)); + return [left, right]; +} + +const LinkOnSide = (props: { item: ArrayElement<MenuItems>; side: 'left' | 'right' }) => { + const { item, side } = props; + if (isLink(item)) { + const icon: Partial<ButtonProps & LinkProps> = {}; + + if (item.showIcon) { + icon.rightIcon = <DynamicIcon icon={{ go: 'GoLinkExternal' }} />; + } + return <FooterLink key={item.title} href={item.url} title={item.title} {...icon} />; + } + if (isMenu(item)) { + return <FooterButton key={item.title} side={side} content={item.content} title={item.title} />; + } +}; + +export const Footer = (): JSX.Element => { + const { web, content } = useConfig(); + + const footerBg = useColorValue('blackAlpha.50', 'whiteAlpha.100'); + const footerColor = useColorValue('black', 'white'); + + const size = useBreakpointValue({ base: useToken('sizes', 4), lg: useToken('sizes', 6) }); + + const isMobile = useMobile(); + + const [left, right] = useMemo(() => buildItems(web.links, web.menus), [web.links, web.menus]); + + return ( + <HStack + px={6} + py={4} + w="100%" + zIndex={1} + as="footer" + bg={footerBg} + whiteSpace="nowrap" + color={footerColor} + spacing={{ base: 8, lg: 6 }} + display={{ base: 'inline-block', lg: 'flex' }} + overflowY={{ base: 'auto', lg: 'unset' }} + justifyContent={{ base: 'center', lg: 'space-between' }} + > + {left.map(item => ( + <LinkOnSide key={item.title} item={item} side="left" /> + ))} + {!isMobile && <Flex p={0} flex="1 0 auto" maxWidth="100%" mr="auto" />} + {right.map(item => ( + <LinkOnSide key={item.title} item={item} side="right" /> + ))} + {web.credit.enable && ( + <FooterButton + key="credit" + side="right" + content={content.credit} + title={<DynamicIcon icon={{ fi: 'FiCode' }} boxSize={size} />} + /> + )} + + <ColorModeToggle size={size} /> + </HStack> + ); +}; diff --git a/hyperglass/ui/components/footer/index.ts b/hyperglass/ui/components/footer/index.ts new file mode 100644 index 0000000..a058eae --- /dev/null +++ b/hyperglass/ui/components/footer/index.ts @@ -0,0 +1 @@ +export * from './footer'; diff --git a/hyperglass/ui/components/footer/link.tsx b/hyperglass/ui/components/footer/link.tsx new file mode 100644 index 0000000..cac171e --- /dev/null +++ b/hyperglass/ui/components/footer/link.tsx @@ -0,0 +1,16 @@ +import { Button, Link } from '@chakra-ui/react'; +import { useBreakpointValue } from '~/hooks'; + +import type { ButtonProps, LinkProps } from '@chakra-ui/react'; + +type FooterLinkProps = ButtonProps & LinkProps & { title: string }; + +export const FooterLink = (props: FooterLinkProps): JSX.Element => { + const { title } = props; + const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }); + return ( + <Button as={Link} isExternal size={btnSize} variant="ghost" aria-label={title} {...props}> + {title} + </Button> + ); +}; diff --git a/hyperglass/ui/components/form-field.tsx b/hyperglass/ui/components/form-field.tsx new file mode 100644 index 0000000..63f02e0 --- /dev/null +++ b/hyperglass/ui/components/form-field.tsx @@ -0,0 +1,75 @@ +import { Flex, FormControl, FormErrorMessage, FormLabel } from '@chakra-ui/react'; +import { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { If, Then } from 'react-if'; +import { useBooleanValue, useColorValue } from '~/hooks'; + +import type { FormControlProps } from '@chakra-ui/react'; +import type { FieldError } from 'react-hook-form'; +import type { FormData } from '~/types'; + +interface FormFieldProps extends FormControlProps { + name: string; + label: string; + hiddenLabels?: boolean; + labelAddOn?: React.ReactNode; + fieldAddOn?: React.ReactNode; +} + +export const FormField = (props: FormFieldProps): JSX.Element => { + const { name, label, children, labelAddOn, fieldAddOn, hiddenLabels = false, ...rest } = props; + const labelColor = useColorValue('blackAlpha.700', 'whiteAlpha.700'); + const errorColor = useColorValue('red.500', 'red.300'); + const opacity = useBooleanValue(hiddenLabels, 0, undefined); + + const { formState } = useFormContext<FormData>(); + + const error = useMemo<FieldError | null>(() => { + if (name in formState.errors) { + console.group(`Error on field '${label}'`); + console.warn(formState.errors[name as keyof FormData]); + console.groupEnd(); + return formState.errors[name as keyof FormData] as FieldError; + } + return null; + }, [formState, label, name]); + + return ( + <FormControl + mx={2} + w="100%" + maxW="100%" + flexDir="column" + alignItems="center" + my={{ base: 2, lg: 4 }} + isInvalid={error !== null} + {...rest} + > + <FormLabel + pr={0} + mb={{ lg: 4 }} + htmlFor={name} + display="flex" + opacity={opacity} + fontWeight="bold" + alignItems="center" + justifyContent="space-between" + color={error !== null ? errorColor : labelColor} + > + {label} + <If condition={typeof labelAddOn !== 'undefined'}> + <Then>{labelAddOn}</Then> + </If> + </FormLabel> + {children} + <If condition={typeof fieldAddOn !== 'undefined'}> + <Then> + <Flex justify="flex-end" pt={3}> + {fieldAddOn} + </Flex> + </Then> + </If> + <FormErrorMessage opacity={opacity}>{error?.message}</FormErrorMessage> + </FormControl> + ); +}; diff --git a/hyperglass/ui/components/greeting.tsx b/hyperglass/ui/components/greeting.tsx new file mode 100644 index 0000000..7ee576a --- /dev/null +++ b/hyperglass/ui/components/greeting.tsx @@ -0,0 +1,70 @@ +import { useEffect } from 'react'; +import { + Modal, + Button, + ModalBody, + ModalHeader, + ModalFooter, + ModalOverlay, + ModalContent, + ModalCloseButton, +} from '@chakra-ui/react'; +import { If, Then } from 'react-if'; +import { Markdown } from '~/elements'; +import { useConfig } from '~/context'; +import { useGreeting, useColorValue, useOpposingColor } from '~/hooks'; + +import type { ModalContentProps } from '@chakra-ui/react'; + +export const Greeting = (props: ModalContentProps): JSX.Element => { + const { web, content } = useConfig(); + const { isAck, isOpen, open, ack } = useGreeting(); + + const bg = useColorValue('white', 'gray.800'); + const color = useOpposingColor(bg); + + useEffect(() => { + if (!web.greeting.enable && !web.greeting.required) { + ack(true, false); + } + if (!isAck && web.greeting.enable) { + open(); + } + }, [isAck, open, web.greeting.enable, web.greeting.required, ack]); + return ( + <Modal + size="lg" + isCentered + onClose={() => ack(false, web.greeting.required)} + isOpen={isOpen} + motionPreset="slideInBottom" + closeOnEsc={web.greeting.required} + closeOnOverlayClick={web.greeting.required} + > + <ModalOverlay /> + <ModalContent + py={4} + bg={bg} + color={color} + borderRadius="md" + maxW={{ base: '95%', md: '75%' }} + {...props} + > + <ModalHeader>{web.greeting.title}</ModalHeader> + <If condition={!web.greeting.required}> + <Then> + <ModalCloseButton /> + </Then> + </If> + <ModalBody> + <Markdown content={content.greeting} /> + </ModalBody> + <ModalFooter> + <Button colorScheme="primary" onClick={() => ack(true, web.greeting.required)}> + {web.greeting.button} + </Button> + </ModalFooter> + </ModalContent> + </Modal> + ); +}; diff --git a/hyperglass/ui/components/header/header.tsx b/hyperglass/ui/components/header/header.tsx new file mode 100644 index 0000000..b36470a --- /dev/null +++ b/hyperglass/ui/components/header/header.tsx @@ -0,0 +1,34 @@ +import { Flex, ScaleFade } from '@chakra-ui/react'; +import { motionChakra } from '~/elements'; +import { useBooleanValue, useBreakpointValue, useFormInteractive } from '~/hooks'; +import { Title } from './title'; + +const Wrapper = motionChakra('header', { + baseStyle: { display: 'flex', px: 4, pt: 6, minH: 16, w: 'full', flex: '0 1 auto' }, +}); + +export const Header = (): JSX.Element => { + const formInteractive = useFormInteractive(); + + const titleWidth = useBooleanValue( + formInteractive, + { base: '75%', lg: '50%' }, + { base: '75%', lg: '75%' }, + ); + + return ( + <Wrapper layout="position"> + <ScaleFade in initialScale={0.5} style={{ width: '100%' }}> + <Flex + height="100%" + maxW={titleWidth} + // This is here for the logo + justifyContent="center" + mx="auto" + > + <Title /> + </Flex> + </ScaleFade> + </Wrapper> + ); +}; diff --git a/hyperglass/ui/components/header/index.ts b/hyperglass/ui/components/header/index.ts new file mode 100644 index 0000000..677ca79 --- /dev/null +++ b/hyperglass/ui/components/header/index.ts @@ -0,0 +1 @@ +export * from './header'; diff --git a/hyperglass/ui/components/header/logo.tsx b/hyperglass/ui/components/header/logo.tsx new file mode 100644 index 0000000..a5aa6ec --- /dev/null +++ b/hyperglass/ui/components/header/logo.tsx @@ -0,0 +1,67 @@ +import { Image, Skeleton } from '@chakra-ui/react'; +import { useCallback, useMemo, useState } from 'react'; +import { useConfig } from '~/context'; +import { useColorValue } from '~/hooks'; + +import type { ImageProps } from '@chakra-ui/react'; + +/** + * Custom hook to handle loading the user's logo, errors loading the logo, and color mode changes. + */ +function useLogo(): [string, () => void] { + const { web } = useConfig(); + const { darkFormat, lightFormat } = web.logo; + + 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. + 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'); + }, [src]); + + // Only return the fallback image if it's been set. + return useMemo(() => [fallback ?? src, setFallback], [fallback, setFallback, src]); +} + +export const Logo = (props: ImageProps): JSX.Element => { + const { web } = useConfig(); + const { width } = web.logo; + + const skeletonA = useColorValue('whiteSolid.100', 'blackSolid.800'); + const skeletonB = useColorValue('light.500', 'dark.500'); + + const [source, setFallback] = useLogo(); + + return ( + <Image + src={source} + alt={web.text.title} + onError={setFallback} + maxW={{ base: '100%', md: width }} + width="auto" + css={{ + userDrag: 'none', + userSelect: 'none', + msUserSelect: 'none', + MozUserSelect: 'none', + WebkitUserDrag: 'none', + WebkitUserSelect: 'none', + }} + fallback={ + <Skeleton + isLoaded={false} + borderRadius="md" + endColor={skeletonB} + startColor={skeletonA} + width={{ base: 64, lg: 80 }} + height={{ base: 12, lg: 16 }} + /> + } + {...props} + /> + ); +}; diff --git a/hyperglass/ui/components/header/subtitle-only.tsx b/hyperglass/ui/components/header/subtitle-only.tsx new file mode 100644 index 0000000..0f9a0cb --- /dev/null +++ b/hyperglass/ui/components/header/subtitle-only.tsx @@ -0,0 +1,22 @@ +import { Heading } from '@chakra-ui/react'; +import { useConfig } from '~/context'; +import { useBreakpointValue } from '~/hooks'; +import { useTitleSize } from './use-title-size'; + +export const SubtitleOnly = (): JSX.Element => { + const { web } = useConfig(); + const sizeSm = useTitleSize(web.text.subtitle, 'sm'); + const fontSize = useBreakpointValue({ base: sizeSm, lg: 'xl' }); + + return ( + <Heading + as="h3" + fontWeight="normal" + fontSize={fontSize} + whiteSpace="break-spaces" + textAlign={{ base: 'left', xl: 'center' }} + > + {web.text.subtitle} + </Heading> + ); +}; diff --git a/hyperglass/ui/components/header/title-only.tsx b/hyperglass/ui/components/header/title-only.tsx new file mode 100644 index 0000000..4b12d1a --- /dev/null +++ b/hyperglass/ui/components/header/title-only.tsx @@ -0,0 +1,17 @@ +import { Heading } from '@chakra-ui/react'; +import { useConfig } from '~/context'; +import { useBooleanValue, useFormInteractive } from '~/hooks'; +import { useTitleSize } from './use-title-size'; + +export const TitleOnly = (): JSX.Element => { + const { web } = useConfig(); + const formInteractive = useFormInteractive(); + const margin = useBooleanValue(formInteractive, 0, 2); + const sizeSm = useTitleSize(web.text.title, '2xl', []); + + return ( + <Heading as="h1" mb={margin} fontSize={{ base: sizeSm, lg: '5xl' }}> + {web.text.title} + </Heading> + ); +}; diff --git a/hyperglass/ui/components/header/title.tsx b/hyperglass/ui/components/header/title.tsx new file mode 100644 index 0000000..c69b036 --- /dev/null +++ b/hyperglass/ui/components/header/title.tsx @@ -0,0 +1,168 @@ +import { Button, Flex, VStack } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +import { isSafari } from 'react-device-detect'; +import { Case, Switch } from 'react-if'; +import { useConfig } from '~/context'; +import { useFormInteractive, useFormState, useMobile } from '~/hooks'; +import { Logo } from './logo'; +import { SubtitleOnly } from './subtitle-only'; +import { TitleOnly } from './title-only'; + +import type { FlexProps, StackProps } from '@chakra-ui/react'; +import type { MotionProps } from 'framer-motion'; + +type DWrapperProps = Omit<StackProps, 'transition'> & MotionProps; +type MWrapperProps = Omit<StackProps, 'transition'> & MotionProps; +type WrapperProps = Partial<MotionProps & Omit<StackProps, 'transition'>>; + +const AnimatedVStack = motion(VStack); +const AnimatedFlex = motion(Flex); + +/** + * Title wrapper for mobile devices, breakpoints sm & md. + */ +const MWrapper = (props: MWrapperProps): JSX.Element => { + const formInteractive = useFormInteractive(); + return ( + <AnimatedVStack + layout + spacing={1} + alignItems={formInteractive ? 'center' : 'flex-start'} + {...props} + /> + ); +}; + +/** + * Title wrapper for desktop devices, breakpoints lg & xl. + */ +const DWrapper = (props: DWrapperProps): JSX.Element => { + const formInteractive = useFormInteractive(); + return ( + <AnimatedVStack + spacing={1} + initial="main" + alignItems="center" + animate={formInteractive} + transition={{ damping: 15, type: 'spring', stiffness: 100 }} + variants={{ results: { scale: 0.5 }, form: { scale: 1 } }} + maxWidth="75%" + {...props} + /> + ); +}; + +/** + * Universal wrapper for title sub-components, which will be different depending on the + * `title_mode` configuration variable. + */ +const TitleWrapper = (props: DWrapperProps | MWrapperProps): JSX.Element => { + const isMobile = useMobile(); + return ( + <> + {isMobile ? ( + <MWrapper {...(props as MWrapperProps)} /> + ) : ( + <DWrapper {...(props as DWrapperProps)} /> + )} + </> + ); +}; + +/** + * Title sub-component if `title_mode` is set to `text_only`. + */ +const TextOnly = (props: WrapperProps): JSX.Element => { + return ( + <TitleWrapper {...props}> + <TitleOnly /> + <SubtitleOnly /> + </TitleWrapper> + ); +}; + +/** + * Title sub-component if `title_mode` is set to `logo_only`. Renders only the logo. + */ +const LogoOnly = (props: WrapperProps): JSX.Element => ( + <TitleWrapper {...props}> + <Logo /> + </TitleWrapper> +); + +/** + * Title sub-component if `title_mode` is set to `logo_subtitle`. Renders the logo with the + * subtitle underneath. + */ +const LogoSubtitle = (props: WrapperProps): JSX.Element => ( + <TitleWrapper {...props}> + <Logo /> + <SubtitleOnly /> + </TitleWrapper> +); + +/** + * Title sub-component if `title_mode` is set to `all`. Renders the logo, title, and subtitle. + */ +const All = (props: WrapperProps): JSX.Element => ( + <TitleWrapper {...props}> + <Logo /> + <TextOnly mt={2} /> + </TitleWrapper> +); + +/** + * Title component which renders sub-components based on the `title_mode` configuration variable. + */ +export const Title = (props: FlexProps): JSX.Element => { + const { fontSize, ...rest } = props; + const { web } = useConfig(); + const { titleMode } = web.text; + + const reset = useFormState(s => s.reset); + const formInteractive = useFormInteractive(); + + return ( + <AnimatedFlex + px={0} + flexWrap="wrap" + flexDir="column" + animate={{ height: formInteractive ? undefined : '20vh' }} + justifyContent="center" + /* flexBasis + This is a fix for Safari specifically. LMGTFY: Safari flex-basis width. Nutshell: Safari + is stupid, in that it infers the default flex-basis from the width, 100%. Other browsers + don't do this. Without the below fix, the logo will appear gigantic, filling the entire + div up to the parent's max-width. The fix is to hard-code a flex-basis width. + */ + flexBasis={{ base: '100%', lg: isSafari ? '33%' : '100%' }} + mt={{ md: formInteractive ? undefined : 'auto' }} + {...rest} + > + <Button + px={0} + variant="link" + flexWrap="wrap" + flexDir="column" + onClick={async () => await reset()} + _focus={{ boxShadow: 'none' }} + _hover={{ textDecoration: 'none' }} + > + <Switch> + <Case condition={titleMode === 'text_only'}> + <TextOnly width={web.logo.width} /> + </Case> + <Case condition={titleMode === 'logo_only'}> + <LogoOnly width={web.logo.width} /> + </Case> + <Case condition={titleMode === 'logo_subtitle'}> + <LogoSubtitle width={web.logo.width} /> + </Case> + <Case condition={titleMode === 'all'}> + <All width={web.logo.width} /> + </Case> + </Switch> + </Button> + </AnimatedFlex> + ); +}; diff --git a/hyperglass/ui/components/header/use-title-size.ts b/hyperglass/ui/components/header/use-title-size.ts new file mode 100644 index 0000000..e6223c9 --- /dev/null +++ b/hyperglass/ui/components/header/use-title-size.ts @@ -0,0 +1,60 @@ +import { useMemo, useState } from 'react'; +import { useToken } from '@chakra-ui/react'; +import { useMobile } from '~/hooks'; + +// Mobile: +// xs: 32 +// sm: 28 +// md: 24 +// lg: 20 +// xl: 16 +// 2xl: 14 +// 3xl: 12 +// 4xl: 10 +// 5xl: 7 +type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl'; + +export function useTitleSize(title: string, defaultSize: Sizes, deps: unknown[] = []): string { + const [size, setSize] = useState<Sizes>(defaultSize); + const realSize = useToken('fontSizes', size) as string; + const isMobile = useMobile(); + function getSize(l: number): void { + switch (true) { + case l > 32: + setSize('xs'); + break; + case l <= 32 && l > 28: + setSize('xs'); + break; + case l <= 28 && l > 24: + setSize('sm'); + break; + case l <= 24 && l > 20: + setSize('md'); + break; + case l <= 20 && l > 16: + setSize('lg'); + break; + case l <= 16 && l > 14: + setSize('xl'); + break; + case l <= 14 && l > 12: + setSize('2xl'); + break; + case l <= 12 && l > 10: + setSize('3xl'); + break; + case l <= 10 && l > 7: + setSize('4xl'); + break; + case l <= 7: + setSize('5xl'); + break; + } + } + return useMemo(() => { + getSize(title.length); + return realSize; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [title, isMobile, realSize, ...deps]); +} diff --git a/hyperglass/ui/components/index.ts b/hyperglass/ui/components/index.ts new file mode 100644 index 0000000..6c0f318 --- /dev/null +++ b/hyperglass/ui/components/index.ts @@ -0,0 +1,30 @@ +/** + * The components directory contains React components that handle logic. + * + * Generally, components that call hooks or reference configuration, or API types should be in + * components. + */ + +export * from './debugger'; +export * from './directive-info-modal'; +export * from './footer'; +export * from './form-field'; +export * from './greeting'; +export * from './header'; +export * from './layout'; +export * from './location-card'; +export * from './looking-glass-form'; +export * from './meta'; +export * from './output'; +export * from './path'; +export * from './prompt'; +export * from './query-location'; +export * from './query-target'; +export * from './query-type'; +export * from './reset-button'; +export * from './resolved-target'; +export * from './results'; +export * from './select'; +export * from './submit-button'; +export * from './table'; +export * from './user-ip'; diff --git a/hyperglass/ui/components/layout.tsx b/hyperglass/ui/components/layout.tsx new file mode 100644 index 0000000..585a73a --- /dev/null +++ b/hyperglass/ui/components/layout.tsx @@ -0,0 +1,76 @@ +import { Flex } from '@chakra-ui/react'; +import { useCallback, useRef } from 'react'; +import { isSafari } from 'react-device-detect'; +import { If, Then } from 'react-if'; +import { Debugger, Footer, Greeting, Header, ResetButton } from '~/components'; +import { useConfig } from '~/context'; +import { motionChakra } from '~/elements'; +import { useFormState } from '~/hooks'; + +import type { FlexProps } from '@chakra-ui/react'; + +const Main = motionChakra('main', { + baseStyle: { + px: 4, + py: 0, + w: '100%', + display: 'flex', + flex: '1 1 auto', + flexDir: 'column', + textAlign: 'center', + alignItems: 'center', + justifyContent: 'start', + }, +}); + +export const Layout = (props: FlexProps): JSX.Element => { + const { developerMode } = useConfig(); + const { setStatus, reset } = useFormState( + useCallback(({ setStatus, reset }) => ({ setStatus, reset }), []), + ); + + const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement); + + async function handleReset() { + containerRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + setStatus('form'); + reset(); + } + + return ( + <> + <Flex + w="100%" + flex="1 0 auto" + flexDir="column" + id="__hyperglass" + ref={containerRef} + /** minHeight + * This is a Safari-specific fix. Without it, the footer will appear to be "under" the + * viewport. Safari needs `-webkit-fill-available`, but other browsers need `100vh`. + * @see https://allthingssmitty.com/2020/05/11/css-fix-for-100vh-in-mobile-webkit/ + */ + minHeight={isSafari ? '-webkit-fill-available' : '100vh'} + > + <Header /> + <Main + layout + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3 }} + exit={{ opacity: 0, x: -300 }} + initial={{ opacity: 0, y: 300 }} + > + {props.children} + </Main> + <Footer /> + <If condition={developerMode}> + <Then> + <Debugger /> + </Then> + </If> + <ResetButton developerMode={developerMode} resetForm={handleReset} /> + </Flex> + <Greeting /> + </> + ); +}; diff --git a/hyperglass/ui/components/location-card.tsx b/hyperglass/ui/components/location-card.tsx new file mode 100644 index 0000000..6797ac9 --- /dev/null +++ b/hyperglass/ui/components/location-card.tsx @@ -0,0 +1,107 @@ +import { useMemo, useState } from 'react'; +import { Flex, Avatar, chakra } from '@chakra-ui/react'; +import { motionChakra } from '~/elements'; +import { useColorValue, useOpposingColor } from '~/hooks'; + +import type { SingleOption } from '~/types'; +import type { LocationOption } from './query-location'; + +interface LocationCardProps { + option: SingleOption; + defaultChecked: boolean; + onChange(a: 'add' | 'remove', v: SingleOption): void; + hasError: boolean; +} + +const LocationCardWrapper = motionChakra('div', { + baseStyle: { + py: 4, + px: 6, + minW: 'xs', + maxW: 'md', + mx: 'auto', + shadow: 'sm', + rounded: 'lg', + cursor: 'pointer', + borderWidth: '1px', + borderStyle: 'solid', + }, +}); + +export const LocationCard = (props: LocationCardProps): JSX.Element => { + const { option, onChange, defaultChecked, hasError } = props; + const { label } = option; + const [isChecked, setChecked] = useState(defaultChecked); + + function handleChange(value: LocationOption) { + if (isChecked) { + setChecked(false); + onChange('remove', value); + } else { + setChecked(true); + onChange('add', value); + } + } + + const bg = useColorValue('white', 'blackSolid.600'); + const imageBorder = useColorValue('gray.600', 'whiteAlpha.800'); + const fg = useOpposingColor(bg); + const checkedBorder = useColorValue('blue.400', 'blue.300'); + const errorBorder = useColorValue('red.500', 'red.300'); + + const borderColor = useMemo( + () => + hasError && isChecked + ? // Highlight red when there are no overlapping query types for the locations selected. + errorBorder + : isChecked && !hasError + ? // Highlight blue when any location is selected and there is no error. + checkedBorder + : // Otherwise, no border. + 'transparent', + + [hasError, isChecked, checkedBorder, errorBorder], + ); + return ( + <LocationCardWrapper + bg={bg} + key={label} + whileHover={{ scale: 1.05 }} + borderColor={borderColor} + onClick={(e: React.MouseEvent) => { + e.preventDefault(); + handleChange(option); + }} + > + <> + <Flex justifyContent="space-between" alignItems="center"> + <chakra.h2 + color={fg} + fontWeight="bold" + mt={{ base: 2, md: 0 }} + fontSize={{ base: 'lg', md: 'xl' }} + > + {label} + </chakra.h2> + <Avatar + color={fg} + name={label} + boxSize={12} + rounded="full" + borderWidth={1} + bg="whiteAlpha.300" + borderStyle="solid" + borderColor={imageBorder} + src={(option.data?.avatar as string) ?? undefined} + /> + </Flex> + + {option?.data?.description && ( + <chakra.p mt={2} color={fg} opacity={0.6} fontSize="sm"> + {option.data.description as string} + </chakra.p> + )} + </> + </LocationCardWrapper> + ); +}; diff --git a/hyperglass/ui/components/looking-glass-form.tsx b/hyperglass/ui/components/looking-glass-form.tsx new file mode 100644 index 0000000..664f8a8 --- /dev/null +++ b/hyperglass/ui/components/looking-glass-form.tsx @@ -0,0 +1,234 @@ +import { Flex, ScaleFade, SlideFade, chakra } from '@chakra-ui/react'; +import { vestResolver } from '@hookform/resolvers/vest'; +import { useCallback, useEffect, useMemo } from 'react'; +import isEqual from 'react-fast-compare'; +import { FormProvider, useForm } from 'react-hook-form'; +import vest, { test, enforce } from 'vest'; +import { + DirectiveInfoModal, + FormField, + QueryLocation, + QueryTarget, + QueryType, + SubmitButton, +} from '~/components'; +import { useConfig } from '~/context'; +import { FormRow } from '~/elements'; +import { useDevice, useFormState, useGreeting, useStrf } from '~/hooks'; +import { Directive, isQueryField, isString } from '~/types'; +import { isFQDN } from '~/util'; + +import type { FormData, OnChangeArgs } from '~/types'; + +export const LookingGlassForm = (): JSX.Element => { + const { web, messages } = useConfig(); + + const greetingReady = useGreeting(s => s.greetingReady); + + const getDevice = useDevice(); + const strF = useStrf(); + const setLoading = useFormState(s => s.setLoading); + const setStatus = useFormState(s => s.setStatus); + const locationChange = useFormState(s => s.locationChange); + const setTarget = useFormState(s => s.setTarget); + const setFormValue = useFormState(s => s.setFormValue); + const { form, filtered, selections } = useFormState( + useCallback(({ form, filtered, selections }) => ({ form, filtered, selections }), []), + isEqual, + ); + + const getDirective = useFormState(useCallback(s => s.getDirective, [])); + const resolvedOpen = useFormState(useCallback(s => s.resolvedOpen, [])); + const resetForm = useFormState(useCallback(s => s.reset, [])); + + const noQueryType = strF(messages.noInput, { field: web.text.queryType }); + const noQueryLoc = strF(messages.noInput, { field: web.text.queryLocation }); + const noQueryTarget = strF(messages.noInput, { field: web.text.queryTarget }); + + const queryTypes = useMemo(() => filtered.types.map(t => t.id), [filtered.types]); + + const formSchema = vest.create((data: FormData = {} as FormData) => { + test('queryLocation', noQueryLoc, () => { + enforce(data.queryLocation).isArrayOf(enforce.isString()).isNotEmpty(); + }); + test('queryTarget', noQueryTarget, () => { + enforce(data.queryTarget).isArrayOf(enforce.isString()).isNotEmpty(); + }); + test('queryType', noQueryType, () => { + enforce(data.queryType).inside(queryTypes); + }); + }); + + const formInstance = useForm<FormData>({ + resolver: vestResolver(formSchema), + defaultValues: { + queryTarget: [], + queryLocation: [], + queryType: '', + }, + }); + + const { handleSubmit, register, setValue, setError, clearErrors } = formInstance; + + const isFqdnQuery = useCallback( + (target: string | string[], fieldType: Directive['fieldType'] | null): boolean => + (typeof target === 'string' || Array.isArray(target)) && + fieldType === 'text' && + isFQDN(target), + [], + ); + + const directive = useMemo<Directive | null>( + () => getDirective(), + [form.queryType, form.queryLocation, getDirective], + ); + + function submitHandler(): void { + if (process.env.NODE_ENV === 'development') { + console.table({ + 'Query Location': form.queryLocation.toString(), + 'Query Type': form.queryType, + 'Query Target': form.queryTarget, + 'Selected Directive': directive?.name ?? null, + }); + } + + // 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) { + resetForm(); + location.reload(); + return; + } + + // Determine if queryTarget is an FQDN. + const isFqdn = isFqdnQuery(form.queryTarget, directive?.fieldType ?? null); + + if (greetingReady && !isFqdn) { + setStatus('results'); + return; + } + + if (greetingReady && isFqdn) { + setLoading(true); + resolvedOpen(); + return; + } + console.group('%cSomething went wrong', 'color:red;'); + console.table({ + 'Greeting Required': web.greeting.required, + 'Greeting Ready': greetingReady, + 'Query Target': form.queryTarget, + 'Query Type': form.queryType, + 'Is FQDN': isFqdn, + }); + console.groupEnd(); + } + + const handleLocChange = (locations: string[]) => + locationChange(locations, { setError, clearErrors, getDevice, text: web.text }); + + function handleChange(e: OnChangeArgs): void { + // Signal the field & value to react-hook-form. + if (isQueryField(e.field)) { + setValue(e.field, e.value); + } else { + throw new Error(`Field '${e.field}' is not a valid form field.`); + } + + if (e.field === 'queryLocation' && Array.isArray(e.value)) { + handleLocChange(e.value); + } else if (e.field === 'queryType' && isString(e.value)) { + setValue('queryType', e.value); + setFormValue('queryType', e.value); + if (form.queryTarget.length !== 0) { + // Reset queryTarget as well, so that, for example, selecting BGP Community, and selecting + // a community, then changing the queryType to BGP Route doesn't preserve the selected + // community as the queryTarget. + setFormValue('queryTarget', []); + setTarget({ display: '' }); + } + } else if (e.field === 'queryTarget') { + if (isString(e.value)) { + setFormValue('queryTarget', [e.value]); + setValue('queryTarget', [e.value]); + } + if (Array.isArray(e.value)) { + setFormValue('queryTarget', e.value); + setValue('queryTarget', e.value); + } + } + } + + useEffect(() => { + register('queryLocation', { required: true }); + register('queryType', { required: true }); + }, [register]); + + return ( + <FormProvider {...formInstance}> + <chakra.form + p={0} + my={4} + w="100%" + mx="auto" + textAlign="left" + maxW={{ base: '100%', lg: '75%' }} + onSubmit={handleSubmit(submitHandler)} + > + <FormRow> + <FormField name="queryLocation" label={web.text.queryLocation}> + <QueryLocation onChange={handleChange} label={web.text.queryLocation} /> + </FormField> + </FormRow> + <FormRow> + <SlideFade offsetX={-100} in={filtered.types.length > 0} unmountOnExit> + <FormField + name="queryType" + label={web.text.queryType} + labelAddOn={ + directive !== null && ( + <DirectiveInfoModal + name="queryType" + title={directive.name ?? null} + item={directive.info ?? null} + visible={selections.queryType !== null && directive.info !== null} + /> + ) + } + > + <QueryType onChange={handleChange} label={web.text.queryType} /> + </FormField> + </SlideFade> + <SlideFade offsetX={100} in={directive !== null} unmountOnExit> + {directive !== null && ( + <FormField name="queryTarget" label={web.text.queryTarget}> + <QueryTarget + name="queryTarget" + register={register} + onChange={handleChange} + placeholder={directive.description} + /> + </FormField> + )} + </SlideFade> + </FormRow> + <FormRow mt={0} justifyContent="flex-end"> + <Flex + my={2} + w="100%" + ml="auto" + maxW="100%" + flex="0 0 0" + flexDir="column" + mr={{ base: 0, lg: 2 }} + > + <ScaleFade initialScale={0.5} in={form.queryTarget.length !== 0}> + <SubmitButton /> + </ScaleFade> + </Flex> + </FormRow> + </chakra.form> + </FormProvider> + ); +}; diff --git a/hyperglass/ui/components/meta.tsx b/hyperglass/ui/components/meta.tsx new file mode 100644 index 0000000..a5c20ae --- /dev/null +++ b/hyperglass/ui/components/meta.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; +import Head from 'next/head'; +import { useConfig } from '~/context'; + +export const Meta = (): JSX.Element => { + const config = useConfig(); + const [location, setLocation] = useState('/'); + + const { + siteTitle: title = 'hyperglass', + siteDescription: description = 'Network Looking Glass', + } = useConfig(); + + const siteName = `${title} - ${description}`; + + useEffect(() => { + if (typeof window !== 'undefined' && location === '/') { + setLocation(window.location.href); + } + }, [location]); + + return ( + <Head> + <title key="title">{title} + + + + + + + + + + ); +}; diff --git a/hyperglass/ui/components/output/bgp-table.tsx b/hyperglass/ui/components/output/bgp-table.tsx new file mode 100644 index 0000000..670194e --- /dev/null +++ b/hyperglass/ui/components/output/bgp-table.tsx @@ -0,0 +1,47 @@ +import { Flex } from '@chakra-ui/react'; +import { Table } from '~/components'; +import { useConfig } from '~/context'; +import { Cell } from './cell'; + +import type { FlexProps } from '@chakra-ui/react'; +import type { TableColumn, ParsedDataField, CellRenderProps } from '~/types'; + +type BGPTableProps = Swap; + +function makeColumns(fields: ParsedDataField[]): TableColumn[] { + return fields.map(pair => { + const [header, accessor, align] = pair; + + const columnConfig = { + align, + accessor, + hidden: false, + Header: header, + } as TableColumn; + + if (align === null) { + columnConfig.hidden = true; + } + + return columnConfig; + }); +} + +export const BGPTable = (props: BGPTableProps): JSX.Element => { + const { children: data, ...rest } = props; + const { parsedDataFields } = useConfig(); + const columns = makeColumns(parsedDataFields); + + return ( + + } + /> + + ); +}; diff --git a/hyperglass/ui/components/output/cell.tsx b/hyperglass/ui/components/output/cell.tsx new file mode 100644 index 0000000..6bf12b7 --- /dev/null +++ b/hyperglass/ui/components/output/cell.tsx @@ -0,0 +1,29 @@ +import { MonoField, Active, Weight, Age, Communities, RPKIState, ASPath } from './fields'; + +import type { CellRenderProps } from '~/types'; + +interface CellProps { + data: CellRenderProps; + rawData: StructuredResponse; +} + +export const Cell = (props: CellProps): JSX.Element => { + const { data, rawData } = props; + const cellId = data.column.id as keyof Route; + const component = { + med: , + age: , + prefix: , + next_hop: , + peer_rid: , + source_as: , + active: , + source_rid: , + local_preference: , + communities: , + as_path: , + rpki_state: , + weight: , + }; + return component[cellId] ?? ''; +}; diff --git a/hyperglass/ui/components/output/fields.tsx b/hyperglass/ui/components/output/fields.tsx new file mode 100644 index 0000000..a24857a --- /dev/null +++ b/hyperglass/ui/components/output/fields.tsx @@ -0,0 +1,233 @@ +import { Box, Flex, Link, Menu, MenuButton, MenuList, Text, Tooltip } from '@chakra-ui/react'; +import dayjs from 'dayjs'; +import relativeTimePlugin from 'dayjs/plugin/relativeTime'; +import utcPlugin from 'dayjs/plugin/utc'; +import { forwardRef } from 'react'; +import { Else, If, Then } from 'react-if'; +import { useConfig } from '~/context'; +import { DynamicIcon } from '~/elements'; +import { useColorValue, useOpposingColor } from '~/hooks'; + +import type { TextProps } from '@chakra-ui/react'; + +interface ActiveProps { + isActive: boolean; +} + +interface MonoFieldProps extends TextProps { + v: React.ReactNode; +} + +interface AgeProps extends TextProps { + inSeconds: number; +} + +interface WeightProps extends TextProps { + weight: number; + winningWeight: 'low' | 'high'; +} + +interface ASPathProps { + path: number[]; + active: boolean; +} + +interface CommunitiesProps { + communities: string[]; +} + +interface RPKIStateProps { + state: + | 0 // Invalid + | 1 // Valid + | 2 // Unknown + | 3; // Unverified + active: boolean; +} + +dayjs.extend(relativeTimePlugin); +dayjs.extend(utcPlugin); + +export const MonoField = (props: MonoFieldProps): JSX.Element => { + const { v, ...rest } = props; + return ( + + {v} + + ); +}; + +export const Active = (props: ActiveProps): JSX.Element => { + const { isActive } = props; + const color = useColorValue(['gray.500', 'green.500'], ['whiteAlpha.300', 'blackAlpha.500']); + return ( + + + + + + + + + ); +}; + +export const Age = (props: AgeProps): JSX.Element => { + const { inSeconds, ...rest } = props; + const now = dayjs.utc(); + const then = now.subtract(inSeconds, 'second'); + return ( + + + {now.to(then, true)} + + + ); +}; + +export const Weight = (props: WeightProps): JSX.Element => { + const { weight, winningWeight, ...rest } = props; + const fixMeText = + winningWeight === 'low' ? 'Lower Weight is Preferred' : 'Higher Weight is Preferred'; + return ( + + + {weight} + + + ); +}; + +export const ASPath = (props: ASPathProps): JSX.Element => { + const { path, active } = props; + const color = useColorValue( + // light: inactive, active + ['blackAlpha.500', 'blackAlpha.500'], + // dark: inactive, active + ['whiteAlpha.600', 'blackAlpha.700'], + ); + + if (path.length === 0) { + return ( + + + + + + ); + } + + const paths = [] as JSX.Element[]; + + path.map((asn, i) => { + const asnStr = String(asn); + i !== 0 && + paths.push( + , + ); + paths.push( + // biome-ignore lint/suspicious/noArrayIndexKey: index makes sense in this case. + + {asnStr} + , + ); + }); + + return {paths}; +}; + +export const Communities = (props: CommunitiesProps): JSX.Element => { + const { communities } = props; + const { web } = useConfig(); + const bg = useColorValue('white', 'gray.900'); + const color = useOpposingColor(bg); + return ( + + + + + + + + + + + + + + + {communities.join('\n')} + + + + + ); +}; + +const _RPKIState: React.ForwardRefRenderFunction = ( + props: RPKIStateProps, + ref, +) => { + const { state, active } = props; + const { web } = useConfig(); + const bg = useColorValue( + [ + ['red.400', 'green.500', 'yellow.400', 'gray.500'], + ['red.500', 'green.500', 'yellow.600', 'gray.600'], + ], + [ + ['red.300', 'green.300', 'yellow.300', 'gray.300'], + ['red.500', 'green.600', 'yellow.600', 'gray.800'], + ], + ); + const color = useOpposingColor(bg[+active][state]); + + const icon = [ + { md: 'MdCancel' }, + { fa: 'FaCheckCircle' }, + { bi: 'BiError' }, + { bs: 'BsQuestionCircleFill' }, + ] as Record[]; + + const text = [ + web.text.rpkiInvalid, + web.text.rpkiValid, + web.text.rpkiUnknown, + web.text.rpkiUnverified, + ]; + + return ( + + + + + + ); +}; + +export const RPKIState = forwardRef(_RPKIState); diff --git a/hyperglass/ui/components/output/highlighted.tsx b/hyperglass/ui/components/output/highlighted.tsx new file mode 100644 index 0000000..e5f61c7 --- /dev/null +++ b/hyperglass/ui/components/output/highlighted.tsx @@ -0,0 +1,55 @@ +import { Badge, Tooltip, useStyleConfig } from '@chakra-ui/react'; +import React, { memo } from 'react'; +import isEqual from 'react-fast-compare'; +import replace from 'react-string-replace'; + +import type { TooltipProps } from '@chakra-ui/react'; +import type { Highlight as HighlightConfig } from '~/types'; + +interface HighlightedProps { + patterns: HighlightConfig[]; + children: string; +} + +interface HighlightProps { + label: string | null; + colorScheme: string; + children: React.ReactNode; +} + +const Highlight = (props: HighlightProps): JSX.Element => { + const { colorScheme, label, children } = props; + const { bg, color } = useStyleConfig('Button', { colorScheme }) as TooltipProps; + return ( + + {children} + + ); +}; + +const _Highlighted = (props: HighlightedProps): JSX.Element => { + const { patterns, children } = props; + let result: React.ReactNode[] = []; + let times: number = 0; + + if (patterns.length === 0) { + result = [children]; + } else { + for (const config of patterns) { + let toReplace: string | React.ReactNode[] = children; + if (times !== 0) { + toReplace = result; + } + result = replace(toReplace, new RegExp(`(${config.pattern})`, 'gm'), (m, i) => ( + + {m} + + )); + times++; + } + } + + return <>{result}; +}; + +export const Highlighted = memo(_Highlighted, isEqual); diff --git a/hyperglass/ui/components/output/index.ts b/hyperglass/ui/components/output/index.ts new file mode 100644 index 0000000..6e28bab --- /dev/null +++ b/hyperglass/ui/components/output/index.ts @@ -0,0 +1,2 @@ +export * from './bgp-table'; +export * from './text-output'; diff --git a/hyperglass/ui/components/output/text-output.tsx b/hyperglass/ui/components/output/text-output.tsx new file mode 100644 index 0000000..1d2c821 --- /dev/null +++ b/hyperglass/ui/components/output/text-output.tsx @@ -0,0 +1,49 @@ +import { Box } from '@chakra-ui/react'; +import { useConfig } from '~/context'; +import { useColorValue } from '~/hooks'; +import { Highlighted } from './highlighted'; + +import type { BoxProps } from '@chakra-ui/react'; + +type TextOutputProps = Swap; + +export const TextOutput = (props: TextOutputProps): JSX.Element => { + const { children, ...rest } = props; + + const bg = useColorValue('blackAlpha.100', 'gray.800'); + const color = useColorValue('black', 'white'); + const selectionBg = useColorValue('black', 'white'); + const selectionColor = useColorValue('white', 'black'); + + const { + web: { highlight }, + } = useConfig(); + + return ( + + + {children.split('\\n').join('\n').replace(/\n\n/g, '\n')} + + + ); +}; diff --git a/hyperglass/ui/components/path/chart.tsx b/hyperglass/ui/components/path/chart.tsx new file mode 100644 index 0000000..e5e95f3 --- /dev/null +++ b/hyperglass/ui/components/path/chart.tsx @@ -0,0 +1,99 @@ +import { Badge, Box, Flex, SkeletonText, VStack } from '@chakra-ui/react'; +import { useMemo } from 'react'; +import ReactFlow, { + Background, + ReactFlowProvider, + Handle, + Position, + isNode, + isEdge, +} from 'reactflow'; +import { useConfig } from '~/context'; +import { useASNDetail, useColorToken, useColorValue } from '~/hooks'; +import { Controls } from './controls'; +import { useElements } from './use-elements'; + +import type { NodeProps as ReactFlowNodeProps } from 'reactflow'; + +interface ChartProps { + data: StructuredResponse; +} + +interface NodeProps extends Omit { + data: D; +} + +export interface NodeData { + asn: string; + name: string; + hasChildren: boolean; + hasParents?: boolean; +} + +export const Chart = (props: ChartProps): JSX.Element => { + const { data } = props; + const { primaryAsn, orgName } = useConfig(); + + const dots = useColorToken('colors', 'blackAlpha.500', 'whiteAlpha.400'); + + const elements = useElements({ asn: primaryAsn, name: orgName }, data); + + const nodes = useMemo(() => elements.filter(isNode), [elements]); + const edges = useMemo(() => elements.filter(isEdge), [elements]); + + return ( + + + setTimeout(() => inst.fitView(), 0)} + proOptions={{ hideAttribution: true }} + > + + + + + + ); +}; + +const ASNode = (props: NodeProps): JSX.Element => { + const { data } = props; + const { asn, name, hasChildren, hasParents } = data; + + 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 + )} + + + {asn} + + + + {hasParents && } + + ); +}; diff --git a/hyperglass/ui/components/path/controls.tsx b/hyperglass/ui/components/path/controls.tsx new file mode 100644 index 0000000..eb0949d --- /dev/null +++ b/hyperglass/ui/components/path/controls.tsx @@ -0,0 +1,36 @@ +import { ButtonGroup, IconButton } from '@chakra-ui/react'; +import { useReactFlow } from 'reactflow'; +import { DynamicIcon } from '~/elements'; + +export const Controls = (): JSX.Element => { + const { fitView, zoomIn, zoomOut } = useReactFlow(); + return ( + + } + onClick={() => zoomIn()} + aria-label="Zoom In" + /> + } + onClick={() => zoomOut()} + aria-label="Zoom Out" + /> + } + onClick={() => fitView()} + aria-label="Fit Nodes" + /> + + ); +}; diff --git a/hyperglass/ui/components/path/index.ts b/hyperglass/ui/components/path/index.ts new file mode 100644 index 0000000..44bb0aa --- /dev/null +++ b/hyperglass/ui/components/path/index.ts @@ -0,0 +1 @@ +export * from './path'; diff --git a/hyperglass/ui/components/path/path-button.tsx b/hyperglass/ui/components/path/path-button.tsx new file mode 100644 index 0000000..4b1d121 --- /dev/null +++ b/hyperglass/ui/components/path/path-button.tsx @@ -0,0 +1,17 @@ +import { Button, Tooltip } from '@chakra-ui/react'; +import { DynamicIcon } from '~/elements'; + +interface PathButtonProps { + onOpen(): void; +} + +export const PathButton = (props: PathButtonProps): JSX.Element => { + const { onOpen } = props; + return ( + + + + ); +}; diff --git a/hyperglass/ui/components/path/path.tsx b/hyperglass/ui/components/path/path.tsx new file mode 100644 index 0000000..0fd3d74 --- /dev/null +++ b/hyperglass/ui/components/path/path.tsx @@ -0,0 +1,51 @@ +import { + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Skeleton, + useDisclosure, +} from '@chakra-ui/react'; +import 'reactflow/dist/style.css'; +import { useBreakpointValue, useColorValue, useFormState } from '~/hooks'; +import { Chart } from './chart'; +import { PathButton } from './path-button'; + +interface PathProps { + device: string; +} + +export const Path = (props: PathProps): JSX.Element => { + const { device } = props; + const displayTarget = useFormState(s => s.target.display); + const getResponse = useFormState(s => s.response); + const { isOpen, onClose, onOpen } = useDisclosure(); + const response = getResponse(device); + const output = response?.output as StructuredResponse; + const bg = useColorValue('light.50', 'dark.900'); + const centered = useBreakpointValue({ base: false, lg: true }) ?? true; + return ( + <> + + + + + {`Path to ${displayTarget}`} + + + + + + + + + + ); +}; diff --git a/hyperglass/ui/components/path/use-elements.ts b/hyperglass/ui/components/path/use-elements.ts new file mode 100644 index 0000000..d7b107b --- /dev/null +++ b/hyperglass/ui/components/path/use-elements.ts @@ -0,0 +1,125 @@ +import dagre from 'dagre'; +import { useMemo } from 'react'; +import isEqual from 'react-fast-compare'; + +import type { Edge, Node } from 'reactflow'; +import type { NodeData } from './chart'; + +interface BasePath { + asn: string; + name: string; +} + +type FlowElement = Node | Edge; + +const NODE_WIDTH = 128; +const NODE_HEIGHT = 48; + +export function useElements(base: BasePath, data: StructuredResponse): FlowElement[] { + return useMemo(() => { + return [...buildElements(base, data)]; + }, [base, data]); +} + +/** + * Calculate the positions for each AS Path. + * @see https://github.com/MrBlenny/react-flow-chart/issues/61 + */ +function* buildElements( + base: BasePath, + data: StructuredResponse, +): 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)]); + + const totalPaths = asPaths.length - 1; + + const g = new dagre.graphlib.Graph(); + g.setGraph({ marginx: 20, marginy: 20 }); + g.setDefaultEdgeLabel(() => ({})); + + // Set the origin (i.e., the hyperglass user) at the base. + g.setNode(base.asn, { width: NODE_WIDTH, height: NODE_HEIGHT }); + + for (const [groupIdx, pathGroup] of asPaths.entries()) { + // For each ROUTE's AS Path: + + // Find the route after this one. + const nextGroup = groupIdx < totalPaths ? asPaths[groupIdx + 1] : []; + + // Connect the first hop in the AS Path to the base (for dagre). + g.setEdge(base.asn, `${groupIdx}-${pathGroup[0]}`); + + // Eliminate duplicate AS Paths. + if (!isEqual(pathGroup, nextGroup)) { + for (const [idx, asn] of pathGroup.entries()) { + // For each ASN in the ROUTE: + + const node = `${groupIdx}-${asn}`; + const endIdx = pathGroup.length - 1; + + // Add the AS as a node. + g.setNode(node, { width: NODE_WIDTH, height: NODE_HEIGHT }); + + // Connect the first hop in the AS Path to the base (for react-flow). + if (idx === 0) { + yield { + id: `e${base.asn}-${node}`, + source: base.asn, + target: node, + }; + } + // Connect every intermediate hop to each other. + if (idx !== endIdx) { + const next = `${groupIdx}-${pathGroup[idx + 1]}`; + g.setEdge(node, next); + yield { + id: `e${node}-${next}`, + source: node, + target: next, + }; + } + } + } + } + + // Now that that nodes are added, create the layout. + dagre.layout(g, { rankdir: 'BT', align: 'UR' }); + + // Get the base ASN's positions. + const x = g.node(base.asn).x - NODE_WIDTH / 2; + const y = g.node(base.asn).y + NODE_HEIGHT * 6; + + yield { + id: base.asn, + type: 'ASNode', + position: { x, y }, + data: { asn: base.asn, name: base.name, hasChildren: true, hasParents: false }, + }; + + for (const [groupIdx, pathGroup] of asPaths.entries()) { + const nextGroup = groupIdx < totalPaths ? asPaths[groupIdx + 1] : []; + if (!isEqual(pathGroup, nextGroup)) { + for (const [idx, asn] of pathGroup.entries()) { + const node = `${groupIdx}-${asn}`; + const endIdx = pathGroup.length - 1; + const x = g.node(node).x - NODE_WIDTH / 2; + const y = g.node(node).y - NODE_HEIGHT * (idx * 6); + + // Get each ASN's positions. + yield { + id: node, + type: 'ASNode', + position: { x, y }, + data: { + asn: `${asn}`, + name: `AS${asn}`, + hasChildren: idx < endIdx, + hasParents: true, + }, + }; + } + } + } +} diff --git a/hyperglass/ui/components/prompt/desktop.tsx b/hyperglass/ui/components/prompt/desktop.tsx new file mode 100644 index 0000000..e9cdd1c --- /dev/null +++ b/hyperglass/ui/components/prompt/desktop.tsx @@ -0,0 +1,27 @@ +import { + Popover, + PopoverBody, + PopoverArrow, + PopoverTrigger, + PopoverContent, + PopoverCloseButton, +} from '@chakra-ui/react'; +import { useColorValue } from '~/hooks'; + +import type { PromptProps } from './types'; + +export const DesktopPrompt = (props: PromptProps): JSX.Element => { + const { trigger, children, ...disclosure } = props; + const bg = useColorValue('white', 'gray.900'); + + return ( + + {trigger} + + + + {children} + + + ); +}; diff --git a/hyperglass/ui/components/prompt/index.tsx b/hyperglass/ui/components/prompt/index.tsx new file mode 100644 index 0000000..1f50f6d --- /dev/null +++ b/hyperglass/ui/components/prompt/index.tsx @@ -0,0 +1,11 @@ +import { useMobile } from '~/hooks'; +import { DesktopPrompt } from './desktop'; +import { MobilePrompt } from './mobile'; + +import type { PromptProps } from './types'; + +export const Prompt = (props: PromptProps): JSX.Element => { + const isMobile = useMobile(); + + return isMobile ? : ; +}; diff --git a/hyperglass/ui/components/prompt/mobile.tsx b/hyperglass/ui/components/prompt/mobile.tsx new file mode 100644 index 0000000..47c0da5 --- /dev/null +++ b/hyperglass/ui/components/prompt/mobile.tsx @@ -0,0 +1,30 @@ +import { Modal, ModalBody, ModalOverlay, ModalContent, ModalCloseButton } from '@chakra-ui/react'; +import { useColorValue } from '~/hooks'; + +import type { PromptProps } from './types'; + +export const MobilePrompt = (props: PromptProps): JSX.Element => { + const { children, trigger, ...disclosure } = props; + const bg = useColorValue('white', 'gray.900'); + return ( + <> + {trigger} + + + + + + {children} + + + + + ); +}; diff --git a/hyperglass/ui/components/prompt/types.ts b/hyperglass/ui/components/prompt/types.ts new file mode 100644 index 0000000..9c32b10 --- /dev/null +++ b/hyperglass/ui/components/prompt/types.ts @@ -0,0 +1,10 @@ +import type { UseDisclosureReturn } from '@chakra-ui/react'; + +type PromptPropsBase = React.PropsWithChildren< + Omit, 'isOpen' | 'onClose'> & + Pick +>; + +export interface PromptProps extends PromptPropsBase { + trigger?: JSX.Element; +} diff --git a/hyperglass/ui/components/query-location.tsx b/hyperglass/ui/components/query-location.tsx new file mode 100644 index 0000000..c8f040f --- /dev/null +++ b/hyperglass/ui/components/query-location.tsx @@ -0,0 +1,181 @@ +import { Flex, Stack, Wrap, chakra } from '@chakra-ui/react'; +import { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { LocationCard, Select } from '~/components'; +import { isMultiValue, isSingleValue } from '~/components/select'; +import { useConfig } from '~/context'; +import { useFormState } from '~/hooks'; + +import type { SelectOnChange } from '~/components/select'; +import type { DeviceGroup, FormData, OnChangeArgs, OptionGroup, SingleOption } from '~/types'; + +/** Location option type alias for future extensions. */ +export type LocationOption = SingleOption; + +interface QueryLocationProps { + onChange: (f: OnChangeArgs) => void; + label: string; +} + +function buildOptions(devices: DeviceGroup[]): OptionGroup[] { + return devices + .map(group => { + const label = group.group; + const options = group.locations + .map( + loc => + ({ + label: loc.name, + value: loc.id, + group: loc.group, + data: { + avatar: loc.avatar, + description: loc.description, + }, + }) as SingleOption, + ) + .sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0)); + return { label: label ?? '', options }; + }) + .sort((a, b) => + (a.label ?? 0) < (b.label ?? 0) ? -1 : (a.label ?? 0) > (b.label ?? 0) ? 1 : 0, + ); +} + +export const QueryLocation = (props: QueryLocationProps): JSX.Element => { + const { onChange, label } = props; + + const { + devices, + web: { locationDisplayMode }, + } = useConfig(); + const { + formState: { errors }, + } = useFormContext(); + const selections = useFormState(s => s.selections); + const setSelection = useFormState(s => s.setSelection); + const { form, filtered } = useFormState(({ form, filtered }) => ({ form, filtered })); + const options = useMemo(() => buildOptions(devices), [devices]); + + const element = useMemo(() => { + if (locationDisplayMode === 'dropdown') { + return 'select'; + } + if (locationDisplayMode === 'gallery') { + return 'cards'; + } + const groups = options.length; + const maxOptionsPerGroup = Math.max(...options.map(opt => opt.options.length)); + const showCards = groups < 5 && maxOptionsPerGroup < 9; + return showCards ? 'cards' : 'select'; + }, [options, locationDisplayMode]); + + const noOverlap = useMemo( + () => form.queryLocation.length > 1 && filtered.types.length === 0, + [form, filtered], + ); + + /** + * Update form and state when a card selections change. + * + * @param action Add or remove the option. + * @param option Full option object. + */ + function handleCardChange(action: 'add' | 'remove', option: SingleOption) { + const exists = selections.queryLocation.map(q => q.value).includes(option.value); + if (action === 'add' && !exists) { + const toAdd = [...form.queryLocation, option.value]; + const newSelections = [...selections.queryLocation, option]; + setSelection('queryLocation', newSelections); + onChange({ field: 'queryLocation', value: toAdd }); + } else if (action === 'remove' && exists) { + const index = selections.queryLocation.findIndex(v => v.value === option.value); + const toRemove = [...form.queryLocation.filter(v => v !== option.value)]; + setSelection( + 'queryLocation', + selections.queryLocation.filter((_, i) => i !== index), + ); + onChange({ field: 'queryLocation', value: toRemove }); + } + } + + /** + * Update form and state when select element values change. + * + * @param options Final value. React-select determines if an option is being added or removed and + * only sends back the final value. + */ + const handleSelectChange: SelectOnChange = (options): void => { + if (isMultiValue(options)) { + onChange({ field: 'queryLocation', value: options.map(o => o.value) }); + setSelection('queryLocation', options); + } else if (isSingleValue(options)) { + onChange({ field: 'queryLocation', value: options.value }); + setSelection('queryLocation', [options]); + } + }; + + if (element === 'cards') { + return ( + <> + {options.length === 1 ? ( + + {options[0].options.map(opt => { + return ( + + ); + })} + + ) : ( + <> + {options.map(group => ( + + + {group.label} + + {group.options.map(opt => { + return ( + + ); + })} + + ))} + + )} + + ); + } + if (element === 'select') { + return ( + + isMulti + options={options} + aria-label={label} + name="queryLocation" + closeMenuOnSelect={true} + onChange={handleSelectChange} + value={selections.queryLocation} + isError={typeof errors.queryLocation !== 'undefined'} + /> + ); + } + return No Locations; +}; diff --git a/hyperglass/ui/components/query-target.tsx b/hyperglass/ui/components/query-target.tsx new file mode 100644 index 0000000..1d452f9 --- /dev/null +++ b/hyperglass/ui/components/query-target.tsx @@ -0,0 +1,113 @@ +import { Input, InputGroup, InputRightElement, Text } from '@chakra-ui/react'; +import { useMemo } from 'react'; +import { components } from 'react-select'; +import { Select } from '~/components'; +import { isSingleValue } from '~/components/select'; +import { useDirective, useFormState } from '~/hooks'; +import { isSelectDirective } from '~/types'; +import { UserIP } from './user-ip'; + +import { type UseFormRegister, useForm } from 'react-hook-form'; +import type { GroupBase, OptionProps } from 'react-select'; +import type { SelectOnChange } from '~/components/select'; +import type { Directive, FormData, OnChangeArgs, SingleOption } from '~/types'; + +type OptionWithDescription = SingleOption<{ description: string | null }>; + +interface QueryTargetProps { + name: string; + placeholder: string; + onChange(e: OnChangeArgs): void; + register: UseFormRegister; +} + +function buildOptions(directive: Nullable): OptionWithDescription[] { + if (directive !== null && isSelectDirective(directive)) { + return directive.options.map(o => ({ + value: o.value, + label: o.name, + data: { description: o.description }, + })); + } + return []; +} + +const Option = (props: OptionProps) => { + const { label, data } = props; + return ( + > {...props}> + {label} +
+ + {data.data?.description} + + + ); +}; + +export const QueryTarget = (props: QueryTargetProps): JSX.Element => { + const { name, register, onChange, placeholder } = props; + + const displayTarget = useFormState(s => s.target.display); + const setTarget = useFormState(s => s.setTarget); + const queryTarget = useFormState(s => s.form.queryTarget); + const directive = useDirective(); + + const options = useMemo(() => buildOptions(directive), [directive]); + const isSelect = useMemo(() => directive !== null && isSelectDirective(directive), [directive]); + + function handleInputChange(e: React.ChangeEvent): void { + setTarget({ display: e.target.value }); + onChange({ field: name, value: [e.target.value] }); + } + + const handleSelectChange: SelectOnChange = e => { + if (isSingleValue(e)) { + onChange({ field: name, value: e.value }); + setTarget({ display: e.value }); + } + }; + + const handleUserIPChange = (target: string): void => { + setTarget({ display: target }); + onChange({ field: name, value: target }); + }; + + return ( + <> + + {isSelect ? ( + + name={name} + options={options} + components={{ Option }} + onChange={handleSelectChange} + /> + ) : ( + + + + + + + )} + + ); +}; diff --git a/hyperglass/ui/components/query-type.tsx b/hyperglass/ui/components/query-type.tsx new file mode 100644 index 0000000..7b9ac73 --- /dev/null +++ b/hyperglass/ui/components/query-type.tsx @@ -0,0 +1,185 @@ +import { useMemo } from 'react'; +import create from 'zustand'; +import { Box, Button, HStack, useRadio, useRadioGroup } from '@chakra-ui/react'; +import { useFormContext } from 'react-hook-form'; +import { components } from 'react-select'; +import { Select } from '~/components'; +import { useFormState, useFormSelections } from '~/hooks'; +import { isSingleValue } from '~/components/select'; + +import type { UseRadioProps } from '@chakra-ui/react'; +import type { MenuListProps } from 'react-select'; +import type { SingleOption, OptionGroup, OptionsOrGroup, OnChangeArgs } from '~/types'; +import type { SelectOnChange } from '~/components/select'; + +type QueryTypeOption = SingleOption<{ group?: string }>; + +interface QueryTypeProps { + onChange: (f: OnChangeArgs) => void; + label: string; +} + +type UserFilter = { + selected: string; + setSelected(n: string): void; + filter(candidate: QueryTypeOption, input: string): boolean; +}; + +function sorter>(a: T, b: T): number { + return a.label < b.label ? -1 : a.label > b.label ? 1 : 0; +} + +const useFilter = create((set, get) => ({ + selected: '', + setSelected(newValue: string) { + set(() => ({ selected: newValue })); + }, + filter(candidate, input): boolean { + const { label, data } = candidate; + const group = data?.group ?? null; + + if (input && (label || group)) { + const search = input.toLowerCase(); + if (group) { + return label.toLowerCase().indexOf(search) > -1 || group.toLowerCase().indexOf(search) > -1; + } + return label.toLowerCase().indexOf(search) > -1; + } + const { selected } = get(); + if (selected !== '' && selected === group) { + return true; + } + if (selected === '') { + return true; + } + return false; + }, +})); + +function useOptions() { + const filtered = useFormState(s => s.filtered); + return useMemo((): OptionsOrGroup => { + const groupNames = new Set( + filtered.types.filter(t => t.groups.length > 0).flatMap(t => t.groups), + ); + const optGroups: OptionGroup[] = Array.from(groupNames).map(group => ({ + label: group, + options: filtered.types + .filter(t => t.groups.includes(group)) + .map(t => ({ label: t.name, value: t.id, group })) + .sort(sorter), + })); + + const noGroups: OptionGroup = { + label: '', + options: filtered.types + .filter(t => t.groups.length === 0) + .map(t => ({ label: t.name, value: t.id, group: '' })) + .sort(sorter), + }; + + return [noGroups, ...optGroups].sort(sorter); + }, [filtered.types]); +} + +const GroupFilter = (props: React.PropsWithChildren): JSX.Element => { + const { children, ...rest } = props; + const { + getInputProps, + getCheckboxProps, + getLabelProps, + htmlProps, + state: { isChecked }, + } = useRadio(rest); + const label = getLabelProps(); + const input = getInputProps(); + const checkbox = getCheckboxProps(); + + return ( + + + + + ); +}; + +const MenuList = (props: MenuListProps): JSX.Element => { + const { children, ...rest } = props; + const filtered = useFormState(s => s.filtered); + const selected = useFilter(state => state.selected); + const setSelected = useFilter(state => state.setSelected); + + const { getRadioProps, getRootProps } = useRadioGroup({ + name: 'queryGroup', + value: selected, + }); + + function handleClick(value: string): void { + setSelected(value); + } + return ( + + + handleClick('') })}> + None + + {filtered.groups.map(value => { + return ( + handleClick(value) })} + > + {value} + + ); + })} + + {children} + + ); +}; + +export const QueryType = (props: QueryTypeProps): JSX.Element => { + const { onChange, label } = props; + const { + formState: { errors }, + } = useFormContext(); + const setSelection = useFormState(s => s.setSelection); + const selections = useFormSelections(); + const setFormValue = useFormState(s => s.setFormValue); + const options = useOptions(); + const { filter } = useFilter(); // Intentionally re-render on any changes + + const handleChange: SelectOnChange = e => { + let value = ''; + if (isSingleValue(e)) { + setSelection('queryType', e); + value = e.value; + } else { + setFormValue('queryType', ''); + setSelection('queryType', null); + } + onChange({ field: 'queryType', value }); + }; + + return ( + + name="queryType" + options={options} + aria-label={label} + filterOption={filter} + onChange={handleChange} + components={{ MenuList }} + isError={'queryType' in errors} + value={selections.queryType} + /> + ); +}; diff --git a/hyperglass/ui/components/reset-button.tsx b/hyperglass/ui/components/reset-button.tsx new file mode 100644 index 0000000..c0ea69f --- /dev/null +++ b/hyperglass/ui/components/reset-button.tsx @@ -0,0 +1,50 @@ +import { Flex, IconButton } from '@chakra-ui/react'; +import { AnimatePresence } from 'framer-motion'; +import { AnimatedDiv, DynamicIcon } from '~/elements'; +import { useColorValue, useOpposingColor, useFormState } from '~/hooks'; + +import type { FlexProps } from '@chakra-ui/react'; + +interface ResetButtonProps extends FlexProps { + developerMode: boolean; + resetForm(): void; +} + +export const ResetButton = (props: ResetButtonProps): JSX.Element => { + const { developerMode, resetForm, ...rest } = props; + const status = useFormState(s => s.status); + const bg = useColorValue('primary.500', 'primary.300'); + const color = useOpposingColor(bg); + return ( + + {status === 'results' && ( + + + } + /> + + + )} + + ); +}; diff --git a/hyperglass/ui/components/resolved-target.tsx b/hyperglass/ui/components/resolved-target.tsx new file mode 100644 index 0000000..9130d6e --- /dev/null +++ b/hyperglass/ui/components/resolved-target.tsx @@ -0,0 +1,138 @@ +import { useMemo } from 'react'; +import { Button, Stack, Text, VStack } from '@chakra-ui/react'; +import { useConfig } from '~/context'; +import { DynamicIcon } from '~/elements'; +import { useStrf, useColorValue, useDNSQuery, useFormState } from '~/hooks'; + +import type { DnsOverHttps } from '~/types'; + +interface ResolvedTargetProps { + errorClose(): void; +} + +function findAnswer(data: DnsOverHttps.Response | undefined): string { + let answer = ''; + if (typeof data !== 'undefined') { + answer = data?.Answer?.filter(answerData => answerData.type === data?.Question[0]?.type)[0] + ?.data; + } + return answer; +} + +export const ResolvedTarget = (props: ResolvedTargetProps): JSX.Element => { + const { errorClose } = props; + const strF = useStrf(); + const { web } = useConfig(); + + const setStatus = useFormState(s => s.setStatus); + const displayTarget = useFormState(s => s.target.display); + const setFormValue = useFormState(s => s.setFormValue); + + const color = useColorValue('secondary.500', 'secondary.300'); + const errorColor = useColorValue('red.500', 'red.300'); + + const tooltip4 = strF(web.text.fqdnTooltip, { protocol: 'IPv4' }); + const tooltip6 = strF(web.text.fqdnTooltip, { protocol: 'IPv6' }); + + const [messageStart, messageEnd] = web.text.fqdnMessage.split('{fqdn}'); + const [errorStart, errorEnd] = web.text.fqdnError.split('{fqdn}'); + + const { + data: data4, + isLoading: isLoading4, + isError: isError4, + error: error4, + } = useDNSQuery(displayTarget, 4); + + const { + data: data6, + isLoading: isLoading6, + isError: isError6, + error: error6, + } = useDNSQuery(displayTarget, 6); + + isError4 && console.error(error4); + isError6 && console.error(error6); + + const answer4 = useMemo(() => findAnswer(data4), [data4]); + const answer6 = useMemo(() => findAnswer(data6), [data6]); + + function selectTarget(value: string): void { + setFormValue('queryTarget', [value]); + setStatus('results'); + } + + const hasAnswer = useMemo( + () => (!isError4 || !isError6) && (answer4 || answer6), + [answer4, answer6, isError4, isError6], + ); + const showA = useMemo(() => !isLoading4 && !isError4 && answer4, [isLoading4, isError4, answer4]); + const showAAAA = useMemo( + () => !isLoading6 && !isError6 && answer6, + [isLoading6, isError6, answer6], + ); + + return ( + + {hasAnswer && ( + + {messageStart} + + {`${displayTarget}`.toLowerCase()} + + {messageEnd} + + )} + + {showA && ( + + )} + {showAAAA && ( + + )} + {!hasAnswer && ( + <> + + {errorStart} + + {`${displayTarget}`.toLowerCase()} + + {errorEnd} + + + + )} + + + ); +}; diff --git a/hyperglass/ui/components/results/copy-button.tsx b/hyperglass/ui/components/results/copy-button.tsx new file mode 100644 index 0000000..fddd87e --- /dev/null +++ b/hyperglass/ui/components/results/copy-button.tsx @@ -0,0 +1,28 @@ +import { Button, Tooltip, useClipboard } from '@chakra-ui/react'; +import { DynamicIcon } from '~/elements'; + +import type { ButtonProps } from '@chakra-ui/react'; + +interface CopyButtonProps extends ButtonProps { + copyValue: string; +} + +export const CopyButton = (props: CopyButtonProps): JSX.Element => { + const { copyValue, ...rest } = props; + const { onCopy, hasCopied } = useClipboard(copyValue); + return ( + + + + ); +}; diff --git a/hyperglass/ui/components/results/formatted-error.tsx b/hyperglass/ui/components/results/formatted-error.tsx new file mode 100644 index 0000000..6070151 --- /dev/null +++ b/hyperglass/ui/components/results/formatted-error.tsx @@ -0,0 +1,37 @@ +import { chakra } from '@chakra-ui/react'; + +interface FormattedErrorProps { + keywords: string[]; + message: string; +} + +type FormatError = string | JSX.Element; + +function formatError(text: string, values: string[], regex: RegExp): FormatError[] | FormatError { + if (!values.length) { + return text; + } + + const parts = text.split(regex); + + return parts.reduce((prev, current, i) => { + if (!i) { + return [current]; + } + + return prev.concat( + values.includes(current) ? {current} : current, + ); + }, [] as FormatError[]); +} + +export const FormattedError = (props: FormattedErrorProps): JSX.Element => { + const { keywords, message } = props; + const pattern = new RegExp(keywords.map(kw => `(${kw})`).join('|'), 'gi'); + const things = formatError(message, keywords, pattern); + return ( + + {keywords.length !== 0 ? things : message} + + ); +}; diff --git a/hyperglass/ui/components/results/group.tsx b/hyperglass/ui/components/results/group.tsx new file mode 100644 index 0000000..2b032d2 --- /dev/null +++ b/hyperglass/ui/components/results/group.tsx @@ -0,0 +1,48 @@ +import { Accordion } from '@chakra-ui/react'; +import { AnimatePresence } from 'framer-motion'; +import { useEffect } from 'react'; +import { AnimatedDiv } from '~/elements'; +import { useFormState } from '~/hooks'; +import { Result } from './individual'; +import { Tags } from './tags'; + +export const Results = (): JSX.Element => { + const { queryLocation } = useFormState(s => s.form); + + // Scroll to the top of the page when results load - primarily for mobile. + useEffect(() => { + if (typeof window !== 'undefined') { + window.scrollTo(0, 0); + } + }, []); + + return ( + <> + + + + + {queryLocation.length > 0 && + queryLocation.map((location, index) => { + return ; + })} + + + + + ); +}; diff --git a/hyperglass/ui/components/results/guards.ts b/hyperglass/ui/components/results/guards.ts new file mode 100644 index 0000000..a70b6f0 --- /dev/null +++ b/hyperglass/ui/components/results/guards.ts @@ -0,0 +1,22 @@ +// biome-ignore lint/suspicious/noExplicitAny: type guard +export function isStackError(error: any): error is Error { + return typeof error !== 'undefined' && error !== null && 'message' in error; +} + +// biome-ignore lint/suspicious/noExplicitAny: type guard +export function isFetchError(error: any): error is Response { + return typeof error !== 'undefined' && error !== null && 'statusText' in error; +} + +// biome-ignore lint/suspicious/noExplicitAny: type guard +export function isLGError(error: any): error is QueryResponse { + return typeof error !== 'undefined' && error !== null && 'output' in error; +} + +/** + * Returns true if the response is an LG error, false if not. + */ +// biome-ignore lint/suspicious/noExplicitAny: type guard +export function isLGOutputOrError(data: any): data is QueryResponse { + return typeof data !== 'undefined' && data !== null && data?.level !== 'success'; +} diff --git a/hyperglass/ui/components/results/header.tsx b/hyperglass/ui/components/results/header.tsx new file mode 100644 index 0000000..f82a62a --- /dev/null +++ b/hyperglass/ui/components/results/header.tsx @@ -0,0 +1,68 @@ +import { AccordionIcon, Box, HStack, Spinner, Text, Tooltip } from '@chakra-ui/react'; +import { useMemo } from 'react'; +import { useConfig } from '~/context'; +import { DynamicIcon } from '~/elements'; +import { useColorValue, useOpposingColor, useStrf } from '~/hooks'; + +import type { ErrorLevels } from '~/types'; + +interface ResultHeaderProps { + title: string; + loading: boolean; + isError?: boolean; + errorMsg: string; + errorLevel: ErrorLevels; + runtime: number; +} + +const runtimeText = (runtime: number, text: string): string => { + let unit = 'seconds'; + if (runtime === 1) { + unit = 'second'; + } + return `${text} ${unit}`; +}; + +export const ResultHeader = (props: ResultHeaderProps): JSX.Element => { + const { title, loading, isError, errorMsg, errorLevel, runtime } = props; + + const status = useColorValue('primary.500', 'primary.300'); + const warning = useColorValue(`${errorLevel}.500`, `${errorLevel}.300`); + const defaultStatus = useColorValue('success.500', 'success.300'); + + const { web } = useConfig(); + const strF = useStrf(); + const text = strF(web.text.completeTime, { seconds: runtime }); + const label = useMemo(() => runtimeText(runtime, text), [runtime, text]); + + const color = useOpposingColor(isError ? warning : defaultStatus); + + return ( + + + + {loading ? ( + + ) : ( + + )} + + + + {title} + + + ); +}; diff --git a/hyperglass/ui/components/results/index.ts b/hyperglass/ui/components/results/index.ts new file mode 100644 index 0000000..8a78f59 --- /dev/null +++ b/hyperglass/ui/components/results/index.ts @@ -0,0 +1 @@ +export * from './group'; diff --git a/hyperglass/ui/components/results/individual.tsx b/hyperglass/ui/components/results/individual.tsx new file mode 100644 index 0000000..a316dd3 --- /dev/null +++ b/hyperglass/ui/components/results/individual.tsx @@ -0,0 +1,307 @@ +import { + AccordionButton, + AccordionItem, + AccordionPanel, + Alert, + Box, + Flex, + HStack, + Tooltip, + chakra, + useAccordionContext, + useToast, +} from '@chakra-ui/react'; +import { motion } from 'framer-motion'; +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 { useConfig } from '~/context'; +import { Countdown, DynamicIcon } from '~/elements'; +import { + useColorValue, + useDevice, + useFormState, + useLGQuery, + useMobile, + useStrf, + useTableToString, +} from '~/hooks'; +import { isStringOutput, isStructuredOutput } from '~/types'; +import { CopyButton } from './copy-button'; +import { FormattedError } from './formatted-error'; +import { isFetchError, isLGError, isLGOutputOrError, isStackError } from './guards'; +import { ResultHeader } from './header'; +import { RequeryButton } from './requery-button'; + +import type { ErrorLevels } from '~/types'; + +interface ResultProps { + index: number; + queryLocation: string; +} + +const AnimatedAccordionItem = motion(AccordionItem); + +const AccordionHeaderWrapper = chakra('div', { + baseStyle: { + display: 'flex', + justifyContent: 'space-between', + _hover: { bg: 'blackAlpha.50' }, + _focus: { boxShadow: 'outline' }, + }, +}); + +const _Result: React.ForwardRefRenderFunction = ( + props: ResultProps, + ref, +) => { + const { index, queryLocation } = props; + const toast = useToast(); + const { web, cache, messages } = useConfig(); + const { index: indices, setIndex } = useAccordionContext(); + const getDevice = useDevice(); + const device = getDevice(queryLocation); + + const isMobile = useMobile(); + const color = useColorValue('black', 'white'); + const scrollbar = useColorValue('blackAlpha.300', 'whiteAlpha.300'); + const scrollbarHover = useColorValue('blackAlpha.400', 'whiteAlpha.400'); + const scrollbarBg = useColorValue('blackAlpha.50', 'whiteAlpha.50'); + + const addResponse = useFormState(s => s.addResponse); + const form = useFormState(s => s.form); + const [errorLevel, _setErrorLevel] = useState('error'); + + const setErrorLevel = (level: ResponseLevel): void => { + let e: ErrorLevels = 'error'; + switch (level) { + case 'success': + e = level; + break; + case 'warning' || 'error': + e = 'warning'; + break; + } + _setErrorLevel(e); + }; + + const { data, error, isLoading, refetch, isFetchedAfterMount } = useLGQuery( + { queryLocation, queryTarget: form.queryTarget, queryType: form.queryType }, + { + onSuccess(data) { + if (device !== null) { + addResponse(device.id, data); + } + if (isLGOutputOrError(data)) { + console.error(data); + setErrorLevel(data.level); + } + }, + onError(error) { + console.error({ error }); + if (isLGOutputOrError(error)) { + setErrorLevel(error.level); + } + }, + }, + ); + + const isError = useMemo(() => isLGOutputOrError(data), [data, error]); + + const isCached = useMemo(() => data?.cached || !isFetchedAfterMount, [data, isFetchedAfterMount]); + + const strF = useStrf(); + const cacheLabel = strF(web.text.cacheIcon, { time: data?.timestamp }); + + const errorKeywords = useMemo(() => { + let kw = [] as string[]; + if (isLGError(data)) { + kw = data.keywords; + } + return kw; + }, [data]); + + // Parse the the response and/or the error to determine from where to extract the error message. + const errorMsg = useMemo(() => { + if (isLGError(error)) { + return error.output as string; + } + if (isLGOutputOrError(data)) { + return data.output as string; + } + if (isFetchError(error)) { + return startCase(error.statusText); + } + if (isStackError(error) && error.message.toLowerCase().startsWith('timeout')) { + return messages.requestTimeout; + } + if (isStackError(error)) { + return startCase(error.message); + } + return messages.general; + }, [error, data, messages.general, messages.requestTimeout]); + + const tableComponent = useMemo(() => { + let result = false; + if (data?.format === 'application/json') { + result = true; + } + return result; + }, [data?.format]); + + let copyValue = data?.output as string; + + const formatData = useTableToString(form.queryTarget, data, [data?.format]); + + if (data?.format === 'application/json') { + copyValue = formatData(); + } + + if (error) { + copyValue = errorMsg; + } + + // 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) { + // Only set the index if the response has data or an error. + if (data || isError) { + setIndex([index]); + } + } + }, [data, index, indices, isLoading, isError, setIndex]); + + if (device === null) { + const id = `toast-queryLocation-${index}-${queryLocation}`; + if (!toast.isActive(id)) { + toast({ + id, + title: messages.general, + description: `Configuration for device with ID '${queryLocation}' not found.`, + status: 'error', + isClosable: true, + }); + } + return <>; + } + + return ( + + + + + + + {isStructuredOutput(data) && data.level === 'success' && tableComponent && ( + + )} + + + + + + + + + + {isStructuredOutput(data) && data.level === 'success' && tableComponent ? ( + {data.output} + ) : isStringOutput(data) && data.level === 'success' && !tableComponent ? ( + {data.output} + ) : isStringOutput(data) && data.level !== 'success' ? ( + + + + ) : ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const Result = memo(forwardRef(_Result), isEqual); diff --git a/hyperglass/ui/components/results/requery-button.tsx b/hyperglass/ui/components/results/requery-button.tsx new file mode 100644 index 0000000..7df11cb --- /dev/null +++ b/hyperglass/ui/components/results/requery-button.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from 'react'; +import { Button, Tooltip } from '@chakra-ui/react'; +import { DynamicIcon } from '~/elements'; + +import type { ButtonProps } from '@chakra-ui/react'; +import type { UseQueryResult } from '@tanstack/react-query'; + +interface RequeryButtonProps extends ButtonProps { + requery: Get, 'refetch'>; +} + +const _RequeryButton: React.ForwardRefRenderFunction = ( + props: RequeryButtonProps, + ref, +) => { + const { requery, ...rest } = props; + + return ( + + + + ); +}; + +export const RequeryButton = forwardRef(_RequeryButton); diff --git a/hyperglass/ui/components/results/tags.tsx b/hyperglass/ui/components/results/tags.tsx new file mode 100644 index 0000000..d03c9a3 --- /dev/null +++ b/hyperglass/ui/components/results/tags.tsx @@ -0,0 +1,98 @@ +import { useMemo } from 'react'; +import { Box, Stack, useToken } from '@chakra-ui/react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useConfig } from '~/context'; +import { Label } from '~/elements'; +import { useFormState, useBreakpointValue } from '~/hooks'; + +import type { Transition } from 'framer-motion'; + +const transition = { duration: 0.3, delay: 0.5 } as Transition; + +export const Tags = (): JSX.Element => { + const { web } = useConfig(); + const form = useFormState(s => s.form); + const getDirective = useFormState(s => s.getDirective); + + const selectedDirective = useMemo(() => { + return getDirective(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.queryType, getDirective]); + + const targetBg = useToken('colors', 'teal.600'); + const queryBg = useToken('colors', 'cyan.500'); + + const animateLeft = useBreakpointValue({ + base: { opacity: 1, x: 0 }, + md: { opacity: 1, x: 0 }, + lg: { opacity: 1, x: 0 }, + xl: { opacity: 1, x: 0 }, + }); + + const animateRight = useBreakpointValue({ + base: { opacity: 1, x: 0 }, + md: { opacity: 1, x: 0 }, + lg: { opacity: 1, x: 0 }, + xl: { opacity: 1, x: 0 }, + }); + + const initialLeft = useBreakpointValue({ + base: { opacity: 0, x: '-100%' }, + md: { opacity: 0, x: '-100%' }, + lg: { opacity: 0, x: '-100%' }, + xl: { opacity: 0, x: '-100%' }, + }); + + const initialRight = useBreakpointValue({ + base: { opacity: 0, x: '100%' }, + md: { opacity: 0, x: '100%' }, + lg: { opacity: 0, x: '100%' }, + xl: { opacity: 0, x: '100%' }, + }); + + return ( + + + + {form.queryLocation.length > 0 && ( + <> + + + + + + )} + + + + ); +}; diff --git a/hyperglass/ui/components/select/index.ts b/hyperglass/ui/components/select/index.ts new file mode 100644 index 0000000..242fc65 --- /dev/null +++ b/hyperglass/ui/components/select/index.ts @@ -0,0 +1,3 @@ +export * from './select'; +export { isSingleValue, isMultiValue } from './types'; +export type { SelectOnChange } from './types'; diff --git a/hyperglass/ui/components/select/option.tsx b/hyperglass/ui/components/select/option.tsx new file mode 100644 index 0000000..fe9267c --- /dev/null +++ b/hyperglass/ui/components/select/option.tsx @@ -0,0 +1,30 @@ +import { Badge, chakra, HStack } from '@chakra-ui/react'; +import { components } from 'react-select'; + +import type { OptionProps, GroupBase } from 'react-select'; +import type { SingleOption } from '~/types'; + +export const Option = ( + props: OptionProps, +): JSX.Element => { + const { label, data } = props; + const tags = Array.isArray(data.tags) ? (data.tags as string[]) : []; + return ( + > {...props}> + {label} + {tags.length > 0 && ( + + {tags.map(tag => ( + + {tag} + + ))} + + )} + + ); +}; diff --git a/hyperglass/ui/components/select/select.tsx b/hyperglass/ui/components/select/select.tsx new file mode 100644 index 0000000..1def4e7 --- /dev/null +++ b/hyperglass/ui/components/select/select.tsx @@ -0,0 +1,101 @@ +import { useDisclosure } from '@chakra-ui/react'; +import { createContext, forwardRef, useContext } from 'react'; +import ReactSelect from 'react-select'; +import { useColorMode } from '~/hooks'; +import { Option } from './option'; +import { + useContainerStyle, + useControlStyle, + useIndicatorSeparatorStyle, + useMenuListStyle, + useMenuPortal, + useMenuStyle, + useMultiValueLabelStyle, + useMultiValueRemoveStyle, + useMultiValueStyle, + useOptionStyle, + usePlaceholderStyle, + useRSTheme, + useSingleValueStyle, +} from './styles'; +import { isSingleValue } from './types'; + +import type { + MultiValue, + OnChangeValue, + Props as ReactSelectProps, + SelectInstance, +} from 'react-select'; +import type { SingleOption } from '~/types'; +import type { SelectContextProps, SelectProps } from './types'; + +const SelectContext = createContext({} as SelectContextProps); +export const useSelectContext = (): SelectContextProps => useContext(SelectContext); + +export const Select = forwardRef( + ( + props: SelectProps, + ref: React.Ref>, + ): JSX.Element => { + const { options, isMulti, onSelect, isError = false, components, ...rest } = props; + + const { isOpen, onOpen, onClose } = useDisclosure(); + + const { colorMode } = useColorMode(); + + const defaultOnChange: ReactSelectProps['onChange'] = changed => { + if (isSingleValue(changed)) { + changed = [changed] as unknown as OnChangeValue; + } + if (typeof onSelect === 'function') { + onSelect(changed as MultiValue); + } + }; + + const container = useContainerStyle({ colorMode }); + const menu = useMenuStyle({ colorMode }); + const menuList = useMenuListStyle({ colorMode }); + const control = useControlStyle({ colorMode }); + const option = useOptionStyle({ colorMode }); + const singleValue = useSingleValueStyle({ colorMode }); + const multiValue = useMultiValueStyle({ colorMode }); + const multiValueLabel = useMultiValueLabelStyle({ colorMode }); + const multiValueRemove = useMultiValueRemoveStyle({ colorMode }); + const menuPortal = useMenuPortal(); + const placeholder = usePlaceholderStyle({ colorMode }); + const indicatorSeparator = useIndicatorSeparatorStyle({ colorMode }); + const rsTheme = useRSTheme(); + + return ( + + + onChange={defaultOnChange} + onMenuClose={onClose} + onMenuOpen={onOpen} + isClearable={true} + options={options} + isMulti={isMulti} + theme={rsTheme} + components={{ Option, ...components }} + menuPortalTarget={typeof document !== 'undefined' ? document.body : undefined} + ref={ref} + styles={{ + container, + menu, + option, + control, + menuList, + menuPortal, + multiValue, + singleValue, + placeholder, + multiValueLabel, + multiValueRemove, + indicatorSeparator, + }} + {...rest} + /> + + ); + }, +); diff --git a/hyperglass/ui/components/select/styles.ts b/hyperglass/ui/components/select/styles.ts new file mode 100644 index 0000000..e1e20b2 --- /dev/null +++ b/hyperglass/ui/components/select/styles.ts @@ -0,0 +1,245 @@ +import { useToken } from '@chakra-ui/react'; +import { mergeWith } from '@chakra-ui/utils'; +/* eslint-disable react-hooks/exhaustive-deps */ +import { useCallback } from 'react'; +import { + useColorToken, + useColorValue, + useMobile, + useOpposingColor, + useOpposingColorCallback, +} from '~/hooks'; +import { useSelectContext } from './select'; + +import * as ReactSelect from 'react-select'; +import type { SingleOption } from '~/types'; +import type { RSStyleCallbackProps, RSStyleFunction, RSThemeFunction } from './types'; + +export const useContainerStyle = ( + props: RSStyleCallbackProps, +): RSStyleFunction<'container', Opt, IsMulti> => { + return useCallback((base, state) => { + return { width: '100%' }; + }, []); +}; + +export const useControlStyle = ( + props: RSStyleCallbackProps, +): RSStyleFunction<'control', Opt, IsMulti> => { + const { colorMode } = props; + + const { isError } = useSelectContext(); + + const minHeight = useToken('space', 12); + const borderRadius = useToken('radii', 'md'); + const color = useColorToken('colors', 'black', 'whiteAlpha.800'); + const focusBorder = useColorToken('colors', 'blue.500', 'blue.300'); + const invalidBorder = useColorToken('colors', 'red.500', 'red.300'); + // const borderColor = useColorToken('colors', 'gray.200', 'whiteAlpha.300'); + const borderColor = useColorToken('colors', 'gray.100', 'whiteAlpha.50'); + const borderHover = useColorToken('colors', 'gray.300', 'whiteAlpha.400'); + const backgroundColor = useColorToken('colors', 'white', 'blackSolid.800'); + + return useCallback( + (base, state) => { + const { isFocused } = state; + const styles = { + backgroundColor, + borderRadius, + color, + minHeight, + transition: 'all 0.2s', + borderColor: isError ? invalidBorder : isFocused ? focusBorder : borderColor, + boxShadow: isError + ? `0 0 0 1px ${invalidBorder}` + : isFocused + ? `0 0 0 1px ${focusBorder}` + : undefined, + '&:hover': { borderColor: isFocused ? focusBorder : borderHover }, + '&:hover > div > span': { backgroundColor: borderHover }, + '&:focus': { borderColor: isError ? invalidBorder : focusBorder }, + '&.invalid': { borderColor: invalidBorder, boxShadow: `0 0 0 1px ${invalidBorder}` }, + }; + return mergeWith({}, base, styles); + }, + [colorMode, isError], + ); +}; + +export const useMenuStyle = ( + props: RSStyleCallbackProps, +): RSStyleFunction<'menu', Opt, IsMulti> => { + const { colorMode } = props; + + const { isOpen } = useSelectContext(); + + const backgroundColor = useColorToken('colors', 'white', 'blackSolid.700'); + const styles = { backgroundColor, zIndex: 1500 }; + + return useCallback(base => mergeWith({}, base, styles), [colorMode, isOpen]); +}; + +export const useMenuListStyle = ( + props: RSStyleCallbackProps, +): RSStyleFunction<'menuList', Opt, IsMulti> => { + const { colorMode } = props; + + const { isOpen } = useSelectContext(); + + const borderRadius = useToken('radii', 'md'); + const backgroundColor = useColorToken('colors', 'white', 'blackSolid.700'); + const scrollbarTrack = useColorToken('colors', 'blackAlpha.50', 'whiteAlpha.50'); + const scrollbarThumb = useColorToken('colors', 'blackAlpha.300', 'whiteAlpha.300'); + const scrollbarThumbHover = useColorToken('colors', 'blackAlpha.400', 'whiteAlpha.400'); + const styles = { + borderRadius, + backgroundColor, + '&::-webkit-scrollbar': { width: '5px' }, + '&::-webkit-scrollbar-track': { backgroundColor: scrollbarTrack }, + '&::-webkit-scrollbar-thumb': { backgroundColor: scrollbarThumb }, + '&::-webkit-scrollbar-thumb:hover': { backgroundColor: scrollbarThumbHover }, + '-ms-overflow-style': { display: 'none' }, + }; + + return useCallback(base => mergeWith({}, base, styles), [colorMode, isOpen]); +}; + +export const useOptionStyle = ( + props: RSStyleCallbackProps, +): RSStyleFunction<'option', Opt, IsMulti> => { + const { colorMode } = props; + + const { isOpen } = useSelectContext(); + + const fontSize = useToken('fontSizes', 'lg'); + const disabled = useToken('colors', 'whiteAlpha.400'); + const active = useColorToken('colors', 'primary.600', 'primary.400'); + const focused = useColorToken('colors', 'primary.500', 'primary.300'); + const selected = useColorToken('colors', 'blackAlpha.400', 'whiteAlpha.400'); + + const activeColor = useOpposingColor(active); + const getColor = useOpposingColorCallback(); + + return useCallback( + (base, state) => { + const { isFocused, isSelected, isDisabled } = state; + + let backgroundColor = 'transparent'; + switch (true) { + case isDisabled: + backgroundColor = disabled; + break; + case isSelected: + backgroundColor = selected; + break; + case isFocused: + backgroundColor = focused; + break; + } + const color = getColor(backgroundColor); + + const styles = { + color: backgroundColor === 'transparent' ? 'currentColor' : color, + '&:active': { backgroundColor: active, color: activeColor }, + '&:focus': { backgroundColor: active, color: activeColor }, + backgroundColor, + fontSize, + }; + + return mergeWith({}, base, styles); + }, + [isOpen, colorMode], + ); +}; + +export const useIndicatorSeparatorStyle = ( + props: RSStyleCallbackProps, +): RSStyleFunction<'indicatorSeparator', Opt, IsMulti> => { + const { colorMode } = props; + const backgroundColor = useColorToken('colors', 'gray.200', 'whiteAlpha.300'); + const styles = { backgroundColor }; + + return useCallback(base => mergeWith({}, base, styles), [colorMode]); +}; + +export const usePlaceholderStyle = ( + props: RSStyleCallbackProps, +): RSStyleFunction<'placeholder', Opt, IsMulti> => { + const { colorMode } = props; + + const color = useColorToken('colors', 'gray.600', 'whiteAlpha.700'); + const fontSize = useToken('fontSizes', 'lg'); + + return useCallback(base => mergeWith({}, base, { color, fontSize }), [colorMode]); +}; + +export const useSingleValueStyle = ( + props: RSStyleCallbackProps, +): RSStyleFunction<'singleValue', Opt, IsMulti> => { + const { colorMode } = props; + + const color = useColorValue('black', 'whiteAlpha.800'); + const fontSize = useToken('fontSizes', 'lg'); + const styles = { color, fontSize }; + + return useCallback(base => mergeWith({}, base, styles), [color, colorMode]); +}; + +export const useMultiValueStyle = ( + props: RSStyleCallbackProps, +): RSStyleFunction<'multiValue', Opt, IsMulti> => { + const { colorMode } = props; + + const backgroundColor = useColorToken('colors', 'primary.500', 'primary.300'); + const color = useOpposingColor(backgroundColor); + const borderRadius = useToken('radii', 'md'); + const styles = { backgroundColor, color, borderRadius }; + + return useCallback(base => mergeWith({}, base, styles), [backgroundColor, colorMode]); +}; + +export const useMultiValueLabelStyle = ( + props: RSStyleCallbackProps, +): RSStyleFunction<'multiValueLabel', Opt, IsMulti> => { + const { colorMode } = props; + + const backgroundColor = useColorToken('colors', 'primary.500', 'primary.300'); + const color = useOpposingColor(backgroundColor); + const styles = { color }; + + return useCallback(base => mergeWith({}, base, styles), [colorMode]); +}; + +export const useMultiValueRemoveStyle = ( + props: RSStyleCallbackProps, +): RSStyleFunction<'multiValueRemove', Opt, IsMulti> => { + const { colorMode } = props; + + const backgroundColor = useColorToken('colors', 'primary.500', 'primary.300'); + const color = useOpposingColor(backgroundColor); + const styles = { + color, + '&:hover': { backgroundColor: 'transparent', color, opacity: 0.8 }, + }; + + return useCallback(base => mergeWith({}, base, styles), [colorMode]); +}; + +export const useRSTheme = (): RSThemeFunction => { + const borderRadius = useToken('radii', 'md') as unknown as number; + + return useCallback((t: ReactSelect.Theme): ReactSelect.Theme => ({ ...t, borderRadius }), []); +}; + +export const useMenuPortal = (): RSStyleFunction< + 'menuPortal', + Opt, + IsMulti +> => { + const isMobile = useMobile(); + const styles = { + zIndex: 1500, + }; + + return useCallback(base => mergeWith({}, base, styles), [isMobile]); +}; diff --git a/hyperglass/ui/components/select/types.ts b/hyperglass/ui/components/select/types.ts new file mode 100644 index 0000000..1889776 --- /dev/null +++ b/hyperglass/ui/components/select/types.ts @@ -0,0 +1,58 @@ +import * as ReactSelect from 'react-select'; + +import type { CSSObjectWithLabel } from 'react-select'; +import type { StylesProps } from 'react-select/dist/declarations/src/styles'; +import type { Theme, SingleOption } from '~/types'; + +type StylesConfigFunction = (base: CSSObjectWithLabel, props: Props) => CSSObjectWithLabel; + +export type SelectOnChange< + Opt extends SingleOption = SingleOption, + IsMulti extends boolean = boolean, +> = NonNullable, 'onChange'>>; + +export interface SelectProps + extends ReactSelect.Props { + name: string; + isMulti?: IsMulti; + isError?: boolean; + required?: boolean; + onSelect?: (s: ReactSelect.MultiValue) => void; + colorScheme?: Theme.ColorNames; +} + +export interface SelectContextProps { + colorMode: 'light' | 'dark'; + isOpen: boolean; + isError: boolean; +} + +export interface RSStyleCallbackProps { + colorMode: 'light' | 'dark'; +} + +type StyleConfigKeys = keyof ReactSelect.StylesConfig< + SingleOption, + boolean, + ReactSelect.GroupBase +>; + +export type RSStyleFunction< + K extends StyleConfigKeys, + Opt extends SingleOption, + IsMulti extends boolean, +> = StylesConfigFunction>[K]>; + +export type RSThemeFunction = (theme: ReactSelect.Theme) => ReactSelect.Theme; + +export function isSingleValue( + value: ReactSelect.SingleValue | ReactSelect.MultiValue, +): value is NonNullable> { + return value !== null && !Array.isArray(value); +} + +export function isMultiValue( + value: ReactSelect.SingleValue | ReactSelect.MultiValue, +): value is NonNullable> { + return value !== null && Array.isArray(value); +} diff --git a/hyperglass/ui/components/submit-button.tsx b/hyperglass/ui/components/submit-button.tsx new file mode 100644 index 0000000..ef4cbd5 --- /dev/null +++ b/hyperglass/ui/components/submit-button.tsx @@ -0,0 +1,137 @@ +import { + IconButton, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalOverlay, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverTrigger, +} from '@chakra-ui/react'; +import { forwardRef } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { Else, If, Then } from 'react-if'; +import { ResolvedTarget } from '~/components'; +import { DynamicIcon } from '~/elements'; +import { useColorValue, useFormState, useMobile } from '~/hooks'; + +import type { IconButtonProps } from '@chakra-ui/react'; + +type SubmitButtonProps = Omit; + +interface ResponsiveSubmitButtonProps { + isOpen: boolean; + onClose(): void; + children: React.ReactNode; +} + +const _SubmitIcon: React.ForwardRefRenderFunction< + HTMLButtonElement, + Omit +> = (props: Omit, ref) => { + const { isLoading, ...rest } = props; + return ( + } + title="Submit Query" + colorScheme="primary" + isLoading={isLoading} + aria-label="Submit Query" + {...rest} + /> + ); +}; +const SubmitIcon = forwardRef>(_SubmitIcon); + +/** + * Mobile Submit Button + */ +const MSubmitButton = (props: ResponsiveSubmitButtonProps): JSX.Element => { + const { children, isOpen, onClose } = props; + const bg = useColorValue('white', 'gray.900'); + return ( + <> + {children} + + + + + + {isOpen && } + + + + + ); +}; + +/** + * Desktop Submit Button + */ +const DSubmitButton = (props: ResponsiveSubmitButtonProps): JSX.Element => { + const { children, isOpen, onClose } = props; + const bg = useColorValue('white', 'gray.900'); + return ( + + {children} + + + + {isOpen && } + + + ); +}; + +export const SubmitButton = (props: SubmitButtonProps): JSX.Element => { + const isMobile = useMobile(); + const loading = useFormState(s => s.loading); + const { + resolvedIsOpen, + resolvedClose, + reset: resetForm, + } = useFormState(({ resolvedIsOpen, resolvedClose, reset }) => ({ + resolvedIsOpen, + resolvedClose, + reset, + })); + + const { reset } = useFormContext(); + + async function handleClose() { + reset(); + resetForm(); + resolvedClose(); + } + + return ( + + + + + + + + + + + + + ); +}; diff --git a/hyperglass/ui/components/table/body.tsx b/hyperglass/ui/components/table/body.tsx new file mode 100644 index 0000000..0f77a7d --- /dev/null +++ b/hyperglass/ui/components/table/body.tsx @@ -0,0 +1,15 @@ +import { chakra } from '@chakra-ui/react'; + +import type { BoxProps } from '@chakra-ui/react'; + +export const TableBody = (props: BoxProps): JSX.Element => ( + +); diff --git a/hyperglass/ui/components/table/button.tsx b/hyperglass/ui/components/table/button.tsx new file mode 100644 index 0000000..6759abe --- /dev/null +++ b/hyperglass/ui/components/table/button.tsx @@ -0,0 +1,9 @@ +import { IconButton } from '@chakra-ui/react'; + +import type { IconButtonProps } from '@chakra-ui/react'; + +type TTableIconButton = Omit; + +export const TableIconButton = (props: TTableIconButton): JSX.Element => ( + +); diff --git a/hyperglass/ui/components/table/cell.tsx b/hyperglass/ui/components/table/cell.tsx new file mode 100644 index 0000000..228576f --- /dev/null +++ b/hyperglass/ui/components/table/cell.tsx @@ -0,0 +1,32 @@ +import { chakra } from '@chakra-ui/react'; +import { useColorValue } from '~/hooks'; + +import type { BoxProps } from '@chakra-ui/react'; + +interface TableCellProps extends Omit { + bordersVertical?: [boolean, number]; + align?: 'left' | 'right' | 'center'; +} + +export const TableCell = (props: TableCellProps): JSX.Element => { + const { bordersVertical = [false, 0], align, ...rest } = props; + const [doVerticalBorders, index] = bordersVertical; + const borderLeftColor = useColorValue('blackAlpha.100', 'whiteAlpha.100'); + + let borderProps = {}; + if (doVerticalBorders && index !== 0) { + borderProps = { borderLeft: '1px solid', borderLeftColor }; + } + + return ( + + ); +}; diff --git a/hyperglass/ui/components/table/head.tsx b/hyperglass/ui/components/table/head.tsx new file mode 100644 index 0000000..3e82fad --- /dev/null +++ b/hyperglass/ui/components/table/head.tsx @@ -0,0 +1,9 @@ +import { chakra } from '@chakra-ui/react'; +import { useColorValue } from '~/hooks'; + +import type { BoxProps } from '@chakra-ui/react'; + +export const TableHead = (props: BoxProps): JSX.Element => { + const bg = useColorValue('blackAlpha.100', 'whiteAlpha.100'); + return ; +}; diff --git a/hyperglass/ui/components/table/index.ts b/hyperglass/ui/components/table/index.ts new file mode 100644 index 0000000..aad1ca8 --- /dev/null +++ b/hyperglass/ui/components/table/index.ts @@ -0,0 +1 @@ +export * from './main'; diff --git a/hyperglass/ui/components/table/main.tsx b/hyperglass/ui/components/table/main.tsx new file mode 100644 index 0000000..fd1eeef --- /dev/null +++ b/hyperglass/ui/components/table/main.tsx @@ -0,0 +1,203 @@ +// This rule isn't needed because react-table does this for us, for better or worse. +/* eslint react/jsx-key: 0 */ +import { Flex, Text } from '@chakra-ui/react'; +import { usePagination, useSortBy, useTable } from 'react-table'; +import { If, Then, Else } from 'react-if'; +import { CardBody, CardFooter, CardHeader, DynamicIcon } from '~/elements'; +import { useMobile } from '~/hooks'; +import { TableMain } from './table'; +import { TableCell } from './cell'; +import { TableHead } from './head'; +import { TableRow } from './row'; +import { TableBody } from './body'; +import { TableIconButton } from './button'; +import { PageSelect } from './page-select'; + +import type { TableOptions, PluginHook } from 'react-table'; +import type { Theme, TableColumn, CellRenderProps } from '~/types'; + +interface TableProps { + data: Route[]; + striped?: boolean; + columns: TableColumn[]; + heading?: React.ReactNode; + bordersVertical?: boolean; + bordersHorizontal?: boolean; + Cell?: React.FC; + rowHighlightProp?: keyof Route; + rowHighlightBg?: Theme.ColorNames; +} + +export const Table = (props: TableProps): JSX.Element => { + const { + data, + columns, + heading, + Cell, + rowHighlightBg, + striped = false, + rowHighlightProp, + bordersVertical = false, + bordersHorizontal = false, + } = props; + + const isMobile = useMobile(); + + const defaultColumn = { + minWidth: 100, + width: 150, + maxWidth: 300, + }; + + const hiddenColumns = [] as string[]; + + for (const col of columns) { + if (col.hidden) { + hiddenColumns.push(col.accessor); + } + } + + const options = { + columns, + defaultColumn, + data, + initialState: { hiddenColumns }, + } as TableOptions; + + const plugins = [useSortBy, usePagination] as PluginHook[]; + + const instance = useTable(options, ...plugins); + + const { + page, + gotoPage, + nextPage, + pageCount, + prepareRow, + canNextPage, + pageOptions, + setPageSize, + headerGroups, + previousPage, + getTableProps, + canPreviousPage, + state: { pageIndex, pageSize }, + } = instance; + + return ( + + {heading && {heading}} + + + {headerGroups.map((headerGroup, i) => ( + + {headerGroup.headers.map(column => ( + + + {column.render('Header') as React.ReactNode} + + + + + + + + + + + + + {''} + + + ))} + + ))} + + + {page.map((row, key) => { + prepareRow(row); + return ( + + {row.cells.map((cell, i) => { + const { column, row, value } = cell as CellRenderProps; + return ( + + {typeof Cell !== 'undefined' ? ( + + ) : ( + (cell.render('Cell') as React.ReactNode) + )} + + ); + })} + + ); + })} + + + + + gotoPage(0)} + isDisabled={!canPreviousPage} + icon={} + /> + previousPage()} + isDisabled={!canPreviousPage} + icon={} + /> + + + + Page{' '} + + {pageIndex + 1} of {pageOptions.length} + {' '} + + {!isMobile && ( + { + setPageSize(Number(e.target.value)); + }} + /> + )} + + + } + /> + } + onClick={() => gotoPage(pageCount ? pageCount - 1 : 1)} + /> + + + + ); +}; diff --git a/hyperglass/ui/components/table/page-select.tsx b/hyperglass/ui/components/table/page-select.tsx new file mode 100644 index 0000000..1605ef4 --- /dev/null +++ b/hyperglass/ui/components/table/page-select.tsx @@ -0,0 +1,16 @@ +import { Select } from '@chakra-ui/react'; + +import type { SelectProps } from '@chakra-ui/react'; + +export const PageSelect = (props: SelectProps): JSX.Element => { + const { value, ...rest } = props; + return ( + + ); +}; diff --git a/hyperglass/ui/components/table/row.tsx b/hyperglass/ui/components/table/row.tsx new file mode 100644 index 0000000..a0497d7 --- /dev/null +++ b/hyperglass/ui/components/table/row.tsx @@ -0,0 +1,58 @@ +import { chakra } from '@chakra-ui/react'; +import { useColorValue, useOpposingColor } from '~/hooks'; + +import type { BoxProps } from '@chakra-ui/react'; +import type { Theme } from '~/types'; + +interface TableRowProps extends BoxProps { + highlightBg?: Theme.ColorNames; + doHorizontalBorders?: boolean; + highlight?: boolean; + doStripe?: boolean; + index: number; +} + +export const TableRow = (props: TableRowProps): JSX.Element => { + const { + index = 0, + doStripe = false, + highlight = false, + highlightBg = 'primary', + doHorizontalBorders = false, + ...rest + } = props; + + const alpha = useColorValue('100', '200'); + const alphaHover = useColorValue('200', '100'); + const bgStripe = useColorValue('blackAlpha.50', 'whiteAlpha.50'); + let hoverBg = useColorValue('blackAlpha.50', 'whiteAlpha.50'); + const rowBorder = useColorValue( + { borderTop: '1px', borderTopColor: 'blackAlpha.100' }, + { borderTop: '1px', borderTopColor: 'whiteAlpha.100' }, + ); + let bg = undefined; + + if (highlight) { + bg = `${String(highlightBg)}.${alpha}`; + hoverBg = `${String(highlightBg)}.${alphaHover}`; + } else if (doStripe && index % 2 !== 0) { + bg = bgStripe; + } + const defaultBg = useColorValue('white', 'black'); + const color = useOpposingColor(bg ?? defaultBg); + const borderProps = doHorizontalBorders && index !== 0 ? rowBorder : {}; + + return ( + td': { color } }} + fontWeight={highlight ? 'bold' : undefined} + _hover={{ + cursor: 'pointer', + backgroundColor: highlight ? `${String(highlightBg)}.${alphaHover}` : hoverBg, + }} + {...borderProps} + {...rest} + /> + ); +}; diff --git a/hyperglass/ui/components/table/table.tsx b/hyperglass/ui/components/table/table.tsx new file mode 100644 index 0000000..c0fba35 --- /dev/null +++ b/hyperglass/ui/components/table/table.tsx @@ -0,0 +1,33 @@ +import { chakra } from '@chakra-ui/react'; +import { useColorValue } from '~/hooks'; + +import type { BoxProps } from '@chakra-ui/react'; + +export const TableMain = (props: BoxProps): JSX.Element => { + const scrollbar = useColorValue('blackAlpha.300', 'whiteAlpha.300'); + const scrollbarHover = useColorValue('blackAlpha.400', 'whiteAlpha.400'); + const scrollbarBg = useColorValue('blackAlpha.50', 'whiteAlpha.50'); + return ( + + ); +}; diff --git a/hyperglass/ui/components/user-ip.tsx b/hyperglass/ui/components/user-ip.tsx new file mode 100644 index 0000000..7a31630 --- /dev/null +++ b/hyperglass/ui/components/user-ip.tsx @@ -0,0 +1,100 @@ +import { useMemo } from 'react'; +import { Button, Stack, Text, VStack, useDisclosure } from '@chakra-ui/react'; +import { Prompt } from '~/components'; +import { useConfig } from '~/context'; +import { DynamicIcon } from '~/elements'; +import { useStrf, useWtf, useColorValue } from '~/hooks'; + +interface UserIPProps { + setTarget(target: string): void; +} + +export const UserIP = (props: UserIPProps): JSX.Element => { + const { setTarget } = props; + const { onOpen, ...disclosure } = useDisclosure(); + const strF = useStrf(); + const { web } = useConfig(); + + const errorColor = useColorValue('red.500', 'red.300'); + + const noIPv4 = strF(web.text.noIp, { protocol: 'IPv4' }); + const noIPv6 = strF(web.text.noIp, { protocol: 'IPv6' }); + + const [ipv4, ipv6, query] = useWtf(); + + const hasResult = useMemo( + () => (!ipv4.isError || !ipv6.isError) && (ipv4.data?.ip !== null || ipv6.data?.ip !== null), + [ipv4, ipv6], + ); + + const show4 = useMemo(() => !ipv4.isError && ipv4.data?.ip !== null, [ipv4]); + const show6 = useMemo(() => !ipv6.isError && ipv6.data?.ip !== null, [ipv6]); + + function handleOpen(): void { + onOpen(); + query(); + } + + return ( + + {web.text.ipButton} + + } + onOpen={handleOpen} + {...disclosure} + > + + {hasResult && ( + + {web.text.ipSelect} + + )} + + {show4 && ( + + )} + {show6 && ( + + )} + {!hasResult && ( + + {web.text.ipError} + + )} + + + + ); +}; diff --git a/hyperglass/ui/context/hyperglass-provider.tsx b/hyperglass/ui/context/hyperglass-provider.tsx new file mode 100644 index 0000000..eca4625 --- /dev/null +++ b/hyperglass/ui/context/hyperglass-provider.tsx @@ -0,0 +1,34 @@ +import { ChakraProvider, localStorageManager } from '@chakra-ui/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createContext, useContext, useMemo } from 'react'; +import { makeTheme } from '~/util'; + +import type { Config } from '~/types'; + +interface HyperglassProviderProps { + config: Config; + children: React.ReactNode; +} + +export const HyperglassContext = createContext({} as Config); + +export const queryClient = new QueryClient(); + +export const HyperglassProvider = (props: HyperglassProviderProps): JSX.Element => { + const { config, children } = props; + const value = useMemo(() => config, []); // eslint-disable-line react-hooks/exhaustive-deps + const theme = useMemo(() => makeTheme(value.web.theme, value.web.theme.defaultColorMode), []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + {children} + + + ); +}; + +/** + * Get the current configuration. + */ +export const useConfig = (): Config => useContext(HyperglassContext); diff --git a/hyperglass/ui/context/index.ts b/hyperglass/ui/context/index.ts new file mode 100644 index 0000000..42f6a79 --- /dev/null +++ b/hyperglass/ui/context/index.ts @@ -0,0 +1 @@ +export * from './hyperglass-provider'; diff --git a/hyperglass/ui/elements/animated.ts b/hyperglass/ui/elements/animated.ts new file mode 100644 index 0000000..eec5d51 --- /dev/null +++ b/hyperglass/ui/elements/animated.ts @@ -0,0 +1,28 @@ +import { chakra } from '@chakra-ui/react'; +import { motion } from 'framer-motion'; + +import type { BoxProps } from '@chakra-ui/react'; +import type { CustomDomComponent, Transition, MotionProps } from 'framer-motion'; + +type MCComponent = Parameters[0]; +type MCOptions = Parameters[1]; +type MakeMotionProps

= React.PropsWithChildren< + Omit & Omit & { transition?: Transition } +>; + +/** + * Combine `chakra` and `motion` factories. + * + * @param component Component or string + * @param options `chakra` options + * @returns Chakra component with motion props. + */ +export function motionChakra

( + component: MCComponent, + options?: MCOptions, +): CustomDomComponent> { + // @ts-expect-error I don't know how to fix this. + return motion

(chakra(component, options)); +} + +export const AnimatedDiv = motionChakra('div'); diff --git a/hyperglass/ui/elements/card/body.tsx b/hyperglass/ui/elements/card/body.tsx new file mode 100644 index 0000000..9d879bc --- /dev/null +++ b/hyperglass/ui/elements/card/body.tsx @@ -0,0 +1,27 @@ +import { Flex } from '@chakra-ui/react'; +import { useColorValue } from '~/hooks'; + +import type { FlexProps } from '@chakra-ui/react'; + +interface CardBodyProps extends Omit { + onClick?: () => boolean; +} + +export const CardBody = (props: CardBodyProps): JSX.Element => { + const { onClick, ...rest } = props; + const bg = useColorValue('white', 'dark.500'); + const color = useColorValue('dark.500', 'white'); + return ( + + ); +}; diff --git a/hyperglass/ui/elements/card/footer.tsx b/hyperglass/ui/elements/card/footer.tsx new file mode 100644 index 0000000..274828e --- /dev/null +++ b/hyperglass/ui/elements/card/footer.tsx @@ -0,0 +1,18 @@ +import { Flex } from '@chakra-ui/react'; + +import type { FlexProps } from '@chakra-ui/react'; + +export const CardFooter = (props: FlexProps): JSX.Element => ( + +); diff --git a/hyperglass/ui/elements/card/header.tsx b/hyperglass/ui/elements/card/header.tsx new file mode 100644 index 0000000..fa4a447 --- /dev/null +++ b/hyperglass/ui/elements/card/header.tsx @@ -0,0 +1,22 @@ +import { Flex, Text } from '@chakra-ui/react'; +import { useColorValue } from '~/hooks'; + +import type { FlexProps } from '@chakra-ui/react'; + +export const CardHeader = (props: FlexProps): JSX.Element => { + const { children, ...rest } = props; + const bg = useColorValue('blackAlpha.50', 'whiteAlpha.100'); + return ( + + {children} + + ); +}; diff --git a/hyperglass/ui/elements/card/index.ts b/hyperglass/ui/elements/card/index.ts new file mode 100644 index 0000000..9f66b54 --- /dev/null +++ b/hyperglass/ui/elements/card/index.ts @@ -0,0 +1,3 @@ +export * from './body'; +export * from './footer'; +export * from './header'; diff --git a/hyperglass/ui/elements/code-block.tsx b/hyperglass/ui/elements/code-block.tsx new file mode 100644 index 0000000..eeb25e8 --- /dev/null +++ b/hyperglass/ui/elements/code-block.tsx @@ -0,0 +1,25 @@ +import { Box } from '@chakra-ui/react'; +import { useColorValue } from '~/hooks'; + +import type { BoxProps } from '@chakra-ui/react'; + +export const CodeBlock = (props: BoxProps): JSX.Element => { + const bg = useColorValue('blackAlpha.100', 'gray.800'); + const color = useColorValue('black', 'white'); + return ( + + ); +}; diff --git a/hyperglass/ui/elements/countdown.tsx b/hyperglass/ui/elements/countdown.tsx new file mode 100644 index 0000000..40a3110 --- /dev/null +++ b/hyperglass/ui/elements/countdown.tsx @@ -0,0 +1,50 @@ +import { chakra, Text } from '@chakra-ui/react'; +import ReactCountdown, { zeroPad } from 'react-countdown'; +import { If, Then, Else } from 'react-if'; +import { useColorValue } from '~/hooks'; + +import type { CountdownRenderProps } from 'react-countdown'; + +interface RendererProps extends CountdownRenderProps { + text: string; +} + +interface CountdownProps { + timeout: number; + text: string; +} + +const Renderer = (props: RendererProps): JSX.Element => { + const { hours, minutes, seconds, completed, text } = props; + const time = [zeroPad(seconds)]; + minutes !== 0 && time.unshift(zeroPad(minutes)); + hours !== 0 && time.unshift(zeroPad(hours)); + const bg = useColorValue('black', 'white'); + return ( + + + + + + + {text} + + {time.join(':')} + + + + + ); +}; + +export const Countdown = (props: CountdownProps): JSX.Element => { + const { timeout, text } = props; + const then = timeout * 1000; + return ( + } + /> + ); +}; diff --git a/hyperglass/ui/elements/custom.tsx b/hyperglass/ui/elements/custom.tsx new file mode 100644 index 0000000..928d375 --- /dev/null +++ b/hyperglass/ui/elements/custom.tsx @@ -0,0 +1,27 @@ +/** + * Render a generic script tag in the `` that contains any custom-defined Javascript, if + * defined. It no custom JS is defined, an empty fragment is rendered, which will not appear in + * the DOM. + */ +export const CustomJavascript = (props: React.PropsWithChildren): JSX.Element => { + const { children } = props; + if (typeof children === 'string' && children !== '') { + // biome-ignore lint/security/noDangerouslySetInnerHtml: required for injecting custom JS + return